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