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
60 changes: 53 additions & 7 deletions apps/desktop/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,55 @@ test.describe('desktop shell smoke', () => {
await expect(window.locator('#detail')).toHaveAttribute('role', 'status')
})

test('offline title-bar geometry follows native fullscreen state across reloads', async () => {
test.skip(process.platform !== 'darwin', 'The traffic-light lane is macOS-specific')
app = await launchApp('http://127.0.0.1:1')
const window = await app.firstWindow()
await expect(window.locator('#server')).toBeVisible()
await expect(window.locator('html')).toHaveAttribute('data-sim-desktop-title-bar', 'inset')
await app.evaluate(({ BrowserWindow }) => {
BrowserWindow.getAllWindows()[0].setFullScreen(true)
})
await expect(window.locator('html')).toHaveAttribute('data-sim-desktop-title-bar', 'fullscreen')
await window.reload()
await expect(window.locator('#server')).toBeVisible()
await expect(window.locator('html')).toHaveAttribute('data-sim-desktop-title-bar', 'fullscreen')
await expect(window.locator('.desktop-title-bar-page')).toHaveCSS('padding-top', '0px')
await app.evaluate(({ BrowserWindow }) => {
BrowserWindow.getAllWindows()[0].setFullScreen(false)
})
await expect(window.locator('html')).toHaveAttribute('data-sim-desktop-title-bar', 'inset')
})

test('bundled dialogs follow the app theme independently of the system theme', async () => {
app = await launchApp(origin)
const window = await app.firstWindow()
await expect(window.locator('#app')).toBeVisible()
await app.evaluate(({ nativeTheme }) => {
nativeTheme.themeSource = 'light'
})
await window.evaluate(() => {
document.documentElement.className = 'dark'
})
const dialogPromise = app.waitForEvent('window')
await app.evaluate(({ BrowserWindow }) => {
BrowserWindow.getAllWindows()[0].webContents.emit('unresponsive')
})
const prompt = await dialogPromise
await expect(prompt.getByRole('dialog')).toBeVisible()
await expect(prompt.locator('html')).toHaveClass('dark')
await expect(prompt.locator('html')).toHaveCSS('color-scheme', 'dark')
await expect(prompt.locator('#dialog-message')).toHaveCSS('-webkit-font-smoothing', 'auto')
await expect(prompt.locator('#dialog-message')).toHaveCSS('font-weight', '400')
await expect(prompt.locator('#dialog-message')).toHaveCSS('font-size', '14px')
await window.evaluate(() => {
document.documentElement.className = 'light'
})
await expect(prompt.locator('html')).toHaveClass('light')
await expect(prompt.locator('html')).toHaveCSS('color-scheme', 'light')
await prompt.getByRole('button', { name: 'Wait', exact: true }).click()
})

