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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/**
|
||||
* Electron Main Process Entry
|
||||
* Manages window creation, system tray, and IPC handlers
|
||||
*/
|
||||
@@ -18,6 +18,7 @@ import { logger } from '../utils/logger';
|
||||
import { initTelemetry } from '../utils/telemetry';
|
||||
|
||||
import { isQuitting, setQuitting } from './app-state';
|
||||
import { registerMainWindowClose } from './main-window-close';
|
||||
import { applyProxySettings } from './proxy';
|
||||
import { syncLaunchAtStartupSettingFromStore } from './launch-at-startup';
|
||||
import {
|
||||
@@ -204,6 +205,8 @@ let imageWorkspaceModule: DesignWorkspaceModule | null = null;
|
||||
let backgroundLifecycle!: BackgroundLifecycleController;
|
||||
let releaseJobs: ReleaseJobManager | null = null;
|
||||
let codingProducts: CodingProductComposition | null = null;
|
||||
let windowIpcBindings: ReturnType<typeof registerIpcHandlers> | null = null;
|
||||
let unsubscribeAuthSession: (() => void) | null = null;
|
||||
const mainWindowFocusState = createMainWindowFocusState();
|
||||
const quitLifecycleState = createQuitLifecycleState();
|
||||
const launchDeepLinkUrl = findNianCodeDeepLinkUrl(process.argv);
|
||||
@@ -337,10 +340,21 @@ function focusMainWindow(): void {
|
||||
focusWindow(mainWindow);
|
||||
}
|
||||
|
||||
function ensureMainWindow(): BrowserWindow | null {
|
||||
if (isQuitting()) return null;
|
||||
if (mainWindow && !mainWindow.isDestroyed()) return mainWindow;
|
||||
// Startup creates the first window after storage is ready. Later activations
|
||||
// recreate only the window, keeping the Host API and running tasks alive.
|
||||
if (!codingProducts) return null;
|
||||
return createMainWindow();
|
||||
}
|
||||
|
||||
function requestMainWindowFocus(reason: string): void {
|
||||
if (isQuitting()) return;
|
||||
const window = ensureMainWindow();
|
||||
const focusRequest = requestSecondInstanceFocus(
|
||||
mainWindowFocusState,
|
||||
Boolean(mainWindow && !mainWindow.isDestroyed()),
|
||||
Boolean(window && !window.webContents.isLoadingMainFrame()),
|
||||
);
|
||||
|
||||
if (focusRequest === 'focus-now') {
|
||||
@@ -378,30 +392,34 @@ function registerMakeloreProtocolClient(): void {
|
||||
if (!registered) logger.warn(`Failed to register ${NIANCODE_APP_PROTOCOL}:// protocol handler`);
|
||||
}
|
||||
|
||||
function sendAuthSession(
|
||||
session: ReturnType<typeof getWorksSquareSessionSnapshot>,
|
||||
reason: WorksSquareSessionChangeReason = 'changed',
|
||||
previousSession: ReturnType<typeof getWorksSquareSessionSnapshot> = null,
|
||||
): void {
|
||||
if (!session && getWorksSquareSessionRestoreStatus() === 'unavailable') return;
|
||||
if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.webContents.isDestroyed()) {
|
||||
mainWindow.webContents.send('auth:session-changed', session);
|
||||
}
|
||||
if (!session && reason === 'terminal') {
|
||||
void clearManagedWorksSquareRuntimeBestEffort({
|
||||
codingProducts: codingProducts ?? undefined,
|
||||
imageWorkspace: imageWorkspaceModule ?? undefined,
|
||||
}, 'terminal session invalidation', previousSession?.accessToken);
|
||||
}
|
||||
}
|
||||
|
||||
function createMainWindow(): BrowserWindow {
|
||||
const win = createWindow();
|
||||
const sendAuthSession = (
|
||||
session: ReturnType<typeof getWorksSquareSessionSnapshot>,
|
||||
reason: WorksSquareSessionChangeReason = 'changed',
|
||||
previousSession: ReturnType<typeof getWorksSquareSessionSnapshot> = null,
|
||||
) => {
|
||||
if (!session && getWorksSquareSessionRestoreStatus() === 'unavailable') return;
|
||||
if (!win.isDestroyed() && !win.webContents.isDestroyed()) {
|
||||
win.webContents.send('auth:session-changed', session);
|
||||
}
|
||||
if (!session && reason === 'terminal') {
|
||||
void clearManagedWorksSquareRuntimeBestEffort({
|
||||
codingProducts: codingProducts ?? undefined,
|
||||
imageWorkspace: imageWorkspaceModule ?? undefined,
|
||||
}, 'terminal session invalidation', previousSession?.accessToken);
|
||||
}
|
||||
};
|
||||
const unsubscribeAuthSession = subscribeWorksSquareSession(sendAuthSession);
|
||||
mainWindow = win;
|
||||
windowIpcBindings?.bindWindow(win);
|
||||
appUpdater.setMainWindow(win);
|
||||
win.webContents.on('did-finish-load', () => {
|
||||
sendAuthSession(getWorksSquareSessionSnapshot());
|
||||
if (mainWindow === win) sendAuthSession(getWorksSquareSessionSnapshot());
|
||||
});
|
||||
|
||||
const closeAgentBrowserForHostRenderer = (reason: string): void => {
|
||||
if (mainWindow !== win) return;
|
||||
void agentBrowser?.close().catch((error) => {
|
||||
logger.warn(`Failed to close Agent Browser after ${reason}:`, error);
|
||||
});
|
||||
@@ -432,14 +450,14 @@ function createMainWindow(): BrowserWindow {
|
||||
win.show();
|
||||
});
|
||||
|
||||
win.on('close', (event) => {
|
||||
if (!isQuitting() && !isE2EMode) {
|
||||
event.preventDefault();
|
||||
win.hide();
|
||||
}
|
||||
registerMainWindowClose(win, {
|
||||
platform: process.platform,
|
||||
isQuitting,
|
||||
hideOnClose: process.platform !== 'darwin' && !isE2EMode,
|
||||
});
|
||||
|
||||
win.on('hide', () => {
|
||||
if (mainWindow !== win) return;
|
||||
backgroundLifecycle?.setActivity({
|
||||
...backgroundLifecycle.getActivity(),
|
||||
visible: false,
|
||||
@@ -452,6 +470,7 @@ function createMainWindow(): BrowserWindow {
|
||||
});
|
||||
|
||||
win.on('show', () => {
|
||||
if (mainWindow !== win) return;
|
||||
backgroundLifecycle?.setActivity({
|
||||
...backgroundLifecycle.getActivity(),
|
||||
visible: true,
|
||||
@@ -459,18 +478,20 @@ function createMainWindow(): BrowserWindow {
|
||||
});
|
||||
|
||||
win.on('closed', () => {
|
||||
unsubscribeAuthSession();
|
||||
const browser = agentBrowser;
|
||||
agentBrowser = null;
|
||||
void browser?.dispose().catch((error) => {
|
||||
logger.warn('Failed to dispose Agent Browser after the main window closed:', error);
|
||||
if (mainWindow !== win) return;
|
||||
mainWindow = null;
|
||||
appUpdater.setMainWindow(null);
|
||||
backgroundLifecycle?.setActivity({
|
||||
...backgroundLifecycle.getActivity(),
|
||||
visible: false,
|
||||
});
|
||||
// The module belongs to the process, not this window. Disposing it here
|
||||
// would leave the coding tools and Host API holding an unusable browser.
|
||||
void agentBrowser?.close().catch((error) => {
|
||||
logger.warn('Failed to close Agent Browser after the main window closed:', error);
|
||||
});
|
||||
if (mainWindow === win) {
|
||||
mainWindow = null;
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow = win;
|
||||
return win;
|
||||
}
|
||||
|
||||
@@ -509,23 +530,23 @@ async function initialize(): Promise<void> {
|
||||
logger.info('AI painting workspace is using the Works Square V2 cloud contract');
|
||||
|
||||
// Set application menu
|
||||
createMenu();
|
||||
createMenu(ensureMainWindow);
|
||||
|
||||
// Create the main window
|
||||
const window = createMainWindow();
|
||||
agentBrowser = new AgentBrowserModule(new ElectronAgentBrowserAdapter(window));
|
||||
agentBrowser = new AgentBrowserModule(new ElectronAgentBrowserAdapter(() => mainWindow));
|
||||
|
||||
// Create system tray
|
||||
if (!isE2EMode) {
|
||||
createTray(window);
|
||||
createTray(() => mainWindow, ensureMainWindow);
|
||||
}
|
||||
|
||||
// Register IPC handlers
|
||||
backgroundLifecycle = new BackgroundLifecycleController({
|
||||
...(isE2EMode ? { idleStopMs: 250 } : {}),
|
||||
onSleep: () => {
|
||||
if (!window.isDestroyed() && !window.webContents.isDestroyed()) {
|
||||
window.webContents.send('lifecycle:sleep');
|
||||
if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.webContents.isDestroyed()) {
|
||||
mainWindow.webContents.send('lifecycle:sleep');
|
||||
}
|
||||
},
|
||||
onStopRuntime: async () => await codingProducts?.sleep('background_sleep'),
|
||||
@@ -544,8 +565,8 @@ async function initialize(): Promise<void> {
|
||||
});
|
||||
releaseJobs.on('status', (status) => {
|
||||
hostEventBus.emit('release-job:status', status);
|
||||
if (!window.isDestroyed() && !window.webContents.isDestroyed()) {
|
||||
window.webContents.send('release-job:status', status);
|
||||
if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.webContents.isDestroyed()) {
|
||||
mainWindow.webContents.send('release-job:status', status);
|
||||
}
|
||||
});
|
||||
const codingAppPath = app.isPackaged
|
||||
@@ -557,14 +578,14 @@ async function initialize(): Promise<void> {
|
||||
browser: agentBrowser,
|
||||
requestAgentBrowserPresentation: (snapshot) => {
|
||||
hostEventBus.emit('agent-browser:show', snapshot);
|
||||
if (!window.isDestroyed() && !window.webContents.isDestroyed()) {
|
||||
window.webContents.send('agent-browser:show', snapshot);
|
||||
if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.webContents.isDestroyed()) {
|
||||
mainWindow.webContents.send('agent-browser:show', snapshot);
|
||||
}
|
||||
},
|
||||
publishAgentBrowserState: (snapshot) => {
|
||||
hostEventBus.emit('agent-browser:state', snapshot);
|
||||
if (!window.isDestroyed() && !window.webContents.isDestroyed()) {
|
||||
window.webContents.send('agent-browser:state', snapshot);
|
||||
if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.webContents.isDestroyed()) {
|
||||
mainWindow.webContents.send('agent-browser:state', snapshot);
|
||||
}
|
||||
},
|
||||
getLocalProxyCredential: () => getHostApiToken() || undefined,
|
||||
@@ -587,7 +608,7 @@ async function initialize(): Promise<void> {
|
||||
const hostApiContext: HostApiContext = {
|
||||
codingProjectStore,
|
||||
eventBus: hostEventBus,
|
||||
mainWindow: window,
|
||||
get mainWindow() { return mainWindow; },
|
||||
agentBrowser,
|
||||
worksSubmissionBinding: worksSubmissionBinding ?? undefined,
|
||||
imageWorkspace,
|
||||
@@ -596,7 +617,8 @@ async function initialize(): Promise<void> {
|
||||
codingProducts,
|
||||
previewDataSession: codingProducts.previewDataSession,
|
||||
};
|
||||
registerIpcHandlers(window, backgroundLifecycle, hostApiContext);
|
||||
windowIpcBindings = registerIpcHandlers(() => mainWindow, backgroundLifecycle, hostApiContext);
|
||||
unsubscribeAuthSession = subscribeWorksSquareSession(sendAuthSession);
|
||||
|
||||
void initializeRememberedPassword({
|
||||
secureStorage: useSecureWorksSquareSessionPersistence ? safeStorage : null,
|
||||
@@ -754,16 +776,12 @@ if (gotTheLock) {
|
||||
// Register activate handler AFTER app is ready to prevent
|
||||
// "Cannot create BrowserWindow before app is ready" on macOS.
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createMainWindow();
|
||||
} else {
|
||||
focusMainWindow();
|
||||
}
|
||||
requestMainWindowFocus('app activation');
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin' || isE2EMode) {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
@@ -783,6 +801,8 @@ if (gotTheLock) {
|
||||
return;
|
||||
}
|
||||
|
||||
unsubscribeAuthSession?.();
|
||||
unsubscribeAuthSession = null;
|
||||
hostEventBus.closeAll();
|
||||
codingProducts?.previewDataSession?.invalidate('main_shutdown');
|
||||
hostApiServer?.close();
|
||||
|
||||
@@ -22,6 +22,11 @@ import type { BackgroundLifecycleController, DesktopActivity } from './backgroun
|
||||
import { collectPerformanceSnapshot } from './performance-diagnostics';
|
||||
import type { HostApiContext } from '../api/context';
|
||||
import { takeCloudAgentRoute } from './app-deep-link';
|
||||
import { resolveMainWindow, type MainWindowProvider } from './window-provider';
|
||||
|
||||
export interface WindowIpcBindings {
|
||||
bindWindow(window: BrowserWindow): void;
|
||||
}
|
||||
|
||||
type UnifiedRequest = {
|
||||
id?: string;
|
||||
@@ -236,24 +241,41 @@ async function handleUnifiedRequest(
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(
|
||||
mainWindow: BrowserWindow,
|
||||
mainWindow: MainWindowProvider,
|
||||
lifecycle?: BackgroundLifecycleController,
|
||||
hostApiContext?: HostApiContext,
|
||||
): void {
|
||||
registerHostApiProxyHandlers(hostApiContext);
|
||||
registerTranscriptExportHandler(mainWindow);
|
||||
registerConversationLinkContextMenuHandler(mainWindow);
|
||||
const rendererLeases = new Set<string>();
|
||||
const releaseRendererLeases = (): void => {
|
||||
if (rendererLeases.size === 0) return;
|
||||
for (const id of rendererLeases) lifecycle?.releaseLease(id);
|
||||
rendererLeases.clear();
|
||||
): WindowIpcBindings {
|
||||
const getMainWindow = (): BrowserWindow | null => resolveMainWindow(mainWindow);
|
||||
const getRequestWindow = (event: Electron.IpcMainInvokeEvent): BrowserWindow | null => (
|
||||
(event?.sender && BrowserWindow.fromWebContents(event.sender)) || getMainWindow()
|
||||
);
|
||||
const requireRequestWindow = (event: Electron.IpcMainInvokeEvent): BrowserWindow => {
|
||||
const window = getRequestWindow(event);
|
||||
if (!window || window.isDestroyed()) throw new Error('Main window is unavailable');
|
||||
return window;
|
||||
};
|
||||
const rendererWebContents = mainWindow.webContents;
|
||||
if (rendererWebContents && typeof rendererWebContents.on === 'function') {
|
||||
rendererWebContents.on('render-process-gone', releaseRendererLeases);
|
||||
rendererWebContents.on('destroyed', releaseRendererLeases);
|
||||
}
|
||||
registerHostApiProxyHandlers(hostApiContext);
|
||||
registerTranscriptExportHandler(getMainWindow);
|
||||
registerConversationLinkContextMenuHandler(getMainWindow);
|
||||
const leasesByRenderer = new WeakMap<Electron.WebContents, Set<string>>();
|
||||
const bindRenderer = (contents: Electron.WebContents): Set<string> => {
|
||||
const existing = leasesByRenderer.get(contents);
|
||||
if (existing) return existing;
|
||||
const leases = new Set<string>();
|
||||
leasesByRenderer.set(contents, leases);
|
||||
const releaseLeases = (): void => {
|
||||
for (const id of leases) lifecycle?.releaseLease(id);
|
||||
leases.clear();
|
||||
};
|
||||
contents.on('render-process-gone', releaseLeases);
|
||||
contents.on('destroyed', releaseLeases);
|
||||
return leases;
|
||||
};
|
||||
const bindWindow = (window: BrowserWindow): void => {
|
||||
if (!window.isDestroyed() && !window.webContents.isDestroyed()) bindRenderer(window.webContents);
|
||||
};
|
||||
const initialWindow = getMainWindow();
|
||||
if (initialWindow) bindWindow(initialWindow);
|
||||
ipcMain.handle('app:request', async (_event, request: UnifiedRequest) => (
|
||||
handleUnifiedRequest(request)
|
||||
));
|
||||
@@ -270,31 +292,40 @@ export function registerIpcHandlers(
|
||||
});
|
||||
ipcMain.handle('app:performance', () => collectPerformanceSnapshot(lifecycle));
|
||||
|
||||
ipcMain.handle('lifecycle:activity', (_event, activity: DesktopActivity) => {
|
||||
ipcMain.handle('lifecycle:activity', (event, activity: DesktopActivity) => {
|
||||
const window = getMainWindow();
|
||||
if (event?.sender && event.sender !== window?.webContents) {
|
||||
return lifecycle?.getActivity();
|
||||
}
|
||||
const nextActivity = activity && typeof activity === 'object'
|
||||
? activity
|
||||
: { visible: true, module: null };
|
||||
lifecycle?.setActivity(nextActivity);
|
||||
if ((!nextActivity.visible || nextActivity.module !== 'painting')
|
||||
&& !mainWindow.isDestroyed()
|
||||
&& !mainWindow.webContents.isDestroyed()) {
|
||||
mainWindow.webContents.send('lifecycle:pause');
|
||||
&& window
|
||||
&& !window.webContents.isDestroyed()) {
|
||||
window.webContents.send('lifecycle:pause');
|
||||
}
|
||||
return lifecycle?.getActivity() ?? nextActivity;
|
||||
});
|
||||
ipcMain.handle('lifecycle:lease', (_event, input: { id: string; kind: string; active: boolean }) => {
|
||||
ipcMain.handle('lifecycle:lease', (event, input: { id: string; kind: string; active: boolean }) => {
|
||||
if (!lifecycle) return { count: 0 };
|
||||
const id = typeof input?.id === 'string' ? input.id.trim() : '';
|
||||
if (!id) return { count: lifecycle.getLeaseCount() };
|
||||
const contents = event?.sender ?? getMainWindow()?.webContents;
|
||||
if (!contents || contents.isDestroyed()) return { count: lifecycle.getLeaseCount() };
|
||||
const rendererLeases = bindRenderer(contents);
|
||||
// A late destroyed event from an old renderer must not release a new one's lease.
|
||||
const leaseId = `renderer:${contents.id}:${id}`;
|
||||
if (input?.active) {
|
||||
lifecycle.acquireLease({
|
||||
id,
|
||||
id: leaseId,
|
||||
kind: typeof input.kind === 'string' && input.kind.trim() ? input.kind.trim() : 'unknown',
|
||||
});
|
||||
rendererLeases.add(id);
|
||||
rendererLeases.add(leaseId);
|
||||
} else {
|
||||
lifecycle.releaseLease(id);
|
||||
rendererLeases.delete(id);
|
||||
lifecycle.releaseLease(leaseId);
|
||||
rendererLeases.delete(leaseId);
|
||||
}
|
||||
return { count: lifecycle.getLeaseCount() };
|
||||
});
|
||||
@@ -330,19 +361,21 @@ export function registerIpcHandlers(
|
||||
ipcMain.handle('shell:showItemInFolder', (_event, path: string) => shell.showItemInFolder(path));
|
||||
ipcMain.handle('shell:openPath', (_event, path: string) => shell.openPath(path));
|
||||
|
||||
ipcMain.handle('dialog:open', (_event, options: Electron.OpenDialogOptions) => dialog.showOpenDialog(mainWindow, options));
|
||||
ipcMain.handle('dialog:save', (_event, options: Electron.SaveDialogOptions) => dialog.showSaveDialog(mainWindow, options));
|
||||
ipcMain.handle('dialog:message', (_event, options: Electron.MessageBoxOptions) => dialog.showMessageBox(mainWindow, options));
|
||||
ipcMain.handle('dialog:open', (event, options: Electron.OpenDialogOptions) => dialog.showOpenDialog(requireRequestWindow(event), options));
|
||||
ipcMain.handle('dialog:save', (event, options: Electron.SaveDialogOptions) => dialog.showSaveDialog(requireRequestWindow(event), options));
|
||||
ipcMain.handle('dialog:message', (event, options: Electron.MessageBoxOptions) => dialog.showMessageBox(requireRequestWindow(event), options));
|
||||
|
||||
ipcMain.handle('window:minimize', () => mainWindow.minimize());
|
||||
ipcMain.handle('window:maximize', () => {
|
||||
if (mainWindow.isMaximized()) {
|
||||
mainWindow.unmaximize();
|
||||
ipcMain.handle('window:minimize', (event) => requireRequestWindow(event).minimize());
|
||||
ipcMain.handle('window:maximize', (event) => {
|
||||
const window = requireRequestWindow(event);
|
||||
if (window.isMaximized()) {
|
||||
window.unmaximize();
|
||||
return false;
|
||||
}
|
||||
mainWindow.maximize();
|
||||
window.maximize();
|
||||
return true;
|
||||
});
|
||||
ipcMain.handle('window:close', () => mainWindow.close());
|
||||
ipcMain.handle('window:isMaximized', () => mainWindow.isMaximized());
|
||||
ipcMain.handle('window:close', (event) => requireRequestWindow(event).close());
|
||||
ipcMain.handle('window:isMaximized', (event) => requireRequestWindow(event).isMaximized());
|
||||
return { bindWindow };
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
shell,
|
||||
type MenuItemConstructorOptions,
|
||||
} from 'electron';
|
||||
import { resolveMainWindow, type MainWindowProvider } from '../window-provider';
|
||||
|
||||
const MAX_LINK_LENGTH = 4_096;
|
||||
const LOCAL_WEB_FILE_PATTERN = /\.(?:html?|xhtml)$/i;
|
||||
@@ -117,7 +118,7 @@ export function createConversationLinkMenuTemplate(
|
||||
throw new Error('Unsupported link context menu request');
|
||||
}
|
||||
|
||||
export function registerConversationLinkContextMenuHandler(mainWindow: BrowserWindow): void {
|
||||
export function registerConversationLinkContextMenuHandler(mainWindow: MainWindowProvider): void {
|
||||
ipcMain.handle('shell:showLinkContextMenu', (event, request: ConversationLinkContextMenuRequest) => {
|
||||
const template = createConversationLinkMenuTemplate(request, {
|
||||
platform: process.platform,
|
||||
@@ -128,7 +129,7 @@ export function registerConversationLinkContextMenuHandler(mainWindow: BrowserWi
|
||||
writeText: (value) => clipboard.writeText(value),
|
||||
});
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
const ownerWindow = BrowserWindow.fromWebContents(event.sender) ?? mainWindow;
|
||||
menu.popup({ window: ownerWindow });
|
||||
const ownerWindow = BrowserWindow.fromWebContents(event.sender) ?? resolveMainWindow(mainWindow);
|
||||
menu.popup(ownerWindow ? { window: ownerWindow } : {});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { dialog, ipcMain, type BrowserWindow } from 'electron';
|
||||
import { BrowserWindow, dialog, ipcMain } from 'electron';
|
||||
import { resolveMainWindow, type MainWindowProvider } from '../window-provider';
|
||||
|
||||
interface TranscriptSaveRequest {
|
||||
defaultPath?: unknown;
|
||||
@@ -15,8 +16,8 @@ function safeDefaultPath(value: unknown): string {
|
||||
return fileName.toLowerCase().endsWith('.md') ? fileName : `${fileName}.md`;
|
||||
}
|
||||
|
||||
export function registerTranscriptExportHandler(mainWindow: BrowserWindow): void {
|
||||
ipcMain.handle('transcript:save', async (_event, request: TranscriptSaveRequest) => {
|
||||
export function registerTranscriptExportHandler(mainWindow: MainWindowProvider): void {
|
||||
ipcMain.handle('transcript:save', async (event, request: TranscriptSaveRequest) => {
|
||||
if (typeof request?.markdown !== 'string') {
|
||||
throw new Error('Invalid transcript markdown');
|
||||
}
|
||||
@@ -26,10 +27,15 @@ export function registerTranscriptExportHandler(mainWindow: BrowserWindow): void
|
||||
|
||||
let selected: Electron.SaveDialogReturnValue;
|
||||
try {
|
||||
selected = await dialog.showSaveDialog(mainWindow, {
|
||||
const owner = (event?.sender && BrowserWindow.fromWebContents(event.sender))
|
||||
|| resolveMainWindow(mainWindow);
|
||||
const options: Electron.SaveDialogOptions = {
|
||||
defaultPath: safeDefaultPath(request.defaultPath),
|
||||
filters: [{ name: 'Markdown', extensions: ['md'] }],
|
||||
});
|
||||
};
|
||||
selected = owner
|
||||
? await dialog.showSaveDialog(owner, options)
|
||||
: await dialog.showSaveDialog(options);
|
||||
} catch {
|
||||
throw new Error('Failed to save transcript');
|
||||
}
|
||||
|
||||
50
electron/main/main-window-close.ts
Normal file
50
electron/main/main-window-close.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
/** Keep a fullscreen Space alive until macOS has finished leaving it. */
|
||||
export function registerMainWindowClose(
|
||||
window: BrowserWindow,
|
||||
options: {
|
||||
platform: NodeJS.Platform;
|
||||
isQuitting: () => boolean;
|
||||
hideOnClose: boolean;
|
||||
},
|
||||
): void {
|
||||
let pendingClose = false;
|
||||
let leavingFullscreen = false;
|
||||
|
||||
const cancelPendingClose = (): void => {
|
||||
pendingClose = false;
|
||||
window.removeListener('leave-full-screen', finishClose);
|
||||
};
|
||||
const finishClose = (): void => {
|
||||
pendingClose = false;
|
||||
if (!window.isDestroyed() && !options.isQuitting()) window.close();
|
||||
};
|
||||
|
||||
window.on('leave-full-screen', () => { leavingFullscreen = false; });
|
||||
window.on('close', (event) => {
|
||||
if (options.isQuitting()) {
|
||||
cancelPendingClose();
|
||||
return;
|
||||
}
|
||||
if (pendingClose) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (options.platform === 'darwin' && window.isFullScreen()) {
|
||||
event.preventDefault();
|
||||
pendingClose = true;
|
||||
window.once('leave-full-screen', finishClose);
|
||||
if (!leavingFullscreen) {
|
||||
leavingFullscreen = true;
|
||||
window.setFullScreen(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (options.hideOnClose) {
|
||||
event.preventDefault();
|
||||
window.hide();
|
||||
}
|
||||
});
|
||||
window.once('closed', cancelPendingClose);
|
||||
}
|
||||
@@ -3,12 +3,18 @@
|
||||
* Creates the native application menu for macOS/Windows/Linux
|
||||
*/
|
||||
import { Menu, app, BrowserWindow } from 'electron';
|
||||
import { sendWhenWindowReady, showMainWindow, type EnsureMainWindow } from './window-provider';
|
||||
|
||||
/**
|
||||
* Create application menu
|
||||
*/
|
||||
export function createMenu(): void {
|
||||
export function createMenu(ensureMainWindow?: EnsureMainWindow): void {
|
||||
const isMac = process.platform === 'darwin';
|
||||
const navigate = (route: string): void => {
|
||||
const window = ensureMainWindow ? ensureMainWindow() : BrowserWindow.getFocusedWindow();
|
||||
showMainWindow(window);
|
||||
sendWhenWindowReady(window, 'navigate', route);
|
||||
};
|
||||
|
||||
const template: Electron.MenuItemConstructorOptions[] = [
|
||||
// App menu (macOS only)
|
||||
@@ -22,10 +28,7 @@ export function createMenu(): void {
|
||||
{
|
||||
label: 'Preferences...',
|
||||
accelerator: 'Cmd+,',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
win?.webContents.send('navigate', '/settings');
|
||||
},
|
||||
click: () => navigate('/settings'),
|
||||
},
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'services' as const },
|
||||
@@ -47,10 +50,7 @@ export function createMenu(): void {
|
||||
{
|
||||
label: 'New Chat',
|
||||
accelerator: 'CmdOrCtrl+N',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
win?.webContents.send('navigate', '/');
|
||||
},
|
||||
click: () => navigate('/'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
isMac ? { role: 'close' } : { role: 'quit' },
|
||||
@@ -104,42 +104,27 @@ export function createMenu(): void {
|
||||
{
|
||||
label: 'Dashboard',
|
||||
accelerator: 'CmdOrCtrl+1',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
win?.webContents.send('navigate', '/');
|
||||
},
|
||||
click: () => navigate('/'),
|
||||
},
|
||||
{
|
||||
label: 'Sessions',
|
||||
accelerator: 'CmdOrCtrl+2',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
win?.webContents.send('navigate', '/sessions');
|
||||
},
|
||||
click: () => navigate('/sessions'),
|
||||
},
|
||||
{
|
||||
label: 'Projects',
|
||||
accelerator: 'CmdOrCtrl+3',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
win?.webContents.send('navigate', '/projects');
|
||||
},
|
||||
click: () => navigate('/projects'),
|
||||
},
|
||||
{
|
||||
label: 'Models',
|
||||
accelerator: 'CmdOrCtrl+4',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
win?.webContents.send('navigate', '/models');
|
||||
},
|
||||
click: () => navigate('/models'),
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
accelerator: isMac ? 'Cmd+,' : 'Ctrl+,',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
win?.webContents.send('navigate', '/settings');
|
||||
},
|
||||
click: () => navigate('/settings'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -2,10 +2,19 @@
|
||||
* System Tray Management
|
||||
* Creates and manages the system tray icon and menu
|
||||
*/
|
||||
import { Tray, Menu, BrowserWindow, app, nativeImage } from 'electron';
|
||||
import { Tray, Menu, app, nativeImage } from 'electron';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
resolveMainWindow,
|
||||
sendWhenWindowReady,
|
||||
showMainWindow,
|
||||
type EnsureMainWindow,
|
||||
type MainWindowProvider,
|
||||
} from './window-provider';
|
||||
|
||||
let tray: Tray | null = null;
|
||||
let windowProvider: MainWindowProvider = () => null;
|
||||
let ensureWindow: EnsureMainWindow | undefined;
|
||||
|
||||
/**
|
||||
* Resolve the icons directory path (works in both dev and packaged mode)
|
||||
@@ -20,7 +29,10 @@ function getIconsDir(): string {
|
||||
/**
|
||||
* Create system tray icon and menu
|
||||
*/
|
||||
export function createTray(mainWindow: BrowserWindow): Tray {
|
||||
export function createTray(mainWindow: MainWindowProvider, ensureMainWindow?: EnsureMainWindow): Tray {
|
||||
windowProvider = mainWindow;
|
||||
ensureWindow = ensureMainWindow;
|
||||
if (tray && !tray.isDestroyed()) return tray;
|
||||
// Use platform-appropriate icon for system tray
|
||||
const iconsDir = getIconsDir();
|
||||
let iconPath: string;
|
||||
@@ -60,9 +72,9 @@ export function createTray(mainWindow: BrowserWindow): Tray {
|
||||
tray.setToolTip('Makelore - AI Assistant');
|
||||
|
||||
const showWindow = () => {
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
const window = resolveMainWindow(windowProvider) ?? ensureWindow?.() ?? null;
|
||||
showMainWindow(window);
|
||||
return window;
|
||||
};
|
||||
|
||||
// Create context menu
|
||||
@@ -93,17 +105,13 @@ export function createTray(mainWindow: BrowserWindow): Tray {
|
||||
{
|
||||
label: 'Open Chat',
|
||||
click: () => {
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
mainWindow.show();
|
||||
mainWindow.webContents.send('navigate', '/');
|
||||
sendWhenWindowReady(showWindow(), 'navigate', '/');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Open Settings',
|
||||
click: () => {
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
mainWindow.show();
|
||||
mainWindow.webContents.send('navigate', '/settings');
|
||||
sendWhenWindowReady(showWindow(), 'navigate', '/settings');
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -114,8 +122,7 @@ export function createTray(mainWindow: BrowserWindow): Tray {
|
||||
{
|
||||
label: 'Check for Updates...',
|
||||
click: () => {
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
mainWindow.webContents.send('update:check');
|
||||
sendWhenWindowReady(showWindow(), 'update:check');
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -133,21 +140,16 @@ export function createTray(mainWindow: BrowserWindow): Tray {
|
||||
|
||||
// Click to show window (Windows/Linux)
|
||||
tray.on('click', () => {
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
if (mainWindow.isVisible()) {
|
||||
mainWindow.hide();
|
||||
const window = resolveMainWindow(windowProvider);
|
||||
if (window?.isVisible()) {
|
||||
window.hide();
|
||||
} else {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
showWindow();
|
||||
}
|
||||
});
|
||||
|
||||
// Double-click to show window (Windows)
|
||||
tray.on('double-click', () => {
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
});
|
||||
tray.on('double-click', showWindow);
|
||||
|
||||
return tray;
|
||||
}
|
||||
@@ -168,5 +170,7 @@ export function destroyTray(): void {
|
||||
if (tray) {
|
||||
tray.destroy();
|
||||
tray = null;
|
||||
windowProvider = () => null;
|
||||
ensureWindow = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ export class AppUpdater extends EventEmitter {
|
||||
/**
|
||||
* Set the main window for sending update events
|
||||
*/
|
||||
setMainWindow(window: BrowserWindow): void {
|
||||
setMainWindow(window: BrowserWindow | null): void {
|
||||
this.mainWindow = window;
|
||||
}
|
||||
|
||||
|
||||
39
electron/main/window-provider.ts
Normal file
39
electron/main/window-provider.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
export type MainWindowProvider = BrowserWindow | (() => BrowserWindow | null);
|
||||
export type EnsureMainWindow = () => BrowserWindow | null;
|
||||
|
||||
export function resolveMainWindow(provider: MainWindowProvider): BrowserWindow | null {
|
||||
const window = typeof provider === 'function' ? provider() : provider;
|
||||
return window && !window.isDestroyed?.() ? window : null;
|
||||
}
|
||||
|
||||
export function showMainWindow(window: BrowserWindow | null): void {
|
||||
if (!window || window.isDestroyed()) return;
|
||||
if (window.isMinimized()) window.restore();
|
||||
window.show();
|
||||
window.focus();
|
||||
}
|
||||
|
||||
/** A newly reopened renderer must finish loading before it can receive menu actions. */
|
||||
export function sendWhenWindowReady(
|
||||
window: BrowserWindow | null,
|
||||
channel: string,
|
||||
...args: unknown[]
|
||||
): void {
|
||||
if (!window || window.isDestroyed() || window.webContents.isDestroyed()) return;
|
||||
const contents = window.webContents;
|
||||
const send = (): void => {
|
||||
contents.removeListener('destroyed', cancel);
|
||||
if (!window.isDestroyed() && !contents.isDestroyed()) contents.send(channel, ...args);
|
||||
};
|
||||
const cancel = (): void => {
|
||||
contents.removeListener('did-finish-load', send);
|
||||
};
|
||||
if (contents.isLoadingMainFrame() || !contents.getURL()) {
|
||||
contents.once('did-finish-load', send);
|
||||
contents.once('destroyed', cancel);
|
||||
} else {
|
||||
send();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user