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>
338 lines
8.5 KiB
Go
338 lines
8.5 KiB
Go
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)
|
|
}
|