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

@@ -0,0 +1,35 @@
package monitor
import "sync"
// ConflictTracker counts sync conflicts across all folders.
type ConflictTracker struct {
mu sync.Mutex
count int
}
// NewConflictTracker creates a new conflict tracker.
func NewConflictTracker() *ConflictTracker {
return &ConflictTracker{}
}
// SetCount updates the total conflict count.
func (ct *ConflictTracker) SetCount(n int) {
ct.mu.Lock()
defer ct.mu.Unlock()
ct.count = n
}
// Increment adds to the conflict count.
func (ct *ConflictTracker) Increment() {
ct.mu.Lock()
defer ct.mu.Unlock()
ct.count++
}
// Count returns the current conflict count.
func (ct *ConflictTracker) Count() int {
ct.mu.Lock()
defer ct.mu.Unlock()
return ct.count
}

View File

@@ -0,0 +1,60 @@
package monitor
import (
"sync"
st "git.davoryn.de/calic/syncwarden/internal/syncthing"
)
// FolderTracker tracks per-folder status.
type FolderTracker struct {
mu sync.Mutex
folders []FolderInfo
}
// NewFolderTracker creates a new folder tracker.
func NewFolderTracker() *FolderTracker {
return &FolderTracker{}
}
// UpdateFromConfig sets the folder list from Syncthing config.
func (ft *FolderTracker) UpdateFromConfig(folders []st.FolderConfig) {
ft.mu.Lock()
defer ft.mu.Unlock()
ft.folders = make([]FolderInfo, len(folders))
for i, f := range folders {
ft.folders[i] = FolderInfo{
ID: f.ID,
Label: f.Label,
Path: f.Path,
State: "unknown",
}
if f.Label == "" {
ft.folders[i].Label = f.ID
}
}
}
// UpdateStatus updates the state for a specific folder.
func (ft *FolderTracker) UpdateStatus(folderID, state string) {
ft.mu.Lock()
defer ft.mu.Unlock()
for i := range ft.folders {
if ft.folders[i].ID == folderID {
ft.folders[i].State = state
return
}
}
}
// Folders returns a snapshot of all folder info.
func (ft *FolderTracker) Folders() []FolderInfo {
ft.mu.Lock()
defer ft.mu.Unlock()
out := make([]FolderInfo, len(ft.folders))
copy(out, ft.folders)
return out
}

384
internal/monitor/monitor.go Normal file
View File

