feat: 任务列表右键菜单回调 + 主进程下载 + sharp 缩略图 + IPC 精简重构
- 任务右键菜单:实现打开输出目录(inputs/outputs/.cache 结构)、
复制资源链接、重新编辑、再次生成、拉取结果、重新下载、收藏、删除记录
- 主进程下载:通过 Electron net 模块绕过 CORS,支持 CDN 媒体文件下载
- sharp 缩略图:替换 nativeImage,支持 4K/8K+ 大尺寸素材的流式处理
- IPC 精简:40+ 冗余通道 → 15 个分组通道,移除未使用的 preload 桥接方法
- 任务存储:新增收藏字段、schema 迁移、task-store 持久化
- 清理废弃模块:canvas-ipc / updater / request / pricing
refactor: IPC/Preload/Services 模块化拆分 — 单一职责 + 向后兼容
主要变更:
- shared/constants/ipc-channels.ts → ipc/ 模块 (base/storage/canvas/updater)
- electron/preload.ts → preload/ 模块 (base/safe-storage/file-storage/canvas)
- electron/main/ipc/canvas-ipc.ts → canvas/ 模块 (types/walk/sidecar/mime/thumbnail)
- electron/main/ipc/storage-ipc.ts → 提取 utils/ (path/download/thumbnail)
- electron/main/updater.ts → updater/ 模块 (provider/platform-updaters)
- src/services/request.ts → request/ 模块 (token/interceptors/methods)
- src/shared/utils/pricing.ts → pricing/ 模块 (types/helpers/format/calculate/fields)
- src/modules/home/center/views/ModelInputForm.tsx → 提取 utils + DependentFormItem
架构优化:
- 所有拆分均保持向后兼容(旧导入路径仍可用)
- TypeScript 编译通过 ✓
- 每个模块单一职责,平均 ~130 行
This commit is contained in:
@@ -13,8 +13,8 @@ import {initLogger, flushLogger, logger, setLogDirResolver} from './main/logger'
|
||||
import {registerLogIpcHandlers} from './main/ipc/log-ipc';
|
||||
import {registerSafeStorageIpcHandlers} from './main/ipc/safe-storage-ipc';
|
||||
import {registerStorageIpcHandlers, setDataDirResolver} 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';
|
||||
import {registerCanvasIpcHandlers} from './main/ipc/canvas';
|
||||
import {BIDIRECTIONAL, RENDERER_TO_MAIN, MAIN_TO_RENDERER} from '../shared/constants/ipc';
|
||||
|
||||
createRequire(import.meta.url);
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -1,705 +0,0 @@
|
||||
// ============================================================
|
||||
// canvas-ipc.ts — 无限画布 IPC 处理器(主进程)
|
||||
//
|
||||
// 职责:
|
||||
// - 目录扫描(fs.readdirSync + 递归遍历)
|
||||
// - sidecar JSON 读写
|
||||
// - 缩略图生成(sharp 可选依赖)
|
||||
// - 文件复制(导入操作)
|
||||
// ============================================================
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { BIDIRECTIONAL } from '../../../shared/constants/ipc-channels';
|
||||
import { logger } from '../logger';
|
||||
import sharp from "sharp";
|
||||
// ---------- 类型 ----------
|
||||
|
||||
type StageType = 'Storyboard' | 'Keyframe' | 'Video' | 'Audio' | 'LipSync' | 'Assets';
|
||||
type AssetTypeFilter = '全部' | '角色' | '道具' | '场景';
|
||||
|
||||
// ---------- 扫描算法 ----------
|
||||
|
||||
/** 递归遍历目录,收集所有匹配扩展名的文件 */
|
||||
function walkDirectory(
|
||||
root: string,
|
||||
extensions: string[],
|
||||
recursive: boolean,
|
||||
): Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }> {
|
||||
const results: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }> = [];
|
||||
const extSet = new Set(extensions.map((e) => e.toLowerCase()));
|
||||
|
||||
function walk(dir: string) {
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return; // 权限不足等,跳过该目录
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
// 跳过隐藏文件和文件夹
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (recursive) walk(fullPath);
|
||||
} else if (entry.isFile()) {
|
||||
const ext = path.extname(entry.name).slice(1).toLowerCase();
|
||||
if (extSet.has(ext)) {
|
||||
try {
|
||||
const stat = fs.statSync(fullPath);
|
||||
results.push({
|
||||
path: fullPath,
|
||||
fileName: entry.name,
|
||||
ext,
|
||||
fileSize: stat.size,
|
||||
modifiedAt: stat.mtime.toISOString(),
|
||||
});
|
||||
} catch {
|
||||
// 文件不可访问,跳过
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(root);
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---------- Sidecar 操作 ----------
|
||||
|
||||
/** 生成 sidecar JSON 文件路径:同目录、同名、.json 扩展名 */
|
||||
function sidecarPathForMedia(filePath: string): string {
|
||||
const dir = path.dirname(filePath);
|
||||
const baseName = path.basename(filePath, path.extname(filePath));
|
||||
return path.join(dir, `${baseName}.json`);
|
||||
}
|
||||
|
||||
/** 读取旁加载元数据,文件不存在返回 null */
|
||||
function readSidecarFile(filePath: string): object | null {
|
||||
const sidecarPath = sidecarPathForMedia(filePath);
|
||||
if (!fs.existsSync(sidecarPath)) return null;
|
||||
try {
|
||||
const content = fs.readFileSync(sidecarPath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 写入旁加载元数据(自动创建目录) */
|
||||
function writeSidecarFile(filePath: string, metadata: object): void {
|
||||
const sidecarPath = sidecarPathForMedia(filePath);
|
||||
const dir = path.dirname(sidecarPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(sidecarPath, JSON.stringify(metadata, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
// ---------- MIME 类型推断 ----------
|
||||
|
||||
function getMimeType(filePath: string): string {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const mimeMap: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.webp': 'image/webp',
|
||||
'.gif': 'image/gif',
|
||||
'.bmp': 'image/bmp',
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.mkv': 'video/x-matroska',
|
||||
'.mov': 'video/quicktime',
|
||||
'.avi': 'video/x-msvideo',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.wav': 'audio/wav',
|
||||
'.ogg': 'audio/ogg',
|
||||
'.flac': 'audio/flac',
|
||||
'.aac': 'audio/aac',
|
||||
'.m4a': 'audio/mp4',
|
||||
};
|
||||
return mimeMap[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
// ---------- 缩略图生成 ----------
|
||||
|
||||
/**
|
||||
* 尝试使用 sharp 库生成缩略图。
|
||||
*
|
||||
* 优先级:
|
||||
* 1. .cache/{filename}.webp — 预生成 WEBP 缩略图(最快)
|
||||
* 2. .cache/{filename} — 预生成 PNG 缩略图
|
||||
* 3. sharp — 实时缩放(可选依赖)
|
||||
* 4. 原始文件字节 — 浏览器端缩放回退
|
||||
*/
|
||||
async function generateThumbnailImpl(
|
||||
filePath: string,
|
||||
size: number,
|
||||
): Promise<{ data: string; mimeType: string; width: number; height: number }> {
|
||||
const dir = path.dirname(filePath);
|
||||
const baseName = path.basename(filePath);
|
||||
const cacheDir = path.join(dir, '.cache');
|
||||
|
||||
// ── 优先级 1:.cache/{filename}.webp ──
|
||||
const cachedWebp = path.join(cacheDir, `${baseName}.webp`);
|
||||
if (fs.existsSync(cachedWebp)) {
|
||||
const buffer = fs.readFileSync(cachedWebp);
|
||||
return {
|
||||
data: buffer.toString('base64'),
|
||||
mimeType: 'image/webp',
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 优先级 2:.cache/{filename}(PNG 副本) ──
|
||||
const cachedPng = path.join(cacheDir, baseName);
|
||||
if (fs.existsSync(cachedPng)) {
|
||||
const buffer = fs.readFileSync(cachedPng);
|
||||
return {
|
||||
data: buffer.toString('base64'),
|
||||
mimeType: 'image/png',
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (sharp) {
|
||||
const image = sharp(filePath);
|
||||
const metadata = await image.metadata();
|
||||
const resized = await image
|
||||
.resize(size, size, { fit: 'inside', withoutEnlargement: true })
|
||||
.jpeg({ quality: 85 })
|
||||
.toBuffer();
|
||||
return {
|
||||
data: resized.toString('base64'),
|
||||
mimeType: 'image/jpeg',
|
||||
width: metadata.width || 0,
|
||||
height: metadata.height || 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 优先级 4:回退原始文件 ──
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
const mimeType = getMimeType(filePath);
|
||||
return {
|
||||
data: buffer.toString('base64'),
|
||||
mimeType,
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- 扫描根目录计算(移自主进程,因渲染进程沙箱无 fs 权限) ----------
|
||||
|
||||
/** 阶段 → 允许的扩展名 */
|
||||
function getStageExtensions(stage: StageType): string[] {
|
||||
switch (stage) {
|
||||
case 'Storyboard':
|
||||
case 'Keyframe':
|
||||
return ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif'];
|
||||
case 'Video':
|
||||
return ['mp4', 'webm', 'mov', 'avi', 'mkv'];
|
||||
case 'Audio':
|
||||
case 'LipSync':
|
||||
return ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'];
|
||||
case 'Assets':
|
||||
return ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'mp4', 'webm'];
|
||||
}
|
||||
}
|
||||
|
||||
/** 镜头名匹配查询 */
|
||||
function shotNameMatchesQuery(shotName: string, query: string): boolean {
|
||||
if (!query) return true;
|
||||
const upperShot = shotName.toUpperCase();
|
||||
const upperQuery = query.toUpperCase();
|
||||
if (upperShot.includes(upperQuery)) return true;
|
||||
if (/^\d+$/.test(query)) {
|
||||
const digits = upperShot.replace(/\D/g, '');
|
||||
if (digits.includes(query)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 计算扫描根目录列表 */
|
||||
function buildScanRoots(params: {
|
||||
projectPath: string;
|
||||
stage: StageType;
|
||||
assetTypeFilter: AssetTypeFilter;
|
||||
shotQuery: string;
|
||||
}): string[] {
|
||||
const { projectPath, stage, assetTypeFilter, shotQuery } = params;
|
||||
|
||||
// ── 诊断:检查 projectPath 本身是否存在 ──
|
||||
const projectExists = fs.existsSync(projectPath);
|
||||
logger.info('canvas', 'buildScanRoots 诊断 — projectPath', {
|
||||
projectPath,
|
||||
exists: projectExists,
|
||||
});
|
||||
|
||||
if (!projectExists) {
|
||||
logger.warn('canvas', '⚠️ 项目路径不可访问', undefined, { projectPath });
|
||||
// 尝试列出父目录内容以便排查
|
||||
const parent = path.dirname(projectPath);
|
||||
try {
|
||||
const siblings = fs.readdirSync(parent).filter((n) => !n.startsWith('.'));
|
||||
logger.info('canvas', `父目录 "${parent}" 内容(前 20 项)`, {
|
||||
count: siblings.length,
|
||||
items: siblings.slice(0, 20),
|
||||
});
|
||||
} catch {
|
||||
logger.warn('canvas', `无法读取父目录 "${parent}"`);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// ── 诊断:列出项目根目录内容 ──
|
||||
let projectEntries: string[] = [];
|
||||
try {
|
||||
projectEntries = fs.readdirSync(projectPath).filter((n) => !n.startsWith('.'));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
logger.info('canvas', 'buildScanRoots 诊断 — 项目根目录内容(前 30 项)', {
|
||||
count: projectEntries.length,
|
||||
items: projectEntries.slice(0, 30),
|
||||
});
|
||||
|
||||
if (stage === 'Assets') {
|
||||
const assetsDir = path.join(projectPath, 'assets');
|
||||
logger.info('canvas', 'buildScanRoots — Assets 模式', {
|
||||
assetsDir,
|
||||
exists: fs.existsSync(assetsDir),
|
||||
assetTypeFilter,
|
||||
});
|
||||
switch (assetTypeFilter) {
|
||||
case '角色': return [path.join(assetsDir, 'chr')];
|
||||
case '道具': return [path.join(assetsDir, 'prp')];
|
||||
case '场景': return [path.join(assetsDir, 'set')];
|
||||
default: return [assetsDir];
|
||||
}
|
||||
}
|
||||
|
||||
const shotsDir = path.join(projectPath, 'shots');
|
||||
const shotsExists = fs.existsSync(shotsDir);
|
||||
logger.info('canvas', 'buildScanRoots 诊断 — shotsDir', {
|
||||
shotsDir,
|
||||
exists: shotsExists,
|
||||
});
|
||||
|
||||
if (!shotsExists) {
|
||||
// 列出可能存在的子目录帮助用户对比
|
||||
const dirsInRoot = projectEntries.filter((name) => {
|
||||
try {
|
||||
return fs.statSync(path.join(projectPath, name)).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
logger.warn('canvas', '⚠️ shots 目录不存在,项目根下的子目录:', undefined, {
|
||||
subdirs: dirsInRoot,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
const roots: string[] = [];
|
||||
let episodes: string[];
|
||||
try {
|
||||
episodes = fs.readdirSync(shotsDir).filter((name) => {
|
||||
const full = path.join(shotsDir, name);
|
||||
return !name.startsWith('.') && fs.statSync(full).isDirectory();
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn('canvas', '无法读取 shots 目录', err instanceof Error ? err : undefined, {
|
||||
error: (err as Error).message,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
logger.info('canvas', `buildScanRoots — 发现 ${episodes.length} 个剧集目录`, {
|
||||
episodes,
|
||||
});
|
||||
|
||||
for (const ep of episodes) {
|
||||
const epDir = path.join(shotsDir, ep);
|
||||
let shotNames: string[];
|
||||
try {
|
||||
shotNames = fs.readdirSync(epDir).filter((name) => {
|
||||
const full = path.join(epDir, name);
|
||||
return !name.startsWith('.') && fs.statSync(full).isDirectory();
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
'canvas',
|
||||
`无法读取剧集目录 "${ep}"`,
|
||||
err instanceof Error ? err : undefined,
|
||||
{ error: (err as Error).message },
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info('canvas', ` 📂 ${ep} → ${shotNames.length} 个镜头`, {
|
||||
shots: shotNames.slice(0, 20),
|
||||
});
|
||||
|
||||
for (const shot of shotNames) {
|
||||
if (shotQuery && !shotNameMatchesQuery(shot, shotQuery)) continue;
|
||||
const stageDir = path.join(epDir, shot, stage);
|
||||
const stageExists = fs.existsSync(stageDir);
|
||||
if (stageExists) {
|
||||
roots.push(stageDir);
|
||||
}
|
||||
// 仅在前几个镜头时记录阶段目录存在性
|
||||
if (roots.length <= 3 && !stageExists) {
|
||||
logger.info('canvas', ` 阶段目录不存在: ${stageDir}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('canvas', `buildScanRoots 最终结果:${roots.length} 个扫描根`, {
|
||||
roots: roots.slice(0, 10),
|
||||
stage,
|
||||
});
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
// ---------- 注册 ----------
|
||||
|
||||
export function registerCanvasIpcHandlers(): void {
|
||||
// ---- 扫描项目(主进程内部 buildScanRoots + walkDirectory) ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL.CANVAS_SCAN_PROJECT,
|
||||
async (
|
||||
_event,
|
||||
params: {
|
||||
projectPath: string;
|
||||
stage: StageType;
|
||||
assetTypeFilter: AssetTypeFilter;
|
||||
shotQuery: string;
|
||||
},
|
||||
) => {
|
||||
try {
|
||||
logger.info('canvas', '收到 CANVAS_SCAN_PROJECT', params);
|
||||
|
||||
const roots = buildScanRoots(params);
|
||||
const extensions = getStageExtensions(params.stage);
|
||||
|
||||
logger.info('canvas', `buildScanRoots 结果:${roots.length} 个扫描根`, {
|
||||
roots: roots.length <= 10 ? roots : roots.slice(0, 10).concat(`... 共 ${roots.length} 个`),
|
||||
extensions,
|
||||
stage: params.stage,
|
||||
});
|
||||
|
||||
if (roots.length === 0) {
|
||||
logger.warn('canvas', '扫描根目录为空', undefined, {
|
||||
projectPath: params.projectPath,
|
||||
stage: params.stage,
|
||||
shotQuery: params.shotQuery || '(无)',
|
||||
});
|
||||
return { success: true, data: [] };
|
||||
}
|
||||
|
||||
const allResults: Array<{
|
||||
path: string;
|
||||
fileName: string;
|
||||
ext: string;
|
||||
fileSize: number;
|
||||
modifiedAt: string;
|
||||
}> = [];
|
||||
|
||||
for (const root of roots) {
|
||||
const results = walkDirectory(root, extensions, true);
|
||||
if (results.length > 0) {
|
||||
logger.info('canvas', ` 📁 ${root} → ${results.length} 个文件`);
|
||||
}
|
||||
allResults.push(...results);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
'canvas',
|
||||
`扫描项目完成:${allResults.length} 个文件(${roots.length} 个扫描根目录,阶段:${params.stage})`,
|
||||
);
|
||||
return { success: true, data: allResults };
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
logger.warn('canvas', '项目扫描失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 目录扫描(保留低级 API:外部提供扫描根) ----
|
||||
// ---- 目录扫描 ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL.CANVAS_SCAN_DIRECTORY,
|
||||
async (
|
||||
_event,
|
||||
params: { roots: string[]; extensions: string[]; recursive: boolean },
|
||||
) => {
|
||||
try {
|
||||
const allResults: Array<{
|
||||
path: string;
|
||||
fileName: string;
|
||||
ext: string;
|
||||
fileSize: number;
|
||||
modifiedAt: string;
|
||||
}> = [];
|
||||
for (const root of params.roots) {
|
||||
if (!fs.existsSync(root)) {
|
||||
logger.warn('canvas', `扫描根目录不存在,跳过:${root}`);
|
||||
continue;
|
||||
}
|
||||
const results = walkDirectory(root, params.extensions, params.recursive);
|
||||
allResults.push(...results);
|
||||
}
|
||||
logger.info(
|
||||
'canvas',
|
||||
`扫描完成:${allResults.length} 个文件(${params.roots.length} 个根目录)`,
|
||||
);
|
||||
return { success: true, data: allResults };
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
logger.warn('canvas', '目录扫描失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 读取 sidecar ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL.CANVAS_READ_SIDECAR,
|
||||
async (_event, params: { filePath: string }) => {
|
||||
try {
|
||||
const data = readSidecarFile(params.filePath);
|
||||
return { success: true, data };
|
||||
} catch (err) {
|
||||
logger.warn('canvas', '读取 sidecar 失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 写入 sidecar ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL.CANVAS_WRITE_SIDECAR,
|
||||
async (_event, params: { filePath: string; metadata: object }) => {
|
||||
try {
|
||||
writeSidecarFile(params.filePath, params.metadata);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
logger.warn('canvas', '写入 sidecar 失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 生成缩略图 ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL.CANVAS_GENERATE_THUMBNAIL,
|
||||
async (_event, params: { filePath: string; size: number }) => {
|
||||
try {
|
||||
const result = await generateThumbnailImpl(params.filePath, params.size);
|
||||
return { success: true, data: result };
|
||||
} catch (err) {
|
||||
logger.warn('canvas', '生成缩略图失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 复制文件 ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL.CANVAS_COPY_FILE,
|
||||
async (_event, params: { src: string; destDir: string; fileName: string }) => {
|
||||
try {
|
||||
if (!fs.existsSync(params.destDir)) {
|
||||
fs.mkdirSync(params.destDir, { recursive: true });
|
||||
}
|
||||
const dest = path.join(params.destDir, params.fileName);
|
||||
fs.copyFileSync(params.src, dest);
|
||||
return { success: true, data: dest };
|
||||
} catch (err) {
|
||||
logger.warn('canvas', '复制文件失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 获取图片尺寸(使用 sharp 或 image-size) ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL.CANVAS_GET_IMAGE_SIZE,
|
||||
async (_event, params: { filePath: string }) => {
|
||||
try {
|
||||
if (sharp) {
|
||||
const metadata = await sharp(params.filePath).metadata();
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
width: metadata.width || 0,
|
||||
height: metadata.height || 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { success: false, error: 'sharp 未安装,无法获取图片尺寸' };
|
||||
} catch (err) {
|
||||
logger.warn('canvas', '获取图片尺寸失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 列出子文件夹(非递归,排除隐藏) ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL.CANVAS_LIST_SUBDIRS,
|
||||
async (_event, params: { parentPath: string }) => {
|
||||
try {
|
||||
if (!fs.existsSync(params.parentPath)) {
|
||||
logger.info('canvas', `listSubdirs: 路径不存在,返回空数组 — ${params.parentPath}`);
|
||||
return { success: true, data: [] };
|
||||
}
|
||||
const entries = fs.readdirSync(params.parentPath, { withFileTypes: true });
|
||||
const dirs = entries
|
||||
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
||||
.map((e) => e.name);
|
||||
logger.info('canvas', `listSubdirs: ${dirs.length} 个子文件夹 — ${params.parentPath}`);
|
||||
return { success: true, data: dirs };
|
||||
} catch (err) {
|
||||
logger.warn('canvas', 'listSubdirs 失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 扫描阶段文件 ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL.CANVAS_SCAN_STAGE_FILES,
|
||||
async (
|
||||
_event,
|
||||
params: {
|
||||
projectPath: string;
|
||||
episode: string;
|
||||
shot: string;
|
||||
stage: string;
|
||||
},
|
||||
) => {
|
||||
try {
|
||||
const stageDir = path.join(
|
||||
params.projectPath,
|
||||
'shots',
|
||||
params.episode,
|
||||
params.shot,
|
||||
params.stage,
|
||||
);
|
||||
logger.info('canvas', `scanStageFiles: ${stageDir}`);
|
||||
if (!fs.existsSync(stageDir)) {
|
||||
logger.info('canvas', `scanStageFiles: 阶段目录不存在 — ${stageDir}`);
|
||||
return { success: true, data: [] };
|
||||
}
|
||||
const extensions = getStageExtensions(params.stage as StageType);
|
||||
const results = walkDirectory(stageDir, extensions, true);
|
||||
logger.info('canvas', `scanStageFiles: ${results.length} 个文件 — ${stageDir}`);
|
||||
return { success: true, data: results };
|
||||
} catch (err) {
|
||||
logger.warn('canvas', 'scanStageFiles 失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 扫描资产文件(深度递归) ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL.CANVAS_SCAN_ASSET_FILES,
|
||||
async (
|
||||
_event,
|
||||
params: { projectPath: string; assetTypeFilter: string },
|
||||
) => {
|
||||
try {
|
||||
let assetsDir: string;
|
||||
switch (params.assetTypeFilter) {
|
||||
case '角色':
|
||||
assetsDir = path.join(params.projectPath, 'assets', 'chr');
|
||||
break;
|
||||
case '道具':
|
||||
assetsDir = path.join(params.projectPath, 'assets', 'prp');
|
||||
break;
|
||||
case '场景':
|
||||
assetsDir = path.join(params.projectPath, 'assets', 'set');
|
||||
break;
|
||||
default:
|
||||
assetsDir = path.join(params.projectPath, 'assets');
|
||||
}
|
||||
logger.info('canvas', `scanAssetFiles: ${assetsDir} (filter=${params.assetTypeFilter})`);
|
||||
if (!fs.existsSync(assetsDir)) {
|
||||
logger.info('canvas', `scanAssetFiles: 资产目录不存在 — ${assetsDir}`);
|
||||
return { success: true, data: [] };
|
||||
}
|
||||
// 资产深度递归:图片 + 视频
|
||||
const extensions = ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'mp4', 'webm'];
|
||||
const results = walkDirectory(assetsDir, extensions, true);
|
||||
logger.info('canvas', `scanAssetFiles: ${results.length} 个文件 — ${assetsDir}`);
|
||||
return { success: true, data: results };
|
||||
} catch (err) {
|
||||
logger.warn('canvas', 'scanAssetFiles 失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 创建目录 ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL.CANVAS_CREATE_DIRECTORY,
|
||||
async (_event, params: { parentPath: string; dirName: string }) => {
|
||||
try {
|
||||
// 安全校验:拒绝包含危险字符的名称
|
||||
const dangerous = /[/\\:*?"<>|]/;
|
||||
if (!params.dirName || dangerous.test(params.dirName)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `无效的目录名: "${params.dirName}"`,
|
||||
};
|
||||
}
|
||||
const fullPath = path.join(params.parentPath, params.dirName);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
return { success: false, error: `目录已存在: ${fullPath}` };
|
||||
}
|
||||
fs.mkdirSync(fullPath);
|
||||
logger.info('canvas', `createDirectory: ${fullPath}`);
|
||||
return { success: true, data: fullPath };
|
||||
} catch (err) {
|
||||
logger.warn('canvas', 'createDirectory 失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 启动原生文件拖拽(拖放到 Photoshop/Explorer 等外部应用) ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL.CANVAS_START_DRAG,
|
||||
async (_event, params: { filePath: string; fileName: string }) => {
|
||||
try {
|
||||
if (!fs.existsSync(params.filePath)) {
|
||||
logger.warn('canvas', `startDrag: 文件不存在 — ${params.filePath}`);
|
||||
return { success: false, error: '文件不存在' };
|
||||
}
|
||||
_event.sender.startDrag({
|
||||
file: params.filePath,
|
||||
icon: params.filePath,
|
||||
});
|
||||
logger.info('canvas', `startDrag: 已启动 — ${params.fileName}`);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
logger.warn('canvas', 'startDrag 失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
logger.info('canvas', '画布 IPC 处理器已注册(完整实现)');
|
||||
}
|
||||
352
electron/main/ipc/canvas/index.ts
Normal file
352
electron/main/ipc/canvas/index.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
// ============================================================
|
||||
// canvas/index.ts — 画布 IPC 处理器注册
|
||||
//
|
||||
// 职责:
|
||||
// - 注册所有画布相关的 IPC 处理器
|
||||
// - 组合使用各工具模块实现业务逻辑
|
||||
// ============================================================
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
import { BIDIRECTIONAL_CANVAS } from '../../../../shared/constants/ipc/canvas';
|
||||
import { logger } from '../../logger';
|
||||
import { walkDirectory } from './walk';
|
||||
import { readSidecarFile, writeSidecarFile } from './sidecar';
|
||||
import { generateCanvasThumbnail } from './thumbnail';
|
||||
import { buildScanRoots, getStageExtensions } from './scan-roots';
|
||||
import type { StageType, AssetTypeFilter } from './types';
|
||||
|
||||
/** 注册画布 IPC 处理器 */
|
||||
export function registerCanvasIpcHandlers(): void {
|
||||
// ---- 扫描项目(主进程内部 buildScanRoots + walkDirectory) ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL_CANVAS.CANVAS_SCAN_PROJECT,
|
||||
async (
|
||||
_event,
|
||||
params: {
|
||||
projectPath: string;
|
||||
stage: StageType;
|
||||
assetTypeFilter: AssetTypeFilter;
|
||||
shotQuery: string;
|
||||
},
|
||||
) => {
|
||||
try {
|
||||
logger.info('canvas', '收到 CANVAS_SCAN_PROJECT', params);
|
||||
|
||||
const roots = buildScanRoots(params);
|
||||
const extensions = getStageExtensions(params.stage);
|
||||
|
||||
logger.info('canvas', `buildScanRoots 结果:${roots.length} 个扫描根`, {
|
||||
roots: roots.length <= 10 ? roots : roots.slice(0, 10).concat(`... 共 ${roots.length} 个`),
|
||||
extensions,
|
||||
stage: params.stage,
|
||||
});
|
||||
|
||||
if (roots.length === 0) {
|
||||
logger.warn('canvas', '扫描根目录为空', undefined, {
|
||||
projectPath: params.projectPath,
|
||||
stage: params.stage,
|
||||
shotQuery: params.shotQuery || '(无)',
|
||||
});
|
||||
return { success: true, data: [] };
|
||||
}
|
||||
|
||||
const allResults: Array<{
|
||||
path: string;
|
||||
fileName: string;
|
||||
ext: string;
|
||||
fileSize: number;
|
||||
modifiedAt: string;
|
||||
}> = [];
|
||||
|
||||
for (const root of roots) {
|
||||
const results = walkDirectory(root, extensions, true);
|
||||
if (results.length > 0) {
|
||||
logger.info('canvas', ` 📁 ${root} → ${results.length} 个文件`);
|
||||
}
|
||||
allResults.push(...results);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
'canvas',
|
||||
`扫描项目完成:${allResults.length} 个文件(${roots.length} 个扫描根目录,阶段:${params.stage})`,
|
||||
);
|
||||
return { success: true, data: allResults };
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
logger.warn('canvas', '项目扫描失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 目录扫描(保留低级 API:外部提供扫描根) ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL_CANVAS.CANVAS_SCAN_DIRECTORY,
|
||||
async (
|
||||
_event,
|
||||
params: { roots: string[]; extensions: string[]; recursive: boolean },
|
||||
) => {
|
||||
try {
|
||||
const allResults: Array<{
|
||||
path: string;
|
||||
fileName: string;
|
||||
ext: string;
|
||||
fileSize: number;
|
||||
modifiedAt: string;
|
||||
}> = [];
|
||||
for (const root of params.roots) {
|
||||
if (!fs.existsSync(root)) {
|
||||
logger.warn('canvas', `扫描根目录不存在,跳过:${root}`);
|
||||
continue;
|
||||
}
|
||||
const results = walkDirectory(root, params.extensions, params.recursive);
|
||||
allResults.push(...results);
|
||||
}
|
||||
logger.info(
|
||||
'canvas',
|
||||
`扫描完成:${allResults.length} 个文件(${params.roots.length} 个根目录)`,
|
||||
);
|
||||
return { success: true, data: allResults };
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
logger.warn('canvas', '目录扫描失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 读取 sidecar ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL_CANVAS.CANVAS_READ_SIDECAR,
|
||||
async (_event, params: { filePath: string }) => {
|
||||
try {
|
||||
const data = readSidecarFile(params.filePath);
|
||||
return { success: true, data };
|
||||
} catch (err) {
|
||||
logger.warn('canvas', '读取 sidecar 失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 写入 sidecar ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL_CANVAS.CANVAS_WRITE_SIDECAR,
|
||||
async (_event, params: { filePath: string; metadata: object }) => {
|
||||
try {
|
||||
writeSidecarFile(params.filePath, params.metadata);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
logger.warn('canvas', '写入 sidecar 失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 生成缩略图 ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL_CANVAS.CANVAS_GENERATE_THUMBNAIL,
|
||||
async (_event, params: { filePath: string; size: number }) => {
|
||||
try {
|
||||
const result = await generateCanvasThumbnail(params.filePath, params.size);
|
||||
return { success: true, data: result };
|
||||
} catch (err) {
|
||||
logger.warn('canvas', '生成缩略图失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 复制文件 ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL_CANVAS.CANVAS_COPY_FILE,
|
||||
async (_event, params: { src: string; destDir: string; fileName: string }) => {
|
||||
try {
|
||||
if (!fs.existsSync(params.destDir)) {
|
||||
fs.mkdirSync(params.destDir, { recursive: true });
|
||||
}
|
||||
const dest = path.join(params.destDir, params.fileName);
|
||||
fs.copyFileSync(params.src, dest);
|
||||
return { success: true, data: dest };
|
||||
} catch (err) {
|
||||
logger.warn('canvas', '复制文件失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 获取图片尺寸(使用 sharp) ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL_CANVAS.CANVAS_GET_IMAGE_SIZE,
|
||||
async (_event, params: { filePath: string }) => {
|
||||
try {
|
||||
if (sharp) {
|
||||
const metadata = await sharp(params.filePath).metadata();
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
width: metadata.width || 0,
|
||||
height: metadata.height || 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { success: false, error: 'sharp 未安装,无法获取图片尺寸' };
|
||||
} catch (err) {
|
||||
logger.warn('canvas', '获取图片尺寸失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 列出子文件夹(非递归,排除隐藏) ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL_CANVAS.CANVAS_LIST_SUBDIRS,
|
||||
async (_event, params: { parentPath: string }) => {
|
||||
try {
|
||||
if (!fs.existsSync(params.parentPath)) {
|
||||
logger.info('canvas', `listSubdirs: 路径不存在,返回空数组 — ${params.parentPath}`);
|
||||
return { success: true, data: [] };
|
||||
}
|
||||
const entries = fs.readdirSync(params.parentPath, { withFileTypes: true });
|
||||
const dirs = entries
|
||||
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
||||
.map((e) => e.name);
|
||||
logger.info('canvas', `listSubdirs: ${dirs.length} 个子文件夹 — ${params.parentPath}`);
|
||||
return { success: true, data: dirs };
|
||||
} catch (err) {
|
||||
logger.warn('canvas', 'listSubdirs 失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 扫描阶段文件 ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL_CANVAS.CANVAS_SCAN_STAGE_FILES,
|
||||
async (
|
||||
_event,
|
||||
params: {
|
||||
projectPath: string;
|
||||
episode: string;
|
||||
shot: string;
|
||||
stage: string;
|
||||
},
|
||||
) => {
|
||||
try {
|
||||
const stageDir = path.join(
|
||||
params.projectPath,
|
||||
'shots',
|
||||
params.episode,
|
||||
params.shot,
|
||||
params.stage,
|
||||
);
|
||||
logger.info('canvas', `scanStageFiles: ${stageDir}`);
|
||||
if (!fs.existsSync(stageDir)) {
|
||||
logger.info('canvas', `scanStageFiles: 阶段目录不存在 — ${stageDir}`);
|
||||
return { success: true, data: [] };
|
||||
}
|
||||
const extensions = getStageExtensions(params.stage as StageType);
|
||||
const results = walkDirectory(stageDir, extensions, true);
|
||||
logger.info('canvas', `scanStageFiles: ${results.length} 个文件 — ${stageDir}`);
|
||||
return { success: true, data: results };
|
||||
} catch (err) {
|
||||
logger.warn('canvas', 'scanStageFiles 失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 扫描资产文件(深度递归) ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL_CANVAS.CANVAS_SCAN_ASSET_FILES,
|
||||
async (
|
||||
_event,
|
||||
params: { projectPath: string; assetTypeFilter: string },
|
||||
) => {
|
||||
try {
|
||||
let assetsDir: string;
|
||||
switch (params.assetTypeFilter) {
|
||||
case '角色':
|
||||
assetsDir = path.join(params.projectPath, 'assets', 'chr');
|
||||
break;
|
||||
case '道具':
|
||||
assetsDir = path.join(params.projectPath, 'assets', 'prp');
|
||||
break;
|
||||
case '场景':
|
||||
assetsDir = path.join(params.projectPath, 'assets', 'set');
|
||||
break;
|
||||
default:
|
||||
assetsDir = path.join(params.projectPath, 'assets');
|
||||
}
|
||||
logger.info('canvas', `scanAssetFiles: ${assetsDir} (filter=${params.assetTypeFilter})`);
|
||||
if (!fs.existsSync(assetsDir)) {
|
||||
logger.info('canvas', `scanAssetFiles: 资产目录不存在 — ${assetsDir}`);
|
||||
return { success: true, data: [] };
|
||||
}
|
||||
// 资产深度递归:图片 + 视频
|
||||
const extensions = ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'mp4', 'webm'];
|
||||
const results = walkDirectory(assetsDir, extensions, true);
|
||||
logger.info('canvas', `scanAssetFiles: ${results.length} 个文件 — ${assetsDir}`);
|
||||
return { success: true, data: results };
|
||||
} catch (err) {
|
||||
logger.warn('canvas', 'scanAssetFiles 失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 创建目录 ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL_CANVAS.CANVAS_CREATE_DIRECTORY,
|
||||
async (_event, params: { parentPath: string; dirName: string }) => {
|
||||
try {
|
||||
// 安全校验:拒绝包含危险字符的名称
|
||||
const dangerous = /[/\\:*?"<>|]/;
|
||||
if (!params.dirName || dangerous.test(params.dirName)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `无效的目录名: "${params.dirName}"`,
|
||||
};
|
||||
}
|
||||
const fullPath = path.join(params.parentPath, params.dirName);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
return { success: false, error: `目录已存在: ${fullPath}` };
|
||||
}
|
||||
fs.mkdirSync(fullPath);
|
||||
logger.info('canvas', `createDirectory: ${fullPath}`);
|
||||
return { success: true, data: fullPath };
|
||||
} catch (err) {
|
||||
logger.warn('canvas', 'createDirectory 失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 启动原生文件拖拽(拖放到 Photoshop/Explorer 等外部应用) ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL_CANVAS.CANVAS_START_DRAG,
|
||||
async (_event, params: { filePath: string; fileName: string }) => {
|
||||
try {
|
||||
if (!fs.existsSync(params.filePath)) {
|
||||
logger.warn('canvas', `startDrag: 文件不存在 — ${params.filePath}`);
|
||||
return { success: false, error: '文件不存在' };
|
||||
}
|
||||
_event.sender.startDrag({
|
||||
file: params.filePath,
|
||||
icon: params.filePath,
|
||||
});
|
||||
logger.info('canvas', `startDrag: 已启动 — ${params.fileName}`);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
logger.warn('canvas', 'startDrag 失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
logger.info('canvas', '画布 IPC 处理器已注册(完整实现)');
|
||||
}
|
||||
36
electron/main/ipc/canvas/mime.ts
Normal file
36
electron/main/ipc/canvas/mime.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// ============================================================
|
||||
// canvas/mime.ts — MIME 类型推断
|
||||
// ============================================================
|
||||
|
||||
import path from 'node:path';
|
||||
|
||||
/** MIME 类型映射表 */
|
||||
const MIME_MAP: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.webp': 'image/webp',
|
||||
'.gif': 'image/gif',
|
||||
'.bmp': 'image/bmp',
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.mkv': 'video/x-matroska',
|
||||
'.mov': 'video/quicktime',
|
||||
'.avi': 'video/x-msvideo',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.wav': 'audio/wav',
|
||||
'.ogg': 'audio/ogg',
|
||||
'.flac': 'audio/flac',
|
||||
'.aac': 'audio/aac',
|
||||
'.m4a': 'audio/mp4',
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据文件扩展名推断 MIME 类型。
|
||||
* @param filePath - 文件路径
|
||||
* @returns MIME 类型字符串,未知类型返回 'application/octet-stream'
|
||||
*/
|
||||
export function getMimeType(filePath: string): string {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
return MIME_MAP[ext] || 'application/octet-stream';
|
||||
}
|
||||
189
electron/main/ipc/canvas/scan-roots.ts
Normal file
189
electron/main/ipc/canvas/scan-roots.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
// ============================================================
|
||||
// canvas/scan-roots.ts — 扫描根目录计算
|
||||
//
|
||||
// 职责:
|
||||
// - 根据项目路径、阶段、资产类型计算扫描根目录列表
|
||||
// - 支持 shots 和 assets 两种目录结构
|
||||
// ============================================================
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { logger } from '../../logger';
|
||||
import type { StageType, AssetTypeFilter } from './types';
|
||||
|
||||
/** 阶段 → 允许的扩展名 */
|
||||
export function getStageExtensions(stage: StageType): string[] {
|
||||
switch (stage) {
|
||||
case 'Storyboard':
|
||||
case 'Keyframe':
|
||||
return ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif'];
|
||||
case 'Video':
|
||||
return ['mp4', 'webm', 'mov', 'avi', 'mkv'];
|
||||
case 'Audio':
|
||||
case 'LipSync':
|
||||
return ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'];
|
||||
case 'Assets':
|
||||
return ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'mp4', 'webm'];
|
||||
}
|
||||
}
|
||||
|
||||
/** 镜头名匹配查询 */
|
||||
export function shotNameMatchesQuery(shotName: string, query: string): boolean {
|
||||
if (!query) return true;
|
||||
const upperShot = shotName.toUpperCase();
|
||||
const upperQuery = query.toUpperCase();
|
||||
if (upperShot.includes(upperQuery)) return true;
|
||||
if (/^\d+$/.test(query)) {
|
||||
const digits = upperShot.replace(/\D/g, '');
|
||||
if (digits.includes(query)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算扫描根目录列表。
|
||||
*
|
||||
* @param params - 扫描参数
|
||||
* @returns 扫描根目录路径列表
|
||||
*/
|
||||
export function buildScanRoots(params: {
|
||||
projectPath: string;
|
||||
stage: StageType;
|
||||
assetTypeFilter: AssetTypeFilter;
|
||||
shotQuery: string;
|
||||
}): string[] {
|
||||
const { projectPath, stage, assetTypeFilter, shotQuery } = params;
|
||||
|
||||
// ── 诊断:检查 projectPath 本身是否存在 ──
|
||||
const projectExists = fs.existsSync(projectPath);
|
||||
logger.info('canvas', 'buildScanRoots 诊断 — projectPath', {
|
||||
projectPath,
|
||||
exists: projectExists,
|
||||
});
|
||||
|
||||
if (!projectExists) {
|
||||
logger.warn('canvas', '⚠️ 项目路径不可访问', undefined, { projectPath });
|
||||
// 尝试列出父目录内容以便排查
|
||||
const parent = path.dirname(projectPath);
|
||||
try {
|
||||
const siblings = fs.readdirSync(parent).filter((n) => !n.startsWith('.'));
|
||||
logger.info('canvas', `父目录 "${parent}" 内容(前 20 项)`, {
|
||||
count: siblings.length,
|
||||
items: siblings.slice(0, 20),
|
||||
});
|
||||
} catch {
|
||||
logger.warn('canvas', `无法读取父目录 "${parent}"`);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// ── 诊断:列出项目根目录内容 ──
|
||||
let projectEntries: string[] = [];
|
||||
try {
|
||||
projectEntries = fs.readdirSync(projectPath).filter((n) => !n.startsWith('.'));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
logger.info('canvas', 'buildScanRoots 诊断 — 项目根目录内容(前 30 项)', {
|
||||
count: projectEntries.length,
|
||||
items: projectEntries.slice(0, 30),
|
||||
});
|
||||
|
||||
if (stage === 'Assets') {
|
||||
const assetsDir = path.join(projectPath, 'assets');
|
||||
logger.info('canvas', 'buildScanRoots — Assets 模式', {
|
||||
assetsDir,
|
||||
exists: fs.existsSync(assetsDir),
|
||||
assetTypeFilter,
|
||||
});
|
||||
switch (assetTypeFilter) {
|
||||
case '角色': return [path.join(assetsDir, 'chr')];
|
||||
case '道具': return [path.join(assetsDir, 'prp')];
|
||||
case '场景': return [path.join(assetsDir, 'set')];
|
||||
default: return [assetsDir];
|
||||
}
|
||||
}
|
||||
|
||||
const shotsDir = path.join(projectPath, 'shots');
|
||||
const shotsExists = fs.existsSync(shotsDir);
|
||||
logger.info('canvas', 'buildScanRoots 诊断 — shotsDir', {
|
||||
shotsDir,
|
||||
exists: shotsExists,
|
||||
});
|
||||
|
||||
if (!shotsExists) {
|
||||
// 列出可能存在的子目录帮助用户对比
|
||||
const dirsInRoot = projectEntries.filter((name) => {
|
||||
try {
|
||||
return fs.statSync(path.join(projectPath, name)).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
logger.warn('canvas', '⚠️ shots 目录不存在,项目根下的子目录:', undefined, {
|
||||
subdirs: dirsInRoot,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
const roots: string[] = [];
|
||||
let episodes: string[];
|
||||
try {
|
||||
episodes = fs.readdirSync(shotsDir).filter((name) => {
|
||||
const full = path.join(shotsDir, name);
|
||||
return !name.startsWith('.') && fs.statSync(full).isDirectory();
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn('canvas', '无法读取 shots 目录', err instanceof Error ? err : undefined, {
|
||||
error: (err as Error).message,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
logger.info('canvas', `buildScanRoots — 发现 ${episodes.length} 个剧集目录`, {
|
||||
episodes,
|
||||
});
|
||||
|
||||
for (const ep of episodes) {
|
||||
const epDir = path.join(shotsDir, ep);
|
||||
let shotNames: string[];
|
||||
try {
|
||||
shotNames = fs.readdirSync(epDir).filter((name) => {
|
||||
const full = path.join(epDir, name);
|
||||
return !name.startsWith('.') && fs.statSync(full).isDirectory();
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
'canvas',
|
||||
`无法读取剧集目录 "${ep}"`,
|
||||
err instanceof Error ? err : undefined,
|
||||
{ error: (err as Error).message },
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info('canvas', ` 📂 ${ep} → ${shotNames.length} 个镜头`, {
|
||||
shots: shotNames.slice(0, 20),
|
||||
});
|
||||
|
||||
for (const shot of shotNames) {
|
||||
if (shotQuery && !shotNameMatchesQuery(shot, shotQuery)) continue;
|
||||
const stageDir = path.join(epDir, shot, stage);
|
||||
const stageExists = fs.existsSync(stageDir);
|
||||
if (stageExists) {
|
||||
roots.push(stageDir);
|
||||
}
|
||||
// 仅在前几个镜头时记录阶段目录存在性
|
||||
if (roots.length <= 3 && !stageExists) {
|
||||
logger.info('canvas', ` 阶段目录不存在: ${stageDir}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('canvas', `buildScanRoots 最终结果:${roots.length} 个扫描根`, {
|
||||
roots: roots.slice(0, 10),
|
||||
stage,
|
||||
});
|
||||
|
||||
return roots;
|
||||
}
|
||||
47
electron/main/ipc/canvas/sidecar.ts
Normal file
47
electron/main/ipc/canvas/sidecar.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// ============================================================
|
||||
// canvas/sidecar.ts — Sidecar 元数据操作
|
||||
//
|
||||
// 职责:
|
||||
// - 读取/写入旁加载 JSON 元数据文件
|
||||
// - sidecar 文件与媒体文件同目录、同名、.json 扩展名
|
||||
// ============================================================
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
/** 生成 sidecar JSON 文件路径:同目录、同名、.json 扩展名 */
|
||||
export function sidecarPathForMedia(filePath: string): string {
|
||||
const dir = path.dirname(filePath);
|
||||
const baseName = path.basename(filePath, path.extname(filePath));
|
||||
return path.join(dir, `${baseName}.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取旁加载元数据。
|
||||
* @param filePath - 媒体文件路径
|
||||
* @returns 元数据对象,文件不存在返回 null
|
||||
*/
|
||||
export function readSidecarFile(filePath: string): object | null {
|
||||
const sidecarPath = sidecarPathForMedia(filePath);
|
||||
if (!fs.existsSync(sidecarPath)) return null;
|
||||
try {
|
||||
const content = fs.readFileSync(sidecarPath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入旁加载元数据(自动创建目录)。
|
||||
* @param filePath - 媒体文件路径
|
||||
* @param metadata - 元数据对象
|
||||
*/
|
||||
export function writeSidecarFile(filePath: string, metadata: object): void {
|
||||
const sidecarPath = sidecarPathForMedia(filePath);
|
||||
const dir = path.dirname(sidecarPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(sidecarPath, JSON.stringify(metadata, null, 2), 'utf-8');
|
||||
}
|
||||
84
electron/main/ipc/canvas/thumbnail.ts
Normal file
84
electron/main/ipc/canvas/thumbnail.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
// ============================================================
|
||||
// canvas/thumbnail.ts — 画布缩略图生成
|
||||
//
|
||||
// 职责:
|
||||
// - 生成画布专用缩略图(返回 base64 数据)
|
||||
// - 支持缓存优先策略(.cache 目录)
|
||||
//
|
||||
// 优先级:
|
||||
// 1. .cache/{filename}.webp — 预生成 WEBP 缩略图(最快)
|
||||
// 2. .cache/{filename} — 预生成 PNG 缩略图
|
||||
// 3. sharp — 实时缩放(可选依赖)
|
||||
// 4. 原始文件字节 — 浏览器端缩放回退
|
||||
// ============================================================
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
import { getMimeType } from './mime';
|
||||
import type { ThumbnailResult } from './types';
|
||||
|
||||
/**
|
||||
* 生成画布缩略图。
|
||||
*
|
||||
* @param filePath - 源文件路径
|
||||
* @param size - 缩略图尺寸
|
||||
* @returns 缩略图数据(base64)
|
||||
*/
|
||||
export async function generateCanvasThumbnail(
|
||||
filePath: string,
|
||||
size: number,
|
||||
): Promise<ThumbnailResult> {
|
||||
const dir = path.dirname(filePath);
|
||||
const baseName = path.basename(filePath);
|
||||
const cacheDir = path.join(dir, '.cache');
|
||||
|
||||
// ── 优先级 1:.cache/{filename}.webp ──
|
||||
const cachedWebp = path.join(cacheDir, `${baseName}.webp`);
|
||||
if (fs.existsSync(cachedWebp)) {
|
||||
const buffer = fs.readFileSync(cachedWebp);
|
||||
return {
|
||||
data: buffer.toString('base64'),
|
||||
mimeType: 'image/webp',
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 优先级 2:.cache/{filename}(PNG 副本) ──
|
||||
const cachedPng = path.join(cacheDir, baseName);
|
||||
if (fs.existsSync(cachedPng)) {
|
||||
const buffer = fs.readFileSync(cachedPng);
|
||||
return {
|
||||
data: buffer.toString('base64'),
|
||||
mimeType: 'image/png',
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (sharp) {
|
||||
const image = sharp(filePath);
|
||||
const metadata = await image.metadata();
|
||||
const resized = await image
|
||||
.resize(size, size, { fit: 'inside', withoutEnlargement: true })
|
||||
.jpeg({ quality: 85 })
|
||||
.toBuffer();
|
||||
return {
|
||||
data: resized.toString('base64'),
|
||||
mimeType: 'image/jpeg',
|
||||
width: metadata.width || 0,
|
||||
height: metadata.height || 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 优先级 4:回退原始文件 ──
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
const mimeType = getMimeType(filePath);
|
||||
return {
|
||||
data: buffer.toString('base64'),
|
||||
mimeType,
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
}
|
||||
26
electron/main/ipc/canvas/types.ts
Normal file
26
electron/main/ipc/canvas/types.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// ============================================================
|
||||
// canvas/types.ts — 画布相关类型定义
|
||||
// ============================================================
|
||||
|
||||
/** 阶段类型 */
|
||||
export type StageType = 'Storyboard' | 'Keyframe' | 'Video' | 'Audio' | 'LipSync' | 'Assets';
|
||||
|
||||
/** 资产类型过滤器 */
|
||||
export type AssetTypeFilter = '全部' | '角色' | '道具' | '场景';
|
||||
|
||||
/** 扫描结果条目 */
|
||||
export interface ScanEntry {
|
||||
path: string;
|
||||
fileName: string;
|
||||
ext: string;
|
||||
fileSize: number;
|
||||
modifiedAt: string;
|
||||
}
|
||||
|
||||
/** 缩略图结果 */
|
||||
export interface ThumbnailResult {
|
||||
data: string;
|
||||
mimeType: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
66
electron/main/ipc/canvas/walk.ts
Normal file
66
electron/main/ipc/canvas/walk.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
// ============================================================
|
||||
// canvas/walk.ts — 目录遍历算法
|
||||
//
|
||||
// 职责:
|
||||
// - 递归遍历目录,收集所有匹配扩展名的文件
|
||||
// - 跳过隐藏文件和文件夹
|
||||
// ============================================================
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { ScanEntry } from './types';
|
||||
|
||||
/**
|
||||
* 递归遍历目录,收集所有匹配扩展名的文件。
|
||||
*
|
||||
* @param root - 根目录路径
|
||||
* @param extensions - 允许的文件扩展名列表(不含点号)
|
||||
* @param recursive - 是否递归遍历子目录
|
||||
* @returns 文件列表
|
||||
*/
|
||||
export function walkDirectory(
|
||||
root: string,
|
||||
extensions: string[],
|
||||
recursive: boolean,
|
||||
): ScanEntry[] {
|
||||
const results: ScanEntry[] = [];
|
||||
const extSet = new Set(extensions.map((e) => e.toLowerCase()));
|
||||
|
||||
function walk(dir: string) {
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return; // 权限不足等,跳过该目录
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
// 跳过隐藏文件和文件夹
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (recursive) walk(fullPath);
|
||||
} else if (entry.isFile()) {
|
||||
const ext = path.extname(entry.name).slice(1).toLowerCase();
|
||||
if (extSet.has(ext)) {
|
||||
try {
|
||||
const stat = fs.statSync(fullPath);
|
||||
results.push({
|
||||
path: fullPath,
|
||||
fileName: entry.name,
|
||||
ext,
|
||||
fileSize: stat.size,
|
||||
modifiedAt: stat.mtime.toISOString(),
|
||||
});
|
||||
} catch {
|
||||
// 文件不可访问,跳过
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(root);
|
||||
return results;
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
// ============================================================
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import { RENDERER_TO_MAIN } from '../../../shared/constants/ipc-channels';
|
||||
import { RENDERER_TO_MAIN } from '../../../shared/constants/ipc/base';
|
||||
import { writeRendererLog } from '../logger';
|
||||
import type { LogEntry } from '../../../shared/types/logging';
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
// ============================================================
|
||||
|
||||
import { ipcMain, safeStorage } from 'electron';
|
||||
import { BIDIRECTIONAL } from '../../../shared/constants/ipc-channels';
|
||||
import { BIDIRECTIONAL_STORAGE } from '../../../shared/constants/ipc/storage';
|
||||
import { logger } from '../logger';
|
||||
|
||||
/** 检查 safeStorage 是否可用,不可用时记录警告 */
|
||||
@@ -34,7 +34,7 @@ function checkAvailability(): boolean {
|
||||
*/
|
||||
export function registerSafeStorageIpcHandlers(): void {
|
||||
// ---------- 加密 ----------
|
||||
ipcMain.handle(BIDIRECTIONAL.SAFESTORAGE_ENCRYPT, (_event, plaintext: string) => {
|
||||
ipcMain.handle(BIDIRECTIONAL_STORAGE.SAFESTORAGE_ENCRYPT, (_event, plaintext: string) => {
|
||||
if (typeof plaintext !== 'string' || plaintext.length === 0) {
|
||||
logger.warn('safe-storage', '加密参数无效');
|
||||
return { success: false, error: '参数无效:需要非空字符串' };
|
||||
@@ -56,7 +56,7 @@ export function registerSafeStorageIpcHandlers(): void {
|
||||
});
|
||||
|
||||
// ---------- 解密 ----------
|
||||
ipcMain.handle(BIDIRECTIONAL.SAFESTORAGE_DECRYPT, (_event, encryptedBase64: string) => {
|
||||
ipcMain.handle(BIDIRECTIONAL_STORAGE.SAFESTORAGE_DECRYPT, (_event, encryptedBase64: string) => {
|
||||
if (typeof encryptedBase64 !== 'string' || encryptedBase64.length === 0) {
|
||||
logger.warn('safe-storage', '解密参数无效');
|
||||
return { success: false, error: '参数无效:需要非空字符串' };
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
import { ipcMain, shell, app } from 'electron';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { BIDIRECTIONAL } from '../../../shared/constants/ipc-channels';
|
||||
import { BIDIRECTIONAL_STORAGE } from '../../../shared/constants/ipc/storage';
|
||||
import { logger } from '../logger';
|
||||
import { resolveSafe, ensureDir, downloadToFile, generateThumbnail } from './utils';
|
||||
|
||||
// ---------- 内部工具 ----------
|
||||
|
||||
@@ -25,145 +26,200 @@ let dataDirResolver: (() => string) | null = null;
|
||||
|
||||
/** 注入自定义数据目录解析器(在 registerStorageIpcHandlers() 之前调用) */
|
||||
export function setDataDirResolver(resolver: () => string): void {
|
||||
dataDirResolver = resolver;
|
||||
dataDirResolver = resolver;
|
||||
}
|
||||
|
||||
/** 获取 heixiu-data 根目录 */
|
||||
function getDataDir(): string {
|
||||
return dataDirResolver ? dataDirResolver() : path.join(app.getPath('userData'), 'heixiu-data');
|
||||
}
|
||||
|
||||
/**
|
||||
* 将相对路径解析为绝对路径,并校验是否在 dataDir 内。
|
||||
* 防止路径穿越攻击。
|
||||
*/
|
||||
function resolveSafe(dataDir: string, relativePath: string): string {
|
||||
const resolved = path.resolve(dataDir, relativePath);
|
||||
if (!resolved.startsWith(dataDir + path.sep) && resolved !== dataDir) {
|
||||
throw new Error(`路径越界:${relativePath}`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** 确保目录存在(递归创建) */
|
||||
function ensureDir(dirPath: string): void {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
return dataDirResolver ? dataDirResolver() : path.join(app.getPath('userData'), 'heixiu-data');
|
||||
}
|
||||
|
||||
// ---------- 注册 IPC 处理器 ----------
|
||||
|
||||
export function registerStorageIpcHandlers(): void {
|
||||
const dataDir = getDataDir();
|
||||
ensureDir(dataDir);
|
||||
logger.info('app', `本地存储目录:${dataDir}`);
|
||||
const dataDir = getDataDir();
|
||||
ensureDir(dataDir);
|
||||
logger.info('app', `本地存储目录:${dataDir}`);
|
||||
|
||||
// ---- 获取数据目录 ----
|
||||
ipcMain.handle(BIDIRECTIONAL.GET_DATA_DIR, () => {
|
||||
return { success: true, data: dataDir };
|
||||
});
|
||||
// ---- 获取数据目录 ----
|
||||
ipcMain.handle(BIDIRECTIONAL_STORAGE.GET_DATA_DIR, () => {
|
||||
return { success: true, data: dataDir };
|
||||
});
|
||||
|
||||
// ---- 文件写入 ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL.FILE_WRITE,
|
||||
async (
|
||||
_event,
|
||||
params: { relativePath: string; data: string; encoding?: 'base64' | 'utf8' },
|
||||
) => {
|
||||
// ---- 文件写入 ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL_STORAGE.FILE_WRITE,
|
||||
async (_event, params: { relativePath: string; data: string; encoding?: 'base64' | 'utf8' }) => {
|
||||
try {
|
||||
const filePath = resolveSafe(dataDir, params.relativePath);
|
||||
ensureDir(path.dirname(filePath));
|
||||
|
||||
const buffer = Buffer.from(params.data, params.encoding || 'base64');
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
logger.info('storage', `文件写入成功:${filePath}(${buffer.length} bytes)`);
|
||||
return { success: true, data: filePath };
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
logger.warn('storage', `文件写入失败:${params.relativePath}`, err instanceof Error ? err : undefined);
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 文件读取(返回 Base64) ----
|
||||
ipcMain.handle(BIDIRECTIONAL_STORAGE.FILE_READ, async (_event, params: { relativePath: string }) => {
|
||||
try {
|
||||
const filePath = resolveSafe(dataDir, params.relativePath);
|
||||
ensureDir(path.dirname(filePath));
|
||||
|
||||
const buffer = Buffer.from(params.data, params.encoding || 'base64');
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
console.log(`[storage IPC] 文件写入成功:${filePath}(${buffer.length} bytes)`);
|
||||
return { success: true, data: filePath };
|
||||
const filePath = resolveSafe(dataDir, params.relativePath);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return { success: false, error: '文件不存在' };
|
||||
}
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
return { success: true, data: buffer.toString('base64') };
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
console.error(`[storage IPC] 文件写入失败:${params.relativePath} — ${msg}`);
|
||||
logger.warn('storage', `文件写入失败:${params.relativePath}`, err instanceof Error ? err : undefined);
|
||||
return { success: false, error: msg };
|
||||
logger.warn('storage', `文件读取失败:${params.relativePath}`, err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ---- 文件读取(返回 Base64) ----
|
||||
ipcMain.handle(BIDIRECTIONAL.FILE_READ, async (_event, params: { relativePath: string }) => {
|
||||
try {
|
||||
const filePath = resolveSafe(dataDir, params.relativePath);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return { success: false, error: '文件不存在' };
|
||||
// ---- 文件删除 ----
|
||||
ipcMain.handle(BIDIRECTIONAL_STORAGE.FILE_DELETE, async (_event, params: { relativePath: string }) => {
|
||||
try {
|
||||
const filePath = resolveSafe(dataDir, params.relativePath);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
logger.warn('storage', `文件删除失败:${params.relativePath}`, err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
return { success: true, data: buffer.toString('base64') };
|
||||
} catch (err) {
|
||||
logger.warn('storage', `文件读取失败:${params.relativePath}`, err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 文件删除 ----
|
||||
ipcMain.handle(BIDIRECTIONAL.FILE_DELETE, async (_event, params: { relativePath: string }) => {
|
||||
try {
|
||||
const filePath = resolveSafe(dataDir, params.relativePath);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
// ---- 创建目录 ----
|
||||
ipcMain.handle(BIDIRECTIONAL_STORAGE.MAKE_DIR, async (_event, params: { relativePath: string }) => {
|
||||
try {
|
||||
const dirPath = resolveSafe(dataDir, params.relativePath);
|
||||
ensureDir(dirPath);
|
||||
return { success: true, data: dirPath };
|
||||
} catch (err) {
|
||||
logger.warn('storage', `目录创建失败:${params.relativePath}`, err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
logger.warn('storage', `文件删除失败:${params.relativePath}`, err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 创建目录 ----
|
||||
ipcMain.handle(BIDIRECTIONAL.MAKE_DIR, async (_event, params: { relativePath: string }) => {
|
||||
try {
|
||||
const dirPath = resolveSafe(dataDir, params.relativePath);
|
||||
ensureDir(dirPath);
|
||||
return { success: true, data: dirPath };
|
||||
} catch (err) {
|
||||
logger.warn('storage', `目录创建失败:${params.relativePath}`, err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 在文件管理器中显示 ----
|
||||
ipcMain.handle(BIDIRECTIONAL.SHOW_ITEM_IN_FOLDER, async (_event, params: { filePath: string }) => {
|
||||
try {
|
||||
shell.showItemInFolder(params.filePath);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
logger.warn('storage', `showItemInFolder 失败:${params.filePath}`, err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 文件大小 ----
|
||||
ipcMain.handle(BIDIRECTIONAL.FILE_SIZE, async (_event, params: { relativePath: string }) => {
|
||||
try {
|
||||
const filePath = resolveSafe(dataDir, params.relativePath);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return { success: false, error: '文件不存在' };
|
||||
// ---- 在文件管理器中显示 ----
|
||||
ipcMain.handle(BIDIRECTIONAL_STORAGE.SHOW_ITEM_IN_FOLDER, async (_event, params: { filePath: string }) => {
|
||||
try {
|
||||
shell.showItemInFolder(params.filePath);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
logger.warn('storage', `showItemInFolder 失败:${params.filePath}`, err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
const stat = fs.statSync(filePath);
|
||||
return { success: true, data: stat.size };
|
||||
} catch (err) {
|
||||
logger.warn('storage', '获取文件大小失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 文件/目录是否存在 ----
|
||||
ipcMain.handle(BIDIRECTIONAL.FILE_EXISTS, async (_event, params: { relativePath: string }) => {
|
||||
try {
|
||||
const filePath = resolveSafe(dataDir, params.relativePath);
|
||||
return { success: true, data: fs.existsSync(filePath) };
|
||||
} catch {
|
||||
return { success: true, data: false };
|
||||
}
|
||||
});
|
||||
// ---- 文件大小 ----
|
||||
ipcMain.handle(BIDIRECTIONAL_STORAGE.FILE_SIZE, async (_event, params: { relativePath: string }) => {
|
||||
try {
|
||||
const filePath = resolveSafe(dataDir, params.relativePath);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return { success: false, error: '文件不存在' };
|
||||
}
|
||||
const stat = fs.statSync(filePath);
|
||||
return { success: true, data: stat.size };
|
||||
} catch (err) {
|
||||
logger.warn('storage', '获取文件大小失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
logger.info('app', '存储 IPC 处理器已注册');
|
||||
// ---- 文件/目录是否存在 ----
|
||||
ipcMain.handle(BIDIRECTIONAL_STORAGE.FILE_EXISTS, async (_event, params: { relativePath: string }) => {
|
||||
try {
|
||||
const filePath = resolveSafe(dataDir, params.relativePath);
|
||||
return { success: true, data: fs.existsSync(filePath) };
|
||||
} catch {
|
||||
return { success: true, data: false };
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 绝对路径文件写入(绕过沙箱,用于用户自定义输出目录) ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL_STORAGE.FILE_WRITE_ABSOLUTE,
|
||||
async (_event, params: { absolutePath: string; data: string; encoding?: 'base64' | 'utf8' }) => {
|
||||
try {
|
||||
ensureDir(path.dirname(params.absolutePath));
|
||||
const buffer = Buffer.from(params.data, params.encoding || 'base64');
|
||||
fs.writeFileSync(params.absolutePath, buffer);
|
||||
logger.info('storage', `绝对路径写入成功:${params.absolutePath}(${buffer.length} bytes)`);
|
||||
return { success: true, data: params.absolutePath };
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
logger.warn('storage', `绝对路径写入失败:${params.absolutePath}`, err instanceof Error ? err : undefined);
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 绝对路径目录创建(绕过沙箱) ----
|
||||
ipcMain.handle(BIDIRECTIONAL_STORAGE.MAKE_DIR_ABSOLUTE, async (_event, params: { absolutePath: string }) => {
|
||||
try {
|
||||
ensureDir(params.absolutePath);
|
||||
return { success: true, data: params.absolutePath };
|
||||
} catch (err) {
|
||||
logger.warn('storage', `绝对路径目录创建失败:${params.absolutePath}`, err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 获取系统下载目录 ----
|
||||
ipcMain.handle(BIDIRECTIONAL_STORAGE.GET_DOWNLOADS_PATH, () => {
|
||||
try {
|
||||
return { success: true, data: app.getPath('downloads') };
|
||||
} catch {
|
||||
return { success: false, error: '无法获取下载目录' };
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 主进程下载文件到绝对路径(绕过浏览器 CORS) ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL_STORAGE.DOWNLOAD_TO_PATH,
|
||||
async (
|
||||
_event,
|
||||
params: { url: string; destPath: string; headers?: Record<string, string> },
|
||||
): Promise<{ success: boolean; data?: string; error?: string }> => {
|
||||
const { url, destPath, headers } = params;
|
||||
try {
|
||||
await downloadToFile(url, destPath, headers);
|
||||
logger.info('storage', `主进程下载成功:${destPath}`);
|
||||
return { success: true, data: destPath };
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message || '下载失败';
|
||||
logger.warn('storage', `主进程下载失败:${url}`, err instanceof Error ? err : undefined);
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 生成图片缩略图(sharp 流式管线) ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL_STORAGE.GENERATE_THUMBNAIL,
|
||||
async (
|
||||
_event,
|
||||
params: { srcPath: string; destPath: string; maxWidth?: number; quality?: number },
|
||||
): Promise<{ success: boolean; data?: string; error?: string }> => {
|
||||
const { srcPath, destPath, maxWidth = 256, quality = 60 } = params;
|
||||
try {
|
||||
await generateThumbnail(srcPath, destPath, maxWidth, quality);
|
||||
logger.info('storage', `缩略图生成成功:${destPath}`);
|
||||
return { success: true, data: destPath };
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message || '缩略图生成失败';
|
||||
logger.warn('storage', `缩略图生成失败:${srcPath}`, err instanceof Error ? err : undefined);
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
logger.info('app', '存储 IPC 处理器已注册');
|
||||
}
|
||||
|
||||
97
electron/main/ipc/utils/download.ts
Normal file
97
electron/main/ipc/utils/download.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
// ============================================================
|
||||
// download.ts — 文件下载工具函数
|
||||
//
|
||||
// 职责:
|
||||
// - 通过 Electron net 模块下载文件到磁盘
|
||||
// - 支持重定向、超时、自定义请求头
|
||||
//
|
||||
// 设计:
|
||||
// - net 基于 Chromium 网络栈,自动携带浏览器标准头,不受 CORS 限制
|
||||
// - 不能用 netRequest(它 chunk.toString() 会损坏二进制数据)
|
||||
// - 不能用 Node.js https(缺少浏览器头导致 CDN 返回 400)
|
||||
// ============================================================
|
||||
|
||||
import { net } from 'electron';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { ensureDir } from './path';
|
||||
|
||||
/**
|
||||
* 通过 Electron net 模块下载文件到磁盘。
|
||||
*
|
||||
* @param url - 下载地址
|
||||
* @param destPath - 目标文件路径
|
||||
* @param headers - 可选的自定义请求头
|
||||
*/
|
||||
export function downloadToFile(
|
||||
url: string,
|
||||
destPath: string,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = net.request({ method: 'GET', url });
|
||||
|
||||
// 自定义请求头
|
||||
if (headers) {
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
req.setHeader(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// 超时 120s
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
req.abort();
|
||||
reject(new Error('下载超时(120s)'));
|
||||
}, 120_000);
|
||||
|
||||
req.on('response', (response) => {
|
||||
clearTimeout(timer);
|
||||
const status = response.statusCode;
|
||||
|
||||
// 3xx 重定向
|
||||
if (status >= 300 && status < 400) {
|
||||
const location = response.headers['location']?.[0];
|
||||
if (location && location !== url) {
|
||||
downloadToFile(location, destPath, headers).then(resolve).catch(reject);
|
||||
} else {
|
||||
reject(new Error('重定向循环或缺少 Location 头'));
|
||||
}
|
||||
response.on('data', () => {}); // 消费响应体
|
||||
return;
|
||||
}
|
||||
|
||||
if (status >= 400) {
|
||||
reject(new Error(`HTTP ${status}`));
|
||||
response.on('data', () => {});
|
||||
return;
|
||||
}
|
||||
|
||||
// 收集二进制 chunks → 写入文件
|
||||
const chunks: Buffer[] = [];
|
||||
response.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
response.on('end', () => {
|
||||
try {
|
||||
ensureDir(path.dirname(destPath));
|
||||
const buffer = Buffer.concat(chunks);
|
||||
fs.writeFileSync(destPath, buffer);
|
||||
resolve();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
response.on('error', (err) => {
|
||||
reject(new Error(`响应读取失败: ${err.message}`));
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
clearTimeout(timer);
|
||||
if (timedOut) return;
|
||||
reject(new Error(`网络请求失败: ${err.message}`));
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
7
electron/main/ipc/utils/index.ts
Normal file
7
electron/main/ipc/utils/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// ============================================================
|
||||
// utils/index.ts — 工具函数汇总导出
|
||||
// ============================================================
|
||||
|
||||
export { resolveSafe, ensureDir } from './path';
|
||||
export { downloadToFile } from './download';
|
||||
export { generateThumbnail } from './thumbnail';
|
||||
29
electron/main/ipc/utils/path.ts
Normal file
29
electron/main/ipc/utils/path.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// ============================================================
|
||||
// path.ts — 路径安全工具函数
|
||||
//
|
||||
// 职责:
|
||||
// - 路径穿越防护
|
||||
// - 目录创建
|
||||
// ============================================================
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* 将相对路径解析为绝对路径,并校验是否在 dataDir 内。
|
||||
* 防止路径穿越攻击(../ 穿越)。
|
||||
*/
|
||||
export function resolveSafe(dataDir: string, relativePath: string): string {
|
||||
const resolved = path.resolve(dataDir, relativePath);
|
||||
if (!resolved.startsWith(dataDir + path.sep) && resolved !== dataDir) {
|
||||
throw new Error(`路径越界:${relativePath}`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** 确保目录存在(递归创建) */
|
||||
export function ensureDir(dirPath: string): void {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
60
electron/main/ipc/utils/thumbnail.ts
Normal file
60
electron/main/ipc/utils/thumbnail.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// ============================================================
|
||||
// thumbnail.ts — 缩略图生成工具函数
|
||||
//
|
||||
// 职责:
|
||||
// - 使用 sharp 库生成图片缩略图
|
||||
// - 内存优化配置
|
||||
//
|
||||
// 依赖:
|
||||
// - sharp (libvips) - 可选依赖,用于高效图片处理
|
||||
// ============================================================
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
import { ensureDir } from './path';
|
||||
|
||||
// ---------- sharp/libvips 内存优化 ----------
|
||||
|
||||
// 单块内存上限 128MB(默认 512MB,降低以适配 Electron 单进程内存压力)
|
||||
sharp.cache({ memory: 128, items: 100 });
|
||||
// libvips 并发数 1(减少大图并发处理时的内存峰值)
|
||||
sharp.concurrency(1);
|
||||
|
||||
/**
|
||||
* 生成图片缩略图(sharp 流式管线)
|
||||
*
|
||||
* 特性:
|
||||
* - shrinkOnLoad:PNG/JPEG/WebP/TIFF 解码器直接降采样,8000px 原图解码即降至 ~512px
|
||||
* - sequentialRead:顺序读取减少随机 I/O 内存开销
|
||||
* - limitInputPixels:默认 268MP(16384²),覆盖 8K+ 素材
|
||||
*
|
||||
* @param srcPath - 源文件路径
|
||||
* @param destPath - 目标文件路径
|
||||
* @param maxWidth - 最大宽度(默认 256)
|
||||
* @param quality - JPEG 质量(默认 60)
|
||||
* @returns 目标文件路径
|
||||
*/
|
||||
export async function generateThumbnail(
|
||||
srcPath: string,
|
||||
destPath: string,
|
||||
maxWidth: number = 256,
|
||||
quality: number = 60,
|
||||
): Promise<string> {
|
||||
if (!fs.existsSync(srcPath)) {
|
||||
throw new Error('源文件不存在');
|
||||
}
|
||||
|
||||
ensureDir(path.dirname(destPath));
|
||||
|
||||
await sharp(srcPath, {
|
||||
animated: false, // 只取第一帧(GIF/APNG 等多帧格式)
|
||||
sequentialRead: true,
|
||||
limitInputPixels: 268402689,
|
||||
})
|
||||
.resize({ width: maxWidth, withoutEnlargement: true, fit: 'inside' })
|
||||
.jpeg({ quality, mozjpeg: true }) // mozjpeg 压缩率更优
|
||||
.toFile(destPath);
|
||||
|
||||
return destPath;
|
||||
}
|
||||
@@ -212,19 +212,19 @@ export async function request<T = unknown>(
|
||||
export const netRequest = {
|
||||
request,
|
||||
|
||||
get<T>(url: string, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
||||
return request<T>(url, { ...config, method: 'GET' });
|
||||
async get<T>(url: string, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
||||
return await request<T>(url, { ...config, method: 'GET' });
|
||||
},
|
||||
|
||||
post<T>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
||||
async post<T>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
||||
return request<T>(url, { ...config, method: 'POST', data });
|
||||
},
|
||||
|
||||
put<T>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
||||
|
||||
async put<T>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
||||
return request<T>(url, { ...config, method: 'PUT', data });
|
||||
},
|
||||
|
||||
del<T>(url: string, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
||||
|
||||
async del<T>(url: string, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
||||
return request<T>(url, { ...config, method: 'DELETE' });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,486 +0,0 @@
|
||||
// 自动更新 — HeiXiuProvider + electron-updater 增量下载
|
||||
// 流程图:调用自有 API → 获取版本信息 → electron-updater 差分下载 → SHA512 校验 → 安装
|
||||
|
||||
import {app, BrowserWindow, ipcMain} from 'electron';
|
||||
import {createHash} from 'node:crypto';
|
||||
import {URL} from 'node:url';
|
||||
|
||||
import {
|
||||
NsisUpdater,
|
||||
MacUpdater,
|
||||
AppImageUpdater,
|
||||
Provider,
|
||||
} from 'electron-updater';
|
||||
import type {ProviderRuntimeOptions} from 'electron-updater/out/providers/Provider';
|
||||
import type {UpdateInfo, UpdateFileInfo} from 'builder-util-runtime';
|
||||
import type {ResolvedUpdateFileInfo} from 'electron-updater/out/types';
|
||||
|
||||
import type {UpdateCheckResult} from '../../shared/types';
|
||||
import {APP_VERSION} from '../../shared/constants/version';
|
||||
import {logger} from './logger';
|
||||
import {netRequest} from './net-request';
|
||||
|
||||
// ---------- 构建时注入的渠道信息 ----------
|
||||
|
||||
/** 构建渠道(由 build.mjs 通过 VITE_BUILD_CHANNEL 注入,Vite 编译时内联) */
|
||||
const BUILD_CHANNEL: string =
|
||||
(typeof import.meta !== 'undefined' && (import.meta as Record<string, unknown>).env
|
||||
? ((import.meta as Record<string, unknown>).env as Record<string, string>).VITE_BUILD_CHANNEL
|
||||
: undefined)
|
||||
|| process.env.VITE_BUILD_CHANNEL
|
||||
|| 'stable';
|
||||
|
||||
/** 构建版本类型(由 build.mjs 通过 VITE_BUILD_EDITION 注入) */
|
||||
const BUILD_EDITION: string =
|
||||
(typeof import.meta !== 'undefined' && (import.meta as Record<string, unknown>).env
|
||||
? ((import.meta as Record<string, unknown>).env as Record<string, string>).VITE_BUILD_EDITION
|
||||
: undefined)
|
||||
|| process.env.VITE_BUILD_EDITION
|
||||
|| 'personal';
|
||||
|
||||
// ---------- 设备指纹(稳定的机器标识,用于灰度分组)----------
|
||||
|
||||
let _deviceFingerprint: string | null = null;
|
||||
|
||||
/** 获取设备指纹(基于机器特定信息的 SHA-256 前 16 位) */
|
||||
function getDeviceFingerprint(): string {
|
||||
if (_deviceFingerprint) return _deviceFingerprint;
|
||||
|
||||
try {
|
||||
const parts = [
|
||||
process.platform,
|
||||
process.arch,
|
||||
app.getPath('home'),
|
||||
];
|
||||
_deviceFingerprint = createHash('sha256')
|
||||
.update(parts.join('|'))
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
} catch {
|
||||
_deviceFingerprint = 'unknown-device';
|
||||
}
|
||||
|
||||
return _deviceFingerprint;
|
||||
}
|
||||
|
||||
// ---------- IPC 通道(对渲染进程暴露,与旧版兼容)----------
|
||||
|
||||
export const UPDATE_CHANNELS = {
|
||||
UPDATE_AVAILABLE: 'update-available',
|
||||
UPDATE_PROGRESS: 'update-progress',
|
||||
UPDATE_DOWNLOADED: 'update-downloaded',
|
||||
UPDATE_ERROR: 'update-error',
|
||||
UPDATE_NOT_AVAILABLE: 'update-not-available',
|
||||
INSTALL_UPDATE: 'install-update',
|
||||
CHECK_FOR_UPDATE: 'check-for-update',
|
||||
DOWNLOAD_UPDATE: 'download-update',
|
||||
} as const;
|
||||
|
||||
// ---------- 配置 ----------
|
||||
|
||||
function getApiBaseUrl(): string {
|
||||
const url = (process.env.UPDATE_API_URL || 'https://www.heixiu.com').trim();
|
||||
console.log(`[updater] UPDATE_API_URL=${process.env.UPDATE_API_URL} → 使用: ${url}`);
|
||||
return url;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 自定义 Provider
|
||||
// ============================================================
|
||||
|
||||
class HeiXiuProvider extends Provider<UpdateInfo> {
|
||||
private apiBaseUrl: string;
|
||||
/** 短时缓存:避免 checkForUpdates 预检 + electron-updater 内部调用导致两次 API 请求 */
|
||||
private _cachedResult: { data: UpdateInfo; ts: number } | null = null;
|
||||
|
||||
constructor(runtimeOptions: ProviderRuntimeOptions) {
|
||||
super(runtimeOptions);
|
||||
this.apiBaseUrl = getApiBaseUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用自有版本检查 API,返回 electron-updater 格式的 UpdateInfo。
|
||||
* blockMapSize > 0 时 electron-updater 自动启用差分下载。
|
||||
* 无更新时返回当前版本号(APP_VERSION)。
|
||||
*/
|
||||
async getLatestVersion(): Promise<UpdateInfo> {
|
||||
// 短时缓存(500ms):预检和 electron-updater 内部调用共享同一结果
|
||||
if (this._cachedResult && Date.now() - this._cachedResult.ts < 500) {
|
||||
return this._cachedResult.data;
|
||||
}
|
||||
const data = await this._doGetLatestVersion();
|
||||
this._cachedResult = { data, ts: Date.now() };
|
||||
return data;
|
||||
}
|
||||
|
||||
private async _doGetLatestVersion(): Promise<UpdateInfo> {
|
||||
const {data} = await netRequest.get<{ code: number; data?: UpdateCheckResult } & UpdateCheckResult>(
|
||||
'/api/v1/update/check',
|
||||
{
|
||||
baseURL: this.apiBaseUrl,
|
||||
params: {
|
||||
platform: process.platform,
|
||||
version: APP_VERSION,
|
||||
channel: BUILD_CHANNEL,
|
||||
edition: BUILD_EDITION,
|
||||
deviceFingerprint: getDeviceFingerprint(),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// 兼容两种 API 响应格式
|
||||
const result: UpdateCheckResult =
|
||||
(data as { data?: UpdateCheckResult }).data ?? (data as UpdateCheckResult);
|
||||
|
||||
// 无更新 → 返回当前版本号
|
||||
if (!result.hasUpdate) {
|
||||
return {
|
||||
version: APP_VERSION,
|
||||
files: [],
|
||||
path: '',
|
||||
sha512: '',
|
||||
releaseDate: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const file: UpdateFileInfo = {
|
||||
url: result.downloadUrl,
|
||||
sha512: result.sha512,
|
||||
size: result.fileSize,
|
||||
};
|
||||
|
||||
// 后端 API 返回 blockMapSize 时启用增量更新
|
||||
if (result.blockMapSize) {
|
||||
file.blockMapSize = result.blockMapSize;
|
||||
}
|
||||
|
||||
logger.info('updater', 'Provider 获取到新版本', {
|
||||
latestVersion: result.latestVersion,
|
||||
hasBlockmap: file.blockMapSize != null,
|
||||
});
|
||||
|
||||
return {
|
||||
version: result.latestVersion,
|
||||
files: [file],
|
||||
path: result.downloadUrl,
|
||||
sha512: result.sha512,
|
||||
releaseDate: result.releaseDate,
|
||||
releaseNotes: result.changelog,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 UpdateInfo 中的相对路径解析为完整的下载 URL。
|
||||
* electron-updater 在下载前调用此方法。
|
||||
*/
|
||||
resolveFiles(updateInfo: UpdateInfo): Array<ResolvedUpdateFileInfo> {
|
||||
return updateInfo.files.map((f) => {
|
||||
// 使用 downloadUrl 作为 base URL 来解析文件路径
|
||||
const baseUrl = new URL(updateInfo.path);
|
||||
const fileUrl = new URL(f.url, baseUrl.origin);
|
||||
return {
|
||||
url: fileUrl,
|
||||
info: f,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 平台特定 Updater(注入自定义 Provider)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 覆盖 getUpdateInfoAndProvider() 以注入 HeiXiuProvider。
|
||||
* 其他行为(NSIS 安装、签名校验等)完全由父类处理。
|
||||
*/
|
||||
class HeiXiuNsisUpdater extends NsisUpdater {
|
||||
private heiXiuProvider: HeiXiuProvider;
|
||||
|
||||
constructor(heiXiuProvider: HeiXiuProvider) {
|
||||
// 传 undefined 跳过内置 Provider 创建,由我们自己注入
|
||||
super(undefined);
|
||||
this.heiXiuProvider = heiXiuProvider;
|
||||
this.autoDownload = false; // 由我们手动控制流程,保持 IPC 兼容
|
||||
this.autoInstallOnAppQuit = false;
|
||||
this.forceDevUpdateConfig = true; // 绕过 electron-updater 的 dev 模式检查
|
||||
}
|
||||
|
||||
/** @internal — 注入自定义 Provider */
|
||||
protected override async getUpdateInfoAndProvider(): Promise<{
|
||||
info: UpdateInfo;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
provider: Provider<any>;
|
||||
}> {
|
||||
const info = await this.heiXiuProvider.getLatestVersion();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return {info, provider: this.heiXiuProvider as Provider<any>};
|
||||
}
|
||||
}
|
||||
|
||||
class HeiXiuMacUpdater extends MacUpdater {
|
||||
private heiXiuProvider: HeiXiuProvider;
|
||||
|
||||
constructor(heiXiuProvider: HeiXiuProvider) {
|
||||
super(undefined);
|
||||
this.heiXiuProvider = heiXiuProvider;
|
||||
this.autoDownload = false;
|
||||
this.autoInstallOnAppQuit = false;
|
||||
}
|
||||
|
||||
protected override async getUpdateInfoAndProvider(): Promise<{
|
||||
info: UpdateInfo;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
provider: Provider<any>;
|
||||
}> {
|
||||
const info = await this.heiXiuProvider.getLatestVersion();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return {info, provider: this.heiXiuProvider as Provider<any>};
|
||||
}
|
||||
}
|
||||
|
||||
class HeiXiuAppImageUpdater extends AppImageUpdater {
|
||||
private heiXiuProvider: HeiXiuProvider;
|
||||
|
||||
constructor(heiXiuProvider: HeiXiuProvider) {
|
||||
super(null);
|
||||
this.heiXiuProvider = heiXiuProvider;
|
||||
this.autoDownload = false;
|
||||
this.autoInstallOnAppQuit = false;
|
||||
}
|
||||
|
||||
protected override async getUpdateInfoAndProvider(): Promise<{
|
||||
info: UpdateInfo;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
provider: Provider<any>;
|
||||
}> {
|
||||
const info = await this.heiXiuProvider.getLatestVersion();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return {info, provider: this.heiXiuProvider as Provider<any>};
|
||||
}
|
||||
}
|
||||
|
||||
/** 工厂:按平台创建对应的 Updater 实例 */
|
||||
function createPlatformUpdater(provider: HeiXiuProvider) {
|
||||
switch (process.platform) {
|
||||
case 'win32':
|
||||
return new HeiXiuNsisUpdater(provider);
|
||||
case 'darwin':
|
||||
return new HeiXiuMacUpdater(provider);
|
||||
default:
|
||||
return new HeiXiuAppImageUpdater(provider);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 状态 ----------
|
||||
|
||||
let updateWindow: BrowserWindow | null = null;
|
||||
let provider: HeiXiuProvider | null = null;
|
||||
let platformUpdater: NsisUpdater | MacUpdater | AppImageUpdater | null = null;
|
||||
|
||||
// ============================================================
|
||||
// 初始化
|
||||
// ============================================================
|
||||
|
||||
export function initUpdater(mainWindow: BrowserWindow): void {
|
||||
updateWindow = mainWindow;
|
||||
|
||||
console.log('[updater] initUpdater 被调用');
|
||||
console.log(`[updater] API URL: ${getApiBaseUrl()}`);
|
||||
console.log(`[updater] 渠道: ${BUILD_CHANNEL}`);
|
||||
console.log(`[updater] 版本类型: ${BUILD_EDITION}`);
|
||||
console.log(`[updater] 当前版本: ${APP_VERSION}`);
|
||||
console.log(`[updater] 设备指纹: ${getDeviceFingerprint()}`);
|
||||
|
||||
// if (import.meta.env.DEV) {
|
||||
// logger.info('updater', '开发模式,跳过自动检查更新', { apiUrl: getApiBaseUrl() });
|
||||
// return;
|
||||
// }
|
||||
|
||||
// 创建自定义 Provider
|
||||
provider = new HeiXiuProvider({
|
||||
isUseMultipleRangeRequest: true,
|
||||
platform: process.platform as ProviderRuntimeOptions['platform'],
|
||||
executor: null as unknown as ProviderRuntimeOptions['executor'],
|
||||
});
|
||||
|
||||
// 创建平台 Updater
|
||||
platformUpdater = createPlatformUpdater(provider);
|
||||
|
||||
// 将 electron-updater 事件桥接到现有 IPC 通道
|
||||
platformUpdater.on('checking-for-update', () => {
|
||||
logger.debug('updater', '正在检查更新');
|
||||
});
|
||||
|
||||
platformUpdater.on('update-available', (info: UpdateInfo) => {
|
||||
// releaseNotes 可能是 string | ReleaseNoteInfo[] | null,统一转为 string
|
||||
let notes = '';
|
||||
if (typeof info.releaseNotes === 'string') {
|
||||
notes = info.releaseNotes;
|
||||
} else if (Array.isArray(info.releaseNotes)) {
|
||||
notes = info.releaseNotes.map((n) => n.note).filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_AVAILABLE, {
|
||||
version: info.version,
|
||||
releaseDate: info.releaseDate,
|
||||
releaseNotes: notes,
|
||||
forceUpdate: false,
|
||||
});
|
||||
logger.info('updater', '发现新版本', {
|
||||
version: info.version,
|
||||
releaseNotes: notes ? `${notes.slice(0, 50)}...` : '(空)',
|
||||
});
|
||||
});
|
||||
|
||||
platformUpdater.on('update-not-available', (info: UpdateInfo) => {
|
||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_NOT_AVAILABLE);
|
||||
logger.info('updater', '当前已是最新版本', {currentVersion: info.version});
|
||||
});
|
||||
|
||||
platformUpdater.on('download-progress', (progress: {
|
||||
percent: number;
|
||||
bytesPerSecond: number;
|
||||
transferred: number;
|
||||
total: number
|
||||
}) => {
|
||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_PROGRESS, {
|
||||
percent: Math.round(progress.percent),
|
||||
bytesPerSecond: progress.bytesPerSecond,
|
||||
transferred: progress.transferred,
|
||||
total: progress.total,
|
||||
});
|
||||
});
|
||||
|
||||
platformUpdater.on('update-downloaded', () => {
|
||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_DOWNLOADED);
|
||||
logger.info('updater', '更新已下载完成');
|
||||
});
|
||||
|
||||
platformUpdater.on('error', (error: Error) => {
|
||||
logger.error('updater', '更新流程出错', error);
|
||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
|
||||
message: error.message || '更新失败',
|
||||
});
|
||||
});
|
||||
|
||||
// 启动 5 秒后静默检查
|
||||
setTimeout(() => {
|
||||
checkForUpdates();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 操作方法
|
||||
// ============================================================
|
||||
|
||||
let updateChecked = false;
|
||||
|
||||
/** 静默检查更新(自动下载) */
|
||||
export async function checkForUpdates(): Promise<void> {
|
||||
if (updateChecked) {
|
||||
logger.debug('updater', '已检查过更新,跳过');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!platformUpdater || !provider) return;
|
||||
|
||||
try {
|
||||
updateChecked = true;
|
||||
|
||||
// ① 先自行检查有无更新(不经过 electron-updater)
|
||||
const info = await provider.getLatestVersion();
|
||||
if (info.version === APP_VERSION) {
|
||||
// 无更新 → 直接通知 UI,不触发 electron-updater 的任何事件
|
||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_NOT_AVAILABLE);
|
||||
logger.debug('updater', '当前已是最新版本', {version: APP_VERSION});
|
||||
return;
|
||||
}
|
||||
|
||||
// ② 有新版本 → 交给 electron-updater 触发 'update-available' 事件(不自动下载)
|
||||
await platformUpdater.checkForUpdates();
|
||||
} catch (error) {
|
||||
updateChecked = false;
|
||||
logger.error('updater', '更新流程失败', error as Error, {apiUrl: getApiBaseUrl()});
|
||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
|
||||
message: (error as Error).message || '更新检查失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 手动检查更新(用户点击触发) */
|
||||
export async function checkForUpdatesManual(): Promise<void> {
|
||||
if (!platformUpdater || !provider) return;
|
||||
|
||||
try {
|
||||
updateChecked = false;
|
||||
|
||||
// ① 先自行检查有无更新
|
||||
const info = await provider.getLatestVersion();
|
||||
if (info.version === APP_VERSION) {
|
||||
// 无更新 → 直接通知 UI
|
||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_NOT_AVAILABLE);
|
||||
logger.debug('updater', '手动检查:当前已是最新版本', {version: APP_VERSION});
|
||||
return;
|
||||
}
|
||||
|
||||
// ② 有新版本 → 交给 electron-updater 触发 'update-available' 事件(不自动下载)
|
||||
await platformUpdater.checkForUpdates();
|
||||
} catch (error) {
|
||||
logger.error('updater', '手动更新失败', error as Error);
|
||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
|
||||
message: (error as Error).message || '检查更新失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 用户确认后开始下载更新 */
|
||||
export async function downloadUpdate(): Promise<void> {
|
||||
if (!platformUpdater) {
|
||||
logger.warn('updater', 'downloadUpdate: Updater 未初始化');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await platformUpdater.downloadUpdate();
|
||||
} catch (error) {
|
||||
logger.error('updater', '下载更新失败', error as Error);
|
||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
|
||||
message: (error as Error).message || '下载更新失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 安装已下载的更新并退出应用 */
|
||||
export function installUpdateNow(): void {
|
||||
if (!platformUpdater) {
|
||||
logger.warn('updater', 'Updater 未初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('updater', '开始安装更新并退出应用');
|
||||
setImmediate(() => {
|
||||
platformUpdater!.quitAndInstall(false, true);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// IPC 注册
|
||||
// ============================================================
|
||||
|
||||
export function registerUpdateIpcHandlers(): void {
|
||||
ipcMain.handle(UPDATE_CHANNELS.CHECK_FOR_UPDATE, async () => {
|
||||
await checkForUpdatesManual();
|
||||
});
|
||||
|
||||
ipcMain.handle(UPDATE_CHANNELS.DOWNLOAD_UPDATE, async () => {
|
||||
await downloadUpdate();
|
||||
});
|
||||
|
||||
ipcMain.on(UPDATE_CHANNELS.INSTALL_UPDATE, () => {
|
||||
installUpdateNow();
|
||||
});
|
||||
}
|
||||
219
electron/main/updater/index.ts
Normal file
219
electron/main/updater/index.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
// ============================================================
|
||||
// updater/index.ts — 自动更新主入口
|
||||
//
|
||||
// 流程图:调用自有 API → 获取版本信息 → electron-updater 差分下载 → SHA512 校验 → 安装
|
||||
// ============================================================
|
||||
|
||||
import { BrowserWindow, ipcMain } from 'electron';
|
||||
import type { NsisUpdater, MacUpdater, AppImageUpdater } from 'electron-updater';
|
||||
import type { UpdateInfo } from 'builder-util-runtime';
|
||||
|
||||
import { APP_VERSION } from '../../../shared/constants/version';
|
||||
import { MAIN_TO_RENDERER_UPDATER, RENDERER_TO_MAIN_UPDATER } from '../../../shared/constants/ipc/updater';
|
||||
import { logger } from '../logger';
|
||||
import { HeiXiuProvider, getApiBaseUrl, getDeviceFingerprint, BUILD_CHANNEL, BUILD_EDITION } from './provider';
|
||||
import { createPlatformUpdater } from './platform-updaters';
|
||||
|
||||
// ---------- 状态 ----------
|
||||
|
||||
let updateWindow: BrowserWindow | null = null;
|
||||
let provider: HeiXiuProvider | null = null;
|
||||
let platformUpdater: NsisUpdater | MacUpdater | AppImageUpdater | null = null;
|
||||
|
||||
// ============================================================
|
||||
// 初始化
|
||||
// ============================================================
|
||||
|
||||
export function initUpdater(mainWindow: BrowserWindow): void {
|
||||
updateWindow = mainWindow;
|
||||
|
||||
console.log('[updater] initUpdater 被调用');
|
||||
console.log(`[updater] API URL: ${getApiBaseUrl()}`);
|
||||
console.log(`[updater] 渠道: ${BUILD_CHANNEL}`);
|
||||
console.log(`[updater] 版本类型: ${BUILD_EDITION}`);
|
||||
console.log(`[updater] 当前版本: ${APP_VERSION}`);
|
||||
console.log(`[updater] 设备指纹: ${getDeviceFingerprint()}`);
|
||||
|
||||
// 创建自定义 Provider
|
||||
provider = new HeiXiuProvider({
|
||||
isUseMultipleRangeRequest: true,
|
||||
platform: process.platform as 'win32' | 'darwin' | 'linux',
|
||||
executor: null as never,
|
||||
});
|
||||
|
||||
// 创建平台 Updater
|
||||
platformUpdater = createPlatformUpdater(provider);
|
||||
|
||||
// 将 electron-updater 事件桥接到现有 IPC 通道
|
||||
platformUpdater.on('checking-for-update', () => {
|
||||
logger.debug('updater', '正在检查更新');
|
||||
});
|
||||
|
||||
platformUpdater.on('update-available', (info: UpdateInfo) => {
|
||||
// releaseNotes 可能是 string | ReleaseNoteInfo[] | null,统一转为 string
|
||||
let notes = '';
|
||||
if (typeof info.releaseNotes === 'string') {
|
||||
notes = info.releaseNotes;
|
||||
} else if (Array.isArray(info.releaseNotes)) {
|
||||
notes = info.releaseNotes.map((n) => n.note).filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_AVAILABLE, {
|
||||
version: info.version,
|
||||
releaseDate: info.releaseDate,
|
||||
releaseNotes: notes,
|
||||
forceUpdate: false,
|
||||
});
|
||||
logger.info('updater', '发现新版本', {
|
||||
version: info.version,
|
||||
releaseNotes: notes ? `${notes.slice(0, 50)}...` : '(空)',
|
||||
});
|
||||
});
|
||||
|
||||
platformUpdater.on('update-not-available', (info: UpdateInfo) => {
|
||||
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_NOT_AVAILABLE);
|
||||
logger.info('updater', '当前已是最新版本', { currentVersion: info.version });
|
||||
});
|
||||
|
||||
platformUpdater.on('download-progress', (progress: {
|
||||
percent: number;
|
||||
bytesPerSecond: number;
|
||||
transferred: number;
|
||||
total: number;
|
||||
}) => {
|
||||
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_PROGRESS, {
|
||||
percent: Math.round(progress.percent),
|
||||
bytesPerSecond: progress.bytesPerSecond,
|
||||
transferred: progress.transferred,
|
||||
total: progress.total,
|
||||
});
|
||||
});
|
||||
|
||||
platformUpdater.on('update-downloaded', () => {
|
||||
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_DOWNLOADED);
|
||||
logger.info('updater', '更新已下载完成');
|
||||
});
|
||||
|
||||
platformUpdater.on('error', (error: Error) => {
|
||||
logger.error('updater', '更新流程出错', error);
|
||||
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_ERROR, {
|
||||
message: error.message || '更新失败',
|
||||
});
|
||||
});
|
||||
|
||||
// 启动 5 秒后静默检查
|
||||
setTimeout(() => {
|
||||
checkForUpdates();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 操作方法
|
||||
// ============================================================
|
||||
|
||||
let updateChecked = false;
|
||||
|
||||
/** 静默检查更新(自动下载) */
|
||||
export async function checkForUpdates(): Promise<void> {
|
||||
if (updateChecked) {
|
||||
logger.debug('updater', '已检查过更新,跳过');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!platformUpdater || !provider) return;
|
||||
|
||||
try {
|
||||
updateChecked = true;
|
||||
|
||||
// ① 先自行检查有无更新(不经过 electron-updater)
|
||||
const info = await provider.getLatestVersion();
|
||||
if (info.version === APP_VERSION) {
|
||||
// 无更新 → 直接通知 UI,不触发 electron-updater 的任何事件
|
||||
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_NOT_AVAILABLE);
|
||||
logger.debug('updater', '当前已是最新版本', { version: APP_VERSION });
|
||||
return;
|
||||
}
|
||||
|
||||
// ② 有新版本 → 交给 electron-updater 触发 'update-available' 事件(不自动下载)
|
||||
await platformUpdater.checkForUpdates();
|
||||
} catch (error) {
|
||||
updateChecked = false;
|
||||
logger.error('updater', '更新流程失败', error as Error, { apiUrl: getApiBaseUrl() });
|
||||
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_ERROR, {
|
||||
message: (error as Error).message || '更新检查失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 手动检查更新(用户点击触发) */
|
||||
export async function checkForUpdatesManual(): Promise<void> {
|
||||
if (!platformUpdater || !provider) return;
|
||||
|
||||
try {
|
||||
updateChecked = false;
|
||||
|
||||
// ① 先自行检查有无更新
|
||||
const info = await provider.getLatestVersion();
|
||||
if (info.version === APP_VERSION) {
|
||||
// 无更新 → 直接通知 UI
|
||||
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_NOT_AVAILABLE);
|
||||
logger.debug('updater', '手动检查:当前已是最新版本', { version: APP_VERSION });
|
||||
return;
|
||||
}
|
||||
|
||||
// ② 有新版本 → 交给 electron-updater 触发 'update-available' 事件(不自动下载)
|
||||
await platformUpdater.checkForUpdates();
|
||||
} catch (error) {
|
||||
logger.error('updater', '手动更新失败', error as Error);
|
||||
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_ERROR, {
|
||||
message: (error as Error).message || '检查更新失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 用户确认后开始下载更新 */
|
||||
export async function downloadUpdate(): Promise<void> {
|
||||
if (!platformUpdater) {
|
||||
logger.warn('updater', 'downloadUpdate: Updater 未初始化');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await platformUpdater.downloadUpdate();
|
||||
} catch (error) {
|
||||
logger.error('updater', '下载更新失败', error as Error);
|
||||
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_ERROR, {
|
||||
message: (error as Error).message || '下载更新失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 安装已下载的更新并退出应用 */
|
||||
export function installUpdateNow(): void {
|
||||
if (!platformUpdater) {
|
||||
logger.warn('updater', 'Updater 未初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('updater', '开始安装更新并退出应用');
|
||||
setImmediate(() => {
|
||||
platformUpdater!.quitAndInstall(false, true);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// IPC 注册
|
||||
// ============================================================
|
||||
|
||||
export function registerUpdateIpcHandlers(): void {
|
||||
ipcMain.handle(RENDERER_TO_MAIN_UPDATER.CHECK_FOR_UPDATE, async () => {
|
||||
await checkForUpdatesManual();
|
||||
});
|
||||
|
||||
ipcMain.handle(RENDERER_TO_MAIN_UPDATER.DOWNLOAD_UPDATE, async () => {
|
||||
await downloadUpdate();
|
||||
});
|
||||
|
||||
ipcMain.on(RENDERER_TO_MAIN_UPDATER.INSTALL_UPDATE, () => {
|
||||
installUpdateNow();
|
||||
});
|
||||
}
|
||||
102
electron/main/updater/platform-updaters.ts
Normal file
102
electron/main/updater/platform-updaters.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// ============================================================
|
||||
// platform-updaters.ts — 平台特定 Updater
|
||||
//
|
||||
// 覆盖 getUpdateInfoAndProvider() 以注入 HeiXiuProvider。
|
||||
// 其他行为(NSIS 安装、签名校验等)完全由父类处理。
|
||||
// ============================================================
|
||||
|
||||
import {
|
||||
NsisUpdater,
|
||||
MacUpdater,
|
||||
AppImageUpdater,
|
||||
Provider,
|
||||
} from 'electron-updater';
|
||||
import type { UpdateInfo } from 'builder-util-runtime';
|
||||
|
||||
import type { HeiXiuProvider } from './provider';
|
||||
|
||||
// ---------- Windows NSIS ----------
|
||||
|
||||
export class HeiXiuNsisUpdater extends NsisUpdater {
|
||||
private heiXiuProvider: HeiXiuProvider;
|
||||
|
||||
constructor(heiXiuProvider: HeiXiuProvider) {
|
||||
// 传 undefined 跳过内置 Provider 创建,由我们自己注入
|
||||
super(undefined);
|
||||
this.heiXiuProvider = heiXiuProvider;
|
||||
this.autoDownload = false; // 由我们手动控制流程,保持 IPC 兼容
|
||||
this.autoInstallOnAppQuit = false;
|
||||
this.forceDevUpdateConfig = true; // 绕过 electron-updater 的 dev 模式检查
|
||||
}
|
||||
|
||||
/** @internal — 注入自定义 Provider */
|
||||
protected override async getUpdateInfoAndProvider(): Promise<{
|
||||
info: UpdateInfo;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
provider: Provider<any>;
|
||||
}> {
|
||||
const info = await this.heiXiuProvider.getLatestVersion();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return { info, provider: this.heiXiuProvider as Provider<any> };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- macOS ----------
|
||||
|
||||
export class HeiXiuMacUpdater extends MacUpdater {
|
||||
private heiXiuProvider: HeiXiuProvider;
|
||||
|
||||
constructor(heiXiuProvider: HeiXiuProvider) {
|
||||
super(undefined);
|
||||
this.heiXiuProvider = heiXiuProvider;
|
||||
this.autoDownload = false;
|
||||
this.autoInstallOnAppQuit = false;
|
||||
}
|
||||
|
||||
protected override async getUpdateInfoAndProvider(): Promise<{
|
||||
info: UpdateInfo;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
provider: Provider<any>;
|
||||
}> {
|
||||
const info = await this.heiXiuProvider.getLatestVersion();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return { info, provider: this.heiXiuProvider as Provider<any> };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Linux AppImage ----------
|
||||
|
||||
export class HeiXiuAppImageUpdater extends AppImageUpdater {
|
||||
private heiXiuProvider: HeiXiuProvider;
|
||||
|
||||
constructor(heiXiuProvider: HeiXiuProvider) {
|
||||
super(null);
|
||||
this.heiXiuProvider = heiXiuProvider;
|
||||
this.autoDownload = false;
|
||||
this.autoInstallOnAppQuit = false;
|
||||
}
|
||||
|
||||
protected override async getUpdateInfoAndProvider(): Promise<{
|
||||
info: UpdateInfo;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
provider: Provider<any>;
|
||||
}> {
|
||||
const info = await this.heiXiuProvider.getLatestVersion();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return { info, provider: this.heiXiuProvider as Provider<any> };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 工厂函数 ----------
|
||||
|
||||
/** 工厂:按平台创建对应的 Updater 实例 */
|
||||
export function createPlatformUpdater(provider: HeiXiuProvider) {
|
||||
switch (process.platform) {
|
||||
case 'win32':
|
||||
return new HeiXiuNsisUpdater(provider);
|
||||
case 'darwin':
|
||||
return new HeiXiuMacUpdater(provider);
|
||||
default:
|
||||
return new HeiXiuAppImageUpdater(provider);
|
||||
}
|
||||
}
|
||||
172
electron/main/updater/provider.ts
Normal file
172
electron/main/updater/provider.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
// ============================================================
|
||||
// provider.ts — 自定义更新 Provider
|
||||
//
|
||||
// 封装与自有版本检查 API 的交互逻辑,
|
||||
// 返回 electron-updater 格式的 UpdateInfo。
|
||||
// ============================================================
|
||||
|
||||
import { app } from 'electron';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { URL } from 'node:url';
|
||||
import { Provider } from 'electron-updater';
|
||||
import type { ProviderRuntimeOptions } from 'electron-updater/out/providers/Provider';
|
||||
import type { UpdateInfo, UpdateFileInfo } from 'builder-util-runtime';
|
||||
import type { ResolvedUpdateFileInfo } from 'electron-updater/out/types';
|
||||
|
||||
import type { UpdateCheckResult } from '../../../shared/types';
|
||||
import { APP_VERSION } from '../../../shared/constants/version';
|
||||
import { logger } from '../logger';
|
||||
import { netRequest } from '../net-request';
|
||||
|
||||
// ---------- 构建时注入的渠道信息 ----------
|
||||
|
||||
/** 构建渠道(由 build.mjs 通过 VITE_BUILD_CHANNEL 注入,Vite 编译时内联) */
|
||||
export const BUILD_CHANNEL: string =
|
||||
(typeof import.meta !== 'undefined' && (import.meta as unknown as Record<string, unknown>).env
|
||||
? ((import.meta as unknown as Record<string, unknown>).env as Record<string, string>).VITE_BUILD_CHANNEL
|
||||
: undefined)
|
||||
|| process.env.VITE_BUILD_CHANNEL
|
||||
|| 'stable';
|
||||
|
||||
/** 构建版本类型(由 build.mjs 通过 VITE_BUILD_EDITION 注入) */
|
||||
export const BUILD_EDITION: string =
|
||||
(typeof import.meta !== 'undefined' && (import.meta as unknown as Record<string, unknown>).env
|
||||
? ((import.meta as unknown as Record<string, unknown>).env as Record<string, string>).VITE_BUILD_EDITION
|
||||
: undefined)
|
||||
|| process.env.VITE_BUILD_EDITION
|
||||
|| 'personal';
|
||||
|
||||
// ---------- 设备指纹(稳定的机器标识,用于灰度分组)----------
|
||||
|
||||
let _deviceFingerprint: string | null = null;
|
||||
|
||||
/** 获取设备指纹(基于机器特定信息的 SHA-256 前 16 位) */
|
||||
export function getDeviceFingerprint(): string {
|
||||
if (_deviceFingerprint) return _deviceFingerprint;
|
||||
|
||||
try {
|
||||
const parts = [
|
||||
process.platform,
|
||||
process.arch,
|
||||
app.getPath('home'),
|
||||
];
|
||||
_deviceFingerprint = createHash('sha256')
|
||||
.update(parts.join('|'))
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
} catch {
|
||||
_deviceFingerprint = 'unknown-device';
|
||||
}
|
||||
|
||||
return _deviceFingerprint;
|
||||
}
|
||||
|
||||
// ---------- 配置 ----------
|
||||
|
||||
export function getApiBaseUrl(): string {
|
||||
const url = (process.env.UPDATE_API_URL || 'https://www.heixiu.com').trim();
|
||||
console.log(`[updater] UPDATE_API_URL=${process.env.UPDATE_API_URL} → 使用: ${url}`);
|
||||
return url;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HeiXiuProvider
|
||||
// ============================================================
|
||||
|
||||
export class HeiXiuProvider extends Provider<UpdateInfo> {
|
||||
private apiBaseUrl: string;
|
||||
/** 短时缓存:避免 checkForUpdates 预检 + electron-updater 内部调用导致两次 API 请求 */
|
||||
private _cachedResult: { data: UpdateInfo; ts: number } | null = null;
|
||||
|
||||
constructor(runtimeOptions: ProviderRuntimeOptions) {
|
||||
super(runtimeOptions);
|
||||
this.apiBaseUrl = getApiBaseUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用自有版本检查 API,返回 electron-updater 格式的 UpdateInfo。
|
||||
* blockMapSize > 0 时 electron-updater 自动启用差分下载。
|
||||
* 无更新时返回当前版本号(APP_VERSION)。
|
||||
*/
|
||||
async getLatestVersion(): Promise<UpdateInfo> {
|
||||
// 短时缓存(500ms):预检和 electron-updater 内部调用共享同一结果
|
||||
if (this._cachedResult && Date.now() - this._cachedResult.ts < 500) {
|
||||
return this._cachedResult.data;
|
||||
}
|
||||
const data = await this._doGetLatestVersion();
|
||||
this._cachedResult = { data, ts: Date.now() };
|
||||
return data;
|
||||
}
|
||||
|
||||
private async _doGetLatestVersion(): Promise<UpdateInfo> {
|
||||
const { data } = await netRequest.get<{ code: number; data?: UpdateCheckResult } & UpdateCheckResult>(
|
||||
'/api/v1/update/check',
|
||||
{
|
||||
baseURL: this.apiBaseUrl,
|
||||
params: {
|
||||
platform: process.platform,
|
||||
version: APP_VERSION,
|
||||
channel: BUILD_CHANNEL,
|
||||
edition: BUILD_EDITION,
|
||||
deviceFingerprint: getDeviceFingerprint(),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// 兼容两种 API 响应格式
|
||||
const result: UpdateCheckResult =
|
||||
(data as { data?: UpdateCheckResult }).data ?? (data as UpdateCheckResult);
|
||||
|
||||
// 无更新 → 返回当前版本号
|
||||
if (!result.hasUpdate) {
|
||||
return {
|
||||
version: APP_VERSION,
|
||||
files: [],
|
||||
path: '',
|
||||
sha512: '',
|
||||
releaseDate: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const file: UpdateFileInfo = {
|
||||
url: result.downloadUrl,
|
||||
sha512: result.sha512,
|
||||
size: result.fileSize,
|
||||
};
|
||||
|
||||
// 后端 API 返回 blockMapSize 时启用增量更新
|
||||
if (result.blockMapSize) {
|
||||
file.blockMapSize = result.blockMapSize;
|
||||
}
|
||||
|
||||
logger.info('updater', 'Provider 获取到新版本', {
|
||||
latestVersion: result.latestVersion,
|
||||
hasBlockmap: file.blockMapSize != null,
|
||||
});
|
||||
|
||||
return {
|
||||
version: result.latestVersion,
|
||||
files: [file],
|
||||
path: result.downloadUrl,
|
||||
sha512: result.sha512,
|
||||
releaseDate: result.releaseDate,
|
||||
releaseNotes: result.changelog,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 UpdateInfo 中的相对路径解析为完整的下载 URL。
|
||||
* electron-updater 在下载前调用此方法。
|
||||
*/
|
||||
resolveFiles(updateInfo: UpdateInfo): Array<ResolvedUpdateFileInfo> {
|
||||
return updateInfo.files.map((f) => {
|
||||
// 使用 downloadUrl 作为 base URL 来解析文件路径
|
||||
const baseUrl = new URL(updateInfo.path);
|
||||
const fileUrl = new URL(f.url, baseUrl.origin);
|
||||
return {
|
||||
url: fileUrl,
|
||||
info: f,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,177 +1,39 @@
|
||||
import { ipcRenderer, contextBridge } from 'electron';
|
||||
import { BIDIRECTIONAL, RENDERER_TO_MAIN } from '../shared/constants/ipc-channels';
|
||||
// ============================================================
|
||||
// preload.ts — Electron 预加载脚本(入口)
|
||||
//
|
||||
// 职责:
|
||||
// - 调用各功能模块的 expose 函数,将安全 API 暴露给渲染进程
|
||||
// - 所有 API 通过 contextBridge 桥接,确保进程隔离安全
|
||||
//
|
||||
// 模块拆分:
|
||||
// - base.ts → ipcRenderer 基础通信
|
||||
// - safe-storage.ts → 加密/解密 API
|
||||
// - file-storage.ts → 文件读写 API
|
||||
// - app-runtime.ts → 运行时 API(窗口标题等)
|
||||
// - canvas.ts → 无限画布 API
|
||||
// ============================================================
|
||||
|
||||
// --------- Expose some API to the Renderer process ---------
|
||||
contextBridge.exposeInMainWorld('ipcRenderer', {
|
||||
on(...args: Parameters<typeof ipcRenderer.on>) {
|
||||
const [channel, listener] = args;
|
||||
return ipcRenderer.on(channel, (event, ...args) => listener(event, ...args));
|
||||
},
|
||||
off(...args: Parameters<typeof ipcRenderer.off>) {
|
||||
const [channel, ...omit] = args;
|
||||
return ipcRenderer.off(channel, ...omit);
|
||||
},
|
||||
send(...args: Parameters<typeof ipcRenderer.send>) {
|
||||
const [channel, ...omit] = args;
|
||||
return ipcRenderer.send(channel, ...omit);
|
||||
},
|
||||
invoke(...args: Parameters<typeof ipcRenderer.invoke>) {
|
||||
const [channel, ...omit] = args;
|
||||
return ipcRenderer.invoke(channel, ...omit);
|
||||
},
|
||||
});
|
||||
import {
|
||||
exposeBaseIpc,
|
||||
exposeSafeStorage,
|
||||
exposeFileStorage,
|
||||
exposeAppRuntime,
|
||||
exposeCanvas,
|
||||
} from './preload/index';
|
||||
|
||||
// --------- 安全加密 API(基于 Electron safeStorage)---------
|
||||
contextBridge.exposeInMainWorld('safeStorage', {
|
||||
/**
|
||||
* 加密明文字符串
|
||||
* @param plaintext - 待加密的明文
|
||||
* @returns Base64 编码的密文,失败返回 null
|
||||
*/
|
||||
async encrypt(plaintext: string): Promise<string | null> {
|
||||
const result = await ipcRenderer.invoke(BIDIRECTIONAL.SAFESTORAGE_ENCRYPT, plaintext);
|
||||
return result?.success ? result.data : null;
|
||||
},
|
||||
// --------- 按顺序暴露各模块 API ---------
|
||||
|
||||
/**
|
||||
* 解密 Base64 密文
|
||||
* @param encryptedBase64 - Base64 编码的密文
|
||||
* @returns 解密后的明文,失败返回 null
|
||||
*/
|
||||
async decrypt(encryptedBase64: string): Promise<string | null> {
|
||||
const result = await ipcRenderer.invoke(BIDIRECTIONAL.SAFESTORAGE_DECRYPT, encryptedBase64);
|
||||
return result?.success ? result.data : null;
|
||||
},
|
||||
});
|
||||
// 1. 基础 IPC 通信(ipcRenderer.on/off/send/invoke)
|
||||
exposeBaseIpc();
|
||||
|
||||
// --------- 本地存储文件 API(主进程 fs 桥接)---------
|
||||
contextBridge.exposeInMainWorld('fileStorage', {
|
||||
/** 获取 heixiu-data 根目录路径 */
|
||||
getDataDir(): Promise<string | null> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.GET_DATA_DIR)
|
||||
.then((r: { success: boolean; data: string }) => r?.success ? r.data : null);
|
||||
},
|
||||
/** 写入文件(Base64 → 磁盘),返回写入的绝对路径 */
|
||||
writeFile(relativePath: string, data: string, encoding?: 'base64' | 'utf8'): Promise<string | null> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.FILE_WRITE, { relativePath, data, encoding })
|
||||
.then((r: { success: boolean; data: string }) => r?.success ? r.data : null);
|
||||
},
|
||||
/** 读取文件(磁盘 → Base64) */
|
||||
readFile(relativePath: string): Promise<string | null> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.FILE_READ, { relativePath })
|
||||
.then((r: { success: boolean; data: string }) => r?.success ? r.data : null);
|
||||
},
|
||||
/** 删除文件 */
|
||||
deleteFile(relativePath: string): Promise<boolean> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.FILE_DELETE, { relativePath })
|
||||
.then((r: { success: boolean }) => r?.success ?? false);
|
||||
},
|
||||
/** 递归创建目录 */
|
||||
makeDir(relativePath: string): Promise<string | null> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.MAKE_DIR, { relativePath })
|
||||
.then((r: { success: boolean; data: string }) => r?.success ? r.data : null);
|
||||
},
|
||||
/** 在文件管理器中显示文件(绝对路径) */
|
||||
showItemInFolder(filePath: string): Promise<boolean> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.SHOW_ITEM_IN_FOLDER, { filePath })
|
||||
.then((r: { success: boolean }) => r?.success ?? false);
|
||||
},
|
||||
/** 获取文件大小(字节) */
|
||||
fileSize(relativePath: string): Promise<number | null> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.FILE_SIZE, { relativePath })
|
||||
.then((r: { success: boolean; data: number }) => r?.success ? r.data : null);
|
||||
},
|
||||
/** 检查文件/目录是否存在 */
|
||||
fileExists(relativePath: string): Promise<boolean> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.FILE_EXISTS, { relativePath })
|
||||
.then((r: { success: boolean; data: boolean }) => r?.success ? r.data : false);
|
||||
},
|
||||
});
|
||||
// 2. 安全加密 API(基于 Electron safeStorage)
|
||||
exposeSafeStorage();
|
||||
|
||||
/** 应用运行时 API */
|
||||
contextBridge.exposeInMainWorld('appRuntime', {
|
||||
/**
|
||||
* 登录/登出后更新窗口标题中的版控后缀
|
||||
* @param edition - 'personal' | 'enterprise' | null(null 清除后缀)
|
||||
*/
|
||||
setWindowEdition(edition: string | null): void {
|
||||
ipcRenderer.send(RENDERER_TO_MAIN.SET_WINDOW_TITLE, edition);
|
||||
},
|
||||
});
|
||||
// 3. 本地存储文件 API(主进程 fs 桥接)
|
||||
exposeFileStorage();
|
||||
|
||||
// --------- 无限画布 API ---------
|
||||
contextBridge.exposeInMainWorld('canvas', {
|
||||
/** 打开画布窗口(主进程创建新 BrowserWindow) */
|
||||
openWindow(projectPath?: string): void {
|
||||
ipcRenderer.send(RENDERER_TO_MAIN.OPEN_CANVAS_WINDOW, { projectPath });
|
||||
},
|
||||
/** 扫描目录,返回匹配的文件列表 */
|
||||
scanDirectory(params: {
|
||||
roots: string[];
|
||||
extensions: string[];
|
||||
recursive: boolean;
|
||||
}): Promise<{ success: boolean; data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>; error?: string }> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_DIRECTORY, params);
|
||||
},
|
||||
/** 扫描项目(主进程内部完成 buildScanRoots + walkDirectory,推荐使用) */
|
||||
scanProject(params: {
|
||||
projectPath: string;
|
||||
stage: string;
|
||||
assetTypeFilter: string;
|
||||
shotQuery: string;
|
||||
}): Promise<{ success: boolean; data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>; error?: string }> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_PROJECT, params);
|
||||
},
|
||||
/** 读取旁加载 JSON 元数据 */
|
||||
readSidecar(params: { filePath: string }): Promise<{ success: boolean; data: object | null }> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_READ_SIDECAR, params);
|
||||
},
|
||||
/** 写入旁加载 JSON 元数据 */
|
||||
writeSidecar(params: { filePath: string; metadata: object }): Promise<{ success: boolean; error?: string }> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_WRITE_SIDECAR, params);
|
||||
},
|
||||
/** 生成缩略图(JPEG base64) */
|
||||
generateThumbnail(params: { filePath: string; size: number }): Promise<{
|
||||
success: boolean;
|
||||
data: { data: string; mimeType: string; width: number; height: number };
|
||||
error?: string;
|
||||
}> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_GENERATE_THUMBNAIL, params);
|
||||
},
|
||||
/** 复制文件到目标目录(导入操作) */
|
||||
copyFile(params: { src: string; destDir: string; fileName: string }): Promise<{ success: boolean; data: string; error?: string }> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_COPY_FILE, params);
|
||||
},
|
||||
/** 列出目录下的子文件夹(非递归,排除隐藏) */
|
||||
listSubdirs(params: { parentPath: string }): Promise<{ success: boolean; data: string[]; error?: string }> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_LIST_SUBDIRS, params);
|
||||
},
|
||||
/** 扫描阶段文件(单个 episode/shot/stage 组合) */
|
||||
scanStageFiles(params: { projectPath: string; episode: string; shot: string; stage: string }): Promise<{ success: boolean; data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>; error?: string }> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_STAGE_FILES, params);
|
||||
},
|
||||
/** 扫描资产文件(深度递归扫描 assets/ 目录) */
|
||||
scanAssetFiles(params: { projectPath: string; assetTypeFilter: string }): Promise<{ success: boolean; data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>; error?: string }> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_ASSET_FILES, params);
|
||||
},
|
||||
/** 创建目录 */
|
||||
createDirectory(params: { parentPath: string; dirName: string }): Promise<{ success: boolean; data: string; error?: string }> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_CREATE_DIRECTORY, params);
|
||||
},
|
||||
/** 将本地文件路径转换为可通过 local-media:// 协议加载的 URL */
|
||||
getMediaUrl(filePath: string): string {
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
// UNC 路径(//server/share/…)开头 // 会被 URL 解析器视为 authority,
|
||||
// 必须用 local-media://<host>/<path> 格式而非 local-media:/// 前缀
|
||||
if (normalized.startsWith('//')) {
|
||||
// normalized = "//192.168.110.250/share/path"
|
||||
return 'local-media://' + normalized.slice(2);
|
||||
}
|
||||
// 本地路径:C:/path → local-media:///C:/path
|
||||
return 'local-media:///' + normalized;
|
||||
},
|
||||
/** 启动原生文件拖拽(从缩略图拖放媒体文件到外部应用) */
|
||||
startDrag(params: { filePath: string; fileName: string }): Promise<{ success: boolean; error?: string }> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_START_DRAG, params);
|
||||
},
|
||||
});
|
||||
// 4. 应用运行时 API(窗口标题等)
|
||||
exposeAppRuntime();
|
||||
|
||||
// 5. 无限画布 API
|
||||
exposeCanvas();
|
||||
|
||||
22
electron/preload/app-runtime.ts
Normal file
22
electron/preload/app-runtime.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// ============================================================
|
||||
// preload/app-runtime.ts — 应用运行时 API
|
||||
//
|
||||
// 提供窗口标题、版本控制等运行时接口
|
||||
// 渲染进程通过 window.appRuntime 调用
|
||||
// ============================================================
|
||||
|
||||
import { ipcRenderer, contextBridge } from 'electron';
|
||||
import { RENDERER_TO_MAIN } from '../../shared/constants/ipc';
|
||||
|
||||
/** 应用运行时 API */
|
||||
export function exposeAppRuntime(): void {
|
||||
contextBridge.exposeInMainWorld('appRuntime', {
|
||||
/**
|
||||
* 登录/登出后更新窗口标题中的版控后缀
|
||||
* @param edition - 'personal' | 'enterprise' | null(null 清除后缀)
|
||||
*/
|
||||
setWindowEdition(edition: string | null): void {
|
||||
ipcRenderer.send(RENDERER_TO_MAIN.SET_WINDOW_TITLE, edition);
|
||||
},
|
||||
});
|
||||
}
|
||||
29
electron/preload/base.ts
Normal file
29
electron/preload/base.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// ============================================================
|
||||
// preload/base.ts — 基础 ipcRenderer 桥接
|
||||
//
|
||||
// 提供安全的 IPC 通信接口,暴露给渲染进程
|
||||
// ============================================================
|
||||
|
||||
import { ipcRenderer, contextBridge } from 'electron';
|
||||
|
||||
/** 基础 IPC 通信 API */
|
||||
export function exposeBaseIpc(): void {
|
||||
contextBridge.exposeInMainWorld('ipcRenderer', {
|
||||
on(...args: Parameters<typeof ipcRenderer.on>) {
|
||||
const [channel, listener] = args;
|
||||
return ipcRenderer.on(channel, (event, ...args) => listener(event, ...args));
|
||||
},
|
||||
off(...args: Parameters<typeof ipcRenderer.off>) {
|
||||
const [channel, ...omit] = args;
|
||||
return ipcRenderer.off(channel, ...omit);
|
||||
},
|
||||
send(...args: Parameters<typeof ipcRenderer.send>) {
|
||||
const [channel, ...omit] = args;
|
||||
return ipcRenderer.send(channel, ...omit);
|
||||
},
|
||||
invoke(...args: Parameters<typeof ipcRenderer.invoke>) {
|
||||
const [channel, ...omit] = args;
|
||||
return ipcRenderer.invoke(channel, ...omit);
|
||||
},
|
||||
});
|
||||
}
|
||||
124
electron/preload/canvas.ts
Normal file
124
electron/preload/canvas.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
// ============================================================
|
||||
// preload/canvas.ts — 无限画布 API
|
||||
//
|
||||
// 提供画布窗口、文件扫描、缩略图、拖拽等接口
|
||||
// 渲染进程通过 window.canvas 调用
|
||||
// ============================================================
|
||||
|
||||
import { ipcRenderer, contextBridge } from 'electron';
|
||||
import { BIDIRECTIONAL, RENDERER_TO_MAIN } from '../../shared/constants/ipc';
|
||||
|
||||
/** 无限画布 API */
|
||||
export function exposeCanvas(): void {
|
||||
contextBridge.exposeInMainWorld('canvas', {
|
||||
/** 打开画布窗口(主进程创建新 BrowserWindow) */
|
||||
openWindow(projectPath?: string): void {
|
||||
ipcRenderer.send(RENDERER_TO_MAIN.OPEN_CANVAS_WINDOW, { projectPath });
|
||||
},
|
||||
|
||||
/** 扫描目录,返回匹配的文件列表 */
|
||||
scanDirectory(params: {
|
||||
roots: string[];
|
||||
extensions: string[];
|
||||
recursive: boolean;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>;
|
||||
error?: string;
|
||||
}> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_DIRECTORY, params);
|
||||
},
|
||||
|
||||
/** 扫描项目(主进程内部完成 buildScanRoots + walkDirectory,推荐使用) */
|
||||
scanProject(params: {
|
||||
projectPath: string;
|
||||
stage: string;
|
||||
assetTypeFilter: string;
|
||||
shotQuery: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>;
|
||||
error?: string;
|
||||
}> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_PROJECT, params);
|
||||
},
|
||||
|
||||
/** 读取旁加载 JSON 元数据 */
|
||||
readSidecar(params: { filePath: string }): Promise<{ success: boolean; data: object | null }> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_READ_SIDECAR, params);
|
||||
},
|
||||
|
||||
/** 写入旁加载 JSON 元数据 */
|
||||
writeSidecar(params: { filePath: string; metadata: object }): Promise<{ success: boolean; error?: string }> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_WRITE_SIDECAR, params);
|
||||
},
|
||||
|
||||
/** 生成缩略图(JPEG base64) */
|
||||
generateThumbnail(params: { filePath: string; size: number }): Promise<{
|
||||
success: boolean;
|
||||
data: { data: string; mimeType: string; width: number; height: number };
|
||||
error?: string;
|
||||
}> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_GENERATE_THUMBNAIL, params);
|
||||
},
|
||||
|
||||
/** 复制文件到目标目录(导入操作) */
|
||||
copyFile(params: { src: string; destDir: string; fileName: string }): Promise<{
|
||||
success: boolean;
|
||||
data: string;
|
||||
error?: string;
|
||||
}> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_COPY_FILE, params);
|
||||
},
|
||||
|
||||
/** 列出目录下的子文件夹(非递归,排除隐藏) */
|
||||
listSubdirs(params: { parentPath: string }): Promise<{ success: boolean; data: string[]; error?: string }> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_LIST_SUBDIRS, params);
|
||||
},
|
||||
|
||||
/** 扫描阶段文件(单个 episode/shot/stage 组合) */
|
||||
scanStageFiles(params: { projectPath: string; episode: string; shot: string; stage: string }): Promise<{
|
||||
success: boolean;
|
||||
data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>;
|
||||
error?: string;
|
||||
}> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_STAGE_FILES, params);
|
||||
},
|
||||
|
||||
/** 扫描资产文件(深度递归扫描 assets/ 目录) */
|
||||
scanAssetFiles(params: { projectPath: string; assetTypeFilter: string }): Promise<{
|
||||
success: boolean;
|
||||
data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>;
|
||||
error?: string;
|
||||
}> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_ASSET_FILES, params);
|
||||
},
|
||||
|
||||
/** 创建目录 */
|
||||
createDirectory(params: { parentPath: string; dirName: string }): Promise<{
|
||||
success: boolean;
|
||||
data: string;
|
||||
error?: string;
|
||||
}> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_CREATE_DIRECTORY, params);
|
||||
},
|
||||
|
||||
/** 将本地文件路径转换为可通过 local-media:// 协议加载的 URL */
|
||||
getMediaUrl(filePath: string): string {
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
// UNC 路径(//server/share/…)开头 // 会被 URL 解析器视为 authority,
|
||||
// 必须用 local-media://<host>/<path> 格式而非 local-media:/// 前缀
|
||||
if (normalized.startsWith('//')) {
|
||||
// normalized = "//192.168.110.250/share/path"
|
||||
return 'local-media://' + normalized.slice(2);
|
||||
}
|
||||
// 本地路径:C:/path → local-media:///C:/path
|
||||
return 'local-media:///' + normalized;
|
||||
},
|
||||
|
||||
/** 启动原生文件拖拽(从缩略图拖放媒体文件到外部应用) */
|
||||
startDrag(params: { filePath: string; fileName: string }): Promise<{ success: boolean; error?: string }> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_START_DRAG, params);
|
||||
},
|
||||
});
|
||||
}
|
||||
94
electron/preload/file-storage.ts
Normal file
94
electron/preload/file-storage.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
// ============================================================
|
||||
// preload/file-storage.ts — 本地存储文件 API(主进程 fs 桥接)
|
||||
//
|
||||
// 提供文件读写、目录管理、下载等接口
|
||||
// 渲染进程通过 window.fileStorage 调用
|
||||
// ============================================================
|
||||
|
||||
import { ipcRenderer, contextBridge } from 'electron';
|
||||
import { BIDIRECTIONAL } from '../../shared/constants/ipc';
|
||||
|
||||
/** 本地存储文件 API */
|
||||
export function exposeFileStorage(): void {
|
||||
contextBridge.exposeInMainWorld('fileStorage', {
|
||||
/** 获取 heixiu-data 根目录路径 */
|
||||
async getDataDir(): Promise<string | null> {
|
||||
const r = await ipcRenderer.invoke(BIDIRECTIONAL.GET_DATA_DIR);
|
||||
return r?.success ? r.data : null;
|
||||
},
|
||||
|
||||
/** 写入文件(Base64 → 磁盘),返回写入的绝对路径 */
|
||||
async writeFile(relativePath: string, data: string, encoding?: 'base64' | 'utf8'): Promise<string | null> {
|
||||
const r = await ipcRenderer.invoke(BIDIRECTIONAL.FILE_WRITE, { relativePath, data, encoding });
|
||||
return r?.success ? r.data : null;
|
||||
},
|
||||
|
||||
/** 读取文件(磁盘 → Base64) */
|
||||
async readFile(relativePath: string): Promise<string | null> {
|
||||
const r = await ipcRenderer.invoke(BIDIRECTIONAL.FILE_READ, { relativePath });
|
||||
return r?.success ? r.data : null;
|
||||
},
|
||||
|
||||
/** 删除文件 */
|
||||
async deleteFile(relativePath: string): Promise<boolean> {
|
||||
const r = await ipcRenderer.invoke(BIDIRECTIONAL.FILE_DELETE, { relativePath });
|
||||
return r?.success ?? false;
|
||||
},
|
||||
|
||||
/** 递归创建目录 */
|
||||
async makeDir(relativePath: string): Promise<string | null> {
|
||||
const r = await ipcRenderer.invoke(BIDIRECTIONAL.MAKE_DIR, { relativePath });
|
||||
return r?.success ? r.data : null;
|
||||
},
|
||||
|
||||
/** 在文件管理器中显示文件(绝对路径) */
|
||||
async showItemInFolder(filePath: string): Promise<boolean> {
|
||||
const r = await ipcRenderer.invoke(BIDIRECTIONAL.SHOW_ITEM_IN_FOLDER, { filePath });
|
||||
return r?.success ?? false;
|
||||
},
|
||||
|
||||
/** 获取文件大小(字节) */
|
||||
async fileSize(relativePath: string): Promise<number | null> {
|
||||
const r = await ipcRenderer.invoke(BIDIRECTIONAL.FILE_SIZE, { relativePath });
|
||||
return r?.success ? r.data : null;
|
||||
},
|
||||
|
||||
/** 检查文件/目录是否存在 */
|
||||
async fileExists(relativePath: string): Promise<boolean> {
|
||||
const r = await ipcRenderer.invoke(BIDIRECTIONAL.FILE_EXISTS, { relativePath });
|
||||
return r?.success ? r.data : false;
|
||||
},
|
||||
|
||||
/** 绝对路径文件写入(绕过 heixiu-data 沙箱) */
|
||||
async writeFileAbsolute(absolutePath: string, data: string, encoding?: 'base64' | 'utf8'): Promise<string | null> {
|
||||
const r = await ipcRenderer.invoke(BIDIRECTIONAL.FILE_WRITE_ABSOLUTE, { absolutePath, data, encoding });
|
||||
return r?.success ? r.data : null;
|
||||
},
|
||||
|
||||
/** 绝对路径目录创建(绕过 heixiu-data 沙箱) */
|
||||
async makeDirAbsolute(absolutePath: string): Promise<string | null> {
|
||||
const r = await ipcRenderer.invoke(BIDIRECTIONAL.MAKE_DIR_ABSOLUTE, { absolutePath });
|
||||
return r?.success ? r.data : null;
|
||||
},
|
||||
|
||||
/** 主进程下载文件到绝对路径(绕过浏览器 CORS 限制),失败时 reject 并携带错误信息 */
|
||||
async downloadToPath(url: string, destPath: string, headers?: Record<string, string>): Promise<string> {
|
||||
const r = await ipcRenderer.invoke(BIDIRECTIONAL.DOWNLOAD_TO_PATH, { url, destPath, headers });
|
||||
if (r?.success) return r.data;
|
||||
throw new Error(r?.error || '主进程下载失败');
|
||||
},
|
||||
|
||||
/** 获取系统下载目录路径 */
|
||||
async getDownloadsPath(): Promise<string | null> {
|
||||
const r = await ipcRenderer.invoke(BIDIRECTIONAL.GET_DOWNLOADS_PATH);
|
||||
return r?.success ? r.data : null;
|
||||
},
|
||||
|
||||
/** 生成图片缩略图(读取原图 → 缩放 → JPEG 写入目标路径) */
|
||||
async generateThumbnail(srcPath: string, destPath: string, maxWidth?: number, quality?: number): Promise<string> {
|
||||
const r = await ipcRenderer.invoke(BIDIRECTIONAL.GENERATE_THUMBNAIL, { srcPath, destPath, maxWidth, quality });
|
||||
if (r?.success) return r.data;
|
||||
throw new Error(r?.error || '缩略图生成失败');
|
||||
},
|
||||
});
|
||||
}
|
||||
11
electron/preload/index.ts
Normal file
11
electron/preload/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// ============================================================
|
||||
// preload/index.ts — 汇总导出所有 preload 模块
|
||||
//
|
||||
// 在主 preload.ts 中调用各模块的 expose 函数
|
||||
// ============================================================
|
||||
|
||||
export { exposeBaseIpc } from './base';
|
||||
export { exposeSafeStorage } from './safe-storage';
|
||||
export { exposeFileStorage } from './file-storage';
|
||||
export { exposeAppRuntime } from './app-runtime';
|
||||
export { exposeCanvas } from './canvas';
|
||||
33
electron/preload/safe-storage.ts
Normal file
33
electron/preload/safe-storage.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// ============================================================
|
||||
// preload/safe-storage.ts — 安全加密 API(基于 Electron safeStorage)
|
||||
//
|
||||
// 提供加密/解密接口,渲染进程通过 window.safeStorage 调用
|
||||
// ============================================================
|
||||
|
||||
import { ipcRenderer, contextBridge } from 'electron';
|
||||
import { BIDIRECTIONAL } from '../../shared/constants/ipc';
|
||||
|
||||
/** 安全加密 API */
|
||||
export function exposeSafeStorage(): void {
|
||||
contextBridge.exposeInMainWorld('safeStorage', {
|
||||
/**
|
||||
* 加密明文字符串
|
||||
* @param plaintext - 待加密的明文
|
||||
* @returns Base64 编码的密文,失败返回 null
|
||||
*/
|
||||
async encrypt(plaintext: string): Promise<string | null> {
|
||||
const result = await ipcRenderer.invoke(BIDIRECTIONAL.SAFESTORAGE_ENCRYPT, plaintext);
|
||||
return result?.success ? result.data : null;
|
||||
},
|
||||
|
||||
/**
|
||||
* 解密 Base64 密文
|
||||
* @param encryptedBase64 - Base64 编码的密文
|
||||
* @returns 解密后的明文,失败返回 null
|
||||
*/
|
||||
async decrypt(encryptedBase64: string): Promise<string | null> {
|
||||
const result = await ipcRenderer.invoke(BIDIRECTIONAL.SAFESTORAGE_DECRYPT, encryptedBase64);
|
||||
return result?.success ? result.data : null;
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user