Implement SyncWarden v0.1.0
Some checks failed
Release / build (push) Failing after 19s

Full Syncthing tray wrapper with:
- System tray with 5 icon states (idle/syncing/paused/error/disconnected)
- Syncthing REST API client with auto-discovered API key
- Long-polling event listener for real-time status
- Transfer rate monitoring, folder tracking, recent files, conflict counting
- Full context menu with folders, recent files, settings toggles
- Embedded admin panel binary (webview, requires CGO)
- OS notifications via beeep (per-event configurable)
- Syncthing process management with auto-restart
- Cross-platform installer with autostart
- CI pipeline for Linux (.deb + .tar.gz) and Windows (.zip)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Axel Meyer
2026-03-03 21:16:28 +01:00
parent 2256df9dd7
commit 34a1a94502
30 changed files with 2156 additions and 38 deletions

View File

@@ -0,0 +1,85 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Extract version
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV
- 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
echo "$(go env GOPATH)/bin" >> $GITHUB_PATH
- name: Install build dependencies
run: |
apt-get update -qq
apt-get install -y -qq gcc g++ mingw-w64 zip libwebkit2gtk-4.0-dev >/dev/null 2>&1
- name: Install nfpm
run: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@latest
- name: Build Linux binaries
run: |
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o syncwarden ./cmd/syncwarden
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o syncwarden-panel ./cmd/panel
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o syncwarden-setup ./cmd/setup
- name: Build Windows binaries
run: |
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -H=windowsgui" -o syncwarden.exe ./cmd/syncwarden
CGO_ENABLED=1 GOOS=windows GOARCH=amd64 CC=x86_64-w64-mingw32-gcc CXX=x86_64-w64-mingw32-g++ go build -trimpath -ldflags="-s -w -H=windowsgui" -o syncwarden-panel.exe ./cmd/panel
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o syncwarden-setup.exe ./cmd/setup
- name: Build .deb package
run: |
export PATH=$PATH:$(go env GOPATH)/bin
VERSION=${{ env.VERSION }} nfpm package --config packaging/nfpm.yaml --packager deb --target syncwarden_${{ env.VERSION }}_amd64.deb
- name: Create Linux tarball
run: |
mkdir -p dist/syncwarden-${{ env.VERSION }}
cp syncwarden syncwarden-panel syncwarden-setup dist/syncwarden-${{ env.VERSION }}/
cp README.md CHANGELOG.md LICENSE dist/syncwarden-${{ env.VERSION }}/
cp packaging/linux/syncwarden.desktop dist/syncwarden-${{ env.VERSION }}/
tar -czf syncwarden_${{ env.VERSION }}_linux_amd64.tar.gz -C dist syncwarden-${{ env.VERSION }}
- name: Create Windows zip
run: |
mkdir -p dist-win/syncwarden-${{ env.VERSION }}
cp syncwarden.exe syncwarden-panel.exe syncwarden-setup.exe dist-win/syncwarden-${{ env.VERSION }}/
cp README.md CHANGELOG.md LICENSE dist-win/syncwarden-${{ env.VERSION }}/
cd dist-win && zip -r ../syncwarden_${{ env.VERSION }}_windows_amd64.zip syncwarden-${{ env.VERSION }}
- name: Create Gitea release
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
TAG="${GITHUB_REF_NAME}"
RELEASE_ID=$(curl -s -X POST "${API}/releases" \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"body\":\"See CHANGELOG.md for details.\",\"draft\":false,\"prerelease\":false}" \
| grep -o '"id":[0-9]*' | grep -m1 -o '[0-9]*')
for FILE in \
syncwarden_${{ env.VERSION }}_amd64.deb \
syncwarden_${{ env.VERSION }}_linux_amd64.tar.gz \
syncwarden_${{ env.VERSION }}_windows_amd64.zip; do
curl -s -X POST "${API}/releases/${RELEASE_ID}/assets?name=${FILE}" \
-H "Authorization: token ${TOKEN}" \
-F "attachment=@${FILE}"
done

19
CHANGELOG.md Normal file
View File

@@ -0,0 +1,19 @@
# Changelog
## v0.1.0
Initial release.
- System tray icon with 5 sync states (idle, syncing, paused, error, disconnected)
- Syncthing REST API integration with auto-discovered API key
- Long-polling event listener for real-time status updates
- Transfer rate monitoring (download/upload speed)
- Dynamic tooltip with status, device count, transfer rates, and last sync time
- Full context menu: status info, folders, recent files, conflicts
- Pause/Resume, Rescan All, Restart Syncthing actions
- Settings submenu with toggle checkboxes
- Embedded admin panel via webview (separate binary)
- OS notifications for sync events (configurable per-event)
- Syncthing process management with auto-restart
- Cross-platform installer with autostart configuration
- Supports Windows (Edge WebView2), Linux (WebKit2GTK), macOS (WebKit)

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 calic
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

66
README.md Normal file
View File

