Initial scaffold: project structure, .gitignore, go.mod

This commit is contained in:
Axel Meyer
2026-03-03 21:07:31 +01:00
commit 2256df9dd7
15 changed files with 892 additions and 0 deletions

35
.gitignore vendored Normal file
View File

@@ -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

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module git.davoryn.de/calic/syncwarden
go 1.24

97
internal/config/config.go Normal file
View File

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

8
internal/config/paths.go Normal file
View File

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

View File

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

View File

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

View File

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

114
internal/icons/render.go Normal file
View File

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

30
internal/icons/states.go Normal file
View File

@@ -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]
}

View File

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

View File

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

View File

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

33
internal/tray/menu.go Normal file
View File

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

76
internal/tray/panel.go Normal file
View File

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

107
internal/tray/tray.go Normal file
View File

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