v0.3.0: fix HTTP client leak, add tests and CI pipeline
Reuse a single long-poll HTTP client instead of creating one per Events() call (~every 30s). Make TLS skip-verify configurable via syncthing_insecure_tls. Log previously swallowed config errors. Add unit tests for all monitor trackers, config, and state logic. Add CI workflow (vet, golangci-lint, govulncheck, go test -race). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
51
.gitea/workflows/ci.yml
Normal file
51
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['*']
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Go
|
||||||
|
run: |
|
||||||
|
curl -sSL https://go.dev/dl/go1.24.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
|
||||||
|
|
||||||
|
- name: go vet
|
||||||
|
run: go vet ./internal/... ./cmd/syncwarden/... ./cmd/setup/...
|
||||||
|
|
||||||
|
- name: Install golangci-lint
|
||||||
|
run: |
|
||||||
|
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.1.6
|
||||||
|
echo "$(go env GOPATH)/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
- name: golangci-lint
|
||||||
|
run: golangci-lint run ./internal/... ./cmd/syncwarden/... ./cmd/setup/...
|
||||||
|
|
||||||
|
- name: Install govulncheck
|
||||||
|
run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||||
|
|
||||||
|
- name: govulncheck
|
||||||
|
run: govulncheck ./internal/... ./cmd/syncwarden/... ./cmd/setup/...
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Go
|
||||||
|
run: |
|
||||||
|
curl -sSL https://go.dev/dl/go1.24.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
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: go test -race -count=1 ./internal/...
|
||||||
13
.golangci.yml
Normal file
13
.golangci.yml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
linters:
|
||||||
|
enable:
|
||||||
|
- errcheck
|
||||||
|
- gosec
|
||||||
|
- govet
|
||||||
|
- ineffassign
|
||||||
|
- staticcheck
|
||||||
|
- unused
|
||||||
|
|
||||||
|
issues:
|
||||||
|
exclude-dirs:
|
||||||
|
- cmd/panel
|
||||||
|
- cmd/icongen
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.3.0
|
||||||
|
|
||||||
|
- Fix memory leak: reuse HTTP client for long-poll events instead of creating a new one every ~30s
|
||||||
|
- Add `syncthing_insecure_tls` config field (default: true) — TLS skip-verify is now opt-out
|
||||||
|
- Log previously swallowed errors: config parse, config save (tray, menu, monitor)
|
||||||
|
- Add unit tests for all monitor trackers, config round-trip, state aggregation, and detect
|
||||||
|
- Add CI pipeline: go vet, golangci-lint, govulncheck, `go test -race`
|
||||||
|
- Add `.golangci.yml` with errcheck, gosec, govet, ineffassign, staticcheck, unused
|
||||||
|
|
||||||
## v0.2.0
|
## v0.2.0
|
||||||
|
|
||||||
- Detect missing Syncthing installation (checks PATH and config file)
|
- Detect missing Syncthing installation (checks PATH and config file)
|
||||||
|
|||||||
@@ -12,8 +12,10 @@
|
|||||||
<img src="https://img.shields.io/badge/Platform-Windows%20|%20Linux%20|%20macOS-informational" alt="Platform: Windows | Linux | macOS" />
|
<img src="https://img.shields.io/badge/Platform-Windows%20|%20Linux%20|%20macOS-informational" alt="Platform: Windows | Linux | macOS" />
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="MIT License" />
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="MIT License" />
|
||||||
<br />
|
<br />
|
||||||
<a href="https://git.davoryn.de/calic/syncwarden/releases"><img src="https://img.shields.io/badge/release-v0.2.0-blue?logo=gitea&logoColor=white" alt="Latest Release" /></a>
|
<a href="https://git.davoryn.de/calic/syncwarden/releases"><img src="https://img.shields.io/badge/release-v0.3.0-blue?logo=gitea&logoColor=white" alt="Latest Release" /></a>
|
||||||
<a href="https://git.davoryn.de/calic/syncwarden/actions"><img src="https://img.shields.io/badge/CI-Gitea%20Actions-success?logo=gitea&logoColor=white" alt="CI" /></a>
|
<a href="https://git.davoryn.de/calic/syncwarden/actions"><img src="https://img.shields.io/badge/tests-passing-brightgreen?logo=gitea&logoColor=white" alt="Tests" /></a>
|
||||||
|
<a href="https://git.davoryn.de/calic/syncwarden/actions"><img src="https://img.shields.io/badge/golangci--lint-passing-brightgreen?logo=go&logoColor=white" alt="golangci-lint" /></a>
|
||||||
|
<a href="https://git.davoryn.de/calic/syncwarden/actions"><img src="https://img.shields.io/badge/govulncheck-clean-brightgreen?logo=go&logoColor=white" alt="govulncheck" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
@@ -9,9 +10,10 @@ import (
|
|||||||
// Config holds SyncWarden configuration.
|
// Config holds SyncWarden configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// Connection
|
// Connection
|
||||||
SyncthingAddress string `json:"syncthing_address"`
|
SyncthingAddress string `json:"syncthing_address"`
|
||||||
SyncthingAPIKey string `json:"syncthing_api_key"`
|
SyncthingAPIKey string `json:"syncthing_api_key"`
|
||||||
SyncthingUseTLS bool `json:"syncthing_use_tls"`
|
SyncthingUseTLS bool `json:"syncthing_use_tls"`
|
||||||
|
SyncthingInsecureTLS bool `json:"syncthing_insecure_tls"`
|
||||||
|
|
||||||
// Feature toggles
|
// Feature toggles
|
||||||
EnableNotifications bool `json:"enable_notifications"`
|
EnableNotifications bool `json:"enable_notifications"`
|
||||||
@@ -36,6 +38,7 @@ var defaults = Config{
|
|||||||
SyncthingAddress: "localhost:8384",
|
SyncthingAddress: "localhost:8384",
|
||||||
SyncthingAPIKey: "",
|
SyncthingAPIKey: "",
|
||||||
SyncthingUseTLS: false,
|
SyncthingUseTLS: false,
|
||||||
|
SyncthingInsecureTLS: true,
|
||||||
EnableNotifications: true,
|
EnableNotifications: true,
|
||||||
EnableRecentFiles: true,
|
EnableRecentFiles: true,
|
||||||
EnableConflictAlerts: true,
|
EnableConflictAlerts: true,
|
||||||
@@ -65,7 +68,9 @@ func Load() Config {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal(data, &cfg)
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
log.Printf("config: parse error: %v", err)
|
||||||
|
}
|
||||||
cached = &cfg
|
cached = &cfg
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
@@ -75,7 +80,7 @@ func Save(cfg Config) error {
|
|||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
|
|
||||||
dir := ConfigDir()
|
dir := configDir()
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
90
internal/config/config_test.go
Normal file
90
internal/config/config_test.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func withTempDir(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
configDirOverride = dir
|
||||||
|
t.Cleanup(func() { configDirOverride = "" })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_Defaults(t *testing.T) {
|
||||||
|
withTempDir(t)
|
||||||
|
|
||||||
|
cfg := Load()
|
||||||
|
if cfg.SyncthingAddress != "localhost:8384" {
|
||||||
|
t.Errorf("expected default address, got %q", cfg.SyncthingAddress)
|
||||||
|
}
|
||||||
|
if !cfg.SyncthingInsecureTLS {
|
||||||
|
t.Error("SyncthingInsecureTLS should default to true")
|
||||||
|
}
|
||||||
|
if !cfg.EnableNotifications {
|
||||||
|
t.Error("EnableNotifications should default to true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveLoadRoundTrip(t *testing.T) {
|
||||||
|
withTempDir(t)
|
||||||
|
|
||||||
|
cfg := Load()
|
||||||
|
cfg.SyncthingAPIKey = "test-key-12345"
|
||||||
|
cfg.SyncthingAddress = "192.168.1.100:8384"
|
||||||
|
cfg.EnableRecentFiles = false
|
||||||
|
|
||||||
|
if err := Save(cfg); err != nil {
|
||||||
|
t.Fatalf("Save failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear cache so Load reads from disk
|
||||||
|
mu.Lock()
|
||||||
|
cached = nil
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
loaded := Load()
|
||||||
|
if loaded.SyncthingAPIKey != "test-key-12345" {
|
||||||
|
t.Errorf("API key not round-tripped: got %q", loaded.SyncthingAPIKey)
|
||||||
|
}
|
||||||
|
if loaded.SyncthingAddress != "192.168.1.100:8384" {
|
||||||
|
t.Errorf("address not round-tripped: got %q", loaded.SyncthingAddress)
|
||||||
|
}
|
||||||
|
if loaded.EnableRecentFiles {
|
||||||
|
t.Error("EnableRecentFiles should be false after round-trip")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBaseURL_TLSToggle(t *testing.T) {
|
||||||
|
cfg := Config{
|
||||||
|
SyncthingAddress: "localhost:8384",
|
||||||
|
SyncthingUseTLS: false,
|
||||||
|
}
|
||||||
|
if cfg.BaseURL() != "http://localhost:8384" {
|
||||||
|
t.Errorf("expected http URL, got %q", cfg.BaseURL())
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.SyncthingUseTLS = true
|
||||||
|
if cfg.BaseURL() != "https://localhost:8384" {
|
||||||
|
t.Errorf("expected https URL, got %q", cfg.BaseURL())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_InvalidJSON(t *testing.T) {
|
||||||
|
withTempDir(t)
|
||||||
|
|
||||||
|
// Write invalid JSON to the config path
|
||||||
|
if err := os.MkdirAll(configDir(), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(ConfigPath(), []byte("{invalid"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should return defaults without panicking
|
||||||
|
cfg := Load()
|
||||||
|
if cfg.SyncthingAddress != "localhost:8384" {
|
||||||
|
t.Errorf("expected defaults on invalid JSON, got %q", cfg.SyncthingAddress)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,18 @@ package config
|
|||||||
|
|
||||||
import "path/filepath"
|
import "path/filepath"
|
||||||
|
|
||||||
|
// configDirOverride allows tests to redirect config I/O to a temp directory.
|
||||||
|
var configDirOverride string
|
||||||
|
|
||||||
|
// configDir returns the override dir if set, otherwise the platform default.
|
||||||
|
func configDir() string {
|
||||||
|
if configDirOverride != "" {
|
||||||
|
return configDirOverride
|
||||||
|
}
|
||||||
|
return ConfigDir()
|
||||||
|
}
|
||||||
|
|
||||||
// ConfigPath returns the path to config.json.
|
// ConfigPath returns the path to config.json.
|
||||||
func ConfigPath() string {
|
func ConfigPath() string {
|
||||||
return filepath.Join(ConfigDir(), "config.json")
|
return filepath.Join(configDir(), "config.json")
|
||||||
}
|
}
|
||||||
|
|||||||
28
internal/monitor/conflicts_test.go
Normal file
28
internal/monitor/conflicts_test.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package monitor
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestConflictTracker_IncrementAndCount(t *testing.T) {
|
||||||
|
ct := NewConflictTracker()
|
||||||
|
if ct.Count() != 0 {
|
||||||
|
t.Fatalf("initial count should be 0, got %d", ct.Count())
|
||||||
|
}
|
||||||
|
|
||||||
|
ct.Increment()
|
||||||
|
ct.Increment()
|
||||||
|
ct.Increment()
|
||||||
|
|
||||||
|
if ct.Count() != 3 {
|
||||||
|
t.Errorf("expected 3, got %d", ct.Count())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConflictTracker_SetCount(t *testing.T) {
|
||||||
|
ct := NewConflictTracker()
|
||||||
|
ct.Increment()
|
||||||
|
ct.SetCount(42)
|
||||||
|
|
||||||
|
if ct.Count() != 42 {
|
||||||
|
t.Errorf("expected 42 after SetCount, got %d", ct.Count())
|
||||||
|
}
|
||||||
|
}
|
||||||
67
internal/monitor/folders_test.go
Normal file
67
internal/monitor/folders_test.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package monitor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
st "git.davoryn.de/calic/syncwarden/internal/syncthing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFolderTracker_UpdateFromConfig(t *testing.T) {
|
||||||
|
ft := NewFolderTracker()
|
||||||
|
ft.UpdateFromConfig([]st.FolderConfig{
|
||||||
|
{ID: "docs", Label: "Documents", Path: "/home/user/docs"},
|
||||||
|
{ID: "photos", Label: "Photos", Path: "/home/user/photos"},
|
||||||
|
})
|
||||||
|
|
||||||
|
folders := ft.Folders()
|
||||||
|
if len(folders) != 2 {
|
||||||
|
t.Fatalf("expected 2 folders, got %d", len(folders))
|
||||||
|
}
|
||||||
|
if folders[0].Label != "Documents" {
|
||||||
|
t.Errorf("expected label 'Documents', got %q", folders[0].Label)
|
||||||
|
}
|
||||||
|
if folders[0].State != "unknown" {
|
||||||
|
t.Errorf("initial state should be 'unknown', got %q", folders[0].State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFolderTracker_EmptyLabelFallback(t *testing.T) {
|
||||||
|
ft := NewFolderTracker()
|
||||||
|
ft.UpdateFromConfig([]st.FolderConfig{
|
||||||
|
{ID: "my-folder", Label: "", Path: "/data"},
|
||||||
|
})
|
||||||
|
|
||||||
|
folders := ft.Folders()
|
||||||
|
if folders[0].Label != "my-folder" {
|
||||||
|
t.Errorf("empty label should fall back to ID, got %q", folders[0].Label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFolderTracker_UpdateStatus(t *testing.T) {
|
||||||
|
ft := NewFolderTracker()
|
||||||
|
ft.UpdateFromConfig([]st.FolderConfig{
|
||||||
|
{ID: "docs", Label: "Docs", Path: "/docs"},
|
||||||
|
})
|
||||||
|
|
||||||
|
ft.UpdateStatus("docs", "syncing")
|
||||||
|
|
||||||
|
folders := ft.Folders()
|
||||||
|
if folders[0].State != "syncing" {
|
||||||
|
t.Errorf("expected 'syncing', got %q", folders[0].State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFolderTracker_UpdateStatusNonexistent(t *testing.T) {
|
||||||
|
ft := NewFolderTracker()
|
||||||
|
ft.UpdateFromConfig([]st.FolderConfig{
|
||||||
|
{ID: "docs", Label: "Docs", Path: "/docs"},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Should not panic
|
||||||
|
ft.UpdateStatus("nonexistent", "idle")
|
||||||
|
|
||||||
|
folders := ft.Folders()
|
||||||
|
if folders[0].State != "unknown" {
|
||||||
|
t.Errorf("existing folder should be unchanged, got %q", folders[0].State)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,7 +82,9 @@ func (m *Monitor) Stop() {
|
|||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.cfg.LastEventID = m.events.LastEventID()
|
m.cfg.LastEventID = m.events.LastEventID()
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
_ = config.Save(m.cfg)
|
if err := config.Save(m.cfg); err != nil {
|
||||||
|
log.Printf("config save error: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Monitor) pollLoop() {
|
func (m *Monitor) pollLoop() {
|
||||||
|
|||||||
44
internal/monitor/recent_test.go
Normal file
44
internal/monitor/recent_test.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package monitor
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestRecentTracker_AddOrder(t *testing.T) {
|
||||||
|
rt := NewRecentTracker()
|
||||||
|
rt.Add("a.txt", "docs")
|
||||||
|
rt.Add("b.txt", "docs")
|
||||||
|
|
||||||
|
files := rt.Files()
|
||||||
|
if len(files) != 2 {
|
||||||
|
t.Fatalf("expected 2 files, got %d", len(files))
|
||||||
|
}
|
||||||
|
if files[0].Name != "b.txt" {
|
||||||
|
t.Errorf("most recent should be first, got %s", files[0].Name)
|
||||||
|
}
|
||||||
|
if files[1].Name != "a.txt" {
|
||||||
|
t.Errorf("oldest should be last, got %s", files[1].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecentTracker_RingBufferOverflow(t *testing.T) {
|
||||||
|
rt := NewRecentTracker()
|
||||||
|
for i := 0; i < 15; i++ {
|
||||||
|
rt.Add("file", "f")
|
||||||
|
}
|
||||||
|
files := rt.Files()
|
||||||
|
if len(files) != maxRecentFiles {
|
||||||
|
t.Errorf("expected %d files after overflow, got %d", maxRecentFiles, len(files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecentTracker_FilesCopy(t *testing.T) {
|
||||||
|
rt := NewRecentTracker()
|
||||||
|
rt.Add("a.txt", "docs")
|
||||||
|
|
||||||
|
files := rt.Files()
|
||||||
|
files[0].Name = "mutated"
|
||||||
|
|
||||||
|
original := rt.Files()
|
||||||
|
if original[0].Name != "a.txt" {
|
||||||
|
t.Error("Files() should return a copy, but internal state was mutated")
|
||||||
|
}
|
||||||
|
}
|
||||||
60
internal/monitor/speed_test.go
Normal file
60
internal/monitor/speed_test.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package monitor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSpeedTracker_FirstUpdateBaseline(t *testing.T) {
|
||||||
|
s := NewSpeedTracker()
|
||||||
|
s.Update(1000, 500)
|
||||||
|
|
||||||
|
down, up := s.Rates()
|
||||||
|
if down != 0 || up != 0 {
|
||||||
|
t.Errorf("first update should be baseline (0,0), got (%f,%f)", down, up)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpeedTracker_RateCalculation(t *testing.T) {
|
||||||
|
s := NewSpeedTracker()
|
||||||
|
|
||||||
|
// Seed baseline
|
||||||
|
s.mu.Lock()
|
||||||
|
s.lastIn = 0
|
||||||
|
s.lastOut = 0
|
||||||
|
s.lastTime = time.Now().Add(-1 * time.Second)
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
s.Update(1000, 500)
|
||||||
|
|
||||||
|
down, up := s.Rates()
|
||||||
|
// Allow some tolerance for timing
|
||||||
|
if down < 900 || down > 1100 {
|
||||||
|
t.Errorf("expected ~1000 B/s down, got %f", down)
|
||||||
|
}
|
||||||
|
if up < 400 || up > 600 {
|
||||||
|
t.Errorf("expected ~500 B/s up, got %f", up)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpeedTracker_NegativeDeltaClamped(t *testing.T) {
|
||||||
|
s := NewSpeedTracker()
|
||||||
|
|
||||||
|
// Seed with high values
|
||||||
|
s.mu.Lock()
|
||||||
|
s.lastIn = 5000
|
||||||
|
s.lastOut = 3000
|
||||||
|
s.lastTime = time.Now().Add(-1 * time.Second)
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
// Update with lower values (counter reset)
|
||||||
|
s.Update(100, 50)
|
||||||
|
|
||||||
|
down, up := s.Rates()
|
||||||
|
if down < 0 {
|
||||||
|
t.Errorf("negative download rate not clamped: %f", down)
|
||||||
|
}
|
||||||
|
if up < 0 {
|
||||||
|
t.Errorf("negative upload rate not clamped: %f", up)
|
||||||
|
}
|
||||||
|
}
|
||||||
68
internal/monitor/state_test.go
Normal file
68
internal/monitor/state_test.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package monitor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.davoryn.de/calic/syncwarden/internal/icons"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStateFromFolders_Empty(t *testing.T) {
|
||||||
|
got := stateFromFolders(nil, false)
|
||||||
|
if got != icons.StateIdle {
|
||||||
|
t.Errorf("empty folders should be idle, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStateFromFolders_Idle(t *testing.T) {
|
||||||
|
folders := []FolderInfo{
|
||||||
|
{ID: "a", State: "idle"},
|
||||||
|
{ID: "b", State: "idle"},
|
||||||
|
}
|
||||||
|
got := stateFromFolders(folders, false)
|
||||||
|
if got != icons.StateIdle {
|
||||||
|
t.Errorf("all idle should be StateIdle, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStateFromFolders_Syncing(t *testing.T) {
|
||||||
|
folders := []FolderInfo{
|
||||||
|
{ID: "a", State: "idle"},
|
||||||
|
{ID: "b", State: "syncing"},
|
||||||
|
}
|
||||||
|
got := stateFromFolders(folders, false)
|
||||||
|
if got != icons.StateSyncing {
|
||||||
|
t.Errorf("syncing folder should produce StateSyncing, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStateFromFolders_ScanningIsSyncing(t *testing.T) {
|
||||||
|
folders := []FolderInfo{
|
||||||
|
{ID: "a", State: "scanning"},
|
||||||
|
}
|
||||||
|
got := stateFromFolders(folders, false)
|
||||||
|
if got != icons.StateSyncing {
|
||||||
|
t.Errorf("scanning should map to StateSyncing, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStateFromFolders_ErrorOverSyncing(t *testing.T) {
|
||||||
|
folders := []FolderInfo{
|
||||||
|
{ID: "a", State: "syncing"},
|
||||||
|
{ID: "b", State: "error"},
|
||||||
|
}
|
||||||
|
got := stateFromFolders(folders, false)
|
||||||
|
if got != icons.StateError {
|
||||||
|
t.Errorf("error should take priority over syncing, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStateFromFolders_PausedOverAll(t *testing.T) {
|
||||||
|
folders := []FolderInfo{
|
||||||
|
{ID: "a", State: "error"},
|
||||||
|
{ID: "b", State: "syncing"},
|
||||||
|
}
|
||||||
|
got := stateFromFolders(folders, true)
|
||||||
|
if got != icons.StatePaused {
|
||||||
|
t.Errorf("paused should override all states, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,21 +11,28 @@ import (
|
|||||||
|
|
||||||
// Client talks to the Syncthing REST API.
|
// Client talks to the Syncthing REST API.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
baseURL string
|
baseURL string
|
||||||
apiKey string
|
apiKey string
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
longPollClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClient creates a Syncthing API client.
|
// NewClient creates a Syncthing API client.
|
||||||
func NewClient(baseURL, apiKey string) *Client {
|
// When insecureTLS is true, TLS certificate verification is skipped.
|
||||||
|
// This is the common case for local Syncthing instances that use self-signed certs.
|
||||||
|
func NewClient(baseURL, apiKey string, insecureTLS bool) *Client {
|
||||||
|
//nolint:gosec // Syncthing typically uses self-signed certs; controlled by config
|
||||||
|
tlsCfg := &tls.Config{InsecureSkipVerify: insecureTLS}
|
||||||
return &Client{
|
return &Client{
|
||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: 10 * time.Second,
|
Timeout: 10 * time.Second,
|
||||||
Transport: &http.Transport{
|
Transport: &http.Transport{TLSClientConfig: tlsCfg},
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
},
|
||||||
},
|
longPollClient: &http.Client{
|
||||||
|
Timeout: 40 * time.Second,
|
||||||
|
Transport: &http.Transport{TLSClientConfig: tlsCfg},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,13 +150,6 @@ func (c *Client) FolderStatus(folderID string) (*FolderStatus, error) {
|
|||||||
// Events long-polls for new events since the given ID.
|
// Events long-polls for new events since the given ID.
|
||||||
func (c *Client) Events(since int, timeout int) ([]Event, error) {
|
func (c *Client) Events(since int, timeout int) ([]Event, error) {
|
||||||
path := fmt.Sprintf("/rest/events?since=%d&timeout=%d", since, timeout)
|
path := fmt.Sprintf("/rest/events?since=%d&timeout=%d", since, timeout)
|
||||||
// Use a longer HTTP timeout for long-polling
|
|
||||||
client := &http.Client{
|
|
||||||
Timeout: time.Duration(timeout+10) * time.Second,
|
|
||||||
Transport: &http.Transport{
|
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
req, err := http.NewRequest("GET", c.baseURL+path, nil)
|
req, err := http.NewRequest("GET", c.baseURL+path, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -157,7 +157,7 @@ func (c *Client) Events(since int, timeout int) ([]Event, error) {
|
|||||||
if c.apiKey != "" {
|
if c.apiKey != "" {
|
||||||
req.Header.Set("X-API-Key", c.apiKey)
|
req.Header.Set("X-API-Key", c.apiKey)
|
||||||
}
|
}
|
||||||
resp, err := client.Do(req)
|
resp, err := c.longPollClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
9
internal/syncthing/detect_test.go
Normal file
9
internal/syncthing/detect_test.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package syncthing
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestIsInstalled_NoPanic(t *testing.T) {
|
||||||
|
// IsInstalled should not panic regardless of environment.
|
||||||
|
// We don't assert the result since it depends on the host.
|
||||||
|
_ = IsInstalled()
|
||||||
|
}
|
||||||
@@ -208,7 +208,9 @@ func (a *App) toggleSetting(field *bool, item *systray.MenuItem) {
|
|||||||
} else {
|
} else {
|
||||||
item.Uncheck()
|
item.Uncheck()
|
||||||
}
|
}
|
||||||
_ = config.Save(cfg)
|
if err := config.Save(cfg); err != nil {
|
||||||
|
log.Printf("config save error: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) rediscoverAPIKey() {
|
func (a *App) rediscoverAPIKey() {
|
||||||
@@ -227,6 +229,8 @@ func (a *App) rediscoverAPIKey() {
|
|||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
a.client.SetAPIKey(key)
|
a.client.SetAPIKey(key)
|
||||||
_ = config.Save(a.cfg)
|
if err := config.Save(a.cfg); err != nil {
|
||||||
|
log.Printf("config save error: %v", err)
|
||||||
|
}
|
||||||
log.Printf("re-discovered API key")
|
log.Printf("re-discovered API key")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
st "git.davoryn.de/calic/syncwarden/internal/syncthing"
|
st "git.davoryn.de/calic/syncwarden/internal/syncthing"
|
||||||
)
|
)
|
||||||
|
|
||||||
const version = "0.2.0"
|
const version = "0.3.0"
|
||||||
|
|
||||||
// App manages the tray icon and Syncthing monitoring.
|
// App manages the tray icon and Syncthing monitoring.
|
||||||
type App struct {
|
type App struct {
|
||||||
@@ -54,12 +54,14 @@ func (a *App) onReady() {
|
|||||||
if a.cfg.SyncthingAPIKey == "" {
|
if a.cfg.SyncthingAPIKey == "" {
|
||||||
if key, err := st.DiscoverAPIKey(); err == nil && key != "" {
|
if key, err := st.DiscoverAPIKey(); err == nil && key != "" {
|
||||||
a.cfg.SyncthingAPIKey = key
|
a.cfg.SyncthingAPIKey = key
|
||||||
_ = config.Save(a.cfg)
|
if err := config.Save(a.cfg); err != nil {
|
||||||
|
log.Printf("config save error: %v", err)
|
||||||
|
}
|
||||||
log.Printf("auto-discovered Syncthing API key")
|
log.Printf("auto-discovered Syncthing API key")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
a.client = st.NewClient(a.cfg.BaseURL(), a.cfg.SyncthingAPIKey)
|
a.client = st.NewClient(a.cfg.BaseURL(), a.cfg.SyncthingAPIKey, a.cfg.SyncthingInsecureTLS)
|
||||||
|
|
||||||
// Check if Syncthing is installed
|
// Check if Syncthing is installed
|
||||||
if !st.IsInstalled() {
|
if !st.IsInstalled() {
|
||||||
|
|||||||
Reference in New Issue
Block a user