From cb0afd99c058c4dd313f14a63474856c302ba430 Mon Sep 17 00:00:00 2001 From: dream_pep Date: Tue, 4 Aug 2026 01:28:16 +0800 Subject: [PATCH] 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. --- .env.beta | 2 +- .env.development | 2 +- .env.production | 2 +- .gitignore | 3 +- .minecraft/instances/123/instance.json | 12 - .npmrc | 12 + Koring.yml | 3 + electron-builder.yml | 21 +- electron/core/auth.js | 180 ------------ electron/core/crash-logger.js | 93 ------- electron/core/installer.js | 181 ------------ electron/core/installer.ts | 81 +++++- electron/core/instance.js | 295 -------------------- electron/core/instance.ts | 91 +++++- electron/core/koring-auth.js | 175 ------------ electron/core/launcher.js | 152 ---------- electron/core/modrinth.js | 163 ----------- electron/core/modrinth.ts | 118 ++++++-- electron/handlers/auth.js | 38 --- electron/handlers/background.js | 128 --------- electron/handlers/config.js | 77 ------ electron/handlers/crash-monitor.js | 206 -------------- electron/handlers/install.js | 74 ----- electron/handlers/instance.js | 151 ---------- electron/handlers/instance.ts | 11 + electron/handlers/koring-auth.js | 113 -------- electron/handlers/launch.js | 45 --- electron/handlers/mods.js | 47 ---- electron/handlers/mods.ts | 24 +- electron/handlers/system.js | 70 ----- electron/handlers/system.ts | 12 +- electron/handlers/task.js | 61 ---- electron/handlers/task.ts | 75 +++-- electron/handlers/window.js | 95 ------- electron/handlers/window.ts | 9 + electron/main.ts | 19 +- package.json | 10 +- pnpm-workspace.yaml | 4 + public/installer-custom.nsh | 43 +++ public/installer-header.png | Bin 0 -> 27445 bytes scripts/switch-icon.js | 113 +++++++- src/App.tsx | 2 + src/api/config.ts | 2 + src/api/instance.ts | 35 ++- src/api/mods.ts | 29 +- src/api/system.ts | 5 + src/components/VersionCard.tsx | 24 +- src/pages/gallery/CreateDialog.tsx | 25 +- src/pages/gallery/EditDialog.tsx | 115 ++++++++ src/pages/gallery/InstanceCard.tsx | 154 ----------- src/pages/gallery/InstanceDetail.tsx | 219 +++++++++++++++ src/pages/gallery/InstanceList.tsx | 108 ++++++++ src/pages/gallery/index.tsx | 293 ++++++++++++-------- src/pages/oobe/step-agreement.tsx | 73 +++-- src/pages/oobe/step-language.tsx | 3 +- src/pages/oobe/step-legal.tsx | 65 +++++ src/pages/oobe/step-version.tsx | 45 +-- src/pages/oobe/step-welcome.tsx | 2 +- src/pages/setting/game/game-dir.tsx | 316 +++++++++++++++++---- src/pages/setting/general/about.tsx | 4 +- src/pages/setting/general/copyright.tsx | 41 ++- src/pages/setting/general/home.tsx | 3 +- src/pages/store/GameVersionView.tsx | 305 ++++++++++++++++++++ src/pages/store/InstallDialog.tsx | 255 +++++++++++++++++ src/pages/store/ModCard.tsx | 90 ++++++ src/pages/store/ModInstallDialog.tsx | 163 +++++++++++ src/pages/store/ResourceBrowseView.tsx | 354 ++++++++++++++++++++++++ src/pages/store/Sidebar.tsx | 93 +++++++ src/pages/store/index.tsx | 77 +++++- src/stores/configStore.ts | 2 +- src/stores/modsStore.ts | 84 +++++- src/stores/routeStore.ts | 7 + tsconfig.electron.json | 2 +- 73 files changed, 3108 insertions(+), 2898 deletions(-) delete mode 100644 .minecraft/instances/123/instance.json create mode 100644 .npmrc delete mode 100644 electron/core/auth.js delete mode 100644 electron/core/crash-logger.js delete mode 100644 electron/core/installer.js delete mode 100644 electron/core/instance.js delete mode 100644 electron/core/koring-auth.js delete mode 100644 electron/core/launcher.js delete mode 100644 electron/core/modrinth.js delete mode 100644 electron/handlers/auth.js delete mode 100644 electron/handlers/background.js delete mode 100644 electron/handlers/config.js delete mode 100644 electron/handlers/crash-monitor.js delete mode 100644 electron/handlers/install.js delete mode 100644 electron/handlers/instance.js delete mode 100644 electron/handlers/koring-auth.js delete mode 100644 electron/handlers/launch.js delete mode 100644 electron/handlers/mods.js delete mode 100644 electron/handlers/system.js delete mode 100644 electron/handlers/task.js delete mode 100644 electron/handlers/window.js create mode 100644 pnpm-workspace.yaml create mode 100644 public/installer-custom.nsh create mode 100644 public/installer-header.png create mode 100644 src/pages/gallery/EditDialog.tsx delete mode 100644 src/pages/gallery/InstanceCard.tsx create mode 100644 src/pages/gallery/InstanceDetail.tsx create mode 100644 src/pages/gallery/InstanceList.tsx create mode 100644 src/pages/oobe/step-legal.tsx create mode 100644 src/pages/store/GameVersionView.tsx create mode 100644 src/pages/store/InstallDialog.tsx create mode 100644 src/pages/store/ModCard.tsx create mode 100644 src/pages/store/ModInstallDialog.tsx create mode 100644 src/pages/store/ResourceBrowseView.tsx create mode 100644 src/pages/store/Sidebar.tsx diff --git a/.env.beta b/.env.beta index eb23526..97b4564 100644 --- a/.env.beta +++ b/.env.beta @@ -1,2 +1,2 @@ VITE_BUILD_MODE=beta -VITE_APP_ICON=/beta.png +VITE_APP_ICON=/icons/beta/icon.png diff --git a/.env.development b/.env.development index 468d727..7ff4877 100644 --- a/.env.development +++ b/.env.development @@ -1,2 +1,2 @@ VITE_BUILD_MODE=dev -VITE_APP_ICON=/dev.png +VITE_APP_ICON=/icons/dev/icon.png diff --git a/.env.production b/.env.production index ce78263..610cd46 100644 --- a/.env.production +++ b/.env.production @@ -1,2 +1,2 @@ VITE_BUILD_MODE=run -VITE_APP_ICON=/run.png +VITE_APP_ICON=/icons/run/icon.png diff --git a/.gitignore b/.gitignore index 3c29258..78d03f5 100644 --- a/.gitignore +++ b/.gitignore @@ -25,8 +25,7 @@ dist-electron *.sw? # Electron -electron/*.js -electron/*.d.ts +electron-dist/ # Build resources (generated by switch-icon.js) build/ diff --git a/.minecraft/instances/123/instance.json b/.minecraft/instances/123/instance.json deleted file mode 100644 index ba8a8a7..0000000 --- a/.minecraft/instances/123/instance.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "123", - "author": "", - "description": "123", - "runtime": { - "minecraft": "1.21.4" - }, - "creationDate": 1785342079436, - "lastAccessDate": 1785342079436, - "lastPlayedDate": 0, - "playtime": 0 -} \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..310d8e2 --- /dev/null +++ b/.npmrc @@ -0,0 +1,12 @@ +# __ __ __ __ ______ __ __ ______ __ __ ______ ______ +# /\ \ /\ \ /\ "-.\ \ /\ ___\ /\ \/ / /\ ___\ /\ "-.\ \ /\ ___\ /\__ _\ +# \ \ \____ \ \ \ \ \ \-. \ \ \ \__ \ \ \ _"-. \ \ __\ \ \ \-. \ \ \ __\ \/_/\ \/ +# \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ \ \_\ \_\ \ \_____\ \ \_\\"\_\ \ \_____\ \ \_\ +# \/_____/ \/_/ \/_/ \/_/ \/_____/ \/_/\/_/ \/_____/ \/_/ \/_/ \/_____/ \/_/ +# +# 所有权利归Lingke Network (china)所有 | dream_pep拥有其艺术改变权力 +# 未经允许的情况下删除此版权头可能会受到民事指控 +# 请勿在未经Lingke Network (china)允许的范围内修改代码并分发 +# +# 项目包管理配置:electron 二进制下载走国内镜像,避免 GitHub 网络不可达 +electron_mirror=https://npmmirror.com/mirrors/electron/ diff --git a/Koring.yml b/Koring.yml index e69de29..0b64fb0 100644 --- a/Koring.yml +++ b/Koring.yml @@ -0,0 +1,3 @@ +oobe: false +game: + gameDirs: [] diff --git a/electron-builder.yml b/electron-builder.yml index bd2c35c..7ee112e 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -3,14 +3,16 @@ productName: Koring Launcher directories: output: dist-electron buildResources: build +compression: maximum +asar: true files: - dist/** - - electron/**/*.js - - "!electron/**/*.ts" + - electron-dist/**/*.js win: target: nsis icon: build/icon.ico artifactName: "koring-launcher-${version}-setup.${ext}" + requestedExecutionLevel: asInvoker mac: target: dmg icon: build/icon.png @@ -23,16 +25,15 @@ nsis: installerIcon: build/icon.ico uninstallerIcon: build/icon.ico installerHeaderIcon: build/icon.ico - installerHeader: build/installer-header.png - license: build/license.rtf + installerSidebar: build/installer-header.bmp + license: build/license.txt createDesktopShortcut: true createStartMenuShortcut: true shortcutName: Koring Launcher include: build/installer-custom.nsh - script: build/installer-strings.nsh - uninstallDisplayName: "卸载 Koring Launcher" - displayLanguageSelector: true + uninstallDisplayName: "Koring Launcher" + deleteAppDataOnUninstall: false installerLanguages: - - 2052 - - 1028 - - 1033 + - "zh_CN" + - "zh_TW" + - "en_US" diff --git a/electron/core/auth.js b/electron/core/auth.js deleted file mode 100644 index 2556747..0000000 --- a/electron/core/auth.js +++ /dev/null @@ -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; - } -} diff --git a/electron/core/crash-logger.js b/electron/core/crash-logger.js deleted file mode 100644 index e0a31eb..0000000 --- a/electron/core/crash-logger.js +++ /dev/null @@ -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 { } -} diff --git a/electron/core/installer.js b/electron/core/installer.js deleted file mode 100644 index 742d23d..0000000 --- a/electron/core/installer.js +++ /dev/null @@ -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', - }; -} diff --git a/electron/core/installer.ts b/electron/core/installer.ts index 7bb9109..4e55c77 100644 --- a/electron/core/installer.ts +++ b/electron/core/installer.ts @@ -6,14 +6,63 @@ import * as http from 'http'; const { net } = electron; -const VERSION_MANIFEST_URL = 'https://launchermeta.mojang.com/mc/game/version_manifest.json'; -const FORGE_VERSION_LIST_URL = 'https://files.minecraftforge.net/net/minecraftforge/forge/json'; -const FABRIC_VERSION_LIST_URL = 'https://meta.fabricmc.net/v2/versions'; +// ==================== BMCLAPI 镜像源配置 ==================== +// 使用 BMCLAPI(bmclapi2.bangbang93.com)镜像加速国内资源下载 +// 版本清单:launchermeta.mojang.com → bmclapi2.bangbang93.com +// Fabric 元数据:meta.fabricmc.net → bmclapi2.bangbang93.com/fabric-meta +const VERSION_MANIFEST_URL = 'https://bmclapi2.bangbang93.com/mc/game/version_manifest.json'; +const FABRIC_VERSION_LIST_URL = 'https://bmclapi2.bangbang93.com/fabric-meta/v2/versions'; + +// BMCLAPI 域名替换规则(按顺序匹配,带路径前缀的规则优先) +// 版本 JSON/客户端:launchermeta|launcher|piston-meta|piston-data.mojang.com → bmclapi2.bangbang93.com +// 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 { id: string; type: string; url: string; + releaseTime: string; } interface VersionManifest { @@ -29,9 +78,11 @@ interface DownloadProgress { } async function downloadFile(url: string, dest: string, onProgress?: (progress: DownloadProgress) => void): Promise { + // 下载前统一改写为 BMCLAPI 镜像地址 + url = rewriteToMirror(url); return new Promise((resolve, reject) => { - const client = url.startsWith('https') ? https : http; - const request = client.get(url, (response) => { + // 根据协议选择 http / https 客户端(分开调用以避免 TS 联合类型签名不兼容) + const handleResponse = (response: http.IncomingMessage) => { if (response.statusCode === 302 || response.statusCode === 301) { downloadFile(response.headers.location!, dest, onProgress).then(resolve).catch(reject); return; @@ -52,7 +103,7 @@ async function downloadFile(url: string, dest: string, onProgress?: (progress: D const file = fs.createWriteStream(dest); - response.on('data', (chunk) => { + response.on('data', (chunk: Buffer) => { downloadedBytes += chunk.length; if (onProgress && totalBytes > 0) { onProgress({ @@ -75,7 +126,14 @@ async function downloadFile(url: string, dest: string, onProgress?: (progress: D fs.unlink(dest, () => {}); reject(err); }); - }); + }; + + let request: http.ClientRequest; + if (url.startsWith('https')) { + request = https.get(url, handleResponse); + } else { + request = http.get(url, handleResponse); + } request.on('error', reject); }); @@ -95,11 +153,12 @@ export async function getVersionList(type?: string): Promise { export async function getForgeVersions(mcVersion?: string) { try { - const response = await net.fetch(FORGE_VERSION_LIST_URL); + // BMCLAPI Forge 版本列表接口(需指定 MC 版本) + if (!mcVersion) return { versions: [] }; + const response = await net.fetch(`https://bmclapi2.bangbang93.com/forge/minecraft/${mcVersion}`); if (!response.ok) throw new Error(`Forge version list failed: ${response.status}`); - const data = await response.json() as { versions: Record }; - const versions = data.versions[mcVersion || ''] || []; - return { versions }; + const data = await response.json() as { version: string }[]; + return { versions: data.map((v) => v.version) }; } catch { return { versions: [] }; } diff --git a/electron/core/instance.js b/electron/core/instance.js deleted file mode 100644 index 4ec5cbc..0000000 --- a/electron/core/instance.js +++ /dev/null @@ -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) }; -} diff --git a/electron/core/instance.ts b/electron/core/instance.ts index 73a2570..e7be36a 100644 --- a/electron/core/instance.ts +++ b/electron/core/instance.ts @@ -14,6 +14,18 @@ import { getFabricLoaders, getQuiltLoaderVersionsByMinecraft, } from '@xmcl/installer'; +import { rewriteToMirror } from './installer'; + +// ==================== BMCLAPI 镜像源配置 ==================== +// 版本清单(@xmcl/installer 的 getVersionList 通过 remote 参数覆盖) +const BMCLAPI_VERSION_MANIFEST = 'https://bmclapi2.bangbang93.com/mc/game/version_manifest.json'; +// Maven 仓库镜像(Libraries / Forge / NeoForge / Fabric 构件) +const BMCLAPI_MAVEN = 'https://bmclapi2.bangbang93.com/maven'; +// 资源文件镜像(assets) +const BMCLAPI_ASSETS = 'https://bmclapi2.bangbang93.com/assets'; + +// 供 @xmcl/installer FetchOptions 使用的镜像 fetch(meta.fabricmc.net 等元数据接口走镜像) +const mirrorFetch: typeof fetch = (url, init) => fetch(rewriteToMirror(String(url)), init); export interface InstanceRuntime { minecraft: string; @@ -232,18 +244,24 @@ export async function installInstanceGame( callbacks?.onProgress?.({ stage: 'installing-minecraft', current: 0, total: 100, message: `Installing Minecraft ${runtime.minecraft}...` }); // Install Minecraft - // First get version list to find the version info - const versionList = await getVersionList(); + // First get version list to find the version info(走 BMCLAPI 镜像) + const versionList = await getVersionList({ remote: BMCLAPI_VERSION_MANIFEST, fetch: mirrorFetch }); const versionInfo = versionList.versions.find((v) => v.id === runtime.minecraft); if (!versionInfo) { throw new Error(`Minecraft version ${runtime.minecraft} not found`); } - await xmclInstall({ id: versionInfo.id, url: versionInfo.url }, instancePath); + await xmclInstall({ id: versionInfo.id, url: versionInfo.url }, instancePath, { + // 版本 JSON / 客户端 JAR 走镜像(BMCLAPI 会回源官方地址) + json: (v) => rewriteToMirror(v.url), + client: (v) => (v.downloads.client ? rewriteToMirror(v.downloads.client.url) : []), + mavenHost: BMCLAPI_MAVEN, + assetsHost: BMCLAPI_ASSETS, + }); // Install mod loaders if (runtime.forge) { callbacks?.onProgress?.({ stage: 'installing-forge', current: 0, total: 100, message: `Installing Forge ${runtime.forge}...` }); - await installForge({ version: runtime.forge, mcversion: runtime.minecraft }, instancePath); + await installForge({ version: runtime.forge, mcversion: runtime.minecraft }, instancePath, { mavenHost: BMCLAPI_MAVEN }); } if (runtime.fabricLoader) { @@ -252,6 +270,7 @@ export async function installInstanceGame( minecraftVersion: runtime.minecraft, version: runtime.fabricLoader, minecraft: instancePath, + fetch: mirrorFetch, }); } @@ -266,7 +285,7 @@ export async function installInstanceGame( if (runtime.neoForged) { callbacks?.onProgress?.({ stage: 'installing-neoforge', current: 0, total: 100, message: `Installing NeoForge ${runtime.neoForged}...` }); - await installNeoForged('neoforge', runtime.neoForged, instancePath, {}); + await installNeoForged('neoforge', runtime.neoForged, instancePath, { mavenHost: BMCLAPI_MAVEN }); } // Note: OptiFine requires downloading the installer JAR first @@ -277,9 +296,9 @@ export async function installInstanceGame( callbacks?.onProgress?.({ stage: 'installing-dependencies', current: 0, total: 100, message: 'Installing dependencies...' }); - // Install all dependencies (libraries + assets) + // Install all dependencies (libraries + assets)(走 BMCLAPI 镜像) const resolved: ResolvedVersion = await Version.parse(instancePath, runtime.minecraft); - await installDependencies(resolved); + await installDependencies(resolved, { mavenHost: BMCLAPI_MAVEN, assetsHost: BMCLAPI_ASSETS }); callbacks?.onProgress?.({ stage: 'done', current: 100, total: 100, message: 'Installation complete' }); @@ -370,12 +389,13 @@ export async function diagnoseInstance( // Version list APIs export async function getMinecraftVersionList(type?: string) { - const manifest = await getVersionList(); + // 版本清单一律走 BMCLAPI 镜像 + const manifest = await getVersionList({ remote: BMCLAPI_VERSION_MANIFEST, fetch: mirrorFetch }); let versions = manifest.versions; if (type && type !== 'all') { - versions = versions.filter((v) => v.type === type); + versions = versions.filter((v: { type: string }) => v.type === type); } - return { versions }; + return { versions, latest: manifest.latest }; } export async function getForgeVersionList(mcVersion?: string) { @@ -384,11 +404,12 @@ export async function getForgeVersionList(mcVersion?: string) { } export async function getFabricVersionList(mcVersion?: string) { + // Fabric 元数据走 BMCLAPI 镜像(meta.fabricmc.net → fabric-meta) if (mcVersion) { - const loaders = await getFabricLoaders(); + const loaders = await getFabricLoaders({ fetch: mirrorFetch }); return { versions: loaders.map((l) => l.version) }; } - const loaders = await getFabricLoaders(); + const loaders = await getFabricLoaders({ fetch: mirrorFetch }); return { versions: loaders.map((l) => l.version) }; } @@ -396,3 +417,49 @@ export async function getQuiltVersionList(mcVersion?: string) { const loaders = await getQuiltLoaderVersionsByMinecraft({ minecraftVersion: mcVersion || '*' }); return { versions: loaders.map((l) => l.loader.version) }; } + +// 扫描目录中已安装版本的信息 +export interface ScannedVersion { + id: string; + type: string; + releaseTime?: string; + /** JAR 文件是否存在 */ + hasJar: boolean; + /** JSON 文件是否存在 */ + hasJson: boolean; +} + +// 扫描指定目录,返回 versions 子目录中所有已安装版本的详细信息 +export function scanGameDirectories(gamePath: string): ScannedVersion[] { + const versionsPath = path.join(gamePath, 'versions'); + if (!fs.existsSync(versionsPath)) return []; + try { + return fs.readdirSync(versionsPath, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => { + const id = e.name; + const versionDir = path.join(versionsPath, id); + const jsonPath = path.join(versionDir, `${id}.json`); + const jarPath = path.join(versionDir, `${id}.jar`); + const hasJson = fs.existsSync(jsonPath); + const hasJar = fs.existsSync(jarPath); + + let type = "unknown"; + let releaseTime: string | undefined; + + // 尝试读取 version JSON 获取类型和时间 + if (hasJson) { + try { + const raw = fs.readFileSync(jsonPath, 'utf-8'); + const json = JSON.parse(raw); + type = json.type || "unknown"; + releaseTime = json.releaseTime; + } catch {} + } + + return { id, type, releaseTime, hasJar, hasJson }; + }); + } catch { + return []; + } +} diff --git a/electron/core/koring-auth.js b/electron/core/koring-auth.js deleted file mode 100644 index fb6640b..0000000 --- a/electron/core/koring-auth.js +++ /dev/null @@ -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: '' }); -} diff --git a/electron/core/launcher.js b/electron/core/launcher.js deleted file mode 100644 index 611c54f..0000000 --- a/electron/core/launcher.js +++ /dev/null @@ -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, - }; -} diff --git a/electron/core/modrinth.js b/electron/core/modrinth.js deleted file mode 100644 index 161a0b3..0000000 --- a/electron/core/modrinth.js +++ /dev/null @@ -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, - }; -} diff --git a/electron/core/modrinth.ts b/electron/core/modrinth.ts index a1689f2..76c0d48 100644 --- a/electron/core/modrinth.ts +++ b/electron/core/modrinth.ts @@ -20,6 +20,19 @@ interface ModSearchResult { source: 'modrinth' | 'curseforge'; } +interface ModSearchResponse { + hits: ModSearchResult[]; + total: number; + limit: number; + offset: number; +} + +interface ModCategory { + name: string; + label: string; + projectType: string; +} + interface ModVersionResult { id: string; name: string; @@ -38,17 +51,21 @@ async function searchModrinth( query?: string, gameVersion?: string, loader?: string, + category?: string, + projectType: string = 'mod', limit: number = 20, offset: number = 0 -): Promise { +): Promise { const facets: string[][] = []; if (gameVersion) facets.push([`versions:${gameVersion}`]); if (loader) facets.push([`categories:${loader}`]); + if (category) facets.push([`categories:${category}`]); const params = new URLSearchParams({ query: query || '', limit: String(limit), offset: String(offset), + project_type: projectType, }); if (facets.length > 0) { @@ -58,30 +75,38 @@ async function searchModrinth( const response = await net.fetch(`${MODRINTH_API}/search?${params.toString()}`); if (!response.ok) throw new Error(`Modrinth search failed: ${response.status}`); - const data = await response.json() as { hits: Array<{ - project_id: string; - slug: string; - title: string; - description: string; - downloads: number; - icon_url?: string; - categories?: string[]; - versions?: string[]; - client_side?: string; - server_side?: string; - }> }; + const data = await response.json() as { + total_hits: number; + hits: Array<{ + project_id: string; + slug: string; + title: string; + description: string; + downloads: number; + icon_url?: string; + categories?: string[]; + versions?: string[]; + client_side?: string; + server_side?: string; + }>; + }; - return data.hits.map((hit) => ({ - id: hit.project_id, - slug: hit.slug, - name: hit.title, - description: hit.description, - downloads: hit.downloads, - iconUrl: hit.icon_url, - categories: hit.categories, - versions: hit.versions, - source: 'modrinth' as const, - })); + return { + hits: data.hits.map((hit) => ({ + id: hit.project_id, + slug: hit.slug, + name: hit.title, + description: hit.description, + downloads: hit.downloads, + iconUrl: hit.icon_url, + categories: hit.categories, + versions: hit.versions, + source: 'modrinth' as const, + })), + total: data.total_hits, + limit, + offset, + }; } async function searchCurseForge( @@ -90,25 +115,62 @@ async function searchCurseForge( loader?: string, limit: number = 20, offset: number = 0 -): Promise { +): Promise { // CurseForge requires API key - return empty for now - return []; + return { hits: [], total: 0, limit, offset }; } export async function searchMods( query?: string, gameVersion?: string, loader?: string, + category?: string, + projectType: string = 'mod', limit?: number, offset?: number, source: 'modrinth' | 'curseforge' = 'modrinth' -): Promise { +): Promise { if (source === 'modrinth') { - return searchModrinth(query, gameVersion, loader, limit, offset); + return searchModrinth(query, gameVersion, loader, category, projectType, limit, offset); } return searchCurseForge(query, gameVersion, loader, limit, offset); } +export async function getCategories(projectType: string = 'mod'): Promise { + 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 { + const response = await net.fetch(`${MODRINTH_API}/tag/game_version`); + if (!response.ok) throw new Error(`Modrinth game versions failed: ${response.status}`); + + const data = await response.json() as string[]; + // 只保留正式版(1.x 且非预览版),并按版本号倒序 + return data + .filter((v) => /^\d+\.\d+(\.\d+)?$/.test(v)) + .sort((a, b) => { + const pa = a.split('.').map(Number); + const pb = b.split('.').map(Number); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const diff = (pb[i] || 0) - (pa[i] || 0); + if (diff !== 0) return diff; + } + return 0; + }); +} + export async function getModDetail( projectId: string, source: 'modrinth' | 'curseforge' = 'modrinth' diff --git a/electron/handlers/auth.js b/electron/handlers/auth.js deleted file mode 100644 index d778a7e..0000000 --- a/electron/handlers/auth.js +++ /dev/null @@ -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) }; - } - }); -} diff --git a/electron/handlers/background.js b/electron/handlers/background.js deleted file mode 100644 index afccffe..0000000 --- a/electron/handlers/background.js +++ /dev/null @@ -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; - } - }); -} diff --git a/electron/handlers/config.js b/electron/handlers/config.js deleted file mode 100644 index 65239e8..0000000 --- a/electron/handlers/config.js +++ /dev/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) }; - } - }); -} diff --git a/electron/handlers/crash-monitor.js b/electron/handlers/crash-monitor.js deleted file mode 100644 index ff498a9..0000000 --- a/electron/handlers/crash-monitor.js +++ /dev/null @@ -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); -} diff --git a/electron/handlers/install.js b/electron/handlers/install.js deleted file mode 100644 index 47f6895..0000000 --- a/electron/handlers/install.js +++ /dev/null @@ -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) }; - } - }); -} diff --git a/electron/handlers/instance.js b/electron/handlers/instance.js deleted file mode 100644 index 1106a93..0000000 --- a/electron/handlers/instance.js +++ /dev/null @@ -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) }; - } - }); -} diff --git a/electron/handlers/instance.ts b/electron/handlers/instance.ts index 8e8125b..88e65e4 100644 --- a/electron/handlers/instance.ts +++ b/electron/handlers/instance.ts @@ -12,6 +12,7 @@ import { getForgeVersionList, getFabricVersionList, getQuiltVersionList, + scanGameDirectories, type InstanceRuntime, type InstanceConfig, } from '../core/instance'; @@ -194,4 +195,14 @@ export function registerInstanceHandlers(win: WinRef) { return { success: false, data: null, error: String(e) }; } }); + + // 扫描游戏目录中的已安装版本 + ipcMain.handle('instance:scan-dir', async (_event, payload: { gamePath: string }) => { + try { + const versions = scanGameDirectories(payload.gamePath); + return { success: true, data: { versions }, error: null }; + } catch (e: unknown) { + return { success: false, data: null, error: String(e) }; + } + }); } diff --git a/electron/handlers/koring-auth.js b/electron/handlers/koring-auth.js deleted file mode 100644 index 63d81a5..0000000 --- a/electron/handlers/koring-auth.js +++ /dev/null @@ -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) }; - } - }); -} diff --git a/electron/handlers/launch.js b/electron/handlers/launch.js deleted file mode 100644 index 16d29ca..0000000 --- a/electron/handlers/launch.js +++ /dev/null @@ -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) }; - } - }); -} diff --git a/electron/handlers/mods.js b/electron/handlers/mods.js deleted file mode 100644 index 9b8bd8e..0000000 --- a/electron/handlers/mods.js +++ /dev/null @@ -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) }; - } - }); -} diff --git a/electron/handlers/mods.ts b/electron/handlers/mods.ts index 2d1f042..6de0190 100644 --- a/electron/handlers/mods.ts +++ b/electron/handlers/mods.ts @@ -1,5 +1,5 @@ import electron from 'electron'; -import { searchMods, getModDetail, getModVersions, installMod } from '../core/modrinth'; +import { searchMods, getModDetail, getModVersions, installMod, getCategories, getGameVersions } from '../core/modrinth'; const { ipcMain } = electron; @@ -8,6 +8,8 @@ export function registerModsHandlers() { query?: string; gameVersion?: string; loader?: string; + category?: string; + projectType?: string; limit?: number; offset?: number; source: string; @@ -17,6 +19,8 @@ export function registerModsHandlers() { payload.query, payload.gameVersion, payload.loader, + payload.category, + payload.projectType, payload.limit, payload.offset, payload.source as 'modrinth' | 'curseforge' @@ -27,6 +31,24 @@ export function registerModsHandlers() { } }); + ipcMain.handle('mods:categories', async (_event, payload: { projectType?: string }) => { + try { + const data = await getCategories(payload.projectType); + return { success: true, data, error: null }; + } catch (e: unknown) { + return { success: false, data: null, error: String(e) }; + } + }); + + ipcMain.handle('mods:game-versions', async () => { + try { + const data = await getGameVersions(); + return { success: true, data, error: null }; + } catch (e: unknown) { + return { success: false, data: null, error: String(e) }; + } + }); + ipcMain.handle('mods:detail', async (_event, payload: { projectId: string; source: string }) => { try { const data = await getModDetail( diff --git a/electron/handlers/system.js b/electron/handlers/system.js deleted file mode 100644 index facd06c..0000000 --- a/electron/handlers/system.js +++ /dev/null @@ -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) }; - } - }); -} diff --git a/electron/handlers/system.ts b/electron/handlers/system.ts index 3edd914..d277432 100644 --- a/electron/handlers/system.ts +++ b/electron/handlers/system.ts @@ -2,7 +2,7 @@ import electron from 'electron'; import { execSync } from 'child_process'; import * as os from 'os'; -const { ipcMain, app } = electron; +const { ipcMain, app, shell } = electron; function getBiosId(): string { if (process.platform !== 'win32') return 'N/A (non-Windows)'; @@ -60,4 +60,14 @@ export function registerSystemHandlers() { return { success: false, data: null, error: String(e) }; } }); + + // 在系统文件管理器中打开指定路径(用于"打开游戏目录"等操作) + ipcMain.handle('system:open-path', async (_event, payload: { path: string }) => { + try { + const error = await shell.openPath(payload.path); + return { success: !error, data: { error }, error: error || null }; + } catch (e: unknown) { + return { success: false, data: null, error: String(e) }; + } + }); } diff --git a/electron/handlers/task.js b/electron/handlers/task.js deleted file mode 100644 index 29ed1ba..0000000 --- a/electron/handlers/task.js +++ /dev/null @@ -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) }; - } - }); -} diff --git a/electron/handlers/task.ts b/electron/handlers/task.ts index 872a472..b425973 100644 --- a/electron/handlers/task.ts +++ b/electron/handlers/task.ts @@ -1,4 +1,5 @@ import electron from 'electron'; +import { createInstance, installInstanceGame, type InstanceRuntime } from '../core/instance'; const { ipcMain } = electron; @@ -8,6 +9,14 @@ interface WinRef { mainWindow: electron.BrowserWindow | null; } +// 在任务中发送进度事件到渲染进程 +function emitProgress(win: WinRef, taskId: string, current: number, total: number, stage: string) { + win.mainWindow?.webContents.send('task:progress', { taskId, current, total, stage, event: 'task:progress' }); +} +function emitLog(win: WinRef, taskId: string, level: string, message: string) { + win.mainWindow?.webContents.send('task:progress', { taskId, event: 'task:log', level, message }); +} + export function registerTaskHandlers(win: WinRef) { ipcMain.handle('task:start', async (_event, payload: { taskId: string; @@ -21,42 +30,68 @@ export function registerTaskHandlers(win: WinRef) { const controller = new AbortController(); runningTasks.set(payload.taskId, controller); - win.mainWindow?.webContents.send('task:started', { + win.mainWindow?.webContents.send('task:progress', { taskId: payload.taskId, + event: 'task:started', xmclPath: payload.executorName, }); - // Simulate task execution - const steps = 20; - for (let i = 0; i <= steps; i++) { - if (controller.signal.aborted) { - win.mainWindow?.webContents.send('task:failed', { - taskId: payload.taskId, - error: 'Task cancelled', - }); - runningTasks.delete(payload.taskId); - return { success: true, data: null, error: null }; - } + // 根据 executorName 选择处理逻辑 + if (payload.executorName === 'install') { + // 使用 @xmcl/core + @xmcl/installer 安装 Minecraft 实例 + const params = payload.params ?? {}; + const name = String(params.name ?? `mc-${Date.now()}`); + const gamePath = String(params.gamePath ?? '.minecraft'); + const runtime = (params.runtime ?? { minecraft: '1.21.4' }) as InstanceRuntime; + const description = String(params.description ?? ''); - win.mainWindow?.webContents.send('task:progress', { - taskId: payload.taskId, - current: i, - total: steps, - stage: `步骤 ${i}/${steps}`, + emitProgress(win, payload.taskId, 0, 100, '创建实例目录'); + emitLog(win, payload.taskId, 'info', `正在创建实例: ${name}`); + + // Step 1: 创建实例(写入 instance.json) + await createInstance(name, gamePath, runtime, { description }); + emitProgress(win, payload.taskId, 10, 100, '实例目录已创建'); + + // Step 2: 安装游戏文件(@xmcl/installer) + emitLog(win, payload.taskId, 'info', `正在下载 Minecraft ${runtime.minecraft}...`); + await installInstanceGame(name, gamePath, { + onProgress: (progress) => { + if (controller.signal.aborted) return; + const mapped = Math.round(10 + (progress.current / Math.max(progress.total, 1)) * 80); + emitProgress(win, payload.taskId, mapped, 100, progress.stage || '安装中'); + if (progress.message) emitLog(win, payload.taskId, 'info', progress.message); + }, }); - await new Promise((r) => setTimeout(r, 150)); + emitProgress(win, payload.taskId, 100, 100, '安装完成'); + emitLog(win, payload.taskId, 'info', `实例「${name}」创建完成`); + } else { + // 默认模拟执行 + const steps = 20; + for (let i = 0; i <= steps; i++) { + if (controller.signal.aborted) throw new Error('已取消'); + emitProgress(win, payload.taskId, i, steps, `步骤 ${i}/${steps}`); + await new Promise((r) => setTimeout(r, 150)); + } + emitLog(win, payload.taskId, 'info', '任务完成'); } runningTasks.delete(payload.taskId); - win.mainWindow?.webContents.send('task:completed', { + win.mainWindow?.webContents.send('task:progress', { taskId: payload.taskId, + event: 'task:completed', }); return { success: true, data: null, error: null }; } catch (e: unknown) { runningTasks.delete(payload.taskId); - return { success: false, data: null, error: String(e) }; + const errMsg = e instanceof Error ? e.message : String(e); + win.mainWindow?.webContents.send('task:progress', { + taskId: payload.taskId, + event: 'task:failed', + error: errMsg, + }); + return { success: false, data: null, error: errMsg }; } }); diff --git a/electron/handlers/window.js b/electron/handlers/window.js deleted file mode 100644 index bdf3a38..0000000 --- a/electron/handlers/window.js +++ /dev/null @@ -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); - }); -} diff --git a/electron/handlers/window.ts b/electron/handlers/window.ts index c84d8bd..8af5e6d 100644 --- a/electron/handlers/window.ts +++ b/electron/handlers/window.ts @@ -105,4 +105,13 @@ export function registerWindowHandlers(win: WinRef) { ipcMain.handle('shell:openExternal', async (_event, url: string) => { await shell.openExternal(url); }); + + // Folder dialog — select a directory + ipcMain.handle('dialog:openFolder', async () => { + const result = await dialog.showOpenDialog(win.mainWindow!, { + properties: ['openDirectory'], + }); + if (result.canceled || result.filePaths.length === 0) return null; + return { folderPath: result.filePaths[0] }; + }); } diff --git a/electron/main.ts b/electron/main.ts index 24ef0af..c19f978 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -53,10 +53,19 @@ function runStartupChecks(): { isFirstLaunch: boolean; config: ReturnType9TSL68IxfkUfU zS^?3ZwLP}A0+m*RQ^F)#f}wQ)3POk)P!xzknL_eiPtbFE-t~XqKdkk;T#CqkhI_c~ z>mGJ;-rKOs++?~5MN#H!R=@oZ{QVt8(cWS)@l|2z=rxM^RI}#oWgqZpPvx{c=QUqB zud&Mgsp!(Gx8IJ8jQnsDekGD$!_xmhetk}s4h|-}L`J^z&f5nr6&IPv){y`?}RpwyZ*T;Y07agw%flWyuh`Co>YrO)G{Z|ZD%H}Y>Ot2VPG z96J@KP90S`Ci_rug6_V5W=F~wI@9Je+J7~9Ippzd4d`y6Z_>@Od)ShY=OxP|N9Elv z^7>L*j;=}nLlbogRjDQ$0naueQ&hvr4-_PMlilseZJZemCY&9Pp--mgjdW4?={8QD zNGhmPTLs&@S#nD~*rk@in?u5d>qF)z+~LomZ8eN(J76ca{d6#3=Gg77^Hn7xDW&jK zMcWqSxZ-BAWdfQ39gmH>83c+e5Bp= zrD^Acx*SOVsJTG2j*~ADK=@HsvK4|PDn7HCZe|rc;ZtlHQ&_z=#I$&c?$*hmi3N_T zXvG%BW{yjI5lt0RXSy9%n@B7+jyYDF8)i^EQeG}Upb4c$^! z&2fP$Ht9Z&OfT*kR77!4-{+e^Tf3nxm=V~kgIhV5YCt@1Wi z01Yy_K(n;Z<9Vx&8`7R)%S2_`qWz+f^q zheQVIf4b0>VQE=ubl#f=uiqam%x^B%y#aS^X9kAMUg*Upx28)Ylx@x)viY!L z@g0tQ>ej*R`w&gA{*Ve!1FPWdo_WH00cAj!8nE;mH(f9Y9b<}4WmxLkU^^EUK*~_I zYz@aqP0^uhnljTmcp_+W*F)wE51L|Iu!ajSk_sp@TNV_V zW}e-+JuqZ`M%81!3C%XRFv_@ky%$f_$MqkP9a#D_5jeD&EnLINtyg1-h1Oiyl>y6R zH4dGbbaW2y_7u(&`{-%fijzU(GachIfmjAChCS{n5lASdiONl3g0nd|kAD zbF1LOMl;rC#^u`%qMfvrnyA2%7yjN9J-5dhPt%yWJ!{h~KUm@Z1C2JV`sa*uhEn=x zkQTGf%lO^QO*Pe42dBT}P4VKXM|25Fc zBz_6q2_#Gg{jKVC9;U5N!lRh`Ys{nAj_X_tH}#s{mWSwh6>etafd zuu}42<~A=;q=9nglcD$ObiPe-x8-pMa_4qb8fBRu65x49V-I#leZ>>Tp8pXao7q0&B0ALPtfPj zyWz;)s#&!R`|h=g4)$G~(RZP{)y#ILF3QbiGi`WA^}PbFd$>pN5+^;P31})ey2;?+ z0-n^k*F-n9*+-MJzt;A6h~yVPUC&JK)|v9oc^y|QvX}{X42Vxgc_Iu2QcT$AGqVnn zJb*ry-~ROKpzdZfH#XgWSl&NxhW(!*&*4u7o|`$ju<4k`-Sf&an`*>%V`nc#Yn^r- zykgwnk#Xs;{2v()-YC*L5zt%sy*1}E>sfoOJ{=rL(>`8Ue73yXL;fN+u6@=MmqJ~e zl)tp7(XD(L9XQjtfpNL@by0Xe-(n~@u`T4e+fH9`y8o2!(iNMhOLgm%0R_?ud#q`( zY!PRN!K`E}@3*w)n#1nxOt_=o?9;lQ_t;Mqd*BaES(I@7@x2;Mc0hh94Ou&ZxQNUh_ReEzVC0RfGW+aa@UjN70n$``&%>uVx0`oVq@OvS zb&q*NoImQmO<2IAF$x=<%d4aU(Q+s!+MDgWNj`6bq-g!WACewSJ{s3z72}_NL(^W! z_feMwg@nVO9Gnr$@MNG3hsSi^vtXL4EKU1tL-ASdoe$GpN;29H$**hr3VRwg7TfY9 zOgJw$54Qj!ESKy}D_BZv*-f5MkSEsPHNWXNE72n4Q?5kg;w77}y;bPCeIdS}RF|a{ z2joYmgeu?qp*q)OqW*+;@I>+V-5rde%N94iSF2+qwGoPdf+X*vMydKMNyzNd@+4c@ zR+@XNSa2cw?uZ>cGzIFkaMu)zZkma^>&+f8_?ZE@;Fq0@TZ z`^&*aq;JXB*vTImc{s~H3H4AfFKm>GrJC^^SF>y*54NRX8C@M>#`0i!Sf&@hak<9) zy*e>tVss`D$Ktmsygv=&`)EHq>L0Bsvb%`~qkwQXU@L537&b-NkX@KL_zxH9^LEYJ z=8~=>2v~8uYOQ6tTZ7AEIJrq$srbvJ`RbAt9G46p!u~Q2pT^nFbWc8UgcttufoXVP zZ~ZL`?&^hJ?sWAGsX9uVfUSZN1#J-ppw0ys*JXCEtbaA=Sk9%RgBCsYqfDkWVUeu% zGFMq7jldJiu6IIa3)cv$q=?}pn#@jV_|pbfpQA_qi}&WL7Q{%f)Ful2K76OW$4M`9BS2giHr zXBx>LcqU{fZ6G2kC6U5Qm#RoIwq&Q7lMBE}SeAm>brsj&GYI{p@p9c_j^(5{x*p#( z7^zKAoCL?oNb-hUtL&wOZ5Q%qs-mq((g4<22Wu&61TP6@S%T@a10_W!u_Nq_bra1c z9Yi1>dTXkaR0sAKPM1Y`aP>O&E(X{&g1|zttJ&mG`Wqn2Ad3XjX`Thft*;FpYbo4S zDRM#}gPC>3bPcLymUgQadVwQ(7D5)0!dxO$;AJ3NSOE8>7~!Rs*9CzBGdd2$4m|h| z`7e&jhh;sfy+JW+!3J6U1h#g>&CHsJLQowNP67tU-9S+!HFXiXgWb33AEcN$jSO-W zupv5w<-4i7X;$< z{8CmaOaDB*(2jSdPrBHVIvG&4plOrt++FLR1#NU7x~kHs{8KJ4#m2j!=;bosT7ylx ztJcB3+Hi*813e%mRnzizHfS`8j6vv;< zzt6aIH19@)(gZ4>pHM^)PROHx1D?j)g&908gO#@|oxb`#!5mhgWKROGVyl@zJ8}Um z=geC(PDM**Mul&w+g~6ruRap~uCO86px;lh$-n@7v9Kp%7Mv8pHEb1bXBiBd4FfQs zBNGIFw+wD&3m2HtK3L|``t#rg3-98Yy@z8Vbp8*KzMqzRAK#jrys)~HvFhOKqjqi0 zZ;29(y@`v&7d1sF4L#T%y0;WnNLC`~bv9t}(cZ8IZU%*&8C=$iF!>**ypj0R0au9t zW17lsJCfHtncm-?b%nVrqe=YH2cl&2zMMv>4PZ0MQ}&7WX`NS24{#Z#h1|NH9mrH8 zqGeFDGjBxOH9K`3RsO`!kK9a8-sLN{O$<$6YrO5*KxFL^`IG6|Ez`(+p_$@lZh1TRgeI$zp7?Y4aZwGI z!sRLodt{nMk_{sVwgdwwCXzv8cm~^p*DM2+uRFLUZPy#UhlPM~{vRIIz8RNZ)Sb8# z7oNT;eN=YKY@8N#m}J~UqiT*+e25}3HNg{5gd~_|PyS^?M-t2+C~E+tzi2XcItnq! z@U3;NL86}bI+BcESpBIf*kX`q=CK}ej{vj!GHgE7BR(_9J3dp7iR$AFI35laG8;Ec z_p%vUB}0rzDDPe!H;^XU=5+JR@^#v+ztR(*)c&c5YlH_)!x7#TZ{VTAlScO_xWP>W zK{BV;c{wny3zCkWm@>IYL{s_CaV=MQ*H(&}x$kP*KW2#%ouYx*+CF1T7<@4T{c1tg zI!ftD^JGcbl7mUKiEghyH32^JXV~}qS;|_$#&&8OHaiNh9^9$$D>MS0X?G(f0i9ea zFt-8-z!vSzpzw+tA@c*tLb5q47Mu)7dFPn!%(VjswI3%dF02sUW;YR*KW_EnD}YF? zrol=e!4E6ih@q_E_te**eh5=S+dn?-8wHELF63ce_*Y7w04#ME>RyVT9`F@ zcwQ+f)F7mzFoO%TCCKKLK=f>IvixNN?V!U>Z3&^v7jhL2MsYu;HR()N7zK4^#Ve8k zVg)(6KtpZkqcL-zRrzHO+TtB^K=lVQdy`$ z6zV>~W$O~Yf(`J%=wRuPS|F2VKV($-uhD>Lzv|SfLH?$RrQtg?WqT4clig{kE`)?@ zSHQ{CE`fx^qR_M*z>OVC^OgzVdfS_wI*uuICtnY*tX{HK_3PTW){(FQZu*%o?jBti8f+!0QQIUao5Scfw~^O7BTD9eCfM!J*Rk&SBgAvVdFlNAV0x#l(lqz zAZ5n)8_xtVBBaK}ZXWaud9qL*B_q#T?fT$`|( zOC=P+X_TIb*GP>m*${9MaD@Ooo}R$d?%r?u{;Y^ktiCsQ-3{GNqq&?UhL3hvwyPPF zk=+v{fLWOVncyFm>-~68U*U~R;=p0wnY@8AvQO@&LJH1OvWt?J49SvL1 z6TU#GhAaf=7NqL#zNyRo+3MyQUdR5pRC=*4QFs;EDmKxb!WD@Tqe4(jIRKy$!$6xj zTkUbxlU@9M$8__i7zlrH6<)Sj^`)+||EDN$Y;^^naV;Jz8x&d?AVSmB#k)YY79`1#d0j_1Qcbgj|)|y$t6a$$KL&-rw>VVt! zgI}8}c4oyjiyjxRKaE0EDbmqa#ZV^_|c8FlKVL{9@b z@pgA_Y_f>^dT?kl!u=er8kHzStX6eYvE&p>BWNs%CH3-(33~-mGqSf@gnI@r zDOl9~dR95&P*)w87R3ZJC^D-CuGkVE-QGN~bV-xW(mSM_XhFu>S-NlOpMzhojoTck zX#pMusMh^oKSC`CFSF+FF*;r>QT}A7xVxfty;hym7)|ICo?8#orj+O9ZQEg*6qDxJ zn^+=LirYM@V_dXj8$`bGcXH)7XWbO`1e74Y1PTz)uX9{r-LPyB6@ma0{m_@s^!Xy0 zVxT|c%C$}I-zM9}4eUp$4bDISGkRe@3PW4;mLfpSN?LK94Tsj7HQDi~QhbBr>NVkKjI%jswcqsH zc2vp|taP(M^YA-54`NjMUFX6#ilUBGt%Hd$VJvye#Ohz))YZRJy>V}SzD4+w_&eh2 zCF#i)ysOUI-BLADXUOFS$B4K*D#LnyKs;e`PrK=9E@4(deh2p4|A~J(*>JdDeHrmk zRThO3F|zjg8ML|xx(D(})Rwm|G!?FgP+83boCQt%BQ)OcrM%y#{_Ge$;ospZiY>8Q zt!Xbpeboar6eQDHkTlDan}j2QX8h+A0djD6Jssgjy(vq4uD)>XIGr`sR)lCx`PxH5q!kOBCicmlFr6YcAwYDuj99%?Z-QC!<*8ZtlhO~t5}Y$bd*BvM^d z6gAZXnO)U7a#JAGb)59mV>;)kjhcxqz5$=z;g?_fu)H8-b|_n=F9$>r&jo>~K;7v& zH%)IqII`K;(=-1X4T>AE68`Ga?+AVkEd+)6Uu(TEyFEA$=A|z&^UtEHGW{+mpJ%Jm!!OWHyS?>o{ob6xrn@g(};kQF}6DO1+ADxPTQ$0Hy>^2e#_b>r-+}*%j_rz0soX z)*c(JbVZbHI`iGA&!-2^ z4CK1Zto{k^tWF`#0sl8^J6XGcO|#*ye;~zWODKAAPlQKsE62#7&=qHwRyb@IHl6HB zdG{Fq^d*aKRhB}*QXn0+YGuE)k&L$a9s8+U3{pFTouKSN46RW>bF9 z!{`i%&E-|qOs9YG z3V=ji-u8L4gv@G4YCCGfsrO&ssr}~K0hf?zL18P_mV;a|3VV@uO znfJ`9pMyfoOP0y2HUm7Mi5BI>72ZHHt~7Ck)LqS9XD4|>XpW%6)y#=&8~@a}F4-2w zQCR|uNfVEPv9zI;^cAbn)Z3u+-a+dVTc#BnFjL2t{^B#}Mn@AT%G&)c^ehOnV?mOr zql;l~k1W$(f|4ZYP-ayP&_VU}n>rJ_;6c%n_%0S;S}y`*2l02*bE`S>IX72&N;LK~v?Y;Z4s?KJ{dhpTkG~WEQrUVi}1*5t( z=_@6Bw#H3JpM0=6+wR7|tnBuM=z@^cUba?);-`L9VL_4>l;zJnG_r-XiP1wxGiB^Gv(8YL<9!*=7^0Nm5KNv{7^T zOMw4eef6zsxzf-I#$vo6+;e+6&CFZBc3893ti%nRZ3kYbrByA5Es&#F>U0OVmn6>} z<7yAKnY^Y5dpfJusl(+O6YPVcBi`oTgfnxBt7-@hfm^8wQj>5!$NFCfUOzR@zPT*G zuSqOFw1lTP(pn-~u8;I-NbtA#O~_CvvOp=s-J24Up(^auJtEg{G+S&y!*N&yLwC zUbm~uZ$TBdn5QLe@5vrtxOU)?!A+?=ydm6Weej4ye9LrgbkI6;y-}m{n8w?nOfvQ; z*b)+nbeOo1mxCp8R^ay*1_PD+-WK6;rJ?B8baA^)kE6b6ebsC=BqB&c!%sKUYK8@G zaFHlH{NjUv+hI=s+-gBeakB!+0YM$qp0YJS*Idwk^z-5Ms2qZK%s~;7alP4eXjjk% zbxJELrgdKL6rz7Da8cF~ER)>m)Sr4(G+?Ps4V$r!8;@8;N3+_*z^YI;?(7>XKh@Cv zSLcv2px*1~;BeNr^Wfw8%LwA$Ao4@*EX%4|&IL4hBZ)?D5%EU2lB8EOi+dhpzd?}; zcaAiU*L`fwEf+};83cIL=$V%$OLPSLxDvatGV2S8~FGHAQLfV+U(6hbB^kCnJ zp|g-XwJ}5N>w0rT9?Iv&QdsZ2g%#kDGz2}Chwc*V&?esPoxb1v%UyHk-P*4H+)GRn zz0o=)e0(4Zl}FTC6kDQE5M~;nwnktIR*8X)vd_Q#qipHxO$Hsm7#$9<>|;RgQ58H} znyq^t4G~5q-pDxaHu%G>xIf=f1l+4XAnvqTYoNDxr0~;~$R@Zn#5}M+*JxCj33Wry zB1(CrVVQXByEkQT&ePQIS3IeUz$^l~^OypeEkt`qfVP#1fr5Y4D(=?&jyHYIyrG-- z=itcqqHV_B1?c#W$mU8oeAIdt;fACUYDuc$dMf)vx?Q{X8J|G?@}D=%<7rN9UYrMX zPeb?Dk_2ti5eF|N!t&6m_s}fb4jRQ4ue>jorlyJCayfdVBHM0*#$_rO9dKblG3W+* z%SepPP!q@xe_tLERJg@(;&J|;)W({UF4vnuc>;#gBsc08G*{4*LoZq%E=RgfyAC!l z;I&?OG+h_*=ir0ic-N-3vcdG6g6PEr=n+yue3l1JCv}-fIO=v(J0wL@LKWZ9n#(w^ zo5u|=)l3LCyO1t}M6tyU|1F@AW6P>TW@{6BO*o%h&pN+*?1A`prtz#q_R%lxR_p1Y zQ!O*6hf4}|ybazkTeAbzs4P&-x(GvFT}?+hg3vcE-`^T67|uyH@*w3n>ecxX!zPbk zQsz3C=f}|${-_k+E*$*&H&KGIx2yo&prR2Osd&y0MHdSUmvcB%UY{s^VMYzP2EAJn zkS_kq=Ik|S6MPNq%akxY)KoB=1A%IL9_vqiA>yY|_r#_30Z*zgs#0={fREaQA|nq} zhL;IIgSU{o+VzoQV>*E55bT4tH!e`LSPE%3<~QibYz4R-(o3`7B(-lPOdL&G*gKbhk68}V0PBU%Ay$Ge>JMxN z8SurH9p}vtcW$_tqqE&u^K?br_;k;t>N*_@7>&5_x8qjZFg>`fKMwxTwEgt`s#AwG zd$tPdZRpsY4XglZETGk?(dyBqHn?(L6#8vx;u|?G{6JNg`^gSXuA6YBW|(w1&Cu>g zosY&O2F!*W9GykKNNNwwlNpU&534jph#{ox<&yR42oR_%V*)#WW1qL@sJcE8XS?+O zvo^=BsJ9hQNTx^x2fli}+7YHN}!cMw|`Wcxf#i%Dd zIC=G8-fmu9!%L4&Q)y04 z-n{|?NqP&QF->s-G;tSJ5WONt(XO4@C!cqy=zhH>(f$q2a}QCh`3874iVrB`BlKbD z(1G}`<$Ls59Yv1bO{RjS&suh4K=3=1>QR0ThiM@}E+Tn`FgH*sUKcE1)FA)YvDKP_ zT{Anbn#2SgShvtxhdgc$`b9Q)g|I2Pqq@1MSCrB&jau&!)$` zpW zUHYo%>RM$^9Y5#F{Qg<>ZHtkfPt{ETP}|VuMMTp>J*)aRFozBQT zp(ElL)75DV)EIIa-M~2zj0m4V@Z@0gfD$t7HP?1cw(f(b@|N5)VOB=gDBlFAx*7yv z|EQBFl8|4pM5Q$=yuY$^e}gQzmMeiW>29b=L|L)^1C4;26i35Q8x0lYO_jKyLcL+_;N1DkoN8LyqDa;xXuO%@?n2|HO$YwH4|%1W}OV)yP-mmOI8xMg>4 zly#viF2@a=oQX8_gu9?c;54v3v%v3&bmVY^{AA9!bN`a`A2E)os|0tNlR-0%wv_$I z?D$9b*vdKDJI$-K7r#X40Ld0fG2d!WJkFTp4vmQX>eYj-_v6;C*UpW|OumcmkMEnU zq&*p^ahfmTqUnPECwef757M$?=6k7P(+D1cM8MYSD>ieAB}`+mUpHYs(2<3K#_&K* z)|P3!(T$Of%7B{fzGm^`X`-*2tpISO0+Kb-6@=STC{n=b4fdyH4J)_DYQOy3Au%@1 zLO9B(ODM;3>KSf%z<^PUJrhjM-XlYqd^^V%3;K5{o=m@X4I>a{ilm&L6`Fy+9hT8D z9JjIH^Gv=gOeB0RY9KUXab5yaEVGiWMW&=_$rd!k)HJCdbiHBTx1r;b)$lp)9Wp~k zre)k!^laVY=scb`1{~30#iZuK_MZmjoiqBRVuP_Cy2@#mNLt`lnBz3`#5@Bs9!&9i zs*jU?QMS$=w)beC*VZEb0>>%N{SNh+i+?uTfKoVzWG32yuRu%UixB_00Q#7PakZQy zBg27Cs=f;hEB(Ut;17m{QAfbkB!pQK$|lh?_-*R`{=!#RpUo+@!m=Od=31K>gT#@F zfx;wXgb%*Gj>C5`UM*c zK>hSN@_(7M$iAARoqMu!d}TXbz0T&{j4f%Ir^+nBBLS#1TDHDKu+U34y7!IC9mZZ2 zNhddDoZiq;IV~#KgSq{)Epf

