Skip to content

Commit fec8b4b

Browse files
committed
Browser updates
1 parent 2dacf52 commit fec8b4b

32 files changed

Lines changed: 1385 additions & 129 deletions

File tree

apps/desktop/scripts/install-local.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,20 @@ if (originFlags.length > 1) {
9898
}
9999

100100
console.log('• Packaging the app from the current checkout…')
101-
run('bun', ['run', 'package:dir'])
101+
run('bun', ['run', 'build'])
102+
// Same as package:dir, minus trusted timestamps: codesign's --timestamp does
103+
// a network round trip to Apple PER FILE (hundreds inside the Electron
104+
// framework), which turns local signing into a multi-minute stall. Local
105+
// installs don't need timestamped signatures — only notarized distribution
106+
// builds do.
107+
run('bunx', [
108+
'electron-builder',
109+
'--mac',
110+
'dir',
111+
'--publish',
112+
'never',
113+
'-c.mac.timestamp=none',
114+
])
102115

103116
const builtApp = RELEASE_DIRS.map((dir) => join(dir, APP_NAME)).find(existsSync)
104117
if (!builtApp) {
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
3+
vi.mock('electron', () => import('@/test/electron-mock'))
4+
5+
import { WebContentsView } from 'electron'
6+
import { setColorScheme } from '@/main/browser-agent/cdp'
7+
8+
describe('browser-agent CDP theme', () => {
9+
it('emulates explicit light and dark preferences', async () => {
10+
const contents = new WebContentsView().webContents
11+
12+
await setColorScheme(contents, 'dark')
13+
await setColorScheme(contents, 'light')
14+
15+
expect(vi.mocked(contents.debugger.sendCommand).mock.calls).toEqual([
16+
[
17+
'Emulation.setEmulatedMedia',
18+
{ features: [{ name: 'prefers-color-scheme', value: 'dark' }] },
19+
],
20+
[
21+
'Emulation.setEmulatedMedia',
22+
{ features: [{ name: 'prefers-color-scheme', value: 'light' }] },
23+
],
24+
])
25+
})
26+
27+
it('clears the override for the system preference', async () => {
28+
const contents = new WebContentsView().webContents
29+
30+
await setColorScheme(contents, 'system')
31+
32+
expect(contents.debugger.sendCommand).toHaveBeenCalledWith('Emulation.setEmulatedMedia', {
33+
features: [],
34+
})
35+
})
36+
})

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
* movement, character insertion) and is honored by code editors. The user
99
* sees and drives the real embedded page, so there is no screencast.
1010
*/
11+
import type { BrowserTheme } from '@sim/browser-protocol'
1112
import { createLogger } from '@sim/logger'
1213
import type { WebContents } from 'electron'
1314

@@ -60,6 +61,16 @@ export async function ensureInstrumented(contents: WebContents, cb: CdpCallbacks
6061
await send(contents, 'Page.setInterceptFileChooserDialog', { enabled: true }).catch(() => {})
6162
}
6263

