Add account admission tickets and nickname feedback for launcher 0.1.2

This commit is contained in:
Emil
2026-09-10 01:30:50 +03:00
parent 3f3be65a3f
commit bf43254c57
21 changed files with 730 additions and 160 deletions
+8 -1
View File
@@ -1,4 +1,5 @@
import { useCallback, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { FeedbackDialog, type Feedback } from './components/FeedbackDialog'
import { Library } from './components/Library'
import { RecoveryCodesModal } from './components/RecoveryCodesModal'
import { PlayDock } from './components/PlayDock'
@@ -18,6 +19,7 @@ export function App() {
const [selected, setSelected] = useState(servers[0])
const [settingsOpen, setSettingsOpen] = useState(false)
const [windowError, setWindowError] = useState<string | null>(null)
const [errorFeedback, setErrorFeedback] = useState<Feedback | null>(null)
const preferences = useSettings()
const session = useAccount()
const launcher = useLauncher()
@@ -35,6 +37,9 @@ export function App() {
(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
useEffect(() => {
if (error) setErrorFeedback({ kind: 'error', title: 'Ошибка лаунчера', message: error })
}, [error])
let label = 'Играть'
if (!desktop) label = 'В приложении'
@@ -73,6 +78,8 @@ export function App() {
<SettingsDrawer open={settingsOpen && !session.recoveryCodes.length} locked={busy} preferences={preferences}
session={session} host={launcher.host} java={launcher.java} onClose={closeSettings} />
<RecoveryCodesModal codes={session.recoveryCodes} onAcknowledge={session.acknowledgeRecoveryCodes} />
<FeedbackDialog feedback={session.recoveryCodes.length ? null : session.feedback ?? errorFeedback}
onDismiss={() => { session.dismissFeedback(); setErrorFeedback(null) }} />
</div>
)
}
+3 -3
View File
@@ -41,15 +41,15 @@ export function AccountSettings({ session, locked }: { session: ReturnType<typeo
</form>
)}
{account && !linkedNickname && (
<form onSubmit={(event) => { event.preventDefault(); if (!disabled) void session.startLink(nickname) }}>
<form noValidate 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>
<small>Свободный ник закрепляется за аккаунтом. Для старого игрового ника обратитесь к администратору.</small>
</label>
<button className="setting-row" type="submit" disabled={disabled || session.linking}>
<span>{session.linking ? 'Ожидаем подтверждения…' : 'Привязать ник'}</span>
<span>{session.linking ? 'Ожидаем подтверждения…' : busy ? 'Создаём проверку…' : 'Привязать ник'}</span>
</button>
</form>
)}
+34
View File
@@ -0,0 +1,34 @@
import { useEffect, useRef } from 'react'
export interface Feedback {
kind: 'info' | 'success' | 'error'
title: string
message: string
}
export function FeedbackDialog({ feedback, onDismiss }: { feedback: Feedback | null; onDismiss: () => void }) {
const dialog = useRef<HTMLDialogElement>(null)
const visible = feedback !== null
useEffect(() => {
const element = dialog.current
if (!visible || !element) return
const previous = document.activeElement
element.showModal()
return () => {
element.close()
if (previous instanceof HTMLElement && previous.isConnected) previous.focus()
}
}, [visible])
if (!feedback) return null
return (
<dialog ref={dialog} className={`feedback-dialog ${feedback.kind}`} aria-labelledby="feedback-title"
aria-describedby="feedback-message" onCancel={(event) => { event.preventDefault(); onDismiss() }}
onKeyDown={(event) => event.stopPropagation()}>
<div aria-live={feedback.kind === 'error' ? 'assertive' : 'polite'} aria-atomic="true">
<h2 id="feedback-title">{feedback.title}</h2>
<p id="feedback-message">{feedback.message}</p>
</div>
<button type="button" autoFocus onClick={onDismiss}>Понятно</button>
</dialog>
)
}
+1
View File
@@ -8,6 +8,7 @@ import type { useAccount } from '../hooks/useAccount'
function session(): ReturnType<typeof useAccount> {
return {
account: null, error: null, busy: false, recoveryCodes: [], linkMessage: null,
feedback: null, dismissFeedback: () => {},
linking: false, linkedNickname: null, authenticate: async () => true,
logout: async () => {}, startLink: async () => {}, clearError: () => {},
acknowledgeRecoveryCodes: () => {},
+35 -59
View File
@@ -1,24 +1,23 @@
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { Feedback } from '../components/FeedbackDialog'
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<ShaCraftAccount | null | undefined>(isNative() ? undefined : null)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([])
const [challenge, setChallenge] = useState<PendingLink | null>(null)
const [linkMessage, setLinkMessage] = useState<string | null>(null)
const [feedback, setFeedback] = useState<Feedback | null>(null)
const reportLink = useCallback((message: string, kind: Feedback['kind'] = 'info') => {
setLinkMessage(message)
setFeedback({ kind, title: kind === 'error' ? 'Не удалось привязать ник' : 'Привязка игрового ника', message })
}, [])
const pending = useRef(false)
const requests = useRef(createRequestScope())
@@ -35,44 +34,6 @@ export function useAccount() {
return () => { active = false; requests.current.invalidate() }
}, [])
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 authenticate = async (username: string, password: string, register: boolean) => {
if (pending.current || account === undefined) return false
if (!validCredentials(username, password)) {
@@ -92,7 +53,6 @@ export function useAccount() {
const result = await native.authenticate(username, password, register)
if (!currentRequest()) return false
setAccount(result.account)
setChallenge(null)
setLinkMessage(null)
setRecoveryCodes(result.recoveryCodes)
return true
@@ -109,7 +69,6 @@ export function useAccount() {
if (!isNative() || pending.current) return
pending.current = true
requests.current.invalidate()
setChallenge(null)
setLinkMessage(null)
setBusy(true)
setError(null)
@@ -126,26 +85,42 @@ export function useAccount() {
}
const startLink = async (nickname: string) => {
if (!isNative() || pending.current || challenge || !account) return
if (pending.current) return
if (!isNative()) {
reportLink('Привязка доступна в приложении лаунчера.', 'error')
return
}
if (!account) {
reportLink('Войдите в аккаунт ShaCraft, затем повторите привязку.', 'error')
return
}
nickname = nickname.trim()
if (!isValidNickname(nickname)) {
setLinkMessage('Ник: 3–16 латинских букв, цифр или _')
reportLink('Ник: 3–16 латинских букв, цифр или _', 'error')
return
}
pending.current = true
setBusy(true)
setLinkMessage('Создаём проверку…')
setError(null)
reportLink('Проверяем аккаунт и закрепляем ник…')
requests.current.invalidate()
const currentRequest = requests.current.capture()
try {
const started = await native.startLink(nickname)
const refreshed = await native.getAccount()
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 })
setAccount(refreshed)
if (!refreshed) {
reportLink('Сессия завершена или аккаунт удалён. Войдите в ShaCraft снова; если аккаунт удалён, создайте новый.', 'error')
return
}
const linkedAccount = await native.claimNickname(nickname)
if (!currentRequest()) return
setAccount(linkedAccount)
const confirmed = linkedNickname(linkedAccount)
reportLink(confirmed ? `Ник ${confirmed} закреплён за аккаунтом. Теперь можно запускать игру.`
: 'Не удалось получить закреплённый ник. Войдите снова.', confirmed ? 'success' : 'error')
} catch (reason) {
setLinkMessage(errorMessage(reason, 'Не удалось начать привязку'))
if (currentRequest()) reportLink(errorMessage(reason, 'Не удалось начать привязку'), 'error')
} finally {
pending.current = false
setBusy(false)
@@ -153,7 +128,8 @@ export function useAccount() {
}
return {
account, error, busy, recoveryCodes, linkMessage, linking: challenge !== null,
account, error, busy, recoveryCodes, linkMessage, linking: false,
feedback, dismissFeedback: () => setFeedback(null),
linkedNickname: linkedNickname(account), authenticate, logout, startLink,
clearError: () => setError(null),
acknowledgeRecoveryCodes: () => setRecoveryCodes([]),
+1
View File
@@ -26,6 +26,7 @@ export const native = {
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 })),
claimNickname: (nickname: string) => accountRequests.enqueue(() => invoke<ShaCraftAccount>('shacraft_claim_nickname', { 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 }),
+8
View File
@@ -166,6 +166,14 @@ button:disabled { cursor: default; }
.login-code { font-family: 'Unbounded'; font-size: 28px; letter-spacing: .08em; color: var(--green); background: #191f1a; border-radius: 10px; padding: 14px; margin-bottom: 12px; }
.login-url { color: var(--ice) !important; font-size: 11px !important; word-break: break-all; }
.feedback-dialog { width: min(440px, calc(100vw - 40px)); max-height: calc(100vh - 80px); overflow-y: auto; padding: 28px; margin: auto; color: #e9eee9; background: #151d17; border: 1px solid #456348; border-radius: 16px; box-shadow: 0 24px 90px #0009; }
.feedback-dialog::backdrop { background: #050a07b8; }
.feedback-dialog.error { border-color: #a46c54; }
.feedback-dialog h2 { margin: 0 0 14px; font-size: 19px; }
.feedback-dialog p { margin: 0 0 24px; color: #c1cbc2; font-size: 14px; line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; }
.feedback-dialog button { width: 100%; padding: 12px; border: 0; border-radius: 9px; background: var(--green); color: #102112; font: inherit; font-weight: 650; cursor: pointer; }
.feedback-dialog button:focus-visible { outline: 2px solid #e5ffe8; outline-offset: 4px; }
@media (max-width: 1160px) {
.workspace { grid-template-columns: 58px 224px 1fr; }
.brand { width: 240px; }