Files
koring-launcher/electron/core/instance.ts
T

466 lines
15 KiB
TypeScript
Raw Normal View History

2026-06-28 03:37:07 +08:00
import * as fs from 'fs';
import * as path from 'path';
2026-07-11 23:33:44 +08:00
import { MinecraftFolder, Version, launch, createMinecraftProcessWatcher, type ResolvedVersion } from '@xmcl/core';
import {
install as xmclInstall,
installForge,
installFabric,
installNeoForged,
installOptifine,
installQuiltVersion,
installDependencies,
getVersionList,
getForgeVersionList as xmclGetForgeVersionList,
getFabricLoaders,
getQuiltLoaderVersionsByMinecraft,
} from '@xmcl/installer';
2026-08-04 01:28:16 +08:00
import { rewriteToMirror } from './installer';
// ==================== BMCLAPI 镜像源配置 ====================
// 版本清单(@xmcl/installer 的 getVersionList 通过 remote 参数覆盖)
const BMCLAPI_VERSION_MANIFEST = 'https://bmclapi2.bangbang93.com/mc/game/version_manifest.json';
// Maven 仓库镜像(Libraries / Forge / NeoForge / Fabric 构件)
const BMCLAPI_MAVEN = 'https://bmclapi2.bangbang93.com/maven';
// 资源文件镜像(assets
const BMCLAPI_ASSETS = 'https://bmclapi2.bangbang93.com/assets';
// 供 @xmcl/installer FetchOptions 使用的镜像 fetchmeta.fabricmc.net 等元数据接口走镜像)
const mirrorFetch: typeof fetch = (url, init) => fetch(rewriteToMirror(String(url)), init);
2026-06-28 03:37:07 +08:00
2026-07-11 23:33:44 +08:00
export interface InstanceRuntime {
minecraft: string;
forge?: string;
neoForged?: string;
fabricLoader?: string;
quiltLoader?: string;
optifine?: string;
2026-06-28 03:37:07 +08:00
}
2026-07-11 23:33:44 +08:00
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 {
2026-06-28 03:37:07 +08:00
name: string;
path: string;
config: InstanceConfig;
2026-07-11 23:33:44 +08:00
modCount: number;
resourcePackCount: number;
screenshotCount: number;
saveCount: number;
healthy: boolean;
issues: string[];
2026-06-28 03:37:07 +08:00
}
2026-07-11 23:33:44 +08:00
export interface InstallProgress {
stage: string;
current: number;
total: number;
message?: string;
}
const INSTANCE_CONFIG_FILE = 'instance.json';
2026-06-28 03:37:07 +08:00
function getInstanceConfigPath(instancePath: string): string {
return path.join(instancePath, INSTANCE_CONFIG_FILE);
}
2026-07-11 23:33:44 +08:00
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;
}
2026-06-28 03:37:07 +08:00
export async function createInstance(
name: string,
gamePath: string,
2026-07-11 23:33:44 +08:00
runtime: InstanceRuntime,
options?: {
author?: string;
description?: string;
java?: string;
minMemory?: number;
maxMemory?: number;
vmOptions?: string[];
mcOptions?: string[];
}
2026-06-28 03:37:07 +08:00
): Promise<InstanceInfo> {
const instancePath = path.join(gamePath, 'instances', name);
2026-07-11 23:33:44 +08:00
if (fs.existsSync(instancePath)) {
throw new Error(`Instance already exists: ${name}`);
2026-06-28 03:37:07 +08:00
}
2026-07-11 23:33:44 +08:00
fs.mkdirSync(instancePath, { recursive: true });
const now = Date.now();
2026-06-28 03:37:07 +08:00
const config: InstanceConfig = {
name,
2026-07-11 23:33:44 +08:00
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,
2026-06-28 03:37:07 +08:00
};
2026-07-11 23:33:44 +08:00
fs.writeFileSync(getInstanceConfigPath(instancePath), JSON.stringify(config, null, 2), 'utf-8');
2026-06-28 03:37:07 +08:00
2026-07-11 23:33:44 +08:00
// Create standard directories
for (const dir of ['mods', 'config', 'resourcepacks', 'shaderpacks', 'saves', 'screenshots', 'logs']) {
fs.mkdirSync(path.join(instancePath, dir), { recursive: true });
2026-06-28 03:37:07 +08:00
}
2026-07-11 23:33:44 +08:00
return getInstanceInfo(name, gamePath);
2026-06-28 03:37:07 +08:00
}
2026-07-11 23:33:44 +08:00
export async function listInstances(gamePath: string): Promise<InstanceInfo[]> {
const instancesPath = path.join(gamePath, 'instances');
2026-06-28 03:37:07 +08:00
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 {
2026-07-11 23:33:44 +08:00
instances.push(await getInstanceInfo(entry.name, gamePath));
2026-06-28 03:37:07 +08:00
} catch {
2026-07-11 23:33:44 +08:00
// Skip invalid instances
2026-06-28 03:37:07 +08:00
}
}
return instances;
}
2026-07-11 23:33:44 +08:00
export async function getInstanceInfo(name: string, gamePath: string): Promise<InstanceInfo> {
const instancePath = path.join(gamePath, 'instances', name);
2026-06-28 03:37:07 +08:00
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;
2026-07-11 23:33:44 +08:00
const issues = getInstanceIssues(instancePath, config.runtime);
2026-06-28 03:37:07 +08:00
return {
name,
path: instancePath,
config,
2026-07-11 23:33:44 +08:00
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,
2026-06-28 03:37:07 +08:00
};
}
2026-07-11 23:33:44 +08:00
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
2026-08-04 01:28:16 +08:00
// First get version list to find the version info(走 BMCLAPI 镜像)
const versionList = await getVersionList({ remote: BMCLAPI_VERSION_MANIFEST, fetch: mirrorFetch });
2026-07-11 23:33:44 +08:00
const versionInfo = versionList.versions.find((v) => v.id === runtime.minecraft);
if (!versionInfo) {
throw new Error(`Minecraft version ${runtime.minecraft} not found`);
}
2026-08-04 01:28:16 +08:00
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,
});
2026-07-11 23:33:44 +08:00
// Install mod loaders
if (runtime.forge) {
callbacks?.onProgress?.({ stage: 'installing-forge', current: 0, total: 100, message: `Installing Forge ${runtime.forge}...` });
2026-08-04 01:28:16 +08:00
await installForge({ version: runtime.forge, mcversion: runtime.minecraft }, instancePath, { mavenHost: BMCLAPI_MAVEN });
2026-07-11 23:33:44 +08:00
}
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,
2026-08-04 01:28:16 +08:00
fetch: mirrorFetch,
2026-07-11 23:33:44 +08:00
});
}
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}...` });
2026-08-04 01:28:16 +08:00
await installNeoForged('neoforge', runtime.neoForged, instancePath, { mavenHost: BMCLAPI_MAVEN });
2026-07-11 23:33:44 +08:00
}
// 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...' });
2026-08-04 01:28:16 +08:00
// Install all dependencies (libraries + assets)(走 BMCLAPI 镜像)
2026-07-11 23:33:44 +08:00
const resolved: ResolvedVersion = await Version.parse(instancePath, runtime.minecraft);
2026-08-04 01:28:16 +08:00
await installDependencies(resolved, { mavenHost: BMCLAPI_MAVEN, assetsHost: BMCLAPI_ASSETS });
2026-07-11 23:33:44 +08:00
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 launchInstance(
name: string,
gamePath: string,
options: {
username: string;
uuid: string;
accessToken?: string;
javaPath?: string;
server?: { host: string; port?: number };
onEvent?: (event: { event: string; [key: string]: unknown }) => void;
}
): Promise<{ pid: number; version: string; username: string }> {
const instance = await getInstanceInfo(name, gamePath);
const { runtime } = instance.config;
const resolved: ResolvedVersion = await Version.parse(instance.path, runtime.minecraft);
const javaPath = options.javaPath || instance.config.java || 'java';
const mcProcess = await launch({
gameProfile: {
id: options.uuid,
name: options.username,
},
javaPath,
version: resolved,
gamePath: instance.path,
minMemory: instance.config.minMemory || 1024,
maxMemory: instance.config.maxMemory || 4096,
extraExecOption: { detached: true, stdio: 'ignore' },
server: options.server ? { ip: options.server.host, port: options.server.port } : undefined,
});
const watcher = createMinecraftProcessWatcher(mcProcess);
watcher.on('minecraft-window-ready', () => {
options.onEvent?.({ event: 'window-ready' });
});
watcher.on('minecraft-exit', ({ code }) => {
options.onEvent?.({ event: 'exit', code });
});
// Update playtime tracking
const startTime = Date.now();
mcProcess.on('exit', async () => {
const elapsed = Date.now() - startTime;
try {
const info = await getInstanceInfo(name, gamePath);
await updateInstance(name, gamePath, {
lastPlayedDate: Date.now(),
playtime: (info.config.playtime || 0) + elapsed,
});
} catch {
// Ignore errors during playtime update
}
});
// Update last access date
await updateInstance(name, gamePath, { lastAccessDate: Date.now() });
return {
pid: mcProcess.pid || 0,
version: runtime.minecraft,
username: options.username,
};
}
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) {
2026-08-04 01:28:16 +08:00
// 版本清单一律走 BMCLAPI 镜像
const manifest = await getVersionList({ remote: BMCLAPI_VERSION_MANIFEST, fetch: mirrorFetch });
2026-07-11 23:33:44 +08:00
let versions = manifest.versions;
if (type && type !== 'all') {
2026-08-04 01:28:16 +08:00
versions = versions.filter((v: { type: string }) => v.type === type);
2026-07-11 23:33:44 +08:00
}
2026-08-04 01:28:16 +08:00
return { versions, latest: manifest.latest };
2026-07-11 23:33:44 +08:00
}
export async function getForgeVersionList(mcVersion?: string) {
const list = await xmclGetForgeVersionList({ minecraft: mcVersion });
return { versions: list.versions };
}
export async function getFabricVersionList(mcVersion?: string) {
2026-08-04 01:28:16 +08:00
// Fabric 元数据走 BMCLAPI 镜像(meta.fabricmc.net → fabric-meta
2026-07-11 23:33:44 +08:00
if (mcVersion) {
2026-08-04 01:28:16 +08:00
const loaders = await getFabricLoaders({ fetch: mirrorFetch });
2026-07-11 23:33:44 +08:00
return { versions: loaders.map((l) => l.version) };
}
2026-08-04 01:28:16 +08:00
const loaders = await getFabricLoaders({ fetch: mirrorFetch });
2026-07-11 23:33:44 +08:00
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) };
}
2026-08-04 01:28:16 +08:00
// 扫描目录中已安装版本的信息
export interface ScannedVersion {
id: string;
type: string;
releaseTime?: string;
/** JAR 文件是否存在 */
hasJar: boolean;
/** JSON 文件是否存在 */
hasJson: boolean;
}
// 扫描指定目录,返回 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;
// 尝试读取 version JSON 获取类型和时间
if (hasJson) {
try {
const raw = fs.readFileSync(jsonPath, 'utf-8');
const json = JSON.parse(raw);
type = json.type || "unknown";
releaseTime = json.releaseTime;
} catch {}
}
return { id, type, releaseTime, hasJar, hasJson };
});
} catch {
return [];
}
}