@@ -0,0 +1,66 @@
# SyncWarden
Lightweight system tray wrapper for [Syncthing](https://syncthing.net/). Cross-platform, native-feeling, ~10 MB.
## 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)
- **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
## 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 |
Separate binaries avoid main-thread conflicts between systray and webview.
## Installation
### From release
Download the latest release for your platform and run `syncwarden-setup`.
### From source
Requires Go 1.24+ and CGO (MinGW-w64 on Windows for the panel binary).
```bash
# Tray binary (pure Go on Windows)
go build -o syncwarden ./cmd/syncwarden
# Panel binary (requires CGO + C++ compiler)
CGO_ENABLED=1 go build -o syncwarden-panel ./cmd/panel
# Setup
go build -o syncwarden-setup ./cmd/setup
```
## Configuration
Config file location:
- **Windows**: `%LOCALAPPDATA%\syncwarden\config.json`
- **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.
## 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
## License
MIT

24
cmd/panel/main.go Normal file
View File

@@ -0,0 +1,24 @@
package main
import (
"flag"
"log"
"github.com/webview/webview_go"
)
func main() {
addr := flag.String("addr", "http://localhost:8384", "Syncthing address")
flag.Parse()
w := webview.New(false)
if w == nil {
log.Fatal("failed to create webview")
}
defer w.Destroy()
w.SetTitle("SyncWarden — Admin Panel")
w.SetSize(1024, 768, webview.HintNone)
w.Navigate(*addr)
w.Run()
}

337
cmd/setup/main.go Normal file
View File

@@ -0,0 +1,337 @@
package main
import (
"bufio"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"git.davoryn.de/calic/syncwarden/internal/config"
)
func main() {
uninstall := flag.Bool("uninstall", false, "Remove installed files and autostart entry")
flag.Parse()
if *uninstall {
runUninstall()
} else {
runInstall()
}
}
func installDir() string {
if runtime.GOOS == "windows" {
return filepath.Join(os.Getenv("LOCALAPPDATA"), "syncwarden")
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".local", "bin")
}
func binaryNames() []string {
if runtime.GOOS == "windows" {
return []string{"syncwarden.exe", "syncwarden-panel.exe"}
}
return []string{"syncwarden", "syncwarden-panel"}
}
func sourceDir() string {
exe, err := os.Executable()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: cannot determine executable path: %v\n", err)
os.Exit(1)
}
return filepath.Dir(exe)
}
func findSourceBinary(dir, name string) string {
p := filepath.Join(dir, name)
if _, err := os.Stat(p); err == nil {
return p
}
// Try without "syncwarden-" prefix for local builds
bare := strings.TrimPrefix(name, "syncwarden-")
if bare != name {
p = filepath.Join(dir, bare)
if _, err := os.Stat(p); err == nil {
return p
}
}
return ""
}
func askYesNo(prompt string) bool {
reader := bufio.NewReader(os.Stdin)
fmt.Printf("%s [Y/n] ", prompt)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
return answer == "" || answer == "y" || answer == "yes"
}
func runInstall() {
fmt.Println("SyncWarden Setup")
fmt.Println(strings.Repeat("=", 40))
fmt.Println()
src := sourceDir()
dst := installDir()
var errors []string
fmt.Printf("Install directory: %s\n\n", dst)
// Create install directory
if err := os.MkdirAll(dst, 0o755); err != nil {
msg := "Could not create install directory: " + err.Error()
fmt.Fprintln(os.Stderr, msg)
showMessage("SyncWarden — Setup", "Something went wrong.\n\n"+msg, true)
os.Exit(1)
}
// Copy binaries
fmt.Println("Installing binaries...")
binaries := binaryNames()
installed := 0
for _, name := range binaries {
srcPath := findSourceBinary(src, name)
dstPath := filepath.Join(dst, name)
if srcPath == "" {
fmt.Printf(" SKIP %s (not found in source directory)\n", name)
continue
}
err := copyFile(srcPath, dstPath)
if err != nil {
if runtime.GOOS == "windows" && isFileInUse(err) {
if isInteractive() {
fmt.Printf(" BUSY %s — please close the app and press Enter to retry: ", name)
bufio.NewReader(os.Stdin).ReadString('\n')
err = copyFile(srcPath, dstPath)
}
}
if err != nil {
errors = append(errors, fmt.Sprintf("%s: %v", name, err))
fmt.Fprintf(os.Stderr, " FAIL %s: %v\n", name, err)
continue
}
}
fmt.Printf(" OK %s\n", name)
installed++
}
fmt.Println()
if installed == 0 {
msg := "No binaries found next to setup. Make sure setup is in the same directory as the binaries."
fmt.Fprintln(os.Stderr, msg)
showMessage("SyncWarden — Setup", "Something went wrong.\n\n"+msg, true)
os.Exit(1)
}
// Autostart
enableAutostart := true
if isInteractive() {
enableAutostart = askYesNo("Enable autostart for SyncWarden?")
}
if enableAutostart {
if err := createAutostart(dst); err != nil {
errors = append(errors, "autostart: "+err.Error())
fmt.Fprintf(os.Stderr, " Warning: could not set up autostart: %v\n", err)
} else {
fmt.Println(" Autostart enabled.")
}
}
fmt.Println()
// Config directory
cfgDir := config.ConfigDir()
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not create config directory: %v\n", err)
}
// Launch on Windows
trayName := "syncwarden"
if runtime.GOOS == "windows" {
trayName = "syncwarden.exe"
}
trayPath := filepath.Join(dst, trayName)
if runtime.GOOS == "windows" {
exec.Command(trayPath).Start()
}
// Summary
fmt.Println(strings.Repeat("-", 40))
fmt.Println("Installation complete!")
fmt.Println()
fmt.Printf(" Binaries: %s\n", dst)
fmt.Printf(" Config: %s\n", cfgDir)
fmt.Println()
fmt.Printf("Launch SyncWarden: %s\n", trayPath)
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)
} else {
showMessage("SyncWarden — Setup",
"Setup complete!\n\nSyncWarden is running.\n\nIt will auto-discover your Syncthing API key.", false)
}
}
func runUninstall() {
fmt.Println("SyncWarden Uninstall")
fmt.Println(strings.Repeat("=", 40))
fmt.Println()
dst := installDir()
var errors []string
// Stop running instance
fmt.Println("Stopping SyncWarden...")
if runtime.GOOS == "windows" {
exec.Command("taskkill", "/F", "/IM", "syncwarden.exe").Run()
exec.Command("taskkill", "/F", "/IM", "syncwarden-panel.exe").Run()
} else {
exec.Command("pkill", "-f", "syncwarden").Run()
}
fmt.Println()
// Remove binaries
fmt.Println("Removing binaries...")
for _, name := range binaryNames() {
p := filepath.Join(dst, name)
if _, err := os.Stat(p); os.IsNotExist(err) {
continue
}
if err := os.Remove(p); err != nil {
if runtime.GOOS == "windows" && isFileInUse(err) {
if isInteractive() {
fmt.Printf(" BUSY %s — please close the app and press Enter to retry: ", name)
bufio.NewReader(os.Stdin).ReadString('\n')
err = os.Remove(p)
}
}
if err != nil {
errors = append(errors, fmt.Sprintf("%s: %v", name, err))
fmt.Fprintf(os.Stderr, " FAIL %s: %v\n", name, err)
continue
}
}
fmt.Printf(" Removed %s\n", name)
}
fmt.Println()
// Remove autostart
fmt.Println("Removing autostart entry...")
if err := removeAutostart(); err != nil {
errors = append(errors, "autostart: "+err.Error())
fmt.Fprintf(os.Stderr, " Warning: %v\n", err)
} else {
fmt.Println(" Autostart removed.")
}
fmt.Println()
fmt.Println("Uninstall complete.")
if len(errors) > 0 {
showMessage("SyncWarden — Uninstall",
"Uninstall completed with warnings:\n\n"+strings.Join(errors, "\n"), true)
} else {
showMessage("SyncWarden — Uninstall",
"Uninstall complete.\n\nAll binaries and autostart entry have been removed.", false)
}
}
func isInteractive() bool {
if runtime.GOOS != "windows" {
return true
}
fi, err := os.Stdin.Stat()
if err != nil {
return false
}
return fi.Mode()&os.ModeCharDevice != 0
}
func copyFile(src, dst string) error {
data, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(dst, data, 0o755)
}
func isFileInUse(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "Access is denied") ||
strings.Contains(msg, "being used by another process")
}
func createAutostart(installDir string) error {
if runtime.GOOS == "windows" {
return createAutostartWindows(installDir)
}
return createAutostartLinux(installDir)
}
func createAutostartWindows(dir string) error {
startupDir := filepath.Join(os.Getenv("APPDATA"), "Microsoft", "Windows", "Start Menu", "Programs", "Startup")
lnkPath := filepath.Join(startupDir, "SyncWarden.lnk")
target := filepath.Join(dir, "syncwarden.exe")
script := fmt.Sprintf(
`$s = (New-Object -COM WScript.Shell).CreateShortcut('%s'); $s.TargetPath = '%s'; $s.WorkingDirectory = '%s'; $s.Save()`,
lnkPath, target, dir,
)
cmd := exec.Command("powershell", "-NoProfile", "-Command", script)
cmd.Stderr = os.Stderr
return cmd.Run()
}
func createAutostartLinux(dir string) error {
autostartDir := filepath.Join(os.Getenv("HOME"), ".config", "autostart")
if err := os.MkdirAll(autostartDir, 0o755); err != nil {
return err
}
desktopEntry := fmt.Sprintf(`[Desktop Entry]
Type=Application
Name=SyncWarden
Exec=%s
Terminal=false
X-GNOME-Autostart-enabled=true
`, filepath.Join(dir, "syncwarden"))
return os.WriteFile(filepath.Join(autostartDir, "syncwarden.desktop"), []byte(desktopEntry), 0o644)
}
func removeAutostart() error {
if runtime.GOOS == "windows" {
return removeAutostartWindows()
}
return removeAutostartLinux()
}
func removeAutostartWindows() error {
lnkPath := filepath.Join(os.Getenv("APPDATA"), "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "SyncWarden.lnk")
if _, err := os.Stat(lnkPath); os.IsNotExist(err) {
return nil
}
return os.Remove(lnkPath)
}
func removeAutostartLinux() error {
desktopPath := filepath.Join(os.Getenv("HOME"), ".config", "autostart", "syncwarden.desktop")
if _, err := os.Stat(desktopPath); os.IsNotExist(err) {
return nil
}
return os.Remove(desktopPath)
}

7
cmd/setup/ui_other.go Normal file
View File

@@ -0,0 +1,7 @@
//go:build !windows
package main
// showMessage is a no-op on non-Windows platforms.
// The CLI output serves as the user interface.
func showMessage(title, text string, isError bool) {}

28
cmd/setup/ui_windows.go Normal file
View File

@@ -0,0 +1,28 @@
//go:build windows
package main
import (
"syscall"
"unsafe"
)
var (
user32 = syscall.NewLazyDLL("user32.dll")
messageBoxW = user32.NewProc("MessageBoxW")
)
func showMessage(title, text string, isError bool) {
flags := uint32(0x00000040) // MB_ICONINFORMATION
if isError {
flags = 0x00000010 // MB_ICONERROR
}
titlePtr, _ := syscall.UTF16PtrFromString(title)
textPtr, _ := syscall.UTF16PtrFromString(text)
messageBoxW.Call(0,
uintptr(unsafe.Pointer(textPtr)),
uintptr(unsafe.Pointer(titlePtr)),
uintptr(flags),
)
}

24
go.mod
View File

@@ -1,3 +1,25 @@
module git.davoryn.de/calic/syncwarden
go 1.24
go 1.24.0
require (
github.com/energye/systray v1.0.3
github.com/fogleman/gg v1.3.0
github.com/gen2brain/beeep v0.11.2
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6
)
require (
git.sr.ht/~jackmordaunt/go-toast v1.1.2 // indirect
github.com/esiqveland/notify v0.13.3 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/jackmordaunt/icns/v3 v3.0.1 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/sergeymakinen/go-bmp v1.0.0 // indirect
github.com/sergeymakinen/go-ico v1.0.0-beta.0 // indirect
github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect
golang.org/x/image v0.36.0 // indirect
golang.org/x/sys v0.30.0 // indirect
)

49
go.sum Normal file
View File

@@ -0,0 +1,49 @@
git.sr.ht/~jackmordaunt/go-toast v1.1.2 h1:/yrfI55LRt1M7H1vkaw+NaH1+L1CDxrqDltwm5euVuE=
git.sr.ht/~jackmordaunt/go-toast v1.1.2/go.mod h1:jA4OqHKTQ4AFBdwrSnwnskUIIS3HYzlJSgdzCKqfavo=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/energye/systray v1.0.3 h1:XnyjJCeRU5z00bpNOic2fGTKz/7yHZMZjWiGIVXDS+4=
github.com/energye/systray v1.0.3/go.mod h1:HelKhC3PXwv3ryDxbuQqV+7kAxAYNzE5cfdrerGOZTc=
github.com/esiqveland/notify v0.13.3 h1:QCMw6o1n+6rl+oLUfg8P1IIDSFsDEb2WlXvVvIJbI/o=
github.com/esiqveland/notify v0.13.3/go.mod h1:hesw/IRYTO0x99u1JPweAl4+5mwXJibQVUcP0Iu5ORE=
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/gen2brain/beeep v0.11.2 h1:+KfiKQBbQCuhfJFPANZuJ+oxsSKAYNe88hIpJuyKWDA=
github.com/gen2brain/beeep v0.11.2/go.mod h1:jQVvuwnLuwOcdctHn/uyh8horSBNJ8uGb9Cn2W4tvoc=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/jackmordaunt/icns/v3 v3.0.1 h1:xxot6aNuGrU+lNgxz5I5H0qSeCjNKp8uTXB1j8D4S3o=
github.com/jackmordaunt/icns/v3 v3.0.1/go.mod h1:5sHL59nqTd2ynTnowxB/MDQFhKNqkK8X687uKNygaSQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sergeymakinen/go-bmp v1.0.0 h1:SdGTzp9WvCV0A1V0mBeaS7kQAwNLdVJbmHlqNWq0R+M=
github.com/sergeymakinen/go-bmp v1.0.0/go.mod h1:/mxlAQZRLxSvJFNIEGGLBE/m40f3ZnUifpgVDlcUIEY=
github.com/sergeymakinen/go-ico v1.0.0-beta.0 h1:m5qKH7uPKLdrygMWxbamVn+tl2HfiA3K6MFJw4GfZvQ=
github.com/sergeymakinen/go-ico v1.0.0-beta.0/go.mod h1:wQ47mTczswBO5F0NoDt7O0IXgnV4Xy3ojrroMQzyhUk=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk=
github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o=
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6 h1:VQpB2SpK88C6B5lPHTuSZKb2Qee1QWwiFlC5CKY4AW0=
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6/go.mod h1:yE65LFCeWf4kyWD5re+h4XNvOHJEXOCOuJZ4v8l5sgk=
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,35 @@
package monitor
import "sync"
// ConflictTracker counts sync conflicts across all folders.
type ConflictTracker struct {
mu sync.Mutex
count int
}
// NewConflictTracker creates a new conflict tracker.
func NewConflictTracker() *ConflictTracker {
return &ConflictTracker{}
}
// SetCount updates the total conflict count.
func (ct *ConflictTracker) SetCount(n int) {
ct.mu.Lock()
defer ct.mu.Unlock()
ct.count = n
}
// Increment adds to the conflict count.
func (ct *ConflictTracker) Increment() {
ct.mu.Lock()
defer ct.mu.Unlock()
ct.count++
}
// Count returns the current conflict count.
func (ct *ConflictTracker) Count() int {
ct.mu.Lock()
defer ct.mu.Unlock()
return ct.count
}

View File

@@ -0,0 +1,60 @@
package monitor
import (
"sync"
st "git.davoryn.de/calic/syncwarden/internal/syncthing"
)
// FolderTracker tracks per-folder status.
type FolderTracker struct {
mu sync.Mutex
folders []FolderInfo
}
// NewFolderTracker creates a new folder tracker.
func NewFolderTracker() *FolderTracker {
return &FolderTracker{}
}
// UpdateFromConfig sets the folder list from Syncthing config.
func (ft *FolderTracker) UpdateFromConfig(folders []st.FolderConfig) {
ft.mu.Lock()
defer ft.mu.Unlock()
ft.folders = make([]FolderInfo, len(folders))
for i, f := range folders {
ft.folders[i] = FolderInfo{
ID: f.ID,
Label: f.Label,
Path: f.Path,
State: "unknown",
}
if f.Label == "" {
ft.folders[i].Label = f.ID
}
}
}
// UpdateStatus updates the state for a specific folder.
func (ft *FolderTracker) UpdateStatus(folderID, state string) {
ft.mu.Lock()
defer ft.mu.Unlock()
for i := range ft.folders {
if ft.folders[i].ID == folderID {
ft.folders[i].State = state
return
}
}
}
// Folders returns a snapshot of all folder info.
func (ft *FolderTracker) Folders() []FolderInfo {
ft.mu.Lock()
defer ft.mu.Unlock()
out := make([]FolderInfo, len(ft.folders))
copy(out, ft.folders)
return out
}

384
internal/monitor/monitor.go Normal file
View File

@@ -0,0 +1,384 @@
package monitor
import (
"encoding/json"
"log"
"path/filepath"
"sync"
"time"
"git.davoryn.de/calic/syncwarden/internal/config"
"git.davoryn.de/calic/syncwarden/internal/icons"
st "git.davoryn.de/calic/syncwarden/internal/syncthing"
)
// StatusCallback is called whenever the aggregate status changes.
type StatusCallback func(AggregateStatus)
// EventCallback is called for notable events (for notifications).
type EventCallback func(eventType string, data map[string]string)
// Monitor coordinates all tracking and polling.
type Monitor struct {
mu sync.Mutex
client *st.Client
cfg config.Config
callback StatusCallback
eventCb EventCallback
speed *SpeedTracker
folders *FolderTracker
recent *RecentTracker
conflicts *ConflictTracker
events *st.EventListener
stopCh chan struct{}
wg sync.WaitGroup
connected bool
paused bool
lastSync time.Time
devicesTotal int
devicesOnline int
pendingDevs int
}
// New creates a new Monitor.
func New(client *st.Client, cfg config.Config, callback StatusCallback, eventCb EventCallback) *Monitor {
return &Monitor{
client: client,
cfg: cfg,
callback: callback,
eventCb: eventCb,
speed: NewSpeedTracker(),
folders: NewFolderTracker(),
recent: NewRecentTracker(),
conflicts: NewConflictTracker(),
stopCh: make(chan struct{}),
}
}
// Start begins all monitoring goroutines.
func (m *Monitor) Start() {
// Start event listener
m.events = st.NewEventListener(m.client, m.cfg.LastEventID, m.onEvents)
m.events.Start()
// Start periodic poller
m.wg.Add(1)
go m.pollLoop()
// Initial full refresh
go m.fullRefresh()
}
// Stop halts all monitoring.
func (m *Monitor) Stop() {
close(m.stopCh)
if m.events != nil {
m.events.Stop()
}
m.wg.Wait()
// Persist last event ID
m.mu.Lock()
m.cfg.LastEventID = m.events.LastEventID()
m.mu.Unlock()
_ = config.Save(m.cfg)
}
func (m *Monitor) pollLoop() {
defer m.wg.Done()
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for {
select {
case <-m.stopCh:
return
case <-ticker.C:
m.pollConnections()
m.pollHealth()
}
}
}
func (m *Monitor) pollHealth() {
_, err := m.client.Health()
m.mu.Lock()
wasConnected := m.connected
m.connected = err == nil
m.mu.Unlock()
if !wasConnected && err == nil {
// Reconnected — do a full refresh
go m.fullRefresh()
}
if wasConnected && err != nil {
m.emitStatus()
}
}
func (m *Monitor) pollConnections() {
conns, err := m.client.SystemConnections()
if err != nil {
return
}
m.speed.Update(conns.Total.InBytesTotal, conns.Total.OutBytesTotal)
online := 0
for _, c := range conns.Connections {
if c.Connected {
online++
}
}
m.mu.Lock()
m.devicesOnline = online
m.mu.Unlock()
m.emitStatus()
}
func (m *Monitor) fullRefresh() {
// Get config (folders + devices)
cfg, err := m.client.Config()
if err != nil {
log.Printf("config fetch error: %v", err)
return
}
m.folders.UpdateFromConfig(cfg.Folders)
m.mu.Lock()
m.devicesTotal = len(cfg.Devices)
m.connected = true
m.mu.Unlock()
// Query each folder's status
allPaused := true
for _, f := range cfg.Folders {
if !f.Paused {
allPaused = false
}
status, err := m.client.FolderStatus(f.ID)
if err != nil {
continue
}
m.folders.UpdateStatus(f.ID, status.State)
}
m.mu.Lock()
m.paused = allPaused && len(cfg.Folders) > 0
m.mu.Unlock()
// Check pending devices
pending, err := m.client.PendingDevices()
if err == nil {
m.mu.Lock()
m.pendingDevs = len(pending)
m.mu.Unlock()
}
m.emitStatus()
}
func (m *Monitor) onEvents(events []st.Event) {
for _, ev := range events {
switch ev.Type {
case "StateChanged":
m.handleStateChanged(ev)
case "ItemFinished":
m.handleItemFinished(ev)
case "DeviceConnected":
m.handleDeviceEvent(ev, true)
case "DeviceDisconnected":
m.handleDeviceEvent(ev, false)
case "PendingDevicesChanged":
go m.refreshPendingDevices()
case "FolderCompletion", "FolderSummary":
// Trigger a folder status refresh
go m.refreshFolderStatuses()
}
}
m.emitStatus()
}
func (m *Monitor) handleStateChanged(ev st.Event) {
data, ok := ev.Data.(map[string]any)
if !ok {
return
}
folder, _ := data["folder"].(string)
from, _ := data["from"].(string)
to, _ := data["to"].(string)
if folder != "" && to != "" {
m.folders.UpdateStatus(folder, to)
// Notify when folder finishes syncing
if from == "syncing" && to == "idle" {
m.emitEvent("SyncComplete", map[string]string{
"folder": folderLabel(m.folders.Folders(), folder),
})
}
}
}
func (m *Monitor) handleItemFinished(ev st.Event) {
data, ok := ev.Data.(map[string]any)
if !ok {
return
}
item, _ := data["item"].(string)
folder, _ := data["folder"].(string)
errStr, _ := data["error"].(string)
action, _ := data["action"].(string)
if errStr != "" {
if isConflict(errStr) {
m.conflicts.Increment()
m.emitEvent("Conflict", map[string]string{
"file": filepath.Base(item),
"folder": folderLabel(m.folders.Folders(), folder),
})
}
return
}
if item != "" && folder != "" && action != "delete" {
m.recent.Add(filepath.Base(item), folderLabel(m.folders.Folders(), folder))
m.mu.Lock()
m.lastSync = time.Now()
m.mu.Unlock()
}
}
func (m *Monitor) handleDeviceEvent(ev st.Event, connected bool) {
// Re-count online devices
go func() {
conns, err := m.client.SystemConnections()
if err != nil {
return
}
online := 0
for _, c := range conns.Connections {
if c.Connected {
online++
}
}
m.mu.Lock()
m.devicesOnline = online
m.mu.Unlock()
m.emitStatus()
}()
data, ok := ev.Data.(map[string]any)
if !ok {
return
}
deviceName, _ := data["name"].(string)
if deviceName == "" {
deviceName, _ = data["id"].(string)
}
if connected {
m.emitEvent("DeviceConnected", map[string]string{"name": deviceName})
} else {
m.emitEvent("DeviceDisconnected", map[string]string{"name": deviceName})
}
}
func (m *Monitor) refreshPendingDevices() {
pending, err := m.client.PendingDevices()
if err != nil {
return
}
m.mu.Lock()
oldCount := m.pendingDevs
m.pendingDevs = len(pending)
m.mu.Unlock()
if len(pending) > oldCount {
for _, dev := range pending {
name := dev.Name
if name == "" {
name = dev.DeviceID[:8]
}
m.emitEvent("NewDevice", map[string]string{"name": name})
}
}
m.emitStatus()
}
func (m *Monitor) emitEvent(eventType string, data map[string]string) {
if m.eventCb != nil {
m.eventCb(eventType, data)
}
}
func (m *Monitor) refreshFolderStatuses() {
for _, f := range m.folders.Folders() {
status, err := m.client.FolderStatus(f.ID)
if err != nil {
continue
}
m.folders.UpdateStatus(f.ID, status.State)
}
m.emitStatus()
}
func (m *Monitor) emitStatus() {
down, up := m.speed.Rates()
folders := m.folders.Folders()
m.mu.Lock()
status := AggregateStatus{
DevicesTotal: m.devicesTotal,
DevicesOnline: m.devicesOnline,
DownRate: down,
UpRate: up,
LastSync: m.lastSync,
Paused: m.paused,
RecentFiles: m.recent.Files(),
ConflictCount: m.conflicts.Count(),
Folders: folders,
PendingDevices: m.pendingDevs,
}
if !m.connected {
status.State = icons.StateDisconnected
} else {
status.State = stateFromFolders(folders, m.paused)
}
m.mu.Unlock()
m.callback(status)
}
// EventData returns the event data field as a typed map.
func EventData(ev st.Event) map[string]any {
if data, ok := ev.Data.(map[string]any); ok {
return data
}
// Try JSON re-marshal for nested types
b, err := json.Marshal(ev.Data)
if err != nil {
return nil
}
var data map[string]any
if json.Unmarshal(b, &data) == nil {
return data
}
return nil
}
func isConflict(errStr string) bool {
return errStr == "conflict" || errStr == "conflicting changes"
}
func folderLabel(folders []FolderInfo, id string) string {
for _, f := range folders {
if f.ID == id {
return f.Label
}
}
return id
}

View File

@@ -0,0 +1,49 @@
package monitor
import (
"sync"
"time"
)
const maxRecentFiles = 10
// RecentTracker maintains a ring buffer of recently synced files.
type RecentTracker struct {
mu sync.Mutex
files []RecentFile
}
// NewRecentTracker creates a new recent files tracker.
func NewRecentTracker() *RecentTracker {
return &RecentTracker{
files: make([]RecentFile, 0, maxRecentFiles),
}
}
// Add records a newly synced file.
func (rt *RecentTracker) Add(name, folder string) {
rt.mu.Lock()
defer rt.mu.Unlock()
rf := RecentFile{
Name: name,
Folder: folder,
Timestamp: time.Now(),
}
// Prepend (most recent first)
rt.files = append([]RecentFile{rf}, rt.files...)
if len(rt.files) > maxRecentFiles {
rt.files = rt.files[:maxRecentFiles]
}
}
// Files returns a snapshot of recent files (most recent first).
func (rt *RecentTracker) Files() []RecentFile {
rt.mu.Lock()
defer rt.mu.Unlock()
out := make([]RecentFile, len(rt.files))
copy(out, rt.files)
return out
}

52
internal/monitor/speed.go Normal file
View File

@@ -0,0 +1,52 @@
package monitor
import (
"sync"
"time"
)
// SpeedTracker calculates transfer rates by diffing byte counters.
type SpeedTracker struct {
mu sync.Mutex
lastIn int64
lastOut int64
lastTime time.Time
downRate float64
upRate float64
}
// NewSpeedTracker creates a new speed tracker.
func NewSpeedTracker() *SpeedTracker {
return &SpeedTracker{}
}
// Update records new byte counters and calculates rates.
func (s *SpeedTracker) Update(inBytes, outBytes int64) {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
if !s.lastTime.IsZero() {
dt := now.Sub(s.lastTime).Seconds()
if dt > 0 {
s.downRate = float64(inBytes-s.lastIn) / dt
s.upRate = float64(outBytes-s.lastOut) / dt
if s.downRate < 0 {
s.downRate = 0
}
if s.upRate < 0 {
s.upRate = 0
}
}
}
s.lastIn = inBytes
s.lastOut = outBytes
s.lastTime = now
}
// Rates returns the current download and upload rates in bytes/sec.
func (s *SpeedTracker) Rates() (down, up float64) {
s.mu.Lock()
defer s.mu.Unlock()
return s.downRate, s.upRate
}

64
internal/monitor/state.go Normal file
View File

@@ -0,0 +1,64 @@
package monitor
import (
"time"
"git.davoryn.de/calic/syncwarden/internal/icons"
)
// AggregateStatus holds the full aggregated status for the tray.
type AggregateStatus struct {
State icons.State
DevicesTotal int
DevicesOnline int
DownRate float64 // bytes/sec
UpRate float64 // bytes/sec
LastSync time.Time
Paused bool
RecentFiles []RecentFile
ConflictCount int
Folders []FolderInfo
PendingDevices int
}
// FolderInfo holds per-folder info for the menu.
type FolderInfo struct {
ID string
Label string
Path string
State string // "idle", "syncing", "scanning", "error", etc.
}
// RecentFile records a recently synced file.
type RecentFile struct {
Name string
Folder string
Timestamp time.Time
}
// stateFromFolders determines the aggregate icon state from folder states.
func stateFromFolders(folders []FolderInfo, paused bool) icons.State {
if paused {
return icons.StatePaused
}
hasError := false
hasSyncing := false
for _, f := range folders {
switch f.State {
case "error":
hasError = true
case "syncing", "sync-preparing", "scanning", "sync-waiting", "scan-waiting":
hasSyncing = true
}
}
if hasError {
return icons.StateError
}
if hasSyncing {
return icons.StateSyncing
}
return icons.StateIdle
}

41
internal/notify/notify.go Normal file
View File

@@ -0,0 +1,41 @@
package notify
import (
"log"
"github.com/gen2brain/beeep"
)
const appName = "SyncWarden"
// Send sends an OS notification.
func Send(title, message string) {
if err := beeep.Notify(title, message, ""); err != nil {
log.Printf("notification error: %v", err)
}
}
// SyncComplete notifies that a folder finished syncing.
func SyncComplete(folder string) {
Send(appName, folder+" finished syncing")
}
// DeviceConnected notifies that a device connected.
func DeviceConnected(name string) {
Send(appName, name+" connected")
}
// DeviceDisconnected notifies that a device disconnected.
func DeviceDisconnected(name string) {
Send(appName, name+" disconnected")
}
// NewDevice notifies about a new device request.
func NewDevice(name string) {
Send(appName, "New device wants to connect: "+name)
}
// Conflict notifies about a sync conflict.
func Conflict(file, folder string) {
Send(appName, "Conflict: "+file+" in "+folder)
}

View File

@@ -0,0 +1,83 @@
package syncthing
import (
"log"
"sync"
"time"
)
// EventHandler is called for each batch of new events.
type EventHandler func(events []Event)
// EventListener long-polls the Syncthing event API.
type EventListener struct {
client *Client
handler EventHandler
sinceID int
stopCh chan struct{}
wg sync.WaitGroup
}
// NewEventListener creates a new event listener.
func NewEventListener(client *Client, sinceID int, handler EventHandler) *EventListener {
return &EventListener{
client: client,
handler: handler,
sinceID: sinceID,
stopCh: make(chan struct{}),
}
}
// Start begins long-polling in a goroutine.
func (el *EventListener) Start() {
el.wg.Add(1)
go el.loop()
}
// Stop stops the event listener and waits for it to finish.
func (el *EventListener) Stop() {
close(el.stopCh)
el.wg.Wait()
}
// LastEventID returns the last processed event ID.
func (el *EventListener) LastEventID() int {
return el.sinceID
}
func (el *EventListener) loop() {
defer el.wg.Done()
backoff := time.Second
maxBackoff := 30 * time.Second
for {
select {
case <-el.stopCh:
return
default:
}
events, err := el.client.Events(el.sinceID, 30)
if err != nil {
log.Printf("event poll error: %v", err)
select {
case <-el.stopCh:
return
case <-time.After(backoff):
}
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
continue
}
backoff = time.Second
if len(events) > 0 {
el.sinceID = events[len(events)-1].ID
el.handler(events)
}
}
}

View File

@@ -0,0 +1,149 @@
package syncthing
import (
"log"
"os/exec"
"sync"
"time"
)
// Process manages the Syncthing child process lifecycle.
type Process struct {
mu sync.Mutex
cmd *exec.Cmd
stopCh chan struct{}
wg sync.WaitGroup
running bool
restarts int
}
// NewProcess creates a new Syncthing process manager.
func NewProcess() *Process {
return &Process{
stopCh: make(chan struct{}),
}
}
// Start launches Syncthing and monitors it (restarts on crash).
func (p *Process) Start() error {
p.mu.Lock()
if p.running {
p.mu.Unlock()
return nil
}
p.running = true
p.mu.Unlock()
p.wg.Add(1)
go p.supervise()
return nil
}
// Stop terminates the Syncthing process.
func (p *Process) Stop() {
p.mu.Lock()
if !p.running {
p.mu.Unlock()
return
}
p.running = false
p.mu.Unlock()
close(p.stopCh)
p.mu.Lock()
cmd := p.cmd
p.mu.Unlock()
if cmd != nil && cmd.Process != nil {
_ = cmd.Process.Kill()
}
p.wg.Wait()
}
// IsRunning returns whether the process is currently running.
func (p *Process) IsRunning() bool {
p.mu.Lock()
defer p.mu.Unlock()
return p.running && p.cmd != nil
}
func (p *Process) supervise() {
defer p.wg.Done()
backoff := 2 * time.Second
maxBackoff := 30 * time.Second
for {
select {
case <-p.stopCh:
return
default:
}
cmd := createSyncthingCmd()
if cmd == nil {
log.Println("syncthing binary not found in PATH")
select {
case <-p.stopCh:
return
case <-time.After(maxBackoff):
}
continue
}
p.mu.Lock()
p.cmd = cmd
p.mu.Unlock()
log.Printf("starting syncthing (pid will follow)")
err := cmd.Start()
if err != nil {
log.Printf("failed to start syncthing: %v", err)
select {
case <-p.stopCh:
return
case <-time.After(backoff):
}
continue
}
log.Printf("syncthing started (pid %d)", cmd.Process.Pid)
err = cmd.Wait()
p.mu.Lock()
p.cmd = nil
running := p.running
p.restarts++
p.mu.Unlock()
if !running {
return
}
if err != nil {
log.Printf("syncthing exited: %v (will restart in %v)", err, backoff)
} else {
log.Printf("syncthing exited normally (will restart in %v)", backoff)
}
select {
case <-p.stopCh:
return
case <-time.After(backoff):
}
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
}
}
func findSyncthing() string {
path, err := exec.LookPath("syncthing")
if err != nil {
return ""
}
return path
}

View File

@@ -0,0 +1,14 @@
//go:build !windows
package syncthing
import "os/exec"
// createSyncthingCmd creates the Syncthing command on Unix.
func createSyncthingCmd() *exec.Cmd {
path := findSyncthing()
if path == "" {
return nil
}
return exec.Command(path, "-no-browser", "-no-restart")
}

View File

@@ -0,0 +1,21 @@
//go:build windows
package syncthing
import (
"os/exec"
"syscall"
)
// createSyncthingCmd creates the Syncthing command with CREATE_NO_WINDOW flag.
func createSyncthingCmd() *exec.Cmd {
path := findSyncthing()
if path == "" {
return nil
}
cmd := exec.Command(path, "-no-browser", "-no-restart")
cmd.SysProcAttr = &syscall.SysProcAttr{
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
return cmd
}

20
internal/tray/icons.go Normal file
View File

@@ -0,0 +1,20 @@
package tray
import (
"log"
"github.com/energye/systray"
"git.davoryn.de/calic/syncwarden/internal/icons"
"git.davoryn.de/calic/syncwarden/internal/monitor"
)
// updateIcon renders and sets the tray icon based on status.
func updateIcon(status monitor.AggregateStatus) {
iconData, err := icons.Render(status.State)
if err != nil {
log.Printf("icon render error: %v", err)
return
}
systray.SetIcon(iconData)
}

View File

@@ -1,33 +1,225 @@
package tray
import (
"fmt"
"log"
"github.com/energye/systray"
"git.davoryn.de/calic/syncwarden/internal/config"
st "git.davoryn.de/calic/syncwarden/internal/syncthing"
)
// buildMenu creates the initial context menu (Phase 1: minimal).
// buildMenu creates the full context menu.
func (a *App) buildMenu() {
mStatus := systray.AddMenuItem("Status: Connecting...", "")
mStatus.Disable()
// Status info section
a.statusItem = systray.AddMenuItem("Status: Connecting...", "")
a.statusItem.Disable()
a.rateItem = systray.AddMenuItem("↓ 0 B/s ↑ 0 B/s", "")
a.rateItem.Disable()
a.devicesItem = systray.AddMenuItem("Devices: —", "")
a.devicesItem.Disable()
a.lastSyncItem = systray.AddMenuItem("Last sync: —", "")
a.lastSyncItem.Disable()
systray.AddSeparator()
// Open Admin Panel
mOpenPanel := systray.AddMenuItem("Open Admin Panel", "Open Syncthing admin panel")
mOpenPanel.Click(func() {
a.openPanel()
})
// Pause/Resume toggle
a.pauseItem = systray.AddMenuItem("Pause All", "Pause/Resume all syncing")
a.pauseItem.Click(func() {
a.togglePause()
})
systray.AddSeparator()
// Folders submenu
a.foldersMenu = systray.AddMenuItem("Folders", "")
emptyFolder := a.foldersMenu.AddSubMenuItem("(loading...)", "")
emptyFolder.Disable()
a.folderItems = []*systray.MenuItem{emptyFolder}
// Recent Files submenu
a.recentMenu = systray.AddMenuItem("Recent Files", "")
emptyRecent := a.recentMenu.AddSubMenuItem("(none)", "")
emptyRecent.Disable()
a.recentItems = []*systray.MenuItem{emptyRecent}
// Conflicts
a.conflictItem = systray.AddMenuItem("Conflicts (0)", "Open conflicts page")
a.conflictItem.Hide()
a.conflictItem.Click(func() {
a.openConflicts()
})
systray.AddSeparator()
// Actions
mRescan := systray.AddMenuItem("Rescan All", "Trigger rescan of all folders")
mRescan.Click(func() {
go func() {
if err := a.client.RescanAll(); err != nil {
log.Printf("rescan error: %v", err)
}
}()
})
mRestart := systray.AddMenuItem("Restart Syncthing", "Restart the Syncthing process")
mRestart.Click(func() {
go func() {
if err := a.client.Restart(); err != nil {
log.Printf("restart error: %v", err)
}
}()
})
systray.AddSeparator()
// Settings submenu
mSettings := systray.AddMenuItem("Settings", "")
chkNotify := mSettings.AddSubMenuItem("Notifications", "Enable/disable notifications")
if a.cfg.EnableNotifications {
chkNotify.Check()
}
chkNotify.Click(func() {
a.toggleSetting(&a.cfg.EnableNotifications, chkNotify)
})
chkRecent := mSettings.AddSubMenuItem("Show Recent Files", "Show recently synced files")
if a.cfg.EnableRecentFiles {
chkRecent.Check()
}
chkRecent.Click(func() {
a.toggleSetting(&a.cfg.EnableRecentFiles, chkRecent)
})
chkConflict := mSettings.AddSubMenuItem("Conflict Alerts", "Alert on sync conflicts")
if a.cfg.EnableConflictAlerts {
chkConflict.Check()
}
chkConflict.Click(func() {
a.toggleSetting(&a.cfg.EnableConflictAlerts, chkConflict)
})
chkRate := mSettings.AddSubMenuItem("Transfer Rate in Tooltip", "Show transfer rate in tooltip")
if a.cfg.EnableTransferRate {
chkRate.Check()
}
chkRate.Click(func() {
a.toggleSetting(&a.cfg.EnableTransferRate, chkRate)
})
chkAutoStart := mSettings.AddSubMenuItem("Auto-start Syncthing", "Start Syncthing when SyncWarden starts")
if a.cfg.AutoStartSyncthing {
chkAutoStart.Check()
}
chkAutoStart.Click(func() {
a.toggleSetting(&a.cfg.AutoStartSyncthing, chkAutoStart)
})
chkLogin := mSettings.AddSubMenuItem("Start on Login", "Start SyncWarden at system login")
if a.cfg.StartOnLogin {
chkLogin.Check()
}
chkLogin.Click(func() {
a.toggleSetting(&a.cfg.StartOnLogin, chkLogin)
})
// API key info
apiKeyDisplay := "API Key: (none)"
if len(a.cfg.SyncthingAPIKey) > 8 {
apiKeyDisplay = fmt.Sprintf("API Key: %s...%s", a.cfg.SyncthingAPIKey[:4], a.cfg.SyncthingAPIKey[len(a.cfg.SyncthingAPIKey)-4:])
} else if a.cfg.SyncthingAPIKey != "" {
apiKeyDisplay = fmt.Sprintf("API Key: %s", a.cfg.SyncthingAPIKey)
}
mAPIKey := mSettings.AddSubMenuItem(apiKeyDisplay, "")
mAPIKey.Disable()
mRediscover := mSettings.AddSubMenuItem("Re-discover API Key", "Re-read API key from Syncthing config")
mRediscover.Click(func() {
go a.rediscoverAPIKey()
})
mAddr := mSettings.AddSubMenuItem(fmt.Sprintf("Address: %s", a.cfg.SyncthingAddress), "")
mAddr.Disable()
// About
mAbout := systray.AddMenuItem(fmt.Sprintf("About (v%s)", version), "")
mAbout.Disable()
systray.AddSeparator()
// Quit
mQuit := systray.AddMenuItem("Quit", "Exit SyncWarden")
mQuit.Click(func() {
log.Println("Quit clicked")
systray.Quit()
})
// Store reference for updates
a.mu.Lock()
a.statusItem = mStatus
a.mu.Unlock()
}
func (a *App) togglePause() {
a.mu.Lock()
paused := a.lastStatus.Paused
a.mu.Unlock()
go func() {
var err error
if paused {
err = a.client.ResumeAll()
} else {
err = a.client.PauseAll()
}
if err != nil {
log.Printf("pause/resume error: %v", err)
}
}()
}
func (a *App) openConflicts() {
// Open the Syncthing conflicts page in the panel
launchPanel(a.cfg.BaseURL())
}
func (a *App) toggleSetting(field *bool, item *systray.MenuItem) {
a.mu.Lock()
*field = !*field
val := *field
cfg := a.cfg
a.mu.Unlock()
if val {
item.Check()
} else {
item.Uncheck()
}
_ = config.Save(cfg)
}
func (a *App) rediscoverAPIKey() {
key, err := st.DiscoverAPIKey()
if err != nil {
log.Printf("API key discovery failed: %v", err)
return
}
if key == "" {
log.Println("no API key found in Syncthing config")
return
}
a.mu.Lock()
a.cfg.SyncthingAPIKey = key
a.mu.Unlock()
a.client.SetAPIKey(key)
_ = config.Save(a.cfg)
log.Printf("re-discovered API key")
}

23
internal/tray/open.go Normal file
View File

@@ -0,0 +1,23 @@
package tray
import (
"log"
"os/exec"
"runtime"
)
// openFileManager opens the given path in the OS file manager.
func openFileManager(path string) {
var cmd *exec.Cmd
switch runtime.GOOS {
case "windows":
cmd = exec.Command("explorer", path)
case "darwin":
cmd = exec.Command("open", path)
default:
cmd = exec.Command("xdg-open", path)
}
if err := cmd.Start(); err != nil {
log.Printf("failed to open file manager: %v", err)
}
}

87
internal/tray/tooltip.go Normal file
View File

@@ -0,0 +1,87 @@
package tray
import (
"fmt"
"time"
"git.davoryn.de/calic/syncwarden/internal/icons"
"git.davoryn.de/calic/syncwarden/internal/monitor"
)
// formatTooltip generates the tooltip text from aggregate status.
func formatTooltip(s monitor.AggregateStatus, showRate bool) string {
stateStr := stateLabel(s.State)
tip := fmt.Sprintf("SyncWarden: %s", stateStr)
// Devices
tip += fmt.Sprintf(" | %d/%d devices", s.DevicesOnline, s.DevicesTotal)
// Transfer rate
if showRate && (s.DownRate > 0 || s.UpRate > 0) {
tip += fmt.Sprintf(" | ↓%s ↑%s", formatBytes(s.DownRate), formatBytes(s.UpRate))
}
// Last sync
if !s.LastSync.IsZero() {
tip += fmt.Sprintf(" | Last sync: %s", formatTimeAgo(s.LastSync))
}
return tip
}
func stateLabel(s icons.State) string {
switch s {
case icons.StateIdle:
return "Idle"
case icons.StateSyncing:
return "Syncing"
case icons.StatePaused:
return "Paused"
case icons.StateError:
return "Error"
case icons.StateDisconnected:
return "Disconnected"
default:
return "Unknown"
}
}
func formatBytes(bps float64) string {
if bps < 1024 {
return fmt.Sprintf("%.0f B/s", bps)
}
if bps < 1024*1024 {
return fmt.Sprintf("%.1f KB/s", bps/1024)
}
if bps < 1024*1024*1024 {
return fmt.Sprintf("%.1f MB/s", bps/(1024*1024))
}
return fmt.Sprintf("%.1f GB/s", bps/(1024*1024*1024))
}
func formatTimeAgo(t time.Time) string {
d := time.Since(t)
if d < time.Minute {
return "just now"
}
if d < time.Hour {
m := int(d.Minutes())
if m == 1 {
return "1 min ago"
}
return fmt.Sprintf("%d min ago", m)
}
if d < 24*time.Hour {
h := int(d.Hours())
if h == 1 {
return "1 hour ago"
}
return fmt.Sprintf("%d hours ago", h)
}
days := int(d.Hours()) / 24
if days == 1 {
return "1 day ago"
}
return fmt.Sprintf("%d days ago", days)
}

View File

@@ -1,6 +1,7 @@
package tray
import (
"fmt"
"log"
"sync"
@@ -8,16 +9,34 @@ import (
"git.davoryn.de/calic/syncwarden/internal/config"
"git.davoryn.de/calic/syncwarden/internal/icons"
stClient "git.davoryn.de/calic/syncwarden/internal/syncthing"
"git.davoryn.de/calic/syncwarden/internal/monitor"
"git.davoryn.de/calic/syncwarden/internal/notify"
st "git.davoryn.de/calic/syncwarden/internal/syncthing"
)
const version = "0.1.0"
// App manages the tray icon and Syncthing monitoring.
type App struct {
mu sync.Mutex
cfg config.Config
client *stClient.Client
state icons.State
statusItem *systray.MenuItem
mu sync.Mutex
cfg config.Config
client *st.Client
monitor *monitor.Monitor
process *st.Process
state icons.State
lastStatus monitor.AggregateStatus
// Menu items that need dynamic updates
statusItem *systray.MenuItem
rateItem *systray.MenuItem
devicesItem *systray.MenuItem
lastSyncItem *systray.MenuItem
pauseItem *systray.MenuItem
foldersMenu *systray.MenuItem
recentMenu *systray.MenuItem
conflictItem *systray.MenuItem
folderItems []*systray.MenuItem
recentItems []*systray.MenuItem
}
// Run starts the tray application (blocking).
@@ -31,14 +50,14 @@ func (a *App) onReady() {
// Auto-discover API key if not configured
if a.cfg.SyncthingAPIKey == "" {
if key, err := stClient.DiscoverAPIKey(); err == nil && key != "" {
if key, err := st.DiscoverAPIKey(); err == nil && key != "" {
a.cfg.SyncthingAPIKey = key
_ = config.Save(a.cfg)
log.Printf("auto-discovered Syncthing API key")
}
}
a.client = stClient.NewClient(a.cfg.BaseURL(), a.cfg.SyncthingAPIKey)
a.client = st.NewClient(a.cfg.BaseURL(), a.cfg.SyncthingAPIKey)
// Set initial icon
a.setState(icons.StateDisconnected)
@@ -55,14 +74,29 @@ func (a *App) onReady() {
a.openPanel()
})
// Build menu
// Auto-start Syncthing if configured
if a.cfg.AutoStartSyncthing {
a.process = st.NewProcess()
if err := a.process.Start(); err != nil {
log.Printf("failed to auto-start syncthing: %v", err)
}
}
// Build full menu
a.buildMenu()
// Check connection
go a.initialCheck()
// Start monitor
a.monitor = monitor.New(a.client, a.cfg, a.onStatusUpdate, a.onEvent)
a.monitor.Start()
}
func (a *App) onExit() {
if a.monitor != nil {
a.monitor.Stop()
}
if a.process != nil {
a.process.Stop()
}
log.Println("SyncWarden exiting")
}
@@ -79,29 +113,141 @@ func (a *App) setState(s icons.State) {
systray.SetIcon(iconData)
}
func (a *App) initialCheck() {
_, err := a.client.Health()
if err != nil {
log.Printf("Syncthing not reachable: %v", err)
a.setState(icons.StateDisconnected)
systray.SetTooltip("SyncWarden: Syncthing not reachable")
a.mu.Lock()
if a.statusItem != nil {
a.statusItem.SetTitle("Status: Disconnected")
}
a.mu.Unlock()
func (a *App) onStatusUpdate(status monitor.AggregateStatus) {
a.mu.Lock()
a.lastStatus = status
a.mu.Unlock()
// Update icon
updateIcon(status)
// Update tooltip
systray.SetTooltip(formatTooltip(status, a.cfg.EnableTransferRate))
// Update menu items
a.updateMenuItems(status)
}
func (a *App) onEvent(eventType string, data map[string]string) {
a.mu.Lock()
cfg := a.cfg
a.mu.Unlock()
if !cfg.EnableNotifications {
return
}
log.Println("Syncthing is reachable")
a.setState(icons.StateIdle)
systray.SetTooltip("SyncWarden: Idle")
a.mu.Lock()
if a.statusItem != nil {
a.statusItem.SetTitle("Status: Idle")
switch eventType {
case "SyncComplete":
if cfg.NotifySyncComplete {
notify.SyncComplete(data["folder"])
}
case "DeviceConnected":
if cfg.NotifyDeviceConnect {
notify.DeviceConnected(data["name"])
}
case "DeviceDisconnected":
if cfg.NotifyDeviceDisconnect {
notify.DeviceDisconnected(data["name"])
}
case "NewDevice":
if cfg.NotifyNewDevice {
notify.NewDevice(data["name"])
}
case "Conflict":
if cfg.NotifyConflict && cfg.EnableConflictAlerts {
notify.Conflict(data["file"], data["folder"])
}
}
a.mu.Unlock()
}
func (a *App) openPanel() {
launchPanel(a.cfg.BaseURL())
}
func (a *App) updateMenuItems(s monitor.AggregateStatus) {
a.mu.Lock()
defer a.mu.Unlock()
if a.statusItem != nil {
a.statusItem.SetTitle(fmt.Sprintf("Status: %s", stateLabel(s.State)))
}
if a.rateItem != nil {
if s.DownRate > 0 || s.UpRate > 0 {
a.rateItem.SetTitle(fmt.Sprintf("↓ %s ↑ %s", formatBytes(s.DownRate), formatBytes(s.UpRate)))
a.rateItem.Show()
} else {
a.rateItem.SetTitle("↓ 0 B/s ↑ 0 B/s")
}
}
if a.devicesItem != nil {
a.devicesItem.SetTitle(fmt.Sprintf("Devices: %d/%d connected", s.DevicesOnline, s.DevicesTotal))
}
if a.lastSyncItem != nil {
if s.LastSync.IsZero() {
a.lastSyncItem.SetTitle("Last sync: —")
} else {
a.lastSyncItem.SetTitle(fmt.Sprintf("Last sync: %s", formatTimeAgo(s.LastSync)))
}
}
if a.pauseItem != nil {
if s.Paused {
a.pauseItem.SetTitle("Resume All")
} else {
a.pauseItem.SetTitle("Pause All")
}
}
// Update folders submenu
if a.foldersMenu != nil {
// Hide old items
for _, item := range a.folderItems {
item.Hide()
}
a.folderItems = a.folderItems[:0]
for _, f := range s.Folders {
label := f.Label
if f.State != "" && f.State != "idle" {
label = fmt.Sprintf("%s (%s)", f.Label, f.State)
}
item := a.foldersMenu.AddSubMenuItem(label, f.Path)
path := f.Path
item.Click(func() {
openFileManager(path)
})
a.folderItems = append(a.folderItems, item)
}
}
// Update recent files submenu
if a.recentMenu != nil && a.cfg.EnableRecentFiles {
for _, item := range a.recentItems {
item.Hide()
}
a.recentItems = a.recentItems[:0]
if len(s.RecentFiles) == 0 {
item := a.recentMenu.AddSubMenuItem("(none)", "")
item.Disable()
a.recentItems = append(a.recentItems, item)
} else {
for _, rf := range s.RecentFiles {
label := fmt.Sprintf("%s (%s)", rf.Name, rf.Folder)
item := a.recentMenu.AddSubMenuItem(label, "")
item.Disable()
a.recentItems = append(a.recentItems, item)
}
}
}
// Update conflicts
if a.conflictItem != nil {
if s.ConflictCount > 0 {
a.conflictItem.SetTitle(fmt.Sprintf("Conflicts (%d)", s.ConflictCount))
a.conflictItem.Show()
} else {
a.conflictItem.Hide()
}
}
}

View File

@@ -0,0 +1,3 @@
#!/bin/sh
echo "SyncWarden installed. It will start automatically on next login."
echo "To start now: syncwarden &"

View File

@@ -0,0 +1,2 @@
#!/bin/sh
pkill -f syncwarden || true

View File

@@ -0,0 +1,8 @@
[Desktop Entry]
Type=Application
Name=SyncWarden
Comment=System tray wrapper for Syncthing
Exec=/usr/bin/syncwarden
Terminal=false
Categories=Network;FileTransfer;
X-GNOME-Autostart-enabled=true

27
packaging/nfpm.yaml Normal file
View File

@@ -0,0 +1,27 @@
name: syncwarden
arch: amd64
platform: linux
version: "${VERSION}"
maintainer: "calic"
description: "Lightweight system tray wrapper for Syncthing"
vendor: "calic"
homepage: "https://git.davoryn.de/calic/syncwarden"
license: MIT
contents:
- src: ./syncwarden
dst: /usr/bin/syncwarden
- src: ./syncwarden-panel
dst: /usr/bin/syncwarden-panel
- src: ./packaging/linux/syncwarden.desktop
dst: /etc/xdg/autostart/syncwarden.desktop
type: config
scripts:
postinstall: ./packaging/linux/postinstall.sh
preremove: ./packaging/linux/preremove.sh
depends:
- syncthing
recommends:
- libwebkit2gtk-4.0-37