feat(hololake): add persistent signed update rollback

This commit is contained in:
冰朔 2026-08-13 14:45:17 +08:00
commit 73eb8bc1ca
9 changed files with 727 additions and 15 deletions

View file

@ -15,6 +15,7 @@ interface HomeStatus {
codeRepositoryMountCount: number
pnccReceiptCount: number
updateState: string
releaseRecoveryState: string
automaticUpstreamUpdates: boolean
mcpRole: string
}
@ -94,6 +95,23 @@ const themes: Array<{ id: ThemeId; name: string }> = [
{ id: 'clear', name: '清浅澄湖' },
]
const recoveryPreviewStates = [
'NONE',
'AWAITING_HUMAN_HEALTH_CONFIRMATION',
'ROLLED_BACK_AWAITING_HUMAN_ACKNOWLEDGEMENT',
'INSTALL_FAILED_BACKUP_RETAINED',
'INSTALL_INTERRUPTED_BACKUP_RETAINED',
'INSTALL_REPLACED_AWAITING_MANUAL_RESTART',
'ROLLBACK_REPLACED_AWAITING_MANUAL_RESTART',
'RECOVERY_STATE_UNREADABLE_ACTION_REQUIRED',
]
const requestedRecoveryPreview = import.meta.env.DEV
? new URLSearchParams(window.location.search).get('releaseRecoveryState')
: null
const recoveryPreviewState = requestedRecoveryPreview && recoveryPreviewStates.includes(requestedRecoveryPreview)
? requestedRecoveryPreview
: 'NONE'
const previewStatus: HomeStatus = {
schema: 'hololake.home-status/preview',
directLocalBrokerState: 'PREVIEW',
@ -102,6 +120,7 @@ const previewStatus: HomeStatus = {
codeRepositoryMountCount: 0,
pnccReceiptCount: 0,
updateState: 'UNPROVISIONED_FAIL_CLOSED',
releaseRecoveryState: recoveryPreviewState,
automaticUpstreamUpdates: false,
mcpRole: 'DISCOVERY_RECOVERY_COMPATIBILITY_ONLY',
}
@ -164,6 +183,15 @@ function HoloLakeApp() {
return { label: '等待连接', detail: '本机直连核心已就绪', tone: 'quiet' }
}, [status])
const releaseRecoveryIdle = useMemo(() => [
'NONE',
'HEALTH_CONFIRMED_BACKUP_REMOVED',
'ROLLBACK_CONFIRMED_BACKUP_REMOVED',
'INSTALL_FAILURE_ACKNOWLEDGED_BACKUP_REMOVED',
].includes(status.releaseRecoveryState), [status.releaseRecoveryState])
const releaseRecoveryNeedsAttention = !releaseRecoveryIdle
const openReceipts = async () => {
setPanel('receipts')
await loadReceipts()
@ -245,6 +273,7 @@ function HoloLakeApp() {
})
setReleaseCandidate(null)
setReleaseMessage('签名安装包已验证并安装。请在准备好后手动重启 HoloLake。')
await refreshStatus()
} catch (error) {
setReleaseMessage(`更新没有安装:${String(error)}`)
} finally {
@ -252,6 +281,25 @@ function HoloLakeApp() {
}
}
const resolveReleaseRecovery = async (action: 'keep' | 'rollback') => {
setReleaseBusy(true)
setReleaseMessage(action === 'rollback' ? '正在复核上一版签名并恢复;完成后仍由你手动重启。' : '正在确认当前版本健康并清理上一版副本。')
try {
if (action === 'rollback') {
await invoke('rollback_hololake_update')
setReleaseMessage('上一版已恢复到应用位置。请手动重启 HoloLake启动后再确认恢复正常。')
} else {
await invoke('confirm_hololake_release_health')
setReleaseMessage('当前运行版本已由你确认,临时回退副本已经清理。')
}
await refreshStatus()
} catch (error) {
setReleaseMessage(`没有改变当前应用:${String(error)}`)
} finally {
setReleaseBusy(false)
}
}
const issueInvitation = async () => {
setMessage('')
try {
@ -335,8 +383,8 @@ function HoloLakeApp() {
<div><h2></h2><p>{status.codeRepositoryMountCount > 0 ? `已验证 · ${status.codeRepositoryMountCount} 个仓库` : '等待人类确认绑定'}</p></div>
</article>
<button className="status-item status-button" type="button" onClick={() => setPanel('updates')}>
<span className={`status-light ${status.updateState.startsWith('READY') ? 'ready' : 'quiet'}`} aria-hidden="true" />
<div><h2>广</h2><p>{status.updateState.startsWith('READY') ? '仅接收光湖签名广播' : '信任根未配置 · 已关闭联网'}</p></div>
<span className={`status-light ${releaseRecoveryNeedsAttention ? 'waiting' : status.updateState.startsWith('READY') ? 'ready' : 'quiet'}`} aria-hidden="true" />
<div><h2>广</h2><p>{releaseRecoveryNeedsAttention ? '更新结果等待你的确认' : status.updateState.startsWith('READY') ? '仅接收光湖签名广播' : '信任根未配置 · 已关闭联网'}</p></div>
</button>
</section>
</main>
@ -384,7 +432,25 @@ function HoloLakeApp() {
<p className="panel-kicker">广</p>
<h2 id="panel-title"></h2>
<p className="panel-intro">HoloLake </p>
{releaseCandidate ? <div className="release-card">
{status.releaseRecoveryState === 'AWAITING_HUMAN_HEALTH_CONFIRMATION' ? <div className="recovery-card">
<div className="candidate-heading"><span className="status-light waiting" /><div><b></b><span></span></div></div>
<p></p>
<div className="recovery-actions"><button className="primary-action" type="button" disabled={releaseBusy} onClick={() => void resolveReleaseRecovery('keep')}></button><button className="secondary-action" type="button" disabled={releaseBusy} onClick={() => void resolveReleaseRecovery('rollback')}></button></div>
</div> : status.releaseRecoveryState === 'ROLLED_BACK_AWAITING_HUMAN_ACKNOWLEDGEMENT' ? <div className="recovery-card">
<div className="candidate-heading"><span className="status-light waiting" /><div><b></b><span></span></div></div>
<p>HoloLake 退</p>
<button className="primary-action panel-action" type="button" disabled={releaseBusy} onClick={() => void resolveReleaseRecovery('keep')}><Icon name="arrow" /></button>
</div> : ['INSTALL_FAILED_BACKUP_RETAINED', 'INSTALL_INTERRUPTED_BACKUP_RETAINED'].includes(status.releaseRecoveryState) ? <div className="recovery-card">
<div className="candidate-heading"><span className="status-light waiting" /><div><b></b><span></span></div></div>
<p> HoloLake </p>
<button className="primary-action panel-action" type="button" disabled={releaseBusy} onClick={() => void resolveReleaseRecovery('keep')}><Icon name="arrow" /></button>
</div> : ['INSTALL_REPLACED_AWAITING_MANUAL_RESTART', 'ROLLBACK_REPLACED_AWAITING_MANUAL_RESTART', 'LAST_KNOWN_GOOD_VERIFIED_INSTALLING'].includes(status.releaseRecoveryState) ? <div className="recovery-card">
<div className="candidate-heading"><span className="status-light waiting" /><div><b></b><span>HoloLake </span></div></div>
<p> HoloLake </p>
</div> : !releaseRecoveryIdle ? <div className="recovery-card">
<div className="candidate-heading"><span className="status-light waiting" /><div><b></b><span>使</span></div></div>
<p>HoloLake </p>
</div> : releaseCandidate ? <div className="release-card">
<div className="release-version"><span></span><b>{releaseCandidate.version}</b><small> {releaseCandidate.currentVersion}</small></div>
<p>{releaseCandidate.notes}</p>
<ul>{releaseCandidate.features.map((feature) => <li key={feature}>{feature}</li>)}{releaseCandidate.fixes.map((fix) => <li key={fix}>{fix}</li>)}</ul>
@ -397,9 +463,9 @@ function HoloLakeApp() {
<p className="release-warning">广Tauri SHA-256</p>
<button className="primary-action panel-action" type="button" disabled={releaseBusy} onClick={() => void confirmUpdateInstall()}><Icon name="arrow" /></button>
</div> : <div className="update-state-card"><span className={`status-light ${status.updateState.startsWith('READY') ? 'ready' : 'quiet'}`} /><div><b>{status.updateState.startsWith('READY') ? '主控信任根已登记' : '更新网络保持关闭'}</b><span>{status.updateState.startsWith('READY') ? '可以由你手动检查签名广播' : '没有正式地址和公钥时,不发起网络请求'}</span></div></div>}
{!releaseCandidate && <button className="primary-action panel-action" type="button" disabled={releaseBusy} onClick={() => void checkUpdate()}>{releaseBusy ? '正在核对…' : '检查光湖更新'}<Icon name="arrow" /></button>}
{!releaseCandidate && releaseRecoveryIdle && <button className="primary-action panel-action" type="button" disabled={releaseBusy} onClick={() => void checkUpdate()}>{releaseBusy ? '正在核对…' : '检查光湖更新'}<Icon name="arrow" /></button>}
{releaseMessage && <p className="panel-message" aria-live="polite">{releaseMessage}</p>}
<p className="boundary-note"></p>
<p className="boundary-note">退线</p>
</>}
{panel === 'settings' && <>
<p className="panel-kicker"></p>

View file

@ -140,6 +140,9 @@ button:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 3px
.release-card > p { color: var(--content-muted); font-size: 11.5px; line-height: 1.7; }
.release-card ul { margin: 18px 0; padding-left: 18px; color: var(--content-secondary); font-size: 11px; line-height: 1.8; }
.release-card .release-warning { color: var(--content-faint); font-size: 10px; }
.recovery-card { display: grid; gap: 17px; margin-top: 27px; padding: 21px; border: 1px solid var(--primitive-line); border-radius: 21px; background: var(--primitive-glass); box-shadow: inset 0 1px var(--primitive-glass-top); }
.recovery-card > p { margin: 0; color: var(--content-muted); font-size: 11.5px; line-height: 1.7; }
.recovery-actions { display: flex; flex-wrap: wrap; gap: 11px; }
.theme-list { display: grid; gap: 9px; margin-top: 30px; }
.theme-list button { width: 100%; min-height: 66px; display: grid; grid-template-columns: 40px 1fr auto; align-items: center; gap: 14px; padding: 10px 14px; border: 0; border-radius: 17px; color: var(--content-secondary); background: transparent; text-align: left; cursor: pointer; }

View file

@ -0,0 +1 @@
/// <reference types="vite/client" />