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:
35
internal/monitor/conflicts.go
Normal file
35
internal/monitor/conflicts.go
Normal 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
|
||||
}
|
||||
60
internal/monitor/folders.go
Normal file
60
internal/monitor/folders.go
Normal 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
384
internal/monitor/monitor.go
Normal 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
|
||||
}
|
||||
49
internal/monitor/recent.go
Normal file
49
internal/monitor/recent.go
Normal 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
52
internal/monitor/speed.go
Normal 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
64
internal/monitor/state.go
Normal 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
41
internal/notify/notify.go
Normal 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)
|
||||
}
|
||||
83
internal/syncthing/events.go
Normal file
83
internal/syncthing/events.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
149
internal/syncthing/process.go
Normal file
149
internal/syncthing/process.go
Normal 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
|
||||
}
|
||||
14
internal/syncthing/process_other.go
Normal file
14
internal/syncthing/process_other.go
Normal 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")
|
||||
}
|
||||
21
internal/syncthing/process_windows.go
Normal file
21
internal/syncthing/process_windows.go
Normal 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
20
internal/tray/icons.go
Normal 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)
|
||||
}
|
||||
@@ -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
23
internal/tray/open.go
Normal 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
87
internal/tray/tooltip.go
Normal 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)
|
||||
}
|
||||
@@ -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