refactor: modularize launcher UI and native services with verified IO

This commit is contained in:
emil28092005
2026-09-09 12:55:15 +03:00
parent cc19a24e45
commit cde13c4489
51 changed files with 3307 additions and 1118 deletions
+75
View File
@@ -0,0 +1,75 @@
import { useCallback, useState } from 'react'
import { Library } from './components/Library'
import { LoginModal } from './components/LoginModal'
import { PlayDock } from './components/PlayDock'
import { ServerStage } from './components/ServerStage'
import { SettingsDrawer } from './components/SettingsDrawer'
import { Titlebar } from './components/Titlebar'
import { servers } from './data/servers'
import { useAccount } from './hooks/useAccount'
import { useLauncher } from './hooks/useLauncher'
import { useSettings } from './hooks/useSettings'
import { isNative } from './services/native'
import { installStageLabels } from './state/game'
export function App() {
const [selected, setSelected] = useState(servers[0])
const [settingsOpen, setSettingsOpen] = useState(false)
const preferences = useSettings()
const session = useAccount()
const launcher = useLauncher()
const closeSettings = useCallback(() => setSettingsOpen(false), [])
const desktop = isNative()
const profile = selected.profileId ? launcher.profiles[selected.profileId] : undefined
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
let label = 'Играть'
if (selected.disabled) label = 'Недоступно'
else 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 (preferences.saving) label = 'Сохраняем…'
else if (accountLoading || !preferences.loaded) label = 'Загрузка…'
else if (needsLogin) label = session.busy ? 'Ждём вход…' : 'Войти через Microsoft'
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)
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}>
<PlayDock server={selected} operation={operation} profile={profile}
memoryGb={preferences.settings.memoryMb / 1024} native={desktop} needsLogin={needsLogin}
error={error} label={label} primaryDisabled={disabled} repairDisabled={repairDisabled}
onPrimary={primary} onRepair={() => { if (!repairDisabled && selected.profileId) void launcher.repair(selected.profileId) }} />
</ServerStage>
</div>
<SettingsDrawer open={settingsOpen && !session.code} locked={busy || session.busy} preferences={preferences}
session={session} host={launcher.host} java={launcher.java} onClose={closeSettings} />
<LoginModal code={session.code} />
</div>
)
}
+61
View File
@@ -0,0 +1,61 @@
import { ChevronRight, Library as LibraryIcon, LogOut, MessageCircle, Newspaper, Settings } from 'lucide-react'
import { servers } from '../data/servers'
import type { ProfileState } from '../state/profiles'
import type { LauncherSettings, MinecraftProfile, Server } from '../types/launcher'
interface LibraryProps {
selected: Server
profiles: Record<string, ProfileState>
settings: LauncherSettings
account: MinecraftProfile | 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 ?? 'Не авторизован'
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>
<aside className="library-panel">
<div className="library-heading"><p>Сборки</p><span>1 доступна</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 ? 'Установлена' : 'Требуется проверка'
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-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>
</aside>
</>
)
}
+16
View File
@@ -0,0 +1,16 @@
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>
</>
)
}
+68
View File
@@ -0,0 +1,68 @@
import { Download, Gauge, Globe2, Play, RotateCcw, ShieldCheck, Wrench } from 'lucide-react'
import type { ProfileState } from '../state/profiles'
import { installPercent, installStageLabels } from '../state/game'
import type { GameOperation } from '../state/game'
import type { Server } from '../types/launcher'
interface PlayDockProps {
server: Server
operation: GameOperation
profile: ProfileState | undefined
memoryGb: number
native: boolean
needsLogin: boolean
error: string | null
label: string
primaryDisabled: boolean
repairDisabled: boolean
onPrimary: () => void
onRepair: () => void
}
export function PlayDock(props: PlayDockProps) {
const { server, operation, profile, memoryGb, needsLogin, error } = props
const progress = operation.phase === 'installing' ? operation.progress : null
const percent = installPercent(progress)
const working = operation.phase === 'syncing' || operation.phase === 'installing' || operation.phase === 'launching'
let title: string
let detail: string
if (operation.phase === 'installing') {
title = progress ? installStageLabels[progress.stage] : 'Готовим установку'
detail = percent === null ? 'Проверяем файлы…' : `${percent}%`
} else if (operation.phase === 'launching') {
title = 'Запускаем игру'; detail = 'Подготавливаем игровой процесс…'
} else if (operation.phase === 'running') {
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' ? 'Проверяем сборку'
: profile?.inspection?.upToDate ? 'Сборка готова' : 'Требуется проверка'
detail = profile?.inspection ? `${profile.inspection.managedFiles} файлов под контролем` : 'Проверяем локальные файлы'
}
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>
<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}
aria-valuemin={0} aria-valuemax={100} aria-valuenow={percent}><i style={{ width: `${percent}%` }} /></div>}
</div>
<div className="build-facts">
<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="play-button" disabled={props.primaryDisabled} onClick={props.onPrimary}>
<Play size={21} fill="currentColor" /><span>{props.label}</span>
</button>
</section>
)
}
+23
View File
@@ -0,0 +1,23 @@
import type { ReactNode } from 'react'
import { Users } from 'lucide-react'
import type { Server } from '../types/launcher'
export function ServerStage({ server, children }: { server: Server; children: ReactNode }) {
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>
<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>
</section>
{children}
</main>
)
}
+95
View File
@@ -0,0 +1,95 @@
import { useEffect, useRef } from 'react'
import { ChevronRight, FolderOpen, LogOut, Users, Wrench, X } from 'lucide-react'
import type { useAccount } from '../hooks/useAccount'
import type { useSettings } from '../hooks/useSettings'
import type { JavaInstallation, NativeHost } from '../types/launcher'
interface SettingsDrawerProps {
open: boolean
locked: boolean
host: NativeHost | null
java: JavaInstallation | null | undefined
preferences: ReturnType<typeof useSettings>
session: ReturnType<typeof useAccount>
onClose: () => void
}
export function SettingsDrawer({ open, locked, host, java, preferences, session, onClose }: SettingsDrawerProps) {
const { settings, nickname, loaded, saving, error, nicknameError } = preferences
const closeButton = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (!open) return
const previous = document.activeElement
closeButton.current?.focus()
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose()
if (event.key !== 'Tab') return
const elements = closeButton.current?.closest('aside')?.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled)')
const first = elements?.[0]
const last = elements?.[elements.length - 1]
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus() }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus() }
}
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('keydown', onKey)
if (previous instanceof HTMLElement) previous.focus()
}
}, [open, onClose])
return (
<>
<div className={`drawer-backdrop ${open ? 'visible' : ''}`} onClick={onClose} />
<aside className={`settings-drawer ${open ? 'open' : ''}`} inert={!open} aria-hidden={!open}
role="dialog" aria-modal={open ? true : undefined} aria-labelledby="settings-title">
<div className="drawer-title">
<div><p>Настройки</p><h2 id="settings-title">Игра</h2></div>
<button ref={closeButton} onClick={onClose} aria-label="Закрыть настройки"><X /></button>
</div>
<label className="range-setting">
<span><strong>Оперативная память</strong><b>{settings.memoryMb / 1024} ГБ</b></span>
<input type="range" min="3" max="12" step="1" value={settings.memoryMb / 1024} disabled={!loaded || locked}
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>
)}
<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>
</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>
</aside>
</>
)
}
+17
View File
@@ -0,0 +1,17 @@
import { Minus, Square, X } from 'lucide-react'
import logo from '../assets/shacraft-logo.png'
import type { NativeHost } from '../types/launcher'
export function Titlebar({ host }: { host: NativeHost | null }) {
return (
<header className="titlebar">
<div className="brand"><img src={logo} alt="" /><span>ShaCraft</span></div>
<div className="titlebar-drag">{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>
</div>
</header>
)
}
+26
View File
@@ -0,0 +1,26 @@
import type { Server } from '../types/launcher'
// Presentation metadata only. Rust reads install versions and managed files
// from the signed manifest; this list cannot control downloads or launch args.
export const servers: readonly [Server, ...Server[]] = [
{
id: 'aoc',
kicker: 'All of Create / сборка 2.5',
name: 'Aeronautics',
subtitle: 'Строй корабли. Поднимай города в небо.',
version: '1.21.1 · NeoForge',
composition: '250 модов',
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,
},
]
+80
View File
@@ -0,0 +1,80 @@
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'
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 [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [eventsReady, setEventsReady] = useState(false)
const pending = useRef(false)
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)
}).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
setAccount(null)
setError(errorMessage(reason, 'Не удалось восстановить аккаунт'))
})
return () => { active = false; subscription.dispose() }
}, [])
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'))
}
}
const logout = async () => {
if (!isNative() || pending.current) return
pending.current = true
setBusy(true)
setError(null)
try {
await native.logout()
setAccount(null)
} catch (reason) {
setError(errorMessage(reason, 'Не удалось выйти из аккаунта'))
} finally {
pending.current = false
setBusy(false)
}
}
return { account, code, error, busy, eventsReady, login, logout }
}
+95
View File
@@ -0,0 +1,95 @@
import { useEffect, useReducer, useRef, useState } from 'react'
import { servers } from '../data/servers'
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'
export function useLauncher() {
const [host, setHost] = useState<NativeHost | null>(null)
const [java, setJava] = useState<JavaInstallation | null | undefined>(undefined)
const [environmentError, setEnvironmentError] = useState<string | null>(null)
const [profiles, updateProfile] = useReducer(profilesReducer, {})
const [game, dispatch] = useReducer(gameReducer, initialGameState)
const [eventsReady, setEventsReady] = useState(false)
const busy = useRef(false)
useEffect(() => {
if (!isNative()) return
let active = true
const subscription = watchGame({
progress: (progress) => { if (active) dispatch({ type: 'progress', progress }) },
exited: (result) => { if (active) dispatch({ type: 'exited', result }) },
})
void subscription.ready.then(() => {
if (active) setEventsReady(true)
}).catch((reason: unknown) => {
if (active) setEnvironmentError(errorMessage(reason, 'Не удалось подключить события игры'))
})
void native.host().then((value) => {
if (active) setHost(value)
}).catch((reason: unknown) => {
if (active) setEnvironmentError(errorMessage(reason, 'Не удалось определить каталог лаунчера'))
})
void native.detectJava().then((value) => {
if (active) setJava(value)
}).catch((reason: unknown) => {
if (!active) return
setJava(null)
setEnvironmentError(errorMessage(reason, 'Не удалось проверить Java'))
})
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 })
}).catch((reason: unknown) => {
if (active) updateProfile({ type: 'failed', profileId, error: errorMessage(reason, 'Не удалось проверить сборку') })
})
}
return () => { active = false; subscription.dispose() }
}, [])
const repair = async (profileId: string) => {
if (!isNative() || busy.current || game.operation.phase !== 'idle' || profiles[profileId]?.status === 'checking') return
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.
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 })
} catch (reason) {
const error = errorMessage(reason, 'Не удалось синхронизировать сборку')
updateProfile({ type: 'failed', profileId, error })
dispatch({ type: 'failed', error })
} finally {
busy.current = false
}
}
const launch = async (profileId: string) => {
if (!isNative() || !eventsReady || busy.current || game.operation.phase !== 'idle') return
busy.current = true
dispatch({ type: 'install', profileId })
try {
await native.installGame(profileId)
dispatch({ type: 'launch', profileId })
await native.launchGame(profileId)
dispatch({ type: 'started', profileId })
} catch (reason) {
dispatch({ type: 'failed', error: errorMessage(reason, 'Не удалось запустить игру') })
} finally {
busy.current = false
}
}
return { host, java, environmentError, profiles, game, eventsReady, repair, launch }
}
+83
View File
@@ -0,0 +1,83 @@
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'
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)
const revision = useRef(0)
const queue = useRef(createSerialQueue())
useEffect(() => {
if (!isNative()) return
let active = true
setError(null)
native.loadSettings().then((value) => {
if (!active) return
current.current = value
durable.current = value
setSettings(value)
setNickname(value.nickname)
setLoaded(true)
}).catch((reason: unknown) => {
if (active) setError(errorMessage(reason, 'Не удалось прочитать настройки'))
})
return () => { active = false }
}, [loadAttempt])
const save = (patch: Partial<LauncherSettings>) => {
if (!loaded) return
const next = { ...current.current, ...patch }
current.current = next
setSettings(next)
setError(null)
if (!isNative()) return
const requestRevision = ++revision.current
setSaving(true)
void queue.current.enqueue(() => native.saveSettings(next)).then((value) => {
durable.current = value
if (revision.current === requestRevision) {
current.current = value
setSettings(value)
}
}).catch((reason: unknown) => {
if (revision.current !== requestRevision) return
current.current = durable.current
setSettings(durable.current)
setError(errorMessage(reason, 'Не удалось сохранить настройки'))
}).finally(() => {
if (revision.current === requestRevision) setSaving(false)
})
}
const saveNickname = () => {
if (!isValidNickname(nickname)) {
setNicknameError('Ник: от 3 до 16 латинских букв, цифр или символов _')
return
}
setNicknameError(null)
save({ nickname })
}
return {
settings, nickname, loaded, saving, error, nicknameError,
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({})
},
}
}
+5 -532
View File
@@ -1,536 +1,9 @@
import React, { useEffect, useState } from 'react'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import {
ChevronRight,
Download,
FolderOpen,
Gauge,
Globe2,
Library,
LogOut,
MessageCircle,
Minus,
Newspaper,
Play,
RotateCcw,
Settings,
ShieldCheck,
Square,
Users,
Wrench,
X,
} from 'lucide-react'
import logo from './assets/shacraft-logo.png'
import { App } from './App'
import './styles.css'
type Server = {
id: string
kicker: string
name: string
subtitle: string
players: string
version: string
memory: string
installed: boolean
profileId?: string
disabled?: boolean
}
const root = document.getElementById('root')
if (!root) throw new Error('Launcher root element is missing')
type NativeHost = {
platform: string
dataDir: string
launcherVersion: string
}
type NativeSettings = {
memoryMb: number
nickname: string
accountMode: 'microsoft' | 'offline'
}
type JavaInstallation = {
executable: string
major: number
version: string
}
type ProfileInspection = {
managedFiles: number
missingFiles: number
mismatchedFiles: number
upToDate: boolean
}
type SyncResult = {
downloadedFiles: number
reusedFiles: number
downloadedBytes: number
}
type MinecraftProfile = {
id: string
name: string
}
type DeviceCodePayload = {
verificationUri: string
userCode: string
expiresInSeconds: number
}
type LoginResultPayload = {
ok: boolean
profile?: MinecraftProfile
error?: string
}
type InstallProgressPayload = {
stage: 'java' | 'neoforge' | 'libraries' | 'assets'
currentBytes: number
totalBytes: number
}
const INSTALL_STAGE_LABEL: Record<InstallProgressPayload['stage'], string> = {
java: 'Готовим Java',
neoforge: 'Устанавливаем NeoForge',
libraries: 'Скачиваем библиотеки',
assets: 'Скачиваем ресурсы игры',
}
const servers: Server[] = [
{
id: 'aoc',
kicker: 'Основная сборка',
name: 'Aeronautics',
subtitle: 'Строй корабли. Поднимай города в небо.',
players: '7 / 20',
version: '1.21.1 · NeoForge',
memory: '6 ГБ',
installed: true,
profileId: 'aeronautics',
},
{
id: 'create',
kicker: 'На техобслуживании',
name: 'Create',
subtitle: 'Механизмы, фабрики и большие идеи.',
players: 'Сервер остановлен',
version: '1.21.1 · NeoForge',
memory: '4 ГБ',
installed: false,
disabled: true,
},
]
function isTauri() {
return '__TAURI_INTERNALS__' in window
}
function App() {
const [selected, setSelected] = useState(servers[0])
const [progress, setProgress] = useState<number | null>(null)
const [ready, setReady] = useState(true)
const [settingsOpen, setSettingsOpen] = useState(false)
const [ram, setRam] = useState(6)
const [nickname, setNickname] = useState('Emil')
const [accountMode, setAccountMode] = useState<'microsoft' | 'offline'>('offline')
const [nativeHost, setNativeHost] = useState<NativeHost | null>(null)
const [java, setJava] = useState<JavaInstallation | null | undefined>(undefined)
const [profile, setProfile] = useState<ProfileInspection | null>(null)
const [syncing, setSyncing] = useState(false)
const [syncError, setSyncError] = useState<string | null>(null)
// undefined = still checking for a saved session; null = signed out.
const [account, setAccount] = useState<MinecraftProfile | null | undefined>(undefined)
const [loginCode, setLoginCode] = useState<DeviceCodePayload | null>(null)
const [loginError, setLoginError] = useState<string | null>(null)
const [loggingIn, setLoggingIn] = useState(false)
const [installing, setInstalling] = useState(false)
const [installProgress, setInstallProgress] = useState<InstallProgressPayload | null>(null)
const [launchError, setLaunchError] = useState<string | null>(null)
useEffect(() => {
if (progress === null) return
if (progress >= 100) {
const done = window.setTimeout(() => {
setProgress(null)
setReady(true)
}, 650)
return () => window.clearTimeout(done)
}
const timer = window.setTimeout(() => setProgress(Math.min(100, progress + 2)), 55)
return () => window.clearTimeout(timer)
}, [progress])
useEffect(() => {
if (!isTauri()) return
invoke<NativeHost>('native_host').then(setNativeHost).catch(() => setNativeHost(null))
invoke<NativeSettings>('load_settings')
.then((settings) => {
setRam(settings.memoryMb / 1024)
setNickname(settings.nickname)
setAccountMode(settings.accountMode)
})
.catch(() => undefined)
invoke<JavaInstallation | null>('detect_java')
.then(setJava)
.catch(() => setJava(null))
invoke<ProfileInspection>('inspect_remote_profile', { profileId: 'aeronautics' })
.then((inspection) => {
setProfile(inspection)
setReady(inspection.upToDate)
})
.catch(() => undefined)
invoke<MinecraftProfile | null>('get_account')
.then(setAccount)
.catch(() => setAccount(null))
}, [])
useEffect(() => {
if (!isTauri()) return
const unlisten = [
listen<DeviceCodePayload>('msa-login-code', (event) => setLoginCode(event.payload)),
listen<LoginResultPayload>('msa-login-result', (event) => {
setLoggingIn(false)
setLoginCode(null)
if (event.payload.ok && event.payload.profile) {
setAccount(event.payload.profile)
setLoginError(null)
} else {
setLoginError(event.payload.error ?? 'Не удалось войти через Microsoft')
}
}),
listen<InstallProgressPayload>('game-install-progress', (event) => setInstallProgress(event.payload)),
listen('game-exited', () => setInstalling(false)),
]
return () => {
unlisten.forEach((promise) => promise.then((off) => off()))
}
}, [])
const saveSettings = (memoryGb = ram, nick = nickname, mode = accountMode) => {
if (isTauri()) {
invoke<NativeSettings>('save_settings', { settings: { memoryMb: memoryGb * 1024, nickname: nick, accountMode: mode } }).catch(() => undefined)
}
}
const updateRam = (memoryGb: number) => {
setRam(memoryGb)
saveSettings(memoryGb)
}
const saveNickname = () => {
if (/^[A-Za-z0-9_]{3,16}$/.test(nickname)) {
saveSettings(ram, nickname)
}
}
const setMode = (mode: 'microsoft' | 'offline') => {
setAccountMode(mode)
saveSettings(ram, nickname, mode)
}
const repair = async () => {
if (selected.disabled) return
if (isTauri() && selected.profileId) {
setSyncError(null)
setSyncing(true)
setReady(false)
try {
const result = await invoke<SyncResult>('sync_remote_profile', { profileId: selected.profileId })
setProfile({ managedFiles: result.downloadedFiles + result.reusedFiles, missingFiles: 0, mismatchedFiles: 0, upToDate: true })
setReady(true)
} catch (error) {
setSyncError(error instanceof Error ? error.message : 'Не удалось синхронизировать сборку')
} finally {
setSyncing(false)
}
return
}
setReady(false)
setProgress(0)
}
const startLogin = async () => {
if (!isTauri()) return
setLoginError(null)
setLoggingIn(true)
try {
await invoke('start_microsoft_login')
} catch (error) {
setLoggingIn(false)
setLoginError(error instanceof Error ? error.message : 'Не удалось начать вход через Microsoft')
}
}
const logout = async () => {
if (!isTauri()) return
await invoke('logout').catch(() => undefined)
setAccount(null)
}
const playOrLogin = async () => {
if (selected.disabled || !isTauri() || !selected.profileId) return
// In offline mode we can launch without any Microsoft session. In
// Microsoft mode a signed-in account is still required first.
if (accountMode === 'microsoft' && (account === null || account === undefined)) {
await startLogin()
return
}
setLaunchError(null)
setInstalling(true)
setInstallProgress(null)
try {
await invoke('ensure_game_installed', { profileId: selected.profileId })
await invoke('launch_game', { profileId: selected.profileId })
} catch (error) {
setLaunchError(error instanceof Error ? error.message : 'Не удалось запустить игру')
setInstalling(false)
}
}
const playLabel = () => {
if (selected.disabled) return 'Недоступно'
if (accountMode === 'microsoft' && account === undefined) return 'Загрузка…'
if (accountMode === 'microsoft' && account === null) return loggingIn ? 'Ждём вход…' : 'Войти через Microsoft'
if (installing) return installProgress ? `${INSTALL_STAGE_LABEL[installProgress.stage]}…` : 'Подготовка…'
if (syncing || progress !== null) return 'Обновление'
return ready ? 'Играть' : 'Проверить'
}
const installPercent = installProgress && installProgress.totalBytes > 0 ? Math.min(100, Math.round((installProgress.currentBytes / installProgress.totalBytes) * 100)) : null
return (
<div className="app-shell">
<header className="titlebar">
<div className="brand">
<img src={logo} alt="" />
<span>ShaCraft</span>
</div>
<div className="titlebar-drag">{nativeHost ? `Лаунчер · ${nativeHost.platform}` : 'Лаунчер'}</div>
<div className="window-actions" aria-label="Управление окном">
<button aria-label="Свернуть"><Minus size={15} /></button>
<button aria-label="Развернуть"><Square size={12} /></button>
<button className="close" aria-label="Закрыть"><X size={15} /></button>
</div>
</header>
<div className="workspace">
<nav className="rail" aria-label="Основное меню">
<div className="rail-main">
<button className="rail-button active" aria-label="Сборки"><Library /></button>
<button className="rail-button" aria-label="Новости"><Newspaper /></button>
<button className="rail-button" aria-label="Сообщество"><MessageCircle /></button>
</div>
<button className="rail-button" aria-label="Настройки" onClick={() => setSettingsOpen(true)}>
<Settings />
</button>
</nav>
<aside className="library-panel">
<div className="library-heading">
<p>Сборки</p>
<span>1 доступна</span>
</div>
<div className="server-list">
{servers.map((server) => (
<button
key={server.id}
className={`server-row ${selected.id === server.id ? 'selected' : ''}`}
onClick={() => {
setSelected(server)
setReady(server.installed)
setProgress(null)
}}
>
<span className={`server-glyph ${server.id}`} aria-hidden="true">
{server.id === 'aoc' ? 'A' : 'C'}
</span>
<span className="server-copy">
<strong>{server.name}</strong>
<small>{server.disabled ? 'На паузе' : 'Установлена'}</small>
</span>
<ChevronRight size={16} />
</button>
))}
</div>
<div className="account-chip">
<span className="avatar">{accountMode === 'offline' ? nickname.slice(0, 2).toUpperCase() : (account ? account.name.slice(0, 2).toUpperCase() : '?')}</span>
<span>
<strong>{accountMode === 'offline' ? nickname : (account === undefined ? 'Проверяем…' : account === null ? 'Не авторизован' : account.name)}</strong>
<small>{accountMode === 'offline' ? 'Offline-аккаунт' : (account ? 'Microsoft-аккаунт' : 'Войдите, чтобы играть')}</small>
</span>
{accountMode === 'microsoft' && account ? (
<button aria-label="Выйти из аккаунта" onClick={logout} style={{ background: 'transparent', border: 0, cursor: 'pointer', color: 'inherit' }}>
<LogOut size={16} />
</button>
) : (
<ChevronRight size={16} />
)}
</div>
</aside>
<main className={`stage stage-${selected.id}`}>
<div className="stage-top">
<div className={`live-pill ${selected.disabled ? 'offline' : ''}`}>
<span /> {selected.disabled ? 'Не в сети' : 'Сервер работает'}
</div>
<div className="players"><Users size={16} /> {selected.players}</div>
</div>
<section className="hero-copy">
<p>{selected.id === 'aoc' ? 'All of Create / сборка 2.5' : selected.kicker}</p>
<h1>{selected.name}</h1>
<h2>{selected.subtitle}</h2>
<dl className="hero-meta">
<div><dt>Состав</dt><dd>{selected.id === 'aoc' ? '250 модов' : '41 мод'}</dd></div>
<div><dt>Загрузчик</dt><dd>{selected.id === 'aoc' ? 'NeoForge 21.1.248' : 'NeoForge 21.1.249'}</dd></div>
<div><dt>Java</dt><dd>Версия 21</dd></div>
</dl>
</section>
<section className="play-dock">
<div className="build-state">
{installing ? (
<>
<span className="state-icon downloading"><Download size={19} /></span>
<span>
<strong>{installProgress ? INSTALL_STAGE_LABEL[installProgress.stage] : 'Готовим установку'}</strong>
<small>{installPercent !== null ? `${installPercent}%` : 'Проверяем файлы…'}</small>
</span>
</>
) : syncing ? (
<>
<span className="state-icon downloading"><Download size={19} /></span>
<span><strong>Синхронизируем сборку</strong><small>Скачиваем и проверяем файлы</small></span>
</>
) : progress !== null ? (
<>
<span className="state-icon downloading"><Download size={19} /></span>
<span>
<strong>Проверяем сборку</strong>
<small>Файлы и обновления · {progress}%</small>
</span>
</>
) : selected.disabled ? (
<>
<span className="state-icon muted"><Wrench size={19} /></span>
<span><strong>Техобслуживание</strong><small>Сообщим, когда сервер вернётся</small></span>
</>
) : (
<>
<span className="state-icon"><ShieldCheck size={19} /></span>
<span>
<strong>{accountMode === 'microsoft' && account === null ? 'Нужен вход' : ready ? 'Сборка готова' : 'Требуется проверка'}</strong>
<small>{launchError || syncError || loginError || (profile ? `${profile.managedFiles} файлов под контролем` : 'Проверяем локальные файлы')}</small>
</span>
</>
)}
{progress !== null && <div className="progress-track"><i style={{ width: `${progress}%` }} /></div>}
{installPercent !== null && <div className="progress-track"><i style={{ width: `${installPercent}%` }} /></div>}
</div>
<div className="build-facts">
<span><Globe2 size={15} /> {selected.version}</span>
<span><Gauge size={15} /> {ram} ГБ памяти</span>
</div>
<button className="repair-button" onClick={repair} disabled={progress !== null || syncing || installing || selected.disabled} aria-label="Проверить файлы">
<RotateCcw size={19} />
</button>
<button
className="play-button"
disabled={progress !== null || syncing || installing || selected.disabled || (accountMode === 'microsoft' && account === undefined) || loggingIn}
onClick={playOrLogin}
>
<Play size={21} fill="currentColor" />
<span>{playLabel()}</span>
</button>
</section>
</main>
</div>
<div className={`drawer-backdrop ${loginCode ? 'visible' : ''}`} />
{loginCode && (
<div className="login-modal" role="dialog" aria-modal="true">
<h2>Вход через Microsoft</h2>
<p>Откройте страницу и введите код, чтобы подтвердить вход в аккаунт с лицензией Minecraft.</p>
<div className="login-code">{loginCode.userCode}</div>
<p className="login-url">{loginCode.verificationUri}</p>
</div>
)}
<div className={`drawer-backdrop ${settingsOpen ? 'visible' : ''}`} onClick={() => setSettingsOpen(false)} />
<aside className={`settings-drawer ${settingsOpen ? 'open' : ''}`} aria-hidden={!settingsOpen}>
<div className="drawer-title">
<div><p>Настройки</p><h2>Игра</h2></div>
<button onClick={() => setSettingsOpen(false)} aria-label="Закрыть настройки"><X /></button>
</div>
<label className="range-setting">
<span><strong>Оперативная память</strong><b>{ram} ГБ</b></span>
<input type="range" min="3" max="12" value={ram} onChange={(e) => updateRam(Number(e.target.value))} />
<small>Для Aeronautics рекомендуется 6 ГБ</small>
</label>
<div className="setting-row static">
<span><Users />Аккаунт</span>
<small>{accountMode === 'offline' ? 'Offline' : (account ? account.name : 'Не авторизован')}</small>
</div>
{accountMode === 'offline' && (
<label className="text-setting">
<span><strong>Игровой ник</strong><small>Offline-профиль</small></span>
<input value={nickname} maxLength={16} onChange={(event) => setNickname(event.target.value)} onBlur={saveNickname} placeholder="Player" />
<small>Латинские буквы, цифры и _ · от 3 до 16 символов</small>
</label>
)}
<div className="setting-row">
<span>Тип аккаунта</span>
<select
value={accountMode}
onChange={(e) => setMode(e.target.value as 'microsoft' | 'offline')}
style={{ background: 'transparent', border: 0, color: 'inherit', textAlign: 'right' }}
>
<option value="offline">Offline</option>
<option value="microsoft">Microsoft</option>
</select>
</div>
{accountMode === 'microsoft' && account && (
<button className="setting-row" onClick={logout}>
<span><LogOut />Выйти из Microsoft</span>
</button>
)}
{accountMode === 'microsoft' && !account && (
<button className="setting-row" onClick={startLogin}>
<span><LogOut />Войти через Microsoft</span>
</button>
)}
<div className="setting-row static">
<span><FolderOpen />Папка игры</span>
<small>{nativeHost ? 'В каталоге лаунчера' : 'Определяется…'}</small>
</div>
<div className="setting-row static">
<span><Wrench />Java</span>
<small>
{java === undefined
? 'Проверяем…'
: java && java.major >= 21
? `Java ${java.major} найдена`
: java
? `Нужна Java 21 · найдена ${java.major}`
: 'Лаунчер установит Java 21 автоматически'}
</small>
</div>
<button className="setting-row"><span><Wrench />Дополнительные параметры</span><ChevronRight /></button>
<div className="drawer-note">
{nativeHost ? `Данные лаунчера: ${nativeHost.dataDir}` : 'Java 21 будет управляться лаунчером автоматически.'}
</div>
</aside>
</div>
)
}
createRoot(document.getElementById('root')!).render(
<React.StrictMode><App /></React.StrictMode>,
)
createRoot(root).render(<StrictMode><App /></StrictMode>)
+97
View File
@@ -0,0 +1,97 @@
import { deepStrictEqual, equal, rejects } from 'node:assert/strict'
import { test } from 'node:test'
import { createSerialQueue, createSubscription, errorMessage, singleFlight } from './async.ts'
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise; reject = rejectPromise
})
return { promise, resolve, reject }
}
test('settings writes are serialized even while earlier requests are pending', async () => {
const queue = createSerialQueue()
const first = deferred<number>()
const order: number[] = []
const savedFirst = queue.enqueue(async () => { order.push(1); return first.promise })
const savedSecond = queue.enqueue(async () => { order.push(2); return 2 })
await Promise.resolve()
deepStrictEqual(order, [1])
first.resolve(1)
equal(await savedFirst, 1)
equal(await savedSecond, 2)
deepStrictEqual(order, [1, 2])
})
test('a failed settings write does not poison later saves', async () => {
const queue = createSerialQueue()
const failed = queue.enqueue(async () => { throw new Error('disk full') })
const retried = queue.enqueue(async () => 'saved')
await rejects(failed, /disk full/)
equal(await retried, 'saved')
await queue.settled()
})
test('unmount before native registration still unregisters the late listener once', async () => {
const registration = deferred<() => void>()
let cleanupCount = 0
const subscription = createSubscription([registration.promise])
subscription.dispose()
registration.resolve(() => { cleanupCount += 1 })
await subscription.ready
subscription.dispose()
equal(cleanupCount, 1)
})
test('partial listener failure cleans up successful and late registrations', async () => {
const failed = deferred<() => void>()
const late = deferred<() => void>()
let cleanupCount = 0
const subscription = createSubscription([
Promise.resolve(() => { cleanupCount += 1 }), failed.promise, late.promise,
])
failed.reject(new Error('listen failed'))
await rejects(subscription.ready, /listen failed/)
equal(cleanupCount, 1)
late.resolve(() => { cleanupCount += 1 })
await Promise.resolve()
equal(cleanupCount, 2)
subscription.dispose()
equal(cleanupCount, 2)
})
test('Rust string errors remain visible instead of being replaced with generic copy', () => {
equal(errorMessage('Invalid signature', 'fallback'), 'Invalid signature')
equal(errorMessage(new Error('Disk full'), 'fallback'), 'Disk full')
equal(errorMessage(null, 'fallback'), 'fallback')
equal(errorMessage('', 'fallback'), 'fallback')
})
test('overlapping account restores share one native request and do not cache the session', async () => {
const first = deferred<string>()
let calls = 0
const restore = singleFlight(() => { calls += 1; return first.promise })
const firstMount = restore()
const strictModeRemount = restore()
equal(firstMount, strictModeRemount)
await Promise.resolve()
equal(calls, 1)
first.resolve('profile')
equal(await strictModeRemount, 'profile')
await restore()
equal(calls, 2)
})
test('failed account restore can be retried', async () => {
let calls = 0
const restore = singleFlight(async () => {
calls += 1
if (calls === 1) throw new Error('network unavailable')
return 'profile'
})
await rejects(restore(), /network unavailable/)
equal(await restore(), 'profile')
equal(calls, 2)
})
+55
View File
@@ -0,0 +1,55 @@
export function errorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.message) return error.message
// Tauri rejects commands with the Rust error string, not an Error instance.
if (typeof error === 'string' && error.trim()) return error
return fallback
}
/** Share a pending restore across React StrictMode's effect restart. */
export function singleFlight<T>(operation: () => Promise<T>): () => Promise<T> {
let pending: Promise<T> | null = null
return () => {
if (pending) return pending
const request = Promise.resolve().then(operation)
pending = request
const clear = () => { if (pending === request) pending = null }
void request.then(clear, clear)
return request
}
}
/** Serializes writes so a slow older save cannot overwrite a newer choice. */
export function createSerialQueue() {
let tail: Promise<unknown> = Promise.resolve()
return {
enqueue<T>(operation: () => Promise<T>): Promise<T> {
const result = tail.then(operation)
// A failed write must not poison all subsequent retries.
tail = result.catch(() => undefined)
return result
},
settled: () => tail,
}
}
/** Handles unmount before asynchronous native listener registration finishes. */
export function createSubscription(
registrations: readonly Promise<() => void>[],
) {
let disposed = false
const cleanups = new Set<() => void>()
const dispose = () => {
disposed = true
cleanups.forEach((cleanup) => cleanup())
cleanups.clear()
}
const ready = Promise.all(registrations.map(async (registration) => {
const cleanup = await registration
if (disposed) cleanup()
else cleanups.add(cleanup)
})).then(() => undefined).catch((error: unknown) => {
dispose()
throw error
})
return { ready, dispose }
}
+47
View File
@@ -0,0 +1,47 @@
import { invoke, isTauri } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import { createSubscription, singleFlight } from './async'
import type {
DeviceCodePayload, GameExitedPayload, InstallProgressPayload,
JavaInstallation, LauncherSettings, LoginResultPayload, MinecraftProfile,
NativeHost, ProfileInspection, SyncResult,
} from '../types/launcher'
export const isNative = () => typeof window !== 'undefined' && isTauri()
const restoreAccount = singleFlight(() => invoke<MinecraftProfile | null>('get_account'))
// Keep the IPC contract in one place. UI components never invoke native
// commands directly and cannot pass arbitrary URLs or filesystem paths.
export const native = {
host: () => invoke<NativeHost>('native_host'),
loadSettings: () => invoke<LauncherSettings>('load_settings'),
saveSettings: (settings: LauncherSettings) => invoke<LauncherSettings>('save_settings', { settings }),
detectJava: () => invoke<JavaInstallation | null>('detect_java'),
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'),
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 function watchGame(handlers: {
progress: (payload: InstallProgressPayload) => void
exited: (payload: GameExitedPayload) => void
}) {
return createSubscription([
listen<InstallProgressPayload>('game-install-progress', ({ payload }) => handlers.progress(payload)),
listen<GameExitedPayload>('game-exited', ({ payload }) => handlers.exited(payload)),
])
}
+52
View File
@@ -0,0 +1,52 @@
import { deepStrictEqual, equal, match } from 'node:assert/strict'
import { test } from 'node:test'
import { gameReducer, initialGameState, installPercent } from './game.ts'
const profileId = 'aeronautics'
const installing = gameReducer(initialGameState, { type: 'install', profileId })
const launching = gameReducer(installing, { type: 'launch', profileId })
test('installation, running game and exit have distinct states', () => {
const progressed = gameReducer(installing, { type: 'progress', progress: { stage: 'assets', currentBytes: 12, totalBytes: 24 } })
equal(progressed.operation.phase, 'installing')
const running = gameReducer(launching, { type: 'started', profileId })
equal(running.operation.phase, 'running')
deepStrictEqual(gameReducer(running, { type: 'exited', result: { profileId, exitCode: 0 } }), initialGameState)
})
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 })
equal(lateAcknowledgement.operation.phase, 'idle')
match(lateAcknowledgement.error ?? '', /кодом 1/)
})
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)
equal(gameReducer(running, { type: 'progress', progress: { stage: 'assets', currentBytes: 1, totalBytes: 1 } }), running)
equal(gameReducer(running, { type: 'sync', profileId }), running)
})
test('sync completion does not complete a different operation', () => {
equal(gameReducer(installing, { type: 'synced', profileId }), installing)
const syncing = gameReducer(initialGameState, { type: 'sync', profileId })
equal(gameReducer(syncing, { type: 'synced', profileId: 'other' }), syncing)
equal(gameReducer(syncing, { type: 'synced', profileId }), initialGameState)
})
test('an operation failure releases the UI and a retry clears the error', () => {
const failed = gameReducer(installing, { type: 'failed', error: 'Network failed' })
equal(failed.operation.phase, 'idle')
equal(failed.error, 'Network failed')
equal(gameReducer(failed, { type: 'install', profileId }).error, null)
})
test('percent is bounded and unknown or invalid totals stay indeterminate', () => {
equal(installPercent(null), null)
equal(installPercent({ stage: 'java', currentBytes: 1, totalBytes: 0 }), null)
equal(installPercent({ stage: 'assets', currentBytes: NaN, totalBytes: 10 }), null)
equal(installPercent({ stage: 'assets', currentBytes: 15, totalBytes: 10 }), 100)
equal(installPercent({ stage: 'assets', currentBytes: -5, totalBytes: 10 }), 0)
equal(installPercent({ stage: 'assets', currentBytes: 5, totalBytes: 20 }), 25)
})
+75
View File
@@ -0,0 +1,75 @@
import type { GameExitedPayload, InstallProgressPayload } from '../types/launcher'
export type GameOperation =
| { phase: 'idle' }
| { phase: 'syncing' | 'launching' | 'running'; profileId: string }
| { phase: 'installing'; profileId: string; progress: InstallProgressPayload | null }
export interface GameState {
operation: GameOperation
error: string | null
}
export type GameAction =
| { type: 'sync'; profileId: string }
| { type: 'synced'; profileId: string }
| { type: 'install'; profileId: string }
| { type: 'progress'; progress: InstallProgressPayload }
| { type: 'launch'; profileId: string }
| { type: 'started'; profileId: string }
| { type: 'exited'; result: GameExitedPayload }
| { type: 'failed'; error: string }
export const initialGameState: GameState = { operation: { phase: 'idle' }, error: null }
export function gameReducer(state: GameState, action: GameAction): GameState {
const operation = state.operation
switch (action.type) {
case 'sync':
case 'install':
if (operation.phase !== 'idle') return state
return {
operation: action.type === 'sync'
? { phase: 'syncing', profileId: action.profileId }
: { phase: 'installing', profileId: action.profileId, progress: null },
error: null,
}
case 'synced':
return operation.phase === 'syncing' && operation.profileId === action.profileId
? initialGameState : state
case 'progress':
return operation.phase === 'installing'
? { ...state, operation: { ...operation, progress: action.progress } } : state
case 'launch':
return operation.phase === 'installing' && operation.profileId === action.profileId
? { ...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
? { ...state, operation: { phase: 'running', profileId: action.profileId } } : state
case 'exited':
if ((operation.phase !== 'launching' && operation.phase !== 'running') ||
operation.profileId !== action.result.profileId) return state
return {
operation: { phase: 'idle' },
error: action.result.exitCode === 0 ? null
: action.result.exitCode === null ? 'Игра завершилась без кода выхода. Проверьте журнал игры.'
: `Игра завершилась с кодом ${action.result.exitCode}. Проверьте журнал игры.`,
}
case 'failed':
return { operation: { phase: 'idle' }, error: action.error }
}
}
export const installStageLabels: Record<InstallProgressPayload['stage'], string> = {
java: 'Готовим Java',
neoforge: 'Устанавливаем NeoForge',
libraries: 'Скачиваем библиотеки',
assets: 'Скачиваем ресурсы игры',
}
export function installPercent(progress: InstallProgressPayload | null): number | null {
if (!progress || progress.totalBytes <= 0 || !Number.isFinite(progress.totalBytes) ||
!Number.isFinite(progress.currentBytes)) return null
return Math.max(0, Math.min(100, Math.round(progress.currentBytes / progress.totalBytes * 100)))
}
+19
View File
@@ -0,0 +1,19 @@
import { equal } from 'node:assert/strict'
import { test } from 'node:test'
import { profilesReducer } from './profiles.ts'
test('a failed repair invalidates an old ready inspection and permits retry', () => {
const profileId = 'aeronautics'
const inspection = { root: '/profiles/aeronautics', managedFiles: 251, missingFiles: 0, mismatchedFiles: 0, upToDate: true }
const ready = profilesReducer({}, { type: 'checked', profileId, inspection })
equal(ready[profileId]?.inspection?.upToDate, true)
const repairing = profilesReducer(ready, { type: 'check', profileId })
equal(repairing[profileId]?.inspection, null)
const failed = profilesReducer(repairing, { type: 'failed', profileId, error: 'Download interrupted' })
equal(failed[profileId]?.status, 'error')
equal(failed[profileId]?.inspection, null)
const retrying = profilesReducer(failed, { type: 'check', profileId })
const repaired = profilesReducer(retrying, { type: 'checked', profileId, inspection })
equal(repaired[profileId]?.inspection?.upToDate, true)
equal(repaired[profileId]?.error, null)
})
+22
View File
@@ -0,0 +1,22 @@
import type { ProfileInspection } from '../types/launcher'
export type ProfileState =
| { status: 'checking'; inspection: null; error: null }
| { status: 'checked'; inspection: ProfileInspection; error: null }
| { status: 'error'; inspection: null; error: string }
type ProfileAction =
| { type: 'check'; profileId: string }
| { type: 'checked'; profileId: string; inspection: ProfileInspection }
| { type: 'failed'; profileId: string; error: string }
export function profilesReducer(
profiles: Record<string, ProfileState>, action: ProfileAction,
): Record<string, ProfileState> {
const next: ProfileState = action.type === 'check'
? { status: 'checking', inspection: null, error: null }
: action.type === 'checked'
? { status: 'checked', inspection: action.inspection, error: null }
: { status: 'error', inspection: null, error: action.error }
return { ...profiles, [action.profileId]: next }
}
+9
View File
@@ -0,0 +1,9 @@
import type { LauncherSettings } from '../types/launcher'
export const defaultSettings: LauncherSettings = {
memoryMb: 6 * 1024,
nickname: 'Emil',
accountMode: 'offline',
}
export const isValidNickname = (nickname: string) => /^[A-Za-z0-9_]{3,16}$/.test(nickname)
+13 -5
View File
@@ -1,6 +1,7 @@
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Unbounded:wght@500;600&display=swap');
:root {
color-scheme: dark;
font-family: 'Manrope', sans-serif;
color: #eef2ed;
background: #090c0a;
@@ -17,10 +18,11 @@
}
* { box-sizing: border-box; }
button, input { font: inherit; }
button, input, select { font: inherit; }
button { color: inherit; }
body { margin: 0; min-width: 1040px; min-height: 680px; overflow: hidden; }
button:focus-visible, input:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; }
button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; }
button:disabled { cursor: default; }
.app-shell {
height: 100vh;
@@ -77,6 +79,7 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--green); out
.account-chip strong { font-size: 12px; }
.account-chip small { color: #6d756e; font-size: 9px; }
.account-chip svg { color: #555e56; }
.account-logout { padding: 0; background: transparent; border: 0; cursor: pointer; color: inherit; }
.stage { position: relative; overflow: hidden; isolation: isolate; background: #16231b; }
.stage::before { content: ''; position: absolute; inset: 0; z-index: -5; background:
@@ -125,7 +128,7 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--green); out
.drawer-backdrop { position: fixed; inset: 48px 0 0; background: rgba(0,0,0,.44); opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 20; }
.drawer-backdrop.visible { opacity: 1; pointer-events: auto; }
.settings-drawer { position: fixed; top: 48px; right: 0; bottom: 0; width: 390px; background: #121713; border-left: 1px solid var(--line); z-index: 21; padding: 28px; transform: translateX(100%); transition: transform .24s cubic-bezier(.2,.8,.2,1); box-shadow: -30px 0 70px rgba(0,0,0,.35); }
.settings-drawer { position: fixed; top: 48px; right: 0; bottom: 0; width: 390px; overflow-y: auto; background: #121713; border-left: 1px solid var(--line); z-index: 21; padding: 28px; transform: translateX(100%); transition: transform .24s cubic-bezier(.2,.8,.2,1); box-shadow: -30px 0 70px rgba(0,0,0,.35); }
.settings-drawer.open { transform: translateX(0); }
.drawer-title { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 34px; }
.drawer-title p { color: #737c74; font-size: 11px; margin: 0 0 5px; }
@@ -144,13 +147,18 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--green); out
.setting-row.static { cursor: default; }
.setting-row.static:hover { color: #abb3ac; }
.setting-row small { color: #7f8a80; font-size: 11px; }
.setting-row select { background: #121713; border: 0; color: inherit; text-align: right; }
.text-setting { display: grid; gap: 9px; padding: 18px 0; border-bottom: 1px solid var(--line); }
.text-setting > span { display: flex; align-items: baseline; justify-content: space-between; }
.text-setting strong { font-size: 12px; }
.text-setting span small, .text-setting > small { color: #7f8a80; font-size: 11px; }
.text-setting input { width: 100%; box-sizing: border-box; background: #0d110e; color: #eef2ed; border: 1px solid #384239; padding: 10px 11px; font: 600 13px/1 Manrope, sans-serif; outline: none; }
.text-setting input:focus { border-color: var(--accent); }
.drawer-note { position: absolute; left: 28px; right: 28px; bottom: 26px; padding: 13px 15px; background: #17231a; color: #8fb193; border-radius: 10px; font-size: 10px; }
.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); }
.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; }
.login-modal { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 22; width: 360px; background: #121713; border: 1px solid var(--line); border-radius: 14px; padding: 26px; text-align: center; box-shadow: 0 30px 80px rgba(0,0,0,.5); }
.login-modal h2 { margin: 0 0 10px; font-family: 'Unbounded'; font-size: 18px; }
+74
View File
@@ -0,0 +1,74 @@
export type AccountMode = 'microsoft' | 'offline'
export interface Server {
id: string
kicker: string
name: string
subtitle: string
version: string
composition: string
loader: string
profileId?: string
disabled?: boolean
}
export interface NativeHost {
platform: string
dataDir: string
launcherVersion: string
}
export interface LauncherSettings {
memoryMb: number
nickname: string
accountMode: AccountMode
}
export interface JavaInstallation {
executable: string
major: number
version: string
}
export interface ProfileInspection {
root: string
managedFiles: number
missingFiles: number
mismatchedFiles: number
upToDate: boolean
}
export interface SyncResult {
root: string
downloadedFiles: number
reusedFiles: number
downloadedBytes: number
}
export interface MinecraftProfile {
id: string
name: string
}
export interface DeviceCodePayload {
verificationUri: string
userCode: string
expiresInSeconds: number
}
export interface LoginResultPayload {
ok: boolean
profile: MinecraftProfile | null
error: string | null
}
export interface InstallProgressPayload {
stage: 'java' | 'neoforge' | 'libraries' | 'assets'
currentBytes: number
totalBytes: number
}
export interface GameExitedPayload {
profileId: string
exitCode: number | null
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />