Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion apps/desktop/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
40 changes: 40 additions & 0 deletions apps/desktop/src/main/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
createConfigStore,
DEFAULT_ORIGIN,
isSafeInternalPath,
isSimCloudOrigin,
partitionForOrigin,
validateOriginInput,
} from '@/main/config'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand Down
22 changes: 22 additions & 0 deletions apps/desktop/src/main/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -659,13 +690,19 @@ function main(): void {
check: () => updater?.check(),
install: () => updater?.install(),
},
server: {
open: () => serverWindow.open(),
getConfiguration: () => serverWindow.getConfiguration(),
setOrigin: (origin) => serverWindow.setOrigin(origin),
},
})
await ensureMainWindow()
installApplicationMenu({
config,
getMainWindow,
allowHttpLocalhost,
openSettings,
openServerSettings: () => serverWindow.open(),
newWindow: () => void createAndLoadAppWindow(),
newChat: () => void openMainWindowAt(newChatRoute(config.get('lastRoute'))),
handleFocusedResourceShortcut: (win, shortcut) =>
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/main/ipc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down
32 changes: 32 additions & 0 deletions apps/desktop/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
} from '@sim/browser-protocol'
import {
type DesktopNotificationPayload,
type DesktopServerChangeResult,
type DesktopServerConfiguration,
type DesktopUpdateState,
type DesktopWindowState,
type DesktopZoomPercent,
Expand Down Expand Up @@ -301,6 +303,11 @@ export interface IpcDeps {
check: () => void
install: () => void
}
server: {
open: () => void
getConfiguration: () => DesktopServerConfiguration
setOrigin: (origin: string) => Promise<DesktopServerChangeResult>
}
}

/**
Expand Down Expand Up @@ -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 => {
Expand Down
13 changes: 11 additions & 2 deletions apps/desktop/src/main/menu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,19 @@ 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(),
} as unknown as ConfigStore,
getMainWindow: vi.fn(() => null),
allowHttpLocalhost: vi.fn(() => false),
openSettings: vi.fn(),
openServerSettings: vi.fn(),
newWindow: vi.fn(),
newChat: vi.fn(),
handleFocusedResourceShortcut: vi.fn(() => false),
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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)
Expand Down
19 changes: 14 additions & 5 deletions apps/desktop/src/main/menu.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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
/**
Expand Down Expand Up @@ -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' },
Expand Down Expand Up @@ -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()),
},
]
: []),
],
},
]
Expand Down
Loading
Loading