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
2 changes: 2 additions & 0 deletions .github/workflows/desktop-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ jobs:
PLAYWRIGHT_JSON_OUTPUT_FILE: test-results/desktop-smoke.json
BACKGROUND_EXECUTOR_REPORT_PATH: test-results/background-executor-report.json
TERMINAL_CANCEL_REPORT_PATH: test-results/terminal-cancel-report.json
CONTINUE_SIGN_IN_REPORT_PATH: test-results/continue-sign-in-report.json

- name: Upload test results
if: failure()
Expand Down Expand Up @@ -192,6 +193,7 @@ jobs:
env:
BACKGROUND_EXECUTOR_REPORT_PATH: test-results/background-executor-report.json
TERMINAL_CANCEL_REPORT_PATH: test-results/terminal-cancel-report.json
CONTINUE_SIGN_IN_REPORT_PATH: test-results/continue-sign-in-report.json

- name: Upload test results
if: failure()
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,11 @@ tabs and popups select their session from the destination origin. Sign-out and a
or server changes use the existing browser teardown. Browser views retain their own
permission/download policy, SSRF guards, and minimal preload with no `simDesktop` API.

**Continue in Chrome** (browser menu, plus a hint on known sign-in hosts) opens the active
https tab in a chosen Chromium-family profile and **Bring session back** imports only that
page's registrable-domain cookies from it; the rationale and gating live on
`SimDesktopBrowserImportApi.continueSignIn` in `@sim/desktop-bridge`.

Generated HTML remains inside its `allow-scripts` sandbox. The driver can inspect and
interact with inline frames through isolated Chromium worlds, including out-of-process
frames; it does not grant those pages access to the parent app. These shell changes
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/e2e-durations.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"chat-panel.spec.ts": 31.08,
"check-report.spec.ts": 3.78,
"computer-use.spec.ts": 2.07,
"continue-sign-in.spec.ts": 5.8,
"desktop-tools-live-sim.spec.ts": 0.0,
"local-files.spec.ts": 27.68,
"packaged-smoke.spec.ts": 0.0,
Expand Down
179 changes: 179 additions & 0 deletions apps/desktop/e2e/continue-sign-in.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { createServer, type Server } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { type ElectronApplication, _electron as electron, expect, test } from '@playwright/test'
import type { SimDesktopApi } from '@sim/desktop-bridge'
import { recordCheck } from './check-report'

const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url))
const SCOPE = 'continue-sign-in-fixture'

