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
4 changes: 4 additions & 0 deletions .github/workflows/desktop-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ on:
- '.github/workflows/desktop-e2e.yml'
- '.github/workflows/desktop-release.yml'
- 'apps/desktop/**'
- 'apps/sim/app/_styles/**'
- 'apps/sim/lib/postcss/**'
- 'apps/sim/postcss.config.mjs'
- 'apps/sim/public/brand/fonts/**'
- 'packages/emcn/**'
- 'packages/desktop-bridge/**'
- 'packages/browser-protocol/**'
- 'packages/terminal-protocol/**'
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/e2e/packaged-smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,8 @@ test('packaged shell renders the bundled offline page', async () => {
.toBe(true)
const picker = findPage('sim-shell://pages/server.html')
if (!picker) throw new Error('server picker disappeared')
await expect(picker.locator('h1')).toHaveText('Sim server')
await expect(picker.locator('#origin')).toHaveValue('http://127.0.0.1:1')
await expect(picker.getByRole('dialog', { name: 'Sim server' })).toBeVisible()
await expect(picker.getByLabel('Server URL')).toHaveValue('http://127.0.0.1:1')
} finally {
await browser?.close().catch(() => {})
if (child.exitCode === null && child.signalCode === null) {
Expand Down
115 changes: 102 additions & 13 deletions apps/desktop/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,11 @@ test.describe('desktop shell smoke', () => {
const window = await app.firstWindow()
await window.waitForSelector('#retry', { timeout: 30_000 })
expect(window.url()).toMatch(/^sim-shell:\/\/pages\/offline\.html\?/)
await expect(window.locator('.wordmark')).toBeVisible()
await expect(window.locator('.wordmark')).toHaveAttribute('aria-label', 'Sim')
await expect(window.getByRole('img', { name: 'Sim', exact: true })).toBeVisible()
await expect(window.getByRole('img', { name: 'Sim', exact: true })).toHaveAttribute(
'aria-label',
'Sim'
)
await expect(window.locator('#title')).toHaveText('Can’t connect to Sim')
// 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
Expand All @@ -173,22 +176,74 @@ test.describe('desktop shell smoke', () => {
await expect
.poll(() => window.evaluate(() => document.fonts.check('16px "Season Sans"')))
.toBe(true)
await expect(window.locator('#retry')).toHaveCSS('height', '30px')
await expect(window.locator('#retry')).toHaveCSS('border-radius', '8px')
await expect(window.locator('#retry')).toHaveCSS('padding-left', '8px')
await expect(window.locator('#retry')).toHaveCSS('font-size', '14px')
await expect(window.locator('#retry')).toHaveCSS('line-height', '20px')
await expect(window.locator('#retry')).toHaveCSS('text-align', 'left')
await window.locator('#retry').focus()
await expect(window.locator('#retry')).toHaveCSS('outline-style', 'solid')
await expect(window.locator('#detail')).toHaveAttribute('role', 'status')
})

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()
await expect(window.locator('#server')).toBeVisible()
const dialogPromise = app.waitForEvent('window')
await app.evaluate(({ BrowserWindow }) => {
BrowserWindow.getAllWindows()[0].webContents.emit('unresponsive')
})
const prompt = await dialogPromise
await expect(prompt.getByRole('dialog', { name: 'Sim', exact: true })).toBeVisible()
await expect(prompt.getByText('Sim isn’t responding')).toBeVisible()
await expect(prompt.getByRole('button', { name: 'Wait', exact: true })).toBeFocused()
await expect
.poll(() =>
prompt
.getByRole('dialog')
.evaluate((element) => element.scrollHeight <= globalThis.innerHeight)
)
.toBe(true)
await expect
.poll(() => prompt.evaluate(() => typeof (globalThis as { simDesktop?: unknown }).simDesktop))
.toBe('undefined')
await prompt.screenshot({
path: test.info().outputPath('recovery-dialog.png'),
animations: 'disabled',
})
await app.evaluate(({ BrowserWindow }) => {
const win = BrowserWindow.getAllWindows().find(
(entry) => entry.webContents.getURL() === 'sim-shell://pages/dialog.html'
)
if (!win) throw new Error('Recovery dialog is missing')
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'
),
type: 'warning',
buttons: ['Wait', 'Reload'],
defaultId: 0,
cancelId: 0,
}))
win.webContents.reload()
})
await expect(
prompt.getByRole('dialog', { name: 'Long recovery message', exact: true })
).toBeVisible()
await expect(prompt.getByRole('button', { name: 'Reload', exact: true })).toBeInViewport()
await expect(prompt.getByRole('button', { name: 'Wait', exact: true })).toBeFocused()
const closed = prompt.waitForEvent('close')
await prompt
.getByRole('button', { name: 'Wait', exact: true })
.press('Enter')
.catch(() => {})
await closed
await expect(window.locator('#server')).toBeVisible()
})

