fix: close and recreate macOS windows safely
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import type { BrowserWindow, Session, WebContents } from 'electron';
|
||||
import { WebContentsView, session } from 'electron';
|
||||
import type { AgentBrowserBounds } from '../../shared/agent-browser';
|
||||
import { AgentBrowserFault } from './fault';
|
||||
import type {
|
||||
AgentBrowserAdapter,
|
||||
AgentBrowserDebuggerPort,
|
||||
@@ -88,11 +89,18 @@ function wrapWebContents(contents: WebContents, isVisible: () => boolean): Agent
|
||||
|
||||
export class ElectronAgentBrowserAdapter implements AgentBrowserAdapter {
|
||||
private readonly nativeViews = new WeakMap<AgentBrowserViewPort, WebContentsView>();
|
||||
private readonly viewOwners = new WeakMap<AgentBrowserViewPort, BrowserWindow>();
|
||||
private readonly guardedSessions = new WeakSet<Session>();
|
||||
|
||||
constructor(private readonly mainWindow: BrowserWindow) {}
|
||||
constructor(private readonly mainWindow: BrowserWindow | (() => BrowserWindow | null)) {}
|
||||
|
||||
private currentWindow(): BrowserWindow | null {
|
||||
const window = typeof this.mainWindow === 'function' ? this.mainWindow() : this.mainWindow;
|
||||
return window && !window.isDestroyed() ? window : null;
|
||||
}
|
||||
|
||||
createView(partition: string): AgentBrowserViewPort {
|
||||
const owner = this.currentWindow();
|
||||
const nativeView = new WebContentsView({
|
||||
webPreferences: {
|
||||
partition,
|
||||
@@ -119,25 +127,29 @@ export class ElectronAgentBrowserAdapter implements AgentBrowserAdapter {
|
||||
const view: AgentBrowserViewPort = {
|
||||
webContents: wrapWebContents(contents, () => nativeView.getVisible()),
|
||||
setBounds: (bounds: AgentBrowserBounds) =>
|
||||
nativeView.setBounds(this.toNativeBounds(bounds)),
|
||||
nativeView.setBounds(this.toNativeBounds(bounds, owner)),
|
||||
setVisible: (visible: boolean) => nativeView.setVisible(visible),
|
||||
};
|
||||
this.nativeViews.set(view, nativeView);
|
||||
if (owner) this.viewOwners.set(view, owner);
|
||||
return view;
|
||||
}
|
||||
|
||||
mount(view: AgentBrowserViewPort): void {
|
||||
const nativeView = this.requireNativeView(view);
|
||||
if (!this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.contentView.addChildView(nativeView);
|
||||
const owner = this.viewOwners.get(view);
|
||||
if (!owner || owner.isDestroyed()) {
|
||||
throw new AgentBrowserFault('CLOSED', '请先打开麦洛窗口,再查看作品。', true);
|
||||
}
|
||||
owner.contentView.addChildView(nativeView);
|
||||
}
|
||||
|
||||
unmount(view: AgentBrowserViewPort): void {
|
||||
const nativeView = this.nativeViews.get(view);
|
||||
if (!nativeView || this.mainWindow.isDestroyed()) return;
|
||||
const owner = this.viewOwners.get(view);
|
||||
if (!nativeView || !owner || owner.isDestroyed()) return;
|
||||
try {
|
||||
this.mainWindow.contentView.removeChildView(nativeView);
|
||||
owner.contentView.removeChildView(nativeView);
|
||||
} catch {
|
||||
// Removing an already detached view is harmless during shutdown.
|
||||
}
|
||||
@@ -147,6 +159,7 @@ export class ElectronAgentBrowserAdapter implements AgentBrowserAdapter {
|
||||
const nativeView = this.nativeViews.get(view);
|
||||
if (!nativeView) return;
|
||||
this.nativeViews.delete(view);
|
||||
this.viewOwners.delete(view);
|
||||
if (!nativeView.webContents.isDestroyed()) {
|
||||
nativeView.webContents.close({ waitForBeforeUnload: false });
|
||||
}
|
||||
@@ -201,10 +214,12 @@ export class ElectronAgentBrowserAdapter implements AgentBrowserAdapter {
|
||||
});
|
||||
}
|
||||
|
||||
private toNativeBounds(bounds: AgentBrowserBounds): AgentBrowserBounds {
|
||||
const zoomFactor = this.mainWindow.webContents.getZoomFactor();
|
||||
private toNativeBounds(bounds: AgentBrowserBounds, owner: BrowserWindow | null): AgentBrowserBounds {
|
||||
// Detached release preflight views also work while there is no main window.
|
||||
if (!owner || owner.isDestroyed()) return bounds;
|
||||
const zoomFactor = owner.webContents.getZoomFactor();
|
||||
const zoom = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1;
|
||||
const contentBounds = this.mainWindow.getContentBounds();
|
||||
const contentBounds = owner.getContentBounds();
|
||||
const contentWidth = Math.max(0, Math.trunc(contentBounds.width));
|
||||
const contentHeight = Math.max(0, Math.trunc(contentBounds.height));
|
||||
const x = clamp(Math.round(bounds.x * zoom), 0, contentWidth);
|
||||
|
||||
@@ -312,6 +312,7 @@ export class AgentBrowserModule {
|
||||
}
|
||||
|
||||
open(input: AgentBrowserOpenInput): Promise<AgentBrowserSnapshot> {
|
||||
const epoch = this.queueEpoch;
|
||||
return this.serialize(async () => {
|
||||
this.assertAvailable();
|
||||
const projectPath = normalizeRequiredPath(input.projectPath);
|
||||
@@ -322,6 +323,7 @@ export class AgentBrowserModule {
|
||||
|
||||
if (this.record && !samePath(this.record.projectPath, projectPath)) {
|
||||
await this.closeInternal();
|
||||
this.assertCommandEpoch(epoch);
|
||||
}
|
||||
if (this.record) {
|
||||
const record = this.record;
|
||||
@@ -335,6 +337,7 @@ export class AgentBrowserModule {
|
||||
}
|
||||
if (record.state !== 'attached') {
|
||||
await this.closeInternal();
|
||||
this.assertCommandEpoch(epoch);
|
||||
}
|
||||
}
|
||||
if (this.record) {
|
||||
@@ -346,6 +349,7 @@ export class AgentBrowserModule {
|
||||
: Boolean(record.previewData);
|
||||
if (shouldReplacePreview) {
|
||||
await this.closeInternal(record);
|
||||
this.assertCommandEpoch(epoch);
|
||||
}
|
||||
}
|
||||
if (this.record) {
|
||||
@@ -357,6 +361,7 @@ export class AgentBrowserModule {
|
||||
try {
|
||||
if (diagnosticOwner) {
|
||||
await this.updateDiagnosticOwner(record, diagnosticOwner, true);
|
||||
this.assertCommandEpoch(epoch);
|
||||
}
|
||||
if (bounds) {
|
||||
this.applyPresentation(record, input.visible ?? true, bounds);
|
||||
@@ -403,9 +408,14 @@ export class AgentBrowserModule {
|
||||
|
||||
this.registerListeners(record);
|
||||
record.view.webContents.denyWindowOpen();
|
||||
if (bounds) record.view.setBounds(bounds);
|
||||
record.view.setVisible(record.visible);
|
||||
this.adapter.mount(record.view);
|
||||
try {
|
||||
if (bounds) record.view.setBounds(bounds);
|
||||
record.view.setVisible(record.visible);
|
||||
this.adapter.mount(record.view);
|
||||
} catch (error) {
|
||||
await this.closeInternal(record);
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.runWhileActive(
|
||||
@@ -413,12 +423,15 @@ export class AgentBrowserModule {
|
||||
record.view.webContents.loadURL(RENDERER_PRIME_URL),
|
||||
OPEN_TIMEOUT_MS,
|
||||
);
|
||||
this.assertCommandEpoch(epoch);
|
||||
await this.runWhileActive(
|
||||
record,
|
||||
this.attachDebugger(record, false),
|
||||
OPEN_TIMEOUT_MS,
|
||||
);
|
||||
this.assertCommandEpoch(epoch);
|
||||
if (injectProjectData) await this.installPreviewData(record, undefined, targetUrl);
|
||||
this.assertCommandEpoch(epoch);
|
||||
} catch (error) {
|
||||
await this.closeInternal(record);
|
||||
throw toFault(
|
||||
@@ -432,6 +445,7 @@ export class AgentBrowserModule {
|
||||
|
||||
try {
|
||||
await this.loadPageUntilReady(record, targetUrl);
|
||||
this.assertCommandEpoch(epoch);
|
||||
try {
|
||||
record.view.webContents.navigationHistory.clear();
|
||||
} catch {
|
||||
@@ -658,8 +672,18 @@ export class AgentBrowserModule {
|
||||
async close(projectPath?: string): Promise<AgentBrowserSnapshot> {
|
||||
if (this.record && projectPath) this.assertProject(this.record, projectPath);
|
||||
this.preemptCommands('开发浏览器已关闭。');
|
||||
await this.closeInternal();
|
||||
return this.closedSnapshot();
|
||||
const previousBarrier = this.lifecycleBarrier;
|
||||
let releaseBarrier!: () => void;
|
||||
this.lifecycleBarrier = new Promise<void>((resolveBarrier) => {
|
||||
releaseBarrier = resolveBarrier;
|
||||
});
|
||||
try {
|
||||
await previousBarrier;
|
||||
await this.closeInternal();
|
||||
return this.closedSnapshot();
|
||||
} finally {
|
||||
releaseBarrier();
|
||||
}
|
||||
}
|
||||
|
||||
async resetProfile(projectPath: string): Promise<AgentBrowserSnapshot> {
|
||||
@@ -1709,6 +1733,7 @@ export class AgentBrowserModule {
|
||||
if (expected && this.record !== expected) return;
|
||||
record.state = 'closing';
|
||||
await this.invalidatePreviewData(record, 'preview_closed');
|
||||
if (this.record !== record) return;
|
||||
this.notifyLifecycle({
|
||||
type: 'closed',
|
||||
projectId: record.projectId,
|
||||
@@ -1812,15 +1837,7 @@ export class AgentBrowserModule {
|
||||
try {
|
||||
await barrier;
|
||||
if (callerSettled) return;
|
||||
if (epoch !== this.queueEpoch) {
|
||||
throw new AgentBrowserFault(
|
||||
'CLOSED',
|
||||
'开发浏览器操作已被新的生命周期取代。',
|
||||
true,
|
||||
this.record?.generation,
|
||||
'unknown',
|
||||
);
|
||||
}
|
||||
this.assertCommandEpoch(epoch);
|
||||
const value = await operation();
|
||||
if (!callerSettled) {
|
||||
callerSettled = true;
|
||||
@@ -1842,6 +1859,18 @@ export class AgentBrowserModule {
|
||||
return caller;
|
||||
}
|
||||
|
||||
private assertCommandEpoch(epoch: number): void {
|
||||
if (epoch !== this.queueEpoch) {
|
||||
throw new AgentBrowserFault(
|
||||
'CLOSED',
|
||||
'开发浏览器操作已被新的生命周期取代。',
|
||||
true,
|
||||
this.record?.generation,
|
||||
'unknown',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private serializeWithTimeout<T>(
|
||||
operation: () => Promise<T>,
|
||||
timeoutMs: number,
|
||||
|
||||
Reference in New Issue
Block a user