From 2256df9dd74bbd252adfdbd2a8bbd79383fb4950 Mon Sep 17 00:00:00 2001 From: Axel Meyer Date: Tue, 3 Mar 2026 21:07:31 +0100 Subject: [PATCH] Initial scaffold: project structure, .gitignore, go.mod --- .gitignore | 35 ++++++ go.mod | 3 + internal/config/config.go | 97 ++++++++++++++ internal/config/paths.go | 8 ++ internal/config/paths_darwin.go | 20 +++ internal/config/paths_linux.go | 26 ++++ internal/config/paths_windows.go | 18 +++ internal/icons/render.go | 114 +++++++++++++++++ internal/icons/states.go | 30 +++++ internal/syncthing/client.go | 210 +++++++++++++++++++++++++++++++ internal/syncthing/config_xml.go | 27 ++++ internal/syncthing/types.go | 88 +++++++++++++ internal/tray/menu.go | 33 +++++ internal/tray/panel.go | 76 +++++++++++ internal/tray/tray.go | 107 ++++++++++++++++ 15 files changed, 892 insertions(+) create mode 100644 .gitignore create mode 100644 go.mod create mode 100644 internal/config/config.go create mode 100644 internal/config/paths.go create mode 100644 internal/config/paths_darwin.go create mode 100644 internal/config/paths_linux.go create mode 100644 internal/config/paths_windows.go create mode 100644 internal/icons/render.go create mode 100644 internal/icons/states.go create mode 100644 internal/syncthing/client.go create mode 100644 internal/syncthing/config_xml.go create mode 100644 internal/syncthing/types.go create mode 100644 internal/tray/menu.go create mode 100644 internal/tray/panel.go create mode 100644 internal/tray/tray.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..64e23bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Binaries +*.exe +*.exe~ +*.dll +*.so +*.dylib +syncwarden +syncwarden-panel +syncwarden-setup + +# Build output +/dist/ +/dist-win/ + +# Test +*.test +*.out +coverage.txt + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Packages +*.deb +*.rpm +*.tar.gz +*.zip diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..757d2a9 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.davoryn.de/calic/syncwarden + +go 1.24 diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..1ef7c4a --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,97 @@ +package config + +import ( + "encoding/json" + "os" + "sync" +) + +// Config holds SyncWarden configuration. +type Config struct { + // Connection + SyncthingAddress string `json:"syncthing_address"` + SyncthingAPIKey string `json:"syncthing_api_key"` + SyncthingUseTLS bool `json:"syncthing_use_tls"` + + // Feature toggles + EnableNotifications bool `json:"enable_notifications"` + EnableRecentFiles bool `json:"enable_recent_files"` + EnableConflictAlerts bool `json:"enable_conflict_alerts"` + EnableTransferRate bool `json:"enable_transfer_rate"` + AutoStartSyncthing bool `json:"auto_start_syncthing"` + StartOnLogin bool `json:"start_on_login"` + + // Per-event notification toggles + NotifySyncComplete bool `json:"notify_sync_complete"` + NotifyDeviceConnect bool `json:"notify_device_connect"` + NotifyDeviceDisconnect bool `json:"notify_device_disconnect"` + NotifyNewDevice bool `json:"notify_new_device"` + NotifyConflict bool `json:"notify_conflict"` + + // Persisted state + LastEventID int `json:"last_event_id"` +} + +var defaults = Config{ + SyncthingAddress: "localhost:8384", + SyncthingAPIKey: "", + SyncthingUseTLS: false, + EnableNotifications: true, + EnableRecentFiles: true, + EnableConflictAlerts: true, + EnableTransferRate: true, + AutoStartSyncthing: false, + StartOnLogin: false, + NotifySyncComplete: true, + NotifyDeviceConnect: false, + NotifyDeviceDisconnect: true, + NotifyNewDevice: true, + NotifyConflict: true, + LastEventID: 0, +} + +var ( + mu sync.Mutex + cached *Config +) + +// Load reads config from disk, merging with defaults. +func Load() Config { + mu.Lock() + defer mu.Unlock() + + cfg := defaults + data, err := os.ReadFile(ConfigPath()) + if err != nil { + return cfg + } + _ = json.Unmarshal(data, &cfg) + cached = &cfg + return cfg +} + +// Save writes config to disk. +func Save(cfg Config) error { + mu.Lock() + defer mu.Unlock() + + dir := ConfigDir() + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + cached = &cfg + return os.WriteFile(ConfigPath(), data, 0o644) +} + +// BaseURL returns the full Syncthing base URL. +func (c Config) BaseURL() string { + scheme := "http" + if c.SyncthingUseTLS { + scheme = "https" + } + return scheme + "://" + c.SyncthingAddress +} diff --git a/internal/config/paths.go b/internal/config/paths.go new file mode 100644 index 0000000..054f898 --- /dev/null +++ b/internal/config/paths.go @@ -0,0 +1,8 @@ +package config + +import "path/filepath" + +// ConfigPath returns the path to config.json. +func ConfigPath() string { + return filepath.Join(ConfigDir(), "config.json") +} diff --git a/internal/config/paths_darwin.go b/internal/config/paths_darwin.go new file mode 100644 index 0000000..4649551 --- /dev/null +++ b/internal/config/paths_darwin.go @@ -0,0 +1,20 @@ +//go:build darwin + +package config + +import ( + "os" + "path/filepath" +) + +// ConfigDir returns ~/Library/Application Support/syncwarden. +func ConfigDir() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, "Library", "Application Support", "syncwarden") +} + +// SyncthingConfigPath returns the default Syncthing config.xml path on macOS. +func SyncthingConfigPath() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, "Library", "Application Support", "Syncthing", "config.xml") +} diff --git a/internal/config/paths_linux.go b/internal/config/paths_linux.go new file mode 100644 index 0000000..bcb6568 --- /dev/null +++ b/internal/config/paths_linux.go @@ -0,0 +1,26 @@ +//go:build linux + +package config + +import ( + "os" + "path/filepath" +) + +// ConfigDir returns ~/.config/syncwarden. +func ConfigDir() string { + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + return filepath.Join(xdg, "syncwarden") + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config", "syncwarden") +} + +// SyncthingConfigPath returns the default Syncthing config.xml path on Linux. +func SyncthingConfigPath() string { + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + return filepath.Join(xdg, "syncthing", "config.xml") + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config", "syncthing", "config.xml") +} diff --git a/internal/config/paths_windows.go b/internal/config/paths_windows.go new file mode 100644 index 0000000..3deff97 --- /dev/null +++ b/internal/config/paths_windows.go @@ -0,0 +1,18 @@ +//go:build windows + +package config + +import ( + "os" + "path/filepath" +) + +// ConfigDir returns %LOCALAPPDATA%\syncwarden. +func ConfigDir() string { + return filepath.Join(os.Getenv("LOCALAPPDATA"), "syncwarden") +} + +// SyncthingConfigPath returns the default Syncthing config.xml path on Windows. +func SyncthingConfigPath() string { + return filepath.Join(os.Getenv("LOCALAPPDATA"), "Syncthing", "config.xml") +} diff --git a/internal/icons/render.go b/internal/icons/render.go new file mode 100644 index 0000000..45abdf1 --- /dev/null +++ b/internal/icons/render.go @@ -0,0 +1,114 @@ +package icons + +import ( + "bytes" + "encoding/binary" + "image/png" + "math" + "runtime" + + "github.com/fogleman/gg" +) + +const iconSize = 64 + +// Render generates a tray icon for the given state. +// Returns ICO bytes on Windows, PNG bytes on other platforms. +func Render(state State) ([]byte, error) { + dc := gg.NewContext(iconSize, iconSize) + drawSyncIcon(dc, state) + + var buf bytes.Buffer + if err := png.Encode(&buf, dc.Image()); err != nil { + return nil, err + } + + pngData := buf.Bytes() + if runtime.GOOS == "windows" { + return wrapPNGInICO(pngData, iconSize, iconSize), nil + } + return pngData, nil +} + +// drawSyncIcon draws a circular sync arrows icon in the state's color. +func drawSyncIcon(dc *gg.Context, state State) { + c := colorForState(state) + cx := float64(iconSize) / 2 + cy := float64(iconSize) / 2 + radius := float64(iconSize)*0.35 + arrowWidth := 5.0 + + dc.SetColor(c) + dc.SetLineWidth(arrowWidth) + dc.SetLineCap(gg.LineCapRound) + + // Draw two circular arcs (sync arrows) + // Top arc: from 220° to 320° + drawArcWithArrow(dc, cx, cy, radius, degToRad(220), degToRad(320), arrowWidth) + // Bottom arc: from 40° to 140° + drawArcWithArrow(dc, cx, cy, radius, degToRad(40), degToRad(140), arrowWidth) +} + +func drawArcWithArrow(dc *gg.Context, cx, cy, radius, startAngle, endAngle, lineWidth float64) { + // Draw the arc + dc.DrawArc(cx, cy, radius, startAngle, endAngle) + dc.Stroke() + + // Draw arrowhead at the end of the arc + endX := cx + radius*math.Cos(endAngle) + endY := cy + radius*math.Sin(endAngle) + + // Arrow direction is tangent to the circle at the endpoint + tangentAngle := endAngle + math.Pi/2 + arrowLen := lineWidth * 2.5 + + // Two points of the arrowhead + a1x := endX + arrowLen*math.Cos(tangentAngle+0.5) + a1y := endY + arrowLen*math.Sin(tangentAngle+0.5) + a2x := endX + arrowLen*math.Cos(tangentAngle-0.5) + a2y := endY + arrowLen*math.Sin(tangentAngle-0.5) + + dc.MoveTo(a1x, a1y) + dc.LineTo(endX, endY) + dc.LineTo(a2x, a2y) + dc.Stroke() +} + +func degToRad(deg float64) float64 { + return deg * math.Pi / 180 +} + +// wrapPNGInICO wraps raw PNG bytes in a minimal ICO container. +func wrapPNGInICO(pngData []byte, width, height int) []byte { + const headerSize = 6 + const entrySize = 16 + imageOffset := headerSize + entrySize + + buf := new(bytes.Buffer) + + // ICONDIR header + binary.Write(buf, binary.LittleEndian, uint16(0)) // Reserved + binary.Write(buf, binary.LittleEndian, uint16(1)) // Type: 1 = icon + binary.Write(buf, binary.LittleEndian, uint16(1)) // Count: 1 image + + // ICONDIRENTRY + w := byte(width) + if width >= 256 { + w = 0 + } + h := byte(height) + if height >= 256 { + h = 0 + } + buf.WriteByte(w) + buf.WriteByte(h) + buf.WriteByte(0) // ColorCount + buf.WriteByte(0) // Reserved + binary.Write(buf, binary.LittleEndian, uint16(1)) // Planes + binary.Write(buf, binary.LittleEndian, uint16(32)) // BitCount + binary.Write(buf, binary.LittleEndian, uint32(len(pngData))) // BytesInRes + binary.Write(buf, binary.LittleEndian, uint32(imageOffset)) // ImageOffset + + buf.Write(pngData) + return buf.Bytes() +} diff --git a/internal/icons/states.go b/internal/icons/states.go new file mode 100644 index 0000000..65cb841 --- /dev/null +++ b/internal/icons/states.go @@ -0,0 +1,30 @@ +package icons + +import "image/color" + +// State represents the current sync state for icon rendering. +type State int + +const ( + StateIdle State = iota // Green — all folders idle + StateSyncing // Blue — syncing/scanning + StatePaused // Gray — all paused + StateError // Red — folder error + StateDisconnected // Dark gray — cannot reach Syncthing +) + +// colors maps each state to its icon color. +var colors = map[State]color.RGBA{ + StateIdle: {76, 175, 80, 255}, // green + StateSyncing: {33, 150, 243, 255}, // blue + StatePaused: {158, 158, 158, 255}, // gray + StateError: {244, 67, 54, 255}, // red + StateDisconnected: {97, 97, 97, 255}, // dark gray +} + +func colorForState(s State) color.RGBA { + if c, ok := colors[s]; ok { + return c + } + return colors[StateDisconnected] +} diff --git a/internal/syncthing/client.go b/internal/syncthing/client.go new file mode 100644 index 0000000..088ca87 --- /dev/null +++ b/internal/syncthing/client.go @@ -0,0 +1,210 @@ +package syncthing + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// Client talks to the Syncthing REST API. +type Client struct { + baseURL string + apiKey string + httpClient *http.Client +} + +// NewClient creates a Syncthing API client. +func NewClient(baseURL, apiKey string) *Client { + return &Client{ + baseURL: baseURL, + apiKey: apiKey, + httpClient: &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + }, + } +} + +// SetAPIKey updates the API key. +func (c *Client) SetAPIKey(key string) { + c.apiKey = key +} + +// SetBaseURL updates the base URL. +func (c *Client) SetBaseURL(url string) { + c.baseURL = url +} + +func (c *Client) get(path string, noAuth bool) ([]byte, error) { + req, err := http.NewRequest("GET", c.baseURL+path, nil) + if err != nil { + return nil, err + } + if !noAuth && c.apiKey != "" { + req.Header.Set("X-API-Key", c.apiKey) + } + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + return io.ReadAll(resp.Body) +} + +func (c *Client) post(path string) error { + req, err := http.NewRequest("POST", c.baseURL+path, nil) + if err != nil { + return err + } + if c.apiKey != "" { + req.Header.Set("X-API-Key", c.apiKey) + } + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + return nil +} + +// Health checks if Syncthing is reachable (no auth required). +func (c *Client) Health() (*HealthStatus, error) { + data, err := c.get("/rest/noauth/health", true) + if err != nil { + return nil, err + } + var s HealthStatus + return &s, json.Unmarshal(data, &s) +} + +// SystemStatus returns system status info. +func (c *Client) SystemStatus() (*SystemStatus, error) { + data, err := c.get("/rest/system/status", false) + if err != nil { + return nil, err + } + var s SystemStatus + return &s, json.Unmarshal(data, &s) +} + +// SystemVersion returns version info. +func (c *Client) SystemVersion() (*SystemVersion, error) { + data, err := c.get("/rest/system/version", false) + if err != nil { + return nil, err + } + var s SystemVersion + return &s, json.Unmarshal(data, &s) +} + +// SystemConnections returns all device connections. +func (c *Client) SystemConnections() (*Connections, error) { + data, err := c.get("/rest/system/connections", false) + if err != nil { + return nil, err + } + var s Connections + return &s, json.Unmarshal(data, &s) +} + +// Config returns the full Syncthing config (folders + devices). +func (c *Client) Config() (*FullConfig, error) { + data, err := c.get("/rest/config", false) + if err != nil { + return nil, err + } + var s FullConfig + return &s, json.Unmarshal(data, &s) +} + +// FolderStatus returns the sync status for a folder. +func (c *Client) FolderStatus(folderID string) (*FolderStatus, error) { + data, err := c.get("/rest/db/status?folder="+folderID, false) + if err != nil { + return nil, err + } + var s FolderStatus + return &s, json.Unmarshal(data, &s) +} + +// Events long-polls for new events since the given ID. +func (c *Client) Events(since int, timeout int) ([]Event, error) { + path := fmt.Sprintf("/rest/events?since=%d&timeout=%d", since, timeout) + // Use a longer HTTP timeout for long-polling + client := &http.Client{ + Timeout: time.Duration(timeout+10) * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + } + req, err := http.NewRequest("GET", c.baseURL+path, nil) + if err != nil { + return nil, err + } + if c.apiKey != "" { + req.Header.Set("X-API-Key", c.apiKey) + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + var events []Event + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + return events, json.Unmarshal(data, &events) +} + +// PendingDevices returns new device requests. +func (c *Client) PendingDevices() (map[string]PendingDevice, error) { + data, err := c.get("/rest/cluster/pending/devices", false) + if err != nil { + return nil, err + } + var s map[string]PendingDevice + return s, json.Unmarshal(data, &s) +} + +// PauseAll pauses all devices. +func (c *Client) PauseAll() error { + return c.post("/rest/system/pause") +} + +// ResumeAll resumes all devices. +func (c *Client) ResumeAll() error { + return c.post("/rest/system/resume") +} + +// RescanAll triggers a rescan of all folders. +func (c *Client) RescanAll() error { + return c.post("/rest/db/scan") +} + +// Restart restarts Syncthing. +func (c *Client) Restart() error { + return c.post("/rest/system/restart") +} + +// Shutdown stops Syncthing. +func (c *Client) Shutdown() error { + return c.post("/rest/system/shutdown") +} diff --git a/internal/syncthing/config_xml.go b/internal/syncthing/config_xml.go new file mode 100644 index 0000000..d8667c9 --- /dev/null +++ b/internal/syncthing/config_xml.go @@ -0,0 +1,27 @@ +package syncthing + +import ( + "encoding/xml" + "os" + + "git.davoryn.de/calic/syncwarden/internal/config" +) + +type syncthingConfig struct { + GUI struct { + APIKey string `xml:"apikey"` + } `xml:"gui"` +} + +// DiscoverAPIKey reads the Syncthing config.xml and extracts the API key. +func DiscoverAPIKey() (string, error) { + data, err := os.ReadFile(config.SyncthingConfigPath()) + if err != nil { + return "", err + } + var cfg syncthingConfig + if err := xml.Unmarshal(data, &cfg); err != nil { + return "", err + } + return cfg.GUI.APIKey, nil +} diff --git a/internal/syncthing/types.go b/internal/syncthing/types.go new file mode 100644 index 0000000..88be74a --- /dev/null +++ b/internal/syncthing/types.go @@ -0,0 +1,88 @@ +package syncthing + +import "time" + +// HealthStatus from /rest/noauth/health. +type HealthStatus struct { + Status string `json:"status"` // "OK" +} + +// SystemStatus from /rest/system/status. +type SystemStatus struct { + MyID string `json:"myID"` + Uptime int `json:"uptime"` + StartTime time.Time `json:"startTime"` +} + +// SystemVersion from /rest/system/version. +type SystemVersion struct { + Version string `json:"version"` + LongVersion string `json:"longVersion"` +} + +// ConnectionInfo for a single device from /rest/system/connections. +type ConnectionInfo struct { + Connected bool `json:"connected"` + Paused bool `json:"paused"` + Address string `json:"address"` + Type string `json:"type"` + InBytesTotal int64 `json:"inBytesTotal"` + OutBytesTotal int64 `json:"outBytesTotal"` +} + +// Connections from /rest/system/connections. +type Connections struct { + Total ConnectionInfo `json:"total"` + Connections map[string]ConnectionInfo `json:"connections"` +} + +// FolderConfig from /rest/config folders array. +type FolderConfig struct { + ID string `json:"id"` + Label string `json:"label"` + Path string `json:"path"` + Paused bool `json:"paused"` +} + +// DeviceConfig from /rest/config devices array. +type DeviceConfig struct { + DeviceID string `json:"deviceID"` + Name string `json:"name"` + Paused bool `json:"paused"` +} + +// FullConfig from /rest/config. +type FullConfig struct { + Folders []FolderConfig `json:"folders"` + Devices []DeviceConfig `json:"devices"` +} + +// FolderStatus from /rest/db/status. +type FolderStatus struct { + State string `json:"state"` // "idle", "scanning", "syncing", "error", etc. + StateChanged time.Time `json:"stateChanged"` + NeedFiles int `json:"needFiles"` + NeedBytes int64 `json:"needBytes"` + GlobalFiles int `json:"globalFiles"` + GlobalBytes int64 `json:"globalBytes"` + LocalFiles int `json:"localFiles"` + LocalBytes int64 `json:"localBytes"` + Errors int `json:"errors"` + PullErrors int `json:"pullErrors"` +} + +// Event from /rest/events. +type Event struct { + ID int `json:"id"` + Type string `json:"type"` + Time time.Time `json:"time"` + Data any `json:"data"` +} + +// PendingDevice from /rest/cluster/pending/devices. +type PendingDevice struct { + DeviceID string `json:"deviceID"` + Name string `json:"name"` + Time time.Time `json:"time"` + Address string `json:"address"` +} diff --git a/internal/tray/menu.go b/internal/tray/menu.go new file mode 100644 index 0000000..c74c58b --- /dev/null +++ b/internal/tray/menu.go @@ -0,0 +1,33 @@ +package tray + +import ( + "log" + + "github.com/energye/systray" +) + +// buildMenu creates the initial context menu (Phase 1: minimal). +func (a *App) buildMenu() { + mStatus := systray.AddMenuItem("Status: Connecting...", "") + mStatus.Disable() + + systray.AddSeparator() + + mOpenPanel := systray.AddMenuItem("Open Admin Panel", "Open Syncthing admin panel") + mOpenPanel.Click(func() { + a.openPanel() + }) + + systray.AddSeparator() + + 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() +} diff --git a/internal/tray/panel.go b/internal/tray/panel.go new file mode 100644 index 0000000..080f4cf --- /dev/null +++ b/internal/tray/panel.go @@ -0,0 +1,76 @@ +package tray + +import ( + "log" + "os" + "os/exec" + "path/filepath" + "runtime" + "sync" +) + +var ( + panelMu sync.Mutex + panelProc *os.Process +) + +// launchPanel starts the syncwarden-panel subprocess if not already running. +func launchPanel(baseURL string) { + panelMu.Lock() + defer panelMu.Unlock() + + // Check if already running + if panelProc != nil { + // Check if process is still alive + if err := panelProc.Signal(os.Signal(nil)); err == nil { + log.Println("panel already running") + return + } + panelProc = nil + } + + panelBin := panelBinaryPath() + if panelBin == "" { + log.Println("syncwarden-panel binary not found") + return + } + + cmd := exec.Command(panelBin, "-addr", baseURL) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + log.Printf("failed to start panel: %v", err) + return + } + panelProc = cmd.Process + log.Printf("panel started (pid %d)", cmd.Process.Pid) + + // Wait for process to exit in background + go func() { + _ = cmd.Wait() + panelMu.Lock() + panelProc = nil + panelMu.Unlock() + log.Println("panel exited") + }() +} + +// panelBinaryPath finds the syncwarden-panel binary next to the main binary. +func panelBinaryPath() string { + exe, err := os.Executable() + if err != nil { + return "" + } + dir := filepath.Dir(exe) + + name := "syncwarden-panel" + if runtime.GOOS == "windows" { + name = "syncwarden-panel.exe" + } + + p := filepath.Join(dir, name) + if _, err := os.Stat(p); err == nil { + return p + } + return "" +} diff --git a/internal/tray/tray.go b/internal/tray/tray.go new file mode 100644 index 0000000..9865b65 --- /dev/null +++ b/internal/tray/tray.go @@ -0,0 +1,107 @@ +package tray + +import ( + "log" + "sync" + + "github.com/energye/systray" + + "git.davoryn.de/calic/syncwarden/internal/config" + "git.davoryn.de/calic/syncwarden/internal/icons" + stClient "git.davoryn.de/calic/syncwarden/internal/syncthing" +) + +// 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 +} + +// Run starts the tray application (blocking). +func Run() { + app := &App{} + systray.Run(app.onReady, app.onExit) +} + +func (a *App) onReady() { + a.cfg = config.Load() + + // Auto-discover API key if not configured + if a.cfg.SyncthingAPIKey == "" { + if key, err := stClient.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) + + // Set initial icon + a.setState(icons.StateDisconnected) + systray.SetTitle("SyncWarden") + systray.SetTooltip("SyncWarden: connecting...") + + // Right-click shows menu + systray.SetOnRClick(func(menu systray.IMenu) { + menu.ShowMenu() + }) + + // Double-click opens admin panel + systray.SetOnDClick(func(menu systray.IMenu) { + a.openPanel() + }) + + // Build menu + a.buildMenu() + + // Check connection + go a.initialCheck() +} + +func (a *App) onExit() { + log.Println("SyncWarden exiting") +} + +func (a *App) setState(s icons.State) { + a.mu.Lock() + a.state = s + a.mu.Unlock() + + iconData, err := icons.Render(s) + if err != nil { + log.Printf("icon render error: %v", err) + return + } + 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() + 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") + } + a.mu.Unlock() +} + +func (a *App) openPanel() { + launchPanel(a.cfg.BaseURL()) +}