CS;Q2S93hct1nNyti2FIQHr@Daczr&bLw`4D)=pZF& zR9ysG%PKdz8>YI%%k=3=1iHDdX3-z;9$aWUF6Fa__KJ6G1uC6qAiPEf8onFY!u8G= zoqln_Ahi+&Q&VI&bO*PE`PFo(xX65Kd-(|u!zZQT>vqts7zadd3>rhgHnS=)hPCKh zCOlhUuI^*Nsb_YSE6vA76a%v1WDG_8Wout9a-+drkU~WYUuT>iZOsAIX|FEReDRdg0QCDA>pJaV5~0pf*})5n+NiXT+4w>j9^lo8+ds&x<{CTVxaGp7bu{N8Pn1P zl=Ex&#&R~BVa3X0AOW{9wh!b= z2qmz015e9fc(XxIOlU09NWln>70c3+jzJtt0mCp47jV}9lNA_LYA14$CC3eUMR$K#GpbU^FX@OhrK>wAGfdp;tBxZLyH$6Pv(G zCukh9Rk}l>Z*4_CN)~_ok2=2L(3Jm;0fX3^&MUPP1O`%ABoBXCk$X_iczD}c?TlyN!C${?e;&8dxWM2dHuo^D^z{FJVi8#Ft@|SYJ2Kr^7{=hI+ zAe#;Rpf3-xh3P>2l<_~Ynf@-5JCb=k402eZ#%Cp4rV02YTd=Dq?mp+oxw>zf{Wah2 zPNSJ3H*?b$G?09lu`zt@h=hOstv1p*_mEHJcGoVR4);Ix;*(O-eBmrWoWxeHQR zb>1j{S^9m$m@S2dO0oUEtg!RMkZABUd!*P>WJ^pGlhVxTmU!Rj|FAZcLaxH_gm?g@ zd5}+-r>+NnJaVDPTq&mgpO$t%4tJw77?3DLLVszX1S8l)>XVu#Z!nO6#9))qQ7{^? zMiOasn6qQ1?P0BPIyuPs&$7 zF=n&a#BYi7!u}B$PFHmcIWifcF1G(-Y@{3LFKEm)JmOzkNc2|MQ+w=J`8?7%ghbMH+4jaRrIa#WA|mH;>4 zJ}dAt7x@iRM7(K7mVz_kMG?xG;PXl{o(c;@&yP+MJ})HK8{ zuF38F`}{dWhs4`KFi5%`L89^h{SJRaWTIZ800x=1EW+3y!J0YR#NuGypm%gBh}fCs z10!oELocMRcdI;o>;l?Q3PWoGWeD4_{JIDlkvARjgh7M(K>oz~tr##L%qL)ZT5-^(;$X27Js&idaZatVc^VYd9>U7NB6RE^>44H)d zBSWvMA;t^4;kc7wJPh3Hp%T%%&IVRbmLrY@^FeGRMx?(Nd8LaYjG^!CG_NHg){!x4 zF>BwMunPo%(d_#V-GO)jz<8MF#9+{=2+RYIK=jDPXvDu^(uS$0 zI+M#2G0;{MLj4lu^$g?y5j#O)Sg$| z#W02*5^9VwOHQ80B>?xZ4+3J~PkPpd05u8p3^pdi0maBw7v_&Tot=hPT@Q9jkBx>+ zxuSM&f87K8^4~!O>;ok>XjYR;ydCBSsSwN|qJX8-42iirW72!_`$RZqR_Lt~tW*bV z2<<)#@G}x#2@i(~UJBoW9r{|4&QopDGEEdI^cdt%-PfbH`@e}E@q1GAVEWuinhbxR zG))3A(;)Fl+aWFs41$y(Ka4Ju*L{)w@qc=)+??~^yFacPtN1qm+2C(@{J+~^GWrq9 zp!X}X^rZdh5gT?7H1^KL1?Vk%Zxlum8gx-M=J-t3MRui%0FBXEoPnRe4O0$ zWxT>+^=xnQ^q{VjFf?gFGW0n9y}<{IqsOQEhqGN5+n&HCTvO{-t{DEp()H0c#K@DYB^hLYV2(q;*((w?y9pUC4w_3 z3mK4HiPnX+AN>E_zn$w)TFUT7Thxb96G_4#5-Hdg-Cd>@8mkh6Hkw zM<84g#!3W82K~`9=~_iAPTv(3=VtYeR=i^1nd=2)A(%TM=1JsUj@Ni!8h(x|YP0`l zQTU470c1oEpd`kIOih=!5%VV3j-=BJ^caQj|I+NFcnFmu=!UbC>|q&;!653K6i&M9a>7;gppRFbG4YAKA@}X{ zEs~;H8>Ve!S}T6)$?SX>9G%;Uj090p-{SVz%rP>Zm<6FC$gINVOZ0s0 zRbEK!5F3Ef>%*@8Uiu9J@na-%VPHPWT6St$D^=PiIPs~kr{X6&+P=!LUl4lrTv?y| zkhg=-ODJILH9|I+v>`+rLZ=bEP=nioS(^7u-K*}k+beOvv5Q^r`|;??>^FH=w14bnqVwxQ_(u*D0B7?yZx zM0y?)n2unKjV7U!xGs@Bg-!+n%-8gU+8<}O&S-L5?;JeQux#Lk>r0|Zy3vMs(G4|K zbZ#UVI2g6Y8|0Y&Clk)_)Rz?>I*UqUAgR;GFTK!dvxO_+1n;I*bwBWq{<-Nxi=V#~ zJw}f5QmZ&PfgX>S2lv!ZFqGg!0L)hFTSOF*N;YgAoB_oaEJfEZ&sZI&?Hu1+*23~l zc)D2H<8_hlRr}*MkFaAW9Cqx+7~YNMOS*raAbE-d@h=G-!vf$}WSAn5ZcuQuv!QF^ zW^BATm_MGloO32XTU8P{<8H^m_o01zd{dRv^}rT<;AQGd!ETYXV|&=JJo)PA);964 zMv_%2BSwSSj)#BwW1YqANbH)~oUA=~&&-=-EQYpRFh*2~7m-(turG*QPrv9y*LaRo zcWSKqeJ2ZnNTK3uvN+lxZQl5j7vF?^({TCO**PS85fHQ`COqlyG^+!%8LT-cEqdpV z+>IUmaBt!;HS+u6RDNl|FSVO=yPLCg2bUOMqi@WxZMHJ|4zosjeYur-VEB}smX?s% zadTi|<(Y0oyEVVz zJKNluT}}pw(2h5-viD7Fc&h0$3)U z+$1BU&pF9@%&0Ia-r77+?06{Soz36lg^_Z3B zy4}#ox0>l-l~*mP@{j2mPSvaub+xAqH6;9+nA0_`a_Zg@Ya#gZ#LQ6j)2^H^w>3~m z6tf9WOtboSSrPaCn>*4k7;W_r%>NFkgOjz39ky*=MyqE1aG?dZXa4FTWsVgnFc? z(|w-6Vz-oY=(042A8M{t-n;3ZA|vwMuRAO+;14DH{?%@9avsgDlpzKZHduoVRJhH8`ATT*Rq&gkkkOzJpSscz(Jf;snv<7CNd>f`dY7FoyO}i^pfJ)SiG%$+XvT=T%o)b5(Wx5u^U$ zYq1k|IS;xmdJL1sHVSKEwST%V`niP@(C+Vl`Hue;>-L62s=%(HhhaA-HaK;4cl6I1 z9

ZH&aU=pB0~|xRgw#G_4W`U~g*h{jiCr$M4-UkS}XF(d{sN{9)AzzpJ%p#KZod z;nuRJWfvW2YPxA7RM^jbv`=5Th@PZVsEMv-!%HGvn_mc*_N`Fj@aa=ti+VE;crWt zL|s1nM+?;H8c7u2jdGf}({;1$-V*An@^n|pt%gKuq_<4F_hx^;$~{S%5v#b>H9j-x zWQpc!fWjqp{IIC&TFSMe`~DQ8|@wL8B)zGrSBh#ZN*3mf+zSr0#5;E4P>>{ z!An4jlF=hhP+4E%K9?BLXr!=YVEk@u1RqB17<0AAC#kP8(z}=z*YdPd?I<2v`()rL zZ7?q@;5hjoWop+5glur7ZP!(k*unGeWcQAN(^tpZ{VFdRNrL%9<9ge1dXAe^-CNQ> ze4%XOjmyflVtYzF+@g~NXd3SnV@+gYqjCwaF=Rqh}p@g^WQV<5#*?r7$Ym%mlHTXYM_z?$1AS zZ}7kJurF@ynm1K$uWmqH9fK}l3Dn3BL>Eu{X@keyCq7AO-YTAIF*MpWahjvO>re3x z`=svf9Qh{9RuDV-D6E@!7v-Cfl2xXKR;rf3Ge&O5YE%1%?>J3pkHh3Pojj~HZ^xv@ zW-gf=@dVqHl2lUI^fyJN5)=Qc@9ILa=2k${MCh}Jp@)L`CtAAGQXie+A3GE9@OA2P z&DpDOlnwq=_Pbhrx@-K(@k;|ssm=%Ix{hUWG~HCn{Q|zJ$~?etk!&0WsC z9ir0vK|B25Y{1xrUqXtJ;PBYv*dF)}<)pqDdxQ8drs{{+AtE?Muq@p zN#c5DR;nukp3UtTxECNdXkEJZi-7UkfCn<&phZ>J)+@LC<7=CI62y9AEp3lAlJDsp z`7&uJSL`d3$uxim*r&nZr)BE5E%F|O1=8<3_r7L zqK{Ag{f^IzFQ9@&Y?4d>%tnV@kz}9OeX~8bCyM_7(IP9QrXfM>G%g%?*s*_@Mr_gl z@^JE^;wEaODQs+>aAa6VN!DKNXjFY#HvSeTAmGK4uEwhIegpZJU1R5xuFst{MdGu+ ztsp^cF?!&zQDAzX52j4s-ZhC1#3J=Jwe+fiWEKDKHwGBY>UH6dA0D~UrJYNKs=p8W z!Xl;5hc3T$b#$C}ZAlc3cRx6FOXtLwVZ}W?rdP*TC27tjQm@t{HmX_QPN%Uvz7t7_ z0x=|OXpi}gYRSiyT`z%}9I7X^r~lGn{T)-?N8w3luk`m(gHim!EX}Bsb~feHk>VBm z{N_MiLc93$oB`e4*!!si+B7H0i(j@;DYpvt`wfrh1fgH1YaS3f_J($SLQ-?JBl z%2cOk z#!fPWi8d$AY`!*&e|@6!@rkY+0+rAAH6^A#Z2{Zk-3fm9Fzo53s;+%klm-09XR7*M zw9`c0%HpK6iK#CA!|TIdJnR@)v9$Ys(#flmxrd8nV_(JIC$1Lu{Eq<{fp9$v{{6o2 z@Mo2}Yy$Rl`Qp%*en!pW_dQLiPSn{CCzJTw`Qo>*%<-A$?xr*b={)(W17|+_`wgGO zw&T_0sv7i1&_xZwCtQ#>nUX>iJ5&7w)N=!BCYBD}513V#F!IO1Xz0y*iB1#&ocqaQ zyNb3i#G|=lxkaiPd=9Yc)A5-XRk?KZ^`BdIQIQo61ZHnQdmRIg;wOz&U0JC$4eAbW z*OVR~zH5&=ta6jnbAoilDw|&V;n)v);s4tzM`4xszYqPwVqc$+A~kGqNx<*-W3Ob4 zFHtV@Q570>Nf8dUikzVC9lt5lu0B59NuXF{GW5E5m(yYp2R(M;qhrYD_klyCU5bI? zNQ6su8dEtPu21mgckS2o4yU*eC*LSY_6vQwC(K9?pvwT(Xa_BlmW{Q25zN2W(*3@> zR>5ihkC*jS8INdVCq8_!5b^K-^+i^lU0gD@psN8PP+0QisF~pOo#}$ImzRg-J#zYP zV0tEA144m{y|98eg-j!&5s3F3F8s~*^jA=iR0NEy&}uAndE-~^_=~NHD^Wq3adD3^ zUuV&=XZ$IrdBrSk9JyQ8(`^mBn69A+P@$y{td|OVdd9^Mx3+b)1L&7Np77gGfPVDY zB+!RFe}6#s=)ONNa6*ssYT*2}wO8z&kPp-uz^wGn#aGpxiHkubUZSk_QT4|T{gv`p zWlF&C_|c#-4?@@J`}<0~10rmGo-X@ja&k99Nz=b!gqH~L@q}HmS3(nzTD2~{up*$u ztGH=o_@9DepYDTS?T~jpg=$rmoi`hojCJdo3>7VnA^~z>;(5T}xd62*b$sd7+Ti`S z#}n>E^p0!Kk2+*2qOH9Pz9*w72r7TyieLJX{9g_sT`to{e3CWbrDTEQ?f35A>+1b2 z=`_G@Rxcb+Z)wmD)v5d~G_!L5)8Ou(8k>hFBGlEappT7K+&@39z#)%R+&F?jR9Wq% z>htIU%RV&icr&B#U8^tozn#8w%zZ@Y^zcIR&CkRjYrLveOO7{CgVz`Lr`mOuJ*NGf3?O(|#C?!|)K^Q#y|!AyvRf=AvIR`o^7) zjkdh?(Yev|fzd@VmwMdXrC@q?Db1G>#zv+DFEJhK6dyN>+i5AYPd}Arh@nQb{^z5T z4g|B+evYs$*5Id`*Go_Y#c_K!z7{ICXG+ihS#psrr*81a^%V z2JWbKa3Ai?s#sKlSI9E2 z4!h4+ud{1WI4rPtDJOGcq~KAG&oShnm!~QFz_deT+;a50pYf3%kD7pWz0&b`UnPdqL14sLD9G&PSd$IsJD890hYP8@l1&v((~ZMzLYcUmgv&ekkIf1GPcqOf{m;9DX`zd~w|88j$x#%IF$F4P96 z+#XlyW{mYi6Q=WIev#7Ec7t3mqKGWnr z{6__?q$WeyOx)P9UA&VdzyyqE1Tf@7i(5cI$KRp|0&56CpnN)0yxdjK+#5=C)lScb zTmp(Ft}NLQmS9dOQRC@QO?^`M&3k+N`J?0f>;3)nj`l5vp5hw!MDhAsQfx@>nhN(E z87Ui|LTzwr?@kC9yf=C$V05kaSx(Tyj_;R{Z$>yh_vjo0Y}qM|WulC*iG1+`0z&fiEvXhn_9HJvS$aPI;u^d-7Opa{qpl&S4=F1o(7D&YP4OP zDT1w~OKNuy3m>Kq=#Iu-KP$eSs|u?>xXC(seMt06DSX#`Xiv9W^H)*Utt7#$v(Xc# zaXn$udtJ@M=)V~uCdUyhpzF~IQTNf}q?0b%SJ&={ zQ#W0Hh+sY$$SYGH6(qf{{|1Tf!&}Q*G$V<8i}B=S&DzfA=3%)$Dc=`7MBsAL?dht{ zRh$b`f0NYLvd?B#ub24wc-7O+ijIMyUuGg2-~9Nm`v|G217iov>K(8C+4z|_R@WBR zJ*-p4a{~Tz=aQy_51kio3HkJs#Wd#rmXaXlN_h5**IRdCynu$$Ll4=b|9B8dV$?NH z!Y2ojQXopHBY+^1AyHN;cU4zC>zfWl8vD!2uFGD%R**S2oGkaA)@BIGZ zv;1cTn&vX?6!NoSFQyJOkU~YG=1JM$IA87CKiq3{uWsv=yJzDXe~W4@l#R%4oAuc~ z`IqSHa-o0`?=V&hjLPlpu=@xYT zULI;Ncro_bHGR#{=pz^*vGOv&-0v@xU*vo3uv@6|3mf0vF)-2j%v_Hh{fWPRq&5y4 zo;l;(T`J|K(PjV2kVK{+XymT}Iq-^Fs+if8zP0qq!JZdh;_+3J9+;T)%ArSIKm-4) zsAck_U1Ac)!RW!)q*Y%gg>C>csl3_FG^QwUm3$M1wqP_koY}vd1agcqZaIn14NV z4NBAix-eE8#!wobAu)q;dNh@zY~m>IJXuW>NscT;1~Pli$8)MVD*Y!~2D`V%={|C4 zmF|wG-g~iBT_#xyT_lK4PNCpQ(f;76{bE8T!jFcj|9A^sCO|YC)Mp12*xk6*;3`s- zijHYBJDT|~`{y}W&+L@V>!qoaJ59q;l-b<~RZ&yvsoi2&w`=@+r#2+%s9fn7)j(pD zU;6h&!_l&-pEaFcu19T(yn9QBS_*7C_YfkHCB0f>hAVUXBP(TOtpYr@k0^FNY#k(( z%>mOR+)qWuQt`jB|4?*YP;@|3G@{XT#ON0EhRAv)L>ALT(mjsZy2Y41@t2q#JxF5q zz(UM6Eymh9+s^S)(F{tzT7H*BW#*suVd~n2)twH};-;gxBT{*`m#+6ua+qhzJJdD# z`sJaWLlNcbu5N*+X3%*ATNqQI%4qYudpqXg4HDqp4-BMDU79BA$-Q3TPdza#LS9Gs z=!IQlJJk8!u2)~3i%d#BvF85T z$Q=>#_AW&+=G1vR{u{egA%lk$bEe9FfPJ6YJGi0g_j28{JWU0EwSWXV|GJ)xM`wFw zwFs$gLRuFh1NlNwOzfSZZr=lKLjCfv`tek1aPoCoK{)@vb|kJhivDzDAiks*`5k!O z;7YP^?fD$$6N9ukm}=p>EVQ%NMC4577-s?J{%SHDXc z9i!ccm=$1(S&CGog(;Y?DPocw9OUSIsp_=UbfNWpU|BzE`2|TZl%r{?n}>`S={UNX zJ?0p3v4=X)7hLCDaeRDAdNbw^=1})#sAB%SiEwqcnEO06sUlfG{oTCjv#YHJepny% z%a5##7oQKQ^o<|kI?$9WVu~-(emAMBYSlSf+6XUZZ_4hd0&=isDSrRUS} zL99;Tt0DZgi5C(YDe7OOJL+xH`EM$4nzNWAaEx+Kbt5)3Dl|I9ile*3xk20DG@=*dUzIp5w}P#C#d2(&^~}kI zB$UEm?70D1qhZjA-0Z^5BT-Ej6OFv?R)5_(Rfb$<+(f3krG%vF7y+(rS-&5j2(M{c ziFK^D_YFGLAv-ZLR{A@uc3s*)62C5b!vBbRfaFLJSr-;`W)}_}cyl@YLx@3Z1Z1+0 z60PYkm*fvkYwnM|HHL5Z){NijzormrBQEq%IDrnh7Z)JlvYA7{{Lv!FaRv)&@WJP@ z;_&nvb`>k;JR077i}jx~=IZ>Jdi9an zZxsnR!xHXSr0vF-OsEctjTyYLBA$bQP79GjxNQ`2=C9yb<*~*qog}%WAAh=te7rfg zrO}z*ADET=2R1U}1sO|1j&2M=&Mhh0#ch0-B4n^QSgg)V5vQmInmvu(VO3&b^V7d| z?x~waHdt}#8jE5sB~9rAe%v^xS-Iy;CaBFIBG~kzSB=1tEL{q?E4>Mu5AG(4m8mEX z0pI9qO>jdWTU)E0S6V+rPLoSrluw75}@~Qvgnp$OjWKl?|TLadv6D`1eob)oXiwVkI~!FIBu`2%+_2=)|ag)IDgMI3-pE zROgR(bt;e<8LBSy>_uT<7VBF|qL8rY^Ih8FM>2y;%$I2SUUpZ+DH#61tchDSmaBl%G5NOfqtOj`(g6!2rayv9Hk2=RO{@$vyh}chu*~!2p`#>Bhl z4n`i#munFSmz!Sh-dmnk`_f8B;5u?@9`bFn(?cPSl(DnnKnJ7-1YHcm>?pIaltPDoCG&++V*3Y!y$82qz`jfmj5c@*+~PwyxmfT zF~>b04`eCp;wzyaqIHdMR=^7-Ux2PX&|eCUncnsCLFKa(ArWDC8pYQXa}>SaggTP~ z&L#;qV4}#Ma#$v~CDcENHx#ZL5`C8>l60XF?zV9&u|C4hYXc?~{g4PK-SE@ z9_V65uTp$jYs(pqwh*6Mx=K|&WY)DhT<^IXed?eo!_I@tM+ZAzM=+|aK-s)|p@!qN zQ(D^K;T3|9JWR}>XKk@r;YR}WJrAq^$(E=iqS-Jvr7ZqR4duBa zIqph%A1n(%gS3p6bO{Zu@dn3rjhS(u`o$)aiv&8jIH3@~#^?q#YAK(`R|^hc7!N<;`_Z;}Mv2{4im=8Az8F*;d!|{u>2oc%Hfmm}vERMvi`z!( zxz1J>Vtj>r5^EwI{WLXi`Kk&TfwD)qDd+e!OOHblp&;0azh*F=1BwPxEAVsj4Xp$~ z=|y2ubP091+MGU)d}G6{WrR=-j28{ip=fd>vsmHcNE*2;=TEXr+Kqw1A*c2weN)i7 z!*~PJ7%&67t@xElC6^OatOYp{EB;=-J6NCvNrMe#H@a&cinG=NL!*p>X|~11V9xRa zba(}lF;qZp&SAifvZ799Mdv1);b{pzEAVIpxQoCdfad}OHOlh_74_GuGaSQ6t1c{| zcUy8p!}WvEpLarV2ZSEBT!6{#CBO<|aAA(MoF8SEwHs~P+-~}>|0lUpEVj{@cZ#(R zh)4j6xxwp8nO^k5x#8VUue51u+F^uorvzQ#ASMM0tmFU^;K@3>J#4L!%uMC8#Xl$3 VyM6Q!kHez={!ZxI7vGA_`aj&st>yp# literal 0 HcmV?d00001 diff --git a/scripts/switch-icon.js b/scripts/switch-icon.js index 2b239f6..8525e82 100644 --- a/scripts/switch-icon.js +++ b/scripts/switch-icon.js @@ -1,4 +1,26 @@ -const { cpSync, existsSync, mkdirSync } = require('fs'); +// __ __ __ __ ______ __ __ ______ __ __ ______ ______ +// /\ \ /\ \ /\ "-.\ \ /\ ___\ /\ \/ / /\ ___\ /\ "-.\ \ /\ ___\ /\__ _\ +// \ \ \____ \ \ \ \ \ \-. \ \ \ \__ \ \ \ _"-. \ \ __\ \ \ \-. \ \ \ __\ \/_/\ \/ +// \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ \ \_\ \_\ \ \_____\ \ \_\\"\_\ \ \_____\ \ \_\ +// \/_____/ \/_/ \/_/ \/_/ \/_____/ \/_/\/_/ \/_____/ \/_/ \/_/ \/_____/ \/_/ +// +// 所有权利归Lingke Network (china)所有 | dream_pep拥有其艺术改变权力 +// 未经允许的情况下删除此版权头可能会受到民事指控 +// 请勿在未经Lingke Network (china)允许的范围内修改代码并分发 + +/** + * switch-icon.js - 构建前资源切换脚本 + * + * 作用: + * 1. 将 public/icons// 下的 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 mode = process.argv[2]; @@ -12,22 +34,97 @@ if (!mode || !validModes.includes(mode)) { const root = join(__dirname, '..'); const srcDir = join(root, 'public', 'icons', mode); const buildDir = join(root, 'build'); +const publicDir = join(root, 'public'); const png = join(srcDir, 'icon.png'); const ico = join(srcDir, 'icon.ico'); +const installerHeader = join(publicDir, 'installer-header.png'); +const nsisCustom = join(publicDir, 'installer-custom.nsh'); +const licenseSrc = join( + publicDir, + mode === 'beta' ? 'protocol-beta.txt' : 'protocol-user.txt' +); -if (!existsSync(png)) { - console.error(`Icon not found: ${png}`); - process.exit(1); +// 必选文件存在性检查 +const requiredFiles = [ + { path: png, label: 'icon.png' }, + { path: ico, label: 'icon.ico' }, + { path: installerHeader, label: 'installer-header.png' }, + { path: nsisCustom, label: 'installer-custom.nsh' }, + { path: licenseSrc, label: 'license 协议文件' }, +]; +for (const file of requiredFiles) { + if (!existsSync(file.path)) { + console.error(`${file.label} not found: ${file.path}`); + process.exit(1); + } } -if (!existsSync(ico)) { - console.error(`Icon not found: ${ico}`); - process.exit(1); + +/** + * 将横向横幅 PNG 适配生成 NSIS 欢迎页左侧大图 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 }); +// 1. 图标 cpSync(png, join(buildDir, 'icon.png'), { overwrite: true }); cpSync(ico, join(buildDir, 'icon.ico'), { overwrite: true }); -console.log(`[switch-icon] Mode: ${mode} → build/icon.png + build/icon.ico updated`); +// 2. 安装程序欢迎页左侧大图(installerSidebar,由横幅 PNG 适配生成) +createSidebarBmp(installerHeader, join(buildDir, 'installer-header.bmp')); + +// 3. NSIS 自定义脚本 +cpSync(nsisCustom, join(buildDir, 'installer-custom.nsh'), { overwrite: true }); + +// 4. 协议文件(带 UTF-8 BOM,防止中文乱码) +writeLicenseWithBom(licenseSrc, join(buildDir, 'license.txt')); + +console.log( + `[switch-icon] Mode: ${mode} → build/icon.png + build/icon.ico + build/installer-header.bmp (sidebar 164x314) + build/installer-custom.nsh + build/license.txt updated` +); diff --git a/src/App.tsx b/src/App.tsx index 0640ebc..9223f66 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -31,6 +31,7 @@ import { OobeWelcome } from "./pages/oobe/step-welcome"; import { OobeVersion } from "./pages/oobe/step-version"; import { OobeBetaTest } from "./pages/oobe/step-beta-test"; import { OobeFinish } from "./pages/oobe/step-finish"; +import { OobeLegal } from "./pages/oobe/step-legal"; import { OobeAboutInfo } from "./pages/oobe/about-info"; const pageMap = { @@ -51,6 +52,7 @@ const pageMap = { "oobe/beta-test": OobeBetaTest, "oobe/finish": OobeFinish, "oobe/about-info": OobeAboutInfo, + "oobe/legal": OobeLegal, debug: Debug, "debug-splash": SplashDebug, "debug-display": DisplayDebug, diff --git a/src/api/config.ts b/src/api/config.ts index fe517a0..b744b89 100644 --- a/src/api/config.ts +++ b/src/api/config.ts @@ -24,6 +24,8 @@ export interface GameConfig { resourceDir: string; savesDir: string; instancesDir: string; + /** 已添加的游戏目录列表 */ + gameDirs: string[]; } export interface JavaConfig { diff --git a/src/api/instance.ts b/src/api/instance.ts index 2f709e1..b2849e3 100644 --- a/src/api/instance.ts +++ b/src/api/instance.ts @@ -118,8 +118,39 @@ export async function diagnoseInstance( return ipcInvoke<{ healthy: boolean; issues: string[] }>('instance:diagnose', { name, gamePath }); } -export async function getMinecraftVersionList(type?: string) { - return ipcInvoke<{ versions: { id: string; type: string; url: string }[] }>('instance:version-list', { type }); +// 版本信息(含发布时间和类型) +export interface VersionEntry { + id: string; + type: string; + url: string; + releaseTime: string; +} + +export interface VersionListResult { + versions: VersionEntry[]; + latest: { release: string; snapshot: string }; +} + +export async function getMinecraftVersionList(type?: string): Promise { + return ipcInvoke('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) { diff --git a/src/api/mods.ts b/src/api/mods.ts index 1eee649..66bd1f0 100644 --- a/src/api/mods.ts +++ b/src/api/mods.ts @@ -13,6 +13,19 @@ export interface ModSearchResult { source: 'modrinth' | 'curseforge'; } +export interface ModSearchResponse { + hits: ModSearchResult[]; + total: number; + limit: number; + offset: number; +} + +export interface ModCategory { + name: string; + label: string; + projectType: string; +} + export interface ModVersionResult { id: string; name: string; @@ -31,20 +44,32 @@ export async function searchMods( query?: string, gameVersion?: string, loader?: string, + category?: string, + projectType: string = "mod", limit?: number, offset?: number, source: 'modrinth' | 'curseforge' = 'modrinth' -): Promise { - return ipcInvoke('mods:search', { +): Promise { + return ipcInvoke('mods:search', { query, gameVersion, loader, + category, + projectType, limit, offset, source, }); } +export async function getCategories(projectType: string = "mod"): Promise { + return ipcInvoke('mods:categories', { projectType }); +} + +export async function getGameVersions(): Promise { + return ipcInvoke('mods:game-versions'); +} + export async function getModDetail( projectId: string, source: 'modrinth' | 'curseforge' diff --git a/src/api/system.ts b/src/api/system.ts index 676b3a5..ea9a2b2 100644 --- a/src/api/system.ts +++ b/src/api/system.ts @@ -11,6 +11,11 @@ export async function getSystemInfo(): Promise { return ipcInvoke('system:info'); } +// 在系统文件管理器中打开指定路径(用于"打开游戏目录"等操作) +export async function openPath(targetPath: string): Promise<{ success: boolean; error?: string }> { + return ipcInvoke<{ success: boolean; error?: string }>('system:open-path', { path: targetPath }); +} + export interface LocaleInfo { language: string; region: string; diff --git a/src/components/VersionCard.tsx b/src/components/VersionCard.tsx index d530803..a3ec1ee 100644 --- a/src/components/VersionCard.tsx +++ b/src/components/VersionCard.tsx @@ -5,7 +5,7 @@ import { relaunchApp } from "@/api/update"; import Silk from "@/components/silk/Silk"; import { Button } from "@heroui/react"; import clsx from "clsx"; -import { Suspense } from "react"; +import { type ReactNode, Suspense } from "react"; const modeColors: Record = { dev: "#F59E0B", @@ -32,6 +32,8 @@ interface VersionCardProps { overrideMode?: string | null; overrideState?: UpdateState | null; simple?: boolean; + /** OOBE 模式:不显示 Silk 动画,按钮仅展示样式不做实际操作 */ + oobe?: boolean; } export function VersionCard({ @@ -39,6 +41,7 @@ export function VersionCard({ overrideMode, overrideState, simple = false, + oobe = false, }: VersionCardProps) { const color = modeColors[overrideMode ?? BUILD_MODE] ?? modeColors.run; const gradient = modeGradients[overrideMode ?? BUILD_MODE] ?? modeGradients.run; @@ -71,13 +74,7 @@ export function VersionCard({ )} - {/* 磨砂遮罩 */} -

- - {/* 内容 */} + {/* 内容(移除颜色遮罩层,保留底部渐变与 Silk 动画渲染) */}
{label}

v{VERSION}

-
+ {/* OOBE 模式:仅显示查看亮点按钮 */} + {oobe ? ( +
+ 查看亮点 +
+ ) : ( +
{effectiveState === "latest" && ( <> @@ -113,7 +116,8 @@ export function VersionCard({ 立即更新 )} -
+
+ )}
); diff --git a/src/pages/gallery/CreateDialog.tsx b/src/pages/gallery/CreateDialog.tsx index f81d158..3249a1f 100644 --- a/src/pages/gallery/CreateDialog.tsx +++ b/src/pages/gallery/CreateDialog.tsx @@ -7,6 +7,8 @@ interface CreateDialogProps { isOpen: boolean; onClose: () => void; onCreate: (name: string, runtime: InstanceRuntime, displayName?: string) => Promise; + /** 预选的 Minecraft 版本(从资源中心"原版游戏"进入时传入) */ + initialVersion?: string; } const loaderOptions = [ @@ -17,12 +19,12 @@ const loaderOptions = [ { value: "neoforged", label: "NeoForge", desc: "Forge 的现代化分支,1.20+ 推荐" }, ]; -export function CreateDialog({ isOpen, onClose, onCreate }: CreateDialogProps) { +export function CreateDialog({ isOpen, onClose, onCreate, initialVersion }: CreateDialogProps) { const [step, setStep] = useState(0); const [name, setName] = useState(""); const [displayName, setDisplayName] = useState(""); const [loader, setLoader] = useState("vanilla"); - const [mcVersion, setMcVersion] = useState(""); + const [mcVersion, setMcVersion] = useState(initialVersion || ""); const [memory, setMemory] = useState(4); const [creating, setCreating] = useState(false); const [mcVersions, setMcVersions] = useState([]); @@ -30,20 +32,23 @@ export function CreateDialog({ isOpen, onClose, onCreate }: CreateDialogProps) { const [error, setError] = useState(""); useEffect(() => { - if (isOpen) { loadMcVersions(); } else { resetForm(); } + if (isOpen) { setMcVersion(initialVersion || ""); loadMcVersions(); } else { resetForm(); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen]); const resetForm = () => { setStep(0); setName(""); setDisplayName(""); - setLoader("vanilla"); setMcVersion(""); setMemory(4); + setLoader("vanilla"); setMcVersion(initialVersion || ""); setMemory(4); setError(""); setCreating(false); }; const loadMcVersions = async () => { setLoadingVersions(true); try { + // instance:version-list 返回 { versions: { id, type, url }[] } const list = await getMinecraftVersionList(); - const releases = list.filter((v: string) => /^1\.\d+/.test(v)).slice(0, 24); + const versionIds = (list.versions ?? []).map((v) => v.id); + const releases = versionIds.filter((v: string) => /^1\.\d+/.test(v)).slice(0, 24); setMcVersions(releases.length ? releases : ["1.21.4","1.21","1.20.6","1.20.4","1.20.1","1.19.4","1.19.2","1.18.2","1.17.1","1.16.5"]); if (!mcVersion && releases.length) setMcVersion(releases[0]); } catch { setMcVersions(["1.21.4","1.21","1.20.1","1.19.2","1.18.2","1.16.5"]); if (!mcVersion) setMcVersion("1.21.4"); } @@ -130,7 +135,7 @@ export function CreateDialog({ isOpen, onClose, onCreate }: CreateDialogProps) {

模组加载器

- + setLoader(String(v))}> {loaderOptions.map((opt) => ( @@ -169,15 +174,15 @@ export function CreateDialog({ isOpen, onClose, onCreate }: CreateDialogProps) { {/* Footer */}
- {step > 0 && } + {step > 0 && }
- + {step < 2 ? ( ) : ( - )} diff --git a/src/pages/gallery/EditDialog.tsx b/src/pages/gallery/EditDialog.tsx new file mode 100644 index 0000000..4fc81cc --- /dev/null +++ b/src/pages/gallery/EditDialog.tsx @@ -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; +} + +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 ( +
+
+
+ {/* 头部 */} +
+
+ +

编辑实例

+
+ +
+ + {/* 主体 */} +
+
+ + setDescription(e.target.value)} + /> +

留空则使用实例 ID({instance.name})

+
+ +
+
+

内存分配

+ {memory} GB +
+ setMemory(typeof v === "number" ? v : v[0])} + minValue={1} + maxValue={16} + step={1} + > + + + + + +
+
+ + {/* 底部 */} +
+ + +
+
+
+ ); +} diff --git a/src/pages/gallery/InstanceCard.tsx b/src/pages/gallery/InstanceCard.tsx deleted file mode 100644 index 188d0cb..0000000 --- a/src/pages/gallery/InstanceCard.tsx +++ /dev/null @@ -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 = { - 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 = { - vanilla: "原版", - forge: "Forge", - fabric: "Fabric", - quilt: "Quilt", - neoforged: "NeoForge", -}; - -const loaderColors: Record = { - 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 ( -
-
- -
-
-
-

{title}

-

{name}

-
- {healthy === false && ( - - 异常 - - )} - -
- -
- - {loaderLabel} - - - {version} - - {modCount > 0 && ( - - {modCount} 模组 - - )} -
- -
- {config.lastPlayedDate ? ( - - {formatLastPlayed(config.lastPlayedDate)} - - ) : null} - {config.playtime > 0 && ( - - {formatPlaytime(config.playtime)} - - )} -
- - -
-
- ); -} diff --git a/src/pages/gallery/InstanceDetail.tsx b/src/pages/gallery/InstanceDetail.tsx new file mode 100644 index 0000000..a035bc9 --- /dev/null +++ b/src/pages/gallery/InstanceDetail.tsx @@ -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 = { + 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 = { + 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 ( +
+ {/* 头部横幅 */} +
+
+
+
+
+ +
+
+

{display}

+

{instance.name}

+
+
+ + {/* 版本 / 加载器 / 健康状态 */} +
+ + {loaderLabel} + + + Minecraft {config.runtime.minecraft || "未知"} + + {instance.healthy ? ( + + 健康 + + ) : ( + + 异常({instance.issues.length} 项) + + )} +
+
+ + {/* 操作按钮 */} +
+ + + +
+
+
+ +
+ {/* 统计网格 */} +
+ {stats.map((s) => ( +
+ +
+

{s.value}

+

{s.label}

+
+
+ ))} +
+ + {/* 基本信息 */} +
+ + + + +
+ + {/* 启动按钮 */} + +
+
+ ); +} + +// 信息行(图标 + 标签 + 值) +function InfoRow({ + icon: Icon, + label, + value, +}: { + icon: React.ComponentType<{ className?: string }>; + label: string; + value: string; +}) { + return ( +
+ + {label} + {value} +
+ ); +} diff --git a/src/pages/gallery/InstanceList.tsx b/src/pages/gallery/InstanceList.tsx new file mode 100644 index 0000000..6002dd0 --- /dev/null +++ b/src/pages/gallery/InstanceList.tsx @@ -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 = { + 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 ( +
+ {loading && instances.length === 0 + ? Array.from({ length: 4 }).map((_, i) => ( +
+
+ +
+ + +
+
+
+ )) + : 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 ( + + ); + })} + + {!loading && instances.length === 0 && ( +
+ +

暂无实例,点击右上角新建

+
+ )} +
+ ); +} diff --git a/src/pages/gallery/index.tsx b/src/pages/gallery/index.tsx index 349c3c0..d4da113 100644 --- a/src/pages/gallery/index.tsx +++ b/src/pages/gallery/index.tsx @@ -1,23 +1,50 @@ -import { useEffect, useState, useCallback } from "react"; +// __ __ __ __ ______ __ __ ______ __ __ ______ ______ +// /\ \ /\ \ /\ "-.\ \ /\ ___\ /\ \/ / /\ ___\ /\ "-.\ \ /\ ___\ /\__ _\ +// \ \ \____ \ \ \ \ \ \-. \ \ \ \__ \ \ \ _"-. \ \ __\ \ \ \-. \ \ \ __\ \/_/\ \/ +// \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ \ \_\ \_\ \ \_____\ \ \_\\"\_\ \ \_____\ \ \_\ +// \/_____/ \/_/ \/_/ \/_/ \/_____/ \/_/\/_/ \/_____/ \/_/ \/_/ \/_____/ \/_/ +// +// 所有权利归Lingke Network (china)所有 | dream_pep拥有其艺术改变权力 +// 未经允许的情况下删除此版权头可能会受到民事指控 +// 请勿在未经Lingke Network (china)允许的范围内修改代码并分发 + +import { useCallback, useEffect, useState } from "react"; import { Button } from "@heroui/react"; -import { Plus, Gamepad2, RefreshCw } from "lucide-react"; +import { Plus, RefreshCw, Gamepad2 } from "lucide-react"; +import { toast } from "sonner"; import { useInstanceStore } from "@/stores/instanceStore"; import { useConfigStore } from "@/stores/configStore"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Surface } from "@/components/setting/Surface"; -import { InstanceCard } from "./InstanceCard"; -import { CreateDialog } from "./CreateDialog"; -import type { InstanceRuntime } from "@/api/instance"; +import { useAuthStore } from "@/stores/authStore"; +import { useConfirmDialogStore } from "@/stores/confirmDialogStore"; +import { useRouteStore } from "@/stores/routeStore"; +import { onLaunchComplete, onLaunchError } from "@/api/instance"; +import { openPath } from "@/api/system"; +import { InstanceList } from "./InstanceList"; +import { InstanceDetail } from "./InstanceDetail"; +import { EditDialog } from "./EditDialog"; export function Gallery() { - const { instances, loading, fetchInstances, create, launch } = useInstanceStore(); + const { instances, loading, fetchInstances, remove, launch } = useInstanceStore(); const gameConfig = useConfigStore((s) => s.config.game); + const javaConfig = useConfigStore((s) => s.config.java); const configInstances = useConfigStore((s) => s.config.instances); const setInstances = useConfigStore((s) => s.setInstances); + const user = useAuthStore((s) => s.user); + const openConfirm = useConfirmDialogStore((s) => s.openDialog); + const navigate = useRouteStore((s) => s.navigate); + const setStoreSection = useRouteStore((s) => s.setStoreSection); - const [createOpen, setCreateOpen] = useState(false); - const [launching, setLaunching] = useState(null); + const [selectedName, setSelectedName] = useState(null); + const [editOpen, setEditOpen] = useState(false); + const [launching, setLaunching] = useState(false); + // 跳转到资源中心"原版游戏"分类创建实例 + const goCreateInstance = useCallback(() => { + setStoreSection("game"); + navigate("store"); + }, [setStoreSection, navigate]); + + // 加载实例列表 const loadInstances = useCallback(async () => { await fetchInstances(gameConfig.gameDir); }, [fetchInstances, gameConfig.gameDir]); @@ -26,66 +53,111 @@ export function Gallery() { loadInstances(); }, [loadInstances]); - const handleCreate = async (name: string, runtime: InstanceRuntime, displayName?: string) => { - await create(name, gameConfig.gameDir, runtime, { - description: displayName, + // 默认选中第一个实例 + useEffect(() => { + if (!selectedName && instances.length > 0) { + setSelectedName(instances[0].name); + } + if (selectedName && !instances.some((i) => i.name === selectedName)) { + setSelectedName(instances[0]?.name ?? null); + } + }, [instances, selectedName]); + + // 监听启动结果 + useEffect(() => { + const offComplete = onLaunchComplete(() => { + setLaunching(false); + toast.success("游戏启动成功"); }); - - // Sync to Koring.yml - const loader = runtime.forge ? "forge" - : runtime.fabricLoader ? "fabric" - : runtime.quiltLoader ? "quilt" - : runtime.neoForged ? "neoforged" - : "vanilla"; - - const loaderVersion = runtime.forge || runtime.fabricLoader || runtime.quiltLoader || runtime.neoForged || ""; - - const updated = configInstances.filter((m) => m.name !== name); - updated.push({ - name, - displayName: displayName || name, - icon: "", - gameVersion: runtime.minecraft, - loader, - loaderVersion, - createdAt: Date.now(), - lastPlayed: 0, - playtime: 0, + const offError = onLaunchError(({ error }) => { + setLaunching(false); + toast.error(`启动失败: ${error}`); }); - setInstances(updated); - setCreateOpen(false); - }; + return () => { + offComplete(); + offError(); + }; + }, []); - const handlePlay = async (name: string) => { - setLaunching(name); + const selected = instances.find((i) => i.name === selectedName) ?? null; + + // 启动游戏 + const handlePlay = async () => { + if (!selected) return; + if (!user?.username || !user?.uuid) { + toast.warning("请先在设置中登录账号"); + return; + } + setLaunching(true); try { - await launch(name, gameConfig.gameDir); - } catch { - // Launch events handled via IPC listeners + await launch(selected.name, gameConfig.gameDir, { + username: user.username, + uuid: user.uuid, + accessToken: user.accessToken, + javaPath: javaConfig.javaPath || undefined, + }); + } catch (e: any) { + setLaunching(false); + toast.error(`启动失败: ${e.message || e}`); } - // Update lastPlayed in config - const idx = configInstances.findIndex((m) => m.name === name); - if (idx >= 0) { - const updated = [...configInstances]; - updated[idx] = { ...updated[idx], lastPlayed: Date.now() }; - setInstances(updated); - } - setLaunching(null); }; - const handleSettings = (name: string) => { - // TODO: Navigate to instance settings - console.log("Settings for:", name); + // 删除实例(带确认) + const handleDelete = () => { + if (!selected) return; + openConfirm({ + title: "删除实例", + description: `确定要删除实例「${selected.config.description || selected.name}」吗?该操作将删除实例目录下所有文件,且无法恢复。`, + confirmLabel: "删除", + countdown: 5, + onConfirm: async () => { + try { + await remove(selected.name, gameConfig.gameDir); + // 同步移除 Koring.yml 中的记录 + setInstances(configInstances.filter((m) => m.name !== selected.name)); + setSelectedName(null); + toast.success("实例已删除"); + } catch (e: any) { + toast.error(`删除失败: ${e.message || e}`); + } + }, + }); }; - const getDisplayName = (name: string) => { - return configInstances.find((m) => m.name === name)?.displayName; + // 编辑实例(显示名/内存) + const handleEditSave = async (patch: { description?: string; minMemory?: number; maxMemory?: number }) => { + if (!selected) return; + try { + await useInstanceStore.getState().select(selected.name, gameConfig.gameDir); + // 更新 instance.json + const result = await import("@/api/instance").then((m) => + m.updateInstance(selected.name, gameConfig.gameDir, patch) + ); + // 刷新列表并同步 config 显示名 + await fetchInstances(gameConfig.gameDir); + const updatedMeta = configInstances.map((m) => + m.name === selected.name ? { ...m, displayName: patch.description?.trim() || m.displayName } : m + ); + setInstances(updatedMeta); + if (result) toast.success("实例设置已保存"); + } catch (e: any) { + toast.error(`保存失败: ${e.message || e}`); + } + }; + + // 打开游戏目录 + const handleOpenFolder = async () => { + if (!selected) return; + const result = await openPath(selected.path); + if (!result.success) { + toast.error(`无法打开目录: ${result.error || "未知错误"}`); + } }; return (
{/* 头部 */} -
+

实例管理

@@ -93,74 +165,73 @@ export function Gallery() {

- -
- {/* 加载骨架 */} - {loading && instances.length === 0 && ( -
- {Array.from({ length: 6 }).map((_, i) => ( - -
- - -
- - -
- -
-
- ))} + {/* 左右布局 */} +
+ {/* 左侧:实例列表 */} +
+

+ 我的实例({instances.length}) +

+
- )} - {/* 空状态 */} - {!loading && instances.length === 0 && ( - -
-
- -
-
-

还没有任何实例

-

- 点击"新建实例"创建你的第一个 Minecraft 游戏实例 -

-
- -
-
- )} - - {/* 实例网格 */} - {instances.length > 0 && ( -
- {instances.map((inst) => ( - + {selected ? ( + setEditOpen(true)} + onOpenFolder={handleOpenFolder} + onDelete={handleDelete} /> - ))} + ) : ( +
+
+ +
+
+

+ {loading ? "正在加载实例..." : "还没有任何实例"} +

+ {!loading && ( +

+ 点击「新建实例」创建你的第一个游戏实例 +

+ )} +
+ {!loading && ( + + )} +
+ )}
- )} +
- setCreateOpen(false)} - onCreate={handleCreate} + setEditOpen(false)} + onSave={handleEditSave} />
); diff --git a/src/pages/oobe/step-agreement.tsx b/src/pages/oobe/step-agreement.tsx index 2db7145..921fb35 100644 --- a/src/pages/oobe/step-agreement.tsx +++ b/src/pages/oobe/step-agreement.tsx @@ -1,34 +1,69 @@ -import { useState, useEffect } from "react"; +// __ __ __ __ ______ __ __ ______ __ __ ______ ______ +// /\ \ /\ \ /\ "-.\ \ /\ ___\ /\ \/ / /\ ___\ /\ "-.\ \ /\ ___\ /\__ _\ +// \ \ \____ \ \ \ \ \ \-. \ \ \ \__ \ \ \ _"-. \ \ __\ \ \ \-. \ \ \ __\ \/_/\ \/ +// \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ \ \_\ \_\ \ \_____\ \ \_\\"\_\ \ \_____\ \ \_\ +// \/_____/ \/_/ \/_/ \/_/ \/_____/ \/_/\/_/ \/_____/ \/_/ \/_/ \/_____/ \/_/ +// +// 所有权利归Lingke Network (china)所有 | dream_pep拥有其艺术改变权力 +// 未经允许的情况下删除此版权头可能会受到民事指控 +// 请勿在未经Lingke Network (china)允许的范围内修改代码并分发 + +import { useState } from "react"; +import { ExternalLink } from "lucide-react"; import { useRouteStore } from "@/stores/routeStore"; import { OobeLayout } from "./layout"; import { NextButton } from "./next-button"; +const openLink = (url: string) => { + window.electronAPI?.openExternal(url); +}; + +const AGREEMENT_LINKS = [ + { + label: "用户协议与免责声明", + desc: "Copyright © Shenzhen Prism Horizon Technology Co., Ltd.", + url: "https://support.lingke.ink/Koring/系列产品用户协议与免责声明", + }, + { + label: "产品分发有限许可", + desc: "除本许可明确授予的权利外,许可方保留软件及其相关知识产权中的全部权利。", + url: "https://support.lingke.ink/Koring/系列产品分发有限许可", + }, +]; + export function OobeAgreement() { const navigate = useRouteStore((s) => s.navigate); - const [text, setText] = useState(""); const [checked, setChecked] = useState(false); - useEffect(() => { - fetch(`${import.meta.env.BASE_URL}protocol-user.txt`) - .then((r) => r.text()) - .then(setText) - .catch(() => setText("无法加载协议内容")); - }, []); - return ( -
+
{/* 标题 */}
-

Koring Team 产品用户协议

-

您需要同意才可以继续

+

确认产品协议

+

您需要同意以下协议才可以继续

- {/* 协议内容 */} -
-
-            {text}
-          
+ {/* 协议链接列表 */} +
+ {AGREEMENT_LINKS.map((item) => ( +
+
+

{item.label}

+

{item.desc}

+
+ +
+ ))}
{/* 勾选框 */} @@ -49,12 +84,12 @@ export function OobeAgreement() {
- 我已详细阅读此协议,且同意其内容并签署此协议。 + 我已详细阅读以上协议,且同意其内容并签署此协议。
- navigate("oobe/welcome")} disabled={!checked} /> + navigate("oobe/legal")} disabled={!checked} />
); } diff --git a/src/pages/oobe/step-language.tsx b/src/pages/oobe/step-language.tsx index ff1d08e..3a54e44 100644 --- a/src/pages/oobe/step-language.tsx +++ b/src/pages/oobe/step-language.tsx @@ -45,7 +45,8 @@ export function OobeLanguage() { ))}
- navigate("oobe/login")} /> + {/* 临时跳过 Koring 账户登录环节,直接进入用户协议 */} + navigate("oobe/agreement")} /> ); } diff --git a/src/pages/oobe/step-legal.tsx b/src/pages/oobe/step-legal.tsx new file mode 100644 index 0000000..ea4535e --- /dev/null +++ b/src/pages/oobe/step-legal.tsx @@ -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 ( + +
+ {/* 标题 */} +
+

法律信息

+

请阅读以下法律声明

+
+ + {/* 法律信息列表 */} +
+ {LEGAL_ITEMS.map((item) => ( +
+

{item.label}

+

+ {item.desc} +

+
+ ))} +
+
+ + navigate("oobe/welcome")} /> +
+ ); +} diff --git a/src/pages/oobe/step-version.tsx b/src/pages/oobe/step-version.tsx index eebfda5..631a077 100644 --- a/src/pages/oobe/step-version.tsx +++ b/src/pages/oobe/step-version.tsx @@ -1,50 +1,22 @@ -import { useState, useEffect } from "react"; import { useRouteStore } from "@/stores/routeStore"; import { OobeLayout } from "./layout"; import { NextButton } from "./next-button"; -import { VERSION } from "@/lib/version"; +import { VersionCard } from "@/components/VersionCard"; import { BUILD_MODE } from "@/lib/mode"; export function OobeVersion() { const navigate = useRouteStore((s) => s.navigate); - const [changelog, setChangelog] = useState(""); const isTestBuild = BUILD_MODE === "dev" || BUILD_MODE === "beta"; - const badgeLabel = BUILD_MODE === "dev" ? "DEV" : BUILD_MODE === "beta" ? "BETA" : null; - const nextRoute = isTestBuild ? "oobe/beta-test" : "oobe/login"; - - useEffect(() => { - fetch(`${import.meta.env.BASE_URL}changelog-${VERSION}.txt`) - .then((r) => r.text()) - .then(setChangelog) - .catch(() => setChangelog("暂无更新内容")); - }, []); + // 临时跳过 Koring 账户登录环节:正式版也直接进入用户协议 + const nextRoute = isTestBuild ? "oobe/beta-test" : "oobe/agreement"; return (
- {/* 标题 */} -

核对您的版本信息

- - {/* 版本号 + Badge */} -
- - v{VERSION} - - {badgeLabel && ( - - {badgeLabel} - - )} -
+ {/* 版本卡片(OOBE 模式:无更新逻辑,按钮仅展示样式) */} + {/* 测试版警告 */} {isTestBuild && ( @@ -52,13 +24,6 @@ export function OobeVersion() { 您正在使用测试版本,它并不稳定,不建议用于正式游戏体验,具体内容请以发行版本为准。

)} - - {/* 更新内容 */} -
-
-            {changelog}
-          
-
navigate(nextRoute)} /> diff --git a/src/pages/oobe/step-welcome.tsx b/src/pages/oobe/step-welcome.tsx index 52fb76b..7052978 100644 --- a/src/pages/oobe/step-welcome.tsx +++ b/src/pages/oobe/step-welcome.tsx @@ -21,7 +21,7 @@ export function OobeWelcome() { return () => clearTimeout(t); }, []); - const displayName = user?.name || user?.username || "用户"; + const displayName = user?.name || user?.username || "玩家"; return ( diff --git a/src/pages/setting/game/game-dir.tsx b/src/pages/setting/game/game-dir.tsx index 895fc26..e19c2f9 100644 --- a/src/pages/setting/game/game-dir.tsx +++ b/src/pages/setting/game/game-dir.tsx @@ -1,26 +1,48 @@ +// __ __ __ __ ______ __ __ ______ __ __ ______ ______ +// /\ \ /\ \ /\ "-.\ \ /\ ___\ /\ \/ / /\ ___\ /\ "-.\ \ /\ ___\ /\__ _\ +// \ \ \____ \ \ \ \ \ \-. \ \ \ \__ \ \ \ _"-. \ \ __\ \ \ \-. \ \ \ __\ \/_/\ \/ +// \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ \ \_\ \_\ \ \_____\ \ \_\\"\_\ \ \_____\ \ \_\ +// \/_____/ \/_/ \/_/ \/_/ \/_____/ \/_/\/_/ \/_____/ \/_/ \/_/ \/_____/ \/_/ +// +// 所有权利归Lingke Network (china)所有 | dream_pep拥有其艺术改变权力 +// 未经允许的情况下删除此版权头可能会受到民事指控 +// 请勿在未经Lingke Network (china)允许的范围内修改代码并分发 + +import { useState } from "react"; +import { Button, Skeleton } from "@heroui/react"; +import { RefreshCw, FolderOpen, Trash2, Search, Plus, CircleCheck, CircleAlert, Home, Check } from "lucide-react"; +import { toast } from "sonner"; import { useConfigStore } from "@/stores/configStore"; -import { Button, Input } from "@heroui/react"; -import { RefreshCw } from "lucide-react"; +import { scanGameDir, selectFolder, type ScannedVersion } from "@/api/instance"; import { SettingCard, PageHeader, SectionTitle } from "@/components/setting"; -function PathInput({ label, desc, value, onChange }: { label: string; desc: string; value: string; onChange: (v: string) => void }) { +// 版本类型标签样式 +const TYPE_BADGE: Record = { + 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 ( - -
-
-

{label}

-

{desc}

-
-
- onChange(e.target.value)} - fullWidth - /> - -
-
-
+ + {ok ? : } + {label} + ); } @@ -28,58 +50,236 @@ export function GameDirSetting() { const game = useConfigStore((s) => s.config.game); const setGame = useConfigStore((s) => s.setGame); + const [scanning, setScanning] = useState(false); + const [scanTarget, setScanTarget] = useState(""); // 正在扫描的目标路径 + const [scanResults, setScanResults] = useState(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 (
- +
+ {/* 主游戏目录配置 */}
- 游戏路径 -
- setGame({ gameDir: v })} - /> - -
-
-

