返回 DeepSeek-Reasonix
desktop_shell.go
根目录 / desktop / desktop_shell.go
1 package main
2
3 import (
4 "context"
5 "log/slog"
6 goruntime "runtime"
7 "sync"
8 "time"
9 )
10
11 const (
12 desktopShellEvent = "desktop:shell-status"
13
14 desktopDOMReadyTimeout = 3 * time.Second
15 desktopFrontendReadyTimeout = 15 * time.Second
16 )
17
18 type desktopShellPhase string
19
20 const (
21 desktopShellStarting desktopShellPhase = "starting"
22 desktopShellDOMReady desktopShellPhase = "dom_ready"
23 desktopShellFrontendReady desktopShellPhase = "frontend_ready"
24 desktopShellVisible desktopShellPhase = "visible"
25 desktopShellBackgroundHidden desktopShellPhase = "background_hidden"
26 desktopShellFailed desktopShellPhase = "failed"
27 )
28
29 // desktopShellCoordinator is the single owner of main-window lifecycle state.
30 // Native window commands stay behind the nativeHost boundary, while every
31 // startup, tray, second-instance, menu and watchdog presentation goes through
32 // Present so platform ordering cannot drift again.
33 type desktopShellCoordinator struct {
34 app *App
35
36 mu sync.Mutex
37 phase desktopShellPhase
38 domReady bool
39 frontendReady bool
40 frontendFirstAt time.Time
41 healthy bool
42 presented bool
43 backgroundHidden bool
44 watchdogCancel context.CancelFunc
45 presentOverride func(string) // test-only, set before concurrent use
46 }
47
48 func newDesktopShellCoordinator(app *App) *desktopShellCoordinator {
49 return &desktopShellCoordinator{app: app, phase: desktopShellStarting}
50 }
51
52 func (c *desktopShellCoordinator) start(ctx context.Context) {
53 if c == nil || c.app == nil {
54 return
55 }
56 c.mu.Lock()
57 if c.watchdogCancel != nil {
58 c.watchdogCancel()
59 }
60 watchdogCtx, cancel := context.WithCancel(ctx)
61 c.watchdogCancel = cancel
62 c.phase = desktopShellStarting
63 c.mu.Unlock()
64
65 c.app.goSafe("desktopDOMReadyWatchdog", func() {
66 timer := time.NewTimer(desktopDOMReadyTimeout)
67 defer timer.Stop()
68 select {
69 case <-watchdogCtx.Done():
70 return
71 case <-timer.C:
72 }
73 c.mu.Lock()
74 ready := c.domReady
75 c.mu.Unlock()
76 if !ready {
77 // A native surface is more useful than a StartHidden process with no
78 // visible recovery path, even when the renderer is still starting.
79 c.app.showMainWindowFrom("startup_dom_timeout")
80 }
81 })
82
83 c.app.goSafe("desktopFrontendReadyWatchdog", func() {
84 timer := time.NewTimer(desktopFrontendReadyTimeout)
85 defer timer.Stop()
86 select {
87 case <-watchdogCtx.Done():
88 return
89 case <-timer.C:
90 }
91 c.mu.Lock()
92 ready := c.frontendReady
93 if !ready {
94 c.phase = desktopShellFailed
95 }
96 c.mu.Unlock()
97 if !ready {
98 c.app.handleDesktopFrontendTimeout("startup")
99 }
100 })
101 }
102
103 func (c *desktopShellCoordinator) stop() {
104 if c == nil {
105 return
106 }
107 c.mu.Lock()
108 if c.watchdogCancel != nil {
109 c.watchdogCancel()
110 c.watchdogCancel = nil
111 }
112 c.mu.Unlock()
113 }
114
115 func (c *desktopShellCoordinator) markDOMReady() {
116 if c == nil {
117 return
118 }
119 c.mu.Lock()
120 c.domReady = true
121 if c.presented {
122 c.phase = desktopShellVisible
123 } else if !c.frontendReady {
124 c.phase = desktopShellDOMReady
125 }
126 c.mu.Unlock()
127 }
128
129 // markFrontendHeartbeat separates the first React + host bridge frame from a
130 // stable renderer. Health requires a later heartbeat at least two seconds
131 // after the first, so one lucky bridge call cannot commit update/LKG state.
132 func (c *desktopShellCoordinator) markFrontendHeartbeat(now time.Time) (first, healthy bool) {
133 if c == nil {
134 return false, false
135 }
136 c.mu.Lock()
137 first = !c.frontendReady
138 c.frontendReady = true
139 if first {
140 c.frontendFirstAt = now
141 }
142 healthy = !c.healthy && !c.frontendFirstAt.IsZero() && now.Sub(c.frontendFirstAt) >= 2*time.Second
143 if healthy {
144 c.healthy = true
145 }
146 if c.backgroundHidden {
147 c.phase = desktopShellBackgroundHidden
148 } else if c.presented {
149 c.phase = desktopShellVisible
150 } else {
151 c.phase = desktopShellFrontendReady
152 }
153 if c.watchdogCancel != nil {
154 c.watchdogCancel()
155 c.watchdogCancel = nil
156 }
157 c.mu.Unlock()
158 return first, healthy
159 }
160
161 func (c *desktopShellCoordinator) Present(source string) {
162 if c == nil || c.app == nil || c.app.ctx == nil {
163 return
164 }
165 c.mu.Lock()
166 wasMaximised := c.app.backgroundMaximised.Swap(false)
167 applyDesktopPresentPlan(c.app.ctx, c.app.nativeHost(), desktopPresentPlanFor(goruntime.GOOS, wasMaximised))
168 c.backgroundHidden = false
169 c.presented = true
170 c.phase = desktopShellVisible
171 c.mu.Unlock()
172 slog.Debug("desktop: present main window", "source", metricBucket(source), "platform", goruntime.GOOS)
173 }
174
175 // hideToBackground linearizes the final tray check with the hide transition.
176 // If the tray disappears immediately afterwards, trayStateChanged waits for
177 // this critical section and re-presents the now-hidden window.
178 func (c *desktopShellCoordinator) hideToBackground(ctx context.Context, canHide func() bool) bool {
179 if c == nil {
180 return false
181 }
182 c.mu.Lock()
183 defer c.mu.Unlock()
184 if canHide != nil && !canHide() {
185 return false
186 }
187 c.backgroundHidden = true
188 c.presented = false
189 c.phase = desktopShellBackgroundHidden
190 hideForBackground(ctx, c.app.nativeHost())
191 return true
192 }
193
194 func (c *desktopShellCoordinator) trayStateChanged(ready bool) {
195 if c == nil || ready {
196 return
197 }
198 c.mu.Lock()
199 hidden := c.backgroundHidden
200 c.mu.Unlock()
201 if hidden {
202 if c.presentOverride != nil {
203 c.presentOverride("tray_unavailable")
204 } else {
205 c.app.showMainWindowFrom("tray_unavailable")
206 }
207 }
208 }
209
210 type desktopPresentAction uint8
211
212 const (
213 desktopPresentApplicationShow desktopPresentAction = iota + 1
214 desktopPresentMaximise
215 desktopPresentWindowShow
216 desktopPresentUnminimise
217 )
218
219 // Electron's restore only unminimises a window; unlike the retired GTK
220 // gtk_window_present path it does not present a hidden Linux window. Every
221 // platform must explicitly show it, preserving maximised state when requested.
222 func desktopPresentPlanFor(goos string, wasMaximised bool) []desktopPresentAction {
223 actions := make([]desktopPresentAction, 0, 3)
224 if goos == "darwin" {
225 actions = append(actions, desktopPresentApplicationShow)
226 }
227 if wasMaximised && goos != "darwin" {
228 actions = append(actions, desktopPresentMaximise, desktopPresentWindowShow)
229 return actions
230 }
231 return append(actions, desktopPresentWindowShow, desktopPresentUnminimise)
232 }
233
234 func applyDesktopPresentPlan(ctx context.Context, host nativeHost, actions []desktopPresentAction) {
235 for _, action := range actions {
236 switch action {
237 case desktopPresentApplicationShow:
238 host.ShowApplication(ctx)
239 case desktopPresentMaximise:
240 host.MaximiseWindow(ctx)
241 case desktopPresentWindowShow:
242 host.ShowWindow(ctx)
243 case desktopPresentUnminimise:
244 host.UnminimiseWindow(ctx)
245 }
246 }
247 }
248
249 // showMainWindowFrom presents the main window through the shell coordinator
250 // (or the raw present plan when no coordinator is attached, e.g. tests).
251 func (a *App) showMainWindowFrom(source string) {
252 if a.ctx == nil {
253 return
254 }
255 if a.desktopShell.coordinator != nil {
256 a.desktopShell.coordinator.Present(source)
257 } else {
258 applyDesktopPresentPlan(a.ctx, a.nativeHost(), desktopPresentPlanFor("", a.backgroundMaximised.Swap(false)))
259 }
260 a.kickDeferredRebuildRetry()
261 }
262
263 // handleDesktopFrontendTimeout fires when the frontend heartbeat never
264 // arrived. The Electron shell reloads a crashed renderer itself; a page that
265 // is alive but never responsive still deserves a presented window and a
266 // diagnostics trail instead of a hidden process.
267 func (a *App) handleDesktopFrontendTimeout(source string) {
268 if a == nil {
269 return
270 }
271 slog.Warn("desktop: frontend never became ready", "source", metricBucket(source))
272 a.recordDiagnosticMetric("desktop_frontend", "ready_timeout."+metricBucket(source))
273 a.showMainWindowFrom("frontend_ready_timeout")
274 }
275
275 lines GO