Some checks failed
Release / build (push) Failing after 2m53s
Add Syncthing installation detection (PATH + config file check) to both the tray app and setup installer. When missing, the tray shows an "Install Syncthing..." menu item and the setup opens the download page. Rewrite README with Mermaid topology graph, per-binary dependency tables, project layout, API endpoint reference, and shields.io badges. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
368 lines
9.3 KiB
Go
368 lines
9.3 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"git.davoryn.de/calic/syncwarden/internal/config"
|
|
st "git.davoryn.de/calic/syncwarden/internal/syncthing"
|
|
)
|
|
|
|
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)
|
|
|
|
// Check if Syncthing is installed
|
|
if !st.IsInstalled() {
|
|
fmt.Println()
|
|
fmt.Println("Note: Syncthing is not installed.")
|
|
fmt.Printf("Download it from: %s\n", st.DownloadURL)
|
|
if isInteractive() {
|
|
openURL(st.DownloadURL)
|
|
}
|
|
}
|
|
|
|
if len(errors) > 0 {
|
|
msg := "Setup completed with warnings:\n\n" + strings.Join(errors, "\n") +
|
|
"\n\nSuccessfully installed " + fmt.Sprint(installed) + " binaries to:\n" + dst
|
|
if !st.IsInstalled() {
|
|
msg += "\n\nNote: Syncthing is not installed.\nDownload it from: " + st.DownloadURL
|
|
}
|
|
showMessage("SyncWarden — Setup", msg, true)
|
|
} else {
|
|
msg := "Setup complete!\n\nSyncWarden is running.\n\nIt will auto-discover your Syncthing API key."
|
|
if !st.IsInstalled() {
|
|
msg += "\n\nNote: Syncthing is not installed.\nDownload it from: " + st.DownloadURL
|
|
}
|
|
showMessage("SyncWarden — Setup", msg, 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 openURL(url string) {
|
|
var cmd *exec.Cmd
|
|
switch runtime.GOOS {
|
|
case "windows":
|
|
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
|
|
case "darwin":
|
|
cmd = exec.Command("open", url)
|
|
default:
|
|
cmd = exec.Command("xdg-open", url)
|
|
}
|
|
_ = cmd.Start()
|
|
}
|
|
|
|
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)
|
|
}
|