macOS menu bar app for Restic
README.md

Snapshot #

A cross-platform app for scheduled restic backups, with native UIs: a menu bar app on macOS, a libadwaita app on GNOME, and a tray app on Windows. This repository holds the shared engine those UIs are built on, plus a command-line front end.

Snapshot backs up; it never runs forget, prune or any other command that deletes data. Retention is the repository server's job.

Layout #

crates/core     snapshot-core: config, restic driver, runs, status, OS scheduling
crates/cli      `snapshot`: terminal front end, and the scheduled-run entry point on Linux
crates/ffi      snapshot-ffi: UniFFI bindings for Swift (and later C#)
crates/bindgen  the Swift binding generator
apple/          the macOS app's Xcode project, whose SnapshotKit framework wraps snapshot-ffi

UI layers use snapshot_core::Core. The GNOME app can depend on the crate directly; the macOS and Windows apps go through snapshot-ffi.

How it works #

Repository and profiles. One repository (a rest: URL or a local path) and any number of profiles. A profile is a set of paths, an exclude list in restic's exclude-file syntax, extra restic flags, and a schedule. Passwords live in the platform credential store, never in the config file. The UI and the scheduled runs are separate processes, so Core::save_config takes the config as it was read and refuses to save over a change made elsewhere since.

Schedules are installed with the OS, so backups run whether or not a UI is open:

Platform Mechanism Job
macOS LaunchAgent in ~/Library/LaunchAgents rs.averyrive.Snapshot.backup.<profile id>
Linux systemd user .timer and .service snapshot-backup-<profile id>
Windows not yet implemented

Each job runs <runner> run --scheduled <profile id>, where the runner is a program the UI layer names (scheduler::RunnerCommand). Schedules are a closed set (manual, clock-aligned intervals, daily, weekly) that maps exactly onto both launchd's StartCalendarInterval and systemd's OnCalendar, so the "next backup" a UI shows is the slot the OS will actually fire. Both OS schedulers run a slot missed during sleep once on wake, not once per missed slot. Core::sync_schedules makes the installed jobs match the config; call it after any change to profiles. Pausing, power and network conditions, and disabled profiles are checked by the run itself, so they need no sync.

Runs. Core::run_backup runs one backup and records it. Runs of different profiles go ahead side by side, since restic backups share the repository lock. A profile never has two runs at once: a second request fails with AlreadyRunning, except that a manual run takes over from a scheduled one that is still waiting.

A scheduled run first waits until conditions allow it: mains power (if require_ac_power is set), an unmetered connection (if skip_metered_networks is set, the default), the machine not being asleep on battery, and the repository being reachable. For rest:, reachable means a TCP connection opens; checking that first matters because restic retries an unreachable server for many minutes. The run re-checks conditions every 30 seconds, and the repository at gaps that double from 30 seconds to 5 minutes. If it still can't start a minute before the profile's next slot, or after a day if that's sooner, it is recorded as skipped, not failed: a laptop on battery or off the tailnet isn't broken. Time asleep doesn't count as waiting: a run the machine sleeps through starts its wait over on waking. The OS doesn't start a profile's job while its last run is alive, so such a run stands in for the slots the sleep missed. A manual run ignores the conditions, and fails at once if the repository can't be reached, since someone is waiting on it. Core::manual_override reports which conditions a manual run would be overriding, for a confirmation.

A locked repository is waited out by scheduled and manual runs alike. Backups share the repository lock with each other, but maintenance such as the server's prune holds it exclusively, and restic's backup then exits at once. A lock held by a live process clears on its own, which is why a manual run waits for it, where an unreachable repository needs something fixed and fails at once. The run tries again at the same growing gaps, without using up its retries. A scheduled run still locked out when its wait ends is recorded as skipped; a manual run gives up after a day and fails.

A holder refreshes its lock every 5 minutes, and restic reports how long ago it last did. A lock that has gone 30 minutes without a refresh, restic's own measure of a stale lock, was left behind by a process that stopped, such as a killed prune. Waiting won't help, and restic never skips such a lock, so the run fails at once, scheduled or manual, with the usual failure notice. The fix is to remove the lock with restic unlock.

Failures during a backup that are likely to fix themselves (network errors, a stalled restic) retry after 30 s, 2 min, 8 min and 30 min. On Linux, a run holds a logind inhibitor so the machine doesn't sleep mid-backup.

Status. Whichever process runs a backup publishes its progress to a file in the state directory, so every UI sees every run, including scheduled ones. Core::overview returns everything a status display needs, including a single Health for a menu bar icon, and Watcher reports changes. Core::cancel_run stops a run in any process.

Notifications. The core decides what to tell the user (Notice) and hands it to a Notifier that the platform layer implements. It reports failures, warnings when they first appear rather than on every run, successes if enabled, and an overdue backup once per stretch of missed backups.

restic updates. With settings.developer.check_restic_updates on, Snapshot asks GitHub for the latest restic release at most once a day and sends one notice per new release. This exists because the macOS app bundles and signs its own restic, which must be rebuilt to update.

Snapshot tags. Every snapshot is tagged with the platform (macOS, Linux), the OS major release (macOS 26, fedora 44), the profile's tag, and manual or scheduled. The vocabulary is fixed because tags can't be corrected afterwards on an append-only repository.

Command line #

The snapshot CLI covers setup and day-to-day use. Install it with cargo install --path crates/cli; scheduled jobs run the copy that schedule sync was run from, so use the installed one rather than a build directory.

OS jobs start with a minimal PATH, so schedule sync gives them the restic it finds through SNAPSHOT_RESTIC or PATH, used when the settings don't name one. Run schedule sync again whenever that path changes, such as after a version manager like mise upgrades restic; a Homebrew restic keeps its path.

For a rest-server reached over Tailscale:

snapshot repo set rest:http://nas.example.ts.net:8000/laptop --username me
snapshot password            # repository password, read from the terminal or stdin
snapshot password --rest     # rest-server password
snapshot repo init           # or `snapshot repo verify` for an existing repository
snapshot profile add Home --path ~ --daily 02:00
snapshot run home            # back up now
snapshot schedule sync       # install the OS jobs
snapshot status

snapshot profile show, profile set and settings show and change the rest; snapshot --help lists everything. Scheduled runs report failures as desktop notifications on Linux; their logs are in the journal (journalctl --user -u 'snapshot-backup-*').

Files #

macOS Linux
Config ~/Library/Application Support/rs.averyrive.Snapshot/config.json ~/.config/snapshot/config.json
State, live runs same directory ~/.local/state/snapshot/
Scheduled-run logs ~/Library/Logs/Snapshot/ the journal
Passwords login Keychain Secret Service (GNOME Keyring, KWallet)

Keychain items use the service rs.averyrive.Snapshot and the accounts repository.password and repository.basic-auth.password, the same as the original macOS-only Snapshot, so its stored passwords carry over.

Environment variables, for development and headless use:

  • SNAPSHOT_HOME=<dir> keeps config, state and logs under <dir>. Its OS jobs get their own names and carry this variable, and its passwords use the credential store service rs.averyrive.Snapshot.<hash of dir>, so a development setup can't replace or remove the real schedules or passwords.
  • SNAPSHOT_SECRETS=file stores passwords in a 0600 file in the config directory instead of the credential store.
  • SNAPSHOT_RESTIC=<path> (CLI only) uses that restic when the settings don't name one.
  • SNAPSHOT_LOG=debug (CLI only) logs restic invocations.

Building a UI layer #

macOS #

  • Bundle restic inside the app (e.g. Contents/Helpers/restic) and pass its path when creating the core. A helper inside the bundle inherits the app's Full Disk Access grant; one elsewhere does not.
  • Use the app's own executable as the RunnerCommand, and have main handle run --scheduled <profile id> by running the backup headless and exiting. Full Disk Access and Keychain access belong to the app's code-signing identity, so the scheduled run must be the app.
  • Implement Notifier with UNUserNotificationCenter; the headless run is the same bundle, so it can post too.
  • Pass a ConditionProbe that sets networkMetered from NWPathMonitor (isExpensive || isConstrained); without it, skipMeteredNetworks has no effect on macOS. Pass it to the SnapshotCore in the headless run too, since only scheduled runs wait on conditions. Leave onAcPower and systemAsleep nil: the built-in probe fills in every nil field, reading both from IOKit, and any value you set, false included, replaces its reading.
  • A headless run asks the probe as soon as it starts, so have current() wait for the monitor's first path. Deliver the monitor's updates on a queue of its own: manualOverride calls current() on its caller's thread, which may be the main thread.
  • Hold a kIOPMAssertNetworkClientActive power assertion around runBackup. Unlike the idle-sleep assertion, it keeps the Mac up through a dark wake, though only on mains.
  • The default Keychain store gives up after 30 seconds rather than hang a 3 a.m. run on a dialog nobody can answer. To control when the Keychain may prompt (for instance, never during scheduled runs), pass your own SecretStore.
  • macOS shows "Background Items Added" when Snapshot installs its LaunchAgents, and lists them under the app in Login Items.
  • The app is apple/Snapshot.xcodeproj. Its SnapshotKit framework target builds the Rust and generates the bindings, which the app gets with import SnapshotKit; Development below covers setup.

GNOME #

  • Depend on snapshot-core directly. The runner can be the snapshot CLI or the app's own binary accepting the same run --scheduled <id> arguments.
  • systemd user timers run while the user's systemd instance does: during a login session, or always after loginctl enable-linger. A run while logged out usually finds the keyring locked.
  • Under Flatpak, writing units and calling systemctl needs host access, and the runner becomes flatpak run --command=snapshot <app id>. That packaging isn't designed yet.

Windows #

The core builds for Windows and uses Credential Manager, but there is no Task Scheduler backend yet.

Development #

mise install          # Rust, restic and rest-server at pinned versions
mise exec -- cargo test --workspace
mise exec -- cargo clippy --workspace --all-targets

mise exec -- puts the pinned tools on PATH; with mise activated in your shell, plain cargo does the same. The integration tests in crates/core/tests run real backups against a local rest-server, and skip themselves when restic or rest-server isn't on PATH. Only one test, restic_version, fails when the restic it finds isn't the version mise.toml pins. The lock handling relies on how the pinned restic words and ages its locks, so its tests hold real locks and write one the way restic does. One slow test, which waits out restic's lock refresh, is #[ignore]d. After changing the restic version in mise.toml, and before committing, run mise install and then this, so that it runs against the new version:

mise exec -- cargo test -p snapshot-core --test rest_server -- --ignored

For the macOS app, run mise install (or install Rust with rustup), open apple/Snapshot.xcodeproj, set Development Team in the project's build settings (every target inherits it) to your own, and run the Snapshot scheme. Building compiles the Rust too, through mise when it's installed: unoptimized for your Mac's architecture in Debug, optimized for Apple silicon and Intel in Release. The first build compiles all of it, so it's the slow one. Xcode doesn't read your shell profile, so the build takes CARGO_HOME and RUSTUP_HOME from your login shell. The app's version is the workspace version in Cargo.toml.

SnapshotKit's backup test skips itself when run from Xcode, which can't see restic or rest-server. Run it from the repository root with:

mise exec -- xcodebuild test -project apple/Snapshot.xcodeproj -scheme Snapshot