From 27acc66252ddc4a11fafb6189cb96b1edaaf5b74 Mon Sep 17 00:00:00 2001 From: dream_pep Date: Sun, 26 Jul 2026 06:42:41 +0800 Subject: [PATCH] Add Koring auth flow and settings UI refactor Implements Koring OIDC device-auth flow and integrates it into the app: core auth (TS/JS), IPC handlers, renderer API, KoringLogin React component, and a zustand koringAuth store. Handlers support device-code request, polling, refresh, get-user and logout and persist user info to existing auth/config. Also refactors many settings pages to use shared Setting components (SettingCard, SettingRow, SectionTitle, PageHeader) and replaces custom controls with @heroui/react primitives (Avatar, Button, Switch, RadioGroup, Slider, Input, TextArea, EmptyState). Adds OOBE login/welcome pages, a setting login route, installer header generator script, and a sample koring-auth.json for development. --- electron/core/koring-auth.js | 175 ++++++++++++++++ electron/core/koring-auth.ts | 178 ++++++++++++++++ electron/handlers/koring-auth.js | 113 ++++++++++ electron/handlers/koring-auth.ts | 113 ++++++++++ koring-auth.json | 7 + scripts/gen-installer-header.js | 24 +++ src/api/koring-auth.ts | 47 +++++ src/components/KoringLogin.tsx | 194 ++++++++++++++++++ src/components/setting/SectionTitle.tsx | 12 ++ src/components/setting/SettingCard.tsx | 9 + src/components/setting/SettingRow.tsx | 11 + src/components/setting/index.ts | 3 + src/pages/oobe/step-login.tsx | 63 ++++++ src/pages/oobe/step-welcome.tsx | 44 ++++ src/pages/setting/game/advanced.tsx | 115 +++++------ src/pages/setting/game/game-account.tsx | 11 +- src/pages/setting/game/game-dir.tsx | 32 ++- src/pages/setting/game/java-mem.tsx | 122 +++++------ src/pages/setting/general/about.tsx | 76 +++---- src/pages/setting/general/account.tsx | 120 +++++------ src/pages/setting/general/copyright.tsx | 58 ++---- src/pages/setting/general/home.tsx | 41 ++-- src/pages/setting/index.tsx | 20 +- src/pages/setting/login.tsx | 23 +++ src/pages/setting/network/download.tsx | 87 ++++---- src/pages/setting/network/ether-online.tsx | 11 +- src/pages/setting/network/security-id.tsx | 43 ++-- src/pages/setting/network/tawa-online.tsx | 11 +- src/pages/setting/other/feedback.tsx | 11 +- src/pages/setting/other/sponsor.tsx | 11 +- src/pages/setting/personalization/a11y.tsx | 54 +++-- src/pages/setting/personalization/lang.tsx | 11 +- .../setting/personalization/theme-bg.tsx | 106 ++++------ src/pages/setting/personalization/ui.tsx | 11 +- src/stores/koringAuthStore.ts | 46 +++++ 35 files changed, 1504 insertions(+), 509 deletions(-) create mode 100644 electron/core/koring-auth.js create mode 100644 electron/core/koring-auth.ts create mode 100644 electron/handlers/koring-auth.js create mode 100644 electron/handlers/koring-auth.ts create mode 100644 koring-auth.json create mode 100644 scripts/gen-installer-header.js create mode 100644 src/api/koring-auth.ts create mode 100644 src/components/KoringLogin.tsx create mode 100644 src/components/setting/SectionTitle.tsx create mode 100644 src/components/setting/SettingCard.tsx create mode 100644 src/components/setting/SettingRow.tsx create mode 100644 src/components/setting/index.ts create mode 100644 src/pages/oobe/step-login.tsx create mode 100644 src/pages/oobe/step-welcome.tsx create mode 100644 src/pages/setting/login.tsx create mode 100644 src/stores/koringAuthStore.ts diff --git a/electron/core/koring-auth.js b/electron/core/koring-auth.js new file mode 100644 index 0000000..fb6640b --- /dev/null +++ b/electron/core/koring-auth.js @@ -0,0 +1,175 @@ +"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/koring-auth.ts b/electron/core/koring-auth.ts new file mode 100644 index 0000000..a9cbaf0 --- /dev/null +++ b/electron/core/koring-auth.ts @@ -0,0 +1,178 @@ +import * as https from 'https'; +import { readAuth, writeAuth } from '../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'; + +export interface DeviceAuthResponse { + device_code: string; + user_code: string; + verification_uri: string; + verification_uri_complete: string; + expires_in: number; +} + +export interface TokenResponse { + access_token: string; + id_token: string; + refresh_token: string; + token_type: string; + expires_in: number; + scope: string; +} + +export interface KoringUser { + sub: string; + name: string; + username: string; + email: string; + picture: string; +} + +function postForm(url: string, data: Record): Promise { + 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: string): Record { + try { + const payload = token.split('.')[1]; + const decoded = Buffer.from(payload, 'base64url').toString('utf-8'); + return JSON.parse(decoded); + } catch { + return {}; + } +} + +export async function requestDeviceCode(): Promise { + 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 as DeviceAuthResponse; +} + +export async function pollForTokenOnce( + device_code: string +): Promise { + 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 as TokenResponse; + } + + 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'); +} + +export async function refreshAccessToken(refresh_token: string): Promise { + 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 as TokenResponse; +} + +export function decodeIdToken(id_token: string): KoringUser { + const payload = parseJwt(id_token); + return { + sub: (payload.sub as string) || '', + name: (payload.name as string) || '', + username: (payload.username as string) || (payload.name as string) || '', + email: (payload.email as string) || '', + picture: (payload.picture as string) || '', + }; +} + +export interface StoredKoringAuth { + user: KoringUser; + access_token: string; + refresh_token: string; + id_token: string; + expires_at: number; +} + +export function saveKoringAuth(tokenRes: TokenResponse): KoringUser { + const user = decodeIdToken(tokenRes.id_token); + const auth: StoredKoringAuth = { + 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 + writeAuth({ + username: user.username, + uuid: user.sub, + accessToken: auth.access_token, + refreshToken: auth.refresh_token, + xboxProfile: JSON.stringify(user), + }); + return user; +} + +export function readKoringAuth(): StoredKoringAuth | null { + const auth = readAuth(); + if (!auth.username || !auth.refreshToken) return null; + let user: KoringUser = { sub: '', name: '', username: '', email: '', picture: '' }; + try { + user = JSON.parse(auth.xboxProfile || '{}') as KoringUser; + } catch {} + return { + user, + access_token: auth.accessToken, + refresh_token: auth.refreshToken, + id_token: '', + expires_at: 0, + }; +} + +export function deleteKoringAuth(): void { + writeAuth({ username: '', uuid: '', accessToken: '', refreshToken: '', xboxProfile: '' }); +} diff --git a/electron/handlers/koring-auth.js b/electron/handlers/koring-auth.js new file mode 100644 index 0000000..63d81a5 --- /dev/null +++ b/electron/handlers/koring-auth.js @@ -0,0 +1,113 @@ +"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/koring-auth.ts b/electron/handlers/koring-auth.ts new file mode 100644 index 0000000..ccda871 --- /dev/null +++ b/electron/handlers/koring-auth.ts @@ -0,0 +1,113 @@ +import electron from 'electron'; +const { ipcMain } = electron; +import { + requestDeviceCode, + pollForTokenOnce, + refreshAccessToken, + saveKoringAuth, + readKoringAuth, + deleteKoringAuth, +} from '../core/koring-auth'; +import { loadConfig, saveConfig } from '../config'; + +export function registerKoringAuthHandlers() { + ipcMain.handle('koring-auth:request-device-code', async () => { + try { + const result = await requestDeviceCode(); + return { success: true, data: result, error: null }; + } catch (e: unknown) { + return { success: false, data: null, error: String(e) }; + } + }); + + ipcMain.handle('koring-auth:poll-token', async (_event, deviceCode: string) => { + try { + const result = await pollForTokenOnce(deviceCode); + const user = saveKoringAuth(result); + + // 同时写入配置文件 + try { + const config = loadConfig(); + (config as any).koringUser = { + sub: user.sub, + name: user.name, + username: user.username, + email: user.email, + picture: user.picture, + accessToken: result.access_token, + refreshToken: result.refresh_token, + }; + saveConfig(config); + } catch (e) { + console.error('[koring-auth] failed to save user to config:', e); + } + + return { success: true, data: { user }, error: null }; + } catch (e: unknown) { + 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 = readKoringAuth(); + if (!stored?.refresh_token) throw new Error('No refresh token'); + const result = await refreshAccessToken(stored.refresh_token); + const user = saveKoringAuth(result); + + // 同步到配置文件 + try { + const config = loadConfig(); + (config as any).koringUser = { + sub: user.sub, + name: user.name, + username: user.username, + email: user.email, + picture: user.picture, + accessToken: result.access_token, + refreshToken: result.refresh_token, + }; + saveConfig(config); + } catch {} + + return { success: true, data: { user }, error: null }; + } catch (e: unknown) { + return { success: false, data: null, error: String(e) }; + } + }); + + ipcMain.handle('koring-auth:get-user', () => { + try { + const stored = readKoringAuth(); + // 也从配置文件读取 + if (!stored?.user?.sub) { + try { + const config = loadConfig(); + const ku = (config as any).koringUser; + if (ku?.sub) { + return { success: true, data: { user: ku, access_token: '', refresh_token: '', id_token: '', expires_at: 0 }, error: null }; + } + } catch {} + } + return { success: true, data: stored, error: null }; + } catch (e: unknown) { + return { success: false, data: null, error: String(e) }; + } + }); + + ipcMain.handle('koring-auth:logout', () => { + try { + deleteKoringAuth(); + // 清除配置文件中的用户数据 + try { + const config = loadConfig(); + delete (config as any).koringUser; + saveConfig(config); + } catch {} + return { success: true, data: null, error: null }; + } catch (e: unknown) { + return { success: false, data: null, error: String(e) }; + } + }); +} diff --git a/koring-auth.json b/koring-auth.json new file mode 100644 index 0000000..a4edadf --- /dev/null +++ b/koring-auth.json @@ -0,0 +1,7 @@ +{ + "username": "dream_pep", + "uuid": "rqkzikchwvjt", + "accessToken": "Nsk5O07OxX_oDQRFHWdIh6oDNHB8WxleTsAi3yAUtCL", + "refreshToken": "rFvsd9kmvyiBYNLID8A5xmeWTqF-Efselc21ug3cGiy", + "xboxProfile": "{\"sub\":\"rqkzikchwvjt\",\"name\":\"周逸\",\"username\":\"dream_pep\",\"email\":\"\",\"picture\":\"\"}" +} \ No newline at end of file diff --git a/scripts/gen-installer-header.js b/scripts/gen-installer-header.js new file mode 100644 index 0000000..f889d98 --- /dev/null +++ b/scripts/gen-installer-header.js @@ -0,0 +1,24 @@ +// Run: node scripts/gen-installer-header.js +const { createCanvas } = require('canvas'); +const fs = require('fs'); +const path = require('path'); + +const c = createCanvas(493, 58); +const ctx = c.getContext('2d'); + +const g = ctx.createLinearGradient(0, 0, 493, 0); +g.addColorStop(0, '#1a1a2e'); +g.addColorStop(1, '#16213e'); +ctx.fillStyle = g; +ctx.fillRect(0, 0, 493, 58); + +ctx.fillStyle = '#ffffff'; +ctx.font = 'bold 24px sans-serif'; +ctx.textAlign = 'center'; +ctx.textBaseline = 'middle'; +ctx.fillText('Koring Launcher', 246, 29); + +const buf = c.toBuffer('image/png'); +const out = path.resolve(__dirname, '..', 'build', 'installer-header.png'); +fs.writeFileSync(out, buf); +console.log('Created:', out); diff --git a/src/api/koring-auth.ts b/src/api/koring-auth.ts new file mode 100644 index 0000000..a2d9ba2 --- /dev/null +++ b/src/api/koring-auth.ts @@ -0,0 +1,47 @@ +import { ipcInvoke } from './ipc'; + +export interface DeviceAuthResponse { + device_code: string; + user_code: string; + verification_uri: string; + verification_uri_complete: string; + expires_in: number; +} + +export interface KoringUser { + sub: string; + name: string; + username: string; + email: string; + picture: string; +} + +export interface KoringAuthData { + user: KoringUser; + access_token: string; + refresh_token: string; + id_token: string; + expires_at: number; +} + +export async function requestDeviceCode(): Promise { + return ipcInvoke('koring-auth:request-device-code'); +} + +export async function pollForToken( + device_code: string +): Promise<{ user: KoringUser }> { + return ipcInvoke<{ user: KoringUser }>('koring-auth:poll-token', device_code); +} + +export async function refreshKoringToken(): Promise<{ user: KoringUser }> { + return ipcInvoke<{ user: KoringUser }>('koring-auth:refresh'); +} + +export async function getKoringUser(): Promise { + return ipcInvoke('koring-auth:get-user'); +} + +export async function logoutKoring(): Promise { + return ipcInvoke('koring-auth:logout'); +} diff --git a/src/components/KoringLogin.tsx b/src/components/KoringLogin.tsx new file mode 100644 index 0000000..86e27b5 --- /dev/null +++ b/src/components/KoringLogin.tsx @@ -0,0 +1,194 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { QRCodeSVG } from "qrcode.react"; +import { + requestDeviceCode, + pollForToken, + type KoringUser, +} from "@/api/koring-auth"; +import { useKoringAuthStore } from "@/stores/koringAuthStore"; +import { Loader2, CheckCircle2, Copy, ExternalLink } from "lucide-react"; + +type Step = "loading" | "scan" | "polling" | "success" | "error"; + +interface KoringLoginProps { + onLoginSuccess?: (user: KoringUser) => void; +} + +export function KoringLogin({ onLoginSuccess }: KoringLoginProps) { + const [step, setStep] = useState("loading"); + const [userCode, setUserCode] = useState(""); + const [verifyUri, setVerifyUri] = useState(""); + const [verifyUriComplete, setVerifyUriComplete] = useState(""); + const [error, setError] = useState(""); + const [copied, setCopied] = useState(false); + const pollRef = useRef | null>(null); + const setUser = useKoringAuthStore((s) => s.setUser); + + const cleanup = useCallback(() => { + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + }, []); + + useEffect(() => cleanup, [cleanup]); + + // Auto-start on mount + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await requestDeviceCode(); + if (cancelled) return; + setUserCode(res.user_code); + setVerifyUri(res.verification_uri); + setVerifyUriComplete(res.verification_uri_complete); + setStep("scan"); + startPolling(res.device_code, res.expires_in, cancelled); + } catch (e: any) { + if (!cancelled) { + setError(e.message || "请求设备码失败"); + setStep("error"); + } + } + })(); + return () => { cancelled = true; cleanup(); }; + }, []); + + const startPolling = (deviceCode: string, expires: number, cancelled: boolean) => { + setStep("polling"); + const startTime = Date.now(); + pollRef.current = setInterval(async () => { + if (cancelled) { cleanup(); return; } + if (Date.now() - startTime > expires * 1000) { + cleanup(); + setError("二维码已过期,请重新获取"); + setStep("error"); + return; + } + try { + const result = await pollForToken(deviceCode); + if (cancelled) return; + cleanup(); + setUser(result.user); + setStep("success"); + onLoginSuccess?.(result.user); + } catch (e: any) { + if (e.message !== "authorization_pending" && e.message !== "slow_down") { + if (!cancelled) { + cleanup(); + setError(e.message || "验证失败"); + setStep("error"); + } + } + } + }, 5000); + }; + + const retry = async () => { + setStep("loading"); + setError(""); + try { + const res = await requestDeviceCode(); + setUserCode(res.user_code); + setVerifyUri(res.verification_uri); + setVerifyUriComplete(res.verification_uri_complete); + setStep("scan"); + startPolling(res.device_code, res.expires_in, false); + } catch (e: any) { + setError(e.message || "请求设备码失败"); + setStep("error"); + } + }; + + const copyCode = () => { + navigator.clipboard.writeText(userCode); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const openVerify = () => { + window.electronAPI?.openExternal(verifyUri); + }; + + if (step === "loading") { + return ( +
+ + 正在获取设备码... +
+ ); + } + + if (step === "error") { + return ( +
+

{error}

+ +
+ ); + } + + return ( +
+ {/* QR Code */} +
+ +
+ + {/* 提示文字 */} +

+ 请使用任意二维码扫描器打开 +
+ 或访问以下链接并输入验证码 +

+ + {/* 验证链接 */} + + + {/* 用户码 */} +
+ + {userCode} + + +
+ + {/* 轮询状态 */} + {step === "polling" && ( +
+ + 等待验证中... +
+ )} + + {step === "success" && ( +
+ + 登录成功 +
+ )} +
+ ); +} diff --git a/src/components/setting/SectionTitle.tsx b/src/components/setting/SectionTitle.tsx new file mode 100644 index 0000000..b08e41c --- /dev/null +++ b/src/components/setting/SectionTitle.tsx @@ -0,0 +1,12 @@ +export function PageHeader({ title, desc }: { title: string; desc: string }) { + return ( + <> +

{title}

+

{desc}

+ + ); +} + +export function SectionTitle({ children }: { children: React.ReactNode }) { + return

{children}

; +} diff --git a/src/components/setting/SettingCard.tsx b/src/components/setting/SettingCard.tsx new file mode 100644 index 0000000..b0cdd9b --- /dev/null +++ b/src/components/setting/SettingCard.tsx @@ -0,0 +1,9 @@ +import { Card } from "@heroui/react"; + +export function SettingCard({ children, className }: { children: React.ReactNode; className?: string }) { + return ( + + {children} + + ); +} diff --git a/src/components/setting/SettingRow.tsx b/src/components/setting/SettingRow.tsx new file mode 100644 index 0000000..25a43de --- /dev/null +++ b/src/components/setting/SettingRow.tsx @@ -0,0 +1,11 @@ +export function SettingRow({ label, desc, children }: { label: string; desc?: string; children: React.ReactNode }) { + return ( +
+
+

{label}

+ {desc &&

{desc}

} +
+
{children}
+
+ ); +} diff --git a/src/components/setting/index.ts b/src/components/setting/index.ts new file mode 100644 index 0000000..1cb71a0 --- /dev/null +++ b/src/components/setting/index.ts @@ -0,0 +1,3 @@ +export { SettingCard } from "./SettingCard"; +export { SettingRow } from "./SettingRow"; +export { PageHeader, SectionTitle } from "./SectionTitle"; diff --git a/src/pages/oobe/step-login.tsx b/src/pages/oobe/step-login.tsx new file mode 100644 index 0000000..5514ea6 --- /dev/null +++ b/src/pages/oobe/step-login.tsx @@ -0,0 +1,63 @@ +import { useState, useEffect, useCallback } from "react"; +import { useRouteStore } from "@/stores/routeStore"; +import { useKoringAuthStore } from "@/stores/koringAuthStore"; +import { OobeLayout } from "./layout"; +import { NextButton } from "./next-button"; +import { KoringLogin } from "@/components/KoringLogin"; + +export function OobeLogin() { + const navigate = useRouteStore((s) => s.navigate); + const user = useKoringAuthStore((s) => s.user); + const [showSkip, setShowSkip] = useState(false); + const [justLoggedIn, setJustLoggedIn] = useState(false); + + // 10秒后显示"我暂时不需要" + useEffect(() => { + const t = setTimeout(() => setShowSkip(true), 10000); + return () => clearTimeout(t); + }, []); + + const handleSuccess = useCallback(() => { + setJustLoggedIn(true); + setTimeout(() => navigate("oobe/agreement"), 1500); + }, [navigate]); + + return ( + +
+ {/* 标题 */} +
+

登录 Koring 账户

+

+ 扫码登录以同步数据、皮肤与个人配置 +

+
+ + {/* 登录组件 — 自动显示 QR */} + + + {/* 已登录提示 */} + {justLoggedIn && ( +

+ 登录成功,正在跳转... +

+ )} +
+ + {/* 下一步按钮 — 需要登录才能点击 */} + navigate("oobe/agreement")} disabled={!user && !justLoggedIn} /> + + {/* 跳过按钮 — 10秒后显示 */} + {showSkip && !user && !justLoggedIn && ( +
+ +
+ )} +
+ ); +} diff --git a/src/pages/oobe/step-welcome.tsx b/src/pages/oobe/step-welcome.tsx new file mode 100644 index 0000000..52fb76b --- /dev/null +++ b/src/pages/oobe/step-welcome.tsx @@ -0,0 +1,44 @@ +import { useState, useEffect } from "react"; +import { useRouteStore } from "@/stores/routeStore"; +import { useKoringAuthStore } from "@/stores/koringAuthStore"; +import { OobeLayout } from "./layout"; +import { NextButton } from "./next-button"; + +export function OobeWelcome() { + const navigate = useRouteStore((s) => s.navigate); + const user = useKoringAuthStore((s) => s.user); + const [showBtn, setShowBtn] = useState(false); + const [animClass, setAnimClass] = useState("scale-90 opacity-0"); + + useEffect(() => { + requestAnimationFrame(() => { + setAnimClass("scale-100 opacity-100"); + }); + }, []); + + useEffect(() => { + const t = setTimeout(() => setShowBtn(true), 1500); + return () => clearTimeout(t); + }, []); + + const displayName = user?.name || user?.username || "用户"; + + return ( + +
+

+ 欢迎回来,{displayName}。 +

+ {user?.sub && ( +

+ UUID: {user.sub} +

+ )} +
+ + {showBtn && navigate("oobe/version")} />} +
+ ); +} diff --git a/src/pages/setting/game/advanced.tsx b/src/pages/setting/game/advanced.tsx index 68a106e..fcda26a 100644 --- a/src/pages/setting/game/advanced.tsx +++ b/src/pages/setting/game/advanced.tsx @@ -1,21 +1,6 @@ import { useConfigStore } from "@/stores/configStore"; -import { Switch } from "@/components/ui/switch"; - -function GlassCard({ children }: { children: React.ReactNode }) { - return
{children}
; -} - -function SettingRow({ label, desc, children }: { label: string; desc?: string; children: React.ReactNode }) { - return ( -
-
-

{label}

- {desc &&

{desc}

} -
-
{children}
-
- ); -} +import { Switch, RadioGroup, Radio, Input, TextArea } from "@heroui/react"; +import { SettingCard, SettingRow, PageHeader, SectionTitle } from "@/components/setting"; const launcherBehavior = [ { value: "close", label: "关闭启动器" }, @@ -35,125 +20,127 @@ export function AdvancedSetting() { return (
-

高级设置

-

游戏高级启动参数、调试选项与实验性功能

+
- {/* ===== 启动行为 ===== */}
-

启动行为

+ 启动行为
- +

启动后启动器行为

游戏启动后启动器的处理方式

-
+ setAdvanced({ afterLaunch: v })} + className="mt-2 space-y-2" + > {launcherBehavior.map((opt) => ( - + + {opt.label} + ))} -
+
-
+
- {/* ===== 窗口设置 ===== */}
-

窗口设置

+ 窗口设置
- +

窗口大小

-
+ setAdvanced({ winMode: v })} + className="space-y-2" + > {windowSize.map((opt) => ( - + + {opt.label} + ))} -
+
{adv.winMode === "custom" && (
- setAdvanced({ customWidth: Number(e.target.value) })} - className="w-20 h-8 px-2 rounded-md border border-input bg-background text-sm text-center font-mono focus:outline-none focus:ring-1 focus:ring-ring" + className="w-20" />
×
- setAdvanced({ customHeight: Number(e.target.value) })} - className="w-20 h-8 px-2 rounded-md border border-input bg-background text-sm text-center font-mono focus:outline-none focus:ring-1 focus:ring-ring" + className="w-20" />
)}
-
+
- {/* ===== 游戏参数 ===== */}
-

