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:
85
.gitea/workflows/release.yml
Normal file
85
.gitea/workflows/release.yml
Normal 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
19
CHANGELOG.md
Normal 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
21
LICENSE
Normal 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
66
README.md
Normal 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
24
cmd/panel/main.go
Normal 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
337
cmd/setup/main.go
Normal 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
7
cmd/setup/ui_other.go
Normal 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
28
cmd/setup/ui_windows.go
Normal 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
24
go.mod
@@ -1,3 +1,25 @@
|
|||||||
module git.davoryn.de/calic/syncwarden
|
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
49
go.sum
Normal 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=
|
||||||
35
internal/monitor/conflicts.go
Normal file
35
internal/monitor/conflicts.go
Normal 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
|
||||||
|
}
|
||||||
60
internal/monitor/folders.go
Normal file
60
internal/monitor/folders.go
Normal 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
384
internal/monitor/monitor.go
Normal 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
|
||||||
|
}
|
||||||
49
internal/monitor/recent.go
Normal file
49
internal/monitor/recent.go
Normal 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
52
internal/monitor/speed.go
Normal 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
64
internal/monitor/state.go
Normal 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
41
internal/notify/notify.go
Normal 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)
|
||||||
|
}
|
||||||
83
internal/syncthing/events.go
Normal file
83
internal/syncthing/events.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
149
internal/syncthing/process.go
Normal file
149
internal/syncthing/process.go
Normal 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
|
||||||
|
}
|
||||||
14
internal/syncthing/process_other.go
Normal file
14
internal/syncthing/process_other.go
Normal 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")
|
||||||
|
}
|
||||||
21
internal/syncthing/process_windows.go
Normal file
21
internal/syncthing/process_windows.go
Normal 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
20
internal/tray/icons.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -1,33 +1,225 @@
|
|||||||
package tray
|
package tray
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
"github.com/energye/systray"
|
"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() {
|
func (a *App) buildMenu() {
|
||||||
mStatus := systray.AddMenuItem("Status: Connecting...", "")
|
// Status info section
|
||||||
mStatus.Disable()
|
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()
|
systray.AddSeparator()
|
||||||
|
|
||||||
|
// Open Admin Panel
|
||||||
mOpenPanel := systray.AddMenuItem("Open Admin Panel", "Open Syncthing admin panel")
|
mOpenPanel := systray.AddMenuItem("Open Admin Panel", "Open Syncthing admin panel")
|
||||||
mOpenPanel.Click(func() {
|
mOpenPanel.Click(func() {
|
||||||
a.openPanel()
|
a.openPanel()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Pause/Resume toggle
|
||||||
|
a.pauseItem = systray.AddMenuItem("Pause All", "Pause/Resume all syncing")
|
||||||
|
a.pauseItem.Click(func() {
|
||||||
|
a.togglePause()
|
||||||
|
})
|
||||||
|
|
||||||
systray.AddSeparator()
|
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 := systray.AddMenuItem("Quit", "Exit SyncWarden")
|
||||||
mQuit.Click(func() {
|
mQuit.Click(func() {
|
||||||
log.Println("Quit clicked")
|
log.Println("Quit clicked")
|
||||||
systray.Quit()
|
systray.Quit()
|
||||||
})
|
})
|
||||||
|
}
|
||||||
// Store reference for updates
|
|
||||||
a.mu.Lock()
|
func (a *App) togglePause() {
|
||||||
a.statusItem = mStatus
|
a.mu.Lock()
|
||||||
a.mu.Unlock()
|
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
23
internal/tray/open.go
Normal 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
87
internal/tray/tooltip.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package tray
|
package tray
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
@@ -8,16 +9,34 @@ import (
|
|||||||
|
|
||||||
"git.davoryn.de/calic/syncwarden/internal/config"
|
"git.davoryn.de/calic/syncwarden/internal/config"
|
||||||
"git.davoryn.de/calic/syncwarden/internal/icons"
|
"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.
|
// App manages the tray icon and Syncthing monitoring.
|
||||||
type App struct {
|
type App struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
client *stClient.Client
|
client *st.Client
|
||||||
|
monitor *monitor.Monitor
|
||||||
|
process *st.Process
|
||||||
state icons.State
|
state icons.State
|
||||||
|
lastStatus monitor.AggregateStatus
|
||||||
|
|
||||||
|
// Menu items that need dynamic updates
|
||||||
statusItem *systray.MenuItem
|
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).
|
// Run starts the tray application (blocking).
|
||||||
@@ -31,14 +50,14 @@ func (a *App) onReady() {
|
|||||||
|
|
||||||
// Auto-discover API key if not configured
|
// Auto-discover API key if not configured
|
||||||
if a.cfg.SyncthingAPIKey == "" {
|
if a.cfg.SyncthingAPIKey == "" {
|
||||||
if key, err := stClient.DiscoverAPIKey(); err == nil && key != "" {
|
if key, err := st.DiscoverAPIKey(); err == nil && key != "" {
|
||||||
a.cfg.SyncthingAPIKey = key
|
a.cfg.SyncthingAPIKey = key
|
||||||
_ = config.Save(a.cfg)
|
_ = config.Save(a.cfg)
|
||||||
log.Printf("auto-discovered Syncthing API key")
|
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
|
// Set initial icon
|
||||||
a.setState(icons.StateDisconnected)
|
a.setState(icons.StateDisconnected)
|
||||||
@@ -55,14 +74,29 @@ func (a *App) onReady() {
|
|||||||
a.openPanel()
|
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()
|
a.buildMenu()
|
||||||
|
|
||||||
// Check connection
|
// Start monitor
|
||||||
go a.initialCheck()
|
a.monitor = monitor.New(a.client, a.cfg, a.onStatusUpdate, a.onEvent)
|
||||||
|
a.monitor.Start()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) onExit() {
|
func (a *App) onExit() {
|
||||||
|
if a.monitor != nil {
|
||||||
|
a.monitor.Stop()
|
||||||
|
}
|
||||||
|
if a.process != nil {
|
||||||
|
a.process.Stop()
|
||||||
|
}
|
||||||
log.Println("SyncWarden exiting")
|
log.Println("SyncWarden exiting")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,29 +113,141 @@ func (a *App) setState(s icons.State) {
|
|||||||
systray.SetIcon(iconData)
|
systray.SetIcon(iconData)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) initialCheck() {
|
func (a *App) onStatusUpdate(status monitor.AggregateStatus) {
|
||||||
_, 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()
|
a.mu.Lock()
|
||||||
if a.statusItem != nil {
|
a.lastStatus = status
|
||||||
a.statusItem.SetTitle("Status: Disconnected")
|
|
||||||
}
|
|
||||||
a.mu.Unlock()
|
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
|
return
|
||||||
}
|
}
|
||||||
log.Println("Syncthing is reachable")
|
|
||||||
a.setState(icons.StateIdle)
|
switch eventType {
|
||||||
systray.SetTooltip("SyncWarden: Idle")
|
case "SyncComplete":
|
||||||
a.mu.Lock()
|
if cfg.NotifySyncComplete {
|
||||||
if a.statusItem != nil {
|
notify.SyncComplete(data["folder"])
|
||||||
a.statusItem.SetTitle("Status: Idle")
|
}
|
||||||
|
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() {
|
func (a *App) openPanel() {
|
||||||
launchPanel(a.cfg.BaseURL())
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
3
packaging/linux/postinstall.sh
Normal file
3
packaging/linux/postinstall.sh
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
echo "SyncWarden installed. It will start automatically on next login."
|
||||||
|
echo "To start now: syncwarden &"
|
||||||
2
packaging/linux/preremove.sh
Normal file
2
packaging/linux/preremove.sh
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
pkill -f syncwarden || true
|
||||||
8
packaging/linux/syncwarden.desktop
Normal file
8
packaging/linux/syncwarden.desktop
Normal 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
27
packaging/nfpm.yaml
Normal 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
|
||||||
Reference in New Issue
Block a user