Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2612d660dd | ||
|
|
795e1348b8 | ||
|
|
c967727ff8 | ||
|
|
17ab9d05e7 | ||
|
|
2e167f0bd1 | ||
|
|
eb14182aa3 | ||
|
|
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.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/...
|
||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
run: |
|
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
|
tar -C /usr/local -xzf /tmp/go.tar.gz
|
||||||
echo "/usr/local/go/bin" >> $GITHUB_PATH
|
echo "/usr/local/go/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ jobs:
|
|||||||
# Delete existing release for this tag (re-release support)
|
# Delete existing release for this tag (re-release support)
|
||||||
EXISTING=$(curl -s "${API}/releases/tags/${TAG}" \
|
EXISTING=$(curl -s "${API}/releases/tags/${TAG}" \
|
||||||
-H "Authorization: token ${TOKEN}" \
|
-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
|
if [ -n "$EXISTING" ]; then
|
||||||
curl -s -X DELETE "${API}/releases/${EXISTING}" \
|
curl -s -X DELETE "${API}/releases/${EXISTING}" \
|
||||||
-H "Authorization: token ${TOKEN}"
|
-H "Authorization: token ${TOKEN}"
|
||||||
|
|||||||
39
.golangci.yml
Normal file
39
.golangci.yml
Normal 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
|
||||||
16
CHANGELOG.md
16
CHANGELOG.md
@@ -1,5 +1,21 @@
|
|||||||
# Changelog
|
# 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
|
## v0.1.0
|
||||||
|
|
||||||
Initial release.
|
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
|
## Features
|
||||||
|
|
||||||
- **Tray icon** with 5 states: idle (green), syncing (blue), paused (gray), error (red), disconnected (dark gray)
|
- **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
|
- **Transfer rates** in tooltip and menu
|
||||||
- **Context menu**: folder list, recent files, conflict counter, pause/resume, rescan, restart
|
- **Context menu**: folder list, recent files, conflict counter, pause/resume, rescan, restart
|
||||||
- **Embedded admin panel** using the system browser engine (Edge WebView2 / WebKit / WebKit2GTK)
|
- **Embedded admin panel** using the system browser engine (Edge WebView2 / WebKit / WebKit2GTK)
|
||||||
- **OS notifications** for sync complete, device connect/disconnect, new device requests, conflicts
|
- **OS notifications** for sync complete, device connect/disconnect, new device requests, conflicts
|
||||||
- **Settings** toggleable via menu checkboxes (persisted to JSON config)
|
- **Settings** toggleable via menu checkboxes (persisted to JSON config)
|
||||||
- **Auto-start Syncthing** with crash recovery
|
- **Auto-start Syncthing** with crash recovery and supervised restart
|
||||||
- **API key auto-discovery** from Syncthing's config.xml
|
- **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
|
## Architecture
|
||||||
|
|
||||||
| Binary | Purpose |
|
SyncWarden ships as three separate binaries to avoid main-thread conflicts between systray and webview:
|
||||||
|--------|---------|
|
|
||||||
| `syncwarden` | Tray icon, menu, API polling, notifications, process management |
|
|
||||||
| `syncwarden-panel` | Embedded browser showing Syncthing admin panel |
|
|
||||||
| `syncwarden-setup` | Cross-platform installer |
|
|
||||||
|
|
||||||
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
|
## Installation
|
||||||
|
|
||||||
### From release
|
### 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
|
### 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
|
```bash
|
||||||
# Tray binary (pure Go on Windows)
|
# Tray binary (pure Go, no CGO needed)
|
||||||
go build -o syncwarden ./cmd/syncwarden
|
go build -o syncwarden ./cmd/syncwarden
|
||||||
|
|
||||||
# Panel binary (requires CGO + C++ compiler)
|
# Panel binary (requires CGO + C++ compiler)
|
||||||
@@ -52,14 +173,49 @@ Config file location:
|
|||||||
- **Linux**: `~/.config/syncwarden/config.json`
|
- **Linux**: `~/.config/syncwarden/config.json`
|
||||||
- **macOS**: `~/Library/Application Support/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
|
## Dependencies
|
||||||
|
|
||||||
- [energye/systray](https://github.com/energye/systray) — System tray
|
### Tray binary (`syncwarden`)
|
||||||
- [webview/webview_go](https://github.com/webview/webview_go) — Embedded browser
|
|
||||||
- [fogleman/gg](https://github.com/fogleman/gg) — Icon rendering
|
| Dependency | Purpose |
|
||||||
- [gen2brain/beeep](https://github.com/gen2brain/beeep) — OS notifications
|
|------------|---------|
|
||||||
|
| [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
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.davoryn.de/calic/syncwarden/internal/config"
|
"git.davoryn.de/calic/syncwarden/internal/config"
|
||||||
|
st "git.davoryn.de/calic/syncwarden/internal/syncthing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -173,13 +174,29 @@ func runInstall() {
|
|||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Printf("Launch SyncWarden: %s\n", trayPath)
|
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 {
|
if len(errors) > 0 {
|
||||||
showMessage("SyncWarden — Setup",
|
msg := "Setup completed with warnings:\n\n" + strings.Join(errors, "\n") +
|
||||||
"Setup completed with warnings:\n\n"+strings.Join(errors, "\n")+
|
"\n\nSuccessfully installed " + fmt.Sprint(installed) + " binaries to:\n" + dst
|
||||||
"\n\nSuccessfully installed "+fmt.Sprint(installed)+" binaries to:\n"+dst, true)
|
if !st.IsInstalled() {
|
||||||
|
msg += "\n\nNote: Syncthing is not installed.\nDownload it from: " + st.DownloadURL
|
||||||
|
}
|
||||||
|
showMessage("SyncWarden — Setup", msg, true)
|
||||||
} else {
|
} else {
|
||||||
showMessage("SyncWarden — Setup",
|
msg := "Setup complete!\n\nSyncWarden is running.\n\nIt will auto-discover your Syncthing API key."
|
||||||
"Setup complete!\n\nSyncWarden is running.\n\nIt will auto-discover your Syncthing API key.", false)
|
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)
|
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 {
|
func removeAutostartLinux() error {
|
||||||
desktopPath := filepath.Join(os.Getenv("HOME"), ".config", "autostart", "syncwarden.desktop")
|
desktopPath := filepath.Join(os.Getenv("HOME"), ".config", "autostart", "syncwarden.desktop")
|
||||||
if _, err := os.Stat(desktopPath); os.IsNotExist(err) {
|
if _, err := os.Stat(desktopPath); os.IsNotExist(err) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
@@ -9,9 +10,10 @@ import (
|
|||||||
// Config holds SyncWarden configuration.
|
// Config holds SyncWarden configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// Connection
|
// Connection
|
||||||
SyncthingAddress string `json:"syncthing_address"`
|
SyncthingAddress string `json:"syncthing_address"`
|
||||||
SyncthingAPIKey string `json:"syncthing_api_key"`
|
SyncthingAPIKey string `json:"syncthing_api_key"`
|
||||||
SyncthingUseTLS bool `json:"syncthing_use_tls"`
|
SyncthingUseTLS bool `json:"syncthing_use_tls"`
|
||||||
|
SyncthingInsecureTLS bool `json:"syncthing_insecure_tls"`
|
||||||
|
|
||||||
// Feature toggles
|
// Feature toggles
|
||||||
EnableNotifications bool `json:"enable_notifications"`
|
EnableNotifications bool `json:"enable_notifications"`
|
||||||
@@ -36,6 +38,7 @@ var defaults = Config{
|
|||||||
SyncthingAddress: "localhost:8384",
|
SyncthingAddress: "localhost:8384",
|
||||||
SyncthingAPIKey: "",
|
SyncthingAPIKey: "",
|
||||||
SyncthingUseTLS: false,
|
SyncthingUseTLS: false,
|
||||||
|
SyncthingInsecureTLS: true,
|
||||||
EnableNotifications: true,
|
EnableNotifications: true,
|
||||||
EnableRecentFiles: true,
|
EnableRecentFiles: true,
|
||||||
EnableConflictAlerts: true,
|
EnableConflictAlerts: true,
|
||||||
@@ -50,10 +53,7 @@ var defaults = Config{
|
|||||||
LastEventID: 0,
|
LastEventID: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var mu sync.Mutex
|
||||||
mu sync.Mutex
|
|
||||||
cached *Config
|
|
||||||
)
|
|
||||||
|
|
||||||
// Load reads config from disk, merging with defaults.
|
// Load reads config from disk, merging with defaults.
|
||||||
func Load() Config {
|
func Load() Config {
|
||||||
@@ -65,8 +65,10 @@ func Load() Config {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal(data, &cfg)
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
cached = &cfg
|
log.Printf("config: parse error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +77,7 @@ func Save(cfg Config) error {
|
|||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
|
|
||||||
dir := ConfigDir()
|
dir := configDir()
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -83,7 +85,7 @@ func Save(cfg Config) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
cached = &cfg
|
|
||||||
return os.WriteFile(ConfigPath(), data, 0o644)
|
return os.WriteFile(ConfigPath(), data, 0o644)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
85
internal/config/config_test.go
Normal file
85
internal/config/config_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,18 @@ package config
|
|||||||
|
|
||||||
import "path/filepath"
|
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.
|
// ConfigPath returns the path to config.json.
|
||||||
func ConfigPath() string {
|
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.mu.Lock()
|
||||||
m.cfg.LastEventID = m.events.LastEventID()
|
m.cfg.LastEventID = m.events.LastEventID()
|
||||||
m.mu.Unlock()
|
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() {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,21 +11,28 @@ import (
|
|||||||
|
|
||||||
// Client talks to the Syncthing REST API.
|
// Client talks to the Syncthing REST API.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
baseURL string
|
baseURL string
|
||||||
apiKey string
|
apiKey string
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
longPollClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClient creates a Syncthing API 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{
|
return &Client{
|
||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: 10 * time.Second,
|
Timeout: 10 * time.Second,
|
||||||
Transport: &http.Transport{
|
Transport: &http.Transport{TLSClientConfig: tlsCfg},
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
},
|
||||||
},
|
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.
|
// Events long-polls for new events since the given ID.
|
||||||
func (c *Client) Events(since int, timeout int) ([]Event, error) {
|
func (c *Client) Events(since int, timeout int) ([]Event, error) {
|
||||||
path := fmt.Sprintf("/rest/events?since=%d&timeout=%d", since, timeout)
|
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)
|
req, err := http.NewRequest("GET", c.baseURL+path, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -157,7 +157,7 @@ func (c *Client) Events(since int, timeout int) ([]Event, error) {
|
|||||||
if c.apiKey != "" {
|
if c.apiKey != "" {
|
||||||
req.Header.Set("X-API-Key", c.apiKey)
|
req.Header.Set("X-API-Key", c.apiKey)
|
||||||
}
|
}
|
||||||
resp, err := client.Do(req)
|
resp, err := c.longPollClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 = systray.AddMenuItem("Last sync: —", "")
|
||||||
a.lastSyncItem.Disable()
|
a.lastSyncItem.Disable()
|
||||||
|
|
||||||
|
if a.syncthingMissing {
|
||||||
|
mInstall := systray.AddMenuItem("Install Syncthing...", "Download Syncthing")
|
||||||
|
mInstall.Click(func() {
|
||||||
|
openBrowser(st.DownloadURL)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
systray.AddSeparator()
|
systray.AddSeparator()
|
||||||
|
|
||||||
// Open Admin Panel
|
// Open Admin Panel
|
||||||
@@ -201,7 +208,9 @@ func (a *App) toggleSetting(field *bool, item *systray.MenuItem) {
|
|||||||
} else {
|
} else {
|
||||||
item.Uncheck()
|
item.Uncheck()
|
||||||
}
|
}
|
||||||
_ = config.Save(cfg)
|
if err := config.Save(cfg); err != nil {
|
||||||
|
log.Printf("config save error: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) rediscoverAPIKey() {
|
func (a *App) rediscoverAPIKey() {
|
||||||
@@ -220,6 +229,8 @@ func (a *App) rediscoverAPIKey() {
|
|||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
a.client.SetAPIKey(key)
|
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")
|
log.Printf("re-discovered API key")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
st "git.davoryn.de/calic/syncwarden/internal/syncthing"
|
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.
|
// App manages the tray icon and Syncthing monitoring.
|
||||||
type App struct {
|
type App struct {
|
||||||
@@ -37,6 +37,8 @@ type App struct {
|
|||||||
conflictItem *systray.MenuItem
|
conflictItem *systray.MenuItem
|
||||||
folderItems []*systray.MenuItem
|
folderItems []*systray.MenuItem
|
||||||
recentItems []*systray.MenuItem
|
recentItems []*systray.MenuItem
|
||||||
|
|
||||||
|
syncthingMissing bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run starts the tray application (blocking).
|
// Run starts the tray application (blocking).
|
||||||
@@ -52,17 +54,29 @@ func (a *App) onReady() {
|
|||||||
if a.cfg.SyncthingAPIKey == "" {
|
if a.cfg.SyncthingAPIKey == "" {
|
||||||
if key, err := st.DiscoverAPIKey(); err == nil && key != "" {
|
if key, err := st.DiscoverAPIKey(); err == nil && key != "" {
|
||||||
a.cfg.SyncthingAPIKey = 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")
|
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
|
// Set initial icon
|
||||||
a.setState(icons.StateDisconnected)
|
a.setState(icons.StateDisconnected)
|
||||||
systray.SetTitle("SyncWarden")
|
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
|
// Right-click shows menu
|
||||||
systray.SetOnRClick(func(menu systray.IMenu) {
|
systray.SetOnRClick(func(menu systray.IMenu) {
|
||||||
|
|||||||
Reference in New Issue
Block a user