Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5683621874 | ||
|
|
59a98843f7 | ||
|
|
99eeffcbe4 |
51
.gitea/workflows/ci.yml
Normal file
51
.gitea/workflows/ci.yml
Normal 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.1.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.1.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/...
|
||||
@@ -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}"
|
||||
|
||||
15
.golangci.yml
Normal file
15
.golangci.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
version: "2"
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- errcheck
|
||||
- gosec
|
||||
- govet
|
||||
- ineffassign
|
||||
- staticcheck
|
||||
- unused
|
||||
|
||||
issues:
|
||||
exclude-dirs:
|
||||
- cmd/panel
|
||||
- cmd/icongen
|
||||
16
CHANGELOG.md
16
CHANGELOG.md
@@ -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
194
README.md
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
@@ -12,6 +13,7 @@ type Config struct {
|
||||
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,
|
||||
@@ -65,7 +68,9 @@ func Load() Config {
|
||||
if err != nil {
|
||||
return cfg
|
||||
}
|
||||
_ = json.Unmarshal(data, &cfg)
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
log.Printf("config: parse error: %v", err)
|
||||
}
|
||||
cached = &cfg
|
||||
return cfg
|
||||
}
|
||||
@@ -75,7 +80,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
|
||||
}
|
||||
|
||||
90
internal/config/config_test.go
Normal file
90
internal/config/config_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// Clear cache so Load reads from disk
|
||||
mu.Lock()
|
||||
cached = nil
|
||||
mu.Unlock()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
28
internal/monitor/conflicts_test.go
Normal file
28
internal/monitor/conflicts_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
67
internal/monitor/folders_test.go
Normal file
67
internal/monitor/folders_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
44
internal/monitor/recent_test.go
Normal file
44
internal/monitor/recent_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
60
internal/monitor/speed_test.go
Normal file
60
internal/monitor/speed_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
68
internal/monitor/state_test.go
Normal file
68
internal/monitor/state_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -14,18 +14,25 @@ type Client struct {
|
||||
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},
|
||||
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
|
||||
}
|
||||
|
||||
23
internal/syncthing/detect.go
Normal file
23
internal/syncthing/detect.go
Normal 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
|
||||
}
|
||||
9
internal/syncthing/detect_test.go
Normal file
9
internal/syncthing/detect_test.go
Normal 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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
if a.syncthingMissing {
|
||||
systray.SetTooltip("SyncWarden: Syncthing not found")
|
||||
} else {
|
||||
systray.SetTooltip("SyncWarden: connecting...")
|
||||
}
|
||||
|
||||
// Right-click shows menu
|
||||
systray.SetOnRClick(func(menu systray.IMenu) {
|
||||
|
||||
Reference in New Issue
Block a user