test('recovery messages use an isolated EMCN dialog with a safe keyboard default', async () => {
app = await launchApp('http://127.0.0.1:1')
const window = await app.firstWindow()
Expand All @@ -188,7 +237,9 @@ test.describe('desktop shell smoke', () => {
BrowserWindow.getAllWindows()[0].webContents.emit('unresponsive')
})
const prompt = await dialogPromise
await expect(prompt.getByRole('dialog', { name: 'Sim', exact: true })).toBeVisible()
await expect(
prompt.getByRole('dialog', { name: 'Sim isn’t responding', exact: true })
).toBeVisible()
await expect(prompt.getByText('Sim isn’t responding')).toBeVisible()
await expect(prompt.getByRole('button', { name: 'Wait', exact: true })).toBeFocused()
await expect
Expand All @@ -213,10 +264,7 @@ test.describe('desktop shell smoke', () => {
win.webContents.ipc.removeHandler('shell:configuration')
win.webContents.ipc.handle('shell:configuration', () => ({
title: 'Long recovery message',
message: 'Recovery details',
detail: Array.from({ length: 80 }, (_, index) => `Diagnostic detail ${index + 1}`).join(
'\n'
),
text: Array.from({ length: 80 }, (_, index) => `Diagnostic detail ${index + 1}`).join('\n'),
type: 'warning',
buttons: ['Wait', 'Reload'],
defaultId: 0,
Expand Down Expand Up @@ -291,8 +339,6 @@ test.describe('desktop shell smoke', () => {
})

const closed = picker.waitForEvent('close')
// The main process destroys the window on the key-down, so the key-up half
// of `press` has no target to reach; the close event is the assertion.
await picker.keyboard.press('Escape').catch(() => {})
await closed
expect(app.windows()).toHaveLength(1)
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ files:
- dist/**
- static/**
- package.json
- from: ../sim/public/brand/fonts
- from: ../sim/app/_styles/fonts/season
to: static
filter:
- SeasonSansUprightsVF.woff2
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/dialogs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ describe('showShellDialog', () => {
resize?.(sender(win), Number.NaN)
expect(win.setContentSize).not.toHaveBeenCalled()
resize?.(sender(win), 100000)
expect(win.setContentSize).toHaveBeenCalledWith(500, 820)
expect(win.setContentSize).toHaveBeenCalledWith(440, 820)
respond(win, 0)
await result
})
Expand Down
11 changes: 6 additions & 5 deletions apps/desktop/src/main/dialogs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import { getErrorMessage } from '@sim/utils/errors'
import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron'
import { app, BrowserWindow, dialog, nativeTheme, session } from 'electron'
import { attachLocalPageProtocol, localPageUrl } from '@/main/local-pages'
import { attachShellTheme, backgroundColorFor, getShellTheme } from '@/main/shell-theme'
import { attachShellWindowSizing, isShellWindowSender } from '@/main/shell-window'
import { createSecureWebPreferences } from '@/main/window-preferences'
import type { ShellDialogConfiguration } from '@/shared/shell'

const logger = createLogger('DesktopDialogs')
const DIALOG_WIDTH = 500
const DIALOG_WIDTH = 440
const DIALOG_PARTITION = 'shell-dialogs'

interface ShellDialogOptions extends MessageBoxOptions {
Expand Down Expand Up @@ -39,9 +40,8 @@ export function showShellDialog(
buttons.findIndex((label) => /^(cancel|no|close|ok)$/i.test(label))
)
const configuration: ShellDialogConfiguration = {
title: options.title ?? 'Sim',
message: options.message,
detail: options.detail ?? '',
title: options.title ?? options.message,
text: [options.title ? options.message : '', options.detail].filter(Boolean).join('\n\n'),
buttons,
defaultId: options.defaultId ?? 0,
cancelId,
Expand All @@ -66,7 +66,7 @@ export function showShellDialog(
fullscreenable: false,
show: false,
title: configuration.title,
backgroundColor: nativeTheme.shouldUseDarkColors ? '#1b1b1b' : '#ffffff',
backgroundColor: backgroundColorFor(getShellTheme(), nativeTheme.shouldUseDarkColors),
...(parent && !parent.isDestroyed() ? { parent, modal: true } : {}),
webPreferences: createSecureWebPreferences(
DIALOG_PARTITION,
Expand All @@ -75,6 +75,7 @@ export function showShellDialog(
),
})
const pageUrl = localPageUrl('dialog.html')
attachShellTheme(win)
let settled = false
const finish = (response: number) => {
if (settled) return
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import {
readSessionUserId,
resolveStartRoute,
} from '@/main/session-lifecycle'
import { setShellTheme } from '@/main/shell-theme'
import { attachTelemetryPolicy } from '@/main/telemetry-policy'
import { TerminalRegistry } from '@/main/terminal/registry'
import { installTray, type TrayHandle } from '@/main/tray'
Expand Down Expand Up @@ -110,6 +111,7 @@ function main(): void {

const userDataPath = app.getPath('userData')
const config = createConfigStore(join(userDataPath, 'settings.json'))
setShellTheme(config.get('themeBackground'))
initializeAccountDataRecovery(join(userDataPath, 'account-data-teardown-required.json'))
const recoveryOrigin = getAccountDataTeardownOrigin()
if (isAccountDataTeardownRequired() && recoveryOrigin && !config.isPersistenceAvailable()) {
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/main/ipc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -665,13 +665,15 @@ describe('registerIpcHandlers', () => {
expect(deps.settings.chooseBrowserDownloadDirectory).toHaveBeenCalledTimes(1)
})

it('reports native fullscreen state only to the app origin', async () => {
it('reports native fullscreen state only to the app origin and bundled pages', async () => {
const { invoke } = collectHandlers()
const getWindowState = invoke.get('desktop:window-state:get')

expect(await getWindowState?.(evilEvent)).toEqual({ isFullScreen: false })
expect(await getWindowState?.(appEvent)).toEqual({ isFullScreen: true })
expect(deps.getWindowState).toHaveBeenCalledWith(appSender)
expect(await getWindowState?.(localPageEvent)).toEqual({ isFullScreen: true })
expect(deps.getWindowState).toHaveBeenCalledWith(localPageSender)
})

it('restricts shell-control channels to bundled local pages', () => {
Expand Down
12 changes: 10 additions & 2 deletions apps/desktop/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,14 +376,15 @@ export interface IpcDeps {
* - `app-origin`: only the remote app origin (main window pages).
* - `local-page`: only the bundled pages served from the shell's own scheme
* (offline, server) — shell control.
* - `app-or-local-page`: read-only window state used by both hosted and bundled pages.
* - `browser-page`: only the built-in browser's own tabs, identified by
* WebContents rather than by URL. These carry reports from the browser
* preload about untrusted pages, so they are the one inbound surface whose
* sender is not the app — the payload is treated as a claim to verify, never
* as an instruction.
* - `any`: sender-independent channels that validate their input instead.
*/
type ChannelGate = 'app-origin' | 'local-page' | 'browser-page' | 'any'
type ChannelGate = 'app-origin' | 'local-page' | 'app-or-local-page' | 'browser-page' | 'any'

/**
* A desktop surface the user can switch off. Channels that drive one are
Expand Down Expand Up @@ -816,7 +817,9 @@ export function registerIpcHandlers(deps: IpcDeps): void {
},
'desktop:window-state:get': {
kind: 'invoke',
gate: 'app-origin',
gate: 'app-or-local-page',
deviationReason:
'Bundled offline pages share the app title-bar geometry and need their own native fullscreen state.',
passSender: true,
denied: { isFullScreen: false },
handler: (sender) => deps.getWindowState(sender as WebContents),
Expand Down Expand Up @@ -1857,6 +1860,11 @@ export function registerIpcHandlers(deps: IpcDeps): void {
const senderAllowed = (event: IpcMainEvent | IpcMainInvokeEvent, gate: ChannelGate): boolean => {
if (gate === 'any') return true
if (gate === 'app-origin') return isAppOriginSender(event, deps.appOrigin())
if (gate === 'app-or-local-page') {
return (
isAppOriginSender(event, deps.appOrigin()) || isLocalPageSender(event, deps.isLocalPageUrl)
)
}
if (gate === 'browser-page') return isAgentWebContents(event.sender)
return isLocalPageSender(event, deps.isLocalPageUrl)
}
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/main/local-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,13 +167,13 @@ async function readFirst(rootDirs: readonly string[], name: string): Promise<Buf
* build: renderer bundles live in `dist/renderer`, and page shells live in
* `static/`, inside the packaged asar as well as in an unpackaged checkout. The brand font is copied into `static/` only when
* packaging (electron-builder.yml); an unpackaged run reads it from the web
* app's public fonts instead, so nothing generated has to exist in the tree
* app's Next font source instead, so nothing generated has to exist in the tree
* and a cached build restores everything the pages need.
*/
function localPageRoots(): string[] {
const roots = [join(__dirname, 'renderer'), join(__dirname, '..', 'static')]
if (!app.isPackaged) {
roots.push(join(__dirname, '..', '..', 'sim', 'public', 'brand', 'fonts'))
roots.push(join(__dirname, '..', '..', 'sim', 'app', '_styles', 'fonts', 'season'))
}
return roots
}
Expand Down
19 changes: 5 additions & 14 deletions apps/desktop/src/main/server-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import type { ConfigStore, DesktopSettings } from '@/main/config'
import { canonicalOrigin, isSimCloudOrigin, validateOriginInput } from '@/main/config'
import { showShellDialog } from '@/main/dialogs'
import { attachLocalPageProtocol, localPageUrl } from '@/main/local-pages'
import { attachShellTheme, backgroundColorFor, getShellTheme } from '@/main/shell-theme'
import { attachShellWindowSizing } from '@/main/shell-window'
import { backgroundColorFor, setupPermissionHandlers } from '@/main/window'
import { setupPermissionHandlers } from '@/main/window'
import { createSecureWebPreferences } from '@/main/window-preferences'

const logger = createLogger('DesktopServerWindow')
Expand Down Expand Up @@ -103,13 +104,7 @@ export interface ServerWindowHandle {
*/
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.
*/
/** Prevents concurrent IPC requests from interleaving server teardown and persistence. */
let changeInFlight = false

const getConfiguration = (): DesktopServerConfiguration => {
Expand Down Expand Up @@ -149,12 +144,7 @@ export function createServerWindow(deps: ServerWindowDeps): ServerWindowHandle {
title: 'Sim Server',
frame: false,
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),
backgroundColor: backgroundColorFor(getShellTheme(), 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.
Expand All @@ -169,6 +159,7 @@ export function createServerWindow(deps: ServerWindowDeps): ServerWindowHandle {
// ways out must therefore work without the page: Escape is handled here,
// and a page that fails to load closes the window instead of leaving a
// blank sheet nothing can dismiss.
attachShellTheme(win)
const opened = win
let closed = false
const closeOpened = () => {
Expand Down
52 changes: 52 additions & 0 deletions apps/desktop/src/main/shell-theme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('electron', () => import('@/test/electron-mock'))

import type { BrowserWindow as ElectronWindow } from 'electron'
import { attachShellTheme, getShellTheme, setShellTheme } from '@/main/shell-theme'
import { BrowserWindow } from '@/test/electron-mock'

beforeEach(() => {
setShellTheme(undefined)
vi.clearAllMocks()
})

describe('shell theme', () => {
it('retains the resolved app theme and updates only bundled pages', () => {
const local = new BrowserWindow({})
const app = new BrowserWindow({})
local.webContents.getURL.mockReturnValue('sim-shell://pages/dialog.html')
app.webContents.getURL.mockReturnValue('https://sim.example')
attachShellTheme(local as unknown as ElectronWindow)
attachShellTheme(app as unknown as ElectronWindow)
setShellTheme('dark')
expect(getShellTheme()).toBe('dark')
expect(local.webContents.send).toHaveBeenCalledWith('shell:theme-changed', 'dark')
expect(app.webContents.send).not.toHaveBeenCalled()
setShellTheme('dark')
expect(local.webContents.send).toHaveBeenCalledOnce()
local.on.mock.calls.find(([event]) => event === 'closed')?.[1]()
app.on.mock.calls.find(([event]) => event === 'closed')?.[1]()
setShellTheme('light')
expect(local.webContents.send).toHaveBeenCalledOnce()
})

it('rejects theme reads from foreign documents and subframes', () => {
const win = new BrowserWindow({})
attachShellTheme(win as unknown as ElectronWindow)
const read = win.webContents.ipc.handle.mock.calls.find(
([channel]) => channel === 'shell:get-theme'
)?.[1]
if (!read) throw new Error('Missing theme handler')
const event = { sender: win.webContents, senderFrame: win.webContents.mainFrame }
win.webContents.mainFrame.url = 'sim-shell://pages/offline.html?kind=dns'
setShellTheme('light')
expect(read(event)).toBe('light')
expect(() => read({ ...event, senderFrame: { url: event.senderFrame.url } })).toThrow(
'Untrusted'
)
win.webContents.mainFrame.url = 'https://untrusted.example'
expect(() => read(event)).toThrow('Untrusted')
win.on.mock.calls.find(([event]) => event === 'closed')?.[1]()
})
})
59 changes: 59 additions & 0 deletions apps/desktop/src/main/shell-theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { BrowserWindow } from 'electron'
import { isLocalPageUrl, localPageUrl } from '@/main/local-pages'
import type { ShellTheme } from '@/shared/shell'

let theme: ShellTheme | undefined
const windows = new Set<BrowserWindow>()

export function getShellTheme(): ShellTheme | undefined {
return theme
}

function isShellPage(url: string): boolean {
return isLocalPageUrl(url) || url === localPageUrl('dialog.html')
}

/** Retains Sim's last resolved theme so recovery works even after its renderer stops. */
export function setShellTheme(next: ShellTheme | undefined): void {
if (theme === next) return
theme = next
for (const win of windows) {
if (!win.isDestroyed() && isShellPage(win.webContents.getURL())) {
win.webContents.send('shell:theme-changed', theme)
}
}
}

/** Only bundled top-level pages can read the shell's appearance. */
export function attachShellTheme(win: BrowserWindow): void {
windows.add(win)
win.on('closed', () => windows.delete(win))
win.webContents.ipc.handle('shell:get-theme', (event) => {
if (
win.isDestroyed() ||
event.sender !== win.webContents ||
event.senderFrame !== win.webContents.mainFrame ||
!isShellPage(event.senderFrame.url)
) {
throw new Error('Untrusted shell theme sender')
}
return theme
})
}

/**
* Picks the pre-paint window background from the persisted web-app theme so
* dark-mode users never see a white flash before the remote page paints.
*/
export function backgroundColorFor(
theme: 'dark' | 'light' | undefined,
systemPrefersDark: boolean
): string {
if (theme === 'dark') {
return '#0c0c0c'
}
if (theme === 'light') {
return '#ffffff'
}
return systemPrefersDark ? '#0c0c0c' : '#ffffff'
}
Loading
Loading