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:
mesalogo
2026-09-09 15:47:24 +08:00
parent b6d4633a33
commit f669e490b8
38 changed files with 2336 additions and 772 deletions
+50 -53
View File
@@ -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
+67 -33
View File
@@ -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
})
+105 -154
View File
@@ -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,