Skip to content

Commit 0434578

Browse files
committed
feat(desktop,cli): support self-hosted desktop installs
The desktop shell was already origin-agnostic at runtime — navigation, CSP, cookie partition, and the update feed all derive from the configured origin, and every deployment already serves /api/desktop/update/download and its updater manifest. The one thing missing was a way to change that origin: ConfigStore.setOrigin had no IPC channel, menu item, or UI behind it, so a self-hoster installing the signed build was stuck on the baked default. Adds the native server picker (Sim → Server…, plus a "Change server" button on the offline page, since a shell pointed at an unreachable origin lands there with nothing else to click). Its IPC family is gated to bundled file: senders: the surface that repoints the shell must keep working when the current server cannot be reached, and must never be drivable by a page that server serves. A confirmed change relaunches rather than swapping in place — the origin keys the cookie partition, update feed, encrypted per-origin task state, and every live browser view and PTY. Adds `sim-setup desktop`, which resolves the installer from the operator's own deployment, checks that the update feed resolves too, and prints the server URL to paste in. Documents the whole path under self-hosting, including the build-your-own escape hatch for organizations with their own Developer ID.
1 parent cc5a39a commit 0434578

21 files changed

Lines changed: 1315 additions & 36 deletions

File tree

apps/desktop/src/main/index.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import { openExternalSafe } from '@/main/navigation'
5151
import { createEventLog } from '@/main/observability'
5252
import { ScopedEventRouter } from '@/main/scoped-event-router'
5353
import { installGlobalGuards } from '@/main/security-guards'
54+
import { createServerWindow, relaunchApp } from '@/main/server-window'
5455
import {
5556
createSessionLifecycleCoordinator,
5657
decideStartRoute,
@@ -473,6 +474,20 @@ function main(): void {
473474
},
474475
})
475476

