Implement SyncWarden v0.1.0
Some checks failed
Release / build (push) Failing after 19s

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:
Axel Meyer
2026-03-03 21:16:28 +01:00
parent 2256df9dd7
commit 34a1a94502
30 changed files with 2156 additions and 38 deletions

View File

@@ -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")
}