feat(ui): expose first-entry verification and explicit legacy mod recovery
This commit is contained in:
+16
-5
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { LegacyModsDialog } from './components/LegacyModsDialog'
|
||||
import { Library } from './components/Library'
|
||||
import { RecoveryCodesModal } from './components/RecoveryCodesModal'
|
||||
import { PlayDock } from './components/PlayDock'
|
||||
@@ -17,6 +18,7 @@ import { installStageLabels } from './state/game'
|
||||
export function App() {
|
||||
const [selected, setSelected] = useState(servers[0])
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [legacyOpen, setLegacyOpen] = useState(false)
|
||||
const [windowError, setWindowError] = useState<string | null>(null)
|
||||
const preferences = useSettings()
|
||||
const session = useAccount()
|
||||
@@ -25,6 +27,9 @@ export function App() {
|
||||
const closeSettings = useCallback(() => setSettingsOpen(false), [])
|
||||
const desktop = isNative()
|
||||
const profile = launcher.profiles[selected.profileId]
|
||||
const metadata = launcher.metadata[selected.profileId]
|
||||
const displayed = { ...selected, version: metadata ? `Minecraft ${metadata.minecraftVersion}` : 'Версия уточняется',
|
||||
loader: metadata ? `${metadata.loaderKind} ${metadata.loaderVersion}` : 'По подписанной сборке' }
|
||||
const ready = profile?.inspection?.upToDate === true
|
||||
const operation = launcher.game.operation
|
||||
const busy = operation.phase !== 'idle'
|
||||
@@ -50,6 +55,11 @@ export function App() {
|
||||
else if (checking) label = 'Проверяем…'
|
||||
else if (!ready) label = 'Проверить'
|
||||
|
||||
const onboard = async (nickname: string) => {
|
||||
if (busy || settingsBlocked || !launcher.eventsReady) return
|
||||
const result = await launcher.onboard(selected.profileId, nickname)
|
||||
if (result?.onboarding) session.acceptChallenge(result.onboarding)
|
||||
}
|
||||
const primary = () => {
|
||||
if (disabled) return
|
||||
if (access !== 'ready') setSettingsOpen(true)
|
||||
@@ -59,19 +69,20 @@ export function App() {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<Titlebar host={launcher.host} onError={setWindowError} />
|
||||
<div className="workspace" inert={session.recoveryCodes.length > 0}>
|
||||
<div className="workspace" inert={session.recoveryCodes.length > 0 || legacyOpen}>
|
||||
<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}
|
||||
<ServerStage server={displayed} status={serverStatus} javaMajor={metadata?.javaMajor}>
|
||||
<PlayDock server={displayed} operation={operation} profile={profile}
|
||||
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) void launcher.repair(selected.profileId) }} />
|
||||
onLegacy={() => setLegacyOpen(true)} onPrimary={primary} onRepair={() => { if (!repairDisabled) void launcher.repair(selected.profileId) }} />
|
||||
</ServerStage>
|
||||
</div>
|
||||
<SettingsDrawer open={settingsOpen && !session.recoveryCodes.length} locked={busy} preferences={preferences}
|
||||
session={session} host={launcher.host} java={launcher.java} onClose={closeSettings} />
|
||||
session={session} host={launcher.host} java={launcher.java} requiredJava={metadata?.javaMajor} onOnboard={onboard} onClose={closeSettings} />
|
||||
{legacyOpen && <LegacyModsDialog profileId={selected.profileId} onClose={() => setLegacyOpen(false)} onChanged={() => { void launcher.refreshProfile(selected.profileId) }} />}
|
||||
<RecoveryCodesModal codes={session.recoveryCodes} onAcknowledge={session.acknowledgeRecoveryCodes} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 }) {
|
||||
export function AccountSettings({ session, locked, onOnboard }: { session: ReturnType<typeof useAccount>; locked: boolean; onOnboard?: (nickname: string) => Promise<void> }) {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [registering, setRegistering] = useState(false)
|
||||
@@ -49,11 +49,17 @@ export function AccountSettings({ session, locked }: { session: ReturnType<typeo
|
||||
<small>Подтвердите владение ником на сервере Aeronautics.</small>
|
||||
</label>
|
||||
<button className="setting-row" type="submit" disabled={disabled || session.linking}>
|
||||
<span>{session.linking ? 'Ожидаем подтверждения…' : 'Привязать ник'}</span>
|
||||
<span>{session.linking ? 'Ожидаем подтверждения…' : 'Привязать ник через открытую игру'}</span>
|
||||
</button>
|
||||
{onOnboard && <button className="setting-row" type="button" disabled={disabled || session.linking || !/^[A-Za-z0-9_]{3,16}$/.test(nickname)}
|
||||
onClick={() => { void onOnboard(nickname) }}><span>Установить и войти для подтверждения</span></button>}
|
||||
<p className="account-hint">Нужна действующая проходка и ник в whitelist. Новый ник: /register, существующий: /login с игровым паролем. После входа выполните команду подтверждения.</p>
|
||||
</form>
|
||||
)}
|
||||
{account && linkedNickname && <div className="setting-row static"><span>Игровой ник</span><small>{linkedNickname}</small></div>}
|
||||
{account && linkedNickname && <>
|
||||
<div className="setting-row static"><span>Игровой ник</span><small>{linkedNickname}</small></div>
|
||||
<button className="setting-row" type="button" disabled={disabled || session.linking} onClick={() => { void session.startLink(linkedNickname) }}><span>Повторно подтвердить этот ник в игре</span></button>
|
||||
</>}
|
||||
{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>}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { native } from '../services/native'
|
||||
import { errorMessage } from '../services/async'
|
||||
import type { LegacyMod } from '../types/launcher'
|
||||
|
||||
export function LegacyModsDialog({ profileId, onClose, onChanged }: { profileId: string; onClose: () => void; onChanged: () => void }) {
|
||||
const [files, setFiles] = useState<LegacyMod[]>([])
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [busy, setBusy] = useState(true)
|
||||
const [moving, setMoving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [backup, setBackup] = useState<string | null>(null)
|
||||
const dialog = useRef<HTMLElement>(null)
|
||||
const closeButton = useRef<HTMLButtonElement>(null)
|
||||
const request = useRef(0)
|
||||
const moveInFlight = useRef(false)
|
||||
const mounted = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true
|
||||
const previous = document.activeElement
|
||||
closeButton.current?.focus()
|
||||
return () => {
|
||||
mounted.current = false
|
||||
if (previous instanceof HTMLElement && previous.isConnected) previous.focus()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const current = ++request.current
|
||||
setFiles([]); setSelected(new Set()); setBackup(null); setError(null); setBusy(true)
|
||||
void native.legacyMods(profileId).then((value) => { if (request.current === current) setFiles(value) })
|
||||
.catch((reason) => { if (request.current === current) setError(errorMessage(reason, 'Не удалось проверить моды')) })
|
||||
.finally(() => { if (request.current === current) setBusy(false) })
|
||||
return () => { request.current++ }
|
||||
}, [profileId])
|
||||
|
||||
const move = async () => {
|
||||
if (busy || moveInFlight.current || !selected.size) return
|
||||
const current = request.current
|
||||
moveInFlight.current = true
|
||||
setBusy(true); setMoving(true); setError(null)
|
||||
dialog.current?.focus()
|
||||
try {
|
||||
const result = await native.backupLegacyMods(profileId, files.filter((f) => selected.has(f.path)).map(({path,sha256}) => ({path,sha256})))
|
||||
moveInFlight.current = false
|
||||
if (mounted.current) setMoving(false)
|
||||
if (request.current !== current) return
|
||||
setBackup(result.backupRoot); setSelected(new Set())
|
||||
onChanged()
|
||||
const remaining = await native.legacyMods(profileId)
|
||||
if (request.current === current) setFiles(remaining)
|
||||
} catch (reason) {
|
||||
if (request.current === current) setError(errorMessage(reason, 'Не удалось перенести выбранные моды'))
|
||||
} finally {
|
||||
moveInFlight.current = false
|
||||
if (mounted.current) setMoving(false)
|
||||
if (request.current === current) setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="legacy-overlay"><section ref={dialog} className="legacy-dialog" role="dialog" aria-modal="true"
|
||||
aria-labelledby="legacy-title" aria-describedby="legacy-description" tabIndex={-1}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault(); event.stopPropagation()
|
||||
if (!moveInFlight.current) onClose()
|
||||
}
|
||||
if (event.key !== 'Tab') return
|
||||
const elements = dialog.current?.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled)')
|
||||
const first = elements?.[0]
|
||||
const last = elements?.[elements.length - 1]
|
||||
if (!first) { event.preventDefault(); dialog.current?.focus() }
|
||||
else if (event.shiftKey && (document.activeElement === first || document.activeElement === dialog.current)) {
|
||||
event.preventDefault(); last?.focus()
|
||||
} else if (!event.shiftKey && (document.activeElement === last || document.activeElement === dialog.current)) {
|
||||
event.preventDefault(); first.focus()
|
||||
}
|
||||
}}>
|
||||
<h2 id="legacy-title">Проверка старых и изменённых модов</h2>
|
||||
<p id="legacy-description">Эти файлы сохранены: их происхождение или содержимое отличается от ожидаемого. Здесь могут быть ваши моды. Выберите только те, которые хотите убрать из сборки в резервную копию. По умолчанию ничего не выбрано.</p>
|
||||
{busy && <p role="status">{moving ? 'Переносим выбранные файлы в резервную копию…' : 'Проверяем файлы…'}</p>}
|
||||
{!busy && files.length === 0 && <p>Файлов для ручного разбора нет.</p>}
|
||||
<div className="legacy-list">{files.map((file) => <label key={file.path}>
|
||||
<input type="checkbox" checked={selected.has(file.path)} disabled={busy} onChange={(e) => setSelected((old) => {
|
||||
const next = new Set(old); if (e.target.checked) next.add(file.path); else next.delete(file.path); return next
|
||||
})} />
|
||||
<span><strong>{file.path}</strong><small>{(file.size / 1024 / 1024).toFixed(1)} МБ · {file.reason === 'changed_managed' ? 'Изменён после установки' : 'Неизвестное происхождение'}</small>
|
||||
<small>SHA-256: {file.sha256}</small></span>
|
||||
</label>)}</div>
|
||||
{backup && <p role="status">Копии сохранены: {backup}. Теперь можно повторить проверку сборки.</p>}
|
||||
{error && <p className="status-error" role="alert">{error}</p>}
|
||||
<div className="legacy-actions"><button disabled={busy || selected.size === 0} onClick={() => { void move() }}>Перенести выбранные ({selected.size}) в резервную копию</button>
|
||||
<button ref={closeButton} disabled={moving} onClick={onClose}>Закрыть</button></div>
|
||||
</section></div>
|
||||
}
|
||||
@@ -18,6 +18,7 @@ interface PlayDockProps {
|
||||
repairDisabled: boolean
|
||||
onPrimary: () => void
|
||||
onRepair: () => void
|
||||
onLegacy: () => void
|
||||
}
|
||||
|
||||
export function PlayDock(props: PlayDockProps) {
|
||||
@@ -41,7 +42,9 @@ export function PlayDock(props: PlayDockProps) {
|
||||
} else {
|
||||
title = needsLogin ? 'Нужен вход ShaCraft' : props.needsLink ? 'Нужно привязать ник' : profile?.status === 'checking' ? 'Проверяем сборку'
|
||||
: profile?.inspection?.upToDate ? 'Сборка готова' : 'Требуется проверка'
|
||||
detail = profile?.inspection ? `${profile.inspection.managedFiles} файлов под контролем` : 'Проверяем локальные файлы'
|
||||
detail = profile?.inspection
|
||||
? `${profile.inspection.upToDate ? 'Проверено файлов сборки' : 'Файлов в сборке'}: ${profile.inspection.managedFiles}`
|
||||
: 'Проверяем локальные файлы'
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -58,7 +61,8 @@ export function PlayDock(props: PlayDockProps) {
|
||||
<span><Globe2 size={15} /> {server.version}</span>
|
||||
<span><Gauge size={15} /> {memoryGb} ГБ памяти</span>
|
||||
</div>
|
||||
<button className="repair-button" onClick={props.onRepair} disabled={props.repairDisabled} aria-label="Проверить файлы"><RotateCcw size={19} /></button>
|
||||
<button className="legacy-button" onClick={props.onLegacy} disabled={props.repairDisabled}>Разобрать моды</button>
|
||||
<button className="repair-button" onClick={props.onRepair} disabled={props.repairDisabled} aria-label="Проверить сборку и восстановить игру" title="Проверить сборку и восстановить игру"><RotateCcw size={19} /></button>
|
||||
<button className="play-button" disabled={props.primaryDisabled} onClick={props.onPrimary}>
|
||||
<Play size={21} fill="currentColor" /><span>{props.label}</span>
|
||||
</button>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Users } from 'lucide-react'
|
||||
import { isNative } from '../services/native'
|
||||
import type { Server, ServerStatus } from '../types/launcher'
|
||||
|
||||
export function ServerStage({ server, status, children }: { server: Server; status: ServerStatus | null; children: ReactNode }) {
|
||||
export function ServerStage({ server, status, javaMajor, children }: { server: Server; status: ServerStatus | null; javaMajor?: number; 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 ? 'Сервер доступен' : 'Сервер недоступен'
|
||||
@@ -17,7 +17,7 @@ export function ServerStage({ server, status, children }: { server: Server; stat
|
||||
<p>{server.kicker}</p><h1>{server.name}</h1><h2>{server.subtitle}</h2>
|
||||
<dl className="hero-meta">
|
||||
<div><dt>Загрузчик</dt><dd>{server.loader}</dd></div>
|
||||
<div><dt>Java</dt><dd>Версия 21</dd></div>
|
||||
<div><dt>Java</dt><dd>{javaMajor ? `Версия ${javaMajor}` : 'По подписанной сборке'}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
{children}
|
||||
|
||||
@@ -13,9 +13,11 @@ interface SettingsDrawerProps {
|
||||
preferences: ReturnType<typeof useSettings>
|
||||
session: ReturnType<typeof useAccount>
|
||||
onClose: () => void
|
||||
requiredJava?: number
|
||||
onOnboard?: (nickname: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function SettingsDrawer({ open, locked, host, java, preferences, session, onClose }: SettingsDrawerProps) {
|
||||
export function SettingsDrawer({ open, locked, host, java, preferences, session, onClose, requiredJava, onOnboard }: SettingsDrawerProps) {
|
||||
const { settings, loaded, saving, error } = preferences
|
||||
const closeButton = useRef<HTMLButtonElement>(null)
|
||||
useEffect(() => {
|
||||
@@ -53,19 +55,20 @@ export function SettingsDrawer({ open, locked, host, java, preferences, session,
|
||||
onChange={(event) => preferences.updateRam(Number(event.target.value))} />
|
||||
<small>Для Aeronautics рекомендуется 6 ГБ</small>
|
||||
</label>
|
||||
<AccountSettings session={session} locked={locked || saving} />
|
||||
<AccountSettings session={session} locked={locked || saving} onOnboard={onOnboard} />
|
||||
<div className="setting-row static"><span><FolderOpen />Папка игры</span><small>{host ? 'В каталоге лаунчера' : 'Определяется…'}</small></div>
|
||||
<div className="setting-row static">
|
||||
<span><Wrench />Java</span>
|
||||
<small>{java === undefined ? host ? 'Проверяем…' : 'Проверяется в приложении'
|
||||
: java?.major === 21 ? 'Java 21 найдена' : java ? `Нужна Java 21 · найдена ${java.major}`
|
||||
: 'Лаунчер установит Java 21 автоматически'}</small>
|
||||
: requiredJava === undefined ? 'Требование Java уточняется по сборке'
|
||||
: java?.major === requiredJava ? `Java ${requiredJava} найдена` : java ? `Нужна Java ${requiredJava} · найдена ${java.major}`
|
||||
: `Лаунчер установит Java ${requiredJava} автоматически`}</small>
|
||||
</div>
|
||||
<div className="settings-feedback" aria-live="polite">
|
||||
{error && <><p className="status-error">{error}</p><button onClick={preferences.retry} disabled={locked || saving}>Повторить</button></>}
|
||||
{saving && <p>Сохраняем настройки…</p>}
|
||||
</div>
|
||||
<div className="drawer-note">{host ? `Данные лаунчера: ${host.dataDir}` : 'Java 21 будет управляться лаунчером автоматически.'}</div>
|
||||
<div className="drawer-note">{host ? `Данные лаунчера: ${host.dataDir}` : 'Java будет подобрана по подписанной сборке.'}</div>
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -3,6 +3,10 @@ import { test } from 'node:test'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { AccountSettings } from './AccountSettings'
|
||||
import { RecoveryCodesModal } from './RecoveryCodesModal'
|
||||
import { LegacyModsDialog } from './LegacyModsDialog'
|
||||
import { PlayDock } from './PlayDock'
|
||||
import { launchAccess } from '../state/account'
|
||||
import { servers } from '../data/servers'
|
||||
import type { useAccount } from '../hooks/useAccount'
|
||||
|
||||
function session(): ReturnType<typeof useAccount> {
|
||||
@@ -10,6 +14,7 @@ function session(): ReturnType<typeof useAccount> {
|
||||
account: null, error: null, busy: false, recoveryCodes: [], linkMessage: null,
|
||||
linking: false, linkedNickname: null, authenticate: async () => true,
|
||||
logout: async () => {}, startLink: async () => {}, clearError: () => {},
|
||||
acceptChallenge: () => {},
|
||||
acknowledgeRecoveryCodes: () => {},
|
||||
}
|
||||
}
|
||||
@@ -42,3 +47,50 @@ test('recovery codes render only until explicitly acknowledged', () => {
|
||||
match(html, /Я сохранил коды/)
|
||||
equal(renderToStaticMarkup(<RecoveryCodesModal codes={[]} onAcknowledge={() => {}} />), '')
|
||||
})
|
||||
|
||||
test('onboarding is visible only for a signed-in unlinked account and needs an explicit nickname', () => {
|
||||
const onOnboard = async () => {}
|
||||
const unlinked = { username: 'website_login', links: [] }
|
||||
const html = renderToStaticMarkup(<AccountSettings session={{ ...session(), account: unlinked }} locked={false} onOnboard={onOnboard} />)
|
||||
match(html, /Установить и войти для подтверждения/)
|
||||
match(html, /<button[^>]*type="button"[^>]*disabled=""[^>]*><span>Установить и войти для подтверждения/)
|
||||
match(html, /minLength="3" maxLength="16" pattern="\[A-Za-z0-9_\]\{3,16\}" required=""/)
|
||||
equal(launchAccess(unlinked), 'link')
|
||||
|
||||
const signedOut = renderToStaticMarkup(<AccountSettings session={session()} locked={false} onOnboard={onOnboard} />)
|
||||
doesNotMatch(signedOut, /Установить и войти для подтверждения/)
|
||||
const linked = { username: 'website_login', links: [{ server_id: 'aoc', mc_username: 'Bound_Name' }] }
|
||||
const linkedHtml = renderToStaticMarkup(<AccountSettings session={{ ...session(), account: linked, linkedNickname: 'Bound_Name' }} locked={false} onOnboard={onOnboard} />)
|
||||
doesNotMatch(linkedHtml, /Установить и войти для подтверждения/)
|
||||
equal(launchAccess(linked), 'ready')
|
||||
})
|
||||
|
||||
test('a verified pack does not make an unlinked account ready for normal play', () => {
|
||||
const access = launchAccess({ username: 'website_login', links: [] })
|
||||
const html = renderToStaticMarkup(<PlayDock server={servers[0]} operation={{ phase: 'idle' }}
|
||||
profile={{ status: 'checked', error: null, inspection: { root: '/test/profile', managedFiles: 12, missingFiles: 0, mismatchedFiles: 0, upToDate: true, legacyFiles: 2 } }}
|
||||
memoryGb={6} native needsLogin={access === 'login'} needsLink={access === 'link'} error={null}
|
||||
label="Привязать ник" primaryDisabled={false} repairDisabled={false} onPrimary={() => {}} onRepair={() => {}} onLegacy={() => {}} />)
|
||||
match(html, /Нужно привязать ник/)
|
||||
doesNotMatch(html, /Сборка готова|файлов под контролем/)
|
||||
match(html, /Проверено файлов сборки: 12/)
|
||||
})
|
||||
|
||||
test('incomplete inspection does not claim that all pack files were verified', () => {
|
||||
const html = renderToStaticMarkup(<PlayDock server={servers[0]} operation={{ phase: 'idle' }}
|
||||
profile={{ status: 'checked', error: null, inspection: { root: '/test/profile', managedFiles: 12, missingFiles: 1, mismatchedFiles: 2, upToDate: false } }}
|
||||
memoryGb={6} native needsLogin={false} needsLink={false} error={null}
|
||||
label="Проверить" primaryDisabled={false} repairDisabled={false} onPrimary={() => {}} onRepair={() => {}} onLegacy={() => {}} />)
|
||||
match(html, /Требуется проверка/)
|
||||
match(html, /Файлов в сборке: 12/)
|
||||
doesNotMatch(html, /Проверено файлов сборки|файлов под контролем/)
|
||||
})
|
||||
|
||||
test('legacy review opens with no selected files and remains closable while its list is loading', () => {
|
||||
const html = renderToStaticMarkup(<LegacyModsDialog profileId="aeronautics" onClose={() => {}} onChanged={() => {}} />)
|
||||
match(html, /role="dialog" aria-modal="true" aria-labelledby="legacy-title" aria-describedby="legacy-description"/)
|
||||
match(html, /По умолчанию ничего не выбрано/)
|
||||
match(html, /<button disabled="">Перенести выбранные \(0\) в резервную копию<\/button>/)
|
||||
match(html, /<button>Закрыть<\/button>/)
|
||||
doesNotMatch(html, /checked=""/)
|
||||
})
|
||||
|
||||
+2
-2
@@ -8,8 +8,8 @@ export const servers: readonly [Server, ...Server[]] = [
|
||||
kicker: 'Основная сборка',
|
||||
name: 'Aeronautics',
|
||||
subtitle: 'Строй корабли. Поднимай города в небо.',
|
||||
version: '1.21.1 · NeoForge 21.1.248',
|
||||
loader: 'NeoForge 21.1.248',
|
||||
version: 'Версия уточняется',
|
||||
loader: 'По подписанной сборке',
|
||||
profileId: 'aeronautics',
|
||||
},
|
||||
]
|
||||
|
||||
+11
-7
@@ -3,7 +3,7 @@ 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'
|
||||
import type { ShaCraftAccount, LinkChallenge } from '../types/launcher'
|
||||
|
||||
interface PendingLink {
|
||||
challengeId: number
|
||||
@@ -125,6 +125,14 @@ export function useAccount() {
|
||||
}
|
||||
}
|
||||
|
||||
const acceptChallenge = (started: LinkChallenge) => {
|
||||
requests.current.invalidate()
|
||||
const current = requests.current.capture()
|
||||
setLinkMessage(`${started.registered_on_server ? 'Войдите на Aeronautics: /login <пароль>.' : 'Войдите на Aeronautics: /register <пароль> <пароль>.'} Затем подтвердите свой аккаунт командой /shacraft link ${started.challenge_id} ${started.proof_code}`)
|
||||
setChallenge({ challengeId: started.challenge_id,
|
||||
expiresAt: Date.now() + started.expires_in_seconds * 1000, isCurrent: current })
|
||||
}
|
||||
|
||||
const startLink = async (nickname: string) => {
|
||||
if (!isNative() || pending.current || challenge || !account) return
|
||||
if (!isValidNickname(nickname)) {
|
||||
@@ -139,11 +147,7 @@ export function useAccount() {
|
||||
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 })
|
||||
acceptChallenge(started)
|
||||
} catch (reason) {
|
||||
setLinkMessage(errorMessage(reason, 'Не удалось начать привязку'))
|
||||
} finally {
|
||||
@@ -154,7 +158,7 @@ export function useAccount() {
|
||||
|
||||
return {
|
||||
account, error, busy, recoveryCodes, linkMessage, linking: challenge !== null,
|
||||
linkedNickname: linkedNickname(account), authenticate, logout, startLink,
|
||||
linkedNickname: linkedNickname(account), authenticate, logout, startLink, acceptChallenge,
|
||||
clearError: () => setError(null),
|
||||
acknowledgeRecoveryCodes: () => setRecoveryCodes([]),
|
||||
}
|
||||
|
||||
+28
-42
@@ -4,7 +4,7 @@ import { errorMessage } from '../services/async'
|
||||
import { isNative, native, watchGame } from '../services/native'
|
||||
import { gameReducer, initialGameState } from '../state/game'
|
||||
import { profilesReducer } from '../state/profiles'
|
||||
import type { JavaInstallation, NativeHost } from '../types/launcher'
|
||||
import type { JavaInstallation, NativeHost, ProfileMetadata, PreparationResult } from '../types/launcher'
|
||||
|
||||
export function useLauncher() {
|
||||
const [host, setHost] = useState<NativeHost | null>(null)
|
||||
@@ -14,6 +14,7 @@ export function useLauncher() {
|
||||
const [game, dispatch] = useReducer(gameReducer, initialGameState)
|
||||
const [eventsReady, setEventsReady] = useState(false)
|
||||
const busy = useRef(false)
|
||||
const [metadata, setMetadata] = useState<Record<string, ProfileMetadata>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNative()) return
|
||||
@@ -41,6 +42,9 @@ export function useLauncher() {
|
||||
})
|
||||
for (const server of servers) {
|
||||
const profileId = server.profileId
|
||||
void native.metadata(profileId).then((value) => {
|
||||
if (active) setMetadata((old) => ({ ...old, [profileId]: value }))
|
||||
}).catch(() => { /* Missing verified metadata is shown as unknown. */ })
|
||||
updateProfile({ type: 'check', profileId })
|
||||
void native.inspectProfile(profileId).then((inspection) => {
|
||||
if (active) updateProfile({ type: 'checked', profileId, inspection })
|
||||
@@ -51,56 +55,38 @@ export function useLauncher() {
|
||||
return () => { active = false; subscription.dispose() }
|
||||
}, [])
|
||||
|
||||
const repair = async (profileId: string) => {
|
||||
if (!isNative() || busy.current || game.operation.phase !== 'idle' || profiles[profileId]?.status === 'checking') return
|
||||
const prepare = async (profileId: string, mode: 'repair' | 'play' | 'onboarding', nickname?: string): Promise<PreparationResult | null> => {
|
||||
if (!isNative() || !eventsReady || busy.current || game.operation.phase !== 'idle') return null
|
||||
busy.current = true
|
||||
dispatch({ type: 'sync', profileId })
|
||||
// A repair may replace only some files before failing. Never keep an older
|
||||
// up-to-date inspection as permission to launch that partial installation.
|
||||
dispatch({ type: 'install', profileId })
|
||||
updateProfile({ type: 'check', profileId })
|
||||
try {
|
||||
const result = await native.syncProfile(profileId)
|
||||
updateProfile({ type: 'checked', profileId,
|
||||
inspection: { root: result.root, managedFiles: result.downloadedFiles + result.reusedFiles,
|
||||
missingFiles: 0, mismatchedFiles: 0, upToDate: true },
|
||||
})
|
||||
dispatch({ type: 'synced', profileId })
|
||||
// The native command owns one snapshot and one lock across every stage.
|
||||
const result = mode === 'repair' ? await native.installGame(profileId)
|
||||
: mode === 'onboarding' ? await native.launchOnboarding(profileId, nickname ?? '')
|
||||
: await native.launchGame(profileId)
|
||||
updateProfile({ type: 'checked', profileId, inspection: result.inspection })
|
||||
setMetadata((old) => ({ ...old, [profileId]: result.metadata }))
|
||||
if (mode === 'repair') dispatch({ type: 'repaired', profileId })
|
||||
else dispatch({ type: 'started', profileId })
|
||||
return result
|
||||
} catch (reason) {
|
||||
const error = errorMessage(reason, 'Не удалось синхронизировать сборку')
|
||||
const error = errorMessage(reason, mode === 'repair' ? 'Не удалось восстановить игру' : 'Не удалось запустить игру')
|
||||
updateProfile({ type: 'failed', profileId, error })
|
||||
dispatch({ type: 'failed', error })
|
||||
} finally {
|
||||
busy.current = false
|
||||
}
|
||||
return null
|
||||
} finally { busy.current = false }
|
||||
}
|
||||
|
||||
const launch = async (profileId: string) => {
|
||||
if (!isNative() || !eventsReady || busy.current || game.operation.phase !== 'idle') return
|
||||
busy.current = true
|
||||
dispatch({ type: 'sync', profileId })
|
||||
const refreshProfile = async (profileId: string) => {
|
||||
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) {
|
||||
const error = errorMessage(reason, 'Не удалось запустить игру')
|
||||
updateProfile({ type: 'failed', profileId, error })
|
||||
dispatch({ type: 'failed', error })
|
||||
} finally {
|
||||
busy.current = false
|
||||
}
|
||||
try { updateProfile({ type: 'checked', profileId, inspection: await native.inspectProfile(profileId) }) }
|
||||
catch (reason) { updateProfile({ type: 'failed', profileId, error: errorMessage(reason, 'Не удалось проверить сборку') }) }
|
||||
}
|
||||
|
||||
return { host, java, environmentError, profiles, game, eventsReady, repair, launch }
|
||||
return { host, java, environmentError, profiles, metadata, game, eventsReady, refreshProfile,
|
||||
repair: (profileId: string) => prepare(profileId, 'repair'),
|
||||
launch: (profileId: string) => prepare(profileId, 'play'),
|
||||
onboard: (profileId: string, nickname: string) => prepare(profileId, 'onboarding', nickname),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createSerialQueue, createSubscription, singleFlight } from './async'
|
||||
import type {
|
||||
GameExitedPayload, InstallProgressPayload, JavaInstallation, LauncherSettings,
|
||||
LinkChallenge, LinkStatus, NativeHost, ProfileInspection, ServerStatus,
|
||||
ShaCraftAccount, ShaCraftLoginResult, SyncResult,
|
||||
ShaCraftAccount, ShaCraftLoginResult, SyncResult, ProfileMetadata, PreparationResult, LegacyMod, LegacySelection, LegacyBackup,
|
||||
} from '../types/launcher'
|
||||
|
||||
export const isNative = () => typeof window !== 'undefined' && isTauri()
|
||||
@@ -16,6 +16,9 @@ const restoreAccount = singleFlight(() => accountRequests.enqueue(() => invoke<S
|
||||
// commands directly and cannot pass arbitrary URLs or filesystem paths.
|
||||
export const native = {
|
||||
host: () => invoke<NativeHost>('native_host'),
|
||||
metadata: (profileId: string) => invoke<ProfileMetadata>('profile_metadata', { profileId }),
|
||||
legacyMods: (profileId: string) => invoke<LegacyMod[]>('legacy_mods', { profileId }),
|
||||
backupLegacyMods: (profileId: string, selections: LegacySelection[]) => invoke<LegacyBackup>('backup_legacy_mods', { profileId, selections }),
|
||||
loadSettings: () => invoke<LauncherSettings>('load_settings'),
|
||||
saveSettings: (settings: LauncherSettings) => invoke<LauncherSettings>('save_settings', { settings }),
|
||||
detectJava: () => invoke<JavaInstallation | null>('detect_java'),
|
||||
@@ -28,8 +31,9 @@ export const native = {
|
||||
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 }),
|
||||
installGame: (profileId: string) => invoke<PreparationResult>('ensure_game_installed', { profileId }),
|
||||
launchGame: (profileId: string) => invoke<PreparationResult>('launch_game', { profileId }),
|
||||
launchOnboarding: (profileId: string, nickname: string) => invoke<PreparationResult>('launch_onboarding', { profileId, nickname }),
|
||||
}
|
||||
|
||||
export const windowControls = {
|
||||
|
||||
@@ -32,6 +32,43 @@ test('a fast child exit cannot be overwritten by a late launch acknowledgement',
|
||||
match(lateAcknowledgement.error ?? '', /кодом 1/)
|
||||
})
|
||||
|
||||
test('a unified native launch can start directly from preparation, while repair only returns to idle', () => {
|
||||
equal(gameReducer(installing, { type: 'started', profileId }).operation.phase, 'running')
|
||||
deepStrictEqual(gameReducer(installing, { type: 'repaired', profileId }), initialGameState)
|
||||
const running = gameReducer(installing, { type: 'started', profileId })
|
||||
equal(gameReducer(running, { type: 'repaired', profileId }), running)
|
||||
equal(gameReducer(launching, { type: 'repaired', profileId }), launching)
|
||||
})
|
||||
|
||||
test('a child exit received during preparation survives late progress and the native launch result', () => {
|
||||
for (const exitCode of [0, 1, null]) {
|
||||
const exited = gameReducer(installing, { type: 'exited', result: { profileId, exitCode } })
|
||||
equal(exited.operation.phase, 'idle')
|
||||
if (exitCode === 0) equal(exited.error, null)
|
||||
else match(exited.error ?? '', exitCode === null ? /без кода выхода/ : /кодом 1/)
|
||||
const lateProgress = gameReducer(exited, { type: 'progress', progress: { stage: 'launch', currentBytes: 0, totalBytes: 0 } })
|
||||
equal(lateProgress, exited)
|
||||
equal(gameReducer(lateProgress, { type: 'started', profileId }), exited)
|
||||
equal(gameReducer(exited, { type: 'repaired', profileId }), exited)
|
||||
}
|
||||
})
|
||||
|
||||
test('completion and launch events for another profile cannot advance or release preparation', () => {
|
||||
equal(gameReducer(installing, { type: 'repaired', profileId: 'other' }), installing)
|
||||
equal(gameReducer(installing, { type: 'launch', profileId: 'other' }), installing)
|
||||
equal(gameReducer(installing, { type: 'started', profileId: 'other' }), installing)
|
||||
equal(gameReducer(installing, { type: 'exited', result: { profileId: 'other', exitCode: 0 } }), installing)
|
||||
equal(gameReducer(launching, { type: 'started', profileId: 'other' }), launching)
|
||||
})
|
||||
|
||||
test('the native launch stage locks the launching state until a matching child event', () => {
|
||||
const nativeLaunching = gameReducer(installing, { type: 'progress', progress: { stage: 'launch', currentBytes: 0, totalBytes: 0 } })
|
||||
deepStrictEqual(nativeLaunching, launching)
|
||||
equal(gameReducer(nativeLaunching, { type: 'repaired', profileId }), nativeLaunching)
|
||||
equal(gameReducer(nativeLaunching, { type: 'install', profileId }), nativeLaunching)
|
||||
equal(gameReducer(nativeLaunching, { type: 'started', profileId }).operation.phase, 'running')
|
||||
})
|
||||
|
||||
test('foreign exit events and late install progress cannot unlock a running game', () => {
|
||||
const running = gameReducer(launching, { type: 'started', profileId })
|
||||
equal(gameReducer(running, { type: 'exited', result: { profileId: 'other', exitCode: 0 } }), running)
|
||||
|
||||
+8
-2
@@ -13,6 +13,7 @@ export interface GameState {
|
||||
export type GameAction =
|
||||
| { type: 'sync'; profileId: string }
|
||||
| { type: 'synced'; profileId: string }
|
||||
| { type: 'repaired'; profileId: string }
|
||||
| { type: 'install'; profileId: string }
|
||||
| { type: 'progress'; progress: InstallProgressPayload }
|
||||
| { type: 'launch'; profileId: string }
|
||||
@@ -37,7 +38,10 @@ export function gameReducer(state: GameState, action: GameAction): GameState {
|
||||
case 'synced':
|
||||
return operation.phase === 'syncing' && operation.profileId === action.profileId
|
||||
? initialGameState : state
|
||||
case 'repaired':
|
||||
return operation.phase === 'installing' && operation.profileId === action.profileId ? initialGameState : state
|
||||
case 'progress':
|
||||
if (operation.phase === 'installing' && action.progress.stage === 'launch') return { ...state, operation: { phase: 'launching', profileId: operation.profileId } }
|
||||
return operation.phase === 'installing'
|
||||
? { ...state, operation: { ...operation, progress: action.progress } } : state
|
||||
case 'launch':
|
||||
@@ -45,10 +49,10 @@ export function gameReducer(state: GameState, action: GameAction): GameState {
|
||||
? { ...state, operation: { phase: 'launching', profileId: action.profileId } } : state
|
||||
case 'started':
|
||||
// A fast-exiting child can emit game-exited before invoke resolves.
|
||||
return operation.phase === 'launching' && operation.profileId === action.profileId
|
||||
return (operation.phase === 'launching' || operation.phase === 'installing') && operation.profileId === action.profileId
|
||||
? { ...state, operation: { phase: 'running', profileId: action.profileId } } : state
|
||||
case 'exited':
|
||||
if ((operation.phase !== 'launching' && operation.phase !== 'running') ||
|
||||
if ((operation.phase !== 'launching' && operation.phase !== 'running' && operation.phase !== 'installing') ||
|
||||
operation.profileId !== action.result.profileId) return state
|
||||
return {
|
||||
operation: { phase: 'idle' },
|
||||
@@ -62,6 +66,8 @@ export function gameReducer(state: GameState, action: GameAction): GameState {
|
||||
}
|
||||
|
||||
export const installStageLabels: Record<InstallProgressPayload['stage'], string> = {
|
||||
mods: 'Обновляем сборку',
|
||||
launch: 'Запускаем игру',
|
||||
java: 'Готовим Java',
|
||||
neoforge: 'Устанавливаем NeoForge',
|
||||
libraries: 'Скачиваем библиотеки',
|
||||
|
||||
@@ -176,3 +176,15 @@ button:disabled { cursor: default; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { transition-duration: .01ms !important; animation-duration: .01ms !important; }
|
||||
}
|
||||
|
||||
.legacy-button { background: transparent; color: var(--text-secondary, #b2bac7); border: 1px solid currentColor; border-radius: 8px; padding: 8px; font: inherit; font-size: 12px; cursor: pointer; }
|
||||
.legacy-overlay { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 20px; background: #000a; }
|
||||
.legacy-dialog { width: min(700px, 100%); max-height: 85vh; overflow: auto; padding: 24px; border-radius: 12px; background: #17201e; color: #e7eee9; }
|
||||
.legacy-dialog h2 { margin: 0 0 12px; font-size: 21px; }
|
||||
.legacy-dialog p { line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.legacy-list label { display: flex; gap: 12px; padding: 12px 0; border-bottom: 1px solid #ffffff20; }
|
||||
.legacy-list span { min-width: 0; overflow-wrap: anywhere; }
|
||||
.legacy-list small { display: block; margin-top: 5px; color: #bac5bd; }
|
||||
.legacy-actions { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 20px; }
|
||||
.legacy-actions button { font: inherit; padding: 10px 14px; cursor: pointer; }
|
||||
.account-hint { white-space: pre-wrap; overflow-wrap: anywhere; user-select: text; }
|
||||
|
||||
+24
-1
@@ -34,8 +34,28 @@ export interface ProfileInspection {
|
||||
missingFiles: number
|
||||
mismatchedFiles: number
|
||||
upToDate: boolean
|
||||
staleFiles?: number
|
||||
conflicts?: string[]
|
||||
pendingUpdate?: boolean
|
||||
legacyFiles?: number
|
||||
}
|
||||
|
||||
export interface ProfileMetadata {
|
||||
snapshot: string
|
||||
minecraftVersion: string
|
||||
loaderKind: string
|
||||
loaderVersion: string
|
||||
javaMajor: number
|
||||
}
|
||||
export interface PreparationResult {
|
||||
inspection: ProfileInspection
|
||||
metadata: ProfileMetadata
|
||||
onboarding: LinkChallenge | null
|
||||
}
|
||||
export interface LegacyMod { path: string; size: number; sha256: string; reason: string }
|
||||
export interface LegacySelection { path: string; sha256: string }
|
||||
export interface LegacyBackup { backupRoot: string; files: string[] }
|
||||
|
||||
export interface SyncResult {
|
||||
root: string
|
||||
downloadedFiles: number
|
||||
@@ -57,6 +77,9 @@ export interface LinkChallenge {
|
||||
challenge_id: number
|
||||
expires_in_seconds: number
|
||||
registered_on_server: boolean
|
||||
proof_code: string
|
||||
mc_username: string
|
||||
player_uuid: string
|
||||
}
|
||||
|
||||
export interface LinkStatus {
|
||||
@@ -71,7 +94,7 @@ export interface ServerStatus {
|
||||
}
|
||||
|
||||
export interface InstallProgressPayload {
|
||||
stage: 'java' | 'neoforge' | 'libraries' | 'assets'
|
||||
stage: 'mods' | 'java' | 'neoforge' | 'libraries' | 'assets' | 'launch'
|
||||
currentBytes: number
|
||||
totalBytes: number
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user