Files
zn-ai/electron/service/window-service.ts

317 lines
10 KiB
TypeScript

import type { WindowNames } from '@electron/types/runtime'
import { CONFIG_KEYS, IPC_EVENTS, WINDOW_NAMES } from '@runtime/lib/constants'
import { app, BrowserWindow, BrowserWindowConstructorOptions, ipcMain, IpcMainInvokeEvent, type IpcMainEvent } from 'electron'
import { createLogo } from '@electron/utils'
import logManager from '@electron/service/logger'
import configManager from '@electron/service/config-service'
import themeManager from '@electron/service/theme-service'
import path from 'node:path';
declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined;
interface WindowState {
instance: BrowserWindow | void;
isHidden: boolean;
onCreate: ((window: BrowserWindow) => void)[];
onClosed: ((window: BrowserWindow) => void)[];
}
interface SizeOptions {
width: number; // 窗口宽度
height: number; // 窗口高度
maxWidth?: number; // 窗口最大宽度,可选
maxHeight?: number; // 窗口最大高度,可选
minWidth?: number; // 窗口最小宽度,可选
minHeight?: number; // 窗口最小高度,可选
}
const isMac = process.platform === 'darwin';
const isWindows = process.platform === 'win32';
const useCustomTitleBar = isWindows;
const preloadEntryPath = MAIN_WINDOW_VITE_DEV_SERVER_URL
? path.join(process.cwd(), 'dist-electron', 'preload', 'preload.js')
: path.join(__dirname, '..', 'preload', 'preload.js');
function getSharedWindowOptions(): BrowserWindowConstructorOptions {
return {
frame: isMac || !useCustomTitleBar,
titleBarStyle: isMac ? 'hiddenInset' : useCustomTitleBar ? 'hidden' : 'default',
trafficLightPosition: isMac ? { x: 16, y: 16 } : undefined,
show: false,
title: 'NIANXX',
darkTheme: themeManager.isDark,
backgroundColor: themeManager.isDark ? '#2C2C2C' : '#FFFFFF',
webPreferences: {
nodeIntegration: false, // 禁用 Node.js 集成,提高安全性
contextIsolation: true, // 启用上下文隔离,防止渲染进程访问主进程 API
sandbox: true, // 启用沙箱模式,进一步增强安全性
backgroundThrottling: false,
preload: preloadEntryPath,
},
};
}
class WindowService {
private static _instance: WindowService;
private _logo = createLogo();
private readonly isDev = !!MAIN_WINDOW_VITE_DEV_SERVER_URL
private _winStates: Record<WindowNames | string, WindowState> = {
main: { instance: void 0, isHidden: false, onCreate: [], onClosed: [] },
setting: { instance: void 0, isHidden: false, onCreate: [], onClosed: [] },
dialog: { instance: void 0, isHidden: false, onCreate: [], onClosed: [] },
login: { instance: void 0, isHidden: false, onCreate: [], onClosed: [] },
loading: { instance: void 0, isHidden: false, onCreate: [], onClosed: [] },
}
private constructor() {
this._setupIpcEvents();
logManager.info('WindowService initialized successfully.');
}
private _isReallyClose(windowName: WindowNames | void) {
if (windowName === WINDOW_NAMES.MAIN) return configManager.get(CONFIG_KEYS.MINIMIZE_TO_TRAY) === false;
if (windowName === WINDOW_NAMES.SETTING) return false;
return true;
}
private _setupIpcEvents() {
ipcMain.handle(IPC_EVENTS.APP_LOAD_PAGE, (e: IpcMainInvokeEvent, page: string) => {
const win = BrowserWindow.fromWebContents(e.sender);
if (win) this._loadPage(win, page);
});
}
public static getInstance(): WindowService {
if (!this._instance) {
this._instance = new WindowService();
}
return this._instance;
}
public create(name: WindowNames, size: SizeOptions, moreOpts?: BrowserWindowConstructorOptions) {
if (this.get(name)) return;
const isHiddenWin = this._isHiddenWin(name);
let window = this._createWinInstance(name, { ...size, ...moreOpts });
this._setupDevtools(window, name);
!isHiddenWin && this
._setupWinLifecycle(window, name)
._loadWindowTemplate(window, name)
this._listenWinReady({
win: window,
isHiddenWin,
size
})
if (!isHiddenWin) {
this._winStates[name].instance = window;
this._winStates[name].onCreate.forEach(callback => callback(window));
}
if (isHiddenWin) {
this._winStates[name].isHidden = false;
logManager.info(`Hidden window show: ${name}`)
}
return window;
}
private _setupDevtools(window: BrowserWindow, name: WindowNames) {
if (!this.isDev) return;
let opened = false;
const openDevtools = () => {
if (opened || window.isDestroyed()) return;
opened = true;
try {
window.webContents.openDevTools({ mode: 'detach', activate: true });
logManager.info(`DevTools opened for window: ${name}`);
} catch (error) {
opened = false;
logManager.warn(`Failed to open DevTools for window: ${name}`, error);
}
};
window.webContents.once('did-finish-load', () => {
setTimeout(openDevtools, 150);
});
window.webContents.on('before-input-event', (_event, input) => {
const isMacDevtoolsShortcut = process.platform === 'darwin' && input.meta && input.alt && input.key.toLowerCase() === 'i';
const isF12 = input.key === 'F12';
if (!isF12 && !isMacDevtoolsShortcut) return;
if (window.webContents.isDevToolsOpened()) {
window.webContents.closeDevTools();
return;
}
openDevtools();
});
}
private _setupWinLifecycle(window: BrowserWindow, name: WindowNames) {
window.once('closed', () => {
this._winStates[name].onClosed.forEach(callback => callback(window));
window?.destroy();
this._winStates[name].instance = void 0;
this._winStates[name].isHidden = false;
logManager.info(`Window closed: ${name}`);
});
return this;
}
private _listenWinReady(params: {
win: BrowserWindow,
isHiddenWin: boolean,
size: SizeOptions,
}) {
const onReady = () => {
params.win?.once('show', () => setTimeout(() => this._applySizeConstraints(params.win, params.size), 2));
params.win?.show();
}
if (!params.isHiddenWin) {
const loadingHandler = this._addLoadingView(params.win, params.size);
loadingHandler?.(onReady)
} else {
onReady();
}
}
private _addLoadingView(window: BrowserWindow, size: SizeOptions) {
let rendererIsReady = false;
const onRendererIsReady = (e: IpcMainEvent) => {
if ((e.sender !== window?.webContents) || rendererIsReady) return;
rendererIsReady = true;
ipcMain.removeListener(IPC_EVENTS.RENDERER_IS_READY, onRendererIsReady);
}
ipcMain.on(IPC_EVENTS.RENDERER_IS_READY, onRendererIsReady);
return (cb: () => void) => {
// Immediately call callback since we don't have a loading view
cb();
}
}
private _applySizeConstraints(win: BrowserWindow, size: SizeOptions) {
if (size.maxHeight && size.maxWidth) {
win.setMaximumSize(size.maxWidth, size.maxHeight);
}
if (size.minHeight && size.minWidth) {
win.setMinimumSize(size.minWidth, size.minHeight);
}
}
private _loadPage(window: BrowserWindow, pageName: string) {
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
return window.loadURL(`${MAIN_WINDOW_VITE_DEV_SERVER_URL}/${pageName}.html`);
}
window.loadFile(path.join(app.getAppPath(), 'dist', `${pageName}.html`));
}
private _loadWindowTemplate(window: BrowserWindow, name: WindowNames) {
// Always load index.html, loading.html has been removed
const page = 'index';
this._loadPage(window, page);
}
private _handleCloseWindowState(target: BrowserWindow, really: boolean) {
const name = this.getName(target) as WindowNames;
if (name) {
if (!really) this._winStates[name].isHidden = true;
else this._winStates[name].instance = void 0;
}
setTimeout(() => {
target[really ? 'close' : 'hide']?.();
this._checkAndCloseAllWinodws();
}, 210)
}
private _checkAndCloseAllWinodws() {
if (!this._winStates[WINDOW_NAMES.MAIN].instance || this._winStates[WINDOW_NAMES.MAIN].instance?.isDestroyed())
return Object.values(this._winStates).forEach(win => win?.instance?.close());
const minimizeToTray = configManager.get(CONFIG_KEYS.MINIMIZE_TO_TRAY);
if (!minimizeToTray && !this.get(WINDOW_NAMES.MAIN)?.isVisible())
return Object.values(this._winStates).forEach(win => !win?.instance?.isVisible() && win?.instance?.close());
}
private _isHiddenWin(name: WindowNames) {
return this._winStates[name] && this._winStates[name].isHidden;
}
private _createWinInstance(name: WindowNames, opts?: BrowserWindowConstructorOptions) {
return this._isHiddenWin(name)
? this._winStates[name].instance as BrowserWindow
: new BrowserWindow({
...getSharedWindowOptions(),
icon: this._logo,
...opts,
});
}
public focus(target: BrowserWindow | void | null) {
if (!target) return;
const name = this.getName(target);
if (target?.isMinimized()) {
target?.restore();
logManager.debug(`Window ${name} restored and focused`);
} else {
logManager.debug(`Window ${name} focused`);
}
target?.focus();
}
public close(target: BrowserWindow | void | null, really: boolean = true) {
if (!target) return;
const name = this.getName(target);
logManager.info(`Close window: ${name}, really: ${really}`);
this._handleCloseWindowState(target, really);
}
public toggleMax(target: BrowserWindow | void | null) {
if (!target) return;
target.isMaximized() ? target.unmaximize() : target.maximize();
}
public getName(target: BrowserWindow | null | void): WindowNames | void {
if (!target) return;
for (const [name, win] of Object.entries(this._winStates) as [WindowNames, { instance: BrowserWindow | void } | void][]) {
if (win?.instance === target) return name;
}
}
public get(name: WindowNames) {
if (this._winStates[name].isHidden) return void 0;
return this._winStates[name].instance;
}
public onWindowCreate(name: WindowNames, callback: (window: BrowserWindow) => void) {
this._winStates[name].onCreate.push(callback);
}
public onWindowClosed(name: WindowNames, callback: (window: BrowserWindow) => void) {
this._winStates[name].onClosed.push(callback);
}
}
export const windowManager = WindowService.getInstance();
export default windowManager;