64+
/**
65+
* Mirrors Sim's theme into the page's `prefers-color-scheme` media query.
66+
* `system` removes the per-tab override so Chromium continues following the OS.
67+
*/
68+
export async function setColorScheme(contents: WebContents, theme: BrowserTheme): Promise<void> {
69+
await send(contents, 'Emulation.setEmulatedMedia', {
70+
features: theme === 'system' ? [] : [{ name: 'prefers-color-scheme', value: theme }],
71+
})
72+
}
73+
6374
function handleDebuggerEvent(
6475
contents: WebContents,
6576
method: string,

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

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@
1515
* is bounded by a watchdog so the Sim side always gets a response instead of
1616
* waiting out its own timeout against silence.
1717
*/
18-
import type { BrowserPageState, BrowserPanelAction, BrowserToolName } from '@sim/browser-protocol'
18+
import type {
19+
BrowserPageState,
20+
BrowserPanelAction,
21+
BrowserTabsState,
22+
BrowserToolName,
23+
} from '@sim/browser-protocol'
1924
import { createLogger } from '@sim/logger'
2025
import type { BrowserWindow, WebContents } from 'electron'
2126
import * as cdp from '@/main/browser-agent/cdp'
@@ -54,6 +59,7 @@ const TOOL_WATCHDOG_MS = 150_000
5459

5560
export interface DriverCallbacks {
5661
onPageState: (state: BrowserPageState) => void
62+
onTabsState: (state: BrowserTabsState) => void
5763
onSessionStatus: (alive: boolean) => void
5864
}
5965

@@ -79,8 +85,9 @@ function recordNotice(notice: string): void {
7985
let takeoverActive = false
8086
let takeoverDone = false
8187

82-
function pageStateFor(contents: WebContents): BrowserPageState {
88+
function pageStateFor(contents: WebContents, tabId: string): BrowserPageState {
8389
return {
90+
tabId,
8491
url: contents.getURL(),
8592
title: contents.getTitle(),
8693
loading: contents.isLoading(),
@@ -91,8 +98,13 @@ function pageStateFor(contents: WebContents): BrowserPageState {
9198

9299
function pushPageState(contents: WebContents): void {
93100
if (contents.isDestroyed()) return
94-
if (session.activeTab()?.view.webContents !== contents) return
95-
driverCallbacks?.onPageState(pageStateFor(contents))
101+
const active = session.activeTab()
102+
if (active?.view.webContents !== contents) return
103+
driverCallbacks?.onPageState(pageStateFor(contents, active.id))
104+
}
105+
106+
function pushTabsState(): void {
107+
driverCallbacks?.onTabsState(session.getTabsState())
96108
}
97109

98110
/** Instruments a fresh tab: CDP dialog/chooser handling + page-state pushes. */
@@ -111,6 +123,7 @@ function instrumentTab(contents: WebContents): void {
111123
)
112124
},
113125
})
126+
.then(() => cdp.setColorScheme(contents, session.getBrowserTheme()))
114127
.catch((error) => {
115128
logger.warn('CDP instrumentation failed', {
116129
error: error instanceof Error ? error.message : String(error),
@@ -123,7 +136,10 @@ function instrumentTab(contents: WebContents): void {
123136
'did-start-loading',
124137
'did-stop-loading',
125138
] as const) {
126-
contents.on(event as 'did-navigate', () => pushPageState(contents))
139+
contents.on(event as 'did-navigate', () => {
140+
pushPageState(contents)
141+
pushTabsState()
142+
})
127143
}
128144
driverCallbacks?.onSessionStatus(true)
129145
}
@@ -140,6 +156,14 @@ export function initDriver(
140156
},
141157
onTabCreated: instrumentTab,
142158
onActiveTabChanged: pushPageState,
159+
onTabsChanged: pushTabsState,
160+
onTabThemeChanged: (contents, theme) => {
161+
void cdp.setColorScheme(contents, theme).catch((error) => {
162+
logger.warn('Could not update browser tab theme', {
163+
error: error instanceof Error ? error.message : String(error),
164+
})
165+
})
166+
},
143167
onDownloadBlocked: (filename) => {
144168
recordNotice(
145169
`The page tried to download "${filename}"; downloads are not supported in the agent browser, so it was blocked.`
@@ -380,7 +404,7 @@ async function executeToolInner(
380404
}
381405

382406
case 'browser_list_tabs': {
383-
return { tabs: session.listTabs() }
407+
return session.getTabsState()
384408
}
385409

386410
case 'browser_wait_for': {
@@ -605,6 +629,10 @@ export async function executeTool(
605629
}
606630
}
607631

632+
export function getTabsState(): BrowserTabsState {
633+
return session.getTabsState()
634+
}
635+
608636
/** Browser-chrome commands from the panel header; fire-and-forget. */
609637
export async function handlePanelAction(action: BrowserPanelAction): Promise<void> {
610638
// The Done chip on the chat's takeover tool row: hands control back to the
@@ -623,6 +651,22 @@ export async function handlePanelAction(action: BrowserPanelAction): Promise<voi
623651
}
624652
return
625653
}
654+
if (action.action === 'new-tab') {
655+
session.addTab()
656+
return
657+
}
658+
if (action.action === 'switch-tab') {
659+
if (typeof action.tabId === 'string') {
660+
session.switchTab(action.tabId)
661+
}
662+
return
663+
}
664+
if (action.action === 'close-tab') {
665+
if (typeof action.tabId === 'string') {
666+
session.closeTab(action.tabId)
667+
}
668+
return
669+
}
626670
const tab = session.activeTab()
627671
if (!tab) return
628672
const contents = tab.view.webContents

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

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
22

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

5+
import { MAX_BROWSER_TABS } from '@sim/browser-protocol'
56
import { BrowserWindow } from 'electron'
67

78
type SessionModule = typeof import('@/main/browser-agent/session')
@@ -15,7 +16,9 @@ interface MockView {
1516
setWindowOpenHandler: ReturnType<typeof vi.fn>
1617
loadURL: ReturnType<typeof vi.fn>
1718
setBackgroundThrottling: ReturnType<typeof vi.fn>
19+
capturePage: ReturnType<typeof vi.fn>
1820
}
21+
setBackgroundColor: ReturnType<typeof vi.fn>
1922
setBounds: ReturnType<typeof vi.fn>
2023
setVisible: ReturnType<typeof vi.fn>
2124
}
@@ -32,15 +35,21 @@ function mainWindowMock() {
3235
return win as unknown as BrowserWindow
3336
}
3437

35-
async function freshSession(win: BrowserWindow | null): Promise<SessionModule> {
38+
async function freshSession(
39+
win: BrowserWindow | null,
40+
eventOverrides: Partial<import('@/main/browser-agent/session').AgentSessionEvents> = {}
41+
): Promise<SessionModule> {
3642
vi.resetModules()
3743
const session = await import('@/main/browser-agent/session')
3844
session.initSession(
3945
{
4046
onSessionClosed: vi.fn(),
4147
onTabCreated: vi.fn(),
4248
onActiveTabChanged: vi.fn(),
49+
onTabsChanged: vi.fn(),
50+
onTabThemeChanged: vi.fn(),
4351
onDownloadBlocked: vi.fn(),
52+
...eventOverrides,
4453
},
4554
() => win
4655
)
@@ -76,6 +85,32 @@ describe('browser-agent session', () => {
7685
expect(contents.setBackgroundThrottling).toHaveBeenLastCalledWith(true)
7786
})
7887

88+
it('updates the native backdrop when Sim changes browser theme', () => {
89+
const tab = session.ensureTab()
90+
const view = tab.view as unknown as MockView
91+
92+
session.setBrowserTheme('dark')
93+
expect(session.getBrowserTheme()).toBe('dark')
94+
expect(view.setBackgroundColor).toHaveBeenLastCalledWith('#0c0c0c')
95+
96+
session.setBrowserTheme('light')
97+
expect(view.setBackgroundColor).toHaveBeenLastCalledWith('#ffffff')
98+
})
99+
100+
it('propagates theme changes to every existing tab', async () => {
101+
const onTabThemeChanged = vi.fn()
102+
const themedSession = await freshSession(win, { onTabThemeChanged })
103+
const first = themedSession.ensureTab()
104+
const second = themedSession.addTab()
105+
106+
themedSession.setBrowserTheme('dark')
107+
108+
expect(onTabThemeChanged.mock.calls).toEqual([
109+
[first.view.webContents, 'dark'],
110+
[second.view.webContents, 'dark'],
111+
])
112+
})
113+
79114
it('requireTab refuses when no page is open yet', () => {
80115
expect(() => session.requireTab()).toThrow(/No page is open yet/)
81116
})
@@ -98,6 +133,18 @@ describe('browser-agent session', () => {
98133
expect(() => session.closeTab('999')).toThrow(/No tab with id 999/)
99134
})
100135

136+
it('limits the browser session to five open tabs', () => {
137+
session.ensureTab()
138+
for (let index = 1; index < MAX_BROWSER_TABS; index++) {
139+
session.addTab()
140+
}
141+
142+
expect(session.listTabs()).toHaveLength(MAX_BROWSER_TABS)
143+
expect(() => session.addTab()).toThrow(
144+
`The browser supports up to ${MAX_BROWSER_TABS} open tabs.`
145+
)
146+
})
147+
101148
it('embeds the active view in the MAIN window only while panel bounds are reported', () => {
102149
const tab = session.ensureTab()
103150
const view = tab.view as unknown as MockView
@@ -136,7 +183,37 @@ describe('browser-agent session', () => {
136183
})
137184
})
138185

139-
it('hardens every tab: agent partition default-denies permissions and popups collapse into the same view', () => {
186+
it('keeps an occluded view attached, captures its frame, and toggles visibility', async () => {
187+
const tab = session.ensureTab()
188+
const view = tab.view as unknown as MockView
189+
const content = (
190+
win as unknown as {
191+
contentView: {
192+
addChildView: ReturnType<typeof vi.fn>
193+
removeChildView: ReturnType<typeof vi.fn>
194+
}
195+
}
196+
).contentView
197+
session.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
198+
content.removeChildView.mockClear()
199+
view.setVisible.mockClear()
200+
201+
session.setPanelOccluded(true)
202+
203+
expect(content.removeChildView).not.toHaveBeenCalled()
204+
expect(view.setVisible).toHaveBeenLastCalledWith(false)
205+
await vi.waitFor(() => {
206+
expect(win.webContents.send).toHaveBeenCalledWith('browser-agent:panel-snapshot', {
207+
dataUrl: 'data:image/png;base64,c2lt',
208+
tabId: tab.id,
209+
})
210+
})
211+
212+
session.setPanelOccluded(false)
213+
expect(view.setVisible).toHaveBeenLastCalledWith(true)
214+
})
215+
216+
it('hardens every tab and keeps http popups inside a new internal tab', () => {
140217
const tab = session.ensureTab()
141218
const contents = (tab.view as unknown as MockView).webContents
142219
expect(contents.session.setPermissionRequestHandler).toHaveBeenCalled()
@@ -146,7 +223,11 @@ describe('browser-agent session', () => {
146223
url: string
147224
}) => { action: string }
148225
expect(openHandler({ url: 'https://example.com/popup' })).toEqual({ action: 'deny' })
149-
expect(contents.loadURL).toHaveBeenCalledWith('https://example.com/popup')
226+
expect(session.listTabs()).toHaveLength(2)
227+
const popupContents = (session.activeTab()?.view as unknown as MockView | undefined)
228+
?.webContents
229+
expect(popupContents?.loadURL).toHaveBeenCalledWith('https://example.com/popup')
230+
expect(contents.loadURL).not.toHaveBeenCalledWith('https://example.com/popup')
150231
// Non-http(s) popups are denied without navigating anywhere.
151232
contents.loadURL.mockClear()
152233
expect(openHandler({ url: 'file:///etc/passwd' })).toEqual({ action: 'deny' })

0 commit comments

Comments
 (0)