macOS menu bar app for Restic
README.md

Snapshot #

A macOS menu bar app for restic backups. Multiple scheduled profiles, live progress, and a status icon that tells the truth about whether your backups are actually happening.

  • Menu bar status — idle, running with a progress ring, retrying, overdue, waiting for network or power, failed, paused.
  • Profiles — each with its own paths, excludes and schedule. "Back Up Now" runs a profile on demand.
  • Laptop-aware — sleeps, network changes and roaming don't produce spurious failures; a run interrupted by a closed lid is retried, not reported as broken. Asking for a backup on battery or over a tether says so and waits for an answer, rather than silently doing either thing you told it not to schedule.
  • Append-only safe — Snapshot never runs forget, prune, rewrite or repair. Retention is the server's job.
  • No Homebrew required — restic is bundled inside the app.

Supported backends are rest: URLs and local paths. See Why not sftp? below.

Requirements #

  • macOS 15 or later
  • Xcode or the Command Line Tools (for swift build). The Command Line Tools 27 SDK makes SwiftUI's @State a macro but ships no plugin to expand it, so the app target won't compile there; the Makefile switches to Xcode's toolchain when it finds one, and a bare swift build needs DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer.
  • A code-signing certificate — see below

Building #

make app        # fetch restic, build, assemble and sign Snapshot.app
make install    # copy it to /Applications
make test       # run the test suite

make app downloads a pinned restic release, verifies it against the checksum in Vendor/restic.sha256, and signs it into Contents/Helpers/restic with the same identity as the app.

Code signing, and why it matters here #

Snapshot needs Full Disk Access, and macOS ties that grant to the app's designated requirement. Sign ad hoc and the requirement is a code hash, so every rebuild looks like a different app and the grant is revoked. Sign with a stable certificate and the requirement names the certificate instead:

identifier "rs.averyrive.Snapshot" and certificate leaf = H"7bdff5b8…"

That survives rebuilds of the app and of restic, because restic's hash is sealed as a resource of a bundle whose own identity never changes.

The build defaults to an identity named restic-backup. Override it with CODESIGN_IDENTITY:

make app CODESIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)"

Creating the certificate #

A self-signed certificate is better here than a free Apple developer account: Apple Development certificates expire after a year, and re-issuing produces a new leaf, which resets Full Disk Access. A self-signed one can last a decade.

  1. Open Keychain Access → menu Keychain Access ▸ Certificate Assistant ▸ Create a Certificate…
  2. Name: restic-backup · Identity Type: Self Signed Root · Certificate Type: Code Signing
  3. Tick Let me override defaults, and set a validity of several thousand days.
  4. Leave everything else as offered; save into the login keychain.

security find-identity -v -p codesigning will report "0 valid identities" for a self-signed certificate — that only means the system doesn't trust it as a root, and does not stop codesign using it.

Full Disk Access #

Grant it to /Applications/Snapshot.app in System Settings ▸ Privacy & Security ▸ Full Disk Access. There is no API to request it, so Snapshot can only detect and explain it.

Without the grant restic skips files it cannot read — Mail, Messages and much of ~/Library — and still reports success. Snapshot counts those and shows "N files couldn't be read" after each run, which is the practical signal that the grant is missing.

Bundling restic is what makes one grant enough: a helper binary inside the app bundle inherits the app's access, while one in ~/Library/Application Support does not.

One thing the certificate does not cover #

Full Disk Access survives rebuilds, because TCC matches the designated requirement, which names the certificate. macOS's legacy Keychain does not work that way: an item's ACL is pinned to the binary that created it, so a rebuilt Snapshot is a stranger to its own stored password.

Measured on macOS 26, with a control (the creating binary reading its own item back succeeds in every case):

Item created with Read by a different build, same identifier and certificate
Default ACL from SecItemAdd Requires authorisation
Explicit SecAccess naming the app (SecTrustedApplicationCreateFromPath) Requires authorisation

So there is no "always allow this app" that survives a rebuild. The modern data-protection Keychain has no such prompts, but SecItemAdd there returns errSecMissingEntitlement (-34018) without a keychain-access-groups entitlement, which needs a provisioning profile and so an Apple developer team. Not available to a self-signed build. kSecUseAuthenticationUIFail looks like a way to detect this without a dialog, but it is a data-protection-Keychain flag and is ignored for legacy items — it prompts anyway.

What Snapshot does about it. Keychain reads refuse user interaction unless you are present:

  • A scheduled run refuses it. After a rebuild it fails immediately with "The Keychain won't release the repository password to this build" instead of raising a dialog at 3am that nobody can answer, or hanging on one.
  • A run you start, and anything in Settings, allows it — you are there to click Always Allow, which adds the new build to the ACL for good.
  • At launch it makes the scheduled kind of read once, and if that is refused the icon says so and the menu offers Authorise…, which makes the other kind. Answering Always Allow is the whole fix; no backup has to run for it. A locked keychain is told apart and offered Unlock… instead.

So after make install, open the menu and authorise it. If you would rather never see the prompt, the alternatives are an allow-any-application ACL, or keeping the password in a 0600 file the way a shell script would — both trade the Keychain's protection away, and neither is the default here.

Updating restic #

