Use BMCLAPI mirror & update build

Route Minecraft/Fabric/Forge downloads through BMCLAPI mirror and update installer logic. Added mirror rewrite utility (rewriteToMirror), mirror-aware downloadFile, and mirrorFetch; switched version manifest and metadata endpoints to bmclapi2.bangbang93.com and wired maven/assets hosts into xmcl/installer calls. Cleaned up compiled electron JS files and consolidated runtime files under electron-dist; updated electron-builder.yml (compression, asar, NSIS settings, license/installer assets and languages). Minor env/path changes (VITE_APP_ICON paths), added .npmrc (electron_mirror), new Koring.yml, pnpm-workspace and public installer assets. Also added/updated multiple frontend pages, store components, and TypeScript handlers to integrate the changes.
This commit is contained in:
2026-08-04 01:28:16 +08:00
parent 06269698a6
commit cb0afd99c0
73 changed files with 3108 additions and 2898 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
VITE_BUILD_MODE=beta
VITE_APP_ICON=/beta.png
VITE_APP_ICON=/icons/beta/icon.png
+1 -1
View File
@@ -1,2 +1,2 @@
VITE_BUILD_MODE=dev
VITE_APP_ICON=/dev.png
VITE_APP_ICON=/icons/dev/icon.png
+1 -1
View File
@@ -1,2 +1,2 @@
VITE_BUILD_MODE=run
VITE_APP_ICON=/run.png
VITE_APP_ICON=/icons/run/icon.png
+1 -2
View File
@@ -25,8 +25,7 @@ dist-electron
*.sw?
# Electron
electron/*.js
electron/*.d.ts
electron-dist/
# Build resources (generated by switch-icon.js)
build/
-12
View File
@@ -1,12 +0,0 @@
{
"name": "123",
"author": "",
"description": "123",
"runtime": {
"minecraft": "1.21.4"
},
"creationDate": 1785342079436,
"lastAccessDate": 1785342079436,
"lastPlayedDate": 0,
"playtime": 0
}
+12
View File
@@ -0,0 +1,12 @@
# __ __ __ __ ______ __ __ ______ __ __ ______ ______
# /\ \ /\ \ /\ "-.\ \ /\ ___\ /\ \/ / /\ ___\ /\ "-.\ \ /\ ___\ /\__ _\
# \ \ \____ \ \ \ \ \ \-. \ \ \ \__ \ \ \ _"-. \ \ __\ \ \ \-. \ \ \ __\ \/_/\ \/
# \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ \ \_\ \_\ \ \_____\ \ \_\\"\_\ \ \_____\ \ \_\
# \/_____/ \/_/ \/_/ \/_/ \/_____/ \/_/\/_/ \/_____/ \/_/ \/_/ \/_____/ \/_/
#
# 所有权利归Lingke Network (china)所有 | dream_pep拥有其艺术改变权力
# 未经允许的情况下删除此版权头可能会受到民事指控
# 请勿在未经Lingke Network (china)允许的范围内修改代码并分发
#
# 项目包管理配置:electron 二进制下载走国内镜像,避免 GitHub 网络不可达
electron_mirror=https://npmmirror.com/mirrors/electron/
+3
View File
@@ -0,0 +1,3 @@
oobe: false
game:
gameDirs: []
+11 -10
View File
@@ -3,14 +3,16 @@ productName: Koring Launcher
directories:
output: dist-electron
buildResources: build
compression: maximum
asar: true
files:
- dist/**
- electron/**/*.js
- "!electron/**/*.ts"
- electron-dist/**/*.js
win:
target: nsis
icon: build/icon.ico
artifactName: "koring-launcher-${version}-setup.${ext}"
requestedExecutionLevel: asInvoker
mac:
target: dmg
icon: build/icon.png
@@ -23,16 +25,15 @@ nsis:
installerIcon: build/icon.ico
uninstallerIcon: build/icon.ico
installerHeaderIcon: build/icon.ico
installerHeader: build/installer-header.png
license: build/license.rtf
installerSidebar: build/installer-header.bmp
license: build/license.txt
createDesktopShortcut: true
createStartMenuShortcut: true
shortcutName: Koring Launcher
include: build/installer-custom.nsh
script: build/installer-strings.nsh
uninstallDisplayName: "卸载 Koring Launcher"
displayLanguageSelector: true
uninstallDisplayName: "Koring Launcher"
deleteAppDataOnUninstall: false
installerLanguages:
- 2052
- 1028
- 1033
- "zh_CN"
- "zh_TW"
- "en_US"
-180
View File
@@ -1,180 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.microsoftLoginStart = microsoftLoginStart;
exports.microsoftLoginCallback = microsoftLoginCallback;
exports.offlineLogin = offlineLogin;
exports.validateMinecraftToken = validateMinecraftToken;
const crypto = __importStar(require("crypto"));
const electron_1 = __importDefault(require("electron"));
const { net } = electron_1.default;
const MC_SERVICES_API = 'https://api.minecraftservices.com';
const XBOX_AUTH_URL = 'https://user.auth.xboxlive.com/user/authenticate';
const XBOX_XSTS_URL = 'https://xsts.auth.xboxlive.com/xsts/authorize';
const MC_AUTH_URL = 'https://api.minecraftservices.com/authentication/login_with_xbox';
const MC_PROFILE_URL = 'https://api.minecraftservices.com/minecraft/profile';
function generateState() {
return crypto.randomBytes(16).toString('hex');
}
function generateVerifier() {
return crypto.randomBytes(32).toString('base64url');
}
function generateChallenge(verifier) {
return crypto.createHash('sha256').update(verifier).digest('base64url');
}
async function microsoftLoginStart(clientId, redirectUri) {
const state = generateState();
const verifier = generateVerifier();
const challenge = generateChallenge(verifier);
const params = new URLSearchParams({
client_id: clientId,
response_type: 'code',
redirect_uri: redirectUri || 'http://localhost:3000/callback',
scope: 'XboxLive.signin offline_access',
state,
code_challenge: challenge,
code_challenge_method: 'S256',
});
const authUrl = `https://login.live.com/oauth20_authorize.srf?${params.toString()}`;
return { state, authUrl, verifier };
}
async function exchangeCodeForToken(code, clientId, redirectUri, verifier) {
const response = await net.fetch('https://login.live.com/oauth20_token.srf', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: clientId,
code,
grant_type: 'authorization_code',
redirect_uri: redirectUri,
code_verifier: verifier,
}).toString(),
});
if (!response.ok)
throw new Error(`MS token exchange failed: ${response.status}`);
return response.json();
}
async function authenticateWithXbox(msToken) {
const response = await net.fetch(XBOX_AUTH_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
Properties: {
AuthMethod: 'RPS',
SiteName: 'user.auth.xboxlive.com',
RpsTicket: msToken,
},
RelyingParty: 'http://auth.xboxlive.com',
TokenType: 'JWT',
}),
});
if (!response.ok)
throw new Error(`Xbox auth failed: ${response.status}`);
return response.json();
}
async function authorizeWithXsts(xboxToken) {
const response = await net.fetch(XBOX_XSTS_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
Properties: {
SandboxId: 'RETAIL',
UserTokens: [xboxToken],
},
RelyingParty: 'rp://api.minecraftservices.com/',
TokenType: 'JWT',
}),
});
if (!response.ok)
throw new Error(`Xbox XSTS failed: ${response.status}`);
return response.json();
}
async function authenticateWithMinecraft(xstsToken) {
const response = await net.fetch(MC_AUTH_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
identityToken: `XBL3.0 x=${xstsToken}`,
}),
});
if (!response.ok)
throw new Error(`MC auth failed: ${response.status}`);
return response.json();
}
async function getMinecraftProfile(accessToken) {
const response = await net.fetch(MC_PROFILE_URL, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok)
throw new Error(`MC profile failed: ${response.status}`);
return response.json();
}
async function microsoftLoginCallback(code, clientId, redirectUri) {
const redirect = redirectUri || 'http://localhost:3000/callback';
const msToken = await exchangeCodeForToken(code, clientId, redirect, '');
const xboxAuth = await authenticateWithXbox(msToken.access_token);
const xstsAuth = await authorizeWithXsts(xboxAuth.Token);
const mcAuth = await authenticateWithMinecraft(xstsAuth.Token);
const profile = await getMinecraftProfile(mcAuth.access_token);
return {
username: profile.name,
uuid: profile.id,
accessToken: mcAuth.access_token,
expiresAt: Date.now() + mcAuth.expires_in * 1000,
};
}
async function offlineLogin(username) {
const uuid = crypto.createHash('md5').update(`OfflinePlayer:${username}`).digest('hex');
const formattedUuid = `${uuid.slice(0, 8)}-${uuid.slice(8, 12)}-${uuid.slice(12, 16)}-${uuid.slice(16, 20)}-${uuid.slice(20)}`;
return {
username,
uuid: formattedUuid,
accessToken: '',
};
}
async function validateMinecraftToken(accessToken) {
try {
const response = await net.fetch(`${MC_SERVICES_API}/minecraft/profile`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
return response.ok;
}
catch {
return false;
}
}
-93
View File
@@ -1,93 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.writeCrashLog = writeCrashLog;
exports.readCrashLog = readCrashLog;
exports.clearCrashLog = clearCrashLog;
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const electron_1 = __importDefault(require("electron"));
const { app } = electron_1.default;
const LOG_FILE = 'koring-crash.log';
const MAX_LINES = 1000;
function logPath() {
if (app.isPackaged) {
return path.join(path.dirname(app.getPath('exe')), LOG_FILE);
}
return path.join(__dirname, '..', LOG_FILE);
}
function trimFile(filePath) {
try {
if (!fs.existsSync(filePath))
return;
const lines = fs.readFileSync(filePath, 'utf-8').split('\n').filter(Boolean);
if (lines.length > MAX_LINES) {
fs.writeFileSync(filePath, lines.slice(-MAX_LINES).join('\n') + '\n', 'utf-8');
}
}
catch { }
}
function writeCrashLog(entry) {
const filePath = logPath();
try {
const line = JSON.stringify(entry) + '\n';
fs.appendFileSync(filePath, line, 'utf-8');
trimFile(filePath);
}
catch { }
}
function readCrashLog() {
const filePath = logPath();
try {
if (!fs.existsSync(filePath))
return '';
return fs.readFileSync(filePath, 'utf-8');
}
catch {
return '';
}
}
function clearCrashLog() {
const filePath = logPath();
try {
if (fs.existsSync(filePath)) {
fs.writeFileSync(filePath, '', 'utf-8');
}
}
catch { }
}
-181
View File
@@ -1,181 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.getVersionList = getVersionList;
exports.getForgeVersions = getForgeVersions;
exports.getFabricVersions = getFabricVersions;
exports.installMinecraft = installMinecraft;
exports.installModLoader = installModLoader;
const electron_1 = __importDefault(require("electron"));
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const https = __importStar(require("https"));
const http = __importStar(require("http"));
const { net } = electron_1.default;
const VERSION_MANIFEST_URL = 'https://launchermeta.mojang.com/mc/game/version_manifest.json';
const FORGE_VERSION_LIST_URL = 'https://files.minecraftforge.net/net/minecraftforge/forge/json';
const FABRIC_VERSION_LIST_URL = 'https://meta.fabricmc.net/v2/versions';
async function downloadFile(url, dest, onProgress) {
return new Promise((resolve, reject) => {
const client = url.startsWith('https') ? https : http;
const request = client.get(url, (response) => {
if (response.statusCode === 302 || response.statusCode === 301) {
downloadFile(response.headers.location, dest, onProgress).then(resolve).catch(reject);
return;
}
if (response.statusCode !== 200) {
reject(new Error(`Download failed: ${response.statusCode}`));
return;
}
const totalBytes = parseInt(response.headers['content-length'] || '0', 10);
let downloadedBytes = 0;
const dir = path.dirname(dest);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const file = fs.createWriteStream(dest);
response.on('data', (chunk) => {
downloadedBytes += chunk.length;
if (onProgress && totalBytes > 0) {
onProgress({
stage: 'downloading',
current: downloadedBytes,
total: totalBytes,
message: `${Math.round((downloadedBytes / totalBytes) * 100)}%`,
});
}
});
response.pipe(file);
file.on('finish', () => {
file.close();
resolve();
});
file.on('error', (err) => {
fs.unlink(dest, () => { });
reject(err);
});
});
request.on('error', reject);
});
}
async function getVersionList(type) {
const response = await net.fetch(VERSION_MANIFEST_URL);
if (!response.ok)
throw new Error(`Failed to fetch version manifest: ${response.status}`);
const manifest = await response.json();
if (type && type !== 'all') {
manifest.versions = manifest.versions.filter((v) => v.type === type);
}
return manifest;
}
async function getForgeVersions(mcVersion) {
try {
const response = await net.fetch(FORGE_VERSION_LIST_URL);
if (!response.ok)
throw new Error(`Forge version list failed: ${response.status}`);
const data = await response.json();
const versions = data.versions[mcVersion || ''] || [];
return { versions };
}
catch {
return { versions: [] };
}
}
async function getFabricVersions(mcVersion) {
try {
const url = mcVersion
? `${FABRIC_VERSION_LIST_URL}/loader/${mcVersion}`
: `${FABRIC_VERSION_LIST_URL}/loader`;
const response = await net.fetch(url);
if (!response.ok)
throw new Error(`Fabric version list failed: ${response.status}`);
const data = await response.json();
return { versions: data.map((v) => v.version) };
}
catch {
return { versions: [] };
}
}
async function installMinecraft(version, gamePath, javaPath, downloadThreads, callbacks) {
const versionDir = path.join(gamePath, 'versions', version);
if (!fs.existsSync(versionDir)) {
fs.mkdirSync(versionDir, { recursive: true });
}
// Download version manifest
const manifestResponse = await net.fetch(VERSION_MANIFEST_URL);
const manifest = await manifestResponse.json();
const versionInfo = manifest.versions.find((v) => v.id === version);
if (!versionInfo)
throw new Error(`Version ${version} not found`);
callbacks?.onProgress?.({ stage: 'downloading', current: 0, total: 100, message: 'Downloading version manifest...' });
// Download version JSON
const versionJsonPath = path.join(versionDir, `${version}.json`);
await downloadFile(versionInfo.url, versionJsonPath, callbacks?.onProgress);
const versionJson = JSON.parse(fs.readFileSync(versionJsonPath, 'utf-8'));
// Download client jar
const clientJar = versionJson.downloads?.client;
if (clientJar) {
callbacks?.onProgress?.({ stage: 'downloading', current: 1, total: 3, message: 'Downloading client jar...' });
const jarPath = path.join(versionDir, `${version}.jar`);
await downloadFile(clientJar.url, jarPath, callbacks?.onProgress);
}
callbacks?.onProgress?.({ stage: 'downloading', current: 2, total: 3, message: 'Installing libraries...' });
// Download libraries
const libraries = versionJson.libraries || [];
for (const lib of libraries) {
if (lib.downloads?.artifact?.url) {
const libPath = path.join(gamePath, 'libraries', lib.downloads.artifact.path);
if (!fs.existsSync(libPath)) {
await downloadFile(lib.downloads.artifact.url, libPath);
}
}
}
callbacks?.onProgress?.({ stage: 'downloading', current: 3, total: 3, message: 'Installation complete' });
return { version, gamePath };
}
async function installModLoader(mcVersion, gamePath, loaderType, loaderVersion, javaPath, callbacks) {
callbacks?.onProgress?.({ stage: 'installing', current: 0, total: 100, message: `Installing ${loaderType}...` });
// Placeholder: actual implementation depends on loader type
// This would call the appropriate installer for Forge/Fabric/Quilt/NeoForge
callbacks?.onProgress?.({ stage: 'installing', current: 100, total: 100, message: `${loaderType} installed` });
return {
loaderType,
mcVersion,
loaderVersion: loaderVersion || 'latest',
};
}
+70 -11
View File
@@ -6,14 +6,63 @@ import * as http from 'http';
const { net } = electron;
const VERSION_MANIFEST_URL = 'https://launchermeta.mojang.com/mc/game/version_manifest.json';
const FORGE_VERSION_LIST_URL = 'https://files.minecraftforge.net/net/minecraftforge/forge/json';
const FABRIC_VERSION_LIST_URL = 'https://meta.fabricmc.net/v2/versions';
// ==================== BMCLAPI 镜像源配置 ====================
// 使用 BMCLAPIbmclapi2.bangbang93.com)镜像加速国内资源下载
// 版本清单:launchermeta.mojang.com → bmclapi2.bangbang93.com
// Fabric 元数据:meta.fabricmc.net → bmclapi2.bangbang93.com/fabric-meta
const VERSION_MANIFEST_URL = 'https://bmclapi2.bangbang93.com/mc/game/version_manifest.json';
const FABRIC_VERSION_LIST_URL = 'https://bmclapi2.bangbang93.com/fabric-meta/v2/versions';
// BMCLAPI 域名替换规则(按顺序匹配,带路径前缀的规则优先)
// 版本 JSON/客户端:launchermeta|launcher|piston-meta|piston-data.mojang.com → bmclapi2.bangbang93.com
// Assetsresources.download.minecraft.net → bmclapi2.bangbang93.com/assets
// Librarieslibraries.minecraft.net → bmclapi2.bangbang93.com/maven
// Forgefiles.minecraftforge.net/maven → bmclapi2.bangbang93.com/maven
// NeoForgemaven.neoforged.net/releases → bmclapi2.bangbang93.com/maven
// Fabricmeta.fabricmc.net → bmclapi2.bangbang93.com/fabric-metamaven.fabricmc.net → bmclapi2.bangbang93.com/maven
const MIRROR_RULES: [string, string][] = [
['files.minecraftforge.net/maven', 'bmclapi2.bangbang93.com/maven'],
['maven.neoforged.net/releases', 'bmclapi2.bangbang93.com/maven'],
['launchermeta.mojang.com', 'bmclapi2.bangbang93.com'],
['launcher.mojang.com', 'bmclapi2.bangbang93.com'],
['piston-meta.mojang.com', 'bmclapi2.bangbang93.com'],
['piston-data.mojang.com', 'bmclapi2.bangbang93.com'],
['resources.download.minecraft.net', 'bmclapi2.bangbang93.com/assets'],
['libraries.minecraft.net', 'bmclapi2.bangbang93.com/maven'],
['maven.minecraftforge.net', 'bmclapi2.bangbang93.com/maven'],
['maven.neoforged.net', 'bmclapi2.bangbang93.com/maven'],
['meta.fabricmc.net', 'bmclapi2.bangbang93.com/fabric-meta'],
['maven.fabricmc.net', 'bmclapi2.bangbang93.com/maven'],
];
// 将官方资源 URL 改写为 BMCLAPI 镜像 URL(未命中的 URL 原样返回)
export function rewriteToMirror(url: string): string {
try {
const u = new URL(url);
for (const [from, to] of MIRROR_RULES) {
const slashIdx = from.indexOf('/');
const fromHost = slashIdx === -1 ? from : from.slice(0, slashIdx);
const fromPath = slashIdx === -1 ? '' : from.slice(slashIdx);
if (u.host !== fromHost) continue;
if (fromPath && !u.pathname.startsWith(fromPath)) continue;
// 命中规则,替换主机(并附加镜像路径前缀)
const toSlashIdx = to.indexOf('/');
u.host = toSlashIdx === -1 ? to : to.slice(0, toSlashIdx);
const toPath = toSlashIdx === -1 ? '' : to.slice(toSlashIdx);
u.pathname = toPath + (fromPath ? u.pathname.slice(fromPath.length) : u.pathname);
return u.toString();
}
return url;
} catch {
return url;
}
}
interface VersionInfo {
id: string;
type: string;
url: string;
releaseTime: string;
}
interface VersionManifest {
@@ -29,9 +78,11 @@ interface DownloadProgress {
}
async function downloadFile(url: string, dest: string, onProgress?: (progress: DownloadProgress) => void): Promise<void> {
// 下载前统一改写为 BMCLAPI 镜像地址
url = rewriteToMirror(url);
return new Promise((resolve, reject) => {
const client = url.startsWith('https') ? https : http;
const request = client.get(url, (response) => {
// 根据协议选择 http / https 客户端(分开调用以避免 TS 联合类型签名不兼容)
const handleResponse = (response: http.IncomingMessage) => {
if (response.statusCode === 302 || response.statusCode === 301) {
downloadFile(response.headers.location!, dest, onProgress).then(resolve).catch(reject);
return;
@@ -52,7 +103,7 @@ async function downloadFile(url: string, dest: string, onProgress?: (progress: D
const file = fs.createWriteStream(dest);
response.on('data', (chunk) => {
response.on('data', (chunk: Buffer) => {
downloadedBytes += chunk.length;
if (onProgress && totalBytes > 0) {
onProgress({
@@ -75,7 +126,14 @@ async function downloadFile(url: string, dest: string, onProgress?: (progress: D
fs.unlink(dest, () => {});
reject(err);
});
});
};
let request: http.ClientRequest;
if (url.startsWith('https')) {
request = https.get(url, handleResponse);
} else {
request = http.get(url, handleResponse);
}
request.on('error', reject);
});
@@ -95,11 +153,12 @@ export async function getVersionList(type?: string): Promise<VersionManifest> {
export async function getForgeVersions(mcVersion?: string) {
try {
const response = await net.fetch(FORGE_VERSION_LIST_URL);
// BMCLAPI Forge 版本列表接口(需指定 MC 版本)
if (!mcVersion) return { versions: [] };
const response = await net.fetch(`https://bmclapi2.bangbang93.com/forge/minecraft/${mcVersion}`);
if (!response.ok) throw new Error(`Forge version list failed: ${response.status}`);
const data = await response.json() as { versions: Record<string, string[]> };
const versions = data.versions[mcVersion || ''] || [];
return { versions };
const data = await response.json() as { version: string }[];
return { versions: data.map((v) => v.version) };
} catch {
return { versions: [] };
}
-295
View File
@@ -1,295 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.createInstance = createInstance;
exports.listInstances = listInstances;
exports.getInstanceInfo = getInstanceInfo;
exports.deleteInstance = deleteInstance;
exports.updateInstance = updateInstance;
exports.installInstanceGame = installInstanceGame;
exports.launchInstance = launchInstance;
exports.diagnoseInstance = diagnoseInstance;
exports.getMinecraftVersionList = getMinecraftVersionList;
exports.getForgeVersionList = getForgeVersionList;
exports.getFabricVersionList = getFabricVersionList;
exports.getQuiltVersionList = getQuiltVersionList;
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const core_1 = require("@xmcl/core");
const installer_1 = require("@xmcl/installer");
const INSTANCE_CONFIG_FILE = 'instance.json';
function getInstanceConfigPath(instancePath) {
return path.join(instancePath, INSTANCE_CONFIG_FILE);
}
function countFiles(dir, ext) {
if (!fs.existsSync(dir))
return 0;
return fs.readdirSync(dir).filter((f) => {
if (ext)
return f.endsWith(ext);
return fs.statSync(path.join(dir, f)).isFile();
}).length;
}
function getInstanceIssues(instancePath, runtime) {
const issues = [];
if (!fs.existsSync(path.join(instancePath, 'versions', runtime.minecraft, `${runtime.minecraft}.json`))) {
issues.push(`Version JSON not found for ${runtime.minecraft}`);
}
if (!fs.existsSync(path.join(instancePath, 'versions', runtime.minecraft, `${runtime.minecraft}.jar`))) {
issues.push(`Client JAR not found for ${runtime.minecraft}`);
}
return issues;
}
async function createInstance(name, gamePath, runtime, options) {
const instancePath = path.join(gamePath, 'instances', name);
if (fs.existsSync(instancePath)) {
throw new Error(`Instance already exists: ${name}`);
}
fs.mkdirSync(instancePath, { recursive: true });
const now = Date.now();
const config = {
name,
author: options?.author || '',
description: options?.description || '',
runtime,
java: options?.java,
minMemory: options?.minMemory,
maxMemory: options?.maxMemory,
vmOptions: options?.vmOptions,
mcOptions: options?.mcOptions,
creationDate: now,
lastAccessDate: now,
lastPlayedDate: 0,
playtime: 0,
};
fs.writeFileSync(getInstanceConfigPath(instancePath), JSON.stringify(config, null, 2), 'utf-8');
// Create standard directories
for (const dir of ['mods', 'config', 'resourcepacks', 'shaderpacks', 'saves', 'screenshots', 'logs']) {
fs.mkdirSync(path.join(instancePath, dir), { recursive: true });
}
return getInstanceInfo(name, gamePath);
}
async function listInstances(gamePath) {
const instancesPath = path.join(gamePath, 'instances');
const instances = [];
if (!fs.existsSync(instancesPath)) {
return instances;
}
const entries = fs.readdirSync(instancesPath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory())
continue;
try {
instances.push(await getInstanceInfo(entry.name, gamePath));
}
catch {
// Skip invalid instances
}
}
return instances;
}
async function getInstanceInfo(name, gamePath) {
const instancePath = path.join(gamePath, 'instances', name);
const configPath = getInstanceConfigPath(instancePath);
if (!fs.existsSync(configPath)) {
throw new Error(`Instance not found: ${name}`);
}
const configRaw = fs.readFileSync(configPath, 'utf-8');
const config = JSON.parse(configRaw);
const issues = getInstanceIssues(instancePath, config.runtime);
return {
name,
path: instancePath,
config,
modCount: countFiles(path.join(instancePath, 'mods'), '.jar'),
resourcePackCount: countFiles(path.join(instancePath, 'resourcepacks'), '.zip'),
screenshotCount: countFiles(path.join(instancePath, 'screenshots')),
saveCount: countFiles(path.join(instancePath, 'saves')),
healthy: issues.length === 0,
issues,
};
}
async function deleteInstance(name, gamePath) {
const instancePath = path.join(gamePath, 'instances', name);
if (!fs.existsSync(instancePath)) {
throw new Error(`Instance not found: ${name}`);
}
fs.rmSync(instancePath, { recursive: true, force: true });
return { deleted: name };
}
async function updateInstance(name, gamePath, patch) {
const instancePath = path.join(gamePath, 'instances', name);
const configPath = getInstanceConfigPath(instancePath);
if (!fs.existsSync(configPath)) {
throw new Error(`Instance not found: ${name}`);
}
const configRaw = fs.readFileSync(configPath, 'utf-8');
const config = JSON.parse(configRaw);
const updated = { ...config, ...patch };
fs.writeFileSync(configPath, JSON.stringify(updated, null, 2), 'utf-8');
return getInstanceInfo(name, gamePath);
}
async function installInstanceGame(name, gamePath, callbacks) {
const instance = await getInstanceInfo(name, gamePath);
const { runtime } = instance.config;
const instancePath = instance.path;
callbacks?.onProgress?.({ stage: 'installing-minecraft', current: 0, total: 100, message: `Installing Minecraft ${runtime.minecraft}...` });
// Install Minecraft
// First get version list to find the version info
const versionList = await (0, installer_1.getVersionList)();
const versionInfo = versionList.versions.find((v) => v.id === runtime.minecraft);
if (!versionInfo) {
throw new Error(`Minecraft version ${runtime.minecraft} not found`);
}
await (0, installer_1.install)({ id: versionInfo.id, url: versionInfo.url }, instancePath);
// Install mod loaders
if (runtime.forge) {
callbacks?.onProgress?.({ stage: 'installing-forge', current: 0, total: 100, message: `Installing Forge ${runtime.forge}...` });
await (0, installer_1.installForge)({ version: runtime.forge, mcversion: runtime.minecraft }, instancePath);
}
if (runtime.fabricLoader) {
callbacks?.onProgress?.({ stage: 'installing-fabric', current: 0, total: 100, message: `Installing Fabric ${runtime.fabricLoader}...` });
await (0, installer_1.installFabric)({
minecraftVersion: runtime.minecraft,
version: runtime.fabricLoader,
minecraft: instancePath,
});
}
if (runtime.quiltLoader) {
callbacks?.onProgress?.({ stage: 'installing-quilt', current: 0, total: 100, message: `Installing Quilt ${runtime.quiltLoader}...` });
await (0, installer_1.installQuiltVersion)({
minecraftVersion: runtime.minecraft,
version: runtime.quiltLoader,
minecraft: instancePath,
});
}
if (runtime.neoForged) {
callbacks?.onProgress?.({ stage: 'installing-neoforge', current: 0, total: 100, message: `Installing NeoForge ${runtime.neoForged}...` });
await (0, installer_1.installNeoForged)('neoforge', runtime.neoForged, instancePath, {});
}
// Note: OptiFine requires downloading the installer JAR first
// if (runtime.optifine) {
// callbacks?.onProgress?.({ stage: 'installing-optifine', current: 0, total: 100, message: `Installing OptiFine ${runtime.optifine}...` });
// await installOptifine(runtime.optifine, instancePath);
// }
callbacks?.onProgress?.({ stage: 'installing-dependencies', current: 0, total: 100, message: 'Installing dependencies...' });
// Install all dependencies (libraries + assets)
const resolved = await core_1.Version.parse(instancePath, runtime.minecraft);
await (0, installer_1.installDependencies)(resolved);
callbacks?.onProgress?.({ stage: 'done', current: 100, total: 100, message: 'Installation complete' });
// Update last access date
await updateInstance(name, gamePath, { lastAccessDate: Date.now() });
return getInstanceInfo(name, gamePath);
}
async function launchInstance(name, gamePath, options) {
const instance = await getInstanceInfo(name, gamePath);
const { runtime } = instance.config;
const resolved = await core_1.Version.parse(instance.path, runtime.minecraft);
const javaPath = options.javaPath || instance.config.java || 'java';
const mcProcess = await (0, core_1.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 = (0, core_1.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,
};
}
async function diagnoseInstance(name, gamePath) {
const instance = await getInstanceInfo(name, gamePath);
return {
healthy: instance.healthy,
issues: instance.issues,
};
}
// Version list APIs
async function getMinecraftVersionList(type) {
const manifest = await (0, installer_1.getVersionList)();
let versions = manifest.versions;
if (type && type !== 'all') {
versions = versions.filter((v) => v.type === type);
}
return { versions };
}
async function getForgeVersionList(mcVersion) {
const list = await (0, installer_1.getForgeVersionList)({ minecraft: mcVersion });
return { versions: list.versions };
}
async function getFabricVersionList(mcVersion) {
if (mcVersion) {
const loaders = await (0, installer_1.getFabricLoaders)();
return { versions: loaders.map((l) => l.version) };
}
const loaders = await (0, installer_1.getFabricLoaders)();
return { versions: loaders.map((l) => l.version) };
}
async function getQuiltVersionList(mcVersion) {
const loaders = await (0, installer_1.getQuiltLoaderVersionsByMinecraft)({ minecraftVersion: mcVersion || '*' });
return { versions: loaders.map((l) => l.loader.version) };
}
+79 -12
View File
@@ -14,6 +14,18 @@ import {
getFabricLoaders,
getQuiltLoaderVersionsByMinecraft,
} from '@xmcl/installer';
import { rewriteToMirror } from './installer';
// ==================== BMCLAPI 镜像源配置 ====================
// 版本清单(@xmcl/installer 的 getVersionList 通过 remote 参数覆盖)
const BMCLAPI_VERSION_MANIFEST = 'https://bmclapi2.bangbang93.com/mc/game/version_manifest.json';
// Maven 仓库镜像(Libraries / Forge / NeoForge / Fabric 构件)
const BMCLAPI_MAVEN = 'https://bmclapi2.bangbang93.com/maven';
// 资源文件镜像(assets
const BMCLAPI_ASSETS = 'https://bmclapi2.bangbang93.com/assets';
// 供 @xmcl/installer FetchOptions 使用的镜像 fetchmeta.fabricmc.net 等元数据接口走镜像)
const mirrorFetch: typeof fetch = (url, init) => fetch(rewriteToMirror(String(url)), init);
export interface InstanceRuntime {
minecraft: string;
@@ -232,18 +244,24 @@ export async function installInstanceGame(
callbacks?.onProgress?.({ stage: 'installing-minecraft', current: 0, total: 100, message: `Installing Minecraft ${runtime.minecraft}...` });
// Install Minecraft
// First get version list to find the version info
const versionList = await getVersionList();
// First get version list to find the version info(走 BMCLAPI 镜像)
const versionList = await getVersionList({ remote: BMCLAPI_VERSION_MANIFEST, fetch: mirrorFetch });
const versionInfo = versionList.versions.find((v) => v.id === runtime.minecraft);
if (!versionInfo) {
throw new Error(`Minecraft version ${runtime.minecraft} not found`);
}
await xmclInstall({ id: versionInfo.id, url: versionInfo.url }, instancePath);
await xmclInstall({ id: versionInfo.id, url: versionInfo.url }, instancePath, {
// 版本 JSON / 客户端 JAR 走镜像(BMCLAPI 会回源官方地址)
json: (v) => rewriteToMirror(v.url),
client: (v) => (v.downloads.client ? rewriteToMirror(v.downloads.client.url) : []),
mavenHost: BMCLAPI_MAVEN,
assetsHost: BMCLAPI_ASSETS,
});
// Install mod loaders
if (runtime.forge) {
callbacks?.onProgress?.({ stage: 'installing-forge', current: 0, total: 100, message: `Installing Forge ${runtime.forge}...` });
await installForge({ version: runtime.forge, mcversion: runtime.minecraft }, instancePath);
await installForge({ version: runtime.forge, mcversion: runtime.minecraft }, instancePath, { mavenHost: BMCLAPI_MAVEN });
}
if (runtime.fabricLoader) {
@@ -252,6 +270,7 @@ export async function installInstanceGame(
minecraftVersion: runtime.minecraft,
version: runtime.fabricLoader,
minecraft: instancePath,
fetch: mirrorFetch,
});
}
@@ -266,7 +285,7 @@ export async function installInstanceGame(
if (runtime.neoForged) {
callbacks?.onProgress?.({ stage: 'installing-neoforge', current: 0, total: 100, message: `Installing NeoForge ${runtime.neoForged}...` });
await installNeoForged('neoforge', runtime.neoForged, instancePath, {});
await installNeoForged('neoforge', runtime.neoForged, instancePath, { mavenHost: BMCLAPI_MAVEN });
}
// Note: OptiFine requires downloading the installer JAR first
@@ -277,9 +296,9 @@ export async function installInstanceGame(
callbacks?.onProgress?.({ stage: 'installing-dependencies', current: 0, total: 100, message: 'Installing dependencies...' });
// Install all dependencies (libraries + assets)
// Install all dependencies (libraries + assets)(走 BMCLAPI 镜像)
const resolved: ResolvedVersion = await Version.parse(instancePath, runtime.minecraft);
await installDependencies(resolved);
await installDependencies(resolved, { mavenHost: BMCLAPI_MAVEN, assetsHost: BMCLAPI_ASSETS });
callbacks?.onProgress?.({ stage: 'done', current: 100, total: 100, message: 'Installation complete' });
@@ -370,12 +389,13 @@ export async function diagnoseInstance(
// Version list APIs
export async function getMinecraftVersionList(type?: string) {
const manifest = await getVersionList();
// 版本清单一律走 BMCLAPI 镜像
const manifest = await getVersionList({ remote: BMCLAPI_VERSION_MANIFEST, fetch: mirrorFetch });
let versions = manifest.versions;
if (type && type !== 'all') {
versions = versions.filter((v) => v.type === type);
versions = versions.filter((v: { type: string }) => v.type === type);
}
return { versions };
return { versions, latest: manifest.latest };
}
export async function getForgeVersionList(mcVersion?: string) {
@@ -384,11 +404,12 @@ export async function getForgeVersionList(mcVersion?: string) {
}
export async function getFabricVersionList(mcVersion?: string) {
// Fabric 元数据走 BMCLAPI 镜像(meta.fabricmc.net → fabric-meta
if (mcVersion) {
const loaders = await getFabricLoaders();
const loaders = await getFabricLoaders({ fetch: mirrorFetch });
return { versions: loaders.map((l) => l.version) };
}
const loaders = await getFabricLoaders();
const loaders = await getFabricLoaders({ fetch: mirrorFetch });
return { versions: loaders.map((l) => l.version) };
}
@@ -396,3 +417,49 @@ export async function getQuiltVersionList(mcVersion?: string) {
const loaders = await getQuiltLoaderVersionsByMinecraft({ minecraftVersion: mcVersion || '*' });
return { versions: loaders.map((l) => l.loader.version) };
}
// 扫描目录中已安装版本的信息
export interface ScannedVersion {
id: string;
type: string;
releaseTime?: string;
/** JAR 文件是否存在 */
hasJar: boolean;
/** JSON 文件是否存在 */
hasJson: boolean;
}
// 扫描指定目录,返回 versions 子目录中所有已安装版本的详细信息
export function scanGameDirectories(gamePath: string): ScannedVersion[] {
const versionsPath = path.join(gamePath, 'versions');
if (!fs.existsSync(versionsPath)) return [];
try {
return fs.readdirSync(versionsPath, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => {
const id = e.name;
const versionDir = path.join(versionsPath, id);
const jsonPath = path.join(versionDir, `${id}.json`);
const jarPath = path.join(versionDir, `${id}.jar`);
const hasJson = fs.existsSync(jsonPath);
const hasJar = fs.existsSync(jarPath);
let type = "unknown";
let releaseTime: string | undefined;
// 尝试读取 version JSON 获取类型和时间
if (hasJson) {
try {
const raw = fs.readFileSync(jsonPath, 'utf-8');
const json = JSON.parse(raw);
type = json.type || "unknown";
releaseTime = json.releaseTime;
} catch {}
}
return { id, type, releaseTime, hasJar, hasJson };
});
} catch {
return [];
}
}
-175
View File
@@ -1,175 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.requestDeviceCode = requestDeviceCode;
exports.pollForTokenOnce = pollForTokenOnce;
exports.refreshAccessToken = refreshAccessToken;
exports.decodeIdToken = decodeIdToken;
exports.saveKoringAuth = saveKoringAuth;
exports.readKoringAuth = readKoringAuth;
exports.deleteKoringAuth = deleteKoringAuth;
const https = __importStar(require("https"));
const auth_1 = require("../auth");
const CLIENT_ID = '547qe8ky1pr69f08b71kj';
const DEVICE_AUTH_URL = 'https://oac.lingke.ink/oidc/device/auth';
const TOKEN_URL = 'https://oac.lingke.ink/oidc/token';
const SCOPE = 'openid offline_access profile';
function postForm(url, data) {
return new Promise((resolve, reject) => {
const params = new URLSearchParams(data);
const body = params.toString().replace(/\+/g, '%20');
const urlObj = new URL(url);
console.log(`[koring-auth] POST ${url}`);
console.log(`[koring-auth] body: ${body}`);
const req = https.request({
hostname: urlObj.hostname,
port: urlObj.port || 443,
path: urlObj.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(body),
},
}, (res) => {
let raw = '';
res.on('data', (chunk) => (raw += chunk));
res.on('end', () => {
console.log(`[koring-auth] response (${res.statusCode}): ${raw}`);
try {
resolve(JSON.parse(raw));
}
catch {
reject(new Error(`Invalid response: ${raw}`));
}
});
});
req.on('error', reject);
req.write(body);
req.end();
});
}
function parseJwt(token) {
try {
const payload = token.split('.')[1];
const decoded = Buffer.from(payload, 'base64url').toString('utf-8');
return JSON.parse(decoded);
}
catch {
return {};
}
}
async function requestDeviceCode() {
const res = await postForm(DEVICE_AUTH_URL, {
client_id: CLIENT_ID,
scope: SCOPE,
});
if (res.error)
throw new Error(res.error_description || res.error);
return res;
}
async function pollForTokenOnce(device_code) {
const res = await postForm(TOKEN_URL, {
client_id: CLIENT_ID,
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
device_code,
});
if (res.access_token) {
return res;
}
if (res.error === 'expired_token' || res.error === 'access_denied') {
throw new Error(res.error);
}
// authorization_pending or slow_down — throw so caller can retry
throw new Error(res.error || 'authorization_pending');
}
async function refreshAccessToken(refresh_token) {
const res = await postForm(TOKEN_URL, {
client_id: CLIENT_ID,
grant_type: 'refresh_token',
refresh_token,
});
if (res.error)
throw new Error(res.error_description || res.error);
return res;
}
function decodeIdToken(id_token) {
const payload = parseJwt(id_token);
return {
sub: payload.sub || '',
name: payload.name || '',
username: payload.username || payload.name || '',
email: payload.email || '',
picture: payload.picture || '',
};
}
function saveKoringAuth(tokenRes) {
const user = decodeIdToken(tokenRes.id_token);
const auth = {
user,
access_token: tokenRes.access_token,
refresh_token: tokenRes.refresh_token,
id_token: tokenRes.id_token,
expires_at: Date.now() + tokenRes.expires_in * 1000,
};
// Reuse existing auth file for storage
(0, auth_1.writeAuth)({
username: user.username,
uuid: user.sub,
accessToken: auth.access_token,
refreshToken: auth.refresh_token,
xboxProfile: JSON.stringify(user),
});
return user;
}
function readKoringAuth() {
const auth = (0, auth_1.readAuth)();
if (!auth.username || !auth.refreshToken)
return null;
let user = { sub: '', name: '', username: '', email: '', picture: '' };
try {
user = JSON.parse(auth.xboxProfile || '{}');
}
catch { }
return {
user,
access_token: auth.accessToken,
refresh_token: auth.refreshToken,
id_token: '',
expires_at: 0,
};
}
function deleteKoringAuth() {
(0, auth_1.writeAuth)({ username: '', uuid: '', accessToken: '', refreshToken: '', xboxProfile: '' });
}
-152
View File
@@ -1,152 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.launchMinecraft = launchMinecraft;
exports.diagnoseVersion = diagnoseVersion;
const child_process_1 = require("child_process");
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const runningProcesses = new Map();
async function launchMinecraft(options) {
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 = [];
// 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) => lib.downloads?.artifact?.path)
.map((lib) => 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 = (0, child_process_1.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,
});
});
}
async function diagnoseVersion(gamePath, version) {
const issues = [];
const versionDir = path.join(gamePath, 'versions', version);
const versionJsonPath = path.join(versionDir, `${version}.json`);
const jarPath = path.join(versionDir, `${version}.jar`);
if (!fs.existsSync(versionJsonPath)) {
issues.push(`Version JSON not found: ${versionJsonPath}`);
}
if (!fs.existsSync(jarPath)) {
issues.push(`Client JAR not found: ${jarPath}`);
}
return {
version,
gamePath,
healthy: issues.length === 0,
issues,
};
}
-163
View File
@@ -1,163 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.searchMods = searchMods;
exports.getModDetail = getModDetail;
exports.getModVersions = getModVersions;
exports.installMod = installMod;
const electron_1 = __importDefault(require("electron"));
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const { net } = electron_1.default;
const MODRINTH_API = 'https://api.modrinth.com/v2';
const CURSEFORGE_API = 'https://api.curseforge.com/v1';
async function searchModrinth(query, gameVersion, loader, limit = 20, offset = 0) {
const facets = [];
if (gameVersion)
facets.push([`versions:${gameVersion}`]);
if (loader)
facets.push([`categories:${loader}`]);
const params = new URLSearchParams({
query: query || '',
limit: String(limit),
offset: String(offset),
});
if (facets.length > 0) {
params.set('facets', JSON.stringify(facets));
}
const response = await net.fetch(`${MODRINTH_API}/search?${params.toString()}`);
if (!response.ok)
throw new Error(`Modrinth search failed: ${response.status}`);
const data = await response.json();
return data.hits.map((hit) => ({
id: hit.project_id,
slug: hit.slug,
name: hit.title,
description: hit.description,
downloads: hit.downloads,
iconUrl: hit.icon_url,
categories: hit.categories,
versions: hit.versions,
source: 'modrinth',
}));
}
async function searchCurseForge(query, gameVersion, loader, limit = 20, offset = 0) {
// CurseForge requires API key - return empty for now
return [];
}
async function searchMods(query, gameVersion, loader, limit, offset, source = 'modrinth') {
if (source === 'modrinth') {
return searchModrinth(query, gameVersion, loader, limit, offset);
}
return searchCurseForge(query, gameVersion, loader, limit, offset);
}
async function getModDetail(projectId, source = 'modrinth') {
if (source === 'modrinth') {
const response = await net.fetch(`${MODRINTH_API}/project/${projectId}`);
if (!response.ok)
throw new Error(`Modrinth detail failed: ${response.status}`);
const data = await response.json();
return {
id: data.id,
slug: data.slug,
name: data.title,
description: data.description,
downloads: data.downloads,
iconUrl: data.icon_url,
categories: data.categories,
versions: data.versions,
source: 'modrinth',
};
}
throw new Error(`CurseForge detail not implemented`);
}
async function getModVersions(projectId, gameVersion, loader, source = 'modrinth') {
if (source === 'modrinth') {
const params = new URLSearchParams();
if (gameVersion)
params.set('game_versions', JSON.stringify([gameVersion]));
if (loader)
params.set('loaders', JSON.stringify([loader]));
const response = await net.fetch(`${MODRINTH_API}/project/${projectId}/version?${params.toString()}`);
if (!response.ok)
throw new Error(`Modrinth versions failed: ${response.status}`);
const data = await response.json();
return data.map((v) => ({
id: v.id,
name: v.name,
versionNumber: v.version_number,
gameVersions: v.game_versions,
loaders: v.loaders,
files: v.files.map((f) => ({
filename: f.filename,
url: f.url,
size: f.size,
primary: f.primary,
})),
}));
}
throw new Error(`CurseForge versions not implemented`);
}
async function installMod(projectId, versionId, gamePath, source = 'modrinth') {
const versions = await getModVersions(projectId, undefined, undefined, source);
const targetVersion = versionId
? versions.find((v) => v.id === versionId)
: versions[0];
if (!targetVersion)
throw new Error('No version found');
const primaryFile = targetVersion.files.find((f) => f.primary) || targetVersion.files[0];
if (!primaryFile)
throw new Error('No file found');
// Download the mod file
const modsDir = path.join(gamePath, 'mods');
if (!fs.existsSync(modsDir)) {
fs.mkdirSync(modsDir, { recursive: true });
}
const filePath = path.join(modsDir, primaryFile.filename);
const response = await net.fetch(primaryFile.url);
if (!response.ok)
throw new Error(`Download failed: ${response.status}`);
const buffer = Buffer.from(await response.arrayBuffer());
fs.writeFileSync(filePath, buffer);
return {
projectId,
versionId: targetVersion.id,
filename: primaryFile.filename,
path: filePath,
};
}
+90 -28
View File
@@ -20,6 +20,19 @@ interface ModSearchResult {
source: 'modrinth' | 'curseforge';
}
interface ModSearchResponse {
hits: ModSearchResult[];
total: number;
limit: number;
offset: number;
}
interface ModCategory {
name: string;
label: string;
projectType: string;
}
interface ModVersionResult {
id: string;
name: string;
@@ -38,17 +51,21 @@ async function searchModrinth(
query?: string,
gameVersion?: string,
loader?: string,
category?: string,
projectType: string = 'mod',
limit: number = 20,
offset: number = 0
): Promise<ModSearchResult[]> {
): Promise<ModSearchResponse> {
const facets: string[][] = [];
if (gameVersion) facets.push([`versions:${gameVersion}`]);
if (loader) facets.push([`categories:${loader}`]);
if (category) facets.push([`categories:${category}`]);
const params = new URLSearchParams({
query: query || '',
limit: String(limit),
offset: String(offset),
project_type: projectType,
});
if (facets.length > 0) {
@@ -58,30 +75,38 @@ async function searchModrinth(
const response = await net.fetch(`${MODRINTH_API}/search?${params.toString()}`);
if (!response.ok) throw new Error(`Modrinth search failed: ${response.status}`);
const data = await response.json() as { hits: Array<{
project_id: string;
slug: string;
title: string;
description: string;
downloads: number;
icon_url?: string;
categories?: string[];
versions?: string[];
client_side?: string;
server_side?: string;
}> };
const data = await response.json() as {
total_hits: number;
hits: Array<{
project_id: string;
slug: string;
title: string;
description: string;
downloads: number;
icon_url?: string;
categories?: string[];
versions?: string[];
client_side?: string;
server_side?: string;
}>;
};
return data.hits.map((hit) => ({
id: hit.project_id,
slug: hit.slug,
name: hit.title,
description: hit.description,
downloads: hit.downloads,
iconUrl: hit.icon_url,
categories: hit.categories,
versions: hit.versions,
source: 'modrinth' as const,
}));
return {
hits: data.hits.map((hit) => ({
id: hit.project_id,
slug: hit.slug,
name: hit.title,
description: hit.description,
downloads: hit.downloads,
iconUrl: hit.icon_url,
categories: hit.categories,
versions: hit.versions,
source: 'modrinth' as const,
})),
total: data.total_hits,
limit,
offset,
};
}
async function searchCurseForge(
@@ -90,25 +115,62 @@ async function searchCurseForge(
loader?: string,
limit: number = 20,
offset: number = 0
): Promise<ModSearchResult[]> {
): Promise<ModSearchResponse> {
// CurseForge requires API key - return empty for now
return [];
return { hits: [], total: 0, limit, offset };
}
export async function searchMods(
query?: string,
gameVersion?: string,
loader?: string,
category?: string,
projectType: string = 'mod',
limit?: number,
offset?: number,
source: 'modrinth' | 'curseforge' = 'modrinth'
): Promise<ModSearchResult[]> {
): Promise<ModSearchResponse> {
if (source === 'modrinth') {
return searchModrinth(query, gameVersion, loader, limit, offset);
return searchModrinth(query, gameVersion, loader, category, projectType, limit, offset);
}
return searchCurseForge(query, gameVersion, loader, limit, offset);
}
export async function getCategories(projectType: string = 'mod'): Promise<ModCategory[]> {
const response = await net.fetch(`${MODRINTH_API}/tag/category`);
if (!response.ok) throw new Error(`Modrinth categories failed: ${response.status}`);
const data = await response.json() as Array<{
name: string;
project_type: string;
header: string;
}>;
// 只返回指定类型分类,label 优先取 header(国际化标题),无则用 name
return data
.filter((c) => c.project_type === projectType)
.map((c) => ({ name: c.name, label: c.header || c.name, projectType: c.project_type }));
}
export async function getGameVersions(): Promise<string[]> {
const response = await net.fetch(`${MODRINTH_API}/tag/game_version`);
if (!response.ok) throw new Error(`Modrinth game versions failed: ${response.status}`);
const data = await response.json() as string[];
// 只保留正式版(1.x 且非预览版),并按版本号倒序
return data
.filter((v) => /^\d+\.\d+(\.\d+)?$/.test(v))
.sort((a, b) => {
const pa = a.split('.').map(Number);
const pb = b.split('.').map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const diff = (pb[i] || 0) - (pa[i] || 0);
if (diff !== 0) return diff;
}
return 0;
});
}
export async function getModDetail(
projectId: string,
source: 'modrinth' | 'curseforge' = 'modrinth'
-38
View File
@@ -1,38 +0,0 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.registerAuthHandlers = registerAuthHandlers;
const electron_1 = __importDefault(require("electron"));
const { ipcMain } = electron_1.default;
const auth_1 = require("../auth");
function registerAuthHandlers() {
ipcMain.handle('auth:get', () => {
try {
const auth = (0, auth_1.readAuth)();
return { success: true, data: auth, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('auth:save', (_event, auth) => {
try {
(0, auth_1.writeAuth)(auth);
return { success: true, data: null, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('auth:delete', () => {
try {
(0, auth_1.deleteAuth)();
return { success: true, data: null, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
}
-128
View File
@@ -1,128 +0,0 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.registerBackgroundHandlers = registerBackgroundHandlers;
const electron_1 = __importDefault(require("electron"));
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const config_1 = require("../config");
const { ipcMain } = electron_1.default;
function registerBackgroundHandlers() {
ipcMain.handle('background:set-image', async (_event, payload) => {
try {
const config = (0, config_1.loadConfig)();
config.background.bgType = 'image';
config.background.image = payload.url;
if (payload.blur !== undefined)
config.background.blur = payload.blur;
if (payload.opacity !== undefined)
config.background.opacity = payload.opacity;
(0, config_1.saveConfig)(config);
return { success: true, data: config.background, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('background:set-color', async (_event, payload) => {
try {
const config = (0, config_1.loadConfig)();
config.background.bgType = 'color';
config.background.image = payload.color;
(0, config_1.saveConfig)(config);
return { success: true, data: config.background, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('background:set-blur', async (_event, payload) => {
try {
const config = (0, config_1.loadConfig)();
config.background.blur = payload.blur;
(0, config_1.saveConfig)(config);
return { success: true, data: config.background, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('background:set-opacity', async (_event, payload) => {
try {
const config = (0, config_1.loadConfig)();
config.background.opacity = payload.opacity;
(0, config_1.saveConfig)(config);
return { success: true, data: config.background, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('background:set-animation', async (_event, payload) => {
try {
// Animation config is not persisted in current design, return defaults
return { success: true, data: { bgType: 'image', image: '/background.png', blur: 0, opacity: 100 }, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('background:get', async () => {
try {
const config = (0, config_1.loadConfig)();
return { success: true, data: config.background, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('background:set-theme', async (_event, payload) => {
try {
const config = (0, config_1.loadConfig)();
config.theme.darkMode = payload.theme;
(0, config_1.saveConfig)(config);
return { success: true, data: config.background, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('background:reset', async () => {
try {
const config = (0, config_1.loadConfig)();
config.background = { bgType: 'image', image: '/background.png', blur: 0, opacity: 100 };
(0, config_1.saveConfig)(config);
return { success: true, data: config.background, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
// Copy file to userData and return the destination path
ipcMain.handle('background:copyToUserData', async (_event, srcPath, ext) => {
try {
const userDataPath = electron_1.default.app.getPath('userData');
const destPath = path_1.default.join(userDataPath, `background-custom${ext}`);
fs_1.default.copyFileSync(srcPath, destPath);
return destPath;
}
catch {
return null;
}
});
// Get cached background file path from userData
ipcMain.handle('background:getCachedPath', async () => {
try {
const userDataPath = electron_1.default.app.getPath('userData');
const files = fs_1.default.readdirSync(userDataPath).filter(f => f.startsWith('background-custom'));
if (files.length === 0)
return null;
return path_1.default.join(userDataPath, files[0]);
}
catch {
return null;
}
});
}
-77
View File
@@ -1,77 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.registerConfigHandlers = registerConfigHandlers;
const electron_1 = __importDefault(require("electron"));
const { ipcMain, app } = electron_1.default;
const fs = __importStar(require("fs"));
const config_1 = require("../config");
function registerConfigHandlers() {
ipcMain.handle('config:get', () => {
try {
const config = (0, config_1.loadConfig)();
return { success: true, data: config, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('config:save', (_event, config) => {
try {
(0, config_1.saveConfig)(config);
return { success: true, data: null, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('config:reset', () => {
try {
const filePath = (0, config_1.configPath)();
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
app.relaunch();
app.exit(0);
return { success: true, data: null, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
}
-206
View File
@@ -1,206 +0,0 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.setupCrashListeners = setupCrashListeners;
exports.registerCrashHandlers = registerCrashHandlers;
exports.testCrashDialog = testCrashDialog;
const electron_1 = __importDefault(require("electron"));
const path_1 = __importDefault(require("path"));
const fs_1 = __importDefault(require("fs"));
const crash_logger_1 = require("../core/crash-logger");
const { app, ipcMain, BrowserWindow } = electron_1.default;
const isDev = !app.isPackaged;
let crashWin = null;
function getIconPath() {
return path_1.default.join(__dirname, '../../build/icon.ico');
}
function createCrashWindow() {
const iconPath = getIconPath();
const win = new BrowserWindow({
width: 600,
height: 460,
minWidth: 500,
maxWidth: 700,
minHeight: 460,
maxHeight: 460,
frame: false,
transparent: false,
resizable: false,
show: false,
icon: iconPath,
webPreferences: {
preload: path_1.default.join(__dirname, '../preload-crash.js'),
nodeIntegration: false,
contextIsolation: true,
sandbox: false,
},
});
if (isDev) {
win.loadURL('http://localhost:1420/crash.html');
}
else {
win.loadFile(path_1.default.join(__dirname, '../../dist/crash.html'));
}
return win;
}
function sendToCrashWindow(data) {
if (!crashWin || crashWin.isDestroyed()) {
crashWin = createCrashWindow();
}
const send = () => {
crashWin?.show();
crashWin?.focus();
crashWin?.webContents.send('crash:show', {
type: data.type,
message: data.message,
timestamp: data.timestamp,
});
};
if (crashWin.webContents.isLoading()) {
crashWin.webContents.once('did-finish-load', send);
}
else {
send();
}
}
function setupCrashListeners(mainWindow) {
mainWindow.webContents.on('render-process-gone', (_event, details) => {
const entry = {
timestamp: new Date().toISOString(),
type: 'renderer-gone',
message: `渲染进程崩溃: ${details.reason} (退出码: ${details.exitCode})`,
details: details,
};
(0, crash_logger_1.writeCrashLog)(entry);
sendToCrashWindow(entry);
});
mainWindow.on('unresponsive', () => {
const entry = {
timestamp: new Date().toISOString(),
type: 'unresponsive',
message: '渲染进程无响应',
};
(0, crash_logger_1.writeCrashLog)(entry);
sendToCrashWindow(entry);
});
// Inject devtools crash tools when devtools opens
if (isDev) {
mainWindow.webContents.on('devtools-opened', () => {
const js = `
(function() {
if (window.__crashToolsLoaded) return;
window.__crashToolsLoaded = true;
console.log('%c[崩溃工具] 已加载', 'color: #f59e0b; font-weight: bold; font-size: 14px;');
console.log('%c可用命令:', 'color: #3b82f6; font-weight: bold;');
console.log('%c crash.simulate() %c— 模拟渲染进程崩溃', 'color: #ef4444; font-weight: bold;', 'color: inherit;');
console.log('%c crash.testDialog() %c— 测试崩溃弹窗', 'color: #ef4444; font-weight: bold;', 'color: inherit;');
console.log('%c crash.readLog() %c— 读取崩溃日志', 'color: #ef4444; font-weight: bold;', 'color: inherit;');
console.log('%c crash.factoryReset()%c— 强还原配置', 'color: #ef4444; font-weight: bold;', 'color: inherit;');
console.log('%c crash.restart() %c— 重启应用', 'color: #ef4444; font-weight: bold;', 'color: inherit;');
console.log('');
window.crash = {
simulate: function() { window.electronAPI?.simulateCrash(); },
testDialog: function() { window.electronAPI?.testCrashDialog(); },
readLog: function() { return window.electronAPI?.invoke('crash:readLog'); },
factoryReset: function() { return window.electronAPI?.invoke('crash:factoryReset'); },
restart: function() { return window.electronAPI?.invoke('crash:restart'); },
};
})();
`;
mainWindow.webContents.executeJavaScript(js);
});
}
}
function registerCrashHandlers() {
// Close crash window
ipcMain.handle('crash:closeWindow', () => {
if (crashWin && !crashWin.isDestroyed()) {
crashWin.close();
crashWin = null;
}
});
// Read crash log
ipcMain.handle('crash:readLog', () => {
return (0, crash_logger_1.readCrashLog)();
});
// Simulate crash — forcefully crash the renderer (devtools only)
ipcMain.handle('crash:simulate', (_event) => {
const senderWebContents = electron_1.default.webContents.getAllWebContents().find((wc) => wc.id === _event.sender.id);
if (senderWebContents) {
senderWebContents.forcefullyCrashRenderer();
}
});
// Test crash dialog — show crash window without actually crashing
ipcMain.handle('crash:testDialog', () => {
testCrashDialog();
});
// Factory reset
ipcMain.handle('crash:factoryReset', () => {
const dataPath = app.isPackaged
? path_1.default.dirname(app.getPath('exe'))
: path_1.default.join(__dirname, '../..');
// Delete config
try {
const configPath = path_1.default.join(dataPath, 'Koring.yml');
if (fs_1.default.existsSync(configPath))
fs_1.default.unlinkSync(configPath);
}
catch { }
// Delete auth
try {
const authPath = path_1.default.join(dataPath, 'koring-auth.json');
if (fs_1.default.existsSync(authPath))
fs_1.default.unlinkSync(authPath);
}
catch { }
// Delete background cache in userData
try {
const bgPath = path_1.default.join(app.getPath('userData'), 'background.png');
if (fs_1.default.existsSync(bgPath))
fs_1.default.unlinkSync(bgPath);
}
catch { }
// Delete crash log
(0, crash_logger_1.clearCrashLog)();
return { success: true };
});
// Restart app
ipcMain.handle('crash:restart', () => {
app.relaunch();
app.exit(0);
});
// Main process error handlers
process.on('uncaughtException', (error) => {
const entry = {
timestamp: new Date().toISOString(),
type: 'uncaught-exception',
message: error.message,
stack: error.stack,
};
(0, crash_logger_1.writeCrashLog)(entry);
sendToCrashWindow(entry);
});
process.on('unhandledRejection', (reason) => {
const entry = {
timestamp: new Date().toISOString(),
type: 'unhandled-rejection',
message: String(reason),
stack: reason instanceof Error ? reason.stack : undefined,
};
(0, crash_logger_1.writeCrashLog)(entry);
sendToCrashWindow(entry);
});
}
// Called from devtools to test crash dialog
function testCrashDialog() {
const entry = {
timestamp: new Date().toISOString(),
type: 'test',
message: '这是一个测试崩溃弹窗',
};
sendToCrashWindow(entry);
}
-74
View File
@@ -1,74 +0,0 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.registerInstallHandlers = registerInstallHandlers;
const electron_1 = __importDefault(require("electron"));
const installer_1 = require("../core/installer");
const { ipcMain } = electron_1.default;
function registerInstallHandlers(win) {
ipcMain.handle('install:version-list', async (_event, payload) => {
try {
const data = await (0, installer_1.getVersionList)(payload.type);
return { success: true, data, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('install:forge-version-list', async (_event, payload) => {
try {
const data = await (0, installer_1.getForgeVersions)(payload.mcVersion);
return { success: true, data, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('install:fabric-version-list', async (_event, payload) => {
try {
const data = await (0, installer_1.getFabricVersions)(payload.mcVersion);
return { success: true, data, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('install:minecraft', async (_event, payload) => {
try {
const requestId = `install-mc-${Date.now()}`;
(0, installer_1.installMinecraft)(payload.version, payload.gamePath, payload.javaPath, payload.downloadThreads, {
onProgress: (progress) => {
win.mainWindow?.webContents.send('install:progress', { requestId, ...progress });
},
}).then((result) => {
win.mainWindow?.webContents.send('install:complete', { requestId, ...result });
}).catch((err) => {
win.mainWindow?.webContents.send('install:error', { requestId, error: String(err) });
});
return { success: true, data: { requestId }, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('install:mod-loader', async (_event, payload) => {
try {
const requestId = `install-loader-${Date.now()}`;
(0, installer_1.installModLoader)(payload.mcVersion, payload.gamePath, payload.loaderType, payload.loaderVersion, payload.javaPath, {
onProgress: (progress) => {
win.mainWindow?.webContents.send('install:progress', { requestId, ...progress });
},
}).then((result) => {
win.mainWindow?.webContents.send('install:complete', { requestId, ...result });
}).catch((err) => {
win.mainWindow?.webContents.send('install:error', { requestId, error: String(err) });
});
return { success: true, data: { requestId }, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
}
-151
View File
@@ -1,151 +0,0 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.registerInstanceHandlers = registerInstanceHandlers;
const electron_1 = __importDefault(require("electron"));
const instance_1 = require("../core/instance");
const { ipcMain } = electron_1.default;
function registerInstanceHandlers(win) {
ipcMain.handle('instance:create', async (_event, payload) => {
try {
const data = await (0, instance_1.createInstance)(payload.name, payload.gamePath, payload.runtime, {
author: payload.author,
description: payload.description,
java: payload.java,
minMemory: payload.minMemory,
maxMemory: payload.maxMemory,
vmOptions: payload.vmOptions,
mcOptions: payload.mcOptions,
});
return { success: true, data, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('instance:list', async (_event, payload) => {
try {
const data = await (0, instance_1.listInstances)(payload.gamePath);
return { success: true, data, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('instance:info', async (_event, payload) => {
try {
const data = await (0, instance_1.getInstanceInfo)(payload.name, payload.gamePath);
return { success: true, data, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('instance:delete', async (_event, payload) => {
try {
const data = await (0, instance_1.deleteInstance)(payload.name, payload.gamePath);
return { success: true, data, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('instance:update', async (_event, payload) => {
try {
const data = await (0, instance_1.updateInstance)(payload.name, payload.gamePath, payload.patch);
return { success: true, data, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('instance:install', async (_event, payload) => {
try {
const requestId = `install-${Date.now()}`;
(0, instance_1.installInstanceGame)(payload.name, payload.gamePath, {
onProgress: (progress) => {
win.mainWindow?.webContents.send('instance:progress', { requestId, ...progress });
},
}).then((data) => {
win.mainWindow?.webContents.send('instance:install-complete', { requestId, data });
}).catch((err) => {
win.mainWindow?.webContents.send('instance:install-error', { requestId, error: String(err) });
});
return { success: true, data: { requestId }, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('instance:launch', async (_event, payload) => {
try {
const requestId = `launch-${Date.now()}`;
(0, instance_1.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) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('instance:diagnose', async (_event, payload) => {
try {
const data = await (0, instance_1.diagnoseInstance)(payload.name, payload.gamePath);
return { success: true, data, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
// Version list APIs
ipcMain.handle('instance:version-list', async (_event, payload) => {
try {
const data = await (0, instance_1.getMinecraftVersionList)(payload.type);
return { success: true, data, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('instance:forge-version-list', async (_event, payload) => {
try {
const data = await (0, instance_1.getForgeVersionList)(payload.mcVersion);
return { success: true, data, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('instance:fabric-version-list', async (_event, payload) => {
try {
const data = await (0, instance_1.getFabricVersionList)(payload.mcVersion);
return { success: true, data, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('instance:quilt-version-list', async (_event, payload) => {
try {
const data = await (0, instance_1.getQuiltVersionList)(payload.mcVersion);
return { success: true, data, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
}
+11
View File
@@ -12,6 +12,7 @@ import {
getForgeVersionList,
getFabricVersionList,
getQuiltVersionList,
scanGameDirectories,
type InstanceRuntime,
type InstanceConfig,
} from '../core/instance';
@@ -194,4 +195,14 @@ export function registerInstanceHandlers(win: WinRef) {
return { success: false, data: null, error: String(e) };
}
});
// 扫描游戏目录中的已安装版本
ipcMain.handle('instance:scan-dir', async (_event, payload: { gamePath: string }) => {
try {
const versions = scanGameDirectories(payload.gamePath);
return { success: true, data: { versions }, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
}
-113
View File
@@ -1,113 +0,0 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.registerKoringAuthHandlers = registerKoringAuthHandlers;
const electron_1 = __importDefault(require("electron"));
const { ipcMain } = electron_1.default;
const koring_auth_1 = require("../core/koring-auth");
const config_1 = require("../config");
function registerKoringAuthHandlers() {
ipcMain.handle('koring-auth:request-device-code', async () => {
try {
const result = await (0, koring_auth_1.requestDeviceCode)();
return { success: true, data: result, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('koring-auth:poll-token', async (_event, deviceCode) => {
try {
const result = await (0, koring_auth_1.pollForTokenOnce)(deviceCode);
const user = (0, koring_auth_1.saveKoringAuth)(result);
// 同时写入配置文件
try {
const config = (0, config_1.loadConfig)();
config.koringUser = {
sub: user.sub,
name: user.name,
username: user.username,
email: user.email,
picture: user.picture,
accessToken: result.access_token,
refreshToken: result.refresh_token,
};
(0, config_1.saveConfig)(config);
}
catch (e) {
console.error('[koring-auth] failed to save user to config:', e);
}
return { success: true, data: { user }, error: null };
}
catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return { success: false, data: null, error: msg };
}
});
ipcMain.handle('koring-auth:refresh', async () => {
try {
const stored = (0, koring_auth_1.readKoringAuth)();
if (!stored?.refresh_token)
throw new Error('No refresh token');
const result = await (0, koring_auth_1.refreshAccessToken)(stored.refresh_token);
const user = (0, koring_auth_1.saveKoringAuth)(result);
// 同步到配置文件
try {
const config = (0, config_1.loadConfig)();
config.koringUser = {
sub: user.sub,
name: user.name,
username: user.username,
email: user.email,
picture: user.picture,
accessToken: result.access_token,
refreshToken: result.refresh_token,
};
(0, config_1.saveConfig)(config);
}
catch { }
return { success: true, data: { user }, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('koring-auth:get-user', () => {
try {
const stored = (0, koring_auth_1.readKoringAuth)();
// 也从配置文件读取
if (!stored?.user?.sub) {
try {
const config = (0, config_1.loadConfig)();
const ku = config.koringUser;
if (ku?.sub) {
return { success: true, data: { user: ku, access_token: '', refresh_token: '', id_token: '', expires_at: 0 }, error: null };
}
}
catch { }
}
return { success: true, data: stored, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('koring-auth:logout', () => {
try {
(0, koring_auth_1.deleteKoringAuth)();
// 清除配置文件中的用户数据
try {
const config = (0, config_1.loadConfig)();
delete config.koringUser;
(0, config_1.saveConfig)(config);
}
catch { }
return { success: true, data: null, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
}
-45
View File
@@ -1,45 +0,0 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.registerLaunchHandlers = registerLaunchHandlers;
const electron_1 = __importDefault(require("electron"));
const launcher_1 = require("../core/launcher");
const { ipcMain } = electron_1.default;
function registerLaunchHandlers(win) {
ipcMain.handle('launch:launch', async (_event, payload) => {
try {
const requestId = `launch-${Date.now()}`;
const result = await (0, launcher_1.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,
onEvent: (event) => {
win.mainWindow?.webContents.send('launch:event', { requestId, ...event });
},
});
return { success: true, data: { ...result, requestId }, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('launch:diagnose', async (_event, payload) => {
try {
const data = await (0, launcher_1.diagnoseVersion)(payload.gamePath, payload.version);
return { success: true, data, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
}
-47
View File
@@ -1,47 +0,0 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.registerModsHandlers = registerModsHandlers;
const electron_1 = __importDefault(require("electron"));
const modrinth_1 = require("../core/modrinth");
const { ipcMain } = electron_1.default;
function registerModsHandlers() {
ipcMain.handle('mods:search', async (_event, payload) => {
try {
const data = await (0, modrinth_1.searchMods)(payload.query, payload.gameVersion, payload.loader, payload.limit, payload.offset, payload.source);
return { success: true, data, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('mods:detail', async (_event, payload) => {
try {
const data = await (0, modrinth_1.getModDetail)(payload.projectId, payload.source);
return { success: true, data, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('mods:versions', async (_event, payload) => {
try {
const data = await (0, modrinth_1.getModVersions)(payload.projectId, payload.gameVersion, payload.loader, payload.source);
return { success: true, data, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('mods:install', async (_event, payload) => {
try {
const data = await (0, modrinth_1.installMod)(payload.projectId, payload.versionId, payload.gamePath, payload.source);
return { success: true, data, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
}
+23 -1
View File
@@ -1,5 +1,5 @@
import electron from 'electron';
import { searchMods, getModDetail, getModVersions, installMod } from '../core/modrinth';
import { searchMods, getModDetail, getModVersions, installMod, getCategories, getGameVersions } from '../core/modrinth';
const { ipcMain } = electron;
@@ -8,6 +8,8 @@ export function registerModsHandlers() {
query?: string;
gameVersion?: string;
loader?: string;
category?: string;
projectType?: string;
limit?: number;
offset?: number;
source: string;
@@ -17,6 +19,8 @@ export function registerModsHandlers() {
payload.query,
payload.gameVersion,
payload.loader,
payload.category,
payload.projectType,
payload.limit,
payload.offset,
payload.source as 'modrinth' | 'curseforge'
@@ -27,6 +31,24 @@ export function registerModsHandlers() {
}
});
ipcMain.handle('mods:categories', async (_event, payload: { projectType?: string }) => {
try {
const data = await getCategories(payload.projectType);
return { success: true, data, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('mods:game-versions', async () => {
try {
const data = await getGameVersions();
return { success: true, data, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('mods:detail', async (_event, payload: { projectId: string; source: string }) => {
try {
const data = await getModDetail(
-70
View File
@@ -1,70 +0,0 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.registerSystemHandlers = registerSystemHandlers;
const electron_1 = __importDefault(require("electron"));
const child_process_1 = require("child_process");
const { ipcMain, app } = electron_1.default;
function getBiosId() {
if (process.platform !== 'win32')
return 'N/A (non-Windows)';
try {
const output = (0, child_process_1.execSync)('powershell -NoProfile -Command "(Get-CimInstance Win32_BIOS).SerialNumber"', {
encoding: 'utf-8',
timeout: 5000,
});
return output.trim() || 'Unknown';
}
catch {
return 'Unknown';
}
}
function getOsVersion() {
if (process.platform !== 'win32')
return 'N/A (non-Windows)';
try {
const output = (0, child_process_1.execSync)('powershell -NoProfile -Command "[System.Environment]::OSVersion.VersionString"', {
encoding: 'utf-8',
timeout: 5000,
});
return output.trim() || 'Unknown';
}
catch {
return 'Unknown';
}
}
function getOsName() {
if (process.platform !== 'win32')
return 'N/A (non-Windows)';
try {
const output = (0, child_process_1.execSync)('powershell -NoProfile -Command "(Get-CimInstance Win32_OperatingSystem).Caption"', {
encoding: 'utf-8',
timeout: 5000,
});
return output.trim() || 'Unknown';
}
catch {
return 'Unknown';
}
}
function registerSystemHandlers() {
ipcMain.handle('system:info', () => {
try {
return {
success: true,
data: {
app_version: app.getVersion(),
bios_id: getBiosId(),
os_version: getOsVersion(),
os_name: getOsName(),
},
error: null,
};
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
}
+11 -1
View File
@@ -2,7 +2,7 @@ import electron from 'electron';
import { execSync } from 'child_process';
import * as os from 'os';
const { ipcMain, app } = electron;
const { ipcMain, app, shell } = electron;
function getBiosId(): string {
if (process.platform !== 'win32') return 'N/A (non-Windows)';
@@ -60,4 +60,14 @@ export function registerSystemHandlers() {
return { success: false, data: null, error: String(e) };
}
});
// 在系统文件管理器中打开指定路径(用于"打开游戏目录"等操作)
ipcMain.handle('system:open-path', async (_event, payload: { path: string }) => {
try {
const error = await shell.openPath(payload.path);
return { success: !error, data: { error }, error: error || null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
}
-61
View File
@@ -1,61 +0,0 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.registerTaskHandlers = registerTaskHandlers;
const electron_1 = __importDefault(require("electron"));
const { ipcMain } = electron_1.default;
const runningTasks = new Map();
function registerTaskHandlers(win) {
ipcMain.handle('task:start', async (_event, payload) => {
try {
const controller = new AbortController();
runningTasks.set(payload.taskId, controller);
win.mainWindow?.webContents.send('task:started', {
taskId: payload.taskId,
xmclPath: payload.executorName,
});
// Simulate task execution
const steps = 20;
for (let i = 0; i <= steps; i++) {
if (controller.signal.aborted) {
win.mainWindow?.webContents.send('task:failed', {
taskId: payload.taskId,
error: 'Task cancelled',
});
runningTasks.delete(payload.taskId);
return { success: true, data: null, error: null };
}
win.mainWindow?.webContents.send('task:progress', {
taskId: payload.taskId,
current: i,
total: steps,
stage: `步骤 ${i}/${steps}`,
});
await new Promise((r) => setTimeout(r, 150));
}
runningTasks.delete(payload.taskId);
win.mainWindow?.webContents.send('task:completed', {
taskId: payload.taskId,
});
return { success: true, data: null, error: null };
}
catch (e) {
runningTasks.delete(payload.taskId);
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('task:cancel', async (_event, payload) => {
try {
const controller = runningTasks.get(payload.taskId);
if (controller) {
controller.abort();
}
return { success: true, data: null, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
}
+55 -20
View File
@@ -1,4 +1,5 @@
import electron from 'electron';
import { createInstance, installInstanceGame, type InstanceRuntime } from '../core/instance';
const { ipcMain } = electron;
@@ -8,6 +9,14 @@ interface WinRef {
mainWindow: electron.BrowserWindow | null;
}
// 在任务中发送进度事件到渲染进程
function emitProgress(win: WinRef, taskId: string, current: number, total: number, stage: string) {
win.mainWindow?.webContents.send('task:progress', { taskId, current, total, stage, event: 'task:progress' });
}
function emitLog(win: WinRef, taskId: string, level: string, message: string) {
win.mainWindow?.webContents.send('task:progress', { taskId, event: 'task:log', level, message });
}
export function registerTaskHandlers(win: WinRef) {
ipcMain.handle('task:start', async (_event, payload: {
taskId: string;
@@ -21,42 +30,68 @@ export function registerTaskHandlers(win: WinRef) {
const controller = new AbortController();
runningTasks.set(payload.taskId, controller);
win.mainWindow?.webContents.send('task:started', {
win.mainWindow?.webContents.send('task:progress', {
taskId: payload.taskId,
event: 'task:started',
xmclPath: payload.executorName,
});
// Simulate task execution
const steps = 20;
for (let i = 0; i <= steps; i++) {
if (controller.signal.aborted) {
win.mainWindow?.webContents.send('task:failed', {
taskId: payload.taskId,
error: 'Task cancelled',
});
runningTasks.delete(payload.taskId);
return { success: true, data: null, error: null };
}
// 根据 executorName 选择处理逻辑
if (payload.executorName === 'install') {
// 使用 @xmcl/core + @xmcl/installer 安装 Minecraft 实例
const params = payload.params ?? {};
const name = String(params.name ?? `mc-${Date.now()}`);
const gamePath = String(params.gamePath ?? '.minecraft');
const runtime = (params.runtime ?? { minecraft: '1.21.4' }) as InstanceRuntime;
const description = String(params.description ?? '');
win.mainWindow?.webContents.send('task:progress', {
taskId: payload.taskId,
current: i,
total: steps,
stage: `步骤 ${i}/${steps}`,
emitProgress(win, payload.taskId, 0, 100, '创建实例目录');
emitLog(win, payload.taskId, 'info', `正在创建实例: ${name}`);
// Step 1: 创建实例(写入 instance.json
await createInstance(name, gamePath, runtime, { description });
emitProgress(win, payload.taskId, 10, 100, '实例目录已创建');
// Step 2: 安装游戏文件(@xmcl/installer
emitLog(win, payload.taskId, 'info', `正在下载 Minecraft ${runtime.minecraft}...`);
await installInstanceGame(name, gamePath, {
onProgress: (progress) => {
if (controller.signal.aborted) return;
const mapped = Math.round(10 + (progress.current / Math.max(progress.total, 1)) * 80);
emitProgress(win, payload.taskId, mapped, 100, progress.stage || '安装中');
if (progress.message) emitLog(win, payload.taskId, 'info', progress.message);
},
});
await new Promise((r) => setTimeout(r, 150));
emitProgress(win, payload.taskId, 100, 100, '安装完成');
emitLog(win, payload.taskId, 'info', `实例「${name}」创建完成`);
} else {
// 默认模拟执行
const steps = 20;
for (let i = 0; i <= steps; i++) {
if (controller.signal.aborted) throw new Error('已取消');
emitProgress(win, payload.taskId, i, steps, `步骤 ${i}/${steps}`);
await new Promise((r) => setTimeout(r, 150));
}
emitLog(win, payload.taskId, 'info', '任务完成');
}
runningTasks.delete(payload.taskId);
win.mainWindow?.webContents.send('task:completed', {
win.mainWindow?.webContents.send('task:progress', {
taskId: payload.taskId,
event: 'task:completed',
});
return { success: true, data: null, error: null };
} catch (e: unknown) {
runningTasks.delete(payload.taskId);
return { success: false, data: null, error: String(e) };
const errMsg = e instanceof Error ? e.message : String(e);
win.mainWindow?.webContents.send('task:progress', {
taskId: payload.taskId,
event: 'task:failed',
error: errMsg,
});
return { success: false, data: null, error: errMsg };
}
});
-95
View File
@@ -1,95 +0,0 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.registerWindowHandlers = registerWindowHandlers;
const electron_1 = __importDefault(require("electron"));
const path_1 = __importDefault(require("path"));
const { ipcMain, dialog, shell } = electron_1.default;
const isDev = !electron_1.default.app.isPackaged;
const MIME_MAP = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
webp: 'image/webp',
gif: 'image/gif',
bmp: 'image/bmp',
svg: 'image/svg+xml',
};
function createSplashWindow() {
const splash = new electron_1.default.BrowserWindow({
width: 480,
height: 320,
transparent: true,
frame: false,
resizable: false,
skipTaskbar: true,
alwaysOnTop: true,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
},
});
if (isDev) {
splash.loadURL('http://localhost:1420/splash.html');
}
else {
splash.loadFile(path_1.default.join(__dirname, '../dist/splash.html'));
}
return splash;
}
function registerWindowHandlers(win) {
ipcMain.handle('window:minimize', () => {
win.mainWindow?.minimize();
});
ipcMain.handle('window:maximize', () => {
if (win.mainWindow?.isMaximized()) {
win.mainWindow.unmaximize();
}
else {
win.mainWindow?.maximize();
}
});
ipcMain.handle('window:close', () => {
win.mainWindow?.close();
});
ipcMain.handle('window:isMaximized', () => {
return win.mainWindow?.isMaximized() ?? false;
});
ipcMain.handle('window:getTheme', () => {
return win.mainWindow?.themeSource ?? null;
});
// Splash window management
ipcMain.handle('window:openSplash', () => {
if (win.splashWindow && !win.splashWindow.isDestroyed()) {
win.splashWindow.focus();
return { success: true };
}
win.splashWindow = createSplashWindow();
return { success: true };
});
ipcMain.handle('window:closeSplash', () => {
if (win.splashWindow && !win.splashWindow.isDestroyed()) {
win.splashWindow.close();
win.splashWindow = null;
}
return { success: true };
});
// File dialog — returns source path and extension for preload to handle
ipcMain.handle('dialog:openFile', async (_event, payload) => {
const result = await dialog.showOpenDialog(win.mainWindow, {
properties: ['openFile'],
filters: payload.filters,
});
if (result.canceled || result.filePaths.length === 0)
return null;
const srcPath = result.filePaths[0];
const ext = path_1.default.extname(srcPath).toLowerCase() || '.png';
return { srcPath, ext };
});
// Open external URL in system browser
ipcMain.handle('shell:openExternal', async (_event, url) => {
await shell.openExternal(url);
});
}
+9
View File
@@ -105,4 +105,13 @@ export function registerWindowHandlers(win: WinRef) {
ipcMain.handle('shell:openExternal', async (_event, url: string) => {
await shell.openExternal(url);
});
// Folder dialog — select a directory
ipcMain.handle('dialog:openFolder', async () => {
const result = await dialog.showOpenDialog(win.mainWindow!, {
properties: ['openDirectory'],
});
if (result.canceled || result.filePaths.length === 0) return null;
return { folderPath: result.filePaths[0] };
});
}
+13 -6
View File
@@ -53,10 +53,19 @@ function runStartupChecks(): { isFirstLaunch: boolean; config: ReturnType<typeof
return { isFirstLaunch, config };
}
// 根据调试/运行状态自动选择窗口图标:
// - 开发调试(未打包):直接使用 public/icons/dev/ 下的 dev 图标
// - 打包运行:electron-builder 构建时 switch-icon 已把对应模式图标复制到 build/
function getWindowIconPath(): string | undefined {
if (!app.isPackaged) {
return path.join(__dirname, '../public/icons/dev/icon.ico');
}
const packagedIcon = path.join(__dirname, '../build/icon.ico');
return fs.existsSync(packagedIcon) ? packagedIcon : undefined;
}
function createSplashWindow(): electron.BrowserWindow {
const iconPath = isDev
? path.join(__dirname, '../build/icon.ico')
: path.join(__dirname, '../build/icon.ico');
const iconPath = getWindowIconPath();
const splash = new electron.BrowserWindow({
width: 480,
@@ -83,9 +92,7 @@ function createSplashWindow(): electron.BrowserWindow {
}
function createMainWindow(): electron.BrowserWindow {
const iconPath = isDev
? path.join(__dirname, '../build/icon.ico')
: path.join(__dirname, '../build/icon.ico');
const iconPath = getWindowIconPath();
const main = new electron.BrowserWindow({
width: 1000,
+2 -8
View File
@@ -1,11 +1,11 @@
{
"name": "koring-launcher",
"private": true,
"version": "1.0.1",
"version": "1.1.2",
"description": "Koring Launcher - Minecraft launcher built with Electron + React",
"author": "Shenzhen Lingke Network Technology Co., Ltd.",
"license": "LL-1.0",
"main": "electron/main.js",
"main": "electron-dist/main.js",
"scripts": {
"dev:renderer": "vite --mode development",
"dev:main": "tsc -p tsconfig.electron.json && electron .",
@@ -56,12 +56,6 @@
"tw-animate-css": "^1.4.0",
"zustand": "^5.0.14"
},
"pnpm": {
"onlyBuiltDependencies": [
"electron",
"esbuild"
]
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.1",
"@types/js-yaml": "^4.0.9",
+4
View File
@@ -0,0 +1,4 @@
# 允许执行构建脚本的依赖(pnpm 10+ 默认禁止依赖运行 postinstall
allowBuilds:
electron: true
esbuild: true
+43
View File
@@ -0,0 +1,43 @@
; __ __ __ __ ______ __ __ ______ __ __ ______ ______
; /\ \ /\ \ /\ "-.\ \ /\ ___\ /\ \/ / /\ ___\ /\ "-.\ \ /\ ___\ /\__ _\
; \ \ \____ \ \ \ \ \ \-. \ \ \ \__ \ \ \ _"-. \ \ __\ \ \ \-. \ \ \ __\ \/_/\ \/
; \ \_____\ \ \_\ \ \_\"\_\ \ \_____\ \ \_\ \_\ \ \_____\ \ \_\"\_\ \ \_____\ \ \_\
; \/_____/ \/_/ \/_/ \/_/ \/_____/ \/_/\/_/ \/_____/ \/_/ \/_/ \/_____/ \/_/
;
; 所有权利归Lingke Network (china)所有 | dream_pep拥有其艺术改变权力
; 未经允许的情况下删除此版权头可能会受到民事指控
; 请勿在未经Lingke Network (china)允许的范围内修改代码并分发
; ============================================================
; NSIS 自定义包含脚本 - Koring Launcher
;
; 注意此文件通过 electron-builder 配置的nsis.include附加在其内部
; NSIS 模板的脚本前缀会先于 installer.nsi 模板被编译
; 因此这里定义的宏可以被 assistedInstaller.nsh 中的
; !ifmacrodef customWelcomePage 检测到
;
; 重要约定必须遵守否则会导致 makensis 编译失败或
; 安装程序在语言选择后直接崩溃退出
; 1) 禁止再定义 .onInit / .onInstSuccess / un.onInit /
; un.onUninstSuccess 这类全局回调函数它们已被
; electron-builder 模板占用
; 2) 禁止使用 !insertmacro LANG_STRING 之类只有
; electron-builder 内部才有的宏
; 3) 禁止在语言宏展开前使用 LANG_SIMPCHINESE 等符号常量
; 直接用数字 LCID205210281033更稳
; 4) 如非必要不要在 include 脚本中写未被模板实际引用
; LangString / Section / Function避免因语言包未
; 加载导致初始化阶段直接退出
; ============================================================
; ------------------------------------------------------------
; 自定义欢迎页宏
; electron-builder 的辅助安装向导oneClick: false默认没有安装
; 欢迎页只有完成页定义此宏后assistedInstaller.nsh
; 插入 MUI_PAGE_WELCOME使安装向导第一页显示欢迎页
; 并展示 MUI_WELCOMEFINISHPAGE_BITMAP installerSidebar 配置的
; build/installer-header.bmp 横幅图
; ------------------------------------------------------------
!macro customWelcomePage
!insertmacro MUI_PAGE_WELCOME
!macroend
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+105 -8
View File
@@ -1,4 +1,26 @@
const { cpSync, existsSync, mkdirSync } = require('fs');
// __ __ __ __ ______ __ __ ______ __ __ ______ ______
// /\ \ /\ \ /\ "-.\ \ /\ ___\ /\ \/ / /\ ___\ /\ "-.\ \ /\ ___\ /\__ _\
// \ \ \____ \ \ \ \ \ \-. \ \ \ \__ \ \ \ _"-. \ \ __\ \ \ \-. \ \ \ __\ \/_/\ \/
// \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ \ \_\ \_\ \ \_____\ \ \_\\"\_\ \ \_____\ \ \_\
// \/_____/ \/_/ \/_/ \/_/ \/_____/ \/_/\/_/ \/_____/ \/_/ \/_/ \/_____/ \/_/
//
// 所有权利归Lingke Network (china)所有 | dream_pep拥有其艺术改变权力
// 未经允许的情况下删除此版权头可能会受到民事指控
// 请勿在未经Lingke Network (china)允许的范围内修改代码并分发
/**
* switch-icon.js - 构建前资源切换脚本
*
* 作用
* 1. public/icons/<mode>/ 下的 icon.png / icon.ico 复制到 build/
* 2. public/installer-header.png横向横幅适配生成 164x314
* installerSidebar BMPNSIS 欢迎页左侧大图只支持 BMP
* 3. public/installer-custom.nsh 复制到 build/
* 4. 根据构建模式选择协议文件写入带 UTF-8 BOM license.txt
* NSIS LicenseData 需要 BOM 才能正确显示中文
*/
const { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } = require('fs');
const { execFileSync } = require('child_process');
const { join } = require('path');
const mode = process.argv[2];
@@ -12,22 +34,97 @@ if (!mode || !validModes.includes(mode)) {
const root = join(__dirname, '..');
const srcDir = join(root, 'public', 'icons', mode);
const buildDir = join(root, 'build');
const publicDir = join(root, 'public');
const png = join(srcDir, 'icon.png');
const ico = join(srcDir, 'icon.ico');
const installerHeader = join(publicDir, 'installer-header.png');
const nsisCustom = join(publicDir, 'installer-custom.nsh');
const licenseSrc = join(
publicDir,
mode === 'beta' ? 'protocol-beta.txt' : 'protocol-user.txt'
);
if (!existsSync(png)) {
console.error(`Icon not found: ${png}`);
process.exit(1);
// 必选文件存在性检查
const requiredFiles = [
{ path: png, label: 'icon.png' },
{ path: ico, label: 'icon.ico' },
{ path: installerHeader, label: 'installer-header.png' },
{ path: nsisCustom, label: 'installer-custom.nsh' },
{ path: licenseSrc, label: 'license 协议文件' },
];
for (const file of requiredFiles) {
if (!existsSync(file.path)) {
console.error(`${file.label} not found: ${file.path}`);
process.exit(1);
}
}
if (!existsSync(ico)) {
console.error(`Icon not found: ${ico}`);
process.exit(1);
/**
* 将横向横幅 PNG 适配生成 NSIS 欢迎页左侧大图 BMP164x31424 位白底
* 横幅等比缩放至宽度 164 并置于画布顶部居中剩余区域填充白色
* 通过 PowerShell + System.Drawing 实现无额外依赖
* @param {string} srcPng 源横幅 PNG 路径
* @param {string} outBmp 输出 BMP 路径
*/
function createSidebarBmp(srcPng, outBmp) {
// NSIS 欢迎/完成页左侧大图标准尺寸
const SIDEBAR_W = 164;
const SIDEBAR_H = 314;
// 路径中的单引号需转义(PowerShell 单引号字符串内用两个单引号表示一个)
const safeSrc = srcPng.replace(/'/g, "''");
const safeOut = outBmp.replace(/'/g, "''");
const script = `
Add-Type -AssemblyName System.Drawing
$src = [System.Drawing.Image]::FromFile('${safeSrc}')
# 强制 24 RGBNSIS 24 BMP 兼容性最好
$bmp = New-Object System.Drawing.Bitmap(${SIDEBAR_W}, ${SIDEBAR_H}, [System.Drawing.Imaging.PixelFormat]::Format24bppRgb)
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.Clear([System.Drawing.Color]::White)
# 横幅等比缩放至画布宽度置于顶部居中
$scale = ${SIDEBAR_W} / $src.Width
$drawH = [int][Math]::Round($src.Height * $scale)
$drawW = ${SIDEBAR_W}
$drawX = [int]((${SIDEBAR_W} - $drawW) / 2)
$drawY = 0
$g.DrawImage($src, $drawX, $drawY, $drawW, $drawH)
$bmp.Save('${safeOut}', [System.Drawing.Imaging.ImageFormat]::Bmp)
$g.Dispose()
$bmp.Dispose()
$src.Dispose()
`;
execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script]);
}
/**
* 读取协议文本并写入带 UTF-8 BOM license.txt
* NSIS LicenseData 只有带 BOM 才能按 UTF-8 解析否则中文乱码
* @param {string} src 源协议文件
* @param {string} out 输出 license.txt
*/
function writeLicenseWithBom(src, out) {
let content = readFileSync(src, 'utf8');
if (!content.startsWith('\uFEFF')) {
content = '\uFEFF' + content;
}
writeFileSync(out, content, 'utf8');
}
mkdirSync(buildDir, { recursive: true });
// 1. 图标
cpSync(png, join(buildDir, 'icon.png'), { overwrite: true });
cpSync(ico, join(buildDir, 'icon.ico'), { overwrite: true });
console.log(`[switch-icon] Mode: ${mode} → build/icon.png + build/icon.ico updated`);
// 2. 安装程序欢迎页左侧大图(installerSidebar,由横幅 PNG 适配生成)
createSidebarBmp(installerHeader, join(buildDir, 'installer-header.bmp'));
// 3. NSIS 自定义脚本
cpSync(nsisCustom, join(buildDir, 'installer-custom.nsh'), { overwrite: true });
// 4. 协议文件(带 UTF-8 BOM,防止中文乱码)
writeLicenseWithBom(licenseSrc, join(buildDir, 'license.txt'));
console.log(
`[switch-icon] Mode: ${mode} → build/icon.png + build/icon.ico + build/installer-header.bmp (sidebar 164x314) + build/installer-custom.nsh + build/license.txt updated`
);
+2
View File
@@ -31,6 +31,7 @@ import { OobeWelcome } from "./pages/oobe/step-welcome";
import { OobeVersion } from "./pages/oobe/step-version";
import { OobeBetaTest } from "./pages/oobe/step-beta-test";
import { OobeFinish } from "./pages/oobe/step-finish";
import { OobeLegal } from "./pages/oobe/step-legal";
import { OobeAboutInfo } from "./pages/oobe/about-info";
const pageMap = {
@@ -51,6 +52,7 @@ const pageMap = {
"oobe/beta-test": OobeBetaTest,
"oobe/finish": OobeFinish,
"oobe/about-info": OobeAboutInfo,
"oobe/legal": OobeLegal,
debug: Debug,
"debug-splash": SplashDebug,
"debug-display": DisplayDebug,
+2
View File
@@ -24,6 +24,8 @@ export interface GameConfig {
resourceDir: string;
savesDir: string;
instancesDir: string;
/** 已添加的游戏目录列表 */
gameDirs: string[];
}
export interface JavaConfig {
+33 -2
View File
@@ -118,8 +118,39 @@ export async function diagnoseInstance(
return ipcInvoke<{ healthy: boolean; issues: string[] }>('instance:diagnose', { name, gamePath });
}
export async function getMinecraftVersionList(type?: string) {
return ipcInvoke<{ versions: { id: string; type: string; url: string }[] }>('instance:version-list', { type });
// 版本信息(含发布时间和类型)
export interface VersionEntry {
id: string;
type: string;
url: string;
releaseTime: string;
}
export interface VersionListResult {
versions: VersionEntry[];
latest: { release: string; snapshot: string };
}
export async function getMinecraftVersionList(type?: string): Promise<VersionListResult> {
return ipcInvoke<VersionListResult>('instance:version-list', { type });
}
// 扫描到的已安装版本信息
export interface ScannedVersion {
id: string;
type: string;
releaseTime?: string;
hasJar: boolean;
hasJson: boolean;
}
export async function scanGameDir(gamePath: string): Promise<{ versions: ScannedVersion[] }> {
return ipcInvoke<{ versions: ScannedVersion[] }>('instance:scan-dir', { gamePath });
}
// 通过系统对话框选择文件夹
export async function selectFolder(): Promise<{ folderPath: string } | null> {
return ipcInvoke<{ folderPath: string } | null>('dialog:openFolder');
}
export async function getForgeVersionList(mcVersion?: string) {
+27 -2
View File
@@ -13,6 +13,19 @@ export interface ModSearchResult {
source: 'modrinth' | 'curseforge';
}
export interface ModSearchResponse {
hits: ModSearchResult[];
total: number;
limit: number;
offset: number;
}
export interface ModCategory {
name: string;
label: string;
projectType: string;
}
export interface ModVersionResult {
id: string;
name: string;
@@ -31,20 +44,32 @@ export async function searchMods(
query?: string,
gameVersion?: string,
loader?: string,
category?: string,
projectType: string = "mod",
limit?: number,
offset?: number,
source: 'modrinth' | 'curseforge' = 'modrinth'
): Promise<ModSearchResult[]> {
return ipcInvoke<ModSearchResult[]>('mods:search', {
): Promise<ModSearchResponse> {
return ipcInvoke<ModSearchResponse>('mods:search', {
query,
gameVersion,
loader,
category,
projectType,
limit,
offset,
source,
});
}
export async function getCategories(projectType: string = "mod"): Promise<ModCategory[]> {
return ipcInvoke<ModCategory[]>('mods:categories', { projectType });
}
export async function getGameVersions(): Promise<string[]> {
return ipcInvoke<string[]>('mods:game-versions');
}
export async function getModDetail(
projectId: string,
source: 'modrinth' | 'curseforge'
+5
View File
@@ -11,6 +11,11 @@ export async function getSystemInfo(): Promise<SystemInfo> {
return ipcInvoke<SystemInfo>('system:info');
}
// 在系统文件管理器中打开指定路径(用于"打开游戏目录"等操作)
export async function openPath(targetPath: string): Promise<{ success: boolean; error?: string }> {
return ipcInvoke<{ success: boolean; error?: string }>('system:open-path', { path: targetPath });
}
export interface LocaleInfo {
language: string;
region: string;
+14 -10
View File
@@ -5,7 +5,7 @@ import { relaunchApp } from "@/api/update";
import Silk from "@/components/silk/Silk";
import { Button } from "@heroui/react";
import clsx from "clsx";
import { Suspense } from "react";
import { type ReactNode, Suspense } from "react";
const modeColors: Record<string, string> = {
dev: "#F59E0B",
@@ -32,6 +32,8 @@ interface VersionCardProps {
overrideMode?: string | null;
overrideState?: UpdateState | null;
simple?: boolean;
/** OOBE 模式:不显示 Silk 动画,按钮仅展示样式不做实际操作 */
oobe?: boolean;
}
export function VersionCard({
@@ -39,6 +41,7 @@ export function VersionCard({
overrideMode,
overrideState,
simple = false,
oobe = false,
}: VersionCardProps) {
const color = modeColors[overrideMode ?? BUILD_MODE] ?? modeColors.run;
const gradient = modeGradients[overrideMode ?? BUILD_MODE] ?? modeGradients.run;
@@ -71,13 +74,7 @@ export function VersionCard({
</Suspense>
)}
{/* 磨砂遮罩 */}
<div
className="absolute inset-0"
style={{ background: "rgba(0,0,0,0.25)", backdropFilter: "blur(12px)" }}
/>
{/* 内容 */}
{/* 内容(移除颜色遮罩层,保留底部渐变与 Silk 动画渲染) */}
<div className="relative z-10 flex flex-col items-center justify-center px-6 py-10 gap-4">
<span className="text-[11px] font-bold tracking-wider text-white/70">{label}</span>
<img
@@ -88,7 +85,13 @@ export function VersionCard({
/>
<p className="text-sm text-white/70 font-medium">v{VERSION}</p>
<div className="flex items-center gap-2 mt-1">
{/* OOBE 模式:仅显示查看亮点按钮 */}
{oobe ? (
<div className="flex items-center gap-2 mt-1">
<Btn></Btn>
</div>
) : (
<div className="flex items-center gap-2 mt-1">
{effectiveState === "latest" && (
<>
<Btn onPress={check} isDisabled={checking}>
@@ -113,7 +116,8 @@ export function VersionCard({
<Btn onPress={relaunchApp}></Btn>
</>
)}
</div>
</div>
)}
</div>
</div>
);
+15 -10
View File
@@ -7,6 +7,8 @@ interface CreateDialogProps {
isOpen: boolean;
onClose: () => void;
onCreate: (name: string, runtime: InstanceRuntime, displayName?: string) => Promise<void>;
/** 预选的 Minecraft 版本(从资源中心"原版游戏"进入时传入) */
initialVersion?: string;
}
const loaderOptions = [
@@ -17,12 +19,12 @@ const loaderOptions = [
{ value: "neoforged", label: "NeoForge", desc: "Forge 的现代化分支,1.20+ 推荐" },
];
export function CreateDialog({ isOpen, onClose, onCreate }: CreateDialogProps) {
export function CreateDialog({ isOpen, onClose, onCreate, initialVersion }: CreateDialogProps) {
const [step, setStep] = useState(0);
const [name, setName] = useState("");
const [displayName, setDisplayName] = useState("");
const [loader, setLoader] = useState("vanilla");
const [mcVersion, setMcVersion] = useState("");
const [mcVersion, setMcVersion] = useState(initialVersion || "");
const [memory, setMemory] = useState(4);
const [creating, setCreating] = useState(false);
const [mcVersions, setMcVersions] = useState<string[]>([]);
@@ -30,20 +32,23 @@ export function CreateDialog({ isOpen, onClose, onCreate }: CreateDialogProps) {
const [error, setError] = useState("");
useEffect(() => {
if (isOpen) { loadMcVersions(); } else { resetForm(); }
if (isOpen) { setMcVersion(initialVersion || ""); loadMcVersions(); } else { resetForm(); }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
const resetForm = () => {
setStep(0); setName(""); setDisplayName("");
setLoader("vanilla"); setMcVersion(""); setMemory(4);
setLoader("vanilla"); setMcVersion(initialVersion || ""); setMemory(4);
setError(""); setCreating(false);
};
const loadMcVersions = async () => {
setLoadingVersions(true);
try {
// instance:version-list 返回 { versions: { id, type, url }[] }
const list = await getMinecraftVersionList();
const releases = list.filter((v: string) => /^1\.\d+/.test(v)).slice(0, 24);
const versionIds = (list.versions ?? []).map((v) => v.id);
const releases = versionIds.filter((v: string) => /^1\.\d+/.test(v)).slice(0, 24);
setMcVersions(releases.length ? releases : ["1.21.4","1.21","1.20.6","1.20.4","1.20.1","1.19.4","1.19.2","1.18.2","1.17.1","1.16.5"]);
if (!mcVersion && releases.length) setMcVersion(releases[0]);
} catch { setMcVersions(["1.21.4","1.21","1.20.1","1.19.2","1.18.2","1.16.5"]); if (!mcVersion) setMcVersion("1.21.4"); }
@@ -130,7 +135,7 @@ export function CreateDialog({ isOpen, onClose, onCreate }: CreateDialogProps) {
<div>
<p className="text-sm font-medium text-foreground mb-2"></p>
<RadioGroup value={loader} onValueChange={setLoader}>
<RadioGroup value={loader} onChange={(v) => setLoader(String(v))}>
{loaderOptions.map((opt) => (
<Radio key={opt.value} value={opt.value} className="py-1.5">
<Radio.Content>
@@ -169,15 +174,15 @@ export function CreateDialog({ isOpen, onClose, onCreate }: CreateDialogProps) {
{/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-border/30 dark:border-white/[0.04]">
<div>
{step > 0 && <Button variant="light" onPress={() => setStep(step - 1)}></Button>}
{step > 0 && <Button variant="ghost" onPress={() => setStep(step - 1)}></Button>}
</div>
<div className="flex items-center gap-2">
<Button variant="light" onPress={onClose}></Button>
<Button variant="ghost" onPress={onClose}></Button>
{step < 2 ? (
<Button onPress={() => setStep(step + 1)} isDisabled={step === 0 ? !name.trim() : !mcVersion}></Button>
) : (
<Button onPress={handleCreate} isDisabled={creating || !name.trim()}
startContent={creating ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : undefined}>
<Button onPress={handleCreate} isDisabled={creating || !name.trim()}>
{creating && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
{creating ? "创建中..." : "创建实例"}
</Button>
)}
+115
View File
@@ -0,0 +1,115 @@
// __ __ __ __ ______ __ __ ______ __ __ ______ ______
// /\ \ /\ \ /\ "-.\ \ /\ ___\ /\ \/ / /\ ___\ /\ "-.\ \ /\ ___\ /\__ _\
// \ \ \____ \ \ \ \ \ \-. \ \ \ \__ \ \ \ _"-. \ \ __\ \ \ \-. \ \ \ __\ \/_/\ \/
// \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ \ \_\ \_\ \ \_____\ \ \_\\"\_\ \ \_____\ \ \_\
// \/_____/ \/_/ \/_/ \/_/ \/_____/ \/_/\/_/ \/_____/ \/_/ \/_/ \/_____/ \/_/
//
// 所有权利归Lingke Network (china)所有 | dream_pep拥有其艺术改变权力
// 未经允许的情况下删除此版权头可能会受到民事指控
// 请勿在未经Lingke Network (china)允许的范围内修改代码并分发
import { useEffect, useState } from "react";
import { Button, Slider } from "@heroui/react";
import { X, Loader2, Save } from "lucide-react";
import { Input } from "@/components/ui/input";
import type { InstanceInfo } from "@/api/instance";
interface EditDialogProps {
instance: InstanceInfo | null;
isOpen: boolean;
onClose: () => void;
onSave: (patch: { description?: string; minMemory?: number; maxMemory?: number }) => Promise<void>;
}
export function EditDialog({ instance, isOpen, onClose, onSave }: EditDialogProps) {
const [description, setDescription] = useState("");
const [memory, setMemory] = useState(4);
const [saving, setSaving] = useState(false);
// 弹窗打开时回填当前值
useEffect(() => {
if (isOpen && instance) {
setDescription(instance.config.description || "");
setMemory(Math.round((instance.config.maxMemory || 4096) / 1024));
}
}, [isOpen, instance]);
if (!isOpen || !instance) return null;
const handleSave = async () => {
setSaving(true);
try {
await onSave({
description: description.trim(),
minMemory: Math.round(memory * 1024 * 0.25),
maxMemory: memory * 1024,
});
onClose();
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onClose} />
<div className="relative z-10 w-full max-w-md mx-4 bg-background border border-border/50 dark:border-white/[0.08] rounded-2xl shadow-2xl overflow-hidden">
{/* 头部 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border/30 dark:border-white/[0.04]">
<div className="flex items-center gap-2">
<Save className="w-4.5 h-4.5 text-primary" />
<h2 className="text-base font-semibold text-foreground"></h2>
</div>
<button onClick={onClose} className="p-1 rounded-md hover:bg-foreground/[0.06] transition-colors">
<X className="w-4 h-4 text-muted-foreground" />
</button>
</div>
{/* 主体 */}
<div className="px-6 py-5 space-y-5">
<div className="space-y-1.5">
<label className="text-[13px] font-medium text-foreground"></label>
<Input
className="h-9"
placeholder="实例显示名称"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<p className="text-[11px] text-muted-foreground/60">使 ID{instance.name}</p>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<p className="text-[13px] font-medium text-foreground"></p>
<span className="text-[13px] font-semibold text-foreground">{memory} GB</span>
</div>
<Slider
value={memory}
onChange={(v) => setMemory(typeof v === "number" ? v : v[0])}
minValue={1}
maxValue={16}
step={1}
>
<Slider.Track>
<Slider.Fill />
<Slider.Thumb />
</Slider.Track>
</Slider>
</div>
</div>
{/* 底部 */}
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border/30 dark:border-white/[0.04]">
<Button variant="ghost" onPress={onClose}></Button>
<Button
onPress={handleSave}
isDisabled={saving}
>
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
{saving ? "保存中..." : "保存"}
</Button>
</div>
</div>
</div>
);
}
-154
View File
@@ -1,154 +0,0 @@
import { Button } from "@heroui/react";
import {
Play,
Settings,
Clock,
Gamepad2,
} from "lucide-react";
import clsx from "clsx";
import type { InstanceInfo } from "@/api/instance";
function getLoader(runtime: InstanceInfo["config"]["runtime"]): string {
if (runtime.forge) return "forge";
if (runtime.fabricLoader) return "fabric";
if (runtime.quiltLoader) return "quilt";
if (runtime.neoForged) return "neoforged";
return "vanilla";
}
function getLoaderVersion(runtime: InstanceInfo["config"]["runtime"]): string {
return runtime.forge || runtime.fabricLoader || runtime.quiltLoader || runtime.neoForged || "";
}
const loaderBadgeColors: Record<string, string> = {
vanilla: "bg-green-500/10 text-green-600 dark:bg-green-500/15 dark:text-green-400 border-green-500/20",
forge: "bg-orange-500/10 text-orange-600 dark:bg-orange-500/15 dark:text-orange-400 border-orange-500/20",
fabric: "bg-sky-500/10 text-sky-600 dark:bg-sky-500/15 dark:text-sky-400 border-sky-500/20",
quilt: "bg-indigo-500/10 text-indigo-600 dark:bg-indigo-500/15 dark:text-indigo-400 border-indigo-500/20",
neoforged: "bg-red-500/10 text-red-600 dark:bg-red-500/15 dark:text-red-400 border-red-500/20",
};
const loaderLabels: Record<string, string> = {
vanilla: "原版",
forge: "Forge",
fabric: "Fabric",
quilt: "Quilt",
neoforged: "NeoForge",
};
const loaderColors: Record<string, string> = {
vanilla: "bg-green-400",
forge: "bg-orange-400",
fabric: "bg-sky-400",
quilt: "bg-indigo-400",
neoforged: "bg-red-400",
};
function formatPlaytime(ms: number): string {
if (!ms || ms < 60000) return "";
const hours = Math.floor(ms / 3600000);
const mins = Math.floor((ms % 3600000) / 60000);
if (hours > 0) return `${hours}h ${mins}m`;
return `${mins}m`;
}
function formatLastPlayed(ts: number): string {
if (!ts) return "";
const diff = Date.now() - ts;
if (diff < 3600000) return "刚刚";
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`;
if (diff < 604800000) return `${Math.floor(diff / 86400000)}天前`;
return new Date(ts).toLocaleDateString("zh-CN");
}
interface InstanceCardProps {
instance: InstanceInfo;
displayName?: string;
onPlay: (name: string) => void;
onSettings: (name: string) => void;
isLaunching: boolean;
}
export function InstanceCard({ instance, displayName, onPlay, onSettings, isLaunching }: InstanceCardProps) {
const { name, config, modCount, healthy } = instance;
const loader = getLoader(config.runtime);
const version = config.runtime.minecraft || "未知";
const loaderLabel = loaderLabels[loader] ?? loader;
const badgeCls = loaderBadgeColors[loader] ?? loaderBadgeColors.vanilla;
const topColor = loaderColors[loader] ?? loaderColors.vanilla;
const title = displayName || config.name || name;
return (
<div
className={clsx(
"relative group rounded-xl transition-all duration-200",
"bg-white/85 dark:bg-black/45",
"border border-black/[0.06] dark:border-white/[0.07]",
"hover:shadow-md hover:border-primary/20 dark:hover:border-primary/20",
"overflow-hidden",
)}
>
<div className={clsx("h-1.5 w-full", topColor)} />
<div className="px-5 py-4 space-y-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-foreground truncate">{title}</p>
<p className="text-[10.5px] text-muted-foreground/60 mt-0.5 truncate">{name}</p>
</div>
{healthy === false && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-destructive/10 text-destructive font-medium shrink-0">
</span>
)}
<Button
size="sm"
variant="light"
className="min-w-0 w-7 h-7 p-0 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
onPress={() => onSettings(name)}
>
<Settings className="w-3.5 h-3.5" />
</Button>
</div>
<div className="flex items-center gap-1.5 flex-wrap">
<span className={clsx("text-[10.5px] px-2 py-0.5 rounded-md border font-medium", badgeCls)}>
{loaderLabel}
</span>
<span className="text-[10.5px] px-2 py-0.5 rounded-md bg-foreground/[0.05] dark:bg-white/[0.05] text-muted-foreground border border-border/30 dark:border-white/[0.05]">
{version}
</span>
{modCount > 0 && (
<span className="text-[10.5px] px-2 py-0.5 rounded-md bg-purple-500/10 text-purple-600 dark:bg-purple-500/15 dark:text-purple-400 border border-purple-500/20">
{modCount}
</span>
)}
</div>
<div className="flex items-center gap-3 text-[11px] text-muted-foreground/60">
{config.lastPlayedDate ? (
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />{formatLastPlayed(config.lastPlayedDate)}
</span>
) : null}
{config.playtime > 0 && (
<span className="flex items-center gap-1">
<Gamepad2 className="w-3 h-3" />{formatPlaytime(config.playtime)}
</span>
)}
</div>
<Button
size="sm"
className="w-full"
variant="flat"
startContent={<Play className="w-3.5 h-3.5" />}
onPress={() => onPlay(name)}
isDisabled={isLaunching}
>
</Button>
</div>
</div>
);
}
+219
View File
@@ -0,0 +1,219 @@
// __ __ __ __ ______ __ __ ______ __ __ ______ ______
// /\ \ /\ \ /\ "-.\ \ /\ ___\ /\ \/ / /\ ___\ /\ "-.\ \ /\ ___\ /\__ _\
// \ \ \____ \ \ \ \ \ \-. \ \ \ \__ \ \ \ _"-. \ \ __\ \ \ \-. \ \ \ __\ \/_/\ \/
// \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ \ \_\ \_\ \ \_____\ \ \_\\"\_\ \ \_____\ \ \_\
// \/_____/ \/_/ \/_/ \/_/ \/_____/ \/_/\/_/ \/_____/ \/_/ \/_/ \/_____/ \/_/
//
// 所有权利归Lingke Network (china)所有 | dream_pep拥有其艺术改变权力
// 未经允许的情况下删除此版权头可能会受到民事指控
// 请勿在未经Lingke Network (china)允许的范围内修改代码并分发
import { Button } from "@heroui/react";
import {
Play,
Pencil,
FolderOpen,
Trash2,
Loader2,
Gamepad2,
Layers,
ImageIcon,
Save,
Clock,
CalendarDays,
} from "lucide-react";
import clsx from "clsx";
import type { InstanceInfo } from "@/api/instance";
const loaderBadgeColors: Record<string, string> = {
vanilla: "bg-green-500/10 text-green-600 dark:bg-green-500/15 dark:text-green-400 border-green-500/20",
forge: "bg-orange-500/10 text-orange-600 dark:bg-orange-500/15 dark:text-orange-400 border-orange-500/20",
fabric: "bg-sky-500/10 text-sky-600 dark:bg-sky-500/15 dark:text-sky-400 border-sky-500/20",
quilt: "bg-indigo-500/10 text-indigo-600 dark:bg-indigo-500/15 dark:text-indigo-400 border-indigo-500/20",
neoforged: "bg-red-500/10 text-red-600 dark:bg-red-500/15 dark:text-red-400 border-red-500/20",
};
const loaderLabels: Record<string, string> = {
vanilla: "原版",
forge: "Forge",
fabric: "Fabric",
quilt: "Quilt",
neoforged: "NeoForge",
};
function getLoader(runtime: InstanceInfo["config"]["runtime"]): string {
if (runtime.forge) return "forge";
if (runtime.fabricLoader) return "fabric";
if (runtime.quiltLoader) return "quilt";
if (runtime.neoForged) return "neoforged";
return "vanilla";
}
function formatDate(ts: number): string {
if (!ts) return "—";
return new Date(ts).toLocaleDateString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" });
}
function formatPlaytime(ms: number): string {
if (!ms || ms < 60000) return "0 分钟";
const hours = Math.floor(ms / 3600000);
const mins = Math.floor((ms % 3600000) / 60000);
if (hours > 0) return `${hours} 小时 ${mins} 分钟`;
return `${mins} 分钟`;
}
interface InstanceDetailProps {
instance: InstanceInfo;
isLaunching: boolean;
onPlay: () => void;
onEdit: () => void;
onOpenFolder: () => void;
onDelete: () => void;
}
export function InstanceDetail({ instance, isLaunching, onPlay, onEdit, onOpenFolder, onDelete }: InstanceDetailProps) {
const { config } = instance;
const loader = getLoader(config.runtime);
const badgeCls = loaderBadgeColors[loader] ?? loaderBadgeColors.vanilla;
const loaderLabel = loaderLabels[loader] ?? loader;
const display = config.description || config.name || instance.name;
// 统计卡片数据
const stats = [
{ label: "模组", value: instance.modCount, icon: Layers },
{ label: "资源包", value: instance.resourcePackCount, icon: ImageIcon },
{ label: "存档", value: instance.saveCount, icon: Save },
{ label: "截图", value: instance.screenshotCount, icon: Gamepad2 },
];
return (
<div className="rounded-2xl overflow-hidden bg-white/85 dark:bg-black/45 border border-black/[0.06] dark:border-white/[0.07]">
{/* 头部横幅 */}
<div className="relative px-6 pt-7 pb-5 bg-gradient-to-br from-primary/[0.08] via-transparent to-transparent dark:from-primary/[0.12]">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-3">
<div className="w-14 h-14 rounded-2xl bg-foreground/[0.05] dark:bg-white/[0.06] flex items-center justify-center shrink-0">
<Gamepad2 className="w-7 h-7 text-primary/70" />
</div>
<div className="min-w-0">
<h2 className="text-xl font-bold text-foreground truncate">{display}</h2>
<p className="text-[12px] text-muted-foreground/70 truncate mt-0.5 font-mono">{instance.name}</p>
</div>
</div>
{/* 版本 / 加载器 / 健康状态 */}
<div className="flex items-center gap-1.5 flex-wrap mt-3.5">
<span className={clsx("text-[11px] px-2 py-0.5 rounded-md border font-medium", badgeCls)}>
{loaderLabel}
</span>
<span className="text-[11px] px-2 py-0.5 rounded-md bg-foreground/[0.05] dark:bg-white/[0.05] text-muted-foreground border border-border/30 dark:border-white/[0.05]">
Minecraft {config.runtime.minecraft || "未知"}
</span>
{instance.healthy ? (
<span className="text-[11px] px-2 py-0.5 rounded-md bg-green-500/10 text-green-600 dark:bg-green-500/15 dark:text-green-400 border border-green-500/20">
</span>
) : (
<span className="text-[11px] px-2 py-0.5 rounded-md bg-destructive/10 text-destructive border border-destructive/20">
{instance.issues.length}
</span>
)}
</div>
</div>
{/* 操作按钮 */}
<div className="flex items-center gap-2 shrink-0">
<Button
size="sm"
variant="ghost"
className="min-w-0 w-8 h-8 p-0"
onPress={onEdit}
aria-label="编辑实例"
>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="min-w-0 w-8 h-8 p-0"
onPress={onOpenFolder}
aria-label="打开游戏目录"
>
<FolderOpen className="w-3.5 h-3.5" />
</Button>
<Button
size="sm"
variant="danger"
className="min-w-0 w-8 h-8 p-0"
onPress={onDelete}
aria-label="删除实例"
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
<div className="px-6 pb-6 space-y-5">
{/* 统计网格 */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{stats.map((s) => (
<div
key={s.label}
className="rounded-xl border border-border/30 dark:border-white/[0.06] bg-foreground/[0.02] dark:bg-white/[0.02] px-3.5 py-3 flex items-center gap-3"
>
<s.icon className="w-4 h-4 text-muted-foreground/50 shrink-0" />
<div className="min-w-0">
<p className="text-lg font-bold text-foreground leading-none">{s.value}</p>
<p className="text-[11px] text-muted-foreground/70 mt-1">{s.label}</p>
</div>
</div>
))}
</div>
{/* 基本信息 */}
<div className="rounded-xl border border-border/30 dark:border-white/[0.06] divide-y divide-border/30 dark:divide-white/[0.05]">
<InfoRow icon={CalendarDays} label="创建时间" value={formatDate(config.creationDate)} />
<InfoRow icon={Clock} label="最近游玩" value={formatDate(config.lastPlayedDate)} />
<InfoRow icon={Gamepad2} label="累计时长" value={formatPlaytime(config.playtime)} />
<InfoRow
icon={Layers}
label="内存分配"
value={config.minMemory || config.maxMemory ? `${config.minMemory || 1024} MB - ${config.maxMemory || 4096} MB` : "默认(自动)"}
/>
</div>
{/* 启动按钮 */}
<Button
className="w-full h-11"
variant="primary"
onPress={onPlay}
isDisabled={isLaunching}
>
{isLaunching ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4 fill-current" />}
{isLaunching ? "正在启动..." : "启动游戏"}
</Button>
</div>
</div>
);
}
// 信息行(图标 + 标签 + 值)
function InfoRow({
icon: Icon,
label,
value,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
value: string;
}) {
return (
<div className="flex items-center gap-3 px-4 py-2.5">
<Icon className="w-3.5 h-3.5 text-muted-foreground/50 shrink-0" />
<span className="text-[12px] text-muted-foreground w-20 shrink-0">{label}</span>
<span className="text-[12px] text-foreground/90 truncate">{value}</span>
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
// __ __ __ __ ______ __ __ ______ __ __ ______ ______
// /\ \ /\ \ /\ "-.\ \ /\ ___\ /\ \/ / /\ ___\ /\ "-.\ \ /\ ___\ /\__ _\
// \ \ \____ \ \ \ \ \ \-. \ \ \ \__ \ \ \ _"-. \ \ __\ \ \ \-. \ \ \ __\ \/_/\ \/
// \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ \ \_\ \_\ \ \_____\ \ \_\\"\_\ \ \_____\ \ \_\
// \/_____/ \/_/ \/_/ \/_/ \/_____/ \/_/\/_/ \/_____/ \/_/ \/_/ \/_____/ \/_/
//
// 所有权利归Lingke Network (china)所有 | dream_pep拥有其艺术改变权力
// 未经允许的情况下删除此版权头可能会受到民事指控
// 请勿在未经Lingke Network (china)允许的范围内修改代码并分发
import clsx from "clsx";
import { Gamepad2, FolderOpen } from "lucide-react";
import type { InstanceInfo } from "@/api/instance";
import { Skeleton } from "@/components/ui/skeleton";
// 加载器颜色映射(顶部色条)
const loaderColors: Record<string, string> = {
vanilla: "bg-green-400",
forge: "bg-orange-400",
fabric: "bg-sky-400",
quilt: "bg-indigo-400",
neoforged: "bg-red-400",
};
// 从运行环境推断加载器类型
function getLoader(runtime: InstanceInfo["config"]["runtime"]): string {
if (runtime.forge) return "forge";
if (runtime.fabricLoader) return "fabric";
if (runtime.quiltLoader) return "quilt";
if (runtime.neoForged) return "neoforged";
return "vanilla";
}
interface InstanceListProps {
instances: InstanceInfo[];
selectedName: string | null;
loading: boolean;
onSelect: (name: string) => void;
}
export function InstanceList({ instances, selectedName, loading, onSelect }: InstanceListProps) {
return (
<div className="flex flex-col gap-2 min-w-0">
{loading && instances.length === 0
? Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="rounded-xl border border-border/30 dark:border-white/[0.06] p-3 space-y-2">
<div className="flex items-center gap-2.5">
<Skeleton className="w-8 h-8 rounded-lg shrink-0" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-3 w-3/4" />
<Skeleton className="h-2.5 w-1/2" />
</div>
</div>
</div>
))
: instances.map((inst) => {
const active = inst.name === selectedName;
const loader = getLoader(inst.config.runtime);
const topColor = loaderColors[loader] ?? loaderColors.vanilla;
const display = inst.config.description || inst.name;
return (
<button
key={inst.name}
onClick={() => onSelect(inst.name)}
className={clsx(
"group relative text-left rounded-xl transition-all duration-150 overflow-hidden cursor-pointer",
"border",
active
? "border-primary/40 bg-primary/[0.06] dark:bg-primary/[0.08] shadow-sm"
: "border-border/40 dark:border-white/[0.07] bg-white/70 dark:bg-black/35 hover:border-primary/25 hover:bg-foreground/[0.02]",
)}
>
<div className={clsx("absolute left-0 top-0 bottom-0 w-1", topColor)} />
<div className="flex items-center gap-2.5 px-3 py-2.5 pl-4">
<div
className={clsx(
"w-8 h-8 rounded-lg flex items-center justify-center shrink-0",
loader === "vanilla"
? "bg-green-500/10 text-green-600 dark:text-green-400"
: "bg-foreground/[0.05] dark:bg-white/[0.06] text-muted-foreground",
)}
>
<Gamepad2 className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<p className={clsx("text-[13px] font-medium truncate", active ? "text-primary" : "text-foreground")}>
{display}
</p>
<p className="text-[11px] text-muted-foreground/70 truncate">
{inst.config.runtime.minecraft || "未知版本"}
{inst.modCount > 0 ? ` · ${inst.modCount} 模组` : ""}
</p>
</div>
</div>
</button>
);
})}
{!loading && instances.length === 0 && (
<div className="flex flex-col items-center gap-2 py-10 text-center">
<FolderOpen className="w-7 h-7 text-muted-foreground/30" />
<p className="text-[12px] text-muted-foreground/70"></p>
</div>
)}
</div>
);
}
+182 -111
View File
@@ -1,23 +1,50 @@
import { useEffect, useState, useCallback } from "react";
// __ __ __ __ ______ __ __ ______ __ __ ______ ______
// /\ \ /\ \ /\ "-.\ \ /\ ___\ /\ \/ / /\ ___\ /\ "-.\ \ /\ ___\ /\__ _\
// \ \ \____ \ \ \ \ \ \-. \ \ \ \__ \ \ \ _"-. \ \ __\ \ \ \-. \ \ \ __\ \/_/\ \/
// \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ \ \_\ \_\ \ \_____\ \ \_\\"\_\ \ \_____\ \ \_\
// \/_____/ \/_/ \/_/ \/_/ \/_____/ \/_/\/_/ \/_____/ \/_/ \/_/ \/_____/ \/_/
//
// 所有权利归Lingke Network (china)所有 | dream_pep拥有其艺术改变权力
// 未经允许的情况下删除此版权头可能会受到民事指控
// 请勿在未经Lingke Network (china)允许的范围内修改代码并分发
import { useCallback, useEffect, useState } from "react";
import { Button } from "@heroui/react";
import { Plus, Gamepad2, RefreshCw } from "lucide-react";
import { Plus, RefreshCw, Gamepad2 } from "lucide-react";
import { toast } from "sonner";
import { useInstanceStore } from "@/stores/instanceStore";
import { useConfigStore } from "@/stores/configStore";
import { Skeleton } from "@/components/ui/skeleton";
import { Surface } from "@/components/setting/Surface";
import { InstanceCard } from "./InstanceCard";
import { CreateDialog } from "./CreateDialog";
import type { InstanceRuntime } from "@/api/instance";
import { useAuthStore } from "@/stores/authStore";
import { useConfirmDialogStore } from "@/stores/confirmDialogStore";
import { useRouteStore } from "@/stores/routeStore";
import { onLaunchComplete, onLaunchError } from "@/api/instance";
import { openPath } from "@/api/system";
import { InstanceList } from "./InstanceList";
import { InstanceDetail } from "./InstanceDetail";
import { EditDialog } from "./EditDialog";
export function Gallery() {
const { instances, loading, fetchInstances, create, launch } = useInstanceStore();
const { instances, loading, fetchInstances, remove, launch } = useInstanceStore();
const gameConfig = useConfigStore((s) => s.config.game);
const javaConfig = useConfigStore((s) => s.config.java);
const configInstances = useConfigStore((s) => s.config.instances);
const setInstances = useConfigStore((s) => s.setInstances);
const user = useAuthStore((s) => s.user);
const openConfirm = useConfirmDialogStore((s) => s.openDialog);
const navigate = useRouteStore((s) => s.navigate);
const setStoreSection = useRouteStore((s) => s.setStoreSection);
const [createOpen, setCreateOpen] = useState(false);
const [launching, setLaunching] = useState<string | null>(null);
const [selectedName, setSelectedName] = useState<string | null>(null);
const [editOpen, setEditOpen] = useState(false);
const [launching, setLaunching] = useState(false);
// 跳转到资源中心"原版游戏"分类创建实例
const goCreateInstance = useCallback(() => {
setStoreSection("game");
navigate("store");
}, [setStoreSection, navigate]);
// 加载实例列表
const loadInstances = useCallback(async () => {
await fetchInstances(gameConfig.gameDir);
}, [fetchInstances, gameConfig.gameDir]);
@@ -26,66 +53,111 @@ export function Gallery() {
loadInstances();
}, [loadInstances]);
const handleCreate = async (name: string, runtime: InstanceRuntime, displayName?: string) => {
await create(name, gameConfig.gameDir, runtime, {
description: displayName,
// 默认选中第一个实例
useEffect(() => {
if (!selectedName && instances.length > 0) {
setSelectedName(instances[0].name);
}
if (selectedName && !instances.some((i) => i.name === selectedName)) {
setSelectedName(instances[0]?.name ?? null);
}
}, [instances, selectedName]);
// 监听启动结果
useEffect(() => {
const offComplete = onLaunchComplete(() => {
setLaunching(false);
toast.success("游戏启动成功");
});
// Sync to Koring.yml
const loader = runtime.forge ? "forge"
: runtime.fabricLoader ? "fabric"
: runtime.quiltLoader ? "quilt"
: runtime.neoForged ? "neoforged"
: "vanilla";
const loaderVersion = runtime.forge || runtime.fabricLoader || runtime.quiltLoader || runtime.neoForged || "";
const updated = configInstances.filter((m) => m.name !== name);
updated.push({
name,
displayName: displayName || name,
icon: "",
gameVersion: runtime.minecraft,
loader,
loaderVersion,
createdAt: Date.now(),
lastPlayed: 0,
playtime: 0,
const offError = onLaunchError(({ error }) => {
setLaunching(false);
toast.error(`启动失败: ${error}`);
});
setInstances(updated);
setCreateOpen(false);
};
return () => {
offComplete();
offError();
};
}, []);
const handlePlay = async (name: string) => {
setLaunching(name);
const selected = instances.find((i) => i.name === selectedName) ?? null;
// 启动游戏
const handlePlay = async () => {
if (!selected) return;
if (!user?.username || !user?.uuid) {
toast.warning("请先在设置中登录账号");
return;
}
setLaunching(true);
try {
await launch(name, gameConfig.gameDir);
} catch {
// Launch events handled via IPC listeners
await launch(selected.name, gameConfig.gameDir, {
username: user.username,
uuid: user.uuid,
accessToken: user.accessToken,
javaPath: javaConfig.javaPath || undefined,
});
} catch (e: any) {
setLaunching(false);
toast.error(`启动失败: ${e.message || e}`);
}
// Update lastPlayed in config
const idx = configInstances.findIndex((m) => m.name === name);
if (idx >= 0) {
const updated = [...configInstances];
updated[idx] = { ...updated[idx], lastPlayed: Date.now() };
setInstances(updated);
}
setLaunching(null);
};
const handleSettings = (name: string) => {
// TODO: Navigate to instance settings
console.log("Settings for:", name);
// 删除实例(带确认)
const handleDelete = () => {
if (!selected) return;
openConfirm({
title: "删除实例",
description: `确定要删除实例「${selected.config.description || selected.name}」吗?该操作将删除实例目录下所有文件,且无法恢复。`,
confirmLabel: "删除",
countdown: 5,
onConfirm: async () => {
try {
await remove(selected.name, gameConfig.gameDir);
// 同步移除 Koring.yml 中的记录
setInstances(configInstances.filter((m) => m.name !== selected.name));
setSelectedName(null);
toast.success("实例已删除");
} catch (e: any) {
toast.error(`删除失败: ${e.message || e}`);
}
},
});
};
const getDisplayName = (name: string) => {
return configInstances.find((m) => m.name === name)?.displayName;
// 编辑实例(显示名/内存)
const handleEditSave = async (patch: { description?: string; minMemory?: number; maxMemory?: number }) => {
if (!selected) return;
try {
await useInstanceStore.getState().select(selected.name, gameConfig.gameDir);
// 更新 instance.json
const result = await import("@/api/instance").then((m) =>
m.updateInstance(selected.name, gameConfig.gameDir, patch)
);
// 刷新列表并同步 config 显示名
await fetchInstances(gameConfig.gameDir);
const updatedMeta = configInstances.map((m) =>
m.name === selected.name ? { ...m, displayName: patch.description?.trim() || m.displayName } : m
);
setInstances(updatedMeta);
if (result) toast.success("实例设置已保存");
} catch (e: any) {
toast.error(`保存失败: ${e.message || e}`);
}
};
// 打开游戏目录
const handleOpenFolder = async () => {
if (!selected) return;
const result = await openPath(selected.path);
if (!result.success) {
toast.error(`无法打开目录: ${result.error || "未知错误"}`);
}
};
return (
<div className="p-6 h-full overflow-y-auto scroll-area">
{/* 头部 */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center justify-between mb-5">
<div>
<h1 className="text-xl font-bold text-foreground"></h1>
<p className="text-[13px] text-muted-foreground mt-1">
@@ -93,74 +165,73 @@ export function Gallery() {
</p>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="light" onPress={loadInstances} isDisabled={loading}>
<Button size="sm" variant="ghost" onPress={loadInstances} isDisabled={loading} className="min-w-0 w-8 h-8 p-0">
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
</Button>
<Button size="sm" startContent={<Plus className="w-4 h-4" />} onPress={() => setCreateOpen(true)}>
<Button size="sm" onPress={goCreateInstance}>
<Plus className="w-4 h-4" />
</Button>
</div>
</div>
{/* 加载骨架 */}
{loading && instances.length === 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<Surface key={i} padding="md">
<div className="space-y-3">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
<div className="flex gap-2">
<Skeleton className="h-5 w-14 rounded-md" />
<Skeleton className="h-5 w-20 rounded-md" />
</div>
<Skeleton className="h-8 w-full rounded-lg" />
</div>
</Surface>
))}
{/* 左右布局 */}
<div className="grid grid-cols-[280px_1fr] gap-5 items-start">
{/* 左侧:实例列表 */}
<div className="min-w-0">
<p className="text-[12px] font-medium text-muted-foreground/80 mb-2 px-1">
{instances.length}
</p>
<InstanceList
instances={instances}
selectedName={selectedName}
loading={loading}
onSelect={setSelectedName}
/>
</div>
)}
{/* 空状态 */}
{!loading && instances.length === 0 && (
<Surface variant="flat" padding="lg">
<div className="flex flex-col items-center gap-4 py-16">
<div className="w-16 h-16 rounded-full bg-foreground/[0.04] dark:bg-white/[0.04] flex items-center justify-center">
<Gamepad2 className="w-8 h-8 text-muted-foreground/30" />
</div>
<div className="text-center">
<p className="text-sm text-muted-foreground"></p>
<p className="text-[12px] text-muted-foreground/60 mt-1 max-w-[300px]">
"新建实例" Minecraft
</p>
</div>
<Button size="sm" startContent={<Plus className="w-4 h-4" />} onPress={() => setCreateOpen(true)}>
</Button>
</div>
</Surface>
)}
{/* 实例网格 */}
{instances.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{instances.map((inst) => (
<InstanceCard
key={inst.name}
instance={inst}
displayName={getDisplayName(inst.name)}
{/* 右侧:实例详情 */}
<div className="min-w-0">
{selected ? (
<InstanceDetail
instance={selected}
isLaunching={launching}
onPlay={handlePlay}
onSettings={handleSettings}
isLaunching={launching === inst.name}
onEdit={() => setEditOpen(true)}
onOpenFolder={handleOpenFolder}
onDelete={handleDelete}
/>
))}
) : (
<div className="flex flex-col items-center gap-4 py-24 rounded-2xl border border-dashed border-border/40 dark:border-white/[0.08]">
<div className="w-16 h-16 rounded-full bg-foreground/[0.04] dark:bg-white/[0.04] flex items-center justify-center">
<Gamepad2 className="w-8 h-8 text-muted-foreground/30" />
</div>
<div className="text-center">
<p className="text-sm text-muted-foreground">
{loading ? "正在加载实例..." : "还没有任何实例"}
</p>
{!loading && (
<p className="text-[12px] text-muted-foreground/60 mt-1">
</p>
)}
</div>
{!loading && (
<Button size="sm" onPress={goCreateInstance}>
<Plus className="w-4 h-4" />
</Button>
)}
</div>
)}
</div>
)}
</div>
<CreateDialog
isOpen={createOpen}
onClose={() => setCreateOpen(false)}
onCreate={handleCreate}
<EditDialog
instance={selected}
isOpen={editOpen}
onClose={() => setEditOpen(false)}
onSave={handleEditSave}
/>
</div>
);
+54 -19
View File
@@ -1,34 +1,69 @@
import { useState, useEffect } from "react";
// __ __ __ __ ______ __ __ ______ __ __ ______ ______
// /\ \ /\ \ /\ "-.\ \ /\ ___\ /\ \/ / /\ ___\ /\ "-.\ \ /\ ___\ /\__ _\
// \ \ \____ \ \ \ \ \ \-. \ \ \ \__ \ \ \ _"-. \ \ __\ \ \ \-. \ \ \ __\ \/_/\ \/
// \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ \ \_\ \_\ \ \_____\ \ \_\\"\_\ \ \_____\ \ \_\
// \/_____/ \/_/ \/_/ \/_/ \/_____/ \/_/\/_/ \/_____/ \/_/ \/_/ \/_____/ \/_/
//
// 所有权利归Lingke Network (china)所有 | dream_pep拥有其艺术改变权力
// 未经允许的情况下删除此版权头可能会受到民事指控
// 请勿在未经Lingke Network (china)允许的范围内修改代码并分发
import { useState } from "react";
import { ExternalLink } from "lucide-react";
import { useRouteStore } from "@/stores/routeStore";
import { OobeLayout } from "./layout";
import { NextButton } from "./next-button";
const openLink = (url: string) => {
window.electronAPI?.openExternal(url);
};
const AGREEMENT_LINKS = [
{
label: "用户协议与免责声明",
desc: "Copyright © Shenzhen Prism Horizon Technology Co., Ltd.",
url: "https://support.lingke.ink/Koring/系列产品用户协议与免责声明",
},
{
label: "产品分发有限许可",
desc: "除本许可明确授予的权利外,许可方保留软件及其相关知识产权中的全部权利。",
url: "https://support.lingke.ink/Koring/系列产品分发有限许可",
},
];
export function OobeAgreement() {
const navigate = useRouteStore((s) => s.navigate);
const [text, setText] = useState("");
const [checked, setChecked] = useState(false);
useEffect(() => {
fetch(`${import.meta.env.BASE_URL}protocol-user.txt`)
.then((r) => r.text())
.then(setText)
.catch(() => setText("无法加载协议内容"));
}, []);
return (
<OobeLayout>
<div className="w-full max-w-lg flex flex-col items-center gap-4 px-6">
<div className="w-full max-w-lg flex flex-col items-center gap-5 px-6">
{/* 标题 */}
<div className="text-center space-y-1">
<h2 className="text-lg font-bold text-foreground">Koring Team </h2>
<p className="text-xs text-muted-foreground"></p>
<h2 className="text-lg font-bold text-foreground"></h2>
<p className="text-xs text-muted-foreground"></p>
</div>
{/* 协议内容 */}
<div className="w-full h-[300px] rounded-xl bg-foreground/[0.03] border border-border/50 p-4 overflow-y-auto">
<pre className="text-xs text-foreground/70 whitespace-pre-wrap font-sans leading-relaxed">
{text}
</pre>
{/* 协议链接列表 */}
<div className="w-full space-y-2">
{AGREEMENT_LINKS.map((item) => (
<div
key={item.label}
className="w-full flex items-center justify-between gap-3 px-4 py-3 rounded-xl bg-foreground/[0.03] border border-border/40 dark:border-white/[0.06]"
>
<div className="flex-1 min-w-0">
<p className="text-[13.5px] font-medium text-foreground">{item.label}</p>
<p className="text-[11.5px] text-muted-foreground/70 mt-0.5 leading-relaxed">{item.desc}</p>
</div>
<button
onClick={() => openLink(item.url)}
className="flex items-center gap-1 px-3 h-7 rounded-lg text-[12px] font-medium bg-primary/10 text-primary hover:bg-primary/20 transition-colors shrink-0 cursor-pointer"
>
<ExternalLink className="w-3 h-3" />
</button>
</div>
))}
</div>
{/* 勾选框 */}
@@ -49,12 +84,12 @@ export function OobeAgreement() {
</div>
</div>
<span className="text-xs text-muted-foreground leading-relaxed group-hover:text-foreground/70 transition-colors">
</span>
</label>
</div>
<NextButton onClick={() => navigate("oobe/welcome")} disabled={!checked} />
<NextButton onClick={() => navigate("oobe/legal")} disabled={!checked} />
</OobeLayout>
);
}
+2 -1
View File
@@ -45,7 +45,8 @@ export function OobeLanguage() {
))}
</div>
<NextButton onClick={() => navigate("oobe/login")} />
{/* 临时跳过 Koring 账户登录环节,直接进入用户协议 */}
<NextButton onClick={() => navigate("oobe/agreement")} />
</OobeLayout>
);
}
+65
View File
@@ -0,0 +1,65 @@
// __ __ __ __ ______ __ __ ______ __ __ ______ ______
// /\ \ /\ \ /\ "-.\ \ /\ ___\ /\ \/ / /\ ___\ /\ "-.\ \ /\ ___\ /\__ _\
// \ \ \____ \ \ \ \ \ \-. \ \ \ \__ \ \ \ _"-. \ \ __\ \ \ \-. \ \ \ __\ \/_/\ \/
// \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ \ \_\ \_\ \ \_____\ \ \_\\"\_\ \ \_____\ \ \_\
// \/_____/ \/_/ \/_/ \/_/ \/_____/ \/_/\/_/ \/_____/ \/_/ \/_/ \/_____/ \/_/
//
// 所有权利归Lingke Network (china)所有 | dream_pep拥有其艺术改变权力
// 未经允许的情况下删除此版权头可能会受到民事指控
// 请勿在未经Lingke Network (china)允许的范围内修改代码并分发
import { useRouteStore } from "@/stores/routeStore";
import { OobeLayout } from "./layout";
import { NextButton } from "./next-button";
const LEGAL_ITEMS = [
{
label: "匿名数据收集",
desc: "您在使用本产品时,可能会收集匿名数据,用于改进产品功能和用户体验。包括崩溃日志、崩溃操作路径与设备状态等脱敏信息。并上传至 Lenjing.Cloud 服务器。",
},
{
label: "隐私声明与个人信息保护策略",
desc: "本产品在现有版本下暂未进行收集任何隐私信息与个人信息。",
},
{
label: "其他信息",
desc: "Copyright © 深圳棱镜视界科技有限公司。\n违法违规行为举报信箱:support@lenjing.email",
},
{
label: "如果不愿意遵守或者同意以上法律声明",
desc: "请退出关闭,并卸载本产品。",
},
];
export function OobeLegal() {
const navigate = useRouteStore((s) => s.navigate);
return (
<OobeLayout>
<div className="w-full max-w-lg flex flex-col items-center gap-5 px-6">
{/* 标题 */}
<div className="text-center space-y-1">
<h2 className="text-lg font-bold text-foreground"></h2>
<p className="text-xs text-muted-foreground"></p>
</div>
{/* 法律信息列表 */}
<div className="w-full space-y-2">
{LEGAL_ITEMS.map((item) => (
<div
key={item.label}
className="w-full px-4 py-3 rounded-xl bg-foreground/[0.03] border border-border/40 dark:border-white/[0.06]"
>
<p className="text-[13.5px] font-medium text-foreground">{item.label}</p>
<p className="text-[11.5px] text-muted-foreground/70 mt-1 leading-relaxed whitespace-pre-line">
{item.desc}
</p>
</div>
))}
</div>
</div>
<NextButton onClick={() => navigate("oobe/welcome")} />
</OobeLayout>
);
}
+5 -40
View File
@@ -1,50 +1,22 @@
import { useState, useEffect } from "react";
import { useRouteStore } from "@/stores/routeStore";
import { OobeLayout } from "./layout";
import { NextButton } from "./next-button";
import { VERSION } from "@/lib/version";
import { VersionCard } from "@/components/VersionCard";
import { BUILD_MODE } from "@/lib/mode";
export function OobeVersion() {
const navigate = useRouteStore((s) => s.navigate);
const [changelog, setChangelog] = useState("");
const isTestBuild = BUILD_MODE === "dev" || BUILD_MODE === "beta";
const badgeLabel = BUILD_MODE === "dev" ? "DEV" : BUILD_MODE === "beta" ? "BETA" : null;
const nextRoute = isTestBuild ? "oobe/beta-test" : "oobe/login";
useEffect(() => {
fetch(`${import.meta.env.BASE_URL}changelog-${VERSION}.txt`)
.then((r) => r.text())
.then(setChangelog)
.catch(() => setChangelog("暂无更新内容"));
}, []);
// 临时跳过 Koring 账户登录环节:正式版也直接进入用户协议
const nextRoute = isTestBuild ? "oobe/beta-test" : "oobe/agreement";
return (
<OobeLayout>
<div className="w-full max-w-lg flex flex-col items-center gap-4 px-6">
{/* 标题 */}
<h2 className="text-lg font-bold text-foreground"></h2>
{/* 版本号 + Badge */}
<div className="flex items-center gap-2.5">
<span className="text-3xl font-bold tracking-tight text-foreground">
v{VERSION}
</span>
{badgeLabel && (
<span
className={[
"text-[11px] font-bold px-2 py-0.5 rounded-full leading-none",
BUILD_MODE === "dev"
? "bg-amber-500/15 text-amber-600 dark:text-amber-400"
: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400",
].join(" ")}
>
{badgeLabel}
</span>
)}
</div>
{/* 版本卡片(OOBE 模式:无更新逻辑,按钮仅展示样式) */}
<VersionCard oobe className="w-full" />
{/* 测试版警告 */}
{isTestBuild && (
@@ -52,13 +24,6 @@ export function OobeVersion() {
使
</p>
)}
{/* 更新内容 */}
<div className="w-full h-[260px] rounded-xl bg-foreground/[0.03] border border-border/50 p-4 overflow-y-auto">
<pre className="text-xs text-foreground/70 whitespace-pre-wrap font-sans leading-relaxed">
{changelog}
</pre>
</div>
</div>
<NextButton onClick={() => navigate(nextRoute)} />
+1 -1
View File
@@ -21,7 +21,7 @@ export function OobeWelcome() {
return () => clearTimeout(t);
}, []);
const displayName = user?.name || user?.username || "用户";
const displayName = user?.name || user?.username || "玩家";
return (
<OobeLayout>
+258 -58
View File
@@ -1,26 +1,48 @@
// __ __ __ __ ______ __ __ ______ __ __ ______ ______
// /\ \ /\ \ /\ "-.\ \ /\ ___\ /\ \/ / /\ ___\ /\ "-.\ \ /\ ___\ /\__ _\
// \ \ \____ \ \ \ \ \ \-. \ \ \ \__ \ \ \ _"-. \ \ __\ \ \ \-. \ \ \ __\ \/_/\ \/
// \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ \ \_\ \_\ \ \_____\ \ \_\\"\_\ \ \_____\ \ \_\
// \/_____/ \/_/ \/_/ \/_/ \/_____/ \/_/\/_/ \/_____/ \/_/ \/_/ \/_____/ \/_/
//
// 所有权利归Lingke Network (china)所有 | dream_pep拥有其艺术改变权力
// 未经允许的情况下删除此版权头可能会受到民事指控
// 请勿在未经Lingke Network (china)允许的范围内修改代码并分发
import { useState } from "react";
import { Button, Skeleton } from "@heroui/react";
import { RefreshCw, FolderOpen, Trash2, Search, Plus, CircleCheck, CircleAlert, Home, Check } from "lucide-react";
import { toast } from "sonner";
import { useConfigStore } from "@/stores/configStore";
import { Button, Input } from "@heroui/react";
import { RefreshCw } from "lucide-react";
import { scanGameDir, selectFolder, type ScannedVersion } from "@/api/instance";
import { SettingCard, PageHeader, SectionTitle } from "@/components/setting";
function PathInput({ label, desc, value, onChange }: { label: string; desc: string; value: string; onChange: (v: string) => void }) {
// 版本类型标签样式
const TYPE_BADGE: Record<string, { label: string; cls: string }> = {
release: { label: "正式版", cls: "bg-green-500/10 text-green-600 dark:text-green-400" },
snapshot: { label: "预览版", cls: "bg-amber-500/10 text-amber-600 dark:text-amber-400" },
"old_alpha": { label: "Alpha", cls: "bg-purple-500/10 text-purple-600 dark:text-purple-400" },
"old_beta": { label: "Beta", cls: "bg-indigo-500/10 text-indigo-600 dark:text-indigo-400" },
unknown: { label: "未知", cls: "bg-foreground/[0.06] dark:bg-white/[0.06] text-muted-foreground" },
};
function getBadge(type: string) {
return TYPE_BADGE[type] ?? TYPE_BADGE.unknown;
}
// 格式化发布时间
function formatTime(iso?: string): string {
if (!iso) return "-";
const d = new Date(iso);
return d.toLocaleDateString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" });
}
// 扫描到的文件状态图标
function FileStatus({ ok, label }: { ok: boolean; label: string }) {
return (
<SettingCard>
<div className="space-y-2">
<div>
<p className="text-sm font-medium text-foreground">{label}</p>
<p className="text-[13px] text-muted-foreground mt-0.5">{desc}</p>
</div>
<div className="flex items-center gap-2">
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
fullWidth
/>
<Button size="sm" variant="outline"></Button>
</div>
</div>
</SettingCard>
<span className={`inline-flex items-center gap-1 text-[11px] ${ok ? "text-green-600 dark:text-green-400" : "text-red-500/70"}`}>
{ok ? <CircleCheck className="w-3 h-3" /> : <CircleAlert className="w-3 h-3" />}
{label}
</span>
);
}
@@ -28,58 +50,236 @@ export function GameDirSetting() {
const game = useConfigStore((s) => s.config.game);
const setGame = useConfigStore((s) => s.setGame);
const [scanning, setScanning] = useState(false);
const [scanTarget, setScanTarget] = useState<string>(""); // 正在扫描的目标路径
const [scanResults, setScanResults] = useState<ScannedVersion[] | null>(null);
const dirs = game.gameDirs ?? [];
// 扫描指定目录中已安装的游戏版本
const handleScan = async (targetPath?: string) => {
const scanPath = targetPath || game.gameDir || ".minecraft";
setScanning(true);
setScanTarget(scanPath);
setScanResults(null);
try {
const result = await scanGameDir(scanPath);
setScanResults(result.versions);
if (result.versions.length === 0) {
toast.info("未在 versions 目录下发现任何已安装版本");
} else {
toast.success(`已扫描到 ${result.versions.length} 个游戏版本`);
}
} catch (e: any) {
toast.error(`扫描失败: ${e.message}`);
}
setScanning(false);
};
// 浏览选择主目录
const handleBrowseMainDir = async () => {
try {
const result = await selectFolder();
if (result?.folderPath) {
setGame({ gameDir: result.folderPath });
toast.success(`已设置游戏目录为: ${result.folderPath}`);
}
} catch (e: any) {
toast.error(`选择目录失败: ${e.message}`);
}
};
// 手动添加目录
const handleAddFolder = async () => {
try {
const result = await selectFolder();
if (result?.folderPath) {
const path = result.folderPath;
const current = game.gameDirs ?? [];
if (current.includes(path)) {
toast.info("该目录已在列表中");
return;
}
setGame({ gameDirs: [...current, path] });
toast.success(`已添加目录: ${path}`);
}
} catch (e: any) {
toast.error(`选择目录失败: ${e.message}`);
}
};
// 删除已添加的目录
const handleRemoveDir = (dir: string) => {
setGame({ gameDirs: dirs.filter((d) => d !== dir) });
toast.success("已移除目录");
};
// 设为当前游戏目录
const handleSetAsMain = (dir: string) => {
setGame({ gameDir: dir });
toast.success(`已将 ${dir} 设为主游戏目录`);
};
return (
<div>
<PageHeader title="游戏目录" desc="设置 Minecraft 游戏安装路径、存档位置与资源包目录" />
<PageHeader title="游戏目录" desc="管理 Minecraft 游戏的存放位置与发现已安装版本" />
<div className="space-y-6">
{/* 主游戏目录配置 */}
<div>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<PathInput
label="游戏安装根目录"
desc="Minecraft 游戏文件的根目录,包含 versions、saves、resourcepacks 等文件夹"
value={game.gameDir}
onChange={(v) => setGame({ gameDir: v })}
/>
<SettingCard>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-foreground"></p>
<p className="text-[13px] text-muted-foreground mt-0.5"></p>
</div>
<Button size="sm" variant="outline">
<RefreshCw className="w-3.5 h-3.5 mr-1.5" />
<SectionTitle></SectionTitle>
<SettingCard>
<div className="flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium font-mono text-foreground truncate">{game.gameDir || ".minecraft"}</p>
<p className="text-[12px] text-muted-foreground/70 mt-0.5"></p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button size="sm" variant="outline" onPress={handleBrowseMainDir}>
<FolderOpen className="w-3.5 h-3.5" />
</Button>
<Button size="sm" variant="outline" onPress={() => handleScan(game.gameDir)} isDisabled={scanning}>
<Search className="w-3.5 h-3.5" />
</Button>
</div>
</SettingCard>
</div>
</div>
{/* 扫描中骨架屏 */}
{scanning && scanTarget === (game.gameDir || ".minecraft") && (
<div className="mt-4 space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full rounded-lg" />
))}
</div>
)}
{/* 扫描结果 */}
{scanResults !== null && scanTarget === (game.gameDir || ".minecraft") && (
<div className="mt-4 border-t border-border/30 dark:border-white/[0.05] pt-4">
{scanResults.length === 0 ? (
<p className="text-[13px] text-muted-foreground/70 text-center py-4">
versions Minecraft
</p>
) : (
<div className="flex flex-col gap-1.5 max-h-72 overflow-y-auto scroll-area pr-1">
{scanResults.map((v) => {
const badge = getBadge(v.type);
return (
<div
key={v.id}
className="flex items-center gap-3 px-3.5 py-2.5 rounded-xl bg-foreground/[0.03] dark:bg-white/[0.03] border border-border/20 dark:border-white/[0.04]"
>
{/* 版本图标 */}
<div className={`w-7 h-7 rounded-lg flex items-center justify-center shrink-0 ${badge.cls}`}>
<Home className="w-3.5 h-3.5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-[13px] font-mono font-semibold text-foreground">{v.id}</p>
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${badge.cls}`}>
{badge.label}
</span>
<span className="text-[11px] text-muted-foreground/60">{formatTime(v.releaseTime)}</span>
</div>
<div className="flex items-center gap-3 mt-1">
<FileStatus ok={v.hasJson} label="JSON" />
<FileStatus ok={v.hasJar} label="JAR" />
</div>
</div>
</div>
);
})}
</div>
)}
</div>
)}
</SettingCard>
</div>
<div>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<PathInput
label="资源包路径"
desc="自定义资源包(材质包)的存放目录,留空使用默认路径"
value={game.resourceDir}
onChange={(v) => setGame({ resourceDir: v })}
/>
</div>
{/* 已添加的目录列表 */}
<div className="flex items-center justify-between">
<SectionTitle>{dirs.length}</SectionTitle>
<Button size="sm" variant="outline" onPress={handleAddFolder}>
<Plus className="w-3.5 h-3.5" />
</Button>
</div>
<div>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<PathInput
label="存档路径"
desc="自定义世界存档的存放目录,留空使用默认路径"
value={game.savesDir}
onChange={(v) => setGame({ savesDir: v })}
/>
{dirs.length === 0 ? (
<SettingCard>
<div className="flex flex-col items-center gap-2 py-10 text-center">
<FolderOpen className="w-7 h-7 text-muted-foreground/25" />
<p className="text-[13px] text-muted-foreground/70"></p>
<p className="text-[12px] text-muted-foreground/50">
使"扫描""手动添加"
</p>
</div>
</SettingCard>
) : (
<div className="space-y-2">
{dirs.map((dir) => (
<SettingCard key={dir}>
<div className="flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-mono font-medium text-foreground truncate">{dir}</p>
{dir === (game.gameDir || ".minecraft") && (
<span className="flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium shrink-0">
<Check className="w-2.5 h-2.5" />
</span>
)}
</div>
<p className="text-[12px] text-muted-foreground/70 mt-0.5"></p>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{dir !== (game.gameDir || ".minecraft") && (
<Button size="sm" variant="ghost" className="min-w-0 h-8 text-[11px] text-muted-foreground" onPress={() => handleSetAsMain(dir)}>
<Home className="w-3.5 h-3.5" />
</Button>
)}
<Button size="sm" variant="ghost" className="min-w-0 h-8 text-[11px] text-muted-foreground" onPress={() => handleScan(dir)} isDisabled={scanning}>
<Search className="w-3.5 h-3.5" />
</Button>
<Button size="sm" variant="danger" className="min-w-0 shrink-0" onPress={() => handleRemoveDir(dir)}>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
{/* 该目录的扫描结果 */}
{scanning && scanTarget === dir && (
<div className="mt-3 space-y-2">
{Array.from({ length: 2 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full rounded-lg" />
))}
</div>
)}
{scanResults !== null && scanTarget === dir && scanTarget !== (game.gameDir || ".minecraft") && (
<div className="mt-3 border-t border-border/30 dark:border-white/[0.05] pt-3">
{scanResults.length === 0 ? (
<p className="text-[12px] text-muted-foreground/60 py-2"></p>
) : (
<div className="flex flex-wrap gap-1.5">
{scanResults.map((v) => {
const badge = getBadge(v.type);
return (
<span key={v.id} className="inline-flex items-center gap-1 text-[11px] px-2 py-1 rounded-md bg-foreground/[0.04] dark:bg-white/[0.04]">
<span className={`w-1.5 h-1.5 rounded-full ${badge.cls.replace(/\/\d+/, "").replace(/\s.*/, "")}`} />
{v.id}
</span>
);
})}
</div>
)}
</div>
)}
</SettingCard>
))}
</div>
</div>
)}
</div>
</div>
);
+2 -2
View File
@@ -11,8 +11,8 @@ const modeLabels: Record<string, string> = {
run: "正式版",
};
const GITHUB_URL = "https://github.com/koring-launcher/koring-launcher";
const OFFICIAL_URL = "https://koring.app";
const GITHUB_URL = "https://github.com/lingke-net/koring-launcher";
const OFFICIAL_URL = "https://koring.space";
export function AboutSetting() {
const openDialog = useConfirmDialogStore((s) => s.openDialog);
+39 -2
View File
@@ -32,9 +32,46 @@ export function CopyrightSetting() {
<div className="space-y-6">
<div>
<SectionTitle></SectionTitle>
<SectionTitle></SectionTitle>
<div className="space-y-2">
<SettingCard>
<SettingRow label="匿名数据收集" desc="您在使用本产品时,可能会收集匿名数据,用于改进产品功能和用户体验。包括崩溃日志、崩溃操作路径与设备状态等脱敏信息。并上传至 Lenjing.Cloud 服务器。">
<Link>
</Link>
</SettingRow>
<SettingRow label="隐私声明与个人信息保护策略" desc="本产品在现有版本下暂未进行收集任何隐私信息与个人信息。">
<Link>
</Link>
</SettingRow>
<SettingRow label="其他信息" desc="Copyright © 深圳棱镜视界科技有限公司
support@lenjing.email">
<Link>
</Link>
</SettingRow>
</SettingCard>
<SettingCard>
<SettingRow label="用户协议与免责声明" desc="Copyright © Shenzhen Prism Horizon Technology Co., Ltd.">
<Link onPress={() => openLink("https://support.lingke.ink/Koring/系列产品用户协议与免责声明")}>
<ExternalLink className="w-3 h-3" />
</Link>
</SettingRow>
</SettingCard>
<SettingCard>
<SettingRow label="产品分发有限许可" desc="除本许可明确授予的权利外,许可方保留软件及其相关知识产权中的全部权利。">
<Link onPress={() => openLink("https://support.lingke.ink/Koring/系列产品分发有限许可")}>
<ExternalLink className="w-3 h-3" />
</Link>
</SettingRow>
</SettingCard>
</div>
</div>
<div>
<SectionTitle></SectionTitle>
<SettingCard>
<SettingRow label="LL-1.0 (LingkeLice 1.0)" desc="Copyright © Shenzhen Lingke Network Technology Co., Ltd.">
<SettingRow label="开源协议 LL-1.0 (LingkeLice 1.0)" desc="开源协议使用 Shenzhen Lingke Network Technology Co., Ltd. 发布的LL-1.0协议">
<Link onPress={() => openLink("https://support.lingke.ink/LL-1.0")}>
<ExternalLink className="w-3 h-3" />
+2 -1
View File
@@ -56,7 +56,8 @@ export function HomeSetting({ onNavigate }: HomeSettingProps) {
<PageHeader title="主页" desc="版本信息与常用设置的快捷入口" />
<div className="space-y-6">
<VersionCard simple />
{/* 与"关于"页使用相同的版本卡片 */}
<VersionCard />
<div>
<SectionTitle></SectionTitle>
+305
View File
@@ -0,0 +1,305 @@
// __ __ __ __ ______ __ __ ______ __ __ ______ ______
// /\ \ /\ \ /\ "-.\ \ /\ ___\ /\ \/ / /\ ___\ /\ "-.\ \ /\ ___\ /\__ _\
// \ \ \____ \ \ \ \ \ \-. \ \ \ \__ \ \ \ _"-. \ \ __\ \ \ \-. \ \ \ __\ \/_/\ \/
// \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ \ \_\ \_\ \ \_____\ \ \_\\"\_\ \ \_____\ \ \_\
// \/_____/ \/_/ \/_/ \/_/ \/_____/ \/_/\/_/ \/_____/ \/_/ \/_/ \/_____/ \/_/
//
// 所有权利归Lingke Network (china)所有 | dream_pep拥有其艺术改变权力
// 未经允许的情况下删除此版权头可能会受到民事指控
// 请勿在未经Lingke Network (china)允许的范围内修改代码并分发
import { useEffect, useMemo, useState } from "react";
import { Accordion, Button, Card, Input, Skeleton } from "@heroui/react";
import { Box, Search, ChevronDown, Plus, Sparkles, Beaker, RotateCcw } from "lucide-react";
import { getMinecraftVersionList, type VersionEntry, type VersionListResult } from "@/api/instance";
import { InstallDialog } from "./InstallDialog";
import clsx from "clsx";
// 版本类型分组标签
type VersionGroup = "release" | "snapshot" | "aprilfool";
// 版本号降序
function compareVersions(a: string, b: string): number {
const pa = a.split(".").map(Number);
const pb = b.split(".").map(Number);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i++) {
const diff = (pb[i] || 0) - (pa[i] || 0);
if (diff !== 0) return diff;
}
return 0;
}
// 判断是否为愚人节版(如 3D Shareware v1.34、Love and Hugs、One Block、20w14∞ 等)
function isAprilFool(v: VersionEntry): boolean {
const known = ["3D Shareware v1.34", "Love and Hugs Update", "One Block at a Time Update",
"1.RV-Pre1", "15w14a", "2.0", "Java Edition 2.0", "20w14∞", "20w14infinite", "23w13a_or_b"];
if (known.some((k) => v.id === k || v.id.toLowerCase().includes(k.toLowerCase()))) return true;
// 负更新三叉戟版
if (v.id.match(/^(?:1\.RV|2\.0|20w14|23w13a)/)) return true;
return false;
}
// 格式化发布时间
function formatTime(iso: string): string {
if (!iso) return "";
const d = new Date(iso);
return d.toLocaleDateString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" });
}
// 折叠面板分组配置
const GROUPS: { group: VersionGroup; label: string; icon: React.ReactNode }[] = [
{ group: "release", label: "正式版", icon: <Sparkles className="w-4 h-4" /> },
{ group: "snapshot", label: "预览版", icon: <Beaker className="w-4 h-4" /> },
{ group: "aprilfool", label: "愚人节版", icon: <RotateCcw className="w-4 h-4" /> },
];
export function GameVersionView() {
const [data, setData] = useState<VersionListResult | null>(null);
const [loading, setLoading] = useState(true);
const [query, setQuery] = useState("");
const [installVersion, setInstallVersion] = useState<string | null>(null);
// 折叠面板展开状态(默认展开正式版,可多选)
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set(["release"]));
// 加载版本清单
useEffect(() => {
let cancelled = false;
setLoading(true);
getMinecraftVersionList()
.then((result) => { if (!cancelled) setData(result); })
.catch(() => {})
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, []);
// 分组
const { latestRelease, latestSnapshot, releaseVersions, snapshotVersions, aprilFoolVersions, allFiltered } = useMemo(() => {
if (!data) return { latestRelease: null, latestSnapshot: null, releaseVersions: [] as VersionEntry[], snapshotVersions: [] as VersionEntry[], aprilFoolVersions: [] as VersionEntry[], allFiltered: [] as VersionEntry[] };
const { versions, latest } = data;
const filtered = query
? versions.filter((v) => v.id.toLowerCase().includes(query.toLowerCase()))
: versions;
const latestRel = versions.find((v) => v.id === latest.release) ?? null;
const latestSnap = versions.find((v) => v.id === latest.snapshot) ?? null;
const releases = filtered.filter((v) => v.type === "release" && !isAprilFool(v)).sort((a, b) => compareVersions(a.id, b.id));
const snapshots = filtered.filter((v) => v.type === "snapshot" && !isAprilFool(v)).sort((a, b) => compareVersions(a.id, b.id));
const aprilFools = filtered.filter((v) => isAprilFool(v)).sort((a, b) => compareVersions(a.id, b.id));
// 全部合并(去重后用于搜索匹配)
const seen = new Set<string>();
const all: VersionEntry[] = [];
const add = (arr: VersionEntry[]) => { for (const v of arr) { if (!seen.has(v.id)) { seen.add(v.id); all.push(v); } } };
add([latestRel, latestSnap].filter(Boolean) as VersionEntry[]);
add(releases);
const restSnapshots = snapshots.filter((s) => s.id !== latest.snapshot);
add(restSnapshots);
return {
latestRelease: latestRel,
latestSnapshot: latestSnap,
releaseVersions: releases,
snapshotVersions: restSnapshots, // 排除最新预览版(已在顶部卡片展示)
aprilFoolVersions: aprilFools,
allFiltered: all,
};
}, [data, query]);
// 各分组对应的版本列表
const groupVersions: Record<VersionGroup, VersionEntry[]> = {
release: releaseVersions,
snapshot: snapshotVersions,
aprilfool: aprilFoolVersions,
};
return (
<div className="min-h-full setting-page-enter">
{/* 顶部搜索框(无标题) */}
<div className="relative w-full mb-5">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4.5 h-4.5 text-muted-foreground/50 z-10 pointer-events-none" />
<Input
fullWidth
className="h-10 pl-10 pr-4 rounded-xl w-full"
placeholder="搜索 Minecraft 版本..."
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
{loading ? (
<div className="flex flex-col gap-3">
<Skeleton className="h-48 w-full rounded-2xl" />
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-12 w-full rounded-xl" />)}
</div>
) : !data ? (
<div className="flex flex-col items-center gap-3 py-24">
<Box className="w-8 h-8 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground"></p>
</div>
) : query ? (
/* 搜索模式:直接展示匹配的结果列表 */
<div className="flex flex-col gap-2">
{allFiltered.length === 0 ? (
<div className="py-12 text-center text-[13px] text-muted-foreground">{query}</div>
) : (
allFiltered.map((v, i) => (
<VersionRow key={v.id} entry={v} index={i} onClick={() => setInstallVersion(v.id)} />
))
)}
</div>
) : (
/* 默认模式:最新版本卡片 + 折叠面板 */
<>
{/* 最新版本卡片 */}
<TopVersionCard
label="最新正式版"
icon={<Sparkles className="w-4.5 h-4.5" />}
version={latestRelease}
onClick={() => latestRelease && setInstallVersion(latestRelease.id)}
/>
<TopVersionCard
label="最新预览版"
icon={<Beaker className="w-4.5 h-4.5" />}
version={latestSnapshot}
className="mt-3"
onClick={() => latestSnapshot && setInstallVersion(latestSnapshot.id)}
/>
{/* 折叠面板(HeroUI Accordion,默认展开正式版) */}
<Accordion
hideSeparator
allowsMultipleExpanded
expandedKeys={expandedKeys}
onExpandedChange={(keys) => setExpandedKeys(new Set(keys as Set<string>))}
className="mt-6 flex flex-col gap-3"
>
{GROUPS.map(({ group, label, icon }) => {
const versions = groupVersions[group];
return (
<Accordion.Item
key={group}
id={group}
className="rounded-2xl border border-border/30 dark:border-white/[0.07] bg-white/60 dark:bg-black/25 overflow-hidden"
>
<Accordion.Heading>
<Accordion.Trigger className="w-full flex items-center gap-3 px-5 py-3 text-left transition-colors hover:bg-foreground/[0.02]">
<span className="text-muted-foreground/60">{icon}</span>
<span className="flex-1 text-[13.5px] font-medium text-foreground">
{label}{versions.length}
</span>
<Accordion.Indicator>
<ChevronDown className="w-4 h-4 text-muted-foreground/50 transition-transform duration-200 data-[expanded]:rotate-180" />
</Accordion.Indicator>
</Accordion.Trigger>
</Accordion.Heading>
<Accordion.Panel>
{versions.length === 0 ? (
<p className="text-[12px] text-muted-foreground/60 px-4 pb-3"></p>
) : (
<Accordion.Body className="flex flex-col gap-1.5 px-2 pb-3">
{versions.map((v, i) => (
<VersionRow key={v.id} entry={v} index={i} onClick={() => setInstallVersion(v.id)} />
))}
</Accordion.Body>
)}
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
</>
)}
{/* 安装弹窗 */}
<InstallDialog
versionId={installVersion}
onClose={() => setInstallVersion(null)}
/>
</div>
);
}
// 顶部版本信息卡片:显示最新正式版 / 预览版
function TopVersionCard({
label, icon, version, className, onClick,
}: {
label: string; icon: React.ReactNode; version: VersionEntry | null; className?: string; onClick: () => void;
}) {
return (
<Card.Root
onClick={onClick}
className={clsx(
"setting-page-enter group relative flex flex-row items-center gap-5 px-5 py-4 rounded-2xl transition-all duration-150 cursor-pointer",
"bg-gradient-to-br from-primary/[0.08] via-white/80 to-white/60",
"dark:from-primary/[0.15] dark:via-black/50 dark:to-black/35",
"border border-primary/15 dark:border-primary/20",
"hover:shadow-lg hover:border-primary/30",
className,
)}
>
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center shrink-0 text-primary">
{icon}
</div>
<div className="flex-1 min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">{label}</p>
{version ? (
<>
<p className="text-[17px] font-bold text-foreground font-mono mt-0.5">{version.id}</p>
<p className="text-[11.5px] text-muted-foreground/70 mt-0.5"> {formatTime(version.releaseTime)}</p>
</>
) : (
<p className="text-[13px] text-muted-foreground/60 mt-1"></p>
)}
</div>
{/* HeroUI Button 会拦截 click 冒泡,必须用 onPress 触发 */}
<Button
size="sm"
variant="secondary"
className="shrink-0 bg-primary/10 text-primary hover:bg-primary/20"
onPress={onClick}
>
<Plus className="w-3.5 h-3.5" />
</Button>
</Card.Root>
);
}
// 单行版本项
function VersionRow({ entry, index = 0, onClick }: { entry: VersionEntry; index?: number; onClick: () => void }) {
return (
<Card.Root
onClick={onClick}
style={{ animationDelay: `${Math.min(index * 20, 200)}ms` }}
className="setting-page-enter group flex flex-row items-center gap-4 px-4 py-2.5 rounded-xl transition-all duration-150 cursor-pointer
bg-white/70 dark:bg-black/30 border border-border/30 dark:border-white/[0.06]
hover:border-primary/25 hover:bg-foreground/[0.02]"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-[13.5px] font-semibold text-foreground font-mono">{entry.id}</p>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium shrink-0">
{entry.type === "release" ? "正式版" : entry.type === "snapshot" ? "预览版" : entry.type}
</span>
</div>
<p className="text-[11.5px] text-muted-foreground/70 mt-0.5">
{formatTime(entry.releaseTime)}
</p>
</div>
{/* HeroUI Button 会拦截 click 冒泡,必须用 onPress 触发 */}
<Button
size="sm"
variant="secondary"
className="shrink-0 h-7 text-[12px] bg-primary/8 text-primary hover:bg-primary/15 opacity-0 group-hover:opacity-100"
onPress={onClick}
>
<Plus className="w-3 h-3" />
</Button>
</Card.Root>
);
}
+255
View File
@@ -0,0 +1,255 @@
// __ __ __ __ ______ __ __ ______ __ __ ______ ______
// /\ \ /\ \ /\ "-.\ \ /\ ___\ /\ \/ / /\ ___\ /\ "-.\ \ /\ ___\ /\__ _\
// \ \ \____ \ \ \ \ \ \-. \ \ \ \__ \ \ \ _"-. \ \ __\ \ \ \-. \ \ \ __\ \/_/\ \/
// \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ \ \_\ \_\ \ \_____\ \ \_\\"\_\ \ \_____\ \ \_\
// \/_____/ \/_/ \/_/ \/_/ \/_____/ \/_/\/_/ \/_____/ \/_/ \/_/ \/_____/ \/_/
//
// 所有权利归Lingke Network (china)所有 | dream_pep拥有其艺术改变权力
// 未经允许的情况下删除此版权头可能会受到民事指控
// 请勿在未经Lingke Network (china)允许的范围内修改代码并分发
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { Button, Input, Modal } from "@heroui/react";
import { Loader2, Box, Check } from "lucide-react";
import { toast } from "sonner";
import { useConfigStore } from "@/stores/configStore";
import { useTaskStore } from "@/stores/taskStore";
import type { InstanceRuntime } from "@/api/instance";
// 加载器选项
const LOADER_OPTIONS = [
{ value: "vanilla", label: "原版", desc: "纯净 Minecraft,无需模组加载器" },
{ value: "forge", label: "Forge", desc: "最经典的模组加载器" },
{ value: "neoforged", label: "NeoForge", desc: "Forge 的现代化分支,1.20+ 推荐" },
{ value: "fabric", label: "Fabric", desc: "轻量快速,适合性能优化与辅助模组" },
] as const;
type LoaderType = typeof LOADER_OPTIONS[number]["value"];
interface InstallDialogProps {
versionId: string | null;
onClose: () => void;
}
export function InstallDialog({ versionId, onClose }: InstallDialogProps) {
const gameDir = useConfigStore((s) => s.config.game.gameDir);
const { addSidecarTask } = useTaskStore();
const [step, setStep] = useState(0);
const [instanceName, setInstanceName] = useState("");
const [loader, setLoader] = useState<LoaderType>("vanilla");
const [loaderVersion, setLoaderVersion] = useState("latest");
const [installing, setInstalling] = useState(false);
// 关闭动画期间保留版本号,避免内容提前消失
const [displayVersion, setDisplayVersion] = useState<string | null>(null);
// 弹窗打开时用版本号预填名称
useEffect(() => {
if (versionId) {
setDisplayVersion(versionId);
setInstanceName(versionId);
setStep(0);
setLoader("vanilla");
setLoaderVersion("latest");
setInstalling(false);
}
}, [versionId]);
const steps = ["命名实例", "选择加载器", "加载器版本", "确认配置"];
// 点击"安装"→提交到任务队列
const handleInstall = () => {
if (!versionId) return;
const runtime: InstanceRuntime = { minecraft: versionId };
if (loader === "forge") runtime.forge = loaderVersion;
else if (loader === "fabric") runtime.fabricLoader = loaderVersion;
else if (loader === "neoforged") runtime.neoForged = loaderVersion;
setInstalling(true);
const label = loader === "vanilla" ? "原版" : LOADER_OPTIONS.find((o) => o.value === loader)?.label ?? loader;
const loaderInfo = loader === "vanilla" ? "" : ` (${label} ${loaderVersion})`;
// 加入任务队列
addSidecarTask({
type: "install",
title: `创建实例: ${instanceName}`,
description: `Minecraft ${versionId}${loaderInfo}`,
executorName: "install",
params: {
name: instanceName,
gamePath: gameDir,
runtime,
description: instanceName,
},
});
toast.success("已加入任务队列,可在任务面板查看进度");
onClose();
};
const valid = instanceName.trim().length > 0;
// Modal 通过 Portal 渲染到 body,确保全屏覆盖而不被内容区裁剪
return createPortal(
<Modal.Root isOpen={!!versionId} onOpenChange={(open) => { if (!open) onClose(); }}>
<Modal.Backdrop variant="blur" isDismissable>
<Modal.Container placement="center">
<Modal.Dialog className="rounded-2xl">{/* ... 内容不变 ... */}
<Modal.CloseTrigger />
{/* 头部 */}
<Modal.Header className="flex items-center gap-2">
<Box className="w-4.5 h-4.5 text-primary" />
<Modal.Heading className="text-base font-semibold">
Minecraft {displayVersion}
</Modal.Heading>
</Modal.Header>
<Modal.Body className="max-h-[55vh] overflow-y-auto scroll-area">
{/* 步骤指示器 */}
<div className="flex items-center gap-1.5 pb-4">
{steps.map((label, i) => (
<div key={i} className="flex items-center gap-1.5">
<div
className={`w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-bold shrink-0 transition-colors ${
i === step
? "bg-primary text-primary-foreground"
: i < step
? "bg-primary/20 text-primary"
: "bg-foreground/[0.06] dark:bg-white/[0.06] text-muted-foreground"
}`}
>
{i < step ? <Check className="w-3 h-3" /> : i + 1}
</div>
<span className={`text-[11px] ${i === step ? "text-foreground font-medium" : "text-muted-foreground"} hidden sm:inline`}>
{label}
</span>
{i < 3 && <div className="w-5 h-px bg-border/40 hidden sm:block" />}
</div>
))}
</div>
{/* 步骤内容 */}
<div className="space-y-5">
{step === 0 && (
<div className="space-y-3 setting-page-enter">
<p className="text-[13px] text-muted-foreground"></p>
<Input
fullWidth
placeholder="实例名称(如 survival-world"
value={instanceName}
onChange={(e) => setInstanceName(e.target.value.replace(/\s/g, "-").toLowerCase())}
autoFocus
/>
</div>
)}
{step === 1 && (
<div className="space-y-2 setting-page-enter">
<p className="text-[13px] text-muted-foreground mb-1"></p>
{LOADER_OPTIONS.map((opt) => {
const active = loader === opt.value;
return (
<button
key={opt.value}
onClick={() => setLoader(opt.value)}
className={`w-full text-left px-4 py-3 rounded-xl border transition-all ${
active
? "border-primary bg-primary/10"
: "border-border/40 dark:border-white/[0.06] hover:border-primary/30"
}`}
>
<div className="flex items-center justify-between">
<p className={`text-[13.5px] font-semibold ${active ? "text-primary" : "text-foreground"}`}>
{opt.label}
</p>
<Check className={`w-4 h-4 ${active ? "text-primary" : "text-transparent"}`} />
</div>
<p className="text-[11.5px] text-muted-foreground/70 mt-0.5">{opt.desc}</p>
</button>
);
})}
</div>
)}
{step === 2 && loader !== "vanilla" && (
<div className="space-y-3 setting-page-enter">
<p className="text-[13px] text-muted-foreground">
{LOADER_OPTIONS.find((o) => o.value === loader)?.label}
</p>
<Input
fullWidth
placeholder="输入版本号(如 latest、0.16.10"
value={loaderVersion}
onChange={(e) => setLoaderVersion(e.target.value)}
autoFocus
/>
<p className="text-[11px] text-muted-foreground/60">
使 "latest"
</p>
</div>
)}
{step === 2 && loader === "vanilla" && (
<div className="py-6 text-center setting-page-enter">
<Box className="w-8 h-8 mx-auto text-muted-foreground/30" />
<p className="text-[13px] text-muted-foreground mt-3"> Minecraft </p>
<p className="text-[12px] text-muted-foreground/60 mt-1"></p>
</div>
)}
{step === 3 && (
<div className="space-y-3 setting-page-enter">
<p className="text-[14px] font-semibold text-foreground text-center"></p>
<div className="rounded-xl border border-border/40 dark:border-white/[0.06] bg-foreground/[0.02] dark:bg-white/[0.02] divide-y divide-border/30 dark:divide-white/[0.05]">
<ConfigRow label="Minecraft 版本" value={displayVersion ?? ""} />
<ConfigRow label="实例名称" value={instanceName} />
<ConfigRow label="模组加载器" value={LOADER_OPTIONS.find((o) => o.value === loader)?.label ?? "原版"} />
{loader !== "vanilla" && (
<ConfigRow label="加载器版本" value={loaderVersion} />
)}
</div>
</div>
)}
</div>
</Modal.Body>
{/* 底部操作 */}
<Modal.Footer className="flex items-center justify-between">
<div>
{step > 0 && (
<Button variant="ghost" onPress={() => setStep(step - 1)}></Button>
)}
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" onPress={onClose}></Button>
{step < 3 ? (
<Button onPress={() => setStep(step + 1)} isDisabled={step === 0 ? !valid : false}>
</Button>
) : (
<Button onPress={handleInstall} isDisabled={installing || !valid}>
{installing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
</Button>
)}
</div>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal.Root>,
document.body,
);
}
function ConfigRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between px-4 py-2.5">
<span className="text-[12px] text-muted-foreground">{label}</span>
<span className="text-[13px] font-medium text-foreground">{value}</span>
</div>
);
}
+90
View File
@@ -0,0 +1,90 @@
// __ __ __ __ ______ __ __ ______ __ __ ______ ______
// /\ \ /\ \ /\ "-.\ \ /\ ___\ /\ \/ / /\ ___\ /\ "-.\ \ /\ ___\ /\__ _\
// \ \ \____ \ \ \ \ \ \-. \ \ \ \__ \ \ \ _"-. \ \ __\ \ \ \-. \ \ \ __\ \/_/\ \/
// \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ \ \_\ \_\ \ \_____\ \ \_\\"\_\ \ \_____\ \ \_\
// \/_____/ \/_/ \/_/ \/_/ \/_____/ \/_/\/_/ \/_____/ \/_/ \/_/ \/_____/ \/_/
//
// 所有权利归Lingke Network (china)所有 | dream_pep拥有其艺术改变权力
// 未经允许的情况下删除此版权头可能会受到民事指控
// 请勿在未经Lingke Network (china)允许的范围内修改代码并分发
import { Download, Package, ChevronRight } from "lucide-react";
import type { ModSearchResult } from "@/api/mods";
// 格式化下载量(如 1234567 -> 123.4万)
function formatDownloads(count: number): string {
if (count >= 100000000) return `${(count / 100000000).toFixed(1)}亿`;
if (count >= 10000) return `${(count / 10000).toFixed(1)}`;
return String(count);
}
interface ModCardProps {
mod: ModSearchResult;
/** 操作按钮文字(MOD:安装 / 整合包:查看) */
actionLabel?: string;
onOpen: (mod: ModSearchResult) => void;
}
// 横条式资源行(一行一个):左侧图标 + 中间信息 + 右侧操作
export function ModCard({ mod, actionLabel = "安装", onOpen }: ModCardProps) {
return (
<div
className="group relative flex items-center gap-4 px-4 py-3 rounded-xl transition-all duration-150
bg-white/85 dark:bg-black/45
border border-black/[0.06] dark:border-white/[0.07]
hover:shadow-md hover:border-primary/25 dark:hover:border-primary/25
cursor-pointer"
onClick={() => onOpen(mod)}
>
{/* 图标 */}
<div className="w-12 h-12 rounded-lg overflow-hidden bg-foreground/[0.05] dark:bg-white/[0.05] flex items-center justify-center shrink-0">
{mod.iconUrl ? (
<img
src={mod.iconUrl}
alt={mod.name}
loading="lazy"
className="w-full h-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
) : (
<Package className="w-5 h-5 text-muted-foreground/40" />
)}
</div>
{/* 中间信息 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-[13.5px] font-semibold text-foreground truncate">{mod.name}</p>
{mod.categories?.slice(0, 2).map((cat) => (
<span
key={cat}
className="text-[10px] px-1.5 py-0.5 rounded bg-foreground/[0.05] dark:bg-white/[0.05] text-muted-foreground/80 border border-border/30 dark:border-white/[0.05] shrink-0"
>
{cat}
</span>
))}
</div>
<p className="text-[12px] leading-relaxed text-muted-foreground/70 truncate mt-0.5">
{mod.description || "暂无描述"}
</p>
</div>
{/* 右侧:下载量 + 操作 */}
<div className="flex items-center gap-4 shrink-0">
<span className="flex items-center gap-1.5 text-[12px] text-muted-foreground/70">
<Download className="w-3.5 h-3.5" />
{formatDownloads(mod.downloads)}
</span>
<span
className="flex items-center gap-1 px-3.5 h-8 rounded-lg text-[12.5px] font-medium
bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
>
{actionLabel}
<ChevronRight className="w-3.5 h-3.5" />
</span>
</div>
</div>
);
}
+163
View File
@@ -0,0 +1,163 @@
// __ __ __ __ ______ __ __ ______ __ __ ______ ______
// /\ \ /\ \ /\ "-.\ \ /\ ___\ /\ \/ / /\ ___\ /\ "-.\ \ /\ ___\ /\__ _\
// \ \ \____ \ \ \ \ \ \-. \ \ \ \__ \ \ \ _"-. \ \ __\ \ \ \-. \ \ \ __\ \/_/\ \/
// \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ \ \_\ \_\ \ \_____\ \ \_\\"\_\ \ \_____\ \ \_\
// \/_____/ \/_/ \/_/ \/_/ \/_____/ \/_/\/_/ \/_____/ \/_/ \/_/ \/_____/ \/_/
//
// 所有权利归Lingke Network (china)所有 | dream_pep拥有其艺术改变权力
// 未经允许的情况下删除此版权头可能会受到民事指控
// 请勿在未经Lingke Network (china)允许的范围内修改代码并分发
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { Button } from "@heroui/react";
import { X, Loader2, Download, FolderOpen, Package } from "lucide-react";
import { toast } from "sonner";
import type { ModSearchResult, ModVersionResult } from "@/api/mods";
import { useModsStore } from "@/stores/modsStore";
import { useInstanceStore } from "@/stores/instanceStore";
import { useConfigStore } from "@/stores/configStore";
interface ModInstallDialogProps {
mod: ModSearchResult | null;
gameVersion?: string;
loader?: string;
onClose: () => void;
}
export function ModInstallDialog({ mod, gameVersion, loader, onClose }: ModInstallDialogProps) {
const { modVersions, getVersions, install, installing, clearError } = useModsStore();
const { instances, fetchInstances } = useInstanceStore();
const gameDir = useConfigStore((s) => s.config.game.gameDir);
const [selectedVersion, setSelectedVersion] = useState<ModVersionResult | null>(null);
const [selectedInstance, setSelectedInstance] = useState<string>("");
useEffect(() => {
if (!mod) return;
setSelectedVersion(null);
clearError();
getVersions(mod.id, gameVersion, loader, mod.source);
}, [mod, gameVersion, loader, getVersions, clearError]);
useEffect(() => {
if (!mod) return;
fetchInstances(gameDir);
}, [mod, gameDir, fetchInstances]);
useEffect(() => {
if (!selectedInstance && instances.length > 0) {
setSelectedInstance(instances[0].name);
}
}, [instances, selectedInstance]);
useEffect(() => {
if (!selectedVersion && modVersions.length > 0) {
setSelectedVersion(modVersions[0]);
}
}, [modVersions, selectedVersion]);
const visibleVersions = useMemo(() => {
if (!gameVersion && !loader) return modVersions;
return modVersions.filter((v) => {
const matchVersion = !gameVersion || v.gameVersions.includes(gameVersion);
const matchLoader = !loader || v.loaders.includes(loader);
return matchVersion && matchLoader;
});
}, [modVersions, gameVersion, loader]);
if (!mod) return null;
const handleInstall = async () => {
if (!selectedVersion) { toast.warning("请先选择一个 Mod 版本"); return; }
if (!selectedInstance) { toast.warning("请先选择一个目标实例"); return; }
const target = instances.find((i) => i.name === selectedInstance);
if (!target) { toast.warning("目标实例不存在"); return; }
await install(mod.id, selectedVersion.id, target.path, mod.source);
const { error } = useModsStore.getState();
if (error) { toast.error(`安装失败: ${error}`); clearError(); }
else { toast.success(`${mod.name}」已安装到 ${target.config.description || target.name}`); onClose(); }
};
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onClose} />
<div className="relative z-10 w-full max-w-xl mx-4 bg-background border border-border/50 dark:border-white/[0.08] rounded-2xl shadow-2xl overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-border/30 dark:border-white/[0.04]">
<div className="flex items-center gap-3 min-w-0">
{mod.iconUrl ? (
<img src={mod.iconUrl} alt={mod.name} className="w-9 h-9 rounded-lg object-cover shrink-0"
onError={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
) : (
<div className="w-9 h-9 rounded-lg bg-foreground/[0.05] dark:bg-white/[0.05] flex items-center justify-center shrink-0">
<Package className="w-4.5 h-4.5 text-muted-foreground/50" />
</div>
)}
<div className="min-w-0">
<h2 className="text-base font-semibold text-foreground truncate">{mod.name}</h2>
<p className="text-[11px] text-muted-foreground truncate">{mod.description}</p>
</div>
</div>
<button onClick={onClose} className="p-1 rounded-md hover:bg-foreground/[0.06] transition-colors shrink-0"><X className="w-4 h-4 text-muted-foreground" /></button>
</div>
<div className="px-6 py-5 space-y-5 max-h-[55vh] overflow-y-auto scroll-area">
<div>
<p className="text-sm font-medium text-foreground mb-2.5"></p>
{modVersions.length === 0 ? (
<div className="flex items-center justify-center py-8 text-muted-foreground/60 text-sm">...</div>
) : visibleVersions.length === 0 ? (
<div className="py-6 text-center text-[13px] text-muted-foreground"></div>
) : (
<div className="space-y-1.5 max-h-56 overflow-y-auto scroll-area pr-1">
{visibleVersions.slice(0, 12).map((v) => {
const active = selectedVersion?.id === v.id;
return (
<button key={v.id} onClick={() => setSelectedVersion(v)}
className={`w-full text-left px-3 py-2 rounded-lg border transition-all ${active ? "border-primary bg-primary/10" : "border-border/40 dark:border-white/[0.06] hover:border-primary/30"}`}>
<div className="flex items-center justify-between gap-2">
<span className={`text-[13px] font-medium truncate ${active ? "text-primary" : "text-foreground"}`}>{v.versionNumber || v.name}</span>
{v.loaders.length > 0 && <span className="text-[10px] px-1.5 py-0.5 rounded bg-foreground/[0.05] text-muted-foreground shrink-0">{v.loaders.join("/")}</span>}
</div>
{v.gameVersions.length > 0 && <p className="text-[10.5px] text-muted-foreground/70 mt-0.5 truncate">: {v.gameVersions.slice(0, 6).join(", ")}</p>}
</button>
);
})}
</div>
)}
</div>
<div>
<p className="text-sm font-medium text-foreground mb-2.5"></p>
{instances.length === 0 ? (
<div className="flex items-center justify-center gap-2 py-6 text-muted-foreground/60 text-[13px]"><FolderOpen className="w-4 h-4" /></div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
{instances.map((inst) => {
const active = selectedInstance === inst.name;
return (
<button key={inst.name} onClick={() => setSelectedInstance(inst.name)}
className={`text-left px-3 py-2 rounded-lg border transition-all ${active ? "border-primary bg-primary/10" : "border-border/40 dark:border-white/[0.06] hover:border-primary/30"}`}>
<p className={`text-[13px] font-medium truncate ${active ? "text-primary" : "text-foreground"}`}>{inst.config.description || inst.name}</p>
<p className="text-[10.5px] text-muted-foreground/70 mt-0.5 truncate">{inst.config.runtime.minecraft} · {inst.name}</p>
</button>
);
})}
</div>
)}
</div>
</div>
<div className="flex items-center justify-between px-6 py-4 border-t border-border/30 dark:border-white/[0.04]">
<p className="text-[12px] text-muted-foreground/70">{selectedVersion?.files[0]?.filename ? `文件: ${selectedVersion.files[0].filename}` : "选择版本后点击安装"}</p>
<div className="flex items-center gap-2">
<Button variant="ghost" onPress={onClose}></Button>
<Button onPress={handleInstall} isDisabled={installing || !selectedVersion || !selectedInstance}>
{installing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Download className="w-3.5 h-3.5" />}
{installing ? "安装中..." : "安装"}
</Button>
</div>
</div>
</div>
</div>,
document.body,
);
}
+354
View File
@@ -0,0 +1,354 @@
// __ __ __ __ ______ __ __ ______ __ __ ______ ______
// /\ \ /\ \ /\ "-.\ \ /\ ___\ /\ \/ / /\ ___\ /\ "-.\ \ /\ ___\ /\__ _\
// \ \ \____ \ \ \ \ \ \-. \ \ \ \__ \ \ \ _"-. \ \ __\ \ \ \-. \ \ \ __\ \/_/\ \/
// \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ \ \_\ \_\ \ \_____\ \ \_\\"\_\ \ \_____\ \ \_\
// \/_____/ \/_/ \/_/ \/_/ \/_____/ \/_/\/_/ \/_____/ \/_/ \/_/ \/_____/ \/_/
//
// 所有权利归Lingke Network (china)所有 | dream_pep拥有其艺术改变权力
// 未经允许的情况下删除此版权头可能会受到民事指控
// 请勿在未经Lingke Network (china)允许的范围内修改代码并分发
import { useCallback, useEffect, useRef, useState } from "react";
import { Button, ListBox, ListBoxItem, Select, Skeleton } from "@heroui/react";
import { Search, ChevronDown, RotateCcw, Package, XCircle, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import { useModsStore } from "@/stores/modsStore";
import { getCategories, getGameVersions } from "@/api/mods";
import type { ModCategory, ModSearchResult } from "@/api/mods";
import { ModCard } from "./ModCard";
import { ModInstallDialog } from "./ModInstallDialog";
// 加载器筛选选项(对应 Modrinth categories 值)
const LOADER_OPTIONS = [
{ value: "fabric", label: "Fabric" },
{ value: "forge", label: "Forge" },
{ value: "quilt", label: "Quilt" },
{ value: "neoforged", label: "NeoForge" },
];
interface ResourceBrowseViewProps {
projectType: "mod" | "modpack";
/** 是否支持安装(整合包暂不支持安装) */
canInstall: boolean;
/** 外层滚动容器(用于无限滚动检测) */
scrollRef: React.RefObject<HTMLElement | null>;
}
export function ResourceBrowseView({ projectType, canInstall, scrollRef }: ResourceBrowseViewProps) {
const { searchResults, total, hasMore, loading, error, search, clear } = useModsStore();
// 页面筛选状态
const [query, setQuery] = useState("");
const [gameVersion, setGameVersion] = useState<string | undefined>();
const [loader, setLoader] = useState<string | undefined>();
const [category, setCategory] = useState<string | undefined>();
// 筛选选项数据
const [categories, setCategories] = useState<ModCategory[]>([]);
const [gameVersions, setGameVersions] = useState<string[]>([]);
// 安装弹窗
const [activeMod, setActiveMod] = useState<ModSearchResult | null>(null);
// 加载更多状态
const [loadingMore, setLoadingMore] = useState(false);
// 用 ref 保存最新筛选值,供无限滚动读取(避免闭包过期)
const filtersRef = useRef({ query, gameVersion, loader, category });
filtersRef.current = { query, gameVersion, loader, category };
const projectTypeRef = useRef(projectType);
projectTypeRef.current = projectType;
const loadingMoreRef = useRef(false);
const doSearch = useCallback((params: Parameters<typeof search>[0]) => search(params), [search]);
// 分类切换:重置筛选与结果
useEffect(() => {
clear();
setQuery("");
setGameVersion(undefined);
setLoader(undefined);
setCategory(undefined);
setActiveMod(null);
getCategories(projectType).then(setCategories).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectType]);
// 初始化游戏版本选项
useEffect(() => {
getGameVersions().then(setGameVersions).catch(() => {});
}, []);
// 搜索防抖(300ms),筛选变化时回到第一页
useEffect(() => {
const timer = setTimeout(() => {
doSearch({ query, gameVersion, loader, category, projectType: projectTypeRef.current, page: 1 });
}, 300);
return () => clearTimeout(timer);
}, [query, gameVersion, loader, category, doSearch]);
// 无限滚动:滚动到底部附近时加载下一页
const loadMore = useCallback(() => {
const state = useModsStore.getState();
if (!state.hasMore || loadingMoreRef.current || state.loading) return;
loadingMoreRef.current = true;
setLoadingMore(true);
const f = filtersRef.current;
state
.search({
query: f.query,
gameVersion: f.gameVersion,
loader: f.loader,
category: f.category,
projectType: projectTypeRef.current,
page: state.currentPage + 1,
append: true,
})
.finally(() => {
loadingMoreRef.current = false;
setLoadingMore(false);
});
}, []);
useEffect(() => {
const el = scrollRef?.current;
if (!el) return;
const onScroll = () => {
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 300) loadMore();
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
}, [scrollRef, loadMore]);
const hasFilter = Boolean(query || gameVersion || loader || category);
// 重置全部筛选
const handleReset = () => {
setQuery("");
setGameVersion(undefined);
setLoader(undefined);
setCategory(undefined);
};
// Select 选项变化处理(RAC Selection 转为值)
const handleSelectChange = (setter: (v: string | undefined) => void) => (keys: unknown) => {
const set = keys as Set<string>;
const value = set.size > 0 ? Array.from(set)[0] : "all";
setter(value === "all" ? undefined : value);
};
// 卡片点击:MOD 打开安装弹窗;整合包暂不支持安装
const handleCardOpen = (mod: ModSearchResult) => {
if (canInstall) {
setActiveMod(mod);
} else {
toast.info("整合包安装功能开发中,敬请期待");
}
};
const title = projectType === "mod" ? "MOD" : "整合包";
const description = projectType === "mod"
? "从 Modrinth 搜索 Mod,一键安装到你的实例"
: "浏览社区整合包(安装功能开发中)";
return (
<div className="min-h-full">
{/* 页头:标题 + 搜索框 */}
<div className="flex items-center justify-between gap-4 mb-5">
<div className="min-w-0">
<h1 className="text-xl font-bold text-foreground">{title}</h1>
<p className="text-[13px] text-muted-foreground mt-1">{description}</p>
</div>
<div className="relative w-72 shrink-0">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground/50" />
<Input
className="h-9 pl-9 pr-3 rounded-xl"
placeholder={`搜索${title}...`}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
</div>
{/* 筛选区:游戏版本 / 加载器 / 分类 */}
<div className="flex items-center gap-2.5 flex-wrap mb-5">
<Select.Root
selectedKey={gameVersion ?? "all"}
onSelectionChange={handleSelectChange(setGameVersion)}
size="sm"
className="w-40"
>
<Select.Trigger className="h-8 rounded-lg border border-border/40 dark:border-white/[0.08] bg-white/60 dark:bg-black/30 px-3 hover:border-primary/30 transition-colors">
<Select.Value className="text-[13px] text-foreground">
{gameVersion ? `Minecraft ${gameVersion}` : "全部版本"}
</Select.Value>
<Select.Indicator>
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
</Select.Indicator>
</Select.Trigger>
<Select.Popover className="z-50 rounded-xl border border-border/50 dark:border-white/[0.08] bg-background shadow-xl p-1.5 w-44">
<ListBox className="max-h-72 overflow-y-auto scroll-area outline-none">
<ListBoxItem id="all" className="text-[13px] py-1.5 px-2.5 rounded-lg data-[selected=true]:bg-primary/10 data-[selected=true]:text-primary outline-none cursor-pointer">
</ListBoxItem>
{gameVersions.slice(0, 30).map((v) => (
<ListBoxItem key={v} id={v} className="text-[13px] py-1.5 px-2.5 rounded-lg data-[selected=true]:bg-primary/10 data-[selected=true]:text-primary outline-none cursor-pointer">
{v}
</ListBoxItem>
))}
</ListBox>
</Select.Popover>
</Select.Root>
<Select.Root
selectedKey={loader ?? "all"}
onSelectionChange={handleSelectChange(setLoader)}
size="sm"
className="w-36"
>
<Select.Trigger className="h-8 rounded-lg border border-border/40 dark:border-white/[0.08] bg-white/60 dark:bg-black/30 px-3 hover:border-primary/30 transition-colors">
<Select.Value className="text-[13px] text-foreground">
{loader ? LOADER_OPTIONS.find((o) => o.value === loader)?.label ?? loader : "全部加载器"}
</Select.Value>
<Select.Indicator>
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
</Select.Indicator>
</Select.Trigger>
<Select.Popover className="z-50 rounded-xl border border-border/50 dark:border-white/[0.08] bg-background shadow-xl p-1.5 w-40">
<ListBox className="outline-none">
<ListBoxItem id="all" className="text-[13px] py-1.5 px-2.5 rounded-lg data-[selected=true]:bg-primary/10 data-[selected=true]:text-primary outline-none cursor-pointer">
</ListBoxItem>
{LOADER_OPTIONS.map((o) => (
<ListBoxItem key={o.value} id={o.value} className="text-[13px] py-1.5 px-2.5 rounded-lg data-[selected=true]:bg-primary/10 data-[selected=true]:text-primary outline-none cursor-pointer">
{o.label}
</ListBoxItem>
))}
</ListBox>
</Select.Popover>
</Select.Root>
<Select.Root
selectedKey={category ?? "all"}
onSelectionChange={handleSelectChange(setCategory)}
size="sm"
className="w-40"
>
<Select.Trigger className="h-8 rounded-lg border border-border/40 dark:border-white/[0.08] bg-white/60 dark:bg-black/30 px-3 hover:border-primary/30 transition-colors">
<Select.Value className="text-[13px] text-foreground">
{category ? categories.find((c) => c.name === category)?.label ?? category : "全部分类"}
</Select.Value>
<Select.Indicator>
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
</Select.Indicator>
</Select.Trigger>
<Select.Popover className="z-50 rounded-xl border border-border/50 dark:border-white/[0.08] bg-background shadow-xl p-1.5 w-44">
<ListBox className="max-h-72 overflow-y-auto scroll-area outline-none">
<ListBoxItem id="all" className="text-[13px] py-1.5 px-2.5 rounded-lg data-[selected=true]:bg-primary/10 data-[selected=true]:text-primary outline-none cursor-pointer">
</ListBoxItem>
{categories.map((c) => (
<ListBoxItem key={c.name} id={c.name} className="text-[13px] py-1.5 px-2.5 rounded-lg data-[selected=true]:bg-primary/10 data-[selected=true]:text-primary outline-none cursor-pointer">
{c.label}
</ListBoxItem>
))}
</ListBox>
</Select.Popover>
</Select.Root>
{/* 结果计数 + 重置 */}
<span className="text-[12px] text-muted-foreground/70 ml-1">
{total}
</span>
{hasFilter && (
<Button size="sm" variant="ghost" className="h-8 min-w-0" onPress={handleReset}>
<RotateCcw className="w-3.5 h-3.5" />
</Button>
)}
</div>
{/* 错误提示 */}
{error && (
<div className="flex items-center gap-2 px-4 py-3 mb-4 rounded-lg bg-destructive/10 border border-destructive/20 text-destructive text-[13px]">
<XCircle className="w-4 h-4 shrink-0" />
<span className="flex-1 min-w-0">{error}</span>
<button
className="text-[12px] underline underline-offset-2 shrink-0"
onClick={() => doSearch({ query, gameVersion, loader, category, projectType: projectTypeRef.current, page: 1 })}
>
</button>
</div>
)}
{/* 列表(无限滚动拼接,一行一条横条) */}
{loading && searchResults.length === 0 ? (
<div className="flex flex-col gap-2.5">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex items-center gap-4 rounded-xl border border-border/30 dark:border-white/[0.06] p-3">
<Skeleton className="w-12 h-12 rounded-lg shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-3.5 w-1/3" />
<Skeleton className="h-3 w-2/3" />
</div>
<Skeleton className="h-8 w-20 rounded-lg shrink-0" />
</div>
))}
</div>
) : !loading && searchResults.length === 0 ? (
<div className="flex flex-col items-center gap-4 py-24">
<div className="w-16 h-16 rounded-full bg-foreground/[0.04] dark:bg-white/[0.04] flex items-center justify-center">
<Package className="w-8 h-8 text-muted-foreground/30" />
</div>
<div className="text-center">
<p className="text-sm text-muted-foreground">{hasFilter ? "没有找到匹配的资源" : "暂无资源"}</p>
<p className="text-[12px] text-muted-foreground/60 mt-1">
{hasFilter ? "尝试调整搜索关键词或筛选条件" : "搜索你想要的资源"}
</p>
</div>
{hasFilter && (
<Button size="sm" variant="secondary" onPress={handleReset}>
</Button>
)}
</div>
) : (
<>
<div className="flex flex-col gap-2.5">
{searchResults.map((mod) => (
<ModCard
key={mod.id}
mod={mod}
actionLabel={canInstall ? "安装" : "查看"}
onOpen={handleCardOpen}
/>
))}
</div>
{/* 底部加载状态 */}
<div className="flex items-center justify-center py-8 text-[13px] text-muted-foreground/60">
{loadingMore ? (
<span className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
...
</span>
) : !hasMore && searchResults.length > 0 ? (
<span></span>
) : null}
</div>
</>
)}
{/* 安装弹窗(仅 MOD */}
{canInstall && (
<ModInstallDialog
mod={activeMod}
gameVersion={gameVersion}
loader={loader}
onClose={() => setActiveMod(null)}
/>
)}
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
// __ __ __ __ ______ __ __ ______ __ __ ______ ______
// /\ \ /\ \ /\ "-.\ \ /\ ___\ /\ \/ / /\ ___\ /\ "-.\ \ /\ ___\ /\__ _\
// \ \ \____ \ \ \ \ \ \-. \ \ \ \__ \ \ \ _"-. \ \ __\ \ \ \-. \ \ \ __\ \/_/\ \/
// \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ \ \_\ \_\ \ \_____\ \ \_\\"\_\ \ \_____\ \ \_\
// \/_____/ \/_/ \/_/ \/_/ \/_____/ \/_/\/_/ \/_____/ \/_/ \/_/ \/_____/ \/_/
//
// 所有权利归Lingke Network (china)所有 | dream_pep拥有其艺术改变权力
// 未经允许的情况下删除此版权头可能会受到民事指控
// 请勿在未经Lingke Network (china)允许的范围内修改代码并分发
import { Box, Puzzle, Package } from "lucide-react";
import clsx from "clsx";
// 侧边栏分类类型
export type StoreSection = "game" | "mod" | "modpack";
const iconCls = "w-[18px] h-[18px] shrink-0";
interface SidebarProps {
section: StoreSection;
onSelect: (section: StoreSection) => void;
}
export function Sidebar({ section, onSelect }: SidebarProps) {
return (
<aside className="scroll-area w-[220px] shrink-0 h-full overflow-y-auto py-5 pl-4 pr-2">
<nav className="space-y-6">
{/* 原版游戏(独立入口) */}
<div>
<div className="space-y-0.5">
<SidebarItem
active={section === "game"}
icon={<Box className={iconCls} />}
label="原版游戏"
onClick={() => onSelect("game")}
/>
</div>
</div>
{/* 社区资源 */}
<div>
<div className="mx-3 mb-3 border-t border-border/40" />
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-foreground/40 mb-2 px-3">
</h3>
<div className="space-y-0.5">
<SidebarItem
active={section === "mod"}
icon={<Puzzle className={iconCls} />}
label="MOD"
onClick={() => onSelect("mod")}
/>
<SidebarItem
active={section === "modpack"}
icon={<Package className={iconCls} />}
label="整合包"
onClick={() => onSelect("modpack")}
/>
</div>
</div>
</nav>
</aside>
);
}
// 单个侧边栏项(样式与设置页一致)
function SidebarItem({
active,
icon,
label,
onClick,
}: {
active: boolean;
icon: React.ReactNode;
label: string;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className={clsx(
"relative w-full flex items-center gap-2.5 px-3 py-2 rounded-md text-[13.5px] transition-all duration-150",
active ? "text-foreground font-medium" : "text-foreground/50 hover:text-foreground/80 hover:bg-foreground/[0.04]",
)}
>
{active && (
<span className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-[16px] rounded-full bg-primary" />
)}
<span className={active ? "text-foreground/80" : "text-foreground/35"}>{icon}</span>
{label}
</button>
);
}
+75 -2
View File
@@ -1,5 +1,78 @@
import { UnderConstruction } from "@/components/UnderConstruction";
// __ __ __ __ ______ __ __ ______ __ __ ______ ______
// /\ \ /\ \ /\ "-.\ \ /\ ___\ /\ \/ / /\ ___\ /\ "-.\ \ /\ ___\ /\__ _\
// \ \ \____ \ \ \ \ \ \-. \ \ \ \__ \ \ \ _"-. \ \ __\ \ \ \-. \ \ \ __\ \/_/\ \/
// \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ \ \_\ \_\ \ \_____\ \ \_\\"\_\ \ \_____\ \ \_\
// \/_____/ \/_/ \/_/ \/_/ \/_____/ \/_/\/_/ \/_____/ \/_/ \/_/ \/_____/ \/_/
//
// 所有权利归Lingke Network (china)所有 | dream_pep拥有其艺术改变权力
// 未经允许的情况下删除此版权头可能会受到民事指控
// 请勿在未经Lingke Network (china)允许的范围内修改代码并分发
import { useEffect, useRef, useState } from "react";
import { ArrowUp } from "lucide-react";
import { useRouteStore } from "@/stores/routeStore";
import { Sidebar } from "./Sidebar";
import { GameVersionView } from "./GameVersionView";
import { ResourceBrowseView } from "./ResourceBrowseView";
export function Store() {
return <UnderConstruction pageName="资源" description="Mod / 整合包 / 材质包,敬请期待" />;
const section = useRouteStore((s) => s.storeSection);
const setStoreSection = useRouteStore((s) => s.setStoreSection);
// 内容区滚动容器
const mainRef = useRef<HTMLElement | null>(null);
const [showTop, setShowTop] = useState(false);
// 监听内容区滚动,非顶部时显示"返回顶部"按钮
useEffect(() => {
const el = mainRef.current;
if (!el) return;
const onScroll = () => setShowTop(el.scrollTop > 300);
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
}, []);
const scrollToTop = () => {
mainRef.current?.scrollTo({ top: 0, behavior: "smooth" });
};
return (
<div className="flex h-full">
{/* 左侧边栏(与设置页同款样式) */}
<Sidebar section={section} onSelect={setStoreSection} />
{/* 右侧内容区(唯一滚动容器,支持无限滚动与返回顶部) */}
<main ref={mainRef} className="scroll-area flex-1 h-full min-w-0 overflow-y-auto p-6">
<div key={section} className="setting-page-enter h-full">
{section === "game" ? (
<GameVersionView />
) : (
<ResourceBrowseView
projectType={section}
canInstall={section === "mod"}
scrollRef={mainRef}
/>
)}
</div>
{/* 返回顶部按钮(非顶部时显示) */}
{showTop && (
<button
onClick={scrollToTop}
aria-label="返回顶部"
className="fixed bottom-6 right-6 z-50 w-10 h-10 rounded-full
bg-background/85 backdrop-blur-xl border border-border/50
shadow-lg shadow-black/10 dark:shadow-black/30
flex items-center justify-center text-muted-foreground hover:text-foreground
hover:border-primary/40 transition-all duration-200 cursor-pointer"
>
<ArrowUp className="w-4.5 h-4.5" />
</button>
)}
</main>
</div>
);
}
// 供外部(实例管理)跳转资源中心分类使用
export type { StoreSection } from "./Sidebar";
+1 -1
View File
@@ -50,7 +50,7 @@ const DEFAULT_CONFIG: AppConfig = {
theme: { darkMode: "auto", parallax: true },
a11y: { reduceMotion: false, reduceTransparency: false, highContrast: false, contentBlurOpacity: 50 },
background: { bgType: "image", image: DEFAULT_BG, 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 },
download: { fileSource: "mirror", versionSource: "mirror", threads: 16, speedLimit: 0 },
+69 -15
View File
@@ -1,20 +1,43 @@
// __ __ __ __ ______ __ __ ______ __ __ ______ ______
// /\ \ /\ \ /\ "-.\ \ /\ ___\ /\ \/ / /\ ___\ /\ "-.\ \ /\ ___\ /\__ _\
// \ \ \____ \ \ \ \ \ \-. \ \ \ \__ \ \ \ _"-. \ \ __\ \ \ \-. \ \ \ __\ \/_/\ \/
// \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ \ \_\ \_\ \ \_____\ \ \_\\"\_\ \ \_____\ \ \_\
// \/_____/ \/_/ \/_/ \/_/ \/_____/ \/_/\/_/ \/_____/ \/_/ \/_/ \/_____/ \/_/
//
// 所有权利归Lingke Network (china)所有 | dream_pep拥有其艺术改变权力
// 未经允许的情况下删除此版权头可能会受到民事指控
// 请勿在未经Lingke Network (china)允许的范围内修改代码并分发
import { create } from "zustand";
import { searchMods, getModDetail, getModVersions, installMod } from "../api/mods";
import type { ModSearchResult, ModVersionResult } from "../api/mods";
import type { ModSearchResult, ModVersionResult, ModSearchResponse } from "../api/mods";
export interface ModSearchParams {
query?: string;
gameVersion?: string;
loader?: string;
category?: string;
projectType?: string;
page?: number;
pageSize?: number;
source?: "modrinth" | "curseforge";
/** 为 true 时将结果追加到已有列表(用于无限滚动),否则替换 */
append?: boolean;
}
interface ModsState {
searchResults: ModSearchResult[];
total: number;
currentPage: number;
pageSize: number;
hasMore: boolean;
currentMod: ModSearchResult | null;
modVersions: ModVersionResult[];
loading: boolean;
installing: boolean;
error: string | null;
search: (
query?: string,
gameVersion?: string,
loader?: string,
source?: "modrinth" | "curseforge"
) => Promise<void>;
search: (params?: ModSearchParams) => Promise<ModSearchResponse | null>;
getDetail: (projectId: string, source: "modrinth" | "curseforge") => Promise<void>;
getVersions: (
projectId: string,
@@ -32,20 +55,48 @@ interface ModsState {
clear: () => void;
}
export const useModsStore = create<ModsState>((set) => ({
export const useModsStore = create<ModsState>((set, get) => ({
searchResults: [],
total: 0,
currentPage: 1,
pageSize: 12,
hasMore: false,
currentMod: null,
modVersions: [],
loading: false,
installing: false,
error: null,
search: async (query?, gameVersion?, loader?, source = "modrinth") => {
set({ loading: true, error: null });
search: async (params = {}) => {
const {
query,
gameVersion,
loader,
category,
projectType = "mod",
page = 1,
pageSize = get().pageSize,
source = "modrinth",
append = false,
} = params;
// 追加模式不需要整体 loading(保持旧列表可见)
if (!append) set({ loading: true, error: null });
try {
const results = await searchMods(query, gameVersion, loader, 20, 0, source);
set({ searchResults: results, loading: false });
const offset = (page - 1) * pageSize;
const response = await searchMods(query, gameVersion, loader, category, projectType, pageSize, offset, source);
set((state) => ({
searchResults: append ? [...state.searchResults, ...response.hits] : response.hits,
total: response.total,
currentPage: page,
pageSize,
hasMore: state.searchResults.length + response.hits.length < response.total,
loading: false,
}));
return response;
} catch (e: any) {
set({ error: e.message, loading: false });
return null;
}
},
@@ -70,12 +121,12 @@ export const useModsStore = create<ModsState>((set) => ({
},
install: async (projectId, versionId, gamePath, source = "modrinth") => {
set({ loading: true, error: null });
set({ installing: true, error: null });
try {
await installMod(projectId, versionId, gamePath, source);
set({ loading: false });
set({ installing: false });
} catch (e: any) {
set({ error: e.message, loading: false });
set({ error: e.message, installing: false });
}
},
@@ -84,6 +135,9 @@ export const useModsStore = create<ModsState>((set) => ({
clear: () =>
set({
searchResults: [],
total: 0,
currentPage: 1,
hasMore: false,
currentMod: null,
modVersions: [],
}),
+7
View File
@@ -18,6 +18,7 @@ export type RouteKey =
| "oobe/welcome"
| "oobe/finish"
| "oobe/about-info"
| "oobe/legal"
| "debug"
| "debug-splash"
| "debug-display"
@@ -59,6 +60,7 @@ export const allRoutes: RouteItem[] = [
{ key: "oobe/welcome", label: "欢迎", path: "/oobe/welcome", hidden: true },
{ key: "oobe/finish", label: "完成", path: "/oobe/finish", hidden: true },
{ key: "oobe/about-info", label: "关于信息", path: "/oobe/about-info", hidden: true, backable: true },
{ key: "oobe/legal", label: "法律信息", path: "/oobe/legal", hidden: true },
{ key: "debug", label: "调试", path: "/debug", hidden: true },
{ key: "debug-splash", label: "启动动画调试", path: "/debug/splash", hidden: true },
{ key: "debug-display", label: "显示效果调试", path: "/debug/display", hidden: true },
@@ -87,8 +89,11 @@ interface RouteState {
titleBarMode: TitleBarMode;
direction: TransitionDirection;
history: RouteKey[];
/** 资源中心选中的分类(原版游戏 / MOD / 整合包) */
storeSection: "game" | "mod" | "modpack";
navigate: (key: RouteKey) => void;
setTitleBarMode: (mode: TitleBarMode) => void;
setStoreSection: (section: "game" | "mod" | "modpack") => void;
goBack: () => void;
}
@@ -97,6 +102,7 @@ export const useRouteStore = create<RouteState>((set, get) => ({
titleBarMode: "default",
direction: "forward",
history: [],
storeSection: "game",
navigate: (key) => {
const prev = get().current;
if (prev === key) return;
@@ -110,6 +116,7 @@ export const useRouteStore = create<RouteState>((set, get) => ({
});
},
setTitleBarMode: (mode) => set({ titleBarMode: mode }),
setStoreSection: (section) => set({ storeSection: section }),
goBack: () => {
const { history } = get();
if (history.length === 0) return;
+1 -1
View File
@@ -3,7 +3,7 @@
"target": "ES2020",
"module": "commonjs",
"moduleResolution": "node",
"outDir": "electron",
"outDir": "electron-dist",
"rootDir": "electron",
"strict": true,
"esModuleInterop": true,