feat: add signed launcher updates with native lifecycle recovery

This commit is contained in:
Emil
2026-09-09 19:48:10 +03:00
parent 7c0eb20894
commit 1d08396b80
39 changed files with 2952 additions and 46 deletions
+23 -5
View File
@@ -11,6 +11,7 @@ import { useAccount } from './hooks/useAccount'
import { useLauncher } from './hooks/useLauncher'
import { useServerStatus } from './hooks/useServerStatus'
import { useSettings } from './hooks/useSettings'
import { useUpdater } from './hooks/useUpdater'
import { isNative } from './services/native'
import { launchAccess } from './state/account'
import { installStageLabels } from './state/game'
@@ -36,13 +37,23 @@ export function App() {
const access = launchAccess(session.account)
const checking = desktop && (!profile || profile.status === 'checking')
const settingsBlocked = !preferences.loaded || preferences.saving || !!preferences.error
const disabled = !desktop || busy || session.busy || access === 'loading' ||
const updaterBlocked = busy ? 'Завершите игру или дождитесь окончания работы со сборкой.'
: settingsBlocked ? 'Дождитесь сохранения настроек; при ошибке повторите сохранение.'
: session.busy || access === 'loading' ? 'Дождитесь завершения работы с аккаунтом.'
: session.linking ? 'Завершите подтверждение игрового ника перед обновлением лаунчера.'
: session.recoveryCodes.length ? 'Сначала сохраните коды восстановления аккаунта.'
: legacyOpen ? 'Закройте проверку старых модов перед обновлением лаунчера.' : null
const updater = useUpdater(updaterBlocked)
const updaterRecovery = updater.state.status?.phase === 'error' && !updater.state.status.canRetry
const updateLocked = updater.mutating || updater.state.status?.phase === 'ready' || updaterRecovery
const disabled = !desktop || busy || updateLocked || session.busy || access === 'loading' ||
(access === 'ready' && (checking || settingsBlocked || !launcher.eventsReady))
const repairDisabled = !desktop || busy || checking
const repairDisabled = !desktop || busy || updateLocked || checking
const error = launcher.game.error ?? preferences.error ?? windowError ?? launcher.environmentError ?? session.error ?? profile?.error ?? null
let label = 'Играть'
if (!desktop) label = 'В приложении'
else if (updateLocked) label = updaterRecovery ? 'Нужно восстановить лаунчер' : 'Обновление лаунчера'
else if (operation.phase === 'running') label = 'Игра запущена'
else if (operation.phase === 'launching') label = 'Запускаем…'
else if (operation.phase === 'installing') label = operation.progress ? `${installStageLabels[operation.progress.stage]}…` : 'Подготовка…'
@@ -56,7 +67,7 @@ export function App() {
else if (!ready) label = 'Проверить'
const onboard = async (nickname: string) => {
if (busy || settingsBlocked || !launcher.eventsReady) return
if (busy || updateLocked || settingsBlocked || !launcher.eventsReady) return
const result = await launcher.onboard(selected.profileId, nickname)
if (result?.onboarding) session.acceptChallenge(result.onboarding)
}
@@ -71,8 +82,14 @@ export function App() {
<Titlebar host={launcher.host} onError={setWindowError} />
<div className="workspace" inert={session.recoveryCodes.length > 0 || legacyOpen}>
<Library selected={selected} profiles={launcher.profiles} account={session.account}
native={desktop} locked={busy || session.busy} onSelect={setSelected} onSettings={() => setSettingsOpen(true)} />
native={desktop} locked={busy || updateLocked || session.busy} onSelect={setSelected} onSettings={() => setSettingsOpen(true)} />
<ServerStage server={displayed} status={serverStatus} javaMajor={metadata?.javaMajor}>
{(updater.state.status?.phase === 'available' || (updater.state.status?.phase === 'manual' && updater.state.status.availableVersion) || updater.state.status?.phase === 'ready' || updater.mutating || updaterRecovery) &&
<button className="launcher-update-notice" onClick={() => setSettingsOpen(true)}>
{updaterRecovery ? 'Нужно восстановить лаунчер' : updater.state.status?.phase === 'ready' ? 'Перезапустите лаунчер после обновления'
: updater.mutating ? 'Обновление лаунчера…' : `Доступен лаунчер ${updater.state.status?.availableVersion ?? ''}`}
<span>Открыть настройки</span>
</button>}
<PlayDock server={displayed} operation={operation} profile={profile}
memoryGb={preferences.settings.memoryMb / 1024} native={desktop}
needsLogin={access === 'login'} needsLink={access === 'link'}
@@ -80,7 +97,8 @@ export function App() {
onLegacy={() => setLegacyOpen(true)} onPrimary={primary} onRepair={() => { if (!repairDisabled) void launcher.repair(selected.profileId) }} />
</ServerStage>
</div>
<SettingsDrawer open={settingsOpen && !session.recoveryCodes.length} locked={busy} preferences={preferences}
<SettingsDrawer open={settingsOpen && !session.recoveryCodes.length} locked={busy || updateLocked} preferences={preferences}
updater={updater} native={desktop}
session={session} host={launcher.host} java={launcher.java} requiredJava={metadata?.javaMajor} onOnboard={onboard} onClose={closeSettings} />
{legacyOpen && <LegacyModsDialog profileId={selected.profileId} onClose={() => setLegacyOpen(false)} onChanged={() => { void launcher.refreshProfile(selected.profileId) }} />}
<RecoveryCodesModal codes={session.recoveryCodes} onAcknowledge={session.acknowledgeRecoveryCodes} />
+76
View File
@@ -0,0 +1,76 @@
import { Download, RefreshCw } from 'lucide-react'
import { canRunUpdater, updaterPercent } from '../state/updater'
import type { UpdaterState } from '../state/updater'
export interface LauncherUpdateProps {
state: UpdaterState
native: boolean
installedVersion?: string
onCheck: () => void
onInstall: () => void
onRestart: () => void
onOpenRelease: () => void
}
export function LauncherUpdate({ state, native, installedVersion, onCheck, onInstall, onRestart, onOpenRelease }: LauncherUpdateProps) {
const status = state.status
const phase = status?.phase
const percent = updaterPercent(status)
const downloadedBytes = status?.downloadedBytes ?? 0
const error = state.error ?? (phase === 'error' ? status?.message : null)
const needsRecovery = phase === 'error' && status?.canRetry === false
const checking = state.pending?.command === 'check' || phase === 'checking' || state.pending?.command === 'status'
const downloading = phase === 'downloading'
const progressing = downloading || phase === 'verifying' || phase === 'installing' || state.pending?.command === 'install'
const packageFormat = status?.packageFormat === 'development' ? 'для разработки'
: status?.packageFormat === 'unpackaged' ? 'без установщика' : status?.packageFormat
const titles = {
idle: 'Можно проверить новую версию', checking: 'Проверяем обновления…',
available: 'Доступно обновление', downloading: 'Скачиваем обновление…',
verifying: 'Проверяем подпись обновления…', installing: 'Устанавливаем обновление…',
ready: 'Обновление установлено — нужен перезапуск', no_update: 'Установлена актуальная версия',
unconfigured: 'Автообновление пока не настроено', manual: 'Обновление пакета вручную', error: 'Не удалось обновить лаунчер',
}
return (
<section className="launcher-update" aria-labelledby="launcher-update-title">
<div className="launcher-update-heading"><h3 id="launcher-update-title">Лаунчер</h3>
<span>Версия {status?.installedVersion ?? installedVersion ?? 'уточняется'}{packageFormat && ` · ${packageFormat}`}</span></div>
{status?.testBuild && <p className="launcher-update-blocked">Тестовая сборка · тестовый канал обновлений</p>}
<p className="launcher-update-status" role="status">{!native ? 'Обновления доступны в приложении лаунчера.'
: phase ? titles[phase] : checking ? 'Читаем состояние обновления…' : 'Проверьте доступные обновления.'}</p>
{status?.availableVersion && <p>Новая версия: <strong>{status.availableVersion}</strong></p>}
{phase === 'manual' && <p>{status?.packageFormat === 'deb'
? 'Этот пакет обновляется вручную. Для установки .deb используйте системный менеджер пакетов; версия и инструкция доступны на странице выпусков.'
: 'Эта сборка обновляется вручную. Выберите пакет для своей системы на странице выпусков.'}</p>}
{phase === 'unconfigured' && <p>Для этой сборки канал обновлений недоступен. Проверка не меняет установленный лаунчер.</p>}
{phase === 'available' && <p>Установка закроет и перезапустит лаунчер. Игру потребуется завершить, изменения настроек — сохранить.</p>}
{phase === 'ready' && <p>Чтобы продолжить работу в новой версии, перезапустите лаунчер.</p>}
{needsRecovery && <p>Автоматическое продолжение недоступно. Восстановите лаунчер из пакета на странице выпусков.</p>}
{status?.message && phase !== 'error' && <p>{status.message}</p>}
{status?.releaseNotes && <details className="launcher-update-notes"><summary>Что изменилось</summary><p tabIndex={0} aria-label="Описание изменений">{status.releaseNotes}</p></details>}
{progressing && <div className="launcher-update-progress" role="progressbar" aria-label="Обновление лаунчера"
aria-valuemin={0} aria-valuemax={100} aria-valuenow={downloading && percent !== null ? percent : undefined}>
<i style={downloading && percent !== null ? { width: `${percent}%` } : undefined} />
</div>}
{downloading && <p>{percent === null
? `Скачано: ${(Math.max(0, Number.isFinite(downloadedBytes) ? downloadedBytes : 0) / 1024 / 1024).toFixed(1)} МБ`
: `Скачано: ${percent}%`}</p>}
{error && <p className="status-error" role="alert">{error}</p>}
{state.blockedReason && native && <p className="launcher-update-blocked">{state.blockedReason}</p>}
<div className="launcher-update-actions">
{phase !== 'ready' && !needsRecovery && <button type="button" disabled={!native || !canRunUpdater(state, 'check')} onClick={onCheck}>
<RefreshCw size={15} />{checking ? 'Проверяем…' : error ? 'Повторить проверку' : 'Проверить обновления'}
</button>}
{phase === 'available' && <button type="button" className="launcher-update-primary" disabled={!native || !canRunUpdater(state, 'install')} onClick={onInstall}>
<Download size={15} />Установить и перезапустить
</button>}
{phase === 'ready' && <button type="button" className="launcher-update-primary" disabled={!native || !canRunUpdater(state, 'restart')} onClick={onRestart}>
{state.pending?.command === 'restart' ? 'Перезапускаем…' : 'Перезапустить лаунчер'}
</button>}
{(phase === 'manual' || needsRecovery) && <button type="button" disabled={!native || !canRunUpdater(state, 'open')} onClick={onOpenRelease}>
{state.pending?.command === 'open' ? 'Открываем…' : 'Открыть страницу выпусков'}
</button>}
</div>
</section>
)
}
+10 -2
View File
@@ -1,6 +1,8 @@
import { useEffect, useRef } from 'react'
import { FolderOpen, Wrench, X } from 'lucide-react'
import { AccountSettings } from './AccountSettings'
import { LauncherUpdate } from './LauncherUpdate'
import type { useUpdater } from '../hooks/useUpdater'
import type { useAccount } from '../hooks/useAccount'
import type { useSettings } from '../hooks/useSettings'
import type { JavaInstallation, NativeHost } from '../types/launcher'
@@ -15,9 +17,11 @@ interface SettingsDrawerProps {
onClose: () => void
requiredJava?: number
onOnboard?: (nickname: string) => Promise<void>
updater: ReturnType<typeof useUpdater>
native: boolean
}
export function SettingsDrawer({ open, locked, host, java, preferences, session, onClose, requiredJava, onOnboard }: SettingsDrawerProps) {
export function SettingsDrawer({ open, locked, host, java, preferences, session, onClose, requiredJava, onOnboard, updater, native }: SettingsDrawerProps) {
const { settings, loaded, saving, error } = preferences
const closeButton = useRef<HTMLButtonElement>(null)
useEffect(() => {
@@ -27,7 +31,9 @@ export function SettingsDrawer({ open, locked, host, java, preferences, session,
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 elements = Array.from(closeButton.current?.closest('aside')?.querySelectorAll<HTMLElement>(
'button:not(:disabled), input:not(:disabled), select:not(:disabled), summary, [tabindex="0"]',
) ?? []).filter((element) => element.getClientRects().length > 0)
const first = elements?.[0]
const last = elements?.[elements.length - 1]
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus() }
@@ -49,6 +55,8 @@ export function SettingsDrawer({ open, locked, host, java, preferences, session,
<div><p>Настройки</p><h2 id="settings-title">Игра</h2></div>
<button ref={closeButton} onClick={onClose} aria-label="Закрыть настройки"><X /></button>
</div>
<LauncherUpdate state={updater.state} native={native} installedVersion={host?.launcherVersion}
onCheck={updater.check} onInstall={updater.install} onRestart={updater.restart} onOpenRelease={updater.openRelease} />
<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}
+86
View File
@@ -0,0 +1,86 @@
import { doesNotMatch, match } from 'node:assert/strict'
import { test } from 'node:test'
import { renderToStaticMarkup } from 'react-dom/server'
import { LauncherUpdate } from './LauncherUpdate'
import { initialUpdaterState } from '../state/updater'
import type { UpdaterState } from '../state/updater'
import type { UpdaterStatus } from '../types/updater'
function status(phase: UpdaterStatus['phase'], extra: Partial<UpdaterStatus> = {}): UpdaterStatus {
return { revision: 1, installedVersion: '0.2.0', testBuild: false, packageFormat: 'development', phase, availableVersion: null, releaseNotes: null,
downloadedBytes: 0, totalBytes: null, canRetry: false, message: null, ...extra }
}
function render(value: UpdaterStatus, extra: Partial<UpdaterState> = {}, native = true) {
return renderToStaticMarkup(<LauncherUpdate state={{ ...initialUpdaterState, status: value, ...extra }} native={native}
onCheck={() => {}} onInstall={() => {}} onRestart={() => {}} onOpenRelease={() => {}} />)
}
test('available updater notes are plain text and installation explicitly includes restart', () => {
const html = render(status('available', { availableVersion: '0.3.0', releaseNotes: '<img src=x onerror="alert(1)">\n[Link](https://example.invalid)' }))
match(html, /Версия 0\.2\.0/)
match(html, /Новая версия: <strong>0\.3\.0/)
match(html, /Установить и перезапустить/)
match(html, /Установка закроет и перезапустит лаунчер/)
match(html, /&lt;img src=x/)
doesNotMatch(html, /<img|<a\s|dangerouslySetInnerHTML|authenticode|notarization/i)
})
test('an unconfigured build still reports its installed native version', () => {
const html = render(status('unconfigured'))
match(html, /Версия 0\.2\.0/)
match(html, /Автообновление пока не настроено/)
doesNotMatch(html, /Установить и перезапустить/)
})
test('test updater builds have a visible native-provided notice', () => {
match(render(status('available', { testBuild: true })), /Тестовая сборка · тестовый канал обновлений/)
doesNotMatch(render(status('available')), /Тестовая сборка/)
})
test('manual Linux packages offer release instructions without a native install button or frontend link', () => {
const html = render(status('manual', { availableVersion: '0.3.0', packageFormat: 'deb' }))
match(html, /Версия 0\.2\.0 · deb/)
match(html, /системный менеджер пакетов/)
match(html, /Открыть страницу выпусков/)
doesNotMatch(html, /Установить и перезапустить|href=/)
})
test('pending game or settings work disables the explicit update action and explains why', () => {
const html = render(status('available'), { blockedReason: 'Игра запущена' })
match(html, /Игра запущена/)
match(html, /<button[^>]*class="launcher-update-primary"[^>]*disabled=""/)
})
test('unknown download size does not announce a false percentage', () => {
const html = render(status('downloading', { downloadedBytes: 1048576 }))
match(html, /Скачано: 1\.0 МБ/)
match(html, /role="progressbar"/)
doesNotMatch(html, /aria-valuenow|Скачано: 0%/)
match(render(status('downloading', { downloadedBytes: 50, totalBytes: 100 })), /aria-valuenow="50"/)
})
test('verification, installation and restart fallback remain distinct', () => {
match(render(status('verifying')), /Проверяем подпись обновления/)
match(render(status('installing')), /Устанавливаем обновление/)
const ready = render(status('ready'), { error: 'Не удалось перезапустить' })
match(ready, /Перезапустить лаунчер/)
match(ready, /role="alert">Не удалось перезапустить/)
doesNotMatch(ready, /Установить и перезапустить|Проверить обновления/)
})
test('check failures allow a clear retry, and an up-to-date result needs no install action', () => {
match(render(status('error', { canRetry: true, message: 'Сеть недоступна' })), /Повторить проверку/)
const current = render(status('no_update'))
match(current, /Установлена актуальная версия/)
doesNotMatch(current, /Установить и перезапустить/)
const preview = render(status('available'), {}, false)
match(preview, /Обновления доступны в приложении лаунчера/)
match(preview, /class="launcher-update-primary" disabled=""/)
})
test('an indeterminate install failure offers manual recovery rather than retrying installation', () => {
const html = render(status('error', { canRetry: false, message: 'Не удалось определить результат установки.' }))
match(html, /Восстановите лаунчер из пакета/)
match(html, /Открыть страницу выпусков/)
doesNotMatch(html, /Повторить проверку|Установить и перезапустить|Перезапустить лаунчер/)
})
+26
View File
@@ -0,0 +1,26 @@
import { useEffect, useRef, useSyncExternalStore } from 'react'
import { isNative, native, watchUpdater } from '../services/native'
import { createUpdaterController } from '../services/updater'
import { initialUpdaterState, updaterMutating } from '../state/updater'
export function useUpdater(blockedReason: string | null) {
const controller = useRef(createUpdaterController({
status: native.updaterStatus,
check: native.checkUpdater,
install: native.installUpdater,
restart: native.restartUpdater,
open: native.openUpdaterRelease,
watch: watchUpdater,
})).current
const state = useSyncExternalStore(controller.subscribe, controller.snapshot, () => initialUpdaterState)
useEffect(() => { controller.setBlockedReason(blockedReason) }, [controller, blockedReason])
useEffect(() => {
if (isNative()) return controller.connect()
}, [controller])
return { state, mutating: updaterMutating(state),
check: () => { void controller.check() },
install: () => { void controller.install() },
restart: () => { void controller.restart() },
openRelease: () => { void controller.open() },
}
}
+12
View File
@@ -2,6 +2,7 @@ import { invoke, isTauri } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { createSerialQueue, createSubscription, singleFlight } from './async'
import type { UpdaterStatus } from '../types/updater'
import type {
GameExitedPayload, InstallProgressPayload, JavaInstallation, LauncherSettings,
LinkChallenge, LinkStatus, NativeHost, ProfileInspection, ServerStatus,
@@ -15,6 +16,11 @@ const restoreAccount = singleFlight(() => accountRequests.enqueue(() => invoke<S
// 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 = {
updaterStatus: () => invoke<UpdaterStatus>('updater_status'),
checkUpdater: () => invoke<UpdaterStatus>('updater_check'),
installUpdater: () => invoke<UpdaterStatus>('updater_download_install'),
restartUpdater: () => invoke<void>('updater_restart'),
openUpdaterRelease: () => invoke<void>('updater_open_release_page'),
host: () => invoke<NativeHost>('native_host'),
metadata: (profileId: string) => invoke<ProfileMetadata>('profile_metadata', { profileId }),
legacyMods: (profileId: string) => invoke<LegacyMod[]>('legacy_mods', { profileId }),
@@ -36,6 +42,12 @@ export const native = {
launchOnboarding: (profileId: string, nickname: string) => invoke<PreparationResult>('launch_onboarding', { profileId, nickname }),
}
export function watchUpdater(receive: (status: UpdaterStatus) => void) {
return createSubscription([
listen<UpdaterStatus>('launcher-update-status', ({ payload }) => receive(payload)),
])
}
export const windowControls = {
minimize: () => getCurrentWindow().minimize(),
toggleMaximize: () => getCurrentWindow().toggleMaximize(),
+112
View File
@@ -0,0 +1,112 @@
import { errorMessage } from './async'
import { canRunUpdater, initialUpdaterState, updaterReducer } from '../state/updater'
import type { UpdaterAction, UpdaterCommand, UpdaterState } from '../state/updater'
import type { UpdaterStatus } from '../types/updater'
export interface UpdaterApi {
status: () => Promise<UpdaterStatus>
check: () => Promise<UpdaterStatus>
install: () => Promise<UpdaterStatus>
restart: () => Promise<void>
open: () => Promise<void>
watch: (receive: (status: UpdaterStatus) => void) => { ready: Promise<void>; dispose: () => void }
}
/** One window lifecycle, including StrictMode reconnects; never installs on its own. */
export function createUpdaterController(api: UpdaterApi) {
let state = initialUpdaterState
const subscribers = new Set<() => void>()
let request = 0
let generation = 0
let connected = false
let initialized = false
let automaticCheckStarted = false
let subscription: ReturnType<UpdaterApi['watch']> | null = null
const dispatch = (action: UpdaterAction) => {
const next = updaterReducer(state, action)
if (next === state) return
state = next
subscribers.forEach((notify) => notify())
}
const ensureEvents = () => {
if (!subscription) {
const current = generation
const created = api.watch((status) => {
if (connected && generation === current) dispatch({ type: 'status', status })
})
subscription = created
void created.ready.catch(() => {
if (subscription === created) { created.dispose(); subscription = null }
})
}
return subscription.ready
}
const run = async (command: Exclude<UpdaterCommand, 'status'>): Promise<boolean> => {
if (!connected || !canRunUpdater(state, command)) return false
const current = ++request
const currentGeneration = generation
dispatch({ type: 'begin', command, request: current })
try {
if (command !== 'open') await ensureEvents()
// Settings/account work may have started while the listener registered.
if (!connected || generation !== currentGeneration || (command !== 'open' && state.blockedReason)) return false
const status = await api[command]()
if (status) dispatch({ type: 'status', status })
return true
} catch (reason) {
dispatch({ type: 'failed', request: current, error: errorMessage(reason, 'Не удалось выполнить обновление лаунчера. Повторите попытку.') })
return false
} finally {
dispatch({ type: 'settled', request: current })
}
}
const automaticCheck = () => {
if (!connected || !initialized || automaticCheckStarted || !canRunUpdater(state, 'check')) return
automaticCheckStarted = true
void run('check')
}
const connect = () => {
connected = true
const current = ++generation
const read = ++request
// A user-started native operation can outlive a UI reconnect.
const ownsPending = state.pending === null
if (ownsPending) dispatch({ type: 'begin', command: 'status', request: read })
void ensureEvents().then(() => connected && generation === current ? api.status() : null).then((status) => {
if (!connected || generation !== current) return
if (status) dispatch({ type: 'status', status })
initialized = true
}).catch((reason) => {
if (connected && generation === current && ownsPending) dispatch({ type: 'failed', request: read,
error: errorMessage(reason, 'Не удалось прочитать состояние обновления лаунчера.') })
}).finally(() => {
if (ownsPending) dispatch({ type: 'settled', request: read })
if (connected && generation === current) automaticCheck()
})
return () => {
if (generation !== current) return
connected = false
initialized = false
generation++
subscription?.dispose()
subscription = null
if (ownsPending) dispatch({ type: 'settled', request: read })
}
}
return {
snapshot: (): UpdaterState => state,
subscribe: (notify: () => void) => { subscribers.add(notify); return () => { subscribers.delete(notify) } },
connect,
setBlockedReason: (reason: string | null) => { dispatch({ type: 'blocked', reason }); automaticCheck() },
check: () => { automaticCheckStarted = true; return run('check') },
install: () => run('install'),
restart: () => run('restart'),
open: () => run('open'),
}
}
+222
View File
@@ -0,0 +1,222 @@
import { deepStrictEqual, equal, match } from 'node:assert/strict'
import { test } from 'node:test'
import { canRunUpdater, initialUpdaterState, updaterMutating, updaterPercent, updaterReducer } from './updater'
import { createUpdaterController } from '../services/updater'
import type { UpdaterApi } from '../services/updater'
import type { UpdaterStatus } from '../types/updater'
function status(phase: UpdaterStatus['phase'], revision = 0): UpdaterStatus {
return { revision, installedVersion: '0.2.0', testBuild: false, packageFormat: 'development', phase, availableVersion: null, releaseNotes: null,
downloadedBytes: 0, totalBytes: null, canRetry: false, message: null }
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (error: unknown) => void
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no })
return { promise, resolve, reject }
}
const flush = () => new Promise<void>((resolve) => setImmediate(resolve))
function fixture(overrides: Partial<UpdaterApi> = {}) {
const calls = { status: 0, check: 0, install: 0, restart: 0, open: 0, disposed: 0 }
const receivers: Array<(value: UpdaterStatus) => void> = []
const api: UpdaterApi = {
status: async () => { calls.status++; return status('idle') },
check: async () => { calls.check++; return status('available', calls.check) },
install: async () => { calls.install++; return status('ready', 50) },
restart: async () => { calls.restart++ },
open: async () => { calls.open++ },
watch: (receive) => { receivers.push(receive); return { ready: Promise.resolve(), dispose: () => { calls.disposed++ } } },
...overrides,
}
return { calls, receivers, api, controller: createUpdaterController(api) }
}
test('newer native progress wins over an older invoke result; stale failures cannot finish another request', () => {
const started = updaterReducer(initialUpdaterState, { type: 'begin', command: 'install', request: 1 })
const progress = updaterReducer(started, { type: 'status', status: status('verifying', 4) })
equal(updaterReducer(progress, { type: 'status', status: status('downloading', 3) }), progress)
equal(updaterReducer(progress, { type: 'status', status: status('available', 4) }), progress)
const failed = updaterReducer(progress, { type: 'failed', request: 1, error: 'Network interrupted' })
const retry = updaterReducer(failed, { type: 'begin', command: 'check', request: 2 })
equal(updaterReducer(retry, { type: 'failed', request: 1, error: 'Old error' }), retry)
equal(updaterReducer(retry, { type: 'settled', request: 1 }), retry)
equal(retry.error, null)
})
test('one startup check survives a StrictMode reconnect and never downloads or restarts', async () => {
const { controller, calls } = fixture()
const disconnect = controller.connect()
disconnect() // StrictMode cleans up before listener registration has resolved.
const disconnectRemount = controller.connect()
await flush()
equal(calls.check, 1)
equal(calls.status, 1)
equal(controller.snapshot().status?.phase, 'available')
disconnectRemount()
const disconnectAgain = controller.connect()
await flush()
equal(calls.check, 1)
equal(calls.install, 0)
equal(calls.restart, 0)
disconnectAgain()
})
test('new pending account work or UI disposal before native handoff cancels an install request', async () => {
const { controller, calls } = fixture()
const disconnect = controller.connect()
await flush()
const blockedBeforeHandoff = controller.install()
controller.setBlockedReason('Аккаунт сохраняется')
equal(await blockedBeforeHandoff, false)
equal(calls.install, 0)
equal(controller.snapshot().pending, null)
controller.setBlockedReason(null)
const cancelledBeforeHandoff = controller.install()
disconnect()
equal(await cancelledBeforeHandoff, false)
equal(calls.install, 0)
})
test('disconnect before event registration resolves cancels initialization and rejects old listener events', async () => {
const ready = deferred<void>()
let receive!: (value: UpdaterStatus) => void
let disposed = 0
const { controller, calls } = fixture({ watch: (callback) => {
receive = callback
return { ready: ready.promise, dispose: () => { disposed++ } }
} })
const disconnect = controller.connect()
disconnect()
ready.resolve()
receive(status('available', 99))
await flush()
equal(disposed, 1)
equal(calls.status, 0)
equal(calls.check, 0)
equal(controller.snapshot().status, null)
equal(controller.snapshot().pending, null)
})
test('an earlier status read cannot hide an in-flight native installation', async () => {
const read = deferred<UpdaterStatus>()
const { controller, receivers, calls } = fixture({ status: () => read.promise })
const disconnect = controller.connect()
await flush()
receivers[0]?.(status('installing', 6))
read.resolve(status('idle', 0))
await flush()
equal(controller.snapshot().status?.phase, 'installing')
equal(updaterMutating(controller.snapshot()), true)
equal(calls.check, 0)
equal(await controller.install(), false)
disconnect()
})
test('startup check waits for pending work and explicit installation cannot overlap it or a double click', async () => {
const installation = deferred<UpdaterStatus>()
let installs = 0
const { controller, calls } = fixture({ install: () => { installs++; return installation.promise } })
controller.setBlockedReason('Сохраняем настройки')
const disconnect = controller.connect()
await flush()
equal(calls.check, 0)
controller.setBlockedReason(null)
await flush()
equal(calls.check, 1)
controller.setBlockedReason('Игра запущена')
equal(await controller.install(), false)
equal(installs, 0)
controller.setBlockedReason(null)
const first = controller.install()
equal(await controller.install(), false)
await flush()
equal(installs, 1)
equal(updaterMutating(controller.snapshot()), true)
installation.resolve(status('ready', 7))
equal(await first, true)
equal(calls.restart, 0) // The native install owns its restart; the UI never sends a second one.
disconnect()
})
test('a failed automatic check releases its request; retry is explicit and never installs', async () => {
let attempts = 0
const { controller, calls } = fixture({ check: async () => {
if (++attempts === 1) throw new Error('Network unavailable')
return status('available', 3)
} })
const disconnect = controller.connect()
await flush()
match(controller.snapshot().error ?? '', /Network unavailable/)
equal(controller.snapshot().pending, null)
await flush()
equal(attempts, 1)
equal(await controller.check(), true)
equal(controller.snapshot().error, null)
equal(controller.snapshot().status?.phase, 'available')
equal(calls.install, 0)
disconnect()
})
test('failed event subscription can be retried without leaving a dead pending operation', async () => {
let subscriptions = 0
let disposed = 0
const { controller, calls } = fixture({ watch: () => ({
ready: ++subscriptions === 1 ? Promise.reject(new Error('Events unavailable')) : Promise.resolve(),
dispose: () => { disposed++ },
}) })
const disconnect = controller.connect()
await flush()
match(controller.snapshot().error ?? '', /Events unavailable/)
equal(calls.check, 0)
equal(await controller.check(), true)
equal(subscriptions, 2)
equal(disposed, 1)
equal(calls.check, 1)
disconnect()
})
test('manual packages and a ready restart cannot accidentally invoke installation', () => {
const manual = { ...initialUpdaterState, status: status('manual') }
equal(canRunUpdater(manual, 'install'), false)
equal(canRunUpdater(manual, 'open'), true)
const ready = { ...initialUpdaterState, status: status('ready') }
equal(canRunUpdater(ready, 'check'), false)
equal(canRunUpdater(ready, 'install'), false)
equal(canRunUpdater(ready, 'restart'), true)
equal(canRunUpdater({ ...ready, blockedReason: 'Аккаунт сохраняется' }, 'restart'), false)
})
test('an indeterminate installer failure only permits the fixed manual recovery page', () => {
const recovery = { ...initialUpdaterState, status: status('error'), blockedReason: 'Настройки не сохранены' }
equal(canRunUpdater(recovery, 'check'), false)
equal(canRunUpdater(recovery, 'install'), false)
equal(canRunUpdater(recovery, 'restart'), false)
equal(canRunUpdater(recovery, 'open'), true)
})
test('manual recovery can open the fixed page even when a failed settings save blocks mutations', async () => {
const { controller, calls } = fixture({ status: async () => status('error', 2) })
controller.setBlockedReason('Настройки не сохранены')
const disconnect = controller.connect()
await flush()
equal(await controller.open(), true)
equal(calls.open, 1)
equal(calls.install, 0)
equal(calls.check, 0)
disconnect()
})
test('unknown download totals stay indeterminate and finite totals are bounded', () => {
equal(updaterPercent(null), null)
const downloading = status('downloading')
equal(updaterPercent(downloading), null)
equal(updaterPercent({ ...downloading, downloadedBytes: 10, totalBytes: 0 }), null)
equal(updaterPercent({ ...downloading, downloadedBytes: NaN, totalBytes: 10 }), null)
equal(updaterPercent({ ...downloading, downloadedBytes: 10, totalBytes: Infinity }), null)
equal(updaterPercent({ ...downloading, downloadedBytes: -1, totalBytes: 10 }), null)
deepStrictEqual([updaterPercent({ ...downloading, downloadedBytes: 7, totalBytes: 10 }),
updaterPercent({ ...downloading, downloadedBytes: 11, totalBytes: 10 })], [70, 100])
})
+56
View File
@@ -0,0 +1,56 @@
import type { UpdaterStatus } from '../types/updater'
export type UpdaterCommand = 'status' | 'check' | 'install' | 'restart' | 'open'
export interface UpdaterState {
status: UpdaterStatus | null
pending: { command: UpdaterCommand; request: number } | null
error: string | null
blockedReason: string | null
}
export const initialUpdaterState: UpdaterState = { status: null, pending: null, error: null, blockedReason: null }
export type UpdaterAction =
| { type: 'status'; status: UpdaterStatus }
| { type: 'begin'; command: UpdaterCommand; request: number }
| { type: 'settled'; request: number }
| { type: 'failed'; request: number; error: string }
| { type: 'blocked'; reason: string | null }
export function updaterReducer(state: UpdaterState, action: UpdaterAction): UpdaterState {
switch (action.type) {
case 'status':
// Event delivery and invoke completion may arrive in either order.
if (state.status && action.status.revision <= state.status.revision) return state
return { ...state, status: action.status, error: null }
case 'begin':
return state.pending ? state : { ...state, pending: { command: action.command, request: action.request }, error: null }
case 'settled':
return state.pending?.request === action.request ? { ...state, pending: null } : state
case 'failed':
return state.pending?.request === action.request ? { ...state, pending: null, error: action.error } : state
case 'blocked':
return state.blockedReason === action.reason ? state : { ...state, blockedReason: action.reason }
}
}
export function updaterMutating(state: UpdaterState): boolean {
return state.pending?.command === 'install' || state.pending?.command === 'restart' ||
state.status?.phase === 'downloading' || state.status?.phase === 'verifying' || state.status?.phase === 'installing'
}
export function canRunUpdater(state: UpdaterState, command: Exclude<UpdaterCommand, 'status'>): boolean {
if (state.pending) return false
const phase = state.status?.phase
if (command === 'open') return phase === 'manual' || (phase === 'error' && state.status?.canRetry === false)
if (state.blockedReason || updaterMutating(state)) return false
if (command === 'restart') return phase === 'ready'
if (command === 'install') return phase === 'available'
if (phase === 'error' && state.status?.canRetry === false) return false
return !phase || ['idle', 'available', 'no_update', 'manual', 'unconfigured', 'error'].includes(phase)
}
export function updaterPercent(status: UpdaterStatus | null): number | null {
if (!status || status.totalBytes === null || !Number.isFinite(status.totalBytes) || status.totalBytes <= 0 ||
!Number.isFinite(status.downloadedBytes) || status.downloadedBytes < 0) return null
return Math.min(100, Math.floor(100 * status.downloadedBytes / status.totalBytes))
}
+23 -1
View File
@@ -126,7 +126,7 @@ button:disabled { cursor: default; }
.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; 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 { position: fixed; top: 48px; right: 0; bottom: 0; width: min(390px, 100vw); overflow-y: auto; background: #121713; border-left: 1px solid var(--line); z-index: 21; padding: clamp(18px, 3vw, 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; }
@@ -188,3 +188,25 @@ button:disabled { cursor: default; }
.legacy-actions { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 20px; }
.legacy-actions button { font: inherit; padding: 10px 14px; cursor: pointer; }
.account-hint { white-space: pre-wrap; overflow-wrap: anywhere; user-select: text; }
.launcher-update { padding: 0 0 24px; margin-bottom: 24px; border-bottom: 1px solid var(--line); }
.launcher-update-heading { display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; align-items: baseline; }
.launcher-update-heading h3 { margin: 0; font-size: 14px; }
.launcher-update-heading > span { color: var(--muted); font-size: 11px; }
.launcher-update p { font-size: 11px; line-height: 1.6; color: #aeb7af; overflow-wrap: anywhere; }
.launcher-update .launcher-update-status { color: var(--green); font-weight: 700; }
.launcher-update .status-error { color: #eea18f; }
.launcher-update .launcher-update-blocked { color: var(--copper); }
.launcher-update-actions { display: grid; gap: 8px; margin-top: 14px; }
.launcher-update-actions button { display: flex; gap: 8px; align-items: center; justify-content: center; border: 1px solid var(--line); border-radius: 5px; background: #222b24; padding: 10px 12px; font-size: 11px; cursor: pointer; }
.launcher-update-actions button.launcher-update-primary { background: var(--green); color: var(--ink); font-weight: 800; }
.launcher-update-actions button:disabled { opacity: .45; cursor: default; }
.launcher-update-notes { margin-top: 12px; font-size: 11px; }
.launcher-update-notes summary { cursor: pointer; color: var(--ice); }
.launcher-update-notes p { white-space: pre-wrap; max-height: 220px; overflow-y: auto; user-select: text; }
.launcher-update-notes p:focus-visible, .launcher-update-notes summary:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; }
.launcher-update-progress { height: 5px; background: #283029; overflow: hidden; border-radius: 3px; }
.launcher-update-progress i { display: block; height: 100%; width: 100%; background: var(--ice); }
.launcher-update-progress:not([aria-valuenow]) i { opacity: .5; }
.launcher-update-notice { position: absolute; top: 70px; right: 26px; display: grid; gap: 3px; text-align: left; padding: 10px 14px; border: 1px solid #34553b; border-radius: 5px; background: #14261b; color: #c6e8c8; font-size: 11px; cursor: pointer; }
.launcher-update-notice span { color: #8daf95; font-size: 10px; }
+15
View File
@@ -0,0 +1,15 @@
/** Native updater owns endpoints, verification keys, package choice and versions. */
export interface UpdaterStatus {
revision: number
installedVersion: string
testBuild: boolean
packageFormat: 'development' | 'AppImage' | 'deb' | 'rpm' | 'MSI' | 'NSIS' | 'app' | 'unpackaged'
phase: 'idle' | 'checking' | 'available' | 'downloading' | 'verifying' | 'installing' |
'ready' | 'no_update' | 'unconfigured' | 'manual' | 'error'
availableVersion: string | null
releaseNotes: string | null
downloadedBytes: number
totalBytes: number | null
canRetry: boolean
message: string | null
}