已安装版本

-

扫描当前目录下的游戏版本

-
- +
-
-
+
+ + {/* 扫描中骨架屏 */} + {scanning && scanTarget === (game.gameDir || ".minecraft") && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ )} + + {/* 扫描结果 */} + {scanResults !== null && scanTarget === (game.gameDir || ".minecraft") && ( +
+ {scanResults.length === 0 ? ( +

+ 未在 versions 目录下发现版本,请确认 Minecraft 已下载到该目录 +

+ ) : ( +
+ {scanResults.map((v) => { + const badge = getBadge(v.type); + return ( +
+ {/* 版本图标 */} +
+ +
+
+
+

{v.id}

+ + {badge.label} + + {formatTime(v.releaseTime)} +
+
+ + +
+
+
+ ); + })} +
+ )} +
+ )} +
-
- 资源包目录 -
- setGame({ resourceDir: v })} - /> -
+ {/* 已添加的目录列表 */} +
+ 已添加目录({dirs.length}) +
-
- 存档目录 -
- setGame({ savesDir: v })} - /> + {dirs.length === 0 ? ( + +
+ +

还没有添加任何游戏目录

+

+ 使用主目录的"扫描"功能发现已安装版本,或点击"手动添加"选择文件夹 +

+
+
+ ) : ( +
+ {dirs.map((dir) => ( + +
+
+
+

{dir}

+ {dir === (game.gameDir || ".minecraft") && ( + + + 当前 + + )} +
+

已连接的游戏目录

+
+
+ {dir !== (game.gameDir || ".minecraft") && ( + + )} + + +
+
+ + {/* 该目录的扫描结果 */} + {scanning && scanTarget === dir && ( +
+ {Array.from({ length: 2 }).map((_, i) => ( + + ))} +
+ )} + {scanResults !== null && scanTarget === dir && scanTarget !== (game.gameDir || ".minecraft") && ( +
+ {scanResults.length === 0 ? ( +

未发现版本文件

+ ) : ( +
+ {scanResults.map((v) => { + const badge = getBadge(v.type); + return ( + + + {v.id} + + ); + })} +
+ )} +
+ )} +
+ ))}
-
+ )}
); diff --git a/src/pages/setting/general/about.tsx b/src/pages/setting/general/about.tsx index 93ec12f..365b247 100644 --- a/src/pages/setting/general/about.tsx +++ b/src/pages/setting/general/about.tsx @@ -11,8 +11,8 @@ const modeLabels: Record = { run: "正式版", }; -const GITHUB_URL = "https://github.com/koring-launcher/koring-launcher"; -const OFFICIAL_URL = "https://koring.app"; +const GITHUB_URL = "https://github.com/lingke-net/koring-launcher"; +const OFFICIAL_URL = "https://koring.space"; export function AboutSetting() { const openDialog = useConfirmDialogStore((s) => s.openDialog); diff --git a/src/pages/setting/general/copyright.tsx b/src/pages/setting/general/copyright.tsx index 72f83e5..c72ec61 100644 --- a/src/pages/setting/general/copyright.tsx +++ b/src/pages/setting/general/copyright.tsx @@ -32,9 +32,46 @@ export function CopyrightSetting() {
- 项目协议 + 法律信息 +
+ + + + + + + + + + + + + + + + + openLink("https://support.lingke.ink/Koring/系列产品用户协议与免责声明")}> + 查看 + + + + + + + openLink("https://support.lingke.ink/Koring/系列产品分发有限许可")}> + 查看 + + + + +
+
+ +
+ 项目代码开源协议 - + openLink("https://support.lingke.ink/LL-1.0")}> 查看 diff --git a/src/pages/setting/general/home.tsx b/src/pages/setting/general/home.tsx index f324e2e..ac5d6f9 100644 --- a/src/pages/setting/general/home.tsx +++ b/src/pages/setting/general/home.tsx @@ -56,7 +56,8 @@ export function HomeSetting({ onNavigate }: HomeSettingProps) {
- + {/* 与"关于"页使用相同的版本卡片 */} +
常用设置 diff --git a/src/pages/store/GameVersionView.tsx b/src/pages/store/GameVersionView.tsx new file mode 100644 index 0000000..a1f737d --- /dev/null +++ b/src/pages/store/GameVersionView.tsx @@ -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: }, + { group: "snapshot", label: "预览版", icon: }, + { group: "aprilfool", label: "愚人节版", icon: }, +]; + +export function GameVersionView() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [query, setQuery] = useState(""); + const [installVersion, setInstallVersion] = useState(null); + + // 折叠面板展开状态(默认展开正式版,可多选) + const [expandedKeys, setExpandedKeys] = useState>(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(); + 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 = { + release: releaseVersions, + snapshot: snapshotVersions, + aprilfool: aprilFoolVersions, + }; + + return ( +
+ {/* 顶部搜索框(无标题) */} +
+ + setQuery(e.target.value)} + /> +
+ + {loading ? ( +
+ + {[1, 2, 3].map((i) => )} +
+ ) : !data ? ( +
+ +

无法获取版本列表

+
+ ) : query ? ( + /* 搜索模式:直接展示匹配的结果列表 */ +
+ {allFiltered.length === 0 ? ( +
未找到与「{query}」匹配的版本
+ ) : ( + allFiltered.map((v, i) => ( + setInstallVersion(v.id)} /> + )) + )} +
+ ) : ( + /* 默认模式:最新版本卡片 + 折叠面板 */ + <> + {/* 最新版本卡片 */} + } + version={latestRelease} + onClick={() => latestRelease && setInstallVersion(latestRelease.id)} + /> + } + version={latestSnapshot} + className="mt-3" + onClick={() => latestSnapshot && setInstallVersion(latestSnapshot.id)} + /> + + {/* 折叠面板(HeroUI Accordion,默认展开正式版) */} + setExpandedKeys(new Set(keys as Set))} + className="mt-6 flex flex-col gap-3" + > + {GROUPS.map(({ group, label, icon }) => { + const versions = groupVersions[group]; + return ( + + + + {icon} + + {label}({versions.length}) + + + + + + + + {versions.length === 0 ? ( +

暂无此分类的版本

+ ) : ( + + {versions.map((v, i) => ( + setInstallVersion(v.id)} /> + ))} + + )} +
+
+ ); + })} +
+ + )} + + {/* 安装弹窗 */} + setInstallVersion(null)} + /> +
+ ); +} + +// 顶部版本信息卡片:显示最新正式版 / 预览版 +function TopVersionCard({ + label, icon, version, className, onClick, +}: { + label: string; icon: React.ReactNode; version: VersionEntry | null; className?: string; onClick: () => void; +}) { + return ( + +
+ {icon} +
+
+