// The picker is the only way to repoint a shell whose server is unreachable.
// Its page, the pre-filled value (which crosses the local-page IPC gate) and
// Escape are asserted together because the packaged build once opened it as
// a blank sheet with no way out.
test('the offline page opens the server picker, pre-filled, and Escape closes it', async () => {
test('the offline server picker renders EMCN controls and handles validation and dismissal', async () => {
const testInfo = test.info()
app = await launchApp('http://127.0.0.1:1')
const window = await app.firstWindow()
await window.waitForSelector('#server', { timeout: 30_000 })
Expand All @@ -198,8 +253,42 @@ test.describe('desktop shell smoke', () => {
const picker = await pickerPromise

expect(picker.url()).toBe('sim-shell://pages/server.html')
await expect(picker.locator('h1')).toHaveText('Sim server')
await expect(picker.locator('#origin')).toHaveValue('http://127.0.0.1:1')
await expect(picker.getByRole('dialog', { name: 'Sim server', exact: true })).toBeVisible()
await expect(picker.getByLabel('Server URL')).toHaveValue('http://127.0.0.1:1')
await expect(picker.getByLabel('Server URL')).toBeFocused()
await expect
.poll(() =>
picker
.getByRole('dialog')
.evaluate((element) => element.scrollHeight <= globalThis.innerHeight)
)
.toBe(true)
await picker.getByLabel('Server URL').fill('http://example.com')
await picker.getByLabel('Server URL').press('Enter')
await expect(picker.getByRole('alert')).toBeVisible()
await expect(picker.getByLabel('Server URL')).toHaveAttribute('aria-invalid', 'true')
await picker.getByLabel('Server URL').fill('http://127.0.0.1:1')
await expect(picker.getByRole('alert')).toHaveCount(0)
await picker.getByRole('button', { name: 'Connect', exact: true }).click()
await expect(picker.getByRole('status')).toHaveText('Already connected to this server.')
await expect
.poll(() =>
picker
.locator('[data-chip-modal-body]')
.evaluate((element) => element.scrollHeight <= element.clientHeight)
)
.toBe(true)
await picker.emulateMedia({ colorScheme: 'light' })
await picker.screenshot({
path: testInfo.outputPath('server-modal-light.png'),
animations: 'disabled',
})
await picker.emulateMedia({ colorScheme: 'dark' })
await expect(picker.locator('html')).toHaveClass('dark')
await picker.screenshot({
path: testInfo.outputPath('server-modal-dark.png'),
animations: 'disabled',
})

const closed = picker.waitForEvent('close')
// The main process destroys the window on the key-down, so the key-up half
Expand Down
9 changes: 8 additions & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"version": "0.0.0",
"private": true,
"license": "Apache-2.0",
"description": "Sim desktop app for macOS Electron shell around the hosted web app",
"description": "Sim desktop app for macOS \u2014 Electron shell around the hosted web app",
"author": "Sim <support@sim.ai>",
"homepage": "https://sim.ai",
"type": "module",
Expand Down Expand Up @@ -47,13 +47,20 @@
"devDependencies": {
"@electron/fuses": "1.8.0",
"@playwright/test": "1.61.1",
"@sim/emcn": "workspace:*",
"@sim/tsconfig": "workspace:*",
"@types/micromatch": "4.0.10",
"@types/node": "24.2.1",
"@types/react": "^19",
"@types/react-dom": "^19",
"electron": "43.5.0",
"electron-builder": "26.15.3",
"esbuild": "0.28.1",
"jsdom": "^26.0.0",
"postcss": "^8",
"postcss-load-config": "6.0.1",
"react": "19.2.4",
"react-dom": "19.2.4",
"typescript": "^7.0.2",
"vitest": "^4.1.0"
}
Expand Down
64 changes: 60 additions & 4 deletions apps/desktop/scripts/build.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { execFileSync } from 'node:child_process'
import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { build } from 'esbuild'
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { type BuildOptions, build } from 'esbuild'
import postcss from 'postcss'
import loadPostcssConfig from 'postcss-load-config'
import { identityForOrigin } from './channels'

const watch = process.argv.includes('--watch')
Expand Down Expand Up @@ -94,10 +96,56 @@ const common = {
},
}

/** Bundles the shared EMCN components and app tokens for offline shell use. */
const renderer: BuildOptions = {
entryPoints: {
server: 'src/renderer/server/index.tsx',
offline: 'src/renderer/offline/index.tsx',
dialog: 'src/renderer/dialog/index.tsx',
},
outdir: 'dist/renderer',
bundle: true,
platform: 'browser',
format: 'iife',
target: 'chrome146',
minify: true,
tsconfig: 'tsconfig.json',
external: ['*.woff2'],
define: { 'process.env.NODE_ENV': '"production"', 'process.env': '{}' },
loader: { '.module.css': 'local-css' },
plugins: [
{
name: 'desktop-tailwind',
setup(builder) {
builder.onLoad({ filter: /shell\.css$/ }, async ({ path }) => {
const config = await loadPostcssConfig({}, resolve('../sim'))
const result = await postcss(config.plugins).process(readFileSync(path, 'utf8'), {
from: path,
})
return {
contents: result.css,
loader: 'css',
resolveDir: dirname(path),
watchFiles: result.messages.flatMap((message) =>
message.type === 'dependency' ? [message.file as string] : []
),
}
})
},
},
],
}

async function run(): Promise<void> {
compileNativeHelpSearch()
if (watch) {
const { context } = await import('esbuild')
const rendererCtx = await context(renderer)
const shellPreloadCtx = await context({
...common,
entryPoints: ['src/preload/shell.ts'],
outfile: 'dist/shell-preload.cjs',
})
const mainCtx = await context({
...common,
entryPoints: ['src/main/index.ts'],
Expand All @@ -115,10 +163,18 @@ async function run(): Promise<void> {
entryPoints: ['src/preload/browser/index.ts'],
outfile: 'dist/browser-preload.cjs',
})
await Promise.all([mainCtx.watch(), preloadCtx.watch(), browserPreloadCtx.watch()])
await Promise.all([
mainCtx.watch(),
preloadCtx.watch(),
browserPreloadCtx.watch(),
rendererCtx.watch(),
shellPreloadCtx.watch(),
])
return
}
await Promise.all([
build(renderer),
build({ ...common, entryPoints: ['src/preload/shell.ts'], outfile: 'dist/shell-preload.cjs' }),
build({ ...common, entryPoints: ['src/main/index.ts'], outfile: 'dist/main.cjs' }),
build({ ...common, entryPoints: ['src/preload/index.ts'], outfile: 'dist/preload.cjs' }),
build({
Expand Down
23 changes: 11 additions & 12 deletions apps/desktop/src/main/browser-agent/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ import type {
} from 'electron'
import {
app,
dialog,
session as electronSession,
Menu,
nativeTheme,
Expand Down Expand Up @@ -71,6 +70,7 @@ import {
} from '@/main/browser-agent/url-guard'
import { browserUserAgent } from '@/main/browser-agent/user-agent'
import type { BrowserSessionSnapshot } from '@/main/desktop-chat-session-store'
import { showShellDialog } from '@/main/dialogs'
import { suggestedFilename, uniqueDownloadPath } from '@/main/downloads'
import {
type FocusedResourceShortcut,
Expand Down Expand Up @@ -1524,17 +1524,16 @@ async function requestSitePermission(details: {
const pending = scoped.tab.pendingSitePermission
if (!pending || pending.request.requestId !== request.requestId) return await allowed
pending.nativePromptController = nativePromptController
void dialog
.showMessageBox(win, {
type: 'warning',
buttons: ['Block', 'Allow'],
defaultId: 0,
cancelId: 0,
noLink: true,
signal: nativePromptController.signal,
message: `Allow this browser task to open ${request.origin}?`,
detail: 'Only allow this site if it is expected for the current task.',
})
void showShellDialog(win, {
type: 'warning',
buttons: ['Block', 'Allow'],
defaultId: 0,
cancelId: 0,
noLink: true,
signal: nativePromptController.signal,
message: `Allow this browser task to open ${request.origin}?`,
detail: 'Only allow this site if it is expected for the current task.',
})
.then(({ response }) => {
withBrowserScope(scoped.scopeId, () => {
respondToSitePermission(request.requestId, response === 1)
Expand Down
7 changes: 4 additions & 3 deletions apps/desktop/src/main/browser-credentials/os-auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import type { MessageBoxOptions } from 'electron'
import { BrowserWindow, dialog, systemPreferences } from 'electron'
import { BrowserWindow, systemPreferences } from 'electron'
import { showShellDialog } from '@/main/dialogs'

const logger = createLogger('BrowserCredentialAuth')

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