mirror of
https://github.com/dream-pep/koring-launcher.git
synced 2026-09-12 05:45:18 +08:00
- 重构背景图全链路处理:改用文件路径存储而非base64,新增`koring-res://`自定义协议流式加载本地壁纸,大幅降低内存占用 - 调整配置文件存储路径至userData目录,解决NSIS重装/升级时配置被清空的问题 - 新增路由调试页面,支持快速跳转所有已注册的应用页面 - 优化OOBE引导和更新完成页的「前往首页」按钮,添加4秒延迟渐入动画 - 自动迁移旧版配置:将安装目录的配置文件迁移至userData,同时将base64格式的背景配置自动转换为文件路径存储 - 重构预加载脚本、主进程IPC处理器与前端页面的背景图相关逻辑 - 更新AGENTS.md开发文档,修正路径与逻辑说明 - 更新应用背景图资源文件
253 lines
8.4 KiB
TypeScript
253 lines
8.4 KiB
TypeScript
/**
|
||
* 背景图处理服务(主进程,程序本体资源管理)。
|
||
*
|
||
* 职责:
|
||
* 1. 把用户自选的大图背景降采样/重编码后**落盘**(只生成屏幕所需尺寸),
|
||
* 返回**文件路径**(供配置文件以路径形式存储,不再使用 BASE64 dataURL);
|
||
* 2. 提供壁纸文件定位/安全校验工具,供 `koring-res://` 协议处理器使用
|
||
* (仅允许访问 userData 目录下 `background-custom*` 白名单文件,防目录穿越)。
|
||
*/
|
||
|
||
import electron from 'electron';
|
||
import * as fs from 'fs';
|
||
import * as path from 'path';
|
||
|
||
const { nativeImage } = electron;
|
||
|
||
export interface OptimizedBackground {
|
||
/** 实际使用的文件路径(优化后文件;无需优化时为原始缓存文件) */
|
||
filePath: string;
|
||
bytes: number;
|
||
width: number;
|
||
height: number;
|
||
/** 是否发生了降采样/重编码 */
|
||
optimized: boolean;
|
||
}
|
||
|
||
const EXT_MIME: Record<string, string> = {
|
||
'.png': 'image/png',
|
||
'.jpg': 'image/jpeg',
|
||
'.jpeg': 'image/jpeg',
|
||
'.webp': 'image/webp',
|
||
'.gif': 'image/gif',
|
||
'.bmp': 'image/bmp',
|
||
};
|
||
|
||
const MIME_TO_EXT: Record<string, string> = {
|
||
'image/png': '.png',
|
||
'image/jpeg': '.jpg',
|
||
'image/webp': '.webp',
|
||
'image/gif': '.gif',
|
||
'image/bmp': '.bmp',
|
||
};
|
||
|
||
/**
|
||
* 旧版配置若仍存 BASE64 dataURL 且 userData 里没有原始缓存文件时,
|
||
* 直接把 dataURL 解码落盘为 `background-custom<ext>`,保证配置文件能迁移为「文件路径」存储。
|
||
*/
|
||
export function recoverBackgroundFromDataUrl(dataUrl: string, userDataDir: string): string | null {
|
||
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=]+)$/.exec(dataUrl.trim());
|
||
if (!match) return null;
|
||
const ext = MIME_TO_EXT[match[1].toLowerCase()] ?? '.png';
|
||
try {
|
||
const buffer = Buffer.from(match[2], 'base64');
|
||
if (!buffer || buffer.length === 0) return null;
|
||
if (!fs.existsSync(userDataDir)) fs.mkdirSync(userDataDir, { recursive: true });
|
||
clearStaleBackgroundFiles(userDataDir, []);
|
||
const rawPath = path.join(userDataDir, `background-custom${ext}`);
|
||
fs.writeFileSync(rawPath, buffer);
|
||
return rawPath;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** 透明通道源格式:优化时用 PNG 无损编码,避免破坏透明背景 */
|
||
const TRANSPARENT_MIMES = new Set(['image/png', 'image/webp', 'image/gif']);
|
||
|
||
export function mimeForExt(ext: string): string {
|
||
return EXT_MIME[ext.toLowerCase()] || 'image/png';
|
||
}
|
||
|
||
export function mimeForFile(filePath: string): string {
|
||
return mimeForExt(path.extname(filePath));
|
||
}
|
||
|
||
function statSize(p: string): number {
|
||
try {
|
||
return fs.statSync(p).size;
|
||
} catch {
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
/** 删除 userData 目录里旧的壁纸缓存文件(保留 keep 中列出的完整路径) */
|
||
export function clearStaleBackgroundFiles(userDataDir: string, keep: string[]): void {
|
||
try {
|
||
const entries = fs.readdirSync(userDataDir);
|
||
const keepSet = new Set(keep.map((p) => path.basename(p)));
|
||
for (const name of entries) {
|
||
if (!name.startsWith('background-custom')) continue;
|
||
if (keepSet.has(name)) continue;
|
||
const full = path.join(userDataDir, name);
|
||
try {
|
||
fs.unlinkSync(full);
|
||
} catch {
|
||
// 忽略删除失败(其它文件占用等)
|
||
}
|
||
}
|
||
} catch {
|
||
// userData 目录不存在等
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 对已复制到 userData 的原始壁纸做「按需」降采样,结果直接落盘。
|
||
* - 长边 ≤ maxEdge:不需要优化 → 直接返回原始缓存文件路径(零损耗,视觉 100% 一致);
|
||
* - 长边 > maxEdge:等比 resize → JPEG(q0.9) 或 PNG(透明/动画不处理) 写为
|
||
* `background-custom-opt.<ext>`,同时清理旧的其它格式 opt 文件;
|
||
* - 动画 GIF 或无法解析:原样返回原始路径(不破坏动画/内容)。
|
||
*/
|
||
export function optimizeBackgroundFile(rawFilePath: string, maxEdge = 4096): OptimizedBackground {
|
||
const fail = (filePath: string): OptimizedBackground => {
|
||
let width = 0;
|
||
let height = 0;
|
||
try {
|
||
const s = nativeImage.createFromPath(filePath).getSize();
|
||
width = s.width || 0;
|
||
height = s.height || 0;
|
||
} catch {
|
||
// 无法读取尺寸时按 0 处理
|
||
}
|
||
return {
|
||
filePath,
|
||
bytes: statSize(filePath),
|
||
width,
|
||
height,
|
||
optimized: false,
|
||
};
|
||
};
|
||
|
||
const outDir = path.dirname(rawFilePath);
|
||
const rawExt = path.extname(rawFilePath).toLowerCase();
|
||
const mime = mimeForExt(rawExt);
|
||
|
||
try {
|
||
// 动画 GIF:nativeImage 只能解码首帧,不处理,保持原样
|
||
if (mime === 'image/gif') {
|
||
return fail(rawFilePath);
|
||
}
|
||
|
||
const image = nativeImage.createFromPath(rawFilePath);
|
||
if (image.isEmpty()) return fail(rawFilePath);
|
||
|
||
const size = image.getSize();
|
||
const longEdge = Math.max(size.width, size.height);
|
||
if (longEdge <= maxEdge || size.width <= 0 || size.height <= 0) {
|
||
// 无需优化:清掉历史遗留的旧 opt/其它扩展名缓存后原样返回
|
||
clearStaleBackgroundFiles(outDir, [rawFilePath]);
|
||
return {
|
||
filePath: rawFilePath,
|
||
bytes: statSize(rawFilePath),
|
||
width: size.width,
|
||
height: size.height,
|
||
optimized: false,
|
||
};
|
||
}
|
||
|
||
const scale = maxEdge / longEdge;
|
||
const w = Math.max(1, Math.round(size.width * scale));
|
||
const h = Math.max(1, Math.round(size.height * scale));
|
||
const output = image.resize({ width: w, height: h, quality: 'best' });
|
||
if (output.isEmpty()) return fail(rawFilePath);
|
||
|
||
const outSize = output.getSize();
|
||
const hasTransparency = TRANSPARENT_MIMES.has(mime);
|
||
const outExt = hasTransparency ? '.png' : '.jpg';
|
||
const outMime = hasTransparency ? 'image/png' : 'image/jpeg';
|
||
|
||
let buffer: Buffer;
|
||
if (hasTransparency) {
|
||
buffer = output.toPNG();
|
||
} else {
|
||
buffer = output.toJPEG(90);
|
||
}
|
||
if (!buffer || buffer.length === 0) return fail(rawFilePath);
|
||
|
||
// 先清理旧 opt 文件(其它扩展名/旧尺寸),再原子写入新文件
|
||
clearStaleBackgroundFiles(outDir, [rawFilePath]);
|
||
const optPath = path.join(outDir, `background-custom-opt${outExt}`);
|
||
const tmpPath = `${optPath}.${process.pid}.tmp`;
|
||
fs.writeFileSync(tmpPath, buffer);
|
||
try {
|
||
fs.renameSync(tmpPath, optPath);
|
||
} catch {
|
||
fs.unlinkSync(tmpPath);
|
||
fs.writeFileSync(optPath, buffer);
|
||
}
|
||
|
||
return {
|
||
filePath: optPath,
|
||
bytes: buffer.length,
|
||
width: outSize.width,
|
||
height: outSize.height,
|
||
optimized: true,
|
||
};
|
||
} catch {
|
||
return fail(rawFilePath);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 复制用户选择的图片到 userData(原始缓存,命名 background-custom<ext>),
|
||
* 清理旧的其它扩展名缓存,然后返回优化结果。
|
||
*/
|
||
export function importUserBackground(srcPath: string, maxEdge = 4096): OptimizedBackground | null {
|
||
try {
|
||
if (!fs.existsSync(srcPath) || !fs.statSync(srcPath).isFile()) return null;
|
||
const userDataDir = electron.app.getPath('userData');
|
||
if (!fs.existsSync(userDataDir)) fs.mkdirSync(userDataDir, { recursive: true });
|
||
|
||
const ext = path.extname(srcPath).toLowerCase() || '.png';
|
||
const rawPath = path.join(userDataDir, `background-custom${ext}`);
|
||
// 删除旧的原始缓存(其它扩展名)
|
||
clearStaleBackgroundFiles(userDataDir, []);
|
||
fs.copyFileSync(srcPath, rawPath);
|
||
return optimizeBackgroundFile(rawPath, maxEdge);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** 找到 userData 中现有的壁纸缓存原始文件(不含 opt 变体) */
|
||
export function findCachedBackgroundRaw(userDataDir: string): string | null {
|
||
try {
|
||
const entries = fs.readdirSync(userDataDir);
|
||
const match = entries
|
||
.filter((n) => n.startsWith('background-custom') && !n.includes('-opt.'))
|
||
.sort()
|
||
.map((n) => path.join(userDataDir, n))
|
||
.find((p) => fs.existsSync(p) && fs.statSync(p).isFile());
|
||
return match ?? null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** 目标路径是否真实地位于 root 之内(realpath 后比较,防符号链接/目录穿越) */
|
||
export function isPathInside(root: string, target: string): boolean {
|
||
try {
|
||
const realRoot = fs.realpathSync(root);
|
||
const realTarget = fs.realpathSync(target);
|
||
const rel = path.relative(realRoot, realTarget);
|
||
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** 是否为受管壁纸文件(供协议处理器白名单使用) */
|
||
export function isManagedBackgroundFile(fileName: string): boolean {
|
||
return fileName.startsWith('background-custom');
|
||
}
|