merge: preserve ShaCraft 0.1.1 account and Windows features in modular launcher

This commit is contained in:
emil28092005
2026-09-09 13:10:17 +03:00
54 changed files with 2130 additions and 551 deletions
+30 -27
View File
@@ -1,6 +1,6 @@
import { useCallback, useState } from 'react'
import { Library } from './components/Library'
import { LoginModal } from './components/LoginModal'
import { RecoveryCodesModal } from './components/RecoveryCodesModal'
import { PlayDock } from './components/PlayDock'
import { ServerStage } from './components/ServerStage'
import { SettingsDrawer } from './components/SettingsDrawer'
@@ -8,68 +8,71 @@ import { Titlebar } from './components/Titlebar'
import { servers } from './data/servers'
import { useAccount } from './hooks/useAccount'
import { useLauncher } from './hooks/useLauncher'
import { useServerStatus } from './hooks/useServerStatus'
import { useSettings } from './hooks/useSettings'
import { isNative } from './services/native'
import { launchAccess } from './state/account'
import { installStageLabels } from './state/game'
export function App() {
const [selected, setSelected] = useState(servers[0])
const [settingsOpen, setSettingsOpen] = useState(false)
const [windowError, setWindowError] = useState<string | null>(null)
const preferences = useSettings()
const session = useAccount()
const launcher = useLauncher()
const serverStatus = useServerStatus(selected.profileId)
const closeSettings = useCallback(() => setSettingsOpen(false), [])
const desktop = isNative()
const profile = selected.profileId ? launcher.profiles[selected.profileId] : undefined
const profile = launcher.profiles[selected.profileId]
const ready = profile?.inspection?.upToDate === true
const operation = launcher.game.operation
const busy = operation.phase !== 'idle'
const microsoft = preferences.settings.accountMode === 'microsoft'
const needsLogin = microsoft && session.account === null
const accountLoading = microsoft && session.account === undefined
const checking = desktop && (!profile || profile.status === 'checking') && !selected.disabled
const settingsBlocked = !preferences.loaded || preferences.saving || !!preferences.error || (!microsoft && !!preferences.nicknameError)
const disabled = !desktop || !!selected.disabled || !selected.profileId || busy || checking || settingsBlocked || accountLoading || session.busy || !launcher.eventsReady || (needsLogin && !session.eventsReady)
const repairDisabled = !desktop || !!selected.disabled || !selected.profileId || busy || checking
const error = launcher.game.error ?? preferences.error ?? (!microsoft ? preferences.nicknameError : null) ?? launcher.environmentError ?? (microsoft ? session.error : null) ?? profile?.error ?? null
const access = launchAccess(session.account)
const checking = desktop && (!profile || profile.status === 'checking')
const settingsBlocked = !preferences.loaded || preferences.saving || !!preferences.error
const disabled = !desktop || busy || session.busy || access === 'loading' ||
(access === 'ready' && (checking || settingsBlocked || !launcher.eventsReady))
const repairDisabled = !desktop || busy || checking
const error = launcher.game.error ?? preferences.error ?? windowError ?? launcher.environmentError ?? session.error ?? profile?.error ?? null
let label = 'Играть'
if (selected.disabled) label = 'Недоступно'
else if (!desktop) label = 'В приложении'
if (!desktop) label = 'В приложении'
else if (operation.phase === 'running') label = 'Игра запущена'
else if (operation.phase === 'launching') label = 'Запускаем…'
else if (operation.phase === 'installing') label = operation.progress ? `${installStageLabels[operation.progress.stage]}…` : 'Подготовка…'
else if (operation.phase === 'syncing') label = 'Обновление'
else if (access === 'loading') label = 'Загрузка…'
else if (access === 'login') label = 'Войти в ShaCraft'
else if (access === 'link') label = 'Привязать ник'
else if (preferences.saving) label = 'Сохраняем…'
else if (accountLoading || !preferences.loaded) label = 'Загрузка…'
else if (needsLogin) label = session.busy ? 'Ждём вход…' : 'Войти через Microsoft'
else if (!preferences.loaded) label = 'Загрузка…'
else if (checking) label = 'Проверяем…'
else if (!ready) label = 'Проверить'
const primary = () => {
if (disabled || !selected.profileId) return
if (needsLogin) void session.login()
else if (!ready) void launcher.repair(selected.profileId)
if (disabled) return
if (access !== 'ready') setSettingsOpen(true)
else void launcher.launch(selected.profileId)
}
return (
<div className="app-shell">
<Titlebar host={launcher.host} />
<div className="workspace">
<Library selected={selected} profiles={launcher.profiles} settings={preferences.settings}
account={session.account} native={desktop} locked={busy || session.busy}
onSelect={setSelected} onSettings={() => setSettingsOpen(true)} onLogout={session.logout} />
<ServerStage server={selected}>
<Titlebar host={launcher.host} onError={setWindowError} />
<div className="workspace" inert={session.recoveryCodes.length > 0}>
<Library selected={selected} profiles={launcher.profiles} account={session.account}
native={desktop} locked={busy || session.busy} onSelect={setSelected} onSettings={() => setSettingsOpen(true)} />
<ServerStage server={selected} status={serverStatus}>
<PlayDock server={selected} operation={operation} profile={profile}
memoryGb={preferences.settings.memoryMb / 1024} native={desktop} needsLogin={needsLogin}
memoryGb={preferences.settings.memoryMb / 1024} native={desktop}
needsLogin={access === 'login'} needsLink={access === 'link'}
error={error} label={label} primaryDisabled={disabled} repairDisabled={repairDisabled}
onPrimary={primary} onRepair={() => { if (!repairDisabled && selected.profileId) void launcher.repair(selected.profileId) }} />
onPrimary={primary} onRepair={() => { if (!repairDisabled) void launcher.repair(selected.profileId) }} />
</ServerStage>
</div>
<SettingsDrawer open={settingsOpen && !session.code} locked={busy || session.busy} preferences={preferences}
<SettingsDrawer open={settingsOpen && !session.recoveryCodes.length} locked={busy} preferences={preferences}
session={session} host={launcher.host} java={launcher.java} onClose={closeSettings} />
<LoginModal code={session.code} />
<RecoveryCodesModal codes={session.recoveryCodes} onAcknowledge={session.acknowledgeRecoveryCodes} />
</div>
)
}
+62
View File
@@ -0,0 +1,62 @@
import { useState } from 'react'
import { LogOut, Users } from 'lucide-react'
import type { useAccount } from '../hooks/useAccount'
import { isNative } from '../services/native'
export function AccountSettings({ session, locked }: { session: ReturnType<typeof useAccount>; locked: boolean }) {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [registering, setRegistering] = useState(false)
const [nickname, setNickname] = useState('')
const { account, linkedNickname, busy } = session
const disabled = locked || busy || account === undefined
return (
<>
<div className="setting-row static"><span><Users />Аккаунт</span><small>{account === undefined ? 'Проверяем…' : account?.username ?? 'Не авторизован'}</small></div>
{!account && (
<form onSubmit={async (event) => {
event.preventDefault()
if (disabled) return
if (await session.authenticate(username, password, registering)) setPassword('')
}}>
<label className="text-setting">
<span><strong>Логин ShaCraft</strong><small>3–32 символа</small></span>
<input value={username} minLength={3} maxLength={32} pattern="[A-Za-z0-9_]{3,32}" required autoComplete="username"
disabled={disabled} onChange={(event) => setUsername(event.target.value)} placeholder="Логин" />
</label>
<label className="text-setting">
<span><strong>Пароль</strong><small>Минимум 3 символа</small></span>
<input type="password" value={password} minLength={3} maxLength={128} required
autoComplete={registering ? 'new-password' : 'current-password'} disabled={disabled}
onChange={(event) => setPassword(event.target.value)} placeholder="Пароль" />
</label>
<button className="setting-row" type="submit" disabled={disabled || !isNative()}>
<span>{busy ? 'Подождите…' : registering ? 'Создать аккаунт' : 'Войти'}</span>
</button>
<button className="setting-row" type="button" disabled={disabled} onClick={() => { setRegistering(!registering); session.clearError() }}>
<span>{registering ? 'Уже есть аккаунт' : 'Нет аккаунта — регистрация'}</span>
</button>
{!isNative() && <p className="account-hint">Вход и регистрация доступны в приложении лаунчера.</p>}
</form>
)}
{account && !linkedNickname && (
<form onSubmit={(event) => { event.preventDefault(); if (!disabled) void session.startLink(nickname) }}>
<label className="text-setting">
<span><strong>Игровой ник</strong><small>Aeronautics</small></span>
<input value={nickname} minLength={3} maxLength={16} pattern="[A-Za-z0-9_]{3,16}" required
disabled={disabled || session.linking} onChange={(event) => setNickname(event.target.value)} placeholder="Player" />
<small>Подтвердите владение ником на сервере Aeronautics.</small>
</label>
<button className="setting-row" type="submit" disabled={disabled || session.linking}>
<span>{session.linking ? 'Ожидаем подтверждения…' : 'Привязать ник'}</span>
</button>
</form>
)}
{account && linkedNickname && <div className="setting-row static"><span>Игровой ник</span><small>{linkedNickname}</small></div>}
{session.linkMessage && <p className="account-hint" role="status">{session.linkMessage}</p>}
{session.error && <p className="status-error account-hint" role="alert">{session.error}</p>}
{account && <button className="setting-row" disabled={disabled} onClick={session.logout}><span><LogOut />Выйти из ShaCraft</span></button>}
</>
)
}
+19 -29
View File
@@ -1,60 +1,50 @@
import { ChevronRight, Library as LibraryIcon, LogOut, MessageCircle, Newspaper, Settings } from 'lucide-react'
import { ChevronRight, Settings } from 'lucide-react'
import { servers } from '../data/servers'
import { linkedNickname } from '../state/account'
import type { ProfileState } from '../state/profiles'
import type { LauncherSettings, MinecraftProfile, Server } from '../types/launcher'
import type { ShaCraftAccount, Server } from '../types/launcher'
interface LibraryProps {
selected: Server
profiles: Record<string, ProfileState>
settings: LauncherSettings
account: MinecraftProfile | null | undefined
account: ShaCraftAccount | null | undefined
locked: boolean
native: boolean
onSelect: (server: Server) => void
onSettings: () => void
onLogout: () => void
}
export function Library(props: LibraryProps) {
const { selected, profiles, settings, account, locked, onSelect, onSettings, onLogout } = props
const name = settings.accountMode === 'offline' ? settings.nickname
: account === undefined ? 'Проверяем…' : account?.name ?? 'Не авторизован'
export function Library({ selected, profiles, account, locked, native, onSelect, onSettings }: LibraryProps) {
const nickname = linkedNickname(account)
const name = account === undefined ? 'Проверяем…' : account === null ? 'Не авторизован' : nickname ?? account.username
return (
<>
<nav className="rail" aria-label="Основное меню">
<div className="rail-main">
<button className="rail-button active" aria-label="Сборки" aria-current="page"><LibraryIcon /></button>
<button className="rail-button" aria-label="Новости" disabled title="Скоро"><Newspaper /></button>
<button className="rail-button" aria-label="Сообщество" disabled title="Скоро"><MessageCircle /></button>
</div>
<button className="rail-button" aria-label="Настройки" onClick={onSettings}><Settings /></button>
<nav className="rail" aria-label="Настройки лаунчера">
<button className="rail-button active" aria-label="Настройки" onClick={onSettings}><Settings /></button>
</nav>
<aside className="library-panel">
<div className="library-heading"><p>Сборки</p><span>1 доступна</span></div>
<div className="library-heading"><p>Сборки</p><span>{servers.length} доступна</span></div>
<div className="server-list">
{servers.map((server) => {
const profile = server.profileId ? profiles[server.profileId] : undefined
const status = server.disabled ? 'На паузе' : !props.native ? 'Доступна'
: !profile || profile.status === 'checking' ? 'Проверяем…'
: profile.inspection?.upToDate ? 'Установлена' : 'Требуется проверка'
const profile = profiles[server.profileId]
const status = !native ? 'Доступна' : !profile || profile.status === 'checking'
? 'Проверяем…' : profile.inspection?.upToDate ? 'Файлы проверены' : 'Требуется проверка'
return (
<button key={server.id} className={`server-row ${selected.id === server.id ? 'selected' : ''}`}
aria-pressed={selected.id === server.id} disabled={locked} onClick={() => onSelect(server)}>
<span className={`server-glyph ${server.id}`} aria-hidden="true">{server.id === 'aoc' ? 'A' : 'C'}</span>
<span className={`server-glyph ${server.id}`} aria-hidden="true">A</span>
<span className="server-copy"><strong>{server.name}</strong><small>{status}</small></span>
<ChevronRight size={16} />
</button>
)
})}
</div>
<div className="account-chip">
<span className="avatar">{settings.accountMode === 'offline' || account ? name.slice(0, 2).toUpperCase() : '?'}</span>
<span><strong>{name}</strong><small>{settings.accountMode === 'offline' ? 'Offline-аккаунт' : account ? 'Microsoft-аккаунт' : 'Войдите, чтобы играть'}</small></span>
{settings.accountMode === 'microsoft' && account ? (
<button className="account-logout" aria-label="Выйти из аккаунта" disabled={locked} onClick={onLogout}><LogOut size={16} /></button>
) : <ChevronRight size={16} />}
</div>
<button className="account-chip" onClick={onSettings} aria-label="Аккаунт ShaCraft">
<span className="avatar">{account ? (nickname ?? account.username).slice(0, 2).toUpperCase() : '?'}</span>
<span><strong>{name}</strong><small>{account ? `ShaCraft · ${account.username}` : 'Войдите, чтобы играть'}</small></span>
<ChevronRight size={16} />
</button>
</aside>
</>
)
-16
View File
@@ -1,16 +0,0 @@
import type { DeviceCodePayload } from '../types/launcher'
export function LoginModal({ code }: { code: DeviceCodePayload | null }) {
if (!code) return null
return (
<>
<div className="drawer-backdrop visible" />
<div className="login-modal" role="dialog" aria-modal="true" aria-labelledby="login-title">
<h2 id="login-title">Вход через Microsoft</h2>
<p>Откройте страницу и введите код, чтобы подтвердить вход в аккаунт с лицензией Minecraft.</p>
<div className="login-code">{code.userCode}</div>
<p className="login-url">{code.verificationUri}</p>
</div>
</>
)
}
+5 -6
View File
@@ -1,4 +1,4 @@
import { Download, Gauge, Globe2, Play, RotateCcw, ShieldCheck, Wrench } from 'lucide-react'
import { Download, Gauge, Globe2, Play, RotateCcw, ShieldCheck } from 'lucide-react'
import type { ProfileState } from '../state/profiles'
import { installPercent, installStageLabels } from '../state/game'
import type { GameOperation } from '../state/game'
@@ -11,6 +11,7 @@ interface PlayDockProps {
memoryGb: number
native: boolean
needsLogin: boolean
needsLink: boolean
error: string | null
label: string
primaryDisabled: boolean
@@ -35,12 +36,10 @@ export function PlayDock(props: PlayDockProps) {
title = 'Игра запущена'; detail = 'Вернитесь после завершения игры'
} else if (operation.phase === 'syncing') {
title = 'Синхронизируем сборку'; detail = 'Скачиваем и проверяем файлы'
} else if (server.disabled) {
title = 'Техобслуживание'; detail = 'Сообщим, когда сервер вернётся'
} else if (!props.native) {
title = 'Предпросмотр интерфейса'; detail = 'Установка и запуск доступны в приложении'
} else {
title = needsLogin ? 'Нужен вход' : profile?.status === 'checking' ? 'Проверяем сборку'
title = needsLogin ? 'Нужен вход ShaCraft' : props.needsLink ? 'Нужно привязать ник' : profile?.status === 'checking' ? 'Проверяем сборку'
: profile?.inspection?.upToDate ? 'Сборка готова' : 'Требуется проверка'
detail = profile?.inspection ? `${profile.inspection.managedFiles} файлов под контролем` : 'Проверяем локальные файлы'
}
@@ -48,8 +47,8 @@ export function PlayDock(props: PlayDockProps) {
return (
<section className="play-dock">
<div className="build-state" aria-live="polite">
<span className={`state-icon ${working ? 'downloading' : server.disabled ? 'muted' : ''}`}>
{working ? <Download size={19} /> : server.disabled ? <Wrench size={19} /> : <ShieldCheck size={19} />}
<span className={`state-icon ${working ? 'downloading' : ''}`}>
{working ? <Download size={19} /> : <ShieldCheck size={19} />}
</span>
<span><strong>{title}</strong><small className={error ? 'status-error' : undefined} title={error ?? undefined}>{error ?? detail}</small></span>
{percent !== null && <div className="progress-track" role="progressbar" aria-label={title}
+24
View File
@@ -0,0 +1,24 @@
import { useEffect, useRef } from 'react'
export function RecoveryCodesModal({ codes, onAcknowledge }: { codes: string[]; onAcknowledge: () => void }) {
const button = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (!codes.length) return
const previous = document.activeElement
button.current?.focus()
return () => { if (previous instanceof HTMLElement) previous.focus() }
}, [codes])
if (!codes.length) return null
return (
<>
<div className="drawer-backdrop visible" />
<div className="login-modal" role="dialog" aria-modal="true" aria-labelledby="recovery-title"
onKeyDown={(event) => { if (event.key === 'Tab') { event.preventDefault(); button.current?.focus() } }}>
<h2 id="recovery-title">Коды восстановления</h2>
<p>Сохраните их сейчас. Каждый код можно использовать один раз для восстановления пароля.</p>
<div className="login-code recovery-codes">{codes.join('\n')}</div>
<button ref={button} className="setting-row" onClick={onAcknowledge}><span>Я сохранил коды</span></button>
</div>
</>
)
}
+8 -5
View File
@@ -1,18 +1,21 @@
import type { ReactNode } from 'react'
import { Users } from 'lucide-react'
import type { Server } from '../types/launcher'
import { isNative } from '../services/native'
import type { Server, ServerStatus } from '../types/launcher'
export function ServerStage({ server, children }: { server: Server; children: ReactNode }) {
export function ServerStage({ server, status, children }: { server: Server; status: ServerStatus | null; children: ReactNode }) {
const online = status?.reachable && status.online !== null && status.max !== null
? `${status.online} / ${status.max}` : !isNative() ? 'В приложении' : status === null ? 'Проверяем…' : 'Нет связи'
const label = !isNative() ? 'Статус в приложении' : status === null ? 'Проверяем сервер' : status.reachable ? 'Сервер доступен' : 'Сервер недоступен'
return (
<main className={`stage stage-${server.id}`}>
<div className="stage-top">
<div className="live-pill offline"><span /> {server.disabled ? 'Не в сети' : 'Статус не проверен'}</div>
<div className="players"><Users size={16} /> {server.disabled ? 'Сервер остановлен' : 'Онлайн неизвестен'}</div>
<div className={`live-pill ${status?.reachable ? '' : 'offline'}`}><span /> {label}</div>
<div className="players"><Users size={16} /> {online}</div>
</div>
<section className="hero-copy">
<p>{server.kicker}</p><h1>{server.name}</h1><h2>{server.subtitle}</h2>
<dl className="hero-meta">
<div><dt>Состав</dt><dd>{server.composition}</dd></div>
<div><dt>Загрузчик</dt><dd>{server.loader}</dd></div>
<div><dt>Java</dt><dd>Версия 21</dd></div>
</dl>
+4 -27
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react'
import { ChevronRight, FolderOpen, LogOut, Users, Wrench, X } from 'lucide-react'
import { FolderOpen, Wrench, X } from 'lucide-react'
import { AccountSettings } from './AccountSettings'
import type { useAccount } from '../hooks/useAccount'
import type { useSettings } from '../hooks/useSettings'
import type { JavaInstallation, NativeHost } from '../types/launcher'
@@ -15,7 +16,7 @@ interface SettingsDrawerProps {
}
export function SettingsDrawer({ open, locked, host, java, preferences, session, onClose }: SettingsDrawerProps) {
const { settings, nickname, loaded, saving, error, nicknameError } = preferences
const { settings, loaded, saving, error } = preferences
const closeButton = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (!open) return
@@ -52,29 +53,7 @@ export function SettingsDrawer({ open, locked, host, java, preferences, session,
onChange={(event) => preferences.updateRam(Number(event.target.value))} />
<small>Для Aeronautics рекомендуется 6 ГБ</small>
</label>
<div className="setting-row static"><span><Users />Аккаунт</span><small>{settings.accountMode === 'offline' ? 'Offline' : session.account?.name ?? 'Не авторизован'}</small></div>
{settings.accountMode === 'offline' && (
<label className="text-setting">
<span><strong>Игровой ник</strong><small>Offline-профиль</small></span>
<input value={nickname} maxLength={16} disabled={!loaded || locked} aria-invalid={!!nicknameError}
aria-describedby="nickname-hint" onChange={(event) => preferences.updateNickname(event.target.value)}
onBlur={preferences.saveNickname} placeholder="Player" />
<small id="nickname-hint" className={nicknameError ? 'status-error' : undefined}>{nicknameError ?? 'Латинские буквы, цифры и _ · от 3 до 16 символов'}</small>
</label>
)}
<label className="setting-row">
<span>Тип аккаунта</span>
<select value={settings.accountMode} disabled={!loaded || locked || saving || session.busy}
onChange={(event) => preferences.updateMode(event.target.value === 'microsoft' ? 'microsoft' : 'offline')}>
<option value="offline">Offline</option><option value="microsoft">Microsoft</option>
</select>
</label>
{settings.accountMode === 'microsoft' && (
<button className="setting-row" disabled={locked || saving || session.busy || session.account === undefined || !session.eventsReady}
onClick={session.account ? session.logout : session.login}>
<span><LogOut />{session.busy ? 'Ждём вход…' : session.account ? 'Выйти из Microsoft' : 'Войти через Microsoft'}</span>
</button>
)}
<AccountSettings session={session} locked={locked || saving} />
<div className="setting-row static"><span><FolderOpen />Папка игры</span><small>{host ? 'В каталоге лаунчера' : 'Определяется…'}</small></div>
<div className="setting-row static">
<span><Wrench />Java</span>
@@ -82,10 +61,8 @@ export function SettingsDrawer({ open, locked, host, java, preferences, session,
: java?.major === 21 ? 'Java 21 найдена' : java ? `Нужна Java 21 · найдена ${java.major}`
: 'Лаунчер установит Java 21 автоматически'}</small>
</div>
<button className="setting-row" disabled title="Скоро"><span><Wrench />Дополнительные параметры</span><ChevronRight /></button>
<div className="settings-feedback" aria-live="polite">
{error && <><p className="status-error">{error}</p><button onClick={preferences.retry} disabled={locked || saving}>Повторить</button></>}
{session.error && settings.accountMode === 'microsoft' && <p className="status-error">{session.error}</p>}
{saving && <p>Сохраняем настройки…</p>}
</div>
<div className="drawer-note">{host ? `Данные лаунчера: ${host.dataDir}` : 'Java 21 будет управляться лаунчером автоматически.'}</div>
+12 -7
View File
@@ -1,16 +1,21 @@
import { Minus, Square, X } from 'lucide-react'
import logo from '../assets/shacraft-logo.png'
import { errorMessage } from '../services/async'
import { isNative, windowControls } from '../services/native'
import type { NativeHost } from '../types/launcher'
export function Titlebar({ host }: { host: NativeHost | null }) {
export function Titlebar({ host, onError }: { host: NativeHost | null; onError: (message: string) => void }) {
const control = (action: () => Promise<void>) => {
if (isNative()) void action().catch((reason: unknown) => onError(errorMessage(reason, 'Не удалось изменить окно')))
}
return (
<header className="titlebar">
<div className="brand"><img src={logo} alt="" /><span>ShaCraft</span></div>
<div className="titlebar-drag">{host ? `Лаунчер · ${host.platform}` : 'Лаунчер'}</div>
<header className="titlebar" data-tauri-drag-region>
<div className="brand" data-tauri-drag-region><img src={logo} alt="" data-tauri-drag-region /><span data-tauri-drag-region>ShaCraft</span></div>
<div className="titlebar-drag" data-tauri-drag-region>{host ? `Лаунчер · ${host.platform}` : 'Лаунчер'}</div>
<div className="window-actions" aria-label="Управление окном">
<button aria-label="Свернуть" disabled title="Управление окном пока недоступно"><Minus size={15} /></button>
<button aria-label="Развернуть" disabled title="Управление окном пока недоступно"><Square size={12} /></button>
<button className="close" aria-label="Закрыть" disabled title="Управление окном пока недоступно"><X size={15} /></button>
<button aria-label="Свернуть" disabled={!isNative()} onClick={() => control(windowControls.minimize)}><Minus size={15} /></button>
<button aria-label="Развернуть" disabled={!isNative()} onClick={() => control(windowControls.toggleMaximize)}><Square size={12} /></button>
<button className="close" aria-label="Закрыть" disabled={!isNative()} onClick={() => control(windowControls.close)}><X size={15} /></button>
</div>
</header>
)
+44
View File
@@ -0,0 +1,44 @@
import { equal, match, doesNotMatch } from 'node:assert/strict'
import { test } from 'node:test'
import { renderToStaticMarkup } from 'react-dom/server'
import { AccountSettings } from './AccountSettings'
import { RecoveryCodesModal } from './RecoveryCodesModal'
import type { useAccount } from '../hooks/useAccount'
function session(): ReturnType<typeof useAccount> {
return {
account: null, error: null, busy: false, recoveryCodes: [], linkMessage: null,
linking: false, linkedNickname: null, authenticate: async () => true,
logout: async () => {}, startLink: async () => {}, clearError: () => {},
acknowledgeRecoveryCodes: () => {},
}
}
test('signed-out settings expose ShaCraft login/registration and the current credential limits', () => {
const html = renderToStaticMarkup(<AccountSettings session={session()} locked={false} />)
match(html, /Логин ShaCraft/)
match(html, /Нет аккаунта — регистрация/)
match(html, /minLength="3" maxLength="32"/)
match(html, /minLength="3" maxLength="128"/)
doesNotMatch(html, /Microsoft|Offline|Тип аккаунта|Игровой ник/)
})
test('linked identity is displayed read-only while an unlinked account offers verification', () => {
const account = { username: 'website_login', links: [{ server_id: 'aoc', mc_username: 'Bound_Name' }] }
const linked = renderToStaticMarkup(<AccountSettings session={{ ...session(), account, linkedNickname: 'Bound_Name' }} locked={false} />)
match(linked, /Bound_Name/)
doesNotMatch(linked, /<input|Привязать ник/)
const unlinked = renderToStaticMarkup(<AccountSettings session={{ ...session(), account: { username: 'website_login', links: [] } }} locked={false} />)
match(unlinked, /Привязать ник/)
match(unlinked, /Aeronautics/)
})
test('recovery codes render only until explicitly acknowledged', () => {
const codes = ['TEST-RECOVERY-ONE', 'TEST-RECOVERY-TWO']
const html = renderToStaticMarkup(<RecoveryCodesModal codes={codes} onAcknowledge={() => {}} />)
match(html, /Коды восстановления/)
match(html, /TEST-RECOVERY-ONE/)
match(html, /TEST-RECOVERY-TWO/)
match(html, /Я сохранил коды/)
equal(renderToStaticMarkup(<RecoveryCodesModal codes={[]} onAcknowledge={() => {}} />), '')
})
+2 -13
View File
@@ -5,22 +5,11 @@ import type { Server } from '../types/launcher'
export const servers: readonly [Server, ...Server[]] = [
{
id: 'aoc',
kicker: 'All of Create / сборка 2.5',
kicker: 'Основная сборка',
name: 'Aeronautics',
subtitle: 'Строй корабли. Поднимай города в небо.',
version: '1.21.1 · NeoForge',
composition: '250 модов',
version: '1.21.1 · NeoForge 21.1.248',
loader: 'NeoForge 21.1.248',
profileId: 'aeronautics',
},
{
id: 'create',
kicker: 'На техобслуживании',
name: 'Create',
subtitle: 'Механизмы, фабрики и большие идеи.',
version: '1.21.1 · NeoForge',
composition: '41 мод',
loader: 'NeoForge 21.1.249',
disabled: true,
},
]
+131 -50
View File
@@ -1,80 +1,161 @@
import { useEffect, useRef, useState } from 'react'
import { errorMessage } from '../services/async'
import { isNative, native, watchAccount } from '../services/native'
import type { DeviceCodePayload, MinecraftProfile } from '../types/launcher'
import { createRequestScope, errorMessage } from '../services/async'
import { isNative, native } from '../services/native'
import { linkedNickname, validCredentials } from '../state/account'
import { isValidNickname } from '../state/settings'
import type { ShaCraftAccount } from '../types/launcher'
interface PendingLink {
challengeId: number
expiresAt: number
isCurrent: () => boolean
}
export function useAccount() {
// undefined = restoring saved account; null = signed out.
const [account, setAccount] = useState<MinecraftProfile | null | undefined>(isNative() ? undefined : null)
const [code, setCode] = useState<DeviceCodePayload | null>(null)
const [account, setAccount] = useState<ShaCraftAccount | null | undefined>(isNative() ? undefined : null)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [eventsReady, setEventsReady] = useState(false)
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([])
const [challenge, setChallenge] = useState<PendingLink | null>(null)
const [linkMessage, setLinkMessage] = useState<string | null>(null)
const pending = useRef(false)
const requests = useRef(createRequestScope())
useEffect(() => {
if (!isNative()) return
let active = true
let changed = false
const subscription = watchAccount({
code: (payload) => { if (active) setCode(payload) },
result: (payload) => {
if (!active) return
changed = true
pending.current = false
setBusy(false)
setCode(null)
if (payload.ok && payload.profile) {
setAccount(payload.profile)
setError(null)
} else setError(payload.error || 'Не удалось войти через Microsoft')
},
})
void subscription.ready.then(() => {
if (active) setEventsReady(true)
void native.getAccount().then((value) => {
if (active) setAccount(value)
}).catch((reason: unknown) => {
if (active) setError(errorMessage(reason, 'Не удалось подготовить вход через Microsoft'))
})
void native.getAccount().then((profile) => {
if (active && !changed) setAccount(profile)
}).catch((reason: unknown) => {
if (!active || changed) return
if (!active) return
setAccount(null)
setError(errorMessage(reason, 'Не удалось восстановить аккаунт'))
setError(errorMessage(reason, 'Не удалось восстановить аккаунт ShaCraft'))
})
return () => { active = false; subscription.dispose() }
return () => { active = false; requests.current.invalidate() }
}, [])
const login = async () => {
if (!isNative() || !eventsReady || pending.current || account === undefined) return
pending.current = true
setBusy(true)
setCode(null)
setError(null)
try {
await native.startLogin()
} catch (reason) {
pending.current = false
setBusy(false)
setError(errorMessage(reason, 'Не удалось начать вход через Microsoft'))
useEffect(() => {
if (!challenge) return
let active = true
let timer: ReturnType<typeof setTimeout> | undefined
const current = () => active && challenge.isCurrent()
const poll = async () => {
if (!current()) return
if (Date.now() >= challenge.expiresAt) {
setChallenge(null)
setLinkMessage('Срок проверки истёк. Начните привязку ещё раз.')
return
}
try {
const result = await native.linkStatus(challenge.challengeId)
if (!current()) return
if (result.status === 'verified') {
const refreshed = await native.getAccount()
if (!current()) return
setAccount(refreshed)
setChallenge(null)
setLinkMessage(linkedNickname(refreshed) ? 'Ник подтверждён.' : 'Не удалось подтвердить привязку. Войдите снова.')
} else if (result.status === 'expired' || result.status === 'conflict') {
setChallenge(null)
setLinkMessage(result.detail || 'Проверка завершилась. Попробуйте ещё раз.')
} else {
// One request at a time; dispose and logout cancel future polling.
timer = setTimeout(() => { void poll() }, 3_000)
}
} catch (reason) {
if (!current()) return
setChallenge(null)
setLinkMessage(errorMessage(reason, 'Не удалось проверить ник'))
}
}
}
timer = setTimeout(() => { void poll() }, 3_000)
return () => { active = false; clearTimeout(timer) }
}, [challenge])
const logout = async () => {
if (!isNative() || pending.current) return
const authenticate = async (username: string, password: string, register: boolean) => {
if (pending.current || account === undefined) return false
if (!validCredentials(username, password)) {
setError('Логин: 3–32 латинских буквы, цифры или _; пароль: 3–128 символов')
return false
}
if (!isNative()) {
setError('Вход в ShaCraft доступен в приложении лаунчера')
return false
}
pending.current = true
requests.current.invalidate()
const currentRequest = requests.current.capture()
setBusy(true)
setError(null)
try {
await native.logout()
setAccount(null)
const result = await native.authenticate(username, password, register)
if (!currentRequest()) return false
setAccount(result.account)
setChallenge(null)
setLinkMessage(null)
setRecoveryCodes(result.recoveryCodes)
return true
} catch (reason) {
setError(errorMessage(reason, 'Не удалось выйти из аккаунта'))
setError(errorMessage(reason, register ? 'Не удалось зарегистрироваться' : 'Не удалось войти'))
return false
} finally {
pending.current = false
setBusy(false)
}
}
return { account, code, error, busy, eventsReady, login, logout }
const logout = async () => {
if (!isNative() || pending.current) return
pending.current = true
requests.current.invalidate()
setChallenge(null)
setLinkMessage(null)
setBusy(true)
setError(null)
try {
await native.logout()
setAccount(null)
setRecoveryCodes([])
} catch (reason) {
setError(errorMessage(reason, 'Не удалось выйти из ShaCraft'))
} finally {
pending.current = false
setBusy(false)
}
}
const startLink = async (nickname: string) => {
if (!isNative() || pending.current || challenge || !account) return
if (!isValidNickname(nickname)) {
setLinkMessage('Ник: 3–16 латинских букв, цифр или _')
return
}
pending.current = true
setBusy(true)
setLinkMessage('Создаём проверку…')
requests.current.invalidate()
const currentRequest = requests.current.capture()
try {
const started = await native.startLink(nickname)
if (!currentRequest()) return
setLinkMessage(started.registered_on_server
? 'Зайдите на Aeronautics с этим ником и выполните /login.'
: 'Зайдите на Aeronautics с этим ником и выполните /register.')
setChallenge({ challengeId: started.challenge_id,
expiresAt: Date.now() + started.expires_in_seconds * 1000, isCurrent: currentRequest })
} catch (reason) {
setLinkMessage(errorMessage(reason, 'Не удалось начать привязку'))
} finally {
pending.current = false
setBusy(false)
}
}
return {
account, error, busy, recoveryCodes, linkMessage, linking: challenge !== null,
linkedNickname: linkedNickname(account), authenticate, logout, startLink,
clearError: () => setError(null),
acknowledgeRecoveryCodes: () => setRecoveryCodes([]),
}
}
+14 -3
View File
@@ -41,7 +41,6 @@ export function useLauncher() {
})
for (const server of servers) {
const profileId = server.profileId
if (!profileId || server.disabled) continue
updateProfile({ type: 'check', profileId })
void native.inspectProfile(profileId).then((inspection) => {
if (active) updateProfile({ type: 'checked', profileId, inspection })
@@ -78,14 +77,26 @@ export function useLauncher() {
const launch = async (profileId: string) => {
if (!isNative() || !eventsReady || busy.current || game.operation.phase !== 'idle') return
busy.current = true
dispatch({ type: 'install', profileId })
dispatch({ type: 'sync', profileId })
updateProfile({ type: 'check', profileId })
try {
// Reconcile the current signed modpack before every Play, even when a
// previous inspection succeeded. Game installation alone omits mods.
const synced = await native.syncProfile(profileId)
updateProfile({ type: 'checked', profileId, inspection: {
root: synced.root, managedFiles: synced.downloadedFiles + synced.reusedFiles,
missingFiles: 0, mismatchedFiles: 0, upToDate: true,
} })
dispatch({ type: 'synced', profileId })
dispatch({ type: 'install', profileId })
await native.installGame(profileId)
dispatch({ type: 'launch', profileId })
await native.launchGame(profileId)
dispatch({ type: 'started', profileId })
} catch (reason) {
dispatch({ type: 'failed', error: errorMessage(reason, 'Не удалось запустить игру') })
const error = errorMessage(reason, 'Не удалось запустить игру')
updateProfile({ type: 'failed', profileId, error })
dispatch({ type: 'failed', error })
} finally {
busy.current = false
}
+27
View File
@@ -0,0 +1,27 @@
import { useEffect, useState } from 'react'
import { isNative, native } from '../services/native'
import type { ServerStatus } from '../types/launcher'
export function useServerStatus(profileId: string) {
const [status, setStatus] = useState<ServerStatus | null>(null)
useEffect(() => {
setStatus(null)
if (!isNative()) return
let active = true
let timer: ReturnType<typeof setTimeout> | undefined
const refresh = async () => {
try {
const next = await native.serverStatus(profileId)
if (active) setStatus(next)
} catch {
if (active) setStatus({ online: null, max: null, reachable: false })
} finally {
// Schedule after completion; slow network calls never overlap.
if (active) timer = setTimeout(() => { void refresh() }, 30_000)
}
}
void refresh()
return () => { active = false; clearTimeout(timer) }
}, [profileId])
return status
}
+3 -19
View File
@@ -1,16 +1,14 @@
import { useEffect, useRef, useState } from 'react'
import { createSerialQueue, errorMessage } from '../services/async'
import { isNative, native } from '../services/native'
import { defaultSettings, isValidNickname } from '../state/settings'
import type { AccountMode, LauncherSettings } from '../types/launcher'
import { defaultSettings } from '../state/settings'
import type { LauncherSettings } from '../types/launcher'
export function useSettings() {
const [settings, setSettings] = useState(defaultSettings)
const [nickname, setNickname] = useState(defaultSettings.nickname)
const [loaded, setLoaded] = useState(!isNative())
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [nicknameError, setNicknameError] = useState<string | null>(null)
const [loadAttempt, setLoadAttempt] = useState(0)
const current = useRef(settings)
const durable = useRef(settings)
@@ -26,7 +24,6 @@ export function useSettings() {
current.current = value
durable.current = value
setSettings(value)
setNickname(value.nickname)
setLoaded(true)
}).catch((reason: unknown) => {
if (active) setError(errorMessage(reason, 'Не удалось прочитать настройки'))
@@ -59,24 +56,11 @@ export function useSettings() {
})
}
const saveNickname = () => {
if (!isValidNickname(nickname)) {
setNicknameError('Ник: от 3 до 16 латинских букв, цифр или символов _')
return
}
setNicknameError(null)
save({ nickname })
}
return {
settings, nickname, loaded, saving, error, nicknameError,
settings, loaded, saving, error,
updateRam: (memoryGb: number) => save({ memoryMb: memoryGb * 1024 }),
updateMode: (accountMode: AccountMode) => save({ accountMode }),
updateNickname: (value: string) => { setNickname(value); setNicknameError(null) },
saveNickname,
retry: () => {
if (!loaded) setLoadAttempt((attempt) => attempt + 1)
else if (isValidNickname(nickname)) save({ nickname })
else save({})
},
}
+17 -1
View File
@@ -1,6 +1,6 @@
import { deepStrictEqual, equal, rejects } from 'node:assert/strict'
import { test } from 'node:test'
import { createSerialQueue, createSubscription, errorMessage, singleFlight } from './async.ts'
import { createRequestScope, createSerialQueue, createSubscription, errorMessage, singleFlight } from './async.ts'
function deferred<T>() {
let resolve!: (value: T) => void
@@ -95,3 +95,19 @@ test('failed account restore can be retried', async () => {
equal(await restore(), 'profile')
equal(calls, 2)
})
test('logout or a newer challenge invalidates a delayed account/link response', async () => {
const requests = createRequestScope()
const response = deferred<string>()
const belongsToAccount = requests.capture()
let displayedAccount: string | null = 'signed in'
const polling = response.promise.then((account) => { if (belongsToAccount()) displayedAccount = account })
requests.invalidate()
displayedAccount = null
response.resolve('old linked account')
await polling
equal(displayedAccount, null)
const belongsToNewChallenge = requests.capture()
equal(belongsToNewChallenge(), true)
equal(belongsToAccount(), false)
})
+12
View File
@@ -18,6 +18,18 @@ export function singleFlight<T>(operation: () => Promise<T>): () => Promise<T> {
}
}
/** Invalidate in-flight responses when an account or link challenge changes. */
export function createRequestScope() {
let revision = 0
return {
invalidate: () => { revision += 1 },
capture: () => {
const captured = revision
return () => captured === revision
},
}
}
/** Serializes writes so a slow older save cannot overwrite a newer choice. */
export function createSerialQueue() {
let tail: Promise<unknown> = Promise.resolve()
+17 -15
View File
@@ -1,14 +1,16 @@
import { invoke, isTauri } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import { createSubscription, singleFlight } from './async'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { createSerialQueue, createSubscription, singleFlight } from './async'
import type {
DeviceCodePayload, GameExitedPayload, InstallProgressPayload,
JavaInstallation, LauncherSettings, LoginResultPayload, MinecraftProfile,
NativeHost, ProfileInspection, SyncResult,
GameExitedPayload, InstallProgressPayload, JavaInstallation, LauncherSettings,
LinkChallenge, LinkStatus, NativeHost, ProfileInspection, ServerStatus,
ShaCraftAccount, ShaCraftLoginResult, SyncResult,
} from '../types/launcher'
export const isNative = () => typeof window !== 'undefined' && isTauri()
const restoreAccount = singleFlight(() => invoke<MinecraftProfile | null>('get_account'))
const accountRequests = createSerialQueue()
const restoreAccount = singleFlight(() => accountRequests.enqueue(() => invoke<ShaCraftAccount | null>('get_shacraft_account')))
// Keep the IPC contract in one place. UI components never invoke native
// commands directly and cannot pass arbitrary URLs or filesystem paths.
@@ -20,20 +22,20 @@ export const native = {
inspectProfile: (profileId: string) => invoke<ProfileInspection>('inspect_remote_profile', { profileId }),
syncProfile: (profileId: string) => invoke<SyncResult>('sync_remote_profile', { profileId }),
getAccount: restoreAccount,
startLogin: () => invoke<void>('start_microsoft_login'),
logout: () => invoke<void>('logout'),
authenticate: (username: string, password: string, register: boolean) =>
accountRequests.enqueue(() => invoke<ShaCraftLoginResult>('shacraft_authenticate', { username, password, register })),
logout: () => accountRequests.enqueue(() => invoke<void>('shacraft_logout')),
startLink: (nickname: string) => accountRequests.enqueue(() => invoke<LinkChallenge>('shacraft_start_link', { nickname })),
linkStatus: (challengeId: number) => accountRequests.enqueue(() => invoke<LinkStatus>('shacraft_link_status', { challengeId })),
serverStatus: (profileId: string) => invoke<ServerStatus>('get_server_status', { profileId }),
installGame: (profileId: string) => invoke<void>('ensure_game_installed', { profileId }),
launchGame: (profileId: string) => invoke<void>('launch_game', { profileId }),
}
export function watchAccount(handlers: {
code: (payload: DeviceCodePayload) => void
result: (payload: LoginResultPayload) => void
}) {
return createSubscription([
listen<DeviceCodePayload>('msa-login-code', ({ payload }) => handlers.code(payload)),
listen<LoginResultPayload>('msa-login-result', ({ payload }) => handlers.result(payload)),
])
export const windowControls = {
minimize: () => getCurrentWindow().minimize(),
toggleMaximize: () => getCurrentWindow().toggleMaximize(),
close: () => getCurrentWindow().close(),
}
export function watchGame(handlers: {
+31
View File
@@ -0,0 +1,31 @@
import { equal } from 'node:assert/strict'
import { test } from 'node:test'
import { launchAccess, linkedNickname, validCredentials } from './account.ts'
test('launch requires a restored ShaCraft account and its verified Aeronautics link', () => {
equal(launchAccess(undefined), 'loading')
equal(launchAccess(null), 'login')
equal(launchAccess({ username: 'account', links: [] }), 'link')
equal(launchAccess({ username: 'account', links: [{ server_id: 'create', mc_username: 'Other_Name' }] }), 'link')
equal(launchAccess({ username: 'account', links: [{ server_id: 'aoc', mc_username: 'Verified_Name' }] }), 'ready')
})
test('identity uses snake-case account link payload and never the account login', () => {
const account = { username: 'Local_Login', links: [
{ server_id: 'create', mc_username: 'Other_Name' },
{ server_id: 'aoc', mc_username: 'Verified_Name' },
] }
equal(linkedNickname(account), 'Verified_Name')
equal(linkedNickname({ username: 'Valid_Login', links: [] }), null)
equal(linkedNickname({ username: 'Valid_Login', links: [{ server_id: 'aoc', mc_username: '../unsafe' }] }), null)
})
test('ShaCraft credentials preserve the server 3–32 login and 3–128 password contract', () => {
equal(validCredentials('abc', '123'), true)
equal(validCredentials('a'.repeat(32), 'p'.repeat(128)), true)
equal(validCredentials('ab', '123'), false)
equal(validCredentials('a'.repeat(33), '123'), false)
equal(validCredentials('неверно', '123'), false)
equal(validCredentials('account', '12'), false)
equal(validCredentials('account', 'p'.repeat(129)), false)
})
+18
View File
@@ -0,0 +1,18 @@
import type { ShaCraftAccount } from '../types/launcher'
import { isValidNickname } from './settings'
/** Only the authenticated account's verified Aeronautics link selects a name. */
export function linkedNickname(account: ShaCraftAccount | null | undefined): string | null {
const nickname = account?.links.find((link) => link.server_id === 'aoc')?.mc_username
return nickname && isValidNickname(nickname) ? nickname : null
}
export function launchAccess(account: ShaCraftAccount | null | undefined) {
if (account === undefined) return 'loading'
if (account === null) return 'login'
return linkedNickname(account) ? 'ready' : 'link'
}
export function validCredentials(username: string, password: string): boolean {
return /^[A-Za-z0-9_]{3,32}$/.test(username) && password.length >= 3 && password.length <= 128
}
+11
View File
@@ -14,6 +14,17 @@ test('installation, running game and exit have distinct states', () => {
deepStrictEqual(gameReducer(running, { type: 'exited', result: { profileId, exitCode: 0 } }), initialGameState)
})
test('every launch can reconcile the modpack before installation and process spawn', () => {
const syncing = gameReducer(initialGameState, { type: 'sync', profileId })
equal(syncing.operation.phase, 'syncing')
const synced = gameReducer(syncing, { type: 'synced', profileId })
const installingAfterSync = gameReducer(synced, { type: 'install', profileId })
equal(installingAfterSync.operation.phase, 'installing')
const launchingAfterInstall = gameReducer(installingAfterSync, { type: 'launch', profileId })
equal(launchingAfterInstall.operation.phase, 'launching')
equal(gameReducer(launchingAfterInstall, { type: 'started', profileId }).operation.phase, 'running')
})
test('a fast child exit cannot be overwritten by a late launch acknowledgement', () => {
const exited = gameReducer(launching, { type: 'exited', result: { profileId, exitCode: 1 } })
const lateAcknowledgement = gameReducer(exited, { type: 'started', profileId })
+5 -5
View File
@@ -50,8 +50,7 @@ button:disabled { cursor: default; }
.window-actions .close:hover { background: #a93939; }
.workspace { height: calc(100vh - 48px); display: grid; grid-template-columns: 64px 250px 1fr; }
.rail { background: #0c100d; border-right: 1px solid var(--line); padding: 18px 10px 14px; display: flex; flex-direction: column; justify-content: space-between; }
.rail-main { display: grid; gap: 8px; }
.rail { background: #0c100d; border-right: 1px solid var(--line); padding: 18px 10px 14px; display: flex; flex-direction: column; justify-content: flex-end; }
.rail-button { border: 0; width: 44px; height: 44px; border-radius: 5px; display: grid; place-items: center; background: transparent; color: #69716a; cursor: pointer; }
.rail-button svg { width: 20px; }
.rail-button:hover { color: #cfd6cf; background: #171c18; }
@@ -73,7 +72,8 @@ button:disabled { cursor: default; }
.server-copy strong { font-size: 13px; font-weight: 700; }
.server-copy small { font-size: 10px; color: var(--green); margin-top: 2px; }
.server-row:not(.selected) .server-copy small { color: #707871; }
.account-chip { margin-top: auto; padding: 10px; border-top: 1px solid var(--line); display: grid; grid-template-columns: 34px 1fr 16px; gap: 9px; align-items: center; }
.account-chip { width: 100%; margin-top: auto; padding: 10px; border: 0; border-top: 1px solid var(--line); background: transparent; color: inherit; display: grid; grid-template-columns: 34px 1fr 16px; gap: 9px; align-items: center; text-align: left; cursor: pointer; }
.account-chip:hover { background: #171c18; }
.account-chip .avatar { width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center; background: #243127; color: #bfe1c1; font-size: 11px; font-weight: 800; }
.account-chip > span:nth-child(2) { display: grid; }
.account-chip strong { font-size: 12px; }
@@ -88,7 +88,6 @@ button:disabled { cursor: default; }
linear-gradient(112deg, #0b130e 0%, #11291d 39%, #28301f 66%, #512713 100%);
}
.stage::after { content: ''; position: absolute; inset: 0; z-index: -1; pointer-events: none; background: linear-gradient(180deg, transparent 45%, rgba(5,8,6,.9) 100%); }
.stage-create::before { filter: grayscale(.55); background: linear-gradient(110deg, #152019, #27372b 55%, #151a16); }
.stage-top { display: flex; justify-content: flex-end; align-items: center; gap: 12px; padding: 22px 26px; }
.live-pill, .players { height: 28px; padding: 0 0 0 12px; border: 0; border-left: 1px solid rgba(255,255,255,.16); border-radius: 0; background: transparent; display: flex; align-items: center; gap: 7px; font-size: 11px; color: #c0c8c0; }
.live-pill span { width: 7px; height: 7px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 4px rgba(119,209,122,.1), 0 0 12px rgba(119,209,122,.55); }
@@ -99,7 +98,6 @@ button:disabled { cursor: default; }
.hero-copy p::before { content: ''; width: 26px; height: 2px; background: var(--copper); }
.hero-copy h1 { font-family: 'Unbounded', sans-serif; font-size: clamp(40px, 5vw, 66px); line-height: 1; letter-spacing: -.045em; margin: 0; text-shadow: 0 8px 36px rgba(0,0,0,.3); }
.hero-copy h2 { max-width: 440px; font-size: 15px; line-height: 1.5; font-weight: 500; color: #aeb7af; margin: 16px 0 0; }
.stage-create .hero-copy p { color: #9aa49c; }
.hero-meta { display: flex; gap: 0; margin: 36px 0 0; padding: 0; }
.hero-meta div { min-width: 130px; padding: 0 26px; border-left: 1px solid rgba(235,243,234,.14); }
.hero-meta div:first-child { padding-left: 0; border-left: 0; }
@@ -156,6 +154,8 @@ button:disabled { cursor: default; }
.text-setting input:focus { border-color: var(--green); }
.drawer-note { margin-top: 24px; overflow-wrap: anywhere; padding: 13px 15px; background: #17231a; color: #8fb193; border-radius: 10px; font-size: 10px; }
.settings-feedback { font-size: 11px; color: var(--muted); }
.account-hint { font-size: 11px; line-height: 1.5; color: var(--muted); overflow-wrap: anywhere; }
.recovery-codes { white-space: pre-line; font-size: 15px; max-height: 45vh; overflow: auto; }
.settings-feedback button { padding: 6px 10px; background: var(--panel-2); border: 1px solid var(--line); cursor: pointer; }
.status-error, .build-state .status-error, .text-setting > .status-error { color: #eea18f; overflow-wrap: anywhere; }
.build-state .status-error { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
+22 -14
View File
@@ -6,10 +6,8 @@ export interface Server {
name: string
subtitle: string
version: string
composition: string
loader: string
profileId?: string
disabled?: boolean
profileId: string
}
export interface NativeHost {
@@ -45,21 +43,31 @@ export interface SyncResult {
downloadedBytes: number
}
export interface MinecraftProfile {
id: string
name: string
export interface ShaCraftAccount {
username: string
links: { server_id: string; mc_username: string }[]
}
export interface DeviceCodePayload {
verificationUri: string
userCode: string
expiresInSeconds: number
export interface ShaCraftLoginResult {
account: ShaCraftAccount
recoveryCodes: string[]
}
export interface LoginResultPayload {
ok: boolean
profile: MinecraftProfile | null
error: string | null
export interface LinkChallenge {
challenge_id: number
expires_in_seconds: number
registered_on_server: boolean
}
export interface LinkStatus {
status: string
detail?: string | null
}
export interface ServerStatus {
online: number | null
max: number | null
reachable: boolean
}
export interface InstallProgressPayload {