@@ -0,0 +1,384 @@
package monitor
import (
"encoding/json"
"log"
"path/filepath"
"sync"
"time"
"git.davoryn.de/calic/syncwarden/internal/config"
"git.davoryn.de/calic/syncwarden/internal/icons"
st "git.davoryn.de/calic/syncwarden/internal/syncthing"
)
// StatusCallback is called whenever the aggregate status changes.
type StatusCallback func(AggregateStatus)
// EventCallback is called for notable events (for notifications).
type EventCallback func(eventType string, data map[string]string)
// Monitor coordinates all tracking and polling.
type Monitor struct {
mu sync.Mutex
client *st.Client
cfg config.Config
callback StatusCallback
eventCb EventCallback
speed *SpeedTracker
folders *FolderTracker
recent *RecentTracker
conflicts *ConflictTracker
events *st.EventListener
stopCh chan struct{}
wg sync.WaitGroup
connected bool
paused bool
lastSync time.Time
devicesTotal int
devicesOnline int
pendingDevs int
}
// New creates a new Monitor.
func New(client *st.Client, cfg config.Config, callback StatusCallback, eventCb EventCallback) *Monitor {
return &Monitor{
client: client,
cfg: cfg,
callback: callback,
eventCb: eventCb,
speed: NewSpeedTracker(),
folders: NewFolderTracker(),
recent: NewRecentTracker(),
conflicts: NewConflictTracker(),
stopCh: make(chan struct{}),
}
}
// Start begins all monitoring goroutines.
func (m *Monitor) Start() {
// Start event listener
m.events = st.NewEventListener(m.client, m.cfg.LastEventID, m.onEvents)
m.events.Start()
// Start periodic poller
m.wg.Add(1)
go m.pollLoop()
// Initial full refresh
go m.fullRefresh()
}
// Stop halts all monitoring.
func (m *Monitor) Stop() {
close(m.stopCh)
if m.events != nil {
m.events.Stop()
}
m.wg.Wait()
// Persist last event ID
m.mu.Lock()
m.cfg.LastEventID = m.events.LastEventID()
m.mu.Unlock()
_ = config.Save(m.cfg)
}
func (m *Monitor) pollLoop() {
defer m.wg.Done()
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for {
select {
case <-m.stopCh:
return
case <-ticker.C:
m.pollConnections()
m.pollHealth()
}
}
}
func (m *Monitor) pollHealth() {
_, err := m.client.Health()
m.mu.Lock()
wasConnected := m.connected
m.connected = err == nil
m.mu.Unlock()
if !wasConnected && err == nil {
// Reconnected — do a full refresh
go m.fullRefresh()
}
if wasConnected && err != nil {
m.emitStatus()
}
}
func (m *Monitor) pollConnections() {
conns, err := m.client.SystemConnections()
if err != nil {
return
}
m.speed.Update(conns.Total.InBytesTotal, conns.Total.OutBytesTotal)
online := 0
for _, c := range conns.Connections {
if c.Connected {
online++
}
}
m.mu.Lock()
m.devicesOnline = online
m.mu.Unlock()
m.emitStatus()
}
func (m *Monitor) fullRefresh() {
// Get config (folders + devices)
cfg, err := m.client.Config()
if err != nil {
log.Printf("config fetch error: %v", err)
return
}
m.folders.UpdateFromConfig(cfg.Folders)
m.mu.Lock()
m.devicesTotal = len(cfg.Devices)
m.connected = true
m.mu.Unlock()
// Query each folder's status
allPaused := true
for _, f := range cfg.Folders {
if !f.Paused {
allPaused = false
}
status, err := m.client.FolderStatus(f.ID)
if err != nil {
continue
}
m.folders.UpdateStatus(f.ID, status.State)
}
m.mu.Lock()
m.paused = allPaused && len(cfg.Folders) > 0
m.mu.Unlock()
// Check pending devices
pending, err := m.client.PendingDevices()
if err == nil {
m.mu.Lock()
m.pendingDevs = len(pending)
m.mu.Unlock()
}
m.emitStatus()
}
func (m *Monitor) onEvents(events []st.Event) {
for _, ev := range events {
switch ev.Type {
case "StateChanged":
m.handleStateChanged(ev)
case "ItemFinished":
m.handleItemFinished(ev)
case "DeviceConnected":
m.handleDeviceEvent(ev, true)
case "DeviceDisconnected":
m.handleDeviceEvent(ev, false)
case "PendingDevicesChanged":
go m.refreshPendingDevices()
case "FolderCompletion", "FolderSummary":
// Trigger a folder status refresh
go m.refreshFolderStatuses()
}
}
m.emitStatus()
}
func (m *Monitor) handleStateChanged(ev st.Event) {
data, ok := ev.Data.(map[string]any)
if !ok {
return
}
folder, _ := data["folder"].(string)
from, _ := data["from"].(string)
to, _ := data["to"].(string)
if folder != "" && to != "" {
m.folders.UpdateStatus(folder, to)
// Notify when folder finishes syncing
if from == "syncing" && to == "idle" {
m.emitEvent("SyncComplete", map[string]string{
"folder": folderLabel(m.folders.Folders(), folder),
})
}
}
}
func (m *Monitor) handleItemFinished(ev st.Event) {
data, ok := ev.Data.(map[string]any)
if !ok {
return
}
item, _ := data["item"].(string)
folder, _ := data["folder"].(string)
errStr, _ := data["error"].(string)
action, _ := data["action"].(string)
if errStr != "" {
if isConflict(errStr) {
m.conflicts.Increment()
m.emitEvent("Conflict", map[string]string{
"file": filepath.Base(item),
"folder": folderLabel(m.folders.Folders(), folder),
})
}
return
}
if item != "" && folder != "" && action != "delete" {
m.recent.Add(filepath.Base(item), folderLabel(m.folders.Folders(), folder))
m.mu.Lock()
m.lastSync = time.Now()
m.mu.Unlock()
}
}
func (m *Monitor) handleDeviceEvent(ev st.Event, connected bool) {
// Re-count online devices
go func() {
conns, err := m.client.SystemConnections()
if err != nil {
return
}
online := 0
for _, c := range conns.Connections {
if c.Connected {
online++
}
}
m.mu.Lock()
m.devicesOnline = online
m.mu.Unlock()
m.emitStatus()
}()
data, ok := ev.Data.(map[string]any)
if !ok {
return
}
deviceName, _ := data["name"].(string)
if deviceName == "" {
deviceName, _ = data["id"].(string)
}
if connected {
m.emitEvent("DeviceConnected", map[string]string{"name": deviceName})
} else {
m.emitEvent("DeviceDisconnected", map[string]string{"name": deviceName})
}
}
func (m *Monitor) refreshPendingDevices() {
pending, err := m.client.PendingDevices()
if err != nil {
return
}
m.mu.Lock()
oldCount := m.pendingDevs
m.pendingDevs = len(pending)
m.mu.Unlock()
if len(pending) > oldCount {
for _, dev := range pending {
name := dev.Name
if name == "" {
name = dev.DeviceID[:8]
}
m.emitEvent("NewDevice", map[string]string{"name": name})
}
}
m.emitStatus()
}
func (m *Monitor) emitEvent(eventType string, data map[string]string) {
if m.eventCb != nil {
m.eventCb(eventType, data)
}
}
func (m *Monitor) refreshFolderStatuses() {
for _, f := range m.folders.Folders() {
status, err := m.client.FolderStatus(f.ID)
if err != nil {
continue
}
m.folders.UpdateStatus(f.ID, status.State)
}
m.emitStatus()
}
func (m *Monitor) emitStatus() {
down, up := m.speed.Rates()
folders := m.folders.Folders()
m.mu.Lock()
status := AggregateStatus{
DevicesTotal: m.devicesTotal,
DevicesOnline: m.devicesOnline,
DownRate: down,
UpRate: up,
LastSync: m.lastSync,
Paused: m.paused,
RecentFiles: m.recent.Files(),
ConflictCount: m.conflicts.Count(),
Folders: folders,
PendingDevices: m.pendingDevs,
}
if !m.connected {
status.State = icons.StateDisconnected
} else {
status.State = stateFromFolders(folders, m.paused)
}
m.mu.Unlock()
m.callback(status)
}
// EventData returns the event data field as a typed map.
func EventData(ev st.Event) map[string]any {
if data, ok := ev.Data.(map[string]any); ok {
return data
}
// Try JSON re-marshal for nested types
b, err := json.Marshal(ev.Data)
if err != nil {
return nil
}
var data map[string]any
if json.Unmarshal(b, &data) == nil {
return data
}
return nil
}
func isConflict(errStr string) bool {
return errStr == "conflict" || errStr == "conflicting changes"
}
func folderLabel(folders []FolderInfo, id string) string {
for _, f := range folders {
if f.ID == id {
return f.Label
}
}
return id
}

