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
+27
View File
@@ -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 });
+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 { 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;
+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[];
}