Add signed launcher self-updates and publish Linux 0.1.3

This commit is contained in:
Emil
2026-09-10 02:47:16 +03:00
parent bf43254c57
commit bb4b1f8051
55 changed files with 6286 additions and 43 deletions
+76
View File
@@ -0,0 +1,76 @@
import { equal, match } from 'node:assert/strict'
import { test } from 'node:test'
import { initialUpdaterState, updateBlocksOperations, updatePercent, updaterReducer } from './updater.ts'
const available = updaterReducer(initialUpdaterState, { type: 'loaded', checked: true,
status: { currentVersion: '0.1.3', supported: true, version: '0.1.4' } })
const downloading = updaterReducer(available, { type: 'install' })
test('checking and available update allow play; installation locks operations until restart', () => {
equal(updateBlocksOperations(available), false)
equal(updateBlocksOperations(updaterReducer(available, { type: 'check' })), false)
equal(updateBlocksOperations(downloading), true)
const ready = updaterReducer(downloading, { type: 'ready' })
equal(updateBlocksOperations(ready), true)
equal(updaterReducer(ready, { type: 'check' }), ready)
equal(updaterReducer(ready, { type: 'install' }), ready)
const restarting = updaterReducer(ready, { type: 'restart' })
equal(updateBlocksOperations(restarting), true)
})
test('an installation failure permits playing and retrying; a restart failure retains the lock', () => {
const failed = updaterReducer(downloading, { type: 'failed', error: 'Подпись не совпадает' })
equal(updateBlocksOperations(failed), false)
equal(failed.status?.version, '0.1.4')
const retry = updaterReducer(failed, { type: 'install' })
equal(retry.error, null)
equal(retry.phase, 'downloading')
const ready = updaterReducer(retry, { type: 'ready' })
const restartError = updaterReducer(updaterReducer(ready, { type: 'restart' }), { type: 'failed', error: 'Перезапустите вручную' })
equal(restartError.phase, 'ready')
match(restartError.error ?? '', /вручную/)
equal(updateBlocksOperations(restartError), true)
})
test('late progress cannot undo completion or a failed installation', () => {
const installing = updaterReducer(downloading, { type: 'progress', progress: { stage: 'installing', downloadedBytes: 20 } })
equal(updaterReducer(installing, { type: 'progress', progress: { stage: 'downloading', downloadedBytes: 10 } }), installing)
const ready = updaterReducer(installing, { type: 'progress', progress: { stage: 'ready', downloadedBytes: 20 } })
equal(updaterReducer(ready, { type: 'progress', progress: { stage: 'installing', downloadedBytes: 20 } }), ready)
const failed = updaterReducer(downloading, { type: 'failed', error: 'Сбой сети' })
equal(updaterReducer(failed, { type: 'ready' }), failed)
equal(updaterReducer(failed, { type: 'progress', progress: { stage: 'ready', downloadedBytes: 20 } }), failed)
})
test('native status restores an update after a webview reload; early events preserve readiness', () => {
for (const stage of ['downloading', 'installing', 'ready'] as const) {
const restored = updaterReducer(initialUpdaterState, { type: 'loaded', checked: false,
status: { ...available.status!, stage } })
equal(restored.phase, stage)
equal(updateBlocksOperations(restored), true)
}
const earlyReady = updaterReducer(initialUpdaterState, { type: 'progress', progress: { stage: 'ready', downloadedBytes: 20 } })
const staleStatus = updaterReducer(earlyReady, { type: 'loaded', checked: false,
status: { ...available.status!, stage: 'downloading' } })
equal(staleStatus.phase, 'ready')
equal(staleStatus.status?.currentVersion, '0.1.3')
})
test('unsupported package and no available version never enter installation', () => {
const unsupported = updaterReducer(initialUpdaterState, { type: 'loaded', checked: false,
status: { currentVersion: '0.1.3', supported: false, reason: 'Используйте AppImage' } })
equal(updaterReducer(unsupported, { type: 'install' }), unsupported)
const current = updaterReducer(initialUpdaterState, { type: 'loaded', checked: true,
status: { currentVersion: '0.1.3', supported: true } })
equal(updaterReducer(current, { type: 'install' }), current)
})
test('unknown, invalid and excessive progress cannot produce misleading percentages', () => {
equal(updatePercent(null), null)
equal(updatePercent({ stage: 'downloading', downloadedBytes: 1 }), null)
equal(updatePercent({ stage: 'downloading', downloadedBytes: NaN, totalBytes: 20 }), null)
equal(updatePercent({ stage: 'downloading', downloadedBytes: 1, totalBytes: Infinity }), null)
equal(updatePercent({ stage: 'downloading', downloadedBytes: 10, totalBytes: 20 }), 50)
equal(updatePercent({ stage: 'downloading', downloadedBytes: 30, totalBytes: 20 }), 100)
equal(updatePercent({ stage: 'downloading', downloadedBytes: -1, totalBytes: 20 }), 0)
})
+59
View File
@@ -0,0 +1,59 @@
import type { LauncherUpdateProgress, LauncherUpdateStatus } from '../types/launcher'
export interface UpdaterState {
phase: 'loading' | 'idle' | 'checking' | 'downloading' | 'installing' | 'ready' | 'restarting'
status: LauncherUpdateStatus | null
progress: LauncherUpdateProgress | null
error: string | null
checked: boolean
}
export const initialUpdaterState: UpdaterState = {
phase: 'loading', status: null, progress: null, error: null, checked: false,
}
type UpdaterAction =
| { type: 'loaded'; status: LauncherUpdateStatus; checked: boolean; error?: string | null }
| { type: 'check' }
| { type: 'install' }
| { type: 'progress'; progress: LauncherUpdateProgress }
| { type: 'ready' }
| { type: 'restart' }
| { type: 'failed'; error: string }
export function updateBlocksOperations(state: UpdaterState): boolean {
return ['downloading', 'installing', 'ready', 'restarting'].includes(state.phase)
}
export function updaterReducer(state: UpdaterState, action: UpdaterAction): UpdaterState {
switch (action.type) {
case 'loaded':
if (updateBlocksOperations(state)) return { ...state, status: action.status }
return { ...state, phase: action.status.stage === 'ready' || action.status.stage === 'downloading' || action.status.stage === 'installing'
? action.status.stage : 'idle', status: action.status,
checked: action.checked, error: action.error ?? null }
case 'check':
return updateBlocksOperations(state) ? state : { ...state, phase: 'checking', error: null }
case 'install':
return state.phase !== 'idle' || !state.status?.supported || !state.status.version ? state
: { ...state, phase: 'downloading', progress: null, error: null }
case 'progress':
if (state.phase !== 'loading' && state.phase !== 'downloading' && state.phase !== 'installing') return state
// A delayed download event cannot revert an installation to downloading.
if (state.phase === 'installing' && action.progress.stage === 'downloading') return state
return { ...state, phase: action.progress.stage, progress: action.progress }
case 'ready':
return state.phase === 'downloading' || state.phase === 'installing'
? { ...state, phase: 'ready', error: null } : state
case 'restart':
return state.phase === 'ready' ? { ...state, phase: 'restarting', error: null } : state
case 'failed':
return { ...state, phase: state.phase === 'ready' || state.phase === 'restarting' ? 'ready' : 'idle', error: action.error }
}
}
export function updatePercent(progress: LauncherUpdateProgress | null): number | null {
if (!progress || !progress.totalBytes || progress.totalBytes <= 0 ||
!Number.isFinite(progress.totalBytes) || !Number.isFinite(progress.downloadedBytes)) return null
return Math.max(0, Math.min(100, Math.round(progress.downloadedBytes / progress.totalBytes * 100)))
}