mirror of
https://github.com/dream-pep/koring-launcher.git
synced 2026-09-12 05:45:18 +08:00
Compare commits
14
Commits
v1.2.6-beta.20
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74512bf935 | ||
|
|
aef49b173f | ||
|
|
4acdad6535 | ||
|
|
3b627389c9 | ||
|
|
caf52b9a3d | ||
|
|
37a5bb7538 | ||
|
|
ffac581018 | ||
|
|
1785d36797 | ||
|
|
bef1bbd85b | ||
|
|
74b7456886 | ||
|
|
dfda648147 | ||
|
|
a11e51d62c | ||
|
|
e861661a55 | ||
|
|
d5004b61d8 |
@@ -13,7 +13,8 @@
|
||||
# - run :创建正式 release
|
||||
# - Release 标题命名(仅展示名,tag/真实版本号不变):run → "{base}"(如 1.2.1),beta → "BETA {base}"(如 BETA 1.2.1)
|
||||
# - Release 正文为中文:版本信息(当前版本 / 编译状态 / 构建来源 commit)+ 提交记录(默认折叠)
|
||||
# - 上传产物:koring-launcher-{base}-{buildId}-setup.exe + latest.yml(electron-updater 更新清单)
|
||||
# - 上传产物(Windows job):koring-launcher-{full}-setup.exe + latest.yml
|
||||
# - Linux(AppImage)由 build-linux job 追加:*.AppImage + latest-linux.yml(beta 另加 beta-linux.yml)
|
||||
# - 构建元数据(commit / buildId)写入 src/lib/buildInfo.ts,打包进渲染层供 UI 显示
|
||||
#
|
||||
# Secrets:
|
||||
@@ -118,7 +119,7 @@ jobs:
|
||||
$buildId = "$env:GITHUB_RUN_NUMBER"
|
||||
$base = node scripts/version.js get
|
||||
if ("${{ inputs.mode }}" -eq "beta") {
|
||||
$full = node scripts/version.js build ci "beta.$buildId"
|
||||
$full = node scripts/version.js build ci "$buildId.beta"
|
||||
} else {
|
||||
$full = node scripts/version.js build ci "$buildId"
|
||||
}
|
||||
@@ -206,3 +207,99 @@ jobs:
|
||||
$ghArgs += "--prerelease"
|
||||
}
|
||||
gh release create @ghArgs
|
||||
|
||||
# ==================== Linux(AppImage)====================
|
||||
# electron-updater 对 Linux 只原生支持 AppImage 自动更新(deb/rpm 走系统包管理器)。
|
||||
# Windows job 创建 Release 后,本 job 把 AppImage + latest-linux.yml 上传到同一 Release。
|
||||
# ⚠️ Linux 清单文件带 -linux 前缀:provider 取 latest-linux.yml(beta 先试 beta-linux.yml 再回退),
|
||||
# 必须上传,否则 Linux 端更新取不到版本。
|
||||
build-linux:
|
||||
needs: build-sign-publish
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_MIRROR: https://npmmirror.com/mirrors/electron/
|
||||
ELECTRON_BUILDER_BINARIES_MIRROR: https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.ref || '' }}
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11.7.0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# 与 Windows job 相同逻辑:BUILD ID = 同一 workflow run 的 RUN_NUMBER,产物版本一致
|
||||
- name: Set version (same RUN_NUMBER)
|
||||
id: linuxver
|
||||
shell: pwsh
|
||||
run: |
|
||||
$buildId = "$env:GITHUB_RUN_NUMBER"
|
||||
if ("${{ inputs.mode }}" -eq "beta") {
|
||||
$full = node scripts/version.js build ci "$buildId.beta"
|
||||
} else {
|
||||
$full = node scripts/version.js build ci "$buildId"
|
||||
}
|
||||
"full=$full" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
|
||||
Write-Host "Linux version: $full"
|
||||
|
||||
- name: Generate build info
|
||||
shell: pwsh
|
||||
run: node scripts/gen-build-info.js ${{ inputs.mode }}
|
||||
env:
|
||||
BUILD_ID: ${{ github.run_number }}
|
||||
|
||||
- name: Build renderer + main (${{ inputs.mode }})
|
||||
shell: pwsh
|
||||
run: |
|
||||
if ("${{ inputs.mode }}" -eq "beta") {
|
||||
pnpm build:beta
|
||||
pnpm icon:beta
|
||||
} else {
|
||||
pnpm build:run
|
||||
pnpm icon:run
|
||||
}
|
||||
|
||||
- name: Package AppImage
|
||||
shell: pwsh
|
||||
run: |
|
||||
for ($attempt = 1; $attempt -le 2; $attempt++) {
|
||||
pnpm exec electron-builder --linux AppImage --publish never
|
||||
if ($LASTEXITCODE -eq 0) { break }
|
||||
Write-Host "electron-builder(AppImage) 失败(第 $attempt 次,exit=$LASTEXITCODE),5 秒后重试..."
|
||||
if ($attempt -eq 2) { exit $LASTEXITCODE }
|
||||
Start-Sleep -Seconds 5
|
||||
}
|
||||
|
||||
- name: Upload AppImage assets to Release
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
$tag = "v${{ steps.linuxver.outputs.full }}"
|
||||
$appImage = (Get-ChildItem "dist-electron/*.AppImage" -ErrorAction Stop | Select-Object -First 1).Name
|
||||
$assets = @(
|
||||
"dist-electron/$appImage",
|
||||
"dist-electron/latest-linux.yml"
|
||||
)
|
||||
# beta:补 beta-linux.yml(provider 先取该文件,避免 404 再回退)
|
||||
if ("${{ inputs.mode }}" -eq "beta") {
|
||||
Copy-Item "dist-electron/latest-linux.yml" "dist-electron/beta-linux.yml" -Force
|
||||
$assets += "dist-electron/beta-linux.yml"
|
||||
}
|
||||
foreach ($a in $assets) {
|
||||
if (-not (Test-Path -LiteralPath $a)) {
|
||||
Write-Error "资产不存在: $a"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
gh release upload $tag @assets --clobber
|
||||
Write-Host "已上传到 $tag : $($assets -join ', ')"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
scene: git_message
|
||||
---
|
||||
|
||||
在此处编写规则,自定义 AI 生成提交信息的风格。
|
||||
@@ -67,6 +67,7 @@ build/ ← generated by switch-icon.js (gitignored)
|
||||
- `electron/auth.ts`: Auth data persistence (JSON file)
|
||||
- `electron/core/`: @xmcl/* integrations (auth, installer, launcher, modrinth, instance)
|
||||
- `electron/core/background-image.ts`: 背景图处理服务 —— 自选壁纸复制到 userData 并按屏幕尺寸降采样/重编码**落盘**,配置文件只存**文件路径**(不使用 BASE64)
|
||||
- `electron/core/logger.ts`: 统一日志 —— 全局包装 `ipcMain.handle`(channel/耗时/成败);开启 debug 模式(`config.advanced.debugMode`)后写 `userData/koring.log`(5MB 轮转),否则仅控制台;**dev(未打包)运行下日志直接写进程 stdout/stderr 输出到启动终端**;渲染端经 `log:write` 桥汇入
|
||||
- `electron/resource-protocol.ts`: `koring-res://` 特权自定义协议 —— 渲染进程以「资源引用」流式读取本地壁纸;仅服务 userData 内 `background-custom*` 白名单文件(realpath 二次校验,防目录穿越)
|
||||
- `electron/handlers/`: IPC handlers (config, auth, install, launch, mods, instance, background, task, system, window)
|
||||
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
appVersion: 1.2.6
|
||||
oobe: false
|
||||
advanced:
|
||||
preLaunchCmd: '123'
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,8 +2,11 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as yaml from 'js-yaml';
|
||||
import electron from 'electron';
|
||||
import { createLogger } from './core/logger';
|
||||
const { app } = electron;
|
||||
|
||||
const log = createLogger('config');
|
||||
|
||||
const CONFIG_FILE = 'Koring.yml';
|
||||
const CURRENT_VERSION = 1;
|
||||
|
||||
@@ -234,6 +237,7 @@ export function loadConfig(): AppConfig {
|
||||
export function saveConfig(config: AppConfig, force = false): void {
|
||||
const filePath = configPath();
|
||||
const sparse = diffValue(config, DEFAULTS) as Record<string, unknown> | undefined;
|
||||
log.debug(`saveConfig → ${filePath} (force=${force}, 稀疏键=${sparse ? Object.keys(sparse).length : 0})`);
|
||||
|
||||
if (!sparse || Object.keys(sparse).length === 0) {
|
||||
if (force) {
|
||||
@@ -292,6 +296,7 @@ export function flushConfig(): void {
|
||||
saveTimer = null;
|
||||
}
|
||||
if (current) {
|
||||
log.debug('flushConfig:内存配置落盘');
|
||||
saveConfig(current);
|
||||
}
|
||||
}
|
||||
@@ -300,6 +305,7 @@ export function flushConfig(): void {
|
||||
export function updateConfig(patch: Record<string, unknown>): AppConfig {
|
||||
const base = getConfig();
|
||||
current = mergeDeep(base, patch) as AppConfig;
|
||||
log.debug('config:update 补丁顶层键', Object.keys(patch ?? {}));
|
||||
scheduleSave();
|
||||
return current;
|
||||
}
|
||||
@@ -310,6 +316,7 @@ export function deleteConfigKey(key: string): AppConfig {
|
||||
const next = { ...(base as unknown as Record<string, unknown>) };
|
||||
delete next[key];
|
||||
current = next as unknown as AppConfig;
|
||||
log.debug(`config:delete 顶层键 ${key}`);
|
||||
scheduleSave();
|
||||
return current;
|
||||
}
|
||||
|
||||
@@ -11,9 +11,12 @@
|
||||
import electron from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { createLogger } from './logger';
|
||||
|
||||
const { nativeImage } = electron;
|
||||
|
||||
const log = createLogger('background-image');
|
||||
|
||||
export interface OptimizedBackground {
|
||||
/** 实际使用的文件路径(优化后文件;无需优化时为原始缓存文件) */
|
||||
filePath: string;
|
||||
@@ -221,8 +224,13 @@ export function importUserBackground(srcPath: string, maxEdge = 4096): Optimized
|
||||
// 删除旧的原始缓存
|
||||
clearStaleBackgroundFiles(userDataDir, []);
|
||||
fs.copyFileSync(srcPath, rawPath);
|
||||
return optimizeBackgroundFile(rawPath, maxEdge);
|
||||
} catch {
|
||||
const result = optimizeBackgroundFile(rawPath, maxEdge);
|
||||
if (result) {
|
||||
log.info(`导入壁纸完成 → ${path.basename(result.filePath)} (${result.width}x${result.height}, ${result.bytes}B, optimized=${result.optimized})`);
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
log.error('导入壁纸失败:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import * as https from 'https';
|
||||
import { readAuth, writeAuth } from '../auth';
|
||||
import { createLogger } from './logger';
|
||||
|
||||
const log = createLogger('core/koring-auth');
|
||||
|
||||
const CLIENT_ID = '547qe8ky1pr69f08b71kj';
|
||||
const DEVICE_AUTH_URL = 'https://oac.lingke.ink/oidc/device/auth';
|
||||
@@ -36,8 +39,8 @@ function postForm(url: string, data: Record<string, string>): Promise<any> {
|
||||
const params = new URLSearchParams(data);
|
||||
const body = params.toString().replace(/\+/g, '%20');
|
||||
const urlObj = new URL(url);
|
||||
console.log(`[koring-auth] POST ${url}`);
|
||||
console.log(`[koring-auth] body: ${body}`);
|
||||
log.info(`[koring-auth] POST ${url}`);
|
||||
log.info(`[koring-auth] body: ${body}`);
|
||||
const req = https.request(
|
||||
{
|
||||
hostname: urlObj.hostname,
|
||||
@@ -53,7 +56,7 @@ function postForm(url: string, data: Record<string, string>): Promise<any> {
|
||||
let raw = '';
|
||||
res.on('data', (chunk) => (raw += chunk));
|
||||
res.on('end', () => {
|
||||
console.log(`[koring-auth] response (${res.statusCode}): ${raw}`);
|
||||
log.info(`[koring-auth] response (${res.statusCode}): ${raw}`);
|
||||
try {
|
||||
resolve(JSON.parse(raw));
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* 统一日志(主进程)。
|
||||
*
|
||||
* 规则:
|
||||
* - 默认(非 debug):warn/error/info 输出到控制台,debug 不输出;
|
||||
* - 用户开启「调试模式」(config.advanced.debugMode,设置→游戏→高级):
|
||||
* debug 也输出控制台,并把全部级别写入 userData/koring.log(超过 5MB 自动轮转为 .old);
|
||||
* - 开发(未打包)运行且开启调试模式时:日志额外/直接输出到启动该进程的终端(stdout/stderr);
|
||||
* - 渲染进程经 `log:write`(ipcRenderer.send)汇入同一套格式/文件。
|
||||
*/
|
||||
|
||||
import electron from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const { ipcMain, app } = electron;
|
||||
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
export interface Logger {
|
||||
debug: (msg: string, ...args: unknown[]) => void;
|
||||
info: (msg: string, ...args: unknown[]) => void;
|
||||
warn: (msg: string, ...args: unknown[]) => void;
|
||||
error: (msg: string, ...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
const MAX_LOG_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
/** 开发(未打包)运行:直接跑在终端里,日志写 stdout/stderr 用户即可实时看到 */
|
||||
const isDevRun = !app.isPackaged;
|
||||
|
||||
let debugModeProvider: () => boolean = () => false;
|
||||
export function setDebugModeProvider(fn: () => boolean): void {
|
||||
debugModeProvider = fn;
|
||||
}
|
||||
export function isDebugMode(): boolean {
|
||||
try {
|
||||
return debugModeProvider();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let logStream: fs.WriteStream | null = null;
|
||||
let cachedFilePath: string | null = null;
|
||||
|
||||
export function getLogFilePath(): string | null {
|
||||
if (!isDebugMode()) return null;
|
||||
if (!cachedFilePath) cachedFilePath = path.join(app.getPath('userData'), 'koring.log');
|
||||
return cachedFilePath;
|
||||
}
|
||||
|
||||
function openLogStream(): void {
|
||||
if (logStream) return;
|
||||
const filePath = getLogFilePath();
|
||||
if (!filePath) return;
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).size > MAX_LOG_BYTES) {
|
||||
try {
|
||||
fs.renameSync(filePath, `${filePath}.old`);
|
||||
} catch {
|
||||
// 轮转失败不阻塞
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 目录不可用等
|
||||
}
|
||||
logStream = fs.createWriteStream(filePath, { flags: 'a', encoding: 'utf8' });
|
||||
logStream.on('error', () => {
|
||||
logStream = null;
|
||||
});
|
||||
}
|
||||
|
||||
function pad(n: number): string {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
function timestamp(): string {
|
||||
const d = new Date();
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${String(d.getMilliseconds()).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
function truncateString(value: string, max: number): string {
|
||||
if (value.length <= max) return value;
|
||||
return `${value.slice(0, max)}…(+${value.length - max}字符)`;
|
||||
}
|
||||
|
||||
function summarizeArg(value: unknown): unknown {
|
||||
if (typeof value === 'string') return truncateString(value, 300);
|
||||
if (value instanceof Error) return truncateString(value.message, 300);
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
try {
|
||||
const json = JSON.stringify(value, (_key, v) => {
|
||||
if (typeof v === 'string') return truncateString(v, 160);
|
||||
if (v instanceof Error) return truncateString(v.message, 160);
|
||||
if (Array.isArray(v) && v.length > 20) return `[Array(${v.length})]`;
|
||||
return v;
|
||||
});
|
||||
return truncateString(json ?? String(value), 400);
|
||||
} catch {
|
||||
return truncateString(String(value), 300);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function write(scope: string, level: LogLevel, args: unknown[]): void {
|
||||
const msg = args.map((a) => {
|
||||
const s = summarizeArg(a);
|
||||
return typeof s === 'string' ? s : String(s);
|
||||
}).join(' ');
|
||||
const line = `[${timestamp()}][${scope}][${level.toUpperCase()}] ${msg}`;
|
||||
|
||||
const enabled = isDebugMode();
|
||||
// debug 仅在调试模式可见;info/warn/error 始终输出
|
||||
const show = level !== 'debug' || enabled;
|
||||
if (show) {
|
||||
if (isDevRun) {
|
||||
// dev(未打包,pnpm dev / electron .):直接写进程 stdout/stderr,
|
||||
// 让详细日志实时出现在启动它的终端里(不依赖外部控制台捕获)。
|
||||
const text = `${line}\n`;
|
||||
if (level === 'error' || level === 'warn') process.stderr.write(text);
|
||||
else process.stdout.write(text);
|
||||
} else if (level === 'error') console.error(line);
|
||||
else if (level === 'warn') console.warn(line);
|
||||
else if (level === 'info') console.info(line);
|
||||
else console.debug(line);
|
||||
}
|
||||
if (!enabled) return;
|
||||
openLogStream();
|
||||
if (logStream) {
|
||||
logStream.write(`${line}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function makeLogger(scope: string): Logger {
|
||||
const bound = (level: LogLevel) => (msg: string, ...args: unknown[]) => write(scope, level, [msg, ...args]);
|
||||
return {
|
||||
debug: bound('debug'),
|
||||
info: bound('info'),
|
||||
warn: bound('warn'),
|
||||
error: bound('error'),
|
||||
};
|
||||
}
|
||||
|
||||
export function createLogger(scope: string): Logger {
|
||||
return makeLogger(scope);
|
||||
}
|
||||
|
||||
// ---------------- IPC 日志(全局包装 ipcMain.handle) ----------------
|
||||
|
||||
function isResultLike(value: unknown): value is { success?: boolean; error?: unknown } {
|
||||
return typeof value === 'object' && value !== null && 'success' in value;
|
||||
}
|
||||
|
||||
function summarize(payload: unknown[]): unknown[] {
|
||||
return payload.map(summarizeArg);
|
||||
}
|
||||
|
||||
/**
|
||||
* 包装所有 ipcMain.handle:每次调用记录 channel、耗时与成败。
|
||||
* 必须在业务 handler 注册前调用(main.ts 顶层)。
|
||||
*/
|
||||
export function installIpcLogging(): void {
|
||||
const rawHandle = ipcMain.handle.bind(ipcMain);
|
||||
const log = makeLogger('ipc');
|
||||
(ipcMain as unknown as { handle: typeof ipcMain.handle }).handle = (
|
||||
channel: string,
|
||||
listener: (event: Electron.IpcMainInvokeEvent, ...args: unknown[]) => unknown,
|
||||
) => {
|
||||
const wrapped = async (event: Electron.IpcMainInvokeEvent, ...args: unknown[]): Promise<unknown> => {
|
||||
log.debug(`→ ${channel}`, summarize(args));
|
||||
const started = Date.now();
|
||||
try {
|
||||
const result = await listener(event, ...args);
|
||||
const ms = Date.now() - started;
|
||||
const failed = isResultLike(result) && result.success === false;
|
||||
if (failed) {
|
||||
log.error(`✗ ${channel} (${ms}ms)`, isResultLike(result) ? (result.error ?? 'unknown error') : 'failed');
|
||||
} else {
|
||||
log.debug(`← ${channel} ok (${ms}ms)`, summarize([result]));
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
const ms = Date.now() - started;
|
||||
log.error(`! ${channel} 异常 (${ms}ms)`, err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
return rawHandle(channel, wrapped as never);
|
||||
};
|
||||
}
|
||||
|
||||
/** 渲染进程日志桥:ipcRenderer.send('log:write', { level, scope, message }) 汇入统一日志 */
|
||||
export function registerRendererLogBridge(): void {
|
||||
ipcMain.on('log:write', (_event, payload: unknown) => {
|
||||
try {
|
||||
const p = payload as { level?: string; scope?: string; message?: string } | null;
|
||||
if (!p || typeof p.message !== 'string') return;
|
||||
const level = (p.level === 'debug' || p.level === 'info' || p.level === 'warn' || p.level === 'error') ? p.level : 'info';
|
||||
const scope = typeof p.scope === 'string' && p.scope ? p.scope : 'renderer';
|
||||
write(`renderer/${scope}`, level, [p.message]);
|
||||
} catch {
|
||||
// 日志桥异常不抛给渲染进程
|
||||
}
|
||||
});
|
||||
ipcMain.handle('log:getInfo', () => ({
|
||||
filePath: isDebugMode() ? getLogFilePath() : null,
|
||||
debugMode: isDebugMode(),
|
||||
}));
|
||||
}
|
||||
+19
-6
@@ -1,17 +1,30 @@
|
||||
// 路径归一化:相对 gameDir(默认 `.minecraft`)在打包后依赖进程 cwd,不可靠。
|
||||
// 统一按与 runStartupChecks 一致的基准解析(打包 → exe 目录;开发 → 项目根)。
|
||||
// 统一按与 runStartupChecks 一致的基准解析(见 dataBasePath)。
|
||||
import * as path from 'path';
|
||||
import electron from 'electron';
|
||||
const { app } = electron;
|
||||
|
||||
function baseDataPath(): string {
|
||||
if (app.isPackaged) {
|
||||
return path.dirname(app.getPath('exe'));
|
||||
/**
|
||||
* 数据基准目录(游戏数据 `.minecraft` 等相对路径的解析根):
|
||||
* - 开发模式 → 项目根
|
||||
* - Linux / AppImage → userData:exe 目录是只读挂载(/tmp/.mount_*),无法写入
|
||||
* - Windows 打包 → exe 目录(沿用历史行为)
|
||||
*/
|
||||
export function dataBasePath(): string {
|
||||
if (!app.isPackaged) {
|
||||
return path.join(__dirname, '..', '..');
|
||||
}
|
||||
return path.join(__dirname, '..', '..');
|
||||
if (process.platform === 'linux') {
|
||||
return app.getPath('userData');
|
||||
}
|
||||
return path.dirname(app.getPath('exe'));
|
||||
}
|
||||
|
||||
/** 相对路径 → 绝对(基准 = exe 目录/项目根);绝对路径原样返回 */
|
||||
function baseDataPath(): string {
|
||||
return dataBasePath();
|
||||
}
|
||||
|
||||
/** 相对路径 → 绝对(基准 = dataBasePath);绝对路径原样返回 */
|
||||
export function resolveGamePath(gamePath: string): string {
|
||||
if (!gamePath || path.isAbsolute(gamePath)) {
|
||||
return gamePath;
|
||||
|
||||
@@ -26,13 +26,26 @@ export function registerConfigHandlers(win: WinRef) {
|
||||
}
|
||||
});
|
||||
|
||||
// 广播防抖:输入框/滑块每键触发 update 时,不立刻全树广播(避免整页重渲染打断交互),
|
||||
// 合并到 250ms 后只广播一次最新配置;渲染端乐观更新保证即时反馈。
|
||||
let broadcastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const scheduleBroadcast = () => {
|
||||
if (broadcastTimer) clearTimeout(broadcastTimer);
|
||||
broadcastTimer = setTimeout(() => {
|
||||
broadcastTimer = null;
|
||||
if (win.mainWindow && !win.mainWindow.isDestroyed()) {
|
||||
win.mainWindow.webContents.send('config:changed', getConfig());
|
||||
}
|
||||
}, 250);
|
||||
};
|
||||
|
||||
// 主进程权威更新:渲染进程提交 { section, patch } 补丁,
|
||||
// 主进程深度合并到内存配置 → debounce 稀疏写盘 → 广播完整配置给所有渲染进程
|
||||
// 主进程深度合并到内存配置 → debounce 稀疏写盘 → 防抖广播完整配置给所有渲染进程
|
||||
ipcMain.handle('config:update', (_event, payload: { section: string; patch: unknown }) => {
|
||||
try {
|
||||
const { section, patch } = payload;
|
||||
const config = updateConfig({ [section]: patch } as Record<string, unknown>);
|
||||
win.mainWindow?.webContents.send('config:changed', config);
|
||||
scheduleBroadcast();
|
||||
return { success: true, data: config, error: null };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
|
||||
@@ -4,9 +4,12 @@ import fs from 'fs';
|
||||
import { writeCrashLog, readCrashLog, clearCrashLog, type CrashEntry } from '../core/crash-logger';
|
||||
import { configPath } from '../config';
|
||||
import { authPath } from '../auth';
|
||||
import { createLogger } from '../core/logger';
|
||||
|
||||
const { app, ipcMain, BrowserWindow } = electron;
|
||||
|
||||
const log = createLogger('crash-monitor');
|
||||
|
||||
const isDev = !app.isPackaged;
|
||||
|
||||
let crashWin: electron.BrowserWindow | null = null;
|
||||
@@ -99,14 +102,14 @@ export function setupCrashListeners(mainWindow: electron.BrowserWindow) {
|
||||
if (window.__crashToolsLoaded) return;
|
||||
window.__crashToolsLoaded = true;
|
||||
|
||||
console.log('%c[崩溃工具] 已加载', 'color: #f59e0b; font-weight: bold; font-size: 14px;');
|
||||
console.log('%c可用命令:', 'color: #3b82f6; font-weight: bold;');
|
||||
console.log('%c crash.simulate() %c— 模拟渲染进程崩溃', 'color: #ef4444; font-weight: bold;', 'color: inherit;');
|
||||
console.log('%c crash.testDialog() %c— 测试崩溃弹窗', 'color: #ef4444; font-weight: bold;', 'color: inherit;');
|
||||
console.log('%c crash.readLog() %c— 读取崩溃日志', 'color: #ef4444; font-weight: bold;', 'color: inherit;');
|
||||
console.log('%c crash.factoryReset()%c— 强还原配置', 'color: #ef4444; font-weight: bold;', 'color: inherit;');
|
||||
console.log('%c crash.restart() %c— 重启应用', 'color: #ef4444; font-weight: bold;', 'color: inherit;');
|
||||
console.log('');
|
||||
log.info('%c[崩溃工具] 已加载', 'color: #f59e0b; font-weight: bold; font-size: 14px;');
|
||||
log.info('%c可用命令:', 'color: #3b82f6; font-weight: bold;');
|
||||
log.info('%c crash.simulate() %c— 模拟渲染进程崩溃', 'color: #ef4444; font-weight: bold;', 'color: inherit;');
|
||||
log.info('%c crash.testDialog() %c— 测试崩溃弹窗', 'color: #ef4444; font-weight: bold;', 'color: inherit;');
|
||||
log.info('%c crash.readLog() %c— 读取崩溃日志', 'color: #ef4444; font-weight: bold;', 'color: inherit;');
|
||||
log.info('%c crash.factoryReset()%c— 强还原配置', 'color: #ef4444; font-weight: bold;', 'color: inherit;');
|
||||
log.info('%c crash.restart() %c— 重启应用', 'color: #ef4444; font-weight: bold;', 'color: inherit;');
|
||||
log.info('');
|
||||
|
||||
window.crash = {
|
||||
simulate: function() { window.electronAPI?.simulateCrash(); },
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
deleteKoringAuth,
|
||||
} from '../core/koring-auth';
|
||||
import { getConfig, updateConfig, deleteConfigKey } from '../config';
|
||||
import { createLogger } from '../core/logger';
|
||||
|
||||
const log = createLogger('koring-auth');
|
||||
|
||||
export function registerKoringAuthHandlers() {
|
||||
ipcMain.handle('koring-auth:request-device-code', async () => {
|
||||
@@ -39,7 +42,7 @@ export function registerKoringAuthHandlers() {
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[koring-auth] failed to save user to config:', e);
|
||||
log.error('[koring-auth] failed to save user to config:', e);
|
||||
}
|
||||
|
||||
return { success: true, data: { user }, error: null };
|
||||
|
||||
@@ -62,9 +62,10 @@ export function registerUpdateHandlers() {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('update:quitAndInstall', () => {
|
||||
ipcMain.handle('update:quitAndInstall', async () => {
|
||||
try {
|
||||
updateService.quitAndInstall();
|
||||
// 未核验通过时内部会先弹确认框(继续安装 / 取消并删除安装包)
|
||||
await updateService.quitAndInstall();
|
||||
return { success: true, data: null, error: null };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
|
||||
+40
-8
@@ -19,11 +19,26 @@ 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';
|
||||
import { createLogger, setDebugModeProvider, installIpcLogging, registerRendererLogBridge } from './core/logger';
|
||||
import { dataBasePath } from './core/paths';
|
||||
|
||||
const { app } = electron;
|
||||
|
||||
const isDev = !app.isPackaged;
|
||||
|
||||
// 统一日志:debug 模式(config.advanced.debugMode)→ 控制台 + userData/koring.log;
|
||||
// dev(未打包)运行下直接写进程 stdout/stderr,日志实时输出到启动它的终端;否则仅控制台
|
||||
const log = createLogger('main');
|
||||
setDebugModeProvider(() => {
|
||||
try {
|
||||
return getConfig()?.advanced?.debugMode === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
// 全局包装 ipcMain.handle:所有 IPC 流程自动带 channel/耗时/成败日志(须在 handler 注册前调用)
|
||||
installIpcLogging();
|
||||
|
||||
// GPU acceleration flags
|
||||
app.commandLine.appendSwitch('enable-gpu-rasterization');
|
||||
app.commandLine.appendSwitch('enable-zero-copy');
|
||||
@@ -51,17 +66,16 @@ function migrateLegacyFiles(): void {
|
||||
if (fs.existsSync(dest)) return;
|
||||
try {
|
||||
fs.copyFileSync(src, dest);
|
||||
console.log(`[migrate] copied Koring.yml ${src} → ${dest}`);
|
||||
log.info(`[migrate] 已复制 Koring.yml ${src} → ${dest}`);
|
||||
} catch (e) {
|
||||
console.error(`[migrate] failed to copy Koring.yml:`, e);
|
||||
log.error('[migrate] 复制 Koring.yml 失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Startup checks: .minecraft dir + config file + first launch detection
|
||||
function runStartupChecks(): { isFirstLaunch: boolean; config: ReturnType<typeof getConfig> } {
|
||||
const dataPath = app.isPackaged
|
||||
? path.dirname(app.getPath('exe'))
|
||||
: path.join(__dirname, '..');
|
||||
// 数据基准:开发→项目根;Windows 打包→exe 目录;Linux/AppImage→userData(exe 目录是只读挂载)
|
||||
const dataPath = dataBasePath();
|
||||
|
||||
// 1. Ensure .minecraft directory exists
|
||||
const minecraftDir = path.join(dataPath, '.minecraft');
|
||||
@@ -99,9 +113,9 @@ function migrateBackgroundDataUrlToPath(config: ReturnType<typeof getConfig>): v
|
||||
if (!result || !result.filePath) return;
|
||||
bg.image = result.filePath;
|
||||
saveConfig(config);
|
||||
console.log('[background] 迁移:dataURL → 文件路径', result.filePath);
|
||||
log.info(`[background] 迁移:dataURL → 文件路径 ${result.filePath}`);
|
||||
} catch (e) {
|
||||
console.error('[background] 背景配置迁移失败:', e);
|
||||
log.error('[background] 背景配置迁移失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +190,13 @@ function createMainWindow(): electron.BrowserWindow {
|
||||
main.webContents.on('did-finish-load', () => {
|
||||
if (main.isDestroyed()) return;
|
||||
main.webContents.send('config:preload', { config: getConfig(), isFirstLaunch: isFirstLaunchFlag });
|
||||
// Linux 打包但并非以 AppImage 方式运行(无 APPIMAGE 环境变量)→ 提示影响更新组件
|
||||
if (app.isPackaged && process.platform === 'linux' && !process.env.APPIMAGE) {
|
||||
main.webContents.send('runtime:notice', {
|
||||
kind: 'linux-appimage-unpacked',
|
||||
message: '您并未解包安装,这可能会影响更新组件的运行',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
main.on('maximize', () => {
|
||||
@@ -207,8 +228,11 @@ function registerAllHandlers() {
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
log.info('应用就绪,开始初始化主进程');
|
||||
registerAllHandlers();
|
||||
registerResourceProtocol();
|
||||
registerRendererLogBridge();
|
||||
log.info('IPC handlers / 资源协议 / 渲染日志桥注册完成');
|
||||
|
||||
// Migrate legacy install-dir config back to userData before anything reads them
|
||||
migrateLegacyFiles();
|
||||
@@ -216,17 +240,20 @@ app.whenReady().then(() => {
|
||||
// Run startup checks before creating windows
|
||||
const { isFirstLaunch, config } = runStartupChecks();
|
||||
isFirstLaunchFlag = isFirstLaunch;
|
||||
log.info(`启动检查完成 isFirstLaunch=${isFirstLaunch} 配置路径=${configPath()}`);
|
||||
|
||||
// 旧配置中 background.image 若是 BASE64 dataURL → 落盘优化并改写为文件路径
|
||||
migrateBackgroundDataUrlToPath(config);
|
||||
|
||||
// 1. Show splash immediately
|
||||
win.splashWindow = createSplashWindow();
|
||||
log.info('Splash 窗口已创建');
|
||||
|
||||
// 2. Create main window in background
|
||||
// (config:preload 推送已内置于 createMainWindow 的 did-finish-load 监听,
|
||||
// 每次加载/刷新都推送 getConfig() 的最新内存配置)
|
||||
win.mainWindow = createMainWindow();
|
||||
log.info('主窗口已创建(后台加载)');
|
||||
|
||||
// 3. When main window finishes loading, wait a minimum time then transition
|
||||
let mainReady = false;
|
||||
@@ -237,10 +264,12 @@ app.whenReady().then(() => {
|
||||
if (win.mainWindow && !win.mainWindow.isDestroyed()) {
|
||||
win.mainWindow.show();
|
||||
win.mainWindow.focus();
|
||||
log.info('主窗口已显示,切换完成');
|
||||
}
|
||||
if (win.splashWindow && !win.splashWindow.isDestroyed()) {
|
||||
win.splashWindow.close();
|
||||
win.splashWindow = null;
|
||||
log.info('Splash 窗口已关闭');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -261,19 +290,22 @@ app.whenReady().then(() => {
|
||||
|
||||
// 延迟静默检查更新(避开启动加载,不抢带宽;开发模式在 updater.init 内自动跳过)
|
||||
setTimeout(() => {
|
||||
log.info('触发启动静默更新检查');
|
||||
updateService.check(false).catch((e) => {
|
||||
console.error('[updater] 启动静默检查失败:', e);
|
||||
log.error('[updater] 启动静默检查失败:', e);
|
||||
});
|
||||
}, 12000);
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
log.info('所有窗口已关闭,flush 配置并退出');
|
||||
flushConfig();
|
||||
app.quit();
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
if (electron.BrowserWindow.getAllWindows().length === 0) {
|
||||
log.info('activate:重建主窗口');
|
||||
win.mainWindow = createMainWindow();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -53,6 +53,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
return () => ipcRenderer.removeListener('config:changed', handler);
|
||||
},
|
||||
|
||||
// 主进程运行时提示(如 Linux 未以 AppImage 方式运行,影响更新组件)
|
||||
onRuntimeNotice: (callback: (notice: { kind: string; message: string }) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, notice: { kind: string; message: string }) => callback(notice);
|
||||
ipcRenderer.on('runtime:notice', handler);
|
||||
return () => ipcRenderer.removeListener('runtime:notice', handler);
|
||||
},
|
||||
|
||||
// 背景图 — 选择本地图片:主进程复制到 userData、按窗口尺寸优化并落盘,
|
||||
// 返回【文件路径】(配置/Store 以路径保存,不使用 BASE64)。
|
||||
pickBackgroundImage: async (): Promise<string | null> => {
|
||||
@@ -86,6 +93,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
return { url: null, bytes: 0 };
|
||||
},
|
||||
|
||||
// 日志:渲染端经 IPC 汇入主进程统一日志(debug 模式写文件;否则仅控制台)
|
||||
log: (level: 'debug' | 'info' | 'warn' | 'error', scope: string, message: string) => {
|
||||
ipcRenderer.send('log:write', { level, scope, message });
|
||||
},
|
||||
|
||||
// Open external URL in system browser
|
||||
openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url),
|
||||
|
||||
|
||||
@@ -14,9 +14,12 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { Readable } from 'stream';
|
||||
import { isManagedBackgroundFile, isPathInside, mimeForFile } from './core/background-image';
|
||||
import { createLogger } from './core/logger';
|
||||
|
||||
const { protocol, app } = electron;
|
||||
|
||||
const log = createLogger('resource-protocol');
|
||||
|
||||
export const RESOURCE_SCHEME = 'koring-res';
|
||||
|
||||
/** 必须在 app ready 之前调用(privileged scheme 注册) */
|
||||
@@ -41,6 +44,7 @@ export function registerResourceProtocol(): void {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
if (url.host !== 'userdata') {
|
||||
log.warn(`拒绝非 userdata 主机: ${url.host}`);
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
@@ -53,11 +57,13 @@ export function registerResourceProtocol(): void {
|
||||
|
||||
// 路径穿越 / 绝对路径直接拒绝
|
||||
if (!relative || relative.includes('..') || path.isAbsolute(relative)) {
|
||||
log.warn(`拒绝非法路径: ${relative}`);
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
const fileName = path.basename(relative);
|
||||
if (fileName !== relative || !isManagedBackgroundFile(fileName)) {
|
||||
log.warn(`拒绝白名单外文件: ${fileName}`);
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
@@ -65,15 +71,18 @@ export function registerResourceProtocol(): void {
|
||||
const target = path.join(userDataDir, fileName);
|
||||
|
||||
if (!isPathInside(userDataDir, target)) {
|
||||
log.warn(`拒绝越权文件: ${target}`);
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
const stat = await fs.promises.stat(target);
|
||||
if (!stat.isFile()) {
|
||||
log.warn(`文件不存在: ${target}`);
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const body = Readable.toWeb(fs.createReadStream(target)) as unknown as BodyInit;
|
||||
log.debug(`服务资源 ${fileName} (${stat.size}B, ${mimeForFile(target)})`);
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
@@ -82,7 +91,8 @@ export function registerResourceProtocol(): void {
|
||||
'cache-control': 'no-store',
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
} catch (e) {
|
||||
log.error(`协议请求处理失败: ${request.url}`, e);
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
});
|
||||
|
||||
+202
-126
@@ -5,9 +5,12 @@ import * as crypto from 'crypto';
|
||||
import * as os from 'os';
|
||||
import semver from 'semver';
|
||||
import { getConfig, updateConfig, flushConfig } from './config';
|
||||
import { createLogger } from './core/logger';
|
||||
|
||||
const { app } = electron;
|
||||
|
||||
const log = createLogger('updater');
|
||||
|
||||
export type UpdateState =
|
||||
| 'idle'
|
||||
| 'checking'
|
||||
@@ -101,35 +104,62 @@ function getMirrors(): string[] {
|
||||
return DEFAULT_MIRRORS;
|
||||
}
|
||||
|
||||
/** 版本格式分类 */
|
||||
type TagKind = 'stable' | 'beta' | 'oldbeta' | 'plain';
|
||||
|
||||
interface ParsedTag {
|
||||
base: number[];
|
||||
/** 构建号(渠道标记不参与排序) */
|
||||
num: number;
|
||||
kind: TagKind;
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目版本序:比较两个版本 tag(如 v1.2.5-17 / v1.2.5-beta.16 / v1.2.0-2608271921),返回 a - b。
|
||||
*
|
||||
* 语义(与 electron-updater 的纯 semver 不同,专为本项目版本方案定制):
|
||||
* 每个 release = {base, 构建号}。beta.N 与 N 的 **beta/正式只是通道标记,不参与新旧排序**:
|
||||
* - base(X.Y.Z)不同 → 按 base 数值比较(优先)
|
||||
* - base 相同 → 按构建号(数字部分,忽略 beta 前缀)比较
|
||||
* - 无构建号(如 1.2.5,旧版正式格式)→ 构建号视为 -1(同 base 内最旧,可被后续带尾号版本覆盖)
|
||||
* - 构建号相同(如 beta.17 与 17)→ 视为相等(通道标记不参与排序;同号跨通道版本实际不会共存)
|
||||
*
|
||||
* 修复目标:v1.2.5-17(构建 17)不应把 v1.2.5-beta.16(构建 16)当新版本;
|
||||
* 但 v1.2.5-beta.18(构建 18)对 v1.2.5-17 是新版本。
|
||||
* 项目版本格式(2026- 起):
|
||||
* - 正式版(run): {base}-{N} 如 1.2.6-25
|
||||
* - 测试版(beta): {base}-{N}.beta 如 1.2.6-16.beta(编号在前、beta 在后)
|
||||
* - 旧测试版(废弃):{base}-beta.{N} 如 1.2.6-beta.16 —— 检测时【屏蔽】不作为候选
|
||||
* - 旧正式版(更早):{base} 如 1.2.5(无构建号,同 base 最旧)
|
||||
*/
|
||||
function parseTag(tag: string): ParsedTag {
|
||||
const s = tag.replace(/^v/i, '');
|
||||
const dash = s.indexOf('-');
|
||||
const baseStr = dash === -1 ? s : s.slice(0, dash);
|
||||
const tail = dash === -1 ? '' : s.slice(dash + 1);
|
||||
const nums = baseStr.split('.').map((n) => parseInt(n, 10) || 0);
|
||||
while (nums.length < 3) nums.push(0);
|
||||
if (tail === '') return { base: nums, num: -1, kind: 'plain' };
|
||||
const mStable = /^(\d+)$/.exec(tail);
|
||||
if (mStable) return { base: nums, num: parseInt(mStable[1], 10), kind: 'stable' };
|
||||
const mBeta = /^(\d+)\.beta$/i.exec(tail);
|
||||
if (mBeta) return { base: nums, num: parseInt(mBeta[1], 10), kind: 'beta' };
|
||||
const mOldBeta = /^beta\.(\d+)$/i.exec(tail);
|
||||
if (mOldBeta) return { base: nums, num: parseInt(mOldBeta[1], 10), kind: 'oldbeta' };
|
||||
// 无法识别 → 视为未知旧格式(候选阶段一并屏蔽),构建号取 0
|
||||
return { base: nums, num: 0, kind: 'oldbeta' };
|
||||
}
|
||||
|
||||
/** 该 tag 是否为"屏蔽的旧格式"(旧 beta:-beta.N 或无法识别的尾巴) */
|
||||
function isMaskedLegacyTag(tag: string): boolean {
|
||||
return parseTag(tag).kind === 'oldbeta';
|
||||
}
|
||||
|
||||
/** 候选是否允许当前通道使用(woker=仅正式;runner=正式+新版测试版;旧格式一律屏蔽) */
|
||||
function isCandidateAllowed(tag: string, allowPrerelease: boolean): boolean {
|
||||
if (isMaskedLegacyTag(tag)) return false;
|
||||
const kind = parseTag(tag).kind;
|
||||
return allowPrerelease || kind === 'stable' || kind === 'plain';
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目版本序:比较两个版本 tag,返回 a - b。
|
||||
* base 数值优先;base 相同比构建号(数字部分)——beta/正式只是通道标记,不参与新旧排序。
|
||||
* 旧格式(-beta.N)按构建号参与排序(使旧版安装也能升级到新格式/更高构建号),
|
||||
* 但不会作为候选被选中(见 isCandidateAllowed)。
|
||||
*/
|
||||
function compareVersionTags(a: string, b: string): number {
|
||||
const parse = (t: string): { base: number[]; num: number } => {
|
||||
const s = t.replace(/^v/i, '');
|
||||
const [base, buildStr = ''] = s.split('-');
|
||||
const nums = base.split('.').map((n) => parseInt(n, 10) || 0);
|
||||
while (nums.length < 3) nums.push(0);
|
||||
if (buildStr === '') {
|
||||
// 无构建号(旧版正式格式):同 base 内视为最旧
|
||||
return { base: nums, num: -1 };
|
||||
}
|
||||
const beta = /^beta\.(\d+)$/i.exec(buildStr);
|
||||
const num = parseInt(beta ? beta[1] : buildStr, 10);
|
||||
return { base: nums, num: Number.isFinite(num) ? num : 0 };
|
||||
};
|
||||
const pa = parse(a);
|
||||
const pb = parse(b);
|
||||
const pa = parseTag(a);
|
||||
const pb = parseTag(b);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (pa.base[i] !== pb.base[i]) return pa.base[i] - pb.base[i];
|
||||
}
|
||||
@@ -170,7 +200,7 @@ class UpdateService {
|
||||
this.currentVersion = app.getVersion();
|
||||
|
||||
if (!app.isPackaged) {
|
||||
console.log('[updater] 开发模式:跳过自动更新');
|
||||
log.info('[updater] 开发模式:跳过自动更新');
|
||||
this.emit();
|
||||
return;
|
||||
}
|
||||
@@ -184,12 +214,14 @@ class UpdateService {
|
||||
// 应用能启动即说明上次安装已完成/已结束,清理持久化的进行中状态
|
||||
const persisted = getConfig().update;
|
||||
if (persisted && persisted.state && persisted.state !== 'idle') {
|
||||
console.log(`[updater] 上次更新状态 ${persisted.state} (v${persisted.version}),已重置`);
|
||||
log.info(`[updater] 上次更新状态 ${persisted.state} (v${persisted.version}),已重置`);
|
||||
this.persistIdleConfig();
|
||||
}
|
||||
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
// 安装一律走我们受控的 quitAndInstall(含核验与确认弹窗),
|
||||
// 禁止 electron-updater 在退出时静默自动安装(否则未核验/核验失败的包可能被直接装上)
|
||||
autoUpdater.autoInstallOnAppQuit = false;
|
||||
this.applyChannel();
|
||||
autoUpdater.logger = console;
|
||||
autoUpdater.on('checking-for-update', () => {
|
||||
@@ -206,7 +238,7 @@ class UpdateService {
|
||||
const first = Array.isArray(anyInfo?.files) ? anyInfo.files[0] : null;
|
||||
this.expectedSha512 = String(first?.sha512 ?? '');
|
||||
this.expectedSize = Number(first?.size ?? 0);
|
||||
console.log(`[updater] 可用更新 ${info.version},期望 sha512=${this.expectedSha512.slice(0, 12)}… size=${this.expectedSize}`);
|
||||
log.info(`[updater] 可用更新 ${info.version},期望 sha512=${this.expectedSha512.slice(0, 12)}… size=${this.expectedSize}`);
|
||||
this.emit();
|
||||
});
|
||||
autoUpdater.on('update-not-available', () => {
|
||||
@@ -220,28 +252,31 @@ class UpdateService {
|
||||
this.progress = p;
|
||||
this.emit();
|
||||
});
|
||||
// 下载完成 → 本地核验安装包(sha512 + 大小)通过后才置为"已下载可安装";
|
||||
// 核验失败 → 进入 error,拒绝安装(防下载损坏 / 篡改)
|
||||
// 下载完成 → 本地核验安装包(sha512 + 大小)。
|
||||
// · 通过:进入"已下载可安装"(verified=true)
|
||||
// · 失败:仍进入"已下载",但 verified=false + 记录原因 —— 点"安装"会弹确认框
|
||||
// (继续安装 / 取消并删除安装包),绝不静默安装未核验包
|
||||
autoUpdater.on('update-downloaded', async (info) => {
|
||||
const errMsg = await this.verifyDownloadedPackage();
|
||||
if (errMsg) {
|
||||
console.error(`[updater] 安装包核验失败: ${errMsg}`);
|
||||
this.state = 'error';
|
||||
this.error = errMsg;
|
||||
this.version = undefined;
|
||||
log.error(`[updater] 安装包核验失败: ${errMsg}`);
|
||||
this.state = 'downloaded';
|
||||
this.verified = false;
|
||||
this.version = info.version;
|
||||
this.error = errMsg;
|
||||
this.emit();
|
||||
return;
|
||||
}
|
||||
console.log('[updater] 安装包核验通过(sha512 + 大小)');
|
||||
log.info('[updater] 安装包核验通过(sha512 + 大小)');
|
||||
this.state = 'downloaded';
|
||||
this.verified = true;
|
||||
this.version = info.version;
|
||||
this.error = undefined;
|
||||
this.emit();
|
||||
});
|
||||
autoUpdater.on('error', (err: Error) => {
|
||||
const message = String(err?.message ?? err);
|
||||
console.warn(`[updater] electron-updater error: ${message}`);
|
||||
log.warn(`[updater] electron-updater error: ${message}`);
|
||||
if (this.suppressErrors) return; // 兜底循环内,忽略
|
||||
if (this.downloadToken?.cancelled) return; // 主动暂停/取消,忽略
|
||||
this.state = 'error';
|
||||
@@ -326,7 +361,7 @@ class UpdateService {
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[updater] 更新进度写入配置失败:', e);
|
||||
log.warn('[updater] 更新进度写入配置失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,23 +375,19 @@ class UpdateService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 按当前通道应用 electron-updater 的 allowPrerelease + 频道(woker=只收正式版 / runner=可收预览版) */
|
||||
/**
|
||||
* 按当前通道应用 electron-updater 参数。
|
||||
* 新版本方案({base}-{N} 正式 / {base}-{N}.beta 测试)下,版本识别不再依赖
|
||||
* electron-updater 的 GitHub 频道循环(-N.beta 的 prerelease[0] 是数字,会被当自定义频道),
|
||||
* 候选由本项目已实现自行选定(check → fetchCandidates/pickCandidate → generic feed)。
|
||||
* 这里统一 channel='latest'(generic feed 取 latest.yml / latest-linux.yml;
|
||||
* 且 setter 自动开启 allowDowngrade,供 semver 门接受"编号更大但 semver 偏旧"的候选)。
|
||||
*/
|
||||
private applyChannel(): void {
|
||||
const def = getChannelDef(this.channelKey);
|
||||
// runner(跑步) 强制开启 allowPrerelease → GitHub provider 走 Atom feed 频道逻辑可收 beta;
|
||||
// woker(慢走) 关闭 → 走 releases/latest 只认稳定版,不被预览版污染。
|
||||
autoUpdater.allowPrerelease = def.allowPrerelease;
|
||||
if (def.allowPrerelease) {
|
||||
// 关键:runner 显式指定频道 "beta"。否则当当前版本是数字尾号稳定版(如 1.2.5-13)时,
|
||||
// semver.prerelease(currentVersion)[0] = "13" 会被 GitHub provider 当作"自定义频道",
|
||||
// 通道循环匹配不到任何版本 → "No published versions on GitHub",
|
||||
// 正式版切跑步模式将无法检测 beta 预览版。
|
||||
autoUpdater.channel = 'beta';
|
||||
} else {
|
||||
// woker 恢复默认 latest 频道(allowPrerelease=false 走 /releases/latest,频道不影响识别)
|
||||
autoUpdater.channel = 'latest';
|
||||
}
|
||||
console.log(`[updater] 更新通道: ${def.label}(${def.key},allowPrerelease=${def.allowPrerelease},channel=${autoUpdater.channel})`);
|
||||
autoUpdater.channel = 'latest';
|
||||
log.info(`[updater] 更新通道: ${def.label}(${def.key},allowPrerelease=${def.allowPrerelease})`);
|
||||
}
|
||||
|
||||
/** 通道定义列表(UI 动态渲染;可扩展) */
|
||||
@@ -367,7 +398,7 @@ class UpdateService {
|
||||
/** 切换更新通道(校验 + 持久化 + 立即生效,下次检查生效) */
|
||||
setChannel(key: string): UpdateStatusPayload {
|
||||
if (!UPDATE_CHANNELS.some((c) => c.key === key)) {
|
||||
console.warn(`[updater] 未知更新通道: ${key}`);
|
||||
log.warn(`[updater] 未知更新通道: ${key}`);
|
||||
return this.buildPayload();
|
||||
}
|
||||
if (this.channelKey === key) return this.buildPayload();
|
||||
@@ -376,7 +407,7 @@ class UpdateService {
|
||||
try {
|
||||
updateConfig({ update: { channel: key } });
|
||||
} catch (e) {
|
||||
console.warn('[updater] 通道写入配置失败:', e);
|
||||
log.warn('[updater] 通道写入配置失败:', e);
|
||||
}
|
||||
this.emit();
|
||||
return this.buildPayload();
|
||||
@@ -393,7 +424,7 @@ class UpdateService {
|
||||
setTestVersion(version: string): UpdateStatusPayload {
|
||||
const v = semver.valid(version.trim());
|
||||
if (!v) {
|
||||
console.warn(`[updater] 无效测试版本号: ${version}`);
|
||||
log.warn(`[updater] 无效测试版本号: ${version}`);
|
||||
return this.buildPayload();
|
||||
}
|
||||
this.currentVersion = v;
|
||||
@@ -401,9 +432,9 @@ class UpdateService {
|
||||
// currentVersion 在类型声明中为 readonly,但运行时可直接赋值(测试工具用)
|
||||
(autoUpdater as unknown as { currentVersion: unknown }).currentVersion = semver.parse(v);
|
||||
} catch (e) {
|
||||
console.warn('[updater] 设置 autoUpdater.currentVersion 失败:', e);
|
||||
log.warn('[updater] 设置 autoUpdater.currentVersion 失败:', e);
|
||||
}
|
||||
console.log(`[updater] 测试版本号 → ${v}`);
|
||||
log.info(`[updater] 测试版本号 → ${v}`);
|
||||
this.emit();
|
||||
return this.buildPayload();
|
||||
}
|
||||
@@ -425,7 +456,7 @@ class UpdateService {
|
||||
private correctAvailability(): void {
|
||||
if (this.state !== 'available' || !this.version) return;
|
||||
if (this.isNewerCandidate(this.currentVersion, this.version)) return;
|
||||
console.warn(`[updater] ${this.version} 不是 ${this.currentVersion} 的新版本(项目版本序,忽略 beta 通道标记),回退为无更新`);
|
||||
log.warn(`[updater] ${this.version} 不是 ${this.currentVersion} 的新版本(项目版本序,忽略 beta 通道标记),回退为无更新`);
|
||||
this.state = 'not-available';
|
||||
this.version = undefined;
|
||||
this.error = undefined;
|
||||
@@ -454,7 +485,12 @@ class UpdateService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查更新:GitHub 官方优先,失败后依次尝试加速源。
|
||||
* 检查更新(版本方案:{base}-{N} 正式 / {base}-{N}.beta 测试 / 旧 -beta.{N} 屏蔽):
|
||||
* 1. 按来源依次(GitHub 官方 → 各加速源)抓取 release 候选;
|
||||
* 2. 按通道(woker=仅正式 / runner=正式+新测试版)过滤并剔除旧格式;
|
||||
* 3. 按项目版本序挑出"比当前新"的最新 tag → 指向该 tag 的 generic feed 走 electron-updater
|
||||
* (下载/校验/安装链路复用);版本序复核通过才判 available。
|
||||
* 不依赖 electron-updater 的 GitHub 频道循环(新格式 -N.beta 会被其当作自定义频道)。
|
||||
*/
|
||||
async check(manual = false): Promise<UpdateStatusPayload> {
|
||||
if (!this.ready) return this.buildPayload();
|
||||
@@ -464,46 +500,44 @@ class UpdateService {
|
||||
|
||||
this.manual = manual;
|
||||
this.suppressErrors = true;
|
||||
|
||||
// 1) GitHub 官方(app-update.yml 内置 github provider)
|
||||
this.source = 'github';
|
||||
const allowPrerelease = getChannelDef(this.channelKey).allowPrerelease;
|
||||
this.state = 'checking';
|
||||
this.emit();
|
||||
try {
|
||||
await autoUpdater.checkForUpdates();
|
||||
this.suppressErrors = false;
|
||||
// 复核 electron-updater 的纯 semver 判定(v1.2.5-17 误判 v1.2.5-beta.16 为新版本)
|
||||
this.correctAvailability();
|
||||
return this.buildPayload();
|
||||
} catch (err) {
|
||||
console.warn(`[updater] GitHub 官方更新源不可用: ${String((err as Error)?.message ?? err)}`);
|
||||
}
|
||||
|
||||
// 2) 加速源兜底:镜像页面发现最新 tag → generic feed → 检查
|
||||
for (const mirror of getMirrors()) {
|
||||
const sources: { label: string; base: string }[] = [
|
||||
{ label: 'github', base: '' },
|
||||
...getMirrors().map((m) => ({ label: m, base: `${m}/` })),
|
||||
];
|
||||
|
||||
for (const { label, base } of sources) {
|
||||
try {
|
||||
const tag = await this.discoverLatestTag(mirror);
|
||||
const candidates = await this.fetchCandidates(base);
|
||||
const tag = this.pickCandidate(candidates, allowPrerelease);
|
||||
if (!tag) {
|
||||
console.warn(`[updater] ${mirror} 无法发现最新版本,跳过`);
|
||||
continue;
|
||||
log.info(`[updater] ${label}: 无符合条件的更新候选(通道/格式/版本序)`);
|
||||
continue; // 该源没有更新,尝试下一个源
|
||||
}
|
||||
const feedUrl = `${mirror}/https://github.com/${OWNER}/${REPO}/releases/download/${tag}/`;
|
||||
console.log(`[updater] 切换加速源: ${mirror} (feed: ${feedUrl})`);
|
||||
const feedUrl = `${base}https://github.com/${OWNER}/${REPO}/releases/download/${tag}/`;
|
||||
log.info(`[updater] 检查源 ${label},命中 ${tag} (feed: ${feedUrl})`);
|
||||
autoUpdater.setFeedURL({ provider: 'generic', url: feedUrl });
|
||||
this.source = mirror;
|
||||
// generic feed 取 latest.yml / latest-linux.yml;channel='latest' 同时开启 allowDowngrade,
|
||||
// 使 semver 门接受"编号更大但 semver 判定偏旧"的候选(如旧 -beta.N 当前 → 新格式)
|
||||
autoUpdater.allowPrerelease = allowPrerelease;
|
||||
autoUpdater.channel = 'latest';
|
||||
this.source = label;
|
||||
this.state = 'checking';
|
||||
this.emit();
|
||||
await autoUpdater.checkForUpdates();
|
||||
this.suppressErrors = false;
|
||||
// 复核 electron-updater 的纯 semver 判定
|
||||
// 项目版本序复核:候选确为更新才保留 available
|
||||
this.correctAvailability();
|
||||
// 镜像若反馈无更新(可能发现的是旧 tag / latest.yml 不匹配),
|
||||
// 不要就此返回 not-available,继续尝试下一个源
|
||||
const mirrorResult = this.buildPayload();
|
||||
if (mirrorResult.state !== 'not-available') return mirrorResult;
|
||||
console.warn(`[updater] ${mirror} 反馈无可用更新,尝试下一个源`);
|
||||
const result = this.buildPayload();
|
||||
if (result.state !== 'not-available') {
|
||||
this.suppressErrors = false;
|
||||
return result;
|
||||
}
|
||||
log.warn(`[updater] ${label} 反馈无可用更新,尝试下一个源`);
|
||||
} catch (err) {
|
||||
console.warn(`[updater] 加速源 ${mirror} 检查失败: ${String((err as Error)?.message ?? err)}`);
|
||||
log.warn(`[updater] 更新源 ${label} 检查失败: ${String((err as Error)?.message ?? err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -515,19 +549,17 @@ class UpdateService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过加速源发现最新 release tag:
|
||||
* 1) /releases/latest 页面 HTML 中提取 tag(多数下载型加速源可代理该页面;
|
||||
* ⚠️ 该页面只指向最新「非 prerelease」release,本项目所有版本都是 prerelease 形式,结果可能偏旧)
|
||||
* 2) GitHub API(经加速源代理,列表含 prerelease),作为备选
|
||||
* 最终取两种方式候选集中版本最大者(buildId 数值比较),避免旧 tag 覆盖新 prerelease。
|
||||
* 抓取某来源的 release tag 候选列表:
|
||||
* base='' 为 GitHub 官方直连;否则 base=`${mirror}/`(加速源前缀,原样拼 https://)。
|
||||
* 组合:API 列表(含全部 release)+ /releases/latest 页面 HTML(兜底)。
|
||||
*/
|
||||
private async discoverLatestTag(mirror: string): Promise<string | null> {
|
||||
private async fetchCandidates(base: string): Promise<string[]> {
|
||||
const candidates: string[] = [];
|
||||
|
||||
// 方式 1:HTML 页面
|
||||
const web = `${base}https://github.com/${OWNER}/${REPO}`;
|
||||
const api = `${base}https://api.github.com/repos/${OWNER}/${REPO}`;
|
||||
// 方式 1:/releases/latest 页面(最新非 prerelease release 的 tag,HTML 正则兜底)
|
||||
try {
|
||||
const pageUrl = `${mirror}/https://github.com/${OWNER}/${REPO}/releases/latest`;
|
||||
const res = await fetch(pageUrl, {
|
||||
const res = await fetch(`${web}/releases/latest`, {
|
||||
headers: { 'User-Agent': 'koring-launcher-updater' },
|
||||
signal: AbortSignal.timeout(DISCOVER_TIMEOUT_MS),
|
||||
});
|
||||
@@ -539,34 +571,42 @@ class UpdateService {
|
||||
} catch {
|
||||
/* 尝试下一种方式 */
|
||||
}
|
||||
|
||||
// 方式 2:GitHub API(经加速源代理)
|
||||
// 方式 2:GitHub API release 列表
|
||||
try {
|
||||
const apiUrl = `${mirror}/https://api.github.com/repos/${OWNER}/${REPO}/releases?per_page=20`;
|
||||
const res = await fetch(apiUrl, {
|
||||
const res = await fetch(`${api}/releases?per_page=40`, {
|
||||
headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'koring-launcher-updater' },
|
||||
signal: AbortSignal.timeout(DISCOVER_TIMEOUT_MS),
|
||||
});
|
||||
if (res.ok) {
|
||||
const releases: { draft?: boolean; prerelease?: boolean; tag_name?: string }[] = await res.json();
|
||||
const allowPrerelease = getChannelDef(this.channelKey).allowPrerelease;
|
||||
const releases: { draft?: boolean; tag_name?: string }[] = await res.json();
|
||||
for (const r of releases ?? []) {
|
||||
// draft 一律跳过;woker(只收正式版)跳过 GitHub 标记为 prerelease 的 release
|
||||
if (r.draft || (!allowPrerelease && r.prerelease)) continue;
|
||||
if (r.tag_name) candidates.push(r.tag_name);
|
||||
if (r.draft || !r.tag_name) continue;
|
||||
candidates.push(r.tag_name);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
if (candidates.length === 0) return null;
|
||||
candidates.sort(compareVersionTags);
|
||||
const latest = candidates[candidates.length - 1];
|
||||
if (candidates.length > 1) {
|
||||
console.log(`[updater] ${mirror} 候选版本: ${candidates.join(', ')} → 取 ${latest}`);
|
||||
}
|
||||
return latest;
|
||||
/** 从候选里挑出允许当前通道、且按项目版本序比当前更新的最新 tag;没有返回 null */
|
||||
private pickCandidate(candidates: string[], allowPrerelease: boolean): string | null {
|
||||
const allowed = candidates
|
||||
.filter((c) => isCandidateAllowed(c, allowPrerelease))
|
||||
.filter((c) => compareVersionTags(c, this.currentVersion) > 0);
|
||||
if (allowed.length === 0) return null;
|
||||
allowed.sort(compareVersionTags);
|
||||
return allowed[allowed.length - 1];
|
||||
}
|
||||
|
||||
/** 不限当前版本:返回某来源允许通道/格式的最新 tag(发布说明回退用);没有返回 null */
|
||||
private async fetchBestCandidate(base: string, allowPrerelease: boolean): Promise<string | null> {
|
||||
const candidates = await this.fetchCandidates(base);
|
||||
const allowed = candidates.filter((c) => isCandidateAllowed(c, allowPrerelease));
|
||||
if (allowed.length === 0) return null;
|
||||
allowed.sort(compareVersionTags);
|
||||
return allowed[allowed.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -578,9 +618,9 @@ class UpdateService {
|
||||
const found = await this.fetchNotesForTag(tag);
|
||||
if (found) return found;
|
||||
|
||||
// 回退:最新版本(通过 /releases/latest 页面发现 tag)
|
||||
// 回退:最新版本(发现 tag 后取发布说明)
|
||||
for (const mirror of getMirrors()) {
|
||||
const latestTag = await this.discoverLatestTag(mirror);
|
||||
const latestTag = await this.fetchBestCandidate(`${mirror}/`, true);
|
||||
if (latestTag && latestTag !== tag) {
|
||||
const foundLatest = await this.fetchNotesForTag(latestTag);
|
||||
if (foundLatest) {
|
||||
@@ -610,7 +650,7 @@ class UpdateService {
|
||||
const notes = await res.text();
|
||||
if (!notes.trim()) continue;
|
||||
if (/^\s*<!doctype html/i.test(notes) || /^\s*<html[\s>]/i.test(notes)) continue;
|
||||
console.log(`[updater] 发布说明来源: ${source} (${tag})`);
|
||||
log.info(`[updater] 发布说明来源: ${source} (${tag})`);
|
||||
return { tag, version: tag.replace(/^v/, ''), notes, source, isLatest: false };
|
||||
}
|
||||
} catch {
|
||||
@@ -631,7 +671,7 @@ class UpdateService {
|
||||
if (this.state !== 'available' && this.state !== 'paused') return;
|
||||
// 复核目标版本确为当前版本的新版本(项目版本序),否则回退为无更新
|
||||
if (this.state === 'available' && this.version && !this.isNewerCandidate(this.currentVersion, this.version)) {
|
||||
console.warn(`[updater] 下载被拒:${this.version} 不是 ${this.currentVersion} 的新版本`);
|
||||
log.warn(`[updater] 下载被拒:${this.version} 不是 ${this.currentVersion} 的新版本`);
|
||||
this.state = 'not-available';
|
||||
this.version = undefined;
|
||||
this.emit();
|
||||
@@ -639,7 +679,7 @@ class UpdateService {
|
||||
}
|
||||
if (this.state === 'paused') {
|
||||
// 继续下载(可能从断点续传,也可能重新开始,取决于 electron-updater 缓存)
|
||||
console.log('[updater] 继续下载');
|
||||
log.info('[updater] 继续下载');
|
||||
}
|
||||
this.state = 'downloading';
|
||||
this.progress = null;
|
||||
@@ -680,17 +720,42 @@ class UpdateService {
|
||||
/**
|
||||
* 退出并安装(NSIS 静默安装,安装完成自动重启)。
|
||||
* 安装状态先写入配置并立即落盘,避免退出时 debounce 未写盘。
|
||||
* 安装包未通过核验(verified=false)时先弹确认框:
|
||||
* 继续安装 / 取消并删除安装包 —— 绝不静默安装校验异常的文件。
|
||||
*/
|
||||
quitAndInstall(): void {
|
||||
async quitAndInstall(): Promise<void> {
|
||||
if (!this.ready || this.state !== 'downloaded') return;
|
||||
// 安装前必须已通过本地核验(sha512 + 大小),防损坏/篡改包被安装
|
||||
|
||||
if (!this.verified) {
|
||||
console.warn('[updater] 安装被拒:安装包未通过核验');
|
||||
this.state = 'error';
|
||||
this.error = '安装包未通过核验,已拒绝安装,请重新检查并下载更新';
|
||||
this.emit();
|
||||
return;
|
||||
const { dialog, BrowserWindow } = electron;
|
||||
const parent = BrowserWindow.getAllWindows().find((w) => w.isVisible()) ?? null;
|
||||
const opts: electron.MessageBoxOptions = {
|
||||
type: 'warning',
|
||||
title: '版本校验异常',
|
||||
message: '请注意,版本校验异常,可能是文件损坏或者被替换,因此您会看到此弹窗,您可以选择继续安装或取消并删除安装包',
|
||||
detail: this.error ? `核验详情:${this.error}` : '核验详情:sha512 校验和与发布记录不一致',
|
||||
buttons: ['继续安装', '取消并删除安装包'],
|
||||
defaultId: 1,
|
||||
cancelId: 1,
|
||||
noLink: true,
|
||||
};
|
||||
const { response } = parent
|
||||
? await dialog.showMessageBox(parent, opts)
|
||||
: await dialog.showMessageBox(opts);
|
||||
if (response !== 0) {
|
||||
// 取消并删除安装包
|
||||
log.warn('[updater] 用户取消安装并删除校验异常包');
|
||||
await this.removeDownloadedPackage().catch((e) => log.warn('[updater] 删除安装包失败:', e));
|
||||
this.state = 'idle';
|
||||
this.version = undefined;
|
||||
this.error = undefined;
|
||||
this.verified = false;
|
||||
this.emit();
|
||||
return;
|
||||
}
|
||||
log.warn('[updater] 用户确认继续安装(校验异常但已确认)');
|
||||
}
|
||||
|
||||
this.state = 'installing';
|
||||
this.emit();
|
||||
try {
|
||||
@@ -702,6 +767,17 @@ class UpdateService {
|
||||
autoUpdater.quitAndInstall(true, true);
|
||||
}
|
||||
|
||||
/** 删除已下载(校验失败)的安装包 */
|
||||
private async removeDownloadedPackage(): Promise<void> {
|
||||
const helper = (autoUpdater as unknown as { downloadedUpdateHelper?: { file?: string } }).downloadedUpdateHelper;
|
||||
const filePath = helper?.file;
|
||||
if (!filePath) return;
|
||||
if (fs.existsSync(filePath)) {
|
||||
await fs.promises.unlink(filePath);
|
||||
log.info(`[updater] 已删除安装包: ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取系统下载临时目录(用于清理提示,暂未启用) */
|
||||
getCacheDir(): string {
|
||||
return os.tmpdir();
|
||||
|
||||
+30
-14
@@ -31,6 +31,10 @@ if (!mode || !validModes.includes(mode)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// NSIS 专属资源(ico / 侧边横幅 BMP / 自定义脚本 / license)仅 Windows 打包需要;
|
||||
// Linux(AppImage 等)只需 build/icon.png,且不能调用 PowerShell。
|
||||
const isWin = process.platform === 'win32';
|
||||
|
||||
const root = join(__dirname, '..');
|
||||
const srcDir = join(root, 'public', 'icons', mode);
|
||||
const buildDir = join(root, 'build');
|
||||
@@ -45,13 +49,17 @@ const licenseSrc = join(
|
||||
mode === 'beta' ? 'protocol-beta.txt' : 'protocol-user.txt'
|
||||
);
|
||||
|
||||
// 必选文件存在性检查
|
||||
// 必选文件存在性检查(Windows 全量;Linux 仅 icon.png)
|
||||
const requiredFiles = [
|
||||
{ path: png, label: 'icon.png' },
|
||||
{ path: ico, label: 'icon.ico' },
|
||||
{ path: installerHeader, label: 'installer-header.png' },
|
||||
{ path: nsisCustom, label: 'installer-custom.nsh' },
|
||||
{ path: licenseSrc, label: 'license 协议文件' },
|
||||
...(isWin
|
||||
? [
|
||||
{ path: ico, label: 'icon.ico' },
|
||||
{ path: installerHeader, label: 'installer-header.png' },
|
||||
{ path: nsisCustom, label: 'installer-custom.nsh' },
|
||||
{ path: licenseSrc, label: 'license 协议文件' },
|
||||
]
|
||||
: []),
|
||||
];
|
||||
for (const file of requiredFiles) {
|
||||
if (!existsSync(file.path)) {
|
||||
@@ -112,19 +120,27 @@ function writeLicenseWithBom(src, out) {
|
||||
|
||||
mkdirSync(buildDir, { recursive: true });
|
||||
|
||||
// 1. 图标
|
||||
// 1. 图标(各平台通用:AppImage / Linux 需要 build/icon.png)
|
||||
cpSync(png, join(buildDir, 'icon.png'), { overwrite: true });
|
||||
cpSync(ico, join(buildDir, 'icon.ico'), { overwrite: true });
|
||||
|
||||
// 2. 安装程序欢迎页左侧大图(installerSidebar,由横幅 PNG 适配生成)
|
||||
createSidebarBmp(installerHeader, join(buildDir, 'installer-header.bmp'));
|
||||
// Windows 专属:NSIS 安装器资源
|
||||
if (isWin) {
|
||||
cpSync(ico, join(buildDir, 'icon.ico'), { overwrite: true });
|
||||
|
||||
// 3. NSIS 自定义脚本
|
||||
cpSync(nsisCustom, join(buildDir, 'installer-custom.nsh'), { overwrite: true });
|
||||
// 2. 安装程序欢迎页左侧大图(installerSidebar,由横幅 PNG 适配生成)
|
||||
createSidebarBmp(installerHeader, join(buildDir, 'installer-header.bmp'));
|
||||
|
||||
// 4. 协议文件(带 UTF-8 BOM,防止中文乱码)
|
||||
writeLicenseWithBom(licenseSrc, join(buildDir, 'license.txt'));
|
||||
// 3. NSIS 自定义脚本
|
||||
cpSync(nsisCustom, join(buildDir, 'installer-custom.nsh'), { overwrite: true });
|
||||
|
||||
// 4. 协议文件(带 UTF-8 BOM,防止中文乱码)
|
||||
writeLicenseWithBom(licenseSrc, join(buildDir, 'license.txt'));
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[switch-icon] Mode: ${mode} → build/icon.png + build/icon.ico + build/installer-header.bmp (sidebar 164x314) + build/installer-custom.nsh + build/license.txt updated`
|
||||
`[switch-icon] Mode: ${mode} → build/icon.png` +
|
||||
(isWin
|
||||
? ' + build/icon.ico + build/installer-header.bmp (sidebar 164x314) + build/installer-custom.nsh + build/license.txt'
|
||||
: '(Linux:仅 icon.png)') +
|
||||
' updated'
|
||||
);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
/**
|
||||
* 主进程运行时提示(一次性,kind 去重):
|
||||
* 目前用于 Linux 未以 AppImage 方式运行时提示更新组件受影响。
|
||||
*/
|
||||
export function RuntimeNotices() {
|
||||
useEffect(() => {
|
||||
const unsub = window.electronAPI?.onRuntimeNotice?.((notice) => {
|
||||
if (!notice?.message) return;
|
||||
// id 固定 → 同一提示只出现一次(页面刷新也不会重复弹)
|
||||
toast.warning(notice.message, {
|
||||
id: `runtime-notice:${notice.kind ?? "generic"}`,
|
||||
duration: Infinity,
|
||||
closeButton: true,
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
unsub?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { onUpdateStatus, getUpdateState } from "@/api/update";
|
||||
import { useRouteStore } from "@/stores/routeStore";
|
||||
import { useUpdateDialogStore } from "@/stores/updateDialogStore";
|
||||
import { BUILD_MODE } from "@/lib/mode";
|
||||
import { VERSION } from "@/lib/version";
|
||||
import { VersionCard } from "@/components/VersionCard";
|
||||
|
||||
// 与 VersionCard 一致的构建模式配色(dev 橙 / beta 绿 / run 蓝),用于主按钮底色
|
||||
const modeGradients: Record<string, string> = {
|
||||
dev: "linear-gradient(135deg, #F59E0B, #D97706)",
|
||||
beta: "linear-gradient(135deg, #10B981, #059669)",
|
||||
run: "linear-gradient(135deg, #3B82F6, #2563EB)",
|
||||
};
|
||||
|
||||
/**
|
||||
* "发现新版本" 弹窗(全局,RootLayout 挂载):
|
||||
* - 主进程检查到新版本(状态进入 available)且不在版本更新页时自动弹出
|
||||
* - 开发者工具可通过 useUpdateDialogStore.show(version) 手动唤起(用于预览)
|
||||
* - 上半部分直接复用 VersionCard(模式渐变 + Silk + Logo,随构建模式变色)
|
||||
* - 按钮:稍后更新 / 立即更新(跳转版本更新页面)
|
||||
*/
|
||||
export function UpdateAvailableDialog() {
|
||||
const open = useUpdateDialogStore((s) => s.open);
|
||||
const version = useUpdateDialogStore((s) => s.version);
|
||||
const hide = useUpdateDialogStore((s) => s.hide);
|
||||
const show = useUpdateDialogStore((s) => s.show);
|
||||
|
||||
const prevStateRef = useRef<string>("idle");
|
||||
const currentRouteRef = useRef<string>(useRouteStore.getState().current);
|
||||
|
||||
useEffect(() => {
|
||||
// 跟随路由(更新页自身不弹,避免打扰已在该页操作的用户)
|
||||
return useRouteStore.subscribe(() => {
|
||||
currentRouteRef.current = useRouteStore.getState().current;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = onUpdateStatus((status) => {
|
||||
// 仅在「进入 available」这一跳变时自动弹出(checking→available / 再次手动检查会重新触发)
|
||||
const alreadyOpen = useUpdateDialogStore.getState().open;
|
||||
if (
|
||||
status.state === "available" &&
|
||||
prevStateRef.current !== "available" &&
|
||||
currentRouteRef.current !== "update" &&
|
||||
!alreadyOpen
|
||||
) {
|
||||
show(status.version);
|
||||
}
|
||||
prevStateRef.current = status.state;
|
||||
});
|
||||
return unsub;
|
||||
}, [show]);
|
||||
|
||||
// 兜底:挂载时拉一次状态快照 —— 若启动静默检查的 available 广播早于本组件订阅
|
||||
// (渲染慢/竞态)会漏弹,这里补一次判定
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getUpdateState()
|
||||
.then((status) => {
|
||||
if (cancelled) return;
|
||||
const alreadyOpen = useUpdateDialogStore.getState().open;
|
||||
if (status.state === "available" && currentRouteRef.current !== "update" && !alreadyOpen) {
|
||||
show(status.version);
|
||||
}
|
||||
prevStateRef.current = status.state;
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [show]);
|
||||
|
||||
const goUpdate = () => {
|
||||
hide();
|
||||
// 等关闭动画结束后再切换路由,避免弹窗关闭与 view transition 快照抢帧导致无过渡
|
||||
window.setTimeout(() => {
|
||||
useRouteStore.getState().navigate("update");
|
||||
}, 220);
|
||||
};
|
||||
|
||||
const gradient = modeGradients[BUILD_MODE] ?? modeGradients.run;
|
||||
const targetVersion = version || VERSION;
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={(o) => !o && hide()}>
|
||||
<AlertDialogContent
|
||||
className="gap-0 overflow-hidden rounded-2xl p-0"
|
||||
style={{ width: "min(720px, calc(100vw - 2rem))", maxWidth: "min(720px, calc(100vw - 2rem))" }}
|
||||
>
|
||||
{/* 统一内边距容器:卡片与正文共用同一水平宽度(安全区不粘连边框) */}
|
||||
<div className="flex flex-col p-2.5 sm:p-3">
|
||||
{/* 上半部分:直接复用 VersionCard(全宽;关闭共享过渡名,避免打断路由切换动画) */}
|
||||
<VersionCard noViewTransition className="w-full" />
|
||||
|
||||
{/* 下半部分:与版本卡同宽的说明 + 按钮 */}
|
||||
<div className="flex flex-col gap-4 px-1 pt-4 pb-1">
|
||||
<div>
|
||||
<AlertDialogTitle className="font-heading text-base font-semibold text-foreground">
|
||||
版本更新可用
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="mt-1.5 text-[13px] leading-relaxed">
|
||||
当前版本 v{VERSION},发现新版本 v{targetVersion}。建议尽快更新以获得最新功能与修复。
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2.5">
|
||||
<button
|
||||
onClick={hide}
|
||||
className="flex-1 h-10 rounded-lg text-[13px] font-medium bg-foreground/[0.05] hover:bg-foreground/[0.1] text-foreground/70 hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
稍后更新
|
||||
</button>
|
||||
<button
|
||||
onClick={goUpdate}
|
||||
className="flex-[1.4] h-10 rounded-lg text-[13px] font-semibold text-white transition-colors cursor-pointer"
|
||||
style={{ background: gradient, boxShadow: "0 2px 10px rgba(0,0,0,0.12)" }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.filter = "brightness(1.08)")}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.filter = "none")}
|
||||
>
|
||||
立即更新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { useRouteStore } from "@/stores/routeStore";
|
||||
import Silk from "@/components/silk/Silk";
|
||||
import clsx from "clsx";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Suspense } from "react";
|
||||
import { Suspense, useEffect, useRef, useState } from "react";
|
||||
|
||||
const modeColors: Record<string, string> = {
|
||||
dev: "#F59E0B",
|
||||
@@ -40,6 +40,11 @@ interface VersionCardProps {
|
||||
* 并显示「查看更新」小字提示;其他页面为 false
|
||||
*/
|
||||
isSettingPage?: boolean;
|
||||
/**
|
||||
* 关闭共享元素过渡(view-transition-name):弹窗/浮层里复用 VersionCard 时开启,
|
||||
* 避免与页面上的 VersionCard 重名导致路由切换过渡失效
|
||||
*/
|
||||
noViewTransition?: boolean;
|
||||
}
|
||||
|
||||
export function VersionCard({
|
||||
@@ -49,6 +54,7 @@ export function VersionCard({
|
||||
simple = false,
|
||||
oobe = false,
|
||||
isSettingPage = false,
|
||||
noViewTransition = false,
|
||||
}: VersionCardProps) {
|
||||
const color = modeColors[overrideMode ?? BUILD_MODE] ?? modeColors.run;
|
||||
const gradient = modeGradients[overrideMode ?? BUILD_MODE] ?? modeGradients.run;
|
||||
@@ -67,8 +73,26 @@ export function VersionCard({
|
||||
if (clickable) navigate("update");
|
||||
};
|
||||
|
||||
// Silk(WebGL 动画)仅在卡片可见时运行:
|
||||
// 设置子页 keep-alive 后隐藏页仍挂在 DOM,若动画照跑会白白占 GPU/rAF →
|
||||
// 用 IntersectionObserver 按可见性挂载/卸载 Silk(隐藏/滚出视野即暂停,观感不变)
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
const [silkVisible, setSilkVisible] = useState(true);
|
||||
useEffect(() => {
|
||||
if (simple || !cardRef.current || typeof IntersectionObserver === "undefined") return;
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const en of entries) setSilkVisible(en.isIntersecting);
|
||||
},
|
||||
{ root: null, threshold: 0.02 },
|
||||
);
|
||||
io.observe(cardRef.current);
|
||||
return () => io.disconnect();
|
||||
}, [simple]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={clsx(
|
||||
"relative overflow-hidden rounded-xl border border-white/10 min-h-[200px]",
|
||||
clickable && "cursor-pointer hover:scale-[1.01] active:scale-[0.99] transition-transform",
|
||||
@@ -76,14 +100,19 @@ export function VersionCard({
|
||||
)}
|
||||
onClick={handleCardClick}
|
||||
// 共享元素过渡:路由切换时(startViewTransition),新旧页面中同名 view-transition-name
|
||||
// 的元素会从上一个位置平滑移动/形变到当前页面的位置
|
||||
style={{ viewTransitionName: "version-card" } as React.CSSProperties}
|
||||
// 的元素会从上一个位置平滑移动/形变到当前页面的位置。
|
||||
// 浮层/弹窗复用(noViewTransition)时关闭,避免与页面卡片重名打断过渡。
|
||||
style={
|
||||
noViewTransition
|
||||
? undefined
|
||||
: ({ viewTransitionName: "version-card" } as React.CSSProperties)
|
||||
}
|
||||
>
|
||||
{/* 背景层 */}
|
||||
<div className="absolute inset-0" style={{ background: gradient }} />
|
||||
|
||||
{/* Silk 动画层 (非 simple 模式) */}
|
||||
{!simple && (
|
||||
{/* Silk 动画层 (非 simple 模式;仅卡片可见时挂载,隐藏即暂停) */}
|
||||
{!simple && silkVisible && (
|
||||
<Suspense fallback={null}>
|
||||
<div className="absolute inset-0 opacity-60 mix-blend-soft-light">
|
||||
<Silk speed={3} scale={1.2} color={color} noiseIntensity={1.2} rotation={0.3} />
|
||||
|
||||
@@ -6,6 +6,9 @@ import { useDevStore } from "@/stores/devStore";
|
||||
import { DEFAULT_BG } from "@/lib/mode";
|
||||
import { resourceRegistry } from "@/resources/registry";
|
||||
import { estimateDataUrlBytes } from "@/resources/image";
|
||||
import { createRendererLogger } from "@/lib/logger";
|
||||
|
||||
const log = createRendererLogger("BackgroundLayer");
|
||||
|
||||
/** 可直接用于 CSS 的源(默认图/相对 URL/http(s)/file:/data:) */
|
||||
const isCssSource = (v: string) => /^(data:|https?:|file:|\/|\.\/|\.\.\/)/i.test(v);
|
||||
@@ -59,10 +62,12 @@ export function BackgroundLayer() {
|
||||
window.electronAPI?.resolveBackgroundResource?.(image).then((res) => {
|
||||
if (cancelled || !res) return;
|
||||
if (res.url) {
|
||||
log.info(`背景资源解析成功:${image} → ${res.url} (${res.bytes}B)`);
|
||||
setBgImage(res.url);
|
||||
setBgBytes(res.bytes || 0);
|
||||
} else {
|
||||
// 文件不可用/越权:回退默认背景(文件已失效,回退优于显示破损背景)
|
||||
log.warn(`背景资源解析失败/越权,回退默认背景:${image}`);
|
||||
setBgImage(DEFAULT_BG);
|
||||
setBgBytes(0);
|
||||
}
|
||||
@@ -80,6 +85,7 @@ export function BackgroundLayer() {
|
||||
setPrevious(oldActive);
|
||||
setActive(bgImage);
|
||||
activeRef.current = bgImage;
|
||||
log.debug(`背景切换 ${oldActive ?? "(无)"} → ${bgImage ?? "(无)"}`);
|
||||
// 已有旧背景且确实发生了内容切换(首次出现不渐入)
|
||||
if (oldActive != null && bgImage != null && oldActive !== bgImage) {
|
||||
setFading(true);
|
||||
@@ -171,6 +177,7 @@ export function BackgroundLayer() {
|
||||
const imageLayerStyle = (url: string): React.CSSProperties => ({
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
pointerEvents: "none", // 背景层永不接收任何指针事件
|
||||
backgroundImage: `url(${url})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
|
||||
@@ -11,11 +11,11 @@ import {
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Select,
|
||||
Switch,
|
||||
TextArea,
|
||||
} from "@heroui/react";
|
||||
import { Check, ChevronDown, FolderOpen, FolderSearch, Loader2 } from "lucide-react";
|
||||
import { ipcInvoke } from "@/api/ipc";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { SettingRow } from "./SettingRow";
|
||||
|
||||
export interface SettingOption {
|
||||
@@ -51,6 +51,7 @@ export function SettingSelect({
|
||||
return (
|
||||
<SettingRow label={label} desc={desc}>
|
||||
<Select.Root
|
||||
aria-label={label}
|
||||
selectedKey={selectedKey}
|
||||
onSelectionChange={(keys) => {
|
||||
// RAC 单选时可能传 Key | null,也可能传 Set<Key>;两种形状都兼容
|
||||
@@ -119,6 +120,7 @@ export function SettingNumberField({
|
||||
<SettingRow label={label} desc={desc}>
|
||||
<div className={`flex items-center gap-1.5 ${className ?? ""}`}>
|
||||
<NumberField.Root
|
||||
aria-label={label}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
minValue={min}
|
||||
@@ -162,12 +164,12 @@ export function SettingSwitch({
|
||||
}) {
|
||||
return (
|
||||
<SettingRow label={label} desc={desc}>
|
||||
{/* 注:HeroUI 3 基于 react-aria,Switch 使用 onChange 而非 onValueChange */}
|
||||
<Switch isSelected={checked} onChange={onChange}>
|
||||
<Switch.Control>
|
||||
<Switch.Thumb />
|
||||
</Switch.Control>
|
||||
</Switch>
|
||||
{/* shadcn Switch(@base-ui/react/switch):替代 HeroUI 3 Switch(鼠标点击不触发 change) */}
|
||||
<Switch
|
||||
aria-label={label}
|
||||
checked={checked}
|
||||
onCheckedChange={onChange}
|
||||
/>
|
||||
</SettingRow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"use client"
|
||||
|
||||
/**
|
||||
* shadcn 风格 Select(基于 @base-ui/react/select,与项目 switch/radio/slider 同底座)。
|
||||
* 用法:
|
||||
* <Select value={v} onValueChange={setV}>
|
||||
* <SelectTrigger aria-label="x"><SelectValue placeholder="请选择" /></SelectTrigger>
|
||||
* <SelectContent>
|
||||
* <SelectItem value="a">A</SelectItem>
|
||||
* </SelectContent>
|
||||
* </Select>
|
||||
*/
|
||||
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.Root.Props<string>) {
|
||||
return (
|
||||
<SelectPrimitive.Root
|
||||
data-slot="select"
|
||||
className={cn("", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
className,
|
||||
placeholder,
|
||||
...props
|
||||
}: SelectPrimitive.Value.Props & { placeholder?: string }) {
|
||||
if (placeholder) {
|
||||
return (
|
||||
<SelectPrimitive.Value
|
||||
placeholder={placeholder}
|
||||
data-slot="select-value"
|
||||
className={cn("text-[13px] text-foreground data-[placeholder]:text-muted-foreground/60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<SelectPrimitive.Value
|
||||
data-slot="select-value"
|
||||
className={cn("text-[13px] text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: SelectPrimitive.Trigger.Props & { size?: "sm" | "default" }) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"inline-flex h-8 w-full shrink-0 items-center justify-between gap-2 rounded-lg border border-border/40 bg-white/60 px-3 text-[13px] text-foreground outline-none transition-colors select-none dark:bg-black/30 dark:border-white/[0.08] focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 hover:border-ring/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectIcon({ className, ...props }: SelectPrimitive.Icon.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Icon
|
||||
data-slot="select-icon"
|
||||
className={cn("shrink-0 text-muted-foreground/60", className)}
|
||||
{...props}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</SelectPrimitive.Icon>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
position = "popper",
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: SelectPrimitive.Popup.Props & {
|
||||
position?: "popper" | "item-aligned"
|
||||
side?: "top" | "bottom" | "left" | "right"
|
||||
sideOffset?: number
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50",
|
||||
position === "popper" &&
|
||||
"w-[var(--anchor-width)] min-w-[10rem]"
|
||||
)}
|
||||
>
|
||||
<SelectPrimitive.Popup
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"relative max-h-72 min-w-[10rem] overflow-y-auto scroll-area rounded-xl border border-border/50 bg-background p-1.5 text-[13px] text-foreground shadow-xl outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Item.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"group/item relative flex w-full cursor-pointer items-center gap-2 rounded-lg py-1.5 pr-8 pl-2.5 text-[13px] text-foreground outline-none select-none",
|
||||
"data-highlighted:bg-muted data-selected:bg-primary/10 data-selected:text-primary",
|
||||
"data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText className="min-w-0 flex-1 truncate">
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator className="absolute right-2 text-primary">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({ className, ...props }: SelectPrimitive.Label.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("px-2.5 py-1.5 text-[12px] text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.Separator.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectIcon,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { type ReactNode } from "react";
|
||||
import { BackgroundLayer } from "@/components/background/BackgroundLayer";
|
||||
import { SystemLayer } from "@/components/system/SystemLayer";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { UpdateAvailableDialog } from "@/components/UpdateAvailableDialog";
|
||||
import { RuntimeNotices } from "@/components/RuntimeNotices";
|
||||
import { Toaster } from "sonner";
|
||||
import { useA11yStore } from "@/stores/a11yStore";
|
||||
import clsx from "clsx";
|
||||
@@ -55,6 +57,12 @@ export function RootLayout({
|
||||
{/* Global confirm dialog */}
|
||||
<ConfirmDialog />
|
||||
|
||||
{/* 全局:发现新版本弹窗(检查到新版本时自动弹出) */}
|
||||
<UpdateAvailableDialog />
|
||||
|
||||
{/* 运行时提示(Linux AppImage 未解包安装等) */}
|
||||
<RuntimeNotices />
|
||||
|
||||
{/* Sonner toaster */}
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 渲染端统一日志。
|
||||
*
|
||||
* 规则(与主进程 electron/core/logger.ts 对齐):
|
||||
* - 默认(非 debug):error/warn/info 输出到控制台(DevTools),debug 不输出;
|
||||
* - 用户开启「调试模式」(config.advanced.debugMode)后:debug 也输出,
|
||||
* 并经由 electronAPI.log → 主进程 log:write 桥汇入主进程统一日志:
|
||||
* dev(未打包)运行下同步输出到启动终端的 stdout/stderr,同时写入 userData/koring.log。
|
||||
*/
|
||||
|
||||
import { useConfigStore } from "@/stores/configStore";
|
||||
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
export interface RendererLogger {
|
||||
debug: (msg: string, ...args: unknown[]) => void;
|
||||
info: (msg: string, ...args: unknown[]) => void;
|
||||
warn: (msg: string, ...args: unknown[]) => void;
|
||||
error: (msg: string, ...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
function serialize(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value.length > 800 ? `${value.slice(0, 800)}…(+${value.length - 800})` : value;
|
||||
}
|
||||
if (value instanceof Error) return value.message;
|
||||
if (typeof value === "object" && value !== null) {
|
||||
try {
|
||||
return JSON.stringify(value) ?? String(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function isDebugMode(): boolean {
|
||||
try {
|
||||
return useConfigStore.getState().config?.advanced?.debugMode === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function emit(scope: string, level: LogLevel, msg: string, args: unknown[]): void {
|
||||
const text = args.length ? `${msg} ${args.map(serialize).join(" ")}` : msg;
|
||||
const line = `[${scope}][${level.toUpperCase()}] ${text}`;
|
||||
const debugOn = isDebugMode();
|
||||
|
||||
if (level === "error") console.error(line);
|
||||
else if (level === "warn") console.warn(line);
|
||||
else if (level === "info") console.info(line);
|
||||
else if (debugOn) console.debug(line);
|
||||
|
||||
if (!debugOn) return;
|
||||
window.electronAPI?.log?.(level, scope, line);
|
||||
}
|
||||
|
||||
function make(scope: string): RendererLogger {
|
||||
const bound = (level: LogLevel) => (msg: string, ...args: unknown[]) => emit(scope, level, msg, args);
|
||||
return {
|
||||
debug: bound("debug"),
|
||||
info: bound("info"),
|
||||
warn: bound("warn"),
|
||||
error: bound("error"),
|
||||
};
|
||||
}
|
||||
|
||||
export function createRendererLogger(scope: string): RendererLogger {
|
||||
return make(scope);
|
||||
}
|
||||
@@ -30,6 +30,8 @@ export function ResourceDebug() {
|
||||
const [proc, setProc] = useState<SystemMemorySnapshot | null>(null);
|
||||
const [heap, setHeap] = useState<JsHeapInfo | null>(null);
|
||||
const [domNodes, setDomNodes] = useState(0);
|
||||
const [logInfo, setLogInfo] = useState<{ filePath: string | null; debugMode: boolean } | null>(null);
|
||||
const [hitInfo, setHitInfo] = useState<string[] | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const snapshot = useResourceStore((s) => s.snapshot);
|
||||
@@ -37,6 +39,54 @@ export function ResourceDebug() {
|
||||
const clearFree = useResourceStore((s) => s.clearFree);
|
||||
const resetCounters = useResourceStore((s) => s.resetCounters);
|
||||
|
||||
/** 诊断「控件无法点击」:找出全屏覆盖且 pointer-events≠none 的元素,并采样几个点位的最上层元素 */
|
||||
const runHitTest = () => {
|
||||
const lines: string[] = [];
|
||||
const all = document.querySelectorAll<HTMLElement>("body *");
|
||||
// 1) 疑似全屏拦截层
|
||||
const seen = new Set<HTMLElement>();
|
||||
all.forEach((el) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const cs = getComputedStyle(el);
|
||||
const covers =
|
||||
rect.width >= window.innerWidth * 0.97 && rect.height >= window.innerHeight * 0.97;
|
||||
const clickable = cs.pointerEvents !== "none";
|
||||
if (!covers || !clickable || seen.has(el)) return;
|
||||
seen.add(el);
|
||||
const tag = el.tagName.toLowerCase();
|
||||
lines.push(
|
||||
`[全屏可点] <${tag}${el.id ? `#${el.id}` : ""}> pos=${cs.position} z=${cs.zIndex} class="${String(el.className).slice(0, 120)}"`,
|
||||
);
|
||||
});
|
||||
// 2) 采样几个位置的最上层元素
|
||||
const points: Array<[number, number, string]> = [
|
||||
[0.5, 0.5, "中央"],
|
||||
[0.5, 0.12, "标题栏下沿"],
|
||||
[0.25, 0.6, "内容区"],
|
||||
[0.75, 0.85, "内容区右下"],
|
||||
];
|
||||
for (const [fx, fy, label] of points) {
|
||||
const el = document.elementFromPoint(Math.floor(innerWidth * fx), Math.floor(innerHeight * fy));
|
||||
if (!el || el === document.body) {
|
||||
lines.push(`[${label}] (${fx},${fy}) → 无元素/body`);
|
||||
continue;
|
||||
}
|
||||
const target = el as HTMLElement;
|
||||
const cs = getComputedStyle(target);
|
||||
const chain: string[] = [];
|
||||
let node: HTMLElement | null = target;
|
||||
for (let i = 0; node && i < 5; i++) {
|
||||
chain.push(
|
||||
`${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ""}${node.className ? `.${String(node.className).split(/\s+/).filter(Boolean).slice(0, 2).join(".")}` : ""}`,
|
||||
);
|
||||
node = node.parentElement;
|
||||
}
|
||||
lines.push(`[${label}] (${fx},${fy}) → ${chain.join(" < ")} | pe=${cs.pointerEvents}`);
|
||||
}
|
||||
if (lines.length === 0) lines.push("未发现明显拦截层(可再多点几个位置)");
|
||||
setHitInfo(lines);
|
||||
};
|
||||
|
||||
const sample = async () => {
|
||||
setHeap(readJsHeap());
|
||||
setDomNodes(document.querySelectorAll("*").length);
|
||||
@@ -46,6 +96,14 @@ export function ResourceDebug() {
|
||||
} catch {
|
||||
setProc(null);
|
||||
}
|
||||
try {
|
||||
const info = (await window.electronAPI?.invoke?.("log:getInfo")) as
|
||||
| { filePath: string | null; debugMode: boolean }
|
||||
| undefined;
|
||||
if (info) setLogInfo(info);
|
||||
} catch {
|
||||
// 忽略日志状态查询失败
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -104,6 +162,58 @@ export function ResourceDebug() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统一日志状态 */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3">
|
||||
统一日志(debug 模式才写文件,否则仅控制台)
|
||||
</h3>
|
||||
<GlassCard>
|
||||
<div className="flex flex-wrap gap-x-8 gap-y-2">
|
||||
<div>
|
||||
<p className="text-[12px] text-muted-foreground">调试模式</p>
|
||||
<p className="text-lg font-semibold text-foreground">
|
||||
{logInfo?.debugMode ? "开启" : "关闭"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[12px] text-muted-foreground">日志文件</p>
|
||||
<p className="text-sm font-medium text-foreground break-all">
|
||||
{logInfo?.filePath ?? "(未开启 → 仅输出到控制台)"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[12px] text-muted-foreground">开启入口</p>
|
||||
<p className="text-sm font-medium text-foreground">设置 → 游戏 → 高级 → 调试模式</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
{/* 点击拦截诊断 */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3">
|
||||
点击拦截诊断(控件点了没反应时使用)
|
||||
</h3>
|
||||
<GlassCard>
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
找出「覆盖全屏且可接收指针」的元素,并采样 4 个点位的最上层元素
|
||||
</p>
|
||||
<button
|
||||
onClick={runHitTest}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-foreground/10 px-2 py-1 text-[12px] text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
检测
|
||||
</button>
|
||||
</div>
|
||||
{hitInfo && (
|
||||
<pre className="max-h-56 overflow-auto rounded-md bg-foreground/[0.04] p-3 text-[12px] leading-relaxed font-mono whitespace-pre-wrap text-foreground/80">
|
||||
{hitInfo.join("\n")}
|
||||
</pre>
|
||||
)}
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
{/* 进程工作集 */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3">
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type UpdateStatusPayload,
|
||||
type VersionCompareResult,
|
||||
} from "@/api/update";
|
||||
import { useUpdateDialogStore } from "@/stores/updateDialogStore";
|
||||
|
||||
/** 更新功能测试:版本识别 / 检查 / 介绍 / 比对 / 下载 */
|
||||
export function UpdateDebug() {
|
||||
@@ -71,7 +72,27 @@ export function UpdateDebug() {
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
{/* 2. 设置版本号 */}
|
||||
{/* 2. 新版本弹窗预览(唤起全局"发现新版本"弹窗) */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3">
|
||||
新版本弹窗预览
|
||||
</h3>
|
||||
<GlassCard>
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<Button size="sm" onClick={() => useUpdateDialogStore.getState().show(s?.version || "9.9.9")}>
|
||||
唤起新版本弹窗
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => useUpdateDialogStore.getState().hide()}>
|
||||
关闭弹窗
|
||||
</Button>
|
||||
<span className="text-[12px] text-muted-foreground font-mono ml-1">
|
||||
版本:{s?.version || "9.9.9"}(无可用版本时用 9.9.9 预览)
|
||||
</span>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
{/* 3. 设置版本号 */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3">
|
||||
设置测试版本号
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useConfigStore } from "@/stores/configStore";
|
||||
import {
|
||||
SettingCard,
|
||||
SettingSelect,
|
||||
SettingSwitch,
|
||||
SettingNumberField,
|
||||
SettingFilePicker,
|
||||
fieldCls,
|
||||
@@ -28,7 +27,7 @@ export function AdvancedSetting() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="高级设置" desc="游戏高级启动参数、调试选项与实验性功能" />
|
||||
<PageHeader title="高级设置" desc="游戏高级启动参数与实验性功能" />
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 启动行为 */}
|
||||
@@ -163,20 +162,6 @@ export function AdvancedSetting() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 调试 */}
|
||||
<div>
|
||||
<SectionTitle>调试</SectionTitle>
|
||||
<div className="space-y-3">
|
||||
<SettingCard>
|
||||
<SettingSwitch
|
||||
label="调试模式"
|
||||
desc="启用后附加 -Dkoring.debugMode=true 并在控制台输出详细日志,可能影响性能"
|
||||
checked={adv.debugMode}
|
||||
onChange={(v) => setAdvanced({ debugMode: v })}
|
||||
/>
|
||||
</SettingCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,8 @@ import { BUILD_MODE } from "@/lib/mode";
|
||||
import { BUILD_COMMIT, BUILD_ID } from "@/lib/buildInfo";
|
||||
import { ExternalLink, GitFork, RotateCcw, ChevronDown } from "lucide-react";
|
||||
import { Link, Select, ListBox, ListBoxItem } from "@heroui/react";
|
||||
import { SettingCard, SettingRow, PageHeader, SectionTitle } from "@/components/setting";
|
||||
import { useConfigStore } from "@/stores/configStore";
|
||||
import { SettingCard, SettingRow, SettingSwitch, PageHeader, SectionTitle } from "@/components/setting";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
getUpdateChannels,
|
||||
@@ -47,6 +48,9 @@ export function AboutSetting() {
|
||||
const [countdown, setCountdown] = useState(5);
|
||||
const canConfirm = countdown <= 0;
|
||||
|
||||
const adv = useConfigStore((s) => s.config.advanced);
|
||||
const setAdvanced = useConfigStore((s) => s.setAdvanced);
|
||||
|
||||
// 设备识别码(组合指纹:主板/硬盘/BIOS → 回退系统安装标识)
|
||||
const [device, setDevice] = useState<DeviceIdentity | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -201,6 +205,20 @@ export function AboutSetting() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SectionTitle>调试</SectionTitle>
|
||||
<div className="space-y-3">
|
||||
<SettingCard>
|
||||
<SettingSwitch
|
||||
label="调试模式"
|
||||
desc="在控制台输出详细日志,可能影响性能"
|
||||
checked={adv?.debugMode ?? false}
|
||||
onChange={(v) => setAdvanced({ debugMode: v })}
|
||||
/>
|
||||
</SettingCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SectionTitle>相关链接</SectionTitle>
|
||||
<div className="space-y-3">
|
||||
|
||||
+23
-10
@@ -110,14 +110,22 @@ function buildMenuData(
|
||||
|
||||
export function Setting() {
|
||||
const [selected, setSelected] = useState("home");
|
||||
const [animKey, setAnimKey] = useState(0);
|
||||
const [animOn, setAnimOn] = useState(true);
|
||||
// 已访问过的子页缓存(keep-alive):切换时不再全量卸载/重挂载,
|
||||
// 避免每个子页的 VersionCard/Silk、发布说明请求等重活反复执行;隐藏页不卸载。
|
||||
const [visited, setVisited] = useState<Record<string, boolean>>({ home: true });
|
||||
const routeNavigate = useRouteStore((s) => s.navigate);
|
||||
|
||||
const switchPage = useCallback((key: string) => {
|
||||
if (key === selected) return;
|
||||
setSelected(key);
|
||||
setAnimKey((k) => k + 1);
|
||||
}, [selected]);
|
||||
setSelected((prev) => {
|
||||
if (prev === key) return prev;
|
||||
setVisited((v) => ({ ...v, [key]: true }));
|
||||
// 两步重放进入动画:先移除类再回加(CSS 动画重新触发,页面无需重挂载)
|
||||
setAnimOn(false);
|
||||
requestAnimationFrame(() => setAnimOn(true));
|
||||
return key;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleItemClick = useCallback((item: MenuItem) => {
|
||||
if (item.onSelect) {
|
||||
@@ -129,7 +137,7 @@ export function Setting() {
|
||||
|
||||
const menuData = buildMenuData(switchPage, routeNavigate);
|
||||
const allItems = menuData.flatMap((g) => g.items);
|
||||
const current = allItems.find((i) => i.key === selected);
|
||||
const cachedItems = allItems.filter((i) => visited[i.key]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
@@ -172,11 +180,16 @@ export function Setting() {
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* 内容区 */}
|
||||
{/* 内容区(已访问页缓存保活;仅活动页可见并重放进入动画) */}
|
||||
<main className="scroll-area flex-1 h-full overflow-y-auto p-8">
|
||||
<div key={animKey} className="setting-page-enter">
|
||||
{current?.component}
|
||||
</div>
|
||||
{cachedItems.map((item) => {
|
||||
const active = selected === item.key;
|
||||
return (
|
||||
<div key={item.key} className={active ? (animOn ? "setting-page-enter" : undefined) : "hidden"}>
|
||||
{item.component}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from "react";
|
||||
import { useConfigStore } from "@/stores/configStore";
|
||||
import { Switch, Input } from "@heroui/react";
|
||||
import { Input } from "@heroui/react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { SettingCard, SettingRow, PageHeader, SectionTitle, fieldCls } from "@/components/setting";
|
||||
|
||||
@@ -30,11 +31,7 @@ export function SecurityIdSetting() {
|
||||
label="启用第三方认证"
|
||||
desc="使用自定义认证服务器替代 Microsoft 认证(适用于离线服务器)"
|
||||
>
|
||||
<Switch isSelected={enabled} onChange={handleToggle}>
|
||||
<Switch.Control>
|
||||
<Switch.Thumb />
|
||||
</Switch.Control>
|
||||
</Switch>
|
||||
<Switch aria-label="启用第三方认证" checked={enabled} onCheckedChange={handleToggle} />
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useA11yStore } from "@/stores/a11yStore";
|
||||
import { Switch } from "@heroui/react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { SettingCard, SettingRow, PageHeader, SectionTitle } from "@/components/setting";
|
||||
|
||||
export function A11ySetting() {
|
||||
@@ -15,31 +15,19 @@ export function A11ySetting() {
|
||||
<div className="space-y-3">
|
||||
<SettingCard>
|
||||
<SettingRow label="减少动画" desc="关闭页面切换动画和背景动效">
|
||||
<Switch isSelected={reduceMotion} onChange={setReduceMotion}>
|
||||
<Switch.Control>
|
||||
<Switch.Thumb />
|
||||
</Switch.Control>
|
||||
</Switch>
|
||||
<Switch aria-label="减少动画" checked={reduceMotion} onCheckedChange={setReduceMotion} />
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
|
||||
<SettingCard>
|
||||
<SettingRow label="减少透明度" desc="将磨砂玻璃效果替换为纯色背景,提升可读性">
|
||||
<Switch isSelected={reduceTransparency} onChange={setReduceTransparency}>
|
||||
<Switch.Control>
|
||||
<Switch.Thumb />
|
||||
</Switch.Control>
|
||||
</Switch>
|
||||
<Switch aria-label="减少透明度" checked={reduceTransparency} onCheckedChange={setReduceTransparency} />
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
|
||||
<SettingCard>
|
||||
<SettingRow label="高对比度" desc="增强文字与背景的对比度,改善可读性">
|
||||
<Switch isSelected={highContrast} onChange={setHighContrast}>
|
||||
<Switch.Control>
|
||||
<Switch.Thumb />
|
||||
</Switch.Control>
|
||||
</Switch>
|
||||
<Switch aria-label="高对比度" checked={highContrast} onCheckedChange={setHighContrast} />
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useThemeStore, type DarkMode } from "@/stores/themeStore";
|
||||
import { useBackgroundStore } from "@/stores/backgroundStore";
|
||||
import { Switch, Button, Slider } from "@heroui/react";
|
||||
import { Button, Slider } from "@heroui/react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { DEFAULT_BG } from "@/lib/mode";
|
||||
import clsx from "clsx";
|
||||
import { SettingCard, SettingRow, PageHeader, SectionTitle } from "@/components/setting";
|
||||
import { createRendererLogger } from "@/lib/logger";
|
||||
|
||||
const log = createRendererLogger("theme-bg");
|
||||
|
||||
/** 可直接用于 CSS/`<img>` 的源(data:/http(s):/file:/相对 URL) */
|
||||
const isCssSource = (v: string) => /^(data:|https?:|file:|\/|\.\/|\.\.\/)/i.test(v);
|
||||
@@ -101,7 +105,10 @@ export function ThemeBgSetting() {
|
||||
};
|
||||
}
|
||||
window.electronAPI?.resolveBackgroundResource?.(image).then((res) => {
|
||||
if (!cancelled) setPreviewUrl(res?.url ?? null);
|
||||
if (!cancelled) {
|
||||
setPreviewUrl(res?.url ?? null);
|
||||
log.debug(`预览资源解析 ${image} → ${res?.url ?? "(失败)"}`);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -112,7 +119,10 @@ export function ThemeBgSetting() {
|
||||
// 返回的是 userData 内的文件路径(配置/Store 以路径保存,不使用 BASE64)
|
||||
const filePath = await window.electronAPI?.pickBackgroundImage?.();
|
||||
if (filePath) {
|
||||
log.info(`选择壁纸完成 → ${filePath}`);
|
||||
setImage(filePath);
|
||||
} else {
|
||||
log.warn("选择壁纸未返回路径(取消或导入失败)");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -198,11 +208,7 @@ export function ThemeBgSetting() {
|
||||
|
||||
<SettingCard>
|
||||
<SettingRow label="背景图片视差" desc="背景图片随窗口滚动产生视差位移">
|
||||
<Switch isSelected={parallax} onChange={setParallax}>
|
||||
<Switch.Control>
|
||||
<Switch.Thumb />
|
||||
</Switch.Control>
|
||||
</Switch>
|
||||
<Switch aria-label="背景图片视差" checked={parallax} onCheckedChange={setParallax} />
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { VersionCard } from "@/components/VersionCard";
|
||||
import { AboutVersion } from "@/components/about-version";
|
||||
import { SectionTitle, SettingCard } from "@/components/setting";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { BUILD_MODE } from "@/lib/mode";
|
||||
@@ -197,7 +198,7 @@ export function UpdatePage() {
|
||||
: st === "downloaded"
|
||||
? status?.verified
|
||||
? "更新已下载完成(安装包已核验,点击安装)"
|
||||
: "更新已下载完成"
|
||||
: "版本校验异常:可能是文件损坏或被替换,点击安装将弹出确认框"
|
||||
: st === "installing"
|
||||
? "正在安装更新,应用即将重启..."
|
||||
: st === "error"
|
||||
@@ -209,6 +210,9 @@ export function UpdatePage() {
|
||||
<div className="space-y-6">
|
||||
<VersionCard />
|
||||
|
||||
{/* 版本卡片下方:当前版本更新内容速览(分类卡片) */}
|
||||
<AboutVersion />
|
||||
|
||||
<div>
|
||||
<SectionTitle>更新内容</SectionTitle>
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
type ResourceEntrySnapshot,
|
||||
type ResourceKind,
|
||||
} from "./types";
|
||||
import { createRendererLogger } from "@/lib/logger";
|
||||
|
||||
const log = createRendererLogger("resourceRegistry");
|
||||
|
||||
export interface AcquireOptions<T> {
|
||||
/** 估算占用字节数(用于预算与面板统计;未提供则记 0) */
|
||||
@@ -83,12 +86,14 @@ class ResourceRegistry {
|
||||
if (existing.settled) {
|
||||
this.hits += 1;
|
||||
this.emit();
|
||||
log.debug(`acquire 命中 ${kind}:${key} (refs=${existing.refs})`);
|
||||
return Promise.resolve(existing.payload as T | null);
|
||||
}
|
||||
return existing.inFlight as Promise<T | null>;
|
||||
}
|
||||
|
||||
this.misses += 1;
|
||||
log.debug(`acquire 创建 ${kind}:${key}`);
|
||||
const entry: InternalEntry = {
|
||||
key,
|
||||
kind,
|
||||
@@ -134,6 +139,7 @@ class ResourceRegistry {
|
||||
if (!entry) return;
|
||||
entry.refs = Math.max(0, entry.refs - 1);
|
||||
entry.lastUsed = Date.now();
|
||||
log.debug(`release ${key} (refs=${entry.refs})`);
|
||||
if (!entry.cache && entry.refs === 0) {
|
||||
this.drop(entry);
|
||||
this.emit();
|
||||
@@ -160,16 +166,16 @@ class ResourceRegistry {
|
||||
}
|
||||
this.emit();
|
||||
}
|
||||
|
||||
/** 释放全部「引用数为 0」的缓存条目(监控面板「释放缓存」按钮) */
|
||||
clearFree(): void {
|
||||
let dropped = false;
|
||||
let dropped = 0;
|
||||
for (const entry of [...this.entries.values()]) {
|
||||
if (entry.refs <= 0 && entry.settled) {
|
||||
this.drop(entry);
|
||||
dropped = true;
|
||||
dropped += 1;
|
||||
}
|
||||
}
|
||||
log.debug(`clearFree 释放缓存条目 ${dropped}`);
|
||||
if (dropped) this.emitNow();
|
||||
}
|
||||
|
||||
@@ -188,6 +194,7 @@ class ResourceRegistry {
|
||||
bytes -= entry.bytes;
|
||||
this.drop(entry);
|
||||
this.evictions += 1;
|
||||
log.debug(`LRU 逐出 ${entry.kind}:${entry.key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -238,6 +245,7 @@ class ResourceRegistry {
|
||||
|
||||
private drop(entry: InternalEntry): void {
|
||||
this.entries.delete(entry.key);
|
||||
log.debug(`释放资源 ${entry.kind}:${entry.key} (${entry.bytes}B, settled=${entry.settled})`);
|
||||
if (entry.settled && entry.payload != null) {
|
||||
try {
|
||||
entry.onRelease?.(entry.payload);
|
||||
|
||||
@@ -23,6 +23,10 @@ const DEFAULT: { type: BackgroundType; image: string; blur: number; opacity: num
|
||||
opacity: 1,
|
||||
};
|
||||
|
||||
/** 旧版主进程默认值用的绝对路径 /background.png(dev 可显示,打包 file:// 下指向文件系统根→黑屏)。
|
||||
* 统一归一化为渲染端 DEFAULT_BG(BASE_URL 相对路径,dev/打包均正确)。 */
|
||||
const normalizeDefaultBg = (url: string): string => (url === "/background.png" ? DEFAULT_BG : url);
|
||||
|
||||
export const useBackgroundStore = create<BackgroundState>((set) => ({
|
||||
...DEFAULT,
|
||||
|
||||
@@ -56,7 +60,7 @@ export function syncBackgroundFromConfig() {
|
||||
const bg = useConfigStore.getState().config.background;
|
||||
useBackgroundStore.setState({
|
||||
type: bg.bgType as BackgroundType,
|
||||
image: bg.image,
|
||||
image: normalizeDefaultBg(bg.image),
|
||||
blur: bg.blur,
|
||||
opacity: bg.opacity / 100,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
/**
|
||||
* "发现新版本"弹窗开关(渲染端共享):
|
||||
* - UpdateAvailableDialog 自动弹窗(状态进入 available)与
|
||||
* 开发者工具的手动唤起都通过这里控制
|
||||
*/
|
||||
interface UpdateDialogState {
|
||||
open: boolean;
|
||||
/** 目标(新)版本号;为空时展示当前版本 */
|
||||
version: string;
|
||||
show: (version?: string) => void;
|
||||
hide: () => void;
|
||||
}
|
||||
|
||||
export const useUpdateDialogStore = create<UpdateDialogState>((set) => ({
|
||||
open: false,
|
||||
version: "",
|
||||
show: (version = "") => set({ open: true, version }),
|
||||
hide: () => set({ open: false }),
|
||||
}));
|
||||
Vendored
+5
@@ -16,6 +16,8 @@ interface ElectronAPI {
|
||||
|
||||
onConfigChanged: (callback: (config: unknown) => void) => () => void;
|
||||
|
||||
onRuntimeNotice: (callback: (notice: { kind: string; message: string }) => void) => () => void;
|
||||
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
|
||||
// Crash monitoring
|
||||
@@ -25,6 +27,9 @@ interface ElectronAPI {
|
||||
// Config reset
|
||||
resetConfig: () => Promise<void>;
|
||||
|
||||
// 渲染端日志 → 主进程统一日志(debug 模式写文件)
|
||||
log: (level: "debug" | "info" | "warn" | "error", scope: string, message: string) => void;
|
||||
|
||||
// 壁纸(文件路径存储 → koring-res:// 资源引用,不使用 BASE64)
|
||||
pickBackgroundImage: () => Promise<string | null>;
|
||||
resolveBackgroundResource: (value: string) => Promise<{ url: string | null; bytes: number }>;
|
||||
|
||||
Reference in New Issue
Block a user