View File

@@ -0,0 +1,49 @@
package monitor
import (
"sync"
"time"
)
const maxRecentFiles = 10
// RecentTracker maintains a ring buffer of recently synced files.
type RecentTracker struct {
mu sync.Mutex
files []RecentFile
}
// NewRecentTracker creates a new recent files tracker.
func NewRecentTracker() *RecentTracker {
return &RecentTracker{
files: make([]RecentFile, 0, maxRecentFiles),
}
}
// Add records a newly synced file.
func (rt *RecentTracker) Add(name, folder string) {
rt.mu.Lock()
defer rt.mu.Unlock()
rf := RecentFile{
Name: name,
Folder: folder,
Timestamp: time.Now(),
}
// Prepend (most recent first)
rt.files = append([]RecentFile{rf}, rt.files...)
if len(rt.files) > maxRecentFiles {
rt.files = rt.files[:maxRecentFiles]
}
}
// Files returns a snapshot of recent files (most recent first).
func (rt *RecentTracker) Files() []RecentFile {
rt.mu.Lock()
defer rt.mu.Unlock()
out := make([]RecentFile, len(rt.files))
copy(out, rt.files)
return out
}

52
internal/monitor/speed.go Normal file
View File

@@ -0,0 +1,52 @@
package monitor
import (
"sync"
"time"
)
// SpeedTracker calculates transfer rates by diffing byte counters.
type SpeedTracker struct {
mu sync.Mutex
lastIn int64
lastOut int64
lastTime time.Time
downRate float64
upRate float64
}
// NewSpeedTracker creates a new speed tracker.
func NewSpeedTracker() *SpeedTracker {
return &SpeedTracker{}
}
// Update records new byte counters and calculates rates.
func (s *SpeedTracker) Update(inBytes, outBytes int64) {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
if !s.lastTime.IsZero() {
dt := now.Sub(s.lastTime).Seconds()
if dt > 0 {
s.downRate = float64(inBytes-s.lastIn) / dt
s.upRate = float64(outBytes-s.lastOut) / dt
if s.downRate < 0 {
s.downRate = 0
}
if s.upRate < 0 {
s.upRate = 0
}
}
}
s.lastIn = inBytes
s.lastOut = outBytes
s.lastTime = now
}
// Rates returns the current download and upload rates in bytes/sec.
func (s *SpeedTracker) Rates() (down, up float64) {
s.mu.Lock()
defer s.mu.Unlock()
return s.downRate, s.upRate
}

