Skip to content

Commit d8fe1ae

Browse files
committed
fix(desktop): unify shell dialogs and recovery screens with emcn
1 parent b49d28f commit d8fe1ae

57 files changed

Lines changed: 1566 additions & 794 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/desktop-e2e.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ on:
1010
- '.github/workflows/desktop-e2e.yml'
1111
- '.github/workflows/desktop-release.yml'
1212
- 'apps/desktop/**'
13+
- 'apps/sim/app/_styles/**'
14+
- 'apps/sim/lib/postcss/**'
15+
- 'apps/sim/postcss.config.mjs'
1316
- 'apps/sim/public/brand/fonts/**'
17+
- 'packages/emcn/**'
1418
- 'packages/desktop-bridge/**'
1519
- 'packages/browser-protocol/**'
1620
- 'packages/terminal-protocol/**'

apps/desktop/e2e/packaged-smoke.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,8 @@ test('packaged shell renders the bundled offline page', async () => {
146146
.toBe(true)
147147
const picker = findPage('sim-shell://pages/server.html')
148148
if (!picker) throw new Error('server picker disappeared')
149-
await expect(picker.locator('h1')).toHaveText('Sim server')
150-
await expect(picker.locator('#origin')).toHaveValue('http://127.0.0.1:1')
149+
await expect(picker.getByRole('dialog', { name: 'Sim server' })).toBeVisible()
150+
await expect(picker.getByLabel('Server URL')).toHaveValue('http://127.0.0.1:1')
151151
} finally {
152152
await browser?.close().catch(() => {})
153153
if (child.exitCode === null && child.signalCode === null) {

apps/desktop/e2e/smoke.spec.ts

Lines changed: 102 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,11 @@ test.describe('desktop shell smoke', () => {
158158
const window = await app.firstWindow()
159159
await window.waitForSelector('#retry', { timeout: 30_000 })
160160
expect(window.url()).toMatch(/^sim-shell:\/\/pages\/offline\.html\?/)
161-
await expect(window.locator('.wordmark')).toBeVisible()
162-
await expect(window.locator('.wordmark')).toHaveAttribute('aria-label', 'Sim')
161+
await expect(window.getByRole('img', { name: 'Sim', exact: true })).toBeVisible()
162+
await expect(window.getByRole('img', { name: 'Sim', exact: true })).toHaveAttribute(
163+
'aria-label',
164+
'Sim'
165+
)
163166
await expect(window.locator('#title')).toHaveText('Can’t connect to Sim')
164167
// The recovery path for a self-hosted shell pointed at a server it cannot
165168
// reach. Exercised end to end here because it is the only coverage of the
@@ -173,22 +176,74 @@ test.describe('desktop shell smoke', () => {
173176
await expect
174177
.poll(() => window.evaluate(() => document.fonts.check('16px "Season Sans"')))
175178
.toBe(true)
176-
await expect(window.locator('#retry')).toHaveCSS('height', '30px')
177-
await expect(window.locator('#retry')).toHaveCSS('border-radius', '8px')
178-
await expect(window.locator('#retry')).toHaveCSS('padding-left', '8px')
179-
await expect(window.locator('#retry')).toHaveCSS('font-size', '14px')
180-
await expect(window.locator('#retry')).toHaveCSS('line-height', '20px')
181-
await expect(window.locator('#retry')).toHaveCSS('text-align', 'left')
182-
await window.locator('#retry').focus()
183-
await expect(window.locator('#retry')).toHaveCSS('outline-style', 'solid')
184179
await expect(window.locator('#detail')).toHaveAttribute('role', 'status')
185180
})
186181

182+
test('recovery messages use an isolated EMCN dialog with a safe keyboard default', async () => {
183+
app = await launchApp('http://127.0.0.1:1')
184+
const window = await app.firstWindow()
185+
await expect(window.locator('#server')).toBeVisible()
186+
const dialogPromise = app.waitForEvent('window')
187+
await app.evaluate(({ BrowserWindow }) => {
188+
BrowserWindow.getAllWindows()[0].webContents.emit('unresponsive')
189+
})
190+
const prompt = await dialogPromise
191+
await expect(prompt.getByRole('dialog', { name: 'Sim', exact: true })).toBeVisible()
192+
await expect(prompt.getByText('Sim isn’t responding')).toBeVisible()
193+
await expect(prompt.getByRole('button', { name: 'Wait', exact: true })).toBeFocused()
194+
await expect
195+
.poll(() =>
196+
prompt
197+
.getByRole('dialog')
198+
.evaluate((element) => element.scrollHeight <= globalThis.innerHeight)
199+
)
200+
.toBe(true)
201+
await expect
202+
.poll(() => prompt.evaluate(() => typeof (globalThis as { simDesktop?: unknown }).simDesktop))
203+
.toBe('undefined')
204+
await prompt.screenshot({
205+
path: test.info().outputPath('recovery-dialog.png'),
206+
animations: 'disabled',
207+
})
208+
await app.evaluate(({ BrowserWindow }) => {
209+
const win = BrowserWindow.getAllWindows().find(
210+
(entry) => entry.webContents.getURL() === 'sim-shell://pages/dialog.html'
211+
)
212+
if (!win) throw new Error('Recovery dialog is missing')
213+
win.webContents.ipc.removeHandler('shell:configuration')
214+
win.webContents.ipc.handle('shell:configuration', () => ({
215+
title: 'Long recovery message',
216+
message: 'Recovery details',
217+
detail: Array.from({ length: 80 }, (_, index) => `Diagnostic detail ${index + 1}`).join(
218+
'\n'
219+
),
220+
type: 'warning',
221+
buttons: ['Wait', 'Reload'],
222+
defaultId: 0,
223+
cancelId: 0,
224+
}))
225+
win.webContents.reload()
226+
})
227+
await expect(
228+
prompt.getByRole('dialog', { name: 'Long recovery message', exact: true })
229+
).toBeVisible()
230+
await expect(prompt.getByRole('button', { name: 'Reload', exact: true })).toBeInViewport()
231+
await expect(prompt.getByRole('button', { name: 'Wait', exact: true })).toBeFocused()
232+
const closed = prompt.waitForEvent('close')
233+
await prompt
234+
.getByRole('button', { name: 'Wait', exact: true })
235+
.press('Enter')
236+
.catch(() => {})
237+
await closed
238+
await expect(window.locator('#server')).toBeVisible()
239+
})
240+
187241
// The picker is the only way to repoint a shell whose server is unreachable.
188242
// Its page, the pre-filled value (which crosses the local-page IPC gate) and
189243
// Escape are asserted together because the packaged build once opened it as
190244
// a blank sheet with no way out.
191-
test('the offline page opens the server picker, pre-filled, and Escape closes it', async () => {
245+
test('the offline server picker renders EMCN controls and handles validation and dismissal', async () => {
246+
const testInfo = test.info()
192247
app = await launchApp('http://127.0.0.1:1')
193248
const window = await app.firstWindow()
194249
await window.waitForSelector('#server', { timeout: 30_000 })
@@ -198,8 +253,42 @@ test.describe('desktop shell smoke', () => {
198253
const picker = await pickerPromise
199254

200255
expect(picker.url()).toBe('sim-shell://pages/server.html')
201-
await expect(picker.locator('h1')).toHaveText('Sim server')
202-
await expect(picker.locator('#origin')).toHaveValue('http://127.0.0.1:1')
256+
await expect(picker.getByRole('dialog', { name: 'Sim server', exact: true })).toBeVisible()
257+
await expect(picker.getByLabel('Server URL')).toHaveValue('http://127.0.0.1:1')
258+
await expect(picker.getByLabel('Server URL')).toBeFocused()
259+
await expect
260+
.poll(() =>
261+
picker
262+
.getByRole('dialog')
263+
.evaluate((element) => element.scrollHeight <= globalThis.innerHeight)
264+
)
265+
.toBe(true)
266+
await picker.getByLabel('Server URL').fill('http://example.com')
267+
await picker.getByLabel('Server URL').press('Enter')
268+
await expect(picker.getByRole('alert')).toBeVisible()
269+
await expect(picker.getByLabel('Server URL')).toHaveAttribute('aria-invalid', 'true')
270+
await picker.getByLabel('Server URL').fill('http://127.0.0.1:1')
271+
await expect(picker.getByRole('alert')).toHaveCount(0)
272+
await picker.getByRole('button', { name: 'Connect', exact: true }).click()
273+
await expect(picker.getByRole('status')).toHaveText('Already connected to this server.')
274+
await expect
275+
.poll(() =>
276+
picker
277+
.locator('[data-chip-modal-body]')
278+
.evaluate((element) => element.scrollHeight <= element.clientHeight)
279+
)
280+
.toBe(true)
281+
await picker.emulateMedia({ colorScheme: 'light' })
282+
await picker.screenshot({
283+
path: testInfo.outputPath('server-modal-light.png'),
284+
animations: 'disabled',
285+
})
286+
await picker.emulateMedia({ colorScheme: 'dark' })
287+
await expect(picker.locator('html')).toHaveClass('dark')
288+
await picker.screenshot({
289+
path: testInfo.outputPath('server-modal-dark.png'),
290+
animations: 'disabled',
291+
})
203292

204293
const closed = picker.waitForEvent('close')
205294
// The main process destroys the window on the key-down, so the key-up half

apps/desktop/package.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"version": "0.0.0",
44
"private": true,
55
"license": "Apache-2.0",
6-
"description": "Sim desktop app for macOS Electron shell around the hosted web app",
6+
"description": "Sim desktop app for macOS \u2014 Electron shell around the hosted web app",
77
"author": "Sim <support@sim.ai>",
88
"homepage": "https://sim.ai",
99
"type": "module",
@@ -47,13 +47,20 @@
4747
"devDependencies": {
4848
"@electron/fuses": "1.8.0",
4949
"@playwright/test": "1.61.1",
50+
"@sim/emcn": "workspace:*",
5051
"@sim/tsconfig": "workspace:*",
5152
"@types/micromatch": "4.0.10",
5253
"@types/node": "24.2.1",
54+
"@types/react": "^19",
55+
"@types/react-dom": "^19",
5356
"electron": "43.5.0",
5457
"electron-builder": "26.15.3",
5558
"esbuild": "0.28.1",
5659
"jsdom": "^26.0.0",
60+
"postcss": "^8",
61+
"postcss-load-config": "6.0.1",
62+
"react": "19.2.4",
63+
"react-dom": "19.2.4",
5764
"typescript": "^7.0.2",
5865
"vitest": "^4.1.0"
5966
}

apps/desktop/scripts/build.ts

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { execFileSync } from 'node:child_process'
2-
import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs'
3-
import { dirname, join } from 'node:path'
4-
import { build } from 'esbuild'
2+
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
3+
import { dirname, join, resolve } from 'node:path'
4+
import { type BuildOptions, build } from 'esbuild'
5+
import postcss from 'postcss'
6+
import loadPostcssConfig from 'postcss-load-config'
57
import { identityForOrigin } from './channels'
68

79
const watch = process.argv.includes('--watch')
@@ -94,10 +96,56 @@ const common = {
9496
},
9597
}
9698

99+
/** Bundles the shared EMCN components and app tokens for offline shell use. */
100+
const renderer: BuildOptions = {
101+
entryPoints: {
102+
server: 'src/renderer/server/index.tsx',
103+
offline: 'src/renderer/offline/index.tsx',
104+
dialog: 'src/renderer/dialog/index.tsx',
105+
},
106+
outdir: 'dist/renderer',
107+
bundle: true,
108+
platform: 'browser',
109+
format: 'iife',
110+
target: 'chrome146',
111+
minify: true,
112+
tsconfig: 'tsconfig.json',
113+
external: ['*.woff2'],
114+
define: { 'process.env.NODE_ENV': '"production"', 'process.env': '{}' },
115+
loader: { '.module.css': 'local-css' },
116+
plugins: [
117+
{
118+
name: 'desktop-tailwind',
119+
setup(builder) {
120+
builder.onLoad({ filter: /shell\.css$/ }, async ({ path }) => {
121+
const config = await loadPostcssConfig({}, resolve('../sim'))
122+
const result = await postcss(config.plugins).process(readFileSync(path, 'utf8'), {
123+
from: path,
124+
})
125+
return {
126+
contents: result.css,
127+
loader: 'css',
128+
resolveDir: dirname(path),
129+
watchFiles: result.messages.flatMap((message) =>
130+
message.type === 'dependency' ? [message.file as string] : []
131+
),
132+
}
133+
})
134+
},
135+
},
136+
],
137+
}
138+
97139
async function run(): Promise<void> {
98140
compileNativeHelpSearch()
99141
if (watch) {
100142
const { context } = await import('esbuild')
143+
const rendererCtx = await context(renderer)
144+
const shellPreloadCtx = await context({
145+
...common,
146+
entryPoints: ['src/preload/shell.ts'],
147+
outfile: 'dist/shell-preload.cjs',
148+
})
101149
const mainCtx = await context({
102150
...common,
103151
entryPoints: ['src/main/index.ts'],
@@ -115,10 +163,18 @@ async function run(): Promise<void> {
115163
entryPoints: ['src/preload/browser/index.ts'],
116164
outfile: 'dist/browser-preload.cjs',
117165
})
118-
await Promise.all([mainCtx.watch(), preloadCtx.watch(), browserPreloadCtx.watch()])
166+
await Promise.all([
167+
mainCtx.watch(),
168+
preloadCtx.watch(),
169+
browserPreloadCtx.watch(),
170+
rendererCtx.watch(),
171+
shellPreloadCtx.watch(),
172+
])
119173
return
120174
}
121175
await Promise.all([
176+
build(renderer),
177+
build({ ...common, entryPoints: ['src/preload/shell.ts'], outfile: 'dist/shell-preload.cjs' }),
122178
build({ ...common, entryPoints: ['src/main/index.ts'], outfile: 'dist/main.cjs' }),
123179
build({ ...common, entryPoints: ['src/preload/index.ts'], outfile: 'dist/preload.cjs' }),
124180
build({

apps/desktop/src/main/browser-agent/session.ts

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ import type {
3636
} from 'electron'
3737
import {
3838
app,
39-
dialog,
4039
session as electronSession,
4140
Menu,
4241
nativeTheme,
@@ -71,6 +70,7 @@ import {
7170
} from '@/main/browser-agent/url-guard'
7271
import { browserUserAgent } from '@/main/browser-agent/user-agent'
7372
import type { BrowserSessionSnapshot } from '@/main/desktop-chat-session-store'
73+
import { showShellDialog } from '@/main/dialogs'
7474
import { suggestedFilename, uniqueDownloadPath } from '@/main/downloads'
7575
import {
7676
type FocusedResourceShortcut,
@@ -1524,17 +1524,16 @@ async function requestSitePermission(details: {
15241524
const pending = scoped.tab.pendingSitePermission
15251525
if (!pending || pending.request.requestId !== request.requestId) return await allowed
15261526
pending.nativePromptController = nativePromptController
1527-
void dialog
1528-
.showMessageBox(win, {
1529-
type: 'warning',
1530-
buttons: ['Block', 'Allow'],
1531-
defaultId: 0,
1532-
cancelId: 0,
1533-
noLink: true,
1534-
signal: nativePromptController.signal,
1535-
message: `Allow this browser task to open ${request.origin}?`,
1536-
detail: 'Only allow this site if it is expected for the current task.',
1537-
})
1527+
void showShellDialog(win, {
1528+
type: 'warning',
1529+
buttons: ['Block', 'Allow'],
1530+
defaultId: 0,
1531+
cancelId: 0,
1532+
noLink: true,
1533+
signal: nativePromptController.signal,
1534+
message: `Allow this browser task to open ${request.origin}?`,
1535+
detail: 'Only allow this site if it is expected for the current task.',
1536+
})
15381537
.then(({ response }) => {
15391538
withBrowserScope(scoped.scopeId, () => {
15401539
respondToSitePermission(request.requestId, response === 1)

apps/desktop/src/main/browser-credentials/os-auth.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createLogger } from '@sim/logger'
22
import type { MessageBoxOptions } from 'electron'
3-
import { BrowserWindow, dialog, systemPreferences } from 'electron'
3+
import { BrowserWindow, systemPreferences } from 'electron'
4+
import { showShellDialog } from '@/main/dialogs'
45

56
const logger = createLogger('BrowserCredentialAuth')
67

@@ -143,8 +144,8 @@ async function promptForSecret(reason: string, action: string): Promise<boolean>
143144
const parent = BrowserWindow.getFocusedWindow()
144145
const { response } =
145146
parent && !parent.isDestroyed()
146-
? await dialog.showMessageBox(parent, options)
147-
: await dialog.showMessageBox(options)
147+
? await showShellDialog(parent, options)
148+
: await showShellDialog(options)
148149
return response === 1
149150
} catch (error) {
150151
// Fail closed: if the confirmation cannot be shown, nothing is revealed.

0 commit comments

Comments
 (0)