{label}

+ {version ? ( + <> +

{version.id}

+

发布于 {formatTime(version.releaseTime)}

+ + ) : ( +

暂未获取到版本

+ )} +
+ {/* HeroUI Button 会拦截 click 冒泡,必须用 onPress 触发 */} + +
+ ); +} + +// 单行版本项 +function VersionRow({ entry, index = 0, onClick }: { entry: VersionEntry; index?: number; onClick: () => void }) { + return ( + +
+
+

{entry.id}

+ + {entry.type === "release" ? "正式版" : entry.type === "snapshot" ? "预览版" : entry.type} + +
+

+ 发布于 {formatTime(entry.releaseTime)} +

+
+ {/* HeroUI Button 会拦截 click 冒泡,必须用 onPress 触发 */} + +
+ ); +} diff --git a/src/pages/store/InstallDialog.tsx b/src/pages/store/InstallDialog.tsx new file mode 100644 index 0000000..5a349fd --- /dev/null +++ b/src/pages/store/InstallDialog.tsx @@ -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("vanilla"); + const [loaderVersion, setLoaderVersion] = useState("latest"); + const [installing, setInstalling] = useState(false); + // 关闭动画期间保留版本号,避免内容提前消失 + const [displayVersion, setDisplayVersion] = useState(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( + { if (!open) onClose(); }}> + + + {/* ... 内容不变 ... */} + + + {/* 头部 */} + + + + 安装 Minecraft {displayVersion} + + + + + {/* 步骤指示器 */} +
+ {steps.map((label, i) => ( +
+
+ {i < step ? : i + 1} +
+ + {i < 3 &&
} +
+ ))} +
+ + {/* 步骤内容 */} +
+ {step === 0 && ( +
+

为此版本命名,默认为版本号

+ setInstanceName(e.target.value.replace(/\s/g, "-").toLowerCase())} + autoFocus + /> +
+ )} + + {step === 1 && ( +
+

选择模组加载器

+ {LOADER_OPTIONS.map((opt) => { + const active = loader === opt.value; + return ( + + ); + })} +
+ )} + + {step === 2 && loader !== "vanilla" && ( +
+

+ 选择 {LOADER_OPTIONS.find((o) => o.value === loader)?.label} 版本 +

+ setLoaderVersion(e.target.value)} + autoFocus + /> +

+ 使用 "latest" 安装最新版,或指定具体版本号。版本列表加载可能需要网络以获取实时数据。 +

+
+ )} + + {step === 2 && loader === "vanilla" && ( +
+ +

原版 Minecraft 无需加载器

+

直接点击下一步进入确认

+
+ )} + + {step === 3 && ( +
+

请确认配置

+
+ + + o.value === loader)?.label ?? "原版"} /> + {loader !== "vanilla" && ( + + )} +
+
+ )} +
+ + + {/* 底部操作 */} + +
+ {step > 0 && ( + + )} +
+
+ + {step < 3 ? ( + + ) : ( + + )} +
+
+ + + + , + document.body, + ); +} + +function ConfigRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/src/pages/store/ModCard.tsx b/src/pages/store/ModCard.tsx new file mode 100644 index 0000000..2c5afe8 --- /dev/null +++ b/src/pages/store/ModCard.tsx @@ -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 ( +
onOpen(mod)} + > + {/* 图标 */} +
+ {mod.iconUrl ? ( + {mod.name} { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + ) : ( + + )} +
+ + {/* 中间信息 */} +
+
+

{mod.name}

+ {mod.categories?.slice(0, 2).map((cat) => ( + + {cat} + + ))} +
+

+ {mod.description || "暂无描述"} +

+
+ + {/* 右侧:下载量 + 操作 */} +
+ + + {formatDownloads(mod.downloads)} + + + {actionLabel} + + +
+
+ ); +} diff --git a/src/pages/store/ModInstallDialog.tsx b/src/pages/store/ModInstallDialog.tsx new file mode 100644 index 0000000..95ca54a --- /dev/null +++ b/src/pages/store/ModInstallDialog.tsx @@ -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(null); + const [selectedInstance, setSelectedInstance] = useState(""); + + 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( +
+
+
+
+
+ {mod.iconUrl ? ( + {mod.name} ((e.target as HTMLImageElement).style.display = "none")} /> + ) : ( +
+ +
+ )} +
+

