Compare commits

...
16 Commits
Author SHA1 Message Date
dream_pep 74512bf935 feat(runtime-notice): 添加 Linux 非 AppImage 运行时警告提示
- 新增 onRuntimeNotice IPC 接口与预加载桥接代码
- 在主进程添加 Linux 环境检查,未使用 AppImage 运行的打包版会提示更新组件可能受影响
- 新建 RuntimeNotices 组件处理并展示去重的持久化警告弹窗
- 将该组件注册到根布局中自动挂载
- 添加项目的 Git 提交信息规范文件
2026-09-05 21:14:43 +08:00
dream_pep aef49b173f feat(settings, version-card): 新增页面保活缓存并暂停离屏动画
为VersionCard添加IntersectionObserver,仅在卡片可见时运行Silk动画以节省GPU资源
在设置页实现页面保活缓存,保留已访问的子页组件优化切换流畅度
2026-09-05 20:57:29 +08:00
dream_pep 4acdad6535 refactor(paths): 重构路径解析逻辑并修复Linux打包版写入问题
适配Linux AppImage打包场景,将数据基准目录改为userData以解决只读目录无法写入的问题;重构路径解析相关函数,添加详细注释并优化代码结构。
2026-09-05 20:36:34 +08:00
dream_pep 3b627389c9 fix: 修复打包版背景图黑屏问题,重构数据路径逻辑
新增背景图路径归一化函数,将旧版使用的绝对路径 `/background.png` 转换为适配全环境的正确相对路径;重构Electron主进程的数据基准路径计算逻辑,修复Linux/AppImage打包版的只读目录限制
2026-09-05 20:35:59 +08:00
dream_pep caf52b9a3d fix(switch-icon脚本): 适配脚本以支持跨平台打包
添加平台检测逻辑,仅在Windows平台处理NSIS安装器所需的专属资源,Linux平台仅复制必要的图标文件,同时更新日志输出以正确显示已生成的文件。
2026-09-05 19:41:35 +08:00
dream_pep 37a5bb7538 refactor(updater, ci): 重构版本处理逻辑,适配新的版本格式
调整GitHub Release工作流的beta版本号格式,将`beta.$buildId`改为`$buildId.beta`,对齐新的版本命名规范
新增统一的版本标签解析与过滤逻辑,支持识别多种版本格式并屏蔽旧版beta候选
重写版本比较函数,修复旧版本比较的逻辑问题
重构更新检查与通道配置流程,不再依赖electron-updater的内置频道规则
优化发布说明获取的回退逻辑,统一使用generic feed结合多镜像源提升更新稳定性
2026-09-05 19:08:23 +08:00
dream_pep ffac581018 ci(release workflow): 新增Linux AppImage构建及上传发布流程
更新了工作流内的产物上传注释说明,新增build-linux job构建Linux AppImage包,生成对应平台的更新清单文件,beta版本还会额外生成beta-linux.yml,最终将所有资产上传至对应发布版本,适配electron-updater的Linux自动更新逻辑。
2026-09-05 18:57:22 +08:00
dream_pep 1785d36797 fix(updater): 为跑步者beta通道添加稳定版更新探测
修复electron-updater的semver版本判断逻辑错误,当跑步者beta通道无可用更新时,自动探测最新稳定版,检查完成后恢复原通道配置。
2026-09-05 01:28:18 +08:00
dream_pep bef1bbd85b chore: 更新 usage 记录 2026-09-05 00:45:06 +08:00
dream_pep 74b7456886 fix(update, setting): 修复更新弹窗漏弹问题并简化调试模式描述
为更新弹窗组件添加挂载时拉取更新状态的兜底逻辑,解决组件订阅晚于静默检查广播导致的更新弹窗漏弹问题
简化关于设置页面中调试模式开关的描述文字,移除冗余的启动参数和开发环境终端输出说明
2026-09-05 00:45:06 +08:00
dream_pep dfda648147 refactor: 迁移组件库至shadcn/ui并新增版本更新功能
迁移项目内所有HeroUI表单组件至shadcn/ui,统一组件风格并修复HeroUI Switch点击无响应的问题
新增全局版本更新弹窗功能,包含状态管理store、全局弹窗组件与调试页面调试工具
优化配置广播逻辑,添加250ms防抖避免频繁触发页面重渲染
新增开发环境下日志直接输出到终端的能力,完善日志输出规则
调整调试模式设置项位置,从高级设置页移动至关于设置页
为背景层添加pointer-events: none,避免遮挡页面点击事件
新增点击拦截诊断工具至资源调试页面,用于排查控件无法点击的问题
更新Koring.yml配置模板,新增advanced.preLaunchCmd字段
2026-09-05 00:45:06 +08:00
dream_pep a11e51d62c feat(更新页面): 添加版本更新速览分类卡片
在原有VersionCard组件下方添加AboutVersion组件,展示当前版本的更新内容概览
2026-09-05 00:45:05 +08:00
dream_pep e861661a55 feat: 新增跨进程统一日志系统并替换旧式控制台日志
- 新增渲染进程日志工具与主进程日志核心,支持按作用域分类日志
- 调试模式下日志会写入轮转文件,否则仅输出到控制台
- 暴露渲染器到主进程的日志 IPC 桥接接口
- 替换全项目所有旧式 console 日志调用为统一日志接口
- 新增调试页面日志状态面板
- 更新相关 TypeScript 类型定义与项目文档
2026-09-04 22:06:59 +08:00
dream_pep d5004b61d8 feat(更新模块): 提升更新安装安全性并添加统一主进程日志
重构更新验证流程:下载包验证失败时保持为已下载状态并标记校验未通过,而非直接置为错误状态
添加安装校验未通过包时的确认弹窗,支持删除异常安装包
禁用应用退出时自动安装,防止静默安装未校验的包
新增统一主进程日志模块,支持渲染进程通过IPC桥接接入日志
更新已下载但校验失败的UI提示文案
2026-09-04 21:59:10 +08:00
dream_pep 744494576e feat(background): 新增壁纸切换时的平滑淡入过渡效果
- 新增0.6秒缓出的背景淡入动画及对应CSS类
- 为所有壁纸缓存文件添加唯一后缀,确保每次切换都触发资源重载
- 重写BackgroundLayer组件,使用双层背景实现平滑交叉过渡
- 修复缓存壁纸查找逻辑,改为按修改时间选取最新文件
- 优化相关注释与代码健壮性
2026-09-04 21:36:05 +08:00
dream_pep bcac166a47 feat: 新增更新包本地完整性校验功能
- 新增更新包SHA512哈希和文件大小校验逻辑,仅在校验通过后才允许安装更新,防止损坏或被篡改的安装包被安装
- 更新更新页面UI,根据更新包是否已通过校验显示差异化提示文本
- 临时隐藏设置菜单中的Koring账户设置选项
- 修复发布说明生成脚本,改为按标签创建时间排序,并针对不同构建模式调整基准标签选取逻辑
2026-09-04 20:36:00 +08:00
44 changed files with 1621 additions and 286 deletions
+99 -2
View File
@@ -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.ymlelectron-updater 更新清单)
# - 上传产物Windows jobkoring-launcher-{full}-setup.exe + latest.yml
# - LinuxAppImage)由 build-linux job 追加:*.AppImage + latest-linux.ymlbeta 另加 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
# ==================== LinuxAppImage====================
# electron-updater 对 Linux 只原生支持 AppImage 自动更新(deb/rpm 走系统包管理器)。
# Windows job 创建 Release 后,本 job 把 AppImage + latest-linux.yml 上传到同一 Release。
# ⚠️ Linux 清单文件带 -linux 前缀:provider 取 latest-linux.ymlbeta 先试 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.ymlprovider 先取该文件,避免 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 ', ')"
+6
View File
@@ -0,0 +1,6 @@
---
alwaysApply: true
scene: git_message
---
在此处编写规则,自定义 AI 生成提交信息的风格。
+1
View File
@@ -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)
+2
View File
@@ -1,2 +1,4 @@
appVersion: 1.2.6
oobe: false
advanced:
preLaunchCmd: '123'
File diff suppressed because one or more lines are too long
+7
View File
@@ -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;
}
+46 -17
View File
@@ -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;
@@ -43,7 +46,8 @@ const MIME_TO_EXT: Record<string, string> = {
/**
* 旧版配置若仍存 BASE64 dataURL 且 userData 里没有原始缓存文件时,
* 直接把 dataURL 解码落盘为 `background-custom<ext>`保证配置文件能迁移为「文件路径」存储。
* 直接把 dataURL 解码落盘为 `background-custom-<唯一后缀><ext>`
* 保证配置文件能迁移为「文件路径」存储。
*/
export function recoverBackgroundFromDataUrl(dataUrl: string, userDataDir: string): string | null {
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=]+)$/.exec(dataUrl.trim());
@@ -54,7 +58,7 @@ export function recoverBackgroundFromDataUrl(dataUrl: string, userDataDir: strin
if (!buffer || buffer.length === 0) return null;
if (!fs.existsSync(userDataDir)) fs.mkdirSync(userDataDir, { recursive: true });
clearStaleBackgroundFiles(userDataDir, []);
const rawPath = path.join(userDataDir, `background-custom${ext}`);
const rawPath = path.join(userDataDir, `background-custom-${uniqueSuffix()}${ext}`);
fs.writeFileSync(rawPath, buffer);
return rawPath;
} catch {
@@ -81,6 +85,11 @@ function statSize(p: string): number {
}
}
/** 生成唯一后缀:每次导入的文件名都不同,保证渲染端 URL/配置路径变化以触发实时刷新与渐入动效 */
function uniqueSuffix(): string {
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
}
/** 删除 userData 目录里旧的壁纸缓存文件(保留 keep 中列出的完整路径) */
export function clearStaleBackgroundFiles(userDataDir: string, keep: string[]): void {
try {
@@ -105,7 +114,8 @@ export function clearStaleBackgroundFiles(userDataDir: string, keep: string[]):
* 对已复制到 userData 的原始壁纸做「按需」降采样,结果直接落盘。
* - 长边 ≤ maxEdge:不需要优化 → 直接返回原始缓存文件路径(零损耗,视觉 100% 一致);
* - 长边 > maxEdge:等比 resize → JPEG(q0.9) 或 PNG(透明/动画不处理) 写为
* `background-custom-opt.<ext>`,同时清理旧的其它格式 opt 文件;
* `background-custom-opt-<唯一后缀>.<ext>`(文件名每次不同,便于渲染端感知变化并做渐入),
* 同时清理旧的其它格式 opt 文件;
* - 动画 GIF 或无法解析:原样返回原始路径(不破坏动画/内容)。
*/
export function optimizeBackgroundFile(rawFilePath: string, maxEdge = 4096): OptimizedBackground {
@@ -174,9 +184,9 @@ export function optimizeBackgroundFile(rawFilePath: string, maxEdge = 4096): Opt
}
if (!buffer || buffer.length === 0) return fail(rawFilePath);
// 先清理旧 opt 文件(其它扩展名/旧尺寸),再原子写入新文件
// 先清理旧文件,再原子写入新文件(文件名带唯一后缀,保证每次导入路径都不同)
clearStaleBackgroundFiles(outDir, [rawFilePath]);
const optPath = path.join(outDir, `background-custom-opt${outExt}`);
const optPath = path.join(outDir, `background-custom-opt-${uniqueSuffix()}${outExt}`);
const tmpPath = `${optPath}.${process.pid}.tmp`;
fs.writeFileSync(tmpPath, buffer);
try {
@@ -199,8 +209,9 @@ export function optimizeBackgroundFile(rawFilePath: string, maxEdge = 4096): Opt
}
/**
* 复制用户选择的图片到 userData(原始缓存,命名 background-custom<ext>),
* 清理旧的其它扩展名缓存,然后返回优化结果。
* 复制用户选择的图片到 userData(原始缓存,命名 background-custom-<唯一后缀><ext>),
* 清理旧缓存,然后返回优化结果。每次导入文件名都不同 → 配置里的路径必然变化,
* 渲染端据此实时刷新并触发切换渐入动效。
*/
export function importUserBackground(srcPath: string, maxEdge = 4096): OptimizedBackground | null {
try {
@@ -209,26 +220,44 @@ export function importUserBackground(srcPath: string, maxEdge = 4096): Optimized
if (!fs.existsSync(userDataDir)) fs.mkdirSync(userDataDir, { recursive: true });
const ext = path.extname(srcPath).toLowerCase() || '.png';
const rawPath = path.join(userDataDir, `background-custom${ext}`);
// 删除旧的原始缓存(其它扩展名)
const rawPath = path.join(userDataDir, `background-custom-${uniqueSuffix()}${ext}`);
// 删除旧的原始缓存
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;
}
}
/** 找到 userData 中现有的壁纸缓存原始文件(不含 opt 变体) */
/** 找到 userData 中最近的壁纸缓存原始文件(不含 opt 变体) */
export function findCachedBackgroundRaw(userDataDir: string): string | null {
try {
const entries = fs.readdirSync(userDataDir);
const match = entries
.filter((n) => n.startsWith('background-custom') && !n.includes('-opt.'))
.sort()
const files = entries
.filter((n) => n.startsWith('background-custom') && !n.includes('-opt'))
.map((n) => path.join(userDataDir, n))
.find((p) => fs.existsSync(p) && fs.statSync(p).isFile());
return match ?? null;
.filter((p) => {
try {
return fs.statSync(p).isFile();
} catch {
return false;
}
});
if (files.length === 0) return null;
files.sort((a, b) => {
try {
return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs;
} catch {
return 0;
}
});
return files[0];
} catch {
return null;
}
+6 -3
View File
@@ -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 {
+212
View File
@@ -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
View File
@@ -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 → userDataexe 目录是只读挂载(/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;
+15 -2
View File
@@ -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) };
+11 -8
View File
@@ -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(); },
+4 -1
View File
@@ -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 };
+3 -2
View File
@@ -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
View File
@@ -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→userDataexe 目录是只读挂载)
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();
}
});
+12
View File
@@ -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),
+11 -1
View File
@@ -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 });
}
});
+265 -113
View File
@@ -1,11 +1,16 @@
import { autoUpdater, CancellationToken, type ProgressInfo } from 'electron-updater';
import electron from 'electron';
import * as fs from 'fs';
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'
@@ -33,6 +38,8 @@ export interface UpdateStatusPayload {
source?: string;
/** 当前更新通道(woker / runner */
channel?: string;
/** 安装包是否已通过本地核验(sha512 / 大小) */
verified?: boolean;
error?: string;
}
@@ -97,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/**
* - baseX.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.betabeta
* - {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];
}
@@ -152,6 +186,12 @@ class UpdateService {
private suppressErrors = false;
/** 当前下载的取消令牌(暂停/取消时 cancel) */
private downloadToken: CancellationToken | null = null;
/** 目标安装包是否已通过本地核验(sha512 + 大小),核验通过前不允许安装 */
private verified = false;
/** update-available 时记录的期望 sha512(来自 latest.yml */
private expectedSha512 = '';
/** 期望文件大小(字节) */
private expectedSize = 0;
/** 当前更新通道(woker 慢走 / runner 跑步;从配置读取,可运行时切换) */
private channelKey: UpdateChannelKey = 'woker';
@@ -160,7 +200,7 @@ class UpdateService {
this.currentVersion = app.getVersion();
if (!app.isPackaged) {
console.log('[updater] 开发模式:跳过自动更新');
log.info('[updater] 开发模式:跳过自动更新');
this.emit();
return;
}
@@ -174,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', () => {
@@ -190,11 +232,19 @@ class UpdateService {
this.state = 'available';
this.version = info.version;
this.error = undefined;
this.verified = false;
// 记录期望安装包校验值(来自 latest.yml 的 files[0]
const anyInfo = info as unknown as { files?: Array<{ sha512?: string; size?: number }> };
const first = Array.isArray(anyInfo?.files) ? anyInfo.files[0] : null;
this.expectedSha512 = String(first?.sha512 ?? '');
this.expectedSize = Number(first?.size ?? 0);
log.info(`[updater] 可用更新 ${info.version},期望 sha512=${this.expectedSha512.slice(0, 12)}… size=${this.expectedSize}`);
this.emit();
});
autoUpdater.on('update-not-available', () => {
this.state = 'not-available';
this.version = undefined;
this.verified = false;
this.emit();
});
autoUpdater.on('download-progress', (p) => {
@@ -202,14 +252,31 @@ class UpdateService {
this.progress = p;
this.emit();
});
autoUpdater.on('update-downloaded', (info) => {
// 下载完成 → 本地核验安装包(sha512 + 大小)。
// · 通过:进入"已下载可安装"verified=true
// · 失败:仍进入"已下载",但 verified=false + 记录原因 —— 点"安装"会弹确认框
// (继续安装 / 取消并删除安装包),绝不静默安装未核验包
autoUpdater.on('update-downloaded', async (info) => {
const errMsg = await this.verifyDownloadedPackage();
if (errMsg) {
log.error(`[updater] 安装包核验失败: ${errMsg}`);
this.state = 'downloaded';
this.verified = false;
this.version = info.version;
this.error = errMsg;
this.emit();
return;
}
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';
@@ -238,10 +305,46 @@ class UpdateService {
bytesPerSecond: this.progress?.bytesPerSecond,
source: this.source,
channel: this.channelKey,
verified: this.verified,
error: this.error,
};
}
/**
*
* 1)
* 2) latest.yml
* 3) sha512 latest.yml /
* null = = error
*/
private async verifyDownloadedPackage(): Promise<string | null> {
const helper = (autoUpdater as unknown as { downloadedUpdateHelper?: { file?: string } }).downloadedUpdateHelper;
const filePath = helper?.file;
if (!filePath) return '未找到已下载的安装包';
try {
const stat = await fs.promises.stat(filePath);
if (this.expectedSize > 0 && stat.size !== this.expectedSize) {
return `安装包大小不符(期望 ${this.expectedSize} 字节,实际 ${stat.size} 字节)`;
}
if (this.expectedSha512) {
const hash = crypto.createHash('sha512');
await new Promise<void>((resolve, reject) => {
const stream = fs.createReadStream(filePath);
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', () => resolve());
stream.on('error', reject);
});
const actual = hash.digest('hex').toLowerCase();
if (actual !== this.expectedSha512.toLowerCase()) {
return '安装包校验和不符(sha512 不匹配),文件可能已损坏或被篡改';
}
}
return null;
} catch (e) {
return `安装包核验失败:${String((e as Error)?.message ?? e)}`;
}
}
/** 将当前状态与下载进度写入配置(下载/安装进度落盘) */
private persist(payload: UpdateStatusPayload): void {
try {
@@ -258,7 +361,7 @@ class UpdateService {
},
});
} catch (e) {
console.warn('[updater] 更新进度写入配置失败:', e);
log.warn('[updater] 更新进度写入配置失败:', e);
}
}
@@ -272,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 动态渲染;可扩展) */
@@ -299,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();
@@ -308,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();
@@ -325,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;
@@ -333,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();
}
@@ -357,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;
@@ -386,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();
@@ -396,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.ymlchannel='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)}`);
}
}
@@ -447,19 +549,17 @@ class UpdateService {
}
/**
* release tag
* 1) /releases/latest HTML tag
* prereleaserelease 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[] = [];
// 方式 1HTML 页面
const web = `${base}https://github.com/${OWNER}/${REPO}`;
const api = `${base}https://api.github.com/repos/${OWNER}/${REPO}`;
// 方式 1/releases/latest 页面(最新非 prerelease release 的 tagHTML 正则兜底)
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),
});
@@ -471,34 +571,42 @@ class UpdateService {
} catch {
/* 尝试下一种方式 */
}
// 方式 2GitHub API(经加速源代理)
// 方式 2GitHub 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];
}
/**
@@ -510,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) {
@@ -542,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 {
@@ -563,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();
@@ -571,7 +679,7 @@ class UpdateService {
}
if (this.state === 'paused') {
// 继续下载(可能从断点续传,也可能重新开始,取决于 electron-updater 缓存)
console.log('[updater] 继续下载');
log.info('[updater] 继续下载');
}
this.state = 'downloading';
this.progress = null;
@@ -612,9 +720,42 @@ class UpdateService {
/**
* 退NSIS
* 退 debounce
* verified=false
* /
*/
quitAndInstall(): void {
async quitAndInstall(): Promise<void> {
if (!this.ready || this.state !== 'downloaded') return;
if (!this.verified) {
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 {
@@ -626,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();
+15 -5
View File
@@ -27,11 +27,21 @@ $rec = [char]0x1e # 记录分隔符(每个 commit 一条)
$sep = [char]0x1f # 字段分隔符(hash / subject / body
$format = "%x1f%H%x1f%s%x1f%b%x1e"
# 自上个 release tag(v*)以来的提交;无 tag 则取全部提交
$lastTag = git tag --sort=-version:refname 2>$null | Where-Object { $_ -match '^v' } | Select-Object -First 1
if ($lastTag) {
Write-Host "Commits since tag: $lastTag"
$raw = git log --format=$format "$lastTag..HEAD"
# 提交范围(base tag):按 tag 创建时间从新到旧取"上一个 release"。
# run(正式版)→ 上一个【正式版】tag:把上一个正式版之后所有 beta 的提交也写进正式版更新内容
# beta → 上一个 release tag(任意通道)
# 注意用 creatordate 而非 git 版本序:git 对 -beta.N / -N 混排不可靠(会把 beta.16 排到 -17 之前)。
$tagsNewestFirst = git for-each-ref --sort=-creatordate --format '%(refname:short)' refs/tags 2>$null |
Where-Object { $_ -match '^v' }
$baseTag = if ($Mode -eq 'run') {
($tagsNewestFirst | Where-Object { $_ -notmatch '-beta\.' } | Select-Object -First 1)
} else {
($tagsNewestFirst | Select-Object -First 1)
}
if ($baseTag) {
Write-Host "Commits since tag: $baseTag (mode=$Mode)"
$raw = git log --format=$format "$baseTag..HEAD"
} else {
Write-Host "No release tags found, listing all commits"
$raw = git log --format=$format "HEAD"
+30 -14
View File
@@ -31,6 +31,10 @@ if (!mode || !validModes.includes(mode)) {
process.exit(1);
}
// NSIS 专属资源(ico / 侧边横幅 BMP / 自定义脚本 / license)仅 Windows 打包需要;
// LinuxAppImage 等)只需 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'
);
+2
View File
@@ -37,6 +37,8 @@ export interface UpdateStatusPayload {
source?: string;
/** 当前更新通道(woker / runner */
channel?: string;
/** 安装包是否已通过本地核验(sha512 / 大小;核验通过前不允许安装) */
verified?: boolean;
error?: string;
}
+25
View File
@@ -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;
}
+136
View File
@@ -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>
);
}
+34 -5
View File
@@ -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} />
+69 -24
View File
@@ -6,10 +6,16 @@ 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);
/** 壁纸切换渐入时长(ms);与 .bg-fade-in 动画时长保持一致 */
const FADE_MS = 600;
export function BackgroundLayer() {
const { type, image, blur, opacity } = useBackgroundStore();
const parallax = useThemeStore((s) => s.parallax);
@@ -18,8 +24,7 @@ export function BackgroundLayer() {
const showContentBlur = route !== "home";
// bgImage:最终可显示的资源引用(CSS url 或 koring-res:// URL 或颜色)。
// 配置里若存的是本地文件路径(自定义壁纸),先置空等待主进程解析成 koring-res:// 引用
// 避免把文件路径直接当 CSS url 使用 / 闪默认图。
// 配置里若存的是本地文件路径(自定义壁纸),先置空等待主进程解析成 koring-res:// 引用
const [bgImage, setBgImage] = useState<string | null>(() => {
if (type === "color" || !image) return image ?? null;
return isCssSource(image) ? image : null;
@@ -27,11 +32,17 @@ export function BackgroundLayer() {
/** 解析后的文件字节(供资源注册表统计) */
const [bgBytes, setBgBytes] = useState(0);
// 双层背景做切换渐入:previous 为旧壁纸(保留至淡出结束),active 为新壁纸(带 fade-in
const [active, setActive] = useState<string | null>(bgImage);
const [previous, setPrevious] = useState<string | null>(null);
const [fading, setFading] = useState(false);
const committedRef = useRef<string | null>(bgImage);
const activeRef = useRef<string | null>(bgImage);
const bgRef = useRef<HTMLDivElement>(null);
// 解析背景源:默认图/颜色/data: 等直接用;
// 本地文件路径 → 主进程校验后返回 koring-res:// 协议引用(流式,无 base64 副本)。
// 代次守卫:防止慢的旧解析结果覆盖用户最新选择(行为不变,仅修竞态)。
// 本地文件路径 → 主进程校验后返回 koring-res:// 协议引用(每次导入文件名唯一 → URL 变化)。
useEffect(() => {
let cancelled = false;
if (type === "color" || !image) {
@@ -51,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);
}
@@ -64,12 +77,32 @@ export function BackgroundLayer() {
};
}, [image, type]);
// 提交新背景到「双层交叉淡化」状态机(相同 URL 忽略;旧背景保留至淡出结束)
useEffect(() => {
if (bgImage === committedRef.current) return;
committedRef.current = bgImage;
const oldActive = activeRef.current;
setPrevious(oldActive);
setActive(bgImage);
activeRef.current = bgImage;
log.debug(`背景切换 ${oldActive ?? "(无)"}${bgImage ?? "(无)"}`);
// 已有旧背景且确实发生了内容切换(首次出现不渐入)
if (oldActive != null && bgImage != null && oldActive !== bgImage) {
setFading(true);
}
}, [bgImage]);
useEffect(() => {
if (!fading) return;
const timer = setTimeout(() => setFading(false), FADE_MS + 120);
return () => clearTimeout(timer);
}, [fading]);
// 当前生效的背景登记进资源注册表(估算字节、单持有者语义):
// 背景切换时旧条目被释放丢弃,大 dataURL/引用不再被缓存层额外持有。
const trackedKey = useMemo(() => {
if (!bgImage) return null;
if (bgImage.startsWith("data:")) {
// 只取前缀 + 长度作为 key,避免把整段 dataURL 重复存进 Map key
return `background:current:${bgImage.length}:${bgImage.slice(0, 96)}`;
}
if (bgImage.startsWith("koring-res:")) {
@@ -119,12 +152,11 @@ export function BackgroundLayer() {
return () => window.removeEventListener("mousemove", handleMouseMove);
}, [parallax, handleMouseMove]);
// 自定义壁纸在解析完成前不显示(等待 koring-res 引用),避免闪默认图
const bgUrl = bgImage || DEFAULT_BG;
const contentBlur = showContentBlur && !forceDisableContentBlur;
const getBackgroundStyle = (): React.CSSProperties => {
const base: React.CSSProperties = {
// 外层容器:定位/不透明度/模糊滤镜/视差变换;图片内容由内部双层承载
const containerStyle = (): React.CSSProperties => {
const style: React.CSSProperties = {
position: "fixed",
inset: parallax ? -20 : 0,
zIndex: 0,
@@ -132,31 +164,44 @@ export function BackgroundLayer() {
opacity,
transition: "filter 0.4s cubic-bezier(0.4, 0, 0.2, 1), transform 0.1s ease-out",
};
const filters: string[] = [];
if (blur > 0) filters.push(`blur(${blur}px)`);
if (contentBlur) filters.push("blur(24px) saturate(120%)");
if (filters.length) base.filter = filters.join(" ");
if (filters.length) style.filter = filters.join(" ");
if (type === "color") {
return {
...base,
backgroundColor: bgUrl,
};
}
const style: React.CSSProperties = { ...base };
if (bgImage) {
style.backgroundImage = `url(${bgUrl})`;
style.backgroundSize = "cover";
style.backgroundPosition = "center";
style.backgroundColor = active ?? image ?? DEFAULT_BG;
}
return style;
};
const imageLayerStyle = (url: string): React.CSSProperties => ({
position: "absolute",
inset: 0,
pointerEvents: "none", // 背景层永不接收任何指针事件
backgroundImage: `url(${url})`,
backgroundSize: "cover",
backgroundPosition: "center",
});
const showPrevious = fading && previous != null && previous !== active;
const isImageType = type !== "color";
return (
<>
<div ref={bgRef} style={getBackgroundStyle()} />
<div ref={bgRef} style={containerStyle()}>
{isImageType && (
<>
{showPrevious && previous && <div style={imageLayerStyle(previous)} />}
{active && (
<div
key={active}
className={showPrevious ? "bg-fade-in" : undefined}
style={imageLayerStyle(active)}
/>
)}
</>
)}
</div>
<div
className="content-blur-overlay"
style={{ opacity: contentBlur ? 1 : 0 }}
+9 -7
View File
@@ -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-ariaSwitch 使用 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>
);
}
+202
View File
@@ -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,
}
+11
View File
@@ -293,6 +293,17 @@
animation: none !important;
}
/* ============================================
壁纸切换渐入BackgroundLayer 双层淡入
============================================ */
@keyframes bg-crossfade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.bg-fade-in {
animation: bg-crossfade-in 0.6s ease-out both;
}
.reduce-transparency {
--titlebar-bg: var(--background);
}
+8
View File
@@ -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"
+71
View File
@@ -0,0 +1,71 @@
/**
*
*
* electron/core/logger.ts
* - debugerror/warn/info DevToolsdebug
* - config.advanced.debugModedebug
* 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);
}
+110
View File
@@ -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">
+22 -1
View File
@@ -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">
+1 -16
View File
@@ -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>
);
+19 -1
View File
@@ -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">
+2 -2
View File
@@ -1,5 +1,4 @@
import {
UserCircle,
Gamepad2,
Palette,
Download,
@@ -37,7 +36,8 @@ function ShortcutTile({ icon, label, desc, navKey, onClick }: ShortcutItem & { o
}
const shortcuts: ShortcutItem[] = [
{ icon: <UserCircle className="w-4 h-4" />, label: "Koring 账户", desc: "同步数据、皮肤与个人配置", navKey: "account" },
// Koring 账户(暂时隐藏)
// { icon: <UserCircle className="w-4 h-4" />, label: "Koring 账户", desc: "同步数据、皮肤与个人配置", navKey: "account" },
{ icon: <Gamepad2 className="w-4 h-4" />, label: "游戏账户与档案", desc: "管理游戏内账户和档案配置", navKey: "game-account" },
{ icon: <Palette className="w-4 h-4" />, label: "主题与背景", desc: "深色模式、背景图片与视差", navKey: "theme-bg" },
{ icon: <Download className="w-4 h-4" />, label: "下载设置", desc: "下载线程数与存储路径", navKey: "download" },
+26 -13
View File
@@ -1,7 +1,6 @@
import { useState, useCallback, type ReactNode } from "react";
import {
Home,
UserCircle,
Info,
Copyright,
Gamepad2,
@@ -23,7 +22,7 @@ import {
import { useRouteStore } from "@/stores/routeStore";
import { HomeSetting } from "./general/home";
import { AccountSetting } from "./general/account";
// import { AccountSetting } from "./general/account"; // Koring 账户(暂时隐藏)
import { AboutSetting } from "./general/about";
import { CopyrightSetting } from "./general/copyright";
import { GameAccountSetting } from "./game/game-account";
@@ -65,7 +64,8 @@ function buildMenuData(
title: "通用",
items: [
{ key: "home", label: "主页", icon: <Home className={iconCls} />, component: <HomeSetting onNavigate={switchPage} /> },
{ key: "account", label: "Koring 账户", icon: <UserCircle className={iconCls} />, component: <AccountSetting /> },
// Koring 账户(暂时隐藏)
// { key: "account", label: "Koring 账户", icon: <UserCircle className={iconCls} />, component: <AccountSetting /> },
{ key: "about", label: "关于", icon: <Info className={iconCls} />, component: <AboutSetting /> },
{ key: "copyright", label: "版权", icon: <Copyright className={iconCls} />, component: <CopyrightSetting /> },
],
@@ -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>
);
+3 -6
View File
@@ -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>
+4 -16
View File
@@ -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>
+13 -7
View File
@@ -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>
+7 -1
View File
@@ -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";
@@ -195,7 +196,9 @@ export function UpdatePage() {
: st === "paused"
? `下载已暂停(${pct.toFixed(0)}%`
: st === "downloaded"
? "更新已下载完成"
? status?.verified
? "更新已下载完成(安装包已核验,点击安装)"
: "版本校验异常:可能是文件损坏或被替换,点击安装将弹出确认框"
: st === "installing"
? "正在安装更新,应用即将重启..."
: st === "error"
@@ -207,6 +210,9 @@ export function UpdatePage() {
<div className="space-y-6">
<VersionCard />
{/* 版本卡片下方:当前版本更新内容速览(分类卡片) */}
<AboutVersion />
<div>
<SectionTitle></SectionTitle>
+11 -3
View File
@@ -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);
+5 -1
View File
@@ -23,6 +23,10 @@ const DEFAULT: { type: BackgroundType; image: string; blur: number; opacity: num
opacity: 1,
};
/** /background.pngdev file://
* DEFAULT_BGBASE_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,
});
+21
View File
@@ -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 }),
}));
+5
View File
@@ -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 }>;