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

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

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

View File

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

View File

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

View File

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

View File

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