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}