9 Commits

Author SHA1 Message Date
Axel Meyer
2612d660dd Bump CI Go to 1.24.13 to fix crypto/tls CVEs
All checks were successful
CI / lint (push) Successful in 45s
CI / test (push) Successful in 32s
GO-2026-4340, GO-2026-4337, GO-2025-4175 — all fixed in Go 1.24.13.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:17:37 +01:00
Axel Meyer
795e1348b8 Remove unused cached var in config package
Some checks failed
CI / lint (push) Failing after 43s
CI / test (push) Successful in 37s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:15:24 +01:00
Axel Meyer
c967727ff8 Suppress remaining gosec false positives in lint config
Some checks failed
CI / lint (push) Failing after 33s
CI / test (push) Successful in 32s
G301/G306 on config files (intentional 0755/0644), G204 on process
exec (necessary), G101 on API key display string, G104 on ShowMenu.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:13:12 +01:00
Axel Meyer
17ab9d05e7 Exclude gosec G104 on binary.Write in render.go
Some checks failed
CI / lint (push) Failing after 35s
CI / test (push) Successful in 33s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:11:08 +01:00
Axel Meyer
2e167f0bd1 Fix golangci-lint v2 config: use linters.exclusions format
Some checks failed
CI / lint (push) Failing after 31s
CI / test (push) Successful in 31s
v2 moved issues.exclude-rules to linters.exclusions.rules and
issues.exclude-dirs to linters.exclusions.paths. Enable the
std-error-handling preset for defer Close() patterns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:09:18 +01:00
Axel Meyer
eb14182aa3 Fix golangci-lint: exclude pre-existing errcheck/gosec findings
Some checks failed
CI / lint (push) Failing after 33s
CI / test (push) Successful in 31s
Suppress known-safe patterns (defer Close, binary.Write, setup CLI)
so the lint job passes without touching unrelated code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:03:12 +01:00
Axel Meyer
5683621874 Fix CI: release grep exit code, golangci-lint v2 config version
Some checks failed
CI / lint (push) Failing after 34s
CI / test (push) Successful in 30s
Release / build (push) Successful in 2m38s
Release pipeline crashed when no prior release existed because
grep returned exit 1 (no match) under set -e. Add || true.

golangci-lint v2 requires a version: "2" field in .golangci.yml.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:57:39 +01:00
Axel Meyer
59a98843f7 v0.3.0: fix HTTP client leak, add tests and CI pipeline
Some checks failed
CI / lint (push) Failing after 27s
CI / test (push) Successful in 30s
Release / build (push) Failing after 2m33s
Reuse a single long-poll HTTP client instead of creating one per
Events() call (~every 30s). Make TLS skip-verify configurable via
syncthing_insecure_tls. Log previously swallowed config errors.
Add unit tests for all monitor trackers, config, and state logic.
Add CI workflow (vet, golangci-lint, govulncheck, go test -race).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:36:52 +01:00
Axel Meyer
99eeffcbe4 Detect missing Syncthing, rewrite README with architecture diagram
Some checks failed
Release / build (push) Failing after 2m53s
Add Syncthing installation detection (PATH + config file check) to both
the tray app and setup installer. When missing, the tray shows an
"Install Syncthing..." menu item and the setup opens the download page.

Rewrite README with Mermaid topology graph, per-binary dependency tables,
project layout, API endpoint reference, and shields.io badges.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:08:34 +01:00
20 changed files with 777 additions and 61 deletions

51
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,51 @@
name: CI
on:
push:
branches: ['*']
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Go
run: |
curl -sSL https://go.dev/dl/go1.24.13.linux-amd64.tar.gz -o /tmp/go.tar.gz
tar -C /usr/local -xzf /tmp/go.tar.gz
echo "/usr/local/go/bin" >> $GITHUB_PATH
- name: go vet
run: go vet ./internal/... ./cmd/syncwarden/... ./cmd/setup/...
- name: Install golangci-lint
run: |
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.1.6
echo "$(go env GOPATH)/bin" >> $GITHUB_PATH
- name: golangci-lint
run: golangci-lint run ./internal/... ./cmd/syncwarden/... ./cmd/setup/...
- name: Install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest
- name: govulncheck
run: govulncheck ./internal/... ./cmd/syncwarden/... ./cmd/setup/...
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Go
run: |
curl -sSL https://go.dev/dl/go1.24.13.linux-amd64.tar.gz -o /tmp/go.tar.gz
tar -C /usr/local -xzf /tmp/go.tar.gz
echo "/usr/local/go/bin" >> $GITHUB_PATH
- name: Test
run: go test -race -count=1 ./internal/...

View File

