chore: restructure project and add i18n support
- Reorganize project structure with new electron and shared directories - Add comprehensive i18n support with Chinese, English, and Japanese locales - Update build configurations and TypeScript paths for new structure - Add various UI components including chat interface and task management - Include Windows release binaries and localization files - Update dependencies and fix import paths throughout the codebase
This commit is contained in:
126
electron/service/config-service/index.ts
Normal file
126
electron/service/config-service/index.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { app, BrowserWindow, ipcMain } from 'electron'
|
||||
import type { ConfigKeys, IConfig } from '@lib/types'
|
||||
import { CONFIG_KEYS, IPC_EVENTS } from '@lib/constants'
|
||||
import { debounce, simpleCloneDeep } from '@lib/utils'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
|
||||
import logManager from '@electron/service/logger'
|
||||
|
||||
const DEFAULT_CONFIG: IConfig = {
|
||||
[CONFIG_KEYS.THEME_MODE]: 'system',
|
||||
[CONFIG_KEYS.PRIMARY_COLOR]: '#BB5BE7',
|
||||
[CONFIG_KEYS.LANGUAGE]: 'zh',
|
||||
[CONFIG_KEYS.FONT_SIZE]: 14,
|
||||
[CONFIG_KEYS.MINIMIZE_TO_TRAY]: false,
|
||||
[CONFIG_KEYS.PROVIDER]: '',
|
||||
[CONFIG_KEYS.DEFAULT_MODEL]: null,
|
||||
}
|
||||
|
||||
export class ConfigService {
|
||||
private static _instance: ConfigService;
|
||||
private _config: IConfig;
|
||||
private _configPath: string;
|
||||
private _defaultConfig: IConfig = DEFAULT_CONFIG;
|
||||
|
||||
private _listeners: Array<(config: IConfig) => void> = [];
|
||||
|
||||
|
||||
private constructor() {
|
||||
// 获取配置文件路径
|
||||
this._configPath = path.join(app.getPath('userData'), 'config.json');
|
||||
// 加载配置
|
||||
this._config = this._loadConfig();
|
||||
// 设置 IPC 事件
|
||||
this._setupIpcEvents();
|
||||
logManager.info('ConfigService initialized successfully.')
|
||||
}
|
||||
|
||||
private _setupIpcEvents() {
|
||||
const duration = 200;
|
||||
const handelUpdate = debounce((val) => this.update(val), duration);
|
||||
|
||||
ipcMain.handle(IPC_EVENTS.GET_CONFIG, (_, key) => this.get(key));
|
||||
ipcMain.on(IPC_EVENTS.SET_CONFIG, (_, key, val) => this.set(key, val));
|
||||
ipcMain.on(IPC_EVENTS.UPDATE_CONFIG, (_, updates) => handelUpdate(updates));
|
||||
}
|
||||
|
||||
public static getInstance(): ConfigService {
|
||||
if (!this._instance) {
|
||||
this._instance = new ConfigService();
|
||||
}
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
private _loadConfig(): IConfig {
|
||||
try {
|
||||
if (fs.existsSync(this._configPath)) {
|
||||
const configContent = fs.readFileSync(this._configPath, 'utf-8');
|
||||
const config = { ...this._defaultConfig, ...JSON.parse(configContent) };
|
||||
logManager.info('Config loaded successfully from:', this._configPath);
|
||||
return config;
|
||||
}
|
||||
} catch (error) {
|
||||
logManager.error('Failed to load config:', error);
|
||||
}
|
||||
return { ...this._defaultConfig };
|
||||
}
|
||||
|
||||
private _saveConfig(): void {
|
||||
try {
|
||||
// 确保目录存在
|
||||
fs.mkdirSync(path.dirname(this._configPath), { recursive: true });
|
||||
// 写入
|
||||
fs.writeFileSync(this._configPath, JSON.stringify(this._config, null, 2), 'utf-8');
|
||||
// 通知监听者
|
||||
this._notifyListeners();
|
||||
|
||||
logManager.info('Config saved successfully to:', this._configPath);
|
||||
} catch (error) {
|
||||
logManager.error('Failed to save config:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private _notifyListeners(): void {
|
||||
BrowserWindow.getAllWindows().forEach(win => win.webContents.send(IPC_EVENTS.CONFIG_UPDATED, this._config));
|
||||
this._listeners.forEach(listener => listener({ ...this._config }));
|
||||
}
|
||||
|
||||
public getConfig(): IConfig {
|
||||
return simpleCloneDeep(this._config);
|
||||
}
|
||||
|
||||
public get<T = any>(key: ConfigKeys): T {
|
||||
return this._config[key] as T
|
||||
}
|
||||
|
||||
public set(key: ConfigKeys, value: unknown, autoSave: boolean = true): void {
|
||||
if (!(key in this._config)) return;
|
||||
const oldValue = this._config[key];
|
||||
if (oldValue === value) return;
|
||||
this._config[key] = value as never;
|
||||
logManager.debug(`Config set: ${key} = ${value}`);
|
||||
autoSave && this._saveConfig();
|
||||
}
|
||||
|
||||
public update(updates: Partial<IConfig>, autoSave: boolean = true): void {
|
||||
this._config = { ...this._config, ...updates };
|
||||
autoSave && this._saveConfig();
|
||||
}
|
||||
|
||||
public resetToDefault(): void {
|
||||
this._config = { ...this._defaultConfig };
|
||||
logManager.info('Config reset to default.');
|
||||
this._saveConfig();
|
||||
}
|
||||
|
||||
public onConfigChange(listener: ((config: IConfig) => void)): () => void {
|
||||
this._listeners.push(listener);
|
||||
|
||||
return () => this._listeners = this._listeners.filter(l => l !== listener);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const configManager = ConfigService.getInstance();
|
||||
export default configManager;
|
||||
88
electron/service/execute-script-service/index.ts
Normal file
88
electron/service/execute-script-service/index.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import { utilityProcess } from 'electron';
|
||||
import log from 'electron-log';
|
||||
|
||||
|
||||
export class executeScriptService extends EventEmitter {
|
||||
// 执行脚本
|
||||
async executeScript(
|
||||
scriptPath: string,
|
||||
options: Record<string, any>,
|
||||
): Promise<{ success: boolean; exitCode: number | null; stdoutTail: string; stderrTail: string; error?: string }> {
|
||||
const MAX_TAIL = 32 * 1024;
|
||||
|
||||
const appendTail = (current: string, chunk: string) => {
|
||||
const next = current + chunk;
|
||||
return next.length > MAX_TAIL ? next.slice(next.length - MAX_TAIL) : next;
|
||||
};
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
try {
|
||||
const roomType = options?.roomType ?? '';
|
||||
const startTime = options?.startTime ?? '';
|
||||
const endTime = options?.endTime ?? '';
|
||||
const operation = options?.operation ?? '';
|
||||
const tabIndex = options?.tabIndex ?? '';
|
||||
const channels = options?.channels ?? '';
|
||||
const startTabIndex = options?.startTabIndex ?? '';
|
||||
|
||||
const child = utilityProcess.fork(scriptPath, [], {
|
||||
env: {
|
||||
...process.env,
|
||||
ROOM_TYPE: String(roomType),
|
||||
START_DATE: String(startTime),
|
||||
END_DATE: String(endTime),
|
||||
OPERATION: String(operation),
|
||||
TAB_INDEX: String(tabIndex),
|
||||
CHANNELS: typeof channels === 'string' ? channels : JSON.stringify(channels),
|
||||
START_TAB_INDEX: String(startTabIndex),
|
||||
},
|
||||
stdio: 'pipe'
|
||||
});
|
||||
|
||||
let stdoutTail = '';
|
||||
let stderrTail = '';
|
||||
|
||||
if (child.stdout) {
|
||||
child.stdout.on('data', (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
stdoutTail = appendTail(stdoutTail, text);
|
||||
log.info(`stdout: ${text}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (child.stderr) {
|
||||
child.stderr.on('data', (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
stderrTail = appendTail(stderrTail, text);
|
||||
log.info(`stderr: ${text}`);
|
||||
});
|
||||
}
|
||||
|
||||
// utilityProcess doesn't throw 'error' directly like child_process, but we can catch spawn errors or just resolve on exit
|
||||
// Electron's utilityProcess emits 'exit' instead of 'close' for completion
|
||||
child.on('exit', (code: number) => {
|
||||
log.info(`子进程退出,退出码 ${code}`);
|
||||
resolve({
|
||||
success: code === 0,
|
||||
exitCode: code,
|
||||
stdoutTail,
|
||||
stderrTail,
|
||||
...(code === 0 ? {} : { error: `Script exited with code ${code}` }),
|
||||
});
|
||||
});
|
||||
} catch (error: any) {
|
||||
resolve({
|
||||
success: false,
|
||||
exitCode: null,
|
||||
stdoutTail: '',
|
||||
stderrTail: '',
|
||||
error: error?.message || '运行 Node 脚本时出错',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
95
electron/service/ipc/index.ts
Normal file
95
electron/service/ipc/index.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { ipcMain, BaseWindow } from 'electron'
|
||||
|
||||
type Handler = (...args: any[]) => any
|
||||
type AsyncHandler = (...args: any[]) => Promise<any>
|
||||
|
||||
export class IPCManager {
|
||||
private static instance: IPCManager
|
||||
private handlers: Map<string, Handler>
|
||||
private asyncHandlers: Map<string, AsyncHandler>
|
||||
private constructor () {
|
||||
this.handlers = new Map()
|
||||
this.asyncHandlers = new Map()
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
public static getInstance(): IPCManager {
|
||||
if (!IPCManager.instance) {
|
||||
IPCManager.instance =new IPCManager()
|
||||
}
|
||||
|
||||
return IPCManager.instance
|
||||
}
|
||||
|
||||
private initialize(): void {
|
||||
//注册同步处理器
|
||||
ipcMain.on('ipc:invoke',(event, channel,...args) => {
|
||||
try {
|
||||
const handler = this.handlers.get(channel)
|
||||
|
||||
if (handler) {
|
||||
event.returnValue = handler(...args)
|
||||
} else {
|
||||
event.returnValue = { success: false, error: `No handler for channel: ${channel}`}
|
||||
}
|
||||
} catch (error) {
|
||||
event.returnValue = { success: false, error:(error as Error).message}
|
||||
}
|
||||
})
|
||||
|
||||
// 注册异步处理器
|
||||
ipcMain.handle('ipc:invokeAsync', async (_event, channel, ...args) => {
|
||||
try {
|
||||
const handler = this.asyncHandlers.get(channel)
|
||||
|
||||
if (handler) {
|
||||
return await handler(...args)
|
||||
}
|
||||
|
||||
// throw new Error(`No async handler for channel: ${channel}`)
|
||||
} catch (error) {
|
||||
// throw error
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('get-window-id',(event: any) => {
|
||||
event.returnValue = event.sender.id
|
||||
})
|
||||
}
|
||||
|
||||
// 注册同步处理器
|
||||
public register(channel:string, handler:Handler):void {
|
||||
this.handlers.set(channel, handler)
|
||||
}
|
||||
|
||||
// 注册异步处理器
|
||||
public registerAsync(channel:string, handler:AsyncHandler):void {
|
||||
this.asyncHandlers.set(channel, handler)
|
||||
}
|
||||
|
||||
// 广播消息给所有窗口
|
||||
public broadcast(channel:string, ...args:any[]):void {
|
||||
BaseWindow.getAllWindows().forEach(window => {
|
||||
if (!window.isDestroyed()) {
|
||||
(window as any).webContents.send(channel, ...args)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 发送消息给指定窗口
|
||||
public sendToWindow(windowId: number, channel:string, ...args:any[]):void {
|
||||
const window = BaseWindow.fromId(windowId)
|
||||
|
||||
if (window && !window.isDestroyed()) {
|
||||
(window as any).webContents.send(channel, ...args)
|
||||
}
|
||||
}
|
||||
|
||||
// 清理所有处理器
|
||||
public clear():void {
|
||||
this.handlers.clear()
|
||||
this.asyncHandlers.clear()
|
||||
}
|
||||
}
|
||||
|
||||
export const ipcManager = IPCManager.getInstance()
|
||||
180
electron/service/logger/index.ts
Normal file
180
electron/service/logger/index.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { IPC_EVENTS } from '@lib/constants';
|
||||
import { promisify } from 'util';
|
||||
import { app, ipcMain } from 'electron';
|
||||
import log from 'electron-log';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
// 转换为Promise形式的fs方法
|
||||
const readdirAsync = promisify(fs.readdir);
|
||||
const statAsync = promisify(fs.stat);
|
||||
const unlinkAsync = promisify(fs.unlink);
|
||||
|
||||
class LogService {
|
||||
private static _instance: LogService;
|
||||
|
||||
// 日志保留天数,默认7天
|
||||
private LOG_RETENTION_DAYS = 7;
|
||||
|
||||
// 清理间隔,默认24小时(毫秒)
|
||||
private readonly CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
private constructor() {
|
||||
const logPath = path.join(app.getPath('userData'), 'logs');
|
||||
// c:users/{username}/AppData/Roaming/{appName}/logs
|
||||
|
||||
// 创建日志目录
|
||||
try {
|
||||
if (!fs.existsSync(logPath)) {
|
||||
fs.mkdirSync(logPath, { recursive: true });
|
||||
}
|
||||
} catch (err) {
|
||||
this.error('Failed to create log directory:', err);
|
||||
}
|
||||
|
||||
// 配置electron-log
|
||||
log.transports.file.resolvePathFn = () => {
|
||||
// 使用当前日期作为日志文件名,格式为 YYYY-MM-DD.log
|
||||
const today = new Date();
|
||||
const formattedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
return path.join(logPath, `${formattedDate}.log`);
|
||||
};
|
||||
|
||||
// 配置日志格式
|
||||
log.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] {text}';
|
||||
|
||||
// 配置日志文件大小限制,默认10MB
|
||||
log.transports.file.maxSize = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
// 配置控制台日志级别,开发环境可以设置为debug,生产环境可以设置为info
|
||||
log.transports.console.level = process.env.NODE_ENV === 'development' ? 'debug' : 'info';
|
||||
|
||||
// 配置文件日志级别
|
||||
log.transports.file.level = 'debug';
|
||||
|
||||
// 设置IPC事件
|
||||
this._setupIpcEvents();
|
||||
// 重写console方法
|
||||
this._rewriteConsole();
|
||||
|
||||
|
||||
this.info('LogService initialized successfully.');
|
||||
this._cleanupOldLogs();
|
||||
// 定时清理旧日志
|
||||
setInterval(() => this._cleanupOldLogs(), this.CLEANUP_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private _setupIpcEvents() {
|
||||
ipcMain.on(IPC_EVENTS.LOG_DEBUG, (_e, message: string, ...meta: any[]) => this.debug(message, ...meta));
|
||||
ipcMain.on(IPC_EVENTS.LOG_INFO, (_e, message: string, ...meta: any[]) => this.info(message, ...meta));
|
||||
ipcMain.on(IPC_EVENTS.LOG_WARN, (_e, message: string, ...meta: any[]) => this.warn(message, ...meta));
|
||||
ipcMain.on(IPC_EVENTS.LOG_ERROR, (_e, message: string, ...meta: any[]) => this.error(message, ...meta));
|
||||
}
|
||||
|
||||
private _rewriteConsole() {
|
||||
console.debug = log.debug;
|
||||
console.log = log.info;
|
||||
console.info = log.info;
|
||||
console.warn = log.warn;
|
||||
console.error = log.error;
|
||||
}
|
||||
|
||||
private async _cleanupOldLogs() {
|
||||
try {
|
||||
const logPath = path.join(app.getPath('userData'), 'logs');
|
||||
|
||||
if (!fs.existsSync(logPath)) return;
|
||||
|
||||
const now = new Date();
|
||||
const expirationDate = new Date(now.getTime() - this.LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000);
|
||||
|
||||
const files = await readdirAsync(logPath);
|
||||
|
||||
let deletedCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.log')) continue;
|
||||
const filePath = path.join(logPath, file);
|
||||
try {
|
||||
const stats = await statAsync(filePath);
|
||||
if (stats.isFile() && (stats.birthtime < expirationDate)) {
|
||||
await unlinkAsync(filePath);
|
||||
deletedCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
this.error(`Failed to delete old log file ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
if (deletedCount > 0) {
|
||||
this.info(`Successfully cleaned up ${deletedCount} old log files.`);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
this.error('Failed to cleanup old logs:', err);
|
||||
}
|
||||
}
|
||||
|
||||
public static getInstance(): LogService {
|
||||
if (!this._instance) {
|
||||
this._instance = new LogService();
|
||||
}
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录调试信息
|
||||
* @param {string} message - 日志消息
|
||||
* @param {any[]} meta - 附加的元数据
|
||||
*/
|
||||
public debug(message: string, ...meta: any[]): void {
|
||||
log.debug(message, ...meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录一般信息
|
||||
* @param {string} message - 日志消息
|
||||
* @param {any[]} meta - 附加的元数据
|
||||
*/
|
||||
public info(message: string, ...meta: any[]): void {
|
||||
log.info(message, ...meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录警告信息
|
||||
* @param {string} message - 日志消息
|
||||
* @param {any[]} meta - 附加的元数据
|
||||
*/
|
||||
public warn(message: string, ...meta: any[]): void {
|
||||
log.warn(message, ...meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录错误信息
|
||||
* @param {string} message - 日志消息
|
||||
* @param {any[]} meta - 附加的元数据,通常是错误对象
|
||||
*/
|
||||
public error(message: string, ...meta: any[]): void {
|
||||
log.error(message, ...meta);
|
||||
}
|
||||
|
||||
|
||||
public logApiRequest(endpoint: string, data: any = {}, method: string = 'POST'): void {
|
||||
this.info(`API Request: ${endpoint}, Method: ${method}, Request: ${JSON.stringify(data)}`);
|
||||
}
|
||||
|
||||
public logApiResponse(endpoint: string, response: any = {}, statusCode: number = 200, responseTime: number = 0): void {
|
||||
if (statusCode >= 400) {
|
||||
this.error(`API Error Response: ${endpoint}, Status: ${statusCode}, Response Time: ${responseTime}ms, Response: ${JSON.stringify(response)}`);
|
||||
} else {
|
||||
this.debug(`API Response: ${endpoint}, Status: ${statusCode}, Response Time: ${responseTime}ms, Response: ${JSON.stringify(response)}`);
|
||||
}
|
||||
}
|
||||
|
||||
public logUserOperation(operation: string, userId: string = 'unknown', details: any = {}): void {
|
||||
this.info(`User Operation: ${operation} by ${userId}, Details: ${JSON.stringify(details)}`);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const logManager = LogService.getInstance();
|
||||
export default logManager;
|
||||
123
electron/service/menu-service/index.ts
Normal file
123
electron/service/menu-service/index.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { ipcMain, Menu, type MenuItemConstructorOptions } from 'electron';
|
||||
import { CONFIG_KEYS, IPC_EVENTS } from '@lib/constants';
|
||||
import { cloneDeep } from '@lib/utils';
|
||||
import { createTranslator } from '@electron/utils'
|
||||
import logManager from '@electron/service/logger'
|
||||
import configManager from '@electron/service/config-service'
|
||||
|
||||
let t: ReturnType<typeof createTranslator> = createTranslator();
|
||||
|
||||
class MenuService {
|
||||
private static _instance: MenuService;
|
||||
private _menuTemplates: Map<string, MenuItemConstructorOptions[]> = new Map();
|
||||
private _currentMenu?: Menu = void 0;
|
||||
|
||||
private constructor() {
|
||||
this._setupIpcListener();
|
||||
this._setupLanguageChangeListener();
|
||||
logManager.info('MenuService initialized successfully.');
|
||||
}
|
||||
|
||||
private _setupIpcListener() {
|
||||
ipcMain.handle(IPC_EVENTS.SHOW_CONTEXT_MENU, (_, menuId, dynamicOptions?: string) => new Promise((resolve) => this.showMenu(menuId, () => resolve(true), dynamicOptions)))
|
||||
}
|
||||
|
||||
private _setupLanguageChangeListener() {
|
||||
configManager.onConfigChange((config)=>{
|
||||
if(!config[CONFIG_KEYS.LANGUAGE]) return;
|
||||
|
||||
t = createTranslator()
|
||||
})
|
||||
}
|
||||
|
||||
public static getInstance() {
|
||||
if (!this._instance)
|
||||
this._instance = new MenuService();
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
public register(menuId: string, template: MenuItemConstructorOptions[]) {
|
||||
this._menuTemplates.set(menuId, template);
|
||||
return menuId;
|
||||
}
|
||||
|
||||
public showMenu(menuId: string, onClose?: () => void, dynamicOptions?: string) {
|
||||
if (this._currentMenu) return;
|
||||
|
||||
const template = cloneDeep(this._menuTemplates.get(menuId));
|
||||
|
||||
if (!template) {
|
||||
logManager.warn(`Menu ${menuId} not found.`);
|
||||
onClose?.();
|
||||
return;
|
||||
}
|
||||
|
||||
let _dynamicOptions: Array<Partial<MenuItemConstructorOptions> & { id: string }> = [];
|
||||
try {
|
||||
_dynamicOptions = Array.isArray(dynamicOptions) ? dynamicOptions : JSON.parse(dynamicOptions ?? '[]');
|
||||
} catch (error) {
|
||||
logManager.error(`Failed to parse dynamicOptions for menu ${menuId}: ${error}`);
|
||||
}
|
||||
|
||||
const translationItem = (item: MenuItemConstructorOptions): MenuItemConstructorOptions => {
|
||||
if (item.submenu) {
|
||||
return {
|
||||
...item,
|
||||
label: t(item?.label) ?? void 0,
|
||||
submenu: (item.submenu as MenuItemConstructorOptions[])?.map((item: MenuItemConstructorOptions) => translationItem(item))
|
||||
}
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
label: t(item?.label) ?? void 0
|
||||
}
|
||||
}
|
||||
const localizedTemplate = template.map(item => {
|
||||
if (!Array.isArray(_dynamicOptions) || !_dynamicOptions.length) {
|
||||
return translationItem(item);
|
||||
}
|
||||
|
||||
const dynamicItem = _dynamicOptions.find(_item => _item.id === item.id);
|
||||
|
||||
if (dynamicItem) {
|
||||
const mergedItem = { ...item, ...dynamicItem };
|
||||
return translationItem(mergedItem);
|
||||
}
|
||||
|
||||
if (item.submenu) {
|
||||
return translationItem({
|
||||
...item,
|
||||
submenu: (item.submenu as MenuItemConstructorOptions[])?.map((__item: MenuItemConstructorOptions) => {
|
||||
const dynamicItem = _dynamicOptions.find(_item => _item.id === __item.id);
|
||||
return { ...__item, ...dynamicItem };
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return translationItem(item);
|
||||
})
|
||||
|
||||
const menu = Menu.buildFromTemplate(localizedTemplate);
|
||||
|
||||
this._currentMenu = menu;
|
||||
|
||||
menu.popup({
|
||||
callback: () => {
|
||||
this._currentMenu = void 0;
|
||||
onClose?.();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public destroyMenu(menuId: string) {
|
||||
this._menuTemplates.delete(menuId);
|
||||
}
|
||||
|
||||
public destroyed() {
|
||||
this._menuTemplates.clear();
|
||||
this._currentMenu = void 0;
|
||||
}
|
||||
}
|
||||
|
||||
export const menuManager = MenuService.getInstance();
|
||||
export default menuManager;
|
||||
234
electron/service/tab-manager/index.ts
Normal file
234
electron/service/tab-manager/index.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { BrowserView, BrowserWindow, ipcMain, IpcMainInvokeEvent } from 'electron'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { IPC_EVENTS } from '@lib/constants'
|
||||
import path from 'node:path'
|
||||
|
||||
declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined;
|
||||
|
||||
type TabId = string
|
||||
type TabInfo = { id: TabId; url: string; title: string; isLoading: boolean; canGoBack: boolean; canGoForward: boolean }
|
||||
|
||||
const UI_HEIGHT = 88
|
||||
|
||||
export class TabManager {
|
||||
private win: BrowserWindow
|
||||
private views: Map<TabId, BrowserView> = new Map()
|
||||
private activeId: TabId | null = null
|
||||
private skipNextNavigate: Map<TabId, boolean> = new Map()
|
||||
private enabled = false
|
||||
|
||||
constructor(win: BrowserWindow) {
|
||||
this.win = win
|
||||
this.win.on('resize', () => this.updateActiveBounds())
|
||||
this._setupIpcEvents()
|
||||
}
|
||||
|
||||
private _setupIpcEvents() {
|
||||
ipcMain.handle(IPC_EVENTS.TAB_CREATE, (_e, url?: string) => {
|
||||
const info = this.create(url)
|
||||
return info
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC_EVENTS.TAB_LIST, () => {
|
||||
return this.list()
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC_EVENTS.TAB_NAVIGATE, (_e, { tabId, url }: { tabId: TabId; url: string }) => {
|
||||
this.navigate(tabId, url)
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC_EVENTS.TAB_RELOAD, (_e, tabId: TabId) => {
|
||||
this.reload(tabId)
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC_EVENTS.TAB_BACK, (_e, tabId: TabId) => {
|
||||
this.goBack(tabId)
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC_EVENTS.TAB_FORWARD, (_e, tabId: TabId) => {
|
||||
this.goForward(tabId)
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC_EVENTS.TAB_SWITCH, (_e, tabId: TabId) => {
|
||||
this.switch(tabId)
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC_EVENTS.TAB_CLOSE, (_e, tabId: TabId) => {
|
||||
this.close(tabId)
|
||||
})
|
||||
}
|
||||
|
||||
enable() {
|
||||
this.enabled = true
|
||||
this.updateActiveBounds()
|
||||
if (this.activeId) this.attach(this.activeId)
|
||||
}
|
||||
|
||||
disable() {
|
||||
this.enabled = false
|
||||
const view = this.activeId ? this.views.get(this.activeId) : null
|
||||
if (view) this.win.removeBrowserView(view)
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.disable()
|
||||
this.views.forEach(view => {
|
||||
// @ts-ignore
|
||||
view.webContents.destroy()
|
||||
})
|
||||
this.views.clear()
|
||||
|
||||
ipcMain.removeHandler(IPC_EVENTS.TAB_CREATE)
|
||||
ipcMain.removeHandler(IPC_EVENTS.TAB_LIST)
|
||||
ipcMain.removeHandler(IPC_EVENTS.TAB_NAVIGATE)
|
||||
ipcMain.removeHandler(IPC_EVENTS.TAB_RELOAD)
|
||||
ipcMain.removeHandler(IPC_EVENTS.TAB_BACK)
|
||||
ipcMain.removeHandler(IPC_EVENTS.TAB_FORWARD)
|
||||
ipcMain.removeHandler(IPC_EVENTS.TAB_SWITCH)
|
||||
ipcMain.removeHandler(IPC_EVENTS.TAB_CLOSE)
|
||||
}
|
||||
|
||||
list(): TabInfo[] {
|
||||
return Array.from(this.views.entries()).map(([id, view]) => this.info(id, view))
|
||||
}
|
||||
|
||||
create(url?: string, active = true): TabInfo {
|
||||
const id = randomUUID()
|
||||
const view = new BrowserView({
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
preload: MAIN_WINDOW_VITE_DEV_SERVER_URL
|
||||
? path.join(process.cwd(), 'dist-electron/preload/preload.js')
|
||||
: path.join(__dirname, 'preload.js'),
|
||||
},
|
||||
})
|
||||
this.views.set(id, view)
|
||||
if (this.enabled && active) this.attach(id)
|
||||
const target = url && url.length > 0 ? url : 'about:blank'
|
||||
view.webContents.loadURL(target)
|
||||
this.bindEvents(id, view)
|
||||
const info = this.info(id, view)
|
||||
this.win.webContents.send('tab-created', info)
|
||||
return info
|
||||
}
|
||||
|
||||
switch(tabId: TabId): void {
|
||||
if (!this.views.has(tabId)) return
|
||||
if (this.enabled) this.attach(tabId)
|
||||
this.win.webContents.send('tab-switched', { tabId })
|
||||
}
|
||||
|
||||
close(tabId: TabId): void {
|
||||
const view = this.views.get(tabId)
|
||||
if (!view) return
|
||||
if (this.activeId === tabId) {
|
||||
this.win.removeBrowserView(view)
|
||||
this.activeId = null
|
||||
}
|
||||
// @ts-ignore
|
||||
view.webContents.destroy()
|
||||
this.views.delete(tabId)
|
||||
this.win.webContents.send('tab-closed', { tabId })
|
||||
const next = this.views.keys().next().value as TabId | undefined
|
||||
if (next) this.switch(next)
|
||||
}
|
||||
|
||||
navigate(tabId: TabId, url: string): void {
|
||||
const view = this.views.get(tabId)
|
||||
if (!view) return
|
||||
this.skipNextNavigate.set(tabId, true)
|
||||
view.webContents.loadURL(url)
|
||||
}
|
||||
|
||||
reload(tabId: TabId): void {
|
||||
const view = this.views.get(tabId)
|
||||
if (!view) return
|
||||
view.webContents.reload()
|
||||
}
|
||||
|
||||
goBack(tabId: TabId): void {
|
||||
const view = this.views.get(tabId)
|
||||
if (!view) return
|
||||
if (view.webContents.canGoBack()) view.webContents.goBack()
|
||||
}
|
||||
|
||||
goForward(tabId: TabId): void {
|
||||
const view = this.views.get(tabId)
|
||||
if (!view) return
|
||||
if (view.webContents.canGoForward()) view.webContents.goForward()
|
||||
}
|
||||
|
||||
private attach(tabId: TabId): void {
|
||||
if (!this.enabled) return
|
||||
const view = this.views.get(tabId)
|
||||
if (!view) return
|
||||
if (this.activeId && this.views.get(this.activeId)) {
|
||||
const prev = this.views.get(this.activeId)!
|
||||
this.win.removeBrowserView(prev)
|
||||
}
|
||||
this.activeId = tabId
|
||||
this.win.addBrowserView(view)
|
||||
this.updateActiveBounds()
|
||||
}
|
||||
|
||||
private updateActiveBounds(): void {
|
||||
if (!this.enabled || !this.activeId) return
|
||||
const view = this.views.get(this.activeId)
|
||||
if (!view) return
|
||||
const [winWidth, winHeight] = this.win.getContentSize()
|
||||
|
||||
const HEADER_HEIGHT = 88
|
||||
const PADDING = 8
|
||||
const RIGHT_PANEL_WIDTH = 392 + 80 + 8 + 8 // TaskList + SideMenu + Gap + RightPadding
|
||||
|
||||
const x = PADDING
|
||||
const y = HEADER_HEIGHT + PADDING
|
||||
const width = winWidth - RIGHT_PANEL_WIDTH - PADDING
|
||||
const height = winHeight - HEADER_HEIGHT - (PADDING * 2)
|
||||
|
||||
view.setBounds({
|
||||
x,
|
||||
y,
|
||||
width: Math.max(0, width),
|
||||
height: Math.max(0, height)
|
||||
})
|
||||
}
|
||||
|
||||
private bindEvents(id: TabId, view: BrowserView): void {
|
||||
const send = () => this.win.webContents.send('tab-updated', this.info(id, view))
|
||||
view.webContents.on('did-start-loading', send)
|
||||
view.webContents.on('did-stop-loading', send)
|
||||
view.webContents.on('did-finish-load', send)
|
||||
view.webContents.on('page-title-updated', send)
|
||||
view.webContents.on('did-navigate', send)
|
||||
view.webContents.on('did-navigate-in-page', send)
|
||||
|
||||
view.webContents.on('will-navigate', (event, url) => {
|
||||
if (this.skipNextNavigate.get(id)) {
|
||||
this.skipNextNavigate.set(id, false)
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
this.create(url)
|
||||
})
|
||||
|
||||
view.webContents.setWindowOpenHandler(({ url }) => {
|
||||
this.create(url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
}
|
||||
|
||||
private info(id: TabId, view: BrowserView): TabInfo {
|
||||
const wc = view.webContents
|
||||
return {
|
||||
id,
|
||||
url: wc.getURL(),
|
||||
title: wc.getTitle(),
|
||||
isLoading: wc.isLoading(),
|
||||
canGoBack: wc.canGoBack(),
|
||||
canGoForward: wc.canGoForward()
|
||||
}
|
||||
}
|
||||
}
|
||||
7
electron/service/task-operation/index.ts
Normal file
7
electron/service/task-operation/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { IPC_EVENTS } from '@lib/constants'
|
||||
import { spawn } from 'child_process'
|
||||
|
||||
|
||||
|
||||
export function runTaskOperationService() {}
|
||||
61
electron/service/theme-service/index.ts
Normal file
61
electron/service/theme-service/index.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { BrowserWindow, ipcMain, nativeTheme } from 'electron'
|
||||
import { configManager } from '@electron/service/config-service'
|
||||
import { logManager } from '@electron/service/logger'
|
||||
import { IPC_EVENTS, CONFIG_KEYS } from '@lib/constants'
|
||||
|
||||
class ThemeService {
|
||||
private static _instance: ThemeService;
|
||||
private _isDark: boolean = nativeTheme.shouldUseDarkColors;
|
||||
|
||||
constructor() {
|
||||
const themeMode = configManager.get(CONFIG_KEYS.THEME_MODE);
|
||||
if (themeMode) {
|
||||
nativeTheme.themeSource = themeMode;
|
||||
this._isDark = nativeTheme.shouldUseDarkColors;
|
||||
}
|
||||
this._setupIpcEvent();
|
||||
logManager.info('ThemeService initialized successfully.');
|
||||
}
|
||||
|
||||
private _setupIpcEvent() {
|
||||
ipcMain.handle(IPC_EVENTS.SET_THEME_MODE, (_e, mode: ThemeMode) => {
|
||||
nativeTheme.themeSource = mode;
|
||||
configManager.set(CONFIG_KEYS.THEME_MODE, mode);
|
||||
return nativeTheme.shouldUseDarkColors;
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_EVENTS.GET_THEME_MODE, () => {
|
||||
return nativeTheme.themeSource;
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_EVENTS.IS_DARK_THEME, () => {
|
||||
return nativeTheme.shouldUseDarkColors;
|
||||
});
|
||||
|
||||
nativeTheme.on('updated', () => {
|
||||
this._isDark = nativeTheme.shouldUseDarkColors;
|
||||
|
||||
BrowserWindow.getAllWindows().forEach(win =>
|
||||
win.webContents.send(IPC_EVENTS.THEME_MODE_UPDATED, this._isDark)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public static getInstance() {
|
||||
if (!this._instance) {
|
||||
this._instance = new ThemeService();
|
||||
}
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
public get isDark() {
|
||||
return this._isDark;
|
||||
}
|
||||
|
||||
public get themeMode() {
|
||||
return nativeTheme.themeSource;
|
||||
}
|
||||
}
|
||||
|
||||
export const themeManager = ThemeService.getInstance();
|
||||
export default themeManager;
|
||||
100
electron/service/tray-service/index.ts
Normal file
100
electron/service/tray-service/index.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Tray, Menu, ipcMain, app } from 'electron'
|
||||
import { createTranslator, createLogo } from '@electron/utils'
|
||||
import { CONFIG_KEYS, IPC_EVENTS, WINDOW_NAMES, MAIN_WIN_SIZE } from '@lib/constants'
|
||||
|
||||
import logManager from '@electron/service/logger'
|
||||
// TODO: shortcutManager
|
||||
import windowManager from '@electron/service/window-service'
|
||||
import configManager from '@electron/service/config-service'
|
||||
|
||||
let t: ReturnType<typeof createTranslator> = createTranslator();
|
||||
|
||||
class TrayService {
|
||||
private static _instance: TrayService;
|
||||
private _tray: Tray | null = null;
|
||||
private _removeLanguageListener?: () => void;
|
||||
|
||||
private _setupLanguageChangeListener() {
|
||||
this._removeLanguageListener = configManager.onConfigChange((config) => {
|
||||
if (!config[CONFIG_KEYS.LANGUAGE]) return;
|
||||
|
||||
// 切换语言后,重新创建翻译器
|
||||
t = createTranslator();
|
||||
|
||||
|
||||
if (this._tray) {
|
||||
this._updateTray();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private _updateTray() {
|
||||
if (!this._tray) {
|
||||
this._tray = new Tray(createLogo());
|
||||
}
|
||||
|
||||
const showWindow = () => {
|
||||
const mainWindow = windowManager.get(WINDOW_NAMES.MAIN);
|
||||
|
||||
if (mainWindow && !mainWindow?.isDestroyed() && mainWindow?.isVisible() && !mainWindow?.isFocused()) {
|
||||
return mainWindow.focus();
|
||||
}
|
||||
|
||||
if (mainWindow?.isMinimized()) {
|
||||
return mainWindow?.restore();
|
||||
}
|
||||
|
||||
if (mainWindow?.isVisible() && mainWindow?.isFocused()) return;
|
||||
|
||||
windowManager.create(WINDOW_NAMES.MAIN, MAIN_WIN_SIZE);
|
||||
}
|
||||
|
||||
this._tray.setToolTip(t('tray.tooltip') ?? 'Diona Application');
|
||||
|
||||
// TODO: 依赖快捷键Service
|
||||
this._tray.setContextMenu(Menu.buildFromTemplate([
|
||||
{ label: t('tray.showWindow'), accelerator: 'CmdOrCtrl+N', click: showWindow },
|
||||
{ type: 'separator' },
|
||||
{ label: t('settings.title'), click: () => ipcMain.emit(`${IPC_EVENTS.OPEN_WINDOW}:${WINDOW_NAMES.SETTING}`) },
|
||||
{ role: 'quit', label: t('tray.exit') }
|
||||
]));
|
||||
|
||||
this._tray.removeAllListeners('click');
|
||||
this._tray.on('click', showWindow);
|
||||
}
|
||||
|
||||
private constructor() {
|
||||
this._setupLanguageChangeListener();
|
||||
logManager.info('TrayService initialized successfully.');
|
||||
}
|
||||
|
||||
public static getInstance() {
|
||||
if (!this._instance) {
|
||||
this._instance = new TrayService();
|
||||
}
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
public create() {
|
||||
if (this._tray) return;
|
||||
this._updateTray();
|
||||
app.on('quit', () => {
|
||||
this.destroy();
|
||||
//TODO: 移除快捷键
|
||||
})
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
this._tray?.destroy();
|
||||
this._tray = null;
|
||||
//TODO: 移除快捷键
|
||||
if (this._removeLanguageListener) {
|
||||
this._removeLanguageListener();
|
||||
this._removeLanguageListener = void 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const trayManager = TrayService.getInstance();
|
||||
export default trayManager;
|
||||
|
||||
322
electron/service/window-service/index.ts
Normal file
322
electron/service/window-service/index.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
import type { WindowNames } from '@lib/types'
|
||||
|
||||
import { CONFIG_KEYS, IPC_EVENTS, WINDOW_NAMES } from '@lib/constants'
|
||||
import { BrowserWindow, BrowserWindowConstructorOptions, ipcMain, IpcMainInvokeEvent, WebContentsView, type IpcMainEvent } from 'electron'
|
||||
import { debounce } from '@lib/utils'
|
||||
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';
|
||||
|
||||
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 SHARED_WINDOW_OPTIONS = {
|
||||
frame: false,
|
||||
titleBarStyle: 'hidden',
|
||||
trafficLightPosition: { x: -100, y: -100 },
|
||||
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: MAIN_WINDOW_VITE_DEV_SERVER_URL
|
||||
? path.join(process.cwd(), 'dist-electron/preload/preload.js')
|
||||
: path.join(__dirname, 'preload.js'),
|
||||
},
|
||||
} as BrowserWindowConstructorOptions;
|
||||
|
||||
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() {
|
||||
const handleCloseWindow = (e: IpcMainEvent) => {
|
||||
const target = BrowserWindow.fromWebContents(e.sender);
|
||||
const winName = this.getName(target);
|
||||
|
||||
this.close(target, this._isReallyClose(winName));
|
||||
}
|
||||
|
||||
const handleMinimizeWindow = (e: IpcMainEvent) => {
|
||||
BrowserWindow.fromWebContents(e.sender)?.minimize();
|
||||
}
|
||||
|
||||
const handleMaximizeWindow = (e: IpcMainEvent) => {
|
||||
this.toggleMax(BrowserWindow.fromWebContents(e.sender));
|
||||
}
|
||||
|
||||
const handleIsWindowMaximized = (e: IpcMainInvokeEvent) => {
|
||||
return BrowserWindow.fromWebContents(e.sender)?.isMaximized() ?? false;
|
||||
}
|
||||
|
||||
ipcMain.on(IPC_EVENTS.WINDOW_CLOSE, handleCloseWindow);
|
||||
ipcMain.on(IPC_EVENTS.WINDOW_MINIMIZE, handleMinimizeWindow);
|
||||
ipcMain.on(IPC_EVENTS.WINDOW_MAXIMIZE, handleMaximizeWindow);
|
||||
ipcMain.handle(IPC_EVENTS.IS_WINDOW_MAXIMIZED, handleIsWindowMaximized);
|
||||
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 });
|
||||
|
||||
if (this.isDev) window.webContents.openDevTools()
|
||||
|
||||
!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 _setupWinLifecycle(window: BrowserWindow, name: WindowNames) {
|
||||
const updateWinStatus = debounce(() => !window?.isDestroyed()
|
||||
&& window?.webContents?.send(IPC_EVENTS.WINDOW_MAXIMIZE + 'back', window?.isMaximized()), 80);
|
||||
window.once('closed', () => {
|
||||
this._winStates[name].onClosed.forEach(callback => callback(window));
|
||||
window?.destroy();
|
||||
window?.removeListener('resize', updateWinStatus);
|
||||
this._winStates[name].instance = void 0;
|
||||
this._winStates[name].isHidden = false;
|
||||
logManager.info(`Window closed: ${name}`);
|
||||
});
|
||||
window.on('resize', updateWinStatus)
|
||||
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 loadingView: WebContentsView | void = new WebContentsView();
|
||||
let rendererIsReady = false;
|
||||
|
||||
window.contentView?.addChildView(loadingView);
|
||||
loadingView.setBounds({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
});
|
||||
|
||||
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
|
||||
loadingView.webContents.loadURL(`${MAIN_WINDOW_VITE_DEV_SERVER_URL}/loading.html`);
|
||||
} else {
|
||||
loadingView.webContents.loadFile(path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/loading.html`));
|
||||
}
|
||||
|
||||
const onRendererIsReady = (e: IpcMainEvent) => {
|
||||
if ((e.sender !== window?.webContents) || rendererIsReady) return;
|
||||
rendererIsReady = true;
|
||||
window.contentView.removeChildView(loadingView as WebContentsView);
|
||||
ipcMain.removeListener(IPC_EVENTS.RENDERER_IS_READY, onRendererIsReady);
|
||||
loadingView = void 0;
|
||||
}
|
||||
ipcMain.on(IPC_EVENTS.RENDERER_IS_READY, onRendererIsReady);
|
||||
|
||||
return (cb: () => void) => loadingView?.webContents.once('dom-ready', () => {
|
||||
loadingView?.webContents.insertCSS(`body {
|
||||
background-color: ${themeManager.isDark ? '#2C2C2C' : '#FFFFFF'} !important;
|
||||
--stop-color-start: ${themeManager.isDark ? '#A0A0A0' : '#7F7F7F'} !important;
|
||||
--stop-color-end: ${themeManager.isDark ? '#A0A0A0' : '#7F7F7F'} !important;
|
||||
}`);
|
||||
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(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/${pageName}.html`));
|
||||
}
|
||||
|
||||
private _loadWindowTemplate(window: BrowserWindow, name: WindowNames) {
|
||||
const page = name === WINDOW_NAMES.LOADING ? 'loading' : '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({
|
||||
...SHARED_WINDOW_OPTIONS,
|
||||
icon: this._logo,
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
public focus(target: BrowserWindow | void | null) {
|
||||
if (!target) return;
|
||||
const name = this.getName(target);
|
||||
if (target?.isMaximized()) {
|
||||
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;
|
||||
Reference in New Issue
Block a user