返回 DeepSeek-Reasonix
host_shell.go
根目录 / desktop / host_shell.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "log/slog"
7 "os"
8 "runtime"
9 "sync"
10
11 "reasonix/desktop/internal/hostrpc"
12 )
13
14 // hostShellBridge is present only when the Electron shell drives this
15 // process. It replaces the in-process tray and the native quit hooks with
16 // host/* requests and routes the shell's hostEvent notifications back into
17 // the App entry points the Wails callbacks used to call.
18 type hostShellBridge struct {
19 app *App
20 server *hostrpc.Server
21 remoteMu sync.Mutex
22 remote *hostRemoteWindows
23 }
24
25 func (a *App) hostMode() bool { return a != nil && a.hostShell != nil }
26
27 type hostTrayLabels struct {
28 OpenTitle string `json:"openTitle"`
29 OpenTooltip string `json:"openTooltip"`
30 QuitTitle string `json:"quitTitle"`
31 QuitTooltip string `json:"quitTooltip"`
32 Tooltip string `json:"tooltip"`
33 }
34
35 type hostTrayResult struct {
36 Ready bool `json:"ready"`
37 Reason string `json:"reason"`
38 }
39
40 func (b *hostShellBridge) trayLabels(locale string) hostTrayLabels {
41 labels := trayMenuLabels(locale)
42 return hostTrayLabels{
43 OpenTitle: labels.openTitle,
44 OpenTooltip: labels.openTooltip,
45 QuitTitle: labels.quitTitle,
46 QuitTooltip: labels.quitTooltip,
47 Tooltip: "Reasonix",
48 }
49 }
50
51 func (b *hostShellBridge) startTray() bool {
52 a := b.app
53 if a.shuttingDown.Load() || a.forceQuit.Load() {
54 return false
55 }
56 a.mu.Lock()
57 if a.tray != nil {
58 a.mu.Unlock()
59 return true
60 }
61 t := newDesktopTray()
62 a.tray = t
63 a.desktopShell.trayState = "probing"
64 a.desktopShell.trayReason = ""
65 a.trayReady = false
66 a.mu.Unlock()
67
68 ctx, cancel := context.WithTimeout(context.Background(), rpcHostWindowTimeout)
69 defer cancel()
70 var result hostTrayResult
71 err := b.server.Request(ctx, "host/tray.ensure", b.trayLabels(a.trayLocale()), &result)
72 switch {
73 case err != nil:
74 a.setTrayHealth(t, "unavailable", "host_unreachable")
75 case !result.Ready:
76 reason := result.Reason
77 if reason == "" {
78 reason = "platform_no_tray"
79 }
80 a.setTrayHealth(t, "unavailable", reason)
81 default:
82 a.setTrayHealth(t, "ready", "")
83 }
84 if !result.Ready || err != nil {
85 a.mu.Lock()
86 if a.tray == t {
87 a.tray = nil
88 }
89 a.mu.Unlock()
90 return false
91 }
92 return true
93 }
94
95 func (b *hostShellBridge) stopTray() {
96 a := b.app
97 a.mu.Lock()
98 t := a.tray
99 a.tray = nil
100 a.mu.Unlock()
101 if t == nil {
102 return
103 }
104 ctx, cancel := context.WithTimeout(context.Background(), rpcHostWindowTimeout)
105 defer cancel()
106 _ = b.server.Request(ctx, "host/tray.destroy", struct{}{}, nil)
107 a.setTrayHealth(nil, "unavailable", "tray_exited")
108 }
109
110 func (b *hostShellBridge) updateTrayLocale(locale string) {
111 a := b.app
112 a.mu.RLock()
113 present := a.tray != nil
114 a.mu.RUnlock()
115 if !present {
116 return
117 }
118 ctx, cancel := context.WithTimeout(context.Background(), rpcHostWindowTimeout)
119 defer cancel()
120 _ = b.server.Request(ctx, "host/tray.ensure", b.trayLabels(locale), nil)
121 }
122
123 // handleHostEvent maps shell-originated events onto the App entry points
124 // that Wails callbacks (second instance, tray menu, app menu) used to call.
125 func (b *hostShellBridge) handleHostEvent(_ context.Context, name string, payload json.RawMessage) error {
126 a := b.app
127 switch name {
128 case "remoteWindow.closed":
129 b.remoteWindowClosed(payload)
130 case "secondInstance":
131 a.goSafe("secondInstanceLaunch", a.secondInstanceLaunch)
132 case "tray.open":
133 a.goSafe("showFromTray", a.showFromTray)
134 case "tray.quit":
135 a.goSafe("quitFromTray", a.quitFromTray)
136 case "menu.showWindow":
137 a.goSafe("showMainWindow", a.showMainWindow)
138 default:
139 slog.Debug("desktop host: ignoring host event", "name", name)
140 }
141 return nil
142 }
143
144 // beforeClose translates the shell's close reason into the same signal the
145 // macOS Cmd+Q hook raised, so quit and updater exits bypass background close.
146 func (b *hostShellBridge) beforeClose(ctx context.Context, reason string) bool {
147 if reason == "quit" || reason == "updater" {
148 markSystemQuitRequested()
149 }
150 return b.app.beforeClose(ctx)
151 }
152
153 // startNativeShellSupport repairs the installed bundle's icon integration.
154 // Under the Electron shell the service owns no native UI thread, so the shell
155 // owns the window-level hooks; this runs only for a bare service launch.
156 func (a *App) startNativeShellSupport() {
157 if a.hostMode() {
158 return
159 }
160 a.goSafe("repairDesktopIconIntegration", func() {
161 if err := repairDesktopIconIntegration(); err != nil {
162 slog.Debug("desktop: repair native icon integration", "err", err)
163 }
164 })
165 }
166
167 // relaunch asks the shell to restart the whole application; the shell then
168 // drives beforeClose and shutdown, so the caller must not exit on its own.
169 func (b *hostShellBridge) relaunch() error {
170 ctx, cancel := context.WithTimeout(context.Background(), rpcHostWindowTimeout)
171 defer cancel()
172 execPath := ""
173 if runtime.GOOS != "darwin" {
174 execPath = currentLauncherPath()
175 }
176 return b.server.Request(ctx, "host/app.relaunch", struct {
177 Args []string `json:"args"`
178 ExecPath string `json:"execPath,omitempty"`
179 }{Args: []string{}, ExecPath: execPath}, nil)
180 }
181
182 // quit asks the shell to shut the application down without restarting it.
183 func (b *hostShellBridge) quit() error {
184 ctx, cancel := context.WithTimeout(context.Background(), rpcHostWindowTimeout)
185 defer cancel()
186 return b.server.Request(ctx, "host/app.quit", struct{}{}, nil)
187 }
188
189 // relaunchDesktop restarts Reasonix after an update. Under the shell the
190 // restart is owned by Electron; the Wails build exits itself after handing
191 // off to the thin launcher.
192 func (a *App) relaunchDesktop(relaunchBinary bool) {
193 if a.hostMode() {
194 var err error
195 if relaunchBinary {
196 err = a.hostShell.relaunch()
197 } else {
198 err = a.hostShell.quit()
199 }
200 if err != nil {
201 slog.Warn("desktop host: relaunch request failed", "err", err)
202 }
203 return
204 }
205 a.shutdown(a.ctx)
206 if relaunchBinary {
207 _ = relaunchThroughLauncher()
208 }
209 os.Exit(0)
210 }
211
212 // relaunchAfterPortableUpdate restarts Reasonix once the platform installer
213 // owns the swap. Under the shell on macOS the detached hand-off reopens the
214 // swapped bundle itself, so the shell must only quit.
215 func (a *App) relaunchAfterPortableUpdate() {
216 if runtime.GOOS == "darwin" && a.hostMode() {
217 if err := a.hostShell.quit(); err != nil {
218 slog.Warn("desktop host: quit request failed", "err", err)
219 }
220 return
221 }
222 a.relaunchDesktop(runtime.GOOS == "linux")
223 }
224
225 // updateHandoffOwnerPID is the process the platform update helper waits for
226 // before it swaps the install: the Electron shell, which is the service's
227 // parent and holds the bundle open, under the shell; this process under Wails.
228 func (a *App) updateHandoffOwnerPID() int {
229 if a.hostMode() {
230 return os.Getppid()
231 }
232 return os.Getpid()
233 }
234
234 lines GO