477+
/**
478+
* The native server picker. Self-hosted operators install the same signed
479+
* build as everyone else and repoint it here — the bundle bakes only a
480+
* DEFAULT origin, and every runtime guard reads the configured one.
481+
*/
482+
const serverWindow = createServerWindow({
483+
config,
484+
defaultOrigin: DEFAULT_ORIGIN,
485+
preloadPath,
486+
isPackaged: app.isPackaged,
487+
getParentWindow: getMainWindow,
488+
relaunch: relaunchApp,
489+
})
490+
476491
/**
477492
* Routes through the coordinator rather than tearing down directly: the
478493
* coordinator holds the in-progress guard, clears the same handoff and grant
@@ -659,13 +674,19 @@ function main(): void {
659674
check: () => updater?.check(),
660675
install: () => updater?.install(),
661676
},
677+
server: {
678+
open: () => serverWindow.open(),
679+
getConfiguration: () => serverWindow.getConfiguration(),
680+
setOrigin: (origin) => serverWindow.setOrigin(origin),
681+
},
662682
})
663683
await ensureMainWindow()
664684
installApplicationMenu({
665685
config,
666686
getMainWindow,
667687
allowHttpLocalhost,
668688
openSettings,
689+
openServerSettings: () => serverWindow.open(),
669690
newWindow: () => void createAndLoadAppWindow(),
670691
newChat: () => void openMainWindowAt(newChatRoute(config.get('lastRoute'))),
671692
handleFocusedResourceShortcut: (win, shortcut) =>

apps/desktop/src/main/ipc.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,11 @@ describe('registerIpcHandlers', () => {
316316
check: vi.fn(),
317317
install: vi.fn(),
318318
},
319+
server: {
320+
open: vi.fn(),
321+
getConfiguration: vi.fn(() => ({ origin: APP, defaultOrigin: APP })),
322+
setOrigin: vi.fn(() => ({ ok: true as const, origin: APP, unchanged: true })),
323+
},
319324
}
320325
registerIpcHandlers(deps)
321326
})

apps/desktop/src/main/ipc.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
} from '@sim/browser-protocol'
1010
import {
1111
type DesktopNotificationPayload,
12+
type DesktopServerChangeResult,
13+
type DesktopServerConfiguration,
1214
type DesktopUpdateState,
1315
type DesktopWindowState,
1416
type DesktopZoomPercent,
@@ -301,6 +303,11 @@ export interface IpcDeps {
301303
check: () => void
302304
install: () => void
303305
}
306+
server: {
307+
open: () => void
308+
getConfiguration: () => DesktopServerConfiguration
309+
setOrigin: (origin: string) => DesktopServerChangeResult
310+
}
304311
}
305312

306313
/**
@@ -1737,6 +1744,31 @@ export function registerIpcHandlers(deps: IpcDeps): void {
17371744
passSender: true,
17381745
handler: (sender) => deps.retryLoad(sender as WebContents),
17391746
},
1747+
// The `server:` family is local-page only, and deliberately so: the one
1748+
// surface that repoints the shell at another deployment must keep working
1749+
// when the current one is unreachable (the offline page is where a
1750+
// self-hoster with a typo'd origin actually lands), and must never be
1751+
// drivable by a page the current server serves.
1752+
'server:open': {
1753+
kind: 'send',
1754+
gate: 'local-page',
1755+
handler: () => deps.server.open(),
1756+
},
1757+
'server:get-configuration': {
1758+
kind: 'invoke',
1759+
gate: 'local-page',
1760+
denied: null,
1761+
handler: () => deps.server.getConfiguration(),
1762+
},
1763+
'server:set-origin': {
1764+
kind: 'invoke',
1765+
gate: 'local-page',
1766+
denied: { ok: false, error: 'The server can only be changed from the Sim app itself.' },
1767+
handler: (origin) =>
1768+
typeof origin === 'string'
1769+
? deps.server.setOrigin(origin)
1770+
: { ok: false, error: 'Server URL is required' },
1771+
},
17401772
}
17411773

17421774
const senderAllowed = (event: IpcMainEvent | IpcMainInvokeEvent, gate: ChannelGate): boolean => {

apps/desktop/src/main/menu.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ function makeDeps(): MenuDeps {
1818
getMainWindow: vi.fn(() => null),
1919
allowHttpLocalhost: vi.fn(() => false),
2020
openSettings: vi.fn(),
21+
openServerSettings: vi.fn(),
2122
newWindow: vi.fn(),
2223
newChat: vi.fn(),
2324
handleFocusedResourceShortcut: vi.fn(() => false),
@@ -51,6 +52,7 @@ describe('buildMenuTemplate', () => {
5152
expect(submenu(template, 'Sim').map((item) => item.label ?? item.role ?? item.type)).toEqual([
5253
'about',
5354
'Settings…',
55+
'Server…',
5456
'Check for Updates…',
5557
'Sign Out',
5658
'separator',

apps/desktop/src/main/menu.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export interface MenuDeps {
1515
getMainWindow: () => BrowserWindow | null
1616
allowHttpLocalhost: () => boolean
1717
openSettings: () => void
18+
/** Opens the native server picker (see main/server-window.ts). */
19+
openServerSettings: () => void
1820
newWindow: () => void
1921
newChat: () => void
2022
/**
@@ -155,6 +157,7 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[]
155157
submenu: [
156158
{ role: 'about' },
157159
{ label: 'Settings…', accelerator: 'CmdOrCtrl+,', click: deps.openSettings },
160+
{ label: 'Server…', click: deps.openServerSettings },
158161
{ label: 'Check for Updates…', click: deps.checkForUpdates },
159162
{ label: 'Sign Out', click: deps.signOut },
160163
{ type: 'separator' },
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
3+
vi.mock('electron', () => import('@/test/electron-mock'))
4+
5+
import type { ConfigStore, OriginValidation } from '@/main/config'
6+
import { createServerWindow, type ServerWindowDeps } from '@/main/server-window'
7+
8+
const CURRENT = 'https://sim.example.com'
9+
const DEFAULT = 'https://www.sim.ai'
10+
11+
function makeConfig(origin: string, validate: (raw: string) => OriginValidation): ConfigStore {
12+
let stored = origin
13+
return {
14+
filePath: '/tmp/settings.json',
15+
getOrigin: () => stored,
16+
setOrigin: vi.fn((raw: string) => {
17+
const result = validate(raw)
18+
if (result.ok) stored = result.origin
19+
return result
20+
}),
21+
get: vi.fn(() => undefined),
22+
set: vi.fn(),
23+
flush: vi.fn(),
24+
} as unknown as ConfigStore
25+
}
26+
27+
function makeDeps(overrides: Partial<ServerWindowDeps> = {}): ServerWindowDeps {
28+
return {
29+
config: makeConfig(CURRENT, (raw) =>
30+
raw.startsWith('https://') ? { ok: true, origin: raw } : { ok: false, error: 'bad origin' }
31+
),
32+
defaultOrigin: DEFAULT,
33+
preloadPath: '/tmp/preload.cjs',
34+
isPackaged: false,
35+
getParentWindow: () => null,
36+
relaunch: vi.fn(),
37+
...overrides,
38+
}
39+
}
40+
41+
describe('server window', () => {
42+
let deps: ServerWindowDeps
43+
44+
beforeEach(() => {
45+
deps = makeDeps()
46+
})
47+
48+
it('reports the configured origin alongside the build default', () => {
49+
expect(createServerWindow(deps).getConfiguration()).toEqual({
50+
origin: CURRENT,
51+
defaultOrigin: DEFAULT,
52+
})
53+
})
54+
55+
it('relaunches after storing a different origin', () => {
56+
const result = createServerWindow(deps).setOrigin('https://sim.other.example')
57+
58+
expect(result).toEqual({ ok: true, origin: 'https://sim.other.example', unchanged: false })
59+
expect(deps.config.flush).toHaveBeenCalled()
60+
expect(deps.relaunch).toHaveBeenCalledTimes(1)
61+
})
62+
63+
// Re-confirming the URL already in the field is the most likely thing a user
64+
// does in this window; restarting the app for it would be pure disruption.
65+
it('does not relaunch when the origin is unchanged', () => {
66+
const result = createServerWindow(deps).setOrigin(CURRENT)
67+
68+
expect(result).toEqual({ ok: true, origin: CURRENT, unchanged: true })
69+
expect(deps.relaunch).not.toHaveBeenCalled()
70+
})
71+
72+
it('surfaces a rejected origin without relaunching', () => {
73+
const result = createServerWindow(deps).setOrigin('ftp://sim.example.com')
74+
75+
expect(result).toEqual({ ok: false, error: 'bad origin' })
76+
expect(deps.relaunch).not.toHaveBeenCalled()
77+
expect(deps.config.getOrigin()).toBe(CURRENT)
78+
})
79+
})
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
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+
}

apps/desktop/src/preload/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ import type {
3333
DesktopOAuthConnectScope,
3434
DesktopPreferenceKey,
3535
DesktopPreferences,
36+
DesktopServerChangeResult,
37+
DesktopServerConfiguration,
3638
DesktopUpdateState,
3739
DesktopWindowState,
3840
DesktopZoomPercent,
@@ -124,6 +126,15 @@ const api: SimDesktopApi = {
124126
offlineRetry: (): void => {
125127
ipcRenderer.send('offline:retry')
126128
},
129+
server: {
130+
open: (): void => {
131+
ipcRenderer.send('server:open')
132+
},
133+
getConfiguration: (): Promise<DesktopServerConfiguration> =>
134+
ipcRenderer.invoke('server:get-configuration'),
135+
setOrigin: (origin: string): Promise<DesktopServerChangeResult> =>
136+
ipcRenderer.invoke('server:set-origin', origin),
137+
},
127138
localFilesystem: (request: LocalFilesystemRequest): Promise<LocalFilesystemResponse> =>
128139
ipcRenderer.invoke('desktop:local-filesystem', request),
129140
onCommand: (callback: (command: DesktopCommand) => void): (() => void) => {

0 commit comments

Comments
 (0)