返回 DeepSeek-Reasonix
external_opener.go
根目录 / desktop / external_opener.go
1 package main
2
3 import (
4 "fmt"
5 "os"
6 "os/exec"
7 "strings"
8 "sync"
9 "time"
10
11 "reasonix/internal/config"
12 )
13
14 const (
15 externalOpenerFileManager = "file-manager"
16 externalOpenerEditor = "editor"
17 externalOpenerTerminal = "terminal"
18 externalOpenerCatalogTTL = 15 * time.Second
19 )
20
21 // ExternalOpenerView is the renderer-safe description of one installed app.
22 // Target executable paths and launch arguments stay in the native shell so the
23 // frontend can never turn this feature into an arbitrary command runner.
24 type ExternalOpenerView struct {
25 ID string `json:"id"`
26 Name string `json:"name"`
27 Kind string `json:"kind"`
28 IconDataURL string `json:"iconDataUrl,omitempty"`
29 }
30
31 // ExternalOpenersView is the complete state for the Codex-style Open control.
32 type ExternalOpenersView struct {
33 Openers []ExternalOpenerView `json:"openers"`
34 Preferred string `json:"preferred"`
35 }
36
37 type externalOpenerSpec struct {
38 View ExternalOpenerView
39 Target string
40 LaunchMode string
41 IconSource string
42 }
43
44 type externalOpenerCatalogCache struct {
45 mu sync.Mutex
46 ttl time.Duration
47 discover func() []externalOpenerSpec
48 now func() time.Time
49 loaded bool
50 loadedAt time.Time
51 specs []externalOpenerSpec
52 refreshDone chan struct{}
53 }
54
55 var platformExternalOpenerCatalog = newExternalOpenerCatalogCache(externalOpenerCatalogTTL, platformExternalOpenerSpecs)
56
57 func newExternalOpenerCatalogCache(ttl time.Duration, discover func() []externalOpenerSpec) *externalOpenerCatalogCache {
58 return &externalOpenerCatalogCache{ttl: ttl, discover: discover, now: time.Now}
59 }
60
61 func cloneExternalOpenerSpecs(specs []externalOpenerSpec) []externalOpenerSpec {
62 return append([]externalOpenerSpec(nil), specs...)
63 }
64
65 func (c *externalOpenerCatalogCache) get() []externalOpenerSpec {
66 for {
67 c.mu.Lock()
68 now := c.now()
69 if c.loaded && now.Sub(c.loadedAt) < c.ttl {
70 specs := cloneExternalOpenerSpecs(c.specs)
71 c.mu.Unlock()
72 return specs
73 }
74 if done := c.refreshDone; done != nil {
75 c.mu.Unlock()
76 <-done
77 continue
78 }
79
80 done := make(chan struct{})
81 c.refreshDone = done
82 discover := c.discover
83 c.mu.Unlock()
84
85 specs := discover()
86
87 c.mu.Lock()
88 c.specs = cloneExternalOpenerSpecs(specs)
89 c.loaded = true
90 c.loadedAt = c.now()
91 c.refreshDone = nil
92 close(done)
93 result := cloneExternalOpenerSpecs(c.specs)
94 c.mu.Unlock()
95 return result
96 }
97 }
98
99 func cachedPlatformExternalOpenerSpecs() []externalOpenerSpec {
100 return platformExternalOpenerCatalog.get()
101 }
102
103 // startDetachedExternalOpener reaps the launched process in the background so
104 // exited children do not pile up as zombies for the desktop app's lifetime.
105 func startDetachedExternalOpener(cmd *exec.Cmd) error {
106 if err := cmd.Start(); err != nil {
107 return err
108 }
109 go func() { _ = cmd.Wait() }()
110 return nil
111 }
112
113 func externalOpenerByID(specs []externalOpenerSpec, id string) (externalOpenerSpec, bool) {
114 id = strings.ToLower(strings.TrimSpace(id))
115 for _, spec := range specs {
116 if spec.View.ID == id {
117 return spec, true
118 }
119 }
120 return externalOpenerSpec{}, false
121 }
122
123 func resolveExternalOpener(specs []externalOpenerSpec, preferred string) (externalOpenerSpec, bool) {
124 if spec, ok := externalOpenerByID(specs, preferred); ok {
125 return spec, true
126 }
127 for _, spec := range specs {
128 if spec.View.Kind == externalOpenerFileManager {
129 return spec, true
130 }
131 }
132 if len(specs) == 0 {
133 return externalOpenerSpec{}, false
134 }
135 return specs[0], true
136 }
137
138 func externalOpenerViews(specs []externalOpenerSpec) []ExternalOpenerView {
139 views := make([]ExternalOpenerView, 0, len(specs))
140 seen := make(map[string]bool, len(specs))
141 for _, spec := range specs {
142 id := strings.ToLower(strings.TrimSpace(spec.View.ID))
143 if id == "" || seen[id] || strings.TrimSpace(spec.View.Name) == "" {
144 continue
145 }
146 spec.View.ID = id
147 views = append(views, spec.View)
148 seen[id] = true
149 }
150 return views
151 }
152
153 func externalOpenerViewsWithIcons(specs []externalOpenerSpec) []ExternalOpenerView {
154 withIcons := make([]externalOpenerSpec, len(specs))
155 copy(withIcons, specs)
156 for i := range withIcons {
157 withIcons[i].View.IconDataURL = externalOpenerIconDataURL(withIcons[i])
158 }
159 return externalOpenerViews(withIcons)
160 }
161
162 func (a *App) preferredExternalOpenerID() string {
163 cfg, _, err := a.loadDesktopUserConfigForView()
164 if err != nil || cfg == nil {
165 return ""
166 }
167 return cfg.DesktopExternalOpener()
168 }
169
170 // ExternalOpeners returns only applications detected on the current machine.
171 // If a preference was copied from another OS or the app was uninstalled, the
172 // returned preferred id falls back without rewriting the user's config.
173 func (a *App) ExternalOpeners() ExternalOpenersView {
174 specs := cachedPlatformExternalOpenerSpecs()
175 views := externalOpenerViewsWithIcons(specs)
176 selected, ok := resolveExternalOpener(specs, a.preferredExternalOpenerID())
177 if !ok {
178 return ExternalOpenersView{Openers: views}
179 }
180 return ExternalOpenersView{Openers: views, Preferred: selected.View.ID}
181 }
182
183 // SetPreferredExternalOpener persists an installed, platform-owned opener id.
184 func (a *App) SetPreferredExternalOpener(id string) error {
185 specs := cachedPlatformExternalOpenerSpecs()
186 spec, ok := externalOpenerByID(specs, id)
187 if !ok {
188 return fmt.Errorf("external opener %q is not available", strings.TrimSpace(id))
189 }
190 return a.applyConfigOnly(func(c *config.Config) error {
191 return c.SetDesktopExternalOpener(spec.View.ID)
192 })
193 }
194
195 // OpenWorkspaceInExternalOpener opens the active workspace using either the
196 // requested installed app or the persisted/fallback selection when id is empty.
197 func (a *App) OpenWorkspaceInExternalOpener(id string) error {
198 return a.OpenWorkspaceInExternalOpenerForTab("", id)
199 }
200
201 // OpenWorkspaceInExternalOpenerForTab is tab-scoped so a rapid tab switch cannot
202 // send the wrong project to an external application.
203 func (a *App) OpenWorkspaceInExternalOpenerForTab(tabID, id string) error {
204 root, _, ok := a.workspaceTargetForTab(tabID)
205 if !ok {
206 return os.ErrNotExist
207 }
208 path, err := workspaceBaseFromRoot(root)
209 if err != nil {
210 return err
211 }
212 info, err := os.Stat(path)
213 if err != nil {
214 return err
215 }
216 if !info.IsDir() {
217 return fmt.Errorf("workspace is not a directory")
218 }
219
220 specs := cachedPlatformExternalOpenerSpecs()
221 var spec externalOpenerSpec
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)
231 }
232
232 lines GO