|
| 1 | +import type { DesktopServerChangeResult, DesktopServerConfiguration } from '@sim/desktop-bridge' |
| 2 | +import { createLogger } from '@sim/logger' |
| 3 | +import { getErrorMessage } from '@sim/utils/errors' |
| 4 | +import { app, BrowserWindow } from 'electron' |
| 5 | +import type { ConfigStore } from '@/main/config' |
| 6 | +import { createSecureWebPreferences } from '@/main/window' |
| 7 | + |
| 8 | +const logger = createLogger('DesktopServerWindow') |
| 9 | + |
| 10 | +/** The bundled local page, resolved the same way the offline page is. */ |
| 11 | +const SERVER_PAGE = 'static/server.html' |
| 12 | + |
| 13 | +const WINDOW_WIDTH = 520 |
| 14 | +const WINDOW_HEIGHT = 340 |
| 15 | + |
| 16 | +/** |
| 17 | + * The partition the server-selection window runs in. |
| 18 | + * |
| 19 | + * Deliberately NOT the app session's partition. This window exists to move the |
| 20 | + * shell between deployments, so binding it to the partition of the deployment |
| 21 | + * being left would tie the escape hatch to the state it is escaping — and the |
| 22 | + * page is a bundled `file:` document that stores nothing, so it has no reason |
| 23 | + * to touch a persistent jar at all. |
| 24 | + */ |
| 25 | +const SERVER_WINDOW_PARTITION = 'server-selection' |
| 26 | + |
| 27 | +export interface ServerWindowDeps { |
| 28 | + config: ConfigStore |
| 29 | + defaultOrigin: string |
| 30 | + preloadPath: string |
| 31 | + isPackaged: boolean |
| 32 | + getParentWindow: () => BrowserWindow | null |
| 33 | + /** |
| 34 | + * Relaunches the shell against the newly stored origin. A full restart |
| 35 | + * rather than an in-place swap: the origin decides the cookie partition, the |
| 36 | + * update feed, the encrypted per-origin task state, and the identity every |
| 37 | + * live browser view and PTY was opened under, and there is no partial |
| 38 | + * teardown of that set which is obviously correct. |
| 39 | + */ |
| 40 | + relaunch: () => void |
| 41 | +} |
| 42 | + |
| 43 | +export interface ServerWindowHandle { |
| 44 | + open(): void |
| 45 | + getConfiguration(): DesktopServerConfiguration |
| 46 | + setOrigin(origin: string): DesktopServerChangeResult |
| 47 | + close(): void |
| 48 | +} |
| 49 | + |
| 50 | +/** |
| 51 | + * The native server picker: how a self-hosted operator points the shell at |
| 52 | + * their own deployment. |
| 53 | + * |
| 54 | + * Native rather than a page in the web app, because the web app is served BY |
| 55 | + * the origin being changed. Someone whose stored origin is unreachable — a |
| 56 | + * typo, a VPN-only host, an instance that moved — can never reach an in-app |
| 57 | + * settings route to fix it, which is exactly when they need this most. The |
| 58 | + * same reasoning gates its IPC channels to bundled `file:` senders. |
| 59 | + */ |
| 60 | +export function createServerWindow(deps: ServerWindowDeps): ServerWindowHandle { |
| 61 | + let win: BrowserWindow | null = null |
| 62 | + |
| 63 | + const getConfiguration = (): DesktopServerConfiguration => ({ |
| 64 | + origin: deps.config.getOrigin(), |
| 65 | + defaultOrigin: deps.defaultOrigin, |
| 66 | + }) |
| 67 | + |
| 68 | + const close = (): void => { |
| 69 | + if (win && !win.isDestroyed()) { |
| 70 | + win.destroy() |
| 71 | + } |
| 72 | + win = null |
| 73 | + } |
| 74 | + |
| 75 | + const open = (): void => { |
| 76 | + if (win && !win.isDestroyed()) { |
| 77 | + win.show() |
| 78 | + win.focus() |
| 79 | + return |
| 80 | + } |
| 81 | + const parent = deps.getParentWindow() |
| 82 | + win = new BrowserWindow({ |
| 83 | + width: WINDOW_WIDTH, |
| 84 | + height: WINDOW_HEIGHT, |
| 85 | + resizable: false, |
| 86 | + minimizable: false, |
| 87 | + maximizable: false, |
| 88 | + fullscreenable: false, |
| 89 | + title: 'Sim Server', |
| 90 | + titleBarStyle: 'hiddenInset', |
| 91 | + show: false, |
| 92 | + // Modal only when there is a live parent to attach to. A shell whose |
| 93 | + // window is gone (or never opened, because the origin failed to load) |
| 94 | + // still has to be able to reach this. |
| 95 | + ...(parent && !parent.isDestroyed() ? { parent, modal: true } : {}), |
| 96 | + webPreferences: createSecureWebPreferences( |
| 97 | + SERVER_WINDOW_PARTITION, |
| 98 | + deps.preloadPath, |
| 99 | + deps.isPackaged |
| 100 | + ), |
| 101 | + }) |
| 102 | + win.once('ready-to-show', () => { |
| 103 | + win?.show() |
| 104 | + }) |
| 105 | + win.on('closed', () => { |
| 106 | + win = null |
| 107 | + }) |
| 108 | + void win.loadFile(SERVER_PAGE).catch((error) => { |
| 109 | + logger.error('Could not open the server window', { error: getErrorMessage(error) }) |
| 110 | + }) |
| 111 | + } |
| 112 | + |
| 113 | + const setOrigin = (raw: string): DesktopServerChangeResult => { |
| 114 | + const current = deps.config.getOrigin() |
| 115 | + const validated = deps.config.setOrigin(raw) |
| 116 | + if (!validated.ok) { |
| 117 | + return validated |
| 118 | + } |
| 119 | + if (validated.origin === current) { |
| 120 | + // Nothing moved, so nothing is torn down. Relaunching anyway would make |
| 121 | + // "confirm the URL I already use" restart the app for no reason. |
| 122 | + return { ok: true, origin: validated.origin, unchanged: true } |
| 123 | + } |
| 124 | + logger.info('Server origin changed; relaunching', { from: current, to: validated.origin }) |
| 125 | + // setOrigin writes through immediately, but the rest of the settings file |
| 126 | + // (window bounds, last route) is debounced — flush before the process goes. |
| 127 | + deps.config.flush() |
| 128 | + close() |
| 129 | + deps.relaunch() |
| 130 | + return { ok: true, origin: validated.origin, unchanged: false } |
| 131 | + } |
| 132 | + |
| 133 | + return { open, getConfiguration, setOrigin, close } |
| 134 | +} |
| 135 | + |
| 136 | +/** Restarts the process in place. Split out so tests can drive the seam. */ |
| 137 | +export function relaunchApp(): void { |
| 138 | + app.relaunch() |
| 139 | + app.quit() |
| 140 | +} |
0 commit comments