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
+15 -5
View File
@@ -9,6 +9,7 @@ import { Titlebar } from './components/Titlebar'
import { servers } from './data/servers'
import { useAccount } from './hooks/useAccount'
import { useLauncher } from './hooks/useLauncher'
import { useLauncherUpdate } from './hooks/useLauncherUpdate'
import { useServerStatus } from './hooks/useServerStatus'
import { useSettings } from './hooks/useSettings'
import { isNative } from './services/native'
@@ -33,9 +34,11 @@ 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 updater = useLauncherUpdate(busy || session.busy || preferences.saving || session.recoveryCodes.length > 0)
const updateLocked = updater.locksOperations
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
useEffect(() => {
if (error) setErrorFeedback({ kind: 'error', title: 'Ошибка лаунчера', message: error })
@@ -43,6 +46,7 @@ export function App() {
let label = 'Играть'
if (!desktop) label = 'В приложении'
else if (updateLocked) label = updater.state.phase === 'ready' || updater.state.phase === 'restarting' ? 'Перезапустите лаунчер' : 'Обновляем лаунчер…'
else if (operation.phase === 'running') label = 'Игра запущена'
else if (operation.phase === 'launching') label = 'Запускаем…'
else if (operation.phase === 'installing') label = operation.progress ? `${installStageLabels[operation.progress.stage]}…` : 'Подготовка…'
@@ -66,7 +70,7 @@ export function App() {
<Titlebar host={launcher.host} onError={setWindowError} />
<div className="workspace" inert={session.recoveryCodes.length > 0}>
<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={selected} status={serverStatus}>
<PlayDock server={selected} operation={operation} profile={profile}
memoryGb={preferences.settings.memoryMb / 1024} native={desktop}
@@ -75,8 +79,14 @@ export function App() {
onPrimary={primary} onRepair={() => { if (!repairDisabled) void launcher.repair(selected.profileId) }} />
</ServerStage>
</div>
<SettingsDrawer open={settingsOpen && !session.recoveryCodes.length} locked={busy} preferences={preferences}
session={session} host={launcher.host} java={launcher.java} onClose={closeSettings} />
{desktop && (updater.state.status?.version || updateLocked) && !settingsOpen && !session.recoveryCodes.length &&
<button className="update-banner" onClick={() => setSettingsOpen(true)}>
<span className="update-banner-dot" />
{updater.state.phase === 'ready' || updater.state.phase === 'restarting' ? 'Обновление установлено · перезапустить'
: updateLocked ? 'Обновляем ShaCraft Launcher…' : `ShaCraft Launcher ${updater.state.status?.version} · обновить`}
</button>}
<SettingsDrawer open={settingsOpen && !session.recoveryCodes.length} locked={busy || updateLocked} preferences={preferences}
session={session} updater={updater} host={launcher.host} java={launcher.java} onClose={closeSettings} />
<RecoveryCodesModal codes={session.recoveryCodes} onAcknowledge={session.acknowledgeRecoveryCodes} />
<FeedbackDialog feedback={session.recoveryCodes.length ? null : session.feedback ?? errorFeedback}
onDismiss={() => { session.dismissFeedback(); setErrorFeedback(null) }} />
+51
View File
@@ -0,0 +1,51 @@
import { ArrowDownToLine, RefreshCw } from 'lucide-react'
import type { useLauncherUpdate } from '../hooks/useLauncherUpdate'
import { isNative } from '../services/native'
import { updatePercent } from '../state/updater'
export function LauncherUpdateSettings({ updater }: { updater: ReturnType<typeof useLauncherUpdate> }) {
const { state, blocked, eventsReady, eventError } = updater
const { phase, status, error } = state
const percent = updatePercent(state.progress)
const working = phase === 'downloading' || phase === 'installing'
const ready = phase === 'ready' || phase === 'restarting'
const checking = phase === 'loading' || phase === 'checking'
return <section className="launcher-update" aria-labelledby="launcher-update-title">
<div className="launcher-update-heading">
<h3 id="launcher-update-title">ShaCraft Launcher</h3>
{status?.currentVersion && <span>{status.currentVersion}</span>}
</div>
<div className="launcher-update-status" aria-live="polite">
{!isNative() ? <p>Обновления доступны в приложении лаунчера.</p>
: ready ? <p className="update-success">Обновление установлено. Перезапустите лаунчер.</p>
: working ? <p>{phase === 'installing' ? 'Проверяем подпись и устанавливаем…'
: `Скачиваем обновление${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} />}
</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>}
{isNative() && <div className="update-actions">
{ready ? <button className="update-primary" disabled={blocked || phase === 'restarting'} onClick={() => void updater.restart()}>
<RefreshCw />{phase === 'restarting' ? 'Перезапускаем…' : 'Перезапустить лаунчер'}
</button> : <>
{status?.supported && status.version && <button className="update-primary"
disabled={blocked || working || checking || !eventsReady} onClick={() => void updater.install()}>
<ArrowDownToLine />{working ? 'Обновляем…' : 'Обновить'}
</button>}
{status?.supported !== false && <button className="update-check" disabled={checking || working} onClick={() => void updater.check()}>
{checking ? 'Проверяем…' : error ? 'Повторить проверку' : 'Проверить обновления'}
</button>}
</>}
</div>}
{blocked && status?.supported && status.version && !working && <p className="update-hint">Завершите игру и текущие операции, чтобы обновить лаунчер.</p>}
</section>
}
+7 -3
View File
@@ -1,8 +1,10 @@
import { useEffect, useRef } from 'react'
import { FolderOpen, Wrench, X } from 'lucide-react'
import { AccountSettings } from './AccountSettings'
import { LauncherUpdateSettings } from './LauncherUpdateSettings'
import type { useAccount } from '../hooks/useAccount'
import type { useSettings } from '../hooks/useSettings'
import type { useLauncherUpdate } from '../hooks/useLauncherUpdate'
import type { JavaInstallation, NativeHost } from '../types/launcher'
interface SettingsDrawerProps {
@@ -12,10 +14,11 @@ interface SettingsDrawerProps {
java: JavaInstallation | null | undefined
preferences: ReturnType<typeof useSettings>
session: ReturnType<typeof useAccount>
updater: ReturnType<typeof useLauncherUpdate>
onClose: () => void
}
export function SettingsDrawer({ open, locked, host, java, preferences, session, onClose }: SettingsDrawerProps) {
export function SettingsDrawer({ open, locked, host, java, preferences, session, updater, onClose }: SettingsDrawerProps) {
const { settings, loaded, saving, error } = preferences
const closeButton = useRef<HTMLButtonElement>(null)
useEffect(() => {
@@ -25,7 +28,7 @@ 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 = closeButton.current?.closest('aside')?.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), summary')
const first = elements?.[0]
const last = elements?.[elements.length - 1]
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus() }
@@ -44,9 +47,10 @@ export function SettingsDrawer({ open, locked, host, java, preferences, session,
<aside className={`settings-drawer ${open ? 'open' : ''}`} inert={!open} aria-hidden={!open}
role="dialog" aria-modal={open ? true : undefined} aria-labelledby="settings-title">
<div className="drawer-title">
<div><p>Настройки</p><h2 id="settings-title">Игра</h2></div>
<div><p>Настройки</p><h2 id="settings-title">Лаунчер и игра</h2></div>
<button ref={closeButton} onClick={onClose} aria-label="Закрыть настройки"><X /></button>
</div>
<LauncherUpdateSettings updater={updater} />
<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}
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useReducer, useRef, useState } from 'react'
import { errorMessage, singleFlight } from '../services/async'
import { isNative, native, watchLauncherUpdate } from '../services/native'
import { initialUpdaterState, updateBlocksOperations, updaterReducer } from '../state/updater'
// StrictMode re-runs effects; share the pending native check without installing
// anything. Manual checks always make a fresh request.
const startupCheck = singleFlight(async () => {
const status = await native.updateStatus()
if (!status.supported || (status.stage && ['downloading', 'installing', 'ready'].includes(status.stage))) return { status, checked: false, error: null }
try {
return { status: await native.checkUpdate(), checked: true, error: null }
} catch (error) {
return { status, checked: false, error: errorMessage(error, 'Не удалось проверить обновления. Повторите попытку.') }
}
})
export function useLauncherUpdate(blocked: boolean) {
const [state, dispatch] = useReducer(updaterReducer, initialUpdaterState)
const [eventsReady, setEventsReady] = useState(false)
const [eventError, setEventError] = useState<string | null>(null)
const pending = useRef(false)
const mounted = useRef(false)
useEffect(() => {
if (!isNative()) return
let active = true
mounted.current = true
const subscription = watchLauncherUpdate((progress) => {
if (active) dispatch({ type: 'progress', progress })
})
void subscription.ready.then(() => {
if (active) { setEventsReady(true); setEventError(null) }
}).catch((reason: unknown) => {
if (active) setEventError(errorMessage(reason, 'Не удалось подключить события обновления. Перезапустите лаунчер.'))
})
pending.current = true
void startupCheck().then((result) => {
if (active) dispatch({ type: 'loaded', ...result })
}).catch((reason: unknown) => {
if (active) dispatch({ type: 'failed', error: errorMessage(reason, 'Не удалось проверить обновления. Повторите попытку.') })
}).finally(() => { if (active) pending.current = false })
return () => { active = false; mounted.current = false; subscription.dispose() }
}, [])
const check = async () => {
if (!isNative() || pending.current || updateBlocksOperations(state)) return
pending.current = true
dispatch({ type: 'check' })
try {
const status = await native.checkUpdate()
if (mounted.current) dispatch({ type: 'loaded', status, checked: true })
} catch (reason) {
if (mounted.current) dispatch({ type: 'failed', error: errorMessage(reason, 'Не удалось проверить обновления. Повторите попытку.') })
} finally { pending.current = false }
}
const install = async () => {
if (!isNative() || pending.current || blocked || !eventsReady || state.phase !== 'idle' ||
!state.status?.supported || !state.status.version) return
pending.current = true
dispatch({ type: 'install' })
try {
await native.installUpdate()
if (mounted.current) dispatch({ type: 'ready' })
} catch (reason) {
if (mounted.current) dispatch({ type: 'failed', error: errorMessage(reason, 'Не удалось установить обновление. Повторите попытку.') })
} finally { pending.current = false }
}
const restart = async () => {
if (!isNative() || pending.current || blocked || state.phase !== 'ready') return
pending.current = true
dispatch({ type: 'restart' })
try {
await native.restartAfterUpdate()
} catch (reason) {
if (mounted.current) dispatch({ type: 'failed', error: errorMessage(reason, 'Не удалось перезапустить лаунчер. Закройте его и откройте снова.') })
} finally { pending.current = false }
}
return { state, eventsReady, eventError, blocked, locksOperations: updateBlocksOperations(state), check, install, restart }
}
+11
View File
@@ -6,6 +6,7 @@ import type {
GameExitedPayload, InstallProgressPayload, JavaInstallation, LauncherSettings,
LinkChallenge, LinkStatus, NativeHost, ProfileInspection, ServerStatus,
ShaCraftAccount, ShaCraftLoginResult, SyncResult,
LauncherUpdateStatus, LauncherUpdateProgress,
} from '../types/launcher'
export const isNative = () => typeof window !== 'undefined' && isTauri()
@@ -31,6 +32,10 @@ export const native = {
serverStatus: (profileId: string) => invoke<ServerStatus>('get_server_status', { profileId }),
installGame: (profileId: string) => invoke<void>('ensure_game_installed', { profileId }),
launchGame: (profileId: string) => invoke<void>('launch_game', { profileId }),
updateStatus: () => invoke<LauncherUpdateStatus>('get_launcher_update_status'),
checkUpdate: () => invoke<LauncherUpdateStatus>('check_launcher_update'),
installUpdate: () => invoke<void>('install_launcher_update'),
restartAfterUpdate: () => invoke<void>('restart_launcher_after_update'),
}
export const windowControls = {
@@ -48,3 +53,9 @@ export function watchGame(handlers: {
listen<GameExitedPayload>('game-exited', ({ payload }) => handlers.exited(payload)),
])
}
export function watchLauncherUpdate(progress: (payload: LauncherUpdateProgress) => void) {
return createSubscription([
listen<LauncherUpdateProgress>('launcher-update-progress', ({ payload }) => progress(payload)),
])
}
+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)))
}
+25 -1
View File
@@ -130,7 +130,7 @@ button:disabled { cursor: default; }
.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; }
.drawer-title h2 { margin: 0; font-family: 'Unbounded'; font-size: 25px; }
.drawer-title h2 { margin: 0; font-family: 'Unbounded', sans-serif; font-size: 21px; }
.drawer-title button { border: 0; background: #222923; width: 38px; height: 38px; border-radius: 10px; display: grid; place-items: center; cursor: pointer; }
.drawer-title button svg { width: 18px; }
.range-setting { display: grid; background: #191f1a; padding: 18px; border-radius: 13px; margin-bottom: 12px; }
@@ -157,6 +157,30 @@ button:disabled { cursor: default; }
.account-hint { font-size: 11px; line-height: 1.5; color: var(--muted); overflow-wrap: anywhere; }
.recovery-codes { white-space: pre-line; font-size: 15px; max-height: 45vh; overflow: auto; }
.settings-feedback button { padding: 6px 10px; background: var(--panel-2); border: 1px solid var(--line); cursor: pointer; }
.update-banner { position: absolute; top: 70px; right: 28px; z-index: 10; display: flex; align-items: center; gap: 9px; max-width: 440px; padding: 10px 14px; border: 1px solid #325939; border-radius: 9px; background: #17291bdc; color: #b6e5b8; font-size: 11px; cursor: pointer; box-shadow: 0 6px 24px #0003; }
.update-banner:hover { background: #203a25; }
.update-banner-dot { width: 6px; height: 6px; flex: 0 0 6px; border-radius: 50%; background: var(--green); }
.launcher-update { border: 1px solid #2c3e30; border-radius: 12px; padding: 17px; margin-bottom: 24px; background: #17201a; }
.launcher-update-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.launcher-update-heading h3 { margin: 0; font-size: 13px; font-weight: 750; }
.launcher-update-heading > span { color: #a6bba8; font-size: 11px; }
.launcher-update p { font-size: 11px; line-height: 1.65; margin: 10px 0 0; color: #97a89a; }
.launcher-update p.update-success { color: #b0e2b1; }
.launcher-update .update-error { color: #eaae89; }
.launcher-update progress { width: 100%; height: 5px; accent-color: var(--green); margin-top: 12px; }
.update-actions { display: flex; align-items: stretch; flex-direction: column; gap: 9px; margin-top: 15px; }
.update-actions button { display: flex; justify-content: center; align-items: center; gap: 8px; border: 1px solid #35543b; border-radius: 7px; min-height: 35px; padding: 8px 10px; font-size: 11px; cursor: pointer; }
.update-actions button:disabled { cursor: default; opacity: .48; }
.update-actions button svg { width: 14px; height: 14px; }
.update-primary { background: var(--green); color: #102313; font-weight: 800; }
.update-primary:hover:not(:disabled) { background: #91df93; }
.update-check { background: transparent; color: #b6c6b9; }
.update-check:hover:not(:disabled) { background: #263b2c; }
.update-notes { margin-top: 10px; font-size: 11px; color: #b6c6b9; }
.update-notes summary { cursor: pointer; }
.update-notes p { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 160px; overflow: auto; }
.launcher-update .update-hint { color: #a8a58e; }
.status-error, .build-state .status-error, .text-setting > .status-error { color: #eea18f; overflow-wrap: anywhere; }
.build-state .status-error { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
+15
View File
@@ -80,3 +80,18 @@ export interface GameExitedPayload {
profileId: string
exitCode: number | null
}
export interface LauncherUpdateStatus {
currentVersion: string
supported: boolean
reason?: string | null
version?: string | null
notes?: string | null
stage?: 'idle' | 'checking' | 'available' | 'downloading' | 'installing' | 'ready'
}
export interface LauncherUpdateProgress {
stage: 'downloading' | 'installing' | 'ready'
downloadedBytes: number
totalBytes?: number | null
}