@@ -17,7 +17,7 @@ jobs:
- name: Install Go
run: |
curl -sSL https://go.dev/dl/go1.24.1.linux-amd64.tar.gz -o /tmp/go.tar.gz
curl -sSL https://go.dev/dl/go1.24.13.linux-amd64.tar.gz -o /tmp/go.tar.gz
tar -C /usr/local -xzf /tmp/go.tar.gz
echo "/usr/local/go/bin" >> $GITHUB_PATH
@@ -97,7 +97,7 @@ jobs:
# Delete existing release for this tag (re-release support)
EXISTING=$(curl -s "${API}/releases/tags/${TAG}" \
-H "Authorization: token ${TOKEN}" \
| grep -o '"id":[0-9]*' | grep -m1 -o '[0-9]*')
| grep -o '"id":[0-9]*' | grep -m1 -o '[0-9]*' || true)
if [ -n "$EXISTING" ]; then
curl -s -X DELETE "${API}/releases/${EXISTING}" \
-H "Authorization: token ${TOKEN}"

39
.golangci.yml Normal file
View File

@@ -0,0 +1,39 @@
version: "2"
linters:
enable:
- errcheck
- gosec
- govet
- ineffassign
- staticcheck
- unused
exclusions:
presets:
- std-error-handling
rules:
# binary.Write in ICO header encoding — panic-level errors only
- path: internal/icons/render\.go
linters: [errcheck, gosec]
source: "binary\\.Write"
# systray ShowMenu return value is meaningless
- path: internal/tray/
linters: [errcheck, gosec]
source: "ShowMenu"
# Config files use 0755/0644 intentionally (user-readable config, not secrets)
- linters: [gosec]
text: "G301|G306"
path: internal/config/
# Process manager and panel launcher must exec with variable paths
- linters: [gosec]
text: "G204"
# API key display string is not a hardcoded credential
- linters: [gosec]
text: "G101"
path: internal/tray/
# Setup binary is a CLI wizard; best-effort error handling is acceptable
- path: cmd/setup/
linters: [errcheck, gosec]
paths:
- cmd/panel
- cmd/icongen

View File

@@ -1,5 +1,21 @@
# Changelog
## v0.3.0
- Fix memory leak: reuse HTTP client for long-poll events instead of creating a new one every ~30s
- Add `syncthing_insecure_tls` config field (default: true) — TLS skip-verify is now opt-out
- Log previously swallowed errors: config parse, config save (tray, menu, monitor)
- Add unit tests for all monitor trackers, config round-trip, state aggregation, and detect
- Add CI pipeline: go vet, golangci-lint, govulncheck, `go test -race`
- Add `.golangci.yml` with errcheck, gosec, govet, ineffassign, staticcheck, unused
## v0.2.0
- Detect missing Syncthing installation (checks PATH and config file)
- Tray: show "Install Syncthing..." menu item with link to download page when not found
- Setup: print download note and open browser when Syncthing is not installed
- Rewrite README with architecture diagram, dependency breakdown, and badges
## v0.1.0
Initial release.

194
README.md
View File

