powerful but friendly backup program that runs in your tray, powered by restic devins.page/restray
go restic system-tray
2

Configure Feed

Select the types of activity you want to include in your feed.

feat: daemon

intergrav (Jun 30, 2026, 4:02 PM EDT) 48994d53 85cb897a

+182 -17
+8 -1
README.md
··· 1 1 # Restray 2 2 3 - A simple Go application that runs in the background/system tray, that can schedule and manage [restic](https://restic.net) backups and profiles easily. It can also use multiple profiles with different schedules and settings at once. It's also pretty configurable, see config below. 3 + Restray is a simple, handy GUI/CLI/daemon to manage and schedule restic backups with ease. Supports multiple profiles and quite a bit of configuration, along with other features (see feature list below). 4 4 5 5 Restray is intended for desktops. Runs on MacOS, Windows, and Linux. If you're on GNOME, you will need to install [this extension](https://extensions.gnome.org/extension/615/appindicator-support) to interact with and use the app since GNOME doesn't natively support the system tray. 6 6 ··· 28 28 - I'd recommend something like [restic-browser](https://github.com/emuell/restic-browser) to browse on Windows, or just open the Shell and use the CLI 29 29 - Full Disk Access detection and prompt on macOS 30 30 - Config file hot-reloading 31 + - Can runs headless as CLI or daemon, not just the tray GUI 31 32 32 33 ## Installation 33 34 ··· 38 39 ### Building from source 39 40 40 41 On [Nix(OS)](https://nixos.org)/nix-darwin, run `nix build` (or add this repo to your flake and simply install the package). Otherwise, install the dependencies from `flake.nix`'s devshell and run `just build` or `just package`. Both default to your current platform. You can also pass a target to cross-compile (e.g. `just build linux amd64`, `just package windows`). 42 + 43 + ## CLI / daemon 44 + 45 + Restray also works without the GUI on MacOS/Linux. Run `restray daemon` to run just the built-in scheduler (no tray GUI), or use one-shot commands for other things. Run `restray --help` for the full command list. 46 + 47 + Linux users: systemd unit files are included (`packaging/linux/restray.service` for system-wide, `packaging/linux/restray-user.service` for per-user). If you use NixOS, a `services.restray` module is also exported from the flake :) 41 48 42 49 ## Configuration 43 50
+90 -9
cmd/restray/cli.go
··· 1 1 package main 2 2 3 3 import ( 4 + "errors" 4 5 "flag" 5 6 "fmt" 6 7 "os" ··· 8 9 "os/signal" 9 10 "runtime" 10 11 "strings" 12 + "sync" 11 13 "sync/atomic" 12 14 "syscall" 15 + 16 + "github.com/robfig/cron/v3" 13 17 ) 14 18 15 19 func cliUsage() { 16 20 fmt.Fprintf(os.Stderr, `Usage: restray [command] 17 21 18 22 Commands: 19 - (no command) Launch GUI with built-in scheduler 23 + (no command) Launch restray (GUI + built-in scheduler) 24 + daemon Launch restray (built-in scheduler only) 20 25 schedule [-p name] Run full schedule with hooks 21 - op|operation backup [-p name] Run backup 22 - op prune [-p name] Prune old snapshots 23 - op check [-p name] Verify repository integrity 24 - op unlock [-p name] Remove stale locks 25 - op shell [-p name] Open shell with restic environment 26 - op mount [-p name] Mount repository (Ctrl+C to unmount) 27 - op pre-hook [-p name] Run pre-hook command 28 - op post-hook [-p name] Run post-hook command 26 + operation backup [-p name] Run backup 27 + operation prune [-p name] Prune old snapshots 28 + operation check [-p name] Verify repository integrity 29 + operation unlock [-p name] Remove stale locks 30 + operation shell [-p name] Open shell with restic environment 31 + operation mount [-p name] Mount repository (Ctrl+C to unmount) 32 + operation pre-hook [-p name] Run pre-hook command 33 + operation post-hook [-p name] Run post-hook command 29 34 configure config Create/open config file in editor 30 35 configure env [-p name] Create/open env file in editor 31 36 ··· 303 308 return 0 304 309 } 305 310 311 + func buildDaemonSchedule(wg *sync.WaitGroup) (*cron.Cron, error) { 312 + sched := cron.New() 313 + for _, prof := range loadConfig().Profiles { 314 + if prof.Schedule.Cron == "" { 315 + continue 316 + } 317 + name := prof.displayName() 318 + _, err := sched.AddFunc(prof.Schedule.Cron, func() { 319 + current := loadConfig() 320 + _, prof, err := resolveProfile(current, name) 321 + if err != nil { 322 + fmt.Fprintf(os.Stderr, "[%s] skipping scheduled run: %v\n", name, err) 323 + return 324 + } 325 + if skipForBattery(prof) { 326 + fmt.Fprintf(os.Stderr, "[%s] skipping scheduled run: on battery power\n", prof.displayName()) 327 + return 328 + } 329 + wg.Add(1) 330 + defer wg.Done() 331 + fmt.Fprintf(os.Stderr, "[%s] running scheduled job\n", prof.displayName()) 332 + cliSchedule(prof) 333 + }) 334 + if err != nil { 335 + return nil, fmt.Errorf("invalid cron expression %q for profile %q: %w", prof.Schedule.Cron, name, err) 336 + } 337 + } 338 + if len(sched.Entries()) == 0 { 339 + return nil, errors.New("no profiles have a schedule configured") 340 + } 341 + return sched, nil 342 + } 343 + 344 + func cliDaemon() int { 345 + p, _ := findRestic() 346 + if p == "" { 347 + fmt.Fprintln(os.Stderr, "error: restic not found") 348 + return 1 349 + } 350 + 351 + var wg sync.WaitGroup 352 + sched, err := buildDaemonSchedule(&wg) 353 + if err != nil { 354 + fmt.Fprintln(os.Stderr, "error:", err) 355 + return 1 356 + } 357 + 358 + sigCh := make(chan os.Signal, 1) 359 + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) 360 + sched.Start() 361 + fmt.Fprintln(os.Stderr, "Scheduler running, press Ctrl+C to stop") 362 + 363 + for sig := range sigCh { 364 + if sig != syscall.SIGHUP { 365 + break 366 + } 367 + next, err := buildDaemonSchedule(&wg) 368 + if err != nil { 369 + fmt.Fprintln(os.Stderr, "error: reload failed, keeping previous schedule:", err) 370 + continue 371 + } 372 + <-sched.Stop().Done() 373 + sched = next 374 + sched.Start() 375 + fmt.Fprintln(os.Stderr, "Scheduler reloaded") 376 + } 377 + 378 + <-sched.Stop().Done() 379 + fmt.Fprintln(os.Stderr, "Waiting for running jobs to finish...") 380 + wg.Wait() 381 + return 0 382 + } 383 + 306 384 func cliResolve(args []string) (Profile, error) { 307 385 name, _ := cliParseFlags(args) 308 386 cfg := loadConfig() ··· 317 395 } 318 396 319 397 switch args[0] { 398 + case "daemon": 399 + return cliDaemon() 400 + 320 401 case "schedule": 321 402 prof, err := cliResolve(args[1:]) 322 403 if err != nil {
+3
cmd/restray/config.go
··· 178 178 } 179 179 180 180 func dataDir() string { 181 + if env := os.Getenv("RESTRAY_STATE"); env != "" { 182 + return env 183 + } 181 184 switch runtime.GOOS { 182 185 case "darwin": 183 186 if home, err := os.UserHomeDir(); err == nil {
+12 -5
cmd/restray/tray.go
··· 488 488 489 489 if ps.errMsg == "" && prof.Schedule.Cron != "" { 490 490 idx := i 491 + name := prof.displayName() 491 492 eid, err := sched.AddFunc(prof.Schedule.Cron, func() { 492 493 current := loadConfig() 493 - if idx >= len(current.Profiles) { 494 + _, prof, err := resolveProfile(current, name) 495 + if err != nil { 496 + log.Printf("[%s] skipping scheduled backup: %v", name, err) 494 497 return 495 498 } 496 - if !current.Profiles[idx].Schedule.OnBattery && onBatteryPower() { 497 - log.Printf("[%s] skipping scheduled backup: on battery power", current.Profiles[idx].displayName()) 499 + if skipForBattery(prof) { 500 + log.Printf("[%s] skipping scheduled backup: on battery power", prof.displayName()) 498 501 return 499 502 } 500 503 go func() { 501 504 defer func() { 502 505 if r := recover(); r != nil { 503 - log.Printf("[%s] scheduled run panicked: %v", current.Profiles[idx].displayName(), r) 506 + log.Printf("[%s] scheduled run panicked: %v", prof.displayName(), r) 504 507 } 505 508 }() 506 - runScheduled(idx, statusItem(idx), current.Profiles[idx], applyConfig) 509 + runScheduled(idx, statusItem(idx), prof, applyConfig) 507 510 }() 508 511 }) 509 512 if err != nil { ··· 835 838 } 836 839 return b.State.Raw == battery.Discharging 837 840 } 841 + 842 + func skipForBattery(prof Profile) bool { 843 + return !prof.Schedule.OnBattery && onBatteryPower() 844 + }
+44 -2
flake.nix
··· 10 10 self, 11 11 nixpkgs, 12 12 flake-utils, 13 - }: 14 - flake-utils.lib.eachDefaultSystem (system: let 13 + }: let 14 + # ./packaging/linux/restray.service 15 + nixosModules.default = { 16 + config, 17 + lib, 18 + pkgs, 19 + ... 20 + }: let 21 + cfg = config.services.restray; 22 + in { 23 + options.services.restray = { 24 + enable = lib.mkEnableOption "Restray backup scheduler daemon"; 25 + package = lib.mkPackageOption self.packages.${pkgs.system} "default" {}; 26 + }; 27 + 28 + config = lib.mkIf cfg.enable { 29 + systemd.services.restray = { 30 + description = "Restray backup scheduler daemon"; 31 + wantedBy = ["multi-user.target"]; 32 + serviceConfig = { 33 + Type = "simple"; 34 + ExecStart = "${lib.getExe cfg.package} daemon"; 35 + ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID"; 36 + Restart = "on-failure"; 37 + StateDirectory = "restray"; 38 + Environment = [ 39 + "RESTRAY_CONFIG=/etc/restray" 40 + "RESTRAY_STATE=/var/lib/restray" 41 + ]; 42 + }; 43 + }; 44 + }; 45 + }; 46 + in 47 + { 48 + inherit nixosModules; 49 + } 50 + // flake-utils.lib.eachDefaultSystem (system: let 15 51 pkgs = nixpkgs.legacyPackages.${system}; 16 52 isDarwin = pkgs.stdenv.hostPlatform.isDarwin; 17 53 in { ··· 47 83 install -Dm644 packaging/linux/restray.png $out/share/icons/hicolor/256x256/apps/restray.png 48 84 substituteInPlace $out/share/applications/restray.desktop \ 49 85 --replace-fail "Exec=restray" "Exec=$out/bin/restray" 86 + install -Dm644 packaging/linux/restray-user.service $out/lib/systemd/user/restray.service 87 + substituteInPlace $out/lib/systemd/user/restray.service \ 88 + --replace-fail "ExecStart=restray daemon" "ExecStart=$out/bin/restray daemon" 89 + install -Dm644 packaging/linux/restray.service $out/lib/systemd/system/restray.service 90 + substituteInPlace $out/lib/systemd/system/restray.service \ 91 + --replace-fail "ExecStart=restray daemon" "ExecStart=$out/bin/restray daemon" 50 92 ''; 51 93 52 94 meta = {
+11
packaging/linux/restray-user.service
··· 1 + [Unit] 2 + Description=Restray backup scheduler 3 + 4 + [Service] 5 + Type=simple 6 + ExecStart=restray daemon 7 + ExecReload=/bin/kill -HUP $MAINPID 8 + Restart=on-failure 9 + 10 + [Install] 11 + WantedBy=default.target
+14
packaging/linux/restray.service
··· 1 + [Unit] 2 + Description=Restray backup scheduler (system-wide) 3 + 4 + [Service] 5 + Type=simple 6 + Environment=RESTRAY_CONFIG=/etc/restray 7 + StateDirectory=restray 8 + Environment=RESTRAY_STATE=/var/lib/restray 9 + ExecStart=restray daemon 10 + ExecReload=/bin/kill -HUP $MAINPID 11 + Restart=on-failure 12 + 13 + [Install] 14 + WantedBy=multi-user.target