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: per-profile conncet/load state

among other state fixes

intergrav (Jul 2, 2026, 1:26 AM EDT) 6129eaac a7378db6

+96 -68
+12 -1
cmd/restray/restic.go
··· 133 133 path, _ := findRestic() 134 134 args := []string{"cat", "config", "--no-lock", "-q"} 135 135 log.Printf("[%s] run: restic %s", prof.displayName(), strings.Join(args, " ")) 136 + start := time.Now() 136 137 cmd := exec.CommandContext(ctx, path, args...) 137 138 cmd.Env = resticEnv(prof) 138 139 hideWindow(cmd) ··· 148 149 msg, code := classifyError(err, lines[len(lines)-1]) 149 150 return repoResult{errMsg: msg, needsInit: code == 10} 150 151 } 152 + log.Printf("[%s] repo status ok (%s)", prof.displayName(), time.Since(start).Round(time.Millisecond)) 151 153 return repoResult{} 152 154 } 153 155 ··· 159 161 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 160 162 defer cancel() 161 163 path, _ := findRestic() 162 - cmd := exec.CommandContext(ctx, path, "snapshots", "--json", "--latest", "1", "--host", host) 164 + args := []string{"snapshots", "--json", "--latest", "1", "--host", host} 165 + log.Printf("[%s] run: restic %s", prof.displayName(), strings.Join(args, " ")) 166 + start := time.Now() 167 + cmd := exec.CommandContext(ctx, path, args...) 163 168 cmd.Env = resticEnv(prof) 164 169 hideWindow(cmd) 165 170 out, err := cmd.Output() 166 171 if err != nil { 172 + if ctx.Err() != nil { 173 + log.Printf("[%s] last backup query timed out after 30s", prof.displayName()) 174 + } else { 175 + log.Printf("[%s] last backup query error: %v", prof.displayName(), err) 176 + } 167 177 return time.Time{} 168 178 } 179 + log.Printf("[%s] last backup query ok (%s)", prof.displayName(), time.Since(start).Round(time.Millisecond)) 169 180 var snaps []struct { 170 181 Time time.Time `json:"time"` 171 182 }
+84 -67
cmd/restray/tray.go
··· 123 123 setIconAnimated("idle") 124 124 systray.SetTooltip("Restray") 125 125 126 - mGlobalStatus := prefixedMenuItem{systray.AddMenuItem("Loading...", ""), ""} 126 + mGlobalStatus := prefixedMenuItem{systray.AddMenuItem("", ""), ""} 127 127 mGlobalStatus.Disable() 128 + mGlobalStatus.Hide() 128 129 var mStatusItems [maxProfiles]*systray.MenuItem 129 130 for i := range mStatusItems { 130 131 mStatusItems[i] = systray.AddMenuItem("", "") ··· 175 176 mInit := systray.AddMenuItem("Initialize Repository", "Initialize a new restic repository") 176 177 mInit.Hide() 177 178 mRepo := systray.AddMenuItem("Operations", "Repository operations") 179 + mRepo.Disable() 178 180 mBackup := mRepo.AddSubMenuItem("Backup", "Run a backup") 181 + mBackup.Disable() 179 182 mPrune := mRepo.AddSubMenuItem("Prune", "Remove old snapshots and free space") 183 + mPrune.Disable() 180 184 mCheck := mRepo.AddSubMenuItem("Check", "Verify repository integrity") 185 + mCheck.Disable() 181 186 mUnlock := mRepo.AddSubMenuItem("Unlock", "Remove stale repository locks") 187 + mUnlock.Disable() 182 188 mMount := mRepo.AddSubMenuItem("Mount", "Mount repository and browse snapshots") 183 189 mConsole := mRepo.AddSubMenuItem("Shell", "Open terminal with repository environment") 184 190 mPreHook := mRepo.AddSubMenuItem("Pre-Hook", "Run the pre-hook command") ··· 313 319 if next != "" { 314 320 return prefix + next + ", last " + last 315 321 } 316 - return prefix + "Last backup " + last 322 + return prefix + "last " + last 317 323 default: 318 324 schedule := describeCron(prof.Schedule.Cron) 319 325 if next != "" { ··· 411 417 } 412 418 } 413 419 } 414 - 415 - updateAllStatusItems(cfg) 416 420 } 417 421 418 422 applyConfig = func() { ··· 475 479 profileMu.Lock() 476 480 profileStates = make([]profileState, len(cfg.Profiles)) 477 481 profileEntryIDs = make(map[int][]cron.EntryID) 482 + if activeProfile >= len(cfg.Profiles) { 483 + activeProfile = 0 484 + } 485 + activeIdx := activeProfile 478 486 profileMu.Unlock() 479 487 480 - for i, prof := range cfg.Profiles { 481 - var ps profileState 482 - ps.errMsg = prof.configError() 483 - if ps.errMsg == "" { 484 - rs := repoStatus(prof) 485 - ps.repoErr = rs.errMsg 486 - ps.needsInit = rs.needsInit 487 - if rs.errMsg == "" && !rs.needsInit && cfg.GUI.ScheduleDisplay == "last" && getLastBackup(i).IsZero() { 488 - if last := queryLastBackup(prof); !last.IsZero() { 489 - setLastBackup(i, last) 490 - } 491 - } 492 - } 488 + markActiveProfile(activeIdx, len(cfg.Profiles)) 489 + setProfileTitle(cfg, activeIdx) 493 490 494 - label := prof.displayName() 491 + sched.Start() 495 492 496 - profileMu.Lock() 497 - profileStates[i] = ps 498 - profileMu.Unlock() 499 - 493 + var wg sync.WaitGroup 494 + for i, prof := range cfg.Profiles { 495 + label := prof.displayName() 500 496 if i < maxProfiles { 497 + connecting := "Connecting..." 498 + if len(cfg.Profiles) > 1 { 499 + connecting = label + " - " + connecting 500 + } 501 + mStatusItems[i].SetTitle(connecting) 502 + mStatusItems[i].Show() 501 503 profileItems[i].SetTitle(label) 502 504 profileItems[i].Show() 503 505 } 506 + wg.Add(1) 507 + go func(i int, prof Profile) { 508 + defer wg.Done() 509 + var ps profileState 510 + ps.errMsg = prof.configError() 511 + if ps.errMsg == "" { 512 + rs := repoStatus(prof) 513 + ps.repoErr = rs.errMsg 514 + ps.needsInit = rs.needsInit 515 + if rs.errMsg == "" && !rs.needsInit && cfg.GUI.ScheduleDisplay == "last" && getLastBackup(i).IsZero() { 516 + if last := queryLastBackup(prof); !last.IsZero() { 517 + setLastBackup(i, last) 518 + } 519 + } 520 + } 504 521 505 - if ps.errMsg == "" && prof.Schedule.Cron != "" { 506 - idx := i 507 - name := prof.displayName() 508 - eid, err := sched.AddFunc(prof.Schedule.Cron, func() { 509 - current := loadConfig() 510 - _, prof, err := resolveProfile(current, name) 522 + if ps.errMsg == "" && prof.Schedule.Cron != "" { 523 + name := prof.displayName() 524 + eid, err := sched.AddFunc(prof.Schedule.Cron, func() { 525 + current := loadConfig() 526 + _, prof, err := resolveProfile(current, name) 527 + if err != nil { 528 + log.Printf("[%s] skipping scheduled backup: %v", name, err) 529 + return 530 + } 531 + if skipForBattery(prof) { 532 + log.Printf("[%s] skipping scheduled backup: on battery power", prof.displayName()) 533 + return 534 + } 535 + go func() { 536 + defer func() { 537 + if r := recover(); r != nil { 538 + log.Printf("[%s] scheduled run panicked: %v", prof.displayName(), r) 539 + } 540 + }() 541 + runScheduled(i, statusItem(i), prof, applyConfig) 542 + }() 543 + }) 511 544 if err != nil { 512 - log.Printf("[%s] skipping scheduled backup: %v", name, err) 513 - return 514 - } 515 - if skipForBattery(prof) { 516 - log.Printf("[%s] skipping scheduled backup: on battery power", prof.displayName()) 517 - return 545 + log.Printf("[%s] invalid cron expression %q: %v", prof.displayName(), prof.Schedule.Cron, err) 546 + ps.errMsg = "Invalid cron: " + prof.Schedule.Cron 547 + } else { 548 + profileMu.Lock() 549 + profileEntryIDs[i] = append(profileEntryIDs[i], eid) 550 + profileMu.Unlock() 518 551 } 519 - go func() { 520 - defer func() { 521 - if r := recover(); r != nil { 522 - log.Printf("[%s] scheduled run panicked: %v", prof.displayName(), r) 523 - } 524 - }() 525 - runScheduled(idx, statusItem(idx), prof, applyConfig) 526 - }() 527 - }) 528 - if err != nil { 529 - log.Printf("[%s] invalid cron expression %q: %v", prof.displayName(), prof.Schedule.Cron, err) 530 - ps.errMsg = "Invalid cron: " + prof.Schedule.Cron 531 - profileMu.Lock() 532 - profileStates[i] = ps 533 - profileMu.Unlock() 534 - } else { 535 - profileMu.Lock() 536 - profileEntryIDs[idx] = append(profileEntryIDs[idx], eid) 537 - profileMu.Unlock() 552 + } 553 + 554 + profileMu.Lock() 555 + profileStates[i] = ps 556 + profileMu.Unlock() 557 + if i < maxProfiles { 558 + mStatusItems[i].SetTitle(profileStatusText(cfg, i)) 559 + } 560 + if i == activeIdx { 561 + applyProfileUI(cfg) 538 562 } 539 - } 563 + }(i, prof) 540 564 } 565 + wg.Wait() 541 566 542 567 for i := len(cfg.Profiles); i < maxProfiles; i++ { 543 568 profileItems[i].Hide() 544 569 profileItems[i].Uncheck() 545 570 } 546 571 547 - profileMu.Lock() 548 - if activeProfile >= len(cfg.Profiles) { 549 - activeProfile = 0 550 - } 551 - activeIdx := activeProfile 552 - profileMu.Unlock() 553 - 554 - markActiveProfile(activeIdx, len(cfg.Profiles)) 555 - setProfileTitle(cfg, activeIdx) 556 - 557 572 if anyError() { 558 573 setIconAnimated("fail") 559 574 } else { 560 575 setIconAnimated("idle") 561 576 } 562 577 563 - sched.Start() 578 + updateAllStatusItems(cfg) 564 579 applyProfileUI(cfg) 565 580 566 581 if managed { ··· 568 583 } 569 584 } 570 585 571 - applyConfig() 586 + go applyConfig() 572 587 if firstLaunch && needsFullDiskAccess() { 573 588 go promptFullDiskAccess() 574 589 } ··· 612 627 if cur != prev { 613 628 prev = cur 614 629 if !isAnyBusy() { 615 - applyProfileUI(loadConfig()) 630 + cfg := loadConfig() 631 + updateAllStatusItems(cfg) 632 + applyProfileUI(cfg) 616 633 continue 617 634 } 618 635 }