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:
duanshuwen
2026-04-06 14:39:06 +08:00
parent e76b034d50
commit 6615d11dd6
311 changed files with 823682 additions and 4460 deletions

View 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;

View 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 脚本时出错',
});
}
});
}
}

View 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()

View 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;

View 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;

View 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()
}
}
}

View File

@@ -0,0 +1,7 @@
import { ipcMain } from 'electron'
import { IPC_EVENTS } from '@lib/constants'
import { spawn } from 'child_process'
export function runTaskOperationService() {}

View 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;

View 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;

View 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;