Some checks failed
Release / build (push) Failing after 2m53s
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>
266 lines
5.9 KiB
Go
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()
|
|
}
|
|
}
|
|
}
|