Edit RESTIC_VERSION in Scripts/fetch-restic.sh and the matching checksums in Vendor/restic.sha256 (from the release's SHA256SUMS), then make app. The Full Disk Access grant is unaffected.

Configuration #

Path Contents
~/Library/Application Support/rs.averyrive.Snapshot/config.json repository, profiles, settings
~/Library/Application Support/rs.averyrive.Snapshot/state.json last run per profile, recent history
~/Library/Application Support/rs.averyrive.Snapshot/excludes/ one generated exclude file per profile
~/Library/Logs/Snapshot/snapshot.log app events and restic's stderr
Login Keychain repository password, rest: basic-auth password

No credentials are ever written to config.json. Environment variables set in Settings are the exception by design — they are stored in plain text, so put proxies and cache paths there, not keys.

restic options #

Rather than enumerating restic's flags as settings, Snapshot has freeform flag fields — one per repository and one per profile. One flag per line, split on the first whitespace:

--compression max
--pack-size 128
--cache-dir /Volumes/Big Disk/restic

Flags Snapshot sets itself (--repo, --tag, --host, --json, --password-file, …) are rejected, because overriding them would produce mistagged snapshots or unparseable output. The profile editor previews the exact command line.

Excludes #

A profile's excludes are restic exclude-file syntax, and are handed to restic as a file rather than as --exclude patterns — so # comments, blank lines and $HOME all behave as restic documents them (~ does not expand; restic never expands it). An existing exclude file can be pasted in whole.

Snapshot tags #

Every snapshot gets exactly four tags, and there is no tag editing UI — a closed vocabulary stays queryable, and tags can't be corrected retroactively on an append-only repository.

Tag Example
Platform macOS
OS major version macOS 26
Profile slug home
Trigger manual or scheduled

Design notes #

Scheduling #

Timers don't fire while a laptop is asleep and can't survive a clock change, so scheduling is driven by a 60-second heartbeat comparing the wall clock against persisted due dates, with immediate re-evaluation on wake, network change and power change.

A backlog collapses: a week asleep produces one catch-up run, not seven. Lateness is measured separately, from the oldest unsatisfied slot, because measured from the most recent one a daily schedule could never be more than 24 hours late.

Transient versus real failure #

Network-shaped errors and stalls back off and retry (30s → 2m → 8m → 30m) without turning the icon red; only a genuine error, or an exhausted ladder, is reported as a failure. A run that produces no output for ten minutes is killed and retried, which stops a dead connection blocking every later run.

Progress, and why it never reaches 100% #

restic's bytes_done counts bytes read, not uploaded, and packs are flushed to the repository asynchronously. So the counter runs ahead of the network and then waits: on an upload-bound run it advances in bursts separated by twenty seconds of nothing, and it hits its total while the last packs, trees and index are still in flight. restic reports nothing at all about that phase — it keeps sending status messages with every field identical except seconds_elapsed.

Measured against a throttled link, that tail was a fifth to a half of the wall time, and on a small backup over a slow link it was all but four seconds of it. So the phase is named rather than drawn: once everything is read the row says Finishing up… with an indeterminate bar and the time spent so far, which is the only honest number available. The remaining-time estimate is also measured across advances only, never across the waits — averaged over the waits it collapses towards zero, and an estimate that divides by it grows without bound.

Diagnostics #

Settings ▸ Advanced ▸ Diagnostics keeps a timestamped, verbatim copy of what restic reports. It is a live debugging aid rather than a record: switch it on, reproduce the problem, read the file. The switch is off again at the next launch, the files go to a per-launch directory under the system's temporary directory, and nothing prunes them — a tool that deletes the transcript you went looking for is worse than one that keeps too many. Reveal Saved Output in Finder opens the current session's directory.

Consecutive identical lines are collapsed to one plus a count, which matters because restic repeats an unchanged status about ten times a second: a 57-second run recorded 576 messages as 123 lines with the timing intact. The wall clock on each line is the point — restic's own seconds_elapsed is truncated to whole seconds, and the gap between messages is what separates a slow upload from a hung one. The files name the paths being backed up, so read one before sending it on.

Why not sftp? #

restic implements sftp: by shelling out to ssh, which brings in agent sockets, known_hosts and key passphrases. A menu bar app spawning ssh non-interactively doesn't fail cleanly on any of those — it blocks on a prompt nobody can see, and an indefinite hang is worse than an error. Object stores (s3:, b2:) need only environment variables and would be cheap to add.

Swapping the backend #

Everything above BackupEngine (in SnapshotKit) is engine-agnostic. Replacing restic means adding one conforming type; no scheduling or UI changes. ResticEngine is the only module that knows restic exists.

Migrating from a launchd agent #

If you already back up via a launchd plist and a shell script:

  1. Set up the repository and a matching profile in Snapshot, and run it once.
  2. Confirm the snapshot appears with the expected tags and host.
  3. Then retire the old agent:
launchctl bootout gui/$(id -u)/local.restic.backup

Snapshot writes its OS tag as macOS 26 (major version only), so it won't match snapshots tagged with a full macOS 26.6.2 by an earlier script.

Layout #

Sources/SnapshotKit      models, config, Keychain, scheduling — pure and testable
Sources/ResticEngine     the only module that knows restic exists
Sources/SnapshotService  run queue, retries, condition monitors, notifications
Sources/SnapshotApp      SwiftUI menu bar, settings, welcome
Scripts/                 fetch-restic.sh, make-app.sh

restic is redistributed under the BSD 2-Clause licence; the notice ships in Contents/Resources/Licenses/ and is shown in Snapshot's About tab.