mirror of
https://github.com/dream-pep/koring-launcher.git
synced 2026-09-12 05:45:18 +08:00
feat: 发布 v1.2.0 版本,新增多项核心功能
- 引入 electron-updater 实现自动更新功能 - 新增 Java 环境扫描与校验的 IPC 处理逻辑 - 实现离线账号登录功能 - 新增配置变更跨进程广播机制 - 重构游戏启动逻辑,使用主进程内存配置作为唯一权威来源 - 新增界面显示与语言设置的配置页面 - 添加 Windows 平台自动发布 CI 流水线 - 迁移旧版配置/认证文件到用户数据目录 - 修复崩溃日志路径、表单控件等多项 bug - 重构设置页组件系统统一界面样式
This commit is contained in:
+11
-6
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 获取类型信息
|
||||
|
||||
@@ -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)G,max=memGB G
|
||||
* - java.gc=zgc/g1 → -XX:+UseZGC / -XX:+UseG1GC
|
||||
* - java.jvmArgs → 逐行解析并入 extraJVMArgs
|
||||
* - advanced.gameArgs → 解析并入 extraMCArgs
|
||||
* - advanced.winMode → resolution(fullscreen / custom 宽高)
|
||||
* - advanced.preLaunchCmd → prependCommand(Windows 批处理需 `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
@@ -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. 配置 → LaunchOption(version 覆盖为已解析版本)
|
||||
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}`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 config(userData / 项目根目录,与 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 auth(userData / 项目根目录,与 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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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() {
|
||||
// 扫描系统已安装的 Java(JAVA_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) };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user