游戏参数

+ 游戏参数
- +

额外游戏启动参数

附加到游戏启动命令末尾的参数

- setAdvanced({ gameArgs: e.target.value })} placeholder="可选,例如 --demo" - className="w-full h-8 px-3 rounded-md border border-input bg-background text-sm font-mono placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" + fullWidth />
-
+
- {/* ===== 启动命令 ===== */}
-

启动命令

+ 启动命令
- +

启动前执行命令

游戏启动前自动执行的命令或程序路径

- setAdvanced({ preLaunchCmd: e.target.value })} placeholder="可选,例如 D:\scripts\pre-launch.bat" - className="w-full h-8 px-3 rounded-md border border-input bg-background text-sm font-mono placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" + fullWidth />
-
+
- {/* ===== 调试 ===== */}
-

调试

+ 调试
- + - setAdvanced({ debugMode: v })} /> + setAdvanced({ debugMode: v })}> + + + + - +
diff --git a/src/pages/setting/game/game-account.tsx b/src/pages/setting/game/game-account.tsx index 8d4bac4..e9d7c3b 100644 --- a/src/pages/setting/game/game-account.tsx +++ b/src/pages/setting/game/game-account.tsx @@ -1,8 +1,15 @@ +import { EmptyState } from "@heroui/react"; +import { Gamepad2 } from "lucide-react"; +import { PageHeader } from "@/components/setting"; + export function GameAccountSetting() { return (
-

游戏账户&档案

-

管理 Minecraft 游戏内账户、正版验证与游戏档案配置

+ + + +

该功能正在开发中

+
); } diff --git a/src/pages/setting/game/game-dir.tsx b/src/pages/setting/game/game-dir.tsx index 53f580c..895fc26 100644 --- a/src/pages/setting/game/game-dir.tsx +++ b/src/pages/setting/game/game-dir.tsx @@ -1,30 +1,26 @@ import { useConfigStore } from "@/stores/configStore"; -import { Button } from "@/components/ui/button"; +import { Button, Input } from "@heroui/react"; import { RefreshCw } from "lucide-react"; - -function GlassCard({ children }: { children: React.ReactNode }) { - return
{children}
; -} +import { SettingCard, PageHeader, SectionTitle } from "@/components/setting"; function PathInput({ label, desc, value, onChange }: { label: string; desc: string; value: string; onChange: (v: string) => void }) { return ( - +

{label}

{desc}

- onChange(e.target.value)} - className="flex-1 h-8 px-3 rounded-md border border-input bg-background text-sm font-mono placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" + fullWidth />
-
+ ); } @@ -34,13 +30,11 @@ export function GameDirSetting() { return (
-

游戏目录

-

设置 Minecraft 游戏安装路径、存档位置与资源包目录

+
- {/* ===== 游戏路径 ===== */}
-

游戏路径

+ 游戏路径
setGame({ gameDir: v })} /> - +

已安装版本

@@ -59,13 +53,12 @@ export function GameDirSetting() { 扫描
- +
- {/* ===== 资源包 ===== */}
-

资源包目录

+ 资源包目录
- {/* ===== 存档 ===== */}
-

存档目录

+ 存档目录
{children}
; -} - -function SettingRow({ label, desc, children }: { label: string; desc?: string; children: React.ReactNode }) { - return ( -
-
-

{label}

- {desc &&

{desc}

} -
-
{children}
-
- ); -} +import { SettingCard, SettingRow, PageHeader, SectionTitle } from "@/components/setting"; interface JavaInfo { path: string; @@ -42,25 +26,23 @@ export function JavaMemSetting() { return (
-

Java 虚拟机与内存

-

配置 Java 运行环境路径、JVM 参数与游戏内存分配

+
- {/* ===== Java 环境 ===== */}
-

Java 环境

+ Java 环境
- + - + {mockJavaList.map((j) => ( - +
@@ -71,47 +53,47 @@ export function JavaMemSetting() { {j.path}

-
-
+ ))} - +

手动指定路径

- setJava({ javaPath: e.target.value })} placeholder="输入 javaw.exe 完整路径" - className="flex-1 h-8 px-3 rounded-md border border-input bg-background text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" + fullWidth />
-
+
- {/* ===== 内存分配 ===== */}
-

内存分配

+ 内存分配
- +
-
- - -
+ setJava({ memMode: v })} + className="flex items-center gap-4" + > + + 自动配置 + + + 自定义 + + {java.memMode === "custom" && (
@@ -119,12 +101,17 @@ export function JavaMemSetting() { {java.memGB} GB
setJava({ memGB: Array.isArray(v) ? v[0] : v })} - min={1} - max={16} + value={java.memGB} + onChange={(v) => setJava({ memGB: typeof v === "number" ? v : v[0] })} + minValue={1} + maxValue={16} step={1} - /> + > + + + + +
1 GB 16 GB @@ -132,47 +119,48 @@ export function JavaMemSetting() {
)}
- +
- {/* ===== JVM 参数 ===== */}
-

JVM 参数

+ JVM 参数
- +

额外 JVM 启动参数

每行一个参数,例如 -XX:+UseZGC

-