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