mirror of
https://github.com/dream-pep/koring-launcher.git
synced 2026-09-12 05:45:18 +08:00
fix: 修复自动更新版本判定误判问题
重构自动更新模块的版本比较逻辑,替换原有的semver比较规则为项目自定义的版本序:仅对比主版本号与构建号,忽略beta通道标记,确保同主版本下按构建号排序。新增correctAvailability方法在检查更新与下载前复核版本有效性,避免陈旧的更新信息导致误更新。调整GitHub release拉取逻辑,过滤掉草稿与不符合通道权限的预发布版本。新增src/resources目录下的运行时资源缓存管理系统,包含类型定义、图片解码管线与全局资源注册表,支持LRU内存逐出、引用计数与预算控制。新增系统内存监控的IPC接口与前端API,支持获取进程内存快照用于调试面板。更新auto-update-plan.md文档,补充本次版本判定修复的相关说明。
This commit is contained in:
@@ -329,6 +329,11 @@ src/
|
||||
- **runner 通道显式设置 `autoUpdater.channel = "beta"`**:否则当前版本为数字尾号稳定版
|
||||
(如 `1.2.1-13`)时,`prerelease[0]="13"` 会被当自定义频道 → 通道循环无匹配,
|
||||
正式版切跑步模式检测不到 beta(已修复,见 updater.ts applyChannel);woker 恢复 `latest`
|
||||
- **新旧判定覆盖 electron-updater 的纯 semver(项目版本序)**:semver 认为同 base 下
|
||||
`beta.N > N`(字母标识优先)→ 会把 `1.2.5-beta.16` 误判为 `1.2.5-17` 的新版本。
|
||||
updater.ts `correctAvailability()` 在每次 check 后按**构建号优先**复核:
|
||||
候选构建号(忽略 beta 前缀)不大于当前 → 回退 not-available;download() 同样复核。
|
||||
语义:base 相同比构建号;beta/正式只是通道标记不参与新旧排序;无构建号视为构建 -1
|
||||
- 所有旧数字版本(`1.2.1-12` / `1.2.1-2608271921`)都小于 `1.2.1-beta.13`,平滑升级,无需提升 base
|
||||
|
||||
## 14. M2 主进程更新模块(2026-08-28,UI 待做)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -4,6 +4,13 @@ import * as os from 'os';
|
||||
|
||||
const { ipcMain, app, shell } = electron;
|
||||
|
||||
interface ProcessMemorySample {
|
||||
type: string;
|
||||
pid: number;
|
||||
workingSetSize: number; // KB
|
||||
peakWorkingSetSize: number; // KB
|
||||
}
|
||||
|
||||
function getBiosId(): string {
|
||||
if (process.platform !== 'win32') return 'N/A (non-Windows)';
|
||||
try {
|
||||
@@ -61,6 +68,40 @@ export function registerSystemHandlers() {
|
||||
}
|
||||
});
|
||||
|
||||
// 进程内存快照(资源/内存调试面板使用;纯读取,无副作用)
|
||||
ipcMain.handle('system:memory', async () => {
|
||||
try {
|
||||
const metrics: ProcessMemorySample[] = app.getAppMetrics().map((m) => ({
|
||||
type: String(m.type),
|
||||
pid: m.pid,
|
||||
workingSetSize: m.memory?.workingSetSize ?? 0,
|
||||
peakWorkingSetSize: m.memory?.peakWorkingSetSize ?? 0,
|
||||
}));
|
||||
let mainProcess: { workingSetSize: number; privateBytes: number } | null = null;
|
||||
try {
|
||||
const info = await process.getProcessMemoryInfo();
|
||||
mainProcess = {
|
||||
workingSetSize: info.workingSetSize,
|
||||
privateBytes: info.privateBytes,
|
||||
};
|
||||
} catch {
|
||||
// 个别平台不支持 getProcessMemoryInfo,忽略
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
app_version: app.getVersion(),
|
||||
timestamp: Date.now(),
|
||||
metrics,
|
||||
mainProcess,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
|
||||
// 在系统文件管理器中打开指定路径(用于"打开游戏目录"等操作)
|
||||
ipcMain.handle('system:open-path', async (_event, payload: { path: string }) => {
|
||||
try {
|
||||
|
||||
+62
-26
@@ -98,43 +98,38 @@ function getMirrors(): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* 比较两个版本 tag(如 v1.2.1-13 / v1.2.1-beta.14 / v1.2.0-2608271921),返回 a - b。
|
||||
* 项目版本为 {base}-{buildId}:base 按 X.Y.Z 数值比较;buildId 按 semver 规则:
|
||||
* - 无 buildId(稳定版)> 任意 prerelease
|
||||
* - 字母标识(beta.N)> 数字标识(N)——与 semver 一致:数字标识永远低于字母标识,
|
||||
* 且不受数字大小影响(任何 beta.N 都大于任何 -N)
|
||||
* - 同标识类型按数值比较
|
||||
* 项目版本序:比较两个版本 tag(如 v1.2.5-17 / v1.2.5-beta.16 / v1.2.0-2608271921),返回 a - b。
|
||||
*
|
||||
* 语义(与 electron-updater 的纯 semver 不同,专为本项目版本方案定制):
|
||||
* 每个 release = {base, 构建号}。beta.N 与 N 的 **beta/正式只是通道标记,不参与新旧排序**:
|
||||
* - base(X.Y.Z)不同 → 按 base 数值比较(优先)
|
||||
* - base 相同 → 按构建号(数字部分,忽略 beta 前缀)比较
|
||||
* - 无构建号(如 1.2.5,旧版正式格式)→ 构建号视为 -1(同 base 内最旧,可被后续带尾号版本覆盖)
|
||||
* - 构建号相同(如 beta.17 与 17)→ 视为相等(通道标记不参与排序;同号跨通道版本实际不会共存)
|
||||
*
|
||||
* 修复目标:v1.2.5-17(构建 17)不应把 v1.2.5-beta.16(构建 16)当新版本;
|
||||
* 但 v1.2.5-beta.18(构建 18)对 v1.2.5-17 是新版本。
|
||||
*/
|
||||
function compareVersionTags(a: string, b: string): number {
|
||||
const parse = (t: string): { base: number[]; pre: { kind: 0 | 1; num: number } | null } => {
|
||||
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);
|
||||
let pre: { kind: 0 | 1; num: number } | null = null;
|
||||
if (buildStr !== '') {
|
||||
const beta = /^beta\.(\d+)$/i.exec(buildStr);
|
||||
// kind: 1 = 字母标识(beta),0 = 数字标识(数字优先级低于字母)
|
||||
pre = beta
|
||||
? { kind: 1, num: parseInt(beta[1], 10) || 0 }
|
||||
: { kind: 0, num: parseInt(buildStr, 10) || 0 };
|
||||
if (buildStr === '') {
|
||||
// 无构建号(旧版正式格式):同 base 内视为最旧
|
||||
return { base: nums, num: -1 };
|
||||
}
|
||||
return { base: nums, pre };
|
||||
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);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (pa.base[i] !== pb.base[i]) return pa.base[i] - pb.base[i];
|
||||
}
|
||||
// 稳定版(无 prerelease)> 任意 prerelease
|
||||
if (pa.pre !== null && pb.pre === null) return -1;
|
||||
if (pa.pre === null && pb.pre !== null) return 1;
|
||||
if (pa.pre === null && pb.pre === null) return 0;
|
||||
// 上面已覆盖全部 null 组合,此处仅用于类型收窄(运行时不可达)
|
||||
if (pa.pre === null || pb.pre === null) return 0;
|
||||
// 字母标识 > 数字标识(与数字大小无关)
|
||||
if (pa.pre.kind !== pb.pre.kind) return pa.pre.kind - pb.pre.kind;
|
||||
return pa.pre.num - pb.pre.num;
|
||||
return pa.num - pb.num;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -345,6 +340,30 @@ class UpdateService {
|
||||
return this.buildPayload();
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目版本序判定:candidate 是否为 current 的「新版本」。
|
||||
* 见 compareVersionTags 的语义(base 相同 → 比构建号;beta/正式只是通道标记,不参与新旧)。
|
||||
*/
|
||||
private isNewerCandidate(current: string, candidate: string): boolean {
|
||||
return compareVersionTags(candidate, current) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 复核 electron-updater 的"新版本"判定并修正状态:
|
||||
* electron-updater 用纯 semver(AppUpdater.isUpdateAvailable → semver.gt),
|
||||
* 而 semver 规定同 base 下字母标识 > 数字标识 → v1.2.5-17 会把 v1.2.5-beta.16
|
||||
* 误判为新版本。按项目版本序复核:候选版本并非更新 → 状态回退为 not-available。
|
||||
*/
|
||||
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 通道标记),回退为无更新`);
|
||||
this.state = 'not-available';
|
||||
this.version = undefined;
|
||||
this.error = undefined;
|
||||
this.emit();
|
||||
}
|
||||
|
||||
/** 版本比对(semver 规则,支持 v 前缀与 prerelease) */
|
||||
compareVersions(a: string, b: string): { a: string; b: string; result: string; detail: string } {
|
||||
const va = semver.valid(a.trim());
|
||||
@@ -385,6 +404,8 @@ class UpdateService {
|
||||
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)}`);
|
||||
@@ -406,6 +427,8 @@ class UpdateService {
|
||||
this.emit();
|
||||
await autoUpdater.checkForUpdates();
|
||||
this.suppressErrors = false;
|
||||
// 复核 electron-updater 的纯 semver 判定
|
||||
this.correctAvailability();
|
||||
// 镜像若反馈无更新(可能发现的是旧 tag / latest.yml 不匹配),
|
||||
// 不要就此返回 not-available,继续尝试下一个源
|
||||
const mirrorResult = this.buildPayload();
|
||||
@@ -457,9 +480,12 @@ class UpdateService {
|
||||
signal: AbortSignal.timeout(DISCOVER_TIMEOUT_MS),
|
||||
});
|
||||
if (res.ok) {
|
||||
const releases: { draft?: boolean; tag_name?: string }[] = await res.json();
|
||||
const releases: { draft?: boolean; prerelease?: boolean; tag_name?: string }[] = await res.json();
|
||||
const allowPrerelease = getChannelDef(this.channelKey).allowPrerelease;
|
||||
for (const r of releases ?? []) {
|
||||
if (!r.draft && r.tag_name) candidates.push(r.tag_name);
|
||||
// draft 一律跳过;woker(只收正式版)跳过 GitHub 标记为 prerelease 的 release
|
||||
if (r.draft || (!allowPrerelease && r.prerelease)) continue;
|
||||
if (r.tag_name) candidates.push(r.tag_name);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -533,6 +559,16 @@ class UpdateService {
|
||||
async download(): Promise<void> {
|
||||
if (!this.ready) return;
|
||||
if (this.state === 'downloading' || this.state === 'downloaded' || this.state === 'installing') return;
|
||||
// 非"可用/已暂停"状态直接忽略(防陈旧 updateInfoAndProvider 被误用)
|
||||
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} 的新版本`);
|
||||
this.state = 'not-available';
|
||||
this.version = undefined;
|
||||
this.emit();
|
||||
return;
|
||||
}
|
||||
if (this.state === 'paused') {
|
||||
// 继续下载(可能从断点续传,也可能重新开始,取决于 electron-updater 缓存)
|
||||
console.log('[updater] 继续下载');
|
||||
|
||||
@@ -11,6 +11,33 @@ export async function getSystemInfo(): Promise<SystemInfo> {
|
||||
return ipcInvoke<SystemInfo>('system:info');
|
||||
}
|
||||
|
||||
// ---- 进程内存快照(资源/内存调试面板用)----
|
||||
|
||||
/** app.getAppMetrics() 的进程项;workingSetSize 单位为 KB */
|
||||
export interface ProcessMemoryMetric {
|
||||
type: string;
|
||||
pid: number;
|
||||
workingSetSize: number; // KB
|
||||
peakWorkingSetSize: number; // KB
|
||||
}
|
||||
|
||||
/** process.getProcessMemoryInfo()(主进程);单位为字节 */
|
||||
export interface MainProcessMemory {
|
||||
workingSetSize: number; // bytes
|
||||
privateBytes: number; // bytes
|
||||
}
|
||||
|
||||
export interface SystemMemorySnapshot {
|
||||
app_version: string;
|
||||
timestamp: number;
|
||||
metrics: ProcessMemoryMetric[];
|
||||
mainProcess: MainProcessMemory | null;
|
||||
}
|
||||
|
||||
export async function getMemorySnapshot(): Promise<SystemMemorySnapshot> {
|
||||
return ipcInvoke<SystemMemorySnapshot>('system:memory');
|
||||
}
|
||||
|
||||
// 在系统文件管理器中打开指定路径(用于"打开游戏目录"等操作)
|
||||
export async function openPath(targetPath: string): Promise<{ success: boolean; error?: string }> {
|
||||
return ipcInvoke<{ success: boolean; error?: string }>('system:open-path', { path: targetPath });
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import { useEffect, useRef, useCallback, useState, useMemo } from "react";
|
||||
import { useBackgroundStore } from "@/stores/backgroundStore";
|
||||
import { useThemeStore } from "@/stores/themeStore";
|
||||
import { useRouteStore } from "@/stores/routeStore";
|
||||
import { useDevStore } from "@/stores/devStore";
|
||||
import { DEFAULT_BG } from "@/lib/mode";
|
||||
import { resourceRegistry } from "@/resources/registry";
|
||||
import { estimateDataUrlBytes } from "@/resources/image";
|
||||
|
||||
export function BackgroundLayer() {
|
||||
const { type, image, blur, opacity } = useBackgroundStore();
|
||||
@@ -15,16 +17,49 @@ export function BackgroundLayer() {
|
||||
|
||||
const bgRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 解析最终背景源:内置 URL / 颜色直接用;自定义路径经主进程取优化 dataURL。
|
||||
// 增加代次守卫:防止慢的旧 IPC 结果覆盖用户最新选择(行为不变,仅修竞态)。
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (image && image !== DEFAULT_BG && !image.startsWith("data:")) {
|
||||
(window as any).electronAPI?.getBackgroundDataUrl?.().then((dataUrl: string | null) => {
|
||||
if (dataUrl) setBgImage(dataUrl);
|
||||
if (!cancelled && dataUrl) setBgImage(dataUrl);
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
setBgImage(image);
|
||||
}
|
||||
setBgImage(image);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [image]);
|
||||
|
||||
// 当前生效的背景登记进资源注册表(估算字节、单持有者语义):
|
||||
// 背景切换时旧条目被释放丢弃,大 dataURL 字符串不再被缓存层额外持有。
|
||||
const trackedKey = useMemo(() => {
|
||||
if (!bgImage || !bgImage.startsWith("data:")) return null;
|
||||
return `background:current:${bgImage.slice(0, 96)}`;
|
||||
}, [bgImage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!trackedKey) return;
|
||||
let cancelled = false;
|
||||
resourceRegistry
|
||||
.acquire<string>(trackedKey, "background", {
|
||||
bytes: estimateDataUrlBytes(bgImage),
|
||||
cache: false,
|
||||
load: async () => bgImage,
|
||||
})
|
||||
.then(() => {
|
||||
if (!cancelled) {
|
||||
resourceRegistry.setBytes(trackedKey, estimateDataUrlBytes(bgImage));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
resourceRegistry.release(trackedKey);
|
||||
};
|
||||
}, [trackedKey, bgImage]);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (!parallax || !bgRef.current) return;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* ManagedImage:经资源管理服务加载/缓存/降采样的 <img>。
|
||||
* 供启动器程序本体 UI(实例图标、资源图标等列表)复用;
|
||||
* 当前尚未被线上页面接入(占位页面保持原样),作为通用组件交付。
|
||||
*/
|
||||
|
||||
import { type ImgHTMLAttributes, type ReactNode } from "react";
|
||||
import { useManagedImage } from "./hooks";
|
||||
|
||||
export interface ManagedImageProps
|
||||
extends Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> {
|
||||
/** 图源:http(s) / blob: / data: */
|
||||
source: string;
|
||||
/** 目标长边上限(超过则降采样,默认 1024) */
|
||||
maxDimension?: number;
|
||||
/** 加载中占位(默认无) */
|
||||
loadingFallback?: ReactNode;
|
||||
/** 失败占位(默认无) */
|
||||
errorFallback?: ReactNode;
|
||||
}
|
||||
|
||||
export function ManagedImage({
|
||||
source,
|
||||
maxDimension,
|
||||
loadingFallback = null,
|
||||
errorFallback = null,
|
||||
alt,
|
||||
...rest
|
||||
}: ManagedImageProps) {
|
||||
const { status, url } = useManagedImage(source, { maxDimension });
|
||||
|
||||
if (status === "idle" || status === "loading") {
|
||||
return <>{loadingFallback}</>;
|
||||
}
|
||||
if (status === "error" || !url) {
|
||||
return <>{errorFallback}</>;
|
||||
}
|
||||
return <img src={url} alt={alt ?? ""} loading="lazy" decoding="async" {...rest} />;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 资源管理相关 React hooks。
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { resourceRegistry } from "./registry";
|
||||
import { decodeImageSource } from "./image";
|
||||
import type { ImageDecodeOptions } from "./image";
|
||||
|
||||
export type ManagedImageStatus = "idle" | "loading" | "ready" | "error";
|
||||
|
||||
export interface ManagedImageValue {
|
||||
status: ManagedImageStatus;
|
||||
/** 可直接用于 <img src> 的 URL(ready 时有效) */
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
interface DecodedPayload {
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
export interface UseManagedImageOptions extends ImageDecodeOptions {
|
||||
/** release 后是否缓存解码结果;默认 true */
|
||||
cache?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理式图片 hook:经资源注册表加载/缓存图片,组件卸载或源变化时释放引用;
|
||||
* 缓存条目被预算逐出时会自动 revokeObjectURL。
|
||||
* 注意:effect 依赖仅使用原始值(maxDimension/cache),对象 options 每次渲染新建不影响。
|
||||
*/
|
||||
export function useManagedImage(
|
||||
source: string | null | undefined,
|
||||
options: UseManagedImageOptions = {},
|
||||
): ManagedImageValue {
|
||||
const maxDimension = options.maxDimension ?? 1024;
|
||||
const cache = options.cache ?? true;
|
||||
const [value, setValue] = useState<ManagedImageValue>({
|
||||
status: source ? "loading" : "idle",
|
||||
url: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!source) {
|
||||
setValue({ status: "idle", url: null });
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
const key = `image:${maxDimension}:${source}`;
|
||||
setValue({ status: "loading", url: null });
|
||||
|
||||
resourceRegistry
|
||||
.acquire<DecodedPayload>(key, "image", {
|
||||
cache,
|
||||
bytes: 0,
|
||||
load: async () => {
|
||||
const decoded = await decodeImageSource(source, { maxDimension });
|
||||
if (!decoded) return null;
|
||||
return {
|
||||
url: decoded.url,
|
||||
width: decoded.width,
|
||||
height: decoded.height,
|
||||
bytes: decoded.bytes,
|
||||
};
|
||||
},
|
||||
onRelease: (payload) => {
|
||||
try {
|
||||
URL.revokeObjectURL(payload.url);
|
||||
} catch {
|
||||
// 释放失败可忽略
|
||||
}
|
||||
},
|
||||
})
|
||||
.then((payload) => {
|
||||
if (!alive) return;
|
||||
if (payload) {
|
||||
resourceRegistry.setBytes(key, payload.bytes);
|
||||
setValue({ status: "ready", url: payload.url });
|
||||
} else {
|
||||
setValue({ status: "error", url: null });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
resourceRegistry.release(key);
|
||||
};
|
||||
}, [source, maxDimension, cache]);
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 图片解码管线(渲染端)。
|
||||
*
|
||||
* 目标:把「远程/本地/Blob 图源」解码为可在 <img>/CSS 使用的对象 URL,
|
||||
* 并按显示尺寸降采样,避免大图以原始分辨率常驻内存。
|
||||
*
|
||||
* 说明:本工具面向启动器程序本体 UI(缩略图/图标列表等),
|
||||
* 与 Minecraft 游戏内容无关;当前由 ManagedImage 使用,
|
||||
* 尚未被任何线上页面接入(占位页面仍保持原样)。
|
||||
*/
|
||||
|
||||
export interface ImageDecodeResult {
|
||||
/** 可直接用于 <img src> / CSS 的 Blob 对象 URL;用完需 revoke */
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
/** 产物编码后字节数(估算内存占用用) */
|
||||
bytes: number;
|
||||
/** 是否实际发生了降采样重编码(false = 原样返回) */
|
||||
downscaled: boolean;
|
||||
}
|
||||
|
||||
export interface ImageDecodeOptions {
|
||||
/** 目标长边上限(CSS 像素);小于源图长边时降采样 */
|
||||
maxDimension?: number;
|
||||
}
|
||||
|
||||
const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));
|
||||
|
||||
/** 读取资源并解析为 Blob(http(s)/blob:/data: 均支持) */
|
||||
export async function fetchBlob(source: string): Promise<Blob | null> {
|
||||
try {
|
||||
const response = await fetch(source);
|
||||
if (!response.ok) return null;
|
||||
return await response.blob();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function decodeToBitmap(blob: Blob): Promise<ImageBitmap | null> {
|
||||
try {
|
||||
return await createImageBitmap(blob);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isOpaqueMime(type: string): boolean {
|
||||
return type !== "image/png" && type !== "image/webp" && type !== "image/gif";
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码图片为「长边不超过 maxDimension」的对象 URL。
|
||||
* - 源图较小:原样转对象 URL(零损耗,视觉 100% 一致);
|
||||
* - 源图较大:整图解码 → 等比绘制到小画布(编码 JPEG/PNG)→ 转对象 URL,
|
||||
* 原始大位图随即 close(),稳态内存远低于让浏览器常驻原始解码。
|
||||
* 失败返回 null(调用方自行降级,不抛异常)。
|
||||
*/
|
||||
export async function decodeImageSource(
|
||||
source: string,
|
||||
options: ImageDecodeOptions = {},
|
||||
): Promise<ImageDecodeResult | null> {
|
||||
const maxDimension = clamp(options.maxDimension ?? 1024, 64, 8192);
|
||||
try {
|
||||
const blob = await fetchBlob(source);
|
||||
if (!blob) return null;
|
||||
|
||||
const bitmap = await decodeToBitmap(blob);
|
||||
if (!bitmap) return null;
|
||||
|
||||
const { width, height } = bitmap;
|
||||
if (width <= 0 || height <= 0) {
|
||||
bitmap.close();
|
||||
return null;
|
||||
}
|
||||
|
||||
const longEdge = Math.max(width, height);
|
||||
if (longEdge <= maxDimension) {
|
||||
bitmap.close();
|
||||
const url = URL.createObjectURL(blob);
|
||||
return { url, width, height, bytes: blob.size, downscaled: false };
|
||||
}
|
||||
|
||||
const scale = maxDimension / longEdge;
|
||||
const targetWidth = Math.max(1, Math.round(width * scale));
|
||||
const targetHeight = Math.max(1, Math.round(height * scale));
|
||||
|
||||
const canvas = new OffscreenCanvas(targetWidth, targetHeight);
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
bitmap.close();
|
||||
return null;
|
||||
}
|
||||
ctx.drawImage(bitmap, 0, 0, targetWidth, targetHeight);
|
||||
bitmap.close();
|
||||
|
||||
const opaque = isOpaqueMime(blob.type);
|
||||
const outBlob = await canvas.convertToBlob({
|
||||
type: opaque ? "image/jpeg" : "image/png",
|
||||
quality: opaque ? 0.9 : undefined,
|
||||
});
|
||||
const url = URL.createObjectURL(outBlob);
|
||||
return { url, width: targetWidth, height: targetHeight, bytes: outBlob.size, downscaled: true };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 估算 dataURL/字符串占用字节(面板统计用) */
|
||||
export function estimateDataUrlBytes(value: string | null | undefined): number {
|
||||
if (!value) return 0;
|
||||
if (value.startsWith("data:")) {
|
||||
const comma = value.indexOf(",");
|
||||
if (comma > 0) {
|
||||
const base64 = value.slice(comma + 1);
|
||||
return Math.floor((base64.length * 3) / 4);
|
||||
}
|
||||
}
|
||||
return value.length;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 启动器程序本体「资源管理」子系统入口。
|
||||
* 管理对象:运行时渲染资源(背景图、缩略图、Blob、文本缓存等),
|
||||
* 与 Minecraft 游戏内容无关。
|
||||
*/
|
||||
|
||||
export * from "./types";
|
||||
export * from "./registry";
|
||||
export * from "./store";
|
||||
export * from "./image";
|
||||
export * from "./hooks";
|
||||
export * from "./ManagedImage";
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* 资源注册表服务(程序本体运行时资源管理核心)。
|
||||
*
|
||||
* 职责:加载 → 缓存 → 引用计数 → 预算/LRU 逐出 → 释放回调。
|
||||
* - 同一 key 并发 acquire 只会执行一次 load;
|
||||
* - 超预算时按 LRU 逐出「引用数 = 0 且已就绪」的条目;
|
||||
* - 逐出/清除时调用条目的 onRelease(如 revokeObjectURL / ImageBitmap.close),
|
||||
* 确保底层内存可被回收;
|
||||
* - 与 React 解耦,通过 subscribe 提供给调试/监控层。
|
||||
*/
|
||||
|
||||
import {
|
||||
DEFAULT_BUDGETS,
|
||||
type RegistrySnapshot,
|
||||
type RegistryStats,
|
||||
type ResourceEntrySnapshot,
|
||||
type ResourceKind,
|
||||
} from "./types";
|
||||
|
||||
export interface AcquireOptions<T> {
|
||||
/** 估算占用字节数(用于预算与面板统计;未提供则记 0) */
|
||||
bytes?: number;
|
||||
/** 资源加载器;同一 key 并发时只会执行一次 */
|
||||
load: () => Promise<T | null>;
|
||||
/** 条目被逐出/清除时回调(用于真正释放底层资源) */
|
||||
onRelease?: (payload: T) => void;
|
||||
/** 是否在 release 后仍缓存结果供复用;默认 true。false 表示「当前唯一持有者」语义(如背景图) */
|
||||
cache?: boolean;
|
||||
}
|
||||
|
||||
interface InternalEntry {
|
||||
key: string;
|
||||
kind: ResourceKind;
|
||||
bytes: number;
|
||||
refs: number;
|
||||
lastUsed: number;
|
||||
settled: boolean;
|
||||
payload: unknown;
|
||||
inFlight: Promise<unknown> | null;
|
||||
cache: boolean;
|
||||
onRelease?: (payload: unknown) => void;
|
||||
}
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
class ResourceRegistry {
|
||||
private entries = new Map<string, InternalEntry>();
|
||||
private budgets: Record<ResourceKind, number> = { ...DEFAULT_BUDGETS };
|
||||
private hits = 0;
|
||||
private misses = 0;
|
||||
private evictions = 0;
|
||||
private listeners = new Set<Listener>();
|
||||
private pendingEmit: ReturnType<typeof setTimeout> | null = null;
|
||||
private lastEmitAt = 0;
|
||||
|
||||
/** 调整某类资源的预算(字节) */
|
||||
setBudget(kind: ResourceKind, bytes: number): void {
|
||||
this.budgets[kind] = Math.max(0, Math.floor(bytes));
|
||||
this.evict();
|
||||
this.emitNow();
|
||||
}
|
||||
|
||||
getBudget(kind: ResourceKind): number {
|
||||
return this.budgets[kind];
|
||||
}
|
||||
|
||||
subscribe(listener: Listener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取(或注册)一个资源。返回与 load 结果一致的 Promise。
|
||||
* 调用方应在不再需要时调用 release(key),引用数归零后条目才可被逐出。
|
||||
*/
|
||||
acquire<T>(key: string, kind: ResourceKind, opts: AcquireOptions<T>): Promise<T | null> {
|
||||
const existing = this.entries.get(key);
|
||||
if (existing) {
|
||||
existing.refs += 1;
|
||||
existing.lastUsed = Date.now();
|
||||
if (existing.settled) {
|
||||
this.hits += 1;
|
||||
this.emit();
|
||||
return Promise.resolve(existing.payload as T | null);
|
||||
}
|
||||
return existing.inFlight as Promise<T | null>;
|
||||
}
|
||||
|
||||
this.misses += 1;
|
||||
const entry: InternalEntry = {
|
||||
key,
|
||||
kind,
|
||||
bytes: Math.max(0, Math.floor(opts.bytes ?? 0)),
|
||||
refs: 1,
|
||||
lastUsed: Date.now(),
|
||||
settled: false,
|
||||
payload: null,
|
||||
inFlight: null,
|
||||
cache: opts.cache ?? true,
|
||||
onRelease: opts.onRelease,
|
||||
};
|
||||
this.entries.set(key, entry);
|
||||
|
||||
const run = async (): Promise<T | null> => {
|
||||
let value: T | null = null;
|
||||
try {
|
||||
value = await opts.load();
|
||||
} catch {
|
||||
value = null;
|
||||
}
|
||||
entry.payload = value;
|
||||
entry.settled = true;
|
||||
entry.inFlight = null;
|
||||
if (entry.refs <= 0) {
|
||||
// 加载期间所有引用都已释放:直接丢弃,不保留缓存
|
||||
this.drop(entry);
|
||||
} else {
|
||||
this.evict();
|
||||
}
|
||||
this.emit();
|
||||
return value;
|
||||
};
|
||||
|
||||
entry.inFlight = run();
|
||||
return entry.inFlight as Promise<T | null>;
|
||||
}
|
||||
|
||||
/** 释放一次引用。cache=false 且引用归零时立即丢弃条目。 */
|
||||
release(key: string): void {
|
||||
const entry = this.entries.get(key);
|
||||
if (!entry) return;
|
||||
entry.refs = Math.max(0, entry.refs - 1);
|
||||
entry.lastUsed = Date.now();
|
||||
if (!entry.cache && entry.refs === 0) {
|
||||
this.drop(entry);
|
||||
this.emit();
|
||||
return;
|
||||
}
|
||||
if (entry.refs === 0 && entry.settled) {
|
||||
this.evict();
|
||||
}
|
||||
this.emit();
|
||||
}
|
||||
|
||||
/** 是否持有(含加载中)某 key */
|
||||
has(key: string): boolean {
|
||||
return this.entries.has(key);
|
||||
}
|
||||
|
||||
/** 加载完成后按实际占用修正估算字节(如解码产物实际大小) */
|
||||
setBytes(key: string, bytes: number): void {
|
||||
const entry = this.entries.get(key);
|
||||
if (!entry) return;
|
||||
entry.bytes = Math.max(0, Math.floor(bytes));
|
||||
if (entry.settled) {
|
||||
this.evict();
|
||||
}
|
||||
this.emit();
|
||||
}
|
||||
|
||||
/** 释放全部「引用数为 0」的缓存条目(监控面板「释放缓存」按钮) */
|
||||
clearFree(): void {
|
||||
let dropped = false;
|
||||
for (const entry of [...this.entries.values()]) {
|
||||
if (entry.refs <= 0 && entry.settled) {
|
||||
this.drop(entry);
|
||||
dropped = true;
|
||||
}
|
||||
}
|
||||
if (dropped) this.emitNow();
|
||||
}
|
||||
|
||||
/** 逐出超过预算的条目(LRU,仅引用数为 0 的已就绪条目) */
|
||||
evict(): void {
|
||||
const budgets = this.budgets;
|
||||
for (const kind of Object.keys(budgets) as ResourceKind[]) {
|
||||
const settled = [...this.entries.values()].filter((e) => e.kind === kind && e.settled);
|
||||
let bytes = settled.reduce((sum, e) => sum + e.bytes, 0);
|
||||
if (bytes <= budgets[kind]) continue;
|
||||
const free = settled
|
||||
.filter((e) => e.refs === 0)
|
||||
.sort((a, b) => a.lastUsed - b.lastUsed);
|
||||
for (const entry of free) {
|
||||
if (bytes <= budgets[kind]) break;
|
||||
bytes -= entry.bytes;
|
||||
this.drop(entry);
|
||||
this.evictions += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stats(): RegistryStats {
|
||||
const byKind: RegistryStats["byKind"] = {};
|
||||
let totalBytes = 0;
|
||||
let entries = 0;
|
||||
for (const entry of this.entries.values()) {
|
||||
if (!entry.settled) continue;
|
||||
entries += 1;
|
||||
totalBytes += entry.bytes;
|
||||
const group = (byKind[entry.kind] ??= { count: 0, bytes: 0 });
|
||||
group.count += 1;
|
||||
group.bytes += entry.bytes;
|
||||
}
|
||||
return {
|
||||
entries,
|
||||
totalBytes,
|
||||
hits: this.hits,
|
||||
misses: this.misses,
|
||||
evictions: this.evictions,
|
||||
byKind,
|
||||
};
|
||||
}
|
||||
|
||||
snapshot(): RegistrySnapshot {
|
||||
const entrySnapshots: ResourceEntrySnapshot[] = [];
|
||||
for (const entry of this.entries.values()) {
|
||||
if (!entry.settled) continue;
|
||||
entrySnapshots.push({
|
||||
key: entry.key,
|
||||
kind: entry.kind,
|
||||
bytes: entry.bytes,
|
||||
refs: entry.refs,
|
||||
});
|
||||
}
|
||||
entrySnapshots.sort((a, b) => b.bytes - a.bytes);
|
||||
return { stats: this.stats(), entries: entrySnapshots };
|
||||
}
|
||||
|
||||
resetCounters(): void {
|
||||
this.hits = 0;
|
||||
this.misses = 0;
|
||||
this.evictions = 0;
|
||||
this.emitNow();
|
||||
}
|
||||
|
||||
private drop(entry: InternalEntry): void {
|
||||
this.entries.delete(entry.key);
|
||||
if (entry.settled && entry.payload != null) {
|
||||
try {
|
||||
entry.onRelease?.(entry.payload);
|
||||
} catch {
|
||||
// 释放回调失败不影响主流程
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
const now = Date.now();
|
||||
if (now - this.lastEmitAt >= 200) {
|
||||
this.emitNow();
|
||||
return;
|
||||
}
|
||||
if (this.pendingEmit) return;
|
||||
this.pendingEmit = setTimeout(() => {
|
||||
this.pendingEmit = null;
|
||||
this.emitNow();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
private emitNow(): void {
|
||||
this.lastEmitAt = Date.now();
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener();
|
||||
} catch {
|
||||
// 单个监听器异常不影响其它监听器
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 全局单例(程序本体资源管理服务) */
|
||||
export const resourceRegistry = new ResourceRegistry();
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 资源注册表 → zustand 镜像(供「资源与内存」调试面板消费)。
|
||||
* 模块加载即订阅注册表事件,事件节流由注册表内部保证,无轮询。
|
||||
*/
|
||||
|
||||
import { create } from "zustand";
|
||||
import { resourceRegistry } from "./registry";
|
||||
import type { RegistrySnapshot } from "./types";
|
||||
|
||||
export interface ResourceStoreState {
|
||||
snapshot: RegistrySnapshot;
|
||||
refreshedAt: number;
|
||||
refresh: () => void;
|
||||
/** 释放全部「无引用」缓存条目(调试面板「释放缓存」按钮) */
|
||||
clearFree: () => void;
|
||||
resetCounters: () => void;
|
||||
setBudget: (kind: "background" | "image" | "blob" | "text", bytes: number) => void;
|
||||
budgets: Record<"background" | "image" | "blob" | "text", number>;
|
||||
}
|
||||
|
||||
function emptySnapshot(): RegistrySnapshot {
|
||||
return {
|
||||
stats: { entries: 0, totalBytes: 0, hits: 0, misses: 0, evictions: 0, byKind: {} },
|
||||
entries: [],
|
||||
};
|
||||
}
|
||||
|
||||
export const useResourceStore = create<ResourceStoreState>((set) => ({
|
||||
snapshot: emptySnapshot(),
|
||||
refreshedAt: 0,
|
||||
budgets: {
|
||||
background: resourceRegistry.getBudget("background"),
|
||||
image: resourceRegistry.getBudget("image"),
|
||||
blob: resourceRegistry.getBudget("blob"),
|
||||
text: resourceRegistry.getBudget("text"),
|
||||
},
|
||||
refresh: () =>
|
||||
set({
|
||||
snapshot: resourceRegistry.snapshot(),
|
||||
refreshedAt: Date.now(),
|
||||
budgets: {
|
||||
background: resourceRegistry.getBudget("background"),
|
||||
image: resourceRegistry.getBudget("image"),
|
||||
blob: resourceRegistry.getBudget("blob"),
|
||||
text: resourceRegistry.getBudget("text"),
|
||||
},
|
||||
}),
|
||||
clearFree: () => {
|
||||
resourceRegistry.clearFree();
|
||||
useResourceStore.getState().refresh();
|
||||
},
|
||||
resetCounters: () => {
|
||||
resourceRegistry.resetCounters();
|
||||
useResourceStore.getState().refresh();
|
||||
},
|
||||
setBudget: (kind, bytes) => {
|
||||
resourceRegistry.setBudget(kind, bytes);
|
||||
useResourceStore.getState().refresh();
|
||||
},
|
||||
}));
|
||||
|
||||
let subscribed = false;
|
||||
|
||||
/** 幂等订阅(任意模块首次 import 后生效) */
|
||||
function ensureSubscribed(): void {
|
||||
if (subscribed) return;
|
||||
subscribed = true;
|
||||
resourceRegistry.subscribe(() => {
|
||||
useResourceStore.getState().refresh();
|
||||
});
|
||||
}
|
||||
|
||||
ensureSubscribed();
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 启动器程序本体「资源管理」类型定义。
|
||||
*
|
||||
* 这里管理的「资源」指启动器自身运行时持有的渲染资源
|
||||
* (背景图 dataURL、远程/本地缩略图位图、Blob、文本缓存等),
|
||||
* 与 Minecraft 游戏内容无关。
|
||||
*/
|
||||
|
||||
export type ResourceKind = "background" | "image" | "blob" | "text";
|
||||
|
||||
/** 每种资源的默认内存预算(字节),超过后按 LRU 逐出未占用项 */
|
||||
export const DEFAULT_BUDGETS: Record<ResourceKind, number> = {
|
||||
background: 16 * 1024 * 1024, // 背景图(同时只应有一张活跃)
|
||||
image: 64 * 1024 * 1024, // 缩略图 / 图标位图
|
||||
blob: 32 * 1024 * 1024, // 通用二进制
|
||||
text: 4 * 1024 * 1024, // 文本 / JSON 片段
|
||||
};
|
||||
|
||||
export interface ResourceEntrySnapshot {
|
||||
key: string;
|
||||
kind: ResourceKind;
|
||||
bytes: number;
|
||||
refs: number;
|
||||
}
|
||||
|
||||
export interface RegistryStats {
|
||||
entries: number;
|
||||
totalBytes: number;
|
||||
hits: number;
|
||||
misses: number;
|
||||
evictions: number;
|
||||
byKind: Partial<Record<ResourceKind, { count: number; bytes: number }>>;
|
||||
}
|
||||
|
||||
export interface RegistrySnapshot {
|
||||
stats: RegistryStats;
|
||||
entries: ResourceEntrySnapshot[];
|
||||
}
|
||||
Reference in New Issue
Block a user