/**
* "Continue in Chrome" through the real preload, IPC table, and browser session.
*
* The shell runs with an empty home directory, so no browser profile exists and
* nothing here can launch the developer's browser or reach their Keychain. That
* is also the unsupported case the web app must hide the feature for. The
* import-back half against a real Chrome profile needs a Keychain item and is
* covered by the importer's fixture-database tests instead.
*/
test.describe('continue a sign-in in another browser', () => {
let server: Server
let origin: string
let site: string
let home: string
let userData: string
let app: ElectronApplication | undefined

test.beforeAll(async () => {
server = createServer((request, response) => {
const path = new URL(request.url ?? '/', 'http://localhost').pathname
if (path === '/api/auth/get-session') {
response.writeHead(200, { 'Content-Type': 'application/json' })
response.end(
JSON.stringify({ user: { id: 'fixture-user' }, session: { id: 'fixture-session' } })
)
return
}
if (path.startsWith('/api/')) {
response.writeHead(200, { 'Content-Type': 'application/json' })
response.end('{}')
return
}
if (request.headers.host?.startsWith('localhost')) {
response.writeHead(200, { 'Content-Type': 'text/html' })
response.end('<!doctype html><title>Sign in</title><h1>Sign in</h1>')
return
}
response.writeHead(200, {
'Content-Type': 'text/html',
'Set-Cookie': 'better-auth.session_token=fixture; HttpOnly; SameSite=Lax; Path=/',
})
response.end(`<!doctype html><title>Sim fixture</title><h1>Continue fixture</h1>
<button id="open">Open</button><button id="continue">Continue</button><button id="back">Back</button>
<script>
const api = globalThis.simDesktop
const scope = ${JSON.stringify(SCOPE)}
const record = (key) => (value) => { globalThis[key] = value }
document.getElementById('open').onclick = () =>
api.browserAgent.openUrl(location.origin.replace('127.0.0.1', 'localhost') + '/login', scope)
.then(record('opened'))
document.getElementById('continue').onclick = () =>
api.browserImport.continueSignIn('chrome:Default', scope).then(record('continued'))
document.getElementById('back').onclick = () =>
api.browserImport.bringSignInBack(scope).then(record('broughtBack'))
</script>`)
})
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Missing fixture address')
origin = `http://127.0.0.1:${address.port}`
site = `http://localhost:${address.port}`
})

test.beforeEach(async () => {
home = mkdtempSync(join(tmpdir(), 'sim-continue-home-'))
userData = mkdtempSync(join(tmpdir(), 'sim-continue-e2e-'))
app = await electron.launch({
args: [process.env.SIM_DESKTOP_E2E_MAIN ?? '.'],
cwd: DESKTOP_DIR,
env: {
...process.env,
HOME: home,
SIM_DESKTOP_ORIGIN: origin,
SIM_DESKTOP_USER_DATA: userData,
},
})
const page = await app.firstWindow()
await expect(page.getByRole('heading')).toHaveText('Continue fixture')
await page.evaluate(async (scope) => {
const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop
await api.browserAgent.activateScope(scope)
api.browserAgent.setPanelBounds({ x: 0, y: 120, width: 800, height: 400 }, null, scope)
}, SCOPE)
})

test.afterEach(async () => {
const testInfo = test.info()
recordCheck(process.env.CONTINUE_SIGN_IN_REPORT_PATH, 'continue-sign-in', {
name: testInfo.title,
status: testInfo.status ?? 'unknown',
durationMs: testInfo.duration,
retry: testInfo.retry,
})
await app?.close()
app = undefined
rmSync(userData, { recursive: true, force: true })
rmSync(home, { recursive: true, force: true })
})

test.afterAll(async () => {
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve()))
)
})

test('advertises the capability, but offers no browser when none is installed', async () => {
const page = await app?.firstWindow()
const surface = await page?.evaluate(async () => {
const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop
return {
continueSignIn: typeof api.browserImport?.continueSignIn,
bringSignInBack: typeof api.browserImport?.bringSignInBack,
pending: await api.browserImport?.getPendingSignIn?.('continue-sign-in-fixture'),
profiles: await api.browserImport?.listChromeProfiles(),
}
})

expect(surface).toEqual({
continueSignIn: 'function',
bringSignInBack: 'function',
pending: null,
profiles: [],
})
})

test('refuses page script that acts without the user', async () => {
const page = await app?.firstWindow()
const results = await page?.evaluate(async (scope) => {
const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop
return {
continued: await api.browserImport?.continueSignIn?.('chrome:Default', scope),
broughtBack: await api.browserImport?.bringSignInBack?.(scope),
}
}, SCOPE)

expect(results).toEqual({
continued: { error: 'unknown' },
broughtBack: { cookiesImported: 0, cookiesSkipped: 0, error: 'unknown' },
})
})

test('reads the page from the tab itself and hands over only https', async () => {
const page = await app?.firstWindow()
if (!page || !app) throw new Error('App did not launch')
const read = (key: string) => page.evaluate((name) => Reflect.get(globalThis, name), key)

await page.locator('#open').click()
await expect.poll(() => read('opened')).toMatchObject({ activeTabId: expect.any(String) })
await expect
.poll(() =>
app?.evaluate(
({ webContents }, target) =>
webContents.getAllWebContents().some((contents) => contents.getURL() === target),
`${site}/login`
)
)
.toBe(true)

await page.locator('#continue').click()
await expect.poll(() => read('continued')).toEqual({ error: 'unsupported-page' })

await page.locator('#back').click()
await expect
.poll(() => read('broughtBack'))
.toEqual({ cookiesImported: 0, cookiesSkipped: 0, error: 'unknown' })
})
})
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"electron-updater": "6.8.9",
"micromatch": "4.0.8",
"safe-regex2": "5.1.2",
"tldts": "7.4.18",
"pdf-lib": "1.17.1",
"@sim/auth-ui": "workspace:*"
},
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/main/browser-agent/known-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
BrowserKnownSessionsState,
BrowserSessionEvidence,
} from '@sim/browser-protocol'
import { isSameOrSubdomain } from '@/main/browser-import/site-scope'
import type { BrowserKnownSiteSetting, ConfigStore, DesktopSettings } from '@/main/config'

