Initial scaffold: project structure, .gitignore, go.mod
This commit is contained in:
35
.gitignore
vendored
Normal file
35
.gitignore
vendored
Normal 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
|
||||
97
internal/config/config.go
Normal file
97
internal/config/config.go
Normal 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
8
internal/config/paths.go
Normal 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")
|
||||
}
|
||||
20
internal/config/paths_darwin.go
Normal file
20
internal/config/paths_darwin.go
Normal 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")
|
||||
}
|
||||
26
internal/config/paths_linux.go
Normal file
26
internal/config/paths_linux.go
Normal 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")
|
||||
}
|
||||
18
internal/config/paths_windows.go
Normal file
18
internal/config/paths_windows.go
Normal 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
114
internal/icons/render.go
Normal 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
30
internal/icons/states.go
Normal 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]
|
||||
}
|
||||
210
internal/syncthing/client.go
Normal file
210
internal/syncthing/client.go
Normal 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")
|
||||
}
|
||||
27
internal/syncthing/config_xml.go
Normal file
27
internal/syncthing/config_xml.go
Normal 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
|
||||
}
|
||||
88
internal/syncthing/types.go
Normal file
88
internal/syncthing/types.go
Normal 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
33
internal/tray/menu.go
Normal 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
76
internal/tray/panel.go
Normal 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
107
internal/tray/tray.go
Normal 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())
|
||||
}
|
||||
Reference in New Issue
Block a user