update_windows.go

  1//go:build windows
  2
  3package update
  4
  5import (
  6	"bytes"
  7	"context"
  8	"fmt"
  9	"os"
 10	"os/exec"
 11	"path/filepath"
 12	"strings"
 13	"time"
 14)
 15
 16// detectInstallMethod determines how Crush was installed on Windows.
 17func detectInstallMethod(exePath string) InstallMethod {
 18	// Normalize path separators for comparison.
 19	exePath = filepath.ToSlash(exePath)
 20	exePathLower := strings.ToLower(exePath)
 21
 22	home, _ := os.UserHomeDir()
 23
 24	// Check for npm global installation.
 25	appData := os.Getenv("APPDATA")
 26	if appData != "" {
 27		npmPath := strings.ToLower(filepath.ToSlash(filepath.Join(appData, "npm")))
 28		if strings.HasPrefix(exePathLower, npmPath) {
 29			return InstallMethodNPM
 30		}
 31	}
 32
 33	// Check for go install.
 34	gopath := os.Getenv("GOPATH")
 35	if gopath == "" && home != "" {
 36		gopath = filepath.Join(home, "go")
 37	}
 38	if gopath != "" {
 39		gopathBin := strings.ToLower(filepath.ToSlash(filepath.Join(gopath, "bin")))
 40		if strings.HasPrefix(exePathLower, gopathBin) {
 41			return InstallMethodGoInstall
 42		}
 43	}
 44
 45	// Use package manager commands for accurate detection.
 46
 47	// Check Scoop.
 48	if isInstalledViaScoop() {
 49		return InstallMethodScoop
 50	}
 51
 52	// Check winget.
 53	if isInstalledViaWinget() {
 54		return InstallMethodWinget
 55	}
 56
 57	// Fallback to path heuristics.
 58	if home != "" {
 59		scoopPath := strings.ToLower(filepath.ToSlash(filepath.Join(home, "scoop")))
 60		if strings.HasPrefix(exePathLower, scoopPath) {
 61			return InstallMethodScoop
 62		}
 63	}
 64
 65	localAppData := os.Getenv("LOCALAPPDATA")
 66	if localAppData != "" {
 67		wingetPath := strings.ToLower(filepath.ToSlash(filepath.Join(localAppData, "Microsoft", "WinGet")))
 68		if strings.HasPrefix(exePathLower, wingetPath) {
 69			return InstallMethodWinget
 70		}
 71	}
 72
 73	return InstallMethodUnknown
 74}
 75
 76// isInstalledViaScoop checks if crush is installed via Scoop.
 77func isInstalledViaScoop() bool {
 78	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
 79	defer cancel()
 80
 81	cmd := exec.CommandContext(ctx, "scoop", "list", "crush")
 82	var stdout bytes.Buffer
 83	cmd.Stdout = &stdout
 84	if err := cmd.Run(); err != nil {
 85		return false
 86	}
 87	// scoop list outputs package info if installed.
 88	return strings.Contains(stdout.String(), "crush")
 89}
 90
 91// isInstalledViaWinget checks if crush is installed via winget.
 92func isInstalledViaWinget() bool {
 93	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
 94	defer cancel()
 95
 96	cmd := exec.CommandContext(ctx, "winget", "list", "--id", "charmbracelet.crush", "--exact")
 97	var stdout bytes.Buffer
 98	cmd.Stdout = &stdout
 99	if err := cmd.Run(); err != nil {
100		return false
101	}
102	// winget list outputs package info if installed.
103	return strings.Contains(strings.ToLower(stdout.String()), "charmbracelet")
104}
105
106// Apply stages the new binary for installation on next startup.
107// On Windows, we cannot replace a running executable, so we copy the new
108// binary to crush.exe.new and apply it on the next startup.
109func Apply(binaryPath string) error {
110	pendingPath, err := pendingUpdatePath()
111	if err != nil {
112		return err
113	}
114
115	// Check write permission before attempting to copy.
116	exeDir := filepath.Dir(pendingPath)
117	if err := checkWritePermission(exeDir); err != nil {
118		return fmt.Errorf("cannot write to %s: %w (you may need to run as administrator)", exeDir, err)
119	}
120
121	// Copy the new binary to the pending location.
122	if err := copyFile(binaryPath, pendingPath); err != nil {
123		_ = os.Remove(pendingPath) // Clean up partially written file.
124		return err
125	}
126
127	return nil
128}
129
130// pendingUpdatePath returns the path where a pending update binary is stored.
131func pendingUpdatePath() (string, error) {
132	exe, err := os.Executable()
133	if err != nil {
134		return "", err
135	}
136	return exe + ".new", nil
137}
138
139// HasPendingUpdate checks if there's a pending update waiting to be applied.
140func HasPendingUpdate() bool {
141	path, err := pendingUpdatePath()
142	if err != nil {
143		return false
144	}
145	_, err = os.Stat(path)
146	return err == nil
147}
148
149// ApplyPendingUpdate applies a pending update that was staged previously.
150// This should be called early in startup, before the main executable is locked.
151func ApplyPendingUpdate() error {
152	exe, err := os.Executable()
153	if err != nil {
154		return err
155	}
156
157	// Clean up any lingering .old file from a previous update.
158	oldPath := exe + ".old"
159	_ = os.Remove(oldPath)
160
161	pendingPath, err := pendingUpdatePath()
162	if err != nil {
163		return err
164	}
165
166	if _, err := os.Stat(pendingPath); os.IsNotExist(err) {
167		return nil // No pending update.
168	}
169
170	// Acquire exclusive lock to prevent race conditions with other processes.
171	lockPath := exe + ".lock"
172	lock, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
173	if err != nil {
174		// Lock exists or can't be created—another process is likely handling this,
175		// or we lack permissions. Either way, silently skip to avoid blocking startup.
176		return nil
177	}
178	defer func() {
179		lock.Close()
180		os.Remove(lockPath)
181	}()
182
183	// Re-check pending update exists after acquiring lock (another process may
184	// have completed).
185	if _, err := os.Stat(pendingPath); os.IsNotExist(err) {
186		return nil
187	}
188
189	// Rename current to .old, new to current.
190	if err := os.Rename(exe, oldPath); err != nil {
191		return err
192	}
193
194	if err := os.Rename(pendingPath, exe); err != nil {
195		// Try to restore.
196		_ = os.Rename(oldPath, exe)
197		return err
198	}
199
200	// Clean up old binary.
201	_ = os.Remove(oldPath)
202	return nil
203}