Full Syncthing tray wrapper with: - System tray with 5 icon states (idle/syncing/paused/error/disconnected) - Syncthing REST API client with auto-discovered API key - Long-polling event listener for real-time status - Transfer rate monitoring, folder tracking, recent files, conflict counting - Full context menu with folders, recent files, settings toggles - Embedded admin panel binary (webview, requires CGO) - OS notifications via beeep (per-event configurable) - Syncthing process management with auto-restart - Cross-platform installer with autostart - CI pipeline for Linux (.deb + .tar.gz) and Windows (.zip) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package tray
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
@@ -8,16 +9,34 @@ import (
|
||||
|
||||
"git.davoryn.de/calic/syncwarden/internal/config"
|
||||
"git.davoryn.de/calic/syncwarden/internal/icons"
|
||||
stClient "git.davoryn.de/calic/syncwarden/internal/syncthing"
|
||||
"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.1.0"
|
||||
|
||||
// App manages the tray icon and Syncthing monitoring.
|
||||
type App struct {
|
||||
mu sync.Mutex
|
||||
cfg config.Config
|
||||
client *stClient.Client
|
||||
state icons.State
|
||||
statusItem *systray.MenuItem
|
||||
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
|
||||
}
|
||||
|
||||
// Run starts the tray application (blocking).
|
||||
@@ -31,14 +50,14 @@ func (a *App) onReady() {
|
||||
|
||||
// Auto-discover API key if not configured
|
||||
if a.cfg.SyncthingAPIKey == "" {
|
||||
if key, err := stClient.DiscoverAPIKey(); err == nil && key != "" {
|
||||
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 = stClient.NewClient(a.cfg.BaseURL(), a.cfg.SyncthingAPIKey)
|
||||
a.client = st.NewClient(a.cfg.BaseURL(), a.cfg.SyncthingAPIKey)
|
||||
|
||||
// Set initial icon
|
||||
a.setState(icons.StateDisconnected)
|
||||
@@ -55,14 +74,29 @@ func (a *App) onReady() {
|
||||
a.openPanel()
|
||||
})
|
||||
|
||||
// Build menu
|
||||
// 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()
|
||||
|
||||
// Check connection
|
||||
go a.initialCheck()
|
||||
// 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")
|
||||
}
|
||||
|
||||
@@ -79,29 +113,141 @@ func (a *App) setState(s icons.State) {
|
||||
systray.SetIcon(iconData)
|
||||
}
|
||||
|
||||
func (a *App) initialCheck() {
|
||||
_, err := a.client.Health()
|
||||
if err != nil {
|
||||
log.Printf("Syncthing not reachable: %v", err)
|
||||
a.setState(icons.StateDisconnected)
|
||||
systray.SetTooltip("SyncWarden: Syncthing not reachable")
|
||||
a.mu.Lock()
|
||||
if a.statusItem != nil {
|
||||
a.statusItem.SetTitle("Status: Disconnected")
|
||||
}
|
||||
a.mu.Unlock()
|
||||
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
|
||||
}
|
||||
log.Println("Syncthing is reachable")
|
||||
a.setState(icons.StateIdle)
|
||||
systray.SetTooltip("SyncWarden: Idle")
|
||||
a.mu.Lock()
|
||||
if a.statusItem != nil {
|
||||
a.statusItem.SetTitle("Status: Idle")
|
||||
|
||||
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"])
|
||||
}
|
||||
}
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user