feat(browser, terminal): implement browser driver, password manager, terminal features (#6196)
* icon styling * feat(desktop): isolate chat browser and terminal sessions * feat(desktop): uncap browser and terminal tabs * feat(desktop): polish browser and terminal resources * improvement desktop * fixes * updates * fixes * fix * update tests
@@ -1,5 +1,5 @@
|
||||
dist/
|
||||
release/
|
||||
build/generated-icon.icns
|
||||
build/generated-icon.icon
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
@@ -100,7 +100,7 @@ Local unsigned build: `bun run package:dir` (app in `release/mac-universal/`). S
|
||||
|
||||
Pre-release share (no Developer ID yet): `SIM_DESKTOP_DEFAULT_ORIGIN=https://www.dev.sim.ai bun run package:share` builds a DMG whose fresh installs default to that origin (baked at build time; official builds leave it unset → prod) and skips per-file signature timestamps. Recipients must clear quarantine once: `xattr -cr /Applications/Sim.app`.
|
||||
|
||||
The build also derives the app icon from `SIM_DESKTOP_DEFAULT_ORIGIN`. Every channel uses the exact production icon with its white background and black `sim` mark. Non-production channels add a thin outline using existing platform colors: dev uses orange, staging uses Loop blue, and localhost uses Workflow violet. The macOS menu-bar icon also carries a compact `D`, `S`, or `L` subscript for those environments; production remains unmarked. Packaged `.icns` files live in `build/`; `scripts/build.ts` copies the selected variant to the ignored `build/generated-icon.icns` path consumed by electron-builder. Matching 512px PNGs in `static/` provide the Dock icon for unpackaged runs.
|
||||
The build also derives the app icon from `SIM_DESKTOP_DEFAULT_ORIGIN`. Every channel uses the exact production icon with its white background and black `sim` mark. Non-production channels add a thin outline using existing platform colors: dev uses orange, staging uses Loop blue, and localhost uses Workflow violet. The macOS menu-bar icon also carries a compact `D`, `S`, or `L` subscript for those environments; production remains unmarked. Native Icon Composer assets live in `build/`; `scripts/build.ts` copies the selected variant to the ignored `build/generated-icon.icon` path consumed by electron-builder. Electron-builder compiles it to `Assets.car` and derives the legacy `.icns` fallback from the same source. Matching 512px PNGs in `static/` provide the Dock icon for unpackaged runs.
|
||||
|
||||
CI (`.github/workflows/desktop-release.yml`, wired into `ci.yml`):
|
||||
- Runs only after `create-release` on a `vX.Y.Z:` commit to main — **never before**: `scripts/create-single-release.ts` skips creation if the tag exists, so a desktop job publishing first would eat the changelog. The job builds `--publish never` and uploads assets with `gh release upload --clobber` (idempotent re-runs).
|
||||
|
||||
|
Before Width: | Height: | Size: 204 B After Width: | Height: | Size: 204 B |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||
<rect x="88" y="88" width="848" height="848" rx="192" fill="none" stroke="#8b5cf6" stroke-width="24"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 204 B |
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"fill": {
|
||||
"solid": "srgb:1.00000,1.00000,1.00000,1.00000"
|
||||
},
|
||||
"groups": [
|
||||
{
|
||||
"layers": [
|
||||
{
|
||||
"image-name": "logo.png",
|
||||
"is-glass": false,
|
||||
"name": "Sim"
|
||||
},
|
||||
{
|
||||
"image-name": "border.svg",
|
||||
"is-glass": false,
|
||||
"name": "Local Border"
|
||||
}
|
||||
],
|
||||
"shadow": {
|
||||
"kind": "neutral",
|
||||
"opacity": 0
|
||||
},
|
||||
"specular": false,
|
||||
"translucency": {
|
||||
"enabled": false,
|
||||
"value": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms": {
|
||||
"squares": ["macOS"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||
<rect x="88" y="88" width="848" height="848" rx="192" fill="none" stroke="#2fb3ff" stroke-width="24"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 204 B |
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"fill": {
|
||||
"solid": "srgb:1.00000,1.00000,1.00000,1.00000"
|
||||
},
|
||||
"groups": [
|
||||
{
|
||||
"layers": [
|
||||
{
|
||||
"image-name": "logo.png",
|
||||
"is-glass": false,
|
||||
"name": "Sim"
|
||||
},
|
||||
{
|
||||
"image-name": "border.svg",
|
||||
"is-glass": false,
|
||||
"name": "Staging Border"
|
||||
}
|
||||
],
|
||||
"shadow": {
|
||||
"kind": "neutral",
|
||||
"opacity": 0
|
||||
},
|
||||
"specular": false,
|
||||
"translucency": {
|
||||
"enabled": false,
|
||||
"value": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms": {
|
||||
"squares": ["macOS"]
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"fill": {
|
||||
"solid": "srgb:1.00000,1.00000,1.00000,1.00000"
|
||||
},
|
||||
"groups": [
|
||||
{
|
||||
"layers": [
|
||||
{
|
||||
"image-name": "logo.png",
|
||||
"is-glass": false,
|
||||
"name": "Sim"
|
||||
}
|
||||
],
|
||||
"shadow": {
|
||||
"kind": "neutral",
|
||||
"opacity": 0
|
||||
},
|
||||
"specular": false,
|
||||
"translucency": {
|
||||
"enabled": false,
|
||||
"value": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms": {
|
||||
"squares": ["macOS"]
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,9 @@ mac:
|
||||
arch: [universal]
|
||||
- target: zip
|
||||
arch: [universal]
|
||||
icon: build/generated-icon.icns
|
||||
# Compiles to Assets.car + CFBundleIconName for the native macOS icon path;
|
||||
# electron-builder derives the legacy .icns fallback from the same source.
|
||||
icon: build/generated-icon.icon
|
||||
# node-pty keeps each architecture's binary at its own path, so both halves of
|
||||
# the universal build carry an identical copy of both. @electron/universal
|
||||
# refuses single-arch Mach-O files it wasn't told about, so name them here —
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { copyFileSync } from 'node:fs'
|
||||
import { cpSync, rmSync } from 'node:fs'
|
||||
import { build } from 'esbuild'
|
||||
import { identityForOrigin } from './channels'
|
||||
|
||||
const watch = process.argv.includes('--watch')
|
||||
|
||||
@@ -23,20 +24,10 @@ if (bakedDefaultOrigin) {
|
||||
console.log(`• Baking default server origin: ${bakedDefaultOrigin}`)
|
||||
}
|
||||
|
||||
/** Selects the branded app icon that matches the build's baked environment. */
|
||||
function iconForOrigin(origin: string): string {
|
||||
if (!origin) return 'build/icon.icns'
|
||||
const host = new URL(origin).hostname.toLowerCase()
|
||||
if (host === 'localhost' || host === '127.0.0.1') return 'build/icon-local.icns'
|
||||
if (host === 'dev.sim.ai' || host.endsWith('.dev.sim.ai')) return 'build/icon-dev.icns'
|
||||
if (host === 'staging.sim.ai' || host.endsWith('.staging.sim.ai')) {
|
||||
return 'build/icon-staging.icns'
|
||||
}
|
||||
return 'build/icon.icns'
|
||||
}
|
||||
|
||||
const appIcon = iconForOrigin(bakedDefaultOrigin)
|
||||
copyFileSync(appIcon, 'build/generated-icon.icns')
|
||||
const appIcon = identityForOrigin(bakedDefaultOrigin).icon
|
||||
const generatedIcon = 'build/generated-icon.icon'
|
||||
rmSync(generatedIcon, { force: true, recursive: true })
|
||||
cpSync(appIcon, generatedIcon, { recursive: true })
|
||||
console.log(`• Selecting desktop icon: ${appIcon}`)
|
||||
|
||||
const common = {
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface ChannelIdentity {
|
||||
appId: string
|
||||
/** Baked default origin + persisted settings origin. */
|
||||
origin: string
|
||||
/** Native Icon Composer source copied into the packager's generated path. */
|
||||
icon: string
|
||||
/**
|
||||
* Artifact filename stem, and the per-channel scratch directory name.
|
||||
* Space-free for the same reason electron-builder.yml's artifactName is:
|
||||
@@ -36,24 +38,28 @@ export const PROD: ChannelIdentity = {
|
||||
name: 'Sim',
|
||||
appId: 'ai.sim.desktop',
|
||||
origin: PROD_ORIGIN,
|
||||
icon: 'build/icon.icon',
|
||||
slug: 'sim',
|
||||
}
|
||||
export const STAGING: ChannelIdentity = {
|
||||
name: 'Sim Staging',
|
||||
appId: 'ai.sim.desktop.staging',
|
||||
origin: STAGING_ORIGIN,
|
||||
icon: 'build/icon-staging.icon',
|
||||
slug: 'sim-staging',
|
||||
}
|
||||
export const DEV: ChannelIdentity = {
|
||||
name: 'Sim Dev',
|
||||
appId: 'ai.sim.desktop.dev',
|
||||
origin: DEV_ORIGIN,
|
||||
icon: 'build/icon-dev.icon',
|
||||
slug: 'sim-dev',
|
||||
}
|
||||
export const LOCAL: ChannelIdentity = {
|
||||
name: 'Sim Local',
|
||||
appId: 'ai.sim.desktop.local',
|
||||
origin: LOCAL_ORIGIN,
|
||||
icon: 'build/icon-local.icon',
|
||||
slug: 'sim-local',
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,9 @@ const identity = channelFlags.length === 1 ? CHANNEL_FLAGS[channelFlags[0]] : DE
|
||||
const APP_NAME = `${identity.name}.app`
|
||||
const INSTALL_PATH = `/Applications/${APP_NAME}`
|
||||
const RELEASE_DIRS = ['release/mac-universal', 'release/mac-arm64', 'release/mac']
|
||||
const LOCAL_BUILD_VERSION = Math.floor(Date.now() / 1000).toString()
|
||||
const LAUNCH_SERVICES_REGISTER =
|
||||
'/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister'
|
||||
/** Matches the app's userData path (app.setName(...) in src/main/index.ts). */
|
||||
const SETTINGS_PATH = join(homedir(), `Library/Application Support/${identity.name}/settings.json`)
|
||||
|
||||
@@ -121,6 +124,13 @@ function applyOrigin(origin: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function refreshInstalledIcon(): void {
|
||||
run('touch', [INSTALL_PATH])
|
||||
if (existsSync(LAUNCH_SERVICES_REGISTER)) {
|
||||
run(LAUNCH_SERVICES_REGISTER, ['-f', INSTALL_PATH])
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`• Packaging ${identity.name} from the current checkout…`)
|
||||
run(
|
||||
'bun',
|
||||
@@ -148,6 +158,7 @@ run('bunx', [
|
||||
'-c.mac.timestamp=none',
|
||||
`-c.productName=${identity.name}`,
|
||||
`-c.appId=${identity.appId}`,
|
||||
`-c.buildVersion=${LOCAL_BUILD_VERSION}`,
|
||||
])
|
||||
|
||||
const builtApp = RELEASE_DIRS.map((dir) => join(dir, APP_NAME)).find(existsSync)
|
||||
@@ -169,6 +180,7 @@ console.log(`• Installing ${builtApp} → ${INSTALL_PATH}`)
|
||||
rmSync(INSTALL_PATH, { recursive: true, force: true })
|
||||
// ditto preserves the code signature and extended attributes, unlike cp.
|
||||
run('ditto', [builtApp, INSTALL_PATH])
|
||||
refreshInstalledIcon()
|
||||
|
||||
if (identity.origin) {
|
||||
applyOrigin(identity.origin)
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
* naming itself "Sim Dev" at runtime.
|
||||
*
|
||||
* Channels build ONE AT A TIME on purpose. scripts/build.ts writes the bundle
|
||||
* to dist/ and the app icon to build/generated-icon.icns, both fixed paths, so
|
||||
* to dist/ and the app icon to build/generated-icon.icon, both fixed paths, so
|
||||
* concurrent channels would overwrite each other's bundle mid-package and ship
|
||||
* a dmg whose baked origin belongs to a different environment — invisible until
|
||||
* someone signs in. Giving each channel its own bundle directory is what would
|
||||
|
||||
@@ -2,8 +2,456 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('electron', () => import('@/test/electron-mock'))
|
||||
|
||||
import { WebContentsView } from 'electron'
|
||||
import { setColorScheme } from '@/main/browser-agent/cdp'
|
||||
import { WebContentsView, type WebFrameMain } from 'electron'
|
||||
import {
|
||||
clickAt,
|
||||
ensureInstrumented,
|
||||
evaluateInIsolatedFrame,
|
||||
insertText,
|
||||
setColorScheme,
|
||||
} from '@/main/browser-agent/cdp'
|
||||
|
||||
function createOopifFrameFixture() {
|
||||
const top = {
|
||||
name: '',
|
||||
url: 'https://app.example/',
|
||||
origin: 'https://app.example',
|
||||
parent: null,
|
||||
frames: [] as WebFrameMain[],
|
||||
top: null,
|
||||
} as unknown as WebFrameMain
|
||||
const child = {
|
||||
name: 'account-menu',
|
||||
url: 'https://accounts.example/menu',
|
||||
origin: 'https://accounts.example',
|
||||
parent: top,
|
||||
frames: [] as WebFrameMain[],
|
||||
top,
|
||||
} as unknown as WebFrameMain
|
||||
;(top.frames as WebFrameMain[]).push(child)
|
||||
|
||||
return {
|
||||
child,
|
||||
frameTree: {
|
||||
frame: { id: 'top', url: 'https://app.example/' },
|
||||
childFrames: [
|
||||
{
|
||||
frame: {
|
||||
id: 'child',
|
||||
name: 'account-menu',
|
||||
url: 'https://accounts.example/menu',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('browser-agent CDP instrumentation', () => {
|
||||
it('leaves file chooser dialogs native so users can upload files', async () => {
|
||||
const contents = new WebContentsView().webContents
|
||||
|
||||
await ensureInstrumented(contents, { onDialog: vi.fn() })
|
||||
|
||||
expect(contents.debugger.sendCommand).toHaveBeenCalledWith('Page.enable', undefined)
|
||||
expect(contents.debugger.sendCommand).not.toHaveBeenCalledWith(
|
||||
'Page.setInterceptFileChooserDialog',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('retries protocol setup after a transient instrumentation failure', async () => {
|
||||
const contents = new WebContentsView().webContents
|
||||
vi.mocked(contents.debugger.isAttached).mockReturnValue(true)
|
||||
let autoAttachAttempts = 0
|
||||
vi.mocked(contents.debugger.sendCommand).mockImplementation((method) => {
|
||||
if (method === 'Target.setAutoAttach' && autoAttachAttempts++ === 0) {
|
||||
return Promise.reject(new Error('setup acknowledgement lost'))
|
||||
}
|
||||
return Promise.resolve({})
|
||||
})
|
||||
|
||||
await expect(ensureInstrumented(contents, { onDialog: vi.fn() })).rejects.toThrow(
|
||||
'setup acknowledgement lost'
|
||||
)
|
||||
await expect(ensureInstrumented(contents, { onDialog: vi.fn() })).resolves.toBeUndefined()
|
||||
|
||||
expect(autoAttachAttempts).toBe(2)
|
||||
})
|
||||
|
||||
it('dismisses an OOPIF dialog on the flattened child session', async () => {
|
||||
const contents = new WebContentsView().webContents
|
||||
const onDialog = vi.fn()
|
||||
await ensureInstrumented(contents, { onDialog })
|
||||
const listener = vi
|
||||
.mocked(contents.debugger.on)
|
||||
.mock.calls.find(([event]) => event === 'message')?.[1] as
|
||||
| ((event: unknown, method: string, params: unknown, sessionId?: string) => void)
|
||||
| undefined
|
||||
expect(listener).toBeTypeOf('function')
|
||||
vi.mocked(contents.debugger.sendCommand).mockClear()
|
||||
|
||||
listener?.(
|
||||
{},
|
||||
'Page.javascriptDialogOpening',
|
||||
{ type: 'alert', message: 'Hello' },
|
||||
'child-session'
|
||||
)
|
||||
await vi.waitFor(() => expect(onDialog).toHaveBeenCalled())
|
||||
|
||||
expect(contents.debugger.sendCommand).toHaveBeenCalledWith(
|
||||
'Page.handleJavaScriptDialog',
|
||||
{ accept: false },
|
||||
'child-session'
|
||||
)
|
||||
expect(onDialog).toHaveBeenCalledWith({ type: 'alert', message: 'Hello', handled: true })
|
||||
})
|
||||
|
||||
it('accepts an OOPIF beforeunload dialog on the flattened child session', async () => {
|
||||
const contents = new WebContentsView().webContents
|
||||
const onDialog = vi.fn()
|
||||
await ensureInstrumented(contents, { onDialog })
|
||||
const listener = vi
|
||||
.mocked(contents.debugger.on)
|
||||
.mock.calls.find(([event]) => event === 'message')?.[1] as
|
||||
| ((event: unknown, method: string, params: unknown, sessionId?: string) => void)
|
||||
| undefined
|
||||
expect(listener).toBeTypeOf('function')
|
||||
vi.mocked(contents.debugger.sendCommand).mockClear()
|
||||
|
||||
listener?.(
|
||||
{},
|
||||
'Page.javascriptDialogOpening',
|
||||
{ type: 'beforeunload', message: 'Leave this page?' },
|
||||
'child-session'
|
||||
)
|
||||
await vi.waitFor(() => expect(onDialog).toHaveBeenCalled())
|
||||
|
||||
expect(contents.debugger.sendCommand).toHaveBeenCalledWith(
|
||||
'Page.handleJavaScriptDialog',
|
||||
{ accept: true },
|
||||
'child-session'
|
||||
)
|
||||
expect(onDialog).toHaveBeenCalledWith({
|
||||
type: 'beforeunload',
|
||||
message: 'Leave this page?',
|
||||
handled: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('reports an OOPIF dialog as unhandled when child and root commands fail', async () => {
|
||||
const contents = new WebContentsView().webContents
|
||||
const onDialog = vi.fn()
|
||||
await ensureInstrumented(contents, { onDialog })
|
||||
const listener = vi
|
||||
.mocked(contents.debugger.on)
|
||||
.mock.calls.find(([event]) => event === 'message')?.[1] as
|
||||
| ((event: unknown, method: string, params: unknown, sessionId?: string) => void)
|
||||
| undefined
|
||||
expect(listener).toBeTypeOf('function')
|
||||
vi.mocked(contents.debugger.sendCommand).mockClear()
|
||||
vi.mocked(contents.debugger.sendCommand).mockRejectedValue(new Error('dialog target closed'))
|
||||
|
||||
listener?.(
|
||||
{},
|
||||
'Page.javascriptDialogOpening',
|
||||
{ type: 'confirm', message: 'Continue?' },
|
||||
'child-session'
|
||||
)
|
||||
await vi.waitFor(() => expect(onDialog).toHaveBeenCalled())
|
||||
|
||||
expect(vi.mocked(contents.debugger.sendCommand).mock.calls).toEqual([
|
||||
['Page.handleJavaScriptDialog', { accept: false }, 'child-session'],
|
||||
['Page.handleJavaScriptDialog', { accept: false }],
|
||||
])
|
||||
expect(onDialog).toHaveBeenCalledWith({
|
||||
type: 'confirm',
|
||||
message: 'Continue?',
|
||||
handled: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('clicks through Chromium trusted mouse input', async () => {
|
||||
const contents = new WebContentsView().webContents
|
||||
|
||||
await clickAt(contents, 120, 240)
|
||||
|
||||
expect(vi.mocked(contents.debugger.sendCommand).mock.calls).toEqual([
|
||||
['Input.dispatchMouseEvent', { type: 'mouseMoved', x: 120, y: 240, button: 'none' }],
|
||||
[
|
||||
'Input.dispatchMouseEvent',
|
||||
{
|
||||
type: 'mousePressed',
|
||||
x: 120,
|
||||
y: 240,
|
||||
button: 'left',
|
||||
buttons: 1,
|
||||
clickCount: 1,
|
||||
},
|
||||
],
|
||||
[
|
||||
'Input.dispatchMouseEvent',
|
||||
{
|
||||
type: 'mouseReleased',
|
||||
x: 120,
|
||||
y: 240,
|
||||
button: 'left',
|
||||
buttons: 0,
|
||||
clickCount: 1,
|
||||
},
|
||||
],
|
||||
])
|
||||
})
|
||||
|
||||
it('releases the mouse after a partial click failure', async () => {
|
||||
const contents = new WebContentsView().webContents
|
||||
vi.mocked(contents.debugger.sendCommand)
|
||||
.mockResolvedValueOnce({})
|
||||
.mockResolvedValueOnce({})
|
||||
.mockRejectedValueOnce(new Error('frame navigated'))
|
||||
.mockResolvedValueOnce({})
|
||||
|
||||
await expect(clickAt(contents, 12, 24)).rejects.toThrow('frame navigated')
|
||||
|
||||
expect(vi.mocked(contents.debugger.sendCommand).mock.calls.at(-1)).toEqual([
|
||||
'Input.dispatchMouseEvent',
|
||||
{
|
||||
type: 'mouseReleased',
|
||||
x: 12,
|
||||
y: 24,
|
||||
button: 'left',
|
||||
buttons: 0,
|
||||
clickCount: 1,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('best-effort releases the mouse when the press response is lost', async () => {
|
||||
const contents = new WebContentsView().webContents
|
||||
vi.mocked(contents.debugger.sendCommand)
|
||||
.mockResolvedValueOnce({})
|
||||
.mockRejectedValueOnce(new Error('mouse press response lost'))
|
||||
.mockRejectedValueOnce(new Error('cleanup unavailable'))
|
||||
|
||||
await expect(clickAt(contents, 36, 48)).rejects.toThrow('mouse press response lost')
|
||||
|
||||
expect(vi.mocked(contents.debugger.sendCommand).mock.calls).toEqual([
|
||||
['Input.dispatchMouseEvent', { type: 'mouseMoved', x: 36, y: 48, button: 'none' }],
|
||||
[
|
||||
'Input.dispatchMouseEvent',
|
||||
{
|
||||
type: 'mousePressed',
|
||||
x: 36,
|
||||
y: 48,
|
||||
button: 'left',
|
||||
buttons: 1,
|
||||
clickCount: 1,
|
||||
},
|
||||
],
|
||||
[
|
||||
'Input.dispatchMouseEvent',
|
||||
{
|
||||
type: 'mouseReleased',
|
||||
x: 36,
|
||||
y: 48,
|
||||
button: 'left',
|
||||
buttons: 0,
|
||||
clickCount: 1,
|
||||
},
|
||||
],
|
||||
])
|
||||
})
|
||||
|
||||
it('times out a hung press and sends cleanup before the tool watchdog can release', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const contents = new WebContentsView().webContents
|
||||
vi.mocked(contents.debugger.sendCommand)
|
||||
.mockResolvedValueOnce({})
|
||||
.mockImplementationOnce(() => new Promise(() => {}))
|
||||
.mockResolvedValueOnce({})
|
||||
|
||||
const click = clickAt(contents, 20, 30)
|
||||
const rejection = expect(click).rejects.toThrow('did not acknowledge input within 5 seconds')
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
|
||||
await rejection
|
||||
expect(vi.mocked(contents.debugger.sendCommand).mock.calls.at(-1)).toEqual([
|
||||
'Input.dispatchMouseEvent',
|
||||
expect.objectContaining({ type: 'mouseReleased', x: 20, y: 30 }),
|
||||
])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds a hung text insertion acknowledgement', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const contents = new WebContentsView().webContents
|
||||
vi.mocked(contents.debugger.sendCommand).mockImplementationOnce(() => new Promise(() => {}))
|
||||
|
||||
const insertion = insertText(contents, 'hello')
|
||||
const rejection = expect(insertion).rejects.toThrow(
|
||||
'did not acknowledge input within 5 seconds'
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
|
||||
await rejection
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('routes OOPIF isolated-world creation and evaluation through its flattened session', async () => {
|
||||
const contents = new WebContentsView().webContents
|
||||
const { child, frameTree } = createOopifFrameFixture()
|
||||
await ensureInstrumented(contents, { onDialog: vi.fn() })
|
||||
const listener = vi
|
||||
.mocked(contents.debugger.on)
|
||||
.mock.calls.find(([event]) => event === 'message')?.[1] as
|
||||
| ((event: unknown, method: string, params: unknown, sessionId?: string) => void)
|
||||
| undefined
|
||||
expect(listener).toBeTypeOf('function')
|
||||
|
||||
listener?.(
|
||||
{},
|
||||
'Target.attachedToTarget',
|
||||
{
|
||||
sessionId: 'child-session',
|
||||
targetInfo: { targetId: 'child', type: 'iframe' },
|
||||
},
|
||||
undefined
|
||||
)
|
||||
expect(contents.debugger.sendCommand).toHaveBeenCalledWith(
|
||||
'Target.setAutoAttach',
|
||||
{ autoAttach: true, waitForDebuggerOnStart: false, flatten: true },
|
||||
'child-session'
|
||||
)
|
||||
vi.mocked(contents.debugger.sendCommand).mockClear()
|
||||
vi.mocked(contents.debugger.sendCommand).mockImplementation((method) => {
|
||||
if (method === 'Page.getFrameTree') {
|
||||
return Promise.resolve({ frameTree })
|
||||
}
|
||||
if (method === 'Page.createIsolatedWorld') {
|
||||
return Promise.resolve({ executionContextId: 42 })
|
||||
}
|
||||
if (method === 'Runtime.evaluate') {
|
||||
return Promise.resolve({ result: { type: 'number', value: 4 } })
|
||||
}
|
||||
return Promise.resolve({})
|
||||
})
|
||||
|
||||
await expect(evaluateInIsolatedFrame(contents, child, '2 + 2')).resolves.toBe(4)
|
||||
|
||||
expect(
|
||||
vi
|
||||
.mocked(contents.debugger.sendCommand)
|
||||
.mock.calls.filter(([method]) =>
|
||||
['Page.createIsolatedWorld', 'Runtime.evaluate'].includes(method)
|
||||
)
|
||||
).toEqual([
|
||||
[
|
||||
'Page.createIsolatedWorld',
|
||||
{
|
||||
frameId: 'child',
|
||||
worldName: 'sim-browser-agent',
|
||||
grantUniveralAccess: false,
|
||||
},
|
||||
'child-session',
|
||||
],
|
||||
[
|
||||
'Runtime.evaluate',
|
||||
{
|
||||
expression: '2 + 2',
|
||||
contextId: 42,
|
||||
returnByValue: true,
|
||||
awaitPromise: true,
|
||||
userGesture: false,
|
||||
},
|
||||
'child-session',
|
||||
],
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to the root target when OOPIF isolated-world creation fails', async () => {
|
||||
const contents = new WebContentsView().webContents
|
||||
const { child, frameTree } = createOopifFrameFixture()
|
||||
await ensureInstrumented(contents, { onDialog: vi.fn() })
|
||||
const listener = vi
|
||||
.mocked(contents.debugger.on)
|
||||
.mock.calls.find(([event]) => event === 'message')?.[1] as
|
||||
| ((event: unknown, method: string, params: unknown, sessionId?: string) => void)
|
||||
| undefined
|
||||
expect(listener).toBeTypeOf('function')
|
||||
|
||||
listener?.(
|
||||
{},
|
||||
'Target.attachedToTarget',
|
||||
{
|
||||
sessionId: 'child-session',
|
||||
targetInfo: { targetId: 'child', type: 'iframe' },
|
||||
},
|
||||
undefined
|
||||
)
|
||||
vi.mocked(contents.debugger.sendCommand).mockClear()
|
||||
vi.mocked(contents.debugger.sendCommand).mockImplementation((method, _params, sessionId) => {
|
||||
if (method === 'Page.getFrameTree') {
|
||||
return Promise.resolve({ frameTree })
|
||||
}
|
||||
if (method === 'Page.createIsolatedWorld') {
|
||||
if (sessionId === 'child-session') {
|
||||
return Promise.reject(new Error('No frame with given id found'))
|
||||
}
|
||||
return Promise.resolve({ executionContextId: 84 })
|
||||
}
|
||||
if (method === 'Runtime.evaluate') {
|
||||
return Promise.resolve({ result: { type: 'string', value: 'root fallback' } })
|
||||
}
|
||||
return Promise.resolve({})
|
||||
})
|
||||
|
||||
await expect(evaluateInIsolatedFrame(contents, child, 'location.href')).resolves.toBe(
|
||||
'root fallback'
|
||||
)
|
||||
|
||||
expect(
|
||||
vi
|
||||
.mocked(contents.debugger.sendCommand)
|
||||
.mock.calls.filter(([method]) =>
|
||||
['Page.createIsolatedWorld', 'Runtime.evaluate'].includes(method)
|
||||
)
|
||||
).toEqual([
|
||||
[
|
||||
'Page.createIsolatedWorld',
|
||||
{
|
||||
frameId: 'child',
|
||||
worldName: 'sim-browser-agent',
|
||||
grantUniveralAccess: false,
|
||||
},
|
||||
'child-session',
|
||||
],
|
||||
[
|
||||
'Page.createIsolatedWorld',
|
||||
{
|
||||
frameId: 'child',
|
||||
worldName: 'sim-browser-agent',
|
||||
grantUniveralAccess: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
'Runtime.evaluate',
|
||||
{
|
||||
expression: 'location.href',
|
||||
contextId: 84,
|
||||
returnByValue: true,
|
||||
awaitPromise: true,
|
||||
userGesture: false,
|
||||
},
|
||||
],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('browser-agent CDP theme', () => {
|
||||
it('emulates explicit light and dark preferences', async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* CDP instrumentation for agent tabs via `webContents.debugger`: auto-handles
|
||||
* the page states that would otherwise wedge automation (JS dialogs, file
|
||||
* choosers), captures screenshots that work even while the view is hidden,
|
||||
* the page states that would otherwise wedge automation (JS dialogs),
|
||||
* captures screenshots that work even while the view is hidden,
|
||||
* and dispatches TRUSTED input (key events, text insertion). Trusted input
|
||||
* goes through Blink's real input pipeline — unlike synthetic DOM
|
||||
* `KeyboardEvent`s, it triggers default actions (select-all, deletion, caret
|
||||
@@ -10,22 +10,25 @@
|
||||
*/
|
||||
import type { BrowserTheme } from '@sim/browser-protocol'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { WebContents } from 'electron'
|
||||
import type { WebContents, WebFrameMain } from 'electron'
|
||||
|
||||
const logger = createLogger('BrowserAgentCdp')
|
||||
|
||||
const PROTOCOL_VERSION = '1.3'
|
||||
// Must settle comfortably before the driver's 20s tool watchdog. The CDP
|
||||
// promise itself is not cancellable, but timing out here lets the caller send
|
||||
// a release/key-up cleanup before the serialized tool queue is released.
|
||||
const INPUT_COMMAND_TIMEOUT_MS = 5_000
|
||||
|
||||
export interface PageDialog {
|
||||
type: string
|
||||
message: string
|
||||
handled: boolean
|
||||
}
|
||||
|
||||
export interface CdpCallbacks {
|
||||
/** A JS dialog was auto-handled; the driver surfaces it to the model. */
|
||||
onDialog: (dialog: PageDialog) => void
|
||||
/** A file chooser was suppressed; the driver surfaces it to the model. */
|
||||
onFileChooser: () => void
|
||||
}
|
||||
|
||||
/** Per-tab callbacks, so a background tab's events reach ITS driver, not the
|
||||
@@ -33,34 +36,74 @@ export interface CdpCallbacks {
|
||||
const callbacksByContents = new WeakMap<WebContents, CdpCallbacks>()
|
||||
/** Contents already instrumented (attach survives for the tab's lifetime). */
|
||||
const instrumented = new WeakSet<WebContents>()
|
||||
/** Flattened CDP child-target sessions keyed by their protocol frame/target id. */
|
||||
const childSessionsByContents = new WeakMap<WebContents, Map<string, string>>()
|
||||
const FRAME_WORLD_NAME = 'sim-browser-agent'
|
||||
|
||||
const AUTO_ATTACH_PARAMS = {
|
||||
autoAttach: true,
|
||||
waitForDebuggerOnStart: false,
|
||||
flatten: true,
|
||||
}
|
||||
|
||||
async function send<T = unknown>(
|
||||
contents: WebContents,
|
||||
method: string,
|
||||
params?: Record<string, unknown>
|
||||
params?: Record<string, unknown>,
|
||||
sessionId?: string
|
||||
): Promise<T> {
|
||||
return (await contents.debugger.sendCommand(method, params)) as T
|
||||
return (await (sessionId
|
||||
? contents.debugger.sendCommand(method, params, sessionId)
|
||||
: contents.debugger.sendCommand(method, params))) as T
|
||||
}
|
||||
|
||||
async function sendInput(
|
||||
contents: WebContents,
|
||||
method: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
let timer: NodeJS.Timeout | undefined
|
||||
const timeout = new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error(`${method} did not acknowledge input within 5 seconds`)),
|
||||
INPUT_COMMAND_TIMEOUT_MS
|
||||
)
|
||||
})
|
||||
try {
|
||||
await Promise.race([send(contents, method, params), timeout])
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
/** Idempotently instruments a tab's WebContents. */
|
||||
export async function ensureInstrumented(contents: WebContents, cb: CdpCallbacks): Promise<void> {
|
||||
callbacksByContents.set(contents, cb)
|
||||
if (instrumented.has(contents) && contents.debugger.isAttached()) return
|
||||
|
||||
if (!contents.debugger.isAttached()) {
|
||||
contents.debugger.attach(PROTOCOL_VERSION)
|
||||
}
|
||||
if (!instrumented.has(contents)) {
|
||||
instrumented.add(contents)
|
||||
contents.debugger.on('message', (_event, method, params) => {
|
||||
handleDebuggerEvent(contents, method, params as Record<string, unknown>)
|
||||
childSessionsByContents.set(contents, new Map())
|
||||
contents.debugger.on('message', (_event, method, params, sessionId) => {
|
||||
handleDebuggerEvent(
|
||||
contents,
|
||||
method,
|
||||
params as Record<string, unknown>,
|
||||
typeof sessionId === 'string' ? sessionId : undefined
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
await send(contents, 'Page.enable')
|
||||
// Suppress native file choosers: nothing can drive them from the panel,
|
||||
// and an open chooser blocks the page. Recorded and surfaced instead.
|
||||
await send(contents, 'Page.setInterceptFileChooserDialog', { enabled: true }).catch(() => {})
|
||||
// These commands are idempotent and intentionally retried. If the first
|
||||
// setup attempt loses its acknowledgement while the debugger stays
|
||||
// attached, treating the installed event listener as proof of successful
|
||||
// configuration leaves every later child-frame action permanently blind.
|
||||
await Promise.all([
|
||||
send(contents, 'Page.enable'),
|
||||
send(contents, 'Target.setAutoAttach', AUTO_ATTACH_PARAMS),
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,8 +119,36 @@ export async function setColorScheme(contents: WebContents, theme: BrowserTheme)
|
||||
function handleDebuggerEvent(
|
||||
contents: WebContents,
|
||||
method: string,
|
||||
params: Record<string, unknown>
|
||||
params: Record<string, unknown>,
|
||||
parentSessionId?: string
|
||||
): void {
|
||||
if (method === 'Target.attachedToTarget') {
|
||||
const sessionId = typeof params.sessionId === 'string' ? params.sessionId : ''
|
||||
const targetInfo = params.targetInfo
|
||||
const targetId =
|
||||
targetInfo && typeof targetInfo === 'object' && 'targetId' in targetInfo
|
||||
? String(targetInfo.targetId || '')
|
||||
: ''
|
||||
if (sessionId && targetId) {
|
||||
childSessionsByContents.get(contents)?.set(targetId, sessionId)
|
||||
// Site isolation can nest out-of-process frames. Auto-attach from the
|
||||
// child session as well so every descendant remains eligible.
|
||||
void send(contents, 'Target.setAutoAttach', AUTO_ATTACH_PARAMS, sessionId).catch(() => {})
|
||||
}
|
||||
return
|
||||
}
|
||||
if (method === 'Target.detachedFromTarget') {
|
||||
const targetId = typeof params.targetId === 'string' ? params.targetId : ''
|
||||
const detachedSession = typeof params.sessionId === 'string' ? params.sessionId : ''
|
||||
const sessions = childSessionsByContents.get(contents)
|
||||
if (targetId) sessions?.delete(targetId)
|
||||
if (detachedSession && sessions) {
|
||||
for (const [id, sessionId] of sessions) {
|
||||
if (sessionId === detachedSession) sessions.delete(id)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
const callbacks = callbacksByContents.get(contents)
|
||||
if (method === 'Page.javascriptDialogOpening') {
|
||||
const type = String(params.type ?? 'dialog')
|
||||
@@ -85,17 +156,181 @@ function handleDebuggerEvent(
|
||||
// beforeunload is accepted (navigation proceeds); everything else is
|
||||
// dismissed — the model reacts to the recorded message instead of a
|
||||
// dialog that would block the page.
|
||||
void send(contents, 'Page.handleJavaScriptDialog', {
|
||||
accept: type === 'beforeunload',
|
||||
}).catch(() => {})
|
||||
logger.info('Auto-handled page dialog', { type })
|
||||
callbacks?.onDialog({ type, message })
|
||||
void (async () => {
|
||||
let handled = false
|
||||
try {
|
||||
await send(
|
||||
contents,
|
||||
'Page.handleJavaScriptDialog',
|
||||
{ accept: type === 'beforeunload' },
|
||||
parentSessionId
|
||||
)
|
||||
handled = true
|
||||
} catch {
|
||||
// Some Chromium builds surface an OOPIF's tab-modal dialog on its
|
||||
// flattened session but accept the dismissal only on the root target.
|
||||
if (parentSessionId) {
|
||||
try {
|
||||
await send(contents, 'Page.handleJavaScriptDialog', {
|
||||
accept: type === 'beforeunload',
|
||||
})
|
||||
handled = true
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (handled) logger.info('Auto-handled page dialog', { type })
|
||||
else logger.warn('Could not auto-handle page dialog', { type })
|
||||
callbacks?.onDialog({ type, message, handled })
|
||||
})()
|
||||
return
|
||||
}
|
||||
if (method === 'Page.fileChooserOpened') {
|
||||
logger.info('Suppressed file chooser in agent browser')
|
||||
callbacks?.onFileChooser()
|
||||
}
|
||||
|
||||
interface ProtocolFrame {
|
||||
id: string
|
||||
parentId?: string
|
||||
name?: string
|
||||
url?: string
|
||||
securityOrigin?: string
|
||||
}
|
||||
|
||||
interface ProtocolFrameTree {
|
||||
frame: ProtocolFrame
|
||||
childFrames?: ProtocolFrameTree[]
|
||||
}
|
||||
|
||||
function frameMatches(candidate: ProtocolFrame, frame: WebFrameMain): boolean {
|
||||
if (candidate.url && frame.url) return candidate.url === frame.url
|
||||
if (candidate.name && frame.name) return candidate.name === frame.name
|
||||
return Boolean(
|
||||
candidate.securityOrigin &&
|
||||
frame.origin &&
|
||||
frame.origin !== 'null' &&
|
||||
candidate.securityOrigin === frame.origin
|
||||
)
|
||||
}
|
||||
|
||||
export function sameWebFrame(left: WebFrameMain, right: WebFrameMain): boolean {
|
||||
if (left === right) return true
|
||||
if (Number.isSafeInteger(left.frameTreeNodeId) && Number.isSafeInteger(right.frameTreeNodeId)) {
|
||||
return left.frameTreeNodeId === right.frameTreeNodeId
|
||||
}
|
||||
if (
|
||||
Number.isSafeInteger(left.processId) &&
|
||||
Number.isSafeInteger(right.processId) &&
|
||||
Number.isSafeInteger(left.routingId) &&
|
||||
Number.isSafeInteger(right.routingId)
|
||||
) {
|
||||
return left.processId === right.processId && left.routingId === right.routingId
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function locateProtocolFrame(root: ProtocolFrameTree, target: WebFrameMain): ProtocolFrame | null {
|
||||
const path: WebFrameMain[] = []
|
||||
for (let current: WebFrameMain | null = target; current?.parent; current = current.parent) {
|
||||
path.push(current)
|
||||
}
|
||||
path.reverse()
|
||||
|
||||
let tree = root
|
||||
let electronParent = target.top ?? target
|
||||
// `target.top` is the main frame. For mocks/edge cases where it is absent,
|
||||
// reconstruct it by walking parents.
|
||||
while (electronParent.parent) electronParent = electronParent.parent
|
||||
for (const frame of path) {
|
||||
const children = tree.childFrames ?? []
|
||||
const siblingIndex = electronParent.frames.findIndex((candidate) =>
|
||||
sameWebFrame(candidate, frame)
|
||||
)
|
||||
const indexed = siblingIndex >= 0 ? children[siblingIndex] : undefined
|
||||
if (indexed && frameMatches(indexed.frame, frame)) {
|
||||
tree = indexed
|
||||
} else {
|
||||
const matches = children.filter((candidate) => frameMatches(candidate.frame, frame))
|
||||
if (matches.length !== 1) return null
|
||||
tree = matches[0]
|
||||
}
|
||||
electronParent = frame
|
||||
}
|
||||
return tree.frame
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes code in a persistent isolated world belonging to one child frame.
|
||||
* WebFrameMain.executeJavaScript runs in the untrusted page's main world,
|
||||
* where the page can replace the ref registry and built-ins between tools.
|
||||
*/
|
||||
export async function evaluateInIsolatedFrame(
|
||||
contents: WebContents,
|
||||
frame: WebFrameMain,
|
||||
expression: string,
|
||||
userGesture = false
|
||||
): Promise<unknown> {
|
||||
const { frameTree } = await send<{ frameTree?: ProtocolFrameTree }>(contents, 'Page.getFrameTree')
|
||||
if (!frameTree) throw new Error('Chromium did not return a frame tree')
|
||||
const protocolFrame = locateProtocolFrame(frameTree, frame)
|
||||
if (!protocolFrame) throw new Error('Could not map the Electron frame to Chromium')
|
||||
|
||||
const childSession = childSessionsByContents.get(contents)?.get(protocolFrame.id)
|
||||
const sessionCandidates = childSession ? [childSession, undefined] : [undefined]
|
||||
let contextId: number | undefined
|
||||
let selectedSession: string | undefined
|
||||
let lastError: unknown
|
||||
for (const sessionId of sessionCandidates) {
|
||||
try {
|
||||
const created = await send<{ executionContextId?: number }>(
|
||||
contents,
|
||||
'Page.createIsolatedWorld',
|
||||
{
|
||||
frameId: protocolFrame.id,
|
||||
worldName: FRAME_WORLD_NAME,
|
||||
grantUniveralAccess: false,
|
||||
},
|
||||
sessionId
|
||||
)
|
||||
if (typeof created.executionContextId !== 'number') {
|
||||
throw new Error('Chromium did not return an isolated execution context')
|
||||
}
|
||||
contextId = created.executionContextId
|
||||
selectedSession = sessionId
|
||||
break
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
}
|
||||
}
|
||||
if (contextId === undefined) {
|
||||
throw lastError instanceof Error ? lastError : new Error('Could not create an isolated world')
|
||||
}
|
||||
|
||||
const evaluation = await send<{
|
||||
result?: { type?: string; value?: unknown; unserializableValue?: string }
|
||||
exceptionDetails?: { text?: string; exception?: { description?: string } }
|
||||
}>(
|
||||
contents,
|
||||
'Runtime.evaluate',
|
||||
{
|
||||
expression,
|
||||
contextId,
|
||||
returnByValue: true,
|
||||
awaitPromise: true,
|
||||
userGesture,
|
||||
},
|
||||
selectedSession
|
||||
)
|
||||
if (evaluation.exceptionDetails) {
|
||||
throw new Error(
|
||||
evaluation.exceptionDetails.exception?.description ||
|
||||
evaluation.exceptionDetails.text ||
|
||||
'Frame evaluation failed'
|
||||
)
|
||||
}
|
||||
if (!evaluation.result) throw new Error('Chromium returned no frame evaluation result')
|
||||
if ('value' in evaluation.result) return evaluation.result.value
|
||||
if (evaluation.result.type === 'undefined') return undefined
|
||||
throw new Error(
|
||||
`Frame evaluation returned unsupported value ${evaluation.result.unserializableValue || evaluation.result.type || ''}`.trim()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -156,7 +391,6 @@ export interface CdpKeyEvent {
|
||||
key: string
|
||||
code: string
|
||||
windowsVirtualKeyCode: number
|
||||
nativeVirtualKeyCode: number
|
||||
text?: string
|
||||
/** Blink editing commands to run with the event (macOS shortcut parity). */
|
||||
commands?: string[]
|
||||
@@ -164,7 +398,68 @@ export interface CdpKeyEvent {
|
||||
|
||||
/** Dispatches one trusted key event through Blink's input pipeline. */
|
||||
export async function dispatchKeyEvent(contents: WebContents, event: CdpKeyEvent): Promise<void> {
|
||||
await send(contents, 'Input.dispatchKeyEvent', event as unknown as Record<string, unknown>)
|
||||
await sendInput(contents, 'Input.dispatchKeyEvent', event as unknown as Record<string, unknown>)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks viewport coordinates through Chromium's trusted pointer pipeline.
|
||||
* React and other delegated event systems can distinguish these events from
|
||||
* page-created MouseEvents via `isTrusted`.
|
||||
*/
|
||||
export async function moveMouse(contents: WebContents, x: number, y: number): Promise<void> {
|
||||
await sendInput(contents, 'Input.dispatchMouseEvent', {
|
||||
type: 'mouseMoved',
|
||||
x,
|
||||
y,
|
||||
button: 'none',
|
||||
})
|
||||
}
|
||||
|
||||
export async function clickAt(
|
||||
contents: WebContents,
|
||||
x: number,
|
||||
y: number,
|
||||
moveBeforePress = true
|
||||
): Promise<void> {
|
||||
if (moveBeforePress) await moveMouse(contents, x, y)
|
||||
let pressed = false
|
||||
try {
|
||||
// Set before awaiting: CDP can deliver the press and then lose/reject the
|
||||
// response (navigation/process swap). In that ambiguous case a release is
|
||||
// safer than leaving Blink's pointer state stuck down.
|
||||
pressed = true
|
||||
await sendInput(contents, 'Input.dispatchMouseEvent', {
|
||||
type: 'mousePressed',
|
||||
x,
|
||||
y,
|
||||
button: 'left',
|
||||
buttons: 1,
|
||||
clickCount: 1,
|
||||
})
|
||||
await sendInput(contents, 'Input.dispatchMouseEvent', {
|
||||
type: 'mouseReleased',
|
||||
x,
|
||||
y,
|
||||
button: 'left',
|
||||
buttons: 0,
|
||||
clickCount: 1,
|
||||
})
|
||||
pressed = false
|
||||
} finally {
|
||||
if (pressed && !contents.isDestroyed()) {
|
||||
// Best-effort cleanup only. The driver deliberately does not retry a
|
||||
// synthetic click after a partial native dispatch: pointerdown handlers
|
||||
// may already have acted, and a retry can double-submit.
|
||||
await sendInput(contents, 'Input.dispatchMouseEvent', {
|
||||
type: 'mouseReleased',
|
||||
x,
|
||||
y,
|
||||
button: 'left',
|
||||
buttons: 0,
|
||||
clickCount: 1,
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,5 +467,5 @@ export async function dispatchKeyEvent(contents: WebContents, event: CdpKeyEvent
|
||||
* native IME path — works in plain fields and code editors alike.
|
||||
*/
|
||||
export async function insertText(contents: WebContents, text: string): Promise<void> {
|
||||
await send(contents, 'Input.insertText', { text })
|
||||
await sendInput(contents, 'Input.insertText', { text })
|
||||
}
|
||||
|
||||
@@ -33,11 +33,18 @@ function params(overrides: Partial<Params> = {}): Params {
|
||||
|
||||
function page(overrides: Partial<Page> = {}): Page {
|
||||
// A fresh tab sits at the panel's baseline, which the menu reports as 100%.
|
||||
return { canGoBack: true, canGoForward: true, zoomFactor: BASE_ZOOM_FACTOR, ...overrides }
|
||||
return {
|
||||
canGoBack: true,
|
||||
canGoForward: true,
|
||||
zoomFactor: BASE_ZOOM_FACTOR,
|
||||
defaultZoomFactor: BASE_ZOOM_FACTOR,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function handlers(): Handlers {
|
||||
return {
|
||||
addToChat: vi.fn(),
|
||||
copy: vi.fn(),
|
||||
paste: vi.fn(),
|
||||
back: vi.fn(),
|
||||
@@ -99,6 +106,22 @@ describe('buildAgentContextMenuTemplate', () => {
|
||||
expect(labels(readOnly)).not.toContain('Paste')
|
||||
})
|
||||
|
||||
it('puts Add to chat first and preserves the exact nonblank selection', () => {
|
||||
const handled = handlers()
|
||||
const template = buildAgentContextMenuTemplate(
|
||||
params({ selectionText: ' selected\ntext ', linkURL: 'https://example.com/docs' }),
|
||||
page(),
|
||||
handled
|
||||
)
|
||||
|
||||
expect(labels(template)[0]).toBe('Add to chat')
|
||||
item(template, 'Add to chat')?.click?.({} as never, undefined as never, {} as never)
|
||||
expect(handled.addToChat).toHaveBeenCalledWith(' selected\ntext ')
|
||||
expect(
|
||||
labels(buildAgentContextMenuTemplate(params({ selectionText: ' \n ' }), page(), handlers()))
|
||||
).not.toContain('Add to chat')
|
||||
})
|
||||
|
||||
it('offers link items for http(s) targets only', () => {
|
||||
const handled = handlers()
|
||||
const template = buildAgentContextMenuTemplate(
|
||||
@@ -153,16 +176,21 @@ describe('buildAgentContextMenuTemplate', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('resets to exactly the baseline, undoing accumulated drift', () => {
|
||||
it('resets to the configured default, undoing accumulated drift', () => {
|
||||
const handled = handlers()
|
||||
// Three rungs of float multiplication up, so the factor no longer sits on a
|
||||
// clean value — reset has to restore the baseline exactly, not step back.
|
||||
const drifted = [1, 1, 1].reduce((factor) => steppedZoomFactor(factor, 1), BASE_ZOOM_FACTOR)
|
||||
const template = buildAgentContextMenuTemplate(params(), page({ zoomFactor: drifted }), handled)
|
||||
const configuredDefault = BASE_ZOOM_FACTOR * 1.25
|
||||
const template = buildAgentContextMenuTemplate(
|
||||
params(),
|
||||
page({ zoomFactor: drifted, defaultZoomFactor: configuredDefault }),
|
||||
handled
|
||||
)
|
||||
|
||||
item(template, 'Actual Size (133%)')?.click?.({} as never, undefined as never, {} as never)
|
||||
|
||||
expect(handled.setZoomFactor).toHaveBeenCalledWith(BASE_ZOOM_FACTOR)
|
||||
expect(handled.setZoomFactor).toHaveBeenCalledWith(configuredDefault)
|
||||
})
|
||||
|
||||
it('never leaves a separator with nothing above it', () => {
|
||||
@@ -190,7 +218,11 @@ describe('attachAgentContextMenu', () => {
|
||||
it('pops a menu built from the page that was right-clicked', () => {
|
||||
const contents = new WebContentsView().webContents
|
||||
vi.mocked(contents.navigationHistory.canGoBack).mockReturnValue(true)
|
||||
attachAgentContextMenu(contents, { openTab: vi.fn() })
|
||||
attachAgentContextMenu(contents, {
|
||||
addToChat: vi.fn(),
|
||||
openTab: vi.fn(),
|
||||
defaultZoomFactor: () => BASE_ZOOM_FACTOR,
|
||||
})
|
||||
|
||||
const listeners = vi.mocked(contents.on).mock.calls as unknown as [
|
||||
string,
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
* terminal's hidden textarea, the roles here act on a real page: `copy` and
|
||||
* `paste` go to the frame that was clicked.
|
||||
*/
|
||||
|
||||
import { resolveDesktopZoom } from '@sim/desktop-bridge'
|
||||
import type { ContextMenuParams, MenuItemConstructorOptions, WebContents } from 'electron'
|
||||
import { clipboard, Menu } from 'electron'
|
||||
|
||||
@@ -22,9 +24,9 @@ import { clipboard, Menu } from 'electron'
|
||||
* Chromium refuses to scale past them, and a rung outside the range would come
|
||||
* back clamped and leave the menu offering a step that never lands.
|
||||
*/
|
||||
const ZOOM_STEP_RATIO = 1.1
|
||||
const MIN_ZOOM_FACTOR = 0.5
|
||||
const MAX_ZOOM_FACTOR = 3
|
||||
const ZOOM_FACTOR_BOUNDS = { min: MIN_ZOOM_FACTOR, max: MAX_ZOOM_FACTOR } as const
|
||||
|
||||
/**
|
||||
* What the panel calls 100%.
|
||||
@@ -32,15 +34,15 @@ const MAX_ZOOM_FACTOR = 3
|
||||
* The browser lives in a panel that is only ever a fraction of the window, so
|
||||
* it renders a rung below Chromium's native scale and treats THAT as its
|
||||
* baseline: the menu reads 100% there, and every other rung is reported
|
||||
* relative to it. Users get a zoom control that behaves the way one should —
|
||||
* starts at 100%, resets to 100% — over a page that is genuinely rendering at
|
||||
* ~91% of native.
|
||||
* relative to it. New installs start there; when a user chooses a different
|
||||
* default, Actual Size returns to that configured percentage. The initial 100%
|
||||
* is genuinely rendering at ~91% of native.
|
||||
*
|
||||
* Defined as one rung below native rather than as a round number so the ladder
|
||||
* still lands exactly on Chromium's 1.0 (the crispest rasterization, one step
|
||||
* up from the baseline) instead of straddling it.
|
||||
*/
|
||||
export const BASE_ZOOM_FACTOR = 1 / ZOOM_STEP_RATIO
|
||||
export const BASE_ZOOM_FACTOR = resolveDesktopZoom(1, 'out', 1, ZOOM_FACTOR_BOUNDS)
|
||||
|
||||
/**
|
||||
* A Chromium zoom factor as a percentage of {@link BASE_ZOOM_FACTOR} — what the
|
||||
@@ -60,9 +62,12 @@ export function zoomPercentOf(factor: number): number {
|
||||
* the item rather than offer a step that does nothing.
|
||||
*/
|
||||
export function steppedZoomFactor(current: number, direction: 1 | -1): number {
|
||||
const base = Number.isFinite(current) && current > 0 ? current : BASE_ZOOM_FACTOR
|
||||
const next = direction === 1 ? base * ZOOM_STEP_RATIO : base / ZOOM_STEP_RATIO
|
||||
return Math.min(MAX_ZOOM_FACTOR, Math.max(MIN_ZOOM_FACTOR, next))
|
||||
return resolveDesktopZoom(
|
||||
current,
|
||||
direction === 1 ? 'in' : 'out',
|
||||
BASE_ZOOM_FACTOR,
|
||||
ZOOM_FACTOR_BOUNDS
|
||||
)
|
||||
}
|
||||
|
||||
/** The parts of a right-click the menu acts on. */
|
||||
@@ -76,9 +81,11 @@ interface AgentPageContext {
|
||||
canGoBack: boolean
|
||||
canGoForward: boolean
|
||||
zoomFactor: number
|
||||
defaultZoomFactor: number
|
||||
}
|
||||
|
||||
interface AgentContextMenuHandlers {
|
||||
addToChat(text: string): void
|
||||
copy(): void
|
||||
paste(): void
|
||||
back(): void
|
||||
@@ -90,8 +97,12 @@ interface AgentContextMenuHandlers {
|
||||
}
|
||||
|
||||
export interface AgentContextMenuHost {
|
||||
/** Attaches selected page text to the chat that owns this browser tab. */
|
||||
addToChat(text: string): void
|
||||
/** Opens a link from the page in another tab of the same browser. */
|
||||
openTab(url: string): void
|
||||
/** Returns the device's current default page zoom factor. */
|
||||
defaultZoomFactor(): number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,6 +120,14 @@ export function buildAgentContextMenuTemplate(
|
||||
): MenuItemConstructorOptions[] {
|
||||
const template: MenuItemConstructorOptions[] = []
|
||||
const linkUrl = /^https?:\/\//i.test(params.linkURL) ? params.linkURL : ''
|
||||
const selectionText = params.selectionText
|
||||
|
||||
if (selectionText.trim()) {
|
||||
template.push(
|
||||
{ label: 'Add to chat', click: () => handlers.addToChat(selectionText) },
|
||||
{ type: 'separator' }
|
||||
)
|
||||
}
|
||||
|
||||
if (linkUrl) {
|
||||
template.push(
|
||||
@@ -118,7 +137,7 @@ export function buildAgentContextMenuTemplate(
|
||||
)
|
||||
}
|
||||
|
||||
if (params.selectionText.trim()) {
|
||||
if (selectionText.trim()) {
|
||||
template.push({ label: 'Copy', click: () => handlers.copy() })
|
||||
}
|
||||
if (params.isEditable && params.editFlags.canPaste) {
|
||||
@@ -151,8 +170,8 @@ export function buildAgentContextMenuTemplate(
|
||||
},
|
||||
{
|
||||
label: `Actual Size (${zoomPercent}%)`,
|
||||
enabled: zoomPercent !== 100,
|
||||
click: () => handlers.setZoomFactor(BASE_ZOOM_FACTOR),
|
||||
enabled: page.zoomFactor !== page.defaultZoomFactor,
|
||||
click: () => handlers.setZoomFactor(page.defaultZoomFactor),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -168,8 +187,10 @@ export function attachAgentContextMenu(contents: WebContents, host: AgentContext
|
||||
canGoBack: contents.navigationHistory.canGoBack(),
|
||||
canGoForward: contents.navigationHistory.canGoForward(),
|
||||
zoomFactor: contents.getZoomFactor(),
|
||||
defaultZoomFactor: host.defaultZoomFactor(),
|
||||
},
|
||||
{
|
||||
addToChat: (text) => host.addToChat(text),
|
||||
copy: () => contents.copy(),
|
||||
paste: () => contents.paste(),
|
||||
back: () => contents.navigationHistory.goBack(),
|
||||
|
||||
@@ -2,7 +2,13 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('electron', () => import('@/test/electron-mock'))
|
||||
|
||||
import { buildKeyDispatchPlan, parseKeyCombo } from '@/main/browser-agent/keyboard'
|
||||
import { WebContentsView } from 'electron'
|
||||
import {
|
||||
buildKeyDispatchPlan,
|
||||
dispatchKeyCombo,
|
||||
KeyDispatchError,
|
||||
parseKeyCombo,
|
||||
} from '@/main/browser-agent/keyboard'
|
||||
|
||||
describe('parseKeyCombo', () => {
|
||||
it('parses named keys, letters, and modifier combos', () => {
|
||||
@@ -17,12 +23,49 @@ describe('parseKeyCombo', () => {
|
||||
shift: true,
|
||||
})
|
||||
expect(parseKeyCombo('5')).toMatchObject({ key: '5', code: 'Digit5' })
|
||||
expect(parseKeyCombo('Cmd+,')).toMatchObject({
|
||||
key: ',',
|
||||
code: 'Comma',
|
||||
keyCode: 188,
|
||||
meta: true,
|
||||
})
|
||||
expect(parseKeyCombo('Mod+K', 'darwin')).toMatchObject({ meta: true, ctrl: false })
|
||||
expect(parseKeyCombo('Mod+K', 'linux')).toMatchObject({ meta: false, ctrl: true })
|
||||
expect(parseKeyCombo('ControlOrMeta+K', 'darwin')).toMatchObject({ meta: true })
|
||||
})
|
||||
|
||||
it('rejects unknown keys and modifiers', () => {
|
||||
expect(() => parseKeyCombo('Hyper+X')).toThrow(/Unrecognized modifier/)
|
||||
expect(() => parseKeyCombo('NotAKey')).toThrow(/Unrecognized key/)
|
||||
})
|
||||
|
||||
it('maps shifted digits and punctuation to their physical Chromium descriptors', () => {
|
||||
expect(parseKeyCombo('Shift+1')).toMatchObject({
|
||||
key: '!',
|
||||
code: 'Digit1',
|
||||
keyCode: 49,
|
||||
shift: true,
|
||||
})
|
||||
expect(parseKeyCombo('Shift+,')).toMatchObject({
|
||||
key: '<',
|
||||
code: 'Comma',
|
||||
keyCode: 188,
|
||||
shift: true,
|
||||
})
|
||||
expect(parseKeyCombo('?')).toMatchObject({
|
||||
key: '?',
|
||||
code: 'Slash',
|
||||
keyCode: 191,
|
||||
shift: true,
|
||||
})
|
||||
expect(parseKeyCombo('+')).toMatchObject({ key: '+', code: 'Equal', keyCode: 187, shift: true })
|
||||
expect(parseKeyCombo('Control++')).toMatchObject({
|
||||
key: '+',
|
||||
code: 'Equal',
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildKeyDispatchPlan', () => {
|
||||
@@ -69,6 +112,20 @@ describe('buildKeyDispatchPlan', () => {
|
||||
expect(down.commands).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses the Chromium punctuation descriptor for Cmd+,', () => {
|
||||
const [down, up] = buildKeyDispatchPlan(parseKeyCombo('Cmd+,'), 'darwin')
|
||||
|
||||
expect(down).toMatchObject({
|
||||
type: 'rawKeyDown',
|
||||
key: ',',
|
||||
code: 'Comma',
|
||||
windowsVirtualKeyCode: 188,
|
||||
modifiers: 4,
|
||||
})
|
||||
expect(down).not.toHaveProperty('nativeVirtualKeyCode')
|
||||
expect(up).not.toHaveProperty('nativeVirtualKeyCode')
|
||||
})
|
||||
|
||||
it('maps Cmd+Shift+Z to redo and Cmd+Z to undo on macOS', () => {
|
||||
const [redo] = buildKeyDispatchPlan(parseKeyCombo('Cmd+Shift+Z'), 'darwin')
|
||||
expect(redo.commands).toEqual(['redo'])
|
||||
@@ -83,4 +140,132 @@ describe('buildKeyDispatchPlan', () => {
|
||||
expect(down.type).toBe('rawKeyDown')
|
||||
expect(down.text).toBeUndefined()
|
||||
})
|
||||
|
||||
it('sends the shifted character as text and keeps Alt-printable combos non-textual', () => {
|
||||
const [shifted] = buildKeyDispatchPlan(parseKeyCombo('Shift+1'), 'linux')
|
||||
expect(shifted).toMatchObject({ key: '!', code: 'Digit1', text: '!', modifiers: 8 })
|
||||
|
||||
const [alt] = buildKeyDispatchPlan(parseKeyCombo('Alt+a'), 'linux')
|
||||
expect(alt).toMatchObject({ type: 'rawKeyDown', key: 'a', modifiers: 1 })
|
||||
expect(alt.text).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispatchKeyCombo', () => {
|
||||
it('keeps agent-issued modifier shortcuts out of the Electron application menu', async () => {
|
||||
const contents = new WebContentsView().webContents
|
||||
|
||||
await dispatchKeyCombo(contents, parseKeyCombo('Cmd+A'))
|
||||
|
||||
expect(contents.setIgnoreMenuShortcuts).toHaveBeenNthCalledWith(1, true)
|
||||
expect(contents.setIgnoreMenuShortcuts).toHaveBeenNthCalledWith(2, false)
|
||||
expect(contents.debugger.sendCommand).toHaveBeenCalledWith(
|
||||
'Input.dispatchKeyEvent',
|
||||
expect.objectContaining({ type: 'rawKeyDown', key: 'a' })
|
||||
)
|
||||
})
|
||||
|
||||
it('restores application-menu shortcuts when CDP dispatch fails', async () => {
|
||||
const contents = new WebContentsView().webContents
|
||||
vi.mocked(contents.debugger.sendCommand).mockRejectedValueOnce(new Error('CDP unavailable'))
|
||||
|
||||
await expect(dispatchKeyCombo(contents, parseKeyCombo('Cmd+A'))).rejects.toThrow(
|
||||
'CDP unavailable'
|
||||
)
|
||||
|
||||
expect(contents.setIgnoreMenuShortcuts).toHaveBeenNthCalledWith(1, true)
|
||||
expect(contents.setIgnoreMenuShortcuts).toHaveBeenNthCalledWith(2, false)
|
||||
})
|
||||
|
||||
it('best-effort releases a key and reports a partial dispatch when key-up fails', async () => {
|
||||
const contents = new WebContentsView().webContents
|
||||
vi.mocked(contents.debugger.sendCommand)
|
||||
.mockResolvedValueOnce({})
|
||||
.mockRejectedValueOnce(new Error('key-up response lost'))
|
||||
.mockRejectedValueOnce(new Error('cleanup unavailable'))
|
||||
|
||||
const dispatch = dispatchKeyCombo(contents, parseKeyCombo('Cmd+A'))
|
||||
|
||||
await expect(dispatch).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
name: KeyDispatchError.name,
|
||||
message: 'key-up response lost',
|
||||
keyDownDispatched: true,
|
||||
})
|
||||
)
|
||||
|
||||
const keyEvents = vi
|
||||
.mocked(contents.debugger.sendCommand)
|
||||
.mock.calls.filter(([method]) => method === 'Input.dispatchKeyEvent')
|
||||
expect(keyEvents).toHaveLength(3)
|
||||
expect(keyEvents[1]).toEqual(keyEvents[2])
|
||||
expect(keyEvents[1]).toEqual([
|
||||
'Input.dispatchKeyEvent',
|
||||
expect.objectContaining({ type: 'keyUp', key: 'a' }),
|
||||
])
|
||||
expect(contents.setIgnoreMenuShortcuts).toHaveBeenNthCalledWith(1, true)
|
||||
expect(contents.setIgnoreMenuShortcuts).toHaveBeenNthCalledWith(2, false)
|
||||
})
|
||||
|
||||
it('treats a rejected key-down acknowledgement as ambiguous and releases it', async () => {
|
||||
const contents = new WebContentsView().webContents
|
||||
vi.mocked(contents.debugger.sendCommand)
|
||||
.mockRejectedValueOnce(new Error('key-down response lost'))
|
||||
.mockResolvedValueOnce({})
|
||||
|
||||
await expect(dispatchKeyCombo(contents, parseKeyCombo('Enter'))).rejects.toMatchObject({
|
||||
name: KeyDispatchError.name,
|
||||
message: 'key-down response lost',
|
||||
keyDownDispatched: true,
|
||||
})
|
||||
|
||||
const keyEvents = vi.mocked(contents.debugger.sendCommand).mock.calls
|
||||
expect(keyEvents).toHaveLength(2)
|
||||
expect(keyEvents[1]).toEqual([
|
||||
'Input.dispatchKeyEvent',
|
||||
expect.objectContaining({ type: 'keyUp', key: 'Enter' }),
|
||||
])
|
||||
})
|
||||
|
||||
it('does not turn menu-restoration cleanup failure into a duplicate key retry signal', async () => {
|
||||
const contents = new WebContentsView().webContents
|
||||
vi.mocked(contents.setIgnoreMenuShortcuts).mockImplementation((ignored: boolean) => {
|
||||
if (!ignored) throw new Error('menu cleanup failed')
|
||||
})
|
||||
|
||||
await expect(dispatchKeyCombo(contents, parseKeyCombo('Cmd+A'))).resolves.toBeUndefined()
|
||||
expect(contents.debugger.sendCommand).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps the application menu isolated until overlapping dispatches finish', async () => {
|
||||
const contents = new WebContentsView().webContents
|
||||
let releaseFirstDown: (() => void) | undefined
|
||||
vi.mocked(contents.debugger.sendCommand).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
releaseFirstDown = () => resolve({})
|
||||
})
|
||||
)
|
||||
|
||||
const first = dispatchKeyCombo(contents, parseKeyCombo('Cmd+A'))
|
||||
await Promise.resolve()
|
||||
const second = dispatchKeyCombo(contents, parseKeyCombo('Cmd+Z'))
|
||||
await second
|
||||
|
||||
expect(contents.setIgnoreMenuShortcuts).toHaveBeenCalledTimes(1)
|
||||
expect(contents.setIgnoreMenuShortcuts).toHaveBeenCalledWith(true)
|
||||
|
||||
releaseFirstDown?.()
|
||||
await first
|
||||
|
||||
expect(contents.setIgnoreMenuShortcuts).toHaveBeenNthCalledWith(2, false)
|
||||
})
|
||||
|
||||
it('does not change application-menu handling for ordinary page keys', async () => {
|
||||
const contents = new WebContentsView().webContents
|
||||
|
||||
await dispatchKeyCombo(contents, parseKeyCombo('Enter'))
|
||||
|
||||
expect(contents.setIgnoreMenuShortcuts).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
* parsing "Cmd+Shift+Z"-style combos and building the trusted CDP
|
||||
* keyDown/keyUp pair. Pure logic except {@link dispatchKeyCombo}.
|
||||
*/
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import type { WebContents } from 'electron'
|
||||
import * as cdp from '@/main/browser-agent/cdp'
|
||||
import { ToolError } from '@/main/browser-agent/errors'
|
||||
|
||||
const applicationMenuIsolationDepth = new WeakMap<WebContents, number>()
|
||||
|
||||
interface KeyDescriptor {
|
||||
key: string
|
||||
code: string
|
||||
@@ -33,8 +36,63 @@ const NAMED_KEYS: Record<string, KeyDescriptor> = {
|
||||
end: { key: 'End', code: 'End', keyCode: 35 },
|
||||
pageup: { key: 'PageUp', code: 'PageUp', keyCode: 33 },
|
||||
pagedown: { key: 'PageDown', code: 'PageDown', keyCode: 34 },
|
||||
',': { key: ',', code: 'Comma', keyCode: 188 },
|
||||
comma: { key: ',', code: 'Comma', keyCode: 188 },
|
||||
'.': { key: '.', code: 'Period', keyCode: 190 },
|
||||
period: { key: '.', code: 'Period', keyCode: 190 },
|
||||
'/': { key: '/', code: 'Slash', keyCode: 191 },
|
||||
';': { key: ';', code: 'Semicolon', keyCode: 186 },
|
||||
"'": { key: "'", code: 'Quote', keyCode: 222 },
|
||||
'[': { key: '[', code: 'BracketLeft', keyCode: 219 },
|
||||
']': { key: ']', code: 'BracketRight', keyCode: 221 },
|
||||
'\\': { key: '\\', code: 'Backslash', keyCode: 220 },
|
||||
'-': { key: '-', code: 'Minus', keyCode: 189 },
|
||||
'=': { key: '=', code: 'Equal', keyCode: 187 },
|
||||
'`': { key: '`', code: 'Backquote', keyCode: 192 },
|
||||
plus: { key: '+', code: 'Equal', keyCode: 187 },
|
||||
}
|
||||
|
||||
const SHIFTED_CHARACTERS: Record<string, string> = {
|
||||
'1': '!',
|
||||
'2': '@',
|
||||
'3': '#',
|
||||
'4': '$',
|
||||
'5': '%',
|
||||
'6': '^',
|
||||
'7': '&',
|
||||
'8': '*',
|
||||
'9': '(',
|
||||
'0': ')',
|
||||
'-': '_',
|
||||
'=': '+',
|
||||
'[': '{',
|
||||
']': '}',
|
||||
'\\': '|',
|
||||
';': ':',
|
||||
"'": '"',
|
||||
',': '<',
|
||||
'.': '>',
|
||||
'/': '?',
|
||||
'`': '~',
|
||||
}
|
||||
|
||||
const BASE_CHARACTER_DESCRIPTORS: Record<string, KeyDescriptor> = Object.fromEntries(
|
||||
Object.values(NAMED_KEYS)
|
||||
.filter((descriptor) => descriptor.key.length === 1)
|
||||
.map((descriptor) => [descriptor.key, descriptor])
|
||||
)
|
||||
for (let digit = 0; digit <= 9; digit++) {
|
||||
const key = String(digit)
|
||||
BASE_CHARACTER_DESCRIPTORS[key] = {
|
||||
key,
|
||||
code: `Digit${key}`,
|
||||
keyCode: key.charCodeAt(0),
|
||||
}
|
||||
}
|
||||
const BASE_FOR_SHIFTED_CHARACTER: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(SHIFTED_CHARACTERS).map(([base, shifted]) => [shifted, base])
|
||||
)
|
||||
|
||||
export interface ParsedCombo extends KeyDescriptor {
|
||||
ctrl: boolean
|
||||
meta: boolean
|
||||
@@ -42,11 +100,37 @@ export interface ParsedCombo extends KeyDescriptor {
|
||||
alt: boolean
|
||||
}
|
||||
|
||||
export function parseKeyCombo(combo: string): ParsedCombo {
|
||||
const parts = combo
|
||||
.split('+')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
export class KeyDispatchError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly keyDownDispatched: boolean
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'KeyDispatchError'
|
||||
}
|
||||
}
|
||||
|
||||
export function parseKeyCombo(
|
||||
combo: string,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): ParsedCombo {
|
||||
const trimmedCombo = combo.trim()
|
||||
const parts =
|
||||
trimmedCombo === '+'
|
||||
? ['+']
|
||||
: trimmedCombo.endsWith('++')
|
||||
? [
|
||||
...trimmedCombo
|
||||
.slice(0, -2)
|
||||
.split('+')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean),
|
||||
'+',
|
||||
]
|
||||
: trimmedCombo
|
||||
.split('+')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
if (parts.length === 0) throw new ToolError(`Unrecognized key: "${combo}"`)
|
||||
const modifiers = { ctrl: false, meta: false, shift: false, alt: false }
|
||||
const keyPart = parts[parts.length - 1]
|
||||
@@ -54,21 +138,38 @@ export function parseKeyCombo(combo: string): ParsedCombo {
|
||||
const lower = part.toLowerCase()
|
||||
if (lower === 'control' || lower === 'ctrl') modifiers.ctrl = true
|
||||
else if (lower === 'meta' || lower === 'cmd' || lower === 'command') modifiers.meta = true
|
||||
else if (lower === 'shift') modifiers.shift = true
|
||||
else if (
|
||||
lower === 'mod' ||
|
||||
lower === 'primary' ||
|
||||
lower === 'controlormeta' ||
|
||||
lower === 'commandorcontrol'
|
||||
) {
|
||||
if (platform === 'darwin') modifiers.meta = true
|
||||
else modifiers.ctrl = true
|
||||
} else if (lower === 'shift') modifiers.shift = true
|
||||
else if (lower === 'alt' || lower === 'option') modifiers.alt = true
|
||||
else throw new ToolError(`Unrecognized modifier: "${part}"`)
|
||||
}
|
||||
const named = NAMED_KEYS[keyPart.toLowerCase()]
|
||||
if (named) return { ...named, ...modifiers }
|
||||
if (named) {
|
||||
const key = modifiers.shift ? (SHIFTED_CHARACTERS[named.key] ?? named.key) : named.key
|
||||
return { ...named, key, ...modifiers }
|
||||
}
|
||||
if (/^[a-zA-Z]$/.test(keyPart)) {
|
||||
const upper = keyPart.toUpperCase()
|
||||
const key = modifiers.shift ? upper : keyPart.toLowerCase()
|
||||
return { key, code: `Key${upper}`, keyCode: upper.charCodeAt(0), ...modifiers }
|
||||
}
|
||||
if (/^[0-9]$/.test(keyPart)) {
|
||||
return { key: keyPart, code: `Digit${keyPart}`, keyCode: keyPart.charCodeAt(0), ...modifiers }
|
||||
const key = modifiers.shift ? (SHIFTED_CHARACTERS[keyPart] ?? keyPart) : keyPart
|
||||
return { key, code: `Digit${keyPart}`, keyCode: keyPart.charCodeAt(0), ...modifiers }
|
||||
}
|
||||
if (keyPart.length === 1) {
|
||||
const base = BASE_FOR_SHIFTED_CHARACTER[keyPart]
|
||||
if (base) {
|
||||
const descriptor = BASE_CHARACTER_DESCRIPTORS[base]
|
||||
return { ...descriptor, key: keyPart, ...modifiers, shift: true }
|
||||
}
|
||||
return { key: keyPart, code: '', keyCode: keyPart.charCodeAt(0), ...modifiers }
|
||||
}
|
||||
throw new ToolError(`Unrecognized key: "${keyPart}"`)
|
||||
@@ -136,22 +237,10 @@ function macEditingCommands(combo: ParsedCombo, platform: NodeJS.Platform): stri
|
||||
*/
|
||||
function insertedTextFor(combo: ParsedCombo): string | undefined {
|
||||
if (combo.key === 'Enter') return '\r'
|
||||
const printable = combo.key.length === 1 && !combo.ctrl && !combo.meta
|
||||
const printable = combo.key.length === 1 && !combo.ctrl && !combo.meta && !combo.alt
|
||||
return printable ? combo.key : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a combo would put characters into whatever the page has focused.
|
||||
* Shares {@link insertedTextFor} with the dispatcher so a guard built on this
|
||||
* cannot drift from what is actually sent.
|
||||
*/
|
||||
export function comboInsertsText(
|
||||
rawCombo: ParsedCombo,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): boolean {
|
||||
return insertedTextFor(normalizeComboForPlatform(rawCombo, platform)) !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the trusted keyDown/keyUp pair for a combo. Printable keys without
|
||||
* ctrl/meta carry `text` so Blink inserts the character; Enter carries "\r"
|
||||
@@ -170,7 +259,6 @@ export function buildKeyDispatchPlan(
|
||||
key: combo.key,
|
||||
code: combo.code,
|
||||
windowsVirtualKeyCode: combo.keyCode,
|
||||
nativeVirtualKeyCode: combo.keyCode,
|
||||
}
|
||||
const text = insertedTextFor(combo)
|
||||
const commands = macEditingCommands(combo, platform)
|
||||
@@ -183,9 +271,58 @@ export function buildKeyDispatchPlan(
|
||||
return [down, { ...base, type: 'keyUp' }]
|
||||
}
|
||||
|
||||
/** Presses a combo through the trusted pipeline. Throws on CDP failure. */
|
||||
/**
|
||||
* Presses a combo through the trusted pipeline. Throws on CDP failure.
|
||||
*
|
||||
* Electron normally lets modified key events escape a focused WebContents to
|
||||
* application-menu accelerators. Agent input must stay inside the browser — a
|
||||
* page-level Cmd shortcut must never reload, close, or open a native window in
|
||||
* Sim — so menu handling is suspended for the complete CDP down/up pair. The
|
||||
* depth counter keeps overlapping tool calls from re-enabling it too early.
|
||||
*/
|
||||
export async function dispatchKeyCombo(contents: WebContents, combo: ParsedCombo): Promise<void> {
|
||||
const [down, up] = buildKeyDispatchPlan(combo)
|
||||
await cdp.dispatchKeyEvent(contents, down)
|
||||
await cdp.dispatchKeyEvent(contents, up)
|
||||
const isolatesApplicationMenu = combo.ctrl || combo.meta || combo.alt
|
||||
if (isolatesApplicationMenu) {
|
||||
const depth = applicationMenuIsolationDepth.get(contents) ?? 0
|
||||
if (depth === 0) contents.setIgnoreMenuShortcuts(true)
|
||||
applicationMenuIsolationDepth.set(contents, depth + 1)
|
||||
}
|
||||
let keyDownDispatched = false
|
||||
try {
|
||||
// Mark before awaiting: Blink may receive the key-down and then lose the
|
||||
// CDP acknowledgement during navigation/process swap. In that ambiguous
|
||||
// case cleanup is required and a synthetic retry could double-act.
|
||||
keyDownDispatched = true
|
||||
await cdp.dispatchKeyEvent(contents, down)
|
||||
await cdp.dispatchKeyEvent(contents, up)
|
||||
} catch (error) {
|
||||
if (keyDownDispatched && !contents.isDestroyed()) {
|
||||
// Like pointer cleanup, this is best effort. The original key-up may
|
||||
// have reached Blink before its CDP response was lost; a duplicate
|
||||
// release is harmless, while omitting it can leave input state stuck.
|
||||
await cdp.dispatchKeyEvent(contents, up).catch(() => {})
|
||||
}
|
||||
throw new KeyDispatchError(
|
||||
getErrorMessage(error, 'Trusted key dispatch failed'),
|
||||
keyDownDispatched
|
||||
)
|
||||
} finally {
|
||||
if (isolatesApplicationMenu) {
|
||||
const depth = applicationMenuIsolationDepth.get(contents) ?? 1
|
||||
if (depth > 1) {
|
||||
applicationMenuIsolationDepth.set(contents, depth - 1)
|
||||
} else {
|
||||
applicationMenuIsolationDepth.delete(contents)
|
||||
if (!contents.isDestroyed()) {
|
||||
// Menu restoration is cleanup, not evidence that page input failed.
|
||||
// Never let a synchronous Electron cleanup error cause the driver to
|
||||
// retry a key that may already have reached the page.
|
||||
try {
|
||||
contents.setIgnoreMenuShortcuts(false)
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
pageContainsText,
|
||||
pressKeyOnPage,
|
||||
readActiveElementState,
|
||||
readChildFrameElementState,
|
||||
readPageActionState,
|
||||
readPageText,
|
||||
readSelectElementState,
|
||||
scrollPage,
|
||||
selectOptionInElement,
|
||||
typeIntoElement,
|
||||
@@ -57,7 +60,16 @@ function runSerialized(fn: (...args: never[]) => unknown, args: unknown[]): unkn
|
||||
|
||||
function visible<T extends Element>(el: T): T {
|
||||
el.getBoundingClientRect = () =>
|
||||
({ x: 0, y: 0, width: 100, height: 20, top: 0, left: 0, right: 100, bottom: 20 }) as DOMRect
|
||||
({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 20,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 100,
|
||||
bottom: 20,
|
||||
}) as DOMRect
|
||||
return el
|
||||
}
|
||||
|
||||
@@ -67,11 +79,18 @@ function visible<T extends Element>(el: T): T {
|
||||
* keys off `document.activeElement`, not on real focus.
|
||||
*/
|
||||
function setActiveElement(doc: Document, el: Element | null): void {
|
||||
Object.defineProperty(doc, 'activeElement', { configurable: true, get: () => el })
|
||||
Object.defineProperty(doc, 'activeElement', {
|
||||
configurable: true,
|
||||
get: () => el,
|
||||
})
|
||||
}
|
||||
|
||||
/** Registers elements the way `collectSnapshot` does, so ids resolve. */
|
||||
function register(...elements: Element[]): void {
|
||||
// Most action tests intentionally exercise the legacy registry seam without
|
||||
// first taking a snapshot. A resolver left behind by a previous test must
|
||||
// not turn those registered elements into fail-closed stale refs.
|
||||
window.__simAgentResolveElement = undefined
|
||||
window.__simAgentElements = elements
|
||||
}
|
||||
|
||||
@@ -79,10 +98,30 @@ function outlineOf(result: unknown): string {
|
||||
return (result as { outline: string }).outline
|
||||
}
|
||||
|
||||
function refFor(outline: string, label: string): number {
|
||||
const line = outline.split('\n').find((candidate) => candidate.includes(`"${label}"`))
|
||||
const match = line?.match(/\[ref=(\d+)\]/)
|
||||
if (!match) throw new Error(`No ref found for ${label}`)
|
||||
return Number(match[1])
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const state of window.__simAgentMutationStates ?? []) state.observer.disconnect()
|
||||
window.__simAgentMutationStates = undefined
|
||||
window.__simAgentNextElementId = 0
|
||||
window.__simAgentResolveElement = undefined
|
||||
installDomShims()
|
||||
Reflect.deleteProperty(document, 'activeElement')
|
||||
document.body.innerHTML = ''
|
||||
window.__simAgentElements = []
|
||||
Object.defineProperty(document, 'elementFromPoint', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
})
|
||||
Object.defineProperty(document, 'elementsFromPoint', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -102,9 +141,16 @@ describe('serialization contract', () => {
|
||||
['typeIntoElement', typeIntoElement, [0, 'text', false]],
|
||||
['readActiveElementState', readActiveElementState, []],
|
||||
['activeElementSecrecy', activeElementSecrecy, []],
|
||||
[
|
||||
'readChildFrameElementState',
|
||||
readChildFrameElementState,
|
||||
['child-frame', 'https://child.example/frame', 'https://child.example', 0],
|
||||
],
|
||||
['pressKeyOnPage', pressKeyOnPage, ['a', 'KeyA', 65, false, false, false, false]],
|
||||
['readPageActionState', readPageActionState, []],
|
||||
['scrollPage', scrollPage, ['down', 100]],
|
||||
['selectOptionInElement', selectOptionInElement, [0, 'value']],
|
||||
['readSelectElementState', readSelectElementState, [0]],
|
||||
['hoverElement', hoverElement, [0]],
|
||||
['readPageText', readPageText, []],
|
||||
['pageContainsText', pageContainsText, ['needle']],
|
||||
@@ -127,7 +173,9 @@ describe('serialization contract', () => {
|
||||
|
||||
expect(runSerialized(clickElement, [0])).toEqual({ error: 'password' })
|
||||
expect(runSerialized(activeElementSecrecy, [])).toBe('secret')
|
||||
expect(runSerialized(readActiveElementState, [])).toMatchObject({ redacted: true })
|
||||
expect(runSerialized(readActiveElementState, [])).toMatchObject({
|
||||
redacted: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -187,11 +235,32 @@ describe('secret-field detection', () => {
|
||||
const button = visible(document.querySelector('button') as HTMLButtonElement)
|
||||
register(text, button, email)
|
||||
|
||||
expect(typeIntoElement(0, 'search terms', false)).toMatchObject({ typed: true })
|
||||
expect(clickElement(1)).toMatchObject({ clicked: true })
|
||||
expect(typeIntoElement(0, 'search terms', false)).toMatchObject({
|
||||
dispatched: true,
|
||||
})
|
||||
expect(clickElement(1)).toMatchObject({ dispatched: true })
|
||||
expect(focusElementForTyping(2)).toMatchObject({ focused: true })
|
||||
})
|
||||
|
||||
it('refuses readonly, disabled, and non-text fields for typing', () => {
|
||||
document.body.innerHTML = `
|
||||
<input type="text" readonly />
|
||||
<textarea disabled></textarea>
|
||||
<input type="checkbox" />
|
||||
`
|
||||
const fields = Array.from(document.querySelectorAll('input, textarea')).map((element) =>
|
||||
visible(element as HTMLElement)
|
||||
)
|
||||
register(...fields)
|
||||
|
||||
expect(focusElementForTyping(0)).toEqual({ error: 'readonly' })
|
||||
expect(typeIntoElement(0, 'change', false)).toEqual({ error: 'readonly' })
|
||||
expect(focusElementForTyping(1)).toEqual({ error: 'disabled' })
|
||||
expect(typeIntoElement(1, 'change', false)).toEqual({ error: 'disabled' })
|
||||
expect(focusElementForTyping(2)).toEqual({ error: 'not-editable' })
|
||||
expect(typeIntoElement(2, 'change', false)).toEqual({ error: 'not-editable' })
|
||||
})
|
||||
|
||||
it('detects a password field reached through a same-origin iframe', () => {
|
||||
// `instanceof HTMLInputElement` is realm-bound and returns false for nodes
|
||||
// owned by a frame, which is why detection matches on tagName instead.
|
||||
@@ -207,6 +276,115 @@ describe('secret-field detection', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('combobox typing surfaces', () => {
|
||||
function composeField(): {
|
||||
wrapper: HTMLDivElement
|
||||
input: HTMLInputElement
|
||||
option: HTMLDivElement
|
||||
} {
|
||||
document.body.innerHTML = `
|
||||
<div role="combobox" aria-label="To:" aria-expanded="true" aria-controls="contact-list">
|
||||
<input type="text" aria-label="Recipients" />
|
||||
</div>
|
||||
<div id="contact-list" role="listbox" aria-label="Contact list">
|
||||
<div role="option">Mondu</div>
|
||||
</div>
|
||||
`
|
||||
const wrapper = visible(document.querySelector('[role="combobox"]') as HTMLDivElement)
|
||||
const input = visible(document.querySelector('input') as HTMLInputElement)
|
||||
visible(document.querySelector('[role="listbox"]') as HTMLDivElement)
|
||||
const option = visible(document.querySelector('[role="option"]') as HTMLDivElement)
|
||||
register(wrapper)
|
||||
return { wrapper, input, option }
|
||||
}
|
||||
|
||||
it('types through a focused combobox own portaled suggestions list', () => {
|
||||
const { input, option } = composeField()
|
||||
Object.defineProperty(document, 'elementFromPoint', {
|
||||
configurable: true,
|
||||
value: () => option,
|
||||
})
|
||||
|
||||
expect(focusElementForTyping(0)).toMatchObject({
|
||||
focused: true,
|
||||
kind: 'input',
|
||||
coveredByRelatedPopup: true,
|
||||
})
|
||||
expect(document.activeElement).toBe(input)
|
||||
expect(focusElementForTyping(0, false)).toMatchObject({
|
||||
focused: true,
|
||||
coveredByRelatedPopup: true,
|
||||
})
|
||||
expect(typeIntoElement(0, 'Mondu', false)).toMatchObject({ dispatched: true })
|
||||
expect(input.value).toBe('Mondu')
|
||||
})
|
||||
|
||||
it('keeps pointer clicks blocked with typing guidance when suggestions own the surface', () => {
|
||||
const { option } = composeField()
|
||||
Object.defineProperty(document, 'elementFromPoint', {
|
||||
configurable: true,
|
||||
value: () => option,
|
||||
})
|
||||
expect(focusElementForTyping(0)).toMatchObject({ focused: true })
|
||||
|
||||
expect(clickElement(0, false)).toMatchObject({
|
||||
error: 'suggestions-open',
|
||||
blocker: 'Mondu',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not give suggestions guidance when any click point has an unrelated blocker', () => {
|
||||
const { option } = composeField()
|
||||
const overlay = visible(document.createElement('div'))
|
||||
overlay.setAttribute('aria-label', 'Unrelated overlay')
|
||||
document.body.append(overlay)
|
||||
Object.defineProperty(document, 'elementFromPoint', {
|
||||
configurable: true,
|
||||
value: (x: number) => (x > 70 ? overlay : option),
|
||||
})
|
||||
expect(focusElementForTyping(0)).toEqual({
|
||||
error: 'obstructed',
|
||||
blocker: 'Mondu',
|
||||
})
|
||||
|
||||
expect(clickElement(0, false)).toMatchObject({
|
||||
error: 'obstructed',
|
||||
blocker: 'Mondu',
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses mixed or unrelated blockers instead of treating them as suggestions', () => {
|
||||
const { option } = composeField()
|
||||
const overlay = visible(document.createElement('div'))
|
||||
overlay.setAttribute('aria-label', 'Unrelated overlay')
|
||||
document.body.append(overlay)
|
||||
Object.defineProperty(document, 'elementFromPoint', {
|
||||
configurable: true,
|
||||
value: (x: number) => (x > 70 ? overlay : option),
|
||||
})
|
||||
|
||||
expect(focusElementForTyping(0)).toEqual({
|
||||
error: 'obstructed',
|
||||
blocker: 'Mondu',
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses ambiguous composite fields and descendant passwords', () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="ambiguous" role="combobox"><input /><input /></div>
|
||||
<div id="secret" role="combobox"><input type="password" /></div>
|
||||
`
|
||||
const ambiguous = visible(document.querySelector('#ambiguous') as HTMLDivElement)
|
||||
const secret = visible(document.querySelector('#secret') as HTMLDivElement)
|
||||
for (const input of Array.from(document.querySelectorAll('input'))) visible(input)
|
||||
register(ambiguous, secret)
|
||||
|
||||
expect(focusElementForTyping(0)).toEqual({ error: 'ambiguous-editable' })
|
||||
expect(focusElementForTyping(1)).toEqual({ error: 'password' })
|
||||
expect(typeIntoElement(1, 'nope', false)).toEqual({ error: 'password' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('elements inside a same-origin iframe', () => {
|
||||
/**
|
||||
* The snapshot walks into same-origin frames and hands the model ids for
|
||||
@@ -234,15 +412,18 @@ describe('elements inside a same-origin iframe', () => {
|
||||
register(field)
|
||||
|
||||
expect(field instanceof HTMLInputElement).toBe(false)
|
||||
expect(typeIntoElement(0, 'hello', false)).toMatchObject({ typed: true })
|
||||
expect(typeIntoElement(0, 'hello', false)).toMatchObject({ dispatched: true })
|
||||
expect(field.value).toBe('hello')
|
||||
})
|
||||
|
||||
it('focuses a framed input for native typing', () => {
|
||||
const inner = framedBody('<input type="text" value="existing" />')
|
||||
register(inner.querySelector('input') as HTMLInputElement)
|
||||
register(visible(inner.querySelector('input') as HTMLInputElement))
|
||||
|
||||
expect(focusElementForTyping(0)).toMatchObject({ focused: true, kind: 'input' })
|
||||
expect(focusElementForTyping(0)).toMatchObject({
|
||||
focused: true,
|
||||
kind: 'input',
|
||||
})
|
||||
})
|
||||
|
||||
it('selects an option in a framed select', () => {
|
||||
@@ -256,6 +437,21 @@ describe('elements inside a same-origin iframe', () => {
|
||||
expect(select.value).toBe('b')
|
||||
})
|
||||
|
||||
it('does not programmatically mutate disabled selects or options', () => {
|
||||
const inner = framedBody(`
|
||||
<select disabled><option value="a">A</option></select>
|
||||
<select><option value="b" disabled>B</option></select>
|
||||
`)
|
||||
const [disabledSelect, optionDisabled] = Array.from(
|
||||
inner.querySelectorAll('select')
|
||||
) as HTMLSelectElement[]
|
||||
register(disabledSelect, optionDisabled)
|
||||
|
||||
expect(selectOptionInElement(0, 'A')).toEqual({ error: 'disabled' })
|
||||
expect(selectOptionInElement(1, 'B')).toEqual({ error: 'disabled' })
|
||||
expect(optionDisabled.value).toBe('')
|
||||
})
|
||||
|
||||
it('focuses a framed element when clicking it', () => {
|
||||
const inner = framedBody('<button>Go</button>')
|
||||
const button = visible(inner.querySelector('button') as HTMLButtonElement)
|
||||
@@ -265,10 +461,27 @@ describe('elements inside a same-origin iframe', () => {
|
||||
focused = true
|
||||
})
|
||||
|
||||
expect(clickElement(0)).toMatchObject({ clicked: true })
|
||||
expect(clickElement(0)).toMatchObject({ dispatched: true })
|
||||
expect(focused).toBe(true)
|
||||
})
|
||||
|
||||
it('does not synthesize Space after focusing a framed text input', () => {
|
||||
const inner = framedBody('<input type="text" /><input type="checkbox" />')
|
||||
const [text, checkbox] = Array.from(inner.querySelectorAll('input')) as HTMLInputElement[]
|
||||
visible(text)
|
||||
visible(checkbox)
|
||||
register(text, checkbox)
|
||||
|
||||
expect(clickElement(0, false, true)).toMatchObject({
|
||||
dispatched: false,
|
||||
activationKey: undefined,
|
||||
})
|
||||
expect(clickElement(1, false, true)).toMatchObject({
|
||||
dispatched: false,
|
||||
activationKey: 'Space',
|
||||
})
|
||||
})
|
||||
|
||||
it('still refuses a framed password field', () => {
|
||||
const inner = framedBody('<input type="password" />')
|
||||
register(inner.querySelector('input') as HTMLInputElement)
|
||||
@@ -325,6 +538,566 @@ describe('collectSnapshot', () => {
|
||||
|
||||
expect(outlineOf(collectSnapshot())).toContain('value="tokyo"')
|
||||
})
|
||||
|
||||
it('exposes roleless delegated React rows instead of dropping their text', () => {
|
||||
document.body.innerHTML = '<div style="cursor: pointer"><span>eng-bugs</span></div>'
|
||||
const row = visible(document.querySelector('div') as HTMLDivElement)
|
||||
visible(document.querySelector('span') as HTMLSpanElement)
|
||||
let clicked = false
|
||||
row.addEventListener('click', () => {
|
||||
clicked = true
|
||||
})
|
||||
|
||||
const outline = outlineOf(collectSnapshot())
|
||||
const ref = refFor(outline, 'eng-bugs')
|
||||
|
||||
expect(outline).toContain('clickable "eng-bugs"')
|
||||
expect(clickElement(ref)).toMatchObject({ dispatched: true })
|
||||
expect(clicked).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses a coordinate click when an overlay owns every hit point', () => {
|
||||
document.body.innerHTML =
|
||||
'<button aria-label="Delete draft">Delete</button><div aria-label="Confirmation overlay"></div>'
|
||||
const button = visible(document.querySelector('button') as HTMLButtonElement)
|
||||
const overlay = visible(document.querySelector('div') as HTMLDivElement)
|
||||
const ref = refFor(outlineOf(collectSnapshot()), 'Delete draft')
|
||||
Object.defineProperty(document, 'elementFromPoint', {
|
||||
configurable: true,
|
||||
value: () => overlay,
|
||||
})
|
||||
|
||||
expect(button.isConnected).toBe(true)
|
||||
expect(clickElement(ref, false)).toEqual({
|
||||
error: 'obstructed',
|
||||
blocker: 'Confirmation overlay',
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a parent click when a nested independent control owns the hit point', () => {
|
||||
document.body.innerHTML = `
|
||||
<div role="button" aria-label="Channel card">
|
||||
<button aria-label="Delete channel">Delete</button>
|
||||
</div>
|
||||
`
|
||||
const card = visible(document.querySelector('[role="button"]') as HTMLDivElement)
|
||||
const nestedButton = visible(document.querySelector('button') as HTMLButtonElement)
|
||||
const ref = refFor(outlineOf(collectSnapshot()), 'Channel card')
|
||||
Object.defineProperty(document, 'elementFromPoint', {
|
||||
configurable: true,
|
||||
value: () => nestedButton,
|
||||
})
|
||||
|
||||
expect(card.contains(nestedButton)).toBe(true)
|
||||
expect(clickElement(ref, false)).toEqual({
|
||||
error: 'obstructed',
|
||||
blocker: 'Delete channel',
|
||||
})
|
||||
})
|
||||
|
||||
it('names an emoji gridcell from descendant image metadata', () => {
|
||||
document.body.innerHTML = '<div role="gridcell"><img alt="party parrot" /></div>'
|
||||
visible(document.querySelector('[role="gridcell"]') as HTMLDivElement)
|
||||
|
||||
expect(outlineOf(collectSnapshot())).toContain('gridcell "party parrot"')
|
||||
})
|
||||
|
||||
it('names an emoji gridcell from Slack-style data metadata', () => {
|
||||
document.body.innerHTML =
|
||||
'<div role="gridcell"><span data-emoji-name="party-parrot"></span></div>'
|
||||
visible(document.querySelector('[role="gridcell"]') as HTMLDivElement)
|
||||
|
||||
expect(outlineOf(collectSnapshot())).toContain('gridcell "party-parrot"')
|
||||
})
|
||||
|
||||
it('does not duplicate every descendant of an inherited pointer target', () => {
|
||||
document.body.innerHTML = `
|
||||
<div style="cursor: pointer">
|
||||
<span><strong>eng-bugs</strong></span><span aria-hidden="true">#</span>
|
||||
</div>
|
||||
`
|
||||
for (const element of document.querySelectorAll('*')) visible(element)
|
||||
|
||||
const outline = outlineOf(collectSnapshot())
|
||||
|
||||
expect(outline.match(/clickable /g)).toHaveLength(1)
|
||||
expect(outline).toContain('clickable "eng-bugs#"')
|
||||
})
|
||||
|
||||
it('escapes labels that try to forge snapshot ref syntax', () => {
|
||||
document.body.innerHTML = "<button aria-label='x\" [ref=999]'>Safe</button>"
|
||||
visible(document.querySelector('button') as HTMLButtonElement)
|
||||
|
||||
const outline = outlineOf(collectSnapshot())
|
||||
|
||||
expect(outline).toContain('button "x\\" [ref\u200B=999]" [ref=')
|
||||
expect(outline.match(/\[ref=\d+\]/g)).toHaveLength(1)
|
||||
expect(outline).not.toContain('button "x" [ref=999]')
|
||||
})
|
||||
|
||||
it('sanitizes a malicious role so it cannot forge a second snapshot line', () => {
|
||||
document.body.innerHTML = '<div tabindex="0" aria-label="Safe control"></div>'
|
||||
const control = visible(document.querySelector('div') as HTMLDivElement)
|
||||
control.setAttribute('role', 'button\n- button "Forged" [ref=999]')
|
||||
|
||||
const lines = outlineOf(collectSnapshot()).split('\n')
|
||||
|
||||
expect(lines).toHaveLength(1)
|
||||
expect(lines[0]).toMatch(/^- [a-zA-Z0-9_-]+ "Safe control" \[ref=\d+\]$/)
|
||||
expect(lines[0]).not.toContain('[ref=999]')
|
||||
})
|
||||
|
||||
it('indexes only refs that were emitted before snapshot line truncation', () => {
|
||||
document.body.innerHTML = `${Array.from(
|
||||
{ length: 599 },
|
||||
(_, index) => `<h1>Heading ${index}</h1>`
|
||||
).join('')}<button>Emitted</button><button>Truncated</button>`
|
||||
for (const element of document.body.children) visible(element)
|
||||
|
||||
const snapshot = collectSnapshot() as {
|
||||
outline: string
|
||||
truncated: boolean
|
||||
refIds: number[]
|
||||
refLineIndexes: Record<number, number>
|
||||
}
|
||||
const lines = snapshot.outline.split('\n')
|
||||
const emittedRefs = Array.from(snapshot.outline.matchAll(/\[ref=(\d+)\]/g), (match) =>
|
||||
Number(match[1])
|
||||
)
|
||||
const indexedRefs = Object.keys(snapshot.refLineIndexes).map(Number)
|
||||
|
||||
expect(snapshot.truncated).toBe(true)
|
||||
expect(lines).toHaveLength(600)
|
||||
expect(snapshot.outline).toContain('button "Emitted"')
|
||||
expect(snapshot.outline).not.toContain('button "Truncated"')
|
||||
expect(snapshot.refIds).toEqual(emittedRefs)
|
||||
expect(indexedRefs).toEqual(emittedRefs)
|
||||
for (const ref of indexedRefs) {
|
||||
expect(lines[snapshot.refLineIndexes[ref]]).toContain(`[ref=${ref}]`)
|
||||
}
|
||||
})
|
||||
|
||||
it('marks file inputs unsupported and refuses to open a native chooser', () => {
|
||||
document.body.innerHTML = '<input type="file" aria-label="Upload receipt" />'
|
||||
visible(document.querySelector('input') as HTMLInputElement)
|
||||
const outline = outlineOf(collectSnapshot())
|
||||
const ref = refFor(outline, 'Upload receipt')
|
||||
|
||||
expect(outline).toContain('file-input "Upload receipt"')
|
||||
expect(outline).toContain('upload-unsupported')
|
||||
expect(clickElement(ref)).toEqual({ error: 'file-input' })
|
||||
})
|
||||
|
||||
it('keeps plain visible leaf text available as an actionable ref', () => {
|
||||
document.body.innerHTML = '<div><span>announce</span></div>'
|
||||
visible(document.querySelector('span') as HTMLSpanElement)
|
||||
|
||||
expect(outlineOf(collectSnapshot())).toContain('text "announce" [ref=')
|
||||
})
|
||||
|
||||
it('retains sender and timestamp text omitted from a row accessibility label', () => {
|
||||
document.body.innerHTML = `
|
||||
<div role="link" aria-label="Quarterly plan Updated forecast">
|
||||
<span>Sid Studio</span>
|
||||
<span>Quarterly plan</span>
|
||||
<span>11:42 AM</span>
|
||||
<span aria-label="Has attachment"></span>
|
||||
</div>
|
||||
`
|
||||
visible(document.querySelector('[role="link"]') as HTMLDivElement)
|
||||
for (const child of document.querySelectorAll('span')) visible(child)
|
||||
|
||||
const outline = outlineOf(collectSnapshot())
|
||||
|
||||
expect(outline).toContain('link "Quarterly plan Updated forecast"')
|
||||
expect(outline).toContain('text "Sid Studio"')
|
||||
expect(outline).toContain('text "11:42 AM"')
|
||||
expect(outline).toContain('text "Has attachment"')
|
||||
expect(outline).not.toContain('text "Quarterly plan"')
|
||||
})
|
||||
|
||||
it('recovers a ref when React uniquely replaces the same logical element', () => {
|
||||
document.body.innerHTML = '<button data-testid="messages-tab">Messages</button>'
|
||||
const original = visible(document.querySelector('button') as HTMLButtonElement)
|
||||
const ref = refFor(outlineOf(collectSnapshot()), 'Messages')
|
||||
const replacement = visible(original.cloneNode(true) as HTMLButtonElement)
|
||||
let clicked = false
|
||||
replacement.addEventListener('click', () => {
|
||||
clicked = true
|
||||
})
|
||||
original.replaceWith(replacement)
|
||||
|
||||
expect(clickElement(ref)).toMatchObject({
|
||||
dispatched: true,
|
||||
refRecovered: true,
|
||||
})
|
||||
expect(clicked).toBe(true)
|
||||
})
|
||||
|
||||
it('recovers from a connected but collapsed node to its unique visible replacement', () => {
|
||||
document.body.innerHTML =
|
||||
'<div role="combobox" data-testid="compose-recipient" aria-label="To:"><input /></div>'
|
||||
const original = visible(document.querySelector('[role="combobox"]') as HTMLDivElement)
|
||||
visible(document.querySelector('input') as HTMLInputElement)
|
||||
const ref = refFor(outlineOf(collectSnapshot()), 'To:')
|
||||
original.getBoundingClientRect = () =>
|
||||
({ width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0 }) as DOMRect
|
||||
const replacement = visible(original.cloneNode(true) as HTMLDivElement)
|
||||
visible(replacement.querySelector('input') as HTMLInputElement)
|
||||
document.body.append(replacement)
|
||||
|
||||
expect(focusElementForTyping(ref)).toMatchObject({
|
||||
focused: true,
|
||||
refRecovered: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not guess between visible replacements for a collapsed connected ref', () => {
|
||||
document.body.innerHTML =
|
||||
'<div role="combobox" data-testid="compose-recipient" aria-label="To:"><input /></div>'
|
||||
const original = visible(document.querySelector('[role="combobox"]') as HTMLDivElement)
|
||||
visible(document.querySelector('input') as HTMLInputElement)
|
||||
const ref = refFor(outlineOf(collectSnapshot()), 'To:')
|
||||
original.getBoundingClientRect = () =>
|
||||
({ width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0 }) as DOMRect
|
||||
for (let index = 0; index < 2; index++) {
|
||||
const replacement = visible(original.cloneNode(true) as HTMLDivElement)
|
||||
visible(replacement.querySelector('input') as HTMLInputElement)
|
||||
document.body.append(replacement)
|
||||
}
|
||||
|
||||
expect(focusElementForTyping(ref)).toEqual({ error: 'stale' })
|
||||
})
|
||||
|
||||
it('refuses to recover a ref when replacement is ambiguous', () => {
|
||||
document.body.innerHTML = '<button>Close</button>'
|
||||
const original = visible(document.querySelector('button') as HTMLButtonElement)
|
||||
const ref = refFor(outlineOf(collectSnapshot()), 'Close')
|
||||
const first = visible(original.cloneNode(true) as HTMLButtonElement)
|
||||
const second = visible(original.cloneNode(true) as HTMLButtonElement)
|
||||
original.replaceWith(first, second)
|
||||
|
||||
expect(clickElement(ref)).toEqual({ error: 'stale' })
|
||||
})
|
||||
|
||||
it('invalidates a connected virtual row when its identity is recycled in place', () => {
|
||||
document.body.innerHTML = '<div role="listitem" data-key="channel-1">eng-bugs</div>'
|
||||
const row = visible(document.querySelector('[role="listitem"]') as HTMLDivElement)
|
||||
const ref = refFor(outlineOf(collectSnapshot()), 'eng-bugs')
|
||||
|
||||
row.textContent = 'random'
|
||||
row.dataset.key = 'channel-2'
|
||||
|
||||
expect(clickElement(ref)).toEqual({ error: 'stale' })
|
||||
})
|
||||
|
||||
it('invalidates a generic connected row action when its surrounding item is recycled', () => {
|
||||
document.body.innerHTML = `
|
||||
<div role="listitem"><span>eng-bugs</span><button aria-label="More actions"></button></div>
|
||||
`
|
||||
for (const element of document.querySelectorAll('*')) visible(element as HTMLElement)
|
||||
const button = document.querySelector('button') as HTMLButtonElement
|
||||
const ref = refFor(outlineOf(collectSnapshot()), 'More actions')
|
||||
|
||||
;(document.querySelector('span') as HTMLSpanElement).textContent = 'random'
|
||||
|
||||
expect(button.isConnected).toBe(true)
|
||||
expect(clickElement(ref)).toEqual({ error: 'stale' })
|
||||
})
|
||||
|
||||
it('never recycles numeric refs across snapshots', () => {
|
||||
document.body.innerHTML = '<button>Pins</button>'
|
||||
visible(document.querySelector('button') as HTMLButtonElement)
|
||||
const firstRef = refFor(outlineOf(collectSnapshot()), 'Pins')
|
||||
const secondRef = refFor(outlineOf(collectSnapshot()), 'Pins')
|
||||
|
||||
expect(secondRef).toBeGreaterThan(firstRef)
|
||||
expect(clickElement(firstRef)).toEqual({ error: 'stale' })
|
||||
expect(clickElement(secondRef)).toMatchObject({ dispatched: true })
|
||||
})
|
||||
|
||||
it('reports a targeted control semantic disappearance after its panel closes', () => {
|
||||
document.body.innerHTML = `
|
||||
<aside aria-label="Thread panel"><button data-testid="close-thread">Close thread</button></aside>
|
||||
`
|
||||
const panel = document.querySelector('aside') as HTMLElement
|
||||
visible(panel)
|
||||
visible(document.querySelector('button') as HTMLButtonElement)
|
||||
const ref = refFor(outlineOf(collectSnapshot()), 'Close thread')
|
||||
|
||||
const before = readPageActionState(true, ref) as {
|
||||
targetState: { present: boolean; rendered: boolean }
|
||||
}
|
||||
panel.remove()
|
||||
const composer = visible(document.createElement('textarea'))
|
||||
composer.setAttribute('aria-label', 'Message')
|
||||
document.body.append(composer)
|
||||
const after = readPageActionState(false, ref) as {
|
||||
targetState: { present: boolean; rendered: boolean }
|
||||
}
|
||||
|
||||
expect(before.targetState).toMatchObject({ present: true, rendered: true })
|
||||
expect(after.targetState).toEqual({ present: false, rendered: false })
|
||||
})
|
||||
|
||||
it('keeps semantic target presence through a unique React replacement', () => {
|
||||
document.body.innerHTML =
|
||||
'<button data-testid="close-thread" aria-label="Close thread"></button>'
|
||||
const original = visible(document.querySelector('button') as HTMLButtonElement)
|
||||
const ref = refFor(outlineOf(collectSnapshot()), 'Close thread')
|
||||
const before = readPageActionState(true, ref) as { targetState: unknown }
|
||||
const replacement = visible(original.cloneNode(true) as HTMLButtonElement)
|
||||
original.replaceWith(replacement)
|
||||
const after = readPageActionState(false, ref) as { targetState: unknown }
|
||||
|
||||
expect(after.targetState).toEqual(before.targetState)
|
||||
})
|
||||
})
|
||||
|
||||
describe('scrollPage', () => {
|
||||
function makeScroller(scrollTop: number): {
|
||||
scroller: HTMLDivElement
|
||||
child: HTMLDivElement
|
||||
} {
|
||||
document.body.innerHTML =
|
||||
'<div id="messages" aria-label="Message history" style="overflow-y: auto"><div>message</div></div>'
|
||||
const scroller = visible(document.querySelector('#messages') as HTMLDivElement)
|
||||
const child = visible(scroller.firstElementChild as HTMLDivElement)
|
||||
Object.defineProperties(scroller, {
|
||||
clientHeight: { configurable: true, value: 200 },
|
||||
scrollHeight: { configurable: true, value: 1_000 },
|
||||
scrollTop: { configurable: true, writable: true, value: scrollTop },
|
||||
})
|
||||
Object.defineProperty(scroller, 'scrollBy', {
|
||||
configurable: true,
|
||||
value: ({ top }: ScrollToOptions) => {
|
||||
const next = scroller.scrollTop + (top || 0)
|
||||
scroller.scrollTop = Math.max(0, Math.min(800, next))
|
||||
},
|
||||
})
|
||||
return { scroller, child }
|
||||
}
|
||||
|
||||
it('scrolls the movable internal container under the viewport center', () => {
|
||||
const { scroller, child } = makeScroller(600)
|
||||
Object.defineProperty(document, 'elementsFromPoint', {
|
||||
configurable: true,
|
||||
value: () => [child, scroller],
|
||||
})
|
||||
|
||||
expect(scrollPage('up', 100)).toMatchObject({
|
||||
target: 'Message history',
|
||||
targetSource: 'viewport-center',
|
||||
scrollTop: 500,
|
||||
movedBy: -100,
|
||||
atTop: false,
|
||||
atBottom: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('targets the nearest scrollable ancestor of an explicit ref', () => {
|
||||
const { scroller, child } = makeScroller(0)
|
||||
const ref = refFor(outlineOf(collectSnapshot()), 'message')
|
||||
|
||||
expect(scrollPage('down', 125, ref)).toMatchObject({
|
||||
target: 'Message history',
|
||||
targetSource: 'element',
|
||||
scrollTop: 125,
|
||||
movedBy: 125,
|
||||
})
|
||||
expect(child.textContent).toBe('message')
|
||||
expect(scroller.scrollTop).toBe(125)
|
||||
})
|
||||
|
||||
it('walks past an immovable nearest scroller to a movable ancestor for an explicit ref', () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="outer" aria-label="Workspace" style="overflow-y: auto">
|
||||
<div id="inner" aria-label="Thread" style="overflow-y: auto">
|
||||
<div>message</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
const outer = visible(document.querySelector('#outer') as HTMLDivElement)
|
||||
const inner = visible(document.querySelector('#inner') as HTMLDivElement)
|
||||
const message = visible(inner.firstElementChild as HTMLDivElement)
|
||||
for (const [element, scrollTop] of [
|
||||
[outer, 500],
|
||||
[inner, 0],
|
||||
] as const) {
|
||||
Object.defineProperties(element, {
|
||||
clientHeight: { configurable: true, value: 200 },
|
||||
scrollHeight: { configurable: true, value: 1_000 },
|
||||
scrollTop: { configurable: true, writable: true, value: scrollTop },
|
||||
})
|
||||
Object.defineProperty(element, 'scrollBy', {
|
||||
configurable: true,
|
||||
value: ({ top }: ScrollToOptions) => {
|
||||
element.scrollTop = Math.max(0, Math.min(800, element.scrollTop + (top || 0)))
|
||||
},
|
||||
})
|
||||
}
|
||||
const ref = refFor(outlineOf(collectSnapshot()), 'message')
|
||||
|
||||
expect(scrollPage('up', 100, ref)).toMatchObject({
|
||||
target: 'Workspace',
|
||||
targetSource: 'element',
|
||||
movedBy: -100,
|
||||
scrollTop: 400,
|
||||
})
|
||||
expect(message.textContent).toBe('message')
|
||||
expect(inner.scrollTop).toBe(0)
|
||||
expect(outer.scrollTop).toBe(400)
|
||||
})
|
||||
|
||||
it('skips an immovable focused sidebar for the movable centered history pane', () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="sidebar" tabindex="0" aria-label="Channels" style="overflow-y: auto"><div>random</div></div>
|
||||
<div id="history" aria-label="Message history" style="overflow-y: auto"><div>message</div></div>
|
||||
`
|
||||
const sidebar = visible(document.querySelector('#sidebar') as HTMLDivElement)
|
||||
const history = visible(document.querySelector('#history') as HTMLDivElement)
|
||||
const message = visible(history.firstElementChild as HTMLDivElement)
|
||||
for (const [element, scrollTop] of [
|
||||
[sidebar, 0],
|
||||
[history, 600],
|
||||
] as const) {
|
||||
Object.defineProperties(element, {
|
||||
clientHeight: { configurable: true, value: 200 },
|
||||
scrollHeight: { configurable: true, value: 1_000 },
|
||||
scrollTop: { configurable: true, writable: true, value: scrollTop },
|
||||
})
|
||||
Object.defineProperty(element, 'scrollBy', {
|
||||
configurable: true,
|
||||
value: ({ top }: ScrollToOptions) => {
|
||||
element.scrollTop = Math.max(0, Math.min(800, element.scrollTop + (top || 0)))
|
||||
},
|
||||
})
|
||||
}
|
||||
setActiveElement(document, sidebar)
|
||||
Object.defineProperty(document, 'elementsFromPoint', {
|
||||
configurable: true,
|
||||
value: () => [message, history],
|
||||
})
|
||||
|
||||
expect(scrollPage('up', 100)).toMatchObject({
|
||||
target: 'Message history',
|
||||
targetSource: 'viewport-center',
|
||||
movedBy: -100,
|
||||
})
|
||||
expect(sidebar.scrollTop).toBe(0)
|
||||
expect(history.scrollTop).toBe(500)
|
||||
})
|
||||
|
||||
it('keeps a centered pane at its boundary instead of scrolling another pane', () => {
|
||||
const { scroller: history, child: message } = makeScroller(800)
|
||||
const sidebar = visible(document.createElement('div'))
|
||||
sidebar.setAttribute('aria-label', 'Channels')
|
||||
sidebar.style.overflowY = 'auto'
|
||||
document.body.prepend(sidebar)
|
||||
Object.defineProperties(sidebar, {
|
||||
clientHeight: { configurable: true, value: 200 },
|
||||
scrollHeight: { configurable: true, value: 1_000 },
|
||||
scrollTop: { configurable: true, writable: true, value: 0 },
|
||||
})
|
||||
Object.defineProperty(sidebar, 'scrollBy', {
|
||||
configurable: true,
|
||||
value: ({ top }: ScrollToOptions) => {
|
||||
sidebar.scrollTop += top || 0
|
||||
},
|
||||
})
|
||||
Object.defineProperty(document, 'elementsFromPoint', {
|
||||
configurable: true,
|
||||
value: () => [message, history],
|
||||
})
|
||||
setActiveElement(document, document.body)
|
||||
|
||||
expect(scrollPage('down', 100)).toMatchObject({
|
||||
target: 'Message history',
|
||||
targetSource: 'viewport-center-boundary',
|
||||
movedBy: 0,
|
||||
atBottom: true,
|
||||
})
|
||||
expect(sidebar.scrollTop).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('readChildFrameElementState', () => {
|
||||
it('rejects a frame hidden by an embedding ancestor', () => {
|
||||
document.body.innerHTML = `
|
||||
<div style="display: none">
|
||||
<iframe name="apps"></iframe>
|
||||
</div>
|
||||
`
|
||||
visible(document.querySelector('iframe') as HTMLIFrameElement)
|
||||
|
||||
expect(readChildFrameElementState('apps', '', '', 0)).toMatchObject({
|
||||
known: true,
|
||||
visible: false,
|
||||
frameName: 'apps',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a covered frame and reports the blocking surface', () => {
|
||||
document.body.innerHTML = `
|
||||
<iframe name="apps"></iframe>
|
||||
<div aria-label="Consent overlay"></div>
|
||||
`
|
||||
const frame = visible(document.querySelector('iframe') as HTMLIFrameElement)
|
||||
const overlay = visible(document.querySelector('div') as HTMLDivElement)
|
||||
Object.defineProperty(document, 'elementFromPoint', {
|
||||
configurable: true,
|
||||
value: () => overlay,
|
||||
})
|
||||
|
||||
expect(frame.isConnected).toBe(true)
|
||||
expect(readChildFrameElementState('apps', '', '', 0)).toMatchObject({
|
||||
known: true,
|
||||
visible: false,
|
||||
blocker: 'Consent overlay',
|
||||
frameName: 'apps',
|
||||
})
|
||||
})
|
||||
|
||||
it('hit-tests a frame against its shadow root instead of the outer document', () => {
|
||||
const host = document.createElement('div')
|
||||
document.body.append(host)
|
||||
const shadow = host.attachShadow({ mode: 'open' })
|
||||
const frame = document.createElement('iframe')
|
||||
frame.name = 'apps'
|
||||
shadow.append(frame)
|
||||
visible(frame)
|
||||
Object.defineProperty(shadow, 'elementFromPoint', {
|
||||
configurable: true,
|
||||
value: () => frame,
|
||||
})
|
||||
Object.defineProperty(document, 'elementFromPoint', {
|
||||
configurable: true,
|
||||
value: () => host,
|
||||
})
|
||||
|
||||
expect(readChildFrameElementState('apps', '', '', 0)).toMatchObject({
|
||||
known: true,
|
||||
visible: true,
|
||||
frameName: 'apps',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses WindowProxy identity to distinguish duplicate frame metadata', () => {
|
||||
document.body.innerHTML = `
|
||||
<iframe name="apps" src="https://example.com/widget"></iframe>
|
||||
<iframe name="apps" src="https://example.com/widget"></iframe>
|
||||
`
|
||||
const frames = Array.from(document.querySelectorAll('iframe')) as HTMLIFrameElement[]
|
||||
frames.forEach(visible)
|
||||
Object.defineProperty(document, 'elementFromPoint', {
|
||||
configurable: true,
|
||||
value: () => frames[1],
|
||||
})
|
||||
|
||||
expect(
|
||||
readChildFrameElementState('apps', 'https://example.com/widget', 'https://example.com', 1)
|
||||
).toMatchObject({ known: true, visible: true, frameName: 'apps' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('readActiveElementState', () => {
|
||||
@@ -346,7 +1119,10 @@ describe('readActiveElementState', () => {
|
||||
'<input type="text" autocomplete="current-password" value="hunter2" />'
|
||||
setActiveElement(document, document.querySelector('input'))
|
||||
|
||||
expect(readActiveElementState()).toMatchObject({ redacted: true, valuePreview: '' })
|
||||
expect(readActiveElementState()).toMatchObject({
|
||||
redacted: true,
|
||||
valuePreview: '',
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -399,7 +1175,10 @@ describe('XHTML lower-case tagName', () => {
|
||||
function lowerCaseTagInput(html: string): HTMLInputElement {
|
||||
document.body.innerHTML = html
|
||||
const input = document.querySelector('input') as HTMLInputElement
|
||||
Object.defineProperty(input, 'tagName', { configurable: true, get: () => 'input' })
|
||||
Object.defineProperty(input, 'tagName', {
|
||||
configurable: true,
|
||||
get: () => 'input',
|
||||
})
|
||||
return input
|
||||
}
|
||||
|
||||
@@ -431,6 +1210,24 @@ describe('activeElementSecrecy', () => {
|
||||
expect(activeElementSecrecy()).toBe('safe')
|
||||
})
|
||||
|
||||
it('distinguishes a different focused element from an invalid target ref', () => {
|
||||
document.body.innerHTML = `
|
||||
<input type="text" aria-label="Expected field" />
|
||||
<input type="text" aria-label="Other field" />
|
||||
`
|
||||
const expected = visible(document.querySelectorAll('input')[0])
|
||||
const other = visible(document.querySelectorAll('input')[1])
|
||||
const snapshot = collectSnapshot() as { refIds: number[] }
|
||||
const expectedRef = snapshot.refIds[0]
|
||||
setActiveElement(document, other)
|
||||
|
||||
expect(activeElementSecrecy(expectedRef)).toBe('different')
|
||||
expect(activeElementSecrecy(Number.MAX_SAFE_INTEGER)).toBe('stale')
|
||||
|
||||
setActiveElement(document, expected)
|
||||
expect(activeElementSecrecy(expectedRef)).toBe('safe')
|
||||
})
|
||||
|
||||
it('reports safe when nothing is focused', () => {
|
||||
setActiveElement(document, document.body)
|
||||
|
||||
@@ -460,7 +1257,10 @@ describe('activeElementSecrecy', () => {
|
||||
document.body.append(frame)
|
||||
// A cross-origin frame yields null here; jsdom cannot host one, so the
|
||||
// boundary is reproduced directly.
|
||||
Object.defineProperty(frame, 'contentDocument', { configurable: true, get: () => null })
|
||||
Object.defineProperty(frame, 'contentDocument', {
|
||||
configurable: true,
|
||||
get: () => null,
|
||||
})
|
||||
setActiveElement(document, frame)
|
||||
|
||||
expect(activeElementSecrecy()).toBe('opaque')
|
||||
|
||||
@@ -19,137 +19,283 @@ function freshPanel(): PanelModule {
|
||||
panelModule.initPanel({
|
||||
getMainWindow: () => null,
|
||||
activeTab: () => null,
|
||||
backgroundColor: () => '#ffffff',
|
||||
ensureInitialTab: () => {},
|
||||
onViewDetached: () => {},
|
||||
})
|
||||
panelModule.activatePanelScope('chat-test')
|
||||
return panelModule
|
||||
}
|
||||
|
||||
const PANEL_RECT = { x: 400, y: 64, width: 600, height: 800 }
|
||||
|
||||
/** A panel showing one tab, which is the state occlusion applies to. */
|
||||
/** A panel showing one tab. */
|
||||
function showPanel(panel: PanelModule) {
|
||||
const win = new BrowserWindow()
|
||||
const view = new WebContentsView()
|
||||
let active = { id: 'tab-1', view, pinned: false }
|
||||
const active = { id: 'tab-1', scopeId: 'chat-test', view, pinned: false }
|
||||
panel.initPanel({
|
||||
getMainWindow: () => win,
|
||||
activeTab: () => active,
|
||||
backgroundColor: () => '#0c0c0c',
|
||||
ensureInitialTab: () => {},
|
||||
onViewDetached: () => {},
|
||||
})
|
||||
panel.activatePanelScope('chat-test')
|
||||
panel.setPanelBounds(PANEL_RECT, win)
|
||||
/** Swaps in another tab's view, as switching tabs does. */
|
||||
const switchTab = (next: WebContentsView) => {
|
||||
active = { id: 'tab-2', view: next, pinned: false }
|
||||
panel.layout()
|
||||
}
|
||||
return { win, view, switchTab }
|
||||
return { win, view }
|
||||
}
|
||||
|
||||
/** When the view was hidden, in the global mock invocation order. */
|
||||
function hiddenAt(view: WebContentsView): number | undefined {
|
||||
const call = vi.mocked(view.setVisible).mock.calls.findIndex(([visible]) => visible === false)
|
||||
return call === -1 ? undefined : vi.mocked(view.setVisible).mock.invocationCallOrder[call]
|
||||
}
|
||||
|
||||
/** When the replacement frame was pushed to the renderer. */
|
||||
function snapshotSentAt(win: BrowserWindow): number | undefined {
|
||||
const call = vi
|
||||
.mocked(win.webContents.send)
|
||||
.mock.calls.findIndex(([channel]) => channel === 'browser-agent:panel-snapshot')
|
||||
return call === -1 ? undefined : vi.mocked(win.webContents.send).mock.invocationCallOrder[call]
|
||||
}
|
||||
|
||||
describe('panel occlusion', () => {
|
||||
describe('panel chat scope', () => {
|
||||
let panel: PanelModule
|
||||
|
||||
beforeEach(() => {
|
||||
panel = freshPanel()
|
||||
})
|
||||
|
||||
it('keeps the page up until its replacement frame exists', async () => {
|
||||
it('requires fresh bounds for the newly active chat and ignores stale reports', () => {
|
||||
const { win, view } = showPanel(panel)
|
||||
const previousScope = panel.getActivePanelScopeId()
|
||||
const nextScope = `${previousScope}:next`
|
||||
|
||||
panel.setPanelOccluded(true, win)
|
||||
panel.activatePanelScope(nextScope)
|
||||
expect(win.contentView.removeChildView).toHaveBeenCalledWith(view)
|
||||
expect(panel.isPanelVisible()).toBe(false)
|
||||
|
||||
// Hiding here is what produced the flash: the renderer paints its snapshot
|
||||
// as soon as it reports occlusion, and the only frame it holds until the
|
||||
// new one lands is the previous overlay's — a different scroll position, or
|
||||
// nothing at all.
|
||||
expect(hiddenAt(view)).toBeUndefined()
|
||||
panel.setPanelBounds(PANEL_RECT, win, undefined, previousScope)
|
||||
expect(panel.isPanelVisible()).toBe(false)
|
||||
|
||||
panel.setPanelBounds(PANEL_RECT, win, undefined, nextScope)
|
||||
expect(panel.isPanelVisible()).toBe(true)
|
||||
})
|
||||
|
||||
it('sends the frame before hiding, so the swap shows no seam', async () => {
|
||||
it('captures a lossless native-resolution frame before changing native-view visibility', async () => {
|
||||
const { win, view } = showPanel(panel)
|
||||
|
||||
panel.setPanelOccluded(true, win)
|
||||
await vi.waitFor(() => expect(hiddenAt(view)).toBeDefined())
|
||||
|
||||
const sent = snapshotSentAt(win)
|
||||
expect(sent).toBeDefined()
|
||||
expect(sent).toBeLessThan(hiddenAt(view) as number)
|
||||
})
|
||||
|
||||
it('stays visible when the overlay closes while the frame is being taken', async () => {
|
||||
const { win, view } = showPanel(panel)
|
||||
|
||||
panel.setPanelOccluded(true, win)
|
||||
panel.setPanelOccluded(false, win)
|
||||
await vi.waitFor(() => expect(snapshotSentAt(win)).toBeDefined())
|
||||
|
||||
// Hiding after the overlay is gone would blank the page with nothing above it.
|
||||
expect(hiddenAt(view)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('photographs a visible page as visible, so no scrollbar flashes into the frame', async () => {
|
||||
const { win, view } = showPanel(panel)
|
||||
|
||||
panel.setPanelOccluded(true, win)
|
||||
|
||||
// Asking to capture a visible page as hidden moves its visibility state,
|
||||
// and Chromium flashes overlay scrollbars across that transition — which
|
||||
// the frame then freezes, so the swap shows a scrollbar the page lacked.
|
||||
expect(view.webContents.capturePage).toHaveBeenCalledWith(undefined, { stayHidden: false })
|
||||
})
|
||||
|
||||
it('keeps an already-hidden page hidden when a tab switch needs a frame', async () => {
|
||||
const { win, view, switchTab } = showPanel(panel)
|
||||
panel.setPanelOccluded(true, win)
|
||||
await vi.waitFor(() => expect(hiddenAt(view)).toBeDefined())
|
||||
|
||||
// Switching tabs under an open overlay re-captures, and that panel really
|
||||
// is hidden — waking it for the shot is what the flag exists to prevent.
|
||||
const next = new WebContentsView()
|
||||
switchTab(next)
|
||||
|
||||
expect(next.webContents.capturePage).toHaveBeenCalledWith(undefined, { stayHidden: true })
|
||||
})
|
||||
|
||||
it('hides anyway when the frame cannot be taken', async () => {
|
||||
const { win, view } = showPanel(panel)
|
||||
vi.mocked(view.webContents.capturePage).mockRejectedValue(new Error('capture failed'))
|
||||
|
||||
panel.setPanelOccluded(true, win)
|
||||
|
||||
// Waiting forever on a failed capture would leave the page over the overlay.
|
||||
await vi.waitFor(() => expect(hiddenAt(view)).toBeDefined())
|
||||
expect(snapshotSentAt(win)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('accepts occlusion again after the panel is hidden and shown', async () => {
|
||||
const { win, view } = showPanel(panel)
|
||||
panel.setPanelOccluded(true, win)
|
||||
await vi.waitFor(() => expect(hiddenAt(view)).toBeDefined())
|
||||
|
||||
// Hiding the panel forgets both halves of the occlusion state; a stale
|
||||
// "already requested" would dedupe the next overlay's report away and leave
|
||||
// the page painting over it.
|
||||
panel.setPanelBounds(null, win)
|
||||
panel.setPanelBounds(PANEL_RECT, win)
|
||||
const scopeId = panel.getActivePanelScopeId()
|
||||
vi.mocked(view.setVisible).mockClear()
|
||||
panel.setPanelOccluded(true, win)
|
||||
vi.mocked(view.setBounds).mockClear()
|
||||
|
||||
await vi.waitFor(() => expect(hiddenAt(view)).toBeDefined())
|
||||
await expect(panel.capturePanelSnapshot(win, scopeId)).resolves.toEqual({
|
||||
dataUrl: 'data:image/png;base64,c2lt',
|
||||
tabId: 'tab-1',
|
||||
zoomPercent: 110,
|
||||
scopeId,
|
||||
viewportBounds: { x: 400, y: 64, width: 600, height: 786 },
|
||||
})
|
||||
expect(view.webContents.capturePage).toHaveBeenCalledWith(undefined, { stayHidden: false })
|
||||
const captureResult = vi.mocked(view.webContents.capturePage).mock.results[0]
|
||||
if (!captureResult) throw new Error('Expected capturePage to return a frame')
|
||||
const image = await captureResult.value
|
||||
expect(image.resize).not.toHaveBeenCalled()
|
||||
expect(image.toJPEG).not.toHaveBeenCalled()
|
||||
expect(image.toDataURL).toHaveBeenCalledOnce()
|
||||
expect(view.setVisible).not.toHaveBeenCalled()
|
||||
|
||||
expect(panel.setPanelOccluded(true, win, scopeId)).toBe(true)
|
||||
expect(view.setVisible).toHaveBeenLastCalledWith(false)
|
||||
expect(view.setBounds).not.toHaveBeenCalled()
|
||||
|
||||
expect(panel.setPanelOccluded(false, win, scopeId)).toBe(true)
|
||||
expect(view.setVisible).toHaveBeenLastCalledWith(true)
|
||||
expect(view.setBounds).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports the exact applied native rectangle in renderer viewport coordinates', async () => {
|
||||
const { win, view } = showPanel(panel)
|
||||
const scopeId = panel.getActivePanelScopeId()
|
||||
vi.mocked(win.webContents.getZoomFactor).mockReturnValue(1.25)
|
||||
vi.mocked(win.getContentSize).mockReturnValue([2_000, 1_400])
|
||||
|
||||
panel.setPanelBounds({ x: 100, y: 50, width: 801, height: 601 }, win)
|
||||
|
||||
expect(view.setBounds).toHaveBeenLastCalledWith({
|
||||
x: 125,
|
||||
y: 63,
|
||||
width: 1001,
|
||||
height: 751,
|
||||
})
|
||||
await expect(panel.capturePanelSnapshot(win, scopeId)).resolves.toMatchObject({
|
||||
viewportBounds: { x: 100, y: 50.4, width: 800.8, height: 600.8 },
|
||||
})
|
||||
})
|
||||
|
||||
it('force-hides the native page when its replacement was captured at stale bounds', async () => {
|
||||
const { win, view } = showPanel(panel)
|
||||
const scopeId = panel.getActivePanelScopeId()
|
||||
|
||||
await expect(panel.capturePanelSnapshot(win, scopeId)).resolves.not.toBeNull()
|
||||
panel.setPanelBounds({ x: 399, y: 64, width: 601, height: 786 }, win)
|
||||
vi.mocked(view.setVisible).mockClear()
|
||||
|
||||
expect(panel.setPanelOccluded(true, win, scopeId)).toBe(false)
|
||||
expect(view.setVisible).not.toHaveBeenCalled()
|
||||
|
||||
expect(panel.setPanelOccluded(true, win, scopeId, true)).toBe(true)
|
||||
expect(view.setVisible).toHaveBeenLastCalledWith(false)
|
||||
})
|
||||
|
||||
it('force-hides the native page without a captured replacement frame', () => {
|
||||
const { win, view } = showPanel(panel)
|
||||
const scopeId = panel.getActivePanelScopeId()
|
||||
vi.mocked(view.setVisible).mockClear()
|
||||
|
||||
expect(panel.setPanelOccluded(true, win, scopeId)).toBe(false)
|
||||
expect(view.setVisible).not.toHaveBeenCalled()
|
||||
|
||||
expect(panel.setPanelOccluded(true, win, scopeId, true)).toBe(true)
|
||||
expect(view.setVisible).toHaveBeenLastCalledWith(false)
|
||||
})
|
||||
|
||||
it('applies a forced hide before the panel reports its first bounds', () => {
|
||||
const win = new BrowserWindow()
|
||||
const view = new WebContentsView()
|
||||
const active = { id: 'tab-1', scopeId: 'chat-test', view, pinned: false }
|
||||
panel.initPanel({
|
||||
getMainWindow: () => win,
|
||||
activeTab: () => active,
|
||||
backgroundColor: () => '#0c0c0c',
|
||||
ensureInitialTab: () => {},
|
||||
onViewDetached: () => {},
|
||||
})
|
||||
panel.activatePanelScope('chat-test')
|
||||
|
||||
expect(panel.setPanelOccluded(true, win, 'chat-test')).toBe(false)
|
||||
expect(panel.setPanelOccluded(true, win, 'chat-test', true)).toBe(true)
|
||||
|
||||
panel.setPanelBounds(PANEL_RECT, win)
|
||||
expect(view.setVisible).toHaveBeenLastCalledWith(false)
|
||||
})
|
||||
|
||||
it('transfers a hidden lease to a focused window before its bounds arrive', () => {
|
||||
const { win, view } = showPanel(panel)
|
||||
const other = new BrowserWindow()
|
||||
const scopeId = panel.getActivePanelScopeId()
|
||||
vi.mocked(other.isFocused).mockReturnValue(true)
|
||||
vi.mocked(view.setVisible).mockClear()
|
||||
|
||||
expect(panel.setPanelOccluded(true, other, scopeId)).toBe(false)
|
||||
expect(panel.setPanelOccluded(true, other, scopeId, true)).toBe(true)
|
||||
expect(win.contentView.removeChildView).toHaveBeenCalledWith(view)
|
||||
|
||||
panel.setPanelBounds(PANEL_RECT, other)
|
||||
expect(view.setVisible).toHaveBeenLastCalledWith(false)
|
||||
expect(panel.setPanelOccluded(false, other, scopeId)).toBe(true)
|
||||
expect(view.setVisible).toHaveBeenLastCalledWith(true)
|
||||
expect(panel.setPanelOccluded(false, win, scopeId)).toBe(true)
|
||||
})
|
||||
|
||||
it('acknowledges forced occlusion in an unfocused window that has no local native view', () => {
|
||||
const { win, view } = showPanel(panel)
|
||||
const other = new BrowserWindow()
|
||||
const scopeId = panel.getActivePanelScopeId()
|
||||
vi.mocked(other.isFocused).mockReturnValue(false)
|
||||
vi.mocked(view.setVisible).mockClear()
|
||||
|
||||
expect(panel.setPanelOccluded(true, other, scopeId, true)).toBe(true)
|
||||
expect(view.setVisible).not.toHaveBeenCalled()
|
||||
expect(panel.setPanelOccluded(false, other, scopeId)).toBe(true)
|
||||
expect(view.setVisible).not.toHaveBeenCalled()
|
||||
expect(panel.setPanelOccluded(false, win, scopeId)).toBe(true)
|
||||
})
|
||||
|
||||
it('acknowledges cross-scope modal leases only for a window without the singleton view', () => {
|
||||
const { win, view } = showPanel(panel)
|
||||
const other = new BrowserWindow()
|
||||
vi.mocked(other.isFocused).mockReturnValue(false)
|
||||
vi.mocked(view.setVisible).mockClear()
|
||||
|
||||
expect(panel.setPanelOccluded(true, other, 'chat-in-other-window', true)).toBe(true)
|
||||
expect(panel.setPanelOccluded(false, other, 'chat-in-other-window')).toBe(true)
|
||||
expect(view.setVisible).not.toHaveBeenCalled()
|
||||
|
||||
vi.mocked(other.isFocused).mockReturnValue(true)
|
||||
expect(panel.setPanelOccluded(true, other, 'focused-other-chat', true)).toBe(false)
|
||||
|
||||
// A stale scope from the real owner must never mutate or falsely
|
||||
// acknowledge the currently hosted native surface.
|
||||
expect(panel.setPanelOccluded(true, win, 'stale-owner-chat', true)).toBe(false)
|
||||
expect(panel.setPanelOccluded(false, win, 'stale-owner-chat')).toBe(false)
|
||||
expect(view.setVisible).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('releases the old window occlusion lease when panel ownership moves', async () => {
|
||||
const { win, view } = showPanel(panel)
|
||||
const other = new BrowserWindow()
|
||||
const scopeId = panel.getActivePanelScopeId()
|
||||
|
||||
await expect(panel.capturePanelSnapshot(win, scopeId)).resolves.not.toBeNull()
|
||||
expect(panel.setPanelOccluded(true, win, scopeId)).toBe(true)
|
||||
expect(view.setVisible).toHaveBeenLastCalledWith(false)
|
||||
|
||||
panel.setPanelBounds(PANEL_RECT, other)
|
||||
expect(view.setVisible).toHaveBeenLastCalledWith(true)
|
||||
// The displaced renderer can retire its stale local snapshot without
|
||||
// changing the new owner's already-visible native view.
|
||||
expect(panel.setPanelOccluded(false, win, scopeId)).toBe(true)
|
||||
expect(view.setVisible).toHaveBeenLastCalledWith(true)
|
||||
})
|
||||
|
||||
it('lets a displaced window retire its snapshot without revealing the current modal lease', async () => {
|
||||
const { win, view } = showPanel(panel)
|
||||
const other = new BrowserWindow()
|
||||
const scopeId = panel.getActivePanelScopeId()
|
||||
|
||||
await expect(panel.capturePanelSnapshot(win, scopeId)).resolves.not.toBeNull()
|
||||
expect(panel.setPanelOccluded(true, win, scopeId)).toBe(true)
|
||||
|
||||
vi.mocked(other.isFocused).mockReturnValue(true)
|
||||
expect(panel.setPanelOccluded(true, other, scopeId, true)).toBe(true)
|
||||
panel.setPanelBounds(PANEL_RECT, other)
|
||||
expect(view.setVisible).toHaveBeenLastCalledWith(false)
|
||||
|
||||
expect(panel.setPanelOccluded(false, win, scopeId)).toBe(true)
|
||||
expect(view.setVisible).toHaveBeenLastCalledWith(false)
|
||||
expect(panel.setPanelOccluded(false, other, scopeId)).toBe(true)
|
||||
expect(view.setVisible).toHaveBeenLastCalledWith(true)
|
||||
})
|
||||
|
||||
it('lets the prior scope retire after a focused window establishes the next hidden lease', () => {
|
||||
const { win, view } = showPanel(panel)
|
||||
const other = new BrowserWindow()
|
||||
const previousScope = panel.getActivePanelScopeId()
|
||||
const nextScope = 'chat-in-focused-window'
|
||||
vi.mocked(other.isFocused).mockReturnValue(true)
|
||||
|
||||
panel.activatePanelScope(nextScope)
|
||||
expect(panel.setPanelOccluded(true, other, nextScope, true)).toBe(true)
|
||||
panel.setPanelBounds(PANEL_RECT, other, undefined, nextScope)
|
||||
expect(view.setVisible).toHaveBeenLastCalledWith(false)
|
||||
|
||||
expect(panel.setPanelOccluded(false, win, previousScope)).toBe(true)
|
||||
expect(view.setVisible).toHaveBeenLastCalledWith(false)
|
||||
expect(panel.setPanelOccluded(false, other, nextScope)).toBe(true)
|
||||
expect(view.setVisible).toHaveBeenLastCalledWith(true)
|
||||
})
|
||||
|
||||
it('treats an unpainted blank tab as a valid backdrop snapshot', async () => {
|
||||
const { win, view } = showPanel(panel)
|
||||
const scopeId = panel.getActivePanelScopeId()
|
||||
vi.mocked(view.webContents.getURL).mockReturnValue('about:blank')
|
||||
vi.mocked(view.webContents.capturePage).mockClear()
|
||||
|
||||
const snapshot = await panel.capturePanelSnapshot(win, scopeId)
|
||||
|
||||
expect(snapshot).toMatchObject({
|
||||
tabId: 'tab-1',
|
||||
zoomPercent: 110,
|
||||
scopeId,
|
||||
})
|
||||
expect(snapshot?.dataUrl).toContain('data:image/svg+xml,')
|
||||
expect(decodeURIComponent(snapshot?.dataUrl ?? '')).toContain('fill="#0c0c0c"')
|
||||
expect(view.webContents.capturePage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses to hide the page for a stale chat scope', () => {
|
||||
const { win, view } = showPanel(panel)
|
||||
vi.mocked(view.setVisible).mockClear()
|
||||
|
||||
expect(panel.setPanelOccluded(true, win, 'some-other-chat')).toBe(false)
|
||||
expect(panel.setPanelOccluded(true, win, 'some-other-chat', true)).toBe(false)
|
||||
expect(view.setVisible).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
* Sim window, when it is visible, and which window owns it.
|
||||
*
|
||||
* The browser is ONE native surface shared by every app window, so exactly one
|
||||
* window may drive it at a time. That, the renderer bounds lease, and the
|
||||
* occlusion snapshot are the intricate parts of the browser and are kept here,
|
||||
* apart from tab bookkeeping.
|
||||
* window may drive it at a time. That and the renderer bounds lease are kept
|
||||
* here, apart from tab bookkeeping.
|
||||
*
|
||||
* Depends on the session only through {@link PanelHost}, injected once at
|
||||
* startup. Tab state changes are pushed in by the session calling {@link layout};
|
||||
@@ -19,6 +18,7 @@ import type {
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import type { BrowserWindow, WebContentsView } from 'electron'
|
||||
import { zoomPercentOf } from '@/main/browser-agent/context-menu'
|
||||
import type { AgentTab } from '@/main/browser-agent/session'
|
||||
|
||||
const logger = createLogger('BrowserAgentPanel')
|
||||
@@ -36,6 +36,8 @@ export interface PanelHost {
|
||||
getMainWindow: () => BrowserWindow | null
|
||||
/** The tab whose view should be composited, or null when there is none. */
|
||||
activeTab: () => AgentTab | null
|
||||
/** Native backdrop used by a blank tab before its first page paint. */
|
||||
backgroundColor: () => string
|
||||
/**
|
||||
* Materializes the initial tab when the panel first becomes visible: a
|
||||
* visible browser resource always represents one open browser window, and
|
||||
@@ -49,6 +51,7 @@ export interface PanelHost {
|
||||
let host: PanelHost = {
|
||||
getMainWindow: () => null,
|
||||
activeTab: () => null,
|
||||
backgroundColor: () => '#ffffff',
|
||||
ensureInitialTab: () => {},
|
||||
onViewDetached: () => {},
|
||||
}
|
||||
@@ -57,20 +60,16 @@ let host: PanelHost = {
|
||||
let panelBounds: BrowserPanelBounds | null = null
|
||||
/** How {@link panelBounds} derives from the viewport, when the renderer said. */
|
||||
let panelAnchor: BrowserPanelAnchor | null = null
|
||||
/** True while the view is actually hidden for renderer-owned UI above it. */
|
||||
/** True only after a replacement frame has painted in Sim's renderer. */
|
||||
let panelOccluded = false
|
||||
/**
|
||||
* What the renderer last reported, which leads {@link panelOccluded} while a
|
||||
* frame is being captured. Hiding is what has to wait: the renderer paints its
|
||||
* snapshot the moment it believes the panel is occluded, and the only frame it
|
||||
* has until the new one lands is the one from the previous overlay — a picture
|
||||
* of a different scroll position, or nothing at all. Staying visible until the
|
||||
* replacement is sent means the swap is invisible instead of a flash.
|
||||
*/
|
||||
let panelOcclusionRequested = false
|
||||
/** Window whose renderer currently owns the native-surface replacement lease. */
|
||||
let occlusionOwnerWindow: BrowserWindow | null = null
|
||||
/** Invalidates captures when ownership, scope, or panel visibility changes. */
|
||||
let panelCaptureGeneration = 0
|
||||
let panelLeaseAt = 0
|
||||
let leaseTimer: ReturnType<typeof setInterval> | null = null
|
||||
let panelSnapshotGeneration = 0
|
||||
/** Chat whose native browser surface may currently be composited. */
|
||||
let activePanelScopeId: string | null = null
|
||||
/** The window currently hosting the active view, for re-parenting checks. */
|
||||
let hostedWindow: BrowserWindow | null = null
|
||||
/** The app window whose renderer most recently leased the visible panel. */
|
||||
@@ -80,6 +79,16 @@ let panelOwnerWindow: BrowserWindow | null = null
|
||||
let attachedView: WebContentsView | null = null
|
||||
let lastAppliedBounds = ''
|
||||
let lastAppliedVisibility: boolean | null = null
|
||||
interface OccludablePanelFrame {
|
||||
view: WebContentsView
|
||||
win: BrowserWindow
|
||||
scopeId: string
|
||||
tabId: string
|
||||
shellZoom: number
|
||||
nativeBounds: BrowserPanelBounds
|
||||
}
|
||||
/** Geometry of the painted frame that is currently allowed to replace the view. */
|
||||
let occludableFrame: OccludablePanelFrame | null = null
|
||||
/** The host window whose `resize` currently drives {@link layout}, if any. */
|
||||
let resizeBoundWindow: BrowserWindow | null = null
|
||||
/** Captures nothing, so one instance serves every window it is bound to. */
|
||||
@@ -102,6 +111,33 @@ export function initPanel(panelHost: PanelHost): void {
|
||||
panelAnchor = null
|
||||
panelLeaseAt = 0
|
||||
panelOwnerWindow = null
|
||||
activePanelScopeId = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the singleton compositor to another chat. Bounds are renderer leases,
|
||||
* so the new chat must report its own rect before any native page is shown.
|
||||
*/
|
||||
export function activatePanelScope(scopeId: string | null): void {
|
||||
if (activePanelScopeId === scopeId) return
|
||||
activePanelScopeId = scopeId
|
||||
resetOcclusion()
|
||||
detachAttachedView()
|
||||
panelBounds = null
|
||||
panelAnchor = null
|
||||
panelLeaseAt = 0
|
||||
panelOwnerWindow = null
|
||||
}
|
||||
|
||||
/** Retags an active pending scope without tearing down the compositor. */
|
||||
export function migratePanelScope(fromScopeId: string, toScopeId: string): void {
|
||||
if (activePanelScopeId !== fromScopeId) return
|
||||
activePanelScopeId = toScopeId
|
||||
panelCaptureGeneration++
|
||||
}
|
||||
|
||||
export function getActivePanelScopeId(): string | null {
|
||||
return activePanelScopeId
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,7 +162,11 @@ export function panelWindow(): BrowserWindow | null {
|
||||
* owned, only the owner — so a stale report from a second window cannot hide
|
||||
* or steal the singleton browser surface.
|
||||
*/
|
||||
export function panelUpdateAllowed(ownerWindow?: BrowserWindow): boolean {
|
||||
export function panelUpdateAllowed(
|
||||
ownerWindow?: BrowserWindow,
|
||||
scopeId = activePanelScopeId
|
||||
): boolean {
|
||||
if (!scopeId || scopeId !== activePanelScopeId) return false
|
||||
if (!ownerWindow) return true
|
||||
const owner = panelOwner()
|
||||
return owner === null || owner === ownerWindow
|
||||
@@ -141,8 +181,10 @@ export function panelUpdateAllowed(ownerWindow?: BrowserWindow): boolean {
|
||||
*/
|
||||
export function canReportPanelBounds(
|
||||
win: BrowserWindow,
|
||||
focusedWindow: BrowserWindow | null
|
||||
focusedWindow: BrowserWindow | null,
|
||||
scopeId = activePanelScopeId
|
||||
): boolean {
|
||||
if (!scopeId || scopeId !== activePanelScopeId) return false
|
||||
const owner = panelOwner()
|
||||
return owner === null || owner === win || focusedWindow === win
|
||||
}
|
||||
@@ -228,13 +270,14 @@ function clampToContent(
|
||||
* Clears the tracked attachment before touching Electron objects so a stale
|
||||
* host or child view cannot leave layout permanently wedged after teardown.
|
||||
*/
|
||||
export function detachAttachedView(): void {
|
||||
function detachAttachedView(): void {
|
||||
const view = attachedView
|
||||
const win = hostedWindow
|
||||
attachedView = null
|
||||
hostedWindow = null
|
||||
lastAppliedBounds = ''
|
||||
lastAppliedVisibility = null
|
||||
occludableFrame = null
|
||||
unbindHostResize()
|
||||
host.onViewDetached(view)
|
||||
|
||||
@@ -249,10 +292,17 @@ export function detachAttachedView(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Reveals the native view and invalidates every frame captured for its old state. */
|
||||
function resetOcclusion(): void {
|
||||
panelOccluded = false
|
||||
occlusionOwnerWindow = null
|
||||
occludableFrame = null
|
||||
panelCaptureGeneration++
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the attached view painting without giving up its compositor surface,
|
||||
* so showing it again is immediate. A hidden view takes no input either, which
|
||||
* is what lets renderer UI sit where it used to be.
|
||||
* so showing it again is immediate when the browser resource becomes visible.
|
||||
*/
|
||||
function hideAttachedView(): void {
|
||||
const view = attachedView
|
||||
@@ -279,77 +329,6 @@ export function detachIfAttached(view: WebContentsView): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Forgets both halves of the occlusion state, so a later report is not deduped away. */
|
||||
function resetOcclusion(): void {
|
||||
panelOccluded = false
|
||||
panelOcclusionRequested = false
|
||||
panelSnapshotGeneration++
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures the current browser frame for the renderer to display while the
|
||||
* native view is hidden beneath an overlay.
|
||||
*
|
||||
* The capture is a copy of the compositor surface, so it can never relayout the
|
||||
* page — but asking to capture a VISIBLE page as hidden perturbs its visibility
|
||||
* bookkeeping, and Chromium flashes overlay scrollbars across that transition.
|
||||
* The frame then freezes the flash, and the swap shows a scrollbar the live page
|
||||
* did not have. So the flag tracks what the view actually is: hidden only for
|
||||
* the one caller that captures an already-hidden page (a tab switched while the
|
||||
* panel is occluded), where it stops Chromium promoting it back for the shot.
|
||||
*/
|
||||
/** Widest the placeholder snapshot needs to be; it sits behind a transient overlay. */
|
||||
const SNAPSHOT_MAX_WIDTH = 1024
|
||||
const SNAPSHOT_JPEG_QUALITY = 70
|
||||
|
||||
/**
|
||||
* Turns a captured frame into a compact JPEG data URL. Resize is a native
|
||||
* operation and JPEG encode runs in native code too, so both are far cheaper
|
||||
* than a full-resolution PNG `toDataURL`, which encodes synchronously on the
|
||||
* event loop.
|
||||
*/
|
||||
function encodeSnapshot(image: Electron.NativeImage): string {
|
||||
const { width } = image.getSize()
|
||||
const scaled = width > SNAPSHOT_MAX_WIDTH ? image.resize({ width: SNAPSHOT_MAX_WIDTH }) : image
|
||||
const jpeg = scaled.toJPEG(SNAPSHOT_JPEG_QUALITY)
|
||||
return `data:image/jpeg;base64,${jpeg.toString('base64')}`
|
||||
}
|
||||
|
||||
function capturePanelSnapshot(onSettled?: () => void): void {
|
||||
const active = host.activeTab()
|
||||
const win = panelWindow()
|
||||
if (!active || !win || active.view.webContents.isDestroyed()) {
|
||||
onSettled?.()
|
||||
return
|
||||
}
|
||||
|
||||
const generation = ++panelSnapshotGeneration
|
||||
const tabId = active.id
|
||||
void active.view.webContents
|
||||
.capturePage(undefined, { stayHidden: panelOccluded })
|
||||
.then((image) => {
|
||||
if (generation !== panelSnapshotGeneration || image.isEmpty()) return
|
||||
// Ownership can move while the capture is in flight. This frame is a
|
||||
// picture of the page, so it goes to the window still showing the
|
||||
// browser or nowhere at all.
|
||||
if (panelWindow() !== win || win.isDestroyed()) return
|
||||
// Downscale and JPEG-encode before crossing IPC. capturePage returns a
|
||||
// device-pixel PNG — on a retina half-window that is millions of pixels,
|
||||
// and toDataURL's PNG encode is synchronous on the main process, so a
|
||||
// full-size encode stalls every window's input for the frame. This is a
|
||||
// placeholder shown under a transient overlay, so a downscaled JPEG is
|
||||
// indistinguishable and an order of magnitude cheaper to encode and send.
|
||||
const snapshot: BrowserPanelSnapshot = { dataUrl: encodeSnapshot(image), tabId }
|
||||
win.webContents.send('browser-agent:panel-snapshot', snapshot)
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.warn('Could not capture browser panel snapshot', {
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
})
|
||||
.finally(() => onSettled?.())
|
||||
}
|
||||
|
||||
/**
|
||||
* Repositions the active view over the panel rect inside its window
|
||||
* (re-parenting if that window was recreated), and detaches it when the panel
|
||||
@@ -380,14 +359,12 @@ export function layout(): void {
|
||||
const win = panelWindow()
|
||||
const active = host.activeTab()
|
||||
const showing = active !== null && panelBounds !== null && win !== null
|
||||
const activeViewChanged = showing && attachedView !== active?.view
|
||||
|
||||
// Detach only when the attached view cannot stay where it is: no tab is
|
||||
// active, a different tab took over, or the hosting window changed.
|
||||
//
|
||||
// A panel that is merely hidden keeps its view attached and invisible, for
|
||||
// the same reason occlusion does (see setPanelOccluded): removing the view
|
||||
// gives up its compositor surface, and rebuilding that on the way back is a
|
||||
// A panel hidden behind another resource keeps its view attached and invisible:
|
||||
// removing the view gives up its compositor surface, and rebuilding it is a
|
||||
// blank repaint that reads as the page having reloaded. Every switch to
|
||||
// another resource and back hides the panel, so that was every switch.
|
||||
if (
|
||||
@@ -405,9 +382,6 @@ export function layout(): void {
|
||||
win.contentView.addChildView(active.view)
|
||||
hostedWindow = win
|
||||
attachedView = active.view
|
||||
if (panelOccluded && activeViewChanged) {
|
||||
capturePanelSnapshot()
|
||||
}
|
||||
}
|
||||
bindHostResize(win)
|
||||
const zoom = win.webContents.getZoomFactor()
|
||||
@@ -430,15 +404,265 @@ export function layout(): void {
|
||||
const boundsKey = `${bounds.x}:${bounds.y}:${bounds.width}:${bounds.height}`
|
||||
if (boundsKey !== lastAppliedBounds) {
|
||||
lastAppliedBounds = boundsKey
|
||||
occludableFrame = null
|
||||
active.view.setBounds(bounds)
|
||||
}
|
||||
const visible = !panelOccluded
|
||||
if (visible !== lastAppliedVisibility) {
|
||||
if (lastAppliedVisibility !== visible) {
|
||||
lastAppliedVisibility = visible
|
||||
active.view.setVisible(visible)
|
||||
}
|
||||
}
|
||||
|
||||
/** Converts the applied native DIP rectangle back into Sim viewport CSS pixels. */
|
||||
function viewportBoundsFor(
|
||||
nativeBounds: BrowserPanelBounds,
|
||||
shellZoom: number
|
||||
): BrowserPanelBounds {
|
||||
return {
|
||||
x: nativeBounds.x / shellZoom,
|
||||
y: nativeBounds.y / shellZoom,
|
||||
width: nativeBounds.width / shellZoom,
|
||||
height: nativeBounds.height / shellZoom,
|
||||
}
|
||||
}
|
||||
|
||||
function sameBounds(left: BrowserPanelBounds, right: BrowserPanelBounds): boolean {
|
||||
return (
|
||||
left.x === right.x &&
|
||||
left.y === right.y &&
|
||||
left.width === right.width &&
|
||||
left.height === right.height
|
||||
)
|
||||
}
|
||||
|
||||
function frameGeometryIsCurrent(frame: OccludablePanelFrame): boolean {
|
||||
return (
|
||||
activePanelScopeId === frame.scopeId &&
|
||||
host.activeTab()?.id === frame.tabId &&
|
||||
attachedView === frame.view &&
|
||||
panelWindow() === frame.win &&
|
||||
!frame.win.isDestroyed() &&
|
||||
!frame.view.webContents.isDestroyed() &&
|
||||
frame.win.webContents.getZoomFactor() === frame.shellZoom &&
|
||||
sameBounds(frame.view.getBounds(), frame.nativeBounds)
|
||||
)
|
||||
}
|
||||
|
||||
/** A blank tab needs only its native backdrop, not a compositor capture. */
|
||||
function blankSnapshot(
|
||||
scopeId: string,
|
||||
tabId: string,
|
||||
zoomPercent: number,
|
||||
viewportBounds: BrowserPanelBounds
|
||||
): BrowserPanelSnapshot {
|
||||
return {
|
||||
scopeId,
|
||||
tabId,
|
||||
zoomPercent,
|
||||
viewportBounds,
|
||||
dataUrl: `data:image/svg+xml,${encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"><path fill="${host.backgroundColor()}" d="M0 0h1v1H0z"/></svg>`
|
||||
)}`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures the compositor surface without resizing or lossy encoding.
|
||||
*
|
||||
* This image temporarily replaces the native view while renderer-owned chrome
|
||||
* is open, so any scaling or JPEG compression is visible as a veil over the
|
||||
* page. Keeping the frame at native resolution and in PNG makes that surface
|
||||
* swap visually seamless.
|
||||
*/
|
||||
export async function capturePanelSnapshot(
|
||||
ownerWindow?: BrowserWindow,
|
||||
scopeId = activePanelScopeId
|
||||
): Promise<BrowserPanelSnapshot | null> {
|
||||
if (
|
||||
!scopeId ||
|
||||
!panelUpdateAllowed(ownerWindow, scopeId) ||
|
||||
panelBounds === null ||
|
||||
panelOccluded
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const active = host.activeTab()
|
||||
const win = panelWindow()
|
||||
if (!active || !win || active.view.webContents.isDestroyed()) return null
|
||||
|
||||
// Ensure the snapshot describes the bounds Electron is actually painting,
|
||||
// not merely the renderer host that requested them.
|
||||
layout()
|
||||
if (attachedView !== active.view) return null
|
||||
|
||||
const generation = ++panelCaptureGeneration
|
||||
occludableFrame = null
|
||||
const tabId = active.id
|
||||
const contents = active.view.webContents
|
||||
const shellZoom = win.webContents.getZoomFactor()
|
||||
const nativeBounds = active.view.getBounds()
|
||||
const frame: OccludablePanelFrame = {
|
||||
view: active.view,
|
||||
win,
|
||||
scopeId,
|
||||
tabId,
|
||||
shellZoom,
|
||||
nativeBounds,
|
||||
}
|
||||
const viewportBounds = viewportBoundsFor(nativeBounds, shellZoom)
|
||||
const zoomPercent = zoomPercentOf(contents.getZoomFactor())
|
||||
const url = contents.getURL()
|
||||
if (url === '' || url === 'about:blank') {
|
||||
if (!frameGeometryIsCurrent(frame)) return null
|
||||
occludableFrame = frame
|
||||
return blankSnapshot(scopeId, tabId, zoomPercent, viewportBounds)
|
||||
}
|
||||
try {
|
||||
const image = await contents.capturePage(undefined, { stayHidden: false })
|
||||
if (
|
||||
generation !== panelCaptureGeneration ||
|
||||
scopeId !== activePanelScopeId ||
|
||||
host.activeTab()?.id !== tabId ||
|
||||
panelWindow() !== win ||
|
||||
win.isDestroyed() ||
|
||||
!frameGeometryIsCurrent(frame) ||
|
||||
image.isEmpty()
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const snapshot: BrowserPanelSnapshot = {
|
||||
scopeId,
|
||||
tabId,
|
||||
zoomPercent,
|
||||
viewportBounds,
|
||||
dataUrl: image.toDataURL(),
|
||||
}
|
||||
occludableFrame = frame
|
||||
return snapshot
|
||||
} catch (error) {
|
||||
logger.warn('Could not capture browser panel for a toolbar menu', {
|
||||
error: getErrorMessage(error, 'unknown'),
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps the native page only after the renderer confirms its exact frame has
|
||||
* painted. Hiding changes visibility alone: bounds and compositor attachment
|
||||
* remain untouched, so revealing cannot relayout or restack the page.
|
||||
*/
|
||||
export function setPanelOccluded(
|
||||
occluded: boolean,
|
||||
ownerWindow?: BrowserWindow,
|
||||
scopeId = activePanelScopeId,
|
||||
force = false
|
||||
): boolean {
|
||||
if (!scopeId) return false
|
||||
|
||||
if (scopeId !== activePanelScopeId) {
|
||||
const currentOwner = occlusionOwnerWindow ?? panelOwner() ?? host.getMainWindow()
|
||||
const requesterOwnsNativeSurface =
|
||||
!ownerWindow || currentOwner === null || ownerWindow === currentOwner
|
||||
|
||||
// Every app window has its own renderer modal state, but the Browser is a
|
||||
// singleton native surface hosted by only one of them. A background
|
||||
// renderer on another chat therefore has nothing local to hide or reveal.
|
||||
// Acknowledge its forced modal lease as a scoped no-op so its strict
|
||||
// pre-paint gate can proceed, while still rejecting stale requests from
|
||||
// the window that actually owns the native surface.
|
||||
if (!requesterOwnsNativeSurface && !occluded) return true
|
||||
if (
|
||||
!requesterOwnsNativeSurface &&
|
||||
force &&
|
||||
ownerWindow &&
|
||||
!ownerWindow.isDestroyed() &&
|
||||
!ownerWindow.isFocused()
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Once ownership has transferred, an old renderer still needs to retire its
|
||||
// local replacement when its modal closes. The old lease was released by
|
||||
// the transfer, so revealing an already-visible panel is a scoped no-op.
|
||||
if (!occluded && !panelOccluded) return true
|
||||
// Another focused window may have replaced this renderer's lease with its
|
||||
// own modal lease. Retiring the displaced renderer's local snapshot is also
|
||||
// a no-op: it must not reveal the CURRENT owner's still-occluded view.
|
||||
if (!occluded && ownerWindow && occlusionOwnerWindow && ownerWindow !== occlusionOwnerWindow) {
|
||||
return true
|
||||
}
|
||||
|
||||
const panelAllowed = panelUpdateAllowed(ownerWindow, scopeId)
|
||||
const focusedForceTransfer =
|
||||
occluded &&
|
||||
force &&
|
||||
!panelAllowed &&
|
||||
Boolean(ownerWindow && !ownerWindow.isDestroyed() && ownerWindow.isFocused())
|
||||
const forceWithoutLocalSurface =
|
||||
occluded &&
|
||||
force &&
|
||||
!panelAllowed &&
|
||||
Boolean(ownerWindow && !ownerWindow.isDestroyed() && !ownerWindow.isFocused())
|
||||
// An unfocused non-owner window has no native view in its compositor. It can
|
||||
// safely open its own renderer modal without mutating the focused/owning
|
||||
// window's lease. If it later gains focus while the marker remains, the
|
||||
// bounds-report guard establishes a real hidden lease before transfer.
|
||||
if (forceWithoutLocalSurface) return true
|
||||
if (!panelAllowed && !focusedForceTransfer) return false
|
||||
|
||||
// A focused second window can open a modal before its next rAF reports new
|
||||
// panel bounds. Transfer a HIDDEN, bounds-less lease atomically: the old
|
||||
// window stops painting now and the new window's first bounds attach the
|
||||
// singleton already hidden. Clearing the old rect also prevents a reveal at
|
||||
// another window's geometry if the modal closes unusually quickly.
|
||||
if (focusedForceTransfer && ownerWindow) {
|
||||
panelOwnerWindow = ownerWindow
|
||||
panelBounds = null
|
||||
panelAnchor = null
|
||||
panelLeaseAt = 0
|
||||
panelOccluded = true
|
||||
occlusionOwnerWindow = ownerWindow
|
||||
occludableFrame = null
|
||||
panelCaptureGeneration++
|
||||
layout()
|
||||
return true
|
||||
}
|
||||
|
||||
if (!occluded) {
|
||||
if (ownerWindow && occlusionOwnerWindow && ownerWindow !== occlusionOwnerWindow) return false
|
||||
panelOccluded = false
|
||||
occlusionOwnerWindow = null
|
||||
occludableFrame = null
|
||||
layout()
|
||||
return true
|
||||
}
|
||||
|
||||
// A pre-paint modal handshake can arrive one React commit before the panel
|
||||
// reports its first bounds. A forced lease must still stick in that state so
|
||||
// a view attached later in the same frame starts hidden, rather than briefly
|
||||
// punching through the already-visible renderer effect.
|
||||
if (occluded && (panelBounds === null || host.activeTab() === null) && !force) return false
|
||||
if (panelOccluded) {
|
||||
return !ownerWindow || !occlusionOwnerWindow || ownerWindow === occlusionOwnerWindow
|
||||
}
|
||||
layout()
|
||||
// The lossless frame is the normal path. A full-screen renderer effect
|
||||
// may explicitly force the final fallback after capture/geometry retries:
|
||||
// a temporarily blank/blurred host is preferable to a native rectangle
|
||||
// punching above a modal or global takeover. Ordinary popovers never force
|
||||
// this path because they must remain pixel-neutral.
|
||||
if ((!occludableFrame || !frameGeometryIsCurrent(occludableFrame)) && !force) return false
|
||||
panelOccluded = true
|
||||
occlusionOwnerWindow = ownerWindow ?? panelWindow()
|
||||
occludableFrame = null
|
||||
layout()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderer-reported panel rect (null = panel hidden/unmounted). When an owner
|
||||
* is supplied, stale reports from another app window cannot steal or hide the
|
||||
@@ -447,8 +671,10 @@ export function layout(): void {
|
||||
export function setPanelBounds(
|
||||
bounds: BrowserPanelBounds | null,
|
||||
ownerWindow?: BrowserWindow,
|
||||
anchor?: BrowserPanelAnchor
|
||||
anchor?: BrowserPanelAnchor,
|
||||
scopeId = activePanelScopeId
|
||||
): void {
|
||||
if (!scopeId || scopeId !== activePanelScopeId) return
|
||||
// A closing window releases the panel from its `closed` handler, by which
|
||||
// point Electron has already destroyed it. That release has to be honoured
|
||||
// or the panel stays "visible" with a dead owner, and the next layout
|
||||
@@ -459,7 +685,12 @@ export function setPanelBounds(
|
||||
// must not pull the browser out from under the window displaying it.
|
||||
if (bounds === null && !panelUpdateAllowed(ownerWindow)) return
|
||||
if (bounds !== null) {
|
||||
panelOwnerWindow = ownerWindow ?? host.getMainWindow()
|
||||
const nextOwner = ownerWindow ?? host.getMainWindow()
|
||||
// Occlusion belongs to a renderer window, not to the mutable singleton.
|
||||
// Moving the native view to another window releases the previous window's
|
||||
// lease; the new owner must establish its own if it also has a modal.
|
||||
if (occlusionOwnerWindow && nextOwner !== occlusionOwnerWindow) resetOcclusion()
|
||||
panelOwnerWindow = nextOwner
|
||||
} else {
|
||||
panelOwnerWindow = null
|
||||
}
|
||||
@@ -467,8 +698,7 @@ export function setPanelBounds(
|
||||
panelAnchor = bounds === null ? null : (anchor ?? null)
|
||||
if (bounds !== null) {
|
||||
host.ensureInitialTab()
|
||||
}
|
||||
if (bounds === null) {
|
||||
} else {
|
||||
resetOcclusion()
|
||||
}
|
||||
panelLeaseAt = Date.now()
|
||||
@@ -489,30 +719,3 @@ export function setPanelBounds(
|
||||
}
|
||||
layout()
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderer-reported native-surface occlusion. The view stays attached and
|
||||
* keeps its bounds while hidden, avoiding the flicker and restacking caused by
|
||||
* removing and re-adding it for every tooltip or menu.
|
||||
*
|
||||
* Revealing is immediate; hiding waits for the frame that replaces it (see
|
||||
* {@link panelOcclusionRequested}). A capture that fails or finds nothing to
|
||||
* photograph still hides, so an overlay is never left with the page on top.
|
||||
*/
|
||||
export function setPanelOccluded(occluded: boolean, ownerWindow?: BrowserWindow): void {
|
||||
if (!panelUpdateAllowed(ownerWindow)) return
|
||||
if (panelOcclusionRequested === occluded) return
|
||||
panelOcclusionRequested = occluded
|
||||
if (!occluded) {
|
||||
panelOccluded = false
|
||||
layout()
|
||||
return
|
||||
}
|
||||
capturePanelSnapshot(() => {
|
||||
// The overlay can close while its frame is being taken; hiding then would
|
||||
// blank the page with nothing above it.
|
||||
if (!panelOcclusionRequested) return
|
||||
panelOccluded = true
|
||||
layout()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { FillCoordinator } from '@/main/browser-credentials/fill'
|
||||
import type { CredentialVault } from '@/main/browser-credentials/vault'
|
||||
|
||||
const ORIGIN = 'https://example.com'
|
||||
const SCOPE = 'chat-a'
|
||||
const WINDOW = {} as BrowserWindow
|
||||
|
||||
function fakeContents(url = `${ORIGIN}/login`) {
|
||||
@@ -42,9 +43,14 @@ type Contents = ReturnType<typeof fakeContents>
|
||||
function setup(contents: Contents = fakeContents(), vault = fakeVault()) {
|
||||
const onAvailabilityChanged = vi.fn()
|
||||
let active: Contents | null = contents
|
||||
let activeScope = SCOPE
|
||||
const contentsScopes = new WeakMap<object, string>()
|
||||
contentsScopes.set(contents, SCOPE)
|
||||
const coordinator = new FillCoordinator({
|
||||
vault: vault as unknown as CredentialVault,
|
||||
getActiveContents: () => active as unknown as WebContents | null,
|
||||
getActiveContents: (scopeId) =>
|
||||
!scopeId || scopeId === activeScope ? (active as unknown as WebContents | null) : null,
|
||||
scopeOwnsContents: (scopeId, candidate) => contentsScopes.get(candidate) === scopeId,
|
||||
onAvailabilityChanged,
|
||||
})
|
||||
return {
|
||||
@@ -52,18 +58,32 @@ function setup(contents: Contents = fakeContents(), vault = fakeVault()) {
|
||||
contents,
|
||||
vault,
|
||||
onAvailabilityChanged,
|
||||
setActive: (next: Contents | null) => {
|
||||
setActive: (next: Contents | null, scopeId = SCOPE) => {
|
||||
active = next
|
||||
activeScope = scopeId
|
||||
if (next) contentsScopes.set(next, scopeId)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function loginFormState(
|
||||
overrides: Partial<{
|
||||
origin: string
|
||||
hasLoginForm: boolean
|
||||
hasPasswordField: boolean
|
||||
}> = {}
|
||||
) {
|
||||
return {
|
||||
origin: ORIGIN,
|
||||
hasLoginForm: true,
|
||||
hasPasswordField: true,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** Reports a login form, opens the chooser, and returns its menu template. */
|
||||
async function openChooser(context: ReturnType<typeof setup>) {
|
||||
context.coordinator.noteFormState(context.contents as unknown as WebContents, {
|
||||
origin: ORIGIN,
|
||||
hasLoginForm: true,
|
||||
})
|
||||
context.coordinator.noteFormState(context.contents as unknown as WebContents, loginFormState())
|
||||
await context.coordinator.showChooser(WINDOW, { x: 10, y: 20 })
|
||||
const template = vi.mocked(Menu.buildFromTemplate).mock.calls.at(-1)?.[0] as
|
||||
| Array<{ label: string; click: () => void }>
|
||||
@@ -92,66 +112,128 @@ beforeEach(() => {
|
||||
describe('fill availability', () => {
|
||||
it('is available once a login form has a saved match', async () => {
|
||||
const context = setup()
|
||||
context.coordinator.noteFormState(context.contents as unknown as WebContents, {
|
||||
origin: ORIGIN,
|
||||
hasLoginForm: true,
|
||||
})
|
||||
context.coordinator.noteFormState(context.contents as unknown as WebContents, loginFormState())
|
||||
|
||||
await expect(context.coordinator.isFillAvailable()).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('replays the active tab availability when its chat is reactivated', async () => {
|
||||
const context = setup()
|
||||
context.coordinator.noteFormState(context.contents as unknown as WebContents, loginFormState())
|
||||
await settle()
|
||||
context.onAvailabilityChanged.mockClear()
|
||||
|
||||
await context.coordinator.refreshAvailability()
|
||||
expect(context.onAvailabilityChanged).not.toHaveBeenCalled()
|
||||
|
||||
await context.coordinator.refreshAvailability(true)
|
||||
expect(context.onAvailabilityChanged).toHaveBeenCalledWith(true, context.contents)
|
||||
})
|
||||
|
||||
it('does not publish a stale match after the page navigates', async () => {
|
||||
let resolveMatches!: (matches: Awaited<ReturnType<CredentialVault['listForOrigin']>>) => void
|
||||
const listForOrigin = vi.fn(
|
||||
() =>
|
||||
new Promise<Awaited<ReturnType<CredentialVault['listForOrigin']>>>((resolve) => {
|
||||
resolveMatches = resolve
|
||||
})
|
||||
)
|
||||
const context = setup(fakeContents(), fakeVault({ listForOrigin }))
|
||||
|
||||
context.coordinator.noteFormState(context.contents as unknown as WebContents, loginFormState())
|
||||
context.coordinator.noteNavigation(context.contents as unknown as WebContents)
|
||||
await settle()
|
||||
resolveMatches([
|
||||
{
|
||||
id: 'c1',
|
||||
origin: ORIGIN,
|
||||
username: 'ada',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
source: 'chrome',
|
||||
},
|
||||
])
|
||||
await settle()
|
||||
|
||||
expect(context.onAvailabilityChanged.mock.calls).toEqual([[false, context.contents]])
|
||||
})
|
||||
|
||||
it('does not publish an old tab after the active tab changes', async () => {
|
||||
let resolveMatches!: (matches: Awaited<ReturnType<CredentialVault['listForOrigin']>>) => void
|
||||
const listForOrigin = vi.fn(
|
||||
() =>
|
||||
new Promise<Awaited<ReturnType<CredentialVault['listForOrigin']>>>((resolve) => {
|
||||
resolveMatches = resolve
|
||||
})
|
||||
)
|
||||
const context = setup(fakeContents(), fakeVault({ listForOrigin }))
|
||||
context.coordinator.noteFormState(context.contents as unknown as WebContents, loginFormState())
|
||||
|
||||
context.setActive(fakeContents('https://other.example/login'))
|
||||
resolveMatches([
|
||||
{
|
||||
id: 'c1',
|
||||
origin: ORIGIN,
|
||||
username: 'ada',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
source: 'chrome',
|
||||
},
|
||||
])
|
||||
await settle()
|
||||
|
||||
expect(context.onAvailabilityChanged).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['there is no login form', { hasLoginForm: false }],
|
||||
['the page origin cannot hold a credential', { origin: 'about:blank' }],
|
||||
])('is unavailable when %s', async (_label, report) => {
|
||||
const context = setup()
|
||||
context.coordinator.noteFormState(context.contents as unknown as WebContents, {
|
||||
origin: ORIGIN,
|
||||
hasLoginForm: true,
|
||||
...report,
|
||||
})
|
||||
context.coordinator.noteFormState(
|
||||
context.contents as unknown as WebContents,
|
||||
loginFormState(report)
|
||||
)
|
||||
|
||||
await expect(context.coordinator.isFillAvailable()).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('is unavailable with no saved credential for the origin', async () => {
|
||||
const context = setup(fakeContents(), fakeVault({ listForOrigin: vi.fn(async () => []) }))
|
||||
context.coordinator.noteFormState(context.contents as unknown as WebContents, {
|
||||
origin: ORIGIN,
|
||||
hasLoginForm: true,
|
||||
})
|
||||
context.coordinator.noteFormState(context.contents as unknown as WebContents, loginFormState())
|
||||
|
||||
await expect(context.coordinator.isFillAvailable()).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('is unavailable when secure storage is unavailable', async () => {
|
||||
const context = setup(fakeContents(), fakeVault({ isAvailable: vi.fn(() => false) }))
|
||||
context.coordinator.noteFormState(context.contents as unknown as WebContents, {
|
||||
origin: ORIGIN,
|
||||
hasLoginForm: true,
|
||||
})
|
||||
context.coordinator.noteFormState(context.contents as unknown as WebContents, loginFormState())
|
||||
|
||||
await expect(context.coordinator.isFillAvailable()).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('drops to unavailable as soon as the page navigates', async () => {
|
||||
const context = setup()
|
||||
context.coordinator.noteFormState(context.contents as unknown as WebContents, {
|
||||
origin: ORIGIN,
|
||||
hasLoginForm: true,
|
||||
})
|
||||
context.coordinator.noteFormState(context.contents as unknown as WebContents, loginFormState())
|
||||
|
||||
context.coordinator.noteNavigation(context.contents as unknown as WebContents)
|
||||
|
||||
await expect(context.coordinator.isFillAvailable()).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('requests a fresh page report after same-document navigation', () => {
|
||||
const context = setup()
|
||||
|
||||
context.coordinator.noteNavigation(context.contents as unknown as WebContents)
|
||||
expect(context.contents.send).not.toHaveBeenCalledWith('browser-credentials:rescan')
|
||||
|
||||
context.coordinator.noteNavigation(context.contents as unknown as WebContents, true)
|
||||
expect(context.contents.send).toHaveBeenCalledWith('browser-credentials:rescan')
|
||||
})
|
||||
|
||||
it('forgets a closed tab', async () => {
|
||||
const context = setup()
|
||||
context.coordinator.noteFormState(context.contents as unknown as WebContents, {
|
||||
origin: ORIGIN,
|
||||
hasLoginForm: true,
|
||||
})
|
||||
context.coordinator.noteFormState(context.contents as unknown as WebContents, loginFormState())
|
||||
|
||||
context.coordinator.forget(context.contents as unknown as WebContents)
|
||||
|
||||
@@ -173,14 +255,97 @@ describe('credential chooser', () => {
|
||||
await expect(context.coordinator.showChooser(WINDOW, { x: 0, y: 0 })).resolves.toBe(false)
|
||||
|
||||
const noMatches = setup(fakeContents(), fakeVault({ listForOrigin: vi.fn(async () => []) }))
|
||||
noMatches.coordinator.noteFormState(noMatches.contents as unknown as WebContents, {
|
||||
origin: ORIGIN,
|
||||
hasLoginForm: true,
|
||||
})
|
||||
noMatches.coordinator.noteFormState(
|
||||
noMatches.contents as unknown as WebContents,
|
||||
loginFormState()
|
||||
)
|
||||
await expect(noMatches.coordinator.showChooser(WINDOW, { x: 0, y: 0 })).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderer credential chooser', () => {
|
||||
it('lists only matching metadata and never reads a password', async () => {
|
||||
const context = setup()
|
||||
context.coordinator.noteFormState(context.contents as unknown as WebContents, loginFormState())
|
||||
|
||||
await expect(context.coordinator.listFillOptions(SCOPE)).resolves.toEqual([
|
||||
expect.objectContaining({ id: 'c1', origin: ORIGIN, username: 'ada' }),
|
||||
])
|
||||
expect(context.vault.listForOrigin).toHaveBeenCalledWith(ORIGIN)
|
||||
expect(context.vault.readForFill).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses to list options for a scope that does not own the active tab', async () => {
|
||||
const context = setup()
|
||||
context.coordinator.noteFormState(context.contents as unknown as WebContents, loginFormState())
|
||||
await settle()
|
||||
context.vault.listForOrigin.mockClear()
|
||||
|
||||
await expect(context.coordinator.listFillOptions('chat-b')).resolves.toEqual([])
|
||||
expect(context.vault.listForOrigin).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fills one selected option and consumes its authorization', async () => {
|
||||
const context = setup()
|
||||
context.coordinator.noteFormState(context.contents as unknown as WebContents, loginFormState())
|
||||
await context.coordinator.listFillOptions(SCOPE)
|
||||
|
||||
await expect(context.coordinator.fillCredential('c1', SCOPE)).resolves.toBe(true)
|
||||
expect(context.contents.send).toHaveBeenCalledWith('browser-credentials:fill', {
|
||||
origin: ORIGIN,
|
||||
username: 'ada',
|
||||
password: 'hunter2',
|
||||
})
|
||||
await expect(context.coordinator.fillCredential('c1', SCOPE)).resolves.toBe(false)
|
||||
expect(context.contents.send).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('refuses an id that was not in the matching option list', async () => {
|
||||
const context = setup()
|
||||
context.coordinator.noteFormState(context.contents as unknown as WebContents, loginFormState())
|
||||
await context.coordinator.listFillOptions(SCOPE)
|
||||
|
||||
await expect(context.coordinator.fillCredential('other', SCOPE)).resolves.toBe(false)
|
||||
expect(context.vault.readForFill).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('invalidates a renderer selection when the page navigates', async () => {
|
||||
const context = setup()
|
||||
context.coordinator.noteFormState(context.contents as unknown as WebContents, loginFormState())
|
||||
await context.coordinator.listFillOptions(SCOPE)
|
||||
|
||||
context.coordinator.noteNavigation(context.contents as unknown as WebContents)
|
||||
context.coordinator.noteFormState(context.contents as unknown as WebContents, loginFormState())
|
||||
|
||||
await expect(context.coordinator.fillCredential('c1', SCOPE)).resolves.toBe(false)
|
||||
expect(context.vault.readForFill).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('revalidates the active scope after the vault read begins', async () => {
|
||||
let releaseRead: () => void = () => {}
|
||||
const pending = new Promise<void>((resolve) => {
|
||||
releaseRead = resolve
|
||||
})
|
||||
const vault = fakeVault({
|
||||
readForFill: vi.fn(async () => {
|
||||
await pending
|
||||
return { username: 'ada', password: 'hunter2' }
|
||||
}),
|
||||
})
|
||||
const context = setup(fakeContents(), vault)
|
||||
context.coordinator.noteFormState(context.contents as unknown as WebContents, loginFormState())
|
||||
await context.coordinator.listFillOptions(SCOPE)
|
||||
|
||||
const fill = context.coordinator.fillCredential('c1', SCOPE)
|
||||
await settle()
|
||||
context.setActive(fakeContents('https://other.test/login'), 'chat-b')
|
||||
releaseRead()
|
||||
|
||||
await expect(fill).resolves.toBe(false)
|
||||
expect(context.contents.send).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('performing a fill', () => {
|
||||
it('sends the credential to the page the user chose it for', async () => {
|
||||
const context = setup()
|
||||
@@ -198,11 +363,10 @@ describe('performing a fill', () => {
|
||||
|
||||
it('fills the email step of a two-step sign-in without sending the password', async () => {
|
||||
const context = setup()
|
||||
context.coordinator.noteFormState(context.contents as unknown as WebContents, {
|
||||
origin: ORIGIN,
|
||||
hasLoginForm: true,
|
||||
hasPasswordField: false,
|
||||
})
|
||||
context.coordinator.noteFormState(
|
||||
context.contents as unknown as WebContents,
|
||||
loginFormState({ hasPasswordField: false })
|
||||
)
|
||||
await context.coordinator.showChooser(WINDOW, { x: 10, y: 20 })
|
||||
const template = vi.mocked(Menu.buildFromTemplate).mock.calls.at(-1)?.[0] as Array<{
|
||||
click: () => void
|
||||
@@ -219,27 +383,6 @@ describe('performing a fill', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('sends the password to a shell that never reported whether a field exists', async () => {
|
||||
const context = setup()
|
||||
// Older preloads omit the flag; assuming a password field keeps them working.
|
||||
context.coordinator.noteFormState(context.contents as unknown as WebContents, {
|
||||
origin: ORIGIN,
|
||||
hasLoginForm: true,
|
||||
})
|
||||
await context.coordinator.showChooser(WINDOW, { x: 10, y: 20 })
|
||||
const template = vi.mocked(Menu.buildFromTemplate).mock.calls.at(-1)?.[0] as Array<{
|
||||
click: () => void
|
||||
}>
|
||||
|
||||
template[0].click()
|
||||
await settle()
|
||||
|
||||
expect(context.contents.send).toHaveBeenCalledWith(
|
||||
'browser-credentials:fill',
|
||||
expect.objectContaining({ password: 'hunter2' })
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses after the page navigated between choosing and clicking', async () => {
|
||||
const context = setup()
|
||||
const template = await openChooser(context)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { BrowserCredentialMetadata } from '@sim/desktop-bridge'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { BrowserWindow, WebContents } from 'electron'
|
||||
import { Menu } from 'electron'
|
||||
@@ -16,11 +17,10 @@ const logger = createLogger('BrowserCredentialFill')
|
||||
* generation, and that binding is revalidated immediately before plaintext
|
||||
* leaves the vault, not just when the chooser opened.
|
||||
*
|
||||
* The chooser is a native menu rather than renderer chrome. That is a security
|
||||
* property, not a styling choice: the selection happens in a surface the main
|
||||
* process owns and the page (and the Sim renderer) cannot synthesize, which is
|
||||
* the main-process-controlled confirmation the design calls for. It also means
|
||||
* no credential id has to cross the preload bridge at all.
|
||||
* Renderer-owned chrome receives only password-free metadata. The main
|
||||
* process keeps the corresponding one-shot authorization, and the password
|
||||
* travels only from the vault to the browser page after every binding is
|
||||
* revalidated.
|
||||
*/
|
||||
|
||||
interface FormState {
|
||||
@@ -41,23 +41,31 @@ interface FormState {
|
||||
|
||||
export interface FillCoordinatorDeps {
|
||||
vault: CredentialVault
|
||||
/** The tab the user is actually looking at. */
|
||||
getActiveContents: () => WebContents | null
|
||||
/** The tab the user is actually looking at, optionally constrained to one chat scope. */
|
||||
getActiveContents: (scopeId?: string) => WebContents | null
|
||||
/** Whether a live tab belongs to the renderer-requested chat scope. */
|
||||
scopeOwnsContents: (scopeId: string, contents: WebContents) => boolean
|
||||
/** Push the fill affordance's visibility to the Sim renderer. */
|
||||
onAvailabilityChanged: (available: boolean) => void
|
||||
onAvailabilityChanged: (available: boolean, contents: WebContents | null) => void
|
||||
}
|
||||
|
||||
export interface FormStateReport {
|
||||
origin: string
|
||||
hasLoginForm: boolean
|
||||
/** Absent from shells that predate identifier-first support; assumed true. */
|
||||
hasPasswordField?: boolean
|
||||
hasPasswordField: boolean
|
||||
}
|
||||
|
||||
export class FillCoordinator {
|
||||
private readonly states = new WeakMap<WebContents, FormState>()
|
||||
private readonly generations = new WeakMap<WebContents, number>()
|
||||
private lastAvailability = false
|
||||
private readonly selectionAuthorizations = new WeakMap<
|
||||
WebContents,
|
||||
{ credentialIds: ReadonlySet<string>; generation: number }
|
||||
>()
|
||||
private readonly availabilityRefreshes = new WeakMap<WebContents, number>()
|
||||
private availabilityRefreshWithoutContents = 0
|
||||
private readonly lastAvailability = new WeakMap<WebContents, boolean>()
|
||||
private lastAvailabilityWithoutContents = false
|
||||
|
||||
constructor(private readonly deps: FillCoordinatorDeps) {}
|
||||
|
||||
@@ -71,6 +79,7 @@ export class FillCoordinator {
|
||||
* checked against the live URL before any fill.
|
||||
*/
|
||||
noteFormState(contents: WebContents, report: FormStateReport): void {
|
||||
this.selectionAuthorizations.delete(contents)
|
||||
const origin = normalizeOrigin(report.origin)
|
||||
if (origin === null) {
|
||||
this.states.delete(contents)
|
||||
@@ -78,7 +87,7 @@ export class FillCoordinator {
|
||||
this.states.set(contents, {
|
||||
origin,
|
||||
hasLoginForm: report.hasLoginForm,
|
||||
hasPasswordField: report.hasPasswordField ?? true,
|
||||
hasPasswordField: report.hasPasswordField,
|
||||
generation: this.generationFor(contents),
|
||||
})
|
||||
}
|
||||
@@ -90,21 +99,28 @@ export class FillCoordinator {
|
||||
* navigation, including in-page ones — a single-page app can swap a login
|
||||
* form for a different site's UI without a document load.
|
||||
*/
|
||||
noteNavigation(contents: WebContents): void {
|
||||
noteNavigation(contents: WebContents, sameDocument = false): void {
|
||||
this.generations.set(contents, this.generationFor(contents) + 1)
|
||||
this.states.delete(contents)
|
||||
this.selectionAuthorizations.delete(contents)
|
||||
// A same-document navigation keeps the preload and its report fingerprint
|
||||
// alive. Ask it to report again after clearing main-process state, or an
|
||||
// unchanged login form remains invisible until a full page reload.
|
||||
// Literal channel name: the IPC contract audit resolves constants only on
|
||||
// the preload side, so main-process sends must inline the channel.
|
||||
if (sameDocument && !contents.isDestroyed()) contents.send('browser-credentials:rescan')
|
||||
void this.refreshAvailability()
|
||||
}
|
||||
|
||||
forget(contents: WebContents): void {
|
||||
this.states.delete(contents)
|
||||
this.generations.delete(contents)
|
||||
this.selectionAuthorizations.delete(contents)
|
||||
void this.refreshAvailability()
|
||||
}
|
||||
|
||||
/** Whether the active tab has a login form with at least one saved match. */
|
||||
async isFillAvailable(): Promise<boolean> {
|
||||
const contents = this.deps.getActiveContents()
|
||||
/** Whether one tab has a login form with at least one saved match. */
|
||||
private async isFillAvailableFor(contents: WebContents | null): Promise<boolean> {
|
||||
if (!contents || contents.isDestroyed()) return false
|
||||
const state = this.states.get(contents)
|
||||
if (!state?.hasLoginForm) return false
|
||||
@@ -112,11 +128,88 @@ export class FillCoordinator {
|
||||
return (await this.deps.vault.listForOrigin(state.origin)).length > 0
|
||||
}
|
||||
|
||||
async refreshAvailability(): Promise<void> {
|
||||
const available = await this.isFillAvailable()
|
||||
if (available === this.lastAvailability) return
|
||||
this.lastAvailability = available
|
||||
this.deps.onAvailabilityChanged(available)
|
||||
/** Whether the active tab can currently be filled. */
|
||||
isFillAvailable(): Promise<boolean> {
|
||||
return this.isFillAvailableFor(this.deps.getActiveContents())
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists only password-safe metadata matching the active scoped login form.
|
||||
*
|
||||
* The result also establishes a one-shot selection authorization bound to
|
||||
* the current tab and navigation generation. A renderer-owned menu can then
|
||||
* ask to fill one of these ids without letting a stale menu follow a
|
||||
* navigation into a replacement document.
|
||||
*/
|
||||
async listFillOptions(scopeId?: string): Promise<BrowserCredentialMetadata[]> {
|
||||
const contents = this.deps.getActiveContents(scopeId)
|
||||
if (!contents || contents.isDestroyed()) return []
|
||||
if (scopeId && !this.deps.scopeOwnsContents(scopeId, contents)) return []
|
||||
|
||||
const state = this.states.get(contents)
|
||||
if (!state?.hasLoginForm || !this.deps.vault.isAvailable()) return []
|
||||
const authorizedGeneration = state.generation
|
||||
if (!this.isStillAuthorized(contents, authorizedGeneration, scopeId)) return []
|
||||
if (normalizeOrigin(contents.getURL()) !== state.origin) return []
|
||||
|
||||
const matches = await this.deps.vault.listForOrigin(state.origin)
|
||||
if (!this.isStillAuthorized(contents, authorizedGeneration, scopeId)) return []
|
||||
if (normalizeOrigin(contents.getURL()) !== state.origin) return []
|
||||
|
||||
this.selectionAuthorizations.set(contents, {
|
||||
credentialIds: new Set(matches.map((credential) => credential.id)),
|
||||
generation: authorizedGeneration,
|
||||
})
|
||||
return matches
|
||||
}
|
||||
|
||||
/** Fills one option from the latest scoped list without returning its password. */
|
||||
async fillCredential(credentialId: string, scopeId?: string): Promise<boolean> {
|
||||
const contents = this.deps.getActiveContents(scopeId)
|
||||
if (!contents || contents.isDestroyed()) return false
|
||||
if (scopeId && !this.deps.scopeOwnsContents(scopeId, contents)) return false
|
||||
|
||||
const authorization = this.selectionAuthorizations.get(contents)
|
||||
this.selectionAuthorizations.delete(contents)
|
||||
if (!authorization?.credentialIds.has(credentialId)) return false
|
||||
|
||||
return this.fill(contents, credentialId, authorization.generation, scopeId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes the active tab's current state. Activation forces a replay so a
|
||||
* newly mounted chat receives its value even when the previous chat happened
|
||||
* to have the same boolean availability.
|
||||
*/
|
||||
async refreshAvailability(force = false): Promise<void> {
|
||||
const contents = this.deps.getActiveContents()
|
||||
const refresh = contents
|
||||
? (this.availabilityRefreshes.get(contents) ?? 0) + 1
|
||||
: this.availabilityRefreshWithoutContents + 1
|
||||
if (contents) {
|
||||
this.availabilityRefreshes.set(contents, refresh)
|
||||
} else {
|
||||
this.availabilityRefreshWithoutContents = refresh
|
||||
}
|
||||
const available = await this.isFillAvailableFor(contents)
|
||||
if (this.deps.getActiveContents() !== contents) return
|
||||
if (
|
||||
contents
|
||||
? this.availabilityRefreshes.get(contents) !== refresh
|
||||
: this.availabilityRefreshWithoutContents !== refresh
|
||||
) {
|
||||
return
|
||||
}
|
||||
const previous = contents
|
||||
? this.lastAvailability.get(contents)
|
||||
: this.lastAvailabilityWithoutContents
|
||||
if (!force && available === previous) return
|
||||
if (contents) {
|
||||
this.lastAvailability.set(contents, available)
|
||||
} else {
|
||||
this.lastAvailabilityWithoutContents = available
|
||||
}
|
||||
this.deps.onAvailabilityChanged(available, contents)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,9 +219,14 @@ export class FillCoordinator {
|
||||
* The navigation generation is captured here and carried into the fill, so a
|
||||
* page that moves while the menu is open invalidates the choice.
|
||||
*/
|
||||
async showChooser(window: BrowserWindow, anchor: { x: number; y: number }): Promise<boolean> {
|
||||
const contents = this.deps.getActiveContents()
|
||||
async showChooser(
|
||||
window: BrowserWindow,
|
||||
anchor: { x: number; y: number },
|
||||
scopeId?: string
|
||||
): Promise<boolean> {
|
||||
const contents = this.deps.getActiveContents(scopeId)
|
||||
if (!contents || contents.isDestroyed()) return false
|
||||
if (scopeId && !this.deps.scopeOwnsContents(scopeId, contents)) return false
|
||||
const state = this.states.get(contents)
|
||||
if (!state?.hasLoginForm) return false
|
||||
|
||||
@@ -140,7 +238,7 @@ export class FillCoordinator {
|
||||
matches.map((credential) => ({
|
||||
label: credential.username || '(no username)',
|
||||
click: () => {
|
||||
void this.fill(contents, credential.id, authorizedGeneration).catch(() => {})
|
||||
void this.fill(contents, credential.id, authorizedGeneration, scopeId).catch(() => {})
|
||||
},
|
||||
}))
|
||||
)
|
||||
@@ -158,22 +256,23 @@ export class FillCoordinator {
|
||||
private async fill(
|
||||
contents: WebContents,
|
||||
credentialId: string,
|
||||
authorizedGeneration: number
|
||||
): Promise<void> {
|
||||
if (!this.isStillAuthorized(contents, authorizedGeneration)) return
|
||||
authorizedGeneration: number,
|
||||
scopeId?: string
|
||||
): Promise<boolean> {
|
||||
if (!this.isStillAuthorized(contents, authorizedGeneration, scopeId)) return false
|
||||
const state = this.states.get(contents)
|
||||
if (!state) return
|
||||
if (!state) return false
|
||||
|
||||
// The origin the preload reported must still be the document's real
|
||||
// origin. This is the check that stops a fill following a page that
|
||||
// navigated to another site.
|
||||
if (normalizeOrigin(contents.getURL()) !== state.origin) return
|
||||
if (normalizeOrigin(contents.getURL()) !== state.origin) return false
|
||||
|
||||
const credential = await this.deps.vault.readForFill(credentialId, state.origin)
|
||||
if (credential === null) return
|
||||
if (credential === null) return false
|
||||
|
||||
if (!this.isStillAuthorized(contents, authorizedGeneration)) return
|
||||
if (normalizeOrigin(contents.getURL()) !== state.origin) return
|
||||
if (!this.isStillAuthorized(contents, authorizedGeneration, scopeId)) return false
|
||||
if (normalizeOrigin(contents.getURL()) !== state.origin) return false
|
||||
|
||||
contents.send('browser-credentials:fill', {
|
||||
origin: state.origin,
|
||||
@@ -184,13 +283,19 @@ export class FillCoordinator {
|
||||
})
|
||||
// Counts and outcomes only — never the origin, username, or password.
|
||||
logger.info('Filled a saved credential at the user\u2019s request')
|
||||
return true
|
||||
}
|
||||
|
||||
private isStillAuthorized(contents: WebContents, authorizedGeneration: number): boolean {
|
||||
private isStillAuthorized(
|
||||
contents: WebContents,
|
||||
authorizedGeneration: number,
|
||||
scopeId?: string
|
||||
): boolean {
|
||||
if (contents.isDestroyed()) return false
|
||||
// A fill must land in the tab the user is looking at. Switching tabs
|
||||
// between choosing and filling cancels it.
|
||||
if (this.deps.getActiveContents() !== contents) return false
|
||||
if (this.deps.getActiveContents(scopeId) !== contents) return false
|
||||
if (scopeId && !this.deps.scopeOwnsContents(scopeId, contents)) return false
|
||||
if (this.generationFor(contents) !== authorizedGeneration) return false
|
||||
return this.states.get(contents)?.generation === authorizedGeneration
|
||||
}
|
||||
|
||||
@@ -139,6 +139,67 @@ describe('CredentialVault', () => {
|
||||
expect((await vault.readForFill(id, 'https://example.com'))?.password).toBe('changed')
|
||||
})
|
||||
|
||||
it('serializes overlapping imports so every write sees the previous result', async () => {
|
||||
const vault = new CredentialVault(vaultPath, encryption())
|
||||
const candidates = Array.from({ length: 12 }, (_, index) => ({
|
||||
origin: `https://site-${index}.test`,
|
||||
username: `user-${index}`,
|
||||
password: `password-${index}`,
|
||||
}))
|
||||
|
||||
await Promise.all(
|
||||
candidates.map((candidate) => vault.importCredentials([candidate], 'keep-existing'))
|
||||
)
|
||||
|
||||
expect(await vault.list()).toHaveLength(candidates.length)
|
||||
})
|
||||
|
||||
it('serializes import, delete, and clear mutations in call order', async () => {
|
||||
const vault = new CredentialVault(vaultPath, encryption())
|
||||
await vault.importCredentials(CANDIDATES, 'keep-existing')
|
||||
const deletedId = (await vault.list()).find((entry) => entry.username === 'ada')?.id ?? ''
|
||||
|
||||
const importBeforeDelete = vault.importCredentials(
|
||||
[{ origin: 'https://third.test', username: 'lin', password: 'third' }],
|
||||
'keep-existing'
|
||||
)
|
||||
const deleteAfterImport = vault.delete(deletedId)
|
||||
await Promise.all([importBeforeDelete, deleteAfterImport])
|
||||
|
||||
expect((await vault.list()).map((entry) => entry.username)).toEqual(['grace', 'lin'])
|
||||
|
||||
const importBeforeClear = vault.importCredentials(
|
||||
[{ origin: 'https://fourth.test', username: 'margaret', password: 'fourth' }],
|
||||
'keep-existing'
|
||||
)
|
||||
const clearAfterImport = vault.clear()
|
||||
await Promise.all([importBeforeClear, clearAfterImport])
|
||||
expect(await vault.list()).toEqual([])
|
||||
|
||||
const clearBeforeImport = vault.clear()
|
||||
const importAfterClear = vault.importCredentials(
|
||||
[{ origin: 'https://fifth.test', username: 'katherine', password: 'fifth' }],
|
||||
'keep-existing'
|
||||
)
|
||||
await Promise.all([clearBeforeImport, importAfterClear])
|
||||
expect((await vault.list()).map((entry) => entry.username)).toEqual(['katherine'])
|
||||
})
|
||||
|
||||
it('continues queued mutations after an earlier mutation fails', async () => {
|
||||
const provider = encryption()
|
||||
provider.encryptString.mockImplementationOnce(() => {
|
||||
throw new Error('encryption failed')
|
||||
})
|
||||
const vault = new CredentialVault(vaultPath, provider)
|
||||
|
||||
const failed = vault.importCredentials([CANDIDATES[0]], 'keep-existing')
|
||||
const queued = vault.importCredentials([CANDIDATES[1]], 'keep-existing')
|
||||
|
||||
await expect(failed).rejects.toThrow('encryption failed')
|
||||
await expect(queued).resolves.toEqual({ added: 1, updated: 0, skipped: 0 })
|
||||
expect((await vault.list()).map((entry) => entry.username)).toEqual(['grace'])
|
||||
})
|
||||
|
||||
it('skips candidates with no usable origin or an empty password', async () => {
|
||||
const vault = new CredentialVault(vaultPath, encryption())
|
||||
|
||||
|
||||
@@ -87,12 +87,32 @@ function toMetadata(record: CredentialRecord): BrowserCredentialMetadata {
|
||||
}
|
||||
|
||||
export class CredentialVault {
|
||||
/**
|
||||
* The tail of this instance's mutation queue.
|
||||
*
|
||||
* Vault updates are read-modify-write operations. Atomic file replacement
|
||||
* prevents torn writes, but without serialization two overlapping mutations
|
||||
* can both read the same snapshot and the later rename silently discards the
|
||||
* earlier result. Keeping the tail resolved after failures also ensures one
|
||||
* failed disk write does not permanently poison subsequent mutations.
|
||||
*/
|
||||
private mutationTail: Promise<void> = Promise.resolve()
|
||||
|
||||
constructor(
|
||||
private readonly filePath: string,
|
||||
private readonly encryption: EncryptionProvider = safeStorage,
|
||||
private readonly now: () => Date = () => new Date()
|
||||
) {}
|
||||
|
||||
private serializeMutation<T>(mutation: () => Promise<T>): Promise<T> {
|
||||
const result = this.mutationTail.then(mutation)
|
||||
this.mutationTail = result.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether credentials can be stored at all. The UI must hide or disable
|
||||
* password features when this is false rather than degrading to plaintext.
|
||||
@@ -197,10 +217,12 @@ export class CredentialVault {
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const records = await this.read()
|
||||
const remaining = records.filter((record) => record.id !== id)
|
||||
if (remaining.length === records.length) return false
|
||||
return this.write(remaining)
|
||||
return this.serializeMutation(async () => {
|
||||
const records = await this.read()
|
||||
const remaining = records.filter((record) => record.id !== id)
|
||||
if (remaining.length === records.length) return false
|
||||
return this.write(remaining)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -215,65 +237,67 @@ export class CredentialVault {
|
||||
candidates: ImportCandidate[],
|
||||
policy: ConflictPolicy
|
||||
): Promise<ImportOutcome> {
|
||||
if (!this.isAvailable()) return { added: 0, updated: 0, skipped: candidates.length }
|
||||
return this.serializeMutation(async () => {
|
||||
if (!this.isAvailable()) return { added: 0, updated: 0, skipped: candidates.length }
|
||||
|
||||
const records = await this.read()
|
||||
const byIdentity = new Map(
|
||||
records.map((record) => [`${record.origin}\u0000${record.username}`, record])
|
||||
)
|
||||
const timestamp = this.now().toISOString()
|
||||
const outcome: ImportOutcome = { added: 0, updated: 0, skipped: 0 }
|
||||
let iconsAdded = false
|
||||
const records = await this.read()
|
||||
const byIdentity = new Map(
|
||||
records.map((record) => [`${record.origin}\u0000${record.username}`, record])
|
||||
)
|
||||
const timestamp = this.now().toISOString()
|
||||
const outcome: ImportOutcome = { added: 0, updated: 0, skipped: 0 }
|
||||
let iconsAdded = false
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const origin = normalizeOrigin(candidate.origin)
|
||||
const username = normalizeUsername(candidate.username)
|
||||
if (origin === null || candidate.password.length === 0) {
|
||||
outcome.skipped += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const identity = `${origin}\u0000${username}`
|
||||
const existing = byIdentity.get(identity)
|
||||
if (existing) {
|
||||
if (policy === 'keep-existing' || existing.password === candidate.password) {
|
||||
// A re-import still refreshes a missing icon; that is not a
|
||||
// credential change, so it does not count as an update.
|
||||
if (candidate.icon && !existing.icon) {
|
||||
existing.icon = candidate.icon
|
||||
iconsAdded = true
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
const origin = normalizeOrigin(candidate.origin)
|
||||
const username = normalizeUsername(candidate.username)
|
||||
if (origin === null || candidate.password.length === 0) {
|
||||
outcome.skipped += 1
|
||||
continue
|
||||
}
|
||||
existing.password = candidate.password
|
||||
existing.updatedAt = timestamp
|
||||
existing.source = 'chrome'
|
||||
if (candidate.icon) existing.icon = candidate.icon
|
||||
outcome.updated += 1
|
||||
continue
|
||||
|
||||
const identity = `${origin}\u0000${username}`
|
||||
const existing = byIdentity.get(identity)
|
||||
if (existing) {
|
||||
if (policy === 'keep-existing' || existing.password === candidate.password) {
|
||||
// A re-import still refreshes a missing icon; that is not a
|
||||
// credential change, so it does not count as an update.
|
||||
if (candidate.icon && !existing.icon) {
|
||||
existing.icon = candidate.icon
|
||||
iconsAdded = true
|
||||
}
|
||||
outcome.skipped += 1
|
||||
continue
|
||||
}
|
||||
existing.password = candidate.password
|
||||
existing.updatedAt = timestamp
|
||||
existing.source = 'chrome'
|
||||
if (candidate.icon) existing.icon = candidate.icon
|
||||
outcome.updated += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const record: CredentialRecord = {
|
||||
id: generateId(),
|
||||
origin,
|
||||
username,
|
||||
password: candidate.password,
|
||||
...(candidate.icon ? { icon: candidate.icon } : {}),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
source: 'chrome',
|
||||
}
|
||||
byIdentity.set(identity, record)
|
||||
records.push(record)
|
||||
outcome.added += 1
|
||||
}
|
||||
|
||||
const record: CredentialRecord = {
|
||||
id: generateId(),
|
||||
origin,
|
||||
username,
|
||||
password: candidate.password,
|
||||
...(candidate.icon ? { icon: candidate.icon } : {}),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
source: 'chrome',
|
||||
if (outcome.added === 0 && outcome.updated === 0 && !iconsAdded) return outcome
|
||||
if (!(await this.write(records))) {
|
||||
return { added: 0, updated: 0, skipped: candidates.length }
|
||||
}
|
||||
byIdentity.set(identity, record)
|
||||
records.push(record)
|
||||
outcome.added += 1
|
||||
}
|
||||
|
||||
if (outcome.added === 0 && outcome.updated === 0 && !iconsAdded) return outcome
|
||||
if (!(await this.write(records))) {
|
||||
return { added: 0, updated: 0, skipped: candidates.length }
|
||||
}
|
||||
return outcome
|
||||
return outcome
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -281,6 +305,6 @@ export class CredentialVault {
|
||||
* machine cannot inherit the previous user's passwords.
|
||||
*/
|
||||
async clear(): Promise<void> {
|
||||
await removeFileIfPresent(this.filePath)
|
||||
await this.serializeMutation(() => removeFileIfPresent(this.filePath))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { normalizeOrigin } from '@/main/browser-credentials/origin'
|
||||
import { deriveEncryptionKey } from '@/main/browser-import/chromium-crypto'
|
||||
import { readBrowserPasswords } from '@/main/browser-import/chromium-passwords'
|
||||
|
||||
@@ -28,6 +29,8 @@ interface FixtureLogin {
|
||||
passwordValue?: Uint8Array
|
||||
blacklisted?: number
|
||||
originUrl?: string
|
||||
modifiedAt?: number
|
||||
createdAt?: number
|
||||
}
|
||||
|
||||
let directory: string
|
||||
@@ -40,19 +43,24 @@ afterEach(async () => {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function writeLoginDatabase(logins: FixtureLogin[]): Promise<string> {
|
||||
async function writeLoginDatabase(
|
||||
logins: FixtureLogin[],
|
||||
fileName = 'Login Data'
|
||||
): Promise<string> {
|
||||
const { DatabaseSync } = await import('node:sqlite')
|
||||
const path = join(directory, 'Login Data')
|
||||
const path = join(directory, fileName)
|
||||
const database = new DatabaseSync(path)
|
||||
database.exec(`
|
||||
CREATE TABLE logins (
|
||||
origin_url TEXT NOT NULL, signon_realm TEXT NOT NULL,
|
||||
username_value TEXT NOT NULL, password_value BLOB,
|
||||
blacklisted_by_user INTEGER NOT NULL
|
||||
blacklisted_by_user INTEGER NOT NULL,
|
||||
date_password_modified INTEGER NOT NULL,
|
||||
date_created INTEGER NOT NULL
|
||||
)
|
||||
`)
|
||||
const insert = database.prepare(
|
||||
'INSERT INTO logins (origin_url, signon_realm, username_value, password_value, blacklisted_by_user) VALUES (?, ?, ?, ?, ?)'
|
||||
'INSERT INTO logins (origin_url, signon_realm, username_value, password_value, blacklisted_by_user, date_password_modified, date_created) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
for (const login of logins) {
|
||||
insert.run(
|
||||
@@ -60,7 +68,9 @@ async function writeLoginDatabase(logins: FixtureLogin[]): Promise<string> {
|
||||
login.signonRealm,
|
||||
login.username,
|
||||
login.passwordValue ?? null,
|
||||
login.blacklisted ?? 0
|
||||
login.blacklisted ?? 0,
|
||||
login.modifiedAt ?? 0,
|
||||
login.createdAt ?? 0
|
||||
)
|
||||
}
|
||||
database.close()
|
||||
@@ -81,10 +91,62 @@ describe.skipIf(!sqliteAvailable)('readBrowserPasswords', () => {
|
||||
|
||||
expect(result.rowsSeen).toBe(1)
|
||||
expect(result.credentials).toEqual([
|
||||
{ origin: 'https://example.com/', username: 'ada', password: 'hunter2' },
|
||||
{
|
||||
origin: 'https://example.com/',
|
||||
username: 'ada',
|
||||
password: 'hunter2',
|
||||
sourceModifiedAt: 0n,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('reads the account-scoped password database with the same protections', async () => {
|
||||
const path = await writeLoginDatabase(
|
||||
[
|
||||
{
|
||||
signonRealm: 'https://accounts.example/',
|
||||
username: 'ada',
|
||||
passwordValue: encryptV10('account-password'),
|
||||
},
|
||||
],
|
||||
'Login Data For Account'
|
||||
)
|
||||
|
||||
await expect(readBrowserPasswords(path, KEY)).resolves.toMatchObject({
|
||||
credentials: [
|
||||
{
|
||||
origin: 'https://accounts.example/',
|
||||
username: 'ada',
|
||||
password: 'account-password',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('carries the best available Chromium modification timestamp', async () => {
|
||||
const path = await writeLoginDatabase([
|
||||
{
|
||||
signonRealm: 'https://modified.test/',
|
||||
username: 'ada',
|
||||
passwordValue: encryptV10('newer'),
|
||||
modifiedAt: 20,
|
||||
createdAt: 10,
|
||||
},
|
||||
{
|
||||
signonRealm: 'https://created.test/',
|
||||
username: 'grace',
|
||||
passwordValue: encryptV10('older'),
|
||||
createdAt: 15,
|
||||
},
|
||||
])
|
||||
|
||||
expect(
|
||||
(await readBrowserPasswords(path, KEY)).credentials.map(
|
||||
({ sourceModifiedAt }) => sourceModifiedAt
|
||||
)
|
||||
).toEqual([20n, 15n])
|
||||
})
|
||||
|
||||
it('does not strip a prefix from password plaintext', async () => {
|
||||
// Unlike cookies, saved passwords carry no domain-bound prefix. Removing
|
||||
// 32 bytes here would silently corrupt every password.
|
||||
@@ -138,6 +200,36 @@ describe.skipIf(!sqliteAvailable)('readBrowserPasswords', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('binds a password to signon_realm when origin_url names another site', async () => {
|
||||
const path = await writeLoginDatabase([
|
||||
{
|
||||
signonRealm: 'https://accounts.google.com/',
|
||||
originUrl: 'https://evil.test/login',
|
||||
username: 'ada',
|
||||
passwordValue: encryptV10('p'),
|
||||
},
|
||||
])
|
||||
|
||||
expect((await readBrowserPasswords(path, KEY)).credentials[0].origin).toBe(
|
||||
'https://accounts.google.com/'
|
||||
)
|
||||
})
|
||||
|
||||
it('never falls back from a non-web realm to a web origin', async () => {
|
||||
const path = await writeLoginDatabase([
|
||||
{
|
||||
signonRealm: 'android://token@com.example/',
|
||||
originUrl: 'https://evil.test/login',
|
||||
username: 'ada',
|
||||
passwordValue: encryptV10('p'),
|
||||
},
|
||||
])
|
||||
|
||||
const [credential] = (await readBrowserPasswords(path, KEY)).credentials
|
||||
expect(credential.origin).toBe('android://token@com.example/')
|
||||
expect(normalizeOrigin(credential.origin)).toBeNull()
|
||||
})
|
||||
|
||||
it('reports an unrecognised schema rather than guessing', async () => {
|
||||
const { DatabaseSync } = await import('node:sqlite')
|
||||
const path = join(directory, 'Login Data')
|
||||
|
||||
@@ -18,13 +18,19 @@ import { toNumber, toText } from '@/main/browser-import/types'
|
||||
const MAX_LOGIN_ROWS = 20_000
|
||||
|
||||
const LOGIN_QUERY = `
|
||||
SELECT signon_realm, origin_url, username_value, password_value, blacklisted_by_user
|
||||
SELECT signon_realm, origin_url, username_value, password_value, blacklisted_by_user,
|
||||
date_password_modified, date_created
|
||||
FROM logins
|
||||
LIMIT ${MAX_LOGIN_ROWS}
|
||||
`
|
||||
|
||||
export interface BrowserPasswordCandidate extends ImportCandidate {
|
||||
/** Chromium timestamp used only to resolve local/account store conflicts. */
|
||||
sourceModifiedAt?: bigint
|
||||
}
|
||||
|
||||
export interface ReadPasswordsResult {
|
||||
credentials: ImportCandidate[]
|
||||
credentials: BrowserPasswordCandidate[]
|
||||
skipped: number
|
||||
/** Rows examined, so the caller can tell "no passwords" from "none decrypted". */
|
||||
rowsSeen: number
|
||||
@@ -35,7 +41,7 @@ export async function readBrowserPasswords(
|
||||
key: Buffer
|
||||
): Promise<ReadPasswordsResult> {
|
||||
const rows = await queryBrowserDatabase(loginDataPath, 'Login Data', LOGIN_QUERY)
|
||||
const credentials: ImportCandidate[] = []
|
||||
const credentials: BrowserPasswordCandidate[] = []
|
||||
let skipped = 0
|
||||
|
||||
for (const raw of rows) {
|
||||
@@ -63,8 +69,23 @@ export async function readBrowserPasswords(
|
||||
// realms do not reduce to an http(s) origin and are dropped by the vault's
|
||||
// own normalization rather than being coerced into one here.
|
||||
const origin = toText(raw.signon_realm) || toText(raw.origin_url)
|
||||
credentials.push({ origin, username: toText(raw.username_value), password })
|
||||
const modifiedAt = chromiumTimestamp(raw.date_password_modified)
|
||||
const createdAt = chromiumTimestamp(raw.date_created)
|
||||
credentials.push({
|
||||
origin,
|
||||
username: toText(raw.username_value),
|
||||
password,
|
||||
sourceModifiedAt: modifiedAt > 0n ? modifiedAt : createdAt,
|
||||
})
|
||||
}
|
||||
|
||||
return { credentials, skipped, rowsSeen: rows.length }
|
||||
}
|
||||
|
||||
function chromiumTimestamp(value: unknown): bigint {
|
||||
if (typeof value === 'bigint') return value > 0n ? value : 0n
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||
return BigInt(Math.trunc(value))
|
||||
}
|
||||
return 0n
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { link, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
@@ -85,22 +85,33 @@ describe('listBrowserProfiles', () => {
|
||||
expect(profile.cookiesPath).toBe(join(userDataDirFor(CHROME, home), 'Default/Cookies'))
|
||||
})
|
||||
|
||||
it('finds the saved-password database alongside the cookies', async () => {
|
||||
it('finds every saved-password database alongside the cookies', async () => {
|
||||
await addProfile(CHROME, 'Default')
|
||||
await addProfile(CHROME, 'Default', 'Login Data For Account')
|
||||
await addProfile(CHROME, 'Default', 'Login Data')
|
||||
|
||||
const [profile] = await listBrowserProfiles(CHROME, home)
|
||||
expect(profile.loginDataPath).toBe(join(userDataDirFor(CHROME, home), 'Default/Login Data'))
|
||||
expect(profile.loginDataPaths).toEqual([
|
||||
join(userDataDirFor(CHROME, home), 'Default/Login Data'),
|
||||
join(userDataDirFor(CHROME, home), 'Default/Login Data For Account'),
|
||||
])
|
||||
})
|
||||
|
||||
it('lists a profile that has only saved passwords', async () => {
|
||||
// Passwords and cookies import independently, so a profile with one and
|
||||
// not the other must still be offered.
|
||||
await addProfile(CHROME, 'Default', 'Login Data')
|
||||
it.each(['Login Data', 'Login Data For Account'])(
|
||||
'lists a profile that has only the %s password database',
|
||||
async (databaseName) => {
|
||||
// Passwords and cookies import independently, so a profile with one and
|
||||
// not the other must still be offered.
|
||||
await addProfile(CHROME, 'Default', databaseName)
|
||||
|
||||
const [profile] = await listBrowserProfiles(CHROME, home)
|
||||
expect(profile).toMatchObject({ id: 'chrome:Default', cookiesPath: null })
|
||||
})
|
||||
const [profile] = await listBrowserProfiles(CHROME, home)
|
||||
expect(profile).toMatchObject({
|
||||
id: 'chrome:Default',
|
||||
cookiesPath: null,
|
||||
loginDataPaths: [join(userDataDirFor(CHROME, home), 'Default', databaseName)],
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('omits profiles with no readable database', async () => {
|
||||
await mkdir(join(userDataDirFor(CHROME, home), 'Profile 3'), { recursive: true })
|
||||
@@ -133,6 +144,33 @@ describe('listBrowserProfiles', () => {
|
||||
expect(profiles.map(({ id }) => id)).toEqual(['chrome:Default'])
|
||||
})
|
||||
|
||||
it('refuses a profile directory redirected through a symlink', async () => {
|
||||
const outsideProfile = join(home, 'outside-profile')
|
||||
await mkdir(outsideProfile, { recursive: true })
|
||||
await writeFile(join(outsideProfile, 'Login Data'), '')
|
||||
const userDataDir = userDataDirFor(CHROME, home)
|
||||
await mkdir(userDataDir, { recursive: true })
|
||||
await symlink(outsideProfile, join(userDataDir, 'Default'))
|
||||
await writeLocalState(CHROME, { Default: { name: 'Person 1' } })
|
||||
|
||||
await expect(listBrowserProfiles(CHROME, home)).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('refuses symlinked, hard-linked, and non-file password databases', async () => {
|
||||
const outsideDatabase = join(home, 'outside-login-data')
|
||||
await writeFile(outsideDatabase, '')
|
||||
|
||||
const userDataDir = userDataDirFor(CHROME, home)
|
||||
for (const directory of ['Default', 'Profile 2', 'Profile 3']) {
|
||||
await mkdir(join(userDataDir, directory), { recursive: true })
|
||||
}
|
||||
await symlink(outsideDatabase, join(userDataDir, 'Default', 'Login Data For Account'))
|
||||
await link(outsideDatabase, join(userDataDir, 'Profile 2', 'Login Data For Account'))
|
||||
await mkdir(join(userDataDir, 'Profile 3', 'Login Data For Account'))
|
||||
|
||||
await expect(listBrowserProfiles(CHROME, home)).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('falls back to directory names when Local State is unreadable', async () => {
|
||||
await addProfile(CHROME, 'Default')
|
||||
await writeFile(join(userDataDirFor(CHROME, home), 'Local State'), 'not json{{')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { constants } from 'node:fs'
|
||||
import { access, readdir, readFile } from 'node:fs/promises'
|
||||
import { access, lstat, readdir, readFile, realpath } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
@@ -20,8 +20,8 @@ import type { BrowserProfile } from '@/main/browser-import/types'
|
||||
|
||||
/** Chromium moved the cookie database under `Network/` in M96. */
|
||||
const COOKIE_DB_RELATIVE_PATHS = [join('Network', 'Cookies'), 'Cookies']
|
||||
/** Saved passwords stayed at the profile root across that move. */
|
||||
const LOGIN_DB_RELATIVE_PATHS = ['Login Data']
|
||||
/** Saved passwords stay at the profile root and may span local and account stores. */
|
||||
const LOGIN_DB_RELATIVE_PATHS = ['Login Data', 'Login Data For Account']
|
||||
/** Site icons, used to give saved passwords a recognisable face. */
|
||||
const FAVICON_DB_RELATIVE_PATHS = ['Favicons']
|
||||
/** Page titles, read only to learn what the imported sites are called. */
|
||||
@@ -56,26 +56,71 @@ function isGenericProfileName(name: string, directory: string, browserLabel: str
|
||||
)
|
||||
}
|
||||
|
||||
async function isReadableFile(path: string): Promise<boolean> {
|
||||
async function isSafeProfileDirectory(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path, constants.R_OK)
|
||||
return true
|
||||
const info = await lstat(path)
|
||||
return info.isDirectory() && !info.isSymbolicLink()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves one fixed database name without following profile/database links.
|
||||
*
|
||||
* Browser profile discovery is read-only, but following a crafted symlink
|
||||
* here could make selecting one profile import another profile's passwords.
|
||||
* Canonical equality also rejects a symlink in an intermediate directory such
|
||||
* as `Network/`. Multi-link files are refused for the same cross-profile
|
||||
* ambiguity; Chromium creates these databases as ordinary single-link files.
|
||||
*/
|
||||
async function readableDatabasePath(
|
||||
profileDir: string,
|
||||
relativePath: string
|
||||
): Promise<string | null> {
|
||||
const candidate = join(profileDir, relativePath)
|
||||
try {
|
||||
const [profileInfo, candidateInfo, canonicalProfile, canonicalCandidate] = await Promise.all([
|
||||
lstat(profileDir),
|
||||
lstat(candidate),
|
||||
realpath(profileDir),
|
||||
realpath(candidate),
|
||||
])
|
||||
if (!profileInfo.isDirectory() || profileInfo.isSymbolicLink()) return null
|
||||
if (!candidateInfo.isFile() || candidateInfo.isSymbolicLink() || candidateInfo.nlink !== 1) {
|
||||
return null
|
||||
}
|
||||
if (canonicalCandidate !== join(canonicalProfile, relativePath)) return null
|
||||
await access(candidate, constants.R_OK)
|
||||
return candidate
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveFirstReadable(
|
||||
profileDir: string,
|
||||
relativePaths: readonly string[]
|
||||
): Promise<string | null> {
|
||||
for (const relative of relativePaths) {
|
||||
const candidate = join(profileDir, relative)
|
||||
if (await isReadableFile(candidate)) return candidate
|
||||
const candidate = await readableDatabasePath(profileDir, relative)
|
||||
if (candidate) return candidate
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function resolveReadableFiles(
|
||||
profileDir: string,
|
||||
relativePaths: readonly string[]
|
||||
): Promise<string[]> {
|
||||
const paths: string[] = []
|
||||
for (const relative of relativePaths) {
|
||||
const candidate = await readableDatabasePath(profileDir, relative)
|
||||
if (candidate) paths.push(candidate)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
/**
|
||||
* A browser's display names, keyed by profile directory.
|
||||
*
|
||||
@@ -140,11 +185,12 @@ export async function listBrowserProfiles(
|
||||
const name = displayNames.get(dir) ?? dir
|
||||
if (isInternalProfileName(name)) continue
|
||||
const profileDir = join(userDataDir, dir)
|
||||
if (!(await isSafeProfileDirectory(profileDir))) continue
|
||||
const cookiesPath = await resolveFirstReadable(profileDir, COOKIE_DB_RELATIVE_PATHS)
|
||||
const loginDataPath = await resolveFirstReadable(profileDir, LOGIN_DB_RELATIVE_PATHS)
|
||||
const loginDataPaths = await resolveReadableFiles(profileDir, LOGIN_DB_RELATIVE_PATHS)
|
||||
const faviconsPath = await resolveFirstReadable(profileDir, FAVICON_DB_RELATIVE_PATHS)
|
||||
const historyPath = await resolveFirstReadable(profileDir, HISTORY_DB_RELATIVE_PATHS)
|
||||
if (cookiesPath === null && loginDataPath === null) continue
|
||||
if (cookiesPath === null && loginDataPaths.length === 0) continue
|
||||
profiles.push({
|
||||
id: formatProfileId(source.id, dir),
|
||||
directory: dir,
|
||||
@@ -153,7 +199,7 @@ export async function listBrowserProfiles(
|
||||
label: isGenericProfileName(name, dir, source.label) ? '' : name,
|
||||
source,
|
||||
cookiesPath,
|
||||
loginDataPath,
|
||||
loginDataPaths,
|
||||
faviconsPath,
|
||||
historyPath,
|
||||
})
|
||||
|
||||
@@ -29,7 +29,7 @@ const PROFILES: BrowserProfile[] = [
|
||||
label: 'Person 1',
|
||||
source: CHROME,
|
||||
cookiesPath: '/chrome/Default/Cookies',
|
||||
loginDataPath: '/chrome/Default/Login Data',
|
||||
loginDataPaths: ['/chrome/Default/Login Data'],
|
||||
faviconsPath: '/chrome/Default/Favicons',
|
||||
historyPath: '/chrome/Default/History',
|
||||
},
|
||||
@@ -39,7 +39,7 @@ const PROFILES: BrowserProfile[] = [
|
||||
label: 'Work',
|
||||
source: ARC,
|
||||
cookiesPath: '/arc/Profile 2/Cookies',
|
||||
loginDataPath: '/arc/Profile 2/Login Data',
|
||||
loginDataPaths: ['/arc/Profile 2/Login Data'],
|
||||
faviconsPath: '/arc/Profile 2/Favicons',
|
||||
historyPath: '/arc/Profile 2/History',
|
||||
},
|
||||
@@ -101,7 +101,7 @@ describe('toDisplayProfiles', () => {
|
||||
label,
|
||||
source,
|
||||
cookiesPath: '/cookies',
|
||||
loginDataPath: null,
|
||||
loginDataPaths: [],
|
||||
faviconsPath: null,
|
||||
historyPath: null,
|
||||
}
|
||||
@@ -334,7 +334,7 @@ describe('importChromeCookies', () => {
|
||||
label: 'Person 1',
|
||||
source: CHROME,
|
||||
cookiesPath: null,
|
||||
loginDataPath: '/chrome/Login Data',
|
||||
loginDataPaths: ['/chrome/Login Data'],
|
||||
faviconsPath: null,
|
||||
historyPath: null,
|
||||
},
|
||||
@@ -440,6 +440,247 @@ describe('importChromePasswords', () => {
|
||||
expect(readPasswordsSpy).toHaveBeenCalledWith('/arc/Profile 2/Login Data', expect.any(Buffer))
|
||||
})
|
||||
|
||||
it('merges local and account-scoped password stores in source order', async () => {
|
||||
const localPath = '/arc/Default/Login Data'
|
||||
const accountPath = '/arc/Default/Login Data For Account'
|
||||
const importCredentials = vi.fn(async (candidates) => ({
|
||||
added: candidates.length,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
}))
|
||||
const readPasswordsSpy = vi.fn(async (path: string) =>
|
||||
path === localPath
|
||||
? readPasswords({
|
||||
credentials: [{ origin: 'https://local.example', username: 'ada', password: 'local' }],
|
||||
skipped: 1,
|
||||
rowsSeen: 2,
|
||||
})
|
||||
: readPasswords({
|
||||
credentials: [
|
||||
{ origin: 'https://account.example', username: 'grace', password: 'account' },
|
||||
],
|
||||
skipped: 2,
|
||||
rowsSeen: 3,
|
||||
})
|
||||
)
|
||||
const deps = createDeps({
|
||||
listProfiles: async () => [
|
||||
{ ...PROFILES[1], id: 'arc:Default', loginDataPaths: [localPath, accountPath] },
|
||||
],
|
||||
readPasswords: readPasswordsSpy,
|
||||
vault: { isAvailable: () => true, importCredentials },
|
||||
})
|
||||
|
||||
await expect(importChromePasswords('arc:Default', 'replace', deps)).resolves.toEqual({
|
||||
passwordsAdded: 2,
|
||||
passwordsUpdated: 0,
|
||||
passwordsSkipped: 3,
|
||||
})
|
||||
expect(readPasswordsSpy.mock.calls.map(([path]) => path)).toEqual([localPath, accountPath])
|
||||
expect(importCredentials).toHaveBeenCalledWith(
|
||||
[
|
||||
expect.objectContaining({ origin: 'https://local.example' }),
|
||||
expect.objectContaining({ origin: 'https://account.example' }),
|
||||
],
|
||||
'replace'
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves cross-store identity collisions before applying the vault policy', async () => {
|
||||
const localPath = '/arc/Default/Login Data'
|
||||
const accountPath = '/arc/Default/Login Data For Account'
|
||||
const importCredentials = vi.fn(async (candidates) => ({
|
||||
added: candidates.length,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
}))
|
||||
const deps = createDeps({
|
||||
listProfiles: async () => [
|
||||
{ ...PROFILES[1], id: 'arc:Default', loginDataPaths: [localPath, accountPath] },
|
||||
],
|
||||
readPasswords: async (path) =>
|
||||
readPasswords({
|
||||
credentials: [
|
||||
{
|
||||
origin:
|
||||
path === accountPath
|
||||
? 'https://accounts.example/login'
|
||||
: 'https://ACCOUNTS.example/old',
|
||||
username: path === accountPath ? 'ada' : ' ada ',
|
||||
password: path === accountPath ? 'account-copy' : 'local-copy',
|
||||
},
|
||||
],
|
||||
}),
|
||||
vault: { isAvailable: () => true, importCredentials },
|
||||
})
|
||||
|
||||
await expect(importChromePasswords('arc:Default', 'keep-existing', deps)).resolves.toEqual({
|
||||
passwordsAdded: 1,
|
||||
passwordsUpdated: 0,
|
||||
passwordsSkipped: 1,
|
||||
})
|
||||
expect(importCredentials).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ password: 'account-copy' })],
|
||||
'keep-existing'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a newer local password over an older account-store copy', async () => {
|
||||
const localPath = '/arc/Default/Login Data'
|
||||
const accountPath = '/arc/Default/Login Data For Account'
|
||||
const importCredentials = vi.fn(async (candidates) => ({
|
||||
added: candidates.length,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
}))
|
||||
const deps = createDeps({
|
||||
listProfiles: async () => [
|
||||
{ ...PROFILES[1], id: 'arc:Default', loginDataPaths: [localPath, accountPath] },
|
||||
],
|
||||
readPasswords: async (path) =>
|
||||
readPasswords({
|
||||
credentials: [
|
||||
{
|
||||
origin: 'https://accounts.example',
|
||||
username: 'ada',
|
||||
password: path === localPath ? 'new-local' : 'stale-account',
|
||||
sourceModifiedAt: path === localPath ? 20n : 10n,
|
||||
},
|
||||
],
|
||||
}),
|
||||
vault: { isAvailable: () => true, importCredentials },
|
||||
})
|
||||
|
||||
await expect(importChromePasswords('arc:Default', 'replace', deps)).resolves.toMatchObject({
|
||||
passwordsAdded: 1,
|
||||
passwordsSkipped: 1,
|
||||
})
|
||||
expect(importCredentials).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ password: 'new-local' })],
|
||||
'replace'
|
||||
)
|
||||
})
|
||||
|
||||
it('collapses exact duplicates shared by both password stores', async () => {
|
||||
const importCredentials = vi.fn(async (candidates) => ({
|
||||
added: candidates.length,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
}))
|
||||
const deps = createDeps({
|
||||
listProfiles: async () => [
|
||||
{
|
||||
...PROFILES[1],
|
||||
id: 'arc:Default',
|
||||
loginDataPaths: ['/arc/Default/Login Data', '/arc/Default/Login Data For Account'],
|
||||
},
|
||||
],
|
||||
readPasswords: async () =>
|
||||
readPasswords({
|
||||
credentials: [{ origin: 'https://example.com', username: 'ada', password: 'same' }],
|
||||
}),
|
||||
vault: { isAvailable: () => true, importCredentials },
|
||||
})
|
||||
|
||||
await expect(importChromePasswords('arc:Default', 'replace', deps)).resolves.toEqual({
|
||||
passwordsAdded: 1,
|
||||
passwordsUpdated: 0,
|
||||
passwordsSkipped: 1,
|
||||
})
|
||||
expect(importCredentials).toHaveBeenCalledWith([expect.any(Object)], 'replace')
|
||||
})
|
||||
|
||||
it('keeps credentials from one password store when the other is unreadable', async () => {
|
||||
const localPath = '/arc/Default/Login Data'
|
||||
const accountPath = '/arc/Default/Login Data For Account'
|
||||
const deps = createDeps({
|
||||
listProfiles: async () => [
|
||||
{ ...PROFILES[1], id: 'arc:Default', loginDataPaths: [localPath, accountPath] },
|
||||
],
|
||||
readPasswords: async (path) => {
|
||||
if (path === accountPath) {
|
||||
throw new ImportFailure('unsupported-schema', 'unknown account-store schema')
|
||||
}
|
||||
return readPasswords()
|
||||
},
|
||||
})
|
||||
|
||||
await expect(importChromePasswords('arc:Default', 'replace', deps)).resolves.toMatchObject({
|
||||
passwordsAdded: 1,
|
||||
passwordsSkipped: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces a failed password store when the other store only has unreadable rows', async () => {
|
||||
const localPath = '/arc/Default/Login Data'
|
||||
const accountPath = '/arc/Default/Login Data For Account'
|
||||
const deps = createDeps({
|
||||
listProfiles: async () => [
|
||||
{ ...PROFILES[1], id: 'arc:Default', loginDataPaths: [localPath, accountPath] },
|
||||
],
|
||||
readPasswords: async (path) => {
|
||||
if (path === accountPath) {
|
||||
throw new ImportFailure('unsupported-schema', 'unknown account-store schema')
|
||||
}
|
||||
return readPasswords({ credentials: [], skipped: 7, rowsSeen: 7 })
|
||||
},
|
||||
})
|
||||
|
||||
await expect(importChromePasswords('arc:Default', 'replace', deps)).resolves.toMatchObject({
|
||||
passwordsAdded: 0,
|
||||
error: 'unsupported-schema',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not let a non-web credential hide another store failure', async () => {
|
||||
const localPath = '/arc/Default/Login Data'
|
||||
const accountPath = '/arc/Default/Login Data For Account'
|
||||
const importCredentials = vi.fn()
|
||||
const deps = createDeps({
|
||||
listProfiles: async () => [
|
||||
{ ...PROFILES[1], id: 'arc:Default', loginDataPaths: [localPath, accountPath] },
|
||||
],
|
||||
readPasswords: async (path) => {
|
||||
if (path === accountPath) {
|
||||
throw new ImportFailure('unsupported-schema', 'unknown account-store schema')
|
||||
}
|
||||
return readPasswords({
|
||||
credentials: [
|
||||
{ origin: 'android://token@com.example/', username: 'ada', password: 'value' },
|
||||
],
|
||||
})
|
||||
},
|
||||
vault: { isAvailable: () => true, importCredentials },
|
||||
})
|
||||
|
||||
await expect(importChromePasswords('arc:Default', 'replace', deps)).resolves.toMatchObject({
|
||||
passwordsAdded: 0,
|
||||
error: 'unsupported-schema',
|
||||
})
|
||||
expect(importCredentials).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports the first reader failure when neither password store can be read', async () => {
|
||||
const localPath = '/arc/Default/Login Data'
|
||||
const accountPath = '/arc/Default/Login Data For Account'
|
||||
const deps = createDeps({
|
||||
listProfiles: async () => [
|
||||
{ ...PROFILES[1], id: 'arc:Default', loginDataPaths: [localPath, accountPath] },
|
||||
],
|
||||
readPasswords: async (path) => {
|
||||
throw new ImportFailure(
|
||||
path === localPath ? 'profile-unreadable' : 'unsupported-schema',
|
||||
'unreadable password store'
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await expect(importChromePasswords('arc:Default', 'replace', deps)).resolves.toMatchObject({
|
||||
passwordsAdded: 0,
|
||||
error: 'profile-unreadable',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the chosen browser\u2019s own Keychain item', async () => {
|
||||
// Reading Chrome's item to import Arc would prompt the user about the
|
||||
// wrong browser — and would derive a key that cannot decrypt anything.
|
||||
@@ -502,6 +743,30 @@ describe('importChromePasswords', () => {
|
||||
expect(observed?.every((byte) => byte === 0)).toBe(true)
|
||||
})
|
||||
|
||||
it('uses one derived key for both stores and zeroes it after the full import', async () => {
|
||||
const observed: Buffer[] = []
|
||||
const localPath = '/arc/Default/Login Data'
|
||||
const accountPath = '/arc/Default/Login Data For Account'
|
||||
await importChromePasswords(
|
||||
'arc:Default',
|
||||
'keep-existing',
|
||||
createDeps({
|
||||
listProfiles: async () => [
|
||||
{ ...PROFILES[1], id: 'arc:Default', loginDataPaths: [localPath, accountPath] },
|
||||
],
|
||||
readPasswords: async (_path, key) => {
|
||||
observed.push(key)
|
||||
expect(key.some((byte) => byte !== 0)).toBe(true)
|
||||
return readPasswords()
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
expect(observed).toHaveLength(2)
|
||||
expect(observed[0]).toBe(observed[1])
|
||||
expect(observed[0].every((byte) => byte === 0)).toBe(true)
|
||||
})
|
||||
|
||||
it('reports rows that all failed to decrypt as an import failure', async () => {
|
||||
const deps = createDeps({
|
||||
readPasswords: async () => readPasswords({ credentials: [], skipped: 7, rowsSeen: 7 }),
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
BrowserPasswordImportResult,
|
||||
} from '@sim/desktop-bridge'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { normalizeOrigin } from '@/main/browser-credentials/origin'
|
||||
import { normalizeOrigin, normalizeUsername } from '@/main/browser-credentials/origin'
|
||||
import type { ImportCandidate, ImportOutcome } from '@/main/browser-credentials/vault'
|
||||
import type { ReadCookiesResult } from '@/main/browser-import/chromium-cookies'
|
||||
import { deriveEncryptionKey } from '@/main/browser-import/chromium-crypto'
|
||||
@@ -269,11 +269,11 @@ async function runPasswordImport(
|
||||
policy: BrowserCredentialConflictPolicy,
|
||||
deps: ImportServiceDeps
|
||||
): Promise<PasswordOutcome> {
|
||||
if (profile.loginDataPath === null) {
|
||||
if (profile.loginDataPaths.length === 0) {
|
||||
return { result: passwordFailure('profile-unreadable'), domains: new Set() }
|
||||
}
|
||||
|
||||
const read: ReadPasswordsResult = await deps.readPasswords(profile.loginDataPath, key)
|
||||
const read = await readProfilePasswords(profile.loginDataPaths, key, deps)
|
||||
if (read.credentials.length === 0) {
|
||||
return {
|
||||
result:
|
||||
@@ -289,8 +289,13 @@ async function runPasswordImport(
|
||||
}
|
||||
}
|
||||
|
||||
// Source timestamps are conflict-resolution metadata, not credential data;
|
||||
// strip them before anything reaches the encrypted vault.
|
||||
const candidates = read.credentials.map(
|
||||
({ sourceModifiedAt: _sourceModifiedAt, ...candidate }) => candidate
|
||||
)
|
||||
const outcome = await deps.vault.importCredentials(
|
||||
await withFavicons(read.credentials, profile.faviconsPath, deps),
|
||||
await withFavicons(candidates, profile.faviconsPath, deps),
|
||||
policy
|
||||
)
|
||||
const result: BrowserPasswordImportResult = {
|
||||
@@ -303,7 +308,79 @@ async function runPasswordImport(
|
||||
updated: result.passwordsUpdated,
|
||||
skipped: result.passwordsSkipped,
|
||||
})
|
||||
return { result, domains: credentialHostnames(read.credentials) }
|
||||
return { result, domains: credentialHostnames(candidates) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads every Chromium password store belonging to one profile.
|
||||
*
|
||||
* Modern Chromium profiles can split credentials between the device-local
|
||||
* `Login Data` database and the signed-in account's `Login Data For Account`
|
||||
* database. Discovery returns the local store first and the account store
|
||||
* second. A shared identity resolves to the most recently modified row;
|
||||
* source order breaks ties deterministically before applying the vault policy.
|
||||
*
|
||||
* One damaged store does not discard credentials already read from the other.
|
||||
* If no store can produce any useful signal, the first concrete reader error
|
||||
* is surfaced instead of reporting a misleading successful import of zero.
|
||||
*/
|
||||
async function readProfilePasswords(
|
||||
paths: readonly string[],
|
||||
key: Buffer,
|
||||
deps: ImportServiceDeps
|
||||
): Promise<ReadPasswordsResult> {
|
||||
const combined: ReadPasswordsResult = { credentials: [], skipped: 0, rowsSeen: 0 }
|
||||
const credentialIndexes = new Map<string, number>()
|
||||
let successfulReads = 0
|
||||
let firstFailure: unknown
|
||||
|
||||
for (const path of paths) {
|
||||
try {
|
||||
const read = await deps.readPasswords(path, key)
|
||||
successfulReads += 1
|
||||
combined.skipped += read.skipped
|
||||
combined.rowsSeen += read.rowsSeen
|
||||
for (const credential of read.credentials) {
|
||||
const origin = normalizeOrigin(credential.origin)
|
||||
if (origin === null) {
|
||||
// Keep invalid candidates for the vault to reject and count using
|
||||
// its canonical validation path.
|
||||
combined.credentials.push(credential)
|
||||
continue
|
||||
}
|
||||
const identity = `${origin}\u0000${normalizeUsername(credential.username)}`
|
||||
const existingIndex = credentialIndexes.get(identity)
|
||||
if (existingIndex === undefined) {
|
||||
credentialIndexes.set(identity, combined.credentials.length)
|
||||
combined.credentials.push(credential)
|
||||
continue
|
||||
}
|
||||
|
||||
// Sim can hold only one password per origin+username. Prefer the most
|
||||
// recently modified Chromium row before applying the user's policy
|
||||
// against the Sim vault; account-store order breaks timestamp ties.
|
||||
const existing = combined.credentials[existingIndex]
|
||||
if ((credential.sourceModifiedAt ?? 0n) >= (existing.sourceModifiedAt ?? 0n)) {
|
||||
combined.credentials[existingIndex] = credential
|
||||
}
|
||||
combined.skipped += 1
|
||||
}
|
||||
} catch (error) {
|
||||
firstFailure ??= error
|
||||
}
|
||||
}
|
||||
|
||||
const hasImportableCredential = combined.credentials.some(
|
||||
(credential) => normalizeOrigin(credential.origin) !== null && credential.password.length > 0
|
||||
)
|
||||
if (firstFailure !== undefined && (successfulReads === 0 || !hasImportableCredential)) {
|
||||
throw firstFailure
|
||||
}
|
||||
if (firstFailure !== undefined) {
|
||||
// Category only: database names and paths are deliberately absent.
|
||||
logger.warn('Could not read every password store in the selected browser profile')
|
||||
}
|
||||
return combined
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,8 +14,8 @@ import { ImportFailure } from '@/main/browser-import/types'
|
||||
* once rather than reimplemented per database.
|
||||
*/
|
||||
|
||||
/** SQLite keeps recent writes beside the main file; both are needed for a faithful copy. */
|
||||
const SQLITE_SIDECAR_SUFFIXES = ['-wal', '-shm']
|
||||
/** SQLite keeps recent writes beside the main file; sidecars are needed for a faithful copy. */
|
||||
const SQLITE_SIDECAR_SUFFIXES = ['-wal', '-shm', '-journal']
|
||||
|
||||
async function queryCopy(databasePath: string, query: string): Promise<Record<string, unknown>[]> {
|
||||
// Imported lazily: `node:sqlite` is only needed on the import path, and this
|
||||
@@ -66,7 +66,7 @@ export async function queryBrowserDatabase(
|
||||
throw new ImportFailure('profile-unreadable', 'Could not read the Chrome database.')
|
||||
}
|
||||
for (const suffix of SQLITE_SIDECAR_SUFFIXES) {
|
||||
// Absent sidecars are normal: they only exist while a WAL is live.
|
||||
// Absent sidecars are normal: they only exist while a transaction is live.
|
||||
await copyFile(`${sourcePath}${suffix}`, `${workingCopy}${suffix}`).catch(() => {})
|
||||
}
|
||||
return await queryCopy(workingCopy, query)
|
||||
|
||||
@@ -21,8 +21,8 @@ export interface BrowserProfile {
|
||||
source: BrowserSource
|
||||
/** That profile's cookie database, or null when it has none. Stays in main. */
|
||||
cookiesPath: string | null
|
||||
/** That profile's saved-password database, or null when it has none. */
|
||||
loginDataPath: string | null
|
||||
/** That profile's readable saved-password databases, local store first. */
|
||||
loginDataPaths: string[]
|
||||
/** That profile's favicon store, or null when it has none. */
|
||||
faviconsPath: string | null
|
||||
/** That profile's history, read only for the names sites go by. */
|
||||
|
||||
@@ -25,6 +25,13 @@ describe('channel identity', () => {
|
||||
it('treats an unrecognized (self-hosted) origin as production', () => {
|
||||
expect(identityForOrigin('https://sim.acme-corp.example')).toBe(PROD)
|
||||
})
|
||||
|
||||
it('selects the icon owned by the resolved build channel', () => {
|
||||
expect(identityForOrigin('').icon).toBe('build/icon.icon')
|
||||
expect(identityForOrigin(LOCAL.origin).icon).toBe('build/icon-local.icon')
|
||||
expect(identityForOrigin(DEV.origin).icon).toBe('build/icon-dev.icon')
|
||||
expect(identityForOrigin(STAGING.origin).icon).toBe('build/icon-staging.icon')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -189,18 +189,18 @@ describe('createConfigStore', () => {
|
||||
// Reference equality can never hold for an array value, so this guard used
|
||||
// to be dead for exactly the settings written most often — every browser
|
||||
// navigation fell through to a synchronous whole-file write.
|
||||
store.set('browserPinnedTabUrls', ['https://a.example/'])
|
||||
store.set('browserKnownSites', [{ hostname: 'a.example', lastVisitedAt: '2026-01-01' }])
|
||||
store.flush()
|
||||
const afterFirst = readFileSync(filePath, 'utf8')
|
||||
|
||||
store.set('browserPinnedTabUrls', ['https://a.example/'])
|
||||
store.set('browserKnownSites', [{ hostname: 'a.example', lastVisitedAt: '2026-01-01' }])
|
||||
store.flush()
|
||||
expect(readFileSync(filePath, 'utf8')).toBe(afterFirst)
|
||||
|
||||
store.set('browserPinnedTabUrls', ['https://b.example/'])
|
||||
store.set('browserKnownSites', [{ hostname: 'b.example', lastVisitedAt: '2026-01-02' }])
|
||||
store.flush()
|
||||
expect(JSON.parse(readFileSync(filePath, 'utf8')).browserPinnedTabUrls).toEqual([
|
||||
'https://b.example/',
|
||||
expect(JSON.parse(readFileSync(filePath, 'utf8')).browserKnownSites).toEqual([
|
||||
{ hostname: 'b.example', lastVisitedAt: '2026-01-02' },
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import type { DesktopZoomPercent, TerminalAppearanceTheme } from '@sim/desktop-bridge'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { isLoopbackHostname } from '@sim/security/ssrf'
|
||||
import { writeJsonFileAtomicallySync } from '@/main/atomic-json-file'
|
||||
@@ -101,25 +102,22 @@ export interface DesktopSettings {
|
||||
autoDownloadUpdates?: boolean
|
||||
browserEnabled?: boolean
|
||||
terminalEnabled?: boolean
|
||||
/**
|
||||
* Where the agent terminal last was. A shell that always reopened in the
|
||||
* home directory would drop the user back at square one every session, and
|
||||
* `$HOME` is the worst possible working directory for tools that ask what
|
||||
* they are allowed to touch. Restored on the next launch when it still
|
||||
* exists.
|
||||
*/
|
||||
terminalCwd?: string
|
||||
/** Device-wide browser page appearance; `app` follows Sim. */
|
||||
browserTheme?: 'app' | 'light' | 'dark'
|
||||
/** Device-wide default zoom for built-in browser pages. */
|
||||
browserDefaultZoom?: DesktopZoomPercent
|
||||
/** Folder where the built-in browser saves downloaded files. */
|
||||
browserDownloadDirectory?: string
|
||||
/** Device-wide terminal canvas appearance; `app` follows Sim. */
|
||||
terminalTheme?: TerminalAppearanceTheme
|
||||
/** Device-wide default zoom for built-in terminal canvases. */
|
||||
terminalDefaultZoom?: DesktopZoomPercent
|
||||
/**
|
||||
* Top-level sites visited in the dedicated agent-browser profile. This is
|
||||
* local inference metadata only; no cookies, credentials, or account data
|
||||
* are persisted here.
|
||||
*/
|
||||
browserKnownSites?: BrowserKnownSiteSetting[]
|
||||
/**
|
||||
* URLs of user-pinned agent-browser tabs, in pinned-strip order. Pinned
|
||||
* pages are restored locally when the browser resource is opened again.
|
||||
*/
|
||||
browserPinnedTabUrls?: string[]
|
||||
}
|
||||
|
||||
export type OriginValidation = { ok: true; origin: string } | { ok: false; error: string }
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('electron', () => import('@/test/electron-mock'))
|
||||
|
||||
import {
|
||||
type BrowserSessionSnapshot,
|
||||
type DesktopChatSessionEncryptionProvider,
|
||||
DesktopChatSessionStore,
|
||||
type TerminalSessionSnapshot,
|
||||
} from '@/main/desktop-chat-session-store'
|
||||
|
||||
function encryption(available = true): DesktopChatSessionEncryptionProvider {
|
||||
return {
|
||||
isEncryptionAvailable: vi.fn(() => available),
|
||||
encryptString: vi.fn((value: string) => Buffer.from(`sealed:${value}`, 'utf8')),
|
||||
decryptString: vi.fn((value: Buffer) => {
|
||||
const encoded = value.toString('utf8')
|
||||
if (!encoded.startsWith('sealed:')) throw new Error('not encrypted by this provider')
|
||||
return encoded.slice('sealed:'.length)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const ORIGIN = 'https://www.sim.ai'
|
||||
const BROWSER: BrowserSessionSnapshot = {
|
||||
v: 1,
|
||||
tabs: [
|
||||
{ url: 'https://example.com/inbox', pinned: true },
|
||||
{ url: 'about:blank', pinned: false },
|
||||
],
|
||||
activeIndex: 1,
|
||||
downloads: [
|
||||
{
|
||||
id: 'download-1',
|
||||
filename: 'report.csv',
|
||||
state: 'completed',
|
||||
receivedBytes: 2_048,
|
||||
totalBytes: 2_048,
|
||||
startedAt: '2026-07-31T12:00:00.000Z',
|
||||
savePath: '/Users/ada/Downloads/report.csv',
|
||||
},
|
||||
],
|
||||
}
|
||||
const TERMINAL: TerminalSessionSnapshot = {
|
||||
v: 1,
|
||||
tabs: [{ cwd: '/Users/ada/code' }, { cwd: '/tmp/build' }],
|
||||
activeIndex: 0,
|
||||
}
|
||||
|
||||
let directory: string
|
||||
let filePath: string
|
||||
|
||||
beforeEach(() => {
|
||||
directory = mkdtempSync(join(tmpdir(), 'sim-desktop-chat-sessions-'))
|
||||
filePath = join(directory, 'desktop-chat-sessions.json')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function open(
|
||||
provider: DesktopChatSessionEncryptionProvider = encryption(),
|
||||
now: () => number = Date.now
|
||||
): DesktopChatSessionStore {
|
||||
const store = new DesktopChatSessionStore(filePath, {
|
||||
encryption: provider,
|
||||
now,
|
||||
writeDelayMs: 60_000,
|
||||
})
|
||||
store.initialize()
|
||||
return store
|
||||
}
|
||||
|
||||
function writeEncryptedPayload(
|
||||
provider: DesktopChatSessionEncryptionProvider,
|
||||
payload: unknown
|
||||
): void {
|
||||
writeFileSync(
|
||||
filePath,
|
||||
JSON.stringify({
|
||||
v: 1,
|
||||
ciphertext: provider.encryptString(JSON.stringify(payload)).toString('base64'),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
describe('DesktopChatSessionStore', () => {
|
||||
it('round-trips browser and terminal descriptors across app restarts', () => {
|
||||
const provider = encryption()
|
||||
const first = open(provider)
|
||||
|
||||
expect(first.setBrowser(ORIGIN, 'chat-a', BROWSER)).toBe(true)
|
||||
expect(first.setTerminal(ORIGIN, 'chat-a', TERMINAL)).toBe(true)
|
||||
expect(first.flush()).toBe(true)
|
||||
|
||||
const restarted = open(provider)
|
||||
expect(restarted.initialize()).toBe(true)
|
||||
expect(restarted.getBrowser(ORIGIN, 'chat-a')).toEqual(BROWSER)
|
||||
expect(restarted.getTerminal(ORIGIN, 'chat-a')).toEqual(TERMINAL)
|
||||
})
|
||||
|
||||
it('encrypts the complete descriptor payload and writes it owner-only', () => {
|
||||
const provider = encryption()
|
||||
const store = open(provider)
|
||||
store.setBrowser(ORIGIN, 'chat-secret', BROWSER)
|
||||
store.setTerminal(ORIGIN, 'chat-secret', TERMINAL)
|
||||
|
||||
expect(store.flush()).toBe(true)
|
||||
|
||||
const onDisk = readFileSync(filePath, 'utf8')
|
||||
expect(onDisk).not.toContain('chat-secret')
|
||||
expect(onDisk).not.toContain('example.com')
|
||||
expect(onDisk).not.toContain('/Users/ada/code')
|
||||
expect(onDisk).not.toContain('report.csv')
|
||||
expect(JSON.parse(onDisk)).toEqual({ v: 1, ciphertext: expect.any(String) })
|
||||
expect(provider.encryptString).toHaveBeenCalledOnce()
|
||||
expect(statSync(filePath).mode & 0o077).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps a pending chat in memory until migration promotes it to a durable chat id', () => {
|
||||
const provider = encryption()
|
||||
const pending = open(provider)
|
||||
pending.setBrowser(ORIGIN, 'pending:workspace-a', BROWSER)
|
||||
pending.setTerminal(ORIGIN, 'pending:workspace-a', TERMINAL)
|
||||
|
||||
expect(pending.getBrowser(ORIGIN, 'pending:workspace-a')).toEqual(BROWSER)
|
||||
expect(pending.flush()).toBe(true)
|
||||
expect(existsSync(filePath)).toBe(false)
|
||||
|
||||
expect(pending.migrateBrowser(ORIGIN, 'pending:workspace-a', 'chat-resolved')).toBe(true)
|
||||
expect(pending.migrateTerminal(ORIGIN, 'pending:workspace-a', 'chat-resolved')).toBe(true)
|
||||
expect(pending.flush()).toBe(true)
|
||||
|
||||
const restarted = open(provider)
|
||||
restarted.initialize()
|
||||
expect(restarted.getBrowser(ORIGIN, 'pending:workspace-a')).toBeNull()
|
||||
expect(restarted.getBrowser(ORIGIN, 'chat-resolved')).toEqual(BROWSER)
|
||||
expect(restarted.getTerminal(ORIGIN, 'chat-resolved')).toEqual(TERMINAL)
|
||||
})
|
||||
|
||||
it('migrates browser and terminal descriptors independently into one durable chat', () => {
|
||||
const provider = encryption()
|
||||
const store = open(provider)
|
||||
store.setBrowser(ORIGIN, 'pending:workspace-a', BROWSER)
|
||||
store.setTerminal(ORIGIN, 'pending:workspace-a', TERMINAL)
|
||||
|
||||
expect(store.migrateBrowser(ORIGIN, 'pending:workspace-a', 'chat-resolved')).toBe(true)
|
||||
expect(store.getBrowser(ORIGIN, 'chat-resolved')).toEqual(BROWSER)
|
||||
expect(store.getTerminal(ORIGIN, 'chat-resolved')).toBeNull()
|
||||
expect(store.getBrowser(ORIGIN, 'pending:workspace-a')).toBeNull()
|
||||
expect(store.getTerminal(ORIGIN, 'pending:workspace-a')).toEqual(TERMINAL)
|
||||
|
||||
expect(store.migrateTerminal(ORIGIN, 'pending:workspace-a', 'chat-resolved')).toBe(true)
|
||||
expect(store.getBrowser(ORIGIN, 'chat-resolved')).toEqual(BROWSER)
|
||||
expect(store.getTerminal(ORIGIN, 'chat-resolved')).toEqual(TERMINAL)
|
||||
expect(store.getBrowser(ORIGIN, 'pending:workspace-a')).toBeNull()
|
||||
expect(store.getTerminal(ORIGIN, 'pending:workspace-a')).toBeNull()
|
||||
|
||||
expect(store.flush()).toBe(true)
|
||||
const restarted = open(provider)
|
||||
expect(restarted.getBrowser(ORIGIN, 'chat-resolved')).toEqual(BROWSER)
|
||||
expect(restarted.getTerminal(ORIGIN, 'chat-resolved')).toEqual(TERMINAL)
|
||||
})
|
||||
|
||||
it('also merges the browser descriptor when terminal migration arrives first', () => {
|
||||
const store = open()
|
||||
store.setBrowser(ORIGIN, 'pending:workspace-a', BROWSER)
|
||||
store.setTerminal(ORIGIN, 'pending:workspace-a', TERMINAL)
|
||||
|
||||
expect(store.migrateTerminal(ORIGIN, 'pending:workspace-a', 'chat-resolved')).toBe(true)
|
||||
expect(store.migrateBrowser(ORIGIN, 'pending:workspace-a', 'chat-resolved')).toBe(true)
|
||||
|
||||
expect(store.getBrowser(ORIGIN, 'chat-resolved')).toEqual(BROWSER)
|
||||
expect(store.getTerminal(ORIGIN, 'chat-resolved')).toEqual(TERMINAL)
|
||||
expect(store.getBrowser(ORIGIN, 'pending:workspace-a')).toBeNull()
|
||||
expect(store.getTerminal(ORIGIN, 'pending:workspace-a')).toBeNull()
|
||||
})
|
||||
|
||||
it('migrates the non-conflicting component without replacing a durable destination', () => {
|
||||
const store = open()
|
||||
const existingBrowser: BrowserSessionSnapshot = {
|
||||
v: 1,
|
||||
tabs: [{ url: 'https://existing.example/', pinned: true }],
|
||||
activeIndex: 0,
|
||||
downloads: [],
|
||||
}
|
||||
store.setBrowser(ORIGIN, 'chat-existing', existingBrowser)
|
||||
store.setBrowser(ORIGIN, 'pending:workspace-a', BROWSER)
|
||||
store.setTerminal(ORIGIN, 'pending:workspace-a', TERMINAL)
|
||||
|
||||
expect(store.migrateBrowser(ORIGIN, 'pending:workspace-a', 'chat-existing')).toBe(false)
|
||||
expect(store.migrateTerminal(ORIGIN, 'pending:workspace-a', 'chat-existing')).toBe(true)
|
||||
|
||||
expect(store.getBrowser(ORIGIN, 'chat-existing')).toEqual(existingBrowser)
|
||||
expect(store.getTerminal(ORIGIN, 'chat-existing')).toEqual(TERMINAL)
|
||||
expect(store.getBrowser(ORIGIN, 'pending:workspace-a')).toEqual(BROWSER)
|
||||
expect(store.getTerminal(ORIGIN, 'pending:workspace-a')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps identical chat ids isolated across server origins', () => {
|
||||
const store = open()
|
||||
store.setBrowser(ORIGIN, 'chat-a', BROWSER)
|
||||
store.setBrowser('https://self-hosted.example/path', 'chat-a', {
|
||||
v: 1,
|
||||
tabs: [{ url: 'https://other.example/', pinned: false }],
|
||||
activeIndex: 0,
|
||||
downloads: [],
|
||||
})
|
||||
|
||||
expect(store.getBrowser(ORIGIN, 'chat-a')?.tabs[0].url).toBe('https://example.com/inbox')
|
||||
expect(store.getBrowser('https://self-hosted.example', 'chat-a')?.tabs[0].url).toBe(
|
||||
'https://other.example/'
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves every browser and terminal tab while clamping active indexes', () => {
|
||||
const provider = encryption()
|
||||
const store = open(provider)
|
||||
store.setBrowser(ORIGIN, 'chat-a', {
|
||||
v: 1,
|
||||
tabs: Array.from({ length: 12 }, (_, index) => ({
|
||||
url: `https://tab-${index}.example/`,
|
||||
pinned: index < 2,
|
||||
})),
|
||||
activeIndex: 99,
|
||||
downloads: [],
|
||||
})
|
||||
store.setTerminal(ORIGIN, 'chat-a', {
|
||||
v: 1,
|
||||
tabs: Array.from({ length: 12 }, (_, index) => ({ cwd: `/tmp/tab-${index}` })),
|
||||
activeIndex: 99,
|
||||
})
|
||||
|
||||
expect(store.flush()).toBe(true)
|
||||
const restarted = open(provider)
|
||||
const browser = restarted.getBrowser(ORIGIN, 'chat-a')
|
||||
const terminal = restarted.getTerminal(ORIGIN, 'chat-a')
|
||||
expect(browser?.tabs).toHaveLength(12)
|
||||
expect(browser?.activeIndex).toBe(11)
|
||||
expect(terminal?.tabs).toHaveLength(12)
|
||||
expect(terminal?.activeIndex).toBe(11)
|
||||
})
|
||||
|
||||
it('filters unsafe or malformed values while loading an encrypted payload', () => {
|
||||
const provider = encryption()
|
||||
writeEncryptedPayload(provider, {
|
||||
v: 1,
|
||||
entries: [
|
||||
{
|
||||
origin: ORIGIN,
|
||||
scope: 'chat-valid',
|
||||
lastAccessedAt: 3,
|
||||
browser: {
|
||||
v: 1,
|
||||
tabs: [
|
||||
{ url: 'https://user:password@example.com/private', pinned: false },
|
||||
{ url: 'file:///Users/ada/.ssh/id_ed25519', pinned: false },
|
||||
{ url: 'javascript:alert(1)', pinned: true },
|
||||
{ url: 'about:blank', pinned: false },
|
||||
{ url: 'http://localhost:3000/path', pinned: true },
|
||||
{ url: `https://example.com/${'x'.repeat(8_200)}`, pinned: false },
|
||||
],
|
||||
activeIndex: 20,
|
||||
downloads: [],
|
||||
},
|
||||
terminal: {
|
||||
v: 1,
|
||||
tabs: [
|
||||
{ cwd: '' },
|
||||
{ cwd: ' ' },
|
||||
{ cwd: '/tmp\0hidden' },
|
||||
{ cwd: 'x'.repeat(4_097) },
|
||||
{ cwd: '/Users/ada/code' },
|
||||
],
|
||||
activeIndex: 50,
|
||||
},
|
||||
},
|
||||
{
|
||||
origin: ORIGIN,
|
||||
scope: 'pending:must-not-load',
|
||||
lastAccessedAt: 4,
|
||||
browser: BROWSER,
|
||||
},
|
||||
{
|
||||
origin: 'https://user:secret@sim.ai',
|
||||
scope: 'chat-bad-origin',
|
||||
lastAccessedAt: 5,
|
||||
browser: BROWSER,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const store = open(provider)
|
||||
expect(store.initialize()).toBe(true)
|
||||
expect(store.getBrowser(ORIGIN, 'chat-valid')).toEqual({
|
||||
v: 1,
|
||||
tabs: [
|
||||
{ url: 'about:blank', pinned: false },
|
||||
{ url: 'http://localhost:3000/path', pinned: true },
|
||||
],
|
||||
activeIndex: 1,
|
||||
downloads: [],
|
||||
})
|
||||
expect(store.getTerminal(ORIGIN, 'chat-valid')).toEqual({
|
||||
v: 1,
|
||||
tabs: [{ cwd: '/Users/ada/code' }],
|
||||
activeIndex: 0,
|
||||
})
|
||||
expect(store.getBrowser(ORIGIN, 'pending:must-not-load')).toBeNull()
|
||||
expect(store.getBrowser(ORIGIN, 'chat-bad-origin')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects persisted browser snapshots without a downloads array', () => {
|
||||
const provider = encryption()
|
||||
writeEncryptedPayload(provider, {
|
||||
v: 1,
|
||||
entries: [
|
||||
{
|
||||
origin: ORIGIN,
|
||||
scope: 'chat-missing-downloads',
|
||||
lastAccessedAt: 1,
|
||||
browser: { v: 1, tabs: [], activeIndex: 0 },
|
||||
},
|
||||
{
|
||||
origin: ORIGIN,
|
||||
scope: 'chat-invalid-downloads',
|
||||
lastAccessedAt: 2,
|
||||
browser: { v: 1, tabs: [], activeIndex: 0, downloads: null },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const store = open(provider)
|
||||
expect(store.getBrowser(ORIGIN, 'chat-missing-downloads')).toBeNull()
|
||||
expect(store.getBrowser(ORIGIN, 'chat-invalid-downloads')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps only the 100 most recently used durable chat entries', () => {
|
||||
let timestamp = 1
|
||||
const provider = encryption()
|
||||
const store = open(provider, () => timestamp++)
|
||||
const snapshot: TerminalSessionSnapshot = {
|
||||
v: 1,
|
||||
tabs: [{ cwd: '/tmp' }],
|
||||
activeIndex: 0,
|
||||
}
|
||||
|
||||
for (let index = 0; index < 100; index += 1) {
|
||||
store.setTerminal(ORIGIN, `chat-${index}`, snapshot)
|
||||
}
|
||||
expect(store.getTerminal(ORIGIN, 'chat-0')).toEqual(snapshot)
|
||||
store.setTerminal(ORIGIN, 'chat-100', snapshot)
|
||||
store.flush()
|
||||
|
||||
const restarted = open(provider, () => timestamp++)
|
||||
restarted.initialize()
|
||||
expect(restarted.getTerminal(ORIGIN, 'chat-0')).toEqual(snapshot)
|
||||
expect(restarted.getTerminal(ORIGIN, 'chat-1')).toBeNull()
|
||||
expect(restarted.getTerminal(ORIGIN, 'chat-100')).toEqual(snapshot)
|
||||
})
|
||||
|
||||
it('applies the durable-entry cap when a pending descriptor is promoted', () => {
|
||||
let timestamp = 1
|
||||
const store = open(encryption(), () => timestamp++)
|
||||
for (let index = 0; index < 100; index += 1) {
|
||||
store.setTerminal(ORIGIN, `chat-${index}`, TERMINAL)
|
||||
}
|
||||
store.setBrowser(ORIGIN, 'pending:new-chat', BROWSER)
|
||||
|
||||
expect(store.migrateBrowser(ORIGIN, 'pending:new-chat', 'chat-promoted')).toBe(true)
|
||||
expect(store.getTerminal(ORIGIN, 'chat-0')).toBeNull()
|
||||
expect(store.getBrowser(ORIGIN, 'chat-promoted')).toEqual(BROWSER)
|
||||
})
|
||||
|
||||
it('stays memory-only with no plaintext fallback when OS encryption is unavailable', () => {
|
||||
const provider = encryption(false)
|
||||
const store = open(provider)
|
||||
|
||||
expect(store.initialize()).toBe(false)
|
||||
expect(store.setBrowser(ORIGIN, 'chat-a', BROWSER)).toBe(true)
|
||||
expect(store.getBrowser(ORIGIN, 'chat-a')).toEqual(BROWSER)
|
||||
expect(store.flush()).toBe(false)
|
||||
expect(provider.encryptString).not.toHaveBeenCalled()
|
||||
expect(existsSync(filePath)).toBe(false)
|
||||
})
|
||||
|
||||
it('also treats an encryption availability error as unavailable', () => {
|
||||
const provider = encryption()
|
||||
provider.isEncryptionAvailable = vi.fn(() => {
|
||||
throw new Error('keychain is locked')
|
||||
})
|
||||
const store = open(provider)
|
||||
|
||||
store.setTerminal(ORIGIN, 'chat-a', TERMINAL)
|
||||
expect(store.isAvailable()).toBe(false)
|
||||
expect(store.flush()).toBe(false)
|
||||
expect(existsSync(filePath)).toBe(false)
|
||||
})
|
||||
|
||||
it('stays memory-only rather than overwriting corrupt or undecryptable files', () => {
|
||||
writeFileSync(filePath, '{not json')
|
||||
const corrupt = open()
|
||||
expect(corrupt.initialize()).toBe(false)
|
||||
expect(corrupt.getBrowser(ORIGIN, 'chat-a')).toBeNull()
|
||||
corrupt.setBrowser(ORIGIN, 'chat-new', BROWSER)
|
||||
expect(corrupt.flush()).toBe(false)
|
||||
expect(readFileSync(filePath, 'utf8')).toBe('{not json')
|
||||
|
||||
const provider = encryption()
|
||||
writeFileSync(filePath, JSON.stringify({ v: 1, ciphertext: 'aW52YWxpZA==' }))
|
||||
const undecryptable = open(provider)
|
||||
expect(undecryptable.initialize()).toBe(false)
|
||||
expect(undecryptable.getTerminal(ORIGIN, 'chat-a')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns defensive copies and clears both memory and disk', () => {
|
||||
const provider = encryption()
|
||||
const store = open(provider)
|
||||
store.setBrowser(ORIGIN, 'chat-a', BROWSER)
|
||||
|
||||
const read = store.getBrowser(ORIGIN, 'chat-a')
|
||||
if (!read) throw new Error('expected browser snapshot')
|
||||
read.tabs[0].url = 'https://mutated.example/'
|
||||
expect(store.getBrowser(ORIGIN, 'chat-a')).toEqual(BROWSER)
|
||||
|
||||
store.flush()
|
||||
expect(existsSync(filePath)).toBe(true)
|
||||
store.clear()
|
||||
expect(store.getBrowser(ORIGIN, 'chat-a')).toBeNull()
|
||||
expect(existsSync(filePath)).toBe(false)
|
||||
expect(() => store.clear()).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,614 @@
|
||||
import { readFileSync, unlinkSync } from 'node:fs'
|
||||
import { isAbsolute } from 'node:path'
|
||||
import { isDesktopScopeId, isPendingDesktopScopeId } from '@sim/desktop-bridge'
|
||||
import { isRecordLike } from '@sim/utils/object'
|
||||
import { safeStorage } from 'electron'
|
||||
import { writeJsonFileAtomicallySync } from '@/main/atomic-json-file'
|
||||
|
||||
const STORE_VERSION = 1
|
||||
const SNAPSHOT_VERSION = 1
|
||||
const MAX_DURABLE_ENTRIES = 100
|
||||
const MAX_ORIGIN_LENGTH = 2_048
|
||||
const MAX_URL_LENGTH = 8_192
|
||||
const MAX_CWD_LENGTH = 4_096
|
||||
const MAX_DOWNLOADS = 5
|
||||
const MAX_DOWNLOAD_ID_LENGTH = 128
|
||||
const MAX_DOWNLOAD_FILENAME_LENGTH = 200
|
||||
const MAX_DOWNLOAD_PATH_LENGTH = 4_096
|
||||
const DEFAULT_WRITE_DELAY_MS = 250
|
||||
|
||||
export interface BrowserSessionSnapshot {
|
||||
v: typeof SNAPSHOT_VERSION
|
||||
tabs: Array<{
|
||||
url: string
|
||||
pinned: boolean
|
||||
}>
|
||||
activeIndex: number
|
||||
downloads: Array<{
|
||||
id: string
|
||||
filename: string
|
||||
state: 'completed' | 'interrupted' | 'cancelled'
|
||||
receivedBytes: number
|
||||
totalBytes: number
|
||||
startedAt: string
|
||||
savePath: string
|
||||
}>
|
||||
}
|
||||
|
||||
export interface TerminalSessionSnapshot {
|
||||
v: typeof SNAPSHOT_VERSION
|
||||
tabs: Array<{
|
||||
cwd: string
|
||||
}>
|
||||
activeIndex: number
|
||||
}
|
||||
|
||||
export interface DesktopChatSessionEncryptionProvider {
|
||||
isEncryptionAvailable(): boolean
|
||||
encryptString(value: string): Buffer
|
||||
decryptString(value: Buffer): string
|
||||
}
|
||||
|
||||
export interface DesktopChatSessionStoreOptions {
|
||||
encryption?: DesktopChatSessionEncryptionProvider
|
||||
now?: () => number
|
||||
writeDelayMs?: number
|
||||
}
|
||||
|
||||
interface SessionEntry {
|
||||
origin: string
|
||||
scope: string
|
||||
browser?: BrowserSessionSnapshot
|
||||
terminal?: TerminalSessionSnapshot
|
||||
lastAccessedAt: number
|
||||
}
|
||||
|
||||
interface PersistedPayload {
|
||||
v: typeof STORE_VERSION
|
||||
entries: SessionEntry[]
|
||||
}
|
||||
|
||||
interface EncryptedEnvelope {
|
||||
v: typeof STORE_VERSION
|
||||
ciphertext: string
|
||||
}
|
||||
|
||||
function normalizeOrigin(origin: unknown): string | null {
|
||||
if (
|
||||
typeof origin !== 'string' ||
|
||||
origin.length === 0 ||
|
||||
origin.length > MAX_ORIGIN_LENGTH ||
|
||||
origin.includes('\0')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(origin)
|
||||
if (
|
||||
(parsed.protocol !== 'http:' && parsed.protocol !== 'https:') ||
|
||||
parsed.username ||
|
||||
parsed.password
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return parsed.origin
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeScope(scope: unknown): string | null {
|
||||
return isDesktopScopeId(scope) ? scope : null
|
||||
}
|
||||
|
||||
function isDurableScope(scope: string): boolean {
|
||||
return !isPendingDesktopScopeId(scope)
|
||||
}
|
||||
|
||||
function normalizeBrowserUrl(value: unknown): string | null {
|
||||
if (typeof value !== 'string' || value.length === 0 || value.length > MAX_URL_LENGTH) return null
|
||||
if (value === 'about:blank') return value
|
||||
|
||||
try {
|
||||
const parsed = new URL(value)
|
||||
if (
|
||||
(parsed.protocol !== 'http:' && parsed.protocol !== 'https:') ||
|
||||
parsed.username ||
|
||||
parsed.password
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return parsed.href
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeActiveIndex(value: unknown, tabCount: number): number {
|
||||
if (tabCount === 0) return 0
|
||||
if (typeof value !== 'number' || !Number.isInteger(value)) return 0
|
||||
return Math.max(0, Math.min(value, tabCount - 1))
|
||||
}
|
||||
|
||||
function normalizeBrowserSnapshot(value: unknown): BrowserSessionSnapshot | null {
|
||||
if (
|
||||
!isRecordLike(value) ||
|
||||
value.v !== SNAPSHOT_VERSION ||
|
||||
!Array.isArray(value.tabs) ||
|
||||
!Array.isArray(value.downloads)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const tabs: BrowserSessionSnapshot['tabs'] = []
|
||||
for (const candidate of value.tabs) {
|
||||
if (!isRecordLike(candidate) || typeof candidate.pinned !== 'boolean') continue
|
||||
const url = normalizeBrowserUrl(candidate.url)
|
||||
if (url === null) continue
|
||||
tabs.push({ url, pinned: candidate.pinned })
|
||||
}
|
||||
|
||||
const downloads: BrowserSessionSnapshot['downloads'] = []
|
||||
for (const candidate of value.downloads) {
|
||||
if (!isRecordLike(candidate)) continue
|
||||
if (
|
||||
typeof candidate.id !== 'string' ||
|
||||
candidate.id.length === 0 ||
|
||||
candidate.id.length > MAX_DOWNLOAD_ID_LENGTH ||
|
||||
typeof candidate.filename !== 'string' ||
|
||||
candidate.filename.length === 0 ||
|
||||
candidate.filename.length > MAX_DOWNLOAD_FILENAME_LENGTH ||
|
||||
(candidate.state !== 'completed' &&
|
||||
candidate.state !== 'interrupted' &&
|
||||
candidate.state !== 'cancelled') ||
|
||||
typeof candidate.receivedBytes !== 'number' ||
|
||||
!Number.isSafeInteger(candidate.receivedBytes) ||
|
||||
candidate.receivedBytes < 0 ||
|
||||
typeof candidate.totalBytes !== 'number' ||
|
||||
!Number.isSafeInteger(candidate.totalBytes) ||
|
||||
candidate.totalBytes < 0 ||
|
||||
typeof candidate.startedAt !== 'string' ||
|
||||
!Number.isFinite(Date.parse(candidate.startedAt)) ||
|
||||
typeof candidate.savePath !== 'string' ||
|
||||
candidate.savePath.length === 0 ||
|
||||
candidate.savePath.length > MAX_DOWNLOAD_PATH_LENGTH ||
|
||||
candidate.savePath.includes('\0') ||
|
||||
!isAbsolute(candidate.savePath)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
downloads.push({
|
||||
id: candidate.id,
|
||||
filename: candidate.filename,
|
||||
state: candidate.state,
|
||||
receivedBytes: candidate.receivedBytes,
|
||||
totalBytes: candidate.totalBytes,
|
||||
startedAt: candidate.startedAt,
|
||||
savePath: candidate.savePath,
|
||||
})
|
||||
if (downloads.length >= MAX_DOWNLOADS) break
|
||||
}
|
||||
|
||||
return {
|
||||
v: SNAPSHOT_VERSION,
|
||||
tabs,
|
||||
activeIndex: normalizeActiveIndex(value.activeIndex, tabs.length),
|
||||
downloads,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTerminalSnapshot(value: unknown): TerminalSessionSnapshot | null {
|
||||
if (!isRecordLike(value) || value.v !== SNAPSHOT_VERSION || !Array.isArray(value.tabs))
|
||||
return null
|
||||
|
||||
const tabs: TerminalSessionSnapshot['tabs'] = []
|
||||
for (const candidate of value.tabs) {
|
||||
if (!isRecordLike(candidate) || typeof candidate.cwd !== 'string') continue
|
||||
if (
|
||||
candidate.cwd.trim().length === 0 ||
|
||||
candidate.cwd.length > MAX_CWD_LENGTH ||
|
||||
candidate.cwd.includes('\0')
|
||||
) {
|
||||
continue
|
||||
}
|
||||
tabs.push({ cwd: candidate.cwd })
|
||||
}
|
||||
|
||||
return {
|
||||
v: SNAPSHOT_VERSION,
|
||||
tabs,
|
||||
activeIndex: normalizeActiveIndex(value.activeIndex, tabs.length),
|
||||
}
|
||||
}
|
||||
|
||||
function keyFor(origin: string, scope: string): string {
|
||||
return JSON.stringify([origin, scope])
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the small descriptors needed to reconstruct each chat's browser
|
||||
* and terminal tabs. Live browser contents and shell processes remain owned by
|
||||
* their scoped registries; this store contains URLs, pin state, and working
|
||||
* directories, and a bounded recent-download list only.
|
||||
*
|
||||
* The complete payload is encrypted with Electron safeStorage before an
|
||||
* atomic owner-only write. When OS-backed encryption is unavailable, the same
|
||||
* synchronous API remains usable for the current process but nothing is
|
||||
* written in plaintext.
|
||||
*/
|
||||
export class DesktopChatSessionStore {
|
||||
private readonly encryption: DesktopChatSessionEncryptionProvider
|
||||
private readonly now: () => number
|
||||
private readonly writeDelayMs: number
|
||||
private readonly entries = new Map<string, SessionEntry>()
|
||||
private accessClock = 0
|
||||
private dirty = false
|
||||
private writeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private initialized = false
|
||||
private persistenceEnabled = false
|
||||
|
||||
constructor(
|
||||
private readonly filePath: string,
|
||||
options: DesktopChatSessionStoreOptions = {}
|
||||
) {
|
||||
this.encryption = options.encryption ?? safeStorage
|
||||
this.now = options.now ?? Date.now
|
||||
this.writeDelayMs = Math.max(0, options.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS)
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
try {
|
||||
return this.encryption.isEncryptionAvailable()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads durable entries into memory. Call only after Electron's app-ready
|
||||
* event, when safeStorage is ready to use.
|
||||
*/
|
||||
initialize(): boolean {
|
||||
if (this.initialized) return this.persistenceEnabled
|
||||
this.initialized = true
|
||||
if (!this.isAvailable()) return false
|
||||
|
||||
try {
|
||||
const envelope = JSON.parse(readFileSync(this.filePath, 'utf8')) as unknown
|
||||
if (
|
||||
!isRecordLike(envelope) ||
|
||||
envelope.v !== STORE_VERSION ||
|
||||
typeof envelope.ciphertext !== 'string'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const decrypted = this.encryption.decryptString(Buffer.from(envelope.ciphertext, 'base64'))
|
||||
const payload = JSON.parse(decrypted) as unknown
|
||||
if (
|
||||
!isRecordLike(payload) ||
|
||||
payload.v !== STORE_VERSION ||
|
||||
!Array.isArray(payload.entries)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const loaded: SessionEntry[] = []
|
||||
for (const candidate of payload.entries) {
|
||||
const entry = this.normalizeEntry(candidate)
|
||||
if (entry && isDurableScope(entry.scope)) loaded.push(entry)
|
||||
}
|
||||
|
||||
loaded.sort(
|
||||
(left, right) =>
|
||||
right.lastAccessedAt - left.lastAccessedAt ||
|
||||
keyFor(left.origin, left.scope).localeCompare(keyFor(right.origin, right.scope))
|
||||
)
|
||||
|
||||
for (const [key, entry] of this.entries) {
|
||||
if (isDurableScope(entry.scope)) this.entries.delete(key)
|
||||
}
|
||||
for (const entry of loaded.slice(0, MAX_DURABLE_ENTRIES)) {
|
||||
this.entries.set(keyFor(entry.origin, entry.scope), entry)
|
||||
this.accessClock = Math.max(this.accessClock, entry.lastAccessedAt)
|
||||
}
|
||||
this.dirty = false
|
||||
this.persistenceEnabled = true
|
||||
return true
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
this.persistenceEnabled = true
|
||||
return true
|
||||
}
|
||||
// Preserve an unread file verbatim. This process remains memory-only
|
||||
// rather than replacing potentially recoverable encrypted state with a
|
||||
// partial fresh payload.
|
||||
this.persistenceEnabled = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
getBrowser(origin: string, scope: string): BrowserSessionSnapshot | null {
|
||||
const entry = this.entryFor(origin, scope)
|
||||
if (!entry?.browser) return null
|
||||
this.touch(entry)
|
||||
this.changed(entry.scope)
|
||||
return structuredClone(entry.browser)
|
||||
}
|
||||
|
||||
setBrowser(origin: string, scope: string, snapshot: BrowserSessionSnapshot): boolean {
|
||||
const normalized = normalizeBrowserSnapshot(snapshot)
|
||||
if (!normalized) return false
|
||||
const entry = this.ensureEntry(origin, scope)
|
||||
if (!entry) return false
|
||||
entry.browser = normalized
|
||||
this.touch(entry)
|
||||
this.changed(entry.scope)
|
||||
return true
|
||||
}
|
||||
|
||||
getTerminal(origin: string, scope: string): TerminalSessionSnapshot | null {
|
||||
const entry = this.entryFor(origin, scope)
|
||||
if (!entry?.terminal) return null
|
||||
this.touch(entry)
|
||||
this.changed(entry.scope)
|
||||
return structuredClone(entry.terminal)
|
||||
}
|
||||
|
||||
setTerminal(origin: string, scope: string, snapshot: TerminalSessionSnapshot): boolean {
|
||||
const normalized = normalizeTerminalSnapshot(snapshot)
|
||||
if (!normalized) return false
|
||||
const entry = this.ensureEntry(origin, scope)
|
||||
if (!entry) return false
|
||||
entry.terminal = normalized
|
||||
this.touch(entry)
|
||||
this.changed(entry.scope)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Promotes only a provisional browser descriptor.
|
||||
*
|
||||
* Browser and terminal adapters migrate independently, and either may arrive
|
||||
* first. Moving one field into an existing destination entry lets the second
|
||||
* adapter add its field afterward without treating the first migration as a
|
||||
* whole-scope collision. An existing destination browser is never replaced.
|
||||
*/
|
||||
migrateBrowser(origin: string, from: string, to: string): boolean {
|
||||
return this.migrateComponent(origin, from, to, 'browser')
|
||||
}
|
||||
|
||||
/**
|
||||
* Promotes only a provisional terminal descriptor. See
|
||||
* {@link migrateBrowser} for the independent-adapter semantics.
|
||||
*/
|
||||
migrateTerminal(origin: string, from: string, to: string): boolean {
|
||||
return this.migrateComponent(origin, from, to, 'terminal')
|
||||
}
|
||||
|
||||
private migrateComponent(
|
||||
origin: string,
|
||||
from: string,
|
||||
to: string,
|
||||
component: 'browser' | 'terminal'
|
||||
): boolean {
|
||||
const normalizedOrigin = normalizeOrigin(origin)
|
||||
const normalizedFrom = normalizeScope(from)
|
||||
const normalizedTo = normalizeScope(to)
|
||||
if (!normalizedOrigin || !normalizedFrom || !normalizedTo) return false
|
||||
if (isDurableScope(normalizedFrom) || !isDurableScope(normalizedTo)) return false
|
||||
|
||||
const fromKey = keyFor(normalizedOrigin, normalizedFrom)
|
||||
const toKey = keyFor(normalizedOrigin, normalizedTo)
|
||||
const source = this.entries.get(fromKey)
|
||||
const destination = this.entries.get(toKey)
|
||||
|
||||
// A dormant durable destination wins even when this component has no
|
||||
// provisional snapshot yet. Retagging live state over it would make the
|
||||
// next ordinary save silently replace the restored chat.
|
||||
if (component === 'browser') {
|
||||
const snapshot = source?.browser
|
||||
if (!snapshot) return destination?.browser === undefined
|
||||
if (destination?.browser) return false
|
||||
const target = destination ?? {
|
||||
origin: normalizedOrigin,
|
||||
scope: normalizedTo,
|
||||
lastAccessedAt: this.nextAccessTime(),
|
||||
}
|
||||
target.browser = snapshot
|
||||
this.touch(target)
|
||||
this.entries.set(toKey, target)
|
||||
if (source.terminal) {
|
||||
this.entries.set(fromKey, {
|
||||
origin: source.origin,
|
||||
scope: source.scope,
|
||||
terminal: source.terminal,
|
||||
lastAccessedAt: source.lastAccessedAt,
|
||||
})
|
||||
} else {
|
||||
this.entries.delete(fromKey)
|
||||
}
|
||||
} else {
|
||||
const snapshot = source?.terminal
|
||||
if (!snapshot) return destination?.terminal === undefined
|
||||
if (destination?.terminal) return false
|
||||
const target = destination ?? {
|
||||
origin: normalizedOrigin,
|
||||
scope: normalizedTo,
|
||||
lastAccessedAt: this.nextAccessTime(),
|
||||
}
|
||||
target.terminal = snapshot
|
||||
this.touch(target)
|
||||
this.entries.set(toKey, target)
|
||||
if (source.browser) {
|
||||
this.entries.set(fromKey, {
|
||||
origin: source.origin,
|
||||
scope: source.scope,
|
||||
browser: source.browser,
|
||||
lastAccessedAt: source.lastAccessedAt,
|
||||
})
|
||||
} else {
|
||||
this.entries.delete(fromKey)
|
||||
}
|
||||
}
|
||||
|
||||
this.changed(normalizedTo)
|
||||
return true
|
||||
}
|
||||
|
||||
deleteScope(origin: string, scope: string): boolean {
|
||||
const normalizedOrigin = normalizeOrigin(origin)
|
||||
const normalizedScope = normalizeScope(scope)
|
||||
if (!normalizedOrigin || !normalizedScope) return false
|
||||
|
||||
const removed = this.entries.delete(keyFor(normalizedOrigin, normalizedScope))
|
||||
if (removed) this.changed(normalizedScope)
|
||||
return removed
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously publishes all durable state. Intended for Electron's
|
||||
* before-quit path, where an asynchronous write may never settle.
|
||||
*/
|
||||
flush(): boolean {
|
||||
this.cancelScheduledWrite()
|
||||
if (!this.dirty) return true
|
||||
if (!this.persistenceEnabled || !this.isAvailable()) return false
|
||||
|
||||
const entries = [...this.entries.values()]
|
||||
.filter((entry) => isDurableScope(entry.scope))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.lastAccessedAt - right.lastAccessedAt ||
|
||||
keyFor(left.origin, left.scope).localeCompare(keyFor(right.origin, right.scope))
|
||||
)
|
||||
.slice(-MAX_DURABLE_ENTRIES)
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
...(entry.browser ? { browser: structuredClone(entry.browser) } : {}),
|
||||
...(entry.terminal ? { terminal: structuredClone(entry.terminal) } : {}),
|
||||
}))
|
||||
|
||||
try {
|
||||
const payload: PersistedPayload = { v: STORE_VERSION, entries }
|
||||
const envelope: EncryptedEnvelope = {
|
||||
v: STORE_VERSION,
|
||||
ciphertext: this.encryption.encryptString(JSON.stringify(payload)).toString('base64'),
|
||||
}
|
||||
writeJsonFileAtomicallySync(this.filePath, envelope)
|
||||
this.dirty = false
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes both the encrypted file and every in-memory descriptor. */
|
||||
clear(): void {
|
||||
this.cancelScheduledWrite()
|
||||
this.entries.clear()
|
||||
this.accessClock = 0
|
||||
this.dirty = false
|
||||
|
||||
try {
|
||||
unlinkSync(this.filePath)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
this.initialized = true
|
||||
this.persistenceEnabled = this.isAvailable()
|
||||
}
|
||||
|
||||
private entryFor(origin: string, scope: string): SessionEntry | null {
|
||||
const normalizedOrigin = normalizeOrigin(origin)
|
||||
const normalizedScope = normalizeScope(scope)
|
||||
if (!normalizedOrigin || !normalizedScope) return null
|
||||
return this.entries.get(keyFor(normalizedOrigin, normalizedScope)) ?? null
|
||||
}
|
||||
|
||||
private ensureEntry(origin: string, scope: string): SessionEntry | null {
|
||||
const normalizedOrigin = normalizeOrigin(origin)
|
||||
const normalizedScope = normalizeScope(scope)
|
||||
if (!normalizedOrigin || !normalizedScope) return null
|
||||
|
||||
const key = keyFor(normalizedOrigin, normalizedScope)
|
||||
const existing = this.entries.get(key)
|
||||
if (existing) return existing
|
||||
|
||||
const entry: SessionEntry = {
|
||||
origin: normalizedOrigin,
|
||||
scope: normalizedScope,
|
||||
lastAccessedAt: this.nextAccessTime(),
|
||||
}
|
||||
this.entries.set(key, entry)
|
||||
if (isDurableScope(normalizedScope)) this.evictExcessDurableEntries()
|
||||
return entry
|
||||
}
|
||||
|
||||
private normalizeEntry(value: unknown): SessionEntry | null {
|
||||
if (!isRecordLike(value)) return null
|
||||
const origin = normalizeOrigin(value.origin)
|
||||
const scope = normalizeScope(value.scope)
|
||||
if (!origin || !scope) return null
|
||||
|
||||
const browser =
|
||||
value.browser === undefined ? undefined : normalizeBrowserSnapshot(value.browser)
|
||||
const terminal =
|
||||
value.terminal === undefined ? undefined : normalizeTerminalSnapshot(value.terminal)
|
||||
if (!browser && !terminal) return null
|
||||
|
||||
return {
|
||||
origin,
|
||||
scope,
|
||||
...(browser ? { browser } : {}),
|
||||
...(terminal ? { terminal } : {}),
|
||||
lastAccessedAt:
|
||||
typeof value.lastAccessedAt === 'number' &&
|
||||
Number.isSafeInteger(value.lastAccessedAt) &&
|
||||
value.lastAccessedAt >= 0
|
||||
? value.lastAccessedAt
|
||||
: 0,
|
||||
}
|
||||
}
|
||||
|
||||
private touch(entry: SessionEntry): void {
|
||||
entry.lastAccessedAt = this.nextAccessTime()
|
||||
}
|
||||
|
||||
private nextAccessTime(): number {
|
||||
this.accessClock = Math.max(this.accessClock + 1, this.now())
|
||||
return this.accessClock
|
||||
}
|
||||
|
||||
private changed(scope: string): void {
|
||||
if (!isDurableScope(scope)) return
|
||||
this.dirty = true
|
||||
this.evictExcessDurableEntries()
|
||||
if (!this.persistenceEnabled || !this.isAvailable() || this.writeTimer) return
|
||||
this.writeTimer = setTimeout(() => {
|
||||
this.writeTimer = null
|
||||
this.flush()
|
||||
}, this.writeDelayMs)
|
||||
this.writeTimer.unref()
|
||||
}
|
||||
|
||||
private evictExcessDurableEntries(): void {
|
||||
const durableEntries = [...this.entries.entries()]
|
||||
.filter(([, entry]) => isDurableScope(entry.scope))
|
||||
.sort(
|
||||
([leftKey, left], [rightKey, right]) =>
|
||||
left.lastAccessedAt - right.lastAccessedAt || leftKey.localeCompare(rightKey)
|
||||
)
|
||||
|
||||
for (const [key] of durableEntries.slice(0, -MAX_DURABLE_ENTRIES)) {
|
||||
this.entries.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
private cancelScheduledWrite(): void {
|
||||
if (!this.writeTimer) return
|
||||
clearTimeout(this.writeTimer)
|
||||
this.writeTimer = null
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { TERMINAL_DARK_THEME } from '@sim/desktop-bridge'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('electron', () => import('@/test/electron-mock'))
|
||||
@@ -10,6 +11,11 @@ import { createConfigStore } from '@/main/config'
|
||||
import { createDesktopSettingsService } from '@/main/desktop-settings'
|
||||
import { Notification } from '@/test/electron-mock'
|
||||
|
||||
const IMPORTED_PALETTE = {
|
||||
...TERMINAL_DARK_THEME,
|
||||
background: '#101010',
|
||||
}
|
||||
|
||||
function makeService() {
|
||||
const config = createConfigStore(
|
||||
join(mkdtempSync(join(tmpdir(), 'sim-desktop-settings-')), 'settings.json'),
|
||||
@@ -21,6 +27,11 @@ function makeService() {
|
||||
const setTrayEnabled = vi.fn()
|
||||
const setBrowserEnabled = vi.fn()
|
||||
const setTerminalEnabled = vi.fn()
|
||||
const setBrowserTheme = vi.fn()
|
||||
const setBrowserDefaultZoom = vi.fn()
|
||||
const setTerminalDefaultZoom = vi.fn()
|
||||
const onBrowserThemeChanged = vi.fn()
|
||||
const chooseBrowserDownloadDirectory = vi.fn(async () => '/tmp/custom-downloads')
|
||||
const service = createDesktopSettingsService({
|
||||
config,
|
||||
getMainWindow: () => window,
|
||||
@@ -29,6 +40,12 @@ function makeService() {
|
||||
setTrayEnabled,
|
||||
setBrowserEnabled,
|
||||
setTerminalEnabled,
|
||||
setBrowserTheme,
|
||||
setBrowserDefaultZoom,
|
||||
setTerminalDefaultZoom,
|
||||
onBrowserThemeChanged,
|
||||
getDefaultBrowserDownloadDirectory: () => '/tmp/Downloads',
|
||||
chooseBrowserDownloadDirectory,
|
||||
})
|
||||
return {
|
||||
config,
|
||||
@@ -38,6 +55,11 @@ function makeService() {
|
||||
setTrayEnabled,
|
||||
setBrowserEnabled,
|
||||
setTerminalEnabled,
|
||||
setBrowserTheme,
|
||||
setBrowserDefaultZoom,
|
||||
setTerminalDefaultZoom,
|
||||
onBrowserThemeChanged,
|
||||
chooseBrowserDownloadDirectory,
|
||||
service,
|
||||
}
|
||||
}
|
||||
@@ -87,6 +109,123 @@ describe('desktop settings service', () => {
|
||||
expect(setTerminalEnabled).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('persists browser and terminal appearance with match-Sim defaults', () => {
|
||||
const { config, service, setBrowserTheme, onBrowserThemeChanged } = makeService()
|
||||
expect(service.getPreferences()).toMatchObject({
|
||||
browserTheme: 'app',
|
||||
terminalTheme: 'app',
|
||||
})
|
||||
|
||||
service.setAppearancePreference('browserTheme', 'dark')
|
||||
service.setAppearancePreference('terminalTheme', 'light')
|
||||
|
||||
expect(config.get('browserTheme')).toBe('dark')
|
||||
expect(config.get('terminalTheme')).toBe('light')
|
||||
expect(setBrowserTheme).toHaveBeenCalledWith('dark')
|
||||
expect(onBrowserThemeChanged).toHaveBeenCalledWith('dark')
|
||||
expect(service.getPreferences()).toMatchObject({
|
||||
browserTheme: 'dark',
|
||||
terminalTheme: 'light',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not announce a browser theme when the preference did not change', () => {
|
||||
const { service, onBrowserThemeChanged } = makeService()
|
||||
|
||||
service.setAppearancePreference('browserTheme', 'app')
|
||||
|
||||
expect(onBrowserThemeChanged).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('persists and applies the default browser zoom', () => {
|
||||
const { config, service, setBrowserDefaultZoom } = makeService()
|
||||
|
||||
expect(service.getPreferences().browserDefaultZoom).toBe(100)
|
||||
|
||||
const preferences = service.setBrowserDefaultZoom(125)
|
||||
|
||||
expect(config.get('browserDefaultZoom')).toBe(125)
|
||||
expect(setBrowserDefaultZoom).toHaveBeenCalledWith(125)
|
||||
expect(preferences.browserDefaultZoom).toBe(125)
|
||||
})
|
||||
|
||||
it('applies the stored browser zoom at startup', () => {
|
||||
const { config, service, setBrowserDefaultZoom } = makeService()
|
||||
config.set('browserDefaultZoom', 150)
|
||||
|
||||
service.applySystemPreferences()
|
||||
|
||||
expect(setBrowserDefaultZoom).toHaveBeenCalledWith(150)
|
||||
})
|
||||
|
||||
it('persists and applies the default terminal zoom', () => {
|
||||
const { config, service, setTerminalDefaultZoom } = makeService()
|
||||
|
||||
expect(service.getPreferences().terminalDefaultZoom).toBe(100)
|
||||
|
||||
const preferences = service.setTerminalDefaultZoom(125)
|
||||
|
||||
expect(config.get('terminalDefaultZoom')).toBe(125)
|
||||
expect(setTerminalDefaultZoom).toHaveBeenCalledWith(125)
|
||||
expect(preferences.terminalDefaultZoom).toBe(125)
|
||||
})
|
||||
|
||||
it('applies the stored terminal zoom at startup', () => {
|
||||
const { config, service, setTerminalDefaultZoom } = makeService()
|
||||
config.set('terminalDefaultZoom', 150)
|
||||
|
||||
service.applySystemPreferences()
|
||||
|
||||
expect(setTerminalDefaultZoom).toHaveBeenCalledWith(150)
|
||||
})
|
||||
|
||||
it('defaults browser downloads to Downloads and persists a chosen folder', async () => {
|
||||
const { chooseBrowserDownloadDirectory, config, service } = makeService()
|
||||
|
||||
expect(service.getPreferences().browserDownloadDirectory).toBe('/tmp/Downloads')
|
||||
|
||||
const preferences = await service.chooseBrowserDownloadDirectory()
|
||||
|
||||
expect(chooseBrowserDownloadDirectory).toHaveBeenCalledWith('/tmp/Downloads')
|
||||
expect(config.get('browserDownloadDirectory')).toBe('/tmp/custom-downloads')
|
||||
expect(preferences?.browserDownloadDirectory).toBe('/tmp/custom-downloads')
|
||||
})
|
||||
|
||||
it('caches and selects a Terminal or iTerm2 profile', () => {
|
||||
const { config, service } = makeService()
|
||||
|
||||
const preferences = service.selectTerminalProfile({
|
||||
id: 'iterm2:ocean',
|
||||
name: 'Ocean',
|
||||
source: 'iterm2',
|
||||
palette: IMPORTED_PALETTE,
|
||||
})
|
||||
|
||||
expect(config.get('terminalTheme')).toEqual({
|
||||
id: 'iterm2:ocean',
|
||||
name: 'Ocean',
|
||||
source: 'iterm2',
|
||||
palette: IMPORTED_PALETTE,
|
||||
})
|
||||
expect(preferences).toMatchObject({
|
||||
terminalTheme: { id: 'iterm2:ocean', name: 'Ocean' },
|
||||
})
|
||||
})
|
||||
|
||||
it('replaces a selected profile completely when a built-in theme is selected', () => {
|
||||
const { service } = makeService()
|
||||
|
||||
service.selectTerminalProfile({
|
||||
id: 'iterm2:ocean',
|
||||
name: 'Ocean',
|
||||
source: 'iterm2',
|
||||
palette: IMPORTED_PALETTE,
|
||||
})
|
||||
const preferences = service.setAppearancePreference('terminalTheme', 'light')
|
||||
|
||||
expect(preferences.terminalTheme).toBe('light')
|
||||
})
|
||||
|
||||
it('applies login-item changes only for packaged builds', () => {
|
||||
const { service } = makeService()
|
||||
service.setPreference('launchAtLogin', true)
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
import type {
|
||||
DesktopNotificationPayload,
|
||||
DesktopPreferenceKey,
|
||||
DesktopPreferences,
|
||||
import { isAbsolute } from 'node:path'
|
||||
import {
|
||||
type DesktopAppearanceTheme,
|
||||
type DesktopNotificationPayload,
|
||||
type DesktopPreferenceKey,
|
||||
type DesktopPreferences,
|
||||
type DesktopZoomPercent,
|
||||
isDesktopAppearanceTheme,
|
||||
isDesktopZoomPercent,
|
||||
isTerminalAppearanceTheme,
|
||||
type TerminalThemeProfile,
|
||||
} from '@sim/desktop-bridge'
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import { app, Notification } from 'electron'
|
||||
import type { ConfigStore } from '@/main/config'
|
||||
import { isSafeInternalPath } from '@/main/config'
|
||||
|
||||
/**
|
||||
* Every key the shell accepts over the settings IPC channel: the closed
|
||||
* `setPreference` union plus preferences added after the first release, which
|
||||
* ride their own optional bridge setters but share this channel.
|
||||
*/
|
||||
export type DesktopSettingKey =
|
||||
| DesktopPreferenceKey
|
||||
| 'trayEnabled'
|
||||
| 'browserEnabled'
|
||||
| 'terminalEnabled'
|
||||
export type DesktopAppearanceSettingKey = 'browserTheme' | 'terminalTheme'
|
||||
|
||||
const PREFERENCE_KEYS: ReadonlySet<string> = new Set<DesktopSettingKey>([
|
||||
const PREFERENCE_KEYS: ReadonlySet<string> = new Set<DesktopPreferenceKey>([
|
||||
'notificationsEnabled',
|
||||
'notificationSounds',
|
||||
'notificationsOnlyWhenUnfocused',
|
||||
@@ -30,13 +28,21 @@ const PREFERENCE_KEYS: ReadonlySet<string> = new Set<DesktopSettingKey>([
|
||||
'terminalEnabled',
|
||||
])
|
||||
|
||||
export function isDesktopPreferenceKey(value: unknown): value is DesktopSettingKey {
|
||||
export function isDesktopPreferenceKey(value: unknown): value is DesktopPreferenceKey {
|
||||
return typeof value === 'string' && PREFERENCE_KEYS.has(value)
|
||||
}
|
||||
|
||||
export interface DesktopSettingsService {
|
||||
getPreferences(): DesktopPreferences
|
||||
setPreference(key: DesktopSettingKey, value: boolean): DesktopPreferences
|
||||
setPreference(key: DesktopPreferenceKey, value: boolean): DesktopPreferences
|
||||
setAppearancePreference(
|
||||
key: DesktopAppearanceSettingKey,
|
||||
value: DesktopAppearanceTheme
|
||||
): DesktopPreferences
|
||||
setBrowserDefaultZoom(zoom: DesktopZoomPercent): DesktopPreferences
|
||||
setTerminalDefaultZoom(zoom: DesktopZoomPercent): DesktopPreferences
|
||||
selectTerminalProfile(profile: TerminalThemeProfile): DesktopPreferences
|
||||
chooseBrowserDownloadDirectory(): Promise<DesktopPreferences | null>
|
||||
notify(payload: DesktopNotificationPayload): boolean
|
||||
applySystemPreferences(): void
|
||||
}
|
||||
@@ -52,9 +58,29 @@ interface DesktopSettingsServiceDeps {
|
||||
setBrowserEnabled: (enabled: boolean) => void
|
||||
/** Ends every open agent shell when the surface is turned off. */
|
||||
setTerminalEnabled: (enabled: boolean) => void
|
||||
/** Repaints current browser tabs when their persisted appearance changes. */
|
||||
setBrowserTheme: (theme: DesktopAppearanceTheme) => void
|
||||
/** Applies a new default zoom to current and future browser tabs. */
|
||||
setBrowserDefaultZoom: (zoom: DesktopZoomPercent) => void
|
||||
/** Applies a new default zoom to current and future terminal tabs. */
|
||||
setTerminalDefaultZoom: (zoom: DesktopZoomPercent) => void
|
||||
/** Notifies renderer chrome after a user-initiated browser appearance change. */
|
||||
onBrowserThemeChanged?: (theme: DesktopAppearanceTheme) => void
|
||||
/** Returns the OS Downloads folder used when no custom location is stored. */
|
||||
getDefaultBrowserDownloadDirectory: () => string
|
||||
/** Shows the OS folder picker, initially focused on the current location. */
|
||||
chooseBrowserDownloadDirectory: (defaultPath: string) => Promise<string | null>
|
||||
}
|
||||
|
||||
function readPreferences(config: ConfigStore): DesktopPreferences {
|
||||
function readPreferences(
|
||||
config: ConfigStore,
|
||||
defaultBrowserDownloadDirectory: string
|
||||
): DesktopPreferences {
|
||||
const browserTheme = config.get('browserTheme')
|
||||
const browserDefaultZoom = config.get('browserDefaultZoom')
|
||||
const terminalDefaultZoom = config.get('terminalDefaultZoom')
|
||||
const storedBrowserDownloadDirectory = config.get('browserDownloadDirectory')
|
||||
const storedTerminalTheme = config.get('terminalTheme')
|
||||
return {
|
||||
notificationsEnabled: config.get('notificationsEnabled') ?? true,
|
||||
notificationSounds: config.get('notificationSounds') ?? true,
|
||||
@@ -64,6 +90,15 @@ function readPreferences(config: ConfigStore): DesktopPreferences {
|
||||
trayEnabled: config.get('trayEnabled') ?? true,
|
||||
browserEnabled: config.get('browserEnabled') ?? true,
|
||||
terminalEnabled: config.get('terminalEnabled') ?? true,
|
||||
browserTheme: isDesktopAppearanceTheme(browserTheme) ? browserTheme : 'app',
|
||||
browserDefaultZoom: isDesktopZoomPercent(browserDefaultZoom) ? browserDefaultZoom : 100,
|
||||
browserDownloadDirectory:
|
||||
typeof storedBrowserDownloadDirectory === 'string' &&
|
||||
isAbsolute(storedBrowserDownloadDirectory)
|
||||
? storedBrowserDownloadDirectory
|
||||
: defaultBrowserDownloadDirectory,
|
||||
terminalTheme: isTerminalAppearanceTheme(storedTerminalTheme) ? storedTerminalTheme : 'app',
|
||||
terminalDefaultZoom: isDesktopZoomPercent(terminalDefaultZoom) ? terminalDefaultZoom : 100,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +110,8 @@ function readPreferences(config: ConfigStore): DesktopPreferences {
|
||||
export function createDesktopSettingsService(
|
||||
deps: DesktopSettingsServiceDeps
|
||||
): DesktopSettingsService {
|
||||
const read = () => readPreferences(deps.config, deps.getDefaultBrowserDownloadDirectory())
|
||||
|
||||
const applyLaunchAtLogin = (enabled: boolean) => {
|
||||
// Registering an unpackaged Electron binary at login is surprising and
|
||||
// points at the wrong executable. Persist the dev preference, then apply
|
||||
@@ -85,7 +122,7 @@ export function createDesktopSettingsService(
|
||||
}
|
||||
|
||||
return {
|
||||
getPreferences: () => readPreferences(deps.config),
|
||||
getPreferences: read,
|
||||
setPreference(key, value) {
|
||||
deps.config.set(key, value)
|
||||
// Not debounced. Every branch below takes effect immediately, and
|
||||
@@ -113,10 +150,52 @@ export function createDesktopSettingsService(
|
||||
default:
|
||||
break
|
||||
}
|
||||
return readPreferences(deps.config)
|
||||
return read()
|
||||
},
|
||||
setAppearancePreference(key, value) {
|
||||
const previousBrowserTheme = key === 'browserTheme' ? read().browserTheme : undefined
|
||||
deps.config.set(key, value)
|
||||
deps.config.flush()
|
||||
if (key === 'browserTheme') {
|
||||
deps.setBrowserTheme(value)
|
||||
if (value !== previousBrowserTheme) {
|
||||
deps.onBrowserThemeChanged?.(value)
|
||||
}
|
||||
}
|
||||
return read()
|
||||
},
|
||||
setBrowserDefaultZoom(zoom) {
|
||||
deps.config.set('browserDefaultZoom', zoom)
|
||||
deps.config.flush()
|
||||
deps.setBrowserDefaultZoom(zoom)
|
||||
return read()
|
||||
},
|
||||
setTerminalDefaultZoom(zoom) {
|
||||
deps.config.set('terminalDefaultZoom', zoom)
|
||||
deps.config.flush()
|
||||
deps.setTerminalDefaultZoom(zoom)
|
||||
return read()
|
||||
},
|
||||
selectTerminalProfile(profile) {
|
||||
deps.config.set('terminalTheme', {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
source: profile.source,
|
||||
palette: { ...profile.palette },
|
||||
})
|
||||
deps.config.flush()
|
||||
return read()
|
||||
},
|
||||
async chooseBrowserDownloadDirectory() {
|
||||
const current = read().browserDownloadDirectory
|
||||
const selected = await deps.chooseBrowserDownloadDirectory(current)
|
||||
if (!selected || !isAbsolute(selected)) return null
|
||||
deps.config.set('browserDownloadDirectory', selected)
|
||||
deps.config.flush()
|
||||
return read()
|
||||
},
|
||||
notify(payload) {
|
||||
const preferences = readPreferences(deps.config)
|
||||
const preferences = read()
|
||||
if (!preferences.notificationsEnabled || !Notification.isSupported()) {
|
||||
return false
|
||||
}
|
||||
@@ -139,9 +218,12 @@ export function createDesktopSettingsService(
|
||||
return true
|
||||
},
|
||||
applySystemPreferences() {
|
||||
const preferences = readPreferences(deps.config)
|
||||
const preferences = read()
|
||||
applyLaunchAtLogin(preferences.launchAtLogin)
|
||||
deps.setAutoDownloadUpdates(preferences.autoDownloadUpdates)
|
||||
deps.setBrowserTheme(preferences.browserTheme)
|
||||
deps.setBrowserDefaultZoom(preferences.browserDefaultZoom)
|
||||
deps.setTerminalDefaultZoom(preferences.terminalDefaultZoom)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { join } from 'node:path'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { basename, extname, join } from 'node:path'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { Session } from 'electron'
|
||||
import { app } from 'electron'
|
||||
@@ -52,6 +53,27 @@ export function suggestedFilename(
|
||||
return `download-${stamp}${extension}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks a Chrome-style non-conflicting destination without overwriting an
|
||||
* existing download: `report.csv`, `report (1).csv`, and so on.
|
||||
*/
|
||||
export function uniqueDownloadPath(
|
||||
directory: string,
|
||||
rawFilename: string,
|
||||
pathExists: (path: string) => boolean = existsSync
|
||||
): string {
|
||||
const filename = sanitizeFilename(rawFilename) || 'download'
|
||||
const extension = extname(filename)
|
||||
const stem = basename(filename, extension)
|
||||
let candidate = join(directory, filename)
|
||||
let copy = 1
|
||||
while (pathExists(candidate)) {
|
||||
candidate = join(directory, `${stem} (${copy})${extension}`)
|
||||
copy += 1
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires will-download so exports, blob URLs, and presigned-URL downloads all
|
||||
* get a native save dialog with a sensible default name, and completed
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
import { join } from 'node:path'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import type { Session, WebContents } from 'electron'
|
||||
import { app, BrowserWindow, crashReporter, net, session } from 'electron'
|
||||
import type { OpenDialogOptions, Session, WebContents } from 'electron'
|
||||
import { app, BrowserWindow, crashReporter, dialog, net, session } from 'electron'
|
||||
import { newChatRoute, settingsRoute } from '@/main/app-routes'
|
||||
import {
|
||||
activateBrowserScope as activateAgentBrowserScope,
|
||||
clearBrowserProfile as clearAgentBrowserProfile,
|
||||
initDriver as initBrowserAgentDriver,
|
||||
} from '@/main/browser-agent/driver'
|
||||
import {
|
||||
canReportPanelBounds,
|
||||
capturePanelSnapshot as captureBrowserAgentPanelSnapshot,
|
||||
setPanelBounds as setBrowserAgentPanelBounds,
|
||||
setPanelOccluded as setBrowserAgentPanelOccluded,
|
||||
} from '@/main/browser-agent/panel'
|
||||
import {
|
||||
closeSession as closeAgentBrowserSession,
|
||||
closeFocusedTab as closeFocusedBrowserTab,
|
||||
reopenFocusedTab as reopenClosedBrowserTab,
|
||||
handleFocusedShortcut as handleFocusedBrowserShortcut,
|
||||
isBrowserScopeSuspended,
|
||||
quiesceBrowserSessions,
|
||||
setBrowserDefaultZoom as setAgentBrowserDefaultZoom,
|
||||
setBrowserAppearanceTheme as setAgentBrowserTheme,
|
||||
setPanelFocused as setBrowserAgentPanelFocused,
|
||||
} from '@/main/browser-agent/session'
|
||||
import {
|
||||
@@ -29,6 +34,7 @@ import {
|
||||
} from '@/main/config'
|
||||
import { attachContextMenu } from '@/main/context-menu'
|
||||
import { attachCspFallback } from '@/main/csp'
|
||||
import { DesktopChatSessionStore } from '@/main/desktop-chat-session-store'
|
||||
import { createDesktopSettingsService } from '@/main/desktop-settings'
|
||||
import { attachDownloadHandling } from '@/main/downloads'
|
||||
import { createAuthFlow, createConnectFlow, createHandoffManager } from '@/main/handoff'
|
||||
@@ -39,6 +45,7 @@ import { createEncryptedLocalFilesystemGrantStore } from '@/main/local-filesyste
|
||||
import { installApplicationMenu } from '@/main/menu'
|
||||
import { openExternalSafe } from '@/main/navigation'
|
||||
import { createEventLog } from '@/main/observability'
|
||||
import { ScopedEventRouter } from '@/main/scoped-event-router'
|
||||
import { installGlobalGuards } from '@/main/security-guards'
|
||||
import {
|
||||
createSessionLifecycleCoordinator,
|
||||
@@ -48,7 +55,7 @@ import {
|
||||
resolveStartRoute,
|
||||
} from '@/main/session-lifecycle'
|
||||
import { attachTelemetryPolicy } from '@/main/telemetry-policy'
|
||||
import { TerminalService } from '@/main/terminal'
|
||||
import { TerminalRegistry } from '@/main/terminal/registry'
|
||||
import { installTray, type TrayHandle } from '@/main/tray'
|
||||
import { checkForUpdatesInteractive, initUpdater, type UpdaterHandle } from '@/main/updater'
|
||||
import { createMainWindow, setupPermissionHandlers } from '@/main/window'
|
||||
@@ -79,14 +86,38 @@ function main(): void {
|
||||
|
||||
const config = createConfigStore(join(app.getPath('userData'), 'settings.json'))
|
||||
const events = createEventLog(join(app.getPath('userData'), 'logs'))
|
||||
const appOrigin = () => config.getOrigin()
|
||||
const desktopChatSessions = new DesktopChatSessionStore(
|
||||
join(app.getPath('userData'), 'desktop-chat-sessions.json')
|
||||
)
|
||||
const clearDesktopChatSessions = (): void => {
|
||||
try {
|
||||
desktopChatSessions.clear()
|
||||
} catch (error) {
|
||||
logger.error('Could not clear encrypted task resource state', {
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
const flushDesktopChatSessions = (phase: 'before-quit' | 'will-quit'): void => {
|
||||
if (!desktopChatSessions.flush()) {
|
||||
logger.warn('Could not flush encrypted task resource state', { phase })
|
||||
}
|
||||
}
|
||||
const localFilesystem = new LocalFilesystemService({
|
||||
grantStore: createEncryptedLocalFilesystemGrantStore(
|
||||
join(app.getPath('userData'), 'local-filesystem-grants.json')
|
||||
),
|
||||
})
|
||||
const terminal = new TerminalService({
|
||||
loadCwd: () => config.get('terminalCwd'),
|
||||
saveCwd: (cwd) => config.set('terminalCwd', cwd),
|
||||
const scopeEvents = new ScopedEventRouter()
|
||||
const terminal = new TerminalRegistry({
|
||||
load: (scopeId) => desktopChatSessions.getTerminal(appOrigin(), scopeId) ?? undefined,
|
||||
save: (scopeId, snapshot) => desktopChatSessions.setTerminal(appOrigin(), scopeId, snapshot),
|
||||
migrate: (fromScopeId, toScopeId) =>
|
||||
desktopChatSessions.migrateTerminal(appOrigin(), fromScopeId, toScopeId),
|
||||
disposeScope: (scopeId) => {
|
||||
desktopChatSessions.deleteScope(appOrigin(), scopeId)
|
||||
},
|
||||
})
|
||||
const preloadPath = join(__dirname, 'preload.cjs')
|
||||
|
||||
@@ -100,7 +131,6 @@ function main(): void {
|
||||
let updater: UpdaterHandle | null = null
|
||||
const configuredPartitions = new Set<string>()
|
||||
|
||||
const appOrigin = () => config.getOrigin()
|
||||
const allowHttpLocalhost = () => !app.isPackaged || appOrigin().startsWith('http://')
|
||||
const getWindows = () => [...windows].filter((win) => !win.isDestroyed())
|
||||
const getMainWindow = () => {
|
||||
@@ -213,11 +243,43 @@ function main(): void {
|
||||
events,
|
||||
getWindows,
|
||||
clearHandoffState: async () => {
|
||||
handoff.clear()
|
||||
tray?.clearRecentChats()
|
||||
await localFilesystem.forgetAll()
|
||||
try {
|
||||
handoff.clear()
|
||||
} catch (error) {
|
||||
logger.error('Could not clear sign-in handoff state', { error: getErrorMessage(error) })
|
||||
}
|
||||
try {
|
||||
tray?.clearRecentChats()
|
||||
} catch (error) {
|
||||
logger.error('Could not clear recent tasks', { error: getErrorMessage(error) })
|
||||
}
|
||||
// Shells are account-scoped runtime state. Leaving them alive across
|
||||
// sign-out would stream the previous account's output into the next
|
||||
// renderer and keep its local processes running invisibly.
|
||||
try {
|
||||
terminal.dispose()
|
||||
} catch (error) {
|
||||
logger.error('Could not stop account terminal sessions', {
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
}
|
||||
clearDesktopChatSessions()
|
||||
await localFilesystem.forgetAll().catch((error) => {
|
||||
logger.error('Could not clear local filesystem grants', {
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
})
|
||||
},
|
||||
clearBrowserProfile: async () => {
|
||||
try {
|
||||
await clearAgentBrowserProfile()
|
||||
} finally {
|
||||
// Browser profile teardown emits empty tab snapshots while closing
|
||||
// its live views. Clear once more afterward so those cannot recreate
|
||||
// account-scoped task descriptors after sign-out.
|
||||
clearDesktopChatSessions()
|
||||
}
|
||||
},
|
||||
clearBrowserProfile: clearAgentBrowserProfile,
|
||||
})
|
||||
return ses
|
||||
}
|
||||
@@ -381,6 +443,30 @@ function main(): void {
|
||||
setTerminalEnabled: (enabled) => {
|
||||
if (!enabled) terminal.dispose()
|
||||
},
|
||||
setBrowserTheme: setAgentBrowserTheme,
|
||||
setBrowserDefaultZoom: setAgentBrowserDefaultZoom,
|
||||
setTerminalDefaultZoom: (zoom) => {
|
||||
broadcast('terminal:default-zoom-changed', zoom)
|
||||
},
|
||||
onBrowserThemeChanged: (theme) => {
|
||||
const win = getMainWindow()
|
||||
if (!win || win.isDestroyed() || win.webContents.isDestroyed()) return
|
||||
win.webContents.send('browser-agent:appearance-theme-changed', theme)
|
||||
},
|
||||
getDefaultBrowserDownloadDirectory: () => app.getPath('downloads'),
|
||||
chooseBrowserDownloadDirectory: async (defaultPath) => {
|
||||
const options: OpenDialogOptions = {
|
||||
title: 'Choose Browser Downloads Folder',
|
||||
buttonLabel: 'Choose',
|
||||
defaultPath,
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
}
|
||||
const win = getMainWindow()
|
||||
const result = win
|
||||
? await dialog.showOpenDialog(win, options)
|
||||
: await dialog.showOpenDialog(options)
|
||||
return result.canceled ? null : (result.filePaths[0] ?? null)
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -410,12 +496,22 @@ function main(): void {
|
||||
tray?.destroy()
|
||||
tray = null
|
||||
localFilesystem.close()
|
||||
// Quiesce native pages before publishing the final encrypted descriptor
|
||||
// set. This prevents a navigation event racing the synchronous quit flush.
|
||||
quiesceBrowserSessions()
|
||||
terminal.dispose()
|
||||
flushDesktopChatSessions('before-quit')
|
||||
// Settings writes coalesce, so a change made in the last moments before
|
||||
// quit is still pending here.
|
||||
config.flush()
|
||||
})
|
||||
|
||||
app.on('will-quit', () => {
|
||||
// Final backstop for any descriptor dirtied while Electron was closing
|
||||
// windows after before-quit.
|
||||
flushDesktopChatSessions('will-quit')
|
||||
})
|
||||
|
||||
app.on('activate', () => {
|
||||
if (app.isReady() && !getMainWindow()) {
|
||||
void ensureMainWindow()
|
||||
@@ -423,9 +519,10 @@ function main(): void {
|
||||
})
|
||||
|
||||
void app.whenReady().then(async () => {
|
||||
// Use the same high-resolution source in packaged and unpackaged apps so
|
||||
// macOS renders every environment marker consistently in the Dock.
|
||||
if (process.platform === 'darwin') {
|
||||
// Packaged apps keep their native bundle icon so the Dock appearance does
|
||||
// not change when the process starts. Unpackaged runs have no branded
|
||||
// bundle, so they still need the channel-specific development icon.
|
||||
if (process.platform === 'darwin' && !app.isPackaged) {
|
||||
const channel = channelForOrigin(config.getOrigin())
|
||||
app.dock?.setIcon(join(__dirname, '..', 'static', DOCK_ICON_FOR_CHANNEL[channel]))
|
||||
}
|
||||
@@ -433,33 +530,60 @@ function main(): void {
|
||||
version: app.getVersion(),
|
||||
electron: process.versions.electron ?? '',
|
||||
})
|
||||
if (!desktopChatSessions.initialize()) {
|
||||
logger.warn(
|
||||
'Encrypted task resource storage is unavailable; browser and terminal state will remain memory-only'
|
||||
)
|
||||
}
|
||||
initBrowserAgentDriver(
|
||||
{
|
||||
onPageState: (state) => {
|
||||
broadcast('browser-agent:page-state', state)
|
||||
scopeEvents.sendBrowser(state.scopeId, 'browser-agent:page-state', state)
|
||||
},
|
||||
onTabsState: (state) => {
|
||||
broadcast('browser-agent:tabs-state', state)
|
||||
scopeEvents.sendBrowser(state.scopeId, 'browser-agent:tabs-state', state)
|
||||
},
|
||||
onSessionStatus: (alive) => {
|
||||
broadcast('browser-agent:session-status', alive)
|
||||
onSessionStatus: (alive, scopeId) => {
|
||||
scopeEvents.sendBrowser(scopeId, 'browser-agent:session-status', alive, scopeId)
|
||||
},
|
||||
onFillAvailability: (available) => {
|
||||
broadcast('browser-credentials:fill-availability', { available })
|
||||
onFillAvailability: (available, scopeId) => {
|
||||
scopeEvents.sendBrowser(scopeId, 'browser-credentials:fill-availability', {
|
||||
available,
|
||||
scopeId,
|
||||
})
|
||||
},
|
||||
onDownloadsChanged: (state) => {
|
||||
scopeEvents.sendBrowser(state.scopeId, 'browser-agent:downloads-state', state)
|
||||
},
|
||||
},
|
||||
getMainWindow,
|
||||
config
|
||||
config,
|
||||
{
|
||||
load: (scopeId) => desktopChatSessions.getBrowser(appOrigin(), scopeId),
|
||||
save: (scopeId, snapshot) => desktopChatSessions.setBrowser(appOrigin(), scopeId, snapshot),
|
||||
migrateScope: (fromScopeId, toScopeId) =>
|
||||
desktopChatSessions.migrateBrowser(appOrigin(), fromScopeId, toScopeId),
|
||||
disposeScope: (scopeId) => {
|
||||
desktopChatSessions.deleteScope(appOrigin(), scopeId)
|
||||
},
|
||||
},
|
||||
{
|
||||
getDirectory: () => desktopSettings.getPreferences().browserDownloadDirectory,
|
||||
}
|
||||
)
|
||||
await localFilesystem.initialize()
|
||||
terminal.setSink({
|
||||
data: (terminalId, data) => broadcast('terminal:data', terminalId, data),
|
||||
tabs: (state) => broadcast('terminal:tabs', state),
|
||||
command: (event) => broadcast('terminal:command', event),
|
||||
data: (scopeId, terminalId, data) =>
|
||||
scopeEvents.sendTerminal(scopeId, 'terminal:data', terminalId, data, scopeId),
|
||||
tabs: (scopeId, state) =>
|
||||
scopeEvents.sendTerminal(scopeId, 'terminal:tabs', { ...state, scopeId }),
|
||||
command: (scopeId, event) =>
|
||||
scopeEvents.sendTerminal(scopeId, 'terminal:command', { ...event, scopeId }),
|
||||
})
|
||||
registerIpcHandlers({
|
||||
appOrigin,
|
||||
allowHttpLocalhost,
|
||||
scopeEvents,
|
||||
retryLoad: (sender) => {
|
||||
const win = windowForContents(sender)
|
||||
if (win) loadHealthByWindow.get(win)?.retry()
|
||||
@@ -472,21 +596,56 @@ function main(): void {
|
||||
}),
|
||||
getWindowForContents: (sender) => windowForContents(sender) ?? null,
|
||||
browserPanel: {
|
||||
setBounds: (sender, bounds, anchor) => {
|
||||
activateScope: (sender, scopeId) => {
|
||||
const win = windowForContents(sender)
|
||||
if (win && focusedAppWindow() === win) {
|
||||
activateAgentBrowserScope(scopeId)
|
||||
}
|
||||
},
|
||||
setBounds: (sender, bounds, anchor, scopeId) => {
|
||||
const win = windowForContents(sender)
|
||||
if (!win) return
|
||||
if (bounds !== null && !canReportPanelBounds(win, focusedAppWindow())) {
|
||||
// A second window can keep reporting its old panel rect after this
|
||||
// task was soft-deleted elsewhere. Bounds are a heartbeat, not a
|
||||
// task-open signal, so they must never clear the suspension
|
||||
// tombstone and recreate the closed WebContents.
|
||||
if (bounds !== null && isBrowserScopeSuspended(scopeId)) return
|
||||
// A window may have become focused without its chat changing, so no
|
||||
// renderer activation effect reran. Its live bounds lease is the
|
||||
// authoritative signal to move the singleton compositor now.
|
||||
if (bounds !== null && focusedAppWindow() === win) {
|
||||
activateAgentBrowserScope(scopeId)
|
||||
}
|
||||
if (bounds !== null && !canReportPanelBounds(win, focusedAppWindow(), scopeId)) {
|
||||
return
|
||||
}
|
||||
setBrowserAgentPanelBounds(bounds, win, anchor)
|
||||
setBrowserAgentPanelBounds(bounds, win, anchor, scopeId)
|
||||
},
|
||||
setFocused: (sender, focused) => {
|
||||
setFocused: (sender, focused, scopeId) => {
|
||||
const win = windowForContents(sender)
|
||||
if (win) setBrowserAgentPanelFocused(focused, win)
|
||||
if (win) setBrowserAgentPanelFocused(focused, win, scopeId)
|
||||
},
|
||||
setOccluded: (sender, occluded) => {
|
||||
captureSnapshot: (sender, scopeId) => {
|
||||
const win = windowForContents(sender)
|
||||
if (win) setBrowserAgentPanelOccluded(occluded, win)
|
||||
return win ? captureBrowserAgentPanelSnapshot(win, scopeId) : Promise.resolve(null)
|
||||
},
|
||||
setOccluded: (sender, occluded, scopeId, force) => {
|
||||
const win = windowForContents(sender)
|
||||
if (!win) return false
|
||||
// A modal in a stale renderer must not resurrect a soft-deleted
|
||||
// Browser scope. There is no local native surface for that scope;
|
||||
// acknowledge only its forced hide/any reveal as scoped no-ops.
|
||||
if (isBrowserScopeSuspended(scopeId)) return !occluded || force === true
|
||||
// The focused window may open a modal before its next bounds frame
|
||||
// has transferred the singleton Browser from another app window.
|
||||
// Move the session scope first so the forced hide establishes the
|
||||
// new owner's hidden lease, rather than acknowledging a background
|
||||
// no-op and then attaching the view visibly on the bounds report.
|
||||
const resolvedScopeId =
|
||||
occluded && force && focusedAppWindow() === win
|
||||
? activateAgentBrowserScope(scopeId)
|
||||
: scopeId
|
||||
return setBrowserAgentPanelOccluded(occluded, win, resolvedScopeId, force)
|
||||
},
|
||||
},
|
||||
beginOAuthConnect: (providerId, scope) => connectFlow.beginConnectHandoff(providerId, scope),
|
||||
@@ -504,10 +663,9 @@ function main(): void {
|
||||
openSettings,
|
||||
newWindow: () => void createAndLoadAppWindow(),
|
||||
newChat: () => void openMainWindowAt(newChatRoute(config.get('lastRoute'))),
|
||||
closeFocusedBrowserTab: (win) => closeFocusedBrowserTab(win),
|
||||
reopenClosedBrowserTab: (win) => reopenClosedBrowserTab(win),
|
||||
closeFocusedTerminal: (win) => terminal.closeFocusedTerminal(win),
|
||||
reopenClosedTerminal: (win) => terminal.reopenClosedTerminal(win),
|
||||
handleFocusedResourceShortcut: (win, shortcut) =>
|
||||
terminal.handleFocusedShortcut(win, shortcut) ||
|
||||
handleFocusedBrowserShortcut(shortcut, win),
|
||||
toggleSidebar: () => getMainWindow()?.webContents.send('desktop:command', 'toggle-sidebar'),
|
||||
signOut: signOutFromMenu,
|
||||
checkForUpdates: () =>
|
||||
|
||||
@@ -20,10 +20,7 @@ function makeDeps(): MenuDeps {
|
||||
openSettings: vi.fn(),
|
||||
newWindow: vi.fn(),
|
||||
newChat: vi.fn(),
|
||||
closeFocusedTerminal: vi.fn(() => false),
|
||||
reopenClosedTerminal: vi.fn(() => false),
|
||||
closeFocusedBrowserTab: vi.fn(() => false),
|
||||
reopenClosedBrowserTab: vi.fn(() => false),
|
||||
handleFocusedResourceShortcut: vi.fn(() => false),
|
||||
toggleSidebar: vi.fn(),
|
||||
signOut: vi.fn(),
|
||||
checkForUpdates: vi.fn(),
|
||||
@@ -65,6 +62,7 @@ describe('buildMenuTemplate', () => {
|
||||
'quit',
|
||||
])
|
||||
expect(submenu(template, 'File').map((item) => item.label ?? item.role ?? item.type)).toEqual([
|
||||
'New Tab',
|
||||
'New Window',
|
||||
'New Chat',
|
||||
'separator',
|
||||
@@ -95,9 +93,9 @@ describe('buildMenuTemplate', () => {
|
||||
expect(view.some((item) => item.role === 'toggleDevTools')).toBe(false)
|
||||
})
|
||||
|
||||
it('routes the close accelerator through the focused browser tab before closing a window', () => {
|
||||
const closeFocusedBrowserTab = vi.fn((_win: BrowserWindow | null) => true)
|
||||
const deps = Object.assign(makeDeps(), { closeFocusedBrowserTab })
|
||||
it('routes the close accelerator through the focused resource before closing a window', () => {
|
||||
const handleFocusedResourceShortcut = vi.fn(() => true)
|
||||
const deps = Object.assign(makeDeps(), { handleFocusedResourceShortcut })
|
||||
const closeItem = submenu(buildMenuTemplate(deps), 'File').find(
|
||||
(item) => item.accelerator === 'CmdOrCtrl+W'
|
||||
)
|
||||
@@ -112,21 +110,22 @@ describe('buildMenuTemplate', () => {
|
||||
) => void
|
||||
click({}, focusedWindow)
|
||||
|
||||
expect(closeFocusedBrowserTab).toHaveBeenCalledWith(focusedWindow)
|
||||
expect(handleFocusedResourceShortcut).toHaveBeenCalledWith(focusedWindow, 'close-tab')
|
||||
expect(focusedWindow.close).not.toHaveBeenCalled()
|
||||
|
||||
closeFocusedBrowserTab.mockReturnValue(false)
|
||||
handleFocusedResourceShortcut.mockReturnValue(false)
|
||||
click({}, focusedWindow)
|
||||
expect(focusedWindow.close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('routes the reopen accelerator through the focused browser session', () => {
|
||||
const reopenClosedBrowserTab = vi.fn((_win: BrowserWindow | null) => true)
|
||||
it('routes new and reopen accelerators through the focused resource', () => {
|
||||
const handleFocusedResourceShortcut = vi.fn(() => true)
|
||||
const template = buildMenuTemplate(
|
||||
Object.assign(makeDeps(), {
|
||||
reopenClosedBrowserTab,
|
||||
handleFocusedResourceShortcut,
|
||||
})
|
||||
)
|
||||
const newItem = submenu(template, 'File').find((item) => item.accelerator === 'CmdOrCtrl+T')
|
||||
const reopenItem = submenu(template, 'File').find(
|
||||
(item) => item.accelerator === 'CmdOrCtrl+Shift+T'
|
||||
)
|
||||
@@ -136,11 +135,79 @@ describe('buildMenuTemplate', () => {
|
||||
accelerator: 'CmdOrCtrl+Shift+T',
|
||||
})
|
||||
const focusedWindow = new BrowserWindow()
|
||||
;(newItem?.click as unknown as (menuItem: unknown, browserWindow: BrowserWindow) => void)(
|
||||
{},
|
||||
focusedWindow
|
||||
)
|
||||
;(reopenItem?.click as unknown as (menuItem: unknown, browserWindow: BrowserWindow) => void)(
|
||||
{},
|
||||
focusedWindow
|
||||
)
|
||||
expect(reopenClosedBrowserTab).toHaveBeenCalledWith(focusedWindow)
|
||||
expect(handleFocusedResourceShortcut).toHaveBeenNthCalledWith(1, focusedWindow, 'new-tab')
|
||||
expect(handleFocusedResourceShortcut).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
focusedWindow,
|
||||
'reopen-closed-tab'
|
||||
)
|
||||
})
|
||||
|
||||
it('routes reload through the focused resource before falling back to the Sim renderer', () => {
|
||||
const handleFocusedResourceShortcut = vi.fn(() => true)
|
||||
const template = buildMenuTemplate(
|
||||
Object.assign(makeDeps(), {
|
||||
handleFocusedResourceShortcut,
|
||||
})
|
||||
)
|
||||
const reloadItem = submenu(template, 'View').find((item) => item.accelerator === 'CmdOrCtrl+R')
|
||||
const focusedWindow = new BrowserWindow()
|
||||
const click = reloadItem?.click as unknown as (
|
||||
menuItem: unknown,
|
||||
browserWindow: BrowserWindow
|
||||
) => void
|
||||
|
||||
click({}, focusedWindow)
|
||||
|
||||
expect(handleFocusedResourceShortcut).toHaveBeenCalledWith(focusedWindow, 'reload-or-clear')
|
||||
expect(focusedWindow.webContents.reload).not.toHaveBeenCalled()
|
||||
|
||||
handleFocusedResourceShortcut.mockReturnValue(false)
|
||||
click({}, focusedWindow)
|
||||
expect(focusedWindow.webContents.reload).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('routes zoom accelerators to the focused resource before changing Sim zoom', () => {
|
||||
const handleFocusedResourceShortcut = vi.fn(() => true)
|
||||
const deps = Object.assign(makeDeps(), { handleFocusedResourceShortcut })
|
||||
const template = buildMenuTemplate(deps)
|
||||
const focusedWindow = new BrowserWindow()
|
||||
const view = submenu(template, 'View')
|
||||
const commands = [
|
||||
{ accelerator: 'CmdOrCtrl+Plus', action: 'in' },
|
||||
{ accelerator: 'CmdOrCtrl+-', action: 'out' },
|
||||
{ accelerator: 'CmdOrCtrl+0', action: 'reset' },
|
||||
] as const
|
||||
|
||||
for (const command of commands) {
|
||||
const item = view.find((entry) => entry.accelerator === command.accelerator)
|
||||
;(item?.click as unknown as (menuItem: unknown, browserWindow: BrowserWindow) => void)(
|
||||
{},
|
||||
focusedWindow
|
||||
)
|
||||
expect(handleFocusedResourceShortcut).toHaveBeenLastCalledWith(
|
||||
focusedWindow,
|
||||
`zoom-${command.action}`
|
||||
)
|
||||
}
|
||||
expect(focusedWindow.webContents.setZoomLevel).not.toHaveBeenCalled()
|
||||
|
||||
handleFocusedResourceShortcut.mockReturnValue(false)
|
||||
const actualSize = view.find((entry) => entry.accelerator === 'CmdOrCtrl+0')
|
||||
;(actualSize?.click as unknown as (menuItem: unknown, browserWindow: BrowserWindow) => void)(
|
||||
{},
|
||||
focusedWindow
|
||||
)
|
||||
expect(focusedWindow.webContents.setZoomLevel).toHaveBeenCalledWith(0)
|
||||
expect(deps.config.set).toHaveBeenCalledWith('zoomLevel', 0)
|
||||
})
|
||||
|
||||
it('offers the standard new-window command', () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { MenuItemConstructorOptions } from 'electron'
|
||||
import { app, BrowserWindow, Menu } from 'electron'
|
||||
import type { ConfigStore } from '@/main/config'
|
||||
import { openExternalSafe } from '@/main/navigation'
|
||||
import type { FocusedResourceShortcut } from '@/main/resource-shortcuts'
|
||||
|
||||
const DOCS_URL = 'https://docs.sim.ai'
|
||||
const STATUS_URL = 'https://status.sim.ai'
|
||||
@@ -14,15 +15,15 @@ export interface MenuDeps {
|
||||
openSettings: () => void
|
||||
newWindow: () => void
|
||||
newChat: () => void
|
||||
closeFocusedBrowserTab: (win: BrowserWindow | null) => boolean
|
||||
reopenClosedBrowserTab: (win: BrowserWindow | null) => boolean
|
||||
/**
|
||||
* Terminal counterparts. Menu accelerators are global, so Cmd-W and
|
||||
* Cmd-Shift-T reach here whatever the user is looking at; each panel gets
|
||||
* asked whether the keystroke was meant for it before the window acts.
|
||||
* Menu accelerators are global, so the focused Browser or Terminal gets the
|
||||
* first chance to claim every resource shortcut before the Sim window uses
|
||||
* its application-level fallback.
|
||||
*/
|
||||
closeFocusedTerminal: (win: BrowserWindow | null) => boolean
|
||||
reopenClosedTerminal: (win: BrowserWindow | null) => boolean
|
||||
handleFocusedResourceShortcut: (
|
||||
win: BrowserWindow | null,
|
||||
shortcut: FocusedResourceShortcut
|
||||
) => boolean
|
||||
toggleSidebar: () => void
|
||||
signOut: () => void
|
||||
checkForUpdates: () => void
|
||||
@@ -41,12 +42,24 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[]
|
||||
}
|
||||
}
|
||||
|
||||
const setZoom = (resolve: (current: number) => number) =>
|
||||
withWindow((win) => {
|
||||
/** Accelerators fire on whichever window has focus; fall back to the main one. */
|
||||
const focusedOrMain = (focusedWindow: unknown): BrowserWindow | null =>
|
||||
focusedWindow instanceof BrowserWindow ? focusedWindow : deps.getMainWindow()
|
||||
|
||||
const setZoom = (
|
||||
action: 'in' | 'out' | 'reset'
|
||||
): NonNullable<MenuItemConstructorOptions['click']> => {
|
||||
const resolve = (current: number) =>
|
||||
action === 'reset' ? 0 : action === 'in' ? current + ZOOM_STEP : current - ZOOM_STEP
|
||||
return (_item, focusedWindow) => {
|
||||
const win = focusedOrMain(focusedWindow)
|
||||
if (!win || win.isDestroyed()) return
|
||||
if (deps.handleFocusedResourceShortcut(win, `zoom-${action}`)) return
|
||||
const level = resolve(win.webContents.getZoomLevel())
|
||||
win.webContents.setZoomLevel(level)
|
||||
deps.config.set('zoomLevel', level)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const viewSubmenu: MenuItemConstructorOptions[] = [
|
||||
{
|
||||
@@ -73,20 +86,17 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[]
|
||||
{
|
||||
label: 'Reload',
|
||||
accelerator: 'CmdOrCtrl+R',
|
||||
click: withWindow((win) => win.webContents.reload()),
|
||||
click: (_item, focusedWindow) => {
|
||||
const win = focusedOrMain(focusedWindow)
|
||||
if (!win || win.isDestroyed()) return
|
||||
if (deps.handleFocusedResourceShortcut(win, 'reload-or-clear')) return
|
||||
win.webContents.reload()
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ label: 'Actual Size', accelerator: 'CmdOrCtrl+0', click: setZoom(() => 0) },
|
||||
{
|
||||
label: 'Zoom In',
|
||||
accelerator: 'CmdOrCtrl+Plus',
|
||||
click: setZoom((current) => current + ZOOM_STEP),
|
||||
},
|
||||
{
|
||||
label: 'Zoom Out',
|
||||
accelerator: 'CmdOrCtrl+-',
|
||||
click: setZoom((current) => current - ZOOM_STEP),
|
||||
},
|
||||
{ label: 'Actual Size', accelerator: 'CmdOrCtrl+0', click: setZoom('reset') },
|
||||
{ label: 'Zoom In', accelerator: 'CmdOrCtrl+Plus', click: setZoom('in') },
|
||||
{ label: 'Zoom Out', accelerator: 'CmdOrCtrl+-', click: setZoom('out') },
|
||||
{ type: 'separator' },
|
||||
]
|
||||
viewSubmenu.push({ role: 'togglefullscreen' })
|
||||
@@ -112,6 +122,13 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[]
|
||||
{
|
||||
label: 'File',
|
||||
submenu: [
|
||||
{
|
||||
label: 'New Tab',
|
||||
accelerator: 'CmdOrCtrl+T',
|
||||
click: (_item, focusedWindow) => {
|
||||
deps.handleFocusedResourceShortcut(focusedOrMain(focusedWindow), 'new-tab')
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'New Window',
|
||||
accelerator: 'CmdOrCtrl+Shift+N',
|
||||
@@ -123,22 +140,15 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[]
|
||||
label: 'Reopen Closed Tab',
|
||||
accelerator: 'CmdOrCtrl+Shift+T',
|
||||
click: (_item, focusedWindow) => {
|
||||
const win =
|
||||
focusedWindow instanceof BrowserWindow ? focusedWindow : deps.getMainWindow()
|
||||
if (deps.reopenClosedTerminal(win)) return
|
||||
deps.reopenClosedBrowserTab(win)
|
||||
deps.handleFocusedResourceShortcut(focusedOrMain(focusedWindow), 'reopen-closed-tab')
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Close Window',
|
||||
accelerator: 'CmdOrCtrl+W',
|
||||
click: (_item, focusedWindow) => {
|
||||
const win =
|
||||
focusedWindow instanceof BrowserWindow ? focusedWindow : deps.getMainWindow()
|
||||
// Both panels are asked window-scoped, so a claim made in one window
|
||||
// cannot answer an accelerator fired in another.
|
||||
if (deps.closeFocusedTerminal(win)) return
|
||||
if (deps.closeFocusedBrowserTab(win)) return
|
||||
const win = focusedOrMain(focusedWindow)
|
||||
if (deps.handleFocusedResourceShortcut(win, 'close-tab')) return
|
||||
if (win && !win.isDestroyed()) win.close()
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { DesktopZoomAction } from '@sim/desktop-bridge'
|
||||
|
||||
/**
|
||||
* Commands claimed by whichever embedded resource currently owns keyboard
|
||||
* focus. The application menu sees these before an embedded page or xterm
|
||||
* does, so Browser and Terminal must resolve them at this shared boundary.
|
||||
*/
|
||||
export type FocusedResourceShortcut =
|
||||
| 'new-tab'
|
||||
| 'reopen-closed-tab'
|
||||
| 'close-tab'
|
||||
| 'reload-or-clear'
|
||||
| `zoom-${DesktopZoomAction}`
|
||||
|
||||
export function zoomActionForShortcut(shortcut: `zoom-${DesktopZoomAction}`): DesktopZoomAction {
|
||||
switch (shortcut) {
|
||||
case 'zoom-in':
|
||||
return 'in'
|
||||
case 'zoom-out':
|
||||
return 'out'
|
||||
case 'zoom-reset':
|
||||
return 'reset'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { WebContents } from 'electron'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ScopedEventRouter } from '@/main/scoped-event-router'
|
||||
|
||||
type EventListener = (...args: unknown[]) => void
|
||||
|
||||
class FakeContents {
|
||||
readonly send = vi.fn()
|
||||
private destroyed = false
|
||||
private readonly listeners = new Map<string, Set<EventListener>>()
|
||||
|
||||
isDestroyed(): boolean {
|
||||
return this.destroyed
|
||||
}
|
||||
|
||||
on(channel: string, listener: EventListener): this {
|
||||
const listeners = this.listeners.get(channel) ?? new Set<EventListener>()
|
||||
listeners.add(listener)
|
||||
this.listeners.set(channel, listeners)
|
||||
return this
|
||||
}
|
||||
|
||||
once(channel: string, listener: EventListener): this {
|
||||
const wrapped: EventListener = (...args) => {
|
||||
this.listeners.get(channel)?.delete(wrapped)
|
||||
listener(...args)
|
||||
}
|
||||
return this.on(channel, wrapped)
|
||||
}
|
||||
|
||||
navigate(): void {
|
||||
this.emit('did-start-navigation', {}, 'https://sim.ai/workspace/ws', false, true)
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.destroyed = true
|
||||
this.emit('destroyed')
|
||||
}
|
||||
|
||||
private emit(channel: string, ...args: unknown[]): void {
|
||||
for (const listener of [...(this.listeners.get(channel) ?? [])]) listener(...args)
|
||||
}
|
||||
}
|
||||
|
||||
function webContents(): { fake: FakeContents; contents: WebContents } {
|
||||
const fake = new FakeContents()
|
||||
return { fake, contents: fake as unknown as WebContents }
|
||||
}
|
||||
|
||||
describe('ScopedEventRouter', () => {
|
||||
it('sends resource events only to renderers activated for the matching scope', () => {
|
||||
const router = new ScopedEventRouter()
|
||||
const chatA = webContents()
|
||||
const alsoChatA = webContents()
|
||||
const chatB = webContents()
|
||||
|
||||
router.activateTerminal(chatA.contents, 'chat-a')
|
||||
router.activateTerminal(alsoChatA.contents, 'chat-a')
|
||||
router.activateTerminal(chatB.contents, 'chat-b')
|
||||
router.sendTerminal('chat-a', 'terminal:data', 'terminal-1', 'secret', 'chat-a')
|
||||
|
||||
expect(chatA.fake.send).toHaveBeenCalledWith('terminal:data', 'terminal-1', 'secret', 'chat-a')
|
||||
expect(alsoChatA.fake.send).toHaveBeenCalledOnce()
|
||||
expect(chatB.fake.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tracks browser and terminal activation independently', () => {
|
||||
const router = new ScopedEventRouter()
|
||||
const renderer = webContents()
|
||||
|
||||
router.activateBrowser(renderer.contents, 'browser-chat')
|
||||
router.activateTerminal(renderer.contents, 'terminal-chat')
|
||||
router.sendBrowser('browser-chat', 'browser-agent:tabs-state', { scopeId: 'browser-chat' })
|
||||
router.sendTerminal('browser-chat', 'terminal:tabs', { scopeId: 'browser-chat' })
|
||||
router.sendTerminal('terminal-chat', 'terminal:tabs', { scopeId: 'terminal-chat' })
|
||||
|
||||
expect(renderer.fake.send).toHaveBeenCalledTimes(2)
|
||||
expect(renderer.fake.send).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'browser-agent:tabs-state',
|
||||
expect.objectContaining({ scopeId: 'browser-chat' })
|
||||
)
|
||||
expect(renderer.fake.send).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'terminal:tabs',
|
||||
expect.objectContaining({ scopeId: 'terminal-chat' })
|
||||
)
|
||||
})
|
||||
|
||||
it('stops delivery to the prior scope as soon as a renderer activates another chat', () => {
|
||||
const router = new ScopedEventRouter()
|
||||
const renderer = webContents()
|
||||
|
||||
router.activateBrowser(renderer.contents, 'chat-a')
|
||||
router.activateBrowser(renderer.contents, 'chat-b')
|
||||
router.sendBrowser('chat-a', 'browser-agent:page-state', { scopeId: 'chat-a' })
|
||||
router.sendBrowser('chat-b', 'browser-agent:page-state', { scopeId: 'chat-b' })
|
||||
|
||||
expect(renderer.fake.send).toHaveBeenCalledOnce()
|
||||
expect(renderer.fake.send).toHaveBeenCalledWith(
|
||||
'browser-agent:page-state',
|
||||
expect.objectContaining({ scopeId: 'chat-b' })
|
||||
)
|
||||
})
|
||||
|
||||
it('forgets activation on navigation and destruction', () => {
|
||||
const router = new ScopedEventRouter()
|
||||
const navigated = webContents()
|
||||
const destroyed = webContents()
|
||||
|
||||
router.activateTerminal(navigated.contents, 'chat-a')
|
||||
router.activateTerminal(destroyed.contents, 'chat-a')
|
||||
navigated.fake.navigate()
|
||||
destroyed.fake.destroy()
|
||||
router.sendTerminal('chat-a', 'terminal:data', 'terminal-1', 'secret', 'chat-a')
|
||||
|
||||
expect(navigated.fake.send).not.toHaveBeenCalled()
|
||||
expect(destroyed.fake.send).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { WebContents } from 'electron'
|
||||
|
||||
interface SurfaceRoutes {
|
||||
activeByContents: WeakMap<WebContents, string>
|
||||
contentsByScope: Map<string, Set<WebContents>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes chat-owned resource events only to renderers currently showing that
|
||||
* chat. Browser and terminal activation are tracked separately because either
|
||||
* surface can mount or unmount without the other.
|
||||
*/
|
||||
export class ScopedEventRouter {
|
||||
private readonly browser = this.createSurfaceRoutes()
|
||||
private readonly terminal = this.createSurfaceRoutes()
|
||||
private readonly observedContents = new WeakSet<WebContents>()
|
||||
|
||||
activateBrowser(contents: WebContents, scopeId: string): void {
|
||||
this.activate(this.browser, contents, scopeId)
|
||||
}
|
||||
|
||||
activateTerminal(contents: WebContents, scopeId: string): void {
|
||||
this.activate(this.terminal, contents, scopeId)
|
||||
}
|
||||
|
||||
sendBrowser(scopeId: string, channel: string, ...args: unknown[]): void {
|
||||
this.send(this.browser, scopeId, channel, args)
|
||||
}
|
||||
|
||||
sendTerminal(scopeId: string, channel: string, ...args: unknown[]): void {
|
||||
this.send(this.terminal, scopeId, channel, args)
|
||||
}
|
||||
|
||||
private createSurfaceRoutes(): SurfaceRoutes {
|
||||
return {
|
||||
activeByContents: new WeakMap<WebContents, string>(),
|
||||
contentsByScope: new Map<string, Set<WebContents>>(),
|
||||
}
|
||||
}
|
||||
|
||||
private activate(routes: SurfaceRoutes, contents: WebContents, scopeId: string): void {
|
||||
const previous = routes.activeByContents.get(contents)
|
||||
if (previous === scopeId) return
|
||||
|
||||
if (previous) {
|
||||
this.removeFromScope(routes, contents, previous)
|
||||
}
|
||||
|
||||
routes.activeByContents.set(contents, scopeId)
|
||||
const recipients = routes.contentsByScope.get(scopeId) ?? new Set<WebContents>()
|
||||
recipients.add(contents)
|
||||
routes.contentsByScope.set(scopeId, recipients)
|
||||
this.observe(contents)
|
||||
}
|
||||
|
||||
private observe(contents: WebContents): void {
|
||||
if (this.observedContents.has(contents)) return
|
||||
this.observedContents.add(contents)
|
||||
|
||||
contents.once('destroyed', () => this.forget(contents))
|
||||
contents.on('did-start-navigation', (_event, _url, isInPlace, isMainFrame) => {
|
||||
// A full main-frame navigation invalidates the renderer that performed
|
||||
// the activation. A reloaded app page must activate its chat again.
|
||||
if (!isInPlace && isMainFrame) this.forget(contents)
|
||||
})
|
||||
}
|
||||
|
||||
private forget(contents: WebContents): void {
|
||||
this.forgetSurface(this.browser, contents)
|
||||
this.forgetSurface(this.terminal, contents)
|
||||
}
|
||||
|
||||
private forgetSurface(routes: SurfaceRoutes, contents: WebContents): void {
|
||||
const scopeId = routes.activeByContents.get(contents)
|
||||
if (!scopeId) return
|
||||
routes.activeByContents.delete(contents)
|
||||
this.removeFromScope(routes, contents, scopeId)
|
||||
}
|
||||
|
||||
private removeFromScope(routes: SurfaceRoutes, contents: WebContents, scopeId: string): void {
|
||||
const recipients = routes.contentsByScope.get(scopeId)
|
||||
if (!recipients) return
|
||||
recipients.delete(contents)
|
||||
if (recipients.size === 0) routes.contentsByScope.delete(scopeId)
|
||||
}
|
||||
|
||||
private send(routes: SurfaceRoutes, scopeId: string, channel: string, args: unknown[]): void {
|
||||
const recipients = routes.contentsByScope.get(scopeId)
|
||||
if (!recipients) return
|
||||
|
||||
for (const contents of [...recipients]) {
|
||||
if (contents.isDestroyed() || routes.activeByContents.get(contents) !== scopeId) {
|
||||
this.removeFromScope(routes, contents, scopeId)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
contents.send(channel, ...args)
|
||||
} catch {
|
||||
// A WebContents can enter teardown between isDestroyed() and send().
|
||||
// Removing it also prevents repeated exceptions from high-volume PTY
|
||||
// output while Electron finishes closing the window.
|
||||
this.forget(contents)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -191,6 +191,27 @@ describe('tearDownSession', () => {
|
||||
expect(clearStorageData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('continues clearing account state when local teardown fails', async () => {
|
||||
const clearStorageData = vi.fn(async () => {})
|
||||
const clearBrowserProfile = vi.fn(async () => {})
|
||||
const session = { clearStorageData } as unknown as Session
|
||||
|
||||
await expect(
|
||||
tearDownSession(
|
||||
session,
|
||||
async () => {
|
||||
throw new Error('local store unavailable')
|
||||
},
|
||||
{ filePath: '/tmp/events.log', record: vi.fn() },
|
||||
clearBrowserProfile,
|
||||
async () => {}
|
||||
)
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
expect(clearBrowserProfile).toHaveBeenCalledOnce()
|
||||
expect(clearStorageData).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('still clears local state when the server-side revoke fails', async () => {
|
||||
// Offline sign-out must not strand the user signed in locally.
|
||||
const clearStorageData = vi.fn(async () => {})
|
||||
|
||||
@@ -262,11 +262,15 @@ export async function tearDownSession(
|
||||
// browser-profile clear is: failing to clear something is bad, failing to
|
||||
// sign out is worse.
|
||||
await revokeSession().catch((error) => logger.error('Session revoke failed', { error }))
|
||||
await clearHandoffState()
|
||||
await Promise.resolve(clearHandoffState()).catch((error) =>
|
||||
logger.error('Local account-state teardown failed', { error })
|
||||
)
|
||||
await clearBrowserProfile().catch((error) =>
|
||||
logger.error('Browser profile teardown failed', { error })
|
||||
)
|
||||
await session.clearStorageData({ storages: [...CLEARED_STORAGES] })
|
||||
await session
|
||||
.clearStorageData({ storages: [...CLEARED_STORAGES] })
|
||||
.catch((error) => logger.error('App partition teardown failed', { error }))
|
||||
}
|
||||
|
||||
export interface SessionLifecycleDeps {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { TERMINAL_DARK_THEME } from '@sim/desktop-bridge'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseTerminalThemeProfiles } from '@/main/terminal-themes'
|
||||
|
||||
const PALETTE = {
|
||||
...TERMINAL_DARK_THEME,
|
||||
background: '#101010',
|
||||
}
|
||||
|
||||
function profile(id: string, overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id,
|
||||
name: 'Ocean',
|
||||
source: 'iterm2',
|
||||
palette: PALETTE,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('parseTerminalThemeProfiles', () => {
|
||||
it('accepts color-only Terminal and iTerm2 profiles', () => {
|
||||
expect(parseTerminalThemeProfiles([profile('iterm2:ocean')])).toEqual([profile('iterm2:ocean')])
|
||||
})
|
||||
|
||||
it('drops malformed colors and unsupported applications', () => {
|
||||
expect(
|
||||
parseTerminalThemeProfiles([
|
||||
profile('bad-color', { palette: { ...PALETTE, background: 'rgb(0, 0, 0)' } }),
|
||||
profile('bad-source', { source: 'warp' }),
|
||||
])
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps only the first profile when ids collide', () => {
|
||||
expect(
|
||||
parseTerminalThemeProfiles([
|
||||
profile('terminal:basic', {
|
||||
name: 'Basic',
|
||||
source: 'terminal',
|
||||
}),
|
||||
profile('terminal:basic', {
|
||||
name: 'Impostor',
|
||||
source: 'terminal',
|
||||
}),
|
||||
])
|
||||
).toEqual([
|
||||
profile('terminal:basic', {
|
||||
name: 'Basic',
|
||||
source: 'terminal',
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,207 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import {
|
||||
isTerminalSelectedProfile,
|
||||
TERMINAL_DARK_THEME,
|
||||
TERMINAL_LIGHT_THEME,
|
||||
TERMINAL_THEME_ANSI_KEYS,
|
||||
type TerminalThemeProfile,
|
||||
} from '@sim/desktop-bridge'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const logger = createLogger('TerminalThemes')
|
||||
|
||||
const JXA_SCRIPT = `
|
||||
ObjC.import('AppKit')
|
||||
|
||||
function clamp(value) {
|
||||
const number = Number(value)
|
||||
return Number.isFinite(number) ? Math.min(1, Math.max(0, number)) : 0
|
||||
}
|
||||
|
||||
function hexComponent(value) {
|
||||
return Math.round(clamp(value) * 255).toString(16).padStart(2, '0')
|
||||
}
|
||||
|
||||
function hex(red, green, blue) {
|
||||
return '#' + hexComponent(red) + hexComponent(green) + hexComponent(blue)
|
||||
}
|
||||
|
||||
function appKitColor(profile, key, fallback) {
|
||||
try {
|
||||
const data = profile.objectForKey(key)
|
||||
if (!data) return fallback
|
||||
const archived = $.NSKeyedUnarchiver.unarchiveObjectWithData(data)
|
||||
const color = archived && archived.colorUsingColorSpace($.NSColorSpace.sRGBColorSpace)
|
||||
if (!color) return fallback
|
||||
return hex(color.redComponent, color.greenComponent, color.blueComponent)
|
||||
} catch (_) {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function dictionaryColor(value, fallback) {
|
||||
if (!value || typeof value !== 'object') return fallback
|
||||
const red = Number(value['Red Component'])
|
||||
const green = Number(value['Green Component'])
|
||||
const blue = Number(value['Blue Component'])
|
||||
if (![red, green, blue].every(Number.isFinite)) return fallback
|
||||
return hex(red, green, blue)
|
||||
}
|
||||
|
||||
function isDark(color) {
|
||||
const red = parseInt(color.slice(1, 3), 16) / 255
|
||||
const green = parseInt(color.slice(3, 5), 16) / 255
|
||||
const blue = parseInt(color.slice(5, 7), 16) / 255
|
||||
return 0.2126 * red + 0.7152 * green + 0.0722 * blue < 0.5
|
||||
}
|
||||
|
||||
const LIGHT_THEME = ${JSON.stringify(TERMINAL_LIGHT_THEME)}
|
||||
const DARK_THEME = ${JSON.stringify(TERMINAL_DARK_THEME)}
|
||||
const PALETTE_KEYS = ${JSON.stringify(TERMINAL_THEME_ANSI_KEYS)}
|
||||
const TERMINAL_ANSI_KEYS = [
|
||||
'ANSIBlackColor', 'ANSIRedColor', 'ANSIGreenColor', 'ANSIYellowColor', 'ANSIBlueColor',
|
||||
'ANSIMagentaColor', 'ANSICyanColor', 'ANSIWhiteColor', 'ANSIBrightBlackColor',
|
||||
'ANSIBrightRedColor', 'ANSIBrightGreenColor', 'ANSIBrightYellowColor', 'ANSIBrightBlueColor',
|
||||
'ANSIBrightMagentaColor', 'ANSIBrightCyanColor', 'ANSIBrightWhiteColor'
|
||||
]
|
||||
|
||||
function terminalPalette(profile) {
|
||||
const background = appKitColor(profile, 'BackgroundColor', LIGHT_THEME.background)
|
||||
const dark = isDark(background)
|
||||
const fallback = dark ? DARK_THEME : LIGHT_THEME
|
||||
const palette = {
|
||||
background: background,
|
||||
foreground: appKitColor(profile, 'TextColor', fallback.foreground),
|
||||
cursor: appKitColor(profile, 'CursorColor', fallback.cursor),
|
||||
cursorAccent: background,
|
||||
selectionBackground: appKitColor(profile, 'SelectionColor', fallback.selectionBackground)
|
||||
}
|
||||
for (let index = 0; index < PALETTE_KEYS.length; index += 1) {
|
||||
const key = PALETTE_KEYS[index]
|
||||
palette[key] = appKitColor(profile, TERMINAL_ANSI_KEYS[index], fallback[key])
|
||||
}
|
||||
return palette
|
||||
}
|
||||
|
||||
function itermPalette(profile) {
|
||||
const background = dictionaryColor(profile['Background Color'], LIGHT_THEME.background)
|
||||
const dark = isDark(background)
|
||||
const fallback = dark ? DARK_THEME : LIGHT_THEME
|
||||
const palette = {
|
||||
background: background,
|
||||
foreground: dictionaryColor(profile['Foreground Color'], fallback.foreground),
|
||||
cursor: dictionaryColor(profile['Cursor Color'], fallback.cursor),
|
||||
cursorAccent: dictionaryColor(profile['Cursor Text Color'], background),
|
||||
selectionBackground: dictionaryColor(profile['Selection Color'], fallback.selectionBackground),
|
||||
selectionForeground: dictionaryColor(profile['Selected Text Color'], fallback.foreground)
|
||||
}
|
||||
for (let index = 0; index < PALETTE_KEYS.length; index += 1) {
|
||||
const key = PALETTE_KEYS[index]
|
||||
palette[key] = dictionaryColor(profile['Ansi ' + index + ' Color'], fallback[key])
|
||||
}
|
||||
return palette
|
||||
}
|
||||
|
||||
const profiles = []
|
||||
|
||||
try {
|
||||
const defaults = $.NSUserDefaults.alloc.initWithSuiteName('com.apple.Terminal')
|
||||
const settings = defaults.dictionaryForKey('Window Settings')
|
||||
const names = settings ? ObjC.deepUnwrap(settings.allKeys) : []
|
||||
for (const name of names) {
|
||||
const profile = settings.objectForKey(name)
|
||||
if (!profile) continue
|
||||
profiles.push({
|
||||
id: 'terminal:' + encodeURIComponent(name),
|
||||
name: String(name),
|
||||
source: 'terminal',
|
||||
palette: terminalPalette(profile)
|
||||
})
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
const defaults = $.NSUserDefaults.alloc.initWithSuiteName('com.googlecode.iterm2')
|
||||
const bookmarks = ObjC.deepUnwrap(defaults.arrayForKey('New Bookmarks')) || []
|
||||
for (const profile of bookmarks) {
|
||||
if (!profile || typeof profile !== 'object') continue
|
||||
const guid = String(profile.Guid || '')
|
||||
const name = String(profile.Name || '')
|
||||
if (!guid || !name) continue
|
||||
profiles.push({
|
||||
id: 'iterm2:' + encodeURIComponent(guid),
|
||||
name: name,
|
||||
source: 'iterm2',
|
||||
palette: itermPalette(profile)
|
||||
})
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
JSON.stringify(profiles)
|
||||
`
|
||||
|
||||
/** Validates the color-only output of the macOS profile reader. */
|
||||
export function parseTerminalThemeProfiles(value: unknown): TerminalThemeProfile[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
const seen = new Set<string>()
|
||||
const profiles: TerminalThemeProfile[] = []
|
||||
for (const candidate of value) {
|
||||
if (!isTerminalSelectedProfile(candidate) || seen.has(candidate.id)) continue
|
||||
seen.add(candidate.id)
|
||||
profiles.push({
|
||||
id: candidate.id,
|
||||
name: candidate.name,
|
||||
source: candidate.source,
|
||||
palette: { ...candidate.palette },
|
||||
})
|
||||
}
|
||||
return profiles.sort(
|
||||
(left, right) => left.source.localeCompare(right.source) || left.name.localeCompare(right.name)
|
||||
)
|
||||
}
|
||||
|
||||
async function readTerminalThemeProfiles(): Promise<TerminalThemeProfile[]> {
|
||||
if (process.platform !== 'darwin') return []
|
||||
const { stdout } = await execFileAsync(
|
||||
'/usr/bin/osascript',
|
||||
['-l', 'JavaScript', '-e', JXA_SCRIPT],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
timeout: 5_000,
|
||||
}
|
||||
)
|
||||
return parseTerminalThemeProfiles(JSON.parse(stdout))
|
||||
}
|
||||
|
||||
let cachedProfiles: TerminalThemeProfile[] | null = null
|
||||
let profileLoad: Promise<TerminalThemeProfile[]> | null = null
|
||||
|
||||
/** Reads Terminal.app and iTerm2 profiles once per desktop process. */
|
||||
export async function listTerminalThemeProfiles(): Promise<TerminalThemeProfile[]> {
|
||||
if (cachedProfiles) return cachedProfiles
|
||||
profileLoad ??= readTerminalThemeProfiles()
|
||||
.then((profiles) => {
|
||||
cachedProfiles = profiles
|
||||
return profiles
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.warn('Could not read terminal theme profiles', { error: getErrorMessage(error) })
|
||||
cachedProfiles = []
|
||||
return cachedProfiles
|
||||
})
|
||||
.finally(() => {
|
||||
profileLoad = null
|
||||
})
|
||||
return profileLoad
|
||||
}
|
||||
|
||||
/** Resolves selection only from profiles already shown to the user. */
|
||||
export function findCachedTerminalThemeProfile(
|
||||
profileId: string
|
||||
): TerminalThemeProfile | undefined {
|
||||
return cachedProfiles?.find(({ id }) => id === profileId)
|
||||
}
|
||||
@@ -11,13 +11,13 @@
|
||||
*/
|
||||
import { statSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import type { TerminalShortcutCommand } from '@sim/desktop-bridge'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import {
|
||||
DEFAULT_RUN_WAIT_MS,
|
||||
isTerminalControlKey,
|
||||
MAX_INPUT_KEYS,
|
||||
MAX_RUN_WAIT_MS,
|
||||
MAX_TERMINALS,
|
||||
MAX_TOOL_OUTPUT_CHARS,
|
||||
type TerminalCommandEvent,
|
||||
type TerminalControlKey,
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
import { sleep } from '@sim/utils/helpers'
|
||||
import { isRecordLike } from '@sim/utils/object'
|
||||
import type { BrowserWindow, WebContents } from 'electron'
|
||||
import type { FocusedResourceShortcut } from '@/main/resource-shortcuts'
|
||||
import { elide, TerminalSession } from '@/main/terminal/session'
|
||||
import {
|
||||
activePane,
|
||||
@@ -75,6 +76,9 @@ const INPUT_SCREEN_LINES = 60
|
||||
*/
|
||||
const CWD_POLL_MS = 1_000
|
||||
|
||||
/** Cmd-Shift-T history; independent of how many terminals may be open. */
|
||||
const MAX_RECENTLY_CLOSED_TERMINALS = 10
|
||||
|
||||
/** Pause between keys sent to a tmux pane, matching the pty keystroke gap. */
|
||||
const TMUX_KEY_GAP_MS = 150
|
||||
|
||||
@@ -148,7 +152,6 @@ export interface TerminalServiceOptions {
|
||||
* to the home directory.
|
||||
*/
|
||||
loadCwd?(): string | undefined
|
||||
saveCwd?(cwd: string): void
|
||||
}
|
||||
|
||||
export class TerminalService {
|
||||
@@ -256,14 +259,16 @@ export class TerminalService {
|
||||
return this.sessions.get(terminalId)?.takeReplaySnapshot() ?? ''
|
||||
}
|
||||
|
||||
/** Clears retained output without disturbing the shell process itself. */
|
||||
clearScrollback(terminalId: string): boolean {
|
||||
const session = this.sessions.get(terminalId)
|
||||
if (!session) return false
|
||||
session.clearScrollback()
|
||||
return true
|
||||
}
|
||||
|
||||
/** Opens an additional terminal and makes it active. */
|
||||
openTerminal(cwd?: string): TerminalTabsState {
|
||||
if (this.sessions.size >= MAX_TERMINALS) {
|
||||
throw new TerminalError(
|
||||
'TOO_MANY_TERMINALS',
|
||||
`Up to ${MAX_TERMINALS} terminals can be open at once. Close one first.`
|
||||
)
|
||||
}
|
||||
const active = this.activeId ? this.sessions.get(this.activeId) : null
|
||||
const size = active ? { cols: active.cols, rows: active.rows } : { cols: 80, rows: 24 }
|
||||
// A new terminal opens where the current one is: the user is almost always
|
||||
@@ -367,26 +372,42 @@ export class TerminalService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reopens the most recently closed terminal, in the directory it was in.
|
||||
* Claims one application-menu shortcut while this terminal owns focus.
|
||||
*
|
||||
* A shell cannot be restored the way a browser tab can — its processes are
|
||||
* gone and its scrollback with them — so this reopens where it was working,
|
||||
* which is the part that is expensive for the user to retype.
|
||||
* Main-owned tab operations happen here. Canvas operations are emitted back
|
||||
* to the renderer that made the focus claim, so the same xterm action serves
|
||||
* native accelerators and the terminal's own menu. Reopening creates a fresh
|
||||
* shell in the last closed terminal's directory; a dead process itself cannot
|
||||
* be restored.
|
||||
*/
|
||||
reopenClosedTerminal(ownerWindow: BrowserWindow | null): boolean {
|
||||
handleFocusedShortcut(
|
||||
shortcut: FocusedResourceShortcut,
|
||||
ownerWindow: BrowserWindow | null,
|
||||
emitRendererCommand: (command: TerminalShortcutCommand, terminalId: string) => void
|
||||
): boolean {
|
||||
if (!this.ownsInteraction(ownerWindow)) return false
|
||||
// Peeked, not shifted: at the cap there is nothing to reopen into, and
|
||||
// consuming the entry here would drop that directory on the floor.
|
||||
if (this.recentlyClosedCwds.length === 0 || this.sessions.size >= MAX_TERMINALS) return false
|
||||
const cwd = this.recentlyClosedCwds.shift()
|
||||
this.openTerminal(cwd || undefined)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Closes the active terminal, but only while the panel owns interaction focus. */
|
||||
closeFocusedTerminal(ownerWindow: BrowserWindow | null): boolean {
|
||||
if (!this.ownsInteraction(ownerWindow) || !this.activeId) return false
|
||||
this.closeTerminal(this.activeId)
|
||||
switch (shortcut) {
|
||||
case 'new-tab':
|
||||
this.openTerminal()
|
||||
return true
|
||||
case 'reopen-closed-tab': {
|
||||
const cwd = this.recentlyClosedCwds.shift()
|
||||
if (cwd !== undefined) this.openTerminal(cwd || undefined)
|
||||
return true
|
||||
}
|
||||
case 'close-tab':
|
||||
if (this.activeId) this.closeTerminal(this.activeId)
|
||||
return true
|
||||
case 'reload-or-clear':
|
||||
if (this.activeId) {
|
||||
this.clearScrollback(this.activeId)
|
||||
emitRendererCommand('clear', this.activeId)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (this.activeId) emitRendererCommand(shortcut, this.activeId)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -458,8 +479,8 @@ export class TerminalService {
|
||||
|
||||
private rememberClosed(cwd: string | null): void {
|
||||
this.recentlyClosedCwds.unshift(cwd ?? '')
|
||||
if (this.recentlyClosedCwds.length > MAX_TERMINALS) {
|
||||
this.recentlyClosedCwds.length = MAX_TERMINALS
|
||||
if (this.recentlyClosedCwds.length > MAX_RECENTLY_CLOSED_TERMINALS) {
|
||||
this.recentlyClosedCwds.length = MAX_RECENTLY_CLOSED_TERMINALS
|
||||
}
|
||||
}
|
||||
|
||||
@@ -486,12 +507,6 @@ export class TerminalService {
|
||||
dispose(): void {
|
||||
this.disposing = true
|
||||
this.stopCwdWatch()
|
||||
// Persisted here rather than left to the onState callback, which resolves
|
||||
// the active session out of the very map this teardown empties. Losing it
|
||||
// reopens the next launch in whichever directory last reported a change
|
||||
// instead of the one the user was working in.
|
||||
const activeCwd = this.activeId ? this.sessions.get(this.activeId)?.currentCwd : null
|
||||
if (activeCwd) this.options.saveCwd?.(activeCwd)
|
||||
// Remove each session before disposing it: dispose() emits state, which
|
||||
// reads back through getTabs(), and a session still in the map there is
|
||||
// published to the renderer as a live tab after its shell is gone.
|
||||
@@ -900,8 +915,6 @@ export class TerminalService {
|
||||
callbacks: {
|
||||
onData: (id, data) => this.sink?.data(id, data),
|
||||
onState: () => {
|
||||
const active = this.activeId ? this.sessions.get(this.activeId) : null
|
||||
if (active?.currentCwd) this.options.saveCwd?.(active.currentCwd)
|
||||
this.emitTabs()
|
||||
},
|
||||
onCommand: (event) => this.sink?.command(event),
|
||||
|
||||
@@ -64,6 +64,7 @@ vi.mock('@/main/terminal/session', () => ({
|
||||
setBusy: vi.fn(),
|
||||
refreshCwd: async () => {},
|
||||
takeReplaySnapshot: () => '',
|
||||
clearScrollback: vi.fn(),
|
||||
tabState: (active: boolean) => ({
|
||||
terminalId,
|
||||
title: 'zsh',
|
||||
@@ -90,7 +91,7 @@ describe('pending tmux runs', () => {
|
||||
tmuxStub.handles.length = 0
|
||||
tmuxStub.complete.clear()
|
||||
tmuxStub.done = false
|
||||
service = new TerminalService({ loadCwd: () => '/tmp', saveCwd: () => {} })
|
||||
service = new TerminalService({ loadCwd: () => '/tmp' })
|
||||
})
|
||||
|
||||
it('reclaims a still-running run when the terminal is closed', async () => {
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { tmpdir } from 'node:os'
|
||||
import type { TerminalCommandEvent } from '@sim/terminal-protocol'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
interface StubSessionControl {
|
||||
terminalId: string
|
||||
cwd: string
|
||||
disposed: boolean
|
||||
emitData(data: string): void
|
||||
emitCommand(event: TerminalCommandEvent): void
|
||||
}
|
||||
|
||||
interface StubSessionCallbacks {
|
||||
onData(terminalId: string, data: string): void
|
||||
onState(): void
|
||||
onCommand(event: TerminalCommandEvent): void
|
||||
onExit(terminalId: string): void
|
||||
}
|
||||
|
||||
const { stubSessions } = vi.hoisted(() => ({
|
||||
stubSessions: [] as StubSessionControl[],
|
||||
}))
|
||||
|
||||
vi.mock('@/main/terminal/session', () => ({
|
||||
elide: (text: string) => ({ text, truncated: false }),
|
||||
TerminalSession: {
|
||||
create: ({
|
||||
terminalId,
|
||||
cwd,
|
||||
cols,
|
||||
rows,
|
||||
callbacks,
|
||||
}: {
|
||||
terminalId: string
|
||||
cwd: string
|
||||
cols: number
|
||||
rows: number
|
||||
callbacks: StubSessionCallbacks
|
||||
}) => {
|
||||
const control: StubSessionControl = {
|
||||
terminalId,
|
||||
cwd,
|
||||
disposed: false,
|
||||
emitData: (data) => callbacks.onData(terminalId, data),
|
||||
emitCommand: (event) => callbacks.onCommand(event),
|
||||
}
|
||||
stubSessions.push(control)
|
||||
return {
|
||||
terminalId,
|
||||
cols,
|
||||
rows,
|
||||
pid: 1000 + stubSessions.length,
|
||||
env: {},
|
||||
shell: 'zsh',
|
||||
get alive() {
|
||||
return !control.disposed
|
||||
},
|
||||
currentCwd: cwd,
|
||||
foreground: null,
|
||||
isBusy: false,
|
||||
hasShellIntegration: true,
|
||||
refreshCwd: async () => {},
|
||||
dispose: () => {
|
||||
control.disposed = true
|
||||
},
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
tabState: (active: boolean) => ({
|
||||
terminalId,
|
||||
title: 'zsh',
|
||||
cwd,
|
||||
running: null,
|
||||
interactive: false,
|
||||
active,
|
||||
}),
|
||||
takeReplaySnapshot: () => '',
|
||||
clearScrollback: vi.fn(),
|
||||
}
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
import {
|
||||
type ScopedTerminalSink,
|
||||
TerminalRegistry,
|
||||
type TerminalScopePersistence,
|
||||
} from '@/main/terminal/registry'
|
||||
|
||||
function registry(): TerminalRegistry {
|
||||
return new TerminalRegistry()
|
||||
}
|
||||
|
||||
function sink(): ScopedTerminalSink {
|
||||
return {
|
||||
data: vi.fn(),
|
||||
tabs: vi.fn(),
|
||||
command: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('TerminalRegistry', () => {
|
||||
beforeEach(() => {
|
||||
stubSessions.length = 0
|
||||
})
|
||||
|
||||
it('restores each chat terminal service independently across A to B to A', () => {
|
||||
const terminals = registry()
|
||||
const events = sink()
|
||||
terminals.setSink(events)
|
||||
|
||||
const firstA = terminals.start('chat-A', { cols: 80, rows: 24 })
|
||||
terminals.openTerminal('chat-A', '/tmp')
|
||||
const firstB = terminals.start('chat-B', { cols: 100, rows: 30 })
|
||||
|
||||
expect(firstA.activeTerminalId).toBe('1')
|
||||
expect(firstB.activeTerminalId).toBe('1')
|
||||
expect(terminals.getTabs('chat-B').tabs).toHaveLength(1)
|
||||
|
||||
terminals.switchTerminal('chat-A', '1')
|
||||
|
||||
expect(terminals.getTabs('chat-A')).toMatchObject({
|
||||
activeTerminalId: '1',
|
||||
tabs: [
|
||||
{ terminalId: '1', active: true },
|
||||
{ terminalId: '2', active: false },
|
||||
],
|
||||
})
|
||||
expect(terminals.getTabs('chat-B')).toMatchObject({
|
||||
activeTerminalId: '1',
|
||||
tabs: [{ terminalId: '1', active: true }],
|
||||
})
|
||||
|
||||
stubSessions[0].emitData('from A')
|
||||
stubSessions[2].emitData('from B')
|
||||
const command: TerminalCommandEvent = {
|
||||
terminalId: '1',
|
||||
phase: 'start',
|
||||
command: 'pwd',
|
||||
}
|
||||
stubSessions[0].emitCommand(command)
|
||||
|
||||
expect(events.data).toHaveBeenCalledWith('chat-A', '1', 'from A')
|
||||
expect(events.data).toHaveBeenCalledWith('chat-B', '1', 'from B')
|
||||
expect(events.tabs).toHaveBeenCalledWith('chat-A', expect.any(Object))
|
||||
expect(events.tabs).toHaveBeenCalledWith('chat-B', expect.any(Object))
|
||||
expect(events.command).toHaveBeenCalledWith('chat-A', command)
|
||||
|
||||
terminals.dispose()
|
||||
expect(stubSessions.every((session) => session.disposed)).toBe(true)
|
||||
})
|
||||
|
||||
it('migrates a provisional scope without restarting shells and retags events', () => {
|
||||
const terminals = registry()
|
||||
const events = sink()
|
||||
terminals.setSink(events)
|
||||
terminals.start('pending:new', { cols: 80, rows: 24 })
|
||||
terminals.openTerminal('pending:new', '/tmp')
|
||||
const before = terminals.getTabs('pending:new')
|
||||
const originalSessions = [...stubSessions]
|
||||
|
||||
expect(terminals.migrateScope('pending:new', 'chat-resolved')).toBe(true)
|
||||
|
||||
expect(terminals.getTabs('chat-resolved')).toEqual(before)
|
||||
expect(stubSessions).toEqual(originalSessions)
|
||||
|
||||
stubSessions[0].emitData('after migration')
|
||||
expect(events.data).toHaveBeenLastCalledWith('chat-resolved', '1', 'after migration')
|
||||
|
||||
expect(terminals.peekTabs('pending:new')).toEqual({
|
||||
tabs: [],
|
||||
activeTerminalId: null,
|
||||
})
|
||||
expect(terminals.migrateScope('missing', 'chat-other')).toBe(true)
|
||||
|
||||
terminals.dispose()
|
||||
})
|
||||
|
||||
it('keeps the provisional service when persisted terminal migration collides', () => {
|
||||
const persistence: TerminalScopePersistence = {
|
||||
load: vi.fn(),
|
||||
save: vi.fn(() => true),
|
||||
migrate: vi.fn(() => false),
|
||||
disposeScope: vi.fn(),
|
||||
}
|
||||
const terminals = new TerminalRegistry(persistence)
|
||||
terminals.start('pending:new', { cols: 80, rows: 24 })
|
||||
|
||||
expect(terminals.migrateScope('pending:new', 'chat-existing')).toBe(false)
|
||||
expect(terminals.peekTabs('pending:new').tabs).toHaveLength(1)
|
||||
expect(terminals.peekTabs('chat-existing').tabs).toHaveLength(0)
|
||||
|
||||
terminals.dispose()
|
||||
})
|
||||
|
||||
it('restores saved tab directories lazily as fresh shells', () => {
|
||||
const persistedTabs = Array.from({ length: 12 }, (_, index) => ({
|
||||
cwd: index % 2 === 0 ? tmpdir() : process.cwd(),
|
||||
}))
|
||||
const persistence: TerminalScopePersistence = {
|
||||
load: vi.fn(() => ({
|
||||
v: 1 as const,
|
||||
tabs: persistedTabs,
|
||||
activeIndex: 11,
|
||||
})),
|
||||
save: vi.fn(() => true),
|
||||
migrate: vi.fn(() => true),
|
||||
disposeScope: vi.fn(),
|
||||
}
|
||||
const terminals = new TerminalRegistry(persistence)
|
||||
|
||||
expect(terminals.peekTabs('chat-A')).toEqual({
|
||||
tabs: [],
|
||||
activeTerminalId: null,
|
||||
})
|
||||
expect(stubSessions).toHaveLength(0)
|
||||
|
||||
const restored = terminals.start('chat-A', { cols: 120, rows: 40 })
|
||||
|
||||
expect(stubSessions.map(({ cwd }) => cwd)).toEqual(persistedTabs.map(({ cwd }) => cwd))
|
||||
expect(restored).toMatchObject({
|
||||
activeTerminalId: '12',
|
||||
tabs: persistedTabs.map(({ cwd }, index) => ({
|
||||
terminalId: String(index + 1),
|
||||
cwd,
|
||||
})),
|
||||
})
|
||||
expect(persistence.save).toHaveBeenLastCalledWith('chat-A', {
|
||||
v: 1,
|
||||
tabs: persistedTabs,
|
||||
activeIndex: 11,
|
||||
})
|
||||
|
||||
terminals.dispose()
|
||||
})
|
||||
|
||||
it('opens a fallback shell for a saved tab whose directory no longer exists', () => {
|
||||
const missingCwd = '/definitely-does-not-exist/sim-terminal-restored-tab'
|
||||
const persistence: TerminalScopePersistence = {
|
||||
load: vi.fn(() => ({
|
||||
v: 1 as const,
|
||||
tabs: [{ cwd: tmpdir() }, { cwd: missingCwd }, { cwd: process.cwd() }],
|
||||
activeIndex: 1,
|
||||
})),
|
||||
save: vi.fn(() => true),
|
||||
migrate: vi.fn(() => true),
|
||||
disposeScope: vi.fn(),
|
||||
}
|
||||
const terminals = new TerminalRegistry(persistence)
|
||||
|
||||
const restored = terminals.start('chat-A', { cols: 120, rows: 40 })
|
||||
|
||||
expect(stubSessions.map(({ cwd }) => cwd)).toEqual([tmpdir(), tmpdir(), process.cwd()])
|
||||
expect(restored.activeTerminalId).toBe('2')
|
||||
expect(restored.tabs).toHaveLength(3)
|
||||
expect(persistence.save).toHaveBeenLastCalledWith('chat-A', {
|
||||
v: 1,
|
||||
tabs: [{ cwd: tmpdir() }, { cwd: tmpdir() }, { cwd: process.cwd() }],
|
||||
activeIndex: 1,
|
||||
})
|
||||
|
||||
terminals.dispose()
|
||||
})
|
||||
|
||||
it('forgets an abandoned provisional scope instead of saving it', () => {
|
||||
const persistence: TerminalScopePersistence = {
|
||||
load: vi.fn(),
|
||||
save: vi.fn(() => true),
|
||||
migrate: vi.fn(() => true),
|
||||
disposeScope: vi.fn(),
|
||||
}
|
||||
const terminals = new TerminalRegistry(persistence)
|
||||
terminals.start('pending:new', { cols: 80, rows: 24 })
|
||||
vi.mocked(persistence.save).mockClear()
|
||||
|
||||
terminals.disposeScope('pending:new')
|
||||
|
||||
expect(stubSessions[0].disposed).toBe(true)
|
||||
expect(persistence.save).not.toHaveBeenCalled()
|
||||
expect(persistence.disposeScope).toHaveBeenCalledWith('pending:new')
|
||||
})
|
||||
|
||||
it('suspends PTYs while retaining their descriptor for fresh-shell restore', () => {
|
||||
let snapshot:
|
||||
| {
|
||||
v: 1
|
||||
tabs: Array<{ cwd: string }>
|
||||
activeIndex: number
|
||||
}
|
||||
| undefined
|
||||
const persistence: TerminalScopePersistence = {
|
||||
load: vi.fn(() => snapshot),
|
||||
save: vi.fn((_scope, nextSnapshot) => {
|
||||
snapshot = structuredClone(nextSnapshot)
|
||||
return true
|
||||
}),
|
||||
migrate: vi.fn(() => true),
|
||||
disposeScope: vi.fn(),
|
||||
}
|
||||
const terminals = new TerminalRegistry(persistence)
|
||||
const rememberedCwd = tmpdir()
|
||||
terminals.start('chat-deleted', { cols: 80, rows: 24 })
|
||||
terminals.openTerminal('chat-deleted', rememberedCwd)
|
||||
terminals.switchTerminal('chat-deleted', '1')
|
||||
const originalSessions = [...stubSessions]
|
||||
const initialCwd = originalSessions[0].cwd
|
||||
vi.mocked(persistence.save).mockClear()
|
||||
|
||||
expect(terminals.suspendScope('chat-deleted')).toBe(true)
|
||||
|
||||
expect(originalSessions.every((session) => session.disposed)).toBe(true)
|
||||
expect(persistence.save).toHaveBeenCalledWith('chat-deleted', {
|
||||
v: 1,
|
||||
tabs: [{ cwd: initialCwd }, { cwd: rememberedCwd }],
|
||||
activeIndex: 0,
|
||||
})
|
||||
expect(persistence.disposeScope).not.toHaveBeenCalled()
|
||||
expect(terminals.peekTabs('chat-deleted')).toEqual({
|
||||
tabs: [],
|
||||
activeTerminalId: null,
|
||||
})
|
||||
|
||||
expect(terminals.start('chat-deleted', { cols: 100, rows: 30 })).toEqual({
|
||||
tabs: [],
|
||||
activeTerminalId: null,
|
||||
})
|
||||
expect(() => terminals.write('chat-deleted', '1', 'stale input')).not.toThrow()
|
||||
expect(() => terminals.resize('chat-deleted', '1', 100, 30)).not.toThrow()
|
||||
expect(() => terminals.finishHandoff('chat-deleted', '1')).not.toThrow()
|
||||
expect(() => terminals.setPanelFocused('chat-deleted', false)).not.toThrow()
|
||||
expect(stubSessions).toHaveLength(2)
|
||||
|
||||
terminals.activateScope('chat-deleted')
|
||||
const restored = terminals.start('chat-deleted', { cols: 100, rows: 30 })
|
||||
expect(stubSessions).toHaveLength(4)
|
||||
expect(stubSessions.slice(2).map(({ cwd }) => cwd)).toEqual([initialCwd, rememberedCwd])
|
||||
expect(restored.activeTerminalId).toBe('1')
|
||||
})
|
||||
|
||||
it('kills live PTYs even when their descriptor cannot be saved', () => {
|
||||
const persistence: TerminalScopePersistence = {
|
||||
load: vi.fn(),
|
||||
save: vi.fn(() => false),
|
||||
migrate: vi.fn(() => true),
|
||||
disposeScope: vi.fn(),
|
||||
}
|
||||
const terminals = new TerminalRegistry(persistence)
|
||||
terminals.start('chat-deleted', { cols: 80, rows: 24 })
|
||||
|
||||
// Suspension accompanies chat deletion: a failed descriptor save must
|
||||
// never leave the deleted chat's shells running invisibly.
|
||||
expect(terminals.suspendScope('chat-deleted')).toBe(true)
|
||||
expect(stubSessions[0].disposed).toBe(true)
|
||||
expect(terminals.peekTabs('chat-deleted')).toEqual({ tabs: [], activeTerminalId: null })
|
||||
})
|
||||
|
||||
it('kills live PTYs even when the descriptor save throws', () => {
|
||||
const persistence: TerminalScopePersistence = {
|
||||
load: vi.fn(),
|
||||
save: vi.fn(() => true),
|
||||
migrate: vi.fn(() => true),
|
||||
disposeScope: vi.fn(),
|
||||
}
|
||||
const terminals = new TerminalRegistry(persistence)
|
||||
terminals.start('chat-deleted', { cols: 80, rows: 24 })
|
||||
vi.mocked(persistence.save).mockImplementation(() => {
|
||||
throw new Error('keychain locked')
|
||||
})
|
||||
|
||||
expect(terminals.suspendScope('chat-deleted')).toBe(true)
|
||||
expect(stubSessions[0].disposed).toBe(true)
|
||||
})
|
||||
|
||||
it('routes renderer shortcuts only to the focused terminal scope', () => {
|
||||
const terminals = registry()
|
||||
terminals.start('chat-A', { cols: 80, rows: 24 })
|
||||
terminals.start('chat-B', { cols: 80, rows: 24 })
|
||||
const listeners = new Map<string, (...args: unknown[]) => void>()
|
||||
const send = vi.fn()
|
||||
const contents = {
|
||||
isDestroyed: () => false,
|
||||
once: (event: string, callback: (...args: unknown[]) => void) =>
|
||||
listeners.set(event, callback),
|
||||
on: (event: string, callback: (...args: unknown[]) => void) => listeners.set(event, callback),
|
||||
removeListener: (event: string) => listeners.delete(event),
|
||||
send,
|
||||
}
|
||||
const ownerWindow = { webContents: contents }
|
||||
|
||||
terminals.setPanelFocused('chat-B', true, contents as never)
|
||||
|
||||
expect(terminals.handleFocusedShortcut(ownerWindow as never, 'reload-or-clear')).toBe(true)
|
||||
expect(send).toHaveBeenCalledWith('terminal:shortcut-command', 'clear', 'chat-B', '1')
|
||||
expect(terminals.handleFocusedShortcut(ownerWindow as never, 'zoom-in')).toBe(true)
|
||||
expect(send).toHaveBeenLastCalledWith('terminal:shortcut-command', 'zoom-in', 'chat-B', '1')
|
||||
|
||||
terminals.setPanelFocused('chat-B', false, contents as never)
|
||||
expect(terminals.handleFocusedShortcut(ownerWindow as never, 'reload-or-clear')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,375 @@
|
||||
import { statSync } from 'node:fs'
|
||||
import type {
|
||||
TerminalCommandEvent,
|
||||
TerminalOperation,
|
||||
TerminalStartOptions,
|
||||
TerminalTabsState,
|
||||
TerminalToolArgs,
|
||||
TerminalToolResponse,
|
||||
} from '@sim/terminal-protocol'
|
||||
import type { BrowserWindow, WebContents } from 'electron'
|
||||
import type { TerminalSessionSnapshot } from '@/main/desktop-chat-session-store'
|
||||
import type { FocusedResourceShortcut } from '@/main/resource-shortcuts'
|
||||
import { TerminalService, type TerminalServiceOptions, type TerminalSink } from '@/main/terminal'
|
||||
|
||||
/** Live terminal events tagged with the chat scope that owns their service. */
|
||||
export interface ScopedTerminalSink {
|
||||
data(scope: string, terminalId: string, data: string): void
|
||||
tabs(scope: string, state: TerminalTabsState): void
|
||||
command(scope: string, event: TerminalCommandEvent): void
|
||||
}
|
||||
|
||||
/** Creates the terminal service owned by one chat scope. */
|
||||
export type TerminalServiceFactory = (
|
||||
scope: string,
|
||||
options: TerminalServiceOptions
|
||||
) => TerminalService
|
||||
|
||||
export interface TerminalScopePersistence {
|
||||
load(scope: string): TerminalSessionSnapshot | undefined
|
||||
save(scope: string, snapshot: TerminalSessionSnapshot): boolean
|
||||
migrate(from: string, to: string): boolean
|
||||
disposeScope(scope: string): void
|
||||
}
|
||||
|
||||
interface TerminalRegistryEntry {
|
||||
scope: string
|
||||
service: TerminalService
|
||||
persisted: TerminalSessionSnapshot | undefined
|
||||
restoreApplied: boolean
|
||||
restoring: boolean
|
||||
}
|
||||
|
||||
function createTerminalService(_scope: string, options: TerminalServiceOptions): TerminalService {
|
||||
return new TerminalService(options)
|
||||
}
|
||||
|
||||
/** Uses the normal shell fallback when a remembered checkout no longer exists. */
|
||||
function restorableCwd(cwd: string): string | undefined {
|
||||
try {
|
||||
return statSync(cwd).isDirectory() ? cwd : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns one independent terminal service per chat scope.
|
||||
*
|
||||
* Terminal ids only need to be unique inside their scope. Keeping the service
|
||||
* itself scoped isolates tab order, active selection, shell processes,
|
||||
* handoffs, focus, and recently closed state without adding a second identity
|
||||
* system inside {@link TerminalService}.
|
||||
*/
|
||||
export class TerminalRegistry {
|
||||
private readonly entries = new Map<string, TerminalRegistryEntry>()
|
||||
/**
|
||||
* Process-local tombstones for soft-deleted tasks. A stale renderer in
|
||||
* another window may still send start/write calls briefly; only the
|
||||
* explicit task activation path is allowed to resume a saved terminal set.
|
||||
*/
|
||||
private readonly suspendedScopes = new Set<string>()
|
||||
private sink: ScopedTerminalSink | null = null
|
||||
|
||||
constructor(
|
||||
private readonly persistence?: TerminalScopePersistence,
|
||||
private readonly serviceFactory: TerminalServiceFactory = createTerminalService
|
||||
) {}
|
||||
|
||||
setSink(sink: ScopedTerminalSink | null): void {
|
||||
this.sink = sink
|
||||
for (const entry of this.entries.values()) {
|
||||
this.bindSink(entry)
|
||||
}
|
||||
}
|
||||
|
||||
getTabs(scope: string): TerminalTabsState {
|
||||
if (this.suspendedScopes.has(scope)) return { tabs: [], activeTerminalId: null }
|
||||
return this.serviceFor(scope).getTabs()
|
||||
}
|
||||
|
||||
/** Reads an already-live group without allocating a service for a visited chat. */
|
||||
peekTabs(scope: string): TerminalTabsState {
|
||||
return this.entries.get(scope)?.service.getTabs() ?? { tabs: [], activeTerminalId: null }
|
||||
}
|
||||
|
||||
/** Resumes a task only when its renderer explicitly opens that task. */
|
||||
activateScope(scope: string): TerminalTabsState {
|
||||
this.suspendedScopes.delete(scope)
|
||||
return this.peekTabs(scope)
|
||||
}
|
||||
|
||||
start(scope: string, options: TerminalStartOptions): TerminalTabsState {
|
||||
if (this.suspendedScopes.has(scope)) return { tabs: [], activeTerminalId: null }
|
||||
const entry = this.entryFor(scope)
|
||||
return this.restoreOrStart(entry, options)
|
||||
}
|
||||
|
||||
getScrollback(scope: string, terminalId: string): string {
|
||||
return this.serviceFor(scope).getScrollback(terminalId)
|
||||
}
|
||||
|
||||
/** Clears only an existing live scope; a stale renderer must not create one. */
|
||||
clearScrollback(scope: string, terminalId: string): boolean {
|
||||
if (this.suspendedScopes.has(scope)) return false
|
||||
return this.entries.get(scope)?.service.clearScrollback(terminalId) ?? false
|
||||
}
|
||||
|
||||
openTerminal(scope: string, cwd?: string): TerminalTabsState {
|
||||
return this.serviceFor(scope).openTerminal(cwd)
|
||||
}
|
||||
|
||||
switchTerminal(scope: string, terminalId: string): TerminalTabsState {
|
||||
return this.serviceFor(scope).switchTerminal(terminalId)
|
||||
}
|
||||
|
||||
closeTerminal(scope: string, terminalId: string): TerminalTabsState {
|
||||
return this.serviceFor(scope).closeTerminal(terminalId)
|
||||
}
|
||||
|
||||
write(scope: string, terminalId: string, data: string): void {
|
||||
if (this.suspendedScopes.has(scope)) return
|
||||
this.serviceFor(scope).write(terminalId, data)
|
||||
}
|
||||
|
||||
resize(scope: string, terminalId: string, cols: number, rows: number): void {
|
||||
if (this.suspendedScopes.has(scope)) return
|
||||
this.serviceFor(scope).resize(terminalId, cols, rows)
|
||||
}
|
||||
|
||||
finishHandoff(scope: string, terminalId: string): void {
|
||||
if (this.suspendedScopes.has(scope)) return
|
||||
this.serviceFor(scope).finishHandoff(terminalId)
|
||||
}
|
||||
|
||||
executeTool(
|
||||
scope: string,
|
||||
toolCallId: string,
|
||||
operation: TerminalOperation,
|
||||
args: TerminalToolArgs
|
||||
): Promise<TerminalToolResponse> {
|
||||
if (this.suspendedScopes.has(scope)) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
code: 'SESSION_CLOSED',
|
||||
error: 'This task terminal is suspended until the task is reopened.',
|
||||
})
|
||||
}
|
||||
const entry = this.entryFor(scope)
|
||||
if (entry.persisted && !entry.restoreApplied) {
|
||||
this.restoreOrStart(entry, { cols: 80, rows: 24 })
|
||||
}
|
||||
return entry.service.executeTool(toolCallId, operation, args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a provisional chat's live service to its durable chat id.
|
||||
*
|
||||
* A populated destination cannot be merged safely because both services may
|
||||
* own live processes with overlapping terminal ids. In that case neither
|
||||
* side is destroyed and the caller gets `false`.
|
||||
*/
|
||||
migrateScope(from: string, to: string): boolean {
|
||||
if (from === to) return this.entries.has(from)
|
||||
const entry = this.entries.get(from)
|
||||
if (this.entries.has(to) || this.suspendedScopes.has(to)) return false
|
||||
if (this.persistence && !this.persistence.migrate(from, to)) return false
|
||||
if (!entry) {
|
||||
return true
|
||||
}
|
||||
|
||||
this.entries.delete(from)
|
||||
entry.scope = to
|
||||
this.entries.set(to, entry)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Records focus inside one scope and drops a stale claim from the same
|
||||
* renderer in any other scope.
|
||||
*/
|
||||
setPanelFocused(scope: string, focused: boolean, owner?: WebContents | null): void {
|
||||
if (this.suspendedScopes.has(scope)) return
|
||||
if (focused && owner) {
|
||||
for (const entry of this.entries.values()) {
|
||||
if (entry.scope !== scope) entry.service.setPanelFocused(false, owner)
|
||||
}
|
||||
}
|
||||
this.serviceFor(scope).setPanelFocused(focused, owner)
|
||||
}
|
||||
|
||||
/** Handles a menu accelerator, which has a window but no renderer chat scope. */
|
||||
handleFocusedShortcut(
|
||||
ownerWindow: BrowserWindow | null,
|
||||
shortcut: FocusedResourceShortcut
|
||||
): boolean {
|
||||
for (const entry of this.entries.values()) {
|
||||
if (
|
||||
entry.service.handleFocusedShortcut(shortcut, ownerWindow, (command, terminalId) => {
|
||||
if (!ownerWindow || ownerWindow.webContents.isDestroyed()) return
|
||||
ownerWindow.webContents.send(
|
||||
'terminal:shortcut-command',
|
||||
command,
|
||||
entry.scope,
|
||||
terminalId
|
||||
)
|
||||
})
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Abandons one provisional chat, including its shells and saved descriptor. */
|
||||
disposeScope(scope: string): void {
|
||||
this.suspendedScopes.delete(scope)
|
||||
const entry = this.entries.get(scope)
|
||||
if (entry) {
|
||||
this.entries.delete(scope)
|
||||
entry.service.setSink(null)
|
||||
entry.service.dispose()
|
||||
}
|
||||
this.persistence?.disposeScope(scope)
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists and stops one durable chat's live PTYs while retaining its saved
|
||||
* descriptor. A later start recreates fresh shells in the remembered cwd
|
||||
* order; process state and scrollback remain intentionally ephemeral.
|
||||
*
|
||||
* The persist is best-effort: suspension accompanies chat deletion, and a
|
||||
* descriptor that could not be saved must never leave the deleted chat's
|
||||
* shells running invisibly. A restore after a failed save falls back to the
|
||||
* last successfully saved descriptor, or a fresh shell.
|
||||
*/
|
||||
suspendScope(scope: string): boolean {
|
||||
const entry = this.entries.get(scope)
|
||||
if (!entry) {
|
||||
this.suspendedScopes.add(scope)
|
||||
return true
|
||||
}
|
||||
try {
|
||||
this.persistEntry(entry)
|
||||
} catch {
|
||||
// Best-effort by design; teardown below must still run.
|
||||
}
|
||||
|
||||
this.suspendedScopes.add(scope)
|
||||
this.entries.delete(scope)
|
||||
entry.service.setSink(null)
|
||||
entry.service.dispose()
|
||||
return true
|
||||
}
|
||||
|
||||
/** Tears down every shell owned by every chat scope. */
|
||||
dispose(): void {
|
||||
const entries = [...this.entries.values()]
|
||||
this.entries.clear()
|
||||
this.suspendedScopes.clear()
|
||||
for (const entry of entries) {
|
||||
this.persistEntry(entry)
|
||||
entry.service.setSink(null)
|
||||
entry.service.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
private serviceFor(scope: string): TerminalService {
|
||||
return this.entryFor(scope).service
|
||||
}
|
||||
|
||||
private entryFor(scope: string): TerminalRegistryEntry {
|
||||
if (this.suspendedScopes.has(scope)) {
|
||||
throw new Error('This task terminal is suspended until the task is reopened.')
|
||||
}
|
||||
const existing = this.entries.get(scope)
|
||||
if (existing) return existing
|
||||
|
||||
const persisted = this.persistence?.load(scope)
|
||||
const rememberedCwd = persisted?.tabs[0]?.cwd
|
||||
const entry: TerminalRegistryEntry = {
|
||||
scope,
|
||||
service: this.serviceFactory(scope, {
|
||||
loadCwd: () => rememberedCwd,
|
||||
}),
|
||||
persisted,
|
||||
restoreApplied: false,
|
||||
restoring: false,
|
||||
}
|
||||
this.entries.set(scope, entry)
|
||||
this.bindSink(entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
/**
|
||||
* Recreates persisted tab descriptors as fresh shells. Process state,
|
||||
* scrollback, environment variables and foreground programs deliberately do
|
||||
* not pretend to survive an app restart.
|
||||
*/
|
||||
private restoreOrStart(
|
||||
entry: TerminalRegistryEntry,
|
||||
options: TerminalStartOptions
|
||||
): TerminalTabsState {
|
||||
if (entry.restoreApplied) return entry.service.start(options)
|
||||
|
||||
entry.restoreApplied = true
|
||||
entry.restoring = true
|
||||
let tabs: TerminalTabsState
|
||||
try {
|
||||
entry.service.start(options)
|
||||
const persisted = entry.persisted
|
||||
if (persisted) {
|
||||
for (const tab of persisted.tabs.slice(1)) {
|
||||
entry.service.openTerminal(restorableCwd(tab.cwd))
|
||||
}
|
||||
const restored = entry.service.getTabs()
|
||||
const active = restored.tabs[persisted.activeIndex]
|
||||
if (active) entry.service.switchTerminal(active.terminalId)
|
||||
}
|
||||
tabs = entry.service.getTabs()
|
||||
} finally {
|
||||
entry.restoring = false
|
||||
entry.persisted = undefined
|
||||
}
|
||||
this.persistTabs(entry, tabs)
|
||||
return tabs
|
||||
}
|
||||
|
||||
/**
|
||||
* The wrapper reads `entry.scope` at delivery time so scope migration
|
||||
* retags future events without replacing the service or its live shells.
|
||||
*/
|
||||
private bindSink(entry: TerminalRegistryEntry): void {
|
||||
if (!this.sink) {
|
||||
entry.service.setSink(null)
|
||||
return
|
||||
}
|
||||
|
||||
const sink: TerminalSink = {
|
||||
data: (terminalId, data) => this.sink?.data(entry.scope, terminalId, data),
|
||||
tabs: (state) => {
|
||||
this.persistTabs(entry, state)
|
||||
this.sink?.tabs(entry.scope, state)
|
||||
},
|
||||
command: (event) => this.sink?.command(entry.scope, event),
|
||||
}
|
||||
entry.service.setSink(sink)
|
||||
}
|
||||
|
||||
private persistEntry(entry: TerminalRegistryEntry): boolean {
|
||||
return this.persistTabs(entry, entry.service.getTabs())
|
||||
}
|
||||
|
||||
private persistTabs(entry: TerminalRegistryEntry, state: TerminalTabsState): boolean {
|
||||
if (entry.restoring || !this.persistence || state.tabs.length === 0) return true
|
||||
const tabs = state.tabs.flatMap((tab) =>
|
||||
typeof tab.cwd === 'string' && tab.cwd.length > 0 ? [{ cwd: tab.cwd }] : []
|
||||
)
|
||||
if (tabs.length === 0) return true
|
||||
const activeIndex = Math.max(
|
||||
0,
|
||||
state.tabs.findIndex((tab) => tab.terminalId === state.activeTerminalId)
|
||||
)
|
||||
return this.persistence.save(entry.scope, { v: 1, tabs, activeIndex })
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { MAX_TERMINALS } from '@sim/terminal-protocol'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { TerminalService } from '@/main/terminal'
|
||||
|
||||
/** Stub sessions by terminal id, populated by the mock below. */
|
||||
const { stubSessions } = vi.hoisted(() => ({
|
||||
stubSessions: new Map<string, { setBusy(busy: boolean): void; exit(): void }>(),
|
||||
stubSessions: new Map<
|
||||
string,
|
||||
{ setBusy(busy: boolean): void; exit(): void; clearScrollback(): void }
|
||||
>(),
|
||||
}))
|
||||
|
||||
/**
|
||||
@@ -76,6 +75,7 @@ vi.mock('@/main/terminal/session', async () => {
|
||||
active,
|
||||
}),
|
||||
takeReplaySnapshot: () => '',
|
||||
clearScrollback: vi.fn(),
|
||||
readScrollback: () => ({
|
||||
output: 'Do you want to proceed? [y/N]',
|
||||
cwd: state.cwd,
|
||||
@@ -92,7 +92,7 @@ vi.mock('@/main/terminal/session', async () => {
|
||||
})
|
||||
|
||||
function service(): TerminalService {
|
||||
return new TerminalService({ loadCwd: () => '/tmp', saveCwd: () => {} })
|
||||
return new TerminalService({ loadCwd: () => '/tmp' })
|
||||
}
|
||||
|
||||
describe('closing terminals', () => {
|
||||
@@ -127,31 +127,18 @@ describe('closing terminals', () => {
|
||||
|
||||
expect(() => terminal.closeTerminal('no-such-terminal')).toThrow()
|
||||
})
|
||||
|
||||
it('persists the active terminal cwd on dispose', () => {
|
||||
// The saved cwd is what the next launch reopens into. It is otherwise only
|
||||
// written when a session REPORTS a cwd change, and switching tabs is not
|
||||
// one — so without an explicit save at teardown, quitting after a switch
|
||||
// reopens in the directory of whichever tab last moved.
|
||||
// Real directories: a remembered cwd that no longer exists falls back to
|
||||
// home, which would make this assert nothing.
|
||||
const projectA = mkdtempSync(join(tmpdir(), 'sim-term-a-'))
|
||||
const projectB = mkdtempSync(join(tmpdir(), 'sim-term-b-'))
|
||||
const saveCwd = vi.fn()
|
||||
const terminal = new TerminalService({ loadCwd: () => projectA, saveCwd })
|
||||
terminal.start({ cols: 80, rows: 24 })
|
||||
const first = terminal.getTabs().activeTerminalId as string
|
||||
terminal.openTerminal(projectB)
|
||||
terminal.switchTerminal(first)
|
||||
saveCwd.mockClear()
|
||||
|
||||
terminal.dispose()
|
||||
|
||||
expect(saveCwd).toHaveBeenCalledWith(projectA)
|
||||
})
|
||||
})
|
||||
|
||||
type OwnerWindow = Parameters<TerminalService['closeFocusedTerminal']>[0]
|
||||
type OwnerWindow = Parameters<TerminalService['handleFocusedShortcut']>[1]
|
||||
|
||||
function runShortcut(
|
||||
terminal: TerminalService,
|
||||
shortcut: Parameters<TerminalService['handleFocusedShortcut']>[0],
|
||||
ownerWindow: OwnerWindow,
|
||||
emit = vi.fn()
|
||||
): boolean {
|
||||
return terminal.handleFocusedShortcut(shortcut, ownerWindow, emit)
|
||||
}
|
||||
|
||||
/**
|
||||
* A stand-in for one app window and the renderer inside it.
|
||||
@@ -186,8 +173,8 @@ describe('focus-gated shortcuts', () => {
|
||||
terminal.start({ cols: 80, rows: 24 })
|
||||
const renderer = rendererStub()
|
||||
|
||||
expect(terminal.closeFocusedTerminal(renderer.window)).toBe(false)
|
||||
expect(terminal.reopenClosedTerminal(renderer.window)).toBe(false)
|
||||
expect(runShortcut(terminal, 'close-tab', renderer.window)).toBe(false)
|
||||
expect(runShortcut(terminal, 'reopen-closed-tab', renderer.window)).toBe(false)
|
||||
})
|
||||
|
||||
it('closes the active terminal once the panel has focus', () => {
|
||||
@@ -197,22 +184,46 @@ describe('focus-gated shortcuts', () => {
|
||||
const renderer = rendererStub()
|
||||
terminal.setPanelFocused(true, renderer.contents)
|
||||
|
||||
expect(terminal.closeFocusedTerminal(renderer.window)).toBe(true)
|
||||
expect(runShortcut(terminal, 'close-tab', renderer.window)).toBe(true)
|
||||
expect(terminal.getTabs().tabs).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reopens a closed terminal, and has nothing to reopen before one closes', () => {
|
||||
it('opens tabs in main and sends canvas commands to the focused renderer', () => {
|
||||
const terminal = service()
|
||||
terminal.start({ cols: 80, rows: 24 })
|
||||
const renderer = rendererStub()
|
||||
const emit = vi.fn()
|
||||
terminal.setPanelFocused(true, renderer.contents)
|
||||
|
||||
expect(runShortcut(terminal, 'new-tab', renderer.window, emit)).toBe(true)
|
||||
expect(terminal.getTabs().tabs).toHaveLength(2)
|
||||
expect(emit).not.toHaveBeenCalled()
|
||||
|
||||
expect(runShortcut(terminal, 'reload-or-clear', renderer.window, emit)).toBe(true)
|
||||
expect(
|
||||
stubSessions.get(terminal.getTabs().activeTerminalId as string)?.clearScrollback
|
||||
).toHaveBeenCalledOnce()
|
||||
expect(emit).toHaveBeenLastCalledWith('clear', terminal.getTabs().activeTerminalId)
|
||||
expect(runShortcut(terminal, 'zoom-in', renderer.window, emit)).toBe(true)
|
||||
expect(emit).toHaveBeenLastCalledWith('zoom-in', terminal.getTabs().activeTerminalId)
|
||||
expect(runShortcut(terminal, 'zoom-out', renderer.window, emit)).toBe(true)
|
||||
expect(emit).toHaveBeenLastCalledWith('zoom-out', terminal.getTabs().activeTerminalId)
|
||||
expect(runShortcut(terminal, 'zoom-reset', renderer.window, emit)).toBe(true)
|
||||
expect(emit).toHaveBeenLastCalledWith('zoom-reset', terminal.getTabs().activeTerminalId)
|
||||
})
|
||||
|
||||
it('claims reopen even with no history, then reopens the latest closed terminal', () => {
|
||||
const terminal = service()
|
||||
terminal.start({ cols: 80, rows: 24 })
|
||||
const renderer = rendererStub()
|
||||
terminal.setPanelFocused(true, renderer.contents)
|
||||
|
||||
expect(terminal.reopenClosedTerminal(renderer.window)).toBe(false)
|
||||
expect(runShortcut(terminal, 'reopen-closed-tab', renderer.window)).toBe(true)
|
||||
|
||||
const second = terminal.openTerminal()
|
||||
terminal.closeTerminal(second.activeTerminalId as string)
|
||||
|
||||
expect(terminal.reopenClosedTerminal(renderer.window)).toBe(true)
|
||||
expect(runShortcut(terminal, 'reopen-closed-tab', renderer.window)).toBe(true)
|
||||
expect(terminal.getTabs().tabs).toHaveLength(2)
|
||||
})
|
||||
|
||||
@@ -226,7 +237,7 @@ describe('focus-gated shortcuts', () => {
|
||||
|
||||
terminal.closeTerminal(started.activeTerminalId as string)
|
||||
|
||||
expect(terminal.reopenClosedTerminal(renderer.window)).toBe(false)
|
||||
expect(runShortcut(terminal, 'reopen-closed-tab', renderer.window)).toBe(true)
|
||||
expect(terminal.getTabs().tabs).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -242,7 +253,7 @@ describe('focus-gated shortcuts', () => {
|
||||
|
||||
renderer.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false })
|
||||
|
||||
expect(terminal.closeFocusedTerminal(renderer.window)).toBe(false)
|
||||
expect(runShortcut(terminal, 'close-tab', renderer.window)).toBe(false)
|
||||
expect(terminal.getTabs().tabs).toHaveLength(2)
|
||||
})
|
||||
|
||||
@@ -255,7 +266,7 @@ describe('focus-gated shortcuts', () => {
|
||||
|
||||
renderer.emit('did-start-navigation', { isMainFrame: true, isSameDocument: true })
|
||||
|
||||
expect(terminal.closeFocusedTerminal(renderer.window)).toBe(true)
|
||||
expect(runShortcut(terminal, 'close-tab', renderer.window)).toBe(true)
|
||||
})
|
||||
|
||||
it('answers only the window whose renderer holds the claim', () => {
|
||||
@@ -265,14 +276,14 @@ describe('focus-gated shortcuts', () => {
|
||||
const renderer = rendererStub()
|
||||
terminal.setPanelFocused(true, renderer.contents)
|
||||
|
||||
expect(terminal.closeFocusedTerminal(rendererStub().window)).toBe(false)
|
||||
expect(runShortcut(terminal, 'close-tab', rendererStub().window)).toBe(false)
|
||||
// And null — no window at all cannot be the window a claim answers for.
|
||||
expect(terminal.closeFocusedTerminal(null)).toBe(false)
|
||||
expect(runShortcut(terminal, 'close-tab', null)).toBe(false)
|
||||
|
||||
expect(terminal.closeFocusedTerminal(renderer.window)).toBe(true)
|
||||
expect(runShortcut(terminal, 'close-tab', renderer.window)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not consume the reopen history when already at the terminal cap', () => {
|
||||
it('opens and reopens more than eight terminals', () => {
|
||||
const terminal = service()
|
||||
terminal.start({ cols: 80, rows: 24 })
|
||||
const renderer = rendererStub()
|
||||
@@ -280,20 +291,14 @@ describe('focus-gated shortcuts', () => {
|
||||
|
||||
const closed = terminal.openTerminal('/alpha')
|
||||
terminal.closeTerminal(closed.activeTerminalId as string)
|
||||
while (terminal.getTabs().tabs.length < MAX_TERMINALS) terminal.openTerminal()
|
||||
while (terminal.getTabs().tabs.length < 12) terminal.openTerminal()
|
||||
|
||||
// Refused, and without opening anything.
|
||||
expect(terminal.reopenClosedTerminal(renderer.window)).toBe(false)
|
||||
expect(terminal.getTabs().tabs).toHaveLength(MAX_TERMINALS)
|
||||
|
||||
// NOTE: that the '/alpha' entry SURVIVES the refusal is the actual point of
|
||||
// the guard, and it is not observable from out here — every close prepends
|
||||
// one history entry and frees exactly one slot, so a reopen can never walk
|
||||
// back past the entries created by the closes that made room for it. The
|
||||
// ordering in reopenClosedTerminal (check the cap, then shift) is what
|
||||
// carries it; this test only pins the refusal itself.
|
||||
terminal.closeTerminal(terminal.getTabs().activeTerminalId as string)
|
||||
expect(terminal.reopenClosedTerminal(renderer.window)).toBe(true)
|
||||
expect(runShortcut(terminal, 'reopen-closed-tab', renderer.window)).toBe(true)
|
||||
const reopened = terminal.getTabs()
|
||||
expect(reopened.tabs).toHaveLength(13)
|
||||
expect(
|
||||
reopened.tabs.find(({ terminalId }) => terminalId === reopened.activeTerminalId)?.cwd
|
||||
).toBe('/alpha')
|
||||
})
|
||||
|
||||
it('ignores a blur reported by a renderer that does not hold the claim', () => {
|
||||
@@ -310,7 +315,7 @@ describe('focus-gated shortcuts', () => {
|
||||
|
||||
terminal.setPanelFocused(false, other.contents)
|
||||
|
||||
expect(terminal.closeFocusedTerminal(holder.window)).toBe(true)
|
||||
expect(runShortcut(terminal, 'close-tab', holder.window)).toBe(true)
|
||||
})
|
||||
|
||||
it('honours a blur from the renderer that does hold the claim', () => {
|
||||
@@ -322,7 +327,7 @@ describe('focus-gated shortcuts', () => {
|
||||
|
||||
terminal.setPanelFocused(false, holder.contents)
|
||||
|
||||
expect(terminal.closeFocusedTerminal(holder.window)).toBe(false)
|
||||
expect(runShortcut(terminal, 'close-tab', holder.window)).toBe(false)
|
||||
})
|
||||
|
||||
it('drops the focus claim when the whole service is disposed', () => {
|
||||
@@ -336,7 +341,7 @@ describe('focus-gated shortcuts', () => {
|
||||
// rather than passing on the "no active terminal" arm.
|
||||
terminal.start({ cols: 80, rows: 24 })
|
||||
|
||||
expect(terminal.closeFocusedTerminal(renderer.window)).toBe(false)
|
||||
expect(runShortcut(terminal, 'close-tab', renderer.window)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -621,6 +621,26 @@ export class TerminalSession {
|
||||
return stripTerminalQueries(this.scrollback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgets output retained for renderer repaints and agent reads.
|
||||
*
|
||||
* The renderer clears its own xterm at the same time. Dropping only that
|
||||
* local buffer is insufficient: the next mount or overflow repaint would
|
||||
* otherwise replay this retained copy and make the cleared output return.
|
||||
*/
|
||||
clearScrollback(): void {
|
||||
this.scrollback = ''
|
||||
this.pendingOutput = ''
|
||||
if (this.flushTimer) {
|
||||
clearTimeout(this.flushTimer)
|
||||
this.flushTimer = null
|
||||
}
|
||||
if (this.paused) {
|
||||
this.paused = false
|
||||
this.pty.resume()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the terminal's screen, building an emulator on demand.
|
||||
*
|
||||
|
||||
@@ -224,7 +224,7 @@ describe('installTray', () => {
|
||||
Menu.buildFromTemplate.mockClear()
|
||||
})
|
||||
|
||||
it('fetches fresh chats on click and pops the menu', async () => {
|
||||
it('attaches a native menu immediately and refreshes it in the background', async () => {
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
@@ -236,19 +236,20 @@ describe('installTray', () => {
|
||||
expect(handle).not.toBeNull()
|
||||
expect(Tray.instances).toHaveLength(1)
|
||||
const tray = Tray.instances[0]
|
||||
|
||||
const clickHandler = tray.on.mock.calls.find(
|
||||
([event]: unknown[]) => event === 'click'
|
||||
)?.[1] as () => void
|
||||
clickHandler()
|
||||
await vi.waitFor(() => expect(tray.popUpContextMenu).toHaveBeenCalledTimes(1))
|
||||
expect(tray.setContextMenu).toHaveBeenCalledTimes(1)
|
||||
expect(tray.setIgnoreDoubleClickEvents).toHaveBeenCalledWith(true)
|
||||
expect(tray.on).not.toHaveBeenCalledWith('click', expect.any(Function))
|
||||
expect(tray.popUpContextMenu).not.toHaveBeenCalled()
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://sim.ai/api/copilot/chats',
|
||||
expect.objectContaining({ credentials: 'include' })
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(JSON.stringify(tray.setContextMenu.mock.calls.at(-1)?.[0])).toContain('Hello')
|
||||
)
|
||||
})
|
||||
|
||||
it('still pops the menu when the chats fetch fails', async () => {
|
||||
it('keeps the immediately attached menu when the chats fetch fails', async () => {
|
||||
vi.mocked(session.fromPartition).mockReturnValue({
|
||||
fetch: vi.fn(async () => {
|
||||
throw new Error('offline')
|
||||
@@ -257,11 +258,58 @@ describe('installTray', () => {
|
||||
|
||||
installTray(makeDeps())
|
||||
const tray = Tray.instances[0]
|
||||
const clickHandler = tray.on.mock.calls.find(
|
||||
([event]: unknown[]) => event === 'click'
|
||||
)?.[1] as () => void
|
||||
clickHandler()
|
||||
await vi.waitFor(() => expect(tray.popUpContextMenu).toHaveBeenCalledTimes(1))
|
||||
expect(tray.setContextMenu).toHaveBeenCalledTimes(1)
|
||||
await vi.waitFor(() => expect(session.fromPartition).toHaveBeenCalled())
|
||||
expect(tray.popUpContextMenu).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves rapid-click toggling to the native attached menu during a slow refresh', async () => {
|
||||
let release: (value: unknown) => void = () => {}
|
||||
const response = new Promise((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const fetchMock = vi.fn(() => response)
|
||||
vi.mocked(session.fromPartition).mockReturnValue({ fetch: fetchMock } as never)
|
||||
|
||||
installTray(makeDeps())
|
||||
const tray = Tray.instances[0]
|
||||
expect(tray.setContextMenu).toHaveBeenCalledTimes(1)
|
||||
expect(tray.on).not.toHaveBeenCalledWith('click', expect.any(Function))
|
||||
expect(tray.on).not.toHaveBeenCalledWith('right-click', expect.any(Function))
|
||||
expect(tray.popUpContextMenu).not.toHaveBeenCalled()
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
release({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ chats: [{ id: 'c1', title: 'Hello', workspaceId: 'ws1' }] }),
|
||||
})
|
||||
await vi.waitFor(() =>
|
||||
expect(JSON.stringify(tray.setContextMenu.mock.calls.at(-1)?.[0])).toContain('Hello')
|
||||
)
|
||||
expect(tray.setContextMenu).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('treats a valid empty chat response as a completed warm-up', async () => {
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ chats: [] }),
|
||||
}))
|
||||
vi.mocked(session.fromPartition).mockReturnValue({ fetch: fetchMock } as never)
|
||||
vi.useFakeTimers()
|
||||
|
||||
const handle = installTray(makeDeps())
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
const tray = Tray.instances[0]
|
||||
expect(tray.on).not.toHaveBeenCalledWith('click', expect.any(Function))
|
||||
expect(tray.popUpContextMenu).not.toHaveBeenCalled()
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
handle?.destroy()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('drops the previous user’s chats when the server says signed out', async () => {
|
||||
@@ -279,32 +327,27 @@ describe('installTray', () => {
|
||||
: { ok: false, status: 401, json: async () => ({}) }
|
||||
)
|
||||
vi.mocked(session.fromPartition).mockReturnValue({ fetch: fetchMock } as never)
|
||||
vi.useFakeTimers()
|
||||
|
||||
installTray(makeDeps())
|
||||
const tray = Tray.instances[0]
|
||||
const clickHandler = tray.on.mock.calls.find(
|
||||
([event]: unknown[]) => event === 'click'
|
||||
)?.[1] as () => void
|
||||
|
||||
// Each click pops from cache then refreshes it, so poll until the menu
|
||||
// settles rather than assuming which click sees the new state.
|
||||
const lastMenu = () => JSON.stringify(Menu.buildFromTemplate.mock.calls.at(-1))
|
||||
await vi.waitFor(() => {
|
||||
clickHandler()
|
||||
const handle = installTray(makeDeps())
|
||||
try {
|
||||
const tray = Tray.instances[0]
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
const lastMenu = () => JSON.stringify(tray.setContextMenu.mock.calls.at(-1)?.[0])
|
||||
expect(lastMenu()).toContain('Secret')
|
||||
})
|
||||
|
||||
signedIn = false
|
||||
await vi.waitFor(() => {
|
||||
clickHandler()
|
||||
signedIn = false
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
expect(lastMenu()).not.toContain('Secret')
|
||||
})
|
||||
expect(tray.popUpContextMenu).toHaveBeenCalled()
|
||||
} finally {
|
||||
handle?.destroy()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('clearRecentChats empties the menu immediately and voids an in-flight fetch', async () => {
|
||||
// Sign-out teardown calls this. The menu pops from cache BEFORE refreshing,
|
||||
// so without it the next open would show the old titles one more time.
|
||||
// Sign-out teardown calls this so the attached native menu cannot retain
|
||||
// the previous user's titles while an older request is still in flight.
|
||||
let release: (value: unknown) => void = () => {}
|
||||
const inFlight = new Promise((resolve) => {
|
||||
release = resolve
|
||||
@@ -325,12 +368,7 @@ describe('installTray', () => {
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled())
|
||||
|
||||
const tray = Tray.instances[0]
|
||||
const clickHandler = tray.on.mock.calls.find(
|
||||
([event]: unknown[]) => event === 'click'
|
||||
)?.[1] as () => void
|
||||
clickHandler()
|
||||
await vi.waitFor(() => expect(tray.popUpContextMenu).toHaveBeenCalled())
|
||||
expect(JSON.stringify(Menu.buildFromTemplate.mock.calls.at(-1))).not.toContain('Secret')
|
||||
expect(JSON.stringify(tray.setContextMenu.mock.calls.at(-1)?.[0])).not.toContain('Secret')
|
||||
})
|
||||
|
||||
it('marks dev, staging, and local status items while leaving production unchanged', () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { join } from 'node:path'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { sleep } from '@sim/utils/helpers'
|
||||
import { truncate } from '@sim/utils/string'
|
||||
import type { MenuItemConstructorOptions, NativeImage } from 'electron'
|
||||
import { app, Menu, nativeImage, session, Tray } from 'electron'
|
||||
@@ -17,10 +16,10 @@ const UNREAD_DOT_COLOR = { r: 0x33, g: 0xc4, b: 0x82 } // green: finished, not y
|
||||
const RECENT_CHATS_INLINE = 5
|
||||
/** Total chats kept (inline + the "More" hover submenu). */
|
||||
const RECENT_CHATS_TOTAL = 30
|
||||
// Generous: the refresh is async (a click always pops the cached menu
|
||||
// immediately), so a slow dev-server compile just delays the NEXT open's
|
||||
// recents instead of dropping them.
|
||||
// Generous: refreshes are detached from the native menu, so a slow dev-server
|
||||
// compile only delays the next cached recents update.
|
||||
const CHATS_FETCH_TIMEOUT_MS = 5000
|
||||
const CHATS_REFRESH_INTERVAL_MS = 60_000
|
||||
const TRAY_ICON_SCALE_FACTOR = 2
|
||||
const TRAY_SUBSCRIPT_WIDTH = 5
|
||||
const TRAY_SUBSCRIPT_HEIGHT = 7
|
||||
@@ -382,20 +381,17 @@ export function buildTrayMenuTemplate(
|
||||
export interface TrayHandle {
|
||||
/**
|
||||
* Drops the cached chat titles. Called from the sign-out teardown: the titles
|
||||
* are the previous user's data and must not outlive their session, and the
|
||||
* menu pops from cache before it refreshes, so waiting for the next fetch
|
||||
* would show them one more time.
|
||||
* are the previous user's data and must not outlive their session.
|
||||
*/
|
||||
clearRecentChats(): void
|
||||
destroy(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* The macOS status item. No static context menu is attached — macOS shows an
|
||||
* attached menu synchronously without emitting 'click', which would freeze
|
||||
* the recent-chats section at creation time. Instead each click fetches the
|
||||
* chat list (bounded by a short timeout, falling back to the last good list)
|
||||
* and pops the freshly built menu.
|
||||
* The macOS status item owns an attached native menu, so macOS handles its
|
||||
* selected/open state and a second icon click closes it normally. Recent chats
|
||||
* refresh independently; each result swaps the attached menu for the next
|
||||
* open without putting network or menu construction on the click path.
|
||||
*/
|
||||
export function installTray(deps: TrayDeps): TrayHandle | null {
|
||||
const iconPath = join(app.getAppPath(), 'static', 'tray', 'simTemplate.png')
|
||||
@@ -410,11 +406,12 @@ export function installTray(deps: TrayDeps): TrayHandle | null {
|
||||
icon.setTemplateImage(true)
|
||||
|
||||
const tray = new Tray(icon)
|
||||
tray.setIgnoreDoubleClickEvents(true)
|
||||
tray.setToolTip(APP_NAME_FOR_CHANNEL[channel])
|
||||
|
||||
let cachedChats: RecentChat[] = []
|
||||
let refreshing = false
|
||||
let refreshPromise: Promise<boolean> | null = null
|
||||
let cacheGeneration = 0
|
||||
tray.setContextMenu(Menu.buildFromTemplate(buildTrayMenuTemplate(deps, [])))
|
||||
|
||||
const fetchRecentChats = async (): Promise<RecentChat[]> => {
|
||||
const ses = session.fromPartition(deps.partition())
|
||||
@@ -434,51 +431,45 @@ export function installTray(deps: TrayDeps): TrayHandle | null {
|
||||
return parseRecentChats(await response.json())
|
||||
}
|
||||
|
||||
/** Update the cached chat list for the NEXT open; never blocks a click. */
|
||||
const refreshChats = async () => {
|
||||
if (refreshing) return
|
||||
refreshing = true
|
||||
const generation = cacheGeneration
|
||||
try {
|
||||
const chats = await fetchRecentChats()
|
||||
// A sign-out while this was in flight invalidates the result — it was
|
||||
// read with the previous user's cookie.
|
||||
if (generation === cacheGeneration) {
|
||||
cachedChats = chats
|
||||
}
|
||||
} catch (error) {
|
||||
// Offline or an older server: keep the last good list rather than
|
||||
// blanking a menu that is still correct.
|
||||
logger.info('Recent chats unavailable for tray menu', { error })
|
||||
} finally {
|
||||
refreshing = false
|
||||
const replaceCachedChats = (chats: RecentChat[]) => {
|
||||
if (!tray.isDestroyed()) {
|
||||
tray.setContextMenu(Menu.buildFromTemplate(buildTrayMenuTemplate(deps, chats)))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pop the menu from the cached chat list so the click feels instant, then
|
||||
* refresh the cache in the background for the next open. One exception: when
|
||||
* the cache is EMPTY (failed launch warm-up, fresh sign-in) the menu would
|
||||
* pop without a Recent section and stay wrong until the next click — so wait
|
||||
* briefly for a refresh, popping no later than the grace period either way.
|
||||
*/
|
||||
const EMPTY_CACHE_POP_GRACE_MS = 600
|
||||
const popMenu = async () => {
|
||||
if (cachedChats.length === 0) {
|
||||
await Promise.race([refreshChats(), sleep(EMPTY_CACHE_POP_GRACE_MS)])
|
||||
}
|
||||
if (tray.isDestroyed()) return
|
||||
tray.popUpContextMenu(Menu.buildFromTemplate(buildTrayMenuTemplate(deps, cachedChats)))
|
||||
void refreshChats()
|
||||
/** Update the cached menu for the next open while sharing one in-flight request. */
|
||||
const refreshChats = (): Promise<boolean> => {
|
||||
if (refreshPromise) return refreshPromise
|
||||
const generation = cacheGeneration
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
const chats = await fetchRecentChats()
|
||||
// A sign-out while this was in flight invalidates the result — it was
|
||||
// read with the previous user's cookie.
|
||||
if (generation === cacheGeneration) {
|
||||
replaceCachedChats(chats)
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
// Offline or an older server: keep the last good list rather than
|
||||
// blanking a menu that is still correct.
|
||||
logger.info('Recent chats unavailable for tray menu', { error })
|
||||
return false
|
||||
} finally {
|
||||
refreshPromise = null
|
||||
}
|
||||
})()
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
// Warm the cache with retries: at launch the first fetch races the server
|
||||
// (dev recompiles, app cold start), and a single failed warm-up would leave
|
||||
// the first tray open without recents until a second click.
|
||||
// recents stale until the periodic refresh.
|
||||
const WARM_UP_BACKOFF_MS = [2_000, 5_000, 10_000, 20_000]
|
||||
const warmUp = async (attempt = 0) => {
|
||||
await refreshChats()
|
||||
if (cachedChats.length === 0 && attempt < WARM_UP_BACKOFF_MS.length && !tray.isDestroyed()) {
|
||||
if (tray.isDestroyed()) return
|
||||
const refreshed = await refreshChats()
|
||||
if (!refreshed && attempt < WARM_UP_BACKOFF_MS.length && !tray.isDestroyed()) {
|
||||
setTimeout(() => void warmUp(attempt + 1), WARM_UP_BACKOFF_MS[attempt]).unref?.()
|
||||
}
|
||||
}
|
||||
@@ -486,16 +477,13 @@ export function installTray(deps: TrayDeps): TrayHandle | null {
|
||||
|
||||
// Keep the cache (and the status dots) current even when the tray hasn't
|
||||
// been clicked in a while.
|
||||
const refreshTimer = setInterval(() => void refreshChats(), 60_000)
|
||||
const refreshTimer = setInterval(() => void refreshChats(), CHATS_REFRESH_INTERVAL_MS)
|
||||
refreshTimer.unref?.()
|
||||
|
||||
tray.on('click', () => void popMenu())
|
||||
tray.on('right-click', () => void popMenu())
|
||||
|
||||
return {
|
||||
clearRecentChats() {
|
||||
cacheGeneration += 1
|
||||
cachedChats = []
|
||||
replaceCachedChats([])
|
||||
},
|
||||
destroy() {
|
||||
clearInterval(refreshTimer)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { ipcOn, ipcSend } = vi.hoisted(() => ({
|
||||
ipcOn: vi.fn(),
|
||||
ipcSend: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcRenderer: {
|
||||
on: ipcOn,
|
||||
send: ipcSend,
|
||||
},
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('browser credential preload', () => {
|
||||
it('survives a missing initial root and detects a form after layout settles', async () => {
|
||||
vi.useFakeTimers()
|
||||
document.documentElement.remove()
|
||||
|
||||
await expect(import('@/preload/browser/index')).resolves.toBeDefined()
|
||||
|
||||
const html = document.createElement('html')
|
||||
const body = document.createElement('body')
|
||||
html.appendChild(body)
|
||||
document.appendChild(html)
|
||||
document.dispatchEvent(new Event('readystatechange'))
|
||||
|
||||
// Google Accounts' password challenge renders the chosen account as
|
||||
// ordinary text, not a username input. The password-only form must still
|
||||
// count as fillable on its first document load.
|
||||
const accountIdentifier = document.createElement('div')
|
||||
accountIdentifier.textContent = 'sid@example.com'
|
||||
body.appendChild(accountIdentifier)
|
||||
const form = document.createElement('form')
|
||||
body.appendChild(form)
|
||||
|
||||
let visible = false
|
||||
const password = document.createElement('input')
|
||||
password.type = 'password'
|
||||
password.name = 'Passwd'
|
||||
password.autocomplete = 'current-password'
|
||||
password.getBoundingClientRect = () =>
|
||||
({
|
||||
width: visible ? 200 : 0,
|
||||
height: visible ? 32 : 0,
|
||||
}) as DOMRect
|
||||
form.appendChild(password)
|
||||
document.dispatchEvent(new Event('DOMContentLoaded'))
|
||||
|
||||
expect(ipcSend).toHaveBeenLastCalledWith('browser-credentials:form-state', {
|
||||
origin: window.location.origin,
|
||||
hasLoginForm: false,
|
||||
hasPasswordField: false,
|
||||
})
|
||||
|
||||
// No DOM mutation here: this models stylesheet/layout completion after
|
||||
// hydration. The bounded initial rescan must still discover the field.
|
||||
visible = true
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
|
||||
expect(ipcSend).toHaveBeenLastCalledWith('browser-credentials:form-state', {
|
||||
origin: window.location.origin,
|
||||
hasLoginForm: true,
|
||||
hasPasswordField: true,
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
ipcSend.mockClear()
|
||||
visible = false
|
||||
password.classList.add('hidden')
|
||||
await Promise.resolve()
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
|
||||
expect(ipcSend).toHaveBeenLastCalledWith('browser-credentials:form-state', {
|
||||
origin: window.location.origin,
|
||||
hasLoginForm: false,
|
||||
hasPasswordField: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -19,7 +19,9 @@ import { ipcRenderer } from 'electron'
|
||||
|
||||
const FORM_STATE_CHANNEL = 'browser-credentials:form-state'
|
||||
const FILL_CHANNEL = 'browser-credentials:fill'
|
||||
const RESCAN_CHANNEL = 'browser-credentials:rescan'
|
||||
const RESCAN_DEBOUNCE_MS = 250
|
||||
const INITIAL_RESCAN_DELAYS_MS = [250, 750, 1_500, 3_000] as const
|
||||
|
||||
interface DetectedForm {
|
||||
username: HTMLInputElement | null
|
||||
@@ -31,6 +33,8 @@ interface DetectedForm {
|
||||
let detected: DetectedForm | null = null
|
||||
let lastReported = ''
|
||||
let rescanTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let formObserver: MutationObserver | null = null
|
||||
let initialRescansScheduled = false
|
||||
|
||||
function isFillable(field: HTMLInputElement): boolean {
|
||||
if (field.disabled || field.readOnly) return false
|
||||
@@ -132,13 +136,58 @@ function reportFormState(): void {
|
||||
}
|
||||
|
||||
function scheduleRescan(): void {
|
||||
if (rescanTimer !== null) clearTimeout(rescanTimer)
|
||||
// Coalesce a mutation burst without letting an animated page postpone the
|
||||
// scan forever by continually resetting a trailing-edge debounce.
|
||||
if (rescanTimer !== null) return
|
||||
rescanTimer = setTimeout(() => {
|
||||
rescanTimer = null
|
||||
reportFormState()
|
||||
}, RESCAN_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
function observeDocument(): void {
|
||||
if (formObserver || !document.documentElement) return
|
||||
formObserver = new MutationObserver(scheduleRescan)
|
||||
formObserver.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
// Login steps often exist at first paint and become usable only after a
|
||||
// framework flips an ancestor's visibility class/style.
|
||||
attributeFilter: [
|
||||
'type',
|
||||
'autocomplete',
|
||||
'disabled',
|
||||
'readonly',
|
||||
'class',
|
||||
'style',
|
||||
'hidden',
|
||||
'aria-hidden',
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
function reportInitialFormState(): void {
|
||||
observeDocument()
|
||||
reportFormState()
|
||||
if (initialRescansScheduled) return
|
||||
initialRescansScheduled = true
|
||||
// Stylesheets, hydration, and password-step transitions can settle without
|
||||
// a useful child mutation. A short bounded tail covers that first-load
|
||||
// window without leaving a permanent poller behind.
|
||||
for (const delay of INITIAL_RESCAN_DELAYS_MS) {
|
||||
setTimeout(reportFormState, delay)
|
||||
}
|
||||
}
|
||||
|
||||
ipcRenderer.on(RESCAN_CHANNEL, () => {
|
||||
// Same-document navigation does not recreate this preload. Main invalidates
|
||||
// its old form state for safety, so bypass the unchanged fingerprint once
|
||||
// and republish the live form after the SPA route has committed.
|
||||
lastReported = ''
|
||||
scheduleRescan()
|
||||
})
|
||||
|
||||
/**
|
||||
* Writes through the native value setter so frameworks that track their own
|
||||
* input state (React and friends) see the change instead of reverting it on
|
||||
@@ -175,16 +224,15 @@ ipcRenderer.on(
|
||||
}
|
||||
)
|
||||
|
||||
document.addEventListener('DOMContentLoaded', reportFormState)
|
||||
window.addEventListener('load', reportFormState)
|
||||
window.addEventListener('pageshow', reportFormState)
|
||||
// Electron preloads can run before `<html>` exists. Never pass that null root
|
||||
// to MutationObserver: the exception would permanently disable dynamic-form
|
||||
// detection for this document.
|
||||
observeDocument()
|
||||
document.addEventListener('readystatechange', observeDocument)
|
||||
document.addEventListener('DOMContentLoaded', reportInitialFormState)
|
||||
window.addEventListener('load', reportInitialFormState)
|
||||
window.addEventListener('pageshow', reportInitialFormState)
|
||||
|
||||
// Login forms are routinely rendered after first paint, behind a "Sign in"
|
||||
// toggle, or swapped in by a single-page router — a one-shot scan would miss
|
||||
// most of them.
|
||||
new MutationObserver(scheduleRescan).observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['type', 'autocomplete', 'disabled', 'readonly'],
|
||||
})
|
||||
// toggle, or swapped in by a single-page router. The observer is installed by
|
||||
// `observeDocument` as soon as a real document root exists.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { SimDesktopApi } from '@sim/desktop-bridge'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { exposeInMainWorld, invoke } = vi.hoisted(() => ({
|
||||
exposeInMainWorld: vi.fn(),
|
||||
invoke: vi.fn(() => Promise.resolve(true)),
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
contextBridge: { exposeInMainWorld },
|
||||
ipcRenderer: {
|
||||
invoke,
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
send: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
await import('@/preload/index')
|
||||
|
||||
describe('desktop preload bridge', () => {
|
||||
it('normalizes and forwards browser panel force-hide requests', async () => {
|
||||
const exposed = exposeInMainWorld.mock.calls.find(([name]) => name === 'simDesktop')?.[1] as
|
||||
| SimDesktopApi
|
||||
| undefined
|
||||
expect(exposed).toBeDefined()
|
||||
if (!exposed) throw new Error('Expected the desktop preload API to be exposed')
|
||||
expect(exposed.browserAgent.supportsAtomicPanelOcclusion).toBe(true)
|
||||
|
||||
await exposed.browserAgent.setPanelOccluded(true, 'chat-default')
|
||||
await exposed.browserAgent.setPanelOccluded(false, 'chat-explicit-false', false)
|
||||
await exposed.browserAgent.setPanelOccluded(true, 'chat-force', true)
|
||||
|
||||
expect(invoke.mock.calls).toEqual([
|
||||
['browser-agent:set-panel-occluded', true, 'chat-default', false],
|
||||
['browser-agent:set-panel-occluded', false, 'chat-explicit-false', false],
|
||||
['browser-agent:set-panel-occluded', true, 'chat-force', true],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -15,14 +15,18 @@ import type {
|
||||
BrowserToolResponse,
|
||||
} from '@sim/browser-protocol'
|
||||
import type {
|
||||
BrowserAddToChatPayload,
|
||||
BrowserChromeImportResult,
|
||||
BrowserCredentialConflictPolicy,
|
||||
BrowserCredentialMetadata,
|
||||
BrowserDownloadsState,
|
||||
BrowserFillAvailability,
|
||||
BrowserImportProfile,
|
||||
BrowserImportResult,
|
||||
BrowserPasswordImportResult,
|
||||
BrowserSiteInfo,
|
||||
BrowserToolbarCommand,
|
||||
DesktopAppearanceTheme,
|
||||
DesktopCommand,
|
||||
DesktopNotificationPayload,
|
||||
DesktopOAuthConnectResult,
|
||||
@@ -31,16 +35,19 @@ import type {
|
||||
DesktopPreferences,
|
||||
DesktopUpdateState,
|
||||
DesktopWindowState,
|
||||
DesktopZoomPercent,
|
||||
LocalFilesystemRequest,
|
||||
LocalFilesystemResponse,
|
||||
SimDesktopApi,
|
||||
TerminalShortcutCommand,
|
||||
TerminalThemeProfile,
|
||||
} from '@sim/desktop-bridge'
|
||||
import {
|
||||
type ScopedTerminalCommandEvent,
|
||||
type ScopedTerminalTabsState,
|
||||
TERMINAL_TOOL_NAME,
|
||||
type TerminalCommandEvent,
|
||||
type TerminalOperation,
|
||||
type TerminalStartOptions,
|
||||
type TerminalTabsState,
|
||||
type TerminalToolArgs,
|
||||
type TerminalToolResponse,
|
||||
} from '@sim/terminal-protocol'
|
||||
@@ -48,15 +55,54 @@ import { contextBridge, ipcRenderer } from 'electron'
|
||||
|
||||
const VERSION_ARG_PREFIX = '--sim-desktop-version='
|
||||
|
||||
interface FillAvailabilitySubscription {
|
||||
callback: (state: BrowserFillAvailability) => void
|
||||
scopeId: string
|
||||
}
|
||||
|
||||
const fillAvailabilitySubscriptions = new Set<FillAvailabilitySubscription>()
|
||||
let latestFillAvailability: BrowserFillAvailability | null = null
|
||||
|
||||
// Keep the latest scoped value in the always-live preload. Browser resources
|
||||
// mount after native scope activation, so a component-level listener alone can
|
||||
// miss the only availability edge that made the key affordance visible.
|
||||
ipcRenderer.on(
|
||||
'browser-credentials:fill-availability',
|
||||
(_event: unknown, state: BrowserFillAvailability) => {
|
||||
if (!state || typeof state.available !== 'boolean') return
|
||||
latestFillAvailability = state
|
||||
for (const subscription of fillAvailabilitySubscriptions) {
|
||||
if (state.scopeId !== subscription.scopeId) {
|
||||
continue
|
||||
}
|
||||
subscription.callback(state)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function subscribeFillAvailability(
|
||||
callback: (state: BrowserFillAvailability) => void,
|
||||
scopeId: string
|
||||
): () => void {
|
||||
const subscription: FillAvailabilitySubscription = { callback, scopeId }
|
||||
fillAvailabilitySubscriptions.add(subscription)
|
||||
if (latestFillAvailability && latestFillAvailability.scopeId === scopeId) {
|
||||
callback(latestFillAvailability)
|
||||
}
|
||||
return () => {
|
||||
fillAvailabilitySubscriptions.delete(subscription)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The shell version injected by the main process as a preload argv flag (see
|
||||
* createSecureWebPreferences). Read synchronously so the web app's minimum
|
||||
* shell version gate has it at first paint.
|
||||
*/
|
||||
function shellVersion(): string | undefined {
|
||||
function shellVersion(): string {
|
||||
const arg = process.argv.find((value) => value.startsWith(VERSION_ARG_PREFIX))
|
||||
const version = arg?.slice(VERSION_ARG_PREFIX.length)
|
||||
return version || undefined
|
||||
return version || '0.0.0'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,7 +110,7 @@ function shellVersion(): string | undefined {
|
||||
* the main process — nothing here grants page code any privilege by itself.
|
||||
*/
|
||||
const api: SimDesktopApi = {
|
||||
...(shellVersion() ? { version: shellVersion() } : {}),
|
||||
version: shellVersion(),
|
||||
openExternal: (url: string): Promise<boolean> => ipcRenderer.invoke('desktop:open-external', url),
|
||||
beginOAuthConnect: (providerId: string, scope?: DesktopOAuthConnectScope): Promise<boolean> =>
|
||||
ipcRenderer.invoke('desktop:oauth-connect', providerId, scope),
|
||||
@@ -103,12 +149,16 @@ const api: SimDesktopApi = {
|
||||
ipcRenderer.invoke('desktop:settings:set', key, value),
|
||||
notify: (payload: DesktopNotificationPayload): Promise<boolean> =>
|
||||
ipcRenderer.invoke('desktop:settings:notify', payload),
|
||||
setTrayEnabled: (enabled: boolean): Promise<DesktopPreferences> =>
|
||||
ipcRenderer.invoke('desktop:settings:set', 'trayEnabled', enabled),
|
||||
setBrowserEnabled: (enabled: boolean): Promise<DesktopPreferences> =>
|
||||
ipcRenderer.invoke('desktop:settings:set', 'browserEnabled', enabled),
|
||||
setTerminalEnabled: (enabled: boolean): Promise<DesktopPreferences> =>
|
||||
ipcRenderer.invoke('desktop:settings:set', 'terminalEnabled', enabled),
|
||||
setBrowserTheme: (theme: DesktopAppearanceTheme): Promise<DesktopPreferences> =>
|
||||
ipcRenderer.invoke('desktop:settings:set-appearance', 'browserTheme', theme),
|
||||
setBrowserDefaultZoom: (zoom: DesktopZoomPercent): Promise<DesktopPreferences> =>
|
||||
ipcRenderer.invoke('desktop:settings:set-browser-default-zoom', zoom),
|
||||
chooseBrowserDownloadDirectory: (): Promise<DesktopPreferences | null> =>
|
||||
ipcRenderer.invoke('desktop:settings:choose-browser-download-directory'),
|
||||
setTerminalTheme: (theme: DesktopAppearanceTheme): Promise<DesktopPreferences> =>
|
||||
ipcRenderer.invoke('desktop:settings:set-appearance', 'terminalTheme', theme),
|
||||
setTerminalDefaultZoom: (zoom: DesktopZoomPercent): Promise<DesktopPreferences> =>
|
||||
ipcRenderer.invoke('desktop:settings:set-terminal-default-zoom', zoom),
|
||||
},
|
||||
updates: {
|
||||
getState: (): Promise<DesktopUpdateState> => ipcRenderer.invoke('desktop:updates:get-state'),
|
||||
@@ -127,77 +177,93 @@ const api: SimDesktopApi = {
|
||||
},
|
||||
},
|
||||
browserAgent: {
|
||||
supportsAtomicPanelOcclusion: true,
|
||||
executeTool: (
|
||||
toolCallId: string,
|
||||
tool: BrowserToolName,
|
||||
params: Record<string, unknown>
|
||||
params: Record<string, unknown>,
|
||||
scopeId: string
|
||||
): Promise<BrowserToolResponse> =>
|
||||
ipcRenderer.invoke('browser-agent:execute-tool', toolCallId, tool, params),
|
||||
panelAction: (action: BrowserPanelAction): void => {
|
||||
ipcRenderer.send('browser-agent:panel-action', action)
|
||||
ipcRenderer.invoke('browser-agent:execute-tool', toolCallId, tool, params, scopeId),
|
||||
panelAction: (action: BrowserPanelAction, scopeId: string): void => {
|
||||
ipcRenderer.send('browser-agent:panel-action', action, scopeId)
|
||||
},
|
||||
setTabPinned: (tabId: string, pinned: boolean): void => {
|
||||
ipcRenderer.send('browser-agent:set-tab-pinned', tabId, pinned)
|
||||
activateScope: (scopeId: string): Promise<BrowserTabsState> =>
|
||||
ipcRenderer.invoke('browser-agent:activate-scope', scopeId),
|
||||
restoreScope: (scopeId: string): Promise<BrowserTabsState> =>
|
||||
ipcRenderer.invoke('browser-agent:restore-scope', scopeId),
|
||||
migrateScope: (fromScopeId: string, toScopeId: string): Promise<BrowserTabsState> =>
|
||||
ipcRenderer.invoke('browser-agent:migrate-scope', fromScopeId, toScopeId),
|
||||
disposeScope: (scopeId: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke('browser-agent:dispose-scope', scopeId),
|
||||
suspendScope: (scopeId: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke('browser-agent:suspend-scope', scopeId),
|
||||
setTabPinned: (tabId: string, pinned: boolean, scopeId: string): void => {
|
||||
ipcRenderer.send('browser-agent:set-tab-pinned', tabId, pinned, scopeId)
|
||||
},
|
||||
reorderTab: (tabId: string, targetIndex: number): void => {
|
||||
ipcRenderer.send('browser-agent:reorder-tab', tabId, targetIndex)
|
||||
showTabContextMenu: (tabId: string, scopeId: string): void => {
|
||||
ipcRenderer.send('browser-agent:show-tab-context-menu', tabId, scopeId)
|
||||
},
|
||||
reorderTab: (tabId: string, targetIndex: number, scopeId: string): void => {
|
||||
ipcRenderer.send('browser-agent:reorder-tab', tabId, targetIndex, scopeId)
|
||||
},
|
||||
setPanelBounds: (
|
||||
bounds: BrowserPanelBounds | null,
|
||||
anchor?: BrowserPanelAnchor | null
|
||||
anchor: BrowserPanelAnchor | null,
|
||||
scopeId: string
|
||||
): void => {
|
||||
ipcRenderer.send('browser-agent:set-panel-bounds', bounds, anchor ?? null)
|
||||
ipcRenderer.send('browser-agent:set-panel-bounds', bounds, anchor ?? null, scopeId)
|
||||
},
|
||||
setPanelFocused: (focused: boolean): void => {
|
||||
ipcRenderer.send('browser-agent:set-panel-focused', focused)
|
||||
},
|
||||
setPanelOccluded: (occluded: boolean): void => {
|
||||
ipcRenderer.send('browser-agent:set-panel-occluded', occluded)
|
||||
capturePanelSnapshot: (scopeId: string): Promise<BrowserPanelSnapshot | null> =>
|
||||
ipcRenderer.invoke('browser-agent:capture-panel-snapshot', scopeId),
|
||||
setPanelOccluded: (occluded: boolean, scopeId: string, force?: boolean): Promise<boolean> =>
|
||||
ipcRenderer.invoke('browser-agent:set-panel-occluded', occluded, scopeId, force ?? false),
|
||||
setPanelFocused: (focused: boolean, scopeId: string): void => {
|
||||
ipcRenderer.send('browser-agent:set-panel-focused', focused, scopeId)
|
||||
},
|
||||
setTheme: (theme: BrowserTheme): void => {
|
||||
ipcRenderer.send('browser-agent:set-theme', theme)
|
||||
},
|
||||
onFocusOmnibox: (callback: (mode: BrowserOmniboxFocusMode) => void): (() => void) => {
|
||||
const listener = (_event: unknown, mode: BrowserOmniboxFocusMode) => callback(mode)
|
||||
onFocusOmnibox: (
|
||||
callback: (mode: BrowserOmniboxFocusMode, scopeId: string) => void
|
||||
): (() => void) => {
|
||||
const listener = (_event: unknown, mode: BrowserOmniboxFocusMode, scopeId: string) =>
|
||||
callback(mode, scopeId)
|
||||
ipcRenderer.on('browser-agent:focus-omnibox', listener)
|
||||
return () => {
|
||||
ipcRenderer.removeListener('browser-agent:focus-omnibox', listener)
|
||||
}
|
||||
},
|
||||
find: (request: BrowserFindRequest): void => {
|
||||
ipcRenderer.send('browser-agent:find', request)
|
||||
find: (request: BrowserFindRequest, scopeId: string): void => {
|
||||
ipcRenderer.send('browser-agent:find', request, scopeId)
|
||||
},
|
||||
stopFind: (focusPage?: boolean): void => {
|
||||
ipcRenderer.send('browser-agent:stop-find', focusPage === true)
|
||||
stopFind: (focusPage: boolean, scopeId: string): void => {
|
||||
ipcRenderer.send('browser-agent:stop-find', focusPage === true, scopeId)
|
||||
},
|
||||
onOpenFind: (callback: () => void): (() => void) => {
|
||||
const listener = () => callback()
|
||||
onOpenFind: (callback: (scopeId: string) => void): (() => void) => {
|
||||
const listener = (_event: unknown, scopeId: string) => callback(scopeId)
|
||||
ipcRenderer.on('browser-agent:open-find', listener)
|
||||
return () => {
|
||||
ipcRenderer.removeListener('browser-agent:open-find', listener)
|
||||
}
|
||||
},
|
||||
onCloseFind: (callback: () => void): (() => void) => {
|
||||
const listener = () => callback()
|
||||
onCloseFind: (callback: (scopeId: string) => void): (() => void) => {
|
||||
const listener = (_event: unknown, scopeId: string) => callback(scopeId)
|
||||
ipcRenderer.on('browser-agent:close-find', listener)
|
||||
return () => {
|
||||
ipcRenderer.removeListener('browser-agent:close-find', listener)
|
||||
}
|
||||
},
|
||||
onFindResult: (callback: (result: BrowserFindResult) => void): (() => void) => {
|
||||
const listener = (_event: unknown, result: BrowserFindResult) => callback(result)
|
||||
onFindResult: (
|
||||
callback: (result: BrowserFindResult, scopeId: string) => void
|
||||
): (() => void) => {
|
||||
const listener = (_event: unknown, result: BrowserFindResult, scopeId: string) =>
|
||||
callback(result, scopeId)
|
||||
ipcRenderer.on('browser-agent:find-result', listener)
|
||||
return () => {
|
||||
ipcRenderer.removeListener('browser-agent:find-result', listener)
|
||||
}
|
||||
},
|
||||
onPanelSnapshot: (callback: (snapshot: BrowserPanelSnapshot) => void): (() => void) => {
|
||||
const listener = (_event: unknown, snapshot: BrowserPanelSnapshot) => callback(snapshot)
|
||||
ipcRenderer.on('browser-agent:panel-snapshot', listener)
|
||||
return () => {
|
||||
ipcRenderer.removeListener('browser-agent:panel-snapshot', listener)
|
||||
}
|
||||
},
|
||||
onPageState: (callback: (state: BrowserPageState) => void): (() => void) => {
|
||||
const listener = (_event: unknown, state: BrowserPageState) => callback(state)
|
||||
ipcRenderer.on('browser-agent:page-state', listener)
|
||||
@@ -205,12 +271,51 @@ const api: SimDesktopApi = {
|
||||
ipcRenderer.removeListener('browser-agent:page-state', listener)
|
||||
}
|
||||
},
|
||||
getTabsState: (): Promise<BrowserTabsState> =>
|
||||
ipcRenderer.invoke('browser-agent:get-tabs-state'),
|
||||
getTabsState: (scopeId: string): Promise<BrowserTabsState> =>
|
||||
ipcRenderer.invoke('browser-agent:get-tabs-state', scopeId),
|
||||
getKnownSessions: (): Promise<BrowserKnownSessionsState> =>
|
||||
ipcRenderer.invoke('browser-agent:get-known-sessions'),
|
||||
clearBrowsingData: (kinds?: readonly BrowserDataKind[]): Promise<BrowserKnownSessionsState> =>
|
||||
ipcRenderer.invoke('browser-agent:clear-browsing-data', kinds),
|
||||
getDownloadsState: (scopeId: string): Promise<BrowserDownloadsState> =>
|
||||
ipcRenderer.invoke('browser-agent:get-downloads-state', scopeId),
|
||||
showDownloadsMenu: (anchor: { x: number; y: number }, scopeId: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke('browser-agent:show-downloads-menu', anchor, scopeId),
|
||||
showToolbarMenu: (anchor: { x: number; y: number }, scopeId: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke('browser-agent:show-toolbar-menu', anchor, scopeId),
|
||||
showDownloadInFolder: (downloadId: string, scopeId: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke('browser-agent:show-download-in-folder', downloadId, scopeId),
|
||||
onDownloadsState: (callback: (state: BrowserDownloadsState) => void): (() => void) => {
|
||||
const listener = (_event: unknown, state: BrowserDownloadsState) => callback(state)
|
||||
ipcRenderer.on('browser-agent:downloads-state', listener)
|
||||
return () => {
|
||||
ipcRenderer.removeListener('browser-agent:downloads-state', listener)
|
||||
}
|
||||
},
|
||||
onToolbarCommand: (
|
||||
callback: (command: BrowserToolbarCommand, scopeId: string) => void
|
||||
): (() => void) => {
|
||||
const listener = (_event: unknown, command: BrowserToolbarCommand, scopeId: string) =>
|
||||
callback(command, scopeId)
|
||||
ipcRenderer.on('browser-agent:toolbar-command', listener)
|
||||
return () => {
|
||||
ipcRenderer.removeListener('browser-agent:toolbar-command', listener)
|
||||
}
|
||||
},
|
||||
onAddToChat: (callback: (payload: BrowserAddToChatPayload) => void): (() => void) => {
|
||||
const listener = (_event: unknown, payload: BrowserAddToChatPayload) => callback(payload)
|
||||
ipcRenderer.on('browser-agent:add-to-chat', listener)
|
||||
return () => {
|
||||
ipcRenderer.removeListener('browser-agent:add-to-chat', listener)
|
||||
}
|
||||
},
|
||||
onAppearanceThemeChanged: (callback: (theme: DesktopAppearanceTheme) => void): (() => void) => {
|
||||
const listener = (_event: unknown, theme: DesktopAppearanceTheme) => callback(theme)
|
||||
ipcRenderer.on('browser-agent:appearance-theme-changed', listener)
|
||||
return () => {
|
||||
ipcRenderer.removeListener('browser-agent:appearance-theme-changed', listener)
|
||||
}
|
||||
},
|
||||
onTabsState: (callback: (state: BrowserTabsState) => void): (() => void) => {
|
||||
const listener = (_event: unknown, state: BrowserTabsState) => callback(state)
|
||||
ipcRenderer.on('browser-agent:tabs-state', listener)
|
||||
@@ -218,13 +323,21 @@ const api: SimDesktopApi = {
|
||||
ipcRenderer.removeListener('browser-agent:tabs-state', listener)
|
||||
}
|
||||
},
|
||||
onSessionStatus: (callback: (alive: boolean) => void): (() => void) => {
|
||||
const listener = (_event: unknown, alive: boolean) => callback(alive)
|
||||
onSessionStatus: (callback: (alive: boolean, scopeId: string) => void): (() => void) => {
|
||||
const listener = (_event: unknown, alive: boolean, scopeId: string) =>
|
||||
callback(alive, scopeId)
|
||||
ipcRenderer.on('browser-agent:session-status', listener)
|
||||
return () => {
|
||||
ipcRenderer.removeListener('browser-agent:session-status', listener)
|
||||
}
|
||||
},
|
||||
onScopeSuspended: (callback: (scopeId: string) => void): (() => void) => {
|
||||
const listener = (_event: unknown, scopeId: string) => callback(scopeId)
|
||||
ipcRenderer.on('browser-agent:scope-suspended', listener)
|
||||
return () => {
|
||||
ipcRenderer.removeListener('browser-agent:scope-suspended', listener)
|
||||
}
|
||||
},
|
||||
},
|
||||
// Omitted entirely off macOS, so the web app's feature detection reflects
|
||||
// whether an import can actually run rather than only whether the shell is
|
||||
@@ -232,6 +345,12 @@ const api: SimDesktopApi = {
|
||||
// worked around.
|
||||
...(process.platform === 'darwin'
|
||||
? {
|
||||
terminalThemes: {
|
||||
listProfiles: (): Promise<TerminalThemeProfile[]> =>
|
||||
ipcRenderer.invoke('terminal-themes:list-profiles'),
|
||||
selectProfile: (profileId: string): Promise<DesktopPreferences | null> =>
|
||||
ipcRenderer.invoke('terminal-themes:select-profile', profileId),
|
||||
},
|
||||
browserImport: {
|
||||
listChromeProfiles: (): Promise<BrowserImportProfile[]> =>
|
||||
ipcRenderer.invoke('browser-import:list-profiles'),
|
||||
@@ -246,10 +365,9 @@ const api: SimDesktopApi = {
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
// Note what is absent: there is no method that returns a password, and none
|
||||
// that names a credential to fill. Filling is completed by a native menu in
|
||||
// the main process, so the strongest thing a compromised renderer can do
|
||||
// here is ask for that menu to open.
|
||||
// Note what is absent: there is no fill path that returns a password. The
|
||||
// renderer can name only an option from the latest active-page match list;
|
||||
// the main process owns the short-lived authorization and the actual fill.
|
||||
browserCredentials: {
|
||||
isAvailable: (): Promise<boolean> => ipcRenderer.invoke('browser-credentials:available'),
|
||||
list: (): Promise<BrowserCredentialMetadata[]> =>
|
||||
@@ -266,20 +384,21 @@ const api: SimDesktopApi = {
|
||||
policy?: BrowserCredentialConflictPolicy
|
||||
): Promise<BrowserPasswordImportResult> =>
|
||||
ipcRenderer.invoke('browser-credentials:import', profileId, policy),
|
||||
showChooser: (anchor: { x: number; y: number }): Promise<boolean> =>
|
||||
ipcRenderer.invoke('browser-credentials:show-chooser', anchor),
|
||||
onFillAvailability: (callback: (state: BrowserFillAvailability) => void): (() => void) => {
|
||||
const listener = (_event: unknown, state: BrowserFillAvailability) => callback(state)
|
||||
ipcRenderer.on('browser-credentials:fill-availability', listener)
|
||||
return () => {
|
||||
ipcRenderer.removeListener('browser-credentials:fill-availability', listener)
|
||||
}
|
||||
},
|
||||
showChooser: (anchor: { x: number; y: number }, scopeId: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke('browser-credentials:show-chooser', anchor, scopeId),
|
||||
listFillOptions: (scopeId: string): Promise<BrowserCredentialMetadata[]> =>
|
||||
ipcRenderer.invoke('browser-credentials:list-fill-options', scopeId),
|
||||
fill: (id: string, scopeId: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke('browser-credentials:fill-selected', id, scopeId),
|
||||
onFillAvailability: subscribeFillAvailability,
|
||||
},
|
||||
terminal: {
|
||||
start: async (options: TerminalStartOptions): Promise<TerminalTabsState> => {
|
||||
const response = (await ipcRenderer.invoke('terminal:start', options)) as
|
||||
| { ok: true; tabs: TerminalTabsState }
|
||||
start: async (
|
||||
options: TerminalStartOptions,
|
||||
scopeId: string
|
||||
): Promise<ScopedTerminalTabsState> => {
|
||||
const response = (await ipcRenderer.invoke('terminal:start', options, scopeId)) as
|
||||
| { ok: true; tabs: ScopedTerminalTabsState }
|
||||
| { ok: false; code?: string; error?: string }
|
||||
if (!response?.ok) {
|
||||
const failure = new Error(response?.error ?? 'Could not open a terminal.')
|
||||
@@ -294,60 +413,108 @@ const api: SimDesktopApi = {
|
||||
executeTool: (
|
||||
toolCallId: string,
|
||||
operation: TerminalOperation,
|
||||
args: TerminalToolArgs
|
||||
args: TerminalToolArgs,
|
||||
scopeId: string
|
||||
): Promise<TerminalToolResponse> =>
|
||||
ipcRenderer.invoke('terminal:execute-tool', toolCallId, TERMINAL_TOOL_NAME, {
|
||||
operation,
|
||||
args,
|
||||
}),
|
||||
write: (terminalId: string, data: string): void => {
|
||||
ipcRenderer.send('terminal:write', terminalId, data)
|
||||
ipcRenderer.invoke(
|
||||
'terminal:execute-tool',
|
||||
toolCallId,
|
||||
TERMINAL_TOOL_NAME,
|
||||
{
|
||||
operation,
|
||||
args,
|
||||
},
|
||||
scopeId
|
||||
),
|
||||
write: (terminalId: string, data: string, scopeId: string): void => {
|
||||
ipcRenderer.send('terminal:write', terminalId, data, scopeId)
|
||||
},
|
||||
paste: (terminalId: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke('terminal:paste', terminalId),
|
||||
resize: (terminalId: string, cols: number, rows: number): void => {
|
||||
ipcRenderer.send('terminal:resize', terminalId, cols, rows)
|
||||
paste: (terminalId: string, scopeId: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke('terminal:paste', terminalId, scopeId),
|
||||
resize: (terminalId: string, cols: number, rows: number, scopeId: string): void => {
|
||||
ipcRenderer.send('terminal:resize', terminalId, cols, rows, scopeId)
|
||||
},
|
||||
openTerminal: (cwd?: string): Promise<TerminalTabsState> =>
|
||||
ipcRenderer.invoke('terminal:open', cwd),
|
||||
switchTerminal: (terminalId: string): Promise<TerminalTabsState> =>
|
||||
ipcRenderer.invoke('terminal:switch', terminalId),
|
||||
closeTerminal: (terminalId: string): Promise<TerminalTabsState> =>
|
||||
ipcRenderer.invoke('terminal:close', terminalId),
|
||||
getTabs: (): Promise<TerminalTabsState> => ipcRenderer.invoke('terminal:get-tabs'),
|
||||
openTerminal: (cwd: string | undefined, scopeId: string): Promise<ScopedTerminalTabsState> =>
|
||||
ipcRenderer.invoke('terminal:open', cwd, scopeId),
|
||||
switchTerminal: (terminalId: string, scopeId: string): Promise<ScopedTerminalTabsState> =>
|
||||
ipcRenderer.invoke('terminal:switch', terminalId, scopeId),
|
||||
closeTerminal: (terminalId: string, scopeId: string): Promise<ScopedTerminalTabsState> =>
|
||||
ipcRenderer.invoke('terminal:close', terminalId, scopeId),
|
||||
getTabs: (scopeId: string): Promise<ScopedTerminalTabsState> =>
|
||||
ipcRenderer.invoke('terminal:get-tabs', scopeId),
|
||||
activateScope: (scopeId: string): Promise<ScopedTerminalTabsState> =>
|
||||
ipcRenderer.invoke('terminal:activate-scope', scopeId),
|
||||
migrateScope: (fromScopeId: string, toScopeId: string): Promise<ScopedTerminalTabsState> =>
|
||||
ipcRenderer.invoke('terminal:migrate-scope', fromScopeId, toScopeId),
|
||||
disposeScope: (scopeId: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke('terminal:dispose-scope', scopeId),
|
||||
suspendScope: (scopeId: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke('terminal:suspend-scope', scopeId),
|
||||
dispose: (): void => {
|
||||
ipcRenderer.send('terminal:dispose')
|
||||
},
|
||||
onData: (callback: (terminalId: string, data: string) => void): (() => void) => {
|
||||
const listener = (_event: unknown, terminalId: string, data: string) =>
|
||||
callback(terminalId, data)
|
||||
onData: (
|
||||
callback: (terminalId: string, data: string, scopeId: string) => void
|
||||
): (() => void) => {
|
||||
const listener = (_event: unknown, terminalId: string, data: string, scopeId: string) =>
|
||||
callback(terminalId, data, scopeId)
|
||||
ipcRenderer.on('terminal:data', listener)
|
||||
return () => {
|
||||
ipcRenderer.removeListener('terminal:data', listener)
|
||||
}
|
||||
},
|
||||
getScrollback: (terminalId: string): Promise<string> =>
|
||||
ipcRenderer.invoke('terminal:scrollback', terminalId),
|
||||
setFocused: (focused: boolean): void => {
|
||||
ipcRenderer.send('terminal:focused', focused)
|
||||
getScrollback: (terminalId: string, scopeId: string): Promise<string> =>
|
||||
ipcRenderer.invoke('terminal:scrollback', terminalId, scopeId),
|
||||
clearScrollback: (terminalId: string, scopeId: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke('terminal:clear-scrollback', terminalId, scopeId),
|
||||
setFocused: (focused: boolean, scopeId: string): void => {
|
||||
ipcRenderer.send('terminal:focused', focused, scopeId)
|
||||
},
|
||||
finishHandoff: (terminalId: string): void => {
|
||||
ipcRenderer.send('terminal:handoff-done', terminalId)
|
||||
finishHandoff: (terminalId: string, scopeId: string): void => {
|
||||
ipcRenderer.send('terminal:handoff-done', terminalId, scopeId)
|
||||
},
|
||||
onTabs: (callback: (state: TerminalTabsState) => void): (() => void) => {
|
||||
const listener = (_event: unknown, state: TerminalTabsState) => callback(state)
|
||||
onTabs: (callback: (state: ScopedTerminalTabsState) => void): (() => void) => {
|
||||
const listener = (_event: unknown, state: ScopedTerminalTabsState) => callback(state)
|
||||
ipcRenderer.on('terminal:tabs', listener)
|
||||
return () => {
|
||||
ipcRenderer.removeListener('terminal:tabs', listener)
|
||||
}
|
||||
},
|
||||
onCommand: (callback: (event: TerminalCommandEvent) => void): (() => void) => {
|
||||
const listener = (_event: unknown, payload: TerminalCommandEvent) => callback(payload)
|
||||
onCommand: (callback: (event: ScopedTerminalCommandEvent) => void): (() => void) => {
|
||||
const listener = (_event: unknown, payload: ScopedTerminalCommandEvent) => callback(payload)
|
||||
ipcRenderer.on('terminal:command', listener)
|
||||
return () => {
|
||||
ipcRenderer.removeListener('terminal:command', listener)
|
||||
}
|
||||
},
|
||||
onShortcutCommand: (
|
||||
callback: (command: TerminalShortcutCommand, scopeId: string, terminalId?: string) => void
|
||||
): (() => void) => {
|
||||
const listener = (
|
||||
_event: unknown,
|
||||
command: TerminalShortcutCommand,
|
||||
scopeId: string,
|
||||
terminalId?: string
|
||||
) => callback(command, scopeId, terminalId)
|
||||
ipcRenderer.on('terminal:shortcut-command', listener)
|
||||
return () => {
|
||||
ipcRenderer.removeListener('terminal:shortcut-command', listener)
|
||||
}
|
||||
},
|
||||
onDefaultZoomChanged: (callback: (zoom: DesktopZoomPercent) => void): (() => void) => {
|
||||
const listener = (_event: unknown, zoom: DesktopZoomPercent) => callback(zoom)
|
||||
ipcRenderer.on('terminal:default-zoom-changed', listener)
|
||||
return () => {
|
||||
ipcRenderer.removeListener('terminal:default-zoom-changed', listener)
|
||||
}
|
||||
},
|
||||
onScopeSuspended: (callback: (scopeId: string) => void): (() => void) => {
|
||||
const listener = (_event: unknown, scopeId: string) => callback(scopeId)
|
||||
ipcRenderer.on('terminal:scope-suspended', listener)
|
||||
return () => {
|
||||
ipcRenderer.removeListener('terminal:scope-suspended', listener)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ export const crashReporter = {
|
||||
|
||||
export const shell = {
|
||||
openExternal: vi.fn(() => Promise.resolve()),
|
||||
openPath: vi.fn(() => Promise.resolve('')),
|
||||
showItemInFolder: vi.fn(),
|
||||
}
|
||||
|
||||
@@ -105,6 +106,7 @@ export class Tray {
|
||||
}
|
||||
setToolTip = vi.fn()
|
||||
setContextMenu = vi.fn()
|
||||
setIgnoreDoubleClickEvents = vi.fn()
|
||||
popUpContextMenu = vi.fn()
|
||||
on = vi.fn()
|
||||
destroy = vi.fn()
|
||||
@@ -131,14 +133,17 @@ function createWebContentsMock() {
|
||||
getTitle: vi.fn(() => 'Example'),
|
||||
loadURL: vi.fn(() => Promise.resolve()),
|
||||
reload: vi.fn(),
|
||||
print: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
isFocused: vi.fn(() => false),
|
||||
close: vi.fn(),
|
||||
isDestroyed: vi.fn(() => false),
|
||||
isLoading: vi.fn(() => false),
|
||||
isLoadingMainFrame: vi.fn(() => false),
|
||||
findInPage: vi.fn(() => 1),
|
||||
stopFindInPage: vi.fn(),
|
||||
setBackgroundThrottling: vi.fn(),
|
||||
setIgnoreMenuShortcuts: vi.fn(),
|
||||
getZoomFactor: vi.fn(() => 1),
|
||||
setZoomFactor: vi.fn(),
|
||||
copy: vi.fn(),
|
||||
@@ -181,7 +186,11 @@ export class WebContentsView {
|
||||
webContents = createWebContentsMock()
|
||||
setBackgroundColor = vi.fn()
|
||||
setVisible = vi.fn()
|
||||
setBounds = vi.fn()
|
||||
private bounds = { x: 0, y: 0, width: 0, height: 0 }
|
||||
setBounds = vi.fn((bounds: { x: number; y: number; width: number; height: number }) => {
|
||||
this.bounds = { ...bounds }
|
||||
})
|
||||
getBounds = vi.fn(() => ({ ...this.bounds }))
|
||||
}
|
||||
|
||||
export class BrowserWindow {
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 16 KiB |
@@ -38,6 +38,7 @@ const globalStyles = read('../_styles/globals.css')
|
||||
const desktopTitleBar = read('../_shell/desktop-title-bar.tsx')
|
||||
const logoShell = read('../(landing)/components/logo-shell/logo-shell.tsx')
|
||||
const pageHeaderBar = read('../../components/page-header-bar.ts')
|
||||
const settingsHeader = read('../../components/settings/settings-header.tsx')
|
||||
const resourceHeader = read(
|
||||
'../workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx'
|
||||
)
|
||||
@@ -170,6 +171,13 @@ describe('desktop title-bar surface audit', () => {
|
||||
expect(resourceHeader).toContain('TITLE_BAR_LANE_PT')
|
||||
expect(resourceHeader).not.toMatch(/py-\[8\.5px\]/)
|
||||
})
|
||||
|
||||
it('keeps top-level settings headers aligned when the desktop sidebar collapses', () => {
|
||||
expect(settingsHeader).toContain('!back && COLLAPSED_DESKTOP_TOP_LEVEL_INSET')
|
||||
expect(settingsHeader).toContain(
|
||||
'[[data-sim-desktop-title-bar=inset]_[data-sidebar-collapsed]_&]:[--workspace-content-title-bar-inset:9px]'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { DesktopUpdateState } from '@sim/desktop-bridge'
|
||||
import { Button } from '@sim/emcn'
|
||||
import { Button, useNativeSurfaceOcclusionReady } from '@sim/emcn'
|
||||
import { getDesktopBridge, getDesktopShellVersion, getDesktopUpdates } from '@/lib/desktop'
|
||||
import { isShellOutdated } from '@/lib/desktop/min-version'
|
||||
|
||||
@@ -72,6 +72,8 @@ export function DesktopUpdateGate() {
|
||||
return unsubscribe
|
||||
}, [])
|
||||
|
||||
const nativeSurfaceReady = useNativeSurfaceOcclusionReady(outdated, 'takeover')
|
||||
|
||||
if (!outdated) {
|
||||
return null
|
||||
}
|
||||
@@ -79,7 +81,11 @@ export function DesktopUpdateGate() {
|
||||
const action = gateActionFor(updateState)
|
||||
|
||||
return (
|
||||
<div className='fixed inset-0 z-[9999] flex flex-col items-center justify-center gap-4 bg-[var(--bg)] px-8 text-center'>
|
||||
<div
|
||||
className='fixed inset-0 z-[9999] flex flex-col items-center justify-center gap-4 bg-[var(--bg)] px-8 text-center'
|
||||
style={{ opacity: nativeSurfaceReady ? 1 : 0 }}
|
||||
data-native-surface-occlusion='takeover'
|
||||
>
|
||||
<div className='flex max-w-sm flex-col gap-2'>
|
||||
<h1 className='font-medium text-[var(--text-primary)] text-lg'>Update Sim to continue</h1>
|
||||
<p className='text-[var(--text-secondary)] text-sm'>
|
||||
|
||||
@@ -17,7 +17,11 @@ import {
|
||||
createUnauthorizedResponse,
|
||||
} from '@/lib/copilot/request/http'
|
||||
import type { ChatResource } from '@/lib/copilot/resources/persistence'
|
||||
import { GENERIC_RESOURCE_TITLES } from '@/lib/copilot/resources/types'
|
||||
import {
|
||||
canonicalizeDesktopSessionResource,
|
||||
canonicalizeDesktopSessionResources,
|
||||
GENERIC_RESOURCE_TITLES,
|
||||
} from '@/lib/copilot/resources/types'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const logger = createLogger('CopilotChatResourcesAPI')
|
||||
@@ -39,7 +43,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { chatId, resource } = parsed.data.body
|
||||
const { chatId, resource: requestedResource } = parsed.data.body
|
||||
const resource = canonicalizeDesktopSessionResource(requestedResource)
|
||||
|
||||
// Ephemeral UI tab (client does not POST this; guard for old clients / bugs).
|
||||
if (resource.id === 'streaming-file') {
|
||||
@@ -62,7 +67,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
|
||||
return createNotFoundResponse('Chat not found or unauthorized')
|
||||
}
|
||||
|
||||
const existing = Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : []
|
||||
const existing = canonicalizeDesktopSessionResources(
|
||||
Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : []
|
||||
)
|
||||
const key = `${resource.type}:${resource.id}`
|
||||
const prev = existing.find((r) => `${r.type}:${r.id}` === key)
|
||||
|
||||
@@ -134,9 +141,12 @@ export const PATCH = withRouteHandler(async (req: NextRequest) => {
|
||||
return createNotFoundResponse('Chat not found or unauthorized')
|
||||
}
|
||||
|
||||
const existing = Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : []
|
||||
const existing = canonicalizeDesktopSessionResources(
|
||||
Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : []
|
||||
)
|
||||
const canonicalOrder = canonicalizeDesktopSessionResources(newOrder)
|
||||
const existingKeys = new Set(existing.map((r) => `${r.type}:${r.id}`))
|
||||
const newKeys = new Set(newOrder.map((r) => `${r.type}:${r.id}`))
|
||||
const newKeys = new Set(canonicalOrder.map((r) => `${r.type}:${r.id}`))
|
||||
|
||||
if (existingKeys.size !== newKeys.size || ![...existingKeys].every((k) => newKeys.has(k))) {
|
||||
return createBadRequestResponse('Reordered resources must match existing resources')
|
||||
@@ -144,7 +154,7 @@ export const PATCH = withRouteHandler(async (req: NextRequest) => {
|
||||
|
||||
await db
|
||||
.update(copilotChats)
|
||||
.set({ resources: sql`${JSON.stringify(newOrder)}::jsonb`, updatedAt: new Date() })
|
||||
.set({ resources: sql`${JSON.stringify(canonicalOrder)}::jsonb`, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(copilotChats.id, chatId),
|
||||
@@ -153,9 +163,9 @@ export const PATCH = withRouteHandler(async (req: NextRequest) => {
|
||||
)
|
||||
)
|
||||
|
||||
logger.info('Reordered resources for chat', { chatId, count: newOrder.length })
|
||||
logger.info('Reordered resources for chat', { chatId, count: canonicalOrder.length })
|
||||
|
||||
return NextResponse.json({ success: true, resources: newOrder })
|
||||
return NextResponse.json({ success: true, resources: canonicalOrder })
|
||||
} catch (error) {
|
||||
logger.error('Error reordering chat resources:', error)
|
||||
return createInternalServerErrorResponse('Failed to reorder resources')
|
||||
@@ -181,13 +191,21 @@ export const DELETE = withRouteHandler(async (req: NextRequest) => {
|
||||
if (!parsed.success) return parsed.response
|
||||
const { chatId, resourceType, resourceId } = parsed.data.body
|
||||
|
||||
// Old builds could persist an inner browser/terminal tab id. Closing the
|
||||
// singleton panel removes every legacy row of that type so it cannot be
|
||||
// canonicalized back into view on the next hydration.
|
||||
const removePredicate =
|
||||
resourceType === 'browser' || resourceType === 'terminal'
|
||||
? sql`elem->>'type' = ${resourceType}`
|
||||
: sql`elem->>'type' = ${resourceType} AND elem->>'id' = ${resourceId}`
|
||||
|
||||
const [updated] = await db
|
||||
.update(copilotChats)
|
||||
.set({
|
||||
resources: sql`COALESCE((
|
||||
SELECT jsonb_agg(elem)
|
||||
FROM jsonb_array_elements(${copilotChats.resources}) elem
|
||||
WHERE NOT (elem->>'type' = ${resourceType} AND elem->>'id' = ${resourceId})
|
||||
WHERE NOT (${removePredicate})
|
||||
), '[]'::jsonb)`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
@@ -43,7 +43,12 @@ describe('desktop tool authorization', () => {
|
||||
toolName: 'read',
|
||||
args: { path: 'user-local/Project--mount-1/README.md', offset: 0, limit: 100 },
|
||||
})
|
||||
getRunSegment.mockResolvedValue({ id: 'run-1', userId: 'user-1', status: 'active' })
|
||||
getRunSegment.mockResolvedValue({
|
||||
id: 'run-1',
|
||||
chatId: 'chat-1',
|
||||
userId: 'user-1',
|
||||
status: 'active',
|
||||
})
|
||||
claimPendingAsyncToolCall.mockResolvedValue({ toolCallId: 'browser-tool', status: 'running' })
|
||||
})
|
||||
|
||||
@@ -52,6 +57,7 @@ describe('desktop tool authorization', () => {
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({
|
||||
chatId: 'chat-1',
|
||||
toolName: 'read',
|
||||
args: { path: 'user-local/Project--mount-1/README.md', offset: 0, limit: 100 },
|
||||
})
|
||||
@@ -69,6 +75,7 @@ describe('desktop tool authorization', () => {
|
||||
const response = await POST(request('browser-tool'))
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({
|
||||
chatId: 'chat-1',
|
||||
toolName: 'browser_navigate',
|
||||
args: { url: 'https://example.com' },
|
||||
})
|
||||
|
||||
@@ -78,5 +78,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
return NextResponse.json({
|
||||
toolName: toolCall.toolName,
|
||||
args,
|
||||
chatId: run.chatId,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -307,6 +307,29 @@ describe('POST /api/mothership/chats/[chatId]/fork', () => {
|
||||
expect(dbChainMockFns.values.mock.calls[0][0].title).toBe('Fork | Generate Logs')
|
||||
})
|
||||
|
||||
it('repairs legacy page-level browser resources while forking', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValue([
|
||||
{
|
||||
...parentRow,
|
||||
resources: [
|
||||
{
|
||||
type: 'browser',
|
||||
id: 'browser-session:slack-tab',
|
||||
title: 'mship-todo (Channel) - sim - Slack',
|
||||
},
|
||||
{ type: 'browser', id: 'browser-session', title: 'Browser' },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const res = await POST(createRequest('chat-1'), makeContext('chat-1'))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(dbChainMockFns.values.mock.calls[0][0].resources).toEqual([
|
||||
{ type: 'browser', id: 'browser-session', title: 'Browser' },
|
||||
])
|
||||
})
|
||||
|
||||
it('still succeeds when the copilot-service clone fails (best-effort)', async () => {
|
||||
mockFetchGo.mockRejectedValue(new Error('mothership unreachable'))
|
||||
const res = await POST(createRequest('chat-1'), makeContext('chat-1'))
|
||||
|
||||
@@ -29,7 +29,10 @@ import {
|
||||
createUnauthorizedResponse,
|
||||
} from '@/lib/copilot/request/http'
|
||||
import { removeChatResources } from '@/lib/copilot/resources/persistence'
|
||||
import type { MothershipResource } from '@/lib/copilot/resources/types'
|
||||
import {
|
||||
canonicalizeDesktopSessionResources,
|
||||
type MothershipResource,
|
||||
} from '@/lib/copilot/resources/types'
|
||||
import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
@@ -115,9 +118,9 @@ export const POST = withRouteHandler(
|
||||
// file resources whose chat-owned file is NOT copied (uploads born
|
||||
// after the cut) are dropped in the rewrite below; everything else is
|
||||
// copied.
|
||||
const parentResources = Array.isArray(parent.resources)
|
||||
? (parent.resources as MothershipResource[])
|
||||
: []
|
||||
const parentResources = canonicalizeDesktopSessionResources(
|
||||
Array.isArray(parent.resources) ? (parent.resources as MothershipResource[]) : []
|
||||
)
|
||||
|
||||
// The source chat's chat-owned file ids (no cut) — the "is this
|
||||
// resource a ghost?" test set for the rewrite.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Chip } from '@sim/emcn'
|
||||
import { Chip, useNativeSurfaceOcclusionReady } from '@sim/emcn'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { recoverFromStaleSession } from '@/lib/auth/stale-session-recovery'
|
||||
|
||||
@@ -41,6 +41,7 @@ export function SessionExpired() {
|
||||
}
|
||||
|
||||
const expired = sawSession && !isPending && !error && !session?.user
|
||||
const nativeSurfaceReady = useNativeSurfaceOcclusionReady(expired, 'takeover')
|
||||
|
||||
const attemptRecovery = useCallback(() => {
|
||||
setFailed(false)
|
||||
@@ -62,7 +63,11 @@ export function SessionExpired() {
|
||||
const subject = wasImpersonation ? 'The impersonation session expired' : 'Your session expired'
|
||||
|
||||
return (
|
||||
<main className='fixed inset-0 z-50 flex flex-col items-center justify-center gap-3 bg-[var(--surface-1)] p-6'>
|
||||
<main
|
||||
className='fixed inset-0 z-50 flex flex-col items-center justify-center gap-3 bg-[var(--surface-1)] p-6'
|
||||
style={{ opacity: nativeSurfaceReady ? 1 : 0 }}
|
||||
data-native-surface-occlusion='takeover'
|
||||
>
|
||||
<p className='text-[var(--text-muted)] text-sm'>
|
||||
{failed ? `${subject}, but signing out failed.` : `${subject}. Signing you out…`}
|
||||
</p>
|
||||
|
||||
@@ -47,6 +47,8 @@ function stubRect(
|
||||
|
||||
interface Harness {
|
||||
state: () => { isPeekActive: boolean; isPeekOpen: boolean }
|
||||
card: () => HTMLElement
|
||||
trigger: () => HTMLElement
|
||||
triggerEnter: () => void
|
||||
triggerLeave: () => void
|
||||
setEnabled: (enabled: boolean) => void
|
||||
@@ -99,6 +101,8 @@ function renderPeek(initialEnabled: boolean): Harness {
|
||||
|
||||
return {
|
||||
state: () => latest,
|
||||
card: () => query('card'),
|
||||
trigger: () => query('trigger'),
|
||||
triggerEnter: () => onTriggerEnter(),
|
||||
triggerLeave: () => onTriggerLeave(),
|
||||
setEnabled: (enabled: boolean) => render({ enabled }),
|
||||
@@ -111,9 +115,8 @@ function renderPeek(initialEnabled: boolean): Harness {
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches a pointer move at client coordinates. `target` only matters for the
|
||||
* portal check — the card and trigger are matched by geometry. Each call advances the
|
||||
* clock past the hook's sample floor so consecutive moves are never coalesced.
|
||||
* Dispatches a pointer move at client coordinates. Each call advances the clock past
|
||||
* the hook's sample floor so consecutive moves are never coalesced.
|
||||
*/
|
||||
function movePointerTo([x, y]: readonly [number, number], target: Element = document.body) {
|
||||
act(() => {
|
||||
@@ -217,6 +220,22 @@ describe('useSidebarPeek', () => {
|
||||
expect(active.state().isPeekOpen).toBe(true)
|
||||
})
|
||||
|
||||
it('does not force layout while the pointer moves across sidebar content', () => {
|
||||
active = renderPeek(true)
|
||||
openPeek(active)
|
||||
|
||||
const row = document.createElement('button')
|
||||
active.card().appendChild(row)
|
||||
const cardMeasure = vi.spyOn(active.card(), 'getBoundingClientRect')
|
||||
const triggerMeasure = vi.spyOn(active.trigger(), 'getBoundingClientRect')
|
||||
|
||||
movePointerTo(POINT.inCard, row)
|
||||
|
||||
expect(cardMeasure).not.toHaveBeenCalled()
|
||||
expect(triggerMeasure).not.toHaveBeenCalled()
|
||||
expect(active.state().isPeekOpen).toBe(true)
|
||||
})
|
||||
|
||||
it('stays open while the pointer is still over the toggle that opened it', () => {
|
||||
active = renderPeek(true)
|
||||
openPeek(active)
|
||||
|
||||
@@ -194,10 +194,24 @@ export function useSidebarPeek(enabled: boolean, dismissed = false): SidebarPeek
|
||||
// bottom edge and the card's top edge. Poppers are matched by DOM instead —
|
||||
// they can be anchored anywhere on screen.
|
||||
const target = event.target instanceof Element ? event.target : null
|
||||
// Almost every move while the user is interacting with the sidebar lands on
|
||||
// a descendant of one of these stable wrappers. Resolve that case without a
|
||||
// layout read: measuring after each row's :hover style change forced the
|
||||
// browser to synchronously flush styles and made the highlight trail the pointer.
|
||||
if (
|
||||
(target && cardRef.current?.contains(target)) ||
|
||||
(target && triggerRef.current?.contains(target)) ||
|
||||
target?.closest(POPPER_SELECTOR)
|
||||
) {
|
||||
clearTimer(closeTimerRef)
|
||||
return
|
||||
}
|
||||
|
||||
// Geometry remains the fallback for the small gap between the title-bar
|
||||
// trigger and card, and for a one-frame-stale target during subtree changes.
|
||||
const inside =
|
||||
containsPoint(cardRef.current, event.clientX, event.clientY, PEEK_GAP_TOLERANCE_PX) ||
|
||||
containsPoint(triggerRef.current, event.clientX, event.clientY, PEEK_GAP_TOLERANCE_PX) ||
|
||||
Boolean(target?.closest(POPPER_SELECTOR))
|
||||
containsPoint(triggerRef.current, event.clientX, event.clientY, PEEK_GAP_TOLERANCE_PX)
|
||||
if (inside) {
|
||||
clearTimer(closeTimerRef)
|
||||
return
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Calendar,
|
||||
Cursor,
|
||||
Database,
|
||||
Folder as FolderIcon,
|
||||
Library,
|
||||
@@ -10,6 +9,7 @@ import {
|
||||
TerminalWindow,
|
||||
Workflow,
|
||||
} from '@sim/emcn/icons'
|
||||
import { Globe } from 'lucide-react'
|
||||
import { AgentSkillsIcon, McpIcon } from '@/components/icons'
|
||||
import { getDocumentIcon } from '@/components/icons/document-icons'
|
||||
import type { ChatContextKind, ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types'
|
||||
@@ -57,7 +57,7 @@ function renderIntegrationTile({ context, className }: RenderIconArgs): ReactNod
|
||||
export const CHAT_CONTEXT_KIND_REGISTRY: Record<ChatContextKind, ChatContextKindConfig> = {
|
||||
browser_tab: {
|
||||
label: 'Browser tab',
|
||||
renderIcon: ({ className }) => <Cursor className={className} />,
|
||||
renderIcon: ({ className }) => <Globe className={className} />,
|
||||
},
|
||||
terminal_tab: {
|
||||
label: 'Terminal',
|
||||
|
||||
@@ -25,6 +25,7 @@ import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
|
||||
import { isHosted } from '@/lib/core/config/env-flags'
|
||||
import { isSafeHttpUrl } from '@/lib/core/utils/urls'
|
||||
import { getDesktopBridge } from '@/lib/desktop'
|
||||
import { desktopChatScopeId } from '@/lib/desktop/chat-scope'
|
||||
import {
|
||||
resolveOAuthServiceForSlug,
|
||||
resolveServiceAccountIntegration,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth'
|
||||
import { getServiceConfigByProviderId } from '@/lib/oauth/utils'
|
||||
import { finishTerminalHandoff, isTerminalAvailable } from '@/lib/terminal/transport'
|
||||
import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
|
||||
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
|
||||
import { QuestionDisplay } from '@/app/workspace/[workspaceId]/home/components/message-content/components/question'
|
||||
import type {
|
||||
@@ -1732,6 +1734,8 @@ function FolderAccessDisplay({ data }: { data: CredentialTagData }) {
|
||||
* is no agent browser to hand back.
|
||||
*/
|
||||
function BrowserTakeoverDisplay({ data }: { data: CredentialTagData }) {
|
||||
const { workspaceId } = useParams<{ workspaceId: string }>()
|
||||
const { chatId } = useChatSurface()
|
||||
const [handedBack, setHandedBack] = useState(false)
|
||||
|
||||
if (!isBrowserAgentAvailable()) return null
|
||||
@@ -1747,7 +1751,7 @@ function BrowserTakeoverDisplay({ data }: { data: CredentialTagData }) {
|
||||
onClick={() => {
|
||||
if (handedBack) return
|
||||
setHandedBack(true)
|
||||
sendBrowserPanelAction('takeover-done')
|
||||
sendBrowserPanelAction('takeover-done', {}, desktopChatScopeId(workspaceId, chatId))
|
||||
}}
|
||||
disabled={handedBack}
|
||||
className={cn(
|
||||
@@ -1905,6 +1909,8 @@ function CredentialLinkDisplay({ data }: { data: CredentialTagData }) {
|
||||
* the right shell. Renders nothing outside the desktop app.
|
||||
*/
|
||||
function TerminalHandoffDisplay({ data }: { data: CredentialTagData }) {
|
||||
const { workspaceId } = useParams<{ workspaceId: string }>()
|
||||
const { chatId } = useChatSurface()
|
||||
const [handedBack, setHandedBack] = useState(false)
|
||||
|
||||
if (!isTerminalAvailable()) return null
|
||||
@@ -1920,7 +1926,7 @@ function TerminalHandoffDisplay({ data }: { data: CredentialTagData }) {
|
||||
onClick={() => {
|
||||
if (handedBack) return
|
||||
setHandedBack(true)
|
||||
finishTerminalHandoff(data.value ?? '')
|
||||
finishTerminalHandoff(data.value ?? '', desktopChatScopeId(workspaceId, chatId))
|
||||
}}
|
||||
disabled={handedBack}
|
||||
className={cn(
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Wrench,
|
||||
} from '@sim/emcn'
|
||||
import { Calendar, Clock, Cursor, Table as TableIcon } from '@sim/emcn/icons'
|
||||
import { Globe } from 'lucide-react'
|
||||
import { AgentIcon, ImageIcon, TTSIcon, VideoIcon } from '@/components/icons'
|
||||
import type { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types'
|
||||
|
||||
@@ -73,7 +74,7 @@ const TOOL_ICONS: Record<string, IconComponent> = {
|
||||
generate_video: VideoIcon,
|
||||
generate_audio: TTSIcon,
|
||||
ffmpeg: Wrench,
|
||||
browser: Cursor,
|
||||
browser: Globe,
|
||||
browser_navigate: Cursor,
|
||||
browser_go_back: Cursor,
|
||||
browser_go_forward: Cursor,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
NATIVE_SURFACE_OCCLUSION_PREPARE_EVENT,
|
||||
Tooltip,
|
||||
} from '@sim/emcn'
|
||||
import { Folder, Plus } from '@sim/emcn/icons'
|
||||
@@ -52,12 +53,16 @@ export interface AddResourceDropdownProps {
|
||||
workspaceId: string
|
||||
existingKeys: Set<string>
|
||||
onAdd: (resource: MothershipResource) => void
|
||||
onSwitch?: (resourceId: string) => void
|
||||
onOpenExisting?: (resource: MothershipResource) => void
|
||||
/**
|
||||
* Resource types to hide from the dropdown. Must be referentially stable
|
||||
* (a module constant) — it keys the underlying group memo.
|
||||
*/
|
||||
excludeTypes?: readonly MothershipResourceType[]
|
||||
/** Delays mounting the menu until a native surface beneath it is hidden. */
|
||||
onRequestOpen?: (open: () => void) => void
|
||||
/** Restores any native surface hidden for this menu. */
|
||||
onClose?: () => Promise<void>
|
||||
}
|
||||
|
||||
interface AvailableItemsByType {
|
||||
@@ -110,6 +115,15 @@ const NO_RESOURCE_GROUPS: AvailableItemsByType[] = []
|
||||
|
||||
const LOG_DROPDOWN_LIMIT = 50
|
||||
|
||||
/** Hide Radix's still-mounted exit surface before a full-screen effect paints. */
|
||||
function hideMountedMenuSurfaces(): void {
|
||||
for (const menu of document.querySelectorAll<HTMLElement>(
|
||||
'[data-native-surface-overlay][role="menu"]'
|
||||
)) {
|
||||
menu.style.setProperty('visibility', 'hidden', 'important')
|
||||
}
|
||||
}
|
||||
|
||||
const LOG_DROPDOWN_FILTERS = {
|
||||
timeRange: 'All time' as const,
|
||||
level: 'all',
|
||||
@@ -273,7 +287,7 @@ export function useAvailableResources(
|
||||
},
|
||||
]
|
||||
// The live browser panel — desktop app only (needs the agent-browser
|
||||
// bridge). A singleton: opening it again just activates the existing tab.
|
||||
// bridge). There is one top-level panel; repeated launches open inner tabs.
|
||||
if (isBrowserAgentAvailable()) {
|
||||
groups.push({
|
||||
type: 'browser' as const,
|
||||
@@ -286,7 +300,7 @@ export function useAvailableResources(
|
||||
})
|
||||
}
|
||||
// The live terminal — desktop app only (needs the PTY bridge), and a
|
||||
// singleton like the browser.
|
||||
// single top-level panel like the browser.
|
||||
if (isTerminalAvailable()) {
|
||||
groups.push({
|
||||
type: 'terminal' as const,
|
||||
@@ -513,10 +527,13 @@ export function AddResourceDropdown({
|
||||
workspaceId,
|
||||
existingKeys,
|
||||
onAdd,
|
||||
onSwitch,
|
||||
onOpenExisting,
|
||||
excludeTypes,
|
||||
onRequestOpen,
|
||||
onClose,
|
||||
}: AddResourceDropdownProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
// Gated on `open` so an idle tab bar never fetches the workspace lists.
|
||||
@@ -525,23 +542,47 @@ export function AddResourceDropdown({
|
||||
excludeTypes,
|
||||
})
|
||||
const treeSections = useResourceTreeSections({ groups: available, structureFolders })
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
setOpen(next)
|
||||
if (!next) {
|
||||
setSearch('')
|
||||
setActiveIndex(0)
|
||||
}
|
||||
}
|
||||
|
||||
const select = (resource: MothershipResource) => {
|
||||
if (onSwitch && existingKeys.has(`${resource.type}:${resource.id}`)) {
|
||||
onSwitch(resource.id)
|
||||
} else {
|
||||
onAdd(resource)
|
||||
}
|
||||
const hasNativeResourceSurface = isBrowserAgentAvailable() || isTerminalAvailable()
|
||||
const closeMenu = useCallback(() => {
|
||||
setOpen(false)
|
||||
setSearch('')
|
||||
setActiveIndex(0)
|
||||
return onClose?.() ?? Promise.resolve()
|
||||
}, [onClose])
|
||||
|
||||
// This popover is shared by Browser and Terminal and sits above the modal
|
||||
// z-layer. Close it inside the pre-paint handshake so resource chrome cannot
|
||||
// remain floating over a newly opened full-screen effect.
|
||||
useEffect(() => {
|
||||
if (!hasNativeResourceSurface) return
|
||||
const handlePrepare = () => {
|
||||
if (open || contentRef.current) hideMountedMenuSurfaces()
|
||||
if (open) void closeMenu()
|
||||
}
|
||||
window.addEventListener(NATIVE_SURFACE_OCCLUSION_PREPARE_EVENT, handlePrepare)
|
||||
return () => window.removeEventListener(NATIVE_SURFACE_OCCLUSION_PREPARE_EVENT, handlePrepare)
|
||||
}, [closeMenu, hasNativeResourceSurface, open])
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (next) {
|
||||
if (onRequestOpen) {
|
||||
onRequestOpen(() => setOpen(true))
|
||||
} else {
|
||||
setOpen(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
void closeMenu()
|
||||
}
|
||||
|
||||
const select = (resource: MothershipResource) => {
|
||||
void closeMenu().then(() => {
|
||||
if (onOpenExisting && existingKeys.has(`${resource.type}:${resource.id}`)) {
|
||||
onOpenExisting(resource)
|
||||
} else {
|
||||
onAdd(resource)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
@@ -570,7 +611,7 @@ export function AddResourceDropdown({
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenu open={open} onOpenChange={handleOpenChange} modal={false}>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -588,6 +629,7 @@ export function AddResourceDropdown({
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<DropdownMenuContent
|
||||
ref={contentRef}
|
||||
align='start'
|
||||
sideOffset={8}
|
||||
className='flex w-[320px] flex-col overflow-hidden'
|
||||
@@ -631,8 +673,8 @@ export function AddResourceDropdown({
|
||||
if (items.length === 0) return null
|
||||
const config = getResourceConfig(type)
|
||||
const Icon = config.icon
|
||||
// The browser and terminal panels are singletons — flat
|
||||
// items, not one-entry submenus.
|
||||
// Browser and terminal each have one top-level panel — flat
|
||||
// launchers here create inner tabs when that panel exists.
|
||||
if (type === 'browser' || type === 'terminal') {
|
||||
const item = items[0]
|
||||
return (
|
||||
|
||||