Files
syncwarden/internal/tray/tray.go
Axel Meyer 99eeffcbe4
Some checks failed
Release / build (push) Failing after 2m53s
Detect missing Syncthing, rewrite README with architecture diagram
Add Syncthing installation detection (PATH + config file check) to both
the tray app and setup installer. When missing, the tray shows an
"Install Syncthing..." menu item and the setup opens the download page.

Rewrite README with Mermaid topology graph, per-binary dependency tables,
project layout, API endpoint reference, and shields.io badges.

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

266 lines
5.9 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.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()
}
}
}