返回 DeepSeek-Reasonix
external_opener_windows.go
根目录 / desktop / external_opener_windows.go
1 //go:build windows
2
3 package main
4
5 import (
6 "os"
7 "os/exec"
8 "path/filepath"
9 "strings"
10 "unsafe"
11
12 "golang.org/x/sys/windows"
13 "golang.org/x/sys/windows/registry"
14
15 "reasonix/internal/proc"
16 )
17
18 const (
19 shgfiIcon = 0x000000100
20 shgfiLargeIcon = 0x000000000
21 dibRGBColors = 0
22 drawIconNormal = 0x0003
23 windowsOpenerIconWidth = 32
24 )
25
26 type windowsShellFileInfo struct {
27 Icon windows.Handle
28 IconIndex int32
29 Attributes uint32
30 DisplayName [260]uint16
31 TypeName [80]uint16
32 }
33
34 type windowsBitmapInfoHeader struct {
35 Size uint32
36 Width int32
37 Height int32
38 Planes uint16
39 BitCount uint16
40 Compression uint32
41 SizeImage uint32
42 XPelsPerMeter int32
43 YPelsPerMeter int32
44 ColorsUsed uint32
45 ColorsNeeded uint32
46 }
47
48 type windowsBitmapInfo struct {
49 Header windowsBitmapInfoHeader
50 Colors [1]uint32
51 }
52
53 var (
54 windowsShell32 = windows.NewLazySystemDLL("shell32.dll")
55 windowsUser32 = windows.NewLazySystemDLL("user32.dll")
56 windowsGDI32 = windows.NewLazySystemDLL("gdi32.dll")
57 windowsSHGetFileInfo = windowsShell32.NewProc("SHGetFileInfoW")
58 windowsDestroyIcon = windowsUser32.NewProc("DestroyIcon")
59 windowsDrawIconEx = windowsUser32.NewProc("DrawIconEx")
60 windowsGetDC = windowsUser32.NewProc("GetDC")
61 windowsReleaseDC = windowsUser32.NewProc("ReleaseDC")
62 windowsCreateCompatibleDC = windowsGDI32.NewProc("CreateCompatibleDC")
63 windowsCreateDIBSection = windowsGDI32.NewProc("CreateDIBSection")
64 windowsSelectObject = windowsGDI32.NewProc("SelectObject")
65 windowsDeleteObject = windowsGDI32.NewProc("DeleteObject")
66 windowsDeleteDC = windowsGDI32.NewProc("DeleteDC")
67 )
68
69 func joinWindowsInstallPath(root string, parts ...string) string {
70 if root == "" {
71 return ""
72 }
73 return filepath.Join(append([]string{root}, parts...)...)
74 }
75
76 func firstWindowsExecutable(names []string, candidates ...string) string {
77 for _, name := range names {
78 if path, err := exec.LookPath(name); err == nil {
79 return path
80 }
81 if path := windowsAppPathExecutable(name); path != "" {
82 return path
83 }
84 }
85 for _, candidate := range candidates {
86 if candidate == "" {
87 continue
88 }
89 if matches, _ := filepath.Glob(candidate); len(matches) > 0 {
90 for _, match := range matches {
91 if info, err := os.Stat(match); err == nil && !info.IsDir() {
92 return match
93 }
94 }
95 }
96 }
97 return ""
98 }
99
100 // windowsAppPathExecutable resolves an install registered under
101 // HKCU/HKLM ...\App Paths\<name>. Custom VS Code / editor installs often only
102 // appear there, not on PATH or under the default Program Files trees.
103 func windowsAppPathExecutable(name string) string {
104 name = strings.TrimSpace(name)
105 if name == "" {
106 return ""
107 }
108 subKey := `SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\` + name
109 for _, root := range []registry.Key{registry.CURRENT_USER, registry.LOCAL_MACHINE} {
110 key, err := registry.OpenKey(root, subKey, registry.QUERY_VALUE)
111 if err != nil {
112 continue
113 }
114 raw, _, err := key.GetStringValue("")
115 key.Close()
116 if err != nil {
117 continue
118 }
119 path := strings.Trim(strings.TrimSpace(raw), `"`)
120 if path == "" {
121 continue
122 }
123 path = os.ExpandEnv(path)
124 if info, err := os.Stat(path); err == nil && !info.IsDir() {
125 return path
126 }
127 }
128 return ""
129 }
130
131 // windowsTerminalIconSource prefers a real Windows Terminal package binary for
132 // SHGetFileInfo. Store installs expose wt.exe as an App Execution Alias (often
133 // zero bytes under LocalAppData\Microsoft\WindowsApps). The package binary may
134 // live under Program Files\WindowsApps, but non-elevated processes frequently
135 // cannot enumerate that protected directory — so a renderable console-host
136 // fallback must win over a zero-byte alias (see pickWindowsTerminalIconSource).
137 func windowsTerminalIconSource(wtPath string) string {
138 var resolved []windowsIconCandidate
139 for _, candidate := range windowsTerminalIconCandidatePaths(
140 wtPath,
141 os.Getenv("LOCALAPPDATA"),
142 os.Getenv("ProgramFiles"),
143 ) {
144 matches := []string{candidate}
145 if strings.ContainsAny(candidate, `*?[`) {
146 globbed, err := filepath.Glob(candidate)
147 if err != nil || len(globbed) == 0 {
148 continue
149 }
150 matches = globbed
151 }
152 for _, match := range matches {
153 info, err := os.Stat(match)
154 if err != nil || info.IsDir() {
155 continue
156 }
157 resolved = append(resolved, windowsIconCandidate{Path: match, Size: info.Size()})
158 }
159 }
160 // Prefer a normal console host icon over a zero-byte wt.exe alias so the
161 // menu never ships a blank glyph when WindowsApps is unreadable.
162 renderable := firstWindowsExecutable([]string{"powershell.exe"},
163 joinWindowsInstallPath(os.Getenv("WINDIR"), "System32", "WindowsPowerShell", "v1.0", "powershell.exe"))
164 if picked := pickWindowsTerminalIconSource(resolved, renderable); picked != "" {
165 return picked
166 }
167 return strings.TrimSpace(wtPath)
168 }
169
170 // shellExecuteOpenFile launches file via ShellExecuteW("open") without cmd.exe.
171 // parameters and directory are optional; directory becomes the process CWD.
172 func shellExecuteOpenFile(file, parameters, directory string) error {
173 verb, err := windows.UTF16PtrFromString("open")
174 if err != nil {
175 return err
176 }
177 filePtr, err := windows.UTF16PtrFromString(file)
178 if err != nil {
179 return err
180 }
181 var paramsPtr *uint16
182 if parameters != "" {
183 paramsPtr, err = windows.UTF16PtrFromString(parameters)
184 if err != nil {
185 return err
186 }
187 }
188 var dirPtr *uint16
189 if directory != "" {
190 dirPtr, err = windows.UTF16PtrFromString(directory)
191 if err != nil {
192 return err
193 }
194 }
195 return windows.ShellExecute(0, verb, filePtr, paramsPtr, dirPtr, windows.SW_SHOWNORMAL)
196 }
197
198 func platformExternalOpenerSpecs() []externalOpenerSpec {
199 local := os.Getenv("LOCALAPPDATA")
200 programFiles := os.Getenv("ProgramFiles")
201 programFilesX86 := os.Getenv("ProgramFiles(x86)")
202 windowsDir := os.Getenv("WINDIR")
203 var specs []externalOpenerSpec
204 add := func(id, name, kind, mode string, names []string, candidates ...string) {
205 if path := firstWindowsExecutable(names, candidates...); path != "" {
206 specs = append(specs, externalOpenerSpec{
207 View: ExternalOpenerView{ID: id, Name: name, Kind: kind},
208 Target: path,
209 LaunchMode: mode,
210 IconSource: path,
211 })
212 }
213 }
214 jetbrainsProgram := func(product, executable string) string {
215 return joinWindowsInstallPath(programFiles, "JetBrains", product+" *", "bin", executable)
216 }
217 jetbrainsToolbox := func(product, executable string) string {
218 return joinWindowsInstallPath(local, "JetBrains", "Toolbox", "apps", product, "*", "*", "bin", executable)
219 }
220
221 add("vscode", "VS Code", externalOpenerEditor, "path", []string{"Code.exe"},
222 joinWindowsInstallPath(local, "Programs", "Microsoft VS Code", "Code.exe"),
223 joinWindowsInstallPath(programFiles, "Microsoft VS Code", "Code.exe"),
224 joinWindowsInstallPath(programFilesX86, "Microsoft VS Code", "Code.exe"))
225 add("vscode-insiders", "VS Code Insiders", externalOpenerEditor, "path", []string{"Code - Insiders.exe"},
226 joinWindowsInstallPath(local, "Programs", "Microsoft VS Code Insiders", "Code - Insiders.exe"))
227 add("cursor", "Cursor", externalOpenerEditor, "path", []string{"Cursor.exe"},
228 joinWindowsInstallPath(local, "Programs", "cursor", "Cursor.exe"),
229 joinWindowsInstallPath(programFiles, "Cursor", "Cursor.exe"))
230 add("file-explorer", "File Explorer", externalOpenerFileManager, "shell-open", []string{"explorer.exe"},
231 joinWindowsInstallPath(windowsDir, "explorer.exe"))
232 if wt := firstWindowsExecutable([]string{"wt.exe"}); wt != "" {
233 specs = append(specs, externalOpenerSpec{
234 View: ExternalOpenerView{ID: "windows-terminal", Name: "Windows Terminal", Kind: externalOpenerTerminal},
235 Target: wt,
236 LaunchMode: "windows-terminal",
237 IconSource: windowsTerminalIconSource(wt),
238 })
239 }
240 add("powershell", "PowerShell", externalOpenerTerminal, "console", []string{"pwsh.exe", "powershell.exe"},
241 joinWindowsInstallPath(windowsDir, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"))
242 add("command-prompt", "Command Prompt", externalOpenerTerminal, "console", []string{"cmd.exe"},
243 joinWindowsInstallPath(windowsDir, "System32", "cmd.exe"))
244 add("android-studio", "Android Studio", externalOpenerEditor, "path", []string{"studio64.exe", "studio.exe"},
245 joinWindowsInstallPath(programFiles, "Android", "Android Studio", "bin", "studio64.exe"))
246 add("goland", "GoLand", externalOpenerEditor, "path", []string{"goland64.exe", "goland.exe"},
247 jetbrainsProgram("GoLand", "goland64.exe"), jetbrainsToolbox("GoLand", "goland64.exe"))
248 add("pycharm", "PyCharm", externalOpenerEditor, "path", []string{"pycharm64.exe", "pycharm.exe"},
249 jetbrainsProgram("PyCharm", "pycharm64.exe"), jetbrainsToolbox("PyCharm*", "pycharm64.exe"))
250 add("intellij-idea", "IntelliJ IDEA", externalOpenerEditor, "path", []string{"idea64.exe", "idea.exe"},
251 jetbrainsProgram("IntelliJ IDEA", "idea64.exe"), jetbrainsToolbox("IDEA*", "idea64.exe"))
252 add("webstorm", "WebStorm", externalOpenerEditor, "path", []string{"webstorm64.exe", "webstorm.exe"},
253 jetbrainsProgram("WebStorm", "webstorm64.exe"), jetbrainsToolbox("WebStorm", "webstorm64.exe"))
254 add("datagrip", "DataGrip", externalOpenerEditor, "path", []string{"datagrip64.exe", "datagrip.exe"},
255 jetbrainsProgram("DataGrip", "datagrip64.exe"), jetbrainsToolbox("DataGrip", "datagrip64.exe"))
256 add("codebuddy", "CodeBuddy", externalOpenerEditor, "path", []string{"CodeBuddy.exe"},
257 joinWindowsInstallPath(local, "Programs", "CodeBuddy", "CodeBuddy.exe"),
258 joinWindowsInstallPath(programFiles, "CodeBuddy", "CodeBuddy.exe"))
259 add("windsurf", "Windsurf", externalOpenerEditor, "path", []string{"Windsurf.exe"},
260 joinWindowsInstallPath(local, "Programs", "Windsurf", "Windsurf.exe"))
261 add("zed", "Zed", externalOpenerEditor, "path", []string{"zed.exe"},
262 joinWindowsInstallPath(local, "Programs", "Zed", "zed.exe"))
263 add("sublime-text", "Sublime Text", externalOpenerEditor, "path", []string{"sublime_text.exe"},
264 joinWindowsInstallPath(programFiles, "Sublime Text", "sublime_text.exe"))
265 add("kiro", "Kiro", externalOpenerEditor, "path", []string{"Kiro.exe"},
266 joinWindowsInstallPath(local, "Programs", "Kiro", "Kiro.exe"))
267 return specs
268 }
269
270 func platformExternalOpenerIconDataURL(spec externalOpenerSpec) string {
271 if spec.IconSource == "" {
272 return ""
273 }
274 path, err := windows.UTF16PtrFromString(spec.IconSource)
275 if err != nil {
276 return ""
277 }
278 var info windowsShellFileInfo
279 result, _, _ := windowsSHGetFileInfo.Call(
280 uintptr(unsafe.Pointer(path)),
281 0,
282 uintptr(unsafe.Pointer(&info)),
283 unsafe.Sizeof(info),
284 shgfiIcon|shgfiLargeIcon,
285 )
286 if result == 0 || info.Icon == 0 {
287 return ""
288 }
289 defer windowsDestroyIcon.Call(uintptr(info.Icon))
290
291 black, ok := renderWindowsExternalOpenerIcon(info.Icon, 0)
292 if !ok {
293 return ""
294 }
295 white, ok := renderWindowsExternalOpenerIcon(info.Icon, 255)
296 if !ok {
297 return ""
298 }
299 return externalOpenerPNGDataURL(externalOpenerPNGFromBGRAComposites(
300 black,
301 white,
302 windowsOpenerIconWidth,
303 windowsOpenerIconWidth,
304 ))
305 }
306
307 func renderWindowsExternalOpenerIcon(icon windows.Handle, background byte) ([]byte, bool) {
308 screenDC, _, _ := windowsGetDC.Call(0)
309 if screenDC == 0 {
310 return nil, false
311 }
312 defer windowsReleaseDC.Call(0, screenDC)
313
314 memoryDC, _, _ := windowsCreateCompatibleDC.Call(screenDC)
315 if memoryDC == 0 {
316 return nil, false
317 }
318 defer windowsDeleteDC.Call(memoryDC)
319
320 bitmapInfo := windowsBitmapInfo{Header: windowsBitmapInfoHeader{
321 Size: uint32(unsafe.Sizeof(windowsBitmapInfoHeader{})),
322 Width: windowsOpenerIconWidth,
323 Height: -windowsOpenerIconWidth,
324 Planes: 1,
325 BitCount: 32,
326 Compression: 0,
327 }}
328 var bits unsafe.Pointer
329 bitmap, _, _ := windowsCreateDIBSection.Call(
330 memoryDC,
331 uintptr(unsafe.Pointer(&bitmapInfo)),
332 dibRGBColors,
333 uintptr(unsafe.Pointer(&bits)),
334 0,
335 0,
336 )
337 if bitmap == 0 || bits == nil {
338 return nil, false
339 }
340 defer windowsDeleteObject.Call(bitmap)
341
342 previous, _, _ := windowsSelectObject.Call(memoryDC, bitmap)
343 if previous == 0 {
344 return nil, false
345 }
346 defer windowsSelectObject.Call(memoryDC, previous)
347
348 pixels := unsafe.Slice((*byte)(bits), windowsOpenerIconWidth*windowsOpenerIconWidth*4)
349 for offset := 0; offset < len(pixels); offset += 4 {
350 pixels[offset] = background
351 pixels[offset+1] = background
352 pixels[offset+2] = background
353 pixels[offset+3] = 255
354 }
355 drawn, _, _ := windowsDrawIconEx.Call(
356 memoryDC,
357 0,
358 0,
359 uintptr(icon),
360 windowsOpenerIconWidth,
361 windowsOpenerIconWidth,
362 0,
363 0,
364 drawIconNormal,
365 )
366 if drawn == 0 {
367 return nil, false
368 }
369 return append([]byte(nil), pixels...), true
370 }
371
372 func launchPlatformExternalOpener(spec externalOpenerSpec, path string, isDir bool) error {
373 var cmd *exec.Cmd
374 launchPath := externalOpenerLaunchPath(spec, path)
375 switch spec.LaunchMode {
376 case "shell-open":
377 return openWorkspacePathWithType(path, isDir)
378 case "path":
379 cmd = proc.VisibleCommand(spec.Target, path)
380 case "windows-terminal":
381 // exec.Command uses CreateProcess argument escaping, not cmd.exe, so
382 // workspace paths with shell metacharacters stay a single -d argument.
383 cmd = proc.VisibleCommand(spec.Target, "-d", launchPath)
384 case "console":
385 // Never route console openers through cmd.exe / start: working-directory
386 // text would be re-parsed as shell syntax (& | ^ etc.). ShellExecute
387 // opens the binary with lpDirectory set to the workspace path.
388 plan := planWindowsConsoleLaunch(spec.Target, launchPath)
389 if plan.File == "" {
390 return os.ErrNotExist
391 }
392 return shellExecuteOpenFile(plan.File, "", plan.Dir)
393 default:
394 cmd = proc.VisibleCommand(spec.Target, path)
395 }
396 return startDetachedExternalOpener(cmd)
397 }
398
398 lines GO