fix: 修复自动更新版本判定误判问题

重构自动更新模块的版本比较逻辑,替换原有的semver比较规则为项目自定义的版本序:仅对比主版本号与构建号,忽略beta通道标记,确保同主版本下按构建号排序。新增correctAvailability方法在检查更新与下载前复核版本有效性,避免陈旧的更新信息导致误更新。调整GitHub release拉取逻辑,过滤掉草稿与不符合通道权限的预发布版本。新增src/resources目录下的运行时资源缓存管理系统,包含类型定义、图片解码管线与全局资源注册表,支持LRU内存逐出、引用计数与预算控制。新增系统内存监控的IPC接口与前端API,支持获取进程内存快照用于调试面板。更新auto-update-plan.md文档,补充本次版本判定修复的相关说明。
This commit is contained in:
2026-09-04 18:42:50 +08:00
parent 2c33015926
commit 7d8b7bd433
13 changed files with 827 additions and 31 deletions
+5
View File
@@ -329,6 +329,11 @@ src/
- **runner 通道显式设置 `autoUpdater.channel = "beta"`**:否则当前版本为数字尾号稳定版 - **runner 通道显式设置 `autoUpdater.channel = "beta"`**:否则当前版本为数字尾号稳定版
(如 `1.2.1-13`)时,`prerelease[0]="13"` 会被当自定义频道 → 通道循环无匹配, (如 `1.2.1-13`)时,`prerelease[0]="13"` 会被当自定义频道 → 通道循环无匹配,
正式版切跑步模式检测不到 beta(已修复,见 updater.ts applyChannel);woker 恢复 `latest` 正式版切跑步模式检测不到 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-availabledownload() 同样复核。
语义:base 相同比构建号;beta/正式只是通道标记不参与新旧排序;无构建号视为构建 -1
- 所有旧数字版本(`1.2.1-12` / `1.2.1-2608271921`)都小于 `1.2.1-beta.13`,平滑升级,无需提升 base - 所有旧数字版本(`1.2.1-12` / `1.2.1-2608271921`)都小于 `1.2.1-beta.13`,平滑升级,无需提升 base
## 14. M2 主进程更新模块(2026-08-28UI 待做) ## 14. M2 主进程更新模块(2026-08-28UI 待做)
File diff suppressed because one or more lines are too long
+41
View File
@@ -4,6 +4,13 @@ import * as os from 'os';
const { ipcMain, app, shell } = electron; const { ipcMain, app, shell } = electron;
interface ProcessMemorySample {
type: string;
pid: number;
workingSetSize: number; // KB
peakWorkingSetSize: number; // KB
}
function getBiosId(): string { function getBiosId(): string {
if (process.platform !== 'win32') return 'N/A (non-Windows)'; if (process.platform !== 'win32') return 'N/A (non-Windows)';
try { 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 }) => { ipcMain.handle('system:open-path', async (_event, payload: { path: string }) => {
try { try {
+62 -26
View File
@@ -98,43 +98,38 @@ function getMirrors(): string[] {
} }
/** /**
* 比较两个版本 tag(如 v1.2.1-13 / v1.2.1-beta.14 / v1.2.0-2608271921),返回 a - b。 * 项目版本序:比较两个版本 tag(如 v1.2.5-17 / v1.2.5-beta.16 / v1.2.0-2608271921),返回 a - b。
* 项目版本为 {base}-{buildId}base 按 X.Y.Z 数值比较;buildId 按 semver 规则: *
* - 无 buildId(稳定版)> 任意 prerelease * 语义(与 electron-updater 的纯 semver 不同,专为本项目版本方案定制):
* - 字母标识(beta.N)> 数字标识(N)——与 semver 一致:数字标识永远低于字母标识, * 每个 release = {base, 构建号}。beta.N 与 N 的 **beta/正式只是通道标记,不参与新旧排序**:
* 且不受数字大小影响(任何 beta.N 都大于任何 -N * - 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 是新版本。
*/ */
function compareVersionTags(a: string, b: string): number { 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 s = t.replace(/^v/i, '');
const [base, buildStr = ''] = s.split('-'); const [base, buildStr = ''] = s.split('-');
const nums = base.split('.').map((n) => parseInt(n, 10) || 0); const nums = base.split('.').map((n) => parseInt(n, 10) || 0);
while (nums.length < 3) nums.push(0); while (nums.length < 3) nums.push(0);
let pre: { kind: 0 | 1; num: number } | null = null; if (buildStr === '') {
if (buildStr !== '') { // 无构建号(旧版正式格式):同 base 内视为最旧
const beta = /^beta\.(\d+)$/i.exec(buildStr); return { base: nums, num: -1 };
// kind: 1 = 字母标识(beta),0 = 数字标识(数字优先级低于字母)
pre = beta
? { kind: 1, num: parseInt(beta[1], 10) || 0 }
: { kind: 0, num: parseInt(buildStr, 10) || 0 };
} }
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 pa = parse(a);
const pb = parse(b); const pb = parse(b);
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
if (pa.base[i] !== pb.base[i]) return pa.base[i] - pb.base[i]; if (pa.base[i] !== pb.base[i]) return pa.base[i] - pb.base[i];
} }
// 稳定版(无 prerelease> 任意 prerelease return pa.num - pb.num;
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;
} }
/** /**
@@ -345,6 +340,30 @@ class UpdateService {
return this.buildPayload(); return this.buildPayload();
} }
/**
* 项目版本序判定:candidate 是否为 current 的「新版本」。
* 见 compareVersionTags 的语义(base 相同 → 比构建号;beta/正式只是通道标记,不参与新旧)。
*/
private isNewerCandidate(current: string, candidate: string): boolean {
return compareVersionTags(candidate, current) > 0;
}
/**
* 复核 electron-updater 的"新版本"判定并修正状态:
* electron-updater 用纯 semverAppUpdater.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 */ /** 版本比对(semver 规则,支持 v 前缀与 prerelease */
compareVersions(a: string, b: string): { a: string; b: string; result: string; detail: string } { compareVersions(a: string, b: string): { a: string; b: string; result: string; detail: string } {
const va = semver.valid(a.trim()); const va = semver.valid(a.trim());
@@ -385,6 +404,8 @@ class UpdateService {
try { try {
await autoUpdater.checkForUpdates(); await autoUpdater.checkForUpdates();
this.suppressErrors = false; this.suppressErrors = false;
// 复核 electron-updater 的纯 semver 判定(v1.2.5-17 误判 v1.2.5-beta.16 为新版本)
this.correctAvailability();
return this.buildPayload(); return this.buildPayload();
} catch (err) { } catch (err) {
console.warn(`[updater] GitHub 官方更新源不可用: ${String((err as Error)?.message ?? err)}`); console.warn(`[updater] GitHub 官方更新源不可用: ${String((err as Error)?.message ?? err)}`);
@@ -406,6 +427,8 @@ class UpdateService {
this.emit(); this.emit();
await autoUpdater.checkForUpdates(); await autoUpdater.checkForUpdates();
this.suppressErrors = false; this.suppressErrors = false;
// 复核 electron-updater 的纯 semver 判定
this.correctAvailability();
// 镜像若反馈无更新(可能发现的是旧 tag / latest.yml 不匹配), // 镜像若反馈无更新(可能发现的是旧 tag / latest.yml 不匹配),
// 不要就此返回 not-available,继续尝试下一个源 // 不要就此返回 not-available,继续尝试下一个源
const mirrorResult = this.buildPayload(); const mirrorResult = this.buildPayload();
@@ -457,9 +480,12 @@ class UpdateService {
signal: AbortSignal.timeout(DISCOVER_TIMEOUT_MS), signal: AbortSignal.timeout(DISCOVER_TIMEOUT_MS),
}); });
if (res.ok) { 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 ?? []) { 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 { } catch {
@@ -533,6 +559,16 @@ class UpdateService {
async download(): Promise<void> { async download(): Promise<void> {
if (!this.ready) return; if (!this.ready) return;
if (this.state === 'downloading' || this.state === 'downloaded' || this.state === 'installing') 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') { if (this.state === 'paused') {
// 继续下载(可能从断点续传,也可能重新开始,取决于 electron-updater 缓存) // 继续下载(可能从断点续传,也可能重新开始,取决于 electron-updater 缓存)
console.log('[updater] 继续下载'); console.log('[updater] 继续下载');
+27
View File
@@ -11,6 +11,33 @@ export async function getSystemInfo(): Promise<SystemInfo> {
return ipcInvoke<SystemInfo>('system:info'); 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 }> { export async function openPath(targetPath: string): Promise<{ success: boolean; error?: string }> {
return ipcInvoke<{ success: boolean; error?: string }>('system:open-path', { path: targetPath }); return ipcInvoke<{ success: boolean; error?: string }>('system:open-path', { path: targetPath });
+39 -4
View File
@@ -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 { useBackgroundStore } from "@/stores/backgroundStore";
import { useThemeStore } from "@/stores/themeStore"; import { useThemeStore } from "@/stores/themeStore";
import { useRouteStore } from "@/stores/routeStore"; import { useRouteStore } from "@/stores/routeStore";
import { useDevStore } from "@/stores/devStore"; import { useDevStore } from "@/stores/devStore";
import { DEFAULT_BG } from "@/lib/mode"; import { DEFAULT_BG } from "@/lib/mode";
import { resourceRegistry } from "@/resources/registry";
import { estimateDataUrlBytes } from "@/resources/image";
export function BackgroundLayer() { export function BackgroundLayer() {
const { type, image, blur, opacity } = useBackgroundStore(); const { type, image, blur, opacity } = useBackgroundStore();
@@ -15,16 +17,49 @@ export function BackgroundLayer() {
const bgRef = useRef<HTMLDivElement>(null); const bgRef = useRef<HTMLDivElement>(null);
// 解析最终背景源:内置 URL / 颜色直接用;自定义路径经主进程取优化 dataURL。
// 增加代次守卫:防止慢的旧 IPC 结果覆盖用户最新选择(行为不变,仅修竞态)。
useEffect(() => { useEffect(() => {
let cancelled = false;
if (image && image !== DEFAULT_BG && !image.startsWith("data:")) { if (image && image !== DEFAULT_BG && !image.startsWith("data:")) {
(window as any).electronAPI?.getBackgroundDataUrl?.().then((dataUrl: string | null) => { (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]); }, [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( const handleMouseMove = useCallback(
(e: MouseEvent) => { (e: MouseEvent) => {
if (!parallax || !bgRef.current) return; if (!parallax || !bgRef.current) return;
+39
View File
@@ -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} />;
}
+94
View File
@@ -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> 的 URLready 时有效) */
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;
}
+121
View File
@@ -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));
/** 读取资源并解析为 Blobhttp(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;
}
+12
View File
@@ -0,0 +1,12 @@
/**
* 启动器程序本体「资源管理」子系统入口。
* 管理对象:运行时渲染资源(背景图、缩略图、Blob、文本缓存等),
* 与 Minecraft 游戏内容无关。
*/
export * from "./types";
export * from "./registry";
export * from "./store";
export * from "./image";
export * from "./hooks";
export * from "./ManagedImage";
+275
View File
@@ -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();
+73
View File
@@ -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();
+38
View File
@@ -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[];
}