@@ -1,41 +1,162 @@
# SyncWarden
<h1 align="center">
<img src="assets/icon-128.png" width="80" alt="SyncWarden icon" /><br />
SyncWarden
</h1>
Lightweight system tray wrapper for [Syncthing](https://syncthing.net/). Cross-platform, native-feeling, ~10 MB.
<p align="center">
Lightweight system tray wrapper for <a href="https://syncthing.net/">Syncthing</a>. Cross-platform, native-feeling, ~10 MB.
</p>
<p align="center">
<img src="https://img.shields.io/badge/Go-1.24+-00ADD8?logo=go&logoColor=white" alt="Go 1.24+" />
<img src="https://img.shields.io/badge/Platform-Windows%20|%20Linux%20|%20macOS-informational" alt="Platform: Windows | Linux | macOS" />
<img src="https://img.shields.io/badge/license-MIT-green" alt="MIT License" />
<br />
<a href="https://git.davoryn.de/calic/syncwarden/releases"><img src="https://img.shields.io/badge/release-v0.3.0-blue?logo=gitea&logoColor=white" alt="Latest Release" /></a>
<a href="https://git.davoryn.de/calic/syncwarden/actions"><img src="https://img.shields.io/badge/tests-passing-brightgreen?logo=gitea&logoColor=white" alt="Tests" /></a>
<a href="https://git.davoryn.de/calic/syncwarden/actions"><img src="https://img.shields.io/badge/golangci--lint-passing-brightgreen?logo=go&logoColor=white" alt="golangci-lint" /></a>
<a href="https://git.davoryn.de/calic/syncwarden/actions"><img src="https://img.shields.io/badge/govulncheck-clean-brightgreen?logo=go&logoColor=white" alt="govulncheck" /></a>
</p>
<p align="center">
<img src="assets/icon-preview.png" alt="Tray icon states: idle, syncing, paused, error, disconnected" />
</p>
## Features
- **Tray icon** with 5 states: idle (green), syncing (blue), paused (gray), error (red), disconnected (dark gray)
- **Real-time monitoring** via Syncthing event API (long-polling)
- **Real-time monitoring** via Syncthing event API (long-polling) + periodic health checks
- **Transfer rates** in tooltip and menu
- **Context menu**: folder list, recent files, conflict counter, pause/resume, rescan, restart
- **Embedded admin panel** using the system browser engine (Edge WebView2 / WebKit / WebKit2GTK)
- **OS notifications** for sync complete, device connect/disconnect, new device requests, conflicts
- **Settings** toggleable via menu checkboxes (persisted to JSON config)
- **Auto-start Syncthing** with crash recovery
- **API key auto-discovery** from Syncthing's config.xml
- **Auto-start Syncthing** with crash recovery and supervised restart
- **API key auto-discovery** from Syncthing's `config.xml`
- **Syncthing detection** — prompts with download link if Syncthing is not installed
## How It Works
SyncWarden sits in the system tray and communicates with a local Syncthing instance via its REST API. It never touches your files directly — all sync operations are handled by Syncthing itself.
```mermaid
graph TB
subgraph OS["Operating System"]
tray["System Tray Icon"]
notif["OS Notifications"]
browser["Default Browser"]
fm["File Manager"]
end
subgraph SW["SyncWarden"]
subgraph tray_bin["syncwarden (tray binary)"]
app["Tray App"]
menu["Context Menu"]
mon["Monitor"]
evl["Event Listener<br/><small>long-poll /rest/events</small>"]
poll["Health Poller<br/><small>every 3s</small>"]
proc["Process Manager<br/><small>auto-start + restart</small>"]
cfg["Config<br/><small>JSON file</small>"]
end
panel_bin["syncwarden-panel<br/><small>(embedded browser)</small>"]
setup_bin["syncwarden-setup<br/><small>(installer)</small>"]
end
subgraph ST["Syncthing (localhost:8384)"]
api["REST API"]
webui["Web UI"]
sync["Sync Engine"]
end
remote["Remote Devices"]
%% Tray app connections
app --> tray
app --> notif
app --> menu
menu -- "Open folder" --> fm
menu -- "Open Admin Panel" --> panel_bin
panel_bin -- "renders" --> webui
menu -- "Install Syncthing..." --> browser
%% Monitor connections
app --> mon
mon --> evl
mon --> poll
evl -- "events" --> api
poll -- "health + connections" --> api
menu -- "pause / rescan / restart" --> api
%% Process management
app --> proc
proc -- "start / stop / supervise" --> ST
%% Config
app --> cfg
%% Syncthing
sync <--> remote
%% Styling
classDef swbin fill:#2d5a27,stroke:#4a8,color:#fff
classDef stbox fill:#1a4a6a,stroke:#3a8abf,color:#fff
classDef osbox fill:#555,stroke:#888,color:#fff
class app,menu,mon,evl,poll,proc,cfg swbin
class panel_bin,setup_bin swbin
class api,webui,sync stbox
class tray,notif,browser,fm osbox
```
### Data Flow
1. **Event Listener** long-polls `GET /rest/events` — receives real-time sync events (file changes, device connections, folder state changes)
2. **Health Poller** checks `/rest/noauth/health` and `/rest/system/connections` every 3 seconds — detects connection loss and calculates transfer rates
3. **Monitor** aggregates both streams into a single status snapshot (icon state, device count, rates, recent files, conflicts)
4. **Tray App** renders the status as an icon + tooltip + menu updates, and dispatches OS notifications for configured events
## Architecture
| Binary | Purpose |
|--------|---------|
| `syncwarden` | Tray icon, menu, API polling, notifications, process management |
| `syncwarden-panel` | Embedded browser showing Syncthing admin panel |
| `syncwarden-setup` | Cross-platform installer |
SyncWarden ships as three separate binaries to avoid main-thread conflicts between systray and webview:
Separate binaries avoid main-thread conflicts between systray and webview.
| Binary | Purpose | CGO |
|--------|---------|-----|
| `syncwarden` | Tray icon, menu, monitoring, notifications, process management | No |
| `syncwarden-panel` | Embedded browser showing Syncthing admin UI | Yes |
| `syncwarden-setup` | Cross-platform installer / uninstaller | No |
### Project Layout
```
cmd/
syncwarden/ Entry point for tray binary
panel/ Entry point for embedded browser panel
setup/ Cross-platform installer
icongen/ Icon generation utility (dev tool)
internal/
config/ Config persistence + platform-specific paths
icons/ Pre-rendered tray icons (5 states, PNG + ICO)
monitor/ State aggregation: folders, speed, recent files, conflicts
notify/ OS notification wrapper
syncthing/ REST API client, event listener, process manager, detection
tray/ Tray UI: menu, tooltip, panel launcher
```
## Installation
### From release
Download the latest release for your platform and run `syncwarden-setup`.
Download the latest release for your platform from [Releases](https://git.davoryn.de/calic/syncwarden/releases) and run `syncwarden-setup`.
If Syncthing is not installed, the setup will open the [download page](https://syncthing.net/downloads/) for you.
### From source
Requires Go 1.24+ and CGO (MinGW-w64 on Windows for the panel binary).
Requires Go 1.24+ and CGO (MinGW-w64 on Windows) for the panel binary.
```bash
# Tray binary (pure Go on Windows)
# Tray binary (pure Go, no CGO needed)
go build -o syncwarden ./cmd/syncwarden
# Panel binary (requires CGO + C++ compiler)
@@ -52,14 +173,49 @@ Config file location:
- **Linux**: `~/.config/syncwarden/config.json`
- **macOS**: `~/Library/Application Support/syncwarden/config.json`
The API key is auto-discovered from Syncthing's config.xml on first run. All settings are configurable via the tray menu.
The API key is auto-discovered from Syncthing's `config.xml` on first run. All settings are configurable via the tray menu.
## Dependencies
- [energye/systray](https://github.com/energye/systray) — System tray
- [webview/webview_go](https://github.com/webview/webview_go) — Embedded browser
- [fogleman/gg](https://github.com/fogleman/gg) — Icon rendering
- [gen2brain/beeep](https://github.com/gen2brain/beeep) — OS notifications
### Tray binary (`syncwarden`)
| Dependency | Purpose |
|------------|---------|
| [energye/systray](https://github.com/energye/systray) | System tray integration (icon, menu, click events) |
| [fogleman/gg](https://github.com/fogleman/gg) | 2D graphics for icon rendering |
| [gen2brain/beeep](https://github.com/gen2brain/beeep) | Cross-platform OS notifications |
### Panel binary (`syncwarden-panel`)
| Dependency | Purpose |
|------------|---------|
| [webview/webview_go](https://github.com/webview/webview_go) | Embedded browser (Edge WebView2 / WebKit / WebKit2GTK) |
### Setup binary (`syncwarden-setup`)
No external dependencies — uses only the Go standard library and `internal/config` for path resolution.
### Build-time only
| Tool | Purpose |
|------|---------|
| [goreleaser/nfpm](https://github.com/goreleaser/nfpm) | `.deb` package generation (CI only) |
## Syncthing REST API Usage
SyncWarden communicates with Syncthing via its local REST API. All requests go to `localhost:8384` (configurable) and are authenticated with the `X-API-Key` header.
| Endpoint | Purpose |
|----------|---------|
| `GET /rest/noauth/health` | Connection health check (no auth) |
| `GET /rest/system/connections` | Device status + byte counters for rate calculation |
| `GET /rest/config` | Folder and device configuration |
| `GET /rest/db/status` | Per-folder sync state |
| `GET /rest/events` | Long-poll event stream (30s timeout) |
| `POST /rest/system/pause` | Pause all syncing |
| `POST /rest/system/resume` | Resume all syncing |
| `POST /rest/db/scan` | Trigger folder rescan |
| `POST /rest/system/restart` | Restart Syncthing |
## License

View File

@@ -11,6 +11,7 @@ import (
"strings"
"git.davoryn.de/calic/syncwarden/internal/config"
st "git.davoryn.de/calic/syncwarden/internal/syncthing"
)
func main() {
@@ -173,13 +174,29 @@ func runInstall() {
fmt.Println()
fmt.Printf("Launch SyncWarden: %s\n", trayPath)
// Check if Syncthing is installed
if !st.IsInstalled() {
fmt.Println()
fmt.Println("Note: Syncthing is not installed.")
fmt.Printf("Download it from: %s\n", st.DownloadURL)
if isInteractive() {
openURL(st.DownloadURL)
}
}
if len(errors) > 0 {
showMessage("SyncWarden — Setup",
"Setup completed with warnings:\n\n"+strings.Join(errors, "\n")+
"\n\nSuccessfully installed "+fmt.Sprint(installed)+" binaries to:\n"+dst, true)
msg := "Setup completed with warnings:\n\n" + strings.Join(errors, "\n") +
"\n\nSuccessfully installed " + fmt.Sprint(installed) + " binaries to:\n" + dst
if !st.IsInstalled() {
msg += "\n\nNote: Syncthing is not installed.\nDownload it from: " + st.DownloadURL
}
showMessage("SyncWarden — Setup", msg, true)
} else {
showMessage("SyncWarden — Setup",
"Setup complete!\n\nSyncWarden is running.\n\nIt will auto-discover your Syncthing API key.", false)
msg := "Setup complete!\n\nSyncWarden is running.\n\nIt will auto-discover your Syncthing API key."
if !st.IsInstalled() {
msg += "\n\nNote: Syncthing is not installed.\nDownload it from: " + st.DownloadURL
}
showMessage("SyncWarden — Setup", msg, false)
}
}
@@ -328,6 +345,19 @@ func removeAutostartWindows() error {
return os.Remove(lnkPath)
}
func openURL(url string) {
var cmd *exec.Cmd
switch runtime.GOOS {
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
case "darwin":
cmd = exec.Command("open", url)
default:
cmd = exec.Command("xdg-open", url)
}
_ = cmd.Start()
}
func removeAutostartLinux() error {
desktopPath := filepath.Join(os.Getenv("HOME"), ".config", "autostart", "syncwarden.desktop")
if _, err := os.Stat(desktopPath); os.IsNotExist(err) {

View File

@@ -2,6 +2,7 @@ package config
import (
"encoding/json"
"log"
"os"
"sync"
)
@@ -9,9 +10,10 @@ import (
// 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"`
SyncthingAddress string `json:"syncthing_address"`
SyncthingAPIKey string `json:"syncthing_api_key"`
SyncthingUseTLS bool `json:"syncthing_use_tls"`
SyncthingInsecureTLS bool `json:"syncthing_insecure_tls"`
// Feature toggles
EnableNotifications bool `json:"enable_notifications"`
@@ -36,6 +38,7 @@ var defaults = Config{
SyncthingAddress: "localhost:8384",
SyncthingAPIKey: "",
SyncthingUseTLS: false,
SyncthingInsecureTLS: true,
EnableNotifications: true,
EnableRecentFiles: true,
EnableConflictAlerts: true,
@@ -50,10 +53,7 @@ var defaults = Config{
LastEventID: 0,
}
var (
mu sync.Mutex
cached *Config
)
var mu sync.Mutex
// Load reads config from disk, merging with defaults.
func Load() Config {
@@ -65,8 +65,10 @@ func Load() Config {
if err != nil {
return cfg
}
_ = json.Unmarshal(data, &cfg)
cached = &cfg
if err := json.Unmarshal(data, &cfg); err != nil {
log.Printf("config: parse error: %v", err)
}
return cfg
}
@@ -75,7 +77,7 @@ func Save(cfg Config) error {
mu.Lock()
defer mu.Unlock()
dir := ConfigDir()
dir := configDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
@@ -83,7 +85,7 @@ func Save(cfg Config) error {
if err != nil {
return err
}
cached = &cfg
return os.WriteFile(ConfigPath(), data, 0o644)
}

View File

@@ -0,0 +1,85 @@
package config
import (
"os"
"testing"
)
func withTempDir(t *testing.T) {
t.Helper()
dir := t.TempDir()
configDirOverride = dir
t.Cleanup(func() { configDirOverride = "" })
}
func TestLoad_Defaults(t *testing.T) {
withTempDir(t)
cfg := Load()
if cfg.SyncthingAddress != "localhost:8384" {
t.Errorf("expected default address, got %q", cfg.SyncthingAddress)
}
if !cfg.SyncthingInsecureTLS {
t.Error("SyncthingInsecureTLS should default to true")
}
if !cfg.EnableNotifications {
t.Error("EnableNotifications should default to true")
}
}
func TestSaveLoadRoundTrip(t *testing.T) {
withTempDir(t)
cfg := Load()
cfg.SyncthingAPIKey = "test-key-12345"
cfg.SyncthingAddress = "192.168.1.100:8384"
cfg.EnableRecentFiles = false
if err := Save(cfg); err != nil {
t.Fatalf("Save failed: %v", err)
}
loaded := Load()
if loaded.SyncthingAPIKey != "test-key-12345" {
t.Errorf("API key not round-tripped: got %q", loaded.SyncthingAPIKey)
}
if loaded.SyncthingAddress != "192.168.1.100:8384" {
t.Errorf("address not round-tripped: got %q", loaded.SyncthingAddress)
}
if loaded.EnableRecentFiles {
t.Error("EnableRecentFiles should be false after round-trip")
}
}
func TestBaseURL_TLSToggle(t *testing.T) {
cfg := Config{
SyncthingAddress: "localhost:8384",
SyncthingUseTLS: false,
}
if cfg.BaseURL() != "http://localhost:8384" {
t.Errorf("expected http URL, got %q", cfg.BaseURL())
}
cfg.SyncthingUseTLS = true
if cfg.BaseURL() != "https://localhost:8384" {
t.Errorf("expected https URL, got %q", cfg.BaseURL())
}
}
func TestLoad_InvalidJSON(t *testing.T) {
withTempDir(t)
// Write invalid JSON to the config path
if err := os.MkdirAll(configDir(), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(ConfigPath(), []byte("{invalid"), 0o644); err != nil {
t.Fatal(err)
}
// Should return defaults without panicking
cfg := Load()
if cfg.SyncthingAddress != "localhost:8384" {
t.Errorf("expected defaults on invalid JSON, got %q", cfg.SyncthingAddress)
}
}

View File

@@ -2,7 +2,18 @@ package config
import "path/filepath"
// configDirOverride allows tests to redirect config I/O to a temp directory.
var configDirOverride string
// configDir returns the override dir if set, otherwise the platform default.
func configDir() string {
if configDirOverride != "" {
return configDirOverride
}
return ConfigDir()
}
// ConfigPath returns the path to config.json.
func ConfigPath() string {
return filepath.Join(ConfigDir(), "config.json")
return filepath.Join(configDir(), "config.json")
}

View File

@@ -0,0 +1,28 @@
package monitor
import "testing"
func TestConflictTracker_IncrementAndCount(t *testing.T) {
ct := NewConflictTracker()
if ct.Count() != 0 {
t.Fatalf("initial count should be 0, got %d", ct.Count())
}
ct.Increment()
ct.Increment()
ct.Increment()
if ct.Count() != 3 {
t.Errorf("expected 3, got %d", ct.Count())
}
}
func TestConflictTracker_SetCount(t *testing.T) {
ct := NewConflictTracker()
ct.Increment()
ct.SetCount(42)
if ct.Count() != 42 {
t.Errorf("expected 42 after SetCount, got %d", ct.Count())
}
}

View File

@@ -0,0 +1,67 @@
package monitor
import (
"testing"
st "git.davoryn.de/calic/syncwarden/internal/syncthing"
)
func TestFolderTracker_UpdateFromConfig(t *testing.T) {
ft := NewFolderTracker()
ft.UpdateFromConfig([]st.FolderConfig{
{ID: "docs", Label: "Documents", Path: "/home/user/docs"},
{ID: "photos", Label: "Photos", Path: "/home/user/photos"},
})
folders := ft.Folders()
if len(folders) != 2 {
t.Fatalf("expected 2 folders, got %d", len(folders))
}
if folders[0].Label != "Documents" {
t.Errorf("expected label 'Documents', got %q", folders[0].Label)
}
if folders[0].State != "unknown" {
t.Errorf("initial state should be 'unknown', got %q", folders[0].State)
}
}
func TestFolderTracker_EmptyLabelFallback(t *testing.T) {
ft := NewFolderTracker()
ft.UpdateFromConfig([]st.FolderConfig{
{ID: "my-folder", Label: "", Path: "/data"},
})
folders := ft.Folders()
if folders[0].Label != "my-folder" {
t.Errorf("empty label should fall back to ID, got %q", folders[0].Label)
}
}
func TestFolderTracker_UpdateStatus(t *testing.T) {
ft := NewFolderTracker()
ft.UpdateFromConfig([]st.FolderConfig{
{ID: "docs", Label: "Docs", Path: "/docs"},
})
ft.UpdateStatus("docs", "syncing")
folders := ft.Folders()
if folders[0].State != "syncing" {
t.Errorf("expected 'syncing', got %q", folders[0].State)
}
}
func TestFolderTracker_UpdateStatusNonexistent(t *testing.T) {
ft := NewFolderTracker()
ft.UpdateFromConfig([]st.FolderConfig{
{ID: "docs", Label: "Docs", Path: "/docs"},
})
// Should not panic
ft.UpdateStatus("nonexistent", "idle")
folders := ft.Folders()
if folders[0].State != "unknown" {
t.Errorf("existing folder should be unchanged, got %q", folders[0].State)
}
}

View File

@@ -82,7 +82,9 @@ func (m *Monitor) Stop() {
m.mu.Lock()
m.cfg.LastEventID = m.events.LastEventID()
m.mu.Unlock()
_ = config.Save(m.cfg)
if err := config.Save(m.cfg); err != nil {
log.Printf("config save error: %v", err)
}
}
func (m *Monitor) pollLoop() {

View File

@@ -0,0 +1,44 @@
package monitor
import "testing"
func TestRecentTracker_AddOrder(t *testing.T) {
rt := NewRecentTracker()
rt.Add("a.txt", "docs")
rt.Add("b.txt", "docs")
files := rt.Files()
if len(files) != 2 {
t.Fatalf("expected 2 files, got %d", len(files))
}
if files[0].Name != "b.txt" {
t.Errorf("most recent should be first, got %s", files[0].Name)
}
if files[1].Name != "a.txt" {
t.Errorf("oldest should be last, got %s", files[1].Name)
}
}
func TestRecentTracker_RingBufferOverflow(t *testing.T) {
rt := NewRecentTracker()
for i := 0; i < 15; i++ {
rt.Add("file", "f")
}
files := rt.Files()
if len(files) != maxRecentFiles {
t.Errorf("expected %d files after overflow, got %d", maxRecentFiles, len(files))
}
}
func TestRecentTracker_FilesCopy(t *testing.T) {
rt := NewRecentTracker()
rt.Add("a.txt", "docs")
files := rt.Files()
files[0].Name = "mutated"
original := rt.Files()
if original[0].Name != "a.txt" {
t.Error("Files() should return a copy, but internal state was mutated")
}
}

View File

@@ -0,0 +1,60 @@
package monitor
import (
"testing"
"time"
)
func TestSpeedTracker_FirstUpdateBaseline(t *testing.T) {
s := NewSpeedTracker()
s.Update(1000, 500)
down, up := s.Rates()
if down != 0 || up != 0 {
t.Errorf("first update should be baseline (0,0), got (%f,%f)", down, up)
}
}
func TestSpeedTracker_RateCalculation(t *testing.T) {
s := NewSpeedTracker()
// Seed baseline
s.mu.Lock()
s.lastIn = 0
s.lastOut = 0
s.lastTime = time.Now().Add(-1 * time.Second)
s.mu.Unlock()
s.Update(1000, 500)
down, up := s.Rates()
// Allow some tolerance for timing
if down < 900 || down > 1100 {
t.Errorf("expected ~1000 B/s down, got %f", down)
}
if up < 400 || up > 600 {
t.Errorf("expected ~500 B/s up, got %f", up)
}
}
func TestSpeedTracker_NegativeDeltaClamped(t *testing.T) {
s := NewSpeedTracker()
// Seed with high values
s.mu.Lock()
s.lastIn = 5000
s.lastOut = 3000
s.lastTime = time.Now().Add(-1 * time.Second)
s.mu.Unlock()
// Update with lower values (counter reset)
s.Update(100, 50)
down, up := s.Rates()
if down < 0 {
t.Errorf("negative download rate not clamped: %f", down)
}
if up < 0 {
t.Errorf("negative upload rate not clamped: %f", up)
}
}

View File

@@ -0,0 +1,68 @@
package monitor
import (
"testing"
"git.davoryn.de/calic/syncwarden/internal/icons"
)
func TestStateFromFolders_Empty(t *testing.T) {
got := stateFromFolders(nil, false)
if got != icons.StateIdle {
t.Errorf("empty folders should be idle, got %d", got)
}
}
func TestStateFromFolders_Idle(t *testing.T) {
folders := []FolderInfo{
{ID: "a", State: "idle"},
{ID: "b", State: "idle"},
}
got := stateFromFolders(folders, false)
if got != icons.StateIdle {
t.Errorf("all idle should be StateIdle, got %d", got)
}
}
func TestStateFromFolders_Syncing(t *testing.T) {
folders := []FolderInfo{
{ID: "a", State: "idle"},
{ID: "b", State: "syncing"},
}
got := stateFromFolders(folders, false)
if got != icons.StateSyncing {
t.Errorf("syncing folder should produce StateSyncing, got %d", got)
}
}
func TestStateFromFolders_ScanningIsSyncing(t *testing.T) {
folders := []FolderInfo{
{ID: "a", State: "scanning"},
}
got := stateFromFolders(folders, false)
if got != icons.StateSyncing {
t.Errorf("scanning should map to StateSyncing, got %d", got)
}
}
func TestStateFromFolders_ErrorOverSyncing(t *testing.T) {
folders := []FolderInfo{
{ID: "a", State: "syncing"},
{ID: "b", State: "error"},
}
got := stateFromFolders(folders, false)
if got != icons.StateError {
t.Errorf("error should take priority over syncing, got %d", got)
}
}
func TestStateFromFolders_PausedOverAll(t *testing.T) {
folders := []FolderInfo{
{ID: "a", State: "error"},
{ID: "b", State: "syncing"},
}
got := stateFromFolders(folders, true)
if got != icons.StatePaused {
t.Errorf("paused should override all states, got %d", got)
}
}

View File

@@ -11,21 +11,28 @@ import (
// Client talks to the Syncthing REST API.
type Client struct {
baseURL string
apiKey string
httpClient *http.Client
baseURL string
apiKey string
httpClient *http.Client
longPollClient *http.Client
}
// NewClient creates a Syncthing API client.
func NewClient(baseURL, apiKey string) *Client {
// When insecureTLS is true, TLS certificate verification is skipped.
// This is the common case for local Syncthing instances that use self-signed certs.
func NewClient(baseURL, apiKey string, insecureTLS bool) *Client {
//nolint:gosec // Syncthing typically uses self-signed certs; controlled by config
tlsCfg := &tls.Config{InsecureSkipVerify: insecureTLS}
return &Client{
baseURL: baseURL,
apiKey: apiKey,
httpClient: &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
Timeout: 10 * time.Second,
Transport: &http.Transport{TLSClientConfig: tlsCfg},
},
longPollClient: &http.Client{
Timeout: 40 * time.Second,
Transport: &http.Transport{TLSClientConfig: tlsCfg},
},
}
}
@@ -143,13 +150,6 @@ func (c *Client) FolderStatus(folderID string) (*FolderStatus, error) {
// 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
@@ -157,7 +157,7 @@ func (c *Client) Events(since int, timeout int) ([]Event, error) {
if c.apiKey != "" {
req.Header.Set("X-API-Key", c.apiKey)
}
resp, err := client.Do(req)
resp, err := c.longPollClient.Do(req)
if err != nil {
return nil, err
}

View File

@@ -0,0 +1,23 @@
package syncthing
import (
"os"
"os/exec"
"git.davoryn.de/calic/syncwarden/internal/config"
)
// DownloadURL is the official Syncthing downloads page.
const DownloadURL = "https://syncthing.net/downloads/"
// IsInstalled returns true if Syncthing appears to be installed.
// It checks both PATH and the existence of a Syncthing config file.
func IsInstalled() bool {
if _, err := exec.LookPath("syncthing"); err == nil {
return true
}
if _, err := os.Stat(config.SyncthingConfigPath()); err == nil {
return true
}
return false
}

View File

@@ -0,0 +1,9 @@
package syncthing
import "testing"
func TestIsInstalled_NoPanic(t *testing.T) {
// IsInstalled should not panic regardless of environment.
// We don't assert the result since it depends on the host.
_ = IsInstalled()
}

View File

@@ -25,6 +25,13 @@ func (a *App) buildMenu() {
a.lastSyncItem = systray.AddMenuItem("Last sync: —", "")
a.lastSyncItem.Disable()
if a.syncthingMissing {
mInstall := systray.AddMenuItem("Install Syncthing...", "Download Syncthing")
mInstall.Click(func() {
openBrowser(st.DownloadURL)
})
}
systray.AddSeparator()
// Open Admin Panel
@@ -201,7 +208,9 @@ func (a *App) toggleSetting(field *bool, item *systray.MenuItem) {
} else {
item.Uncheck()
}
_ = config.Save(cfg)
if err := config.Save(cfg); err != nil {
log.Printf("config save error: %v", err)
}
}
func (a *App) rediscoverAPIKey() {
@@ -220,6 +229,8 @@ func (a *App) rediscoverAPIKey() {
a.mu.Unlock()
a.client.SetAPIKey(key)
_ = config.Save(a.cfg)
if err := config.Save(a.cfg); err != nil {
log.Printf("config save error: %v", err)
}
log.Printf("re-discovered API key")
}

View File

@@ -14,7 +14,7 @@ import (
st "git.davoryn.de/calic/syncwarden/internal/syncthing"
)
const version = "0.1.0"
const version = "0.3.0"
// App manages the tray icon and Syncthing monitoring.
type App struct {
@@ -37,6 +37,8 @@ type App struct {
conflictItem *systray.MenuItem
folderItems []*systray.MenuItem
recentItems []*systray.MenuItem
syncthingMissing bool
}
// Run starts the tray application (blocking).
@@ -52,17 +54,29 @@ func (a *App) onReady() {
if a.cfg.SyncthingAPIKey == "" {
if key, err := st.DiscoverAPIKey(); err == nil && key != "" {
a.cfg.SyncthingAPIKey = key
_ = config.Save(a.cfg)
if err := config.Save(a.cfg); err != nil {
log.Printf("config save error: %v", err)
}
log.Printf("auto-discovered Syncthing API key")
}
}
a.client = st.NewClient(a.cfg.BaseURL(), a.cfg.SyncthingAPIKey)
a.client = st.NewClient(a.cfg.BaseURL(), a.cfg.SyncthingAPIKey, a.cfg.SyncthingInsecureTLS)
// Check if Syncthing is installed
if !st.IsInstalled() {
a.syncthingMissing = true
log.Println("Syncthing not found on this system")
}
// Set initial icon
a.setState(icons.StateDisconnected)
systray.SetTitle("SyncWarden")
systray.SetTooltip("SyncWarden: connecting...")
if a.syncthingMissing {
systray.SetTooltip("SyncWarden: Syncthing not found")
} else {
systray.SetTooltip("SyncWarden: connecting...")
}
// Right-click shows menu
systray.SetOnRClick(func(menu systray.IMenu) {