{"id":48901134,"url":"https://github.com/marcelrgberger/auto-brew","last_synced_at":"2026-05-26T09:00:44.382Z","repository":{"id":346825248,"uuid":"1191529799","full_name":"marcelrgberger/auto-brew","owner":"marcelrgberger","description":"Native macOS app for Homebrew: background auto-updates, a full Brew GUI for browsing and installing casks, and an AppSnapshot engine for backing up and migrating app data across Macs.","archived":false,"fork":false,"pushed_at":"2026-05-23T07:53:25.000Z","size":9586,"stargazers_count":53,"open_issues_count":0,"forks_count":1,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-23T09:34:03.349Z","etag":null,"topics":["automation","backup","brew","cask","homebrew","homebrew-cask","macos","macos-app","menu-bar","migration","package-manager","snapshot","sparkle","swift","swiftui"],"latest_commit_sha":null,"homepage":null,"language":"Swift","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/marcelrgberger.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":".github/FUNDING.yml","license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null},"funding":{"github":["marcelrgberger"]}},"created_at":"2026-03-25T10:36:04.000Z","updated_at":"2026-05-23T07:53:26.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/marcelrgberger/auto-brew","commit_stats":null,"previous_names":["marcelrgberger/auto-brew"],"tags_count":40,"template":false,"template_full_name":null,"purl":"pkg:github/marcelrgberger/auto-brew","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/marcelrgberger%2Fauto-brew","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/marcelrgberger%2Fauto-brew/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/marcelrgberger%2Fauto-brew/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/marcelrgberger%2Fauto-brew/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/marcelrgberger","download_url":"https://codeload.github.com/marcelrgberger/auto-brew/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/marcelrgberger%2Fauto-brew/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33512325,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T03:12:49.672Z","status":"ssl_error","status_checked_at":"2026-05-26T03:12:47.976Z","response_time":63,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["automation","backup","brew","cask","homebrew","homebrew-cask","macos","macos-app","menu-bar","migration","package-manager","snapshot","sparkle","swift","swiftui"],"created_at":"2026-04-16T15:08:04.384Z","updated_at":"2026-05-26T09:00:44.358Z","avatar_url":"https://github.com/marcelrgberger.png","language":"Swift","funding_links":["https://github.com/sponsors/marcelrgberger"],"categories":[],"sub_categories":[],"readme":"# AutoBrew\n\nA native macOS menu bar app that automatically keeps Homebrew and all installed packages up to date — silently, in the background.\n\n\u003e **Safe by design.** When you install AutoBrew via the official channels — the DMG from [GitHub Releases](https://github.com/marcelrgberger/auto-brew/releases) or `brew install --cask autobrew` from the Homebrew tap — every release is built with Apple's Developer ID certificate, notarized by Apple, and stapled before it ships. macOS Gatekeeper accepts AutoBrew without warnings. The auto-update channel is signed with an EdDSA Ed25519 key — AutoBrew refuses to install an update whose signature doesn't verify. Source is open under the MIT License, no telemetry, no AutoBrew backend, no account — everything runs locally on your Mac.\n\n## Install — Homebrew (recommended)\n\n```bash\nbrew tap marcelrgberger/tap\nbrew install --cask autobrew\n```\n\nOther ways to install (manual DMG, requirements) are covered in the [Install](#install) section below.\n\n## Table of Contents\n\n- [Features](#features)\n- [Quick Start](#quick-start)\n- [User Guide](#user-guide)\n  - [First Launch](#first-launch)\n  - [Choosing an Update Trigger](#choosing-an-update-trigger)\n  - [Configuring the Update Policy](#configuring-the-update-policy)\n  - [Pending Approvals](#pending-approvals-workflow)\n  - [Browsing \u0026 Installing Casks](#browsing--installing-casks)\n  - [Searching the Catalog](#searching-the-catalog)\n  - [Managing Installed Apps](#managing-installed-apps)\n  - [Creating an App Snapshot](#creating-an-app-snapshot)\n  - [Restoring a Snapshot](#restoring-a-snapshot)\n  - [Migrating to Another Mac](#migrating-to-another-mac)\n  - [URL Scheme \u0026 Deep Links](#url-scheme--deep-links)\n  - [Notifications](#notifications)\n  - [Languages](#languages)\n- [BrewStore](#brewstore)\n  - [Discover \u0026 Browse](#discover--browse)\n  - [Global Search](#global-search)\n  - [Installed](#installed)\n  - [Snapshots](#snapshots)\n- [Selective Update Policy](#selective-update-policy)\n  - [Defaults](#defaults)\n  - [Per-Package Overrides](#per-package-overrides)\n  - [Pending Approvals](#pending-approvals)\n  - [Cool-off Tracking](#cool-off-tracking)\n- [Install](#install)\n- [Requirements](#requirements)\n- [Setup (Developers)](#setup-developers)\n- [CI / Release Pipeline](#ci--release-pipeline)\n- [Architecture](#architecture)\n- [Project Structure](#project-structure)\n- [Tests](#tests)\n- [Security \u0026 Data Integrity](#security--data-integrity)\n- [Support](#support)\n- [License](#license)\n\n## Features\n\n- **Automatic Updates** — Runs `brew update → policy gate → selective brew upgrade → brew cleanup` once daily\n- **Selective Update Policy** — Per-bump-type and per-package rules: patches roll out fast, minors wait a configurable cool-off, majors require explicit approval\n- **In-App Legal Section** — Privacy, Terms, EULA, Imprint, Trademark, Open-Source licenses — localized into all supported languages\n- **Idle-Based Trigger** — Waits for configurable idle time before running (default: 30 min)\n- **Scheduled Trigger** — Alternatively, run at a fixed time of day\n- **Works While Locked** — Uses IOKit idle detection, independent of screen lock state\n- **Missed Run Recovery** — If the Mac was asleep during a scheduled run, prompts the user on wake\n- **Outdated Package List** — Shows outdated formulae and casks with current and available versions\n- **Homebrew Auto-Install** — Installs Homebrew automatically if not present (guided onboarding)\n- **Login Item** — Starts automatically with the system via SMAppService\n- **Auto-Updates** — Keeps itself up to date via Sparkle\n- **8 Languages** — English, German, French, Italian, Dutch, Polish, Portuguese (Brazil), Spanish\n\n## Quick Start\n\n1. Install AutoBrew via Homebrew: `brew tap marcelrgberger/tap \u0026\u0026 brew install --cask autobrew`\n2. Launch AutoBrew once from `/Applications`. The mug icon appears in the menu bar.\n3. If Homebrew isn't on your Mac yet, the onboarding screen installs it for you.\n4. Grant the notification permission when prompted — it's how AutoBrew tells you when an update needs approval.\n5. Click the menu-bar mug → **Settings…** to pick a trigger mode (Idle or Scheduled) and review the default update policy.\n6. That's it — AutoBrew runs in the background. Open it any time from the menu bar to inspect outdated packages, browse the BrewStore, or review pending approvals.\n\n## User Guide\n\nA walkthrough of every feature, in the order you'd typically encounter them.\n\n### First Launch\n\nWhen AutoBrew starts for the first time it runs an onboarding flow:\n\n1. **Notification permission** — AutoBrew uses local notifications for missed-run reminders and pending-approval alerts. Allow them; they're never sent over a network.\n2. **Homebrew detection** — if `brew` is missing, the onboarding offers to run the official installer (`/bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"`). Install requires admin password (sudo) — same as a manual Homebrew install.\n3. **Launch at login** — opt-in checkbox. Wires up `SMAppService` so AutoBrew restarts automatically with macOS.\n\nOnboarding only runs once. To re-trigger it manually, remove `~/Library/Preferences/za.co.digitalfreedom.AutoBrew.plist` and relaunch.\n\n### Choosing an Update Trigger\n\nSettings → **Update Trigger** picks how AutoBrew decides to run:\n\n- **Idle mode** (default) — polls system idle time every 60 s and starts a Brew run after the user has been idle for at least `N` minutes (configurable, default 30). Runs at most once per calendar day. Idle detection reads `HIDIdleTime` from `IOHIDSystem` via IOKit, so it works even while the screen is locked.\n- **Scheduled mode** — runs at a fixed time of day (configurable). If the Mac was asleep at the scheduled time, AutoBrew triggers a missed-run notification on wake instead of silently skipping.\n\nSwitch between modes at any time. The scheduler restarts cleanly when you do.\n\n### Configuring the Update Policy\n\nSettings → **Update Policy** has six pickers (patch / minor / major × Casks / Formulae) plus per-package overrides. Each picker accepts one of four policies:\n\n| Policy | Behaviour |\n|---|---|\n| **Auto** | Install on the next scheduled run. |\n| **Wait N days** | Install only after the new version has been visible for at least `N` days. Cool-off resets when a newer version supersedes the pending one. |\n| **Ask me** | Don't install — surface the update in the Pending Approvals section instead. |\n| **Skip** | Never install for this bump type. |\n\nThe default profile is conservative: patches roll out quickly, minors get a cool-off window, majors always wait for the user. Defaults differ between Casks and Formulae because Formulae carry security patches more often.\n\nFor one specific package, open it in the BrewStore detail view and click **Update Policy**. The override sheet lets you set patch/minor/major independently; leaving a row on **Default** inherits the global setting.\n\n### Pending Approvals Workflow\n\nMajor updates (and anything where the version string isn't parseable enough to classify) land in the **Pending Approvals** queue:\n\n1. AutoBrew detects the update during a Brew run.\n2. The menu-bar icon grows a small orange dot.\n3. A notification fires once per new entry (`X updates need approval (Package A, B, …)`).\n4. Click **Review** on the notification, or open BrewStore → **Pending Approvals**.\n5. Per row: **Approve** (queues for the next run), **Reject** (sticky — won't ask again until a newer version arrives), or use the toolbar **Approve All** / **Reject All**.\n\nOnce approved entries actually install during the next scheduler run, they are removed from the queue automatically.\n\n### Browsing \u0026 Installing Casks\n\nBrewStore → **Discover** shows App-Store-style sections (Top Ranked, plus categories like Browsers, Developer Tools, Productivity, …) sorted by 365-day install popularity from the public Homebrew analytics.\n\n- Click any tile to see the cask's description, version, homepage, and the per-package Update Policy button.\n- Click **Install** to run `brew install --cask \u003ctoken\u003e` directly. The button label flips to **Open** once the app is on disk.\n- Hover any row to see the full description (and the brew token for `@variant` casks).\n\n### Searching the Catalog\n\nThe search field in the sidebar searches the **entire** cask catalog as soon as you type — across name, description, token, and the variant-decorated presentation name. The detail pane switches to a global Search Results view; clearing the field returns you to whichever section you were on. Results are sorted by install popularity so the most likely match floats to the top.\n\n### Managing Installed Apps\n\nBrewStore → **Installed** lists every `.app` in `/Applications` and `~/Applications` (Apple system apps filtered out). Each row shows the bundle ID, version, and — when applicable — the brew token managing the app.\n\nHow AutoBrew decides whether to show brew actions:\n\n- It runs `brew info --cask --json=v2 --installed` once per refresh to learn what brew is **actually** tracking, including custom-tap installs.\n- An app is marked as brew-managed only when the resolved token appears in that authoritative set. Manually installed apps stay unmanaged — no broken Upgrade/Uninstall buttons.\n\nPer row (`⋯` menu):\n\n- **Take Snapshot** — always available.\n- **Upgrade via Brew** / **Uninstall via Brew** — only when brew is tracking the cask.\n\n### Creating an App Snapshot\n\nA snapshot is a point-in-time copy of everything an application owns outside its `.app` bundle. Use one before a risky upgrade, before migrating to a new Mac, or before deleting an app you might miss.\n\n1. BrewStore → **Snapshots** → **New Snapshot** (or use the `⋯` menu of an Installed app).\n2. Pick the app to snapshot. AutoBrew offers to quit it first — answer **Yes** unless you're sure the app isn't writing to disk.\n3. AutoBrew copies every readable user-data folder for the app (preferences, application support, containers, group containers, saved state, caches), hashes each component with SHA-256, and writes a `manifest.json` next to the components. Files macOS refuses to hand over (e.g. `.com.apple.containermanagerd.metadata.plist` inside `Library/Containers/`) are silently skipped so a single locked-down sibling can't kill the whole snapshot; non-permission I/O errors still abort.\n4. The snapshot appears in the Snapshots list with the timestamp and size.\n\nTo free disk space later, either delete individual snapshots in the Snapshots view or enable Settings → **Auto-clean up old snapshots** with a retention window (default 90 days).\n\n### Restoring a Snapshot\n\n1. Open the snapshot in the Snapshots view.\n2. Click **Restore**.\n3. AutoBrew re-verifies every component hash before touching disk; mismatch → restore aborts.\n4. The currently installed user-data is renamed to `.autobrew-rollback-\u003cuuid\u003e` (atomic on the same volume). If anything goes wrong after, that rollback copy is moved back into place.\n5. The snapshot content is copied into the target paths.\n6. Hashes are recomputed on the written files. A second mismatch triggers the rollback path.\n7. On success the temporary rollback siblings are removed.\n\nThe whole restore is transactional — there is no partial state. You can opt out of the \"quit the app first\" step, but that risks data corruption if the app is mid-write.\n\n### Migrating to Another Mac\n\nTwo flavours:\n\n- **Single-app**: Snapshot detail → **Export…** writes a self-contained `.autobrewsnapshot` file (ZIP archive built with `ditto -c -k --sequesterRsrc` to preserve macOS extended attributes). Drop it onto the target Mac and double-click to import.\n- **Bulk**: Snapshots view → **Export All…** produces an `.autobrewbundle` directory with one `.autobrewsnapshot` per app plus a `restore_list.json` index. Copy the whole directory.\n\nOn the new Mac open the **Restore Wizard** (Snapshots → Import…), point it at the `.autobrewsnapshot` or `.autobrewbundle`, pick which apps to restore, and AutoBrew:\n\n1. Validates the manifest (non-empty bundle IDs, ≥ 1 component, hashes well-formed, no zip-slip in the archive).\n2. Installs missing casks via `brew install --cask \u003ctoken\u003e`; if the cask was renamed since the snapshot, `brew search` finds the new token automatically.\n3. Restores each app via the same transactional flow as a local restore.\n\n### URL Scheme \u0026 Deep Links\n\nAutoBrew registers the `autobrew://` URL scheme:\n\n- `autobrew://open` — bring the BrewStore window forward (works from Terminal, a browser link, or another app's automation).\n- `autobrew://install/\u003ccask-token\u003e` — install a cask in the background. Tokens are validated against `^[a-zA-Z0-9][a-zA-Z0-9._-]*$` and a confirmation dialog appears before the install runs, so a malicious link can't silently install software.\n\n### Notifications\n\nAutoBrew uses three notification types:\n\n- **Completion** — fires after every successful or failed Brew run. Body shows success / error detail.\n- **Missed-run** — fires after wake-from-sleep if the Mac was asleep during a scheduled run. Actions: **Update Now** (runs immediately) or **Skip** (waits for the next cycle).\n- **Pending approvals** — fires when one or more new major updates were detected. **Review** opens BrewStore → Pending Approvals.\n\nAll notifications can be turned off globally in Settings → **Show Notifications**.\n\n### Languages\n\nAutoBrew ships in 8 languages: English, German, French, Italian, Dutch, Polish, Portuguese (Brazil), and Spanish. The active locale follows the macOS system language. The Legal documents (Imprint, Privacy, Terms, EULA, Trademark, Open-Source Licenses) are translated too — open them from Settings → **Legal** → \\\u003cdocument\\\u003e.\n\n## BrewStore\n\nStarting with version 2.0.0, AutoBrew ships a full Homebrew GUI and an AppSnapshot engine.\n\n### Discover \u0026 Browse\nFull Homebrew cask catalog (`formulae.brew.sh`) organised as App-Store-style Discover sections plus hand-curated categories (Browsers, Developer Tools, Productivity, …), each ranked by 365-day install popularity. Every row has a hover tooltip with the full description and the brew token for `@variant` casks. Variants (`alfred`, `alfred@4`, `alfred@prerelease`) are decorated in the title — \"Alfred\", \"Alfred 4\", \"Alfred (Prerelease)\" — so they're never visually identical.\n\n### Global Search\nThe sidebar search field walks the entire cask catalog (token, name, description) regardless of which section is selected. Clearing the field returns the user to whatever they were looking at before.\n\n### Installed\nScans `/Applications` and `~/Applications`, reconciles each `.app` bundle against `brew info --cask --json=v2 --installed` so:\n- Apps installed manually (DMG, drag-to-Applications) show up without a cask token — no Upgrade/Uninstall buttons that would fail.\n- Apps installed via a **custom Homebrew tap** are still tracked correctly (uses `full_token` instead of the public catalog).\n- When several casks share the same `.app` (`alfred` vs `alfred@4`), the row reflects which cask brew is actually managing — not whichever the public catalog happened to list first.\n\nPer app: create snapshot, upgrade via Brew, or uninstall.\n\n### Snapshots\n\nA snapshot is a point-in-time copy of **everything an application owns outside its `.app` bundle** — its preferences, its sandbox data, its caches, its login items. Combined, those folders are what makes an app \"yours\" after a fresh install. Without them, reinstalling Slack means re-signing in, reinstalling Visual Studio Code means losing every extension and setting, etc.\n\n#### What gets captured\n\nFor each app, AutoBrew copies the contents of these standard macOS user-data locations (where they exist):\n\n| Path | Purpose |\n|---|---|\n| `~/Library/Preferences/\u003cbundleID\u003e.plist` | UserDefaults — settings, recent items, window positions |\n| `~/Library/Application Support/\u003cbundleID\u003e/` | App-managed data — databases, projects, extensions |\n| `~/Library/Containers/\u003cbundleID\u003e/` | App-Sandbox data (sandboxed apps store everything here) |\n| `~/Library/Saved Application State/\u003cbundleID\u003e.savedState/` | Window restoration on next launch |\n| `~/Library/Group Containers/\u003cgroupID\u003e/` | Shared data between an app and its extensions |\n| `~/Library/Caches/\u003cbundleID\u003e/` | Caches — included for completeness, opt out in Settings if you'd rather not |\n\nEach file plus every directory tree gets a **SHA-256 hash** in the manifest. On restore the hash is recomputed and compared — if the archive was tampered with, the restore aborts before touching your disk.\n\n#### Storage on the source Mac\n\nSnapshots live under `~/Library/Application Support/AutoBrew/Snapshots/`, one folder per snapshot. Folder name is `\u003cbundleID\u003e_\u003ctimestamp\u003e` so they sort chronologically. Each folder contains the raw component copies plus a `manifest.json` with:\n\n- App's bundle ID, display name, version at snapshot time\n- Cask token (when AutoBrew can resolve it)\n- Component list with paths, sizes, and hashes\n- Snapshot creation timestamp\n\n#### Restore flow\n\n1. AutoBrew offers to **terminate the running app** (you can opt out — restore over a running app risks data corruption).\n2. The current state of every component path is **rolled into a transactional backup** next to the original — if anything fails midway, the original state is restored.\n3. Components are copied from the snapshot into their target paths.\n4. Hashes are recomputed and compared against the manifest. Mismatch → roll back.\n5. On success, the temporary backup is removed and the user can relaunch the app.\n\n#### Cross-Mac migration\n\n- **Single-snapshot export** — `.autobrewsnapshot` file: a ZIP bundle (created with `ditto -c -k --sequesterRsrc` so extended attributes and symlinks survive) containing the raw components plus `manifest.json`. Double-clickable from Finder, or attach to a message.\n- **Bulk export** — `.autobrewbundle` directory containing one `.autobrewsnapshot` per app plus a `restore_list.json` index. Use this when migrating a whole Mac.\n- **Restore wizard** — point AutoBrew at an `.autobrewsnapshot` or an `.autobrewbundle`:\n  1. The manifests are validated (bundle IDs non-empty, components ≥ 1, hashes well-formed).\n  2. You pick which apps to restore.\n  3. If a target app isn't installed yet, AutoBrew runs `brew install --cask \u003ctoken\u003e`; if the cask was renamed since the snapshot, `brew search` is used to find the replacement (so a snapshot taken under `vscode` still restores after Homebrew renamed the cask to `visual-studio-code`).\n  4. Each picked app is restored via the same transactional flow as a local restore.\n\n#### What snapshots don't capture\n\n- Files outside the standard user-data locations (e.g. data dumped under `/Library/...` system-wide, or in custom-configured paths)\n- App Store receipts (StoreKit re-verifies on first launch, so this normally just means signing in again)\n- License keys stored in the macOS Keychain (Keychain isn't snapshotted — restore the app and re-enter the licence)\n- Files actively being written by the app at the moment of snapshot (that's why AutoBrew offers to terminate it first)\n\n#### How it works under the hood\n\nThe snapshot subsystem is three Swift services collaborating with a small amount of disk state:\n\n- **`SnapshotPathResolver`** — given a bundle ID, returns every candidate user-data path that exists on disk (the table above). Lookups are cheap (file-existence checks only); paths that don't exist are skipped, so the manifest only carries real components. Group containers are matched by an identifying reverse-domain prefix plus a distinctive last segment (≥6 chars, not in the generic blocklist `.app/.mac/.ios/…`) — a previous \"match anything containing the last segment\" heuristic pulled in Apple's own group containers (e.g. `group.com.apple.stocks-news` matched `com.usebruno.app` because \"apple\" contains \"app\") and the tightened matcher closes that class of false positive.\n- **`Sha256Hasher`** — streams a file or directory tree through `CryptoKit.SHA256` in chunks, so even multi-gigabyte caches don't blow up memory. Directory trees are hashed deterministically: each entry is fed in with a length-prefixed binary encoding (relative path → file mode → file content hash → entry-terminator byte) so the hash is stable across runs as long as the contents and structure didn't change.\n- **`SnapshotArchiver`** — wraps Apple's `ditto -c -k --sequesterRsrc` to ZIP/UNZIP. Using `ditto` instead of `zip` matters: it preserves macOS extended attributes (`com.apple.metadata:*`), the resource fork on legacy files, and symlinks pointing inside the bundle. Archives created with `zip` would silently lose all of that and produce subtly broken restores.\n\n**Create flow** (`SnapshotService.createSnapshot`):\n\n1. Resolve all candidate paths via the resolver. If the set is empty after filtering, the snapshot is **rejected** — an empty snapshot is more dangerous than no snapshot (it would \"restore\" a wiped state).\n2. Stream-copy each path into a fresh `\u003cbundleID\u003e_\u003ctimestamp\u003e/` folder under `~/Library/Application Support/AutoBrew/Snapshots/`. Directories are walked entry-by-entry; permission-denied files and vanished entries are skipped (so a single unreadable sibling doesn't abort the whole copy), every other enumeration error rethrows so a partial component can never claim to be complete.\n3. Compute the SHA-256 for each component (file → file hash, directory → tree hash).\n4. Write `manifest.json` last, atomically. If the process is killed before this step, the folder is partial and ignored by the snapshot list — no half-state.\n\n**Restore flow** (`SnapshotService.restoreSnapshot`):\n\n1. Re-verify every component hash against the manifest. Mismatch → abort with `BrewError.snapshotCorrupted`.\n2. Offer to quit the app via `AppQuitter` — polite `terminate()` first, then `forceTerminate()` after `timeout` seconds; cancellable.\n3. For every component path, the current state is renamed in place to `\u003cpath\u003e.autobrew-rollback-\u003cuuid\u003e` (zero-copy, atomic on the same filesystem). At this point the original is staged for cleanup but still recoverable.\n4. The snapshot version is copied into the target path.\n5. Hashes are recomputed on the **written** files and compared against the manifest. Any mismatch triggers the rollback step.\n6. On success, the `.autobrew-rollback-*` siblings are deleted. On failure, they are renamed back over the failed restore and the leftover write attempt is removed.\n\n**Auto-cleanup** (Settings → Snapshots → \"Auto-clean up old snapshots\"): after every successful Brew run, `SnapshotService.cleanup(olderThanDays:)` walks the snapshot folder and removes any folder whose `manifest.json` creation timestamp is older than the configured retention window (default 90 days). Snapshots without a parseable manifest are left alone — we'd rather keep orphans than delete by guess.\n\n**Export** (`SnapshotService.exportSnapshot`) zips the folder with `ditto`, names it `\u003cDisplayName\u003e_\u003ctimestamp\u003e.autobrewsnapshot`, and writes the same manifest at the archive root so the file is self-describing.\n\n**Import** (`SnapshotService.importSnapshot`) takes any `.autobrewsnapshot` URL, runs hardening checks against zip-slip and absolute-path symlinks before extracting, validates the manifest, re-verifies the hashes, and only then publishes the snapshot into the local store. Imported snapshots get a fresh UUID so they don't collide with one another after a cross-Mac migration.\n\n### URL Scheme\n- `autobrew://open` — open the main window.\n- `autobrew://install/\u003ccask-token\u003e` — install a cask in the background (token validated against `[a-zA-Z0-9][a-zA-Z0-9._-]*`).\n\n### Auto-Cleanup\nIn Settings: automatically remove old snapshots after N days (default 90). Cleanup runs after each successful Brew update.\n\n## Selective Update Policy\n\nAutoBrew classifies each pending update as **patch**, **minor**, or **major** (based on SemVer parsing) and routes it through one of four policies:\n\n| Policy | Behaviour |\n|---|---|\n| **Auto** | Install on the next scheduled run |\n| **Wait N days** | Install once the version has been available for at least N days |\n| **Ask me** | Stay in the \"Pending Approvals\" list until the user approves or rejects |\n| **Skip** | Never install for that bump type |\n\n### Defaults\n\nConservative starter values picked so security patches land fast while breaking changes stay opt-in:\n\n|  | Casks | Formulae |\n|---|---|---|\n| Patch | Wait 2 days | Auto |\n| Minor | Wait 14 days | Wait 7 days |\n| Major | Ask me | Ask me |\n\nConfigure in **Settings → Update Policy**.\n\n### Per-Package Overrides\n\nOpen any cask in the BrewStore detail view and click **Update Policy** to set patch/minor/major rules just for that package. Leave a row on \"Default\" to inherit the global setting.\n\n### Pending Approvals\n\nMajor updates (and anything classified as `unknown` because the version string isn't SemVer-shaped) wait for the user. They show up in:\n- **BrewStore → Pending Approvals** — sidebar entry only appears when there's something pending\n- **Menu bar icon** — small orange dot\n- **Notification** — fires once when the pending count grows; tapping it opens the approvals view directly\n\nRejected entries stay sticky until a newer version arrives, so you're not re-asked about the same major release on every scan.\n\n### Cool-off Tracking\n\nA small JSON ledger in `~/Library/Application Support/AutoBrew/UpdateLedger.json` records when each `(kind, token, version)` first appeared as outdated. The \"Wait N days\" policy measures the window from that first sighting, not from each scan, so multiple scheduler runs don't reset the timer.\n\n## Install\n\n### Via Homebrew (recommended)\n\n```bash\nbrew tap marcelrgberger/tap\nbrew install --cask autobrew\n```\n\n### Manual Download\n\nDownload the latest DMG from [GitHub Releases](https://github.com/marcelrgberger/auto-brew/releases), open it, and drag AutoBrew to your Applications folder.\n\nThe app is signed and notarized by Apple — no Gatekeeper warnings.\n\n## Requirements\n\nAutoBrew runs on every macOS release from Sonoma onward:\n\n| macOS | Version | Year | Status |\n|---|---|---|---|\n| Sonoma | 14 | 2023 | Supported |\n| Sequoia | 15 | 2024 | Supported |\n| Tahoe | 26 | 2025 | Supported |\n\nOlder releases (macOS 13 Ventura and earlier) are not supported — AutoBrew relies on SwiftUI APIs (`@Observable`, `ContentUnavailableView`, `.symbolEffect`) introduced in macOS 14.\n\n### Build requirements (developers only)\n\n- Xcode 26+ (the macOS 26 SDK is required because the UI references Liquid Glass APIs behind `if #available(macOS 26, *)` gates — older SDKs cannot resolve the symbols even though the binary still deploys to macOS 14+)\n- Swift 6.0\n- [XcodeGen](https://github.com/yonaskolb/XcodeGen)\n\n## Setup (Developers)\n\n```bash\n# Generate Xcode project\nxcodegen generate\n\n# Build\nxcodebuild build -scheme AutoBrew -destination 'platform=macOS'\n\n# Run tests\nxcodebuild test -scheme AutoBrew -destination 'platform=macOS'\n```\n\n## CI / Release Pipeline\n\nFour GitHub Actions workflows, one per channel. Branch strategy:\n`development → test → beta → main`.\n\n| # | Workflow | Trigger | What it does |\n|---|---|---|---|\n| 01 | Set new Version | manual | Bumps `MARKETING_VERSION` / `CURRENT_PROJECT_VERSION` in `project.yml`. |\n| 02 | Dev Build Check | push to `development`, PRs to any channel | Debug build + unit tests, no signing, no artefacts. The fast quality gate. |\n| 03 | Beta / Test Build | push to `test` or `beta` | Signed + notarized DMG named `AutoBrew-test.dmg` / `AutoBrew-beta.dmg`, uploaded as a GitHub Pre-Release tagged `vX.Y.Z-\u003cchannel\u003e`. The pre-release is replaced on each push so the latest channel build is always the canonical download. |\n| 04 | Release Build (Main) | manual only (`workflow_dispatch`) | Signed + notarized `AutoBrew.dmg` + `AutoBrew.zip`. Creates the GitHub Release, signs the ZIP for Sparkle (EdDSA), and updates `appcast.xml` so existing users get the in-app update prompt. |\n\nThe release workflow is intentionally **not** auto-triggered on push to `main` — that previously produced a release per commit (including doc-only pushes). Releases are kicked off from the Actions tab when an actual release is ready.\n\n## Architecture\n\nAutoBrew is structured around three responsibilities — the auto-update engine (menu bar lifecycle, scheduling, Brew execution), the BrewStore browse/install surface (catalog, installed apps, casks), and the AppSnapshot subsystem (capture, restore, cross-Mac migration). Each is shown as its own class diagram below.\n\n### Diagram 1 — App Lifecycle \u0026 Auto-Update Engine\n\n```mermaid\nclassDiagram\n    class AutoBrewApp {\n        +body: Scene\n        -delegate: AppDelegate\n    }\n\n    class AppDelegate {\n        +applicationDidFinishLaunching()\n        +handleOpenURL(URL)\n    }\n\n    class SchedulerService {\n        -state: SchedulerState\n        -pollingTask: Task\n        -scheduledTask: Task\n        +start()\n        +restartScheduling()\n        +triggerManualRun()\n        -runBrewUpdate()\n        -handleMissedRun()\n    }\n\n    class BrewManager {\n        +brewPath: String?\n        +isHomebrewInstalled: Bool\n        +installHomebrew()\n        +runUpdate()\n        +runUpgrade(formulae, casks)\n        +runCleanup()\n        +fetchOutdated()\n    }\n\n    class BrewProcess {\n        +run(executable, arguments, brewPath): ProcessResult\n    }\n\n    class BrewError\n    class OutdatedPackage\n\n    class UpdateEvaluator {\n        +evaluate(outdated, ledger, now): UpdateDecisionBundle\n    }\n\n    class UpdateDecisionBundle {\n        +autoInstall: [OutdatedPackage]\n        +waitingForCooldown: [WaitingEntry]\n        +needsApproval: [PendingUpdate]\n        +skipped: [SkippedEntry]\n    }\n\n    class UpdateLedgerStore {\n        +touch(kind, token, version): Date\n        +purge(keeping)\n    }\n\n    class PendingUpdatesStore {\n        +updates: [PendingUpdate]\n        +pendingCount: Int\n        +approvedTokens: [String]\n        +approve(id)\n        +reject(id)\n        +replace(with)\n        +remove(tokens)\n    }\n\n    class SettingsStore {\n        +triggerMode: TriggerMode\n        +idleMinutes: Int\n        +scheduledHour: Int\n        +scheduledMinute: Int\n        +lastRunDate: Date?\n        +loginItemEnabled: Bool\n        +snapshotRetentionDays: Int\n        +policyDefaults: UpdatePolicyDefaults\n        +packageOverrides: [PackagePolicyOverride]\n    }\n\n    class IdleDetector {\n        +systemIdleTime(): TimeInterval?\n    }\n\n    class SleepWakeObserver {\n        +onWakeWithMissedRun: Callback\n        +startObserving()\n        +clearMissedRun()\n    }\n\n    class NotificationManager {\n        +onRunNowRequested: Callback\n        +requestAuthorization()\n        +showMissedRunNotification()\n        +showCompletionNotification()\n    }\n\n    class LoginItemManager {\n        +isEnabled: Bool\n        +setEnabled(Bool)\n    }\n\n    class UpdaterService {\n        +canCheckForUpdates: Bool\n        +checkForUpdates()\n    }\n\n    AutoBrewApp --\u003e AppDelegate\n    AutoBrewApp --\u003e MenuBarView\n    AppDelegate --\u003e SchedulerService\n    AppDelegate --\u003e NotificationManager\n    AppDelegate --\u003e BrewInstaller : autobrew install URL\n    SchedulerService --\u003e BrewManager\n    SchedulerService --\u003e SettingsStore\n    SchedulerService --\u003e SleepWakeObserver\n    SchedulerService --\u003e NotificationManager\n    SchedulerService --\u003e IdleDetector\n    SchedulerService --\u003e SnapshotService : auto-cleanup\n    SchedulerService --\u003e UpdateEvaluator\n    SchedulerService --\u003e UpdateLedgerStore\n    SchedulerService --\u003e PendingUpdatesStore\n    UpdateEvaluator --\u003e UpdateDecisionBundle\n    BrewManager --\u003e BrewProcess\n    BrewManager --\u003e OutdatedPackage\n    BrewManager ..\u003e BrewError\n    MenuBarView --\u003e SchedulerService\n    MenuBarView --\u003e BrewManager\n    MenuBarView --\u003e SettingsStore\n    MenuBarView --\u003e MenuBarIcon\n    MenuBarView --\u003e LogView\n    MenuBarView --\u003e OnboardingView\n    MenuBarView --\u003e BrewStoreWindow\n    SettingsView --\u003e SettingsStore\n    SettingsView --\u003e LoginItemManager\n    SettingsView --\u003e UpdaterService\n```\n\n### Diagram 2 — BrewStore: Browse, Install, Manage\n\n```mermaid\nclassDiagram\n    class BrewStoreWindow {\n        +body: View\n    }\n    class BrewStoreSidebar\n    class DiscoverView\n    class DiscoverSection\n    class RankedCaskRow\n    class CategoryListView\n    class UpdatesView\n    class BrowseDetailView\n    class CaskIconView\n    class InstalledAppsView\n    class InstalledAppRowView\n\n    class CatalogStore {\n        +casks: [CaskCatalogEntry]\n        +analytics: CaskAnalytics?\n        +categories: [BrowseCategory]\n        +isLoading: Bool\n        +refresh()\n        +replaceAll(casks, analytics)\n        +topRanked(limit) [CaskCatalogEntry]\n    }\n\n    class InstalledAppsStore {\n        +apps: [InstalledApp]\n        +isLoading: Bool\n        +refresh()\n    }\n\n    class BrewCatalogService {\n        +refresh()\n        +loadCache()\n    }\n\n    class BrewInstaller {\n        +install(token)\n        +upgrade(token)\n        +uninstall(token, zap)\n        +searchCask(query) String?\n    }\n\n    class AppDiscoveryService {\n        +scan(directories, resolver) [InstalledApp]\n        +readApp(at) InstalledApp?\n    }\n\n    class CaskNameResolver {\n        +token(forAppName) String?\n    }\n\n    class RemoteIconLoader {\n        +cached(token) NSImage?\n        +fetch(token, displayName, homepage) NSImage?\n        +diskCacheSize() Int64\n        +clearCache()\n    }\n\n    class CaskCatalogEntry\n    class CaskAnalytics\n    class InstalledApp\n    class BrowseCategory\n\n    BrewStoreWindow --\u003e BrewStoreSidebar\n    BrewStoreWindow --\u003e DiscoverView\n    BrewStoreWindow --\u003e CategoryListView\n    BrewStoreWindow --\u003e UpdatesView\n    BrewStoreWindow --\u003e InstalledAppsView\n    DiscoverView --\u003e DiscoverSection\n    DiscoverSection --\u003e RankedCaskRow\n    RankedCaskRow --\u003e CaskIconView\n    CategoryListView --\u003e RankedCaskRow\n    RankedCaskRow --\u003e BrowseDetailView\n    BrowseDetailView --\u003e CaskIconView\n    InstalledAppsView --\u003e InstalledAppRowView\n\n    DiscoverView --\u003e CatalogStore\n    CategoryListView --\u003e CatalogStore\n    UpdatesView --\u003e CatalogStore\n    UpdatesView --\u003e InstalledAppsStore\n    InstalledAppsView --\u003e InstalledAppsStore\n    BrowseDetailView --\u003e CatalogStore\n    BrowseDetailView --\u003e BrewInstaller\n    InstalledAppRowView --\u003e BrewInstaller\n\n    CatalogStore --\u003e BrewCatalogService\n    CatalogStore --\u003e CaskCatalogEntry\n    CatalogStore --\u003e CaskAnalytics\n    CatalogStore --\u003e BrowseCategory\n    InstalledAppsStore --\u003e AppDiscoveryService\n    InstalledAppsStore --\u003e CaskNameResolver\n    InstalledAppsStore --\u003e InstalledApp\n    AppDiscoveryService --\u003e CaskNameResolver\n    CaskIconView --\u003e RemoteIconLoader\n```\n\n### Diagram 3 — AppSnapshot Engine \u0026 Cross-Mac Restore\n\n```mermaid\nclassDiagram\n    class SnapshotsRootView\n    class SnapshotListView\n    class SnapshotDetailView\n    class NewSnapshotView\n    class RestoreWizardView\n    class RestoreProgressView\n\n    class SnapshotsStore {\n        +snapshots: [AppSnapshot]\n        +refresh()\n        +createSnapshot(for app)\n        +delete(snapshot)\n        +restore(snapshot, terminateApp)\n    }\n\n    class RestoreWizardStore {\n        +list: RestoreList?\n        +imported: [AppSnapshot]\n        +selection: Set~UUID~\n        +loadBundle(url)\n        +performRestore()\n    }\n\n    class SnapshotService {\n        +createSnapshot(bundleID, displayName, caskToken, sourceAppVersion)\n        +listSnapshots() [AppSnapshot]\n        +deleteSnapshot(snapshot)\n        +cleanup(olderThanDays)\n        +restoreSnapshot(snapshot, terminateApp)\n        +exportSnapshot(snapshot, destination)\n        +importSnapshot(archiveURL) AppSnapshot\n        +exportRestoreList(snapshots, directory)\n        +importRestoreList(directory)\n    }\n\n    class SnapshotPathResolver {\n        +candidatePaths() [URL]\n        +groupContainerPaths() [URL]\n        +existingPaths() [URL]\n    }\n\n    class SnapshotArchiver {\n        +archive(snapshot, destination)\n        +unarchive(archive, destination)\n    }\n\n    class AppQuitter {\n        +quit(bundleID)\n    }\n\n    class Sha256Hasher {\n        +hashFile(url) String\n        +hashTree(url) String\n    }\n\n    class AppSnapshot\n    class SnapshotManifest\n    class SnapshotComponent\n    class RestoreList\n\n    SnapshotsRootView --\u003e SnapshotListView\n    SnapshotsRootView --\u003e SnapshotDetailView\n    SnapshotsRootView --\u003e NewSnapshotView\n    SnapshotsRootView --\u003e RestoreWizardView\n    NewSnapshotView --\u003e SnapshotsStore\n    SnapshotListView --\u003e SnapshotsStore\n    SnapshotDetailView --\u003e SnapshotsStore\n    RestoreWizardView --\u003e RestoreWizardStore\n    RestoreWizardView --\u003e RestoreProgressView\n\n    SnapshotsStore --\u003e SnapshotService\n    RestoreWizardStore --\u003e SnapshotService\n    RestoreWizardStore --\u003e BrewInstaller : install missing casks\n\n    SnapshotService --\u003e SnapshotPathResolver\n    SnapshotService --\u003e SnapshotArchiver\n    SnapshotService --\u003e AppQuitter\n    SnapshotService --\u003e Sha256Hasher\n    SnapshotService --\u003e AppSnapshot\n    SnapshotService --\u003e SnapshotManifest\n    SnapshotService --\u003e RestoreList\n    SnapshotManifest --\u003e SnapshotComponent\n    AppSnapshot --\u003e SnapshotManifest\n```\n\n### Application Flow\n\n```mermaid\nflowchart TD\n    A[App Launch] --\u003e B[AppDelegate.didFinishLaunching]\n    B --\u003e C[Request Notification Permission]\n    B --\u003e D{Homebrew Installed?}\n\n    D --\u003e|No| E[Show Onboarding]\n    E --\u003e F[Install Homebrew]\n    F --\u003e G[Start SchedulerService]\n    D --\u003e|Yes| G\n\n    G --\u003e H{Trigger Mode?}\n\n    H --\u003e|Idle| I[Poll System Idle Time Every 60s]\n    H --\u003e|Scheduled| J[Calculate Time Until Next Run]\n\n    I --\u003e K{Idle \u003e= Threshold?}\n    K --\u003e|No| I\n    K --\u003e|Yes| L{Already Ran Today?}\n    L --\u003e|Yes| I\n    L --\u003e|No| M[Run Brew Update]\n\n    J --\u003e N[Sleep Until Scheduled Time]\n    N --\u003e O{Already Ran Today?}\n    O --\u003e|Yes| P[Wait Until Tomorrow]\n    O --\u003e|No| M\n    P --\u003e J\n\n    M --\u003e Q[brew update]\n    Q --\u003e Q1[fetchOutdated]\n    Q1 --\u003e Q2[UpdateEvaluator.evaluate]\n    Q2 --\u003e Q3{For each package}\n    Q3 --\u003e|policy=auto| R1[Add to autoInstall]\n    Q3 --\u003e|policy=delayedDays| Q4{Cooled off?}\n    Q4 --\u003e|Yes| R1\n    Q4 --\u003e|No| R2[Wait — keep ledger entry]\n    Q3 --\u003e|policy=manualApproval| Q5{Prior decision?}\n    Q5 --\u003e|Approved| R1\n    Q5 --\u003e|Rejected/Pending| R3[Add to PendingUpdatesStore]\n    Q3 --\u003e|policy=skip| R4[Skip]\n    R1 --\u003e R[brew upgrade selected formulae]\n    R --\u003e S[brew upgrade --cask selected casks]\n    S --\u003e T[brew cleanup --prune=7]\n    T --\u003e U{Success?}\n\n    U --\u003e|Yes| V[Save Last Run Date]\n    V --\u003e V1{New approvals?}\n    V1 --\u003e|Yes| V2[Show Pending Approvals Notification]\n    V1 --\u003e|No| W[Show Success Notification]\n    V2 --\u003e W\n\n    U --\u003e|No| X[Show Error Notification]\n\n    subgraph Sleep/Wake Recovery\n        Y[System Sleep] --\u003e Z[Record Sleep Time]\n        AA[System Wake] --\u003e AB{Missed Run?}\n        AB --\u003e|Yes| AC[Show Missed Run Notification]\n        AC --\u003e AD{User Action}\n        AD --\u003e|Run Now| M\n        AD --\u003e|Skip| I\n        AB --\u003e|No| I\n    end\n```\n\n### State Machine\n\n```mermaid\nstateDiagram-v2\n    [*] --\u003e Idle: App Start\n\n    Idle --\u003e WaitingForIdle: Trigger Mode = Idle\n    Idle --\u003e WaitingForSchedule: Trigger Mode = Scheduled\n\n    WaitingForIdle --\u003e Running: Idle Threshold Reached\n    WaitingForSchedule --\u003e Running: Scheduled Time Reached\n\n    WaitingForIdle --\u003e Running: Manual Trigger\n    WaitingForSchedule --\u003e Running: Manual Trigger\n\n    Running --\u003e Completed: Success\n    Running --\u003e Failed: Error\n\n    Completed --\u003e WaitingForIdle: Next Day (Idle Mode)\n    Completed --\u003e WaitingForSchedule: Next Day (Scheduled Mode)\n\n    Failed --\u003e WaitingForIdle: Retry Next Cycle\n    Failed --\u003e WaitingForSchedule: Retry Next Cycle\n```\n\n### Platform-Adaptive UI\n\nAutoBrew ships a single binary that targets macOS 14 (Sonoma) and up, but picks the most native surface treatment for whichever release the user is running. The choice happens at runtime through `if #available` gates collected in `Sources/Utilities/PlatformAdaptive.swift`:\n\n| Helper | macOS 26+ (Tahoe / Liquid Glass) | macOS 14 / 15 (classic) |\n|---|---|---|\n| `rotatingSymbolEffect(isActive:)` | `.symbolEffect(.rotate)` | `.symbolEffect(.pulse)` |\n| `adaptiveGlassCard(cornerRadius:)` | `glassEffect(.regular, in: .rect(...))` | `.background(.quaternary, in: RoundedRectangle(...))` |\n| `adaptiveGlassCapsule(tint:)` | `glassEffect(.regular.tint(...), in: .capsule)` | `.background(.tertiary` / `tint.opacity(0.2), in: Capsule())` |\n| `adaptiveProminentButtonStyle()` | `.buttonStyle(.glassProminent)` | `.buttonStyle(.borderedProminent)` |\n| `adaptiveBorderedButtonStyle()` | `.buttonStyle(.glass)` | `.buttonStyle(.bordered)` |\n\nEvery call-site in the app uses these helpers instead of the underlying style, so adding a new platform tier or tightening a fallback only happens in one place. Building the project still requires Xcode 26+ because the Liquid Glass symbols (`glassEffect`, `.glass`, `.glassProminent`) come from the macOS 26 SDK — but the produced binary deploys cleanly to macOS 14.\n\n## Project Structure\n\n```\nauto-brew/\n├── project.yml                          # XcodeGen project definition\n├── appcast.xml                          # Sparkle update feed\n├── AutoBrew/                            # Bundle resources\n│   ├── Info.plist                       # LSUIElement = true, autobrew:// URL scheme\n│   ├── AutoBrew.entitlements            # No sandbox (direct distribution)\n│   ├── Assets.xcassets                  # App icon\n│   ├── Localizable.xcstrings            # 8-language string catalog\n│   └── {en,de,fr,it,nl,pl,pt-BR,es}.lproj/InfoPlist.strings\n├── Sources/\n│   ├── App/                             # Entry point\n│   │   ├── AutoBrewApp.swift            # @main, MenuBarExtra scene\n│   │   └── AppDelegate.swift            # Lifecycle, autobrew:// URL handler\n│   ├── Models/                          # Plain value types (Codable, Sendable)\n│   │   ├── BrewError.swift, BrewStage.swift, OutdatedPackage.swift,\n│   │   ├── ProcessResult.swift, SchedulerState.swift, TriggerMode.swift\n│   │   ├── CaskCatalogEntry.swift       # formulae.brew.sh entry\n│   │   ├── CaskAnalytics.swift          # 30-day install counts\n│   │   ├── InstalledApp.swift           # /Applications scan result\n│   │   ├── BrowseCategory.swift         # Discover-section taxonomy\n│   │   ├── AppSnapshot.swift, SnapshotComponent.swift, SnapshotManifest.swift\n│   │   └── RestoreList.swift            # Cross-Mac bundle index\n│   ├── Services/                        # Stateful logic (@MainActor or Sendable)\n│   │   ├── BrewProcess.swift, BrewManager.swift, SchedulerService.swift\n│   │   ├── IdleDetector.swift, SleepWakeObserver.swift,\n│   │   ├── NotificationManager.swift, LoginItemManager.swift, UpdaterService.swift\n│   │   ├── BrewCatalogService.swift     # Catalog + analytics download/cache\n│   │   ├── BrewInstaller.swift          # install / upgrade / uninstall / search\n│   │   ├── AppDiscoveryService.swift    # /Applications scanner\n│   │   ├── CaskNameResolver.swift       # App name -\u003e cask token mapping\n│   │   ├── SnapshotService.swift        # Create / list / restore / export / import\n│   │   ├── SnapshotArchiver.swift       # ZIP bundle + manifest validation\n│   │   ├── SnapshotPathResolver.swift   # Per-bundle-id Library paths\n│   │   ├── AppQuitter.swift             # Quit before restore\n│   │   └── RemoteIconLoader.swift       # Cask icon fetch + on-disk cache\n│   ├── ViewModels/                      # @Observable @MainActor stores\n│   │   ├── SettingsStore.swift          # UserDefaults bridge\n│   │   ├── CatalogStore.swift           # BrewStore browse/discover state\n│   │   ├── InstalledAppsStore.swift     # Installed apps + cask matching\n│   │   ├── SnapshotsStore.swift         # Snapshot list + operations\n│   │   └── RestoreWizardStore.swift     # Cross-Mac restore flow\n│   ├── Views/                           # SwiftUI views\n│   │   ├── MenuBarView.swift, MenuBarIcon.swift, SettingsView.swift\n│   │   ├── LogView.swift, OnboardingView.swift\n│   │   ├── BrewStoreWindow.swift        # Root window for BrewStore\n│   │   ├── BrewStore/                   # Sidebar + sections\n│   │   │   ├── BrewStoreSidebar.swift, DiscoverView.swift, DiscoverSection.swift\n│   │   │   ├── RankedCaskRow.swift, CategoryListView.swift, UpdatesView.swift\n│   │   ├── Browse/                      # Cask detail\n│   │   │   ├── BrowseDetailView.swift, CaskIconView.swift\n│   │   ├── Installed/\n│   │   │   ├── InstalledAppsView.swift, InstalledAppRowView.swift\n│   │   ├── Snapshots/\n│   │   │   ├── SnapshotsRootView.swift, SnapshotListView.swift,\n│   │   │   ├── SnapshotDetailView.swift, NewSnapshotView.swift\n│   │   └── Restore/\n│   │       ├── RestoreWizardView.swift, RestoreProgressView.swift\n│   └── Utilities/                       # Pure helpers\n│       ├── AppLogger.swift              # Unified os.Logger\n│       ├── AppleAppFilter.swift         # Drop Apple-bundled apps from discovery\n│       ├── Sha256Hasher.swift           # File + length-prefixed tree hashes\n│       ├── ByteFormatter.swift          # Human-readable sizes\n│       ├── NSPanelAsync.swift           # async/await wrapper around NSOpenPanel\n│       └── PlatformAdaptive.swift       # `if #available` View modifiers — Liquid Glass on macOS 26+, classic materials on macOS 14/15\n└── Tests/                               # XCTest (121 tests across 22 files)\n    ├── Models:   CaskCatalogEntryTests, RestoreListTests, BrowseCategoryTests,\n    │             LegalDocumentTests, PendingUpdatesStoreTests, SemVerTests\n    ├── Services: BrewCatalogServiceTests, AppDiscoveryServiceTests,\n    │             CaskNameResolverTests, SnapshotServiceTests,\n    │             SnapshotArchiverTests, SnapshotPathResolverTests,\n    │             BrewManagerTests, IdleDetectorTests,\n    │             UpdateEvaluatorTests, UpdateLedgerTests\n    ├── ViewModels: CatalogStoreTests, SettingsStoreTests, SupportPromptStoreTests\n    └── Utilities: AppleAppFilterTests, Sha256HasherTests, MarkdownParserTests\n```\n\n## Tests\n\nXCTest covers the model layer, services, view-models, and utilities — currently **121 tests** across 22 files:\n\n| Layer | Suites |\n|---|---|\n| Models | `CaskCatalogEntryTests`, `RestoreListTests`, `BrowseCategoryTests`, `LegalDocumentTests`, `PendingUpdatesStoreTests`, `SemVerTests` |\n| Services | `BrewCatalogServiceTests`, `AppDiscoveryServiceTests`, `CaskNameResolverTests`, `SnapshotServiceTests`, `SnapshotArchiverTests`, `SnapshotPathResolverTests`, `BrewManagerTests`, `IdleDetectorTests`, `UpdateEvaluatorTests`, `UpdateLedgerTests` |\n| ViewModels | `CatalogStoreTests`, `SettingsStoreTests`, `SupportPromptStoreTests` |\n| Utilities | `AppleAppFilterTests`, `Sha256HasherTests`, `MarkdownParserTests` |\n\nRun with:\n\n```bash\nxcodebuild test -scheme AutoBrew -destination 'platform=macOS'\n```\n\n## Security \u0026 Data Integrity\n\nThe AppSnapshot engine handles arbitrary user data. AutoBrew implements:\n\n- **Path-traversal protection**: snapshot restore validates every path stays inside `$HOME`; archive extraction rejects symlinks and absolute paths; bundle IDs validated against `^[a-zA-Z0-9][a-zA-Z0-9._-]*$`.\n- **Hash verification**: every snapshot file has a SHA-256 hash; every directory has a tree hash using length-prefixed binary framing. Mismatch aborts the restore before any data is overwritten.\n- **Transactional restore**: two-phase commit — all existing destinations are moved to backups first, then copies happen, then backups are removed. Failure at any step rolls back atomically.\n- **TOCTOU protection**: hashes are re-verified after copy.\n- **URL-scheme CSRF**: `autobrew://install/\u003ctoken\u003e` opens an `NSAlert` requiring user confirmation; token regex blocks flag injection (`--cask`, etc.).\n- **Process isolation**: `brew` invocations use a lock-protected `Process` wrapper to prevent race conditions; respects parent task cancellation.\n- **Schema versioning**: imports reject unsupported schema versions.\n- **Saturating arithmetic**: cumulative file sizes use overflow-reporting addition to prevent Int64 wrap.\n\n## Support\n\nIf you find AutoBrew useful, consider [sponsoring the project](https://github.com/sponsors/marcelrgberger).\n\n## License\n\nMIT License — see [LICENSE](LICENSE) for details.\n\nCopyright 2026 Marcel R. G. Berger.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmarcelrgberger%2Fauto-brew","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmarcelrgberger%2Fauto-brew","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmarcelrgberger%2Fauto-brew/lists"}