diff --git a/Makefile b/Makefile index 45a2865..75512cf 100644 --- a/Makefile +++ b/Makefile @@ -55,7 +55,8 @@ go-build-release: \ go-build-release-linux-amd64 \ go-build-release-linux-riscv64 \ go-build-release-darwin-arm64 \ - go-build-release-darwin-amd64 + go-build-release-darwin-amd64 \ + go-build-release-windows-amd64 go-build-release-linux-arm64: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/pangolin-cli_linux_arm64 @@ -76,4 +77,7 @@ go-build-release-darwin-arm64: CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o bin/pangolin-cli_darwin_arm64 go-build-release-darwin-amd64: - CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o bin/pangolin-cli_darwin_amd64 \ No newline at end of file + CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o bin/pangolin-cli_darwin_amd64 + +go-build-release-windows-amd64: + CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o bin/pangolin-cli_windows_amd64.exe \ No newline at end of file diff --git a/README.md b/README.md index 3b1cdf3..f85583e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # [Pangolin](https://pangolin.net) CLI -This is the official Pangolin CLI tool and VPN client for Unix devices. Pangolin CLI is currently only available on macOS and Linux. Windows support is coming soon. +This is the official Pangolin CLI tool. Since there isn't an official GUI application for Linux, this CLI serves as the official way to connect to Pangolin VPN on Linux. diff --git a/cmd/authdaemon/authdaemon.go b/cmd/authdaemon/authdaemon.go index ed223b1..803a719 100644 --- a/cmd/authdaemon/authdaemon.go +++ b/cmd/authdaemon/authdaemon.go @@ -1,3 +1,5 @@ +//go:build linux + package authdaemon import ( @@ -6,7 +8,6 @@ import ( "fmt" "os" "os/signal" - "runtime" "syscall" "github.com/fosrl/cli/internal/logger" @@ -38,9 +39,6 @@ func AuthDaemonCmd() *cobra.Command { Short: "Start the auth daemon", Long: "Start the auth daemon for remote SSH authentication", PreRunE: func(c *cobra.Command, args []string) error { - if runtime.GOOS != "linux" { - return fmt.Errorf("auth-daemon is only supported on Linux, not %s", runtime.GOOS) - } if opts.PreSharedKey == "" { return errPresharedKeyRequired } diff --git a/cmd/authdaemon/authdaemon_darwin.go b/cmd/authdaemon/authdaemon_darwin.go new file mode 100644 index 0000000..96ebe68 --- /dev/null +++ b/cmd/authdaemon/authdaemon_darwin.go @@ -0,0 +1,12 @@ +//go:build darwin + +package authdaemon + +import ( + "github.com/spf13/cobra" +) + +// AuthDaemonCmd returns nil on macOS as this command is not supported. +func AuthDaemonCmd() *cobra.Command { + return nil +} diff --git a/cmd/authdaemon/authdaemon_windows.go b/cmd/authdaemon/authdaemon_windows.go new file mode 100644 index 0000000..f7b329a --- /dev/null +++ b/cmd/authdaemon/authdaemon_windows.go @@ -0,0 +1,12 @@ +//go:build windows + +package authdaemon + +import ( + "github.com/spf13/cobra" +) + +// AuthDaemonCmd returns nil on Windows as this command is not supported. +func AuthDaemonCmd() *cobra.Command { + return nil +} diff --git a/cmd/down/down.go b/cmd/down/down_unix.go similarity index 95% rename from cmd/down/down.go rename to cmd/down/down_unix.go index 984557d..984a2f0 100644 --- a/cmd/down/down.go +++ b/cmd/down/down_unix.go @@ -1,3 +1,5 @@ +//go:build !windows + package down import ( @@ -20,4 +22,4 @@ If ran with no subcommand, 'client' is passed. cmd.AddCommand(client.ClientDownCmd()) return cmd -} +} \ No newline at end of file diff --git a/cmd/down/down_windows.go b/cmd/down/down_windows.go new file mode 100644 index 0000000..5fec68e --- /dev/null +++ b/cmd/down/down_windows.go @@ -0,0 +1,12 @@ +//go:build windows + +package down + +import ( + "github.com/spf13/cobra" +) + +// DownCmd returns nil on Windows as this command is not supported. +func DownCmd() *cobra.Command { + return nil +} \ No newline at end of file diff --git a/cmd/logs/logs.go b/cmd/logs/logs_unix.go similarity index 92% rename from cmd/logs/logs.go rename to cmd/logs/logs_unix.go index 5777188..7836735 100644 --- a/cmd/logs/logs.go +++ b/cmd/logs/logs_unix.go @@ -1,3 +1,5 @@ +//go:build !windows + package logs import ( @@ -15,4 +17,4 @@ func LogsCmd() *cobra.Command { cmd.AddCommand(client.ClientLogsCmd()) return cmd -} +} \ No newline at end of file diff --git a/cmd/logs/logs_windows.go b/cmd/logs/logs_windows.go new file mode 100644 index 0000000..4945cdc --- /dev/null +++ b/cmd/logs/logs_windows.go @@ -0,0 +1,12 @@ +//go:build windows + +package logs + +import ( + "github.com/spf13/cobra" +) + +// LogsCmd returns nil on Windows as this command is not supported. +func LogsCmd() *cobra.Command { + return nil +} \ No newline at end of file diff --git a/cmd/root.go b/cmd/root.go index 2baca0d..46b1002 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -44,14 +44,27 @@ func RootCommand(initResources bool) (*cobra.Command, error) { } cmd.AddCommand(auth.AuthCommand()) - cmd.AddCommand(authdaemon.AuthDaemonCmd()) + if authDaemonCmd := authdaemon.AuthDaemonCmd(); authDaemonCmd != nil { + cmd.AddCommand(authDaemonCmd) + } cmd.AddCommand(apply.ApplyCommand()) cmd.AddCommand(selectcmd.SelectCmd()) - cmd.AddCommand(up.UpCmd()) - cmd.AddCommand(down.DownCmd()) - cmd.AddCommand(logs.LogsCmd()) + + // Platform-specific commands - nil on unsupported platforms + if upCmd := up.UpCmd(); upCmd != nil { + cmd.AddCommand(upCmd) + } + if downCmd := down.DownCmd(); downCmd != nil { + cmd.AddCommand(downCmd) + } + if logsCmd := logs.LogsCmd(); logsCmd != nil { + cmd.AddCommand(logsCmd) + } + if statusCmd := status.StatusCmd(); statusCmd != nil { + cmd.AddCommand(statusCmd) + } + cmd.AddCommand(ssh.SSHCmd()) - cmd.AddCommand(status.StatusCmd()) cmd.AddCommand(update.UpdateCmd()) cmd.AddCommand(version.VersionCmd()) cmd.AddCommand(login.LoginCmd()) diff --git a/cmd/ssh/runner_exec.go b/cmd/ssh/runner_exec_unix.go similarity index 99% rename from cmd/ssh/runner_exec.go rename to cmd/ssh/runner_exec_unix.go index ad23ee3..335cba0 100644 --- a/cmd/ssh/runner_exec.go +++ b/cmd/ssh/runner_exec_unix.go @@ -1,3 +1,5 @@ +// +build !windows + package ssh import ( diff --git a/cmd/ssh/runner_exec_windows.go b/cmd/ssh/runner_exec_windows.go new file mode 100644 index 0000000..e4fddbb --- /dev/null +++ b/cmd/ssh/runner_exec_windows.go @@ -0,0 +1,150 @@ +//go:build windows +// +build windows + +package ssh + +import ( + "errors" + "os" + "os/exec" + "strconv" +) + +// execSSHSearchPaths are fallback locations for the ssh executable on Windows. +var execSSHSearchPaths = []string{ + `C:\Windows\System32\OpenSSH\ssh.exe`, +} + +func findExecSSHPathWindows() (string, error) { + if path, err := exec.LookPath("ssh"); err == nil { + return path, nil + } + for _, p := range execSSHSearchPaths { + if _, err := os.Stat(p); err == nil { + return p, nil + } + } + return "", errors.New("ssh executable not found in PATH or in OpenSSH location (C:\\Windows\\System32\\OpenSSH\\ssh.exe)") +} + +func execExitCode(err error) int { + if err == nil { + return 0 + } + if exitErr, ok := err.(*exec.ExitError); ok { + return exitErr.ExitCode() + } + return 1 +} + +// RunOpts is shared by both the exec and native SSH runners. +// PrivateKeyPEM and Certificate are set just-in-time (JIT) before connect; no file paths. +// Port is optional: 0 means use default (22 or whatever is in Hostname); >0 overrides. +type RunOpts struct { + User string + Hostname string + Port int // optional; 0 = default + PrivateKeyPEM string // in-memory private key (PEM, OpenSSH format) + Certificate string // in-memory certificate from sign-key API + PassThrough []string +} + +// RunExec runs an interactive SSH session by executing the system ssh binary. +// On Windows the system SSH has better support (e.g. terminal, agent). Requires ssh to be installed. +// opts.PrivateKeyPEM and opts.Certificate must be set (JIT key + signed cert). +func RunExec(opts RunOpts) (int, error) { + sshPath, err := findExecSSHPathWindows() + if err != nil { + return 1, err + } + + keyPath, certPath, cleanup, err := writeExecKeyFilesWindows(opts) + if err != nil { + return 1, err + } + if cleanup != nil { + defer cleanup() + } + + argv := buildExecSSHArgsWindows(sshPath, opts.User, opts.Hostname, opts.Port, keyPath, certPath, opts.PassThrough) + cmd := exec.Command(argv[0], argv[1:]...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return execExitCode(err), nil + } + return 0, nil +} + +func writeExecKeyFilesWindows(opts RunOpts) (keyPath, certPath string, cleanup func(), err error) { + if opts.PrivateKeyPEM == "" { + return "", "", nil, errors.New("private key required (JIT flow)") + } + keyFile, err := os.CreateTemp("", "pangolin-ssh-key-*") + if err != nil { + return "", "", nil, err + } + if _, err := keyFile.WriteString(opts.PrivateKeyPEM); err != nil { + keyFile.Close() + os.Remove(keyFile.Name()) + return "", "", nil, err + } + if err := keyFile.Chmod(0o600); err != nil { + keyFile.Close() + os.Remove(keyFile.Name()) + return "", "", nil, err + } + if err := keyFile.Close(); err != nil { + os.Remove(keyFile.Name()) + return "", "", nil, err + } + keyPath = keyFile.Name() + + if opts.Certificate != "" { + certFile, err := os.CreateTemp("", "pangolin-ssh-cert-*") + if err != nil { + os.Remove(keyPath) + return "", "", nil, err + } + if _, err := certFile.WriteString(opts.Certificate); err != nil { + certFile.Close() + os.Remove(certFile.Name()) + os.Remove(keyPath) + return "", "", nil, err + } + if err := certFile.Close(); err != nil { + os.Remove(certFile.Name()) + os.Remove(keyPath) + return "", "", nil, err + } + certPath = certFile.Name() + } + + cleanup = func() { + os.Remove(keyPath) + if certPath != "" { + os.Remove(certPath) + } + } + return keyPath, certPath, cleanup, nil +} + +func buildExecSSHArgsWindows(sshPath, user, hostname string, port int, keyPath, certPath string, passThrough []string) []string { + args := []string{sshPath} + if user != "" { + args = append(args, "-l", user) + } + if keyPath != "" { + args = append(args, "-i", keyPath) + } + if certPath != "" { + args = append(args, "-o", "CertificateFile="+certPath) + } + if port > 0 { + args = append(args, "-p", strconv.Itoa(port)) + } + args = append(args, hostname) + args = append(args, passThrough...) + return args +} diff --git a/cmd/ssh/runner_native.go b/cmd/ssh/runner_native.go index bb304e8..976e833 100644 --- a/cmd/ssh/runner_native.go +++ b/cmd/ssh/runner_native.go @@ -5,11 +5,8 @@ import ( "fmt" "net" "os" - "os/signal" - "runtime" "strconv" "strings" - "syscall" "github.com/mattn/go-isatty" "golang.org/x/crypto/ssh" @@ -44,7 +41,7 @@ func RunNative(opts RunOpts) (int, error) { defer session.Close() stdinFd := int(os.Stdin.Fd()) - useRaw := isatty.IsTerminal(uintptr(stdinFd)) && runtime.GOOS != "windows" + useRaw := isatty.IsTerminal(uintptr(stdinFd)) if useRaw { oldState, err := term.MakeRaw(stdinFd) if err != nil { @@ -69,19 +66,9 @@ func RunNative(opts RunOpts) (int, error) { return 1, fmt.Errorf("request pty: %w", err) } - // Resize on SIGWINCH (Unix, TTY only) + // Setup terminal window resize handling (platform-specific) if useRaw { - winchCh := make(chan os.Signal, 1) - signal.Notify(winchCh, syscall.SIGWINCH) - go func() { - for range winchCh { - if w, h, err := term.GetSize(stdinFd); err == nil { - _ = session.WindowChange(h, w) - } - } - }() - defer signal.Stop(winchCh) - winchCh <- syscall.SIGWINCH + setupWindowChangeHandler(session, stdinFd) } session.Stdin = os.Stdin diff --git a/cmd/ssh/runner_native_unix.go b/cmd/ssh/runner_native_unix.go new file mode 100644 index 0000000..d501e1c --- /dev/null +++ b/cmd/ssh/runner_native_unix.go @@ -0,0 +1,29 @@ +//go:build !windows + +package ssh + +import ( + "os" + "os/signal" + "syscall" + + "golang.org/x/crypto/ssh" + "golang.org/x/term" +) + +// setupWindowChangeHandler sets up terminal window size change handling for Unix systems. +// It listens for SIGWINCH signals and updates the SSH session's terminal size accordingly. +func setupWindowChangeHandler(session *ssh.Session, stdinFd int) { + winchCh := make(chan os.Signal, 1) + signal.Notify(winchCh, syscall.SIGWINCH) + go func() { + for range winchCh { + if w, h, err := term.GetSize(stdinFd); err == nil { + _ = session.WindowChange(h, w) + } + } + }() + defer signal.Stop(winchCh) + // Trigger initial window size update + winchCh <- syscall.SIGWINCH +} diff --git a/cmd/ssh/runner_native_windows.go b/cmd/ssh/runner_native_windows.go new file mode 100644 index 0000000..4313514 --- /dev/null +++ b/cmd/ssh/runner_native_windows.go @@ -0,0 +1,13 @@ +//go:build windows + +package ssh + +import ( + "golang.org/x/crypto/ssh" +) + +// setupWindowChangeHandler is a no-op on Windows. +// Windows does not support SIGWINCH signals for terminal resize detection. +func setupWindowChangeHandler(session *ssh.Session, stdinFd int) { + // No-op: Windows doesn't have SIGWINCH +} diff --git a/cmd/ssh/ssh.go b/cmd/ssh/ssh.go index 1394116..4ea7759 100644 --- a/cmd/ssh/ssh.go +++ b/cmd/ssh/ssh.go @@ -3,6 +3,7 @@ package ssh import ( "errors" "os" + "runtime" "github.com/fosrl/cli/internal/api" "github.com/fosrl/cli/internal/config" @@ -12,10 +13,11 @@ import ( ) var ( - errHostnameRequired = errors.New("API did not return a hostname for the connection") - errResourceIDRequired = errors.New("Resource (alias or identifier) is required") - errOrgRequired = errors.New("Organization is required") - errNoClientRunning = errors.New("No client is currently running. Start the client first with `pangolin up`") + errHostnameRequired = errors.New("API did not return a hostname for the connection") + errResourceIDRequired = errors.New("Resource (alias or identifier) is required; example: pangolin ssh my-server.internal") + errOrgRequired = errors.New("Organization is required") + errNoClientRunning = errors.New("No client is currently running. Start the client first with `pangolin up`") + errNoClientRunningWindows = errors.New("No client is currently running. Start the client first in the system tray") ) func SSHCmd() *cobra.Command { @@ -26,7 +28,7 @@ func SSHCmd() *cobra.Command { }{} cmd := &cobra.Command{ - Use: "ssh [-- passthrough...]", + Use: "ssh ", Short: "Run an interactive SSH session", Long: `Run an SSH client in the terminal. Generates a key pair and signs it just-in-time, then connects to the target resource.`, PreRunE: func(c *cobra.Command, args []string) error { @@ -37,10 +39,21 @@ func SSHCmd() *cobra.Command { return nil }, Run: func(c *cobra.Command, args []string) { - client := olm.NewClient("") - if !client.IsRunning() { - logger.Error("%v", errNoClientRunning) - os.Exit(1) + if runtime.GOOS != "windows" { + client := olm.NewClient("") + if !client.IsRunning() { + logger.Error("%v", errNoClientRunning) + os.Exit(1) + } + } else { + // check if the named pipe exists by trying to open it. If it doesn't exist, the client is not running. + pipePath := `\\.\pipe\pangolin-olm` + pipeFile, err := os.Open(pipePath) + if err != nil { + logger.Error("%v", errNoClientRunningWindows) + os.Exit(1) + } + pipeFile.Close() } apiClient := api.FromContext(c.Context()) @@ -72,8 +85,13 @@ func SSHCmd() *cobra.Command { PassThrough: passThrough, } + // On Windows, use the system ssh binary by default (better terminal/agent support). + useExec := opts.Exec || runtime.GOOS == "windows" + if len(passThrough) > 0 && !useExec { + logger.Warning("Passthrough arguments are ignored by the built-in client. Use --exec to pass them to the system ssh.") + } var exitCode int - if opts.Exec { + if useExec { exitCode, err = RunExec(runOpts) } else { exitCode, err = RunNative(runOpts) @@ -89,8 +107,6 @@ func SSHCmd() *cobra.Command { cmd.Flags().BoolVar(&opts.Exec, "exec", false, "Use system ssh binary instead of the built-in client") cmd.Flags().IntVarP(&opts.Port, "port", "p", 0, "SSH port (default: 22)") - cmd.Args = cobra.MinimumNArgs(1) - cmd.AddCommand(SignCmd()) return cmd diff --git a/cmd/status/status.go b/cmd/status/status_unix.go similarity index 95% rename from cmd/status/status.go rename to cmd/status/status_unix.go index 12939e8..549f6d3 100644 --- a/cmd/status/status.go +++ b/cmd/status/status_unix.go @@ -1,3 +1,5 @@ +//go:build !windows + package status import ( @@ -20,4 +22,4 @@ If ran with no subcommand, 'client' is passed. cmd.AddCommand(client.ClientStatusCmd()) return cmd -} +} \ No newline at end of file diff --git a/cmd/status/status_windows.go b/cmd/status/status_windows.go new file mode 100644 index 0000000..4e84e0c --- /dev/null +++ b/cmd/status/status_windows.go @@ -0,0 +1,12 @@ +//go:build windows + +package status + +import ( + "github.com/spf13/cobra" +) + +// StatusCmd returns nil on Windows as this command is not supported. +func StatusCmd() *cobra.Command { + return nil +} \ No newline at end of file diff --git a/cmd/up/up.go b/cmd/up/up_unix.go similarity index 94% rename from cmd/up/up.go rename to cmd/up/up_unix.go index 083bbd5..c540293 100644 --- a/cmd/up/up.go +++ b/cmd/up/up_unix.go @@ -1,3 +1,5 @@ +//go:build !windows + package up import ( @@ -20,4 +22,4 @@ If ran with no subcommand, 'client' is passed. cmd.AddCommand(client.ClientUpCmd()) return cmd -} +} \ No newline at end of file diff --git a/cmd/up/up_windows.go b/cmd/up/up_windows.go new file mode 100644 index 0000000..665e8ca --- /dev/null +++ b/cmd/up/up_windows.go @@ -0,0 +1,12 @@ +//go:build windows + +package up + +import ( + "github.com/spf13/cobra" +) + +// UpCmd returns nil on Windows as this command is not supported. +func UpCmd() *cobra.Command { + return nil +} \ No newline at end of file diff --git a/cmd/update/update.go b/cmd/update/update_unix.go similarity index 97% rename from cmd/update/update.go rename to cmd/update/update_unix.go index 44cfcc0..5e61ac4 100644 --- a/cmd/update/update.go +++ b/cmd/update/update_unix.go @@ -1,3 +1,5 @@ +//go:build !windows + package update import ( @@ -40,4 +42,4 @@ func updateMain() error { logger.Success("Pangolin CLI updated successfully!") return nil -} +} \ No newline at end of file diff --git a/cmd/update/update_windows.go b/cmd/update/update_windows.go new file mode 100644 index 0000000..5da4fcc --- /dev/null +++ b/cmd/update/update_windows.go @@ -0,0 +1,34 @@ +//go:build windows + +package update + +import ( + "os" + + "github.com/fosrl/cli/internal/logger" + "github.com/spf13/cobra" +) + +func UpdateCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "update", + Short: "Update Pangolin CLI to the latest version", + Long: "Update Pangolin CLI to the latest version by downloading the new installer from GitHub", + Run: func(cmd *cobra.Command, args []string) { + if err := updateMain(); err != nil { + os.Exit(1) + } + }, + } + + return cmd +} + +func updateMain() error { + logger.Info("To update Pangolin CLI on Windows, please download the latest installer from:") + logger.Info("https://github.com/fosrl/cli/releases") + logger.Info("") + logger.Info("Download and run the latest .msi or .exe installer to update to the newest version.") + + return nil +} \ No newline at end of file diff --git a/internal/version/consts.go b/internal/version/consts.go index a5d6e78..ea7cca8 100644 --- a/internal/version/consts.go +++ b/internal/version/consts.go @@ -1,4 +1,4 @@ package version // Version is the current version of the Pangolin CLI -const Version = "0.4.0" +const Version = "0.5.0" diff --git a/pangolin b/pangolin deleted file mode 100755 index 40ef390..0000000 --- a/pangolin +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/sh -go run . "$@" diff --git a/pangolin-cli.iss b/pangolin-cli.iss new file mode 100644 index 0000000..a8e7bdd --- /dev/null +++ b/pangolin-cli.iss @@ -0,0 +1,152 @@ +; Script generated by the Inno Setup Script Wizard. +; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! + +#define MyAppName "pangolin-cli" +#define MyAppVersion "1.0.0" +#define MyAppPublisher "Fossorial Inc." +#define MyAppURL "https://pangolin.net" +#define MyAppExeName "pangolin.exe" + +[Setup] +; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. +; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) +AppId={{35A1E3C4-C273-4334-9DF3-57408E83012E} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +;AppVerName={#MyAppName} {#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL} +AppUpdatesURL={#MyAppURL} +DefaultDirName={autopf}\{#MyAppName} +UninstallDisplayIcon={app}\{#MyAppExeName} +; "ArchitecturesAllowed=x64compatible" specifies that Setup cannot run +; on anything but x64 and Windows 11 on Arm. +ArchitecturesAllowed=x64compatible +; "ArchitecturesInstallIn64BitMode=x64compatible" requests that the +; install be done in "64-bit mode" on x64 or Windows 11 on Arm, +; meaning it should use the native 64-bit Program Files directory and +; the 64-bit view of the registry. +ArchitecturesInstallIn64BitMode=x64compatible +DefaultGroupName={#MyAppName} +DisableProgramGroupPage=yes +; Uncomment the following line to run in non administrative install mode (install for current user only). +;PrivilegesRequired=lowest +OutputBaseFilename=pangolin-cli_windows_installer +SolidCompression=yes +WizardStyle=modern +; Add this to ensure PATH changes are applied and the system is prompted for a restart if needed +RestartIfNeededByRun=no +ChangesEnvironment=true + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Files] +; The 'DestName' flag ensures that 'pangolin-cli_windows_amd64.exe' is installed as 'pangolin-cli.exe' +Source: "Z:\pangolin-cli_windows_amd64.exe"; DestDir: "{app}"; DestName: "{#MyAppExeName}"; Flags: ignoreversion +Source: "Z:\wintun.dll"; DestDir: "{app}"; Flags: ignoreversion +; NOTE: Don't use "Flags: ignoreversion" on any shared system files + +[Icons] +Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" + +[Registry] +; Add the application's installation directory to the system PATH environment variable. +; HKLM (HKEY_LOCAL_MACHINE) is used for system-wide changes. +; The 'Path' variable is located under 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment'. +; ValueType: expandsz allows for environment variables (like %ProgramFiles%) in the path. +; ValueData: "{olddata};{app}" appends the current application directory to the existing PATH. +; Note: Removal during uninstallation is handled by CurUninstallStepChanged procedure in [Code] section. +; Check: NeedsAddPath ensures this is applied only if the path is not already present. +[Registry] +; Add the application's installation directory to the system PATH. +Root: HKLM; Subkey: "SYSTEM\CurrentControlSet\Control\Session Manager\Environment"; \ + ValueType: expandsz; ValueName: "Path"; ValueData: "{olddata};{app}"; \ + Check: NeedsAddPath(ExpandConstant('{app}')) + +[Code] +function NeedsAddPath(Path: string): boolean; +var + OrigPath: string; +begin + if not RegQueryStringValue(HKEY_LOCAL_MACHINE, + 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment', + 'Path', OrigPath) + then begin + // Path variable doesn't exist at all, so we definitely need to add it. + Result := True; + exit; + end; + + // Perform a case-insensitive check to see if the path is already present. + // We add semicolons to prevent partial matches (e.g., matching C:\App in C:\App2). + if Pos(';' + UpperCase(Path) + ';', ';' + UpperCase(OrigPath) + ';') > 0 then + Result := False + else + Result := True; +end; + +procedure RemovePathEntry(PathToRemove: string); +var + OrigPath: string; + NewPath: string; + PathList: TStringList; + I: Integer; +begin + if not RegQueryStringValue(HKEY_LOCAL_MACHINE, + 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment', + 'Path', OrigPath) + then begin + // Path variable doesn't exist, nothing to remove + exit; + end; + + // Create a string list to parse the PATH entries + PathList := TStringList.Create; + try + // Split the PATH by semicolons + PathList.Delimiter := ';'; + PathList.StrictDelimiter := True; + PathList.DelimitedText := OrigPath; + + // Find and remove the matching entry (case-insensitive) + for I := PathList.Count - 1 downto 0 do + begin + if CompareText(Trim(PathList[I]), Trim(PathToRemove)) = 0 then + begin + Log('Found and removing PATH entry: ' + PathList[I]); + PathList.Delete(I); + end; + end; + + // Reconstruct the PATH + NewPath := PathList.DelimitedText; + + // Write the new PATH back to the registry + if RegWriteExpandStringValue(HKEY_LOCAL_MACHINE, + 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment', + 'Path', NewPath) + then + Log('Successfully removed path entry: ' + PathToRemove) + else + Log('Failed to write modified PATH to registry'); + finally + PathList.Free; + end; +end; + +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +var + AppPath: string; +begin + if CurUninstallStep = usUninstall then + begin + // Get the application installation path + AppPath := ExpandConstant('{app}'); + Log('Removing PATH entry for: ' + AppPath); + + // Remove only our path entry from the system PATH + RemovePathEntry(AppPath); + end; +end;