feat: add workspace file management and embedded browsing
Users previously had to leave the workbar to manage project files and inspect Git branches or commit history. Add file and directory creation, rename, move, delete and properties, plus searchable branches, branch creation and switching, explicit fetch, and commit file diffs. Keep file previews in the right workspace pane with directory expansion, selection, scroll and focus preserved on return. Add compact action menus, directory pickers, preview refresh and source switching. Embed the shared browser session directly in the workbar with navigation, resizing and fullscreen controls instead of a separate interaction window. Moves stay within the current workspace, deletion is permanent after confirmation, and Git switch reports conflicts without discarding or stashing changes. Typecheck, lint and staged whitespace checks pass; existing full-suite failures and the blocked real remote directory validation remain recorded in the workbar progress document. Release note: 可在右侧工作区管理文件、切换 Git 分支并查看提交历史,返回预览后保留浏览位置;浏览器支持在工作栏内直接交互和全屏显示。
This commit is contained in:
@@ -14,6 +14,7 @@ type HarnessSlot = {
|
||||
currentUrl?: string
|
||||
approvedOrigin?: string
|
||||
emitLoading(isLoading: boolean): void
|
||||
emitNavigation(url: string): void
|
||||
session: BrowserSessionLike
|
||||
driver: BrowserDriverLike
|
||||
}
|
||||
@@ -55,6 +56,7 @@ function createHarness(options: {
|
||||
getURL: () => slot.currentUrl ?? `${slot.currentOrigin}/page`
|
||||
} as BrowserWebContents
|
||||
const loadingListeners = new Set<(isLoading: boolean) => void>()
|
||||
const navigationListeners = new Set<(url: string) => void>()
|
||||
let isLoading = false
|
||||
const session: BrowserSessionLike = {
|
||||
webContents,
|
||||
@@ -67,7 +69,11 @@ function createHarness(options: {
|
||||
loadingListeners.add(listener)
|
||||
return () => loadingListeners.delete(listener)
|
||||
}),
|
||||
openInteraction: vi.fn(async () => undefined),
|
||||
onNavigationChange: vi.fn((listener) => {
|
||||
navigationListeners.add(listener)
|
||||
return () => navigationListeners.delete(listener)
|
||||
}),
|
||||
setViewport: vi.fn(),
|
||||
stopLoading: vi.fn(),
|
||||
...(options.captureScreenshot
|
||||
? { captureScreenshot: vi.fn(options.captureScreenshot) }
|
||||
@@ -123,6 +129,13 @@ function createHarness(options: {
|
||||
for (const listener of loadingListeners) {
|
||||
listener(loading)
|
||||
}
|
||||
},
|
||||
emitNavigation: (url: string) => {
|
||||
slot.currentUrl = url
|
||||
slot.currentOrigin = canonicalizeBrowserUrl(url).origin
|
||||
for (const listener of navigationListeners) {
|
||||
listener(url)
|
||||
}
|
||||
}
|
||||
})
|
||||
slots.push(slot)
|
||||
@@ -152,6 +165,42 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('BrowserService', () => {
|
||||
it('presents one session in the shared viewport and tracks user navigation', async () => {
|
||||
const harness = createHarness()
|
||||
const bounds = { x: 900, y: 120, width: 420, height: 640 }
|
||||
const states: BrowserLiveState[] = []
|
||||
harness.service.onState((state) => states.push(state))
|
||||
harness.service.setViewport('conversation', bounds)
|
||||
|
||||
await harness.service.navigate(
|
||||
'conversation',
|
||||
'https://example.com/',
|
||||
new AbortController().signal
|
||||
)
|
||||
const slot = harness.slots[0]
|
||||
expect(slot?.session.setViewport).toHaveBeenCalledWith(bounds)
|
||||
|
||||
vi.mocked(slot!.driver.getNavigationMetadata).mockResolvedValueOnce({
|
||||
url: 'https://example.com/account',
|
||||
canGoBack: true
|
||||
})
|
||||
slot?.emitNavigation('https://example.com/account')
|
||||
await vi.waitFor(() => {
|
||||
expect(states.at(-1)).toMatchObject({
|
||||
status: 'ready',
|
||||
url: 'https://example.com/account',
|
||||
canGoBack: true
|
||||
})
|
||||
})
|
||||
expect(harness.service.getOrigin('conversation')).toBe(
|
||||
'https://example.com'
|
||||
)
|
||||
|
||||
harness.service.setViewport()
|
||||
expect(slot?.session.setViewport).toHaveBeenLastCalledWith(undefined)
|
||||
await harness.service.dispose()
|
||||
})
|
||||
|
||||
it('publishes browser status and live frames through session cleanup', async () => {
|
||||
const harness = createHarness()
|
||||
const states: Array<{
|
||||
@@ -464,58 +513,6 @@ describe('BrowserService', () => {
|
||||
await harness.service.dispose()
|
||||
})
|
||||
|
||||
it('pauses agent operations while the user interacts with the same session', async () => {
|
||||
const harness = createHarness()
|
||||
const signal = new AbortController().signal
|
||||
const states: BrowserLiveState[] = []
|
||||
harness.service.onState((state) => states.push(state))
|
||||
await harness.service.navigate(
|
||||
'conversation',
|
||||
'https://a.example/',
|
||||
signal
|
||||
)
|
||||
const interactionGate = deferred<
|
||||
Awaited<ReturnType<BrowserSessionLike['openInteraction']>>
|
||||
>()
|
||||
const slot = harness.slots[0]
|
||||
if (!slot) {
|
||||
throw new Error('slot missing')
|
||||
}
|
||||
vi.mocked(slot.session.openInteraction).mockReturnValueOnce(
|
||||
interactionGate.promise
|
||||
)
|
||||
|
||||
const interaction = harness.service.interact(
|
||||
'conversation',
|
||||
signal
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(slot.session.openInteraction).toHaveBeenCalledOnce()
|
||||
)
|
||||
const snapshot = harness.service.snapshot('conversation', signal)
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
expect(slot.driver.snapshot).not.toHaveBeenCalled()
|
||||
|
||||
interactionGate.resolve({
|
||||
type: 'image',
|
||||
mimeType: 'image/jpeg',
|
||||
data: 'closing-frame'
|
||||
})
|
||||
await interaction
|
||||
expect(states.slice(-2).map((state) => state.status)).toEqual([
|
||||
'interactive',
|
||||
'ready'
|
||||
])
|
||||
expect(states.at(-1)?.frameDataUrl).toBe(
|
||||
'data:image/jpeg;base64,closing-frame'
|
||||
)
|
||||
expect(harness.service.getSessionCount()).toBe(1)
|
||||
expect(slot.session.dispose).not.toHaveBeenCalled()
|
||||
await snapshot
|
||||
expect(slot.driver.snapshot).toHaveBeenCalledOnce()
|
||||
await harness.service.dispose()
|
||||
})
|
||||
|
||||
it('does not let a canceled queued waiter clear the active operation owner', async () => {
|
||||
const harness = createHarness()
|
||||
const signal = new AbortController().signal
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { BrowserScreenshot } from './browser-screenshot'
|
||||
import {
|
||||
ElectronBrowserSession,
|
||||
type BrowserParentWindowHandle,
|
||||
type BrowserWindowBounds,
|
||||
type BrowserWebContents
|
||||
} from './electron-browser-session'
|
||||
import type { BrowserLiveState } from '../../shared/contracts'
|
||||
@@ -31,7 +32,8 @@ export type BrowserSessionLike = {
|
||||
getCurrentOrigin(): string | undefined
|
||||
isLoading(): boolean
|
||||
onLoadingChange(listener: (isLoading: boolean) => void): () => void
|
||||
openInteraction(): Promise<BrowserScreenshot | undefined>
|
||||
onNavigationChange?(listener: (url: string) => void): () => void
|
||||
setViewport?(bounds?: BrowserWindowBounds): void
|
||||
stopLoading(): void
|
||||
captureScreenshot?(signal: AbortSignal): Promise<BrowserScreenshot>
|
||||
dispose(): Promise<void>
|
||||
@@ -84,6 +86,7 @@ type BrowserSlot = {
|
||||
isLoading: boolean
|
||||
stopLoadingRequested: boolean
|
||||
removeLoadingListener: () => void
|
||||
removeNavigationListener: () => void
|
||||
idleTimer?: ReturnType<typeof setTimeout>
|
||||
lastUsedAt: number
|
||||
released: boolean
|
||||
@@ -172,6 +175,8 @@ export class BrowserService {
|
||||
(state: BrowserLiveState) => void
|
||||
>()
|
||||
private readonly liveStates = new Map<string, BrowserLiveState>()
|
||||
private visibleConversationId?: string
|
||||
private viewportBounds?: BrowserWindowBounds
|
||||
private lifecycle = new AbortController()
|
||||
private clearOperation?: Promise<void>
|
||||
private clearing = false
|
||||
@@ -221,6 +226,19 @@ export class BrowserService {
|
||||
}
|
||||
}
|
||||
|
||||
setViewport(
|
||||
conversationId?: string,
|
||||
bounds?: BrowserWindowBounds
|
||||
): void {
|
||||
this.visibleConversationId = conversationId
|
||||
this.viewportBounds = bounds
|
||||
for (const [id, slot] of this.slots) {
|
||||
slot.session.setViewport?.(
|
||||
id === conversationId ? bounds : undefined
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private emitState(
|
||||
conversationId: string,
|
||||
status: BrowserLiveState['status'],
|
||||
@@ -500,10 +518,16 @@ export class BrowserService {
|
||||
isLoading: session.isLoading(),
|
||||
stopLoadingRequested: false,
|
||||
removeLoadingListener: () => undefined,
|
||||
removeNavigationListener: () => undefined,
|
||||
lastUsedAt: Date.now(),
|
||||
released: false
|
||||
}
|
||||
this.slots.set(conversationId, slot)
|
||||
slot.session.setViewport?.(
|
||||
this.visibleConversationId === conversationId
|
||||
? this.viewportBounds
|
||||
: undefined
|
||||
)
|
||||
slot.removeLoadingListener = session.onLoadingChange((isLoading) => {
|
||||
if (!isLoading) {
|
||||
slot.stopLoadingRequested = false
|
||||
@@ -518,9 +542,49 @@ export class BrowserService {
|
||||
slot.isLoading = isLoading
|
||||
const current = this.liveStates.get(conversationId)
|
||||
if (current) {
|
||||
this.emitState(conversationId, current.status, { isLoading })
|
||||
const status =
|
||||
slot.active?.status ??
|
||||
(isLoading
|
||||
? 'loading'
|
||||
: current.status === 'loading'
|
||||
? 'ready'
|
||||
: current.status)
|
||||
this.emitState(conversationId, status, { isLoading })
|
||||
}
|
||||
})
|
||||
slot.removeNavigationListener =
|
||||
session.onNavigationChange?.((url) => {
|
||||
if (slot.released || this.slots.get(conversationId) !== slot) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const target = canonicalizeBrowserUrl(url)
|
||||
slot.origin = target.origin
|
||||
slot.lastUsedAt = Date.now()
|
||||
this.scheduleIdleExpiry(slot)
|
||||
const status = slot.active?.status ?? 'ready'
|
||||
this.emitState(conversationId, status, { url: target.href })
|
||||
void slot.driver
|
||||
.getNavigationMetadata(AbortSignal.timeout(2_000))
|
||||
.then((metadata) => {
|
||||
if (
|
||||
slot.released ||
|
||||
this.slots.get(conversationId) !== slot ||
|
||||
canonicalizeBrowserUrl(metadata.url).href !== target.href
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.emitState(
|
||||
conversationId,
|
||||
slot.active?.status ?? 'ready',
|
||||
{ url: target.href, canGoBack: metadata.canGoBack }
|
||||
)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
} catch {
|
||||
// The session rejects unsupported top-level URLs.
|
||||
}
|
||||
}) ?? (() => undefined)
|
||||
this.scheduleIdleExpiry(slot)
|
||||
return slot
|
||||
}
|
||||
@@ -878,37 +942,6 @@ export class BrowserService {
|
||||
)
|
||||
}
|
||||
|
||||
async interact(
|
||||
conversationId: string,
|
||||
signal: AbortSignal
|
||||
): Promise<void> {
|
||||
await this.runInSession(
|
||||
conversationId,
|
||||
signal,
|
||||
'interactive',
|
||||
false,
|
||||
'浏览器交互',
|
||||
async (slot, effectiveSignal) => {
|
||||
await this.verifyCurrentOriginOrRelease(slot)
|
||||
const closingFrame = await waitFor(
|
||||
slot.session.openInteraction(),
|
||||
effectiveSignal
|
||||
)
|
||||
const currentUrl = canonicalizeBrowserUrl(
|
||||
slot.session.webContents.getURL()
|
||||
)
|
||||
slot.origin = currentUrl.origin
|
||||
await this.captureFrame(
|
||||
conversationId,
|
||||
slot,
|
||||
effectiveSignal,
|
||||
currentUrl.href,
|
||||
closingFrame
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async stopLoading(conversationId: string): Promise<boolean> {
|
||||
const slot = this.slots.get(conversationId)
|
||||
const active = slot?.active
|
||||
@@ -1043,6 +1076,7 @@ export class BrowserService {
|
||||
slot.idleTimer = undefined
|
||||
}
|
||||
slot.removeLoadingListener()
|
||||
slot.removeNavigationListener()
|
||||
slot.active?.controller.abort(new Error('浏览器会话已释放'))
|
||||
try {
|
||||
slot.driver.dispose()
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
ElectronBrowserSession,
|
||||
type BrowserPartitionSession,
|
||||
type BrowserWebContents,
|
||||
type BrowserWindowHandle,
|
||||
type BrowserViewHandle,
|
||||
type FilteringProxyLike
|
||||
} from './electron-browser-session'
|
||||
|
||||
@@ -21,7 +21,6 @@ function createHarness() {
|
||||
const debuggerEvents = new EventEmitter()
|
||||
const contentEvents = new EventEmitter()
|
||||
const partitionEvents = new EventEmitter()
|
||||
const windowEvents = new EventEmitter()
|
||||
let currentUrl = ''
|
||||
let loadingMainFrame = false
|
||||
let openHandler: ((details: { url: string }) => { action: 'deny' }) | undefined
|
||||
@@ -63,34 +62,20 @@ function createHarness() {
|
||||
openHandler = handler
|
||||
}),
|
||||
capturePage: vi.fn(async () => capturedImage),
|
||||
loadURL: vi.fn(async (url: string) => {
|
||||
currentUrl = url
|
||||
}),
|
||||
getURL: vi.fn(() => currentUrl),
|
||||
isLoadingMainFrame: vi.fn(() => loadingMainFrame),
|
||||
stop: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
isDestroyed: vi.fn(() => false)
|
||||
}
|
||||
const window: BrowserWindowHandle = {
|
||||
const view: BrowserViewHandle = {
|
||||
nativeView: {},
|
||||
webContents,
|
||||
loadURL: vi.fn(async (url: string) => {
|
||||
currentUrl = url
|
||||
}),
|
||||
show: vi.fn(),
|
||||
minimize: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
isMinimized: vi.fn(() => false),
|
||||
focus: vi.fn(),
|
||||
on: (event, listener) =>
|
||||
windowEvents.on(
|
||||
event,
|
||||
listener as (...argumentsValue: unknown[]) => void
|
||||
),
|
||||
off: (event, listener) =>
|
||||
windowEvents.off(
|
||||
event,
|
||||
listener as (...argumentsValue: unknown[]) => void
|
||||
),
|
||||
destroy: vi.fn(),
|
||||
isDestroyed: vi.fn(() => false)
|
||||
setVisible: vi.fn(),
|
||||
setBounds: vi.fn(),
|
||||
}
|
||||
let permissionCheck: ((...values: unknown[]) => boolean) | undefined
|
||||
let permissionRequest:
|
||||
@@ -143,13 +128,12 @@ function createHarness() {
|
||||
contentEvents,
|
||||
debuggerEvents,
|
||||
partitionEvents,
|
||||
windowEvents,
|
||||
partition,
|
||||
proxy,
|
||||
policy,
|
||||
sendCommand,
|
||||
webContents,
|
||||
window,
|
||||
window: view,
|
||||
setCurrentUrl(value: string) {
|
||||
currentUrl = value
|
||||
},
|
||||
@@ -166,7 +150,7 @@ function createHarness() {
|
||||
describe('ElectronBrowserSession', () => {
|
||||
it('creates an isolated sandboxed partition and denies privileged capabilities', async () => {
|
||||
const harness = createHarness()
|
||||
const createWindow = vi.fn(async (options: Record<string, unknown>) => {
|
||||
const createView = vi.fn(async (options: Record<string, unknown>) => {
|
||||
const preferences = options.webPreferences as Record<string, unknown>
|
||||
expect(preferences).toMatchObject({
|
||||
sandbox: true,
|
||||
@@ -181,7 +165,7 @@ describe('ElectronBrowserSession', () => {
|
||||
const session = await ElectronBrowserSession.create({
|
||||
policy: harness.policy,
|
||||
createPartition: vi.fn(async () => harness.partition),
|
||||
createWindow,
|
||||
createView,
|
||||
createProxy: () => harness.proxy
|
||||
})
|
||||
|
||||
@@ -208,9 +192,9 @@ describe('ElectronBrowserSession', () => {
|
||||
expect(harness.getOpenHandler()?.({ url: 'https://example.com' })).toEqual({
|
||||
action: 'deny'
|
||||
})
|
||||
expect(harness.window.loadURL).toHaveBeenCalledWith('about:blank')
|
||||
expect(harness.webContents.loadURL).toHaveBeenCalledWith('about:blank')
|
||||
expect(
|
||||
vi.mocked(harness.window.loadURL).mock.invocationCallOrder[0]
|
||||
vi.mocked(harness.webContents.loadURL!).mock.invocationCallOrder[0]
|
||||
).toBeLessThan(
|
||||
vi.mocked(harness.webContents.debugger.attach).mock
|
||||
.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY
|
||||
@@ -230,6 +214,16 @@ describe('ElectronBrowserSession', () => {
|
||||
})
|
||||
session.stopLoading()
|
||||
expect(harness.webContents.stop).toHaveBeenCalledOnce()
|
||||
session.setViewport({ x: 900, y: 120, width: 420, height: 640 })
|
||||
expect(harness.window.setBounds).toHaveBeenCalledWith({
|
||||
x: 900,
|
||||
y: 120,
|
||||
width: 420,
|
||||
height: 640
|
||||
})
|
||||
expect(harness.window.setVisible).toHaveBeenLastCalledWith(true)
|
||||
session.setViewport()
|
||||
expect(harness.window.setVisible).toHaveBeenLastCalledWith(false)
|
||||
|
||||
const downloadEvent = { preventDefault: vi.fn() }
|
||||
const item = { cancel: vi.fn() }
|
||||
@@ -256,7 +250,7 @@ describe('ElectronBrowserSession', () => {
|
||||
const session = await ElectronBrowserSession.create({
|
||||
policy: harness.policy,
|
||||
createPartition: async () => harness.partition,
|
||||
createWindow: async () => harness.window,
|
||||
createView: async () => harness.window,
|
||||
createProxy: () => harness.proxy
|
||||
})
|
||||
const target = await harness.policy.validate(
|
||||
@@ -306,7 +300,7 @@ describe('ElectronBrowserSession', () => {
|
||||
const session = await ElectronBrowserSession.create({
|
||||
policy: harness.policy,
|
||||
createPartition: async () => harness.partition,
|
||||
createWindow: async () => harness.window,
|
||||
createView: async () => harness.window,
|
||||
createProxy: () => harness.proxy
|
||||
})
|
||||
expect(session.isLoading()).toBe(true)
|
||||
@@ -328,72 +322,30 @@ describe('ElectronBrowserSession', () => {
|
||||
await session.dispose()
|
||||
})
|
||||
|
||||
it('restores the browser for interaction and minimizes it on close', async () => {
|
||||
it('attaches the browser view to the parent content tree and removes it on dispose', async () => {
|
||||
const harness = createHarness()
|
||||
const parentWindow = {
|
||||
setEnabled: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
contentView: {
|
||||
addChildView: vi.fn(),
|
||||
removeChildView: vi.fn()
|
||||
},
|
||||
isDestroyed: vi.fn(() => false)
|
||||
}
|
||||
let createdWindowOptions: Record<string, unknown> | undefined
|
||||
const createWindow = vi.fn(
|
||||
async (options: Record<string, unknown>) => {
|
||||
createdWindowOptions = options
|
||||
return harness.window
|
||||
}
|
||||
)
|
||||
const session = await ElectronBrowserSession.create({
|
||||
policy: harness.policy,
|
||||
parentWindow,
|
||||
createPartition: async () => harness.partition,
|
||||
createWindow,
|
||||
createView: async () => harness.window,
|
||||
createProxy: () => harness.proxy
|
||||
})
|
||||
|
||||
expect(createWindow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
parent: parentWindow,
|
||||
show: false,
|
||||
title: 'GoodBuddy 浏览器交互'
|
||||
})
|
||||
expect(parentWindow.contentView.addChildView).toHaveBeenCalledWith(
|
||||
harness.window.nativeView
|
||||
)
|
||||
expect(createdWindowOptions?.modal).toBeUndefined()
|
||||
const interaction = session.openInteraction()
|
||||
expect(parentWindow.setEnabled).toHaveBeenCalledWith(false)
|
||||
expect(harness.window.show).toHaveBeenCalledOnce()
|
||||
expect(harness.window.focus).toHaveBeenCalledOnce()
|
||||
const closeEvent = { preventDefault: vi.fn() }
|
||||
harness.windowEvents.emit('close', closeEvent)
|
||||
|
||||
await expect(interaction).resolves.toEqual({
|
||||
type: 'image',
|
||||
mimeType: 'image/jpeg',
|
||||
data: '/9j/2Q=='
|
||||
})
|
||||
expect(closeEvent.preventDefault).toHaveBeenCalledOnce()
|
||||
expect(harness.webContents.capturePage).toHaveBeenCalledOnce()
|
||||
expect(harness.window.minimize).toHaveBeenCalledOnce()
|
||||
expect(parentWindow.setEnabled).toHaveBeenLastCalledWith(true)
|
||||
expect(parentWindow.focus).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
vi.mocked(harness.webContents.capturePage!).mock
|
||||
.invocationCallOrder[0]
|
||||
).toBeLessThan(
|
||||
vi.mocked(harness.window.minimize).mock.invocationCallOrder[0] ??
|
||||
Number.POSITIVE_INFINITY
|
||||
)
|
||||
const repeatedCloseEvent = { preventDefault: vi.fn() }
|
||||
harness.windowEvents.emit('close', repeatedCloseEvent)
|
||||
expect(repeatedCloseEvent.preventDefault).toHaveBeenCalledOnce()
|
||||
expect(harness.window.minimize).toHaveBeenCalledTimes(2)
|
||||
expect(harness.window.destroy).not.toHaveBeenCalled()
|
||||
vi.mocked(harness.window.isMinimized).mockReturnValue(true)
|
||||
const reopenedInteraction = session.openInteraction()
|
||||
expect(harness.window.restore).toHaveBeenCalledOnce()
|
||||
harness.windowEvents.emit('close', { preventDefault: vi.fn() })
|
||||
await reopenedInteraction
|
||||
await session.dispose()
|
||||
expect(harness.window.destroy).toHaveBeenCalledOnce()
|
||||
expect(parentWindow.contentView.removeChildView).toHaveBeenCalledWith(
|
||||
harness.window.nativeView
|
||||
)
|
||||
})
|
||||
|
||||
it('detaches listeners and clears isolated data on idempotent disposal', async () => {
|
||||
@@ -401,14 +353,14 @@ describe('ElectronBrowserSession', () => {
|
||||
const session = await ElectronBrowserSession.create({
|
||||
policy: harness.policy,
|
||||
createPartition: async () => harness.partition,
|
||||
createWindow: async () => harness.window,
|
||||
createView: async () => harness.window,
|
||||
createProxy: () => harness.proxy
|
||||
})
|
||||
await session.dispose()
|
||||
await session.dispose()
|
||||
|
||||
expect(harness.webContents.debugger.detach).toHaveBeenCalledOnce()
|
||||
expect(harness.window.destroy).toHaveBeenCalledOnce()
|
||||
expect(harness.webContents.destroy).toHaveBeenCalledOnce()
|
||||
expect(harness.partition.closeAllConnections).toHaveBeenCalledOnce()
|
||||
expect(harness.partition.clearData).toHaveBeenCalledOnce()
|
||||
expect(harness.proxy.dispose).toHaveBeenCalledOnce()
|
||||
@@ -423,11 +375,11 @@ describe('ElectronBrowserSession', () => {
|
||||
ElectronBrowserSession.create({
|
||||
policy: harness.policy,
|
||||
createPartition: async () => harness.partition,
|
||||
createWindow: async () => harness.window,
|
||||
createView: async () => harness.window,
|
||||
createProxy: () => harness.proxy
|
||||
})
|
||||
).rejects.toThrow('无法创建安全浏览器会话')
|
||||
expect(harness.window.destroy).toHaveBeenCalled()
|
||||
expect(harness.webContents.destroy).toHaveBeenCalled()
|
||||
expect(harness.partition.clearData).toHaveBeenCalled()
|
||||
expect(harness.proxy.dispose).toHaveBeenCalled()
|
||||
})
|
||||
@@ -439,7 +391,7 @@ describe('ElectronBrowserSession', () => {
|
||||
policy: harness.policy,
|
||||
setupTimeoutMs: 5,
|
||||
createPartition: () => partitionGate.promise,
|
||||
createWindow: async () => harness.window,
|
||||
createView: async () => harness.window,
|
||||
createProxy: () => harness.proxy
|
||||
})
|
||||
|
||||
@@ -451,20 +403,22 @@ describe('ElectronBrowserSession', () => {
|
||||
expect(harness.partition.closeAllConnections).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('destroys a hidden window that resolves after setup times out', async () => {
|
||||
it('destroys a detached view that resolves after setup times out', async () => {
|
||||
const harness = createHarness()
|
||||
const windowGate = deferred<BrowserWindowHandle>()
|
||||
const viewGate = deferred<BrowserViewHandle>()
|
||||
const creation = ElectronBrowserSession.create({
|
||||
policy: harness.policy,
|
||||
setupTimeoutMs: 5,
|
||||
createPartition: async () => harness.partition,
|
||||
createWindow: () => windowGate.promise,
|
||||
createView: () => viewGate.promise,
|
||||
createProxy: () => harness.proxy
|
||||
})
|
||||
|
||||
await expect(creation).rejects.toThrow('无法创建安全浏览器会话')
|
||||
windowGate.resolve(harness.window)
|
||||
await vi.waitFor(() => expect(harness.window.destroy).toHaveBeenCalledOnce())
|
||||
viewGate.resolve(harness.window)
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.webContents.destroy).toHaveBeenCalledOnce()
|
||||
)
|
||||
expect(harness.partition.clearData).toHaveBeenCalledOnce()
|
||||
expect(harness.proxy.dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
@@ -480,7 +434,7 @@ describe('ElectronBrowserSession', () => {
|
||||
policy: harness.policy,
|
||||
setupTimeoutMs: 5,
|
||||
createPartition: async () => harness.partition,
|
||||
createWindow: async () => harness.window,
|
||||
createView: async () => harness.window,
|
||||
createProxy: () => proxy
|
||||
})
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ export type BrowserWebContents = {
|
||||
handler: (details: { url: string }) => { action: 'deny' }
|
||||
): void
|
||||
capturePage?(): Promise<BrowserCapturedImage>
|
||||
loadURL?(url: string): Promise<unknown>
|
||||
getURL(): string
|
||||
isLoadingMainFrame?(): boolean
|
||||
stop(): void
|
||||
@@ -47,26 +48,28 @@ export type BrowserWebContents = {
|
||||
isDestroyed(): boolean
|
||||
}
|
||||
|
||||
export type BrowserWindowHandle = {
|
||||
export type BrowserViewHandle = {
|
||||
webContents: BrowserWebContents
|
||||
loadURL(url: string): Promise<unknown>
|
||||
show(): void
|
||||
minimize(): void
|
||||
restore(): void
|
||||
isMinimized(): boolean
|
||||
focus(): void
|
||||
on(event: string, listener: BrowserEventListener): unknown
|
||||
off(event: string, listener: BrowserEventListener): unknown
|
||||
destroy(): void
|
||||
isDestroyed(): boolean
|
||||
nativeView?: unknown
|
||||
setVisible(visible: boolean): void
|
||||
setBounds(bounds: BrowserWindowBounds): void
|
||||
}
|
||||
|
||||
export type BrowserParentWindowHandle = {
|
||||
setEnabled?(enabled: boolean): void
|
||||
focus?(): void
|
||||
contentView?: {
|
||||
addChildView(view: unknown): void
|
||||
removeChildView(view: unknown): void
|
||||
}
|
||||
isDestroyed?(): boolean
|
||||
}
|
||||
|
||||
export type BrowserWindowBounds = {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export type BrowserPartitionSession = {
|
||||
setPermissionCheckHandler(
|
||||
handler: (...argumentsValue: never[]) => boolean
|
||||
@@ -110,9 +113,9 @@ export type ElectronBrowserSessionOptions = {
|
||||
cleanupTimeoutMs?: number
|
||||
setupTimeoutMs?: number
|
||||
createPartition?: (partition: string) => Promise<BrowserPartitionSession>
|
||||
createWindow?: (
|
||||
createView?: (
|
||||
options: Record<string, unknown>
|
||||
) => Promise<BrowserWindowHandle>
|
||||
) => Promise<BrowserViewHandle>
|
||||
createProxy?: (policy: BrowserUrlPolicy) => FilteringProxyLike
|
||||
parentWindow?: BrowserParentWindowHandle
|
||||
}
|
||||
@@ -214,11 +217,18 @@ async function defaultCreatePartition(
|
||||
) as unknown as BrowserPartitionSession
|
||||
}
|
||||
|
||||
async function defaultCreateWindow(
|
||||
async function defaultCreateView(
|
||||
options: Record<string, unknown>
|
||||
): Promise<BrowserWindowHandle> {
|
||||
): Promise<BrowserViewHandle> {
|
||||
const electron = await import('electron')
|
||||
return new electron.BrowserWindow(options) as unknown as BrowserWindowHandle
|
||||
const view = new electron.WebContentsView(options)
|
||||
view.setVisible(false)
|
||||
return {
|
||||
nativeView: view,
|
||||
webContents: view.webContents as unknown as BrowserWebContents,
|
||||
setVisible: (visible) => view.setVisible(visible),
|
||||
setBounds: (bounds) => view.setBounds(bounds)
|
||||
}
|
||||
}
|
||||
|
||||
export class ElectronBrowserSession {
|
||||
@@ -226,26 +236,22 @@ export class ElectronBrowserSession {
|
||||
readonly webContents: BrowserWebContents
|
||||
private approvedOrigin?: string
|
||||
private readonly listeners: Listener[] = []
|
||||
private interaction?: {
|
||||
promise: Promise<BrowserScreenshot | undefined>
|
||||
resolve(frame?: BrowserScreenshot): void
|
||||
}
|
||||
private interactionClosing?: Promise<void>
|
||||
private readonly loadingListeners = new Set<(isLoading: boolean) => void>()
|
||||
private readonly navigationListeners = new Set<(url: string) => void>()
|
||||
private loading = false
|
||||
private disposed = false
|
||||
|
||||
private constructor(
|
||||
private readonly policy: BrowserUrlPolicy,
|
||||
private readonly partitionSession: BrowserPartitionSession,
|
||||
private readonly window: BrowserWindowHandle,
|
||||
private readonly view: BrowserViewHandle,
|
||||
private readonly proxy: FilteringProxyLike,
|
||||
partition: string,
|
||||
private readonly cleanupTimeoutMs: number,
|
||||
private readonly parentWindow?: BrowserParentWindowHandle
|
||||
) {
|
||||
this.partition = partition
|
||||
this.webContents = window.webContents
|
||||
this.webContents = view.webContents
|
||||
}
|
||||
|
||||
static async create(
|
||||
@@ -254,7 +260,7 @@ export class ElectronBrowserSession {
|
||||
): Promise<ElectronBrowserSession> {
|
||||
const partition = `browser-${randomUUID()}`
|
||||
const createPartition = options.createPartition ?? defaultCreatePartition
|
||||
const createWindow = options.createWindow ?? defaultCreateWindow
|
||||
const createView = options.createView ?? defaultCreateView
|
||||
const cleanupTimeoutMs = options.cleanupTimeoutMs ?? 5_000
|
||||
const setupTimeoutMs = options.setupTimeoutMs ?? 15_000
|
||||
if (!Number.isSafeInteger(cleanupTimeoutMs) || cleanupTimeoutMs < 1) {
|
||||
@@ -275,7 +281,8 @@ export class ElectronBrowserSession {
|
||||
}
|
||||
}
|
||||
let partitionSession: BrowserPartitionSession | undefined
|
||||
let window: BrowserWindowHandle | undefined
|
||||
let view: BrowserViewHandle | undefined
|
||||
let viewAttached = false
|
||||
let result: ElectronBrowserSession | undefined
|
||||
let setupStage = '启动代理'
|
||||
try {
|
||||
@@ -318,19 +325,9 @@ export class ElectronBrowserSession {
|
||||
signal,
|
||||
setupTimeoutMs
|
||||
)
|
||||
setupStage = '创建浏览器窗口'
|
||||
window = await boundedSetup(
|
||||
createWindow({
|
||||
show: false,
|
||||
width: 1280,
|
||||
height: 900,
|
||||
title: 'GoodBuddy 浏览器交互',
|
||||
autoHideMenuBar: true,
|
||||
...(options.parentWindow
|
||||
? {
|
||||
parent: options.parentWindow
|
||||
}
|
||||
: {}),
|
||||
setupStage = '创建浏览器视图'
|
||||
view = await boundedSetup(
|
||||
createView({
|
||||
webPreferences: {
|
||||
partition,
|
||||
sandbox: true,
|
||||
@@ -348,24 +345,32 @@ export class ElectronBrowserSession {
|
||||
}),
|
||||
signal,
|
||||
setupTimeoutMs,
|
||||
(lateWindow) => {
|
||||
if (!lateWindow.isDestroyed()) {
|
||||
lateWindow.destroy()
|
||||
} else if (!lateWindow.webContents.isDestroyed()) {
|
||||
lateWindow.webContents.destroy()
|
||||
(lateView) => {
|
||||
if (!lateView.webContents.isDestroyed()) {
|
||||
lateView.webContents.close?.({ waitForBeforeUnload: false })
|
||||
if (!lateView.webContents.isDestroyed()) {
|
||||
lateView.webContents.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
if (options.parentWindow?.contentView && view.nativeView) {
|
||||
options.parentWindow.contentView.addChildView(view.nativeView)
|
||||
viewAttached = true
|
||||
}
|
||||
setupStage = '加载初始页面'
|
||||
if (!view.webContents.loadURL) {
|
||||
throw new Error('浏览器视图导航不可用')
|
||||
}
|
||||
await boundedSetup(
|
||||
window.loadURL('about:blank'),
|
||||
view.webContents.loadURL('about:blank'),
|
||||
signal,
|
||||
setupTimeoutMs
|
||||
)
|
||||
result = new ElectronBrowserSession(
|
||||
options.policy,
|
||||
partitionSession,
|
||||
window,
|
||||
view,
|
||||
managedProxy,
|
||||
partition,
|
||||
cleanupTimeoutMs,
|
||||
@@ -377,8 +382,16 @@ export class ElectronBrowserSession {
|
||||
} catch (error) {
|
||||
if (result) {
|
||||
await result.dispose().catch(() => undefined)
|
||||
} else if (window && !window.isDestroyed()) {
|
||||
window.destroy()
|
||||
} else if (view) {
|
||||
if (viewAttached && view.nativeView) {
|
||||
options.parentWindow?.contentView?.removeChildView(view.nativeView)
|
||||
}
|
||||
if (!view.webContents.isDestroyed()) {
|
||||
view.webContents.close?.({ waitForBeforeUnload: false })
|
||||
if (!view.webContents.isDestroyed()) {
|
||||
view.webContents.destroy()
|
||||
}
|
||||
}
|
||||
await cleanupIsolatedState(
|
||||
partitionSession,
|
||||
managedProxy,
|
||||
@@ -422,21 +435,6 @@ export class ElectronBrowserSession {
|
||||
this.listen(contents, 'did-stop-loading', () => {
|
||||
this.setLoading(contents.isLoadingMainFrame?.() ?? false)
|
||||
})
|
||||
this.listen(
|
||||
this.window,
|
||||
'close',
|
||||
(event: { preventDefault(): void }) => {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
if (this.interaction) {
|
||||
void this.captureAndFinishInteraction()
|
||||
} else {
|
||||
this.window.minimize()
|
||||
}
|
||||
}
|
||||
)
|
||||
contents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||
this.listen(contents, 'will-navigate', (event: { preventDefault(): void }, details: { url?: string } | string) => {
|
||||
const url = typeof details === 'string' ? details : details.url
|
||||
@@ -471,6 +469,15 @@ export class ElectronBrowserSession {
|
||||
this.listen(contents, 'did-navigate', (_event: unknown, url: string) => {
|
||||
if (url && !this.updateOriginFromUrl(url)) {
|
||||
contents.stop()
|
||||
return
|
||||
}
|
||||
if (url) {
|
||||
this.emitNavigation(url)
|
||||
}
|
||||
})
|
||||
this.listen(contents, 'did-navigate-in-page', (_event: unknown, url: string) => {
|
||||
if (url) {
|
||||
this.emitNavigation(url)
|
||||
}
|
||||
})
|
||||
this.listen(
|
||||
@@ -553,6 +560,30 @@ export class ElectronBrowserSession {
|
||||
}
|
||||
}
|
||||
|
||||
onNavigationChange(listener: (url: string) => void): () => void {
|
||||
this.assertOpen()
|
||||
this.navigationListeners.add(listener)
|
||||
return () => {
|
||||
this.navigationListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
private emitNavigation(url: string): void {
|
||||
for (const listener of this.navigationListeners) {
|
||||
listener(url)
|
||||
}
|
||||
}
|
||||
|
||||
setViewport(bounds?: BrowserWindowBounds): void {
|
||||
this.assertOpen()
|
||||
if (!bounds) {
|
||||
this.view.setVisible(false)
|
||||
return
|
||||
}
|
||||
this.view.setBounds(bounds)
|
||||
this.view.setVisible(true)
|
||||
}
|
||||
|
||||
private setLoading(isLoading: boolean): void {
|
||||
if (this.loading === isLoading) {
|
||||
return
|
||||
@@ -594,91 +625,6 @@ export class ElectronBrowserSession {
|
||||
this.approvedOrigin = target.origin
|
||||
}
|
||||
|
||||
openInteraction(): Promise<BrowserScreenshot | undefined> {
|
||||
this.assertOpen()
|
||||
if (this.interaction) {
|
||||
this.setParentEnabled(false)
|
||||
if (this.window.isMinimized()) {
|
||||
this.window.restore()
|
||||
}
|
||||
this.window.show()
|
||||
this.window.focus()
|
||||
return this.interaction.promise
|
||||
}
|
||||
let resolve!: (frame?: BrowserScreenshot) => void
|
||||
const promise = new Promise<BrowserScreenshot | undefined>(
|
||||
(resolvePromise) => {
|
||||
resolve = resolvePromise
|
||||
}
|
||||
)
|
||||
this.interaction = { promise, resolve }
|
||||
this.setParentEnabled(false)
|
||||
try {
|
||||
if (this.window.isMinimized()) {
|
||||
this.window.restore()
|
||||
}
|
||||
this.window.show()
|
||||
this.window.focus()
|
||||
} catch (error) {
|
||||
this.finishInteraction()
|
||||
throw error
|
||||
}
|
||||
return promise
|
||||
}
|
||||
|
||||
private captureAndFinishInteraction(): Promise<void> {
|
||||
if (this.interactionClosing) {
|
||||
return this.interactionClosing
|
||||
}
|
||||
const operation = (async (): Promise<void> => {
|
||||
let frame: BrowserScreenshot | undefined
|
||||
try {
|
||||
frame = await this.captureScreenshot(AbortSignal.timeout(2_000))
|
||||
} catch {
|
||||
// The session remains usable even if the final visible frame fails.
|
||||
}
|
||||
try {
|
||||
if (!this.disposed && !this.window.isDestroyed()) {
|
||||
this.window.minimize()
|
||||
}
|
||||
} catch {
|
||||
// Resolving interaction must not depend on native minimize success.
|
||||
}
|
||||
this.finishInteraction(frame)
|
||||
})()
|
||||
this.interactionClosing = operation
|
||||
void operation.finally(() => {
|
||||
if (this.interactionClosing === operation) {
|
||||
this.interactionClosing = undefined
|
||||
}
|
||||
})
|
||||
return operation
|
||||
}
|
||||
|
||||
private finishInteraction(frame?: BrowserScreenshot): void {
|
||||
const interaction = this.interaction
|
||||
this.interaction = undefined
|
||||
this.setParentEnabled(true)
|
||||
interaction?.resolve(frame)
|
||||
}
|
||||
|
||||
private setParentEnabled(enabled: boolean): void {
|
||||
try {
|
||||
if (
|
||||
!this.parentWindow ||
|
||||
this.parentWindow.isDestroyed?.() === true
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.parentWindow.setEnabled?.(enabled)
|
||||
if (enabled) {
|
||||
this.parentWindow.focus?.()
|
||||
}
|
||||
} catch {
|
||||
// Parent-window state must not break browser-session cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
if (this.disposed) {
|
||||
return
|
||||
@@ -686,7 +632,7 @@ export class ElectronBrowserSession {
|
||||
this.disposed = true
|
||||
this.approvedOrigin = undefined
|
||||
this.loadingListeners.clear()
|
||||
this.finishInteraction()
|
||||
this.navigationListeners.clear()
|
||||
for (const { target, event, listener } of this.listeners.splice(0)) {
|
||||
target.off(event, listener)
|
||||
}
|
||||
@@ -694,10 +640,15 @@ export class ElectronBrowserSession {
|
||||
this.webContents.debugger.detach()
|
||||
}
|
||||
this.webContents.stop()
|
||||
if (!this.window.isDestroyed()) {
|
||||
this.window.destroy()
|
||||
} else if (!this.webContents.isDestroyed()) {
|
||||
this.webContents.destroy()
|
||||
this.view.setVisible(false)
|
||||
if (this.view.nativeView) {
|
||||
this.parentWindow?.contentView?.removeChildView(this.view.nativeView)
|
||||
}
|
||||
if (!this.webContents.isDestroyed()) {
|
||||
this.webContents.close?.({ waitForBeforeUnload: false })
|
||||
if (!this.webContents.isDestroyed()) {
|
||||
this.webContents.destroy()
|
||||
}
|
||||
}
|
||||
await cleanupIsolatedState(
|
||||
this.partitionSession,
|
||||
|
||||
Reference in New Issue
Block a user