feat: add signed launcher updates with native lifecycle recovery
This commit is contained in:
+23
-5
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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, /<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, /Повторить проверку|Установить и перезапустить|Перезапустить лаунчер/)
|
||||
})
|
||||
@@ -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() },
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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'),
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
})
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user