返回 DeepSeek-Reasonix
external_opener.go
根目录 / desktop / external_opener.go
1 package main
2
3 import (
4 "errors"
5 "fmt"
6 "io"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strings"
11 "sync"
12 "time"
13
14 "reasonix/internal/config"
15 )
16
17 const (
18 externalOpenerFileManager = "file-manager"
19 externalOpenerEditor = "editor"
20 externalOpenerTerminal = "terminal"
21 externalOpenerCatalogTTL = 15 * time.Second
22 )
23
24 // ExternalOpenerView is the renderer-safe description of one installed app.
25 // Target executable paths and launch arguments stay in the native shell so the
26 // frontend can never turn this feature into an arbitrary command runner.
27 type ExternalOpenerView struct {
28 ID string `json:"id"`
29 Name string `json:"name"`
30 Kind string `json:"kind"`
31 IconDataURL string `json:"iconDataUrl,omitempty"`
32 }
33
34 // ExternalOpenersView is the complete state for the Codex-style Open control.
35 type ExternalOpenersView struct {
36 Openers []ExternalOpenerView `json:"openers"`
37 Preferred string `json:"preferred"`
38 WorkspaceOpenable bool `json:"workspaceOpenable,omitempty"`
39 }
40
41 type externalOpenerSpec struct {
42 View ExternalOpenerView
43 Target string
44 LaunchMode string
45 IconSource string
46 }
47
48 type externalOpenerCatalogCache struct {
49 mu sync.Mutex
50 ttl time.Duration
51 discover func() []externalOpenerSpec
52 now func() time.Time
53 loaded bool
54 loadedAt time.Time
55 specs []externalOpenerSpec
56 refreshDone chan struct{}
57 }
58
59 var platformExternalOpenerCatalog = newExternalOpenerCatalogCache(externalOpenerCatalogTTL, platformExternalOpenerSpecs)
60
61 func newExternalOpenerCatalogCache(ttl time.Duration, discover func() []externalOpenerSpec) *externalOpenerCatalogCache {
62 return &externalOpenerCatalogCache{ttl: ttl, discover: discover, now: time.Now}
63 }
64
65 func cloneExternalOpenerSpecs(specs []externalOpenerSpec) []externalOpenerSpec {
66 return append([]externalOpenerSpec(nil), specs...)
67 }
68
69 func (c *externalOpenerCatalogCache) get() []externalOpenerSpec {
70 for {
71 c.mu.Lock()
72 now := c.now()
73 if c.loaded && now.Sub(c.loadedAt) < c.ttl {
74 specs := cloneExternalOpenerSpecs(c.specs)
75 c.mu.Unlock()
76 return specs
77 }
78 if done := c.refreshDone; done != nil {
79 c.mu.Unlock()
80 <-done
81 continue
82 }
83
84 done := make(chan struct{})
85 c.refreshDone = done
86 discover := c.discover
87 c.mu.Unlock()
88
89 specs := discover()
90
91 c.mu.Lock()
92 c.specs = cloneExternalOpenerSpecs(specs)
93 c.loaded = true
94 c.loadedAt = c.now()
95 c.refreshDone = nil
96 close(done)
97 result := cloneExternalOpenerSpecs(c.specs)
98 c.mu.Unlock()
99 return result
100 }
101 }
102
103 func cachedPlatformExternalOpenerSpecs() []externalOpenerSpec {
104 return platformExternalOpenerCatalog.get()
105 }
106
107 // startDetachedExternalOpener reaps the launched process in the background so
108 // exited children do not pile up as zombies for the desktop app's lifetime.
109 func startDetachedExternalOpener(cmd *exec.Cmd) error {
110 if err := cmd.Start(); err != nil {
111 return err
112 }
113 go func() { _ = cmd.Wait() }()
114 return nil
115 }
116
117 func externalOpenerByID(specs []externalOpenerSpec, id string) (externalOpenerSpec, bool) {
118 id = strings.ToLower(strings.TrimSpace(id))
119 for _, spec := range specs {
120 if spec.View.ID == id {
121 return spec, true
122 }
123 }
124 return externalOpenerSpec{}, false
125 }
126
127 func resolveExternalOpener(specs []externalOpenerSpec, preferred string) (externalOpenerSpec, bool) {
128 if spec, ok := externalOpenerByID(specs, preferred); ok {
129 return spec, true
130 }
131 for _, spec := range specs {
132 if spec.View.Kind == externalOpenerFileManager {
133 return spec, true
134 }
135 }
136 if len(specs) == 0 {
137 return externalOpenerSpec{}, false
138 }
139 return specs[0], true
140 }
141
142 func externalOpenerViews(specs []externalOpenerSpec) []ExternalOpenerView {
143 views := make([]ExternalOpenerView, 0, len(specs))
144 seen := make(map[string]bool, len(specs))
145 for _, spec := range specs {
146 id := strings.ToLower(strings.TrimSpace(spec.View.ID))
147 if id == "" || seen[id] || strings.TrimSpace(spec.View.Name) == "" {
148 continue
149 }
150 spec.View.ID = id
151 views = append(views, spec.View)
152 seen[id] = true
153 }
154 return views
155 }
156
157 func externalOpenerViewsWithIcons(specs []externalOpenerSpec) []ExternalOpenerView {
158 withIcons := make([]externalOpenerSpec, len(specs))
159 copy(withIcons, specs)
160 for i := range withIcons {
161 withIcons[i].View.IconDataURL = externalOpenerIconDataURL(withIcons[i])
162 }
163 return externalOpenerViews(withIcons)
164 }
165
166 func (a *App) preferredExternalOpenerID() string {
167 cfg, _, err := a.loadDesktopUserConfigForView()
168 if err != nil || cfg == nil {
169 return ""
170 }
171 return cfg.DesktopExternalOpener()
172 }
173
174 // ExternalOpeners returns only applications detected on the current machine.
175 // If a preference was copied from another OS or the app was uninstalled, the
176 // returned preferred id falls back without rewriting the user's config.
177 func (a *App) ExternalOpeners() ExternalOpenersView {
178 specs := cachedPlatformExternalOpenerSpecs()
179 views := externalOpenerViewsWithIcons(specs)
180 selected, ok := resolveExternalOpener(specs, a.preferredExternalOpenerID())
181 if !ok {
182 return ExternalOpenersView{Openers: views}
183 }
184 return ExternalOpenersView{Openers: views, Preferred: selected.View.ID}
185 }
186
187 // ExternalOpenersForTab adds the tab-scoped local-workspace capability used by
188 // the chat-header Open control. Scope is intentionally not part of the check:
189 // both project tabs and Global tabs can own a real local workspace directory.
190 func (a *App) ExternalOpenersForTab(tabID string) ExternalOpenersView {
191 if _, err := a.externalOpenerWorkspacePathForTab(tabID); err != nil {
192 return ExternalOpenersView{Openers: []ExternalOpenerView{}}
193 }
194 view := a.ExternalOpeners()
195 view.WorkspaceOpenable = true
196 return view
197 }
198
199 // SetPreferredExternalOpener persists an installed, platform-owned opener id.
200 func (a *App) SetPreferredExternalOpener(id string) error {
201 specs := cachedPlatformExternalOpenerSpecs()
202 spec, ok := externalOpenerByID(specs, id)
203 if !ok {
204 return fmt.Errorf("external opener %q is not available", strings.TrimSpace(id))
205 }
206 return a.applyConfigOnly(func(c *config.Config) error {
207 return c.SetDesktopExternalOpener(spec.View.ID)
208 })
209 }
210
211 // OpenWorkspaceInExternalOpenerForTab is tab-scoped so a rapid tab switch cannot
212 // send the wrong project to an external application.
213 func (a *App) OpenWorkspaceInExternalOpenerForTab(tabID, id string) error {
214 path, err := a.externalOpenerWorkspacePathForTab(tabID)
215 if err != nil {
216 return err
217 }
218
219 specs := cachedPlatformExternalOpenerSpecs()
220 var spec externalOpenerSpec
221 var ok bool
222 if strings.TrimSpace(id) == "" {
223 spec, ok = resolveExternalOpener(specs, a.preferredExternalOpenerID())
224 } else {
225 spec, ok = externalOpenerByID(specs, id)
226 }
227 if !ok {
228 return fmt.Errorf("external opener %q is not available", strings.TrimSpace(id))
229 }
230 return launchPlatformExternalOpener(spec, path, true)
231 }
232
233 func (a *App) externalOpenerWorkspacePathForTab(tabID string) (string, error) {
234 root, _, ok := a.workspaceTargetForTab(tabID)
235 if !ok {
236 return "", os.ErrNotExist
237 }
238 // A bound tab with no root has no stable workspace to expose. Keep the legacy
239 // no-tab current-directory fallback, but do not turn an incomplete tab into
240 // an opener for the Desktop process's launch directory.
241 if strings.TrimSpace(root) == "" {
242 return "", os.ErrNotExist
243 }
244 path, err := workspaceBaseFromRoot(root)
245 if err != nil {
246 return "", err
247 }
248 info, err := os.Stat(path)
249 if err != nil {
250 return "", err
251 }
252 if !info.IsDir() {
253 return "", fmt.Errorf("workspace is not a directory")
254 }
255 return path, nil
256 }
257
258 // OpenLocalPathInExternalOpener opens an absolute local path with one of the
259 // detected, platform-owned applications. It shares OpenLocalPath's safety
260 // checks so a markdown link cannot turn an AI-generated executable path into
261 // an executable launch.
262 func (a *App) OpenLocalPathInExternalOpener(path, id string) error {
263 path, err := normalizeLocalOpenPath(path)
264 if err != nil {
265 return err
266 }
267 info, err := os.Stat(path)
268 if err != nil {
269 return err
270 }
271 if !openTargetPathAllowed(path, info) {
272 return fmt.Errorf("refusing to open executable target %q", path)
273 }
274
275 specs := cachedPlatformExternalOpenerSpecs()
276 var spec externalOpenerSpec
277 var ok bool
278 if strings.TrimSpace(id) == "" {
279 spec, ok = resolveExternalOpener(specs, a.preferredExternalOpenerID())
280 } else {
281 spec, ok = externalOpenerByID(specs, id)
282 }
283 if !ok {
284 return fmt.Errorf("external opener %q is not available", strings.TrimSpace(id))
285 }
286 return launchPlatformExternalOpener(spec, path, info.IsDir())
287 }
288
289 // SaveLocalPathAs copies a local file to a user-selected destination without
290 // changing the source. Directories are intentionally excluded: Finder's
291 // reveal action is the appropriate operation for them.
292 func (a *App) SaveLocalPathAs(path string) (string, error) {
293 path, err := normalizeLocalOpenPath(path)
294 if err != nil {
295 return "", err
296 }
297 info, err := os.Stat(path)
298 if err != nil {
299 return "", err
300 }
301 if info.IsDir() {
302 return "", fmt.Errorf("cannot save a directory as a file")
303 }
304 if a.ctx == nil {
305 return "", nil
306 }
307 target, err := a.nativeHost().SaveFileDialog(a.ctx, nativeDialogOptions{
308 Title: "Save file as",
309 DefaultDirectory: filepath.Dir(path),
310 DefaultFilename: filepath.Base(path),
311 CanCreateDirectories: true,
312 })
313 if err != nil || target == "" {
314 return "", err
315 }
316 if filepath.Clean(target) == filepath.Clean(path) {
317 return "", fmt.Errorf("destination is the same as the source")
318 }
319 if err := copyLocalPathAs(path, target); err != nil {
320 return "", err
321 }
322 return target, nil
323 }
324
325 func copyLocalPathAs(path, target string) (err error) {
326 src, err := os.Open(path)
327 if err != nil {
328 return err
329 }
330 defer src.Close()
331 sourceInfo, err := src.Stat()
332 if err != nil {
333 return err
334 }
335 if sourceInfo.IsDir() {
336 return fmt.Errorf("cannot save a directory as a file")
337 }
338 sameFile, err := localSaveDestinationIsSource(sourceInfo, target)
339 if err != nil {
340 return err
341 }
342 if sameFile {
343 return fmt.Errorf("destination is the same as the source")
344 }
345
346 tmp, err := os.CreateTemp(filepath.Dir(target), "."+filepath.Base(target)+".reasonix-copy-*")
347 if err != nil {
348 return err
349 }
350 tmpName := tmp.Name()
351 defer func() {
352 if tmpName != "" {
353 _ = os.Remove(tmpName)
354 }
355 }()
356 if _, err = io.Copy(tmp, src); err != nil {
357 _ = tmp.Close()
358 return err
359 }
360 if err = tmp.Sync(); err != nil {
361 _ = tmp.Close()
362 return err
363 }
364 if err = tmp.Chmod(sourceInfo.Mode().Perm()); err != nil {
365 _ = tmp.Close()
366 return err
367 }
368 if err = tmp.Close(); err != nil {
369 return err
370 }
371 if err = replaceLocalSaveDestination(tmpName, target); err != nil {
372 return err
373 }
374 tmpName = ""
375 return nil
376 }
377
378 // localSaveDestinationIsSource compares filesystem identity, not just path
379 // spelling. os.Stat follows aliases, so this catches case-insensitive paths,
380 // symlinks, and hard links before the destination is replaced.
381 func localSaveDestinationIsSource(sourceInfo os.FileInfo, target string) (bool, error) {
382 targetInfo, err := os.Stat(target)
383 if errors.Is(err, os.ErrNotExist) {
384 return false, nil
385 }
386 if err != nil {
387 return false, err
388 }
389 return os.SameFile(sourceInfo, targetInfo), nil
390 }
391
392 // externalOpenerWorkingDirectory returns a valid directory for process launch.
393 // Editors still receive the original file path as an argument; only the
394 // process CWD changes when the requested path is a regular file.
395 func externalOpenerWorkingDirectory(path string) string {
396 info, err := os.Stat(path)
397 if err == nil && !info.IsDir() {
398 return filepath.Dir(path)
399 }
400 return path
401 }
402
403 func externalOpenerLaunchPath(spec externalOpenerSpec, path string) string {
404 if spec.View.Kind == externalOpenerTerminal || isTerminalLaunchMode(spec.LaunchMode) {
405 return externalOpenerWorkingDirectory(path)
406 }
407 return path
408 }
409
410 func isTerminalLaunchMode(mode string) bool {
411 switch mode {
412 case "ghostty", "gnome-terminal", "konsole", "kitty", "alacritty", "cwd", "windows-terminal", "console":
413 return true
414 default:
415 return false
416 }
417 }
418
418 lines GO