| 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 |