Support signed deb self-updates with system authorization in 0.1.4

This commit is contained in:
Emil
2026-09-10 03:28:51 +03:00
parent bb4b1f8051
commit 260e4577d0
19 changed files with 1202 additions and 65 deletions
+7 -2
View File
@@ -10,6 +10,7 @@ export function LauncherUpdateSettings({ updater }: { updater: ReturnType<typeof
const working = phase === 'downloading' || phase === 'installing'
const ready = phase === 'ready' || phase === 'restarting'
const checking = phase === 'loading' || phase === 'checking'
const deb = status?.installationKind === 'deb'
return <section className="launcher-update" aria-labelledby="launcher-update-title">
<div className="launcher-update-heading">
@@ -19,20 +20,24 @@ export function LauncherUpdateSettings({ updater }: { updater: ReturnType<typeof
<div className="launcher-update-status" aria-live="polite">
{!isNative() ? <p>Обновления доступны в приложении лаунчера.</p>
: ready ? <p className="update-success">Обновление установлено. Перезапустите лаунчер.</p>
: working ? <p>{phase === 'installing' ? 'Проверяем подпись и устанавливаем…'
: working ? <p>{phase === 'installing' ? deb
? 'Подтвердите установку в системном окне и дождитесь завершения.'
: 'Проверяем подпись и устанавливаем…'
: `Скачиваем обновление${percent === null ? '…' : ` · ${percent}%`}`}</p>
: checking ? <p>Проверяем обновления…</p>
: status?.supported === false ? <p>{status.reason || 'Для этой установки обновление доступно вручную на shacraft.ru/help#launcher.'}</p>
: status?.version ? <p className="update-success">Доступна версия {status.version}</p>
: state.checked && !error ? <p>У вас последняя версия.</p>
: <p>Проверка новой версии лаунчера.</p>}
{working && <progress aria-label="Загрузка обновления лаунчера" max={100} value={phase === 'installing' ? undefined : percent ?? undefined} />}
{working && <progress aria-label={phase === 'installing' ? 'Установка обновления лаунчера' : 'Загрузка обновления лаунчера'} max={100} value={phase === 'installing' ? undefined : percent ?? undefined} />}
</div>
{status?.notes && status.version && !working && !ready && <details className="update-notes">
<summary>Что нового</summary><p>{status.notes.slice(0, 1600)}</p>
</details>}
{error && <p className="status-error update-error" role="status">{error}</p>}
{eventError && <p className="status-error update-error" role="status">{eventError} Перезапустите лаунчер, чтобы включить установку обновлений.</p>}
{deb && status?.supported && status.version && !working && !ready &&
<p className="update-hint">Для обновления deb потребуется подтверждение администратора в системном окне.</p>}
{isNative() && <div className="update-actions">
{ready ? <button className="update-primary" disabled={blocked || phase === 'restarting'} onClick={() => void updater.restart()}>
<RefreshCw />{phase === 'restarting' ? 'Перезапускаем…' : 'Перезапустить лаунчер'}
+75
View File
@@ -0,0 +1,75 @@
import { doesNotMatch, match } from 'node:assert/strict'
import { test } from 'node:test'
import { renderToStaticMarkup } from 'react-dom/server'
import { LauncherUpdateSettings } from './LauncherUpdateSettings'
import type { useLauncherUpdate } from '../hooks/useLauncherUpdate'
import { initialUpdaterState, updaterReducer, updateBlocksOperations, type UpdaterState } from '../state/updater'
function render(state: UpdaterState): string {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window')
const previousTauri = Object.getOwnPropertyDescriptor(globalThis, 'isTauri')
Object.defineProperty(globalThis, 'window', { configurable: true, value: { isTauri: true } })
Object.defineProperty(globalThis, 'isTauri', { configurable: true, value: true })
try {
const updater: ReturnType<typeof useLauncherUpdate> = {
state, blocked: false, eventsReady: true, eventError: null,
locksOperations: updateBlocksOperations(state), check: async () => {},
install: async () => {}, restart: async () => {},
}
return renderToStaticMarkup(<LauncherUpdateSettings updater={updater} />)
} finally {
if (previous) Object.defineProperty(globalThis, 'window', previous)
else Reflect.deleteProperty(globalThis, 'window')
if (previousTauri) Object.defineProperty(globalThis, 'isTauri', previousTauri)
else Reflect.deleteProperty(globalThis, 'isTauri')
}
}
const available = updaterReducer(initialUpdaterState, { type: 'loaded', checked: true,
status: { currentVersion: '0.1.4', supported: true, installationKind: 'deb', version: '0.1.5' } })
test('deb announces system authorization before installation without collecting credentials', () => {
const html = render(available)
match(html, /Для обновления deb потребуется подтверждение администратора в системном окне/)
match(html, /class="update-primary"><[^>]+.*Обновить<\/button>/)
doesNotMatch(html, /<input|type="password"|Перезапустить лаунчер/)
})
test('deb installation requests system confirmation and prevents duplicate install or check', () => {
const downloading = updaterReducer(available, { type: 'install' })
const installing = updaterReducer(downloading, { type: 'progress',
progress: { stage: 'installing', downloadedBytes: 10, totalBytes: 10 } })
const html = render(installing)
match(html, /Подтвердите установку в системном окне и дождитесь завершения/)
match(html, /aria-label="Установка обновления лаунчера"/)
match(html, /class="update-primary" disabled=""/)
match(html, /class="update-check" disabled=""/)
doesNotMatch(html, /Для обновления deb потребуется|<input|type="password"/)
})
test('cancelled deb authorization remains visible and permits explicit retry without claiming success', () => {
const downloading = updaterReducer(available, { type: 'install' })
const installing = updaterReducer(downloading, { type: 'progress',
progress: { stage: 'installing', downloadedBytes: 10 } })
const cancelled = updaterReducer(installing, { type: 'failed', error: 'Установка отменена в системном окне.' })
const html = render(cancelled)
match(html, /role="status">Установка отменена в системном окне/)
match(html, /class="update-primary">/)
match(html, /Повторить проверку/)
doesNotMatch(html, /disabled=""|Обновление установлено|Перезапустить лаунчер/)
const retry = render(updaterReducer(cancelled, { type: 'install' }))
match(retry, /Скачиваем обновление/)
doesNotMatch(retry, /Установка отменена/)
})
test('AppImage and older native status retain their installation copy without administrator hints', () => {
for (const installationKind of ['appimage', undefined] as const) {
const status = { ...available.status!, installationKind }
const downloading = updaterReducer({ ...available, status }, { type: 'install' })
const installing = updaterReducer(downloading, { type: 'progress',
progress: { stage: 'installing', downloadedBytes: 10 } })
const html = render(installing)
match(html, /Проверяем подпись и устанавливаем/)
doesNotMatch(html, /администратора|системном окне/)
}
})
+1
View File
@@ -84,6 +84,7 @@ export interface GameExitedPayload {
export interface LauncherUpdateStatus {
currentVersion: string
supported: boolean
installationKind?: 'appimage' | 'deb' | 'other'
reason?: string | null
version?: string | null
notes?: string | null