From 34a1a94502129885bcc840e065ec83f107037edf Mon Sep 17 00:00:00 2001 From: Axel Meyer Date: Tue, 3 Mar 2026 21:16:28 +0100 Subject: [PATCH] Implement SyncWarden v0.1.0 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 --- .gitea/workflows/release.yml | 85 ++++++ CHANGELOG.md | 19 ++ LICENSE | 21 ++ README.md | 66 +++++ cmd/panel/main.go | 24 ++ cmd/setup/main.go | 337 ++++++++++++++++++++++ cmd/setup/ui_other.go | 7 + cmd/setup/ui_windows.go | 28 ++ go.mod | 24 +- go.sum | 49 ++++ internal/monitor/conflicts.go | 35 +++ internal/monitor/folders.go | 60 ++++ internal/monitor/monitor.go | 384 ++++++++++++++++++++++++++ internal/monitor/recent.go | 49 ++++ internal/monitor/speed.go | 52 ++++ internal/monitor/state.go | 64 +++++ internal/notify/notify.go | 41 +++ internal/syncthing/events.go | 83 ++++++ internal/syncthing/process.go | 149 ++++++++++ internal/syncthing/process_other.go | 14 + internal/syncthing/process_windows.go | 21 ++ internal/tray/icons.go | 20 ++ internal/tray/menu.go | 208 +++++++++++++- internal/tray/open.go | 23 ++ internal/tray/tooltip.go | 87 ++++++ internal/tray/tray.go | 204 ++++++++++++-- packaging/linux/postinstall.sh | 3 + packaging/linux/preremove.sh | 2 + packaging/linux/syncwarden.desktop | 8 + packaging/nfpm.yaml | 27 ++ 30 files changed, 2156 insertions(+), 38 deletions(-) create mode 100644 .gitea/workflows/release.yml create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cmd/panel/main.go create mode 100644 cmd/setup/main.go create mode 100644 cmd/setup/ui_other.go create mode 100644 cmd/setup/ui_windows.go create mode 100644 go.sum create mode 100644 internal/monitor/conflicts.go create mode 100644 internal/monitor/folders.go create mode 100644 internal/monitor/monitor.go create mode 100644 internal/monitor/recent.go create mode 100644 internal/monitor/speed.go create mode 100644 internal/monitor/state.go create mode 100644 internal/notify/notify.go create mode 100644 internal/syncthing/events.go create mode 100644 internal/syncthing/process.go create mode 100644 internal/syncthing/process_other.go create mode 100644 internal/syncthing/process_windows.go create mode 100644 internal/tray/icons.go create mode 100644 internal/tray/open.go create mode 100644 internal/tray/tooltip.go create mode 100644 packaging/linux/postinstall.sh create mode 100644 packaging/linux/preremove.sh create mode 100644 packaging/linux/syncwarden.desktop create mode 100644 packaging/nfpm.yaml diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..1e36737 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bc997cd --- /dev/null +++ b/CHANGELOG.md @@ -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) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4b24d25 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7c62a63 --- /dev/null +++ b/README.md @@ -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 diff --git a/cmd/panel/main.go b/cmd/panel/main.go new file mode 100644 index 0000000..ef774cf --- /dev/null +++ b/cmd/panel/main.go @@ -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() +} diff --git a/cmd/setup/main.go b/cmd/setup/main.go new file mode 100644 index 0000000..cd401a4 --- /dev/null +++ b/cmd/setup/main.go @@ -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) +} diff --git a/cmd/setup/ui_other.go b/cmd/setup/ui_other.go new file mode 100644 index 0000000..1ee548b --- /dev/null +++ b/cmd/setup/ui_other.go @@ -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) {} diff --git a/cmd/setup/ui_windows.go b/cmd/setup/ui_windows.go new file mode 100644 index 0000000..3b71868 --- /dev/null +++ b/cmd/setup/ui_windows.go @@ -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), + ) +} diff --git a/go.mod b/go.mod index 757d2a9..623d29a 100644 --- a/go.mod +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c7e6006 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/monitor/conflicts.go b/internal/monitor/conflicts.go new file mode 100644 index 0000000..8e63f33 --- /dev/null +++ b/internal/monitor/conflicts.go @@ -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 +} diff --git a/internal/monitor/folders.go b/internal/monitor/folders.go new file mode 100644 index 0000000..a10ee56 --- /dev/null +++ b/internal/monitor/folders.go @@ -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 +} diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go new file mode 100644 index 0000000..7799a51 --- /dev/null +++ b/internal/monitor/monitor.go @@ -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 +} diff --git a/internal/monitor/recent.go b/internal/monitor/recent.go new file mode 100644 index 0000000..18f2455 --- /dev/null +++ b/internal/monitor/recent.go @@ -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 +} diff --git a/internal/monitor/speed.go b/internal/monitor/speed.go new file mode 100644 index 0000000..0eb6f9a --- /dev/null +++ b/internal/monitor/speed.go @@ -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 +} diff --git a/internal/monitor/state.go b/internal/monitor/state.go new file mode 100644 index 0000000..8fb5d51 --- /dev/null +++ b/internal/monitor/state.go @@ -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 +} diff --git a/internal/notify/notify.go b/internal/notify/notify.go new file mode 100644 index 0000000..a64cf5f --- /dev/null +++ b/internal/notify/notify.go @@ -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) +} diff --git a/internal/syncthing/events.go b/internal/syncthing/events.go new file mode 100644 index 0000000..7d55567 --- /dev/null +++ b/internal/syncthing/events.go @@ -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) + } + } +} diff --git a/internal/syncthing/process.go b/internal/syncthing/process.go new file mode 100644 index 0000000..2b6dace --- /dev/null +++ b/internal/syncthing/process.go @@ -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 +} diff --git a/internal/syncthing/process_other.go b/internal/syncthing/process_other.go new file mode 100644 index 0000000..57f5f9d --- /dev/null +++ b/internal/syncthing/process_other.go @@ -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") +} diff --git a/internal/syncthing/process_windows.go b/internal/syncthing/process_windows.go new file mode 100644 index 0000000..d745a44 --- /dev/null +++ b/internal/syncthing/process_windows.go @@ -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 +} diff --git a/internal/tray/icons.go b/internal/tray/icons.go new file mode 100644 index 0000000..fb8c69c --- /dev/null +++ b/internal/tray/icons.go @@ -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) +} diff --git a/internal/tray/menu.go b/internal/tray/menu.go index c74c58b..4c1afaf 100644 --- a/internal/tray/menu.go +++ b/internal/tray/menu.go @@ -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") } diff --git a/internal/tray/open.go b/internal/tray/open.go new file mode 100644 index 0000000..d5c0adb --- /dev/null +++ b/internal/tray/open.go @@ -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) + } +} diff --git a/internal/tray/tooltip.go b/internal/tray/tooltip.go new file mode 100644 index 0000000..ce41cc6 --- /dev/null +++ b/internal/tray/tooltip.go @@ -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) +} diff --git a/internal/tray/tray.go b/internal/tray/tray.go index 9865b65..1c54610 100644 --- a/internal/tray/tray.go +++ b/internal/tray/tray.go @@ -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() + } + } +} diff --git a/packaging/linux/postinstall.sh b/packaging/linux/postinstall.sh new file mode 100644 index 0000000..508def6 --- /dev/null +++ b/packaging/linux/postinstall.sh @@ -0,0 +1,3 @@ +#!/bin/sh +echo "SyncWarden installed. It will start automatically on next login." +echo "To start now: syncwarden &" diff --git a/packaging/linux/preremove.sh b/packaging/linux/preremove.sh new file mode 100644 index 0000000..2ec4bd1 --- /dev/null +++ b/packaging/linux/preremove.sh @@ -0,0 +1,2 @@ +#!/bin/sh +pkill -f syncwarden || true diff --git a/packaging/linux/syncwarden.desktop b/packaging/linux/syncwarden.desktop new file mode 100644 index 0000000..b5989f8 --- /dev/null +++ b/packaging/linux/syncwarden.desktop @@ -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 diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml new file mode 100644 index 0000000..07e1b66 --- /dev/null +++ b/packaging/nfpm.yaml @@ -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