{mod.name}

+

{mod.description}

+
+
+ +
+
+
+

选择版本

+ {modVersions.length === 0 ? ( +
正在加载版本列表...
+ ) : visibleVersions.length === 0 ? ( +
没有匹配的版本
+ ) : ( +
+ {visibleVersions.slice(0, 12).map((v) => { + const active = selectedVersion?.id === v.id; + return ( + + ); + })} +
+ )} +
+
+

安装到实例

+ {instances.length === 0 ? ( +
暂无实例
+ ) : ( +
+ {instances.map((inst) => { + const active = selectedInstance === inst.name; + return ( + + ); + })} +
+ )} +
+
+
+

{selectedVersion?.files[0]?.filename ? `文件: ${selectedVersion.files[0].filename}` : "选择版本后点击安装"}

+
+ + +
+
+
+
, + document.body, + ); +} diff --git a/src/pages/store/ResourceBrowseView.tsx b/src/pages/store/ResourceBrowseView.tsx new file mode 100644 index 0000000..364db27 --- /dev/null +++ b/src/pages/store/ResourceBrowseView.tsx @@ -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; +} + +export function ResourceBrowseView({ projectType, canInstall, scrollRef }: ResourceBrowseViewProps) { + const { searchResults, total, hasMore, loading, error, search, clear } = useModsStore(); + + // 页面筛选状态 + const [query, setQuery] = useState(""); + const [gameVersion, setGameVersion] = useState(); + const [loader, setLoader] = useState(); + const [category, setCategory] = useState(); + + // 筛选选项数据 + const [categories, setCategories] = useState([]); + const [gameVersions, setGameVersions] = useState([]); + + // 安装弹窗 + const [activeMod, setActiveMod] = useState(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[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; + 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 ( +
+ {/* 页头:标题 + 搜索框 */} +
+
+

{title}

+

{description}

+
+
+ + setQuery(e.target.value)} + /> +
+
+ + {/* 筛选区:游戏版本 / 加载器 / 分类 */} +
+ + + + {gameVersion ? `Minecraft ${gameVersion}` : "全部版本"} + + + + + + + + + 全部版本 + + {gameVersions.slice(0, 30).map((v) => ( + + {v} + + ))} + + + + + + + + {loader ? LOADER_OPTIONS.find((o) => o.value === loader)?.label ?? loader : "全部加载器"} + + + + + + + + + 全部加载器 + + {LOADER_OPTIONS.map((o) => ( + + {o.label} + + ))} + + + + + + + + {category ? categories.find((c) => c.name === category)?.label ?? category : "全部分类"} + + + + + + + + + 全部分类 + + {categories.map((c) => ( + + {c.label} + + ))} + + + + + {/* 结果计数 + 重置 */} + + 共 {total} 个结果 + + {hasFilter && ( + + )} +
+ + {/* 错误提示 */} + {error && ( +
+ + 搜索失败:{error} + +
+ )} + + {/* 列表(无限滚动拼接,一行一条横条) */} + {loading && searchResults.length === 0 ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ +
+ + +
+ +
+ ))} +
+ ) : !loading && searchResults.length === 0 ? ( +
+
+ +
+
+

{hasFilter ? "没有找到匹配的资源" : "暂无资源"}

+

+ {hasFilter ? "尝试调整搜索关键词或筛选条件" : "搜索你想要的资源"} +

+
+ {hasFilter && ( + + )} +
+ ) : ( + <> +
+ {searchResults.map((mod) => ( + + ))} +
+ + {/* 底部加载状态 */} +
+ {loadingMore ? ( + + + 加载更多... + + ) : !hasMore && searchResults.length > 0 ? ( + 已经到底啦 + ) : null} +
+ + )} + + {/* 安装弹窗(仅 MOD) */} + {canInstall && ( + setActiveMod(null)} + /> + )} +
+ ); +} diff --git a/src/pages/store/Sidebar.tsx b/src/pages/store/Sidebar.tsx new file mode 100644 index 0000000..96a6f97 --- /dev/null +++ b/src/pages/store/Sidebar.tsx @@ -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 ( + + ); +} + +// 单个侧边栏项(样式与设置页一致) +function SidebarItem({ + active, + icon, + label, + onClick, +}: { + active: boolean; + icon: React.ReactNode; + label: string; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/src/pages/store/index.tsx b/src/pages/store/index.tsx index ff50ab8..0f68045 100644 --- a/src/pages/store/index.tsx +++ b/src/pages/store/index.tsx @@ -1,5 +1,78 @@ -import { UnderConstruction } from "@/components/UnderConstruction"; +// __ __ __ __ ______ __ __ ______ __ __ ______ ______ +// /\ \ /\ \ /\ "-.\ \ /\ ___\ /\ \/ / /\ ___\ /\ "-.\ \ /\ ___\ /\__ _\ +// \ \ \____ \ \ \ \ \ \-. \ \ \ \__ \ \ \ _"-. \ \ __\ \ \ \-. \ \ \ __\ \/_/\ \/ +// \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ \ \_\ \_\ \ \_____\ \ \_\\"\_\ \ \_____\ \ \_\ +// \/_____/ \/_/ \/_/ \/_/ \/_____/ \/_/\/_/ \/_____/ \/_/ \/_/ \/_____/ \/_/ +// +// 所有权利归Lingke Network (china)所有 | dream_pep拥有其艺术改变权力 +// 未经允许的情况下删除此版权头可能会受到民事指控 +// 请勿在未经Lingke Network (china)允许的范围内修改代码并分发 + +import { useEffect, useRef, useState } from "react"; +import { ArrowUp } from "lucide-react"; +import { useRouteStore } from "@/stores/routeStore"; +import { Sidebar } from "./Sidebar"; +import { GameVersionView } from "./GameVersionView"; +import { ResourceBrowseView } from "./ResourceBrowseView"; export function Store() { - return ; + const section = useRouteStore((s) => s.storeSection); + const setStoreSection = useRouteStore((s) => s.setStoreSection); + + // 内容区滚动容器 + const mainRef = useRef(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 ( +
+ {/* 左侧边栏(与设置页同款样式) */} + + + {/* 右侧内容区(唯一滚动容器,支持无限滚动与返回顶部) */} +
+
+ {section === "game" ? ( + + ) : ( + + )} +
+ + {/* 返回顶部按钮(非顶部时显示) */} + {showTop && ( + + )} +
+
+ ); } + +// 供外部(实例管理)跳转资源中心分类使用 +export type { StoreSection } from "./Sidebar"; diff --git a/src/stores/configStore.ts b/src/stores/configStore.ts index 18d4f8c..37118c5 100644 --- a/src/stores/configStore.ts +++ b/src/stores/configStore.ts @@ -50,7 +50,7 @@ const DEFAULT_CONFIG: AppConfig = { theme: { darkMode: "auto", parallax: true }, a11y: { reduceMotion: false, reduceTransparency: false, highContrast: false, contentBlurOpacity: 50 }, background: { bgType: "image", image: DEFAULT_BG, blur: 0, opacity: 100 }, - game: { gameDir: ".minecraft", resourceDir: "", savesDir: "", instancesDir: ".minecraft/instances" }, + game: { gameDir: ".minecraft", resourceDir: "", savesDir: "", instancesDir: ".minecraft/instances", gameDirs: [] }, java: { javaPath: "", memMode: "auto", memGB: 4, gc: "auto", jvmArgs: "" }, advanced: { afterLaunch: "close", winMode: "default", customWidth: 854, customHeight: 480, gameArgs: "", preLaunchCmd: "", debugMode: false }, download: { fileSource: "mirror", versionSource: "mirror", threads: 16, speedLimit: 0 }, diff --git a/src/stores/modsStore.ts b/src/stores/modsStore.ts index 6811a0a..e4d914d 100644 --- a/src/stores/modsStore.ts +++ b/src/stores/modsStore.ts @@ -1,20 +1,43 @@ +// __ __ __ __ ______ __ __ ______ __ __ ______ ______ +// /\ \ /\ \ /\ "-.\ \ /\ ___\ /\ \/ / /\ ___\ /\ "-.\ \ /\ ___\ /\__ _\ +// \ \ \____ \ \ \ \ \ \-. \ \ \ \__ \ \ \ _"-. \ \ __\ \ \ \-. \ \ \ __\ \/_/\ \/ +// \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ \ \_\ \_\ \ \_____\ \ \_\\"\_\ \ \_____\ \ \_\ +// \/_____/ \/_/ \/_/ \/_/ \/_____/ \/_/\/_/ \/_____/ \/_/ \/_/ \/_____/ \/_/ +// +// 所有权利归Lingke Network (china)所有 | dream_pep拥有其艺术改变权力 +// 未经允许的情况下删除此版权头可能会受到民事指控 +// 请勿在未经Lingke Network (china)允许的范围内修改代码并分发 + import { create } from "zustand"; import { searchMods, getModDetail, getModVersions, installMod } from "../api/mods"; -import type { ModSearchResult, ModVersionResult } from "../api/mods"; +import type { ModSearchResult, ModVersionResult, ModSearchResponse } from "../api/mods"; + +export interface ModSearchParams { + query?: string; + gameVersion?: string; + loader?: string; + category?: string; + projectType?: string; + page?: number; + pageSize?: number; + source?: "modrinth" | "curseforge"; + /** 为 true 时将结果追加到已有列表(用于无限滚动),否则替换 */ + append?: boolean; +} interface ModsState { searchResults: ModSearchResult[]; + total: number; + currentPage: number; + pageSize: number; + hasMore: boolean; currentMod: ModSearchResult | null; modVersions: ModVersionResult[]; loading: boolean; + installing: boolean; error: string | null; - search: ( - query?: string, - gameVersion?: string, - loader?: string, - source?: "modrinth" | "curseforge" - ) => Promise; + search: (params?: ModSearchParams) => Promise; getDetail: (projectId: string, source: "modrinth" | "curseforge") => Promise; getVersions: ( projectId: string, @@ -32,20 +55,48 @@ interface ModsState { clear: () => void; } -export const useModsStore = create((set) => ({ +export const useModsStore = create((set, get) => ({ searchResults: [], + total: 0, + currentPage: 1, + pageSize: 12, + hasMore: false, currentMod: null, modVersions: [], loading: false, + installing: false, error: null, - search: async (query?, gameVersion?, loader?, source = "modrinth") => { - set({ loading: true, error: null }); + search: async (params = {}) => { + const { + query, + gameVersion, + loader, + category, + projectType = "mod", + page = 1, + pageSize = get().pageSize, + source = "modrinth", + append = false, + } = params; + + // 追加模式不需要整体 loading(保持旧列表可见) + if (!append) set({ loading: true, error: null }); try { - const results = await searchMods(query, gameVersion, loader, 20, 0, source); - set({ searchResults: results, loading: false }); + const offset = (page - 1) * pageSize; + const response = await searchMods(query, gameVersion, loader, category, projectType, pageSize, offset, source); + set((state) => ({ + searchResults: append ? [...state.searchResults, ...response.hits] : response.hits, + total: response.total, + currentPage: page, + pageSize, + hasMore: state.searchResults.length + response.hits.length < response.total, + loading: false, + })); + return response; } catch (e: any) { set({ error: e.message, loading: false }); + return null; } }, @@ -70,12 +121,12 @@ export const useModsStore = create((set) => ({ }, install: async (projectId, versionId, gamePath, source = "modrinth") => { - set({ loading: true, error: null }); + set({ installing: true, error: null }); try { await installMod(projectId, versionId, gamePath, source); - set({ loading: false }); + set({ installing: false }); } catch (e: any) { - set({ error: e.message, loading: false }); + set({ error: e.message, installing: false }); } }, @@ -84,6 +135,9 @@ export const useModsStore = create((set) => ({ clear: () => set({ searchResults: [], + total: 0, + currentPage: 1, + hasMore: false, currentMod: null, modVersions: [], }), diff --git a/src/stores/routeStore.ts b/src/stores/routeStore.ts index 261e293..c475789 100644 --- a/src/stores/routeStore.ts +++ b/src/stores/routeStore.ts @@ -18,6 +18,7 @@ export type RouteKey = | "oobe/welcome" | "oobe/finish" | "oobe/about-info" + | "oobe/legal" | "debug" | "debug-splash" | "debug-display" @@ -59,6 +60,7 @@ export const allRoutes: RouteItem[] = [ { key: "oobe/welcome", label: "欢迎", path: "/oobe/welcome", hidden: true }, { key: "oobe/finish", label: "完成", path: "/oobe/finish", hidden: true }, { key: "oobe/about-info", label: "关于信息", path: "/oobe/about-info", hidden: true, backable: true }, + { key: "oobe/legal", label: "法律信息", path: "/oobe/legal", hidden: true }, { key: "debug", label: "调试", path: "/debug", hidden: true }, { key: "debug-splash", label: "启动动画调试", path: "/debug/splash", hidden: true }, { key: "debug-display", label: "显示效果调试", path: "/debug/display", hidden: true }, @@ -87,8 +89,11 @@ interface RouteState { titleBarMode: TitleBarMode; direction: TransitionDirection; history: RouteKey[]; + /** 资源中心选中的分类(原版游戏 / MOD / 整合包) */ + storeSection: "game" | "mod" | "modpack"; navigate: (key: RouteKey) => void; setTitleBarMode: (mode: TitleBarMode) => void; + setStoreSection: (section: "game" | "mod" | "modpack") => void; goBack: () => void; } @@ -97,6 +102,7 @@ export const useRouteStore = create((set, get) => ({ titleBarMode: "default", direction: "forward", history: [], + storeSection: "game", navigate: (key) => { const prev = get().current; if (prev === key) return; @@ -110,6 +116,7 @@ export const useRouteStore = create((set, get) => ({ }); }, setTitleBarMode: (mode) => set({ titleBarMode: mode }), + setStoreSection: (section) => set({ storeSection: section }), goBack: () => { const { history } = get(); if (history.length === 0) return; diff --git a/tsconfig.electron.json b/tsconfig.electron.json index 7fb3e56..bc1ae12 100644 --- a/tsconfig.electron.json +++ b/tsconfig.electron.json @@ -3,7 +3,7 @@ "target": "ES2020", "module": "commonjs", "moduleResolution": "node", - "outDir": "electron", + "outDir": "electron-dist", "rootDir": "electron", "strict": true, "esModuleInterop": true,