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,33 +1,225 @@
|
||||
package tray
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/energye/systray"
|
||||
|
||||
"git.davoryn.de/calic/syncwarden/internal/config"
|
||||
st "git.davoryn.de/calic/syncwarden/internal/syncthing"
|
||||
)
|
||||
|
||||
// buildMenu creates the initial context menu (Phase 1: minimal).
|
||||
// buildMenu creates the full context menu.
|
||||
func (a *App) buildMenu() {
|
||||
mStatus := systray.AddMenuItem("Status: Connecting...", "")
|
||||
mStatus.Disable()
|
||||
// Status info section
|
||||
a.statusItem = systray.AddMenuItem("Status: Connecting...", "")
|
||||
a.statusItem.Disable()
|
||||
|
||||
a.rateItem = systray.AddMenuItem("↓ 0 B/s ↑ 0 B/s", "")
|
||||
a.rateItem.Disable()
|
||||
|
||||
a.devicesItem = systray.AddMenuItem("Devices: —", "")
|
||||
a.devicesItem.Disable()
|
||||
|
||||
a.lastSyncItem = systray.AddMenuItem("Last sync: —", "")
|
||||
a.lastSyncItem.Disable()
|
||||
|
||||
systray.AddSeparator()
|
||||
|
||||
// Open Admin Panel
|
||||
mOpenPanel := systray.AddMenuItem("Open Admin Panel", "Open Syncthing admin panel")
|
||||
mOpenPanel.Click(func() {
|
||||
a.openPanel()
|
||||
})
|
||||
|
||||
// Pause/Resume toggle
|
||||
a.pauseItem = systray.AddMenuItem("Pause All", "Pause/Resume all syncing")
|
||||
a.pauseItem.Click(func() {
|
||||
a.togglePause()
|
||||
})
|
||||
|
||||
systray.AddSeparator()
|
||||
|
||||
// Folders submenu
|
||||
a.foldersMenu = systray.AddMenuItem("Folders", "")
|
||||
emptyFolder := a.foldersMenu.AddSubMenuItem("(loading...)", "")
|
||||
emptyFolder.Disable()
|
||||
a.folderItems = []*systray.MenuItem{emptyFolder}
|
||||
|
||||
// Recent Files submenu
|
||||
a.recentMenu = systray.AddMenuItem("Recent Files", "")
|
||||
emptyRecent := a.recentMenu.AddSubMenuItem("(none)", "")
|
||||
emptyRecent.Disable()
|
||||
a.recentItems = []*systray.MenuItem{emptyRecent}
|
||||
|
||||
// Conflicts
|
||||
a.conflictItem = systray.AddMenuItem("Conflicts (0)", "Open conflicts page")
|
||||
a.conflictItem.Hide()
|
||||
a.conflictItem.Click(func() {
|
||||
a.openConflicts()
|
||||
})
|
||||
|
||||
systray.AddSeparator()
|
||||
|
||||
// Actions
|
||||
mRescan := systray.AddMenuItem("Rescan All", "Trigger rescan of all folders")
|
||||
mRescan.Click(func() {
|
||||
go func() {
|
||||
if err := a.client.RescanAll(); err != nil {
|
||||
log.Printf("rescan error: %v", err)
|
||||
}
|
||||
}()
|
||||
})
|
||||
|
||||
mRestart := systray.AddMenuItem("Restart Syncthing", "Restart the Syncthing process")
|
||||
mRestart.Click(func() {
|
||||
go func() {
|
||||
if err := a.client.Restart(); err != nil {
|
||||
log.Printf("restart error: %v", err)
|
||||
}
|
||||
}()
|
||||
})
|
||||
|
||||
systray.AddSeparator()
|
||||
|
||||
// Settings submenu
|
||||
mSettings := systray.AddMenuItem("Settings", "")
|
||||
|
||||
chkNotify := mSettings.AddSubMenuItem("Notifications", "Enable/disable notifications")
|
||||
if a.cfg.EnableNotifications {
|
||||
chkNotify.Check()
|
||||
}
|
||||
chkNotify.Click(func() {
|
||||
a.toggleSetting(&a.cfg.EnableNotifications, chkNotify)
|
||||
})
|
||||
|
||||
chkRecent := mSettings.AddSubMenuItem("Show Recent Files", "Show recently synced files")
|
||||
if a.cfg.EnableRecentFiles {
|
||||
chkRecent.Check()
|
||||
}
|
||||
chkRecent.Click(func() {
|
||||
a.toggleSetting(&a.cfg.EnableRecentFiles, chkRecent)
|
||||
})
|
||||
|
||||
chkConflict := mSettings.AddSubMenuItem("Conflict Alerts", "Alert on sync conflicts")
|
||||
if a.cfg.EnableConflictAlerts {
|
||||
chkConflict.Check()
|
||||
}
|
||||
chkConflict.Click(func() {
|
||||
a.toggleSetting(&a.cfg.EnableConflictAlerts, chkConflict)
|
||||
})
|
||||
|
||||
chkRate := mSettings.AddSubMenuItem("Transfer Rate in Tooltip", "Show transfer rate in tooltip")
|
||||
if a.cfg.EnableTransferRate {
|
||||
chkRate.Check()
|
||||
}
|
||||
chkRate.Click(func() {
|
||||
a.toggleSetting(&a.cfg.EnableTransferRate, chkRate)
|
||||
})
|
||||
|
||||
chkAutoStart := mSettings.AddSubMenuItem("Auto-start Syncthing", "Start Syncthing when SyncWarden starts")
|
||||
if a.cfg.AutoStartSyncthing {
|
||||
chkAutoStart.Check()
|
||||
}
|
||||
chkAutoStart.Click(func() {
|
||||
a.toggleSetting(&a.cfg.AutoStartSyncthing, chkAutoStart)
|
||||
})
|
||||
|
||||
chkLogin := mSettings.AddSubMenuItem("Start on Login", "Start SyncWarden at system login")
|
||||
if a.cfg.StartOnLogin {
|
||||
chkLogin.Check()
|
||||
}
|
||||
chkLogin.Click(func() {
|
||||
a.toggleSetting(&a.cfg.StartOnLogin, chkLogin)
|
||||
})
|
||||
|
||||
// API key info
|
||||
apiKeyDisplay := "API Key: (none)"
|
||||
if len(a.cfg.SyncthingAPIKey) > 8 {
|
||||
apiKeyDisplay = fmt.Sprintf("API Key: %s...%s", a.cfg.SyncthingAPIKey[:4], a.cfg.SyncthingAPIKey[len(a.cfg.SyncthingAPIKey)-4:])
|
||||
} else if a.cfg.SyncthingAPIKey != "" {
|
||||
apiKeyDisplay = fmt.Sprintf("API Key: %s", a.cfg.SyncthingAPIKey)
|
||||
}
|
||||
mAPIKey := mSettings.AddSubMenuItem(apiKeyDisplay, "")
|
||||
mAPIKey.Disable()
|
||||
|
||||
mRediscover := mSettings.AddSubMenuItem("Re-discover API Key", "Re-read API key from Syncthing config")
|
||||
mRediscover.Click(func() {
|
||||
go a.rediscoverAPIKey()
|
||||
})
|
||||
|
||||
mAddr := mSettings.AddSubMenuItem(fmt.Sprintf("Address: %s", a.cfg.SyncthingAddress), "")
|
||||
mAddr.Disable()
|
||||
|
||||
// About
|
||||
mAbout := systray.AddMenuItem(fmt.Sprintf("About (v%s)", version), "")
|
||||
mAbout.Disable()
|
||||
|
||||
systray.AddSeparator()
|
||||
|
||||
// Quit
|
||||
mQuit := systray.AddMenuItem("Quit", "Exit SyncWarden")
|
||||
mQuit.Click(func() {
|
||||
log.Println("Quit clicked")
|
||||
systray.Quit()
|
||||
})
|
||||
|
||||
// Store reference for updates
|
||||
a.mu.Lock()
|
||||
a.statusItem = mStatus
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
func (a *App) togglePause() {
|
||||
a.mu.Lock()
|
||||
paused := a.lastStatus.Paused
|
||||
a.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
var err error
|
||||
if paused {
|
||||
err = a.client.ResumeAll()
|
||||
} else {
|
||||
err = a.client.PauseAll()
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("pause/resume error: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (a *App) openConflicts() {
|
||||
// Open the Syncthing conflicts page in the panel
|
||||
launchPanel(a.cfg.BaseURL())
|
||||
}
|
||||
|
||||
func (a *App) toggleSetting(field *bool, item *systray.MenuItem) {
|
||||
a.mu.Lock()
|
||||
*field = !*field
|
||||
val := *field
|
||||
cfg := a.cfg
|
||||
a.mu.Unlock()
|
||||
|
||||
if val {
|
||||
item.Check()
|
||||
} else {
|
||||
item.Uncheck()
|
||||
}
|
||||
_ = config.Save(cfg)
|
||||
}
|
||||
|
||||
func (a *App) rediscoverAPIKey() {
|
||||
key, err := st.DiscoverAPIKey()
|
||||
if err != nil {
|
||||
log.Printf("API key discovery failed: %v", err)
|
||||
return
|
||||
}
|
||||
if key == "" {
|
||||
log.Println("no API key found in Syncthing config")
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.cfg.SyncthingAPIKey = key
|
||||
a.mu.Unlock()
|
||||
|
||||
a.client.SetAPIKey(key)
|
||||
_ = config.Save(a.cfg)
|
||||
log.Printf("re-discovered API key")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user