const MAX_STORED_SITES = 100
Expand Down Expand Up @@ -107,7 +108,7 @@ function recordsEqual(

function cookieMatchesHost(cookieDomain: string, hostname: string): boolean {
const domain = normalizePublicHostname(cookieDomain)
return domain !== null && (hostname === domain || hostname.endsWith(`.${domain}`))
return domain !== null && isSameOrSubdomain(hostname, domain)
}

/**
Expand Down
25 changes: 25 additions & 0 deletions apps/desktop/src/main/browser-import/browser-launch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'

const execFileAsync = promisify(execFile)

const OPEN_TIMEOUT_MS = 15_000

/**
* Opens `url` in one profile of an installed Chromium-family browser.
*
* `-n` launches a fresh instance whose command line Chromium's process
* singleton forwards to the running browser; without it a running browser
* drops `--args` and opens the page in whichever profile was last active.
*/
export async function openInBrowserProfile(
bundleId: string,
profileDirectory: string,
url: string
): Promise<void> {
await execFileAsync(
'/usr/bin/open',
['-n', '-b', bundleId, '--args', `--profile-directory=${profileDirectory}`, url],
{ timeout: OPEN_TIMEOUT_MS }
)
}
9 changes: 9 additions & 0 deletions apps/desktop/src/main/browser-import/browser-sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export interface BrowserSource {
userDataSegments: readonly string[]
/** The login-Keychain generic-password item holding the Safe Storage key. */
keychain: { service: string; account: string }
/** The macOS bundle identifier, used to open a page in this browser. */
bundleId: string
}

export const BROWSER_SOURCES: readonly BrowserSource[] = [
Expand All @@ -34,42 +36,49 @@ export const BROWSER_SOURCES: readonly BrowserSource[] = [
label: 'Chrome',
userDataSegments: ['Library', 'Application Support', 'Google', 'Chrome'],
keychain: { service: 'Chrome Safe Storage', account: 'Chrome' },
bundleId: 'com.google.Chrome',
},
{
id: 'arc',
label: 'Arc',
userDataSegments: ['Library', 'Application Support', 'Arc', 'User Data'],
keychain: { service: 'Arc Safe Storage', account: 'Arc' },
bundleId: 'company.thebrowser.Browser',
},
{
id: 'dia',
label: 'Dia',
userDataSegments: ['Library', 'Application Support', 'Dia', 'User Data'],
keychain: { service: 'Dia Safe Storage', account: 'Dia' },
bundleId: 'company.thebrowser.dia',
},
{
id: 'brave',
label: 'Brave',
userDataSegments: ['Library', 'Application Support', 'BraveSoftware', 'Brave-Browser'],
keychain: { service: 'Brave Safe Storage', account: 'Brave' },
bundleId: 'com.brave.Browser',
},
{
id: 'edge',
label: 'Microsoft Edge',
userDataSegments: ['Library', 'Application Support', 'Microsoft Edge'],
keychain: { service: 'Microsoft Edge Safe Storage', account: 'Microsoft Edge' },
bundleId: 'com.microsoft.edgemac',
},
{
id: 'vivaldi',
label: 'Vivaldi',
userDataSegments: ['Library', 'Application Support', 'Vivaldi'],
keychain: { service: 'Vivaldi Safe Storage', account: 'Vivaldi' },
bundleId: 'com.vivaldi.Vivaldi',
},
{
id: 'chromium',
label: 'Chromium',
userDataSegments: ['Library', 'Application Support', 'Chromium'],
keychain: { service: 'Chromium Safe Storage', account: 'Chromium' },
bundleId: 'org.chromium.Chromium',
},
] as const

Expand Down
Loading
Loading