Files
dream_pep 3653e26a09 feat: 发布 v1.2.0 版本,新增多项核心功能
- 引入 electron-updater 实现自动更新功能
- 新增 Java 环境扫描与校验的 IPC 处理逻辑
- 实现离线账号登录功能
- 新增配置变更跨进程广播机制
- 重构游戏启动逻辑,使用主进程内存配置作为唯一权威来源
- 新增界面显示与语言设置的配置页面
- 添加 Windows 平台自动发布 CI 流水线
- 迁移旧版配置/认证文件到用户数据目录
- 修复崩溃日志路径、表单控件等多项 bug
- 重构设置页组件系统统一界面样式
2026-08-28 02:08:12 +08:00

537 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as fs from 'fs';
import * as path from 'path';
import { Version, type ResolvedVersion } from '@xmcl/core';
import {
install as xmclInstall,
installForge,
installFabric,
installNeoForged,
installOptifine,
installQuiltVersion,
installDependencies,
getVersionList,
getForgeVersionList as xmclGetForgeVersionList,
getFabricLoaders,
getQuiltLoaderVersionsByMinecraft,
} from '@xmcl/installer';
import { rewriteToMirror } from './installer';
// ==================== BMCLAPI 镜像源配置 ====================
// 版本清单(@xmcl/installer 的 getVersionList 通过 remote 参数覆盖)
export const BMCLAPI_VERSION_MANIFEST = 'https://bmclapi2.bangbang93.com/mc/game/version_manifest.json';
// Maven 仓库镜像(Libraries / Forge / NeoForge / Fabric 构件)
export const BMCLAPI_MAVEN = 'https://bmclapi2.bangbang93.com/maven';
// 资源文件镜像(assets
export const BMCLAPI_ASSETS = 'https://bmclapi2.bangbang93.com/assets';
// 供 @xmcl/installer FetchOptions 使用的镜像 fetchmeta.fabricmc.net 等元数据接口走镜像)
export const mirrorFetch: typeof fetch = (url, init) => fetch(rewriteToMirror(String(url)), init);
export interface InstanceRuntime {
minecraft: string;
forge?: string;
neoForged?: string;
fabricLoader?: string;
quiltLoader?: string;
optifine?: string;
}
export interface InstanceConfig {
name: string;
author?: string;
description?: string;
runtime: InstanceRuntime;
java?: string;
minMemory?: number;
maxMemory?: number;
vmOptions?: string[];
mcOptions?: string[];
server?: { host: string; port?: number; name?: string };
showLog?: boolean;
hideLauncher?: boolean;
icon?: string;
creationDate: number;
lastAccessDate: number;
lastPlayedDate: number;
playtime: number;
}
export interface InstanceInfo {
name: string;
path: string;
config: InstanceConfig;
modCount: number;
resourcePackCount: number;
screenshotCount: number;
saveCount: number;
healthy: boolean;
issues: string[];
}
export interface InstallProgress {
stage: string;
current: number;
total: number;
message?: string;
}
const INSTANCE_CONFIG_FILE = 'instance.json';
function getInstanceConfigPath(instancePath: string): string {
return path.join(instancePath, INSTANCE_CONFIG_FILE);
}
function countFiles(dir: string, ext?: string): number {
if (!fs.existsSync(dir)) return 0;
return fs.readdirSync(dir).filter((f) => {
if (ext) return f.endsWith(ext);
return fs.statSync(path.join(dir, f)).isFile();
}).length;
}
function getInstanceIssues(instancePath: string, runtime: InstanceRuntime): string[] {
const issues: string[] = [];
if (!fs.existsSync(path.join(instancePath, 'versions', runtime.minecraft, `${runtime.minecraft}.json`))) {
issues.push(`Version JSON not found for ${runtime.minecraft}`);
}
if (!fs.existsSync(path.join(instancePath, 'versions', runtime.minecraft, `${runtime.minecraft}.jar`))) {
issues.push(`Client JAR not found for ${runtime.minecraft}`);
}
return issues;
}
export async function createInstance(
name: string,
gamePath: string,
runtime: InstanceRuntime,
options?: {
author?: string;
description?: string;
java?: string;
minMemory?: number;
maxMemory?: number;
vmOptions?: string[];
mcOptions?: string[];
}
): Promise<InstanceInfo> {
const instancePath = path.join(gamePath, 'instances', name);
if (fs.existsSync(instancePath)) {
throw new Error(`Instance already exists: ${name}`);
}
fs.mkdirSync(instancePath, { recursive: true });
const now = Date.now();
const config: InstanceConfig = {
name,
author: options?.author || '',
description: options?.description || '',
runtime,
java: options?.java,
minMemory: options?.minMemory,
maxMemory: options?.maxMemory,
vmOptions: options?.vmOptions,
mcOptions: options?.mcOptions,
creationDate: now,
lastAccessDate: now,
lastPlayedDate: 0,
playtime: 0,
};
fs.writeFileSync(getInstanceConfigPath(instancePath), JSON.stringify(config, null, 2), 'utf-8');
// Create standard directories
for (const dir of ['mods', 'config', 'resourcepacks', 'shaderpacks', 'saves', 'screenshots', 'logs']) {
fs.mkdirSync(path.join(instancePath, dir), { recursive: true });
}
return getInstanceInfo(name, gamePath);
}
export async function listInstances(gamePath: string): Promise<InstanceInfo[]> {
const instancesPath = path.join(gamePath, 'instances');
const instances: InstanceInfo[] = [];
if (!fs.existsSync(instancesPath)) {
return instances;
}
const entries = fs.readdirSync(instancesPath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
try {
instances.push(await getInstanceInfo(entry.name, gamePath));
} catch {
// Skip invalid instances
}
}
return instances;
}
export async function getInstanceInfo(name: string, gamePath: string): Promise<InstanceInfo> {
const instancePath = path.join(gamePath, 'instances', name);
const configPath = getInstanceConfigPath(instancePath);
if (!fs.existsSync(configPath)) {
throw new Error(`Instance not found: ${name}`);
}
const configRaw = fs.readFileSync(configPath, 'utf-8');
const config = JSON.parse(configRaw) as InstanceConfig;
const issues = getInstanceIssues(instancePath, config.runtime);
return {
name,
path: instancePath,
config,
modCount: countFiles(path.join(instancePath, 'mods'), '.jar'),
resourcePackCount: countFiles(path.join(instancePath, 'resourcepacks'), '.zip'),
screenshotCount: countFiles(path.join(instancePath, 'screenshots')),
saveCount: countFiles(path.join(instancePath, 'saves')),
healthy: issues.length === 0,
issues,
};
}
export async function deleteInstance(name: string, gamePath: string): Promise<{ deleted: string }> {
const instancePath = path.join(gamePath, 'instances', name);
if (!fs.existsSync(instancePath)) {
throw new Error(`Instance not found: ${name}`);
}
fs.rmSync(instancePath, { recursive: true, force: true });
return { deleted: name };
}
export async function updateInstance(
name: string,
gamePath: string,
patch: Partial<Omit<InstanceConfig, 'name' | 'creationDate'>>
): Promise<InstanceInfo> {
const instancePath = path.join(gamePath, 'instances', name);
const configPath = getInstanceConfigPath(instancePath);
if (!fs.existsSync(configPath)) {
throw new Error(`Instance not found: ${name}`);
}
const configRaw = fs.readFileSync(configPath, 'utf-8');
const config = JSON.parse(configRaw) as InstanceConfig;
const updated = { ...config, ...patch };
fs.writeFileSync(configPath, JSON.stringify(updated, null, 2), 'utf-8');
return getInstanceInfo(name, gamePath);
}
export async function installInstanceGame(
name: string,
gamePath: string,
callbacks?: { onProgress?: (progress: InstallProgress) => void }
): Promise<InstanceInfo> {
const instance = await getInstanceInfo(name, gamePath);
const { runtime } = instance.config;
const instancePath = instance.path;
callbacks?.onProgress?.({ stage: 'installing-minecraft', current: 0, total: 100, message: `Installing Minecraft ${runtime.minecraft}...` });
// Install Minecraft
// First get version list to find the version info(走 BMCLAPI 镜像)
const versionList = await getVersionList({ remote: BMCLAPI_VERSION_MANIFEST, fetch: mirrorFetch });
const versionInfo = versionList.versions.find((v) => v.id === runtime.minecraft);
if (!versionInfo) {
throw new Error(`Minecraft version ${runtime.minecraft} not found`);
}
await xmclInstall({ id: versionInfo.id, url: versionInfo.url }, instancePath, {
// 版本 JSON / 客户端 JAR 走镜像(BMCLAPI 会回源官方地址)
json: (v) => rewriteToMirror(v.url),
client: (v) => (v.downloads.client ? rewriteToMirror(v.downloads.client.url) : []),
mavenHost: BMCLAPI_MAVEN,
assetsHost: BMCLAPI_ASSETS,
});
// Install mod loaders
if (runtime.forge) {
callbacks?.onProgress?.({ stage: 'installing-forge', current: 0, total: 100, message: `Installing Forge ${runtime.forge}...` });
await installForge({ version: runtime.forge, mcversion: runtime.minecraft }, instancePath, { mavenHost: BMCLAPI_MAVEN });
}
if (runtime.fabricLoader) {
callbacks?.onProgress?.({ stage: 'installing-fabric', current: 0, total: 100, message: `Installing Fabric ${runtime.fabricLoader}...` });
await installFabric({
minecraftVersion: runtime.minecraft,
version: runtime.fabricLoader,
minecraft: instancePath,
fetch: mirrorFetch,
});
}
if (runtime.quiltLoader) {
callbacks?.onProgress?.({ stage: 'installing-quilt', current: 0, total: 100, message: `Installing Quilt ${runtime.quiltLoader}...` });
await installQuiltVersion({
minecraftVersion: runtime.minecraft,
version: runtime.quiltLoader,
minecraft: instancePath,
});
}
if (runtime.neoForged) {
callbacks?.onProgress?.({ stage: 'installing-neoforge', current: 0, total: 100, message: `Installing NeoForge ${runtime.neoForged}...` });
await installNeoForged('neoforge', runtime.neoForged, instancePath, { mavenHost: BMCLAPI_MAVEN });
}
// Note: OptiFine requires downloading the installer JAR first
// if (runtime.optifine) {
// callbacks?.onProgress?.({ stage: 'installing-optifine', current: 0, total: 100, message: `Installing OptiFine ${runtime.optifine}...` });
// await installOptifine(runtime.optifine, instancePath);
// }
callbacks?.onProgress?.({ stage: 'installing-dependencies', current: 0, total: 100, message: 'Installing dependencies...' });
// Install all dependencies (libraries + assets)(走 BMCLAPI 镜像)
const resolved: ResolvedVersion = await Version.parse(instancePath, runtime.minecraft);
await installDependencies(resolved, { mavenHost: BMCLAPI_MAVEN, assetsHost: BMCLAPI_ASSETS });
callbacks?.onProgress?.({ stage: 'done', current: 100, total: 100, message: 'Installation complete' });
// Update last access date
await updateInstance(name, gamePath, { lastAccessDate: Date.now() });
return getInstanceInfo(name, gamePath);
}
export async function diagnoseInstance(
name: string,
gamePath: string
): Promise<{ healthy: boolean; issues: string[] }> {
const instance = await getInstanceInfo(name, gamePath);
return {
healthy: instance.healthy,
issues: instance.issues,
};
}
// Version list APIs
export async function getMinecraftVersionList(type?: string) {
// 版本清单一律走 BMCLAPI 镜像
const manifest = await getVersionList({ remote: BMCLAPI_VERSION_MANIFEST, fetch: mirrorFetch });
let versions = manifest.versions;
if (type && type !== 'all') {
versions = versions.filter((v: { type: string }) => v.type === type);
}
return { versions, latest: manifest.latest };
}
export async function getForgeVersionList(mcVersion?: string) {
const list = await xmclGetForgeVersionList({ minecraft: mcVersion });
return { versions: list.versions };
}
export async function getFabricVersionList(mcVersion?: string) {
// Fabric 元数据走 BMCLAPI 镜像(meta.fabricmc.net → fabric-meta
if (mcVersion) {
const loaders = await getFabricLoaders({ fetch: mirrorFetch });
return { versions: loaders.map((l) => l.version) };
}
const loaders = await getFabricLoaders({ fetch: mirrorFetch });
return { versions: loaders.map((l) => l.version) };
}
export async function getQuiltVersionList(mcVersion?: string) {
const loaders = await getQuiltLoaderVersionsByMinecraft({ minecraftVersion: mcVersion || '*' });
return { versions: loaders.map((l) => l.loader.version) };
}
// 扫描目录中已安装版本的信息
export interface ScannedVersion {
id: string;
type: string;
releaseTime?: string;
/** JAR 文件是否存在 */
hasJar: boolean;
/** JSON 文件是否存在 */
hasJson: boolean;
/** 检测到的 mod 加载器 */
loaders: string[];
/** 是否健康(JSON + JAR 都存在) */
healthy: boolean;
}
// 从版本 JSON 中检测 mod 加载器
function detectLoaders(jsonPath: string): string[] {
const loaders: string[] = [];
try {
const raw = fs.readFileSync(jsonPath, 'utf-8');
const json = JSON.parse(raw);
const libs: { name?: string }[] = json.libraries || [];
for (const lib of libs) {
const name = lib.name || "";
if (name.startsWith("net.minecraftforge:forge:") || name.startsWith("net.neoforged:")) {
if (!loaders.includes("forge")) loaders.push("forge");
} else if (name.startsWith("net.fabricmc:yarn:") || name.startsWith("net.fabricmc:fabric-loader:")) {
if (!loaders.includes("fabric")) loaders.push("fabric");
} else if (name.startsWith("org.quiltmc:quilt-loader:")) {
if (!loaders.includes("quilt")) loaders.push("quilt");
} else if (name.startsWith("optifine:OptiFine:")) {
if (!loaders.includes("optifine")) loaders.push("optifine");
}
}
} catch {}
return loaders;
}
// 扫描指定目录,返回 versions 子目录中所有已安装版本的详细信息
export function scanGameDirectories(gamePath: string): ScannedVersion[] {
const versionsPath = path.join(gamePath, 'versions');
if (!fs.existsSync(versionsPath)) return [];
try {
return fs.readdirSync(versionsPath, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => {
const id = e.name;
const versionDir = path.join(versionsPath, id);
const jsonPath = path.join(versionDir, `${id}.json`);
const jarPath = path.join(versionDir, `${id}.jar`);
const hasJson = fs.existsSync(jsonPath);
const hasJar = fs.existsSync(jarPath);
let type = "unknown";
let releaseTime: string | undefined;
if (hasJson) {
try {
const raw = fs.readFileSync(jsonPath, 'utf-8');
const json = JSON.parse(raw);
type = json.type || "unknown";
releaseTime = json.releaseTime;
} catch {}
}
const loaders = hasJson ? detectLoaders(jsonPath) : [];
const healthy = hasJson && hasJar;
return { id, type, releaseTime, hasJar, hasJson, loaders, healthy };
});
} catch {
return [];
}
}
// 从已安装版本导入实例(不下载,直接复制版本文件)
export async function importExistingInstance(
name: string,
gamePath: string,
versionId: string,
options?: {
description?: string;
java?: string;
minMemory?: number;
maxMemory?: number;
/** 版本文件来源目录(默认 = gamePath);扫描副目录导入时传 scanTarget */
sourceGamePath?: string;
}
): Promise<InstanceInfo> {
const instancePath = path.join(gamePath, 'instances', name);
if (fs.existsSync(instancePath)) {
throw new Error(`Instance already exists: ${name}`);
}
// 源版本目录:默认取 gamePath,扫描副目录时取 sourceGamePath
const srcGamePath = options?.sourceGamePath || gamePath;
const srcVersionDir = path.join(srcGamePath, 'versions', versionId);
if (!fs.existsSync(srcVersionDir)) {
throw new Error(`Version directory not found: ${versionId}(来源:${srcGamePath}`);
}
// 读取源版本 JSON 获取类型信息
const srcJsonPath = path.join(srcVersionDir, `${versionId}.json`);
if (!fs.existsSync(srcJsonPath)) {
throw new Error(`Version JSON not found: ${versionId}`);
}
let type = "release";
let releaseTime: string | undefined;
try {
const raw = fs.readFileSync(srcJsonPath, 'utf-8');
const json = JSON.parse(raw);
type = json.type || "release";
releaseTime = json.releaseTime;
} catch {}
// 构建 InstanceRuntime
const runtime: InstanceRuntime = {
minecraft: versionId,
};
// 从 JSON 中检测加载器并填充 runtime
try {
const raw = fs.readFileSync(srcJsonPath, 'utf-8');
const json = JSON.parse(raw);
const libs: { name?: string }[] = json.libraries || [];
for (const lib of libs) {
const name = lib.name || "";
// Forge: net.minecraftforge:forge:1.20.1-47.2.0
const forgeMatch = name.match(/^net\.minecraftforge:forge:([\d.]+(?:-\d+(?:\.\d+)*)?)/);
if (forgeMatch) { runtime.forge = forgeMatch[1]; continue; }
// NeoForge: net.neoforged:neoforge:21.0.0
const neoMatch = name.match(/^net\.neoforged:neoforge:([\d.]+)/);
if (neoMatch) { runtime.neoForged = neoMatch[1]; continue; }
// Fabric: net.fabricmc:fabric-loader:0.15.11
const fabricMatch = name.match(/^net\.fabricmc:fabric-loader:([\d.]+)/);
if (fabricMatch) { runtime.fabricLoader = fabricMatch[1]; continue; }
// Quilt: org.quiltmc:quilt-loader:0.26.0
const quiltMatch = name.match(/^org\.quiltmc:quilt-loader:([\d.]+)/);
if (quiltMatch) { runtime.quiltLoader = quiltMatch[1]; continue; }
}
} catch {}
// 创建实例目录结构
fs.mkdirSync(instancePath, { recursive: true });
const now = Date.now();
const config: InstanceConfig = {
name,
author: '',
description: options?.description || `Imported from version ${versionId}`,
runtime,
java: options?.java,
minMemory: options?.minMemory,
maxMemory: options?.maxMemory,
creationDate: now,
lastAccessDate: now,
lastPlayedDate: 0,
playtime: 0,
};
fs.writeFileSync(getInstanceConfigPath(instancePath), JSON.stringify(config, null, 2), 'utf-8');
// 创建标准子目录
for (const dir of ['mods', 'config', 'resourcepacks', 'shaderpacks', 'saves', 'screenshots', 'logs']) {
fs.mkdirSync(path.join(instancePath, dir), { recursive: true });
}
// 复制版本文件到实例目录
const destVersionDir = path.join(instancePath, 'versions', versionId);
fs.mkdirSync(destVersionDir, { recursive: true });
// 复制所有版本文件
const files = fs.readdirSync(srcVersionDir);
for (const file of files) {
const srcFile = path.join(srcVersionDir, file);
const destFile = path.join(destVersionDir, file);
if (fs.statSync(srcFile).isFile()) {
fs.copyFileSync(srcFile, destFile);
}
}
return getInstanceInfo(name, gamePath);
}