Files
syncwarden/internal/tray/tray.go
Axel Meyer 59a98843f7
Some checks failed
CI / lint (push) Failing after 27s
CI / test (push) Successful in 30s
Release / build (push) Failing after 2m33s
v0.3.0: fix HTTP client leak, add tests and CI pipeline
Reuse a single long-poll HTTP client instead of creating one per
Events() call (~every 30s). Make TLS skip-verify configurable via
syncthing_insecure_tls. Log previously swallowed config errors.
Add unit tests for all monitor trackers, config, and state logic.
Add CI workflow (vet, golangci-lint, govulncheck, go test -race).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:36:52 +01:00

268 lines
6.0 KiB
Go

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.3.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
if err := config.Save(a.cfg); err != nil {
log.Printf("config save error: %v", err)
}
log.Printf("auto-discovered Syncthing API key")
}
}
a.client = st.NewClient(a.cfg.BaseURL(), a.cfg.SyncthingAPIKey, a.cfg.SyncthingInsecureTLS)
// 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()
}
}
}