64
internal/monitor/state.go Normal file
View File

@@ -0,0 +1,64 @@
package monitor
import (
"time"
"git.davoryn.de/calic/syncwarden/internal/icons"
)
// AggregateStatus holds the full aggregated status for the tray.
type AggregateStatus struct {
State icons.State
DevicesTotal int
DevicesOnline int
DownRate float64 // bytes/sec
UpRate float64 // bytes/sec
LastSync time.Time
Paused bool
RecentFiles []RecentFile
ConflictCount int
Folders []FolderInfo
PendingDevices int
}
// FolderInfo holds per-folder info for the menu.
type FolderInfo struct {
ID string
Label string
Path string
State string // "idle", "syncing", "scanning", "error", etc.
}
// RecentFile records a recently synced file.
type RecentFile struct {
Name string
Folder string
Timestamp time.Time
}
// stateFromFolders determines the aggregate icon state from folder states.
func stateFromFolders(folders []FolderInfo, paused bool) icons.State {
if paused {
return icons.StatePaused
}
hasError := false
hasSyncing := false
for _, f := range folders {
switch f.State {
case "error":
hasError = true
case "syncing", "sync-preparing", "scanning", "sync-waiting", "scan-waiting":
hasSyncing = true
}
}
if hasError {
return icons.StateError
}
if hasSyncing {
return icons.StateSyncing
}
return icons.StateIdle
}

41
internal/notify/notify.go Normal file
View File

@@ -0,0 +1,41 @@
package notify
import (
"log"
"github.com/gen2brain/beeep"
)
const appName = "SyncWarden"
// Send sends an OS notification.
func Send(title, message string) {
if err := beeep.Notify(title, message, ""); err != nil {
log.Printf("notification error: %v", err)
}
}
// SyncComplete notifies that a folder finished syncing.
func SyncComplete(folder string) {
Send(appName, folder+" finished syncing")
}
// DeviceConnected notifies that a device connected.
func DeviceConnected(name string) {
Send(appName, name+" connected")
}
// DeviceDisconnected notifies that a device disconnected.
func DeviceDisconnected(name string) {
Send(appName, name+" disconnected")
}
// NewDevice notifies about a new device request.
func NewDevice(name string) {
Send(appName, "New device wants to connect: "+name)
}
// Conflict notifies about a sync conflict.
func Conflict(file, folder string) {
Send(appName, "Conflict: "+file+" in "+folder)
}

View File

@@ -0,0 +1,83 @@
package syncthing
import (
"log"
"sync"
"time"
)
// EventHandler is called for each batch of new events.
type EventHandler func(events []Event)
// EventListener long-polls the Syncthing event API.
type EventListener struct {
client *Client
handler EventHandler
sinceID int
stopCh chan struct{}
wg sync.WaitGroup
}
// NewEventListener creates a new event listener.
func NewEventListener(client *Client, sinceID int, handler EventHandler) *EventListener {
return &EventListener{
client: client,
handler: handler,
sinceID: sinceID,
stopCh: make(chan struct{}),
}
}
// Start begins long-polling in a goroutine.
func (el *EventListener) Start() {
el.wg.Add(1)
go el.loop()
}
// Stop stops the event listener and waits for it to finish.
func (el *EventListener) Stop() {
close(el.stopCh)
el.wg.Wait()
}
// LastEventID returns the last processed event ID.
func (el *EventListener) LastEventID() int {
return el.sinceID
}
func (el *EventListener) loop() {
defer el.wg.Done()
backoff := time.Second
maxBackoff := 30 * time.Second
for {
select {
case <-el.stopCh:
return
default:
}
events, err := el.client.Events(el.sinceID, 30)
if err != nil {
log.Printf("event poll error: %v", err)
select {
case <-el.stopCh:
return
case <-time.After(backoff):
}
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
continue
}
backoff = time.Second
if len(events) > 0 {
el.sinceID = events[len(events)-1].ID
el.handler(events)
}
}
}

