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

20
internal/tray/icons.go Normal file
View File

@@ -0,0 +1,20 @@
package tray
import (
"log"
"github.com/energye/systray"
"git.davoryn.de/calic/syncwarden/internal/icons"
"git.davoryn.de/calic/syncwarden/internal/monitor"
)
// updateIcon renders and sets the tray icon based on status.
func updateIcon(status monitor.AggregateStatus) {
iconData, err := icons.Render(status.State)
if err != nil {
log.Printf("icon render error: %v", err)
return
}
systray.SetIcon(iconData)
}

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

23
internal/tray/open.go Normal file
View File

@@ -0,0 +1,23 @@
package tray
import (
"log"
"os/exec"
"runtime"
)
// openFileManager opens the given path in the OS file manager.
func openFileManager(path string) {
var cmd *exec.Cmd
switch runtime.GOOS {
case "windows":
cmd = exec.Command("explorer", path)
case "darwin":
cmd = exec.Command("open", path)
default:
cmd = exec.Command("xdg-open", path)
}
if err := cmd.Start(); err != nil {
log.Printf("failed to open file manager: %v", err)
}
}

87
internal/tray/tooltip.go Normal file
View File

@@ -0,0 +1,87 @@
package tray
import (
"fmt"
"time"
"git.davoryn.de/calic/syncwarden/internal/icons"
"git.davoryn.de/calic/syncwarden/internal/monitor"
)
// formatTooltip generates the tooltip text from aggregate status.
func formatTooltip(s monitor.AggregateStatus, showRate bool) string {
stateStr := stateLabel(s.State)
tip := fmt.Sprintf("SyncWarden: %s", stateStr)
// Devices
tip += fmt.Sprintf(" | %d/%d devices", s.DevicesOnline, s.DevicesTotal)
// Transfer rate
if showRate && (s.DownRate > 0 || s.UpRate > 0) {
tip += fmt.Sprintf(" | ↓%s ↑%s", formatBytes(s.DownRate), formatBytes(s.UpRate))
}
// Last sync
if !s.LastSync.IsZero() {
tip += fmt.Sprintf(" | Last sync: %s", formatTimeAgo(s.LastSync))
}
return tip
}
func stateLabel(s icons.State) string {
switch s {
case icons.StateIdle:
return "Idle"
case icons.StateSyncing:
return "Syncing"
case icons.StatePaused:
return "Paused"
case icons.StateError:
return "Error"
case icons.StateDisconnected:
return "Disconnected"
default:
return "Unknown"
}
}
func formatBytes(bps float64) string {
if bps < 1024 {
return fmt.Sprintf("%.0f B/s", bps)
}
if bps < 1024*1024 {
return fmt.Sprintf("%.1f KB/s", bps/1024)
}
if bps < 1024*1024*1024 {
return fmt.Sprintf("%.1f MB/s", bps/(1024*1024))
}
return fmt.Sprintf("%.1f GB/s", bps/(1024*1024*1024))
}
func formatTimeAgo(t time.Time) string {
d := time.Since(t)
if d < time.Minute {
return "just now"
}
if d < time.Hour {
m := int(d.Minutes())
if m == 1 {
return "1 min ago"
}
return fmt.Sprintf("%d min ago", m)
}
if d < 24*time.Hour {
h := int(d.Hours())
if h == 1 {
return "1 hour ago"
}
return fmt.Sprintf("%d hours ago", h)
}
days := int(d.Hours()) / 24
if days == 1 {
return "1 day ago"
}
return fmt.Sprintf("%d days ago", days)
}

View File

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