feat: 重构背景图处理体系,优化用户UX体验

- 重构背景图全链路处理:改用文件路径存储而非base64,新增`koring-res://`自定义协议流式加载本地壁纸,大幅降低内存占用
- 调整配置文件存储路径至userData目录,解决NSIS重装/升级时配置被清空的问题
- 新增路由调试页面,支持快速跳转所有已注册的应用页面
- 优化OOBE引导和更新完成页的「前往首页」按钮,添加4秒延迟渐入动画
- 自动迁移旧版配置:将安装目录的配置文件迁移至userData,同时将base64格式的背景配置自动转换为文件路径存储
- 重构预加载脚本、主进程IPC处理器与前端页面的背景图相关逻辑
- 更新AGENTS.md开发文档,修正路径与逻辑说明
- 更新应用背景图资源文件
This commit is contained in:
2026-09-04 19:34:58 +08:00
parent cd4578de5a
commit fad8f612bf
22 changed files with 757 additions and 196 deletions
+5 -4
View File
@@ -27,8 +27,8 @@ pnpm dist:run # production icon + Windows installer
- **Preload script**: `electron/preload.ts` exposes `window.electronAPI` via context bridge.
- **IPC handlers**: All handlers are in `electron/handlers/` directory.
- **Mutable win ref**: `electron/main.ts` uses a mutable `win` object — handlers read `win.mainWindow` at runtime, not at registration time.
- **Config**: YAML format (`Koring.yml`) stored next to executable. Sparse save (only non-default values).
- **Auth**: JSON file (`koring-auth.json`) stored next to executable.
- **Config**: YAML format (`Koring.yml`) stored in userData (packaged) / project root (dev). Sparse save (only non-default values). ⚠️ 不可放安装目录:NSIS 重装/升级会经旧卸载器删除整个安装目录。
- **Auth**: JSON file (`koring-auth.json`) stored in userData (packaged) / project root (dev).
- **Path alias**: `@/` maps to `src/` (configured in `vite.config.ts` and `tsconfig.json`).
- **Dev mode**: Vite runs on port 1420, Electron loads from localhost.
- **Asset paths**: Use `import.meta.env.BASE_URL` prefix for public assets. Absolute paths break in packaged app.
@@ -66,7 +66,8 @@ build/ ← generated by switch-icon.js (gitignored)
- `electron/config.ts`: YAML config management (sparse save)
- `electron/auth.ts`: Auth data persistence (JSON file)
- `electron/core/`: @xmcl/* integrations (auth, installer, launcher, modrinth, instance)
- `electron/core/background-image.ts`: 背景图处理服务(自选背景降采样/重编码,程序本体资源管理
- `electron/core/background-image.ts`: 背景图处理服务 —— 自选壁纸复制到 userData 并按屏幕尺寸降采样/重编码**落盘**,配置文件只存**文件路径**(不使用 BASE64
- `electron/resource-protocol.ts`: `koring-res://` 特权自定义协议 —— 渲染进程以「资源引用」流式读取本地壁纸;仅服务 userData 内 `background-custom*` 白名单文件(realpath 二次校验,防目录穿越)
- `electron/handlers/`: IPC handlers (config, auth, install, launch, mods, instance, background, task, system, window)
## Frontend notes
@@ -78,7 +79,7 @@ build/ ← generated by switch-icon.js (gitignored)
- `registry.ts` 资源注册表服务(acquire/release、引用计数、预算 + LRU 逐出、onRelease 释放回调)
- `store.ts` 注册表 → zustand 镜像(调试面板消费)
- `image.ts` 图片解码管线(按显示尺寸降采样)、`hooks.ts`/`ManagedImage.tsx` 复用组件(供列表缩略图)
- 当前接线点:`BackgroundLayer` 把当前背景登记为 `background` 类资源;主进程 `background:prepare` 在进渲染端前压小大图
- 当前接线点:`BackgroundLayer` 把当前背景dataURL 或 koring-res 引用)登记为 `background` 类资源;自选壁纸经 `background:import` 落盘、配置存文件路径、渲染端经 `background:resolve``koring-res://` 引用(全程无 base64
- 监控入口:debug 页「资源与内存」(`debug-resource`)
- `src/hooks/useTheme.ts`: Dark mode sync with Electron theme
- `src/components/system/WindowControls.tsx`: Custom window controls (min/max/close), uses `<button>` with `WebkitAppRegion: "no-drag"`
+1 -1
View File
@@ -1,2 +1,2 @@
appVersion: 1.2.5
appVersion: 1.2.6
oobe: false
File diff suppressed because one or more lines are too long
+4 -3
View File
@@ -9,13 +9,14 @@ const CURRENT_VERSION = 1;
/**
* 配置文件路径:
* - 打包后 → 安装目录(可执行文件旁)。默认 per-user 安装(%LOCALAPPDATA%\Programs)可写;
* 若当初选择 per-machine 安装(Program Files)会无写权限,属已知限制
* - 打包后 → 系统用户数据目录(userData)。
* ⚠️ 不能放安装目录:NSIS 每次重装/升级都会经旧卸载器 RMDir /r 删除整个安装目录,
* /KEEP_APP_DATA 只保护 %APPDATA%userData),安装目录内的配置文件会被清空
* - 开发模式 → 项目根目录(与旧行为一致,方便调试)
*/
export function configPath(): string {
if (app.isPackaged) {
return path.join(path.dirname(app.getPath('exe')), CONFIG_FILE);
return path.join(app.getPath('userData'), CONFIG_FILE);
}
return path.join(__dirname, '..', CONFIG_FILE);
}
+219 -76
View File
@@ -1,15 +1,11 @@
/**
* 背景图处理服务(主进程,程序本体资源管理)。
*
* 目标:把用户自选的大图背景在进入渲染进程前,降采样到
* 「窗口实际需要」的尺寸(长边按 maxEdge 限制),从而避免
* 数 MB~数十 MB 的原图以 base64 + 全分辨率解码的形式常驻内存,
* 且不改变可见显示效果(超出屏幕物理像素的部分在视觉上不可见)。
*
* 规则:
* - 长边 ≤ maxEdge → 原样返回(零损耗,效果 100% 一致);
* - 长边 > maxEdge → 等比 resize 后重编码(JPEG 有损 q0.9 / 带透明通道用 PNG 无损);
* - 动画 GIF / 解析失败 / 无法解码 → 返回 null 或原样,由调用方回退到原始文件(不改变现有行为)。
* 职责:
* 1. 把用户自选的大图背景降采样/重编码后**落盘**(只生成屏幕所需尺寸),
* 返回**文件路径**(供配置文件以路径形式存储,不再使用 BASE64 dataURL);
* 2. 提供壁纸文件定位/安全校验工具,供 `koring-res://` 协议处理器使用
* (仅允许访问 userData 目录下 `background-custom*` 白名单文件,防目录穿越)。
*/
import electron from 'electron';
@@ -18,15 +14,17 @@ import * as path from 'path';
const { nativeImage } = electron;
export interface PreparedBackground {
dataUrl: string | null;
export interface OptimizedBackground {
/** 实际使用的文件路径(优化后文件;无需优化时为原始缓存文件) */
filePath: string;
bytes: number;
width: number;
height: number;
/** 是否发生了降采样/重编码 */
optimized: boolean;
}
const MIME_MAP: Record<string, string> = {
const EXT_MIME: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
@@ -35,75 +33,220 @@ const MIME_MAP: Record<string, string> = {
'.bmp': 'image/bmp',
};
const TRANSPARENT_MIMES = new Set(['image/png', 'image/webp', 'image/gif']);
function bufferToDataUrl(buffer: Buffer, mime: string): string {
return `data:${mime};base64,${buffer.toString('base64')}`;
}
const MIME_TO_EXT: Record<string, string> = {
'image/png': '.png',
'image/jpeg': '.jpg',
'image/webp': '.webp',
'image/gif': '.gif',
'image/bmp': '.bmp',
};
/**
* 解析并(按需)优化背景图片,返回可直接给 CSS 使用的 data URL
* @param srcPath 原图文件路径
* @param maxEdge 目标长边上限(像素),默认 4096
* 旧版配置若仍存 BASE64 dataURL 且 userData 里没有原始缓存文件时,
* 直接把 dataURL 解码落盘为 `background-custom<ext>`,保证配置文件能迁移为「文件路径」存储。
*/
export function prepareBackgroundImage(srcPath: string, maxEdge = 4096): PreparedBackground | null {
const raw: PreparedBackground = { dataUrl: null, bytes: 0, width: 0, height: 0, optimized: false };
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 = fs.readFileSync(srcPath);
const ext = path.extname(srcPath).toLowerCase();
const mime = MIME_MAP[ext] || 'image/png';
// 动画 GIFnativeImage 只能解码首帧,直接原样返回,避免破坏动画
if (mime === 'image/gif') {
raw.dataUrl = bufferToDataUrl(buffer, mime);
raw.bytes = buffer.length;
return raw;
}
const image = nativeImage.createFromBuffer(buffer);
if (image.isEmpty()) return null;
const size = image.getSize();
const longEdge = Math.max(size.width, size.height);
const needResize = longEdge > maxEdge && size.width > 0 && size.height > 0;
// 仅当确实需要降尺寸时才做重编码(视觉零影响的边界:超出屏幕物理像素的部分不可见);
// 未超限但体积大的图原样返回,避免任何有损重编码改变显示效果。
if (!needResize) {
raw.dataUrl = bufferToDataUrl(buffer, mime);
raw.bytes = buffer.length;
raw.width = size.width;
raw.height = size.height;
return raw;
}
let output = image;
const scale = maxEdge / longEdge;
const w = Math.max(1, Math.round(size.width * scale));
const h = Math.max(1, Math.round(size.height * scale));
output = image.resize({ width: w, height: h, quality: 'best' });
if (output.isEmpty()) return null;
const outSize = output.getSize();
const hasTransparency = TRANSPARENT_MIMES.has(mime);
let outBuffer: Buffer;
let outMime: string;
if (hasTransparency) {
outBuffer = output.toPNG();
outMime = 'image/png';
} else {
outBuffer = output.toJPEG(90);
outMime = 'image/jpeg';
}
if (outBuffer.length === 0) return null;
raw.dataUrl = bufferToDataUrl(outBuffer, outMime);
raw.bytes = outBuffer.length;
raw.width = outSize.width;
raw.height = outSize.height;
raw.optimized = true;
return raw;
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 {
// 动画 GIFnativeImage 只能解码首帧,不处理,保持原样
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');
}
+37 -4
View File
@@ -2,7 +2,8 @@ import electron from 'electron';
import fs from 'fs';
import path from 'path';
import { loadConfig, saveConfig } from '../config';
import { prepareBackgroundImage } from '../core/background-image';
import { importUserBackground, isManagedBackgroundFile, isPathInside } from '../core/background-image';
import { backgroundResourceUrl } from '../resource-protocol';
const { ipcMain } = electron;
@@ -119,13 +120,45 @@ export function registerBackgroundHandlers() {
}
});
// 背景图降采样/重编码(程序本体资源管理):把大图在进渲染进程前压到屏幕所需尺寸
ipcMain.handle('background:prepare', async (_event, payload: { srcPath: string; maxEdge?: number }) => {
// 导入自选壁纸:复制到 userData → 按屏幕所需尺寸降采样落盘,
// 返回【文件路径】(配置文件以路径存储,不再使用 BASE64 dataURL
ipcMain.handle('background:import', async (_event, payload: { srcPath: string; maxEdge?: number }) => {
try {
const result = prepareBackgroundImage(payload.srcPath, payload.maxEdge || 4096);
const result = importUserBackground(payload.srcPath, payload.maxEdge || 4096);
return { success: true, data: result, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
// 把配置/Store 中的「壁纸值」解析为可显示的资源引用:
// - data:/http(s):/file: 及 /、./ 相对 URL → 原样返回(本身可直接用于 CSS);
// - userData 内的受管壁纸文件路径 → koring-res:// 协议 URL(流式读取,全程无 base64 副本);
// - 其它(越权路径/不存在等)→ data:null,调用方回退默认背景。
ipcMain.handle('background:resolve', async (_event, payload: { value?: string }) => {
try {
const value = typeof payload?.value === 'string' ? payload.value.trim() : '';
if (!value) return { success: true, data: null, error: null };
if (/^(data:|https?:|file:|\.\.\/|\/|\.\/)/i.test(value)) {
return { success: true, data: { url: value, bytes: 0 }, error: null };
}
const userDataDir = electron.app.getPath('userData');
if (!path.isAbsolute(value) || !isPathInside(userDataDir, value)) {
return { success: true, data: null, error: null };
}
const fileName = path.basename(value);
if (!isManagedBackgroundFile(fileName)) {
return { success: true, data: null, error: null };
}
const stat = fs.statSync(value);
if (!stat.isFile()) return { success: true, data: null, error: null };
return {
success: true,
data: { url: backgroundResourceUrl(fileName), bytes: stat.size },
error: null,
};
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
}
+53 -12
View File
@@ -17,6 +17,8 @@ import { registerJavaHandlers } from './handlers/java';
import { registerUpdateHandlers } from './handlers/update';
import { updateService } from './updater';
import { saveConfig, configExists, getConfig, flushConfig, configPath } from './config';
import { findCachedBackgroundRaw, optimizeBackgroundFile, recoverBackgroundFromDataUrl } from './core/background-image';
import { registerResourceSchemePrivileges, registerResourceProtocol } from './resource-protocol';
const { app } = electron;
@@ -26,20 +28,27 @@ const isDev = !app.isPackaged;
app.commandLine.appendSwitch('enable-gpu-rasterization');
app.commandLine.appendSwitch('enable-zero-copy');
// 自定义资源协议(koring-res://)特权注册:必须在 app ready 之前完成
registerResourceSchemePrivileges();
const win: { mainWindow: electron.BrowserWindow | null; splashWindow: electron.BrowserWindow | null } = {
mainWindow: null,
splashWindow: null,
};
// 迁移旧版 userData 存储 → 安装目录(仅打包模式;配置已改回存安装目录)。
// 复制而非移动,避免破坏用户已有文件;安装目录已有目标文件则跳过。
// 首次启动标记(由 runStartupChecks 决定);窗口每次加载/刷新时随 config:preload 一起推送
let isFirstLaunchFlag = false;
// 迁移旧版「安装目录」存储 → userData(仅打包模式)。配置必须存 userData 才能跨重装/更新
// 存活:NSIS 每次重装/升级会经旧卸载器删除整个安装目录(RMDir /r),/KEEP_APP_DATA 只保护 %APPDATA%。
// 复制而非移动,避免破坏用户已有文件;userData 已有目标文件则跳过。
function migrateLegacyFiles(): void {
if (!app.isPackaged) return;
const exeDir = path.dirname(app.getPath('exe'));
const dest = path.join(exeDir, 'Koring.yml');
if (fs.existsSync(dest)) return;
const src = path.join(app.getPath('userData'), 'Koring.yml');
const src = path.join(exeDir, 'Koring.yml');
if (!fs.existsSync(src)) return;
const dest = path.join(app.getPath('userData'), 'Koring.yml');
if (fs.existsSync(dest)) return;
try {
fs.copyFileSync(src, dest);
console.log(`[migrate] copied Koring.yml ${src}${dest}`);
@@ -74,6 +83,28 @@ function runStartupChecks(): { isFirstLaunch: boolean; config: ReturnType<typeof
return { isFirstLaunch, config };
}
// 迁移旧版「配置里存 BASE64 dataURL」→ 「存文件路径」:
// 优先使用 userData 里已有的原始缓存;没有缓存时直接把 dataURL 解码落盘,
// 保证任意旧配置都能改写成文件路径(显示内容与原 dataURL 完全一致)。
function migrateBackgroundDataUrlToPath(config: ReturnType<typeof getConfig>): void {
const bg = config?.background;
if (!bg || bg.bgType !== 'image' || typeof bg.image !== 'string' || !bg.image.startsWith('data:')) {
return;
}
try {
const userDataDir = app.getPath('userData');
const raw = findCachedBackgroundRaw(userDataDir) ?? recoverBackgroundFromDataUrl(bg.image, userDataDir);
if (!raw) return;
const result = optimizeBackgroundFile(raw, 4096);
if (!result || !result.filePath) return;
bg.image = result.filePath;
saveConfig(config);
console.log('[background] 迁移:dataURL → 文件路径', result.filePath);
} catch (e) {
console.error('[background] 背景配置迁移失败:', e);
}
}
// 根据调试/运行状态自动选择窗口图标:
// - 开发调试(未打包):直接使用 public/icons/dev/ 下的 dev 图标
// - 打包运行:electron-builder 构建时 switch-icon 已把对应模式图标复制到 build/
@@ -139,6 +170,14 @@ function createMainWindow(): electron.BrowserWindow {
main.loadFile(path.join(__dirname, '../dist/index.html'));
}
// 每次页面加载完成(含 Ctrl+R / F5 刷新、HMR 全量重载)都推送【最新权威配置】给渲染端:
// - 用 getConfig()(主进程内存权威)而非启动时的快照,刷新后拿到的是当前值而不是过期快照
// - 监听器挂在具体窗口实例上,后续重建的窗口(如 macOS activate)也能收到
main.webContents.on('did-finish-load', () => {
if (main.isDestroyed()) return;
main.webContents.send('config:preload', { config: getConfig(), isFirstLaunch: isFirstLaunchFlag });
});
main.on('maximize', () => {
main.webContents.send('window:resized');
});
@@ -169,25 +208,27 @@ function registerAllHandlers() {
app.whenReady().then(() => {
registerAllHandlers();
registerResourceProtocol();
// Migrate legacy userData config to install dir before anything reads them
// Migrate legacy install-dir config back to userData before anything reads them
migrateLegacyFiles();
// Run startup checks before creating windows
const { isFirstLaunch, config } = runStartupChecks();
isFirstLaunchFlag = isFirstLaunch;
// 旧配置中 background.image 若是 BASE64 dataURL → 落盘优化并改写为文件路径
migrateBackgroundDataUrlToPath(config);
// 1. Show splash immediately
win.splashWindow = createSplashWindow();
// 2. Create main window in background
// config:preload 推送已内置于 createMainWindow 的 did-finish-load 监听,
// 每次加载/刷新都推送 getConfig() 的最新内存配置)
win.mainWindow = createMainWindow();
// 3. Preload config into renderer before it renders
win.mainWindow.webContents.on('did-finish-load', () => {
win.mainWindow?.webContents.send('config:preload', { config, isFirstLaunch });
});
// 4. When main window finishes loading, wait a minimum time then transition
// 3. When main window finishes loading, wait a minimum time then transition
let mainReady = false;
let splashMinTimeDone = false;
+26 -51
View File
@@ -1,30 +1,8 @@
import electron from 'electron';
import path from 'path';
import fs from 'fs';
const { contextBridge, ipcRenderer } = electron;
const MIME_MAP: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.gif': 'image/gif',
'.bmp': 'image/bmp',
};
function getFileAsDataUrl(filePath: string): string | null {
try {
const buffer = fs.readFileSync(filePath);
const ext = path.extname(filePath).toLowerCase();
const mime = MIME_MAP[ext] || 'image/png';
return `data:${mime};base64,${buffer.toString('base64')}`;
} catch {
return null;
}
}
// 按当前窗口实际像素需求计算背景图长边上限(含高分屏余量),
// 避免把数 MB~数十 MB 原图原样塞进渲染进程。
// 按当前窗口实际像素需求计算壁纸长边上限(含高分屏余量),
// 主进程据此把大图压到「屏幕可见」尺寸后落盘(配置只存文件路径,不存 BASE64)。
function computeMaxEdge(): number {
const dpr = window.devicePixelRatio || 1;
const css = Math.max(window.innerWidth || 1280, window.innerHeight || 800);
@@ -32,22 +10,6 @@ function computeMaxEdge(): number {
return Math.min(4096, Math.max(1920, target));
}
// 经主进程降采样/重编码后返回 data URL;主进程无法处理时回退到原始文件(行为不变)。
async function prepareBackgroundDataUrl(filePath: string): Promise<string | null> {
try {
const result = (await ipcRenderer.invoke('background:prepare', {
srcPath: filePath,
maxEdge: computeMaxEdge(),
})) as { success?: boolean; data?: { dataUrl?: string | null } | null };
if (result?.success && typeof result.data?.dataUrl === 'string' && result.data.dataUrl.length > 0) {
return result.data.dataUrl;
}
} catch {
// fallthrough to raw
}
return getFileAsDataUrl(filePath);
}
contextBridge.exposeInMainWorld('electronAPI', {
// Generic IPC
invoke: (channel: string, ...args: unknown[]) =>
@@ -91,24 +53,37 @@ contextBridge.exposeInMainWorld('electronAPI', {
return () => ipcRenderer.removeListener('config:changed', handler);
},
// Background image — pick file, copy to userData, return base64 data URL
// 背景图 — 选择本地图片:主进程复制到 userData、按窗口尺寸优化并落盘,
// 返回【文件路径】(配置/Store 以路径保存,不使用 BASE64)。
pickBackgroundImage: async (): Promise<string | null> => {
const result = await ipcRenderer.invoke('dialog:openFile', {
filters: [{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'] }],
});
if (!result) return null;
const { srcPath, ext } = result as { srcPath: string; ext: string };
// Copy to userData via main process
const destPath = await ipcRenderer.invoke('background:copyToUserData', srcPath, ext);
if (!destPath) return null;
return prepareBackgroundDataUrl(destPath);
const { srcPath } = result as { srcPath: string };
const imported = await ipcRenderer.invoke('background:import', {
srcPath,
maxEdge: computeMaxEdge(),
});
if (imported && imported.success && typeof imported.data?.filePath === 'string' && imported.data.filePath) {
return imported.data.filePath;
}
return null;
},
// Get cached background as base64 data URL(自动降采样到屏幕所需尺寸)
getBackgroundDataUrl: async (): Promise<string | null> => {
const filePath = await ipcRenderer.invoke('background:getCachedPath');
if (!filePath) return null;
return prepareBackgroundDataUrl(filePath);
// 把配置文件/Store 中的壁纸值解析为可显示的资源引用:
// data:/http(s):/file: 及 /、./ 相对 URL → 原样返回;
// userData 内受管壁纸文件路径 → koring-res:// 协议 URL(流式读取,无 base64 副本)。
resolveBackgroundResource: async (value: string): Promise<{ url: string | null; bytes: number }> => {
try {
const result = await ipcRenderer.invoke('background:resolve', { value });
if (result && result.success && typeof result.data?.url === 'string' && result.data.url) {
return { url: result.data.url, bytes: typeof result.data.bytes === 'number' ? result.data.bytes : 0 };
}
} catch {
// fallthrough
}
return { url: null, bytes: 0 };
},
// Open external URL in system browser
+94
View File
@@ -0,0 +1,94 @@
/**
* `koring-res://` 自定义协议:让渲染进程以「资源引用」方式使用本地文件
* (不再把图片以 BASE64 dataURL 传入渲染进程 / 写入配置文件)。
*
* 权限/安全约束:
* - 只允许 host 为 `userdata`,即仅服务 app.getPath('userData') 目录;
* - 只允许文件名以 `background-custom` 开头的受管壁纸文件(白名单);
* - realpath 二次校验目标必须位于 userData 之内(防目录穿越 / 符号链接);
* - 以流方式返回文件(stream),渲染进程按需解码,主进程不产生 base64 副本。
*/
import electron from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import { Readable } from 'stream';
import { isManagedBackgroundFile, isPathInside, mimeForFile } from './core/background-image';
const { protocol, app } = electron;
export const RESOURCE_SCHEME = 'koring-res';
/** 必须在 app ready 之前调用(privileged scheme 注册) */
export function registerResourceSchemePrivileges(): void {
protocol.registerSchemesAsPrivileged([
{
scheme: RESOURCE_SCHEME,
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
corsEnabled: true,
stream: true,
},
},
]);
}
/** app ready 之后调用:注册协议处理器 */
export function registerResourceProtocol(): void {
protocol.handle(RESOURCE_SCHEME, async (request) => {
try {
const url = new URL(request.url);
if (url.host !== 'userdata') {
return new Response('Forbidden', { status: 403 });
}
let relative: string;
try {
relative = decodeURIComponent(url.pathname).replace(/^[/\\]+/, '');
} catch {
return new Response('Bad Request', { status: 400 });
}
// 路径穿越 / 绝对路径直接拒绝
if (!relative || relative.includes('..') || path.isAbsolute(relative)) {
return new Response('Forbidden', { status: 403 });
}
const fileName = path.basename(relative);
if (fileName !== relative || !isManagedBackgroundFile(fileName)) {
return new Response('Forbidden', { status: 403 });
}
const userDataDir = app.getPath('userData');
const target = path.join(userDataDir, fileName);
if (!isPathInside(userDataDir, target)) {
return new Response('Forbidden', { status: 403 });
}
const stat = await fs.promises.stat(target);
if (!stat.isFile()) {
return new Response('Not Found', { status: 404 });
}
const body = Readable.toWeb(fs.createReadStream(target)) as unknown as BodyInit;
return new Response(body, {
status: 200,
headers: {
'content-type': mimeForFile(target),
'content-length': String(stat.size),
'cache-control': 'no-store',
},
});
} catch {
return new Response('Not Found', { status: 404 });
}
});
}
/** 生成 userData 壁纸文件的协议 URLfileName 需已通过白名单校验) */
export function backgroundResourceUrl(fileName: string): string {
return `${RESOURCE_SCHEME}://userdata/${encodeURIComponent(fileName)}`;
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "koring-launcher",
"private": true,
"version": "1.2.5",
"version": "1.2.6",
"description": "Koring Launcher - Minecraft launcher built with Electron + React",
"author": "Shenzhen Lingke Network Technology Co., Ltd.",
"license": "LL-1.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 216 KiB

+43 -7
View File
@@ -25,6 +25,7 @@ import { UpdateDebug } from "./pages/debug/update-debug";
import { TaskDebug } from "./pages/debug/task-debug";
import { CrashDebug } from "./pages/debug/crash-debug";
import { ResourceDebug } from "./pages/debug/resource-debug";
import { RoutesDebug } from "./pages/debug/routes-debug";
import { Oobe } from "./pages/oobe";
import { OobeLanguage } from "./pages/oobe/step-language";
import { OobeAgreement } from "./pages/oobe/step-agreement";
@@ -76,6 +77,7 @@ const pageMap = {
"debug-task": TaskDebug,
"debug-crash": CrashDebug,
"debug-resource": ResourceDebug,
"debug-routes": RoutesDebug,
} as const;
function App() {
@@ -84,13 +86,18 @@ function App() {
const Page = pageMap[current];
useEffect(() => {
// Listen for preloaded config from main process
const unsub = window.electronAPI?.onConfigPreload((data) => {
const { config, isFirstLaunch } = data;
const cfg = config as AppConfig;
let done = false;
let cancelled = false;
// 配置引导(幂等,最多执行一次):
// 主进程的 config:preload 推送 与 config:get 兜底拉取,谁先完成谁引导,
// 避免重复同步派生 store / 重复导航 / 重复补写版本号。
const finishBoot = (cfg: AppConfig, isFirstLaunch: boolean) => {
if (done || cancelled) return;
done = true;
useConfigStore.getState().applyPreloaded(cfg, isFirstLaunch);
// 语言偏好 → <html lang>
document.documentElement.lang = (cfg as AppConfig).app?.language ?? "zh-CN";
document.documentElement.lang = cfg.app?.language ?? "zh-CN";
syncThemeFromConfig();
syncA11yFromConfig();
syncBackgroundFromConfig();
@@ -114,9 +121,38 @@ function App() {
if (cfg.appVersion !== VERSION) {
useRouteStore.getState().navigate("upvp/complete");
}
});
};
return () => { unsub?.(); };
const api = window.electronAPI;
let unsub: (() => void) | undefined;
if (api) {
// 主进程在每次 did-finish-load(含 Ctrl+R / F5 刷新)时推送的最新权威配置
unsub = api.onConfigPreload((data) => {
finishBoot(data.config as AppConfig, data.isFirstLaunch);
});
// 兜底:config:preload 是主进程在页面加载完成后的一次性推送,
// 若它早于本订阅到达(页面刷新/快速重载时的竞态)就会被丢弃,store 将永远停在默认值。
// 此时主动走 config:get 拉取主进程权威配置,保证刷新后配置一定能被读到。
if (!useConfigStore.getState().loaded) {
useConfigStore
.getState()
.init()
.then(() => {
if (cancelled) return;
const { config, isFirstLaunch } = useConfigStore.getState();
finishBoot(config, isFirstLaunch);
})
.catch((e) => {
console.error("[config] fallback init failed:", e);
});
}
}
return () => {
cancelled = true;
unsub?.();
};
}, []);
// 主进程权威配置广播 → 覆盖本地镜像并同步派生 store
+65 -24
View File
@@ -7,59 +7,98 @@ import { DEFAULT_BG } from "@/lib/mode";
import { resourceRegistry } from "@/resources/registry";
import { estimateDataUrlBytes } from "@/resources/image";
/** 可直接用于 CSS 的源(默认图/相对 URL/http(s)/file:/data: */
const isCssSource = (v: string) => /^(data:|https?:|file:|\/|\.\/|\.\.\/)/i.test(v);
export function BackgroundLayer() {
const { type, image, blur, opacity } = useBackgroundStore();
const parallax = useThemeStore((s) => s.parallax);
const route = useRouteStore((s) => s.current);
const forceDisableContentBlur = useDevStore((s) => s.forceDisableContentBlur);
const showContentBlur = route !== "home";
const [bgImage, setBgImage] = useState(image);
// bgImage:最终可显示的资源引用(CSS url 或 koring-res:// URL 或颜色)。
// 配置里若存的是本地文件路径(自定义壁纸),先置空等待主进程解析成 koring-res:// 引用,
// 避免把文件路径直接当 CSS url 使用 / 闪默认图。
const [bgImage, setBgImage] = useState<string | null>(() => {
if (type === "color" || !image) return image ?? null;
return isCssSource(image) ? image : null;
});
/** 解析后的文件字节(供资源注册表统计) */
const [bgBytes, setBgBytes] = useState(0);
const bgRef = useRef<HTMLDivElement>(null);
// 解析最终背景源:内置 URL / 颜色直接用;自定义路径经主进程取优化 dataURL。
// 增加代次守卫:防止慢的旧 IPC 结果覆盖用户最新选择(行为不变,仅修竞态)。
// 解析背景源:默认图/颜色/data: 等直接用;
// 本地文件路径 → 主进程校验后返回 koring-res:// 协议引用(流式,无 base64 副本)。
// 代次守卫:防止慢的旧解析结果覆盖用户最新选择(行为不变,仅修竞态)。
useEffect(() => {
let cancelled = false;
if (image && image !== DEFAULT_BG && !image.startsWith("data:")) {
(window as any).electronAPI?.getBackgroundDataUrl?.().then((dataUrl: string | null) => {
if (!cancelled && dataUrl) setBgImage(dataUrl);
});
} else {
setBgImage(image);
if (type === "color" || !image) {
setBgImage(image ?? null);
setBgBytes(0);
return () => {
cancelled = true;
};
}
if (image === DEFAULT_BG || isCssSource(image)) {
setBgImage(image);
setBgBytes(0);
return () => {
cancelled = true;
};
}
window.electronAPI?.resolveBackgroundResource?.(image).then((res) => {
if (cancelled || !res) return;
if (res.url) {
setBgImage(res.url);
setBgBytes(res.bytes || 0);
} else {
// 文件不可用/越权:回退默认背景(文件已失效,回退优于显示破损背景)
setBgImage(DEFAULT_BG);
setBgBytes(0);
}
});
return () => {
cancelled = true;
};
}, [image]);
}, [image, type]);
// 当前生效的背景登记进资源注册表(估算字节、单持有者语义):
// 背景切换时旧条目被释放丢弃,大 dataURL 字符串不再被缓存层额外持有。
// 背景切换时旧条目被释放丢弃,大 dataURL/引用不再被缓存层额外持有。
const trackedKey = useMemo(() => {
if (!bgImage || !bgImage.startsWith("data:")) return null;
// 只取前缀 + 长度作为 key,避免把整段数 MB 的 dataURL 重复存进 Map key
return `background:current:${bgImage.length}:${bgImage.slice(0, 96)}`;
if (!bgImage) return null;
if (bgImage.startsWith("data:")) {
// 只取前缀 + 长度作为 key,避免把整段 dataURL 重复存进 Map key
return `background:current:${bgImage.length}:${bgImage.slice(0, 96)}`;
}
if (bgImage.startsWith("koring-res:")) {
return `background:current:${bgImage}`;
}
return null;
}, [bgImage]);
useEffect(() => {
if (!trackedKey) return;
let cancelled = false;
const isFileRef = bgImage?.startsWith("koring-res:");
const bytes = isFileRef ? bgBytes : estimateDataUrlBytes(bgImage);
resourceRegistry
.acquire<string>(trackedKey, "background", {
bytes: estimateDataUrlBytes(bgImage),
bytes,
cache: false,
load: async () => bgImage,
load: async () => bgImage ?? "",
})
.then(() => {
if (!cancelled) {
resourceRegistry.setBytes(trackedKey, estimateDataUrlBytes(bgImage));
resourceRegistry.setBytes(trackedKey, bytes);
}
});
return () => {
cancelled = true;
resourceRegistry.release(trackedKey);
};
}, [trackedKey, bgImage]);
}, [trackedKey, bgImage, bgBytes]);
const handleMouseMove = useCallback(
(e: MouseEvent) => {
@@ -80,6 +119,7 @@ export function BackgroundLayer() {
return () => window.removeEventListener("mousemove", handleMouseMove);
}, [parallax, handleMouseMove]);
// 自定义壁纸在解析完成前不显示(等待 koring-res 引用),避免闪默认图
const bgUrl = bgImage || DEFAULT_BG;
const contentBlur = showContentBlur && !forceDisableContentBlur;
@@ -105,12 +145,13 @@ export function BackgroundLayer() {
};
}
return {
...base,
backgroundImage: `url(${bgUrl})`,
backgroundSize: "cover",
backgroundPosition: "center",
};
const style: React.CSSProperties = { ...base };
if (bgImage) {
style.backgroundImage = `url(${bgUrl})`;
style.backgroundSize = "cover";
style.backgroundPosition = "center";
}
return style;
};
return (
+9 -1
View File
@@ -1,7 +1,15 @@
import { useRouteStore } from "@/stores/routeStore";
import { Monitor, Paintbrush, CreditCard, ListTodo, ChevronRight, FlaskConical, Rocket, AlertTriangle, RefreshCw, SquareTerminal, MemoryStick } from "lucide-react";
import { Waypoints, Monitor, Paintbrush, CreditCard, ListTodo, ChevronRight, FlaskConical, Rocket, AlertTriangle, RefreshCw, SquareTerminal, MemoryStick } from "lucide-react";
const debugPages = [
{
key: "debug-routes" as const,
icon: Waypoints,
title: "页面跳转",
desc: "列出所有已注册的页面(含隐藏页),点击即可快速跳转预览",
color: "text-sky-500",
bg: "bg-sky-500/10",
},
{
key: "debug-update" as const,
icon: RefreshCw,
+137
View File
@@ -0,0 +1,137 @@
import { useMemo, useState } from "react";
import clsx from "clsx";
import { ChevronRight, Search } from "lucide-react";
import { allRoutes, routes, useRouteStore } from "@/stores/routeStore";
import type { RouteKey } from "@/stores/routeStore";
import { Input } from "@/components/ui/input";
import { PageHeader } from "./components";
type GroupKey = "main" | "app" | "debug" | "oobe" | "upvp";
const GROUPS: { key: GroupKey; title: string; desc: string }[] = [
{ key: "main", title: "主导航", desc: "顶部导航栏显示的页面" },
{ key: "app", title: "功能子页", desc: "从主导航进入的二级页面" },
{ key: "debug", title: "调试工具", desc: "开发者工具页面" },
{ key: "oobe", title: "OOBE 引导", desc: "首次启动开箱引导流程页面" },
{ key: "upvp", title: "更新引导", desc: "版本更新引导流程页面" },
];
/** 顶部导航的页面集合 */
const topLevelKeys = new Set<RouteKey>(routes.map((r) => r.key));
function groupOf(key: RouteKey): GroupKey {
if (topLevelKeys.has(key)) return "main";
if (key === "oobe" || key.startsWith("oobe/")) return "oobe";
if (key === "upvp" || key.startsWith("upvp/")) return "upvp";
if (key === "debug" || key.startsWith("debug-")) return "debug";
return "app";
}
/** 页面跳转:列出全部已注册页面,点击即可跳转 */
export function RoutesDebug() {
const current = useRouteStore((s) => s.current);
const navigate = useRouteStore((s) => s.navigate);
const [query, setQuery] = useState("");
const grouped = useMemo(() => {
const q = query.trim().toLowerCase();
const buckets = new Map<GroupKey, typeof allRoutes>();
for (const route of allRoutes) {
if (q) {
const hay = `${route.label} ${route.key} ${route.path}`.toLowerCase();
if (!hay.includes(q)) continue;
}
const g = groupOf(route.key);
const list = buckets.get(g) ?? [];
list.push(route);
buckets.set(g, list);
}
return GROUPS.map((g) => ({
...g,
items: buckets.get(g.key) ?? [],
})).filter((g) => g.items.length > 0);
}, [query]);
const total = allRoutes.length;
return (
<div className="max-w-2xl mx-auto p-8">
<PageHeader
title="页面跳转"
desc={`启动器当前注册了 ${total} 个页面,点击任意条目即可跳转预览`}
/>
{/* 搜索过滤 */}
<div className="relative mb-6">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground/40" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索页面名称 / 路由 key / 路径…"
className="pl-9"
/>
</div>
<div className="space-y-7">
{grouped.map((group) => (
<div key={group.key}>
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider">
{group.title}
<span className="ml-2 normal-case font-normal text-[12px] text-muted-foreground/60">
{group.desc}
</span>
</h3>
<div className="mt-2 space-y-1.5">
{group.items.map((route) => {
const active = route.key === current;
return (
<button
key={route.key}
onClick={() => navigate(route.key)}
disabled={active}
className={clsx(
"glass-card w-full px-4 py-3 text-left flex items-center gap-3 transition-all",
!active &&
"hover:scale-[1.01] active:scale-[0.99] cursor-pointer group",
active && "opacity-80 cursor-default",
)}
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground flex items-center gap-2">
{route.label}
{route.hidden && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-foreground/[0.06] text-muted-foreground/70">
</span>
)}
{active && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-foreground/[0.08] text-foreground/70">
</span>
)}
</p>
<p className="font-mono text-[11px] text-muted-foreground/70 mt-0.5 truncate">
{route.key} · {route.path}
</p>
</div>
<ChevronRight className="w-4 h-4 shrink-0 text-foreground/25 group-hover:text-foreground/50" />
</button>
);
})}
</div>
</div>
))}
{grouped.length === 0 && (
<p className="text-sm text-muted-foreground/60 text-center py-10">
{query.trim()}
</p>
)}
</div>
<p className="text-[12px] text-muted-foreground/50 mt-8 text-center">
OOBE /
</p>
</div>
);
}
+10 -1
View File
@@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import { useRouteStore } from "@/stores/routeStore";
import { useConfigStore } from "@/stores/configStore";
import { OobeLayout } from "./layout";
@@ -7,6 +8,13 @@ export function OobeFinish() {
const navigate = useRouteStore((s) => s.navigate);
const setOobe = useConfigStore((s) => s.setOobe);
// 「前往首页」按钮先隐藏,hello 动画播完后(4 秒)渐入浮现
const [btnVisible, setBtnVisible] = useState(false);
useEffect(() => {
const t = setTimeout(() => setBtnVisible(true), 4000);
return () => clearTimeout(t);
}, []);
const handleFinish = () => {
setOobe(false);
navigate("home");
@@ -21,7 +29,8 @@ export function OobeFinish() {
<div className="absolute bottom-12">
<button
onClick={handleFinish}
className="h-12 px-6 rounded-full bg-foreground/[0.06] hover:bg-foreground/[0.12] flex items-center justify-center text-foreground/60 hover:text-foreground transition-all duration-200 text-sm font-medium"
className="h-12 px-6 rounded-full bg-foreground/[0.06] hover:bg-foreground/[0.12] flex items-center justify-center text-foreground/60 hover:text-foreground transition-opacity duration-700 ease-out text-sm font-medium"
style={{ opacity: btnVisible ? 1 : 0, pointerEvents: btnVisible ? "auto" : "none" }}
>
</button>
+35 -8
View File
@@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import { useThemeStore, type DarkMode } from "@/stores/themeStore";
import { useBackgroundStore } from "@/stores/backgroundStore";
import { Switch, Button, Slider } from "@heroui/react";
@@ -5,6 +6,9 @@ import { DEFAULT_BG } from "@/lib/mode";
import clsx from "clsx";
import { SettingCard, SettingRow, PageHeader, SectionTitle } from "@/components/setting";
/** 可直接用于 CSS/`<img>` 的源(data:/http(s):/file:/相对 URL */
const isCssSource = (v: string) => /^(data:|https?:|file:|\/|\.\/|\.\.\/)/i.test(v);
function ThemePreviewCard({ mode, selected, onClick }: { mode: DarkMode; selected: boolean; onClick: () => void }) {
const isDark = mode === "dark";
const isAuto = mode === "auto";
@@ -85,11 +89,30 @@ function ThemePreviewCard({ mode, selected, onClick }: { mode: DarkMode; selecte
export function ThemeBgSetting() {
const { darkMode, setDarkMode, parallax, setParallax } = useThemeStore();
const { image, opacity, setOpacity, blur, setBlur, setImage, reset } = useBackgroundStore();
// 预览图:配置文件里存的是文件路径 → 主进程解析为 koring-res:// 引用再显示
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
if (!image || image === DEFAULT_BG || isCssSource(image)) {
setPreviewUrl(null);
return () => {
cancelled = true;
};
}
window.electronAPI?.resolveBackgroundResource?.(image).then((res) => {
if (!cancelled) setPreviewUrl(res?.url ?? null);
});
return () => {
cancelled = true;
};
}, [image]);
const handlePickImage = async () => {
const dataUrl = await (window as any).electronAPI?.pickBackgroundImage();
if (dataUrl) {
setImage(dataUrl);
// 返回的是 userData 内的文件路径(配置/Store 以路径保存,不使用 BASE64)
const filePath = await window.electronAPI?.pickBackgroundImage?.();
if (filePath) {
setImage(filePath);
}
};
@@ -124,11 +147,15 @@ export function ThemeBgSetting() {
</SettingRow>
{image && image !== DEFAULT_BG && (
<div className="mt-3 rounded-lg overflow-hidden border border-border/50">
<img
src={image}
alt="背景预览"
className="w-full h-[120px] object-cover"
/>
{previewUrl ? (
<img src={previewUrl} alt="背景预览" className="w-full h-[120px] object-cover" />
) : isCssSource(image) ? (
<img src={image} alt="背景预览" className="w-full h-[120px] object-cover" />
) : (
<div className="w-full h-[120px] grid place-items-center text-[12px] text-muted-foreground/70 bg-foreground/[0.03]">
</div>
)}
</div>
)}
</SettingCard>
+9 -1
View File
@@ -20,6 +20,13 @@ export function UpvpFinish() {
const [ready, setReady] = useState(false);
const [shouldWrite, setShouldWrite] = useState(false);
// 「前往首页」按钮先隐藏,hello 动画播完后(4 秒)渐入浮现
const [btnVisible, setBtnVisible] = useState(false);
useEffect(() => {
const t = setTimeout(() => setBtnVisible(true), 4000);
return () => clearTimeout(t);
}, []);
useEffect(() => {
let cancelled = false;
compareVersions(VERSION, appVersion)
@@ -58,7 +65,8 @@ export function UpvpFinish() {
<button
onClick={handleFinish}
disabled={!ready}
className="h-12 px-6 rounded-full bg-foreground/[0.06] hover:bg-foreground/[0.12] flex items-center justify-center text-foreground/60 hover:text-foreground transition-all duration-200 text-sm font-medium disabled:opacity-40 disabled:cursor-not-allowed"
className="h-12 px-6 rounded-full bg-foreground/[0.06] hover:bg-foreground/[0.12] flex items-center justify-center text-foreground/60 hover:text-foreground transition-opacity duration-700 ease-out text-sm font-medium disabled:opacity-40 disabled:cursor-not-allowed"
style={{ opacity: btnVisible ? (ready ? 1 : undefined) : 0, pointerEvents: btnVisible ? "auto" : "none" }}
>
</button>
+4 -1
View File
@@ -33,7 +33,8 @@ export type RouteKey =
| "debug-update"
| "debug-task"
| "debug-resource"
| "debug-crash";
| "debug-crash"
| "debug-routes";
export type TitleBarMode = "default" | "sub" | "window" | "oobe";
export type TransitionDirection = "forward" | "backward";
@@ -83,6 +84,8 @@ export const allRoutes: RouteItem[] = [
{ key: "debug-update", label: "更新功能测试", path: "/debug/update", hidden: true },
{ key: "debug-task", label: "任务队列调试", path: "/debug/task", hidden: true },
{ key: "debug-resource", label: "资源与内存", path: "/debug/resource", hidden: true },
{ key: "debug-crash", label: "崩溃测试", path: "/debug/crash", hidden: true },
{ key: "debug-routes", label: "页面跳转", path: "/debug/routes", hidden: true },
];
const topLevelKeys = new Set(routes.map((r) => r.key));
+4
View File
@@ -25,6 +25,10 @@ interface ElectronAPI {
// Config reset
resetConfig: () => Promise<void>;
// 壁纸(文件路径存储 → koring-res:// 资源引用,不使用 BASE64
pickBackgroundImage: () => Promise<string | null>;
resolveBackgroundResource: (value: string) => Promise<{ url: string | null; bytes: number }>;
// Auto-update
checkForUpdates: (manual?: boolean) => Promise<unknown>;
downloadUpdate: () => Promise<unknown>;