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 servicers.averyrive.Snapshot.<hash of dir>, so a development setup can't replace or remove the real schedules or passwords.SNAPSHOT_SECRETS=filestores passwords in a0600file 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 havemainhandlerun --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
NotifierwithUNUserNotificationCenter; the headless run is the same bundle, so it can post too. - Pass a
ConditionProbethat setsnetworkMeteredfromNWPathMonitor(isExpensive || isConstrained); without it,skipMeteredNetworkshas no effect on macOS. Pass it to theSnapshotCorein the headless run too, since only scheduled runs wait on conditions. LeaveonAcPowerandsystemAsleepnil: the built-in probe fills in everynilfield, reading both from IOKit, and any value you set,falseincluded, 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:manualOverridecallscurrent()on its caller's thread, which may be the main thread. - Hold a
kIOPMAssertNetworkClientActivepower assertion aroundrunBackup. 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 withimport SnapshotKit; Development below covers setup.
GNOME #
- Depend on
snapshot-coredirectly. The runner can be thesnapshotCLI or the app's own binary accepting the samerun --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
systemctlneeds host access, and the runner becomesflatpak 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