feat: 发布 v1.2.0 版本,新增多项核心功能

- 引入 electron-updater 实现自动更新功能
- 新增 Java 环境扫描与校验的 IPC 处理逻辑
- 实现离线账号登录功能
- 新增配置变更跨进程广播机制
- 重构游戏启动逻辑,使用主进程内存配置作为唯一权威来源
- 新增界面显示与语言设置的配置页面
- 添加 Windows 平台自动发布 CI 流水线
- 迁移旧版配置/认证文件到用户数据目录
- 修复崩溃日志路径、表单控件等多项 bug
- 重构设置页组件系统统一界面样式
This commit is contained in:
2026-08-28 02:08:12 +08:00
parent 1cf906d0db
commit 3653e26a09
60 changed files with 2908 additions and 961 deletions
+11 -6
View File
@@ -11,15 +11,20 @@ export interface AuthData {
xboxProfile: string;
}
const authFile = (): string => {
/**
* 认证文件路径(与配置一致):
* - 打包后 → 系统用户数据目录(userData)
* - 开发模式 → 项目根目录
*/
export function authPath(): string {
if (app.isPackaged) {
return path.join(path.dirname(app.getPath('exe')), 'koring-auth.json');
return path.join(app.getPath('userData'), 'koring-auth.json');
}
return path.join(__dirname, '..', 'koring-auth.json');
};
}
export function readAuth(): AuthData {
const filePath = authFile();
const filePath = authPath();
if (!fs.existsSync(filePath)) {
return { username: '', uuid: '', accessToken: '', refreshToken: '', xboxProfile: '' };
}
@@ -32,12 +37,12 @@ export function readAuth(): AuthData {
}
export function writeAuth(auth: AuthData): void {
const filePath = authFile();
const filePath = authPath();
fs.writeFileSync(filePath, JSON.stringify(auth, null, 2), 'utf-8');
}
export function deleteAuth(): void {
const filePath = authFile();
const filePath = authPath();
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
+99 -3
View File
@@ -7,9 +7,14 @@ const { app } = electron;
const CONFIG_FILE = 'Koring.yml';
const CURRENT_VERSION = 1;
/**
* 配置文件路径:
* - 打包后 → 系统用户数据目录(userData),避免安装到 Program Files 等只读目录时写入失败
* - 开发模式 → 项目根目录(与旧行为一致,方便调试)
*/
export function configPath(): string {
if (app.isPackaged) {
return path.join(path.dirname(app.getPath('exe')), CONFIG_FILE);
return path.join(app.getPath('userData'), CONFIG_FILE);
}
return path.join(__dirname, '..', CONFIG_FILE);
}
@@ -38,6 +43,8 @@ export interface GameConfig {
resourceDir: string;
savesDir: string;
instancesDir: string;
/** 已添加的游戏目录列表 */
gameDirs: string[];
}
export interface JavaConfig {
@@ -48,6 +55,11 @@ export interface JavaConfig {
jvmArgs: string;
}
export interface ServerConfig {
ip: string;
port: number;
}
export interface AdvancedConfig {
afterLaunch: string;
winMode: string;
@@ -56,6 +68,20 @@ export interface AdvancedConfig {
gameArgs: string;
preLaunchCmd: string;
debugMode: boolean;
/** 快速进入服务器(启动后自动加入;ip 为空则不自动加入) */
server: ServerConfig;
}
export interface AppInfoConfig {
/** 界面语言偏好(zh-CN | en-US);语言包开发中,暂仅保存并设置 <html lang> */
language: string;
}
export interface UiConfig {
/** 首页实例标题显示 */
showInstanceTitle: boolean;
/** 标题栏任务队列按钮显示 */
showTaskButton: boolean;
}
export interface DownloadConfig {
@@ -89,6 +115,7 @@ export interface NetworkConfig {
export interface AppConfig {
version: number;
oobe: boolean;
app: AppInfoConfig;
theme: ThemeConfig;
a11y: A11yConfig;
background: BackgroundConfig;
@@ -97,20 +124,23 @@ export interface AppConfig {
advanced: AdvancedConfig;
download: DownloadConfig;
network: NetworkConfig;
ui: UiConfig;
instances: InstanceMeta[];
}
const DEFAULTS: AppConfig = {
version: CURRENT_VERSION,
oobe: true,
app: { language: 'zh-CN' },
theme: { darkMode: 'auto', parallax: true },
a11y: { reduceMotion: false, reduceTransparency: false, highContrast: false, contentBlurOpacity: 50 },
background: { bgType: 'image', image: '/background.png', blur: 0, opacity: 100 },
game: { gameDir: '.minecraft', resourceDir: '', savesDir: '', instancesDir: '.minecraft/instances' },
game: { gameDir: '.minecraft', resourceDir: '', savesDir: '', instancesDir: '.minecraft/instances', gameDirs: [] },
java: { javaPath: '', memMode: 'auto', memGB: 4, gc: 'auto', jvmArgs: '' },
advanced: { afterLaunch: 'close', winMode: 'default', customWidth: 854, customHeight: 480, gameArgs: '', preLaunchCmd: '', debugMode: false },
advanced: { afterLaunch: 'close', winMode: 'default', customWidth: 854, customHeight: 480, gameArgs: '', preLaunchCmd: '', debugMode: false, server: { ip: '', port: 25565 } },
download: { fileSource: 'mirror', versionSource: 'mirror', threads: 16, speedLimit: 0 },
network: { securityId: { enabled: false, authUrl: '' } },
ui: { showInstanceTitle: true, showTaskButton: true },
instances: [],
};
@@ -183,3 +213,69 @@ export function saveConfig(config: AppConfig): void {
const yamlStr = yaml.dump(sparse, { lineWidth: -1 });
fs.writeFileSync(filePath, yamlStr, 'utf-8');
}
// ==================== 主进程权威配置模型 ====================
// 主进程内存缓存是唯一权威(single source of truth):
// 渲染进程通过 config:update 提交补丁 → updateConfig 合并 → debounce 稀疏写盘
// 启动游戏时直接读内存缓存,保证永远是最新配置(无磁盘竞争)。
let current: AppConfig | null = null;
function mergeDeep<T>(base: T, patch: unknown): T {
if (patch === null || patch === undefined) return base;
if (typeof base !== 'object' || typeof patch !== 'object' || Array.isArray(base) || Array.isArray(patch)) {
return patch as T;
}
const result: Record<string, unknown> = { ...(base as Record<string, unknown>) };
for (const key of Object.keys(patch as Record<string, unknown>)) {
result[key] = mergeDeep(result[key], (patch as Record<string, unknown>)[key]);
}
return result as T;
}
/** 读取当前配置(内存优先,未加载则从磁盘读取) */
export function getConfig(): AppConfig {
if (!current) {
current = loadConfig();
}
return current;
}
let saveTimer: ReturnType<typeof setTimeout> | null = null;
function scheduleSave(): void {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
saveTimer = null;
flushConfig();
}, 300);
}
/** 立即将内存配置写盘(应用退出前调用) */
export function flushConfig(): void {
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
if (current) {
saveConfig(current);
}
}
/** 深度合并补丁到内存配置并返回合并结果(300ms debounce 写盘) */
export function updateConfig(patch: Record<string, unknown>): AppConfig {
const base = getConfig();
current = mergeDeep(base, patch) as AppConfig;
scheduleSave();
return current;
}
/** 删除内存配置中的指定顶层键(如 koringUser),300ms debounce 写盘 */
export function deleteConfigKey(key: string): AppConfig {
const base = getConfig();
const next = { ...(base as unknown as Record<string, unknown>) };
delete next[key];
current = next as unknown as AppConfig;
scheduleSave();
return current;
}
+1 -1
View File
@@ -8,7 +8,7 @@ const MAX_LINES = 1000;
function logPath(): string {
if (app.isPackaged) {
return path.join(path.dirname(app.getPath('exe')), LOG_FILE);
return path.join(app.getPath('userData'), LOG_FILE);
}
return path.join(__dirname, '..', LOG_FILE);
}
+7 -72
View File
@@ -1,6 +1,6 @@
import * as fs from 'fs';
import * as path from 'path';
import { MinecraftFolder, Version, launch, createMinecraftProcessWatcher, type ResolvedVersion } from '@xmcl/core';
import { Version, type ResolvedVersion } from '@xmcl/core';
import {
install as xmclInstall,
installForge,
@@ -308,74 +308,6 @@ export async function installInstanceGame(
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
@@ -503,6 +435,8 @@ export async function importExistingInstance(
java?: string;
minMemory?: number;
maxMemory?: number;
/** 版本文件来源目录(默认 = gamePath);扫描副目录导入时传 scanTarget */
sourceGamePath?: string;
}
): Promise<InstanceInfo> {
const instancePath = path.join(gamePath, 'instances', name);
@@ -510,10 +444,11 @@ export async function importExistingInstance(
throw new Error(`Instance already exists: ${name}`);
}
// 检查源版本目录是否存在
const srcVersionDir = path.join(gamePath, 'versions', versionId);
// 源版本目录:默认取 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}`);
throw new Error(`Version directory not found: ${versionId}(来源:${srcGamePath}`);
}
// 读取源版本 JSON 获取类型信息
+148
View File
@@ -0,0 +1,148 @@
// __ __ __ __ ______ __ __ ______ __ __ ______ ______
// /\ \ /\ \ /\ "-.\ \ /\ ___\ /\ \/ / /\ ___\ /\ "-.\ \ /\ ___\ /\__ _\
// \ \ \____ \ \ \ \ \ \-. \ \ \ \__ \ \ \ _"-. \ \ __\ \ \ \-. \ \ \ __\ \/_/\ \/
// \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ \ \_\ \_\ \ \_____\ \ \_\\"\_\ \ \_____\ \ \_\
// \/_____/ \/_/ \/_/ \/_/ \/_____/ \/_/\/_/ \/_____/ \/_/ \/_/ \/_____/ \/_/
//
// 所有权利归Lingke Network (china)所有 | dream_pep拥有其艺术改变权力
// 未经允许的情况下删除此版权头可能会受到民事指控
// 请勿在未经Lingke Network (china)允许的范围内修改代码并分发
import type { LaunchOption } from '@xmcl/core';
import { resolveJava, getPotentialJavaLocations } from '@xmcl/installer';
import type { AppConfig } from '../config';
import type { InstanceInfo } from './instance';
/** 游戏启动所需的账户档案 */
export interface LaunchProfile {
username: string;
uuid: string;
accessToken?: string;
}
/** 快速联机目标服务器 */
export interface LaunchServer {
ip: string;
port?: number;
}
/**
* 引号感知的命令行参数切分器。
* 支持双引号 / 单引号包裹的含空格参数与反斜杠转义。
* 用于 jvmArgs / gameArgs / preLaunchCmd 的解析。
*/
export function parseArgs(line: string): string[] {
const args: string[] = [];
const re = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|(\S+)/g;
let match: RegExpExecArray | null;
while ((match = re.exec(line)) !== null) {
if (match[1] !== undefined) {
args.push(match[1].replace(/\\(["\\])/g, '$1'));
} else if (match[2] !== undefined) {
args.push(match[2].replace(/\\(['\\])/g, '$1'));
} else {
args.push(match[3]);
}
}
return args;
}
/**
* 将启动器配置(AppConfig.java / AppConfig.advanced+ 实例信息 + 账户档案
* 映射为 @xmcl/core 的 LaunchOption。
*
* 配置 → 启动参数 对应关系:
* - java.memMode=auto → 实例 minMemory/maxMemory(未设置则 1024/4096
* - java.memMode=custom → min=min(2,memGB)Gmax=memGB G
* - java.gc=zgc/g1 → -XX:+UseZGC / -XX:+UseG1GC
* - java.jvmArgs → 逐行解析并入 extraJVMArgs
* - advanced.gameArgs → 解析并入 extraMCArgs
* - advanced.winMode → resolutionfullscreen / custom 宽高)
* - advanced.preLaunchCmd → prependCommandWindows 批处理需 `cmd /c` 前缀)
* - advanced.debugMode → -Dkoring.debugMode=true
*/
export function buildLaunchOptions(
config: AppConfig,
instance: InstanceInfo,
profile: LaunchProfile,
javaPath: string,
server?: LaunchServer,
): LaunchOption {
const java = config.java;
const adv = config.advanced;
// ---- 内存 ----
let minMemory = instance.config.minMemory ?? 1024;
let maxMemory = instance.config.maxMemory ?? 4096;
if (java.memMode === 'custom') {
const gb = Math.max(1, Math.min(16, java.memGB || 4));
minMemory = Math.min(2, gb) * 1024;
maxMemory = gb * 1024;
}
// ---- JVM 参数 ----
const extraJVMArgs: string[] = [];
if (java.gc === 'zgc') extraJVMArgs.push('-XX:+UseZGC');
else if (java.gc === 'g1') extraJVMArgs.push('-XX:+UseG1GC');
if (java.jvmArgs?.trim()) {
extraJVMArgs.push(...parseArgs(java.jvmArgs));
}
if (adv.debugMode) {
extraJVMArgs.push('-Dkoring.debugMode=true');
}
// ---- 游戏参数 ----
const extraMCArgs: string[] = [];
if (adv.gameArgs?.trim()) {
extraMCArgs.push(...parseArgs(adv.gameArgs));
}
// ---- 窗口 / 分辨率 ----
let resolution: { width?: number; height?: number; fullscreen?: boolean } | undefined;
if (adv.winMode === 'fullscreen') {
resolution = { fullscreen: true };
} else if (adv.winMode === 'custom') {
resolution = { width: adv.customWidth || 854, height: adv.customHeight || 480 };
}
return {
gameProfile: { name: profile.username, id: profile.uuid },
accessToken: profile.accessToken,
javaPath,
// version 由调用方在 Version.parse 后覆盖为 ResolvedVersion
version: instance.config.runtime.minecraft,
gamePath: instance.path,
minMemory,
maxMemory,
resolution,
extraJVMArgs,
extraMCArgs,
server,
prependCommand: adv.preLaunchCmd?.trim() ? parseArgs(adv.preLaunchCmd) : undefined,
launcherName: 'Koring Launcher',
launcherBrand: 'Koring',
};
}
/**
* 解析 Java 可执行文件路径,优先级:
* 1. 用户配置路径(resolveJava 校验,无效则继续向下)
* 2. 系统扫描(`where java` / `which java` 结果逐个 resolve
* 3. 兜底 PATH 中的 `java`
*/
export async function resolveJavaPath(configuredPath: string): Promise<string> {
if (configuredPath?.trim()) {
const info = await resolveJava(configuredPath.trim()).catch(() => undefined);
if (info) return info.path;
}
try {
const locations = await getPotentialJavaLocations();
for (const loc of locations) {
const info = await resolveJava(loc).catch(() => undefined);
if (info) return info.path;
}
} catch {
// 扫描失败则继续回退
}
return 'java';
}
+116 -135
View File
@@ -1,147 +1,129 @@
import { spawn, ChildProcess } from 'child_process';
// __ __ __ __ ______ __ __ ______ __ __ ______ ______
// /\ \ /\ \ /\ "-.\ \ /\ ___\ /\ \/ / /\ ___\ /\ "-.\ \ /\ ___\ /\__ _\
// \ \ \____ \ \ \ \ \ \-. \ \ \ \__ \ \ \ _"-. \ \ __\ \ \ \-. \ \ \ __\ \/_/\ \/
// \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ \ \_\ \_\ \ \_____\ \ \_\\"\_\ \ \_____\ \ \_\
// \/_____/ \/_/ \/_/ \/_/ \/_____/ \/_/\/_/ \/_____/ \/_/ \/_/ \/_____/ \/_/
//
// 所有权利归Lingke Network (china)所有 | dream_pep拥有其艺术改变权力
// 未经允许的情况下删除此版权头可能会受到民事指控
// 请勿在未经Lingke Network (china)允许的范围内修改代码并分发
import * as fs from 'fs';
import * as path from 'path';
import { Version, launch, createMinecraftProcessWatcher } from '@xmcl/core';
import type { AppConfig } from '../config';
import { getInstanceInfo, updateInstance } from './instance';
import {
buildLaunchOptions,
resolveJavaPath,
type LaunchProfile,
type LaunchServer,
} from './launch-options';
interface LaunchOptions {
gamePath: string;
javaPath: string;
version: string;
username: string;
uuid: string;
accessToken?: string;
memory?: { min?: string; max?: string };
jvmArgs?: string[];
gameArgs?: string[];
server?: { ip: string; port?: number };
detached?: boolean;
onEvent?: (event: { event: string; [key: string]: unknown }) => void;
}
interface LaunchResult {
export interface GameLaunchResult {
pid: number;
version: string;
username: string;
}
const runningProcesses = new Map<string, ChildProcess>();
export async function launchMinecraft(options: LaunchOptions): Promise<LaunchResult> {
const versionJsonPath = path.join(options.gamePath, 'versions', options.version, `${options.version}.json`);
if (!fs.existsSync(versionJsonPath)) {
throw new Error(`Version JSON not found: ${versionJsonPath}`);
}
const versionJson = JSON.parse(fs.readFileSync(versionJsonPath, 'utf-8'));
const mainClass = versionJson.mainClass;
if (!mainClass) {
throw new Error('Main class not found in version JSON');
}
const args: string[] = [];
// Memory
const minMem = options.memory?.min || '512M';
const maxMem = options.memory?.max || '4G';
args.push(`-Xms${minMem}`);
args.push(`-Xmx${maxMem}`);
// JVM args
if (options.jvmArgs) {
args.push(...options.jvmArgs);
}
// Native libraries path
const nativesDir = path.join(options.gamePath, 'versions', options.version, `${options.version}-natives`);
if (fs.existsSync(nativesDir)) {
args.push(`-Djava.library.path=${nativesDir}`);
}
// Classpath
const libraries = versionJson.libraries || [];
const classpath = libraries
.filter((lib: { downloads?: { artifact?: { path: string } } }) => lib.downloads?.artifact?.path)
.map((lib: { downloads: { artifact: { path: string } } }) => path.join(options.gamePath, 'libraries', lib.downloads.artifact.path));
const clientJar = path.join(options.gamePath, 'versions', options.version, `${options.version}.jar`);
if (fs.existsSync(clientJar)) {
classpath.push(clientJar);
}
args.push('-cp');
args.push(classpath.join(path.delimiter));
args.push(mainClass);
// Game args
args.push(`--username`, options.username);
args.push(`--version`, options.version);
args.push(`--gameDir`, options.gamePath);
args.push(`--assetsDir`, path.join(options.gamePath, 'assets'));
args.push(`--assetIndex`, versionJson.assetIndex?.id || options.version);
args.push(`--uuid`, options.uuid);
if (options.accessToken) {
args.push(`--accessToken`, options.accessToken);
}
if (options.server) {
args.push(`--server`, options.server.ip);
if (options.server.port) {
args.push(`--port`, String(options.server.port));
}
}
if (options.gameArgs) {
args.push(...options.gameArgs);
}
const javaPath = options.javaPath || 'java';
return new Promise((resolve, reject) => {
const child = spawn(javaPath, args, {
cwd: options.gamePath,
detached: options.detached,
stdio: ['ignore', 'pipe', 'pipe'],
});
const requestId = `mc-${Date.now()}`;
runningProcesses.set(requestId, child);
child.stdout?.on('data', (data) => {
const line = data.toString().trim();
if (line) {
options.onEvent?.({ event: 'stdout', message: line });
}
});
child.stderr?.on('data', (data) => {
const line = data.toString().trim();
if (line) {
options.onEvent?.({ event: 'stderr', message: line });
}
});
child.on('error', (err) => {
runningProcesses.delete(requestId);
options.onEvent?.({ event: 'error', error: String(err) });
reject(err);
});
child.on('exit', (code) => {
runningProcesses.delete(requestId);
options.onEvent?.({ event: 'exit', code });
});
resolve({
pid: child.pid || 0,
version: options.version,
username: options.username,
});
});
export interface LaunchEvent {
event: string;
[key: string]: unknown;
}
export interface LaunchGameOptions {
/** 主进程权威配置(内存中最新值) */
config: AppConfig;
/** 实例名 */
instanceName: string;
/** 实例父目录(游戏根目录) */
gamePath: string;
/** 账户档案 */
profile: LaunchProfile;
/** 快速联机目标服务器 */
server?: LaunchServer;
/** 事件回调(stdout / stderr / window-ready / exit */
onEvent?: (event: LaunchEvent) => void;
}
/**
* 统一游戏启动入口:
* 实例信息 → 版本解析 → Java 解析 → 配置映射(buildLaunchOptions)→ @xmcl/core launch
* 启动后监听 window-ready / exit,并在退出时累计实例 playtime。
*/
export async function launchGame(options: LaunchGameOptions): Promise<GameLaunchResult> {
const { config, instanceName, gamePath, profile, server, onEvent } = options;
// 1. 读取实例信息并做健康检查
const instance = await getInstanceInfo(instanceName, gamePath);
if (!instance.healthy) {
const detail = instance.issues.join('') || '未知问题';
throw new Error(`实例「${instanceName}」未安装完整:${detail}。请先在资源中心安装或重新安装该实例。`);
}
// 2. 解析版本
const resolved = await Version.parse(instance.path, instance.config.runtime.minecraft);
// 3. 解析 Java 路径(配置路径 → 系统扫描 → PATH)
const javaPath = await resolveJavaPath(config.java.javaPath);
// 4. 配置 → LaunchOptionversion 覆盖为已解析版本)
const launchOption = buildLaunchOptions(config, instance, profile, javaPath, server);
launchOption.version = resolved;
// 5. 启动(detached:启动器关闭后游戏继续运行;pipe:转发 stdout/stderr
const mcProcess = await launch({
...launchOption,
extraExecOption: { detached: true, stdio: 'pipe' },
});
// 6. 事件监听
const watcher = createMinecraftProcessWatcher(mcProcess);
watcher.on('minecraft-window-ready', () => {
onEvent?.({ event: 'window-ready' });
});
watcher.on('minecraft-exit', ({ code }) => {
onEvent?.({ event: 'exit', code });
});
mcProcess.stdout?.on('data', (chunk: Buffer) => {
const message = chunk.toString();
if (message.trim()) onEvent?.({ event: 'stdout', message });
});
mcProcess.stderr?.on('data', (chunk: Buffer) => {
const message = chunk.toString();
if (message.trim()) onEvent?.({ event: 'stderr', message });
});
// 7. playtime 累计(游戏进程退出时)
const startTime = Date.now();
mcProcess.on('exit', async () => {
const elapsed = Date.now() - startTime;
try {
const info = await getInstanceInfo(instanceName, gamePath);
await updateInstance(instanceName, gamePath, {
lastPlayedDate: Date.now(),
playtime: (info.config.playtime || 0) + elapsed,
});
} catch {
// 忽略 playtime 更新失败
}
});
// 8. 更新最近访问时间
await updateInstance(instanceName, gamePath, { lastAccessDate: Date.now() }).catch(() => {});
return {
pid: mcProcess.pid || 0,
version: instance.config.runtime.minecraft,
username: profile.username,
};
}
/**
* 诊断指定游戏目录下某个版本的健康状态(JSON + JAR 是否存在)。
* 供 launch:diagnose 使用。
*/
export async function diagnoseVersion(gamePath: string, version: string): Promise<Record<string, unknown>> {
const issues: string[] = [];
const versionDir = path.join(gamePath, 'versions', version);
@@ -151,7 +133,6 @@ export async function diagnoseVersion(gamePath: string, version: string): Promis
if (!fs.existsSync(versionJsonPath)) {
issues.push(`Version JSON not found: ${versionJsonPath}`);
}
if (!fs.existsSync(jarPath)) {
issues.push(`Client JAR not found: ${jarPath}`);
}
+20
View File
@@ -0,0 +1,20 @@
// 路径归一化:相对 gameDir(默认 `.minecraft`)在打包后依赖进程 cwd,不可靠。
// 统一按与 runStartupChecks 一致的基准解析(打包 → exe 目录;开发 → 项目根)。
import * as path from 'path';
import electron from 'electron';
const { app } = electron;
function baseDataPath(): string {
if (app.isPackaged) {
return path.dirname(app.getPath('exe'));
}
return path.join(__dirname, '..', '..');
}
/** 相对路径 → 绝对(基准 = exe 目录/项目根);绝对路径原样返回 */
export function resolveGamePath(gamePath: string): string {
if (!gamePath || path.isAbsolute(gamePath)) {
return gamePath;
}
return path.join(baseDataPath(), gamePath);
}
+4 -1
View File
@@ -30,6 +30,7 @@ import {
mirrorFetch,
type InstanceRuntime,
} from './instance';
import { resolveGamePath } from './paths';
// 任务执行钩子:主进程用它向渲染进程广播日志
export interface TaskHooks {
@@ -204,7 +205,9 @@ executorRegistry.set('install-sim', (raw, hooks) => {
// 任务树:install.create → install.minecraft → (forge|neoforge|fabric|quilt) → install.dependencies
executorRegistry.set('install', (rawParams, hooks) => {
const p = rawParams as unknown as InstallTaskParams;
const { name, gamePath, runtime } = p;
const { name, runtime } = p;
// 相对 gameDir 归一化(与 runStartupChecks 基准一致)
const gamePath = resolveGamePath(p.gamePath);
const instancePath = path.join(gamePath, 'instances', name);
return task('install', async function () {
+18
View File
@@ -1,6 +1,7 @@
import electron from 'electron';
const { ipcMain } = electron;
import { readAuth, writeAuth, deleteAuth } from '../auth';
import { offlineLogin } from '../core/auth';
export function registerAuthHandlers() {
ipcMain.handle('auth:get', () => {
@@ -29,4 +30,21 @@ export function registerAuthHandlers() {
return { success: false, data: null, error: String(e) };
}
});
// 离线账号登录(离线模式不需要微软 OAuth,用户名即可生成 UUID)
ipcMain.handle('auth:offline-login', async (_event, payload: { username: string }) => {
try {
const username = (payload?.username || '').trim();
if (!username) {
return { success: false, data: null, error: '用户名不能为空' };
}
if (username.length > 16) {
return { success: false, data: null, error: '用户名长度不能超过 16 个字符' };
}
const data = await offlineLogin(username);
return { success: true, data, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
}
+20 -4
View File
@@ -1,13 +1,16 @@
import electron from 'electron';
const { ipcMain, app } = electron;
import * as fs from 'fs';
import * as path from 'path';
import { loadConfig, saveConfig, type AppConfig, configPath } from '../config';
import { getConfig, saveConfig, updateConfig, type AppConfig, configPath } from '../config';
export function registerConfigHandlers() {
interface WinRef {
mainWindow: electron.BrowserWindow | null;
}
export function registerConfigHandlers(win: WinRef) {
ipcMain.handle('config:get', () => {
try {
const config = loadConfig();
const config = getConfig();
return { success: true, data: config, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
@@ -23,6 +26,19 @@ export function registerConfigHandlers() {
}
});
// 主进程权威更新:渲染进程提交 { section, patch } 补丁,
// 主进程深度合并到内存配置 → debounce 稀疏写盘 → 广播完整配置给所有渲染进程
ipcMain.handle('config:update', (_event, payload: { section: string; patch: unknown }) => {
try {
const { section, patch } = payload;
const config = updateConfig({ [section]: patch } as Record<string, unknown>);
win.mainWindow?.webContents.send('config:changed', config);
return { success: true, data: config, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('config:reset', () => {
try {
const filePath = configPath();
+8 -10
View File
@@ -2,6 +2,8 @@ import electron from 'electron';
import path from 'path';
import fs from 'fs';
import { writeCrashLog, readCrashLog, clearCrashLog, type CrashEntry } from '../core/crash-logger';
import { configPath } from '../config';
import { authPath } from '../auth';
const { app, ipcMain, BrowserWindow } = electron;
@@ -151,20 +153,16 @@ export function registerCrashHandlers() {
// Factory reset
ipcMain.handle('crash:factoryReset', () => {
const dataPath = app.isPackaged
? path.dirname(app.getPath('exe'))
: path.join(__dirname, '../..');
// Delete config
// Delete configuserData / 项目根目录,与 configPath 一致)
try {
const configPath = path.join(dataPath, 'Koring.yml');
if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
const config = configPath();
if (fs.existsSync(config)) fs.unlinkSync(config);
} catch {}
// Delete auth
// Delete authuserData / 项目根目录,与 authPath 一致)
try {
const authPath = path.join(dataPath, 'koring-auth.json');
if (fs.existsSync(authPath)) fs.unlinkSync(authPath);
const auth = authPath();
if (fs.existsSync(auth)) fs.unlinkSync(auth);
} catch {}
// Delete background cache in userData
+15 -43
View File
@@ -6,7 +6,6 @@ import {
deleteInstance,
updateInstance,
installInstanceGame,
launchInstance,
diagnoseInstance,
getMinecraftVersionList,
getForgeVersionList,
@@ -17,6 +16,7 @@ import {
type InstanceRuntime,
type InstanceConfig,
} from '../core/instance';
import { resolveGamePath } from '../core/paths';
const { ipcMain } = electron;
@@ -38,9 +38,10 @@ export function registerInstanceHandlers(win: WinRef) {
mcOptions?: string[];
}) => {
try {
const gamePath = resolveGamePath(payload.gamePath);
const data = await createInstance(
payload.name,
payload.gamePath,
gamePath,
payload.runtime,
{
author: payload.author,
@@ -60,7 +61,7 @@ export function registerInstanceHandlers(win: WinRef) {
ipcMain.handle('instance:list', async (_event, payload: { gamePath: string }) => {
try {
const data = await listInstances(payload.gamePath);
const data = await listInstances(resolveGamePath(payload.gamePath));
return { success: true, data, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
@@ -69,7 +70,7 @@ export function registerInstanceHandlers(win: WinRef) {
ipcMain.handle('instance:info', async (_event, payload: { name: string; gamePath: string }) => {
try {
const data = await getInstanceInfo(payload.name, payload.gamePath);
const data = await getInstanceInfo(payload.name, resolveGamePath(payload.gamePath));
return { success: true, data, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
@@ -78,7 +79,7 @@ export function registerInstanceHandlers(win: WinRef) {
ipcMain.handle('instance:delete', async (_event, payload: { name: string; gamePath: string }) => {
try {
const data = await deleteInstance(payload.name, payload.gamePath);
const data = await deleteInstance(payload.name, resolveGamePath(payload.gamePath));
return { success: true, data, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
@@ -91,7 +92,7 @@ export function registerInstanceHandlers(win: WinRef) {
patch: Partial<Omit<InstanceConfig, 'name' | 'creationDate'>>;
}) => {
try {
const data = await updateInstance(payload.name, payload.gamePath, payload.patch);
const data = await updateInstance(payload.name, resolveGamePath(payload.gamePath), payload.patch);
return { success: true, data, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
@@ -101,8 +102,9 @@ export function registerInstanceHandlers(win: WinRef) {
ipcMain.handle('instance:install', async (_event, payload: { name: string; gamePath: string }) => {
try {
const requestId = `install-${Date.now()}`;
const gamePath = resolveGamePath(payload.gamePath);
installInstanceGame(payload.name, payload.gamePath, {
installInstanceGame(payload.name, gamePath, {
onProgress: (progress) => {
win.mainWindow?.webContents.send('instance:progress', { requestId, ...progress });
},
@@ -118,42 +120,9 @@ export function registerInstanceHandlers(win: WinRef) {
}
});
ipcMain.handle('instance:launch', async (_event, payload: {
name: string;
gamePath: string;
username: string;
uuid: string;
accessToken?: string;
javaPath?: string;
server?: { host: string; port?: number };
}) => {
try {
const requestId = `launch-${Date.now()}`;
launchInstance(payload.name, payload.gamePath, {
username: payload.username,
uuid: payload.uuid,
accessToken: payload.accessToken,
javaPath: payload.javaPath,
server: payload.server,
onEvent: (event) => {
win.mainWindow?.webContents.send('instance:launch-event', { requestId, ...event });
},
}).then((data) => {
win.mainWindow?.webContents.send('instance:launch-complete', { requestId, data });
}).catch((err) => {
win.mainWindow?.webContents.send('instance:launch-error', { requestId, error: String(err) });
});
return { success: true, data: { requestId }, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('instance:diagnose', async (_event, payload: { name: string; gamePath: string }) => {
try {
const data = await diagnoseInstance(payload.name, payload.gamePath);
const data = await diagnoseInstance(payload.name, resolveGamePath(payload.gamePath));
return { success: true, data, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
@@ -200,7 +169,7 @@ export function registerInstanceHandlers(win: WinRef) {
// 扫描游戏目录中的已安装版本
ipcMain.handle('instance:scan-dir', async (_event, payload: { gamePath: string }) => {
try {
const versions = scanGameDirectories(payload.gamePath);
const versions = scanGameDirectories(resolveGamePath(payload.gamePath));
return { success: true, data: { versions }, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
@@ -216,17 +185,20 @@ export function registerInstanceHandlers(win: WinRef) {
java?: string;
minMemory?: number;
maxMemory?: number;
/** 版本文件来源目录(扫描副目录导入时传扫描目录) */
sourceGamePath?: string;
}) => {
try {
const data = await importExistingInstance(
payload.name,
payload.gamePath,
resolveGamePath(payload.gamePath),
payload.versionId,
{
description: payload.description,
java: payload.java,
minMemory: payload.minMemory,
maxMemory: payload.maxMemory,
sourceGamePath: payload.sourceGamePath ? resolveGamePath(payload.sourceGamePath) : undefined,
}
);
return { success: true, data, error: null };
+62
View File
@@ -0,0 +1,62 @@
import electron from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import { scanLocalJava, resolveJava } from '@xmcl/installer';
const { ipcMain } = electron;
// 常见 Java 安装目录(Windows),按一层子目录枚举 bin/java.exe
function scanCommonJavaDirs(): string[] {
const exe = process.platform === 'win32' ? 'java.exe' : 'java';
const roots = [
'C:\\Program Files\\Java',
'C:\\Program Files (x86)\\Java',
'C:\\Program Files\\Eclipse Adoptium',
'C:\\Program Files\\Microsoft',
'C:\\Program Files\\Zulu',
'C:\\Program Files\\Amazon Corretto',
];
const out: string[] = [];
for (const root of roots) {
try {
const entries = fs.readdirSync(root);
for (const e of entries) {
out.push(path.join(root, e, 'bin', exe));
}
} catch {
// 目录不存在则跳过
}
out.push(path.join(root, 'bin', exe));
}
return out;
}
export function registerJavaHandlers() {
// 扫描系统已安装的 JavaJAVA_HOME / PATH / 常见安装目录)
ipcMain.handle('java:scan', async () => {
try {
const candidates = scanCommonJavaDirs();
const list = await scanLocalJava(candidates);
// 按路径去重(同一安装可能被多个来源发现)
const seen = new Set<string>();
const javaList = list.filter((j) => {
if (seen.has(j.path)) return false;
seen.add(j.path);
return true;
});
return { success: true, data: { javaList }, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
// 校验指定路径是否为可用的 Java 可执行文件
ipcMain.handle('java:resolve', async (_event, payload: { path: string }) => {
try {
const java = await resolveJava(payload.path);
return { success: true, data: { java: java ?? null }, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
}
+27 -29
View File
@@ -8,7 +8,7 @@ import {
readKoringAuth,
deleteKoringAuth,
} from '../core/koring-auth';
import { loadConfig, saveConfig } from '../config';
import { getConfig, updateConfig, deleteConfigKey } from '../config';
export function registerKoringAuthHandlers() {
ipcMain.handle('koring-auth:request-device-code', async () => {
@@ -25,19 +25,19 @@ export function registerKoringAuthHandlers() {
const result = await pollForTokenOnce(deviceCode);
const user = saveKoringAuth(result);
// 同时写入配置文件
// 同步到配置文件(主进程权威模型:合并内存缓存 + debounce 写盘)
try {
const config = loadConfig();
(config as any).koringUser = {
sub: user.sub,
name: user.name,
username: user.username,
email: user.email,
picture: user.picture,
accessToken: result.access_token,
refreshToken: result.refresh_token,
};
saveConfig(config);
updateConfig({
koringUser: {
sub: user.sub,
name: user.name,
username: user.username,
email: user.email,
picture: user.picture,
accessToken: result.access_token,
refreshToken: result.refresh_token,
},
});
} catch (e) {
console.error('[koring-auth] failed to save user to config:', e);
}
@@ -58,17 +58,17 @@ export function registerKoringAuthHandlers() {
// 同步到配置文件
try {
const config = loadConfig();
(config as any).koringUser = {
sub: user.sub,
name: user.name,
username: user.username,
email: user.email,
picture: user.picture,
accessToken: result.access_token,
refreshToken: result.refresh_token,
};
saveConfig(config);
updateConfig({
koringUser: {
sub: user.sub,
name: user.name,
username: user.username,
email: user.email,
picture: user.picture,
accessToken: result.access_token,
refreshToken: result.refresh_token,
},
});
} catch {}
return { success: true, data: { user }, error: null };
@@ -80,10 +80,10 @@ export function registerKoringAuthHandlers() {
ipcMain.handle('koring-auth:get-user', () => {
try {
const stored = readKoringAuth();
// 也从配置文件读取
// 也从配置文件读取(内存权威)
if (!stored?.user?.sub) {
try {
const config = loadConfig();
const config = getConfig();
const ku = (config as any).koringUser;
if (ku?.sub) {
return { success: true, data: { user: ku, access_token: '', refresh_token: '', id_token: '', expires_at: 0 }, error: null };
@@ -101,9 +101,7 @@ export function registerKoringAuthHandlers() {
deleteKoringAuth();
// 清除配置文件中的用户数据
try {
const config = loadConfig();
delete (config as any).koringUser;
saveConfig(config);
deleteConfigKey('koringUser');
} catch {}
return { success: true, data: null, error: null };
} catch (e: unknown) {
+33 -22
View File
@@ -1,5 +1,7 @@
import electron from 'electron';
import { launchMinecraft, diagnoseVersion } from '../core/launcher';
import { launchGame, diagnoseVersion } from '../core/launcher';
import { resolveGamePath } from '../core/paths';
import { getConfig, type AppConfig } from '../config';
const { ipcMain } = electron;
@@ -7,36 +9,45 @@ interface WinRef {
mainWindow: electron.BrowserWindow | null;
}
// 游戏窗口就绪后按配置处理启动器窗口(afterLaunch
function applyAfterLaunch(config: AppConfig, win: WinRef): void {
const mode = config.advanced?.afterLaunch ?? 'close';
if (mode === 'close') {
win.mainWindow?.close();
} else if (mode === 'minimize') {
win.mainWindow?.minimize();
}
// 'keep' → 无操作
}
export function registerLaunchHandlers(win: WinRef) {
ipcMain.handle('launch:launch', async (_event, payload: {
instanceName: string;
gamePath: string;
javaPath: string;
version: string;
username: string;
uuid: string;
accessToken?: string;
memory?: { min?: string; max?: string };
jvmArgs?: string[];
gameArgs?: string[];
profile: { username: string; uuid: string; accessToken?: string };
server?: { ip: string; port?: number };
detached?: boolean;
}) => {
try {
const requestId = `launch-${Date.now()}`;
const result = await launchMinecraft({
gamePath: payload.gamePath,
javaPath: payload.javaPath,
version: payload.version,
username: payload.username,
uuid: payload.uuid,
accessToken: payload.accessToken,
memory: payload.memory,
jvmArgs: payload.jvmArgs,
gameArgs: payload.gameArgs,
server: payload.server,
detached: payload.detached,
// 使用主进程内存配置(唯一权威,永远是最新值,无磁盘竞争)
const config = getConfig();
// 快速进入服务器:UI 显式传入优先,否则使用配置中保存的 advanced.server
const server = payload.server
?? (config.advanced?.server?.ip ? config.advanced.server : undefined);
const result = await launchGame({
config,
instanceName: payload.instanceName,
gamePath: resolveGamePath(payload.gamePath),
profile: payload.profile,
server,
onEvent: (event) => {
// afterLaunch 副作用:窗口就绪后关闭/最小化启动器
if (event.event === 'window-ready') {
applyAfterLaunch(config, win);
}
win.mainWindow?.webContents.send('launch:event', { requestId, ...event });
},
});
+34 -5
View File
@@ -13,7 +13,9 @@ import { registerSystemHandlers } from './handlers/system';
import { registerWindowHandlers } from './handlers/window';
import { registerCrashHandlers, setupCrashListeners, testCrashDialog } from './handlers/crash-monitor';
import { registerKoringAuthHandlers } from './handlers/koring-auth';
import { loadConfig, saveConfig, configExists } from './config';
import { registerJavaHandlers } from './handlers/java';
import { saveConfig, configExists, getConfig, flushConfig, configPath } from './config';
import { authPath } from './auth';
const { app } = electron;
@@ -28,8 +30,30 @@ const win: { mainWindow: electron.BrowserWindow | null; splashWindow: electron.B
splashWindow: null,
};
// 迁移旧版「可执行文件旁」存储 → userData(仅打包模式)。
// 复制而非移动,避免破坏用户已有文件;userData 已有目标文件则跳过。
function migrateLegacyFiles(): void {
if (!app.isPackaged) return;
const exeDir = path.dirname(app.getPath('exe'));
const pairs: { name: string; dest: string }[] = [
{ name: 'Koring.yml', dest: configPath() },
{ name: 'koring-auth.json', dest: authPath() },
];
for (const { name, dest } of pairs) {
if (fs.existsSync(dest)) continue;
const src = path.join(exeDir, name);
if (!fs.existsSync(src)) continue;
try {
fs.copyFileSync(src, dest);
console.log(`[migrate] copied ${name}${dest}`);
} catch (e) {
console.error(`[migrate] failed to copy ${name}:`, e);
}
}
}
// Startup checks: .minecraft dir + config file + first launch detection
function runStartupChecks(): { isFirstLaunch: boolean; config: ReturnType<typeof loadConfig> } {
function runStartupChecks(): { isFirstLaunch: boolean; config: ReturnType<typeof getConfig> } {
const dataPath = app.isPackaged
? path.dirname(app.getPath('exe'))
: path.join(__dirname, '..');
@@ -44,8 +68,8 @@ function runStartupChecks(): { isFirstLaunch: boolean; config: ReturnType<typeof
const hasConfig = configExists();
const isFirstLaunch = !hasConfig;
// 3. Load (or create) config
const config = loadConfig();
// 3. Load (or create) config(getConfig 会缓存到主进程内存,成为唯一权威)
const config = getConfig();
if (isFirstLaunch) {
saveConfig(config);
}
@@ -130,7 +154,7 @@ function createMainWindow(): electron.BrowserWindow {
}
function registerAllHandlers() {
registerConfigHandlers();
registerConfigHandlers(win);
registerAuthHandlers();
registerInstallHandlers(win);
registerLaunchHandlers(win);
@@ -142,11 +166,15 @@ function registerAllHandlers() {
registerWindowHandlers(win);
registerCrashHandlers();
registerKoringAuthHandlers();
registerJavaHandlers();
}
app.whenReady().then(() => {
registerAllHandlers();
// Migrate legacy exe-dir config/auth to userData before anything reads them
migrateLegacyFiles();
// Run startup checks before creating windows
const { isFirstLaunch, config } = runStartupChecks();
@@ -194,6 +222,7 @@ app.whenReady().then(() => {
});
app.on('window-all-closed', () => {
flushConfig();
app.quit();
});
+7
View File
@@ -58,6 +58,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
return () => ipcRenderer.removeListener('config:preload', handler);
},
// Config changed broadcast (authoritative full config from main process)
onConfigChanged: (callback: (config: unknown) => void) => {
const handler = (_event: Electron.IpcRendererEvent, config: unknown) => callback(config);
ipcRenderer.on('config:changed', handler);
return () => ipcRenderer.removeListener('config:changed', handler);
},
// Background image — pick file, copy to userData, return base64 data URL
pickBackgroundImage: async (): Promise<string | null> => {
const result = await ipcRenderer.invoke('dialog:openFile', {