- CanvasPage: AntD Layout 双栏布局 (Sider+Content),预览状态提升解耦 DOM - CanvasSidebar (新): 项目/集数选择器 + 镜头搜索 + 列表 + 固定刷新按钮 - CategoryTabs (新): 双模式分类标签 (Assets/审核) + 数量徽标 + 媒体细分 - CanvasViewport: Ctrl/Cmd 拖拽平移 (pointerEvents:none 防误抓缩略图) - CanvasViewport: previewActiveRef 双守卫阻止预览穿透缩放/拖拽 - MediaPreview: 键盘控制 Space 暂停/播放、<- -> 快退快进 ±0.5s - electron/main.ts: local-media:// 协议 handler 转发 Range 头修复 seek - StageTabs: 无限画布入口按钮 + 全局字号上调 Range seek 调试: 之前 net.fetch 未转发 Range 头 → 浏览器 seek 时 返回 200 全文件而非 206 Partial Content → currentTime 赋值无法定位 → 播放位置回退到 0。修复: 提取 Range 头单向转发到 file:// handler。 Co-Authored-By: Claude <noreply@anthropic.com>
341 lines
12 KiB
TypeScript
341 lines
12 KiB
TypeScript
import {app, BrowserWindow, ipcMain, dialog, session, protocol, net} from 'electron';
|
||
import {createRequire} from 'node:module';
|
||
import {fileURLToPath} from 'node:url';
|
||
import path from 'node:path';
|
||
|
||
import {getPlatform, isMacOS} from './main/utils/platform';
|
||
import {getWindowIconPath} from './main/utils/logo';
|
||
import {buildWindowTitle} from '../shared/constants/app';
|
||
import {loadEnvFile} from './main/load-env';
|
||
import {initUpdater, registerUpdateIpcHandlers} from './main/updater';
|
||
import {setupAppMenu} from './main/menu';
|
||
import {initLogger, flushLogger, logger} from './main/logger';
|
||
import {registerLogIpcHandlers} from './main/ipc/log-ipc';
|
||
import {registerSafeStorageIpcHandlers} from './main/ipc/safe-storage-ipc';
|
||
import {registerStorageIpcHandlers} from './main/ipc/storage-ipc';
|
||
import {registerCanvasIpcHandlers} from './main/ipc/canvas-ipc';
|
||
import {BIDIRECTIONAL, RENDERER_TO_MAIN, MAIN_TO_RENDERER} from '../shared/constants/ipc-channels';
|
||
|
||
createRequire(import.meta.url);
|
||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||
|
||
process.env.APP_ROOT = path.join(__dirname, '..');
|
||
|
||
// 加载 .env 文件中的环境变量到 process.env
|
||
// 必须在任何读取 process.env.UPDATE_API_URL 的模块之前调用
|
||
loadEnvFile();
|
||
|
||
export const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL'];
|
||
export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron');
|
||
export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist');
|
||
|
||
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
|
||
? path.join(process.env.APP_ROOT, 'public')
|
||
: RENDERER_DIST;
|
||
|
||
// ---------- 平台 & 版本信息 ----------
|
||
const currentPlatform = getPlatform();
|
||
/** 当前版控后缀(登录后通过 IPC 更新) */
|
||
let currentEdition: string | null = null;
|
||
/** 基础标题(无版控后缀),登录后通过 IPC 追加 */
|
||
const WINDOW_TITLE = buildWindowTitle(currentPlatform);
|
||
|
||
// ============================================================
|
||
// 全局未捕获异常(主进程兜底记录)
|
||
// ============================================================
|
||
|
||
process.on('uncaughtException', (error) => {
|
||
try {
|
||
logger.error('app', 'Uncaught exception (main process)', error);
|
||
} catch {
|
||
/* logger 自身异常 — 最后防线 */
|
||
}
|
||
});
|
||
|
||
process.on('unhandledRejection', (reason) => {
|
||
try {
|
||
const error = reason instanceof Error ? reason : new Error(String(reason));
|
||
logger.error('app', 'Unhandled rejection (main process)', error);
|
||
} catch {
|
||
/* logger 自身异常 — 最后防线 */
|
||
}
|
||
});
|
||
|
||
// ============================================================
|
||
// 窗口引用
|
||
// ============================================================
|
||
|
||
let mainWindow: BrowserWindow | null = null;
|
||
let canvasWindow: BrowserWindow | null = null;
|
||
|
||
// ============================================================
|
||
// 主窗口
|
||
// ============================================================
|
||
|
||
function createMainWindow(): BrowserWindow {
|
||
const iconPath = getWindowIconPath(app.getAppPath());
|
||
|
||
mainWindow = new BrowserWindow({
|
||
title: WINDOW_TITLE,
|
||
icon: iconPath,
|
||
width: 1280,
|
||
height: 800,
|
||
minWidth: 960,
|
||
minHeight: 600,
|
||
show: false,
|
||
...(isMacOS() ? {titleBarStyle: 'hiddenInset' as const} : {}),
|
||
webPreferences: {
|
||
preload: path.join(__dirname, 'preload.mjs'),
|
||
contextIsolation: true,
|
||
nodeIntegration: false,
|
||
},
|
||
});
|
||
|
||
mainWindow.setTitle(WINDOW_TITLE);
|
||
|
||
if (VITE_DEV_SERVER_URL) {
|
||
mainWindow.webContents.openDevTools();
|
||
}
|
||
|
||
// macOS 全屏事件
|
||
if (isMacOS()) {
|
||
mainWindow.on('enter-full-screen', () =>
|
||
mainWindow?.webContents.send('window-fullscreen-changed', true),
|
||
);
|
||
mainWindow.on('leave-full-screen', () =>
|
||
mainWindow?.webContents.send('window-fullscreen-changed', false),
|
||
);
|
||
}
|
||
|
||
mainWindow.webContents.on('did-finish-load', () => {
|
||
mainWindow?.webContents.send('main-process-message', new Date().toLocaleString());
|
||
mainWindow?.webContents.send('platform-info', {
|
||
platform: currentPlatform,
|
||
edition: currentEdition,
|
||
});
|
||
});
|
||
|
||
// ready-to-show 后显示,避免白屏闪烁
|
||
mainWindow.once('ready-to-show', () => {
|
||
mainWindow?.show();
|
||
});
|
||
|
||
if (VITE_DEV_SERVER_URL) {
|
||
mainWindow.loadURL(VITE_DEV_SERVER_URL);
|
||
} else {
|
||
mainWindow.loadFile(path.join(RENDERER_DIST, 'index.html'));
|
||
}
|
||
|
||
return mainWindow;
|
||
}
|
||
|
||
// ============================================================
|
||
// 画布窗口
|
||
// ============================================================
|
||
|
||
function createCanvasWindow(): BrowserWindow {
|
||
const iconPath = getWindowIconPath(app.getAppPath());
|
||
|
||
logger.info('canvas-window', '创建画布窗口', {
|
||
isDev: !!VITE_DEV_SERVER_URL,
|
||
url: VITE_DEV_SERVER_URL
|
||
? VITE_DEV_SERVER_URL + '#/canvas'
|
||
: path.join(RENDERER_DIST, 'index.html') + '#/canvas',
|
||
});
|
||
|
||
canvasWindow = new BrowserWindow({
|
||
title: '无限画布 - HeiXiu',
|
||
icon: iconPath,
|
||
width: 1400,
|
||
height: 900,
|
||
minWidth: 800,
|
||
minHeight: 600,
|
||
show: false,
|
||
webPreferences: {
|
||
preload: path.join(__dirname, 'preload.mjs'),
|
||
contextIsolation: true,
|
||
nodeIntegration: false,
|
||
},
|
||
});
|
||
|
||
if (VITE_DEV_SERVER_URL) {
|
||
canvasWindow.loadURL(VITE_DEV_SERVER_URL + '#/canvas');
|
||
canvasWindow.webContents.openDevTools();
|
||
logger.info('canvas-window', '画布窗口 DevTools 已自动打开(开发模式)');
|
||
} else {
|
||
canvasWindow.loadFile(path.join(RENDERER_DIST, 'index.html'), {
|
||
hash: '/canvas',
|
||
});
|
||
}
|
||
|
||
// 禁用浏览器默认页面缩放(Ctrl/Cmd+滚轮 和 触控板捏合)。
|
||
// 画布使用 CSS transform 实现独立缩放,不允许 Chromium 页面级缩放干扰。
|
||
// before-input-event 在 Chromium 处理输入之前触发,比 JS event listener 更早。
|
||
canvasWindow.webContents.on('before-input-event', (event, input) => {
|
||
if (input.type === 'mouseWheel' && (input.control || input.meta)) {
|
||
event.preventDefault();
|
||
}
|
||
});
|
||
|
||
canvasWindow.once('ready-to-show', () => {
|
||
logger.info('canvas-window', '画布窗口 ready-to-show,即将显示');
|
||
canvasWindow?.show();
|
||
});
|
||
|
||
canvasWindow.on('closed', () => {
|
||
logger.info('canvas-window', '画布窗口已关闭');
|
||
canvasWindow = null;
|
||
});
|
||
|
||
return canvasWindow;
|
||
}
|
||
|
||
// ============================================================
|
||
// 自定义协议:local-media — 安全加载本地文件
|
||
// 必须在 app.whenReady 之前注册为特权 scheme
|
||
// ============================================================
|
||
|
||
protocol.registerSchemesAsPrivileged([
|
||
{
|
||
scheme: 'local-media',
|
||
privileges: {
|
||
standard: true,
|
||
secure: true,
|
||
supportFetchAPI: true,
|
||
bypassCSP: true,
|
||
stream: true,
|
||
},
|
||
},
|
||
]);
|
||
|
||
// ============================================================
|
||
// 应用生命周期
|
||
// ============================================================
|
||
|
||
app.on('window-all-closed', () => {
|
||
if (!isMacOS()) {
|
||
app.quit();
|
||
}
|
||
});
|
||
|
||
app.on('activate', () => {
|
||
if (BrowserWindow.getAllWindows().length === 0) {
|
||
createMainWindow();
|
||
}
|
||
});
|
||
|
||
app.on('before-quit', () => {
|
||
flushLogger();
|
||
});
|
||
|
||
app.whenReady().then(() => {
|
||
initLogger();
|
||
registerLogIpcHandlers();
|
||
registerSafeStorageIpcHandlers();
|
||
registerStorageIpcHandlers();
|
||
registerCanvasIpcHandlers();
|
||
|
||
// 注册 local-media:// 自定义协议(用于全屏预览加载本地文件)
|
||
// 支持两种 URL 格式:
|
||
// 本地路径:local-media:///C:/Users/... (authority 为空,路径以 / 开头)
|
||
// UNC 路径:local-media://192.168.110.250/share/... (authority 为服务器主机名)
|
||
protocol.handle('local-media', (request) => {
|
||
const urlObj = new URL(request.url);
|
||
let filePath: string;
|
||
|
||
if (urlObj.hostname) {
|
||
// UNC 路径:local-media://server/share/path → \\server\share\path
|
||
filePath = '\\\\' + urlObj.hostname + decodeURIComponent(urlObj.pathname).replace(/\//g, '\\');
|
||
} else {
|
||
// 本地路径:local-media:///C:/path/to/file → C:/path/to/file
|
||
filePath = decodeURIComponent(urlObj.pathname.slice(1));
|
||
}
|
||
|
||
const fwd = filePath.replace(/\\/g, '/');
|
||
let fileUrl: string;
|
||
if (fwd.startsWith('//')) {
|
||
fileUrl = 'file:' + fwd;
|
||
} else {
|
||
fileUrl = 'file:///' + fwd;
|
||
}
|
||
|
||
// 只转发 Range 头,确保音视频 seek 时返回 206 Partial Content。
|
||
// ⚠️ 不能转发所有 headers(Origin/Referer/Sec-Fetch-* 等),
|
||
// 否则会干扰 Chromium 内部的 file:// 协议处理,导致请求失败。
|
||
const rangeHeader = request.headers.get('Range');
|
||
if (rangeHeader) {
|
||
return net.fetch(fileUrl, { headers: { Range: rangeHeader } });
|
||
}
|
||
return net.fetch(fileUrl);
|
||
});
|
||
|
||
setupAppMenu();
|
||
app.setName(WINDOW_TITLE);
|
||
registerUpdateIpcHandlers();
|
||
const fakeUserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36';
|
||
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
|
||
const url = details.url;
|
||
if (url.includes("rh-images-1252422369.cos.ap-beijing.myqcloud.com")) {
|
||
details.requestHeaders['User-Agent'] = fakeUserAgent;
|
||
}
|
||
callback({
|
||
requestHeaders: details.requestHeaders
|
||
});
|
||
});
|
||
|
||
// 文件对话框 IPC 处理(供渲染进程选择文件夹)
|
||
ipcMain.handle(BIDIRECTIONAL.FILE_DIALOG, async (_event, options: Electron.OpenDialogOptions) => {
|
||
const parentWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : canvasWindow;
|
||
if (!parentWindow) return {canceled: true, filePaths: []};
|
||
return dialog.showOpenDialog(parentWindow, {
|
||
title: options?.title || '选择文件夹',
|
||
defaultPath: options?.defaultPath || app.getPath('home'),
|
||
properties: options?.properties || ['openDirectory'],
|
||
});
|
||
});
|
||
|
||
// 窗口标题版控后缀(渲染进程登录/登出后调用)
|
||
ipcMain.on(RENDERER_TO_MAIN.SET_WINDOW_TITLE, (_event, edition: string | null) => {
|
||
currentEdition = edition;
|
||
if (!mainWindow) return;
|
||
const title = edition
|
||
? buildWindowTitle(currentPlatform, undefined, edition as 'personal' | 'enterprise')
|
||
: buildWindowTitle(currentPlatform);
|
||
mainWindow.setTitle(title);
|
||
});
|
||
|
||
// 打开无限画布窗口(渲染进程请求 → 主进程创建新 BrowserWindow)
|
||
ipcMain.on(RENDERER_TO_MAIN.OPEN_CANVAS_WINDOW, (_event, payload?: { projectPath?: string }) => {
|
||
const existingAlive = canvasWindow && !canvasWindow.isDestroyed();
|
||
logger.info('canvas-window', '收到 OPEN_CANVAS_WINDOW IPC', {
|
||
projectPath: payload?.projectPath || '(未指定)',
|
||
existingAlive,
|
||
});
|
||
|
||
if (existingAlive) {
|
||
logger.info('canvas-window', '画布窗口已存在,聚焦现有窗口');
|
||
canvasWindow?.focus();
|
||
} else {
|
||
logger.info('canvas-window', '画布窗口不存在或已销毁,创建新窗口');
|
||
canvasWindow = createCanvasWindow();
|
||
}
|
||
// 画布页面加载完成后,发送初始化参数
|
||
if (payload?.projectPath) {
|
||
const readyHandler = () => {
|
||
logger.info('canvas-window', '画布页面 did-finish-load,发送 CANVAS_READY', {
|
||
projectPath: payload.projectPath,
|
||
});
|
||
canvasWindow?.webContents.send(MAIN_TO_RENDERER.CANVAS_READY, {
|
||
projectPath: payload.projectPath,
|
||
});
|
||
};
|
||
canvasWindow?.webContents.on('did-finish-load', readyHandler);
|
||
}
|
||
});
|
||
|
||
// 始终打开主窗口
|
||
// 登录/注册由渲染进程内的 Modal 弹层处理,不再新开窗口
|
||
const win = createMainWindow();
|
||
initUpdater(win);
|
||
});
|