View File

@@ -0,0 +1,149 @@
package syncthing
import (
"log"
"os/exec"
"sync"
"time"
)
// Process manages the Syncthing child process lifecycle.
type Process struct {
mu sync.Mutex
cmd *exec.Cmd
stopCh chan struct{}
wg sync.WaitGroup
running bool
restarts int
}
// NewProcess creates a new Syncthing process manager.
func NewProcess() *Process {
return &Process{
stopCh: make(chan struct{}),
}
}
// Start launches Syncthing and monitors it (restarts on crash).
func (p *Process) Start() error {
p.mu.Lock()
if p.running {
p.mu.Unlock()
return nil
}
p.running = true
p.mu.Unlock()
p.wg.Add(1)
go p.supervise()
return nil
}
// Stop terminates the Syncthing process.
func (p *Process) Stop() {
p.mu.Lock()
if !p.running {
p.mu.Unlock()
return
}
p.running = false
p.mu.Unlock()
close(p.stopCh)
p.mu.Lock()
cmd := p.cmd
p.mu.Unlock()
if cmd != nil && cmd.Process != nil {
_ = cmd.Process.Kill()
}
p.wg.Wait()
}
// IsRunning returns whether the process is currently running.
func (p *Process) IsRunning() bool {
p.mu.Lock()
defer p.mu.Unlock()
return p.running && p.cmd != nil
}
func (p *Process) supervise() {
defer p.wg.Done()
backoff := 2 * time.Second
maxBackoff := 30 * time.Second
for {
select {
case <-p.stopCh:
return
default:
}
cmd := createSyncthingCmd()
if cmd == nil {
log.Println("syncthing binary not found in PATH")
select {
case <-p.stopCh:
return
case <-time.After(maxBackoff):
}
continue
}
p.mu.Lock()
p.cmd = cmd
p.mu.Unlock()
log.Printf("starting syncthing (pid will follow)")
err := cmd.Start()
if err != nil {
log.Printf("failed to start syncthing: %v", err)
select {
case <-p.stopCh:
return
case <-time.After(backoff):
}
continue
}
log.Printf("syncthing started (pid %d)", cmd.Process.Pid)
err = cmd.Wait()
p.mu.Lock()
p.cmd = nil
running := p.running
p.restarts++
p.mu.Unlock()
if !running {
return
}
if err != nil {
log.Printf("syncthing exited: %v (will restart in %v)", err, backoff)
} else {
log.Printf("syncthing exited normally (will restart in %v)", backoff)
}
select {
case <-p.stopCh:
return
case <-time.After(backoff):
}
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
}
}
func findSyncthing() string {
path, err := exec.LookPath("syncthing")
if err != nil {
return ""
}
return path
}

View File

@@ -0,0 +1,14 @@
//go:build !windows
package syncthing
import "os/exec"
// createSyncthingCmd creates the Syncthing command on Unix.
func createSyncthingCmd() *exec.Cmd {
path := findSyncthing()
if path == "" {
return nil
}
return exec.Command(path, "-no-browser", "-no-restart")
}

View File

@@ -0,0 +1,21 @@
//go:build windows
package syncthing
import (
"os/exec"
"syscall"
)
// createSyncthingCmd creates the Syncthing command with CREATE_NO_WINDOW flag.
func createSyncthingCmd() *exec.Cmd {
path := findSyncthing()
if path == "" {
return nil
}
cmd := exec.Command(path, "-no-browser", "-no-restart")
cmd.SysProcAttr = &syscall.SysProcAttr{
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
return cmd
}

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