mirror of
https://github.com/dream-pep/koring-launcher.git
synced 2026-09-12 05:45:18 +08:00
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:
@@ -1,2 +1,2 @@
|
|||||||
VITE_BUILD_MODE=beta
|
VITE_BUILD_MODE=beta
|
||||||
VITE_APP_ICON=/beta.png
|
VITE_APP_ICON=/icons/beta/icon.png
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
VITE_BUILD_MODE=dev
|
VITE_BUILD_MODE=dev
|
||||||
VITE_APP_ICON=/dev.png
|
VITE_APP_ICON=/icons/dev/icon.png
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
VITE_BUILD_MODE=run
|
VITE_BUILD_MODE=run
|
||||||
VITE_APP_ICON=/run.png
|
VITE_APP_ICON=/icons/run/icon.png
|
||||||
|
|||||||
+1
-2
@@ -25,8 +25,7 @@ dist-electron
|
|||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
# Electron
|
# Electron
|
||||||
electron/*.js
|
electron-dist/
|
||||||
electron/*.d.ts
|
|
||||||
|
|
||||||
# Build resources (generated by switch-icon.js)
|
# Build resources (generated by switch-icon.js)
|
||||||
build/
|
build/
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "123",
|
|
||||||
"author": "",
|
|
||||||
"description": "123",
|
|
||||||
"runtime": {
|
|
||||||
"minecraft": "1.21.4"
|
|
||||||
},
|
|
||||||
"creationDate": 1785342079436,
|
|
||||||
"lastAccessDate": 1785342079436,
|
|
||||||
"lastPlayedDate": 0,
|
|
||||||
"playtime": 0
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# __ __ __ __ ______ __ __ ______ __ __ ______ ______
|
||||||
|
# /\ \ /\ \ /\ "-.\ \ /\ ___\ /\ \/ / /\ ___\ /\ "-.\ \ /\ ___\ /\__ _\
|
||||||
|
# \ \ \____ \ \ \ \ \ \-. \ \ \ \__ \ \ \ _"-. \ \ __\ \ \ \-. \ \ \ __\ \/_/\ \/
|
||||||
|
# \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ \ \_\ \_\ \ \_____\ \ \_\\"\_\ \ \_____\ \ \_\
|
||||||
|
# \/_____/ \/_/ \/_/ \/_/ \/_____/ \/_/\/_/ \/_____/ \/_/ \/_/ \/_____/ \/_/
|
||||||
|
#
|
||||||
|
# 所有权利归Lingke Network (china)所有 | dream_pep拥有其艺术改变权力
|
||||||
|
# 未经允许的情况下删除此版权头可能会受到民事指控
|
||||||
|
# 请勿在未经Lingke Network (china)允许的范围内修改代码并分发
|
||||||
|
#
|
||||||
|
# 项目包管理配置:electron 二进制下载走国内镜像,避免 GitHub 网络不可达
|
||||||
|
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
oobe: false
|
||||||
|
game:
|
||||||
|
gameDirs: []
|
||||||
|
|||||||
+11
-10
@@ -3,14 +3,16 @@ productName: Koring Launcher
|
|||||||
directories:
|
directories:
|
||||||
output: dist-electron
|
output: dist-electron
|
||||||
buildResources: build
|
buildResources: build
|
||||||
|
compression: maximum
|
||||||
|
asar: true
|
||||||
files:
|
files:
|
||||||
- dist/**
|
- dist/**
|
||||||
- electron/**/*.js
|
- electron-dist/**/*.js
|
||||||
- "!electron/**/*.ts"
|
|
||||||
win:
|
win:
|
||||||
target: nsis
|
target: nsis
|
||||||
icon: build/icon.ico
|
icon: build/icon.ico
|
||||||
artifactName: "koring-launcher-${version}-setup.${ext}"
|
artifactName: "koring-launcher-${version}-setup.${ext}"
|
||||||
|
requestedExecutionLevel: asInvoker
|
||||||
mac:
|
mac:
|
||||||
target: dmg
|
target: dmg
|
||||||
icon: build/icon.png
|
icon: build/icon.png
|
||||||
@@ -23,16 +25,15 @@ nsis:
|
|||||||
installerIcon: build/icon.ico
|
installerIcon: build/icon.ico
|
||||||
uninstallerIcon: build/icon.ico
|
uninstallerIcon: build/icon.ico
|
||||||
installerHeaderIcon: build/icon.ico
|
installerHeaderIcon: build/icon.ico
|
||||||
installerHeader: build/installer-header.png
|
installerSidebar: build/installer-header.bmp
|
||||||
license: build/license.rtf
|
license: build/license.txt
|
||||||
createDesktopShortcut: true
|
createDesktopShortcut: true
|
||||||
createStartMenuShortcut: true
|
createStartMenuShortcut: true
|
||||||
shortcutName: Koring Launcher
|
shortcutName: Koring Launcher
|
||||||
include: build/installer-custom.nsh
|
include: build/installer-custom.nsh
|
||||||
script: build/installer-strings.nsh
|
uninstallDisplayName: "Koring Launcher"
|
||||||
uninstallDisplayName: "卸载 Koring Launcher"
|
deleteAppDataOnUninstall: false
|
||||||
displayLanguageSelector: true
|
|
||||||
installerLanguages:
|
installerLanguages:
|
||||||
- 2052
|
- "zh_CN"
|
||||||
- 1028
|
- "zh_TW"
|
||||||
- 1033
|
- "en_US"
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 { }
|
|
||||||
}
|
|
||||||
@@ -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
@@ -6,14 +6,63 @@ import * as http from 'http';
|
|||||||
|
|
||||||
const { net } = electron;
|
const { net } = electron;
|
||||||
|
|
||||||
const VERSION_MANIFEST_URL = 'https://launchermeta.mojang.com/mc/game/version_manifest.json';
|
// ==================== BMCLAPI 镜像源配置 ====================
|
||||||
const FORGE_VERSION_LIST_URL = 'https://files.minecraftforge.net/net/minecraftforge/forge/json';
|
// 使用 BMCLAPI(bmclapi2.bangbang93.com)镜像加速国内资源下载
|
||||||
const FABRIC_VERSION_LIST_URL = 'https://meta.fabricmc.net/v2/versions';
|
// 版本清单: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
|
||||||
|
// Assets:resources.download.minecraft.net → bmclapi2.bangbang93.com/assets
|
||||||
|
// Libraries:libraries.minecraft.net → bmclapi2.bangbang93.com/maven
|
||||||
|
// Forge:files.minecraftforge.net/maven → bmclapi2.bangbang93.com/maven
|
||||||
|
// NeoForge:maven.neoforged.net/releases → bmclapi2.bangbang93.com/maven
|
||||||
|
// Fabric:meta.fabricmc.net → bmclapi2.bangbang93.com/fabric-meta;maven.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 {
|
interface VersionInfo {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
url: string;
|
url: string;
|
||||||
|
releaseTime: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VersionManifest {
|
interface VersionManifest {
|
||||||
@@ -29,9 +78,11 @@ interface DownloadProgress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function downloadFile(url: string, dest: string, onProgress?: (progress: DownloadProgress) => void): Promise<void> {
|
async function downloadFile(url: string, dest: string, onProgress?: (progress: DownloadProgress) => void): Promise<void> {
|
||||||
|
// 下载前统一改写为 BMCLAPI 镜像地址
|
||||||
|
url = rewriteToMirror(url);
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const client = url.startsWith('https') ? https : http;
|
// 根据协议选择 http / https 客户端(分开调用以避免 TS 联合类型签名不兼容)
|
||||||
const request = client.get(url, (response) => {
|
const handleResponse = (response: http.IncomingMessage) => {
|
||||||
if (response.statusCode === 302 || response.statusCode === 301) {
|
if (response.statusCode === 302 || response.statusCode === 301) {
|
||||||
downloadFile(response.headers.location!, dest, onProgress).then(resolve).catch(reject);
|
downloadFile(response.headers.location!, dest, onProgress).then(resolve).catch(reject);
|
||||||
return;
|
return;
|
||||||
@@ -52,7 +103,7 @@ async function downloadFile(url: string, dest: string, onProgress?: (progress: D
|
|||||||
|
|
||||||
const file = fs.createWriteStream(dest);
|
const file = fs.createWriteStream(dest);
|
||||||
|
|
||||||
response.on('data', (chunk) => {
|
response.on('data', (chunk: Buffer) => {
|
||||||
downloadedBytes += chunk.length;
|
downloadedBytes += chunk.length;
|
||||||
if (onProgress && totalBytes > 0) {
|
if (onProgress && totalBytes > 0) {
|
||||||
onProgress({
|
onProgress({
|
||||||
@@ -75,7 +126,14 @@ async function downloadFile(url: string, dest: string, onProgress?: (progress: D
|
|||||||
fs.unlink(dest, () => {});
|
fs.unlink(dest, () => {});
|
||||||
reject(err);
|
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);
|
request.on('error', reject);
|
||||||
});
|
});
|
||||||
@@ -95,11 +153,12 @@ export async function getVersionList(type?: string): Promise<VersionManifest> {
|
|||||||
|
|
||||||
export async function getForgeVersions(mcVersion?: string) {
|
export async function getForgeVersions(mcVersion?: string) {
|
||||||
try {
|
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}`);
|
if (!response.ok) throw new Error(`Forge version list failed: ${response.status}`);
|
||||||
const data = await response.json() as { versions: Record<string, string[]> };
|
const data = await response.json() as { version: string }[];
|
||||||
const versions = data.versions[mcVersion || ''] || [];
|
return { versions: data.map((v) => v.version) };
|
||||||
return { versions };
|
|
||||||
} catch {
|
} catch {
|
||||||
return { versions: [] };
|
return { versions: [] };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -14,6 +14,18 @@ import {
|
|||||||
getFabricLoaders,
|
getFabricLoaders,
|
||||||
getQuiltLoaderVersionsByMinecraft,
|
getQuiltLoaderVersionsByMinecraft,
|
||||||
} from '@xmcl/installer';
|
} 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 使用的镜像 fetch(meta.fabricmc.net 等元数据接口走镜像)
|
||||||
|
const mirrorFetch: typeof fetch = (url, init) => fetch(rewriteToMirror(String(url)), init);
|
||||||
|
|
||||||
export interface InstanceRuntime {
|
export interface InstanceRuntime {
|
||||||
minecraft: string;
|
minecraft: string;
|
||||||
@@ -232,18 +244,24 @@ export async function installInstanceGame(
|
|||||||
callbacks?.onProgress?.({ stage: 'installing-minecraft', current: 0, total: 100, message: `Installing Minecraft ${runtime.minecraft}...` });
|
callbacks?.onProgress?.({ stage: 'installing-minecraft', current: 0, total: 100, message: `Installing Minecraft ${runtime.minecraft}...` });
|
||||||
|
|
||||||
// Install Minecraft
|
// Install Minecraft
|
||||||
// First get version list to find the version info
|
// First get version list to find the version info(走 BMCLAPI 镜像)
|
||||||
const versionList = await getVersionList();
|
const versionList = await getVersionList({ remote: BMCLAPI_VERSION_MANIFEST, fetch: mirrorFetch });
|
||||||
const versionInfo = versionList.versions.find((v) => v.id === runtime.minecraft);
|
const versionInfo = versionList.versions.find((v) => v.id === runtime.minecraft);
|
||||||
if (!versionInfo) {
|
if (!versionInfo) {
|
||||||
throw new Error(`Minecraft version ${runtime.minecraft} not found`);
|
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
|
// Install mod loaders
|
||||||
if (runtime.forge) {
|
if (runtime.forge) {
|
||||||
callbacks?.onProgress?.({ stage: 'installing-forge', current: 0, total: 100, message: `Installing Forge ${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) {
|
if (runtime.fabricLoader) {
|
||||||
@@ -252,6 +270,7 @@ export async function installInstanceGame(
|
|||||||
minecraftVersion: runtime.minecraft,
|
minecraftVersion: runtime.minecraft,
|
||||||
version: runtime.fabricLoader,
|
version: runtime.fabricLoader,
|
||||||
minecraft: instancePath,
|
minecraft: instancePath,
|
||||||
|
fetch: mirrorFetch,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,7 +285,7 @@ export async function installInstanceGame(
|
|||||||
|
|
||||||
if (runtime.neoForged) {
|
if (runtime.neoForged) {
|
||||||
callbacks?.onProgress?.({ stage: 'installing-neoforge', current: 0, total: 100, message: `Installing NeoForge ${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
|
// 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...' });
|
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);
|
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' });
|
callbacks?.onProgress?.({ stage: 'done', current: 100, total: 100, message: 'Installation complete' });
|
||||||
|
|
||||||
@@ -370,12 +389,13 @@ export async function diagnoseInstance(
|
|||||||
|
|
||||||
// Version list APIs
|
// Version list APIs
|
||||||
export async function getMinecraftVersionList(type?: string) {
|
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;
|
let versions = manifest.versions;
|
||||||
if (type && type !== 'all') {
|
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) {
|
export async function getForgeVersionList(mcVersion?: string) {
|
||||||
@@ -384,11 +404,12 @@ export async function getForgeVersionList(mcVersion?: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getFabricVersionList(mcVersion?: string) {
|
export async function getFabricVersionList(mcVersion?: string) {
|
||||||
|
// Fabric 元数据走 BMCLAPI 镜像(meta.fabricmc.net → fabric-meta)
|
||||||
if (mcVersion) {
|
if (mcVersion) {
|
||||||
const loaders = await getFabricLoaders();
|
const loaders = await getFabricLoaders({ fetch: mirrorFetch });
|
||||||
return { versions: loaders.map((l) => l.version) };
|
return { versions: loaders.map((l) => l.version) };
|
||||||
}
|
}
|
||||||
const loaders = await getFabricLoaders();
|
const loaders = await getFabricLoaders({ fetch: mirrorFetch });
|
||||||
return { versions: loaders.map((l) => l.version) };
|
return { versions: loaders.map((l) => l.version) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,3 +417,49 @@ export async function getQuiltVersionList(mcVersion?: string) {
|
|||||||
const loaders = await getQuiltLoaderVersionsByMinecraft({ minecraftVersion: mcVersion || '*' });
|
const loaders = await getQuiltLoaderVersionsByMinecraft({ minecraftVersion: mcVersion || '*' });
|
||||||
return { versions: loaders.map((l) => l.loader.version) };
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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: '' });
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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
@@ -20,6 +20,19 @@ interface ModSearchResult {
|
|||||||
source: 'modrinth' | 'curseforge';
|
source: 'modrinth' | 'curseforge';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ModSearchResponse {
|
||||||
|
hits: ModSearchResult[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModCategory {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
projectType: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ModVersionResult {
|
interface ModVersionResult {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -38,17 +51,21 @@ async function searchModrinth(
|
|||||||
query?: string,
|
query?: string,
|
||||||
gameVersion?: string,
|
gameVersion?: string,
|
||||||
loader?: string,
|
loader?: string,
|
||||||
|
category?: string,
|
||||||
|
projectType: string = 'mod',
|
||||||
limit: number = 20,
|
limit: number = 20,
|
||||||
offset: number = 0
|
offset: number = 0
|
||||||
): Promise<ModSearchResult[]> {
|
): Promise<ModSearchResponse> {
|
||||||
const facets: string[][] = [];
|
const facets: string[][] = [];
|
||||||
if (gameVersion) facets.push([`versions:${gameVersion}`]);
|
if (gameVersion) facets.push([`versions:${gameVersion}`]);
|
||||||
if (loader) facets.push([`categories:${loader}`]);
|
if (loader) facets.push([`categories:${loader}`]);
|
||||||
|
if (category) facets.push([`categories:${category}`]);
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
query: query || '',
|
query: query || '',
|
||||||
limit: String(limit),
|
limit: String(limit),
|
||||||
offset: String(offset),
|
offset: String(offset),
|
||||||
|
project_type: projectType,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (facets.length > 0) {
|
if (facets.length > 0) {
|
||||||
@@ -58,30 +75,38 @@ async function searchModrinth(
|
|||||||
const response = await net.fetch(`${MODRINTH_API}/search?${params.toString()}`);
|
const response = await net.fetch(`${MODRINTH_API}/search?${params.toString()}`);
|
||||||
if (!response.ok) throw new Error(`Modrinth search failed: ${response.status}`);
|
if (!response.ok) throw new Error(`Modrinth search failed: ${response.status}`);
|
||||||
|
|
||||||
const data = await response.json() as { hits: Array<{
|
const data = await response.json() as {
|
||||||
project_id: string;
|
total_hits: number;
|
||||||
slug: string;
|
hits: Array<{
|
||||||
title: string;
|
project_id: string;
|
||||||
description: string;
|
slug: string;
|
||||||
downloads: number;
|
title: string;
|
||||||
icon_url?: string;
|
description: string;
|
||||||
categories?: string[];
|
downloads: number;
|
||||||
versions?: string[];
|
icon_url?: string;
|
||||||
client_side?: string;
|
categories?: string[];
|
||||||
server_side?: string;
|
versions?: string[];
|
||||||
}> };
|
client_side?: string;
|
||||||
|
server_side?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
return data.hits.map((hit) => ({
|
return {
|
||||||
id: hit.project_id,
|
hits: data.hits.map((hit) => ({
|
||||||
slug: hit.slug,
|
id: hit.project_id,
|
||||||
name: hit.title,
|
slug: hit.slug,
|
||||||
description: hit.description,
|
name: hit.title,
|
||||||
downloads: hit.downloads,
|
description: hit.description,
|
||||||
iconUrl: hit.icon_url,
|
downloads: hit.downloads,
|
||||||
categories: hit.categories,
|
iconUrl: hit.icon_url,
|
||||||
versions: hit.versions,
|
categories: hit.categories,
|
||||||
source: 'modrinth' as const,
|
versions: hit.versions,
|
||||||
}));
|
source: 'modrinth' as const,
|
||||||
|
})),
|
||||||
|
total: data.total_hits,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function searchCurseForge(
|
async function searchCurseForge(
|
||||||
@@ -90,25 +115,62 @@ async function searchCurseForge(
|
|||||||
loader?: string,
|
loader?: string,
|
||||||
limit: number = 20,
|
limit: number = 20,
|
||||||
offset: number = 0
|
offset: number = 0
|
||||||
): Promise<ModSearchResult[]> {
|
): Promise<ModSearchResponse> {
|
||||||
// CurseForge requires API key - return empty for now
|
// CurseForge requires API key - return empty for now
|
||||||
return [];
|
return { hits: [], total: 0, limit, offset };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchMods(
|
export async function searchMods(
|
||||||
query?: string,
|
query?: string,
|
||||||
gameVersion?: string,
|
gameVersion?: string,
|
||||||
loader?: string,
|
loader?: string,
|
||||||
|
category?: string,
|
||||||
|
projectType: string = 'mod',
|
||||||
limit?: number,
|
limit?: number,
|
||||||
offset?: number,
|
offset?: number,
|
||||||
source: 'modrinth' | 'curseforge' = 'modrinth'
|
source: 'modrinth' | 'curseforge' = 'modrinth'
|
||||||
): Promise<ModSearchResult[]> {
|
): Promise<ModSearchResponse> {
|
||||||
if (source === 'modrinth') {
|
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);
|
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(
|
export async function getModDetail(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
source: 'modrinth' | 'curseforge' = 'modrinth'
|
source: 'modrinth' | 'curseforge' = 'modrinth'
|
||||||
|
|||||||
@@ -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) };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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) };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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) };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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) };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
getForgeVersionList,
|
getForgeVersionList,
|
||||||
getFabricVersionList,
|
getFabricVersionList,
|
||||||
getQuiltVersionList,
|
getQuiltVersionList,
|
||||||
|
scanGameDirectories,
|
||||||
type InstanceRuntime,
|
type InstanceRuntime,
|
||||||
type InstanceConfig,
|
type InstanceConfig,
|
||||||
} from '../core/instance';
|
} from '../core/instance';
|
||||||
@@ -194,4 +195,14 @@ export function registerInstanceHandlers(win: WinRef) {
|
|||||||
return { success: false, data: null, error: String(e) };
|
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) };
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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) };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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) };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import electron from 'electron';
|
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;
|
const { ipcMain } = electron;
|
||||||
|
|
||||||
@@ -8,6 +8,8 @@ export function registerModsHandlers() {
|
|||||||
query?: string;
|
query?: string;
|
||||||
gameVersion?: string;
|
gameVersion?: string;
|
||||||
loader?: string;
|
loader?: string;
|
||||||
|
category?: string;
|
||||||
|
projectType?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
source: string;
|
source: string;
|
||||||
@@ -17,6 +19,8 @@ export function registerModsHandlers() {
|
|||||||
payload.query,
|
payload.query,
|
||||||
payload.gameVersion,
|
payload.gameVersion,
|
||||||
payload.loader,
|
payload.loader,
|
||||||
|
payload.category,
|
||||||
|
payload.projectType,
|
||||||
payload.limit,
|
payload.limit,
|
||||||
payload.offset,
|
payload.offset,
|
||||||
payload.source as 'modrinth' | 'curseforge'
|
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 }) => {
|
ipcMain.handle('mods:detail', async (_event, payload: { projectId: string; source: string }) => {
|
||||||
try {
|
try {
|
||||||
const data = await getModDetail(
|
const data = await getModDetail(
|
||||||
|
|||||||
@@ -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) };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@ import electron from 'electron';
|
|||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
|
|
||||||
const { ipcMain, app } = electron;
|
const { ipcMain, app, shell } = electron;
|
||||||
|
|
||||||
function getBiosId(): string {
|
function getBiosId(): string {
|
||||||
if (process.platform !== 'win32') return 'N/A (non-Windows)';
|
if (process.platform !== 'win32') return 'N/A (non-Windows)';
|
||||||
@@ -60,4 +60,14 @@ export function registerSystemHandlers() {
|
|||||||
return { success: false, data: null, error: String(e) };
|
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) };
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -1,4 +1,5 @@
|
|||||||
import electron from 'electron';
|
import electron from 'electron';
|
||||||
|
import { createInstance, installInstanceGame, type InstanceRuntime } from '../core/instance';
|
||||||
|
|
||||||
const { ipcMain } = electron;
|
const { ipcMain } = electron;
|
||||||
|
|
||||||
@@ -8,6 +9,14 @@ interface WinRef {
|
|||||||
mainWindow: electron.BrowserWindow | null;
|
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) {
|
export function registerTaskHandlers(win: WinRef) {
|
||||||
ipcMain.handle('task:start', async (_event, payload: {
|
ipcMain.handle('task:start', async (_event, payload: {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
@@ -21,42 +30,68 @@ export function registerTaskHandlers(win: WinRef) {
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
runningTasks.set(payload.taskId, controller);
|
runningTasks.set(payload.taskId, controller);
|
||||||
|
|
||||||
win.mainWindow?.webContents.send('task:started', {
|
win.mainWindow?.webContents.send('task:progress', {
|
||||||
taskId: payload.taskId,
|
taskId: payload.taskId,
|
||||||
|
event: 'task:started',
|
||||||
xmclPath: payload.executorName,
|
xmclPath: payload.executorName,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate task execution
|
// 根据 executorName 选择处理逻辑
|
||||||
const steps = 20;
|
if (payload.executorName === 'install') {
|
||||||
for (let i = 0; i <= steps; i++) {
|
// 使用 @xmcl/core + @xmcl/installer 安装 Minecraft 实例
|
||||||
if (controller.signal.aborted) {
|
const params = payload.params ?? {};
|
||||||
win.mainWindow?.webContents.send('task:failed', {
|
const name = String(params.name ?? `mc-${Date.now()}`);
|
||||||
taskId: payload.taskId,
|
const gamePath = String(params.gamePath ?? '.minecraft');
|
||||||
error: 'Task cancelled',
|
const runtime = (params.runtime ?? { minecraft: '1.21.4' }) as InstanceRuntime;
|
||||||
});
|
const description = String(params.description ?? '');
|
||||||
runningTasks.delete(payload.taskId);
|
|
||||||
return { success: true, data: null, error: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
win.mainWindow?.webContents.send('task:progress', {
|
emitProgress(win, payload.taskId, 0, 100, '创建实例目录');
|
||||||
taskId: payload.taskId,
|
emitLog(win, payload.taskId, 'info', `正在创建实例: ${name}`);
|
||||||
current: i,
|
|
||||||
total: steps,
|
// Step 1: 创建实例(写入 instance.json)
|
||||||
stage: `步骤 ${i}/${steps}`,
|
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);
|
runningTasks.delete(payload.taskId);
|
||||||
win.mainWindow?.webContents.send('task:completed', {
|
win.mainWindow?.webContents.send('task:progress', {
|
||||||
taskId: payload.taskId,
|
taskId: payload.taskId,
|
||||||
|
event: 'task:completed',
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true, data: null, error: null };
|
return { success: true, data: null, error: null };
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
runningTasks.delete(payload.taskId);
|
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 };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -105,4 +105,13 @@ export function registerWindowHandlers(win: WinRef) {
|
|||||||
ipcMain.handle('shell:openExternal', async (_event, url: string) => {
|
ipcMain.handle('shell:openExternal', async (_event, url: string) => {
|
||||||
await shell.openExternal(url);
|
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
@@ -53,10 +53,19 @@ function runStartupChecks(): { isFirstLaunch: boolean; config: ReturnType<typeof
|
|||||||
return { isFirstLaunch, config };
|
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 {
|
function createSplashWindow(): electron.BrowserWindow {
|
||||||
const iconPath = isDev
|
const iconPath = getWindowIconPath();
|
||||||
? path.join(__dirname, '../build/icon.ico')
|
|
||||||
: path.join(__dirname, '../build/icon.ico');
|
|
||||||
|
|
||||||
const splash = new electron.BrowserWindow({
|
const splash = new electron.BrowserWindow({
|
||||||
width: 480,
|
width: 480,
|
||||||
@@ -83,9 +92,7 @@ function createSplashWindow(): electron.BrowserWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createMainWindow(): electron.BrowserWindow {
|
function createMainWindow(): electron.BrowserWindow {
|
||||||
const iconPath = isDev
|
const iconPath = getWindowIconPath();
|
||||||
? path.join(__dirname, '../build/icon.ico')
|
|
||||||
: path.join(__dirname, '../build/icon.ico');
|
|
||||||
|
|
||||||
const main = new electron.BrowserWindow({
|
const main = new electron.BrowserWindow({
|
||||||
width: 1000,
|
width: 1000,
|
||||||
|
|||||||
+2
-8
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "koring-launcher",
|
"name": "koring-launcher",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.1",
|
"version": "1.1.2",
|
||||||
"description": "Koring Launcher - Minecraft launcher built with Electron + React",
|
"description": "Koring Launcher - Minecraft launcher built with Electron + React",
|
||||||
"author": "Shenzhen Lingke Network Technology Co., Ltd.",
|
"author": "Shenzhen Lingke Network Technology Co., Ltd.",
|
||||||
"license": "LL-1.0",
|
"license": "LL-1.0",
|
||||||
"main": "electron/main.js",
|
"main": "electron-dist/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:renderer": "vite --mode development",
|
"dev:renderer": "vite --mode development",
|
||||||
"dev:main": "tsc -p tsconfig.electron.json && electron .",
|
"dev:main": "tsc -p tsconfig.electron.json && electron .",
|
||||||
@@ -56,12 +56,6 @@
|
|||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
|
||||||
"onlyBuiltDependencies": [
|
|
||||||
"electron",
|
|
||||||
"esbuild"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.1",
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# 允许执行构建脚本的依赖(pnpm 10+ 默认禁止依赖运行 postinstall)
|
||||||
|
allowBuilds:
|
||||||
|
electron: true
|
||||||
|
esbuild: true
|
||||||
@@ -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 等符号常量,
|
||||||
|
; 直接用数字 LCID(2052、1028、1033)更稳。
|
||||||
|
; 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
@@ -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 BMP(NSIS 欢迎页左侧大图,只支持 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 { join } = require('path');
|
||||||
|
|
||||||
const mode = process.argv[2];
|
const mode = process.argv[2];
|
||||||
@@ -12,22 +34,97 @@ if (!mode || !validModes.includes(mode)) {
|
|||||||
const root = join(__dirname, '..');
|
const root = join(__dirname, '..');
|
||||||
const srcDir = join(root, 'public', 'icons', mode);
|
const srcDir = join(root, 'public', 'icons', mode);
|
||||||
const buildDir = join(root, 'build');
|
const buildDir = join(root, 'build');
|
||||||
|
const publicDir = join(root, 'public');
|
||||||
|
|
||||||
const png = join(srcDir, 'icon.png');
|
const png = join(srcDir, 'icon.png');
|
||||||
const ico = join(srcDir, 'icon.ico');
|
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}`);
|
const requiredFiles = [
|
||||||
process.exit(1);
|
{ 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 欢迎页左侧大图 BMP(164x314,24 位白底)。
|
||||||
|
* 横幅等比缩放至宽度 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 位 RGB(NSIS 对 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 });
|
mkdirSync(buildDir, { recursive: true });
|
||||||
|
|
||||||
|
// 1. 图标
|
||||||
cpSync(png, join(buildDir, 'icon.png'), { overwrite: true });
|
cpSync(png, join(buildDir, 'icon.png'), { overwrite: true });
|
||||||
cpSync(ico, join(buildDir, 'icon.ico'), { 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`
|
||||||
|
);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { OobeWelcome } from "./pages/oobe/step-welcome";
|
|||||||
import { OobeVersion } from "./pages/oobe/step-version";
|
import { OobeVersion } from "./pages/oobe/step-version";
|
||||||
import { OobeBetaTest } from "./pages/oobe/step-beta-test";
|
import { OobeBetaTest } from "./pages/oobe/step-beta-test";
|
||||||
import { OobeFinish } from "./pages/oobe/step-finish";
|
import { OobeFinish } from "./pages/oobe/step-finish";
|
||||||
|
import { OobeLegal } from "./pages/oobe/step-legal";
|
||||||
import { OobeAboutInfo } from "./pages/oobe/about-info";
|
import { OobeAboutInfo } from "./pages/oobe/about-info";
|
||||||
|
|
||||||
const pageMap = {
|
const pageMap = {
|
||||||
@@ -51,6 +52,7 @@ const pageMap = {
|
|||||||
"oobe/beta-test": OobeBetaTest,
|
"oobe/beta-test": OobeBetaTest,
|
||||||
"oobe/finish": OobeFinish,
|
"oobe/finish": OobeFinish,
|
||||||
"oobe/about-info": OobeAboutInfo,
|
"oobe/about-info": OobeAboutInfo,
|
||||||
|
"oobe/legal": OobeLegal,
|
||||||
debug: Debug,
|
debug: Debug,
|
||||||
"debug-splash": SplashDebug,
|
"debug-splash": SplashDebug,
|
||||||
"debug-display": DisplayDebug,
|
"debug-display": DisplayDebug,
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export interface GameConfig {
|
|||||||
resourceDir: string;
|
resourceDir: string;
|
||||||
savesDir: string;
|
savesDir: string;
|
||||||
instancesDir: string;
|
instancesDir: string;
|
||||||
|
/** 已添加的游戏目录列表 */
|
||||||
|
gameDirs: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JavaConfig {
|
export interface JavaConfig {
|
||||||
|
|||||||
+33
-2
@@ -118,8 +118,39 @@ export async function diagnoseInstance(
|
|||||||
return ipcInvoke<{ healthy: boolean; issues: string[] }>('instance:diagnose', { name, gamePath });
|
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) {
|
export async function getForgeVersionList(mcVersion?: string) {
|
||||||
|
|||||||
+27
-2
@@ -13,6 +13,19 @@ export interface ModSearchResult {
|
|||||||
source: 'modrinth' | 'curseforge';
|
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 {
|
export interface ModVersionResult {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -31,20 +44,32 @@ export async function searchMods(
|
|||||||
query?: string,
|
query?: string,
|
||||||
gameVersion?: string,
|
gameVersion?: string,
|
||||||
loader?: string,
|
loader?: string,
|
||||||
|
category?: string,
|
||||||
|
projectType: string = "mod",
|
||||||
limit?: number,
|
limit?: number,
|
||||||
offset?: number,
|
offset?: number,
|
||||||
source: 'modrinth' | 'curseforge' = 'modrinth'
|
source: 'modrinth' | 'curseforge' = 'modrinth'
|
||||||
): Promise<ModSearchResult[]> {
|
): Promise<ModSearchResponse> {
|
||||||
return ipcInvoke<ModSearchResult[]>('mods:search', {
|
return ipcInvoke<ModSearchResponse>('mods:search', {
|
||||||
query,
|
query,
|
||||||
gameVersion,
|
gameVersion,
|
||||||
loader,
|
loader,
|
||||||
|
category,
|
||||||
|
projectType,
|
||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
source,
|
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(
|
export async function getModDetail(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
source: 'modrinth' | 'curseforge'
|
source: 'modrinth' | 'curseforge'
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ export async function getSystemInfo(): Promise<SystemInfo> {
|
|||||||
return ipcInvoke<SystemInfo>('system:info');
|
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 {
|
export interface LocaleInfo {
|
||||||
language: string;
|
language: string;
|
||||||
region: string;
|
region: string;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { relaunchApp } from "@/api/update";
|
|||||||
import Silk from "@/components/silk/Silk";
|
import Silk from "@/components/silk/Silk";
|
||||||
import { Button } from "@heroui/react";
|
import { Button } from "@heroui/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Suspense } from "react";
|
import { type ReactNode, Suspense } from "react";
|
||||||
|
|
||||||
const modeColors: Record<string, string> = {
|
const modeColors: Record<string, string> = {
|
||||||
dev: "#F59E0B",
|
dev: "#F59E0B",
|
||||||
@@ -32,6 +32,8 @@ interface VersionCardProps {
|
|||||||
overrideMode?: string | null;
|
overrideMode?: string | null;
|
||||||
overrideState?: UpdateState | null;
|
overrideState?: UpdateState | null;
|
||||||
simple?: boolean;
|
simple?: boolean;
|
||||||
|
/** OOBE 模式:不显示 Silk 动画,按钮仅展示样式不做实际操作 */
|
||||||
|
oobe?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VersionCard({
|
export function VersionCard({
|
||||||
@@ -39,6 +41,7 @@ export function VersionCard({
|
|||||||
overrideMode,
|
overrideMode,
|
||||||
overrideState,
|
overrideState,
|
||||||
simple = false,
|
simple = false,
|
||||||
|
oobe = false,
|
||||||
}: VersionCardProps) {
|
}: VersionCardProps) {
|
||||||
const color = modeColors[overrideMode ?? BUILD_MODE] ?? modeColors.run;
|
const color = modeColors[overrideMode ?? BUILD_MODE] ?? modeColors.run;
|
||||||
const gradient = modeGradients[overrideMode ?? BUILD_MODE] ?? modeGradients.run;
|
const gradient = modeGradients[overrideMode ?? BUILD_MODE] ?? modeGradients.run;
|
||||||
@@ -71,13 +74,7 @@ export function VersionCard({
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 磨砂遮罩 */}
|
{/* 内容(移除颜色遮罩层,保留底部渐变与 Silk 动画渲染) */}
|
||||||
<div
|
|
||||||
className="absolute inset-0"
|
|
||||||
style={{ background: "rgba(0,0,0,0.25)", backdropFilter: "blur(12px)" }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 内容 */}
|
|
||||||
<div className="relative z-10 flex flex-col items-center justify-center px-6 py-10 gap-4">
|
<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>
|
<span className="text-[11px] font-bold tracking-wider text-white/70">{label}</span>
|
||||||
<img
|
<img
|
||||||
@@ -88,7 +85,13 @@ export function VersionCard({
|
|||||||
/>
|
/>
|
||||||
<p className="text-sm text-white/70 font-medium">v{VERSION}</p>
|
<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" && (
|
{effectiveState === "latest" && (
|
||||||
<>
|
<>
|
||||||
<Btn onPress={check} isDisabled={checking}>
|
<Btn onPress={check} isDisabled={checking}>
|
||||||
@@ -113,7 +116,8 @@ export function VersionCard({
|
|||||||
<Btn onPress={relaunchApp}>立即更新</Btn>
|
<Btn onPress={relaunchApp}>立即更新</Btn>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ interface CreateDialogProps {
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCreate: (name: string, runtime: InstanceRuntime, displayName?: string) => Promise<void>;
|
onCreate: (name: string, runtime: InstanceRuntime, displayName?: string) => Promise<void>;
|
||||||
|
/** 预选的 Minecraft 版本(从资源中心"原版游戏"进入时传入) */
|
||||||
|
initialVersion?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loaderOptions = [
|
const loaderOptions = [
|
||||||
@@ -17,12 +19,12 @@ const loaderOptions = [
|
|||||||
{ value: "neoforged", label: "NeoForge", desc: "Forge 的现代化分支,1.20+ 推荐" },
|
{ 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 [step, setStep] = useState(0);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [displayName, setDisplayName] = useState("");
|
const [displayName, setDisplayName] = useState("");
|
||||||
const [loader, setLoader] = useState("vanilla");
|
const [loader, setLoader] = useState("vanilla");
|
||||||
const [mcVersion, setMcVersion] = useState("");
|
const [mcVersion, setMcVersion] = useState(initialVersion || "");
|
||||||
const [memory, setMemory] = useState(4);
|
const [memory, setMemory] = useState(4);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [mcVersions, setMcVersions] = useState<string[]>([]);
|
const [mcVersions, setMcVersions] = useState<string[]>([]);
|
||||||
@@ -30,20 +32,23 @@ export function CreateDialog({ isOpen, onClose, onCreate }: CreateDialogProps) {
|
|||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) { loadMcVersions(); } else { resetForm(); }
|
if (isOpen) { setMcVersion(initialVersion || ""); loadMcVersions(); } else { resetForm(); }
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setStep(0); setName(""); setDisplayName("");
|
setStep(0); setName(""); setDisplayName("");
|
||||||
setLoader("vanilla"); setMcVersion(""); setMemory(4);
|
setLoader("vanilla"); setMcVersion(initialVersion || ""); setMemory(4);
|
||||||
setError(""); setCreating(false);
|
setError(""); setCreating(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadMcVersions = async () => {
|
const loadMcVersions = async () => {
|
||||||
setLoadingVersions(true);
|
setLoadingVersions(true);
|
||||||
try {
|
try {
|
||||||
|
// instance:version-list 返回 { versions: { id, type, url }[] }
|
||||||
const list = await getMinecraftVersionList();
|
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"]);
|
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]);
|
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"); }
|
} 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>
|
<div>
|
||||||
<p className="text-sm font-medium text-foreground mb-2">模组加载器</p>
|
<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) => (
|
{loaderOptions.map((opt) => (
|
||||||
<Radio key={opt.value} value={opt.value} className="py-1.5">
|
<Radio key={opt.value} value={opt.value} className="py-1.5">
|
||||||
<Radio.Content>
|
<Radio.Content>
|
||||||
@@ -169,15 +174,15 @@ export function CreateDialog({ isOpen, onClose, onCreate }: CreateDialogProps) {
|
|||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-t border-border/30 dark:border-white/[0.04]">
|
<div className="flex items-center justify-between px-6 py-4 border-t border-border/30 dark:border-white/[0.04]">
|
||||||
<div>
|
<div>
|
||||||
{step > 0 && <Button variant="light" onPress={() => setStep(step - 1)}>上一步</Button>}
|
{step > 0 && <Button variant="ghost" onPress={() => setStep(step - 1)}>上一步</Button>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="light" onPress={onClose}>取消</Button>
|
<Button variant="ghost" onPress={onClose}>取消</Button>
|
||||||
{step < 2 ? (
|
{step < 2 ? (
|
||||||
<Button onPress={() => setStep(step + 1)} isDisabled={step === 0 ? !name.trim() : !mcVersion}>下一步</Button>
|
<Button onPress={() => setStep(step + 1)} isDisabled={step === 0 ? !name.trim() : !mcVersion}>下一步</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button onPress={handleCreate} isDisabled={creating || !name.trim()}
|
<Button onPress={handleCreate} isDisabled={creating || !name.trim()}>
|
||||||
startContent={creating ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : undefined}>
|
{creating && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||||
{creating ? "创建中..." : "创建实例"}
|
{creating ? "创建中..." : "创建实例"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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 { 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 { useInstanceStore } from "@/stores/instanceStore";
|
||||||
import { useConfigStore } from "@/stores/configStore";
|
import { useConfigStore } from "@/stores/configStore";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
import { Surface } from "@/components/setting/Surface";
|
import { useConfirmDialogStore } from "@/stores/confirmDialogStore";
|
||||||
import { InstanceCard } from "./InstanceCard";
|
import { useRouteStore } from "@/stores/routeStore";
|
||||||
import { CreateDialog } from "./CreateDialog";
|
import { onLaunchComplete, onLaunchError } from "@/api/instance";
|
||||||
import type { InstanceRuntime } from "@/api/instance";
|
import { openPath } from "@/api/system";
|
||||||
|
import { InstanceList } from "./InstanceList";
|
||||||
|
import { InstanceDetail } from "./InstanceDetail";
|
||||||
|
import { EditDialog } from "./EditDialog";
|
||||||
|
|
||||||
export function Gallery() {
|
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 gameConfig = useConfigStore((s) => s.config.game);
|
||||||
|
const javaConfig = useConfigStore((s) => s.config.java);
|
||||||
const configInstances = useConfigStore((s) => s.config.instances);
|
const configInstances = useConfigStore((s) => s.config.instances);
|
||||||
const setInstances = useConfigStore((s) => s.setInstances);
|
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 [selectedName, setSelectedName] = useState<string | null>(null);
|
||||||
const [launching, setLaunching] = 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 () => {
|
const loadInstances = useCallback(async () => {
|
||||||
await fetchInstances(gameConfig.gameDir);
|
await fetchInstances(gameConfig.gameDir);
|
||||||
}, [fetchInstances, gameConfig.gameDir]);
|
}, [fetchInstances, gameConfig.gameDir]);
|
||||||
@@ -26,66 +53,111 @@ export function Gallery() {
|
|||||||
loadInstances();
|
loadInstances();
|
||||||
}, [loadInstances]);
|
}, [loadInstances]);
|
||||||
|
|
||||||
const handleCreate = async (name: string, runtime: InstanceRuntime, displayName?: string) => {
|
// 默认选中第一个实例
|
||||||
await create(name, gameConfig.gameDir, runtime, {
|
useEffect(() => {
|
||||||
description: displayName,
|
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("游戏启动成功");
|
||||||
});
|
});
|
||||||
|
const offError = onLaunchError(({ error }) => {
|
||||||
// Sync to Koring.yml
|
setLaunching(false);
|
||||||
const loader = runtime.forge ? "forge"
|
toast.error(`启动失败: ${error}`);
|
||||||
: 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,
|
|
||||||
});
|
});
|
||||||
setInstances(updated);
|
return () => {
|
||||||
setCreateOpen(false);
|
offComplete();
|
||||||
};
|
offError();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handlePlay = async (name: string) => {
|
const selected = instances.find((i) => i.name === selectedName) ?? null;
|
||||||
setLaunching(name);
|
|
||||||
|
// 启动游戏
|
||||||
|
const handlePlay = async () => {
|
||||||
|
if (!selected) return;
|
||||||
|
if (!user?.username || !user?.uuid) {
|
||||||
|
toast.warning("请先在设置中登录账号");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLaunching(true);
|
||||||
try {
|
try {
|
||||||
await launch(name, gameConfig.gameDir);
|
await launch(selected.name, gameConfig.gameDir, {
|
||||||
} catch {
|
username: user.username,
|
||||||
// Launch events handled via IPC listeners
|
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
|
const handleDelete = () => {
|
||||||
console.log("Settings for:", name);
|
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 (
|
return (
|
||||||
<div className="p-6 h-full overflow-y-auto scroll-area">
|
<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>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-foreground">实例管理</h1>
|
<h1 className="text-xl font-bold text-foreground">实例管理</h1>
|
||||||
<p className="text-[13px] text-muted-foreground mt-1">
|
<p className="text-[13px] text-muted-foreground mt-1">
|
||||||
@@ -93,74 +165,73 @@ export function Gallery() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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" : ""}`} />
|
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
</Button>
|
</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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 加载骨架 */}
|
{/* 左右布局 */}
|
||||||
{loading && instances.length === 0 && (
|
<div className="grid grid-cols-[280px_1fr] gap-5 items-start">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
{/* 左侧:实例列表 */}
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
<div className="min-w-0">
|
||||||
<Surface key={i} padding="md">
|
<p className="text-[12px] font-medium text-muted-foreground/80 mb-2 px-1">
|
||||||
<div className="space-y-3">
|
我的实例({instances.length})
|
||||||
<Skeleton className="h-4 w-3/4" />
|
</p>
|
||||||
<Skeleton className="h-3 w-1/2" />
|
<InstanceList
|
||||||
<div className="flex gap-2">
|
instances={instances}
|
||||||
<Skeleton className="h-5 w-14 rounded-md" />
|
selectedName={selectedName}
|
||||||
<Skeleton className="h-5 w-20 rounded-md" />
|
loading={loading}
|
||||||
</div>
|
onSelect={setSelectedName}
|
||||||
<Skeleton className="h-8 w-full rounded-lg" />
|
/>
|
||||||
</div>
|
|
||||||
</Surface>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 空状态 */}
|
{/* 右侧:实例详情 */}
|
||||||
{!loading && instances.length === 0 && (
|
<div className="min-w-0">
|
||||||
<Surface variant="flat" padding="lg">
|
{selected ? (
|
||||||
<div className="flex flex-col items-center gap-4 py-16">
|
<InstanceDetail
|
||||||
<div className="w-16 h-16 rounded-full bg-foreground/[0.04] dark:bg-white/[0.04] flex items-center justify-center">
|
instance={selected}
|
||||||
<Gamepad2 className="w-8 h-8 text-muted-foreground/30" />
|
isLaunching={launching}
|
||||||
</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)}
|
|
||||||
onPlay={handlePlay}
|
onPlay={handlePlay}
|
||||||
onSettings={handleSettings}
|
onEdit={() => setEditOpen(true)}
|
||||||
isLaunching={launching === inst.name}
|
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>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
<CreateDialog
|
<EditDialog
|
||||||
isOpen={createOpen}
|
instance={selected}
|
||||||
onClose={() => setCreateOpen(false)}
|
isOpen={editOpen}
|
||||||
onCreate={handleCreate}
|
onClose={() => setEditOpen(false)}
|
||||||
|
onSave={handleEditSave}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 { useRouteStore } from "@/stores/routeStore";
|
||||||
import { OobeLayout } from "./layout";
|
import { OobeLayout } from "./layout";
|
||||||
import { NextButton } from "./next-button";
|
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() {
|
export function OobeAgreement() {
|
||||||
const navigate = useRouteStore((s) => s.navigate);
|
const navigate = useRouteStore((s) => s.navigate);
|
||||||
const [text, setText] = useState("");
|
|
||||||
const [checked, setChecked] = useState(false);
|
const [checked, setChecked] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch(`${import.meta.env.BASE_URL}protocol-user.txt`)
|
|
||||||
.then((r) => r.text())
|
|
||||||
.then(setText)
|
|
||||||
.catch(() => setText("无法加载协议内容"));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OobeLayout>
|
<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">
|
<div className="text-center space-y-1">
|
||||||
<h2 className="text-lg font-bold text-foreground">Koring Team 产品用户协议</h2>
|
<h2 className="text-lg font-bold text-foreground">确认产品协议</h2>
|
||||||
<p className="text-xs text-muted-foreground">您需要同意才可以继续</p>
|
<p className="text-xs text-muted-foreground">您需要同意以下协议才可以继续</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 协议内容 */}
|
{/* 协议链接列表 */}
|
||||||
<div className="w-full h-[300px] rounded-xl bg-foreground/[0.03] border border-border/50 p-4 overflow-y-auto">
|
<div className="w-full space-y-2">
|
||||||
<pre className="text-xs text-foreground/70 whitespace-pre-wrap font-sans leading-relaxed">
|
{AGREEMENT_LINKS.map((item) => (
|
||||||
{text}
|
<div
|
||||||
</pre>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* 勾选框 */}
|
{/* 勾选框 */}
|
||||||
@@ -49,12 +84,12 @@ export function OobeAgreement() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground leading-relaxed group-hover:text-foreground/70 transition-colors">
|
<span className="text-xs text-muted-foreground leading-relaxed group-hover:text-foreground/70 transition-colors">
|
||||||
我已详细阅读此协议,且同意其内容并签署此协议。
|
我已详细阅读以上协议,且同意其内容并签署此协议。
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NextButton onClick={() => navigate("oobe/welcome")} disabled={!checked} />
|
<NextButton onClick={() => navigate("oobe/legal")} disabled={!checked} />
|
||||||
</OobeLayout>
|
</OobeLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ export function OobeLanguage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NextButton onClick={() => navigate("oobe/login")} />
|
{/* 临时跳过 Koring 账户登录环节,直接进入用户协议 */}
|
||||||
|
<NextButton onClick={() => navigate("oobe/agreement")} />
|
||||||
</OobeLayout>
|
</OobeLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,50 +1,22 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import { useRouteStore } from "@/stores/routeStore";
|
import { useRouteStore } from "@/stores/routeStore";
|
||||||
import { OobeLayout } from "./layout";
|
import { OobeLayout } from "./layout";
|
||||||
import { NextButton } from "./next-button";
|
import { NextButton } from "./next-button";
|
||||||
import { VERSION } from "@/lib/version";
|
import { VersionCard } from "@/components/VersionCard";
|
||||||
import { BUILD_MODE } from "@/lib/mode";
|
import { BUILD_MODE } from "@/lib/mode";
|
||||||
|
|
||||||
export function OobeVersion() {
|
export function OobeVersion() {
|
||||||
const navigate = useRouteStore((s) => s.navigate);
|
const navigate = useRouteStore((s) => s.navigate);
|
||||||
const [changelog, setChangelog] = useState("");
|
|
||||||
|
|
||||||
const isTestBuild = BUILD_MODE === "dev" || BUILD_MODE === "beta";
|
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";
|
// 临时跳过 Koring 账户登录环节:正式版也直接进入用户协议
|
||||||
|
const nextRoute = isTestBuild ? "oobe/beta-test" : "oobe/agreement";
|
||||||
useEffect(() => {
|
|
||||||
fetch(`${import.meta.env.BASE_URL}changelog-${VERSION}.txt`)
|
|
||||||
.then((r) => r.text())
|
|
||||||
.then(setChangelog)
|
|
||||||
.catch(() => setChangelog("暂无更新内容"));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OobeLayout>
|
<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-4 px-6">
|
||||||
{/* 标题 */}
|
{/* 版本卡片(OOBE 模式:无更新逻辑,按钮仅展示样式) */}
|
||||||
<h2 className="text-lg font-bold text-foreground">核对您的版本信息</h2>
|
<VersionCard oobe className="w-full" />
|
||||||
|
|
||||||
{/* 版本号 + 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>
|
|
||||||
|
|
||||||
{/* 测试版警告 */}
|
{/* 测试版警告 */}
|
||||||
{isTestBuild && (
|
{isTestBuild && (
|
||||||
@@ -52,13 +24,6 @@ export function OobeVersion() {
|
|||||||
您正在使用测试版本,它并不稳定,不建议用于正式游戏体验,具体内容请以发行版本为准。
|
您正在使用测试版本,它并不稳定,不建议用于正式游戏体验,具体内容请以发行版本为准。
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
<NextButton onClick={() => navigate(nextRoute)} />
|
<NextButton onClick={() => navigate(nextRoute)} />
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function OobeWelcome() {
|
|||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const displayName = user?.name || user?.username || "用户";
|
const displayName = user?.name || user?.username || "玩家";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OobeLayout>
|
<OobeLayout>
|
||||||
|
|||||||
@@ -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 { useConfigStore } from "@/stores/configStore";
|
||||||
import { Button, Input } from "@heroui/react";
|
import { scanGameDir, selectFolder, type ScannedVersion } from "@/api/instance";
|
||||||
import { RefreshCw } from "lucide-react";
|
|
||||||
import { SettingCard, PageHeader, SectionTitle } from "@/components/setting";
|
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 (
|
return (
|
||||||
<SettingCard>
|
<span className={`inline-flex items-center gap-1 text-[11px] ${ok ? "text-green-600 dark:text-green-400" : "text-red-500/70"}`}>
|
||||||
<div className="space-y-2">
|
{ok ? <CircleCheck className="w-3 h-3" /> : <CircleAlert className="w-3 h-3" />}
|
||||||
<div>
|
{label}
|
||||||
<p className="text-sm font-medium text-foreground">{label}</p>
|
</span>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,58 +50,236 @@ export function GameDirSetting() {
|
|||||||
const game = useConfigStore((s) => s.config.game);
|
const game = useConfigStore((s) => s.config.game);
|
||||||
const setGame = useConfigStore((s) => s.setGame);
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PageHeader title="游戏目录" desc="设置 Minecraft 游戏安装路径、存档位置与资源包目录" />
|
<PageHeader title="游戏目录" desc="管理 Minecraft 游戏的存放位置与发现已安装版本" />
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* 主游戏目录配置 */}
|
||||||
<div>
|
<div>
|
||||||
<SectionTitle>游戏路径</SectionTitle>
|
<SectionTitle>主游戏目录</SectionTitle>
|
||||||
<div className="space-y-3">
|
<SettingCard>
|
||||||
<PathInput
|
<div className="flex items-center justify-between gap-4">
|
||||||
label="游戏安装根目录"
|
<div className="flex-1 min-w-0">
|
||||||
desc="Minecraft 游戏文件的根目录,包含 versions、saves、resourcepacks 等文件夹"
|
<p className="text-sm font-medium font-mono text-foreground truncate">{game.gameDir || ".minecraft"}</p>
|
||||||
value={game.gameDir}
|
<p className="text-[12px] text-muted-foreground/70 mt-0.5">新实例默认安装到此目录</p>
|
||||||
onChange={(v) => setGame({ gameDir: v })}
|
</div>
|
||||||
/>
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<SettingCard>
|
<Button size="sm" variant="outline" onPress={handleBrowseMainDir}>
|
||||||
<div className="flex items-center justify-between">
|
<FolderOpen className="w-3.5 h-3.5" />
|
||||||
<div>
|
浏览
|
||||||
<p className="text-sm font-medium text-foreground">已安装版本</p>
|
</Button>
|
||||||
<p className="text-[13px] text-muted-foreground mt-0.5">扫描当前目录下的游戏版本</p>
|
<Button size="sm" variant="outline" onPress={() => handleScan(game.gameDir)} isDisabled={scanning}>
|
||||||
</div>
|
<Search className="w-3.5 h-3.5" />
|
||||||
<Button size="sm" variant="outline">
|
|
||||||
<RefreshCw className="w-3.5 h-3.5 mr-1.5" />
|
|
||||||
扫描
|
扫描
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div>
|
{/* 已添加的目录列表 */}
|
||||||
<SectionTitle>资源包目录</SectionTitle>
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-3">
|
<SectionTitle>已添加目录({dirs.length})</SectionTitle>
|
||||||
<PathInput
|
<Button size="sm" variant="outline" onPress={handleAddFolder}>
|
||||||
label="资源包路径"
|
<Plus className="w-3.5 h-3.5" />
|
||||||
desc="自定义资源包(材质包)的存放目录,留空使用默认路径"
|
手动添加
|
||||||
value={game.resourceDir}
|
</Button>
|
||||||
onChange={(v) => setGame({ resourceDir: v })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{dirs.length === 0 ? (
|
||||||
<SectionTitle>存档目录</SectionTitle>
|
<SettingCard>
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col items-center gap-2 py-10 text-center">
|
||||||
<PathInput
|
<FolderOpen className="w-7 h-7 text-muted-foreground/25" />
|
||||||
label="存档路径"
|
<p className="text-[13px] text-muted-foreground/70">还没有添加任何游戏目录</p>
|
||||||
desc="自定义世界存档的存放目录,留空使用默认路径"
|
<p className="text-[12px] text-muted-foreground/50">
|
||||||
value={game.savesDir}
|
使用主目录的"扫描"功能发现已安装版本,或点击"手动添加"选择文件夹
|
||||||
onChange={(v) => setGame({ savesDir: v })}
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ const modeLabels: Record<string, string> = {
|
|||||||
run: "正式版",
|
run: "正式版",
|
||||||
};
|
};
|
||||||
|
|
||||||
const GITHUB_URL = "https://github.com/koring-launcher/koring-launcher";
|
const GITHUB_URL = "https://github.com/lingke-net/koring-launcher";
|
||||||
const OFFICIAL_URL = "https://koring.app";
|
const OFFICIAL_URL = "https://koring.space";
|
||||||
|
|
||||||
export function AboutSetting() {
|
export function AboutSetting() {
|
||||||
const openDialog = useConfirmDialogStore((s) => s.openDialog);
|
const openDialog = useConfirmDialogStore((s) => s.openDialog);
|
||||||
|
|||||||
@@ -32,9 +32,46 @@ export function CopyrightSetting() {
|
|||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<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>
|
<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")}>
|
<Link onPress={() => openLink("https://support.lingke.ink/LL-1.0")}>
|
||||||
查看
|
查看
|
||||||
<ExternalLink className="w-3 h-3" />
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ export function HomeSetting({ onNavigate }: HomeSettingProps) {
|
|||||||
<PageHeader title="主页" desc="版本信息与常用设置的快捷入口" />
|
<PageHeader title="主页" desc="版本信息与常用设置的快捷入口" />
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<VersionCard simple />
|
{/* 与"关于"页使用相同的版本卡片 */}
|
||||||
|
<VersionCard />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<SectionTitle>常用设置</SectionTitle>
|
<SectionTitle>常用设置</SectionTitle>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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() {
|
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";
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const DEFAULT_CONFIG: AppConfig = {
|
|||||||
theme: { darkMode: "auto", parallax: true },
|
theme: { darkMode: "auto", parallax: true },
|
||||||
a11y: { reduceMotion: false, reduceTransparency: false, highContrast: false, contentBlurOpacity: 50 },
|
a11y: { reduceMotion: false, reduceTransparency: false, highContrast: false, contentBlurOpacity: 50 },
|
||||||
background: { bgType: "image", image: DEFAULT_BG, blur: 0, opacity: 100 },
|
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: "" },
|
java: { javaPath: "", memMode: "auto", memGB: 4, gc: "auto", jvmArgs: "" },
|
||||||
advanced: { afterLaunch: "close", winMode: "default", customWidth: 854, customHeight: 480, gameArgs: "", preLaunchCmd: "", debugMode: false },
|
advanced: { afterLaunch: "close", winMode: "default", customWidth: 854, customHeight: 480, gameArgs: "", preLaunchCmd: "", debugMode: false },
|
||||||
download: { fileSource: "mirror", versionSource: "mirror", threads: 16, speedLimit: 0 },
|
download: { fileSource: "mirror", versionSource: "mirror", threads: 16, speedLimit: 0 },
|
||||||
|
|||||||
+69
-15
@@ -1,20 +1,43 @@
|
|||||||
|
// __ __ __ __ ______ __ __ ______ __ __ ______ ______
|
||||||
|
// /\ \ /\ \ /\ "-.\ \ /\ ___\ /\ \/ / /\ ___\ /\ "-.\ \ /\ ___\ /\__ _\
|
||||||
|
// \ \ \____ \ \ \ \ \ \-. \ \ \ \__ \ \ \ _"-. \ \ __\ \ \ \-. \ \ \ __\ \/_/\ \/
|
||||||
|
// \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ \ \_\ \_\ \ \_____\ \ \_\\"\_\ \ \_____\ \ \_\
|
||||||
|
// \/_____/ \/_/ \/_/ \/_/ \/_____/ \/_/\/_/ \/_____/ \/_/ \/_/ \/_____/ \/_/
|
||||||
|
//
|
||||||
|
// 所有权利归Lingke Network (china)所有 | dream_pep拥有其艺术改变权力
|
||||||
|
// 未经允许的情况下删除此版权头可能会受到民事指控
|
||||||
|
// 请勿在未经Lingke Network (china)允许的范围内修改代码并分发
|
||||||
|
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { searchMods, getModDetail, getModVersions, installMod } from "../api/mods";
|
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 {
|
interface ModsState {
|
||||||
searchResults: ModSearchResult[];
|
searchResults: ModSearchResult[];
|
||||||
|
total: number;
|
||||||
|
currentPage: number;
|
||||||
|
pageSize: number;
|
||||||
|
hasMore: boolean;
|
||||||
currentMod: ModSearchResult | null;
|
currentMod: ModSearchResult | null;
|
||||||
modVersions: ModVersionResult[];
|
modVersions: ModVersionResult[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
installing: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|
||||||
search: (
|
search: (params?: ModSearchParams) => Promise<ModSearchResponse | null>;
|
||||||
query?: string,
|
|
||||||
gameVersion?: string,
|
|
||||||
loader?: string,
|
|
||||||
source?: "modrinth" | "curseforge"
|
|
||||||
) => Promise<void>;
|
|
||||||
getDetail: (projectId: string, source: "modrinth" | "curseforge") => Promise<void>;
|
getDetail: (projectId: string, source: "modrinth" | "curseforge") => Promise<void>;
|
||||||
getVersions: (
|
getVersions: (
|
||||||
projectId: string,
|
projectId: string,
|
||||||
@@ -32,20 +55,48 @@ interface ModsState {
|
|||||||
clear: () => void;
|
clear: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useModsStore = create<ModsState>((set) => ({
|
export const useModsStore = create<ModsState>((set, get) => ({
|
||||||
searchResults: [],
|
searchResults: [],
|
||||||
|
total: 0,
|
||||||
|
currentPage: 1,
|
||||||
|
pageSize: 12,
|
||||||
|
hasMore: false,
|
||||||
currentMod: null,
|
currentMod: null,
|
||||||
modVersions: [],
|
modVersions: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
|
installing: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
search: async (query?, gameVersion?, loader?, source = "modrinth") => {
|
search: async (params = {}) => {
|
||||||
set({ loading: true, error: null });
|
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 {
|
try {
|
||||||
const results = await searchMods(query, gameVersion, loader, 20, 0, source);
|
const offset = (page - 1) * pageSize;
|
||||||
set({ searchResults: results, loading: false });
|
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) {
|
} catch (e: any) {
|
||||||
set({ error: e.message, loading: false });
|
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") => {
|
install: async (projectId, versionId, gamePath, source = "modrinth") => {
|
||||||
set({ loading: true, error: null });
|
set({ installing: true, error: null });
|
||||||
try {
|
try {
|
||||||
await installMod(projectId, versionId, gamePath, source);
|
await installMod(projectId, versionId, gamePath, source);
|
||||||
set({ loading: false });
|
set({ installing: false });
|
||||||
} catch (e: any) {
|
} 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: () =>
|
clear: () =>
|
||||||
set({
|
set({
|
||||||
searchResults: [],
|
searchResults: [],
|
||||||
|
total: 0,
|
||||||
|
currentPage: 1,
|
||||||
|
hasMore: false,
|
||||||
currentMod: null,
|
currentMod: null,
|
||||||
modVersions: [],
|
modVersions: [],
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type RouteKey =
|
|||||||
| "oobe/welcome"
|
| "oobe/welcome"
|
||||||
| "oobe/finish"
|
| "oobe/finish"
|
||||||
| "oobe/about-info"
|
| "oobe/about-info"
|
||||||
|
| "oobe/legal"
|
||||||
| "debug"
|
| "debug"
|
||||||
| "debug-splash"
|
| "debug-splash"
|
||||||
| "debug-display"
|
| "debug-display"
|
||||||
@@ -59,6 +60,7 @@ export const allRoutes: RouteItem[] = [
|
|||||||
{ key: "oobe/welcome", label: "欢迎", path: "/oobe/welcome", hidden: true },
|
{ key: "oobe/welcome", label: "欢迎", path: "/oobe/welcome", hidden: true },
|
||||||
{ key: "oobe/finish", label: "完成", path: "/oobe/finish", 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/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", label: "调试", path: "/debug", hidden: true },
|
||||||
{ key: "debug-splash", label: "启动动画调试", path: "/debug/splash", hidden: true },
|
{ key: "debug-splash", label: "启动动画调试", path: "/debug/splash", hidden: true },
|
||||||
{ key: "debug-display", label: "显示效果调试", path: "/debug/display", hidden: true },
|
{ key: "debug-display", label: "显示效果调试", path: "/debug/display", hidden: true },
|
||||||
@@ -87,8 +89,11 @@ interface RouteState {
|
|||||||
titleBarMode: TitleBarMode;
|
titleBarMode: TitleBarMode;
|
||||||
direction: TransitionDirection;
|
direction: TransitionDirection;
|
||||||
history: RouteKey[];
|
history: RouteKey[];
|
||||||
|
/** 资源中心选中的分类(原版游戏 / MOD / 整合包) */
|
||||||
|
storeSection: "game" | "mod" | "modpack";
|
||||||
navigate: (key: RouteKey) => void;
|
navigate: (key: RouteKey) => void;
|
||||||
setTitleBarMode: (mode: TitleBarMode) => void;
|
setTitleBarMode: (mode: TitleBarMode) => void;
|
||||||
|
setStoreSection: (section: "game" | "mod" | "modpack") => void;
|
||||||
goBack: () => void;
|
goBack: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +102,7 @@ export const useRouteStore = create<RouteState>((set, get) => ({
|
|||||||
titleBarMode: "default",
|
titleBarMode: "default",
|
||||||
direction: "forward",
|
direction: "forward",
|
||||||
history: [],
|
history: [],
|
||||||
|
storeSection: "game",
|
||||||
navigate: (key) => {
|
navigate: (key) => {
|
||||||
const prev = get().current;
|
const prev = get().current;
|
||||||
if (prev === key) return;
|
if (prev === key) return;
|
||||||
@@ -110,6 +116,7 @@ export const useRouteStore = create<RouteState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
setTitleBarMode: (mode) => set({ titleBarMode: mode }),
|
setTitleBarMode: (mode) => set({ titleBarMode: mode }),
|
||||||
|
setStoreSection: (section) => set({ storeSection: section }),
|
||||||
goBack: () => {
|
goBack: () => {
|
||||||
const { history } = get();
|
const { history } = get();
|
||||||
if (history.length === 0) return;
|
if (history.length === 0) return;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"outDir": "electron",
|
"outDir": "electron-dist",
|
||||||
"rootDir": "electron",
|
"rootDir": "electron",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user