merge: preserve ShaCraft 0.1.1 account and Windows features in modular launcher
This commit is contained in:
+30
-27
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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([]),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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({})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user