diff --git a/apps/desktop/e2e/smoke.spec.ts b/apps/desktop/e2e/smoke.spec.ts
index 6e1277dbeb6..4bc71a1ee87 100644
--- a/apps/desktop/e2e/smoke.spec.ts
+++ b/apps/desktop/e2e/smoke.spec.ts
@@ -113,7 +113,15 @@ test.describe('desktop shell smoke', () => {
await expect(window.locator('.wordmark')).toBeVisible()
await expect(window.locator('.wordmark')).toHaveAttribute('aria-label', 'Sim')
await expect(window.locator('#title')).toHaveText('Can’t connect to Sim')
- await expect(window.locator('#status')).toHaveText('Check status')
+ // The recovery path for a self-hosted shell pointed at a server it cannot
+ // reach. Exercised end to end here because it is the only coverage of the
+ // `server:` local-page IPC gate: the bundled page reads the configuration
+ // over the real preload bridge, and status.sim.ai is withheld because this
+ // origin is not one of Sim's own. `toBeHidden` is load-bearing — the page's
+ // own `button { display: inline-flex }` outranks the UA `[hidden]` rule, so
+ // the attribute alone does not hide it.
+ await expect(window.locator('#server')).toBeVisible()
+ await expect(window.locator('#status')).toBeHidden()
await expect
.poll(() => window.evaluate(() => document.fonts.check('16px "Season Sans"')))
.toBe(true)
diff --git a/apps/desktop/src/main/config.test.ts b/apps/desktop/src/main/config.test.ts
index fefba510536..ed60924a261 100644
--- a/apps/desktop/src/main/config.test.ts
+++ b/apps/desktop/src/main/config.test.ts
@@ -9,6 +9,7 @@ import {
createConfigStore,
DEFAULT_ORIGIN,
isSafeInternalPath,
+ isSimCloudOrigin,
partitionForOrigin,
validateOriginInput,
} from '@/main/config'
@@ -145,6 +146,26 @@ describe('createConfigStore', () => {
expect(reloaded.getOrigin()).toBe('https://self-hosted.example')
})
+ // setOrigin writes the whole settings file synchronously on the main thread,
+ // and re-confirming the URL already in the field is the common case in the
+ // server picker.
+ it('does not rewrite settings when setOrigin is given the stored origin', () => {
+ const filePath = tempSettingsPath()
+ const store = createConfigStore(filePath, {})
+ store.setOrigin('https://self-hosted.example')
+ // A sentinel only this test could have written. A rewrite serializes the
+ // in-memory settings over it, so its survival proves no write happened —
+ // unlike an mtime comparison, which two writes a fraction of a millisecond
+ // apart can pass by accident.
+ writeFileSync(filePath, `${readFileSync(filePath, 'utf8')}\n// sentinel\n`)
+
+ expect(store.setOrigin('https://self-hosted.example')).toEqual({
+ ok: true,
+ origin: 'https://self-hosted.example',
+ })
+ expect(readFileSync(filePath, 'utf8')).toContain('// sentinel')
+ })
+
it('canonicalizes the apex production origin on setOrigin, not just on load', () => {
// Entering https://sim.ai mid-session must not persist the apex: the
// running session would use the wrong cookie partition and misclassify
@@ -223,6 +244,25 @@ describe('createConfigStore', () => {
})
})
+describe('isSimCloudOrigin', () => {
+ it('recognizes Sim-operated origins and nothing else', () => {
+ for (const origin of ['https://sim.ai', 'https://www.sim.ai', 'https://www.staging.sim.ai']) {
+ expect(isSimCloudOrigin(origin)).toBe(true)
+ }
+ // A lookalike host must not pass — the suffix check is on the parsed
+ // hostname, never a prefix or substring of the raw string.
+ for (const origin of [
+ 'https://sim.example.com',
+ 'https://sim.ai.evil.example',
+ 'https://notsim.ai',
+ 'http://localhost:3000',
+ 'not a url',
+ ]) {
+ expect(isSimCloudOrigin(origin)).toBe(false)
+ }
+ })
+})
+
describe('channelForOrigin', () => {
it('maps each environment origin to its channel', () => {
expect(channelForOrigin('https://sim.ai')).toBe('prod')
diff --git a/apps/desktop/src/main/config.ts b/apps/desktop/src/main/config.ts
index 654fbacfa35..14ce4b00f8d 100644
--- a/apps/desktop/src/main/config.ts
+++ b/apps/desktop/src/main/config.ts
@@ -179,6 +179,22 @@ export function canonicalOrigin(origin: string): string {
return ORIGIN_REWRITES[origin] ?? origin
}
+/**
+ * Whether an origin is one of Sim's own deployments rather than a self-hosted
+ * one. Sim-operated resources — the public status page above all — describe
+ * only these, so a shell pointed elsewhere must not be offered them: telling a
+ * self-hoster whose server is down to consult a page that is always green
+ * sends the person who most needs an answer to the one place that has none.
+ */
+export function isSimCloudOrigin(origin: string): boolean {
+ try {
+ const host = new URL(origin).hostname.toLowerCase()
+ return host === 'sim.ai' || host.endsWith('.sim.ai')
+ } catch {
+ return false
+ }
+}
+
/**
* Maps a server origin to its cookie/storage partition. Each origin gets an
* isolated persistent partition so sessions never leak across instances.
@@ -339,6 +355,12 @@ export function createConfigStore(
// only repairs it on the next launch. The canonical origin is also
// returned so the caller sees what was actually stored.
const origin = canonicalOrigin(validated.origin)
+ // Re-confirming the origin already stored is the common case in the
+ // server picker, and setOrigin's write is a synchronous mkdir + whole-file
+ // write + rename on the main thread. There is nothing to persist.
+ if (origin === settings.origin) {
+ return { ok: true, origin }
+ }
settings.origin = origin
// Not debounced: changing the origin tears the session down and
// reloads, so a pending write could be lost on the way out — and this
diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts
index cf82af3da67..8af9f55d550 100644
--- a/apps/desktop/src/main/index.ts
+++ b/apps/desktop/src/main/index.ts
@@ -51,6 +51,7 @@ import { openExternalSafe } from '@/main/navigation'
import { createEventLog } from '@/main/observability'
import { ScopedEventRouter } from '@/main/scoped-event-router'
import { installGlobalGuards } from '@/main/security-guards'
+import { createServerWindow, relaunchApp } from '@/main/server-window'
import {
createSessionLifecycleCoordinator,
decideStartRoute,
@@ -78,6 +79,7 @@ function reportHandoffFailure(error: unknown): void {
}
const OFFLINE_PAGE = 'static/offline.html'
+const SERVER_PAGE = 'static/server.html'
const DOCK_ICON_FOR_CHANNEL = {
prod: 'dock-icon.png',
staging: 'dock-icon-staging.png',
@@ -473,6 +475,35 @@ function main(): void {
},
})
+ const serverWindow = createServerWindow({
+ config,
+ defaultOrigin: DEFAULT_ORIGIN,
+ pagePath: SERVER_PAGE,
+ preloadPath,
+ isPackaged: app.isPackaged,
+ getParentWindow: getMainWindow,
+ clearDeploymentScopedState: async () => {
+ // allSettled, not sequential awaits: these are independent stores, and a
+ // rejection from the first must not skip the second — leaving the store
+ // that would have cleared fine still holding the outgoing deployment's
+ // access. Each failure is named so the picker can say what survived.
+ const stores = [
+ { label: 'local file access', clear: () => localFilesystem.forgetAll() },
+ { label: 'built-in browser sessions', clear: () => clearAgentBrowserProfile() },
+ ]
+ const outcomes = await Promise.allSettled(stores.map((store) => store.clear()))
+ return outcomes.flatMap((outcome, index) => {
+ if (outcome.status === 'fulfilled') return []
+ logger.error('Could not clear deployment-scoped state', {
+ store: stores[index].label,
+ error: getErrorMessage(outcome.reason),
+ })
+ return [stores[index].label]
+ })
+ },
+ relaunch: relaunchApp,
+ })
+
/**
* Routes through the coordinator rather than tearing down directly: the
* coordinator holds the in-progress guard, clears the same handoff and grant
@@ -659,6 +690,11 @@ function main(): void {
check: () => updater?.check(),
install: () => updater?.install(),
},
+ server: {
+ open: () => serverWindow.open(),
+ getConfiguration: () => serverWindow.getConfiguration(),
+ setOrigin: (origin) => serverWindow.setOrigin(origin),
+ },
})
await ensureMainWindow()
installApplicationMenu({
@@ -666,6 +702,7 @@ function main(): void {
getMainWindow,
allowHttpLocalhost,
openSettings,
+ openServerSettings: () => serverWindow.open(),
newWindow: () => void createAndLoadAppWindow(),
newChat: () => void openMainWindowAt(newChatRoute(config.get('lastRoute'))),
handleFocusedResourceShortcut: (win, shortcut) =>
diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts
index c20e907e55d..ba248a3f46b 100644
--- a/apps/desktop/src/main/ipc.test.ts
+++ b/apps/desktop/src/main/ipc.test.ts
@@ -316,6 +316,11 @@ describe('registerIpcHandlers', () => {
check: vi.fn(),
install: vi.fn(),
},
+ server: {
+ open: vi.fn(),
+ getConfiguration: vi.fn(() => ({ origin: APP, defaultOrigin: APP, isSimCloud: true })),
+ setOrigin: vi.fn(async () => ({ ok: true as const, origin: APP, unchanged: true })),
+ },
}
registerIpcHandlers(deps)
})
diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts
index b230af7ad4d..8d5861eb506 100644
--- a/apps/desktop/src/main/ipc.ts
+++ b/apps/desktop/src/main/ipc.ts
@@ -9,6 +9,8 @@ import {
} from '@sim/browser-protocol'
import {
type DesktopNotificationPayload,
+ type DesktopServerChangeResult,
+ type DesktopServerConfiguration,
type DesktopUpdateState,
type DesktopWindowState,
type DesktopZoomPercent,
@@ -301,6 +303,11 @@ export interface IpcDeps {
check: () => void
install: () => void
}
+ server: {
+ open: () => void
+ getConfiguration: () => DesktopServerConfiguration
+ setOrigin: (origin: string) => Promise
+ }
}
/**
@@ -1737,6 +1744,31 @@ export function registerIpcHandlers(deps: IpcDeps): void {
passSender: true,
handler: (sender) => deps.retryLoad(sender as WebContents),
},
+ // The `server:` family is local-page only, and deliberately so: the one
+ // surface that repoints the shell at another deployment must keep working
+ // when the current one is unreachable (the offline page is where a
+ // self-hoster with a typo'd origin actually lands), and must never be
+ // drivable by a page the current server serves.
+ 'server:open': {
+ kind: 'send',
+ gate: 'local-page',
+ handler: () => deps.server.open(),
+ },
+ 'server:get-configuration': {
+ kind: 'invoke',
+ gate: 'local-page',
+ denied: null,
+ handler: () => deps.server.getConfiguration(),
+ },
+ 'server:set-origin': {
+ kind: 'invoke',
+ gate: 'local-page',
+ denied: { ok: false, error: 'The server can only be changed from the Sim app itself.' },
+ handler: (origin) =>
+ typeof origin === 'string'
+ ? deps.server.setOrigin(origin)
+ : { ok: false, error: 'Server URL is required' },
+ },
}
const senderAllowed = (event: IpcMainEvent | IpcMainInvokeEvent, gate: ChannelGate): boolean => {
diff --git a/apps/desktop/src/main/menu.test.ts b/apps/desktop/src/main/menu.test.ts
index b98be440434..63dbc05357d 100644
--- a/apps/desktop/src/main/menu.test.ts
+++ b/apps/desktop/src/main/menu.test.ts
@@ -6,11 +6,11 @@ import { BrowserWindow, type MenuItemConstructorOptions } from 'electron'
import type { ConfigStore } from '@/main/config'
import { buildMenuTemplate, type MenuDeps } from '@/main/menu'
-function makeDeps(): MenuDeps {
+function makeDeps(origin = 'https://sim.ai'): MenuDeps {
return {
config: {
filePath: '/tmp/settings.json',
- getOrigin: vi.fn(() => 'https://sim.ai'),
+ getOrigin: vi.fn(() => origin),
setOrigin: vi.fn(),
get: vi.fn(() => undefined),
set: vi.fn(),
@@ -18,6 +18,7 @@ function makeDeps(): MenuDeps {
getMainWindow: vi.fn(() => null),
allowHttpLocalhost: vi.fn(() => false),
openSettings: vi.fn(),
+ openServerSettings: vi.fn(),
newWindow: vi.fn(),
newChat: vi.fn(),
handleFocusedResourceShortcut: vi.fn(() => false),
@@ -51,6 +52,7 @@ describe('buildMenuTemplate', () => {
expect(submenu(template, 'Sim').map((item) => item.label ?? item.role ?? item.type)).toEqual([
'about',
'Settings…',
+ 'Server…',
'Check for Updates…',
'Sign Out',
'separator',
@@ -105,6 +107,13 @@ describe('buildMenuTemplate', () => {
expect(help.map((item) => item.label)).toEqual(['Sim Documentation', 'Sim Status'])
})
+ // status.sim.ai reports on Sim's deployments only, so it is worse than
+ // useless to an operator whose own server is the one that is down.
+ it('drops Sim status for a self-hosted server', () => {
+ const help = submenu(buildMenuTemplate(makeDeps('https://sim.example.com')), 'Help')
+ expect(help.map((item) => item.label)).toEqual(['Sim Documentation'])
+ })
+
it('never exposes developer tools in the application menu', () => {
const view = submenu(buildMenuTemplate(makeDeps()), 'View')
expect(view.some((item) => item.role === 'toggleDevTools')).toBe(false)
diff --git a/apps/desktop/src/main/menu.ts b/apps/desktop/src/main/menu.ts
index d5594f2c5d1..4223e8a8b7b 100644
--- a/apps/desktop/src/main/menu.ts
+++ b/apps/desktop/src/main/menu.ts
@@ -1,6 +1,6 @@
import type { MenuItemConstructorOptions } from 'electron'
import { app, BrowserWindow, Menu } from 'electron'
-import type { ConfigStore } from '@/main/config'
+import { type ConfigStore, isSimCloudOrigin } from '@/main/config'
import { DOCS_URL, STATUS_URL } from '@/main/external-links'
import { openExternalSafe } from '@/main/navigation'
import type {
@@ -15,6 +15,8 @@ export interface MenuDeps {
getMainWindow: () => BrowserWindow | null
allowHttpLocalhost: () => boolean
openSettings: () => void
+ /** Opens the native server picker (see main/server-window.ts). */
+ openServerSettings: () => void
newWindow: () => void
newChat: () => void
/**
@@ -155,6 +157,7 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[]
submenu: [
{ role: 'about' },
{ label: 'Settings…', accelerator: 'CmdOrCtrl+,', click: deps.openSettings },
+ { label: 'Server…', click: deps.openServerSettings },
{ label: 'Check for Updates…', click: deps.checkForUpdates },
{ label: 'Sign Out', click: deps.signOut },
{ type: 'separator' },
@@ -250,10 +253,16 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[]
label: 'Sim Documentation',
click: () => void openExternalSafe(DOCS_URL, deps.allowHttpLocalhost()),
},
- {
- label: 'Sim Status',
- click: () => void openExternalSafe(STATUS_URL, deps.allowHttpLocalhost()),
- },
+ // Omitted for a self-hosted shell, like the offline page's status
+ // button — see isSimCloudOrigin.
+ ...(isSimCloudOrigin(deps.config.getOrigin())
+ ? [
+ {
+ label: 'Sim Status',
+ click: () => void openExternalSafe(STATUS_URL, deps.allowHttpLocalhost()),
+ },
+ ]
+ : []),
],
},
]
diff --git a/apps/desktop/src/main/server-window.test.ts b/apps/desktop/src/main/server-window.test.ts
new file mode 100644
index 00000000000..ced9788b13c
--- /dev/null
+++ b/apps/desktop/src/main/server-window.test.ts
@@ -0,0 +1,206 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('electron', () => import('@/test/electron-mock'))
+
+import type { ConfigStore, OriginValidation } from '@/main/config'
+import { createServerWindow, type ServerWindowDeps } from '@/main/server-window'
+
+const CURRENT = 'https://sim.example.com'
+const DEFAULT = 'https://www.sim.ai'
+
+function makeConfig(origin: string, validate: (raw: string) => OriginValidation): ConfigStore {
+ let stored = origin
+ return {
+ filePath: '/tmp/settings.json',
+ getOrigin: () => stored,
+ setOrigin: vi.fn((raw: string) => {
+ const result = validate(raw)
+ if (result.ok) stored = result.origin
+ return result
+ }),
+ get: vi.fn(() => undefined),
+ set: vi.fn(),
+ flush: vi.fn(),
+ } as unknown as ConfigStore
+}
+
+function makeDeps(overrides: Partial = {}): ServerWindowDeps {
+ return {
+ config: makeConfig(CURRENT, (raw) =>
+ raw.startsWith('https://') ? { ok: true, origin: raw } : { ok: false, error: 'bad origin' }
+ ),
+ defaultOrigin: DEFAULT,
+ pagePath: 'static/server.html',
+ preloadPath: '/tmp/preload.cjs',
+ isPackaged: false,
+ getParentWindow: () => null,
+ clearDeploymentScopedState: vi.fn(async (): Promise => []),
+ relaunch: vi.fn(),
+ ...overrides,
+ }
+}
+
+describe('server window', () => {
+ let deps: ServerWindowDeps
+
+ beforeEach(() => {
+ deps = makeDeps()
+ })
+
+ it('reports the configured origin alongside the build default', () => {
+ expect(createServerWindow(deps).getConfiguration()).toEqual({
+ origin: CURRENT,
+ defaultOrigin: DEFAULT,
+ isSimCloud: false,
+ })
+ })
+
+ // Drives whether the offline page offers Sim's status page, which describes
+ // only Sim's own deployments.
+ it('marks a sim.ai origin as Sim cloud', () => {
+ const cloud = makeDeps({
+ config: makeConfig('https://www.sim.ai', (raw) => ({ ok: true, origin: raw })),
+ })
+
+ expect(createServerWindow(cloud).getConfiguration().isSimCloud).toBe(true)
+ })
+
+ it('relaunches after storing a different origin', async () => {
+ const result = await createServerWindow(deps).setOrigin('https://sim.other.example')
+
+ expect(result).toEqual({ ok: true, origin: 'https://sim.other.example', unchanged: false })
+ expect(deps.config.flush).toHaveBeenCalled()
+ expect(deps.relaunch).toHaveBeenCalledTimes(1)
+ })
+
+ // The saved route carries the previous deployment's workspace id, and
+ // resolveStartRoute only discards a route on a confirmed 403 — a fresh
+ // partition answers 401, so a kept route would survive onto the new server.
+ it('drops the saved route when the origin changes', async () => {
+ await createServerWindow(deps).setOrigin('https://sim.other.example')
+
+ expect(deps.config.set).toHaveBeenCalledWith('lastRoute', undefined)
+ })
+
+ it('keeps the saved route when the origin is unchanged', async () => {
+ await createServerWindow(deps).setOrigin(CURRENT)
+
+ expect(deps.config.set).not.toHaveBeenCalled()
+ })
+
+ // Re-confirming the pre-filled URL is the common case here.
+ it('does not relaunch when the origin is unchanged', async () => {
+ const result = await createServerWindow(deps).setOrigin(CURRENT)
+
+ expect(result).toEqual({ ok: true, origin: CURRENT, unchanged: true })
+ expect(deps.relaunch).not.toHaveBeenCalled()
+ })
+
+ // Filesystem grants and the agent browser's jar are device-global with no
+ // origin key, so without this the incoming deployment inherits directory
+ // access and live third-party sessions the user granted the outgoing one.
+ it('clears deployment-scoped capabilities before relaunching', async () => {
+ await createServerWindow(deps).setOrigin('https://sim.other.example')
+
+ expect(deps.clearDeploymentScopedState).toHaveBeenCalledTimes(1)
+ expect(vi.mocked(deps.clearDeploymentScopedState).mock.invocationCallOrder[0]).toBeLessThan(
+ vi.mocked(deps.relaunch).mock.invocationCallOrder[0]
+ )
+ })
+
+ it('does not clear them when the origin is unchanged', async () => {
+ await createServerWindow(deps).setOrigin(CURRENT)
+
+ expect(deps.clearDeploymentScopedState).not.toHaveBeenCalled()
+ })
+
+ // The picker re-enables its button while a request is pending, and the IPC
+ // boundary is reachable regardless of what the page does, so the transaction
+ // has to be serialized here rather than in the renderer.
+ it('refuses a second change while one is in flight', async () => {
+ let release: (() => void) | undefined
+ const gate = new Promise((resolve) => {
+ release = resolve
+ })
+ const slow = makeDeps({
+ clearDeploymentScopedState: vi.fn(async (): Promise => {
+ await gate
+ return []
+ }),
+ })
+ const handle = createServerWindow(slow)
+
+ const first = handle.setOrigin('https://sim.other.example')
+ const second = await handle.setOrigin('https://sim.third.example')
+
+ expect(second).toMatchObject({ ok: false })
+ expect(second).toHaveProperty('error', expect.stringContaining('already in progress'))
+ release?.()
+ await expect(first).resolves.toMatchObject({ ok: true, unchanged: false })
+ expect(slow.relaunch).toHaveBeenCalledTimes(1)
+ expect(slow.config.setOrigin).toHaveBeenCalledTimes(1)
+ expect(slow.config.setOrigin).toHaveBeenCalledWith('https://sim.other.example')
+ })
+
+ // The guard must not latch: a refused change has to leave the picker usable.
+ it('allows a later change once the first has settled', async () => {
+ const failing = makeDeps({
+ clearDeploymentScopedState: vi.fn(async () => ['local file access']),
+ })
+ const handle = createServerWindow(failing)
+
+ await handle.setOrigin('https://sim.other.example')
+ const second = await handle.setOrigin('https://sim.third.example')
+
+ expect(second).toMatchObject({ ok: false })
+ expect(second).toHaveProperty('error', expect.stringContaining('local file access'))
+ })
+
+ // Fail closed. A store that could not be emptied is access the incoming
+ // deployment would inherit and that startup would restore, so the change is
+ // refused outright — and because nothing is persisted until the teardown
+ // succeeds, refusing leaves the shell exactly where it was.
+ it('refuses the change when a store could not be cleared', async () => {
+ const failing = makeDeps({
+ clearDeploymentScopedState: vi.fn(async () => ['local file access']),
+ })
+
+ const result = await createServerWindow(failing).setOrigin('https://sim.other.example')
+
+ expect(result).toMatchObject({ ok: false })
+ expect(result).toHaveProperty('error', expect.stringContaining('local file access'))
+ // The stores clear independently, so the other one may already be empty and
+ // cannot be restored. Naming only the failure would read as "nothing
+ // happened", which is not what happened.
+ expect(result).toHaveProperty('error', expect.stringContaining('may already have been cleared'))
+ expect(failing.relaunch).not.toHaveBeenCalled()
+ expect(failing.config.setOrigin).not.toHaveBeenCalled()
+ expect(failing.config.getOrigin()).toBe(CURRENT)
+ })
+
+ it('refuses the change when the teardown throws outright', async () => {
+ const throwing = makeDeps({
+ clearDeploymentScopedState: vi.fn(async () => {
+ throw new Error('keychain unavailable')
+ }),
+ })
+
+ const result = await createServerWindow(throwing).setOrigin('https://sim.other.example')
+
+ expect(result).toMatchObject({ ok: false })
+ expect(throwing.relaunch).not.toHaveBeenCalled()
+ expect(throwing.config.getOrigin()).toBe(CURRENT)
+ })
+
+ // Validated up front with the shell's own rule, before anything is torn down
+ // or written, so a typo costs nothing.
+ it('surfaces a rejected origin without tearing anything down', async () => {
+ const result = await createServerWindow(deps).setOrigin('ftp://sim.example.com')
+
+ expect(result).toMatchObject({ ok: false })
+ expect(result).toHaveProperty('error', expect.stringContaining('HTTPS'))
+ expect(deps.clearDeploymentScopedState).not.toHaveBeenCalled()
+ expect(deps.relaunch).not.toHaveBeenCalled()
+ expect(deps.config.getOrigin()).toBe(CURRENT)
+ })
+})
diff --git a/apps/desktop/src/main/server-window.ts b/apps/desktop/src/main/server-window.ts
new file mode 100644
index 00000000000..03d6eb003b5
--- /dev/null
+++ b/apps/desktop/src/main/server-window.ts
@@ -0,0 +1,242 @@
+import type { DesktopServerChangeResult, DesktopServerConfiguration } from '@sim/desktop-bridge'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { app, BrowserWindow, nativeTheme, session } from 'electron'
+import type { ConfigStore, DesktopSettings } from '@/main/config'
+import { canonicalOrigin, isSimCloudOrigin, validateOriginInput } from '@/main/config'
+import {
+ backgroundColorFor,
+ createSecureWebPreferences,
+ setupPermissionHandlers,
+} from '@/main/window'
+
+const logger = createLogger('DesktopServerWindow')
+
+const WINDOW_WIDTH = 520
+const WINDOW_HEIGHT = 340
+
+/**
+ * The partition the server-selection window runs in.
+ *
+ * Deliberately NOT the app session's partition. This window exists to move the
+ * shell between deployments, so binding it to the partition of the deployment
+ * being left would tie the escape hatch to the state it is escaping — and the
+ * page is a bundled `file:` document that stores nothing, so it has no reason
+ * to touch a persistent jar at all.
+ */
+const SERVER_WINDOW_PARTITION = 'server-selection'
+
+/**
+ * Settings that describe the deployment rather than the device, and so must not
+ * survive a move to a different one. The home for this rule: `DesktopSettings`
+ * is a single global record with no per-origin namespace, so anything added
+ * there that names a Sim resource belongs in this list.
+ *
+ * `lastRoute` carries a workspace id in its path, so keeping it would open
+ * `/workspace/` on the new server. `resolveStartRoute` cannot rescue
+ * that: it discards a route only on a confirmed 403, and a fresh partition has
+ * no session, so the new server answers 401 and the stale route survives the
+ * probe. `browserKnownSites` describes the agent-browser profile that
+ * {@link ServerWindowDeps.clearDeploymentScopedState} clears, and is dropped
+ * with it so Sim is never left believing in sign-ins the profile no longer has.
+ */
+const ORIGIN_SCOPED_SETTINGS: readonly (keyof DesktopSettings)[] = [
+ 'lastRoute',
+ 'browserKnownSites',
+]
+
+export interface ServerWindowDeps {
+ config: ConfigStore
+ defaultOrigin: string
+ /** The bundled page to load, resolved by the caller like the offline page. */
+ pagePath: string
+ preloadPath: string
+ isPackaged: boolean
+ getParentWindow: () => BrowserWindow | null
+ /**
+ * Drops the capabilities the OUTGOING deployment was granted, and reports
+ * what it could not drop.
+ *
+ * Local-filesystem grants and the agent browser's cookie jar live in
+ * device-global stores with no origin key, and both are capabilities the user
+ * handed to a specific Sim server: directories its agent may read, and live
+ * third-party sessions its agent may drive. Carrying them across would let
+ * the next deployment act with authority it was never given — which is why
+ * sign-out clears exactly this pair.
+ *
+ * Returns the human-readable name of each store that survived; empty means
+ * everything is gone. Reporting rather than throwing is what lets one store's
+ * failure not hide another's, and lets the caller refuse to move.
+ */
+ clearDeploymentScopedState: () => Promise
+ /**
+ * Relaunches the shell against the newly stored origin. A full restart rather
+ * than an in-place swap: the origin decides the cookie partition, the update
+ * feed, the encrypted per-origin task state, and the identity every live
+ * browser view and PTY was opened under. Nothing in the app exposes a reset
+ * for that set — `ensureAppSession` and the partition cache are one-way
+ * memoizations, and the sign-out coordinator revokes server-side, which is
+ * wrong here (the old server's session should stay valid). The quit path
+ * already performs the orderly teardown, so relaunching reuses it.
+ */
+ relaunch: () => void
+}
+
+export interface ServerWindowHandle {
+ open(): void
+ getConfiguration(): DesktopServerConfiguration
+ setOrigin(origin: string): Promise
+}
+
+/**
+ * The native server picker: how a self-hosted operator points the shell at
+ * their own deployment.
+ *
+ * Native rather than a page in the web app, because the web app is served BY
+ * the origin being changed. Someone whose stored origin is unreachable — a
+ * typo, a VPN-only host, an instance that moved — can never reach an in-app
+ * settings route to fix it, which is exactly when they need this most. The
+ * same reasoning gates its IPC channels to bundled `file:` senders.
+ */
+export function createServerWindow(deps: ServerWindowDeps): ServerWindowHandle {
+ let win: BrowserWindow | null = null
+ /**
+ * Serializes the destructive part of a change, the way the sign-out
+ * coordinator guards its own teardown. The picker re-enables its button
+ * while a request is pending, and the IPC boundary is reachable regardless
+ * of what the page does, so without this two changes could interleave their
+ * teardown and their write and let the later write pick the next server.
+ */
+ let changeInFlight = false
+
+ const getConfiguration = (): DesktopServerConfiguration => {
+ const origin = deps.config.getOrigin()
+ return { origin, defaultOrigin: deps.defaultOrigin, isSimCloud: isSimCloudOrigin(origin) }
+ }
+
+ const close = (): void => {
+ if (win && !win.isDestroyed()) {
+ win.destroy()
+ }
+ win = null
+ }
+
+ const open = (): void => {
+ if (win && !win.isDestroyed()) {
+ win.show()
+ win.focus()
+ return
+ }
+ const parent = deps.getParentWindow()
+ // Every other session in the app installs a permission handler; without one
+ // Electron decides for itself what a page may ask the OS for. The page here
+ // asks for nothing, and a foreign origin can never load in this window, so
+ // the shared handler resolves to a deny-all — which is the intent.
+ setupPermissionHandlers(session.fromPartition(SERVER_WINDOW_PARTITION), deps.config.getOrigin)
+ win = new BrowserWindow({
+ width: WINDOW_WIDTH,
+ height: WINDOW_HEIGHT,
+ resizable: false,
+ minimizable: false,
+ maximizable: false,
+ fullscreenable: false,
+ title: 'Sim Server',
+ titleBarStyle: 'hiddenInset',
+ show: false,
+ // System preference only, unlike the main window: that one pre-paints for
+ // the web app it is about to load, whose theme the user picked in Sim.
+ // This window loads a bundled page that follows `prefers-color-scheme`,
+ // so honouring the stored web-app theme here would pre-paint dark behind
+ // a page about to render light whenever the two disagree.
+ backgroundColor: backgroundColorFor(undefined, nativeTheme.shouldUseDarkColors),
+ // Modal only when there is a live parent to attach to. A shell whose
+ // window is gone (or never opened, because the origin failed to load)
+ // still has to be able to reach this.
+ ...(parent && !parent.isDestroyed() ? { parent, modal: true } : {}),
+ webPreferences: createSecureWebPreferences(
+ SERVER_WINDOW_PARTITION,
+ deps.preloadPath,
+ deps.isPackaged
+ ),
+ })
+ win.once('ready-to-show', () => {
+ win?.show()
+ })
+ win.on('closed', () => {
+ win = null
+ })
+ void win.loadFile(deps.pagePath).catch((error) => {
+ logger.error('Could not open the server window', { error: getErrorMessage(error) })
+ })
+ }
+
+ const setOrigin = async (raw: string): Promise => {
+ const validated = validateOriginInput(raw)
+ if (!validated.ok) {
+ return validated
+ }
+ // Same canonicalization the store applies, so the comparison below matches
+ // what would actually be written.
+ const origin = canonicalOrigin(validated.origin)
+ const current = deps.config.getOrigin()
+ if (origin === current) {
+ // Nothing moves, so nothing is torn down. Relaunching anyway would make
+ // "confirm the URL I already use" restart the app for no reason.
+ return { ok: true, origin, unchanged: true }
+ }
+
+ if (changeInFlight) {
+ return { ok: false, error: 'A server change is already in progress.' }
+ }
+ changeInFlight = true
+ try {
+ // Fail closed, and clear BEFORE persisting. If a store cannot be emptied,
+ // the shell must not move: the incoming deployment would otherwise
+ // inherit folder grants and authenticated browser sessions the outgoing
+ // one was given, and they are restored on the next startup. Nothing has
+ // been written at this point, so refusing leaves the shell on the server
+ // it was already using rather than half-applying the change.
+ const surviving = await deps.clearDeploymentScopedState().catch((error) => {
+ logger.error('Deployment-scoped teardown threw', { error: getErrorMessage(error) })
+ return ['local file access and built-in browser sessions']
+ })
+ if (surviving.length > 0) {
+ logger.error('Refusing to change server; deployment-scoped state survived', { surviving })
+ // Deliberately describes the whole teardown, not just what failed. The
+ // stores clear independently, so one may already be empty by now, and
+ // there is nothing to roll back to — a revoked cookie jar and deleted
+ // security-scoped bookmarks cannot be un-deleted. Saying "some may have
+ // been cleared" is the honest account, and a retry is safe: clearing an
+ // already-empty store succeeds, so it finishes the job rather than
+ // repeating it.
+ return {
+ ok: false,
+ error: `Could not clear ${surviving.join(' or ')} from the current server, so the server was not changed. Some local access may already have been cleared. Try again to finish, or sign out first.`,
+ }
+ }
+
+ logger.info('Server origin changed; relaunching', { from: current, to: origin })
+ deps.config.setOrigin(raw)
+ for (const key of ORIGIN_SCOPED_SETTINGS) {
+ deps.config.set(key, undefined)
+ }
+ // setOrigin writes through immediately; the clears above are debounced
+ // like every other setting. `before-quit` flushes too, but doing it here
+ // keeps the write independent of the Electron quit sequence.
+ deps.config.flush()
+ close()
+ deps.relaunch()
+ return { ok: true, origin, unchanged: false }
+ } finally {
+ changeInFlight = false
+ }
+ }
+
+ return { open, getConfiguration, setOrigin }
+}
+
+/** Restarts the process in place. Split out so tests can drive the seam. */
+export function relaunchApp(): void {
+ app.relaunch()
+ app.quit()
+}
diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts
index afe84f2a42e..07d4e9e59e4 100644
--- a/apps/desktop/src/preload/index.ts
+++ b/apps/desktop/src/preload/index.ts
@@ -33,6 +33,8 @@ import type {
DesktopOAuthConnectScope,
DesktopPreferenceKey,
DesktopPreferences,
+ DesktopServerChangeResult,
+ DesktopServerConfiguration,
DesktopUpdateState,
DesktopWindowState,
DesktopZoomPercent,
@@ -124,6 +126,15 @@ const api: SimDesktopApi = {
offlineRetry: (): void => {
ipcRenderer.send('offline:retry')
},
+ server: {
+ open: (): void => {
+ ipcRenderer.send('server:open')
+ },
+ getConfiguration: (): Promise =>
+ ipcRenderer.invoke('server:get-configuration'),
+ setOrigin: (origin: string): Promise =>
+ ipcRenderer.invoke('server:set-origin', origin),
+ },
localFilesystem: (request: LocalFilesystemRequest): Promise =>
ipcRenderer.invoke('desktop:local-filesystem', request),
onCommand: (callback: (command: DesktopCommand) => void): (() => void) => {
diff --git a/apps/desktop/static/offline.html b/apps/desktop/static/offline.html
index 231c415a83e..20d82e4bab9 100644
--- a/apps/desktop/static/offline.html
+++ b/apps/desktop/static/offline.html
@@ -126,6 +126,12 @@
stroke 150ms cubic-bezier(0.4, 0, 0.2, 1);
-webkit-app-region: no-drag;
}
+ /* `button { display: inline-flex }` is an author rule, so it beats the UA
+ stylesheet's `[hidden] { display: none }` no matter the specificity —
+ without this the hidden status button renders anyway. */
+ button[hidden] {
+ display: none;
+ }
button .label {
min-width: 0;
flex: 1;
@@ -190,7 +196,8 @@
Can’t connect to Sim
-
+
+
@@ -227,10 +234,23 @@
Can’t connect to Sim
if (detail) document.getElementById('detail').textContent = detail
const bridge = window.simDesktop
+ const statusButton = document.getElementById('status')
document.getElementById('retry').addEventListener('click', () => bridge?.offlineRetry())
- document
- .getElementById('status')
- .addEventListener('click', () => bridge?.openExternal('https://status.sim.ai'))
+ statusButton.addEventListener('click', () => bridge?.openExternal('https://status.sim.ai'))
+ // The recovery path for a shell pointed at a server it cannot reach —
+ // a mistyped self-hosted origin strands the app here with nothing else
+ // to click.
+ document.getElementById('server').addEventListener('click', () => bridge?.server?.open())
+
+ // Hidden in the markup and only ever revealed, never the reverse: an
+ // older shell with no `server` bridge, or a configuration read that
+ // fails, must not leave a status link a self-hoster cannot use.
+ bridge?.server
+ ?.getConfiguration()
+ .then(({ isSimCloud }) => {
+ if (isSimCloud) statusButton.hidden = false
+ })
+ .catch(() => {})