package tray import ( "fmt" "log" "sync" "github.com/energye/systray" "git.davoryn.de/calic/syncwarden/internal/config" "git.davoryn.de/calic/syncwarden/internal/icons" "git.davoryn.de/calic/syncwarden/internal/monitor" "git.davoryn.de/calic/syncwarden/internal/notify" st "git.davoryn.de/calic/syncwarden/internal/syncthing" ) const version = "0.2.0" // App manages the tray icon and Syncthing monitoring. type App struct { mu sync.Mutex cfg config.Config client *st.Client monitor *monitor.Monitor process *st.Process state icons.State lastStatus monitor.AggregateStatus // Menu items that need dynamic updates statusItem *systray.MenuItem rateItem *systray.MenuItem devicesItem *systray.MenuItem lastSyncItem *systray.MenuItem pauseItem *systray.MenuItem foldersMenu *systray.MenuItem recentMenu *systray.MenuItem conflictItem *systray.MenuItem folderItems []*systray.MenuItem recentItems []*systray.MenuItem syncthingMissing bool } // Run starts the tray application (blocking). func Run() { app := &App{} systray.Run(app.onReady, app.onExit) } func (a *App) onReady() { a.cfg = config.Load() // Auto-discover API key if not configured if a.cfg.SyncthingAPIKey == "" { if key, err := st.DiscoverAPIKey(); err == nil && key != "" { a.cfg.SyncthingAPIKey = key _ = config.Save(a.cfg) log.Printf("auto-discovered Syncthing API key") } } a.client = st.NewClient(a.cfg.BaseURL(), a.cfg.SyncthingAPIKey) // Check if Syncthing is installed if !st.IsInstalled() { a.syncthingMissing = true log.Println("Syncthing not found on this system") } // Set initial icon a.setState(icons.StateDisconnected) systray.SetTitle("SyncWarden") if a.syncthingMissing { systray.SetTooltip("SyncWarden: Syncthing not found") } else { systray.SetTooltip("SyncWarden: connecting...") } // Right-click shows menu systray.SetOnRClick(func(menu systray.IMenu) { menu.ShowMenu() }) // Double-click opens admin panel systray.SetOnDClick(func(menu systray.IMenu) { a.openPanel() }) // Auto-start Syncthing if configured if a.cfg.AutoStartSyncthing { a.process = st.NewProcess() if err := a.process.Start(); err != nil { log.Printf("failed to auto-start syncthing: %v", err) } } // Build full menu a.buildMenu() // Start monitor a.monitor = monitor.New(a.client, a.cfg, a.onStatusUpdate, a.onEvent) a.monitor.Start() } func (a *App) onExit() { if a.monitor != nil { a.monitor.Stop() } if a.process != nil { a.process.Stop() } log.Println("SyncWarden exiting") } func (a *App) setState(s icons.State) { a.mu.Lock() a.state = s a.mu.Unlock() iconData, err := icons.Render(s) if err != nil { log.Printf("icon render error: %v", err) return } systray.SetIcon(iconData) } func (a *App) onStatusUpdate(status monitor.AggregateStatus) { a.mu.Lock() a.lastStatus = status a.mu.Unlock() // Update icon updateIcon(status) // Update tooltip systray.SetTooltip(formatTooltip(status, a.cfg.EnableTransferRate)) // Update menu items a.updateMenuItems(status) } func (a *App) onEvent(eventType string, data map[string]string) { a.mu.Lock() cfg := a.cfg a.mu.Unlock() if !cfg.EnableNotifications { return } switch eventType { case "SyncComplete": if cfg.NotifySyncComplete { notify.SyncComplete(data["folder"]) } case "DeviceConnected": if cfg.NotifyDeviceConnect { notify.DeviceConnected(data["name"]) } case "DeviceDisconnected": if cfg.NotifyDeviceDisconnect { notify.DeviceDisconnected(data["name"]) } case "NewDevice": if cfg.NotifyNewDevice { notify.NewDevice(data["name"]) } case "Conflict": if cfg.NotifyConflict && cfg.EnableConflictAlerts { notify.Conflict(data["file"], data["folder"]) } } } func (a *App) openPanel() { launchPanel(a.cfg.BaseURL()) } func (a *App) updateMenuItems(s monitor.AggregateStatus) { a.mu.Lock() defer a.mu.Unlock() if a.statusItem != nil { a.statusItem.SetTitle(fmt.Sprintf("Status: %s", stateLabel(s.State))) } if a.rateItem != nil { if s.DownRate > 0 || s.UpRate > 0 { a.rateItem.SetTitle(fmt.Sprintf("↓ %s ↑ %s", formatBytes(s.DownRate), formatBytes(s.UpRate))) a.rateItem.Show() } else { a.rateItem.SetTitle("↓ 0 B/s ↑ 0 B/s") } } if a.devicesItem != nil { a.devicesItem.SetTitle(fmt.Sprintf("Devices: %d/%d connected", s.DevicesOnline, s.DevicesTotal)) } if a.lastSyncItem != nil { if s.LastSync.IsZero() { a.lastSyncItem.SetTitle("Last sync: —") } else { a.lastSyncItem.SetTitle(fmt.Sprintf("Last sync: %s", formatTimeAgo(s.LastSync))) } } if a.pauseItem != nil { if s.Paused { a.pauseItem.SetTitle("Resume All") } else { a.pauseItem.SetTitle("Pause All") } } // Update folders submenu if a.foldersMenu != nil { // Hide old items for _, item := range a.folderItems { item.Hide() } a.folderItems = a.folderItems[:0] for _, f := range s.Folders { label := f.Label if f.State != "" && f.State != "idle" { label = fmt.Sprintf("%s (%s)", f.Label, f.State) } item := a.foldersMenu.AddSubMenuItem(label, f.Path) path := f.Path item.Click(func() { openFileManager(path) }) a.folderItems = append(a.folderItems, item) } } // Update recent files submenu if a.recentMenu != nil && a.cfg.EnableRecentFiles { for _, item := range a.recentItems { item.Hide() } a.recentItems = a.recentItems[:0] if len(s.RecentFiles) == 0 { item := a.recentMenu.AddSubMenuItem("(none)", "") item.Disable() a.recentItems = append(a.recentItems, item) } else { for _, rf := range s.RecentFiles { label := fmt.Sprintf("%s (%s)", rf.Name, rf.Folder) item := a.recentMenu.AddSubMenuItem(label, "") item.Disable() a.recentItems = append(a.recentItems, item) } } } // Update conflicts if a.conflictItem != nil { if s.ConflictCount > 0 { a.conflictItem.SetTitle(fmt.Sprintf("Conflicts (%d)", s.ConflictCount)) a.conflictItem.Show() } else { a.conflictItem.Hide() } } }