diff --git a/cmd/iptw/daemon_unix.go b/cmd/iptw/daemon_unix.go new file mode 100644 index 0000000..f4da0cd --- /dev/null +++ b/cmd/iptw/daemon_unix.go @@ -0,0 +1,55 @@ +//go:build !windows + +package main + +import ( + "os" + "os/exec" + "syscall" +) + +// maybeDaemonize re-executes the process detached from the controlling +// terminal so the user can close the launching shell once the tray icon +// appears. It is a no-op when --foreground is passed. +// +// How it works: +// 1. The original (parent) process re-execs itself with all the same +// arguments plus "--foreground", using Setsid=true so the child gets a +// new session and is no longer attached to the terminal. +// 2. The parent then exits with code 0, freeing the terminal. +// 3. The child (which has --foreground set) skips this function and runs +// the application normally. +func maybeDaemonize(foreground bool) { + if foreground { + return + } + + exe, err := os.Executable() + if err != nil { + // Cannot determine the executable path; just run in the foreground. + return + } + + // Forward every original argument and add --foreground so the child does + // not daemonize again. + args := make([]string, 0, len(os.Args)-1+1) + args = append(args, os.Args[1:]...) + args = append(args, "--foreground") + + cmd := exec.Command(exe, args...) + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + // Disconnect all standard streams so the child has no reference to the + // parent terminal. + cmd.Stdin = nil + cmd.Stdout = nil + cmd.Stderr = nil + + if err := cmd.Start(); err != nil { + // If we cannot spawn the background child, fall through and run + // normally in the foreground rather than failing silently. + return + } + + // Parent exits, releasing the terminal. + os.Exit(0) +} diff --git a/cmd/iptw/daemon_windows.go b/cmd/iptw/daemon_windows.go new file mode 100644 index 0000000..c26af62 --- /dev/null +++ b/cmd/iptw/daemon_windows.go @@ -0,0 +1,7 @@ +//go:build windows + +package main + +// maybeDaemonize is a no-op on Windows. Binaries built with -H windowsgui +// already have no console window, so there is nothing to detach from. +func maybeDaemonize(_ bool) {} diff --git a/cmd/iptw/main.go b/cmd/iptw/main.go index 70cabb9..d4acc9a 100644 --- a/cmd/iptw/main.go +++ b/cmd/iptw/main.go @@ -23,8 +23,10 @@ var ( func main() { var forceStart bool var showVersion bool + var foreground bool flag.BoolVar(&forceStart, "force", false, "Force start even if another instance appears to be running") flag.BoolVar(&showVersion, "version", false, "Show version information") + flag.BoolVar(&foreground, "foreground", false, "Run in the foreground (keep terminal attached)") flag.Parse() // Handle version request @@ -35,6 +37,11 @@ func main() { return } + // On macOS/Linux: detach from the terminal so the user can close the + // launching shell. The process re-execs itself with --foreground and + // the parent exits immediately. This is a no-op on Windows. + maybeDaemonize(foreground) + // On Windows GUI builds, set up file logging immediately so that any // startup failure is recorded even before the config is read. if closer := logging.SetupWindowsFileLogger(slog.LevelDebug); closer != nil {