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.

fix: various fixes and logging improvements

also fix up services to work properly

intergrav (Jul 2, 2026, 12:19 AM EDT) ae99af1e 5de01cb6

+42 -17
+14 -13
cmd/restray/cli.go
··· 4 4 "context" 5 5 "errors" 6 6 "fmt" 7 + "log" 7 8 "os" 8 9 "os/exec" 9 10 "os/signal" ··· 182 183 } 183 184 184 185 if prof.PreHook != "" { 185 - fmt.Fprintln(os.Stderr, "Running pre-hook...") 186 + log.Printf("[%s] running pre-hook", prof.displayName()) 186 187 if err := cliRunHook(prof.PreHook, prof, hookEnv...); err != nil { 187 188 return cli.Exit("error: pre-hook failed", 1) 188 189 } ··· 191 192 failed := false 192 193 193 194 if !interrupted.Load() && prof.Schedule.BackupEnabled() { 194 - fmt.Fprintln(os.Stderr, "Running backup...") 195 + log.Printf("[%s] running backup", prof.displayName()) 195 196 if err := cliBackup(prof, true); err != nil { 196 197 failed = true 197 198 } 198 199 } 199 200 200 201 if !interrupted.Load() && prof.Schedule.Prune && len(prof.Prune.Args) > 0 { 201 - fmt.Fprintln(os.Stderr, "Running prune...") 202 + log.Printf("[%s] running prune", prof.displayName()) 202 203 if err := cliPrune(prof); err != nil { 203 204 failed = true 204 205 } 205 206 } 206 207 207 208 if !interrupted.Load() && prof.Schedule.Check { 208 - fmt.Fprintln(os.Stderr, "Running check...") 209 + log.Printf("[%s] running check", prof.displayName()) 209 210 if err := cliCheck(prof); err != nil { 210 211 failed = true 211 212 } ··· 213 214 214 215 if prof.PostHook != "" { 215 216 signal.Reset(os.Interrupt, syscall.SIGTERM) 216 - fmt.Fprintln(os.Stderr, "Running post-hook...") 217 + log.Printf("[%s] running post-hook", prof.displayName()) 217 218 postEnv := hookEnv 218 219 if failed || interrupted.Load() { 219 220 errMsg := "operation failed" ··· 244 245 current := loadConfig() 245 246 _, prof, err := resolveProfile(current, name) 246 247 if err != nil { 247 - fmt.Fprintf(os.Stderr, "[%s] skipping scheduled run: %v\n", name, err) 248 + log.Printf("[%s] skipping scheduled run: %v", name, err) 248 249 return 249 250 } 250 251 if skipForBattery(prof) { 251 - fmt.Fprintf(os.Stderr, "[%s] skipping scheduled run: on battery power\n", prof.displayName()) 252 + log.Printf("[%s] skipping scheduled run: on battery power", prof.displayName()) 252 253 return 253 254 } 254 255 wg.Add(1) ··· 256 257 defer wg.Done() 257 258 defer func() { 258 259 if r := recover(); r != nil { 259 - fmt.Fprintf(os.Stderr, "[%s] scheduled run panicked: %v\n", prof.displayName(), r) 260 + log.Printf("[%s] scheduled run panicked: %v", prof.displayName(), r) 260 261 } 261 262 }() 262 - fmt.Fprintf(os.Stderr, "[%s] running scheduled job\n", prof.displayName()) 263 + log.Printf("[%s] running scheduled job", prof.displayName()) 263 264 cliSchedule(prof) 264 265 }() 265 266 }) ··· 288 289 sigCh := make(chan os.Signal, 1) 289 290 signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) 290 291 sched.Start() 291 - fmt.Fprintln(os.Stderr, "Scheduler running, press Ctrl+C to stop") 292 + log.Print("Scheduler running, press Ctrl+C to stop") 292 293 293 294 for sig := range sigCh { 294 295 if sig != syscall.SIGHUP { ··· 296 297 } 297 298 next, err := buildDaemonSchedule(&wg) 298 299 if err != nil { 299 - fmt.Fprintln(os.Stderr, "error: reload failed, keeping previous schedule:", err) 300 + log.Printf("error: reload failed, keeping previous schedule: %v", err) 300 301 continue 301 302 } 302 303 <-sched.Stop().Done() 303 304 sched = next 304 305 sched.Start() 305 - fmt.Fprintln(os.Stderr, "Scheduler reloaded") 306 + log.Print("Scheduler reloaded") 306 307 } 307 308 308 309 <-sched.Stop().Done() 309 - fmt.Fprintln(os.Stderr, "Waiting for running jobs to finish...") 310 + log.Print("Waiting for running jobs to finish...") 310 311 wg.Wait() 311 312 return nil 312 313 }
+6
cmd/restray/operations.go
··· 99 99 log.Printf("[%s] %s killed by system, retrying once", prof.displayName(), args[0]) 100 100 msg, err = runResticOnce(idx, prof, mStatus, args...) 101 101 } 102 + if err != nil && takeCancelled(idx) && retryLockEnabled(prof) { 103 + retryUnlock(idx, prof, mStatus) 104 + } 102 105 return msg, err 103 106 } 104 107 ··· 190 193 mStatus.SetTitle("Backing up...") 191 194 setProfileFailed(idx, "") 192 195 ok, code = doBackupOnce(idx, mStatus, prof, args) 196 + } 197 + if !ok && takeCancelled(idx) && retryLockEnabled(prof) { 198 + retryUnlock(idx, prof, mStatus) 193 199 } 194 200 if ok { 195 201 setLastBackup(idx, time.Now())
+20 -4
cmd/restray/restic.go
··· 128 128 } 129 129 130 130 func repoStatus(prof Profile) repoResult { 131 - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 131 + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 132 132 defer cancel() 133 133 path, _ := findRestic() 134 - cmd := exec.CommandContext(ctx, path, "cat", "config", "--no-lock", "-q") 134 + args := []string{"cat", "config", "--no-lock", "-q"} 135 + log.Printf("[%s] run: restic %s", prof.displayName(), strings.Join(args, " ")) 136 + cmd := exec.CommandContext(ctx, path, args...) 135 137 cmd.Env = resticEnv(prof) 136 138 hideWindow(cmd) 137 139 out, err := cmd.CombinedOutput() 138 140 if err != nil { 141 + trimmed := strings.TrimSpace(string(out)) 139 142 if ctx.Err() != nil { 143 + log.Printf("[%s] repo status timed out after 30s; last output: %s", prof.displayName(), trimmed) 140 144 return repoResult{errMsg: "Repository unreachable"} 141 145 } 142 - lines := strings.Split(strings.TrimSpace(string(out)), "\n") 146 + log.Printf("[%s] repo status error: %s", prof.displayName(), trimmed) 147 + lines := strings.Split(trimmed, "\n") 143 148 msg, code := classifyError(err, lines[len(lines)-1]) 144 149 return repoResult{errMsg: msg, needsInit: code == 10} 145 150 } ··· 151 156 if err != nil { 152 157 return time.Time{} 153 158 } 154 - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 159 + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 155 160 defer cancel() 156 161 path, _ := findRestic() 157 162 cmd := exec.CommandContext(ctx, path, "snapshots", "--json", "--latest", "1", "--host", host) ··· 179 184 notifications string 180 185 failStatus map[int]string 181 186 lastBackup map[int]time.Time 187 + cancelled map[int]bool 182 188 } 183 189 184 190 var state = appState{ ··· 187 193 mountStopping: make(map[int]bool), 188 194 failStatus: make(map[int]string), 189 195 lastBackup: make(map[int]time.Time), 196 + cancelled: make(map[int]bool), 197 + } 198 + 199 + func takeCancelled(idx int) bool { 200 + state.mu.Lock() 201 + defer state.mu.Unlock() 202 + was := state.cancelled[idx] 203 + delete(state.cancelled, idx) 204 + return was 190 205 } 191 206 192 207 func setLastBackup(idx int, t time.Time) { ··· 257 272 state.mu.Lock() 258 273 defer state.mu.Unlock() 259 274 if cmd := state.busyProfiles[idx]; cmd != nil && cmd.Process != nil { 275 + state.cancelled[idx] = true 260 276 interruptProcess(cmd.Process) 261 277 } 262 278 }
+1
nix/nixos-module.nix
··· 38 38 Environment = [ 39 39 "RESTRAY_CONFIG=/etc/restray" 40 40 "RESTRAY_STATE=/var/lib/restray" 41 + "HOME=/var/lib/restray" 41 42 ]; 42 43 }; 43 44 };
+1
packaging/linux/restray.service
··· 6 6 Environment=RESTRAY_CONFIG=/etc/restray 7 7 StateDirectory=restray 8 8 Environment=RESTRAY_STATE=/var/lib/restray 9 + Environment=HOME=/var/lib/restray 9 10 ExecStart=restray daemon 10 11 ExecReload=/bin/kill -HUP $MAINPID 11 12 Restart=on-failure