From 189068be63efbed740e43b1d52a38a0b4440a67d Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 9 Sep 2026 18:59:21 +0300 Subject: [PATCH] feat(ui): expose first-entry verification and explicit legacy mod recovery --- src/App.tsx | 21 +++++-- src/components/AccountSettings.tsx | 12 +++- src/components/LegacyModsDialog.tsx | 96 +++++++++++++++++++++++++++++ src/components/PlayDock.tsx | 8 ++- src/components/ServerStage.tsx | 4 +- src/components/SettingsDrawer.tsx | 13 ++-- src/components/account.test.tsx | 52 ++++++++++++++++ src/data/servers.ts | 4 +- src/hooks/useAccount.ts | 18 +++--- src/hooks/useLauncher.ts | 70 +++++++++------------ src/services/native.ts | 10 ++- src/state/game.test.ts | 37 +++++++++++ src/state/game.ts | 10 ++- src/styles.css | 12 ++++ src/types/launcher.ts | 25 +++++++- 15 files changed, 318 insertions(+), 74 deletions(-) create mode 100644 src/components/LegacyModsDialog.tsx diff --git a/src/App.tsx b/src/App.tsx index da97d00..d5f6e3b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(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 (
-
0}> +
0 || legacyOpen}> setSettingsOpen(true)} /> - - + { if (!repairDisabled) void launcher.repair(selected.profileId) }} /> + onLegacy={() => setLegacyOpen(true)} onPrimary={primary} onRepair={() => { if (!repairDisabled) void launcher.repair(selected.profileId) }} />
+ session={session} host={launcher.host} java={launcher.java} requiredJava={metadata?.javaMajor} onOnboard={onboard} onClose={closeSettings} /> + {legacyOpen && setLegacyOpen(false)} onChanged={() => { void launcher.refreshProfile(selected.profileId) }} />}
) diff --git a/src/components/AccountSettings.tsx b/src/components/AccountSettings.tsx index aad6ce9..90c9aac 100644 --- a/src/components/AccountSettings.tsx +++ b/src/components/AccountSettings.tsx @@ -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; locked: boolean }) { +export function AccountSettings({ session, locked, onOnboard }: { session: ReturnType; locked: boolean; onOnboard?: (nickname: string) => Promise }) { const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [registering, setRegistering] = useState(false) @@ -49,11 +49,17 @@ export function AccountSettings({ session, locked }: { session: ReturnTypeПодтвердите владение ником на сервере Aeronautics. + {onOnboard && } +

Нужна действующая проходка и ник в whitelist. Новый ник: /register, существующий: /login с игровым паролем. После входа выполните команду подтверждения.

)} - {account && linkedNickname &&
Игровой ник{linkedNickname}
} + {account && linkedNickname && <> +
Игровой ник{linkedNickname}
+ + } {session.linkMessage &&

{session.linkMessage}

} {session.error &&

{session.error}

} {account && } diff --git a/src/components/LegacyModsDialog.tsx b/src/components/LegacyModsDialog.tsx new file mode 100644 index 0000000..be1044d --- /dev/null +++ b/src/components/LegacyModsDialog.tsx @@ -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([]) + const [selected, setSelected] = useState>(new Set()) + const [busy, setBusy] = useState(true) + const [moving, setMoving] = useState(false) + const [error, setError] = useState(null) + const [backup, setBackup] = useState(null) + const dialog = useRef(null) + const closeButton = useRef(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
{ + if (event.key === 'Escape') { + event.preventDefault(); event.stopPropagation() + if (!moveInFlight.current) onClose() + } + if (event.key !== 'Tab') return + const elements = dialog.current?.querySelectorAll('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() + } + }}> +

Проверка старых и изменённых модов

+

Эти файлы сохранены: их происхождение или содержимое отличается от ожидаемого. Здесь могут быть ваши моды. Выберите только те, которые хотите убрать из сборки в резервную копию. По умолчанию ничего не выбрано.

+ {busy &&

{moving ? 'Переносим выбранные файлы в резервную копию…' : 'Проверяем файлы…'}

} + {!busy && files.length === 0 &&

Файлов для ручного разбора нет.

} +
{files.map((file) => )}
+ {backup &&

Копии сохранены: {backup}. Теперь можно повторить проверку сборки.

} + {error &&

{error}

} +
+
+
+} diff --git a/src/components/PlayDock.tsx b/src/components/PlayDock.tsx index 4d899e7..f168056 100644 --- a/src/components/PlayDock.tsx +++ b/src/components/PlayDock.tsx @@ -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) { {server.version} {memoryGb} ГБ памяти
- + + diff --git a/src/components/ServerStage.tsx b/src/components/ServerStage.tsx index ebfc846..fbb1aca 100644 --- a/src/components/ServerStage.tsx +++ b/src/components/ServerStage.tsx @@ -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

{server.kicker}

{server.name}

{server.subtitle}

Загрузчик
{server.loader}
-
Java
Версия 21
+
Java
{javaMajor ? `Версия ${javaMajor}` : 'По подписанной сборке'}
{children} diff --git a/src/components/SettingsDrawer.tsx b/src/components/SettingsDrawer.tsx index 04bce7a..556a3f4 100644 --- a/src/components/SettingsDrawer.tsx +++ b/src/components/SettingsDrawer.tsx @@ -13,9 +13,11 @@ interface SettingsDrawerProps { preferences: ReturnType session: ReturnType onClose: () => void + requiredJava?: number + onOnboard?: (nickname: string) => Promise } -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(null) useEffect(() => { @@ -53,19 +55,20 @@ export function SettingsDrawer({ open, locked, host, java, preferences, session, onChange={(event) => preferences.updateRam(Number(event.target.value))} /> Для Aeronautics рекомендуется 6 ГБ - +
Папка игры{host ? 'В каталоге лаунчера' : 'Определяется…'}
Java {java === undefined ? host ? 'Проверяем…' : 'Проверяется в приложении' - : java?.major === 21 ? 'Java 21 найдена' : java ? `Нужна Java 21 · найдена ${java.major}` - : 'Лаунчер установит Java 21 автоматически'} + : requiredJava === undefined ? 'Требование Java уточняется по сборке' + : java?.major === requiredJava ? `Java ${requiredJava} найдена` : java ? `Нужна Java ${requiredJava} · найдена ${java.major}` + : `Лаунчер установит Java ${requiredJava} автоматически`}
{error && <>

{error}

} {saving &&

Сохраняем настройки…

}
-
{host ? `Данные лаунчера: ${host.dataDir}` : 'Java 21 будет управляться лаунчером автоматически.'}
+
{host ? `Данные лаунчера: ${host.dataDir}` : 'Java будет подобрана по подписанной сборке.'}
) diff --git a/src/components/account.test.tsx b/src/components/account.test.tsx index 525c0ff..5dd8626 100644 --- a/src/components/account.test.tsx +++ b/src/components/account.test.tsx @@ -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 { @@ -10,6 +14,7 @@ function session(): ReturnType { 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( {}} />), '') }) + +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() + match(html, /Установить и войти для подтверждения/) + match(html, /]*type="button"[^>]*disabled=""[^>]*>Установить и войти для подтверждения/) + match(html, /minLength="3" maxLength="16" pattern="\[A-Za-z0-9_\]\{3,16\}" required=""/) + equal(launchAccess(unlinked), 'link') + + const signedOut = renderToStaticMarkup() + doesNotMatch(signedOut, /Установить и войти для подтверждения/) + const linked = { username: 'website_login', links: [{ server_id: 'aoc', mc_username: 'Bound_Name' }] } + const linkedHtml = renderToStaticMarkup() + 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( {}} onRepair={() => {}} onLegacy={() => {}} />) + match(html, /Нужно привязать ник/) + doesNotMatch(html, /Сборка готова|файлов под контролем/) + match(html, /Проверено файлов сборки: 12/) +}) + +test('incomplete inspection does not claim that all pack files were verified', () => { + const html = renderToStaticMarkup( {}} 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( {}} onChanged={() => {}} />) + match(html, /role="dialog" aria-modal="true" aria-labelledby="legacy-title" aria-describedby="legacy-description"/) + match(html, /По умолчанию ничего не выбрано/) + match(html, /