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
+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)