feat(hololake): add human-confirmed signed updates

This commit is contained in:
冰朔 2026-08-13 14:11:48 +08:00
commit 70556ed45f
8 changed files with 721 additions and 5 deletions

View file

@ -23,3 +23,9 @@ HoloLake routes work but does not host the user's private work lake, pool users
Stage one now contains a read-only GH-PNCC core beneath the public surface. A local persona repository is bound to an exact Git root and full committed head. A remote persona object is read through a credential-free HTTPS Git v2 partial-object channel with an app-owned bare `blob:none` cache and a separate durable ancestry cursor. Verified reads append to a bounded hash-chained projection for the human panel and authenticated external programming AIs.
This core does not run a model, configure an API, acquire a persona lease, mutate memory or execute reality actions. Arbitrary repository paths and remote URLs cannot be registered by the WebView or external AI. A human may choose a folder through the native operating-system picker; HoloLake then displays the committed persona, responsibility subject, full head and checkpoint. A short-lived hashed confirmation token is required before the exact revalidated evidence becomes a read-only mount.
## HoloLake-owned release broadcast
No upstream product endpoint is inherited. When the embedded release trust is unprovisioned, even a human check returns locally without a network request. Provisioning requires one HTTPS endpoint, one matching HoloLake-owned host and the updater public key. A human check creates a short-lived candidate showing HoloLake metadata; install requires a second confirmation, exact broadcast revalidation, same-host package URL, Tauri signature verification, declared byte length and SHA-256. Startup checking, automatic download, automatic installation and automatic restart remain disabled.
The current runtime does not yet retain a durable last-known-good application bundle after successful replacement. Therefore production updater activation remains blocked until the JD controller trust, signed release pipeline and persistent rollback executor are all evidenced.

View file

@ -15,6 +15,11 @@
"release_broadcast_contract": "contracts/release-broadcast.schema.json",
"release_trust_source": "src-tauri/release-trust.json",
"release_trust_state": "UNPROVISIONED_FAIL_CLOSED",
"release_manual_check_runtime_implemented": true,
"release_candidate_human_confirmation_runtime_implemented": true,
"release_package_signature_size_sha256_verification_implemented": true,
"release_persistent_rollback_executor_implemented": false,
"release_production_activation_state": "BLOCKED_PENDING_JD_TRUST_SIGNED_PIPELINE_AND_PERSISTENT_ROLLBACK",
"tauri_update_artifacts_enabled": false,
"tauri_update_artifacts_enablement_gate": "JD_CONTROLLER_PUBLIC_KEY_AND_SIGNED_RELEASE_PIPELINE_REQUIRED",
"automatic_update_check_on_startup": false,

View file

@ -40,6 +40,10 @@ test('release activation remains explicitly human controlled', () => {
assert.equal(foundation.automatic_update_check_on_startup, false)
assert.equal(foundation.human_opt_in_download_install_required, true)
assert.equal(foundation.automatic_restart_allowed, false)
assert.equal(foundation.release_manual_check_runtime_implemented, true)
assert.equal(foundation.release_candidate_human_confirmation_runtime_implemented, true)
assert.equal(foundation.release_package_signature_size_sha256_verification_implemented, true)
assert.equal(foundation.release_persistent_rollback_executor_implemented, false)
})
test('unprovisioned builds cannot emit updater artifacts or expose updater IPC', () => {

View file

@ -7,6 +7,7 @@ mod pncc_receipt_projection;
mod pncc_remote_git;
mod pncc_repository_binding;
mod release_trust;
mod release_update;
use tauri::Manager;
@ -20,6 +21,8 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![
home_status::get_hololake_home_status,
release_update::check_hololake_update,
release_update::confirm_hololake_update_install,
direct_local_session::issue_direct_local_discovery_ticket,
direct_local_session::open_direct_local_session,
direct_local_session::resume_direct_local_session,

View file

@ -33,6 +33,24 @@ enum ValidatedReleaseTrust {
},
}
pub(crate) struct ProvisionedReleaseTrust {
pub endpoints: Vec<Url>,
pub allowed_release_host: String,
}
pub(crate) fn provisioned_release_trust() -> Result<Option<ProvisionedReleaseTrust>, String> {
match validate_release_trust(EMBEDDED_RELEASE_TRUST)? {
ValidatedReleaseTrust::Disabled => Ok(None),
ValidatedReleaseTrust::Enabled { endpoints, .. } => Ok(Some(ProvisionedReleaseTrust {
allowed_release_host: endpoints[0]
.host_str()
.ok_or("the HoloLake release endpoint host is missing")?
.to_owned(),
endpoints,
})),
}
}
pub(crate) fn release_trust_state() -> Result<&'static str, String> {
match validate_release_trust(EMBEDDED_RELEASE_TRUST)? {
ValidatedReleaseTrust::Disabled => Ok("UNPROVISIONED_FAIL_CLOSED"),
@ -111,7 +129,7 @@ pub fn install_updater_if_provisioned<R: Runtime>(
.pubkey(public_key)
.build();
app.plugin(updater)?;
app.updater_builder().endpoints(endpoints)?.build()?;
let _ = app.updater_builder().endpoints(endpoints)?.build()?;
Ok(())
}
}

View file

@ -0,0 +1,581 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::release_trust::provisioned_release_trust;
use fs2::FileExt;
use ring::digest::{digest, SHA256};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::{AppHandle, Manager};
use tauri_plugin_updater::{Update, UpdaterExt};
use uuid::Uuid;
const CANDIDATE_SCHEMA: &str = "hololake.release-candidate/v1";
const RECEIPT_SCHEMA: &str = "hololake.release-install-receipt/v1";
const CANDIDATE_TTL_MS: u128 = 30 * 60 * 1000;
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct ReleaseCandidateRecord {
schema: String,
state: String,
candidate_id: String,
confirmation_token_sha256: String,
release_id: String,
current_version: String,
version: String,
raw_broadcast_sha256: String,
download_url: String,
package_sha256: String,
package_size: u64,
issued_at_unix_ms: u128,
expires_at_unix_ms: u128,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ConfirmReleaseInstallInput {
candidate_id: String,
confirmation_token: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReleaseEnvelope {
schema: String,
release_id: String,
version: String,
notes: String,
platforms: serde_json::Map<String, Value>,
hololake: HoloLakeMetadata,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct HoloLakeMetadata {
features: Vec<String>,
#[serde(default)]
fixes: Vec<String>,
compatibility: CompatibilityMetadata,
restart: RestartMetadata,
rollback: RollbackMetadata,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct CompatibilityMetadata {
minimum_version: String,
data_migration_required: bool,
notes: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct RestartMetadata {
required: bool,
automatic_allowed: bool,
message: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct RollbackMetadata {
supported: bool,
health_receipt_required: bool,
previous_version: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
struct PlatformRelease {
url: String,
signature: String,
size: u64,
sha256: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReleaseCandidateReceipt {
schema: &'static str,
state: &'static str,
candidate_id: String,
confirmation_token: String,
release_id: String,
current_version: String,
version: String,
notes: String,
features: Vec<String>,
fixes: Vec<String>,
minimum_version: String,
data_migration_required: bool,
compatibility_notes: Option<String>,
restart_message: Option<String>,
rollback_supported: bool,
previous_version: Option<String>,
expires_at_unix_ms: u128,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReleaseCheckReceipt {
schema: &'static str,
state: &'static str,
candidate: Option<ReleaseCandidateReceipt>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReleaseInstallReceipt {
schema: &'static str,
state: &'static str,
release_id: String,
previous_version: String,
installed_version: String,
package_sha256: String,
package_size: u64,
automatic_restart: bool,
health_receipt_required: bool,
rollback_declared_by_broadcast: bool,
receipt_id: String,
}
#[tauri::command]
pub async fn check_hololake_update(app: AppHandle) -> Result<ReleaseCheckReceipt, String> {
let trust = provisioned_release_trust()?
.ok_or_else(|| "HOLOLAKE_RELEASE_TRUST_UNPROVISIONED_NO_NETWORK_REQUEST".to_string())?;
let update = app
.updater_builder()
.endpoints(trust.endpoints.clone())
.map_err(|error| format!("HOLOLAKE_RELEASE_UPDATER_INVALID: {error}"))?
.build()
.map_err(|error| format!("HOLOLAKE_RELEASE_UPDATER_INVALID: {error}"))?
.check()
.await
.map_err(|error| format!("HOLOLAKE_RELEASE_CHECK_FAILED: {error}"))?;
let Some(update) = update else {
return Ok(ReleaseCheckReceipt {
schema: CANDIDATE_SCHEMA,
state: "CURRENT_VERSION_IS_LATEST",
candidate: None,
});
};
let snapshot = validate_update(&update, &trust.allowed_release_host)?;
let root = release_update_root(&app)?;
let candidate = create_candidate_at(&root, &snapshot)?;
Ok(ReleaseCheckReceipt {
schema: CANDIDATE_SCHEMA,
state: "UPDATE_AVAILABLE_AWAITING_HUMAN_CONFIRMATION",
candidate: Some(candidate),
})
}
#[tauri::command]
pub async fn confirm_hololake_update_install(
app: AppHandle,
input: ConfirmReleaseInstallInput,
) -> Result<ReleaseInstallReceipt, String> {
let trust = provisioned_release_trust()?
.ok_or_else(|| "HOLOLAKE_RELEASE_TRUST_UNPROVISIONED_NO_NETWORK_REQUEST".to_string())?;
let root = release_update_root(&app)?;
let record = authorize_candidate_at(&root, &input)?;
let update = app
.updater_builder()
.endpoints(trust.endpoints.clone())
.map_err(|error| format!("HOLOLAKE_RELEASE_UPDATER_INVALID: {error}"))?
.build()
.map_err(|error| format!("HOLOLAKE_RELEASE_UPDATER_INVALID: {error}"))?
.check()
.await
.map_err(|error| format!("HOLOLAKE_RELEASE_RECHECK_FAILED: {error}"))?
.ok_or_else(|| "HOLOLAKE_RELEASE_CANDIDATE_NO_LONGER_AVAILABLE".to_string())?;
let snapshot = validate_update(&update, &trust.allowed_release_host)?;
require_same_candidate(&record, &snapshot)?;
let bytes = update
.download(|_, _| {}, || {})
.await
.map_err(|error| format!("HOLOLAKE_RELEASE_DOWNLOAD_OR_SIGNATURE_FAILED: {error}"))?;
if bytes.len() as u64 != snapshot.package_size || sha256_hex(&bytes) != snapshot.package_sha256
{
return Err("HOLOLAKE_RELEASE_PACKAGE_DIGEST_OR_SIZE_MISMATCH".into());
}
let receipt = ReleaseInstallReceipt {
schema: RECEIPT_SCHEMA,
state: "SIGNED_PACKAGE_VERIFIED_INSTALL_STARTED_RESTART_REQUIRED",
release_id: snapshot.envelope.release_id.clone(),
previous_version: snapshot.current_version.clone(),
installed_version: snapshot.envelope.version.clone(),
package_sha256: snapshot.package_sha256.clone(),
package_size: snapshot.package_size,
automatic_restart: false,
health_receipt_required: snapshot.envelope.hololake.rollback.health_receipt_required,
rollback_declared_by_broadcast: snapshot.envelope.hololake.rollback.supported,
receipt_id: sha256_hex(
format!(
"{}\n{}\n{}\n{}",
snapshot.envelope.release_id,
snapshot.current_version,
snapshot.envelope.version,
snapshot.package_sha256
)
.as_bytes(),
),
};
write_json_atomic(
&root.join("pending-install-receipt.json"),
&receipt,
"RECEIPT",
)?;
mark_candidate_installing_at(&root, &record.candidate_id)?;
update
.install(&bytes)
.map_err(|error| format!("HOLOLAKE_RELEASE_INSTALL_FAILED: {error}"))?;
Ok(receipt)
}
struct UpdateSnapshot {
current_version: String,
envelope: ReleaseEnvelope,
raw_broadcast_sha256: String,
download_url: String,
package_sha256: String,
package_size: u64,
}
fn validate_update(update: &Update, allowed_host: &str) -> Result<UpdateSnapshot, String> {
let raw = serde_json::to_vec(&update.raw_json)
.map_err(|error| format!("HOLOLAKE_RELEASE_BROADCAST_INVALID: {error}"))?;
let envelope: ReleaseEnvelope = serde_json::from_value(update.raw_json.clone())
.map_err(|error| format!("HOLOLAKE_RELEASE_BROADCAST_INVALID: {error}"))?;
if envelope.schema != "hololake.release-broadcast/v1"
|| envelope.release_id.trim().is_empty()
|| envelope.version != update.version
|| envelope.notes.trim().is_empty()
|| envelope.hololake.features.is_empty()
|| !envelope.hololake.restart.required
|| envelope.hololake.restart.automatic_allowed
|| !envelope.hololake.rollback.supported
|| !envelope.hololake.rollback.health_receipt_required
{
return Err("HOLOLAKE_RELEASE_BROADCAST_POLICY_INVALID".into());
}
let url = &update.download_url;
if url.scheme() != "https" || url.host_str() != Some(allowed_host) {
return Err("HOLOLAKE_RELEASE_PACKAGE_HOST_NOT_TRUSTED".into());
}
let platform = envelope
.platforms
.values()
.filter_map(|value| serde_json::from_value::<PlatformRelease>(value.clone()).ok())
.find(|platform| platform.url == url.as_str() && platform.signature == update.signature)
.ok_or("HOLOLAKE_RELEASE_PLATFORM_EVIDENCE_MISSING")?;
validate_sha256(&platform.sha256)?;
if platform.size == 0 {
return Err("HOLOLAKE_RELEASE_PACKAGE_SIZE_INVALID".into());
}
Ok(UpdateSnapshot {
current_version: update.current_version.clone(),
envelope,
raw_broadcast_sha256: sha256_hex(&raw),
download_url: url.to_string(),
package_sha256: platform.sha256,
package_size: platform.size,
})
}
fn create_candidate_at(
root: &Path,
snapshot: &UpdateSnapshot,
) -> Result<ReleaseCandidateReceipt, String> {
let _lock = lock(root)?;
let issued_at_unix_ms = now_unix_ms()?;
let expires_at_unix_ms = issued_at_unix_ms + CANDIDATE_TTL_MS;
let candidate_id = format!("release-{}", Uuid::new_v4());
let confirmation_token = format!("install-{}-{}", Uuid::new_v4(), Uuid::new_v4());
let record = ReleaseCandidateRecord {
schema: CANDIDATE_SCHEMA.into(),
state: "PENDING_HUMAN_CONFIRMATION".into(),
candidate_id: candidate_id.clone(),
confirmation_token_sha256: sha256_hex(confirmation_token.as_bytes()),
release_id: snapshot.envelope.release_id.clone(),
current_version: snapshot.current_version.clone(),
version: snapshot.envelope.version.clone(),
raw_broadcast_sha256: snapshot.raw_broadcast_sha256.clone(),
download_url: snapshot.download_url.clone(),
package_sha256: snapshot.package_sha256.clone(),
package_size: snapshot.package_size,
issued_at_unix_ms,
expires_at_unix_ms,
};
write_json_atomic(&candidate_path(root, &candidate_id), &record, "CANDIDATE")?;
Ok(ReleaseCandidateReceipt {
schema: CANDIDATE_SCHEMA,
state: "AWAITING_HUMAN_DOWNLOAD_AND_INSTALL_CONFIRMATION",
candidate_id,
confirmation_token,
release_id: snapshot.envelope.release_id.clone(),
current_version: snapshot.current_version.clone(),
version: snapshot.envelope.version.clone(),
notes: snapshot.envelope.notes.clone(),
features: snapshot.envelope.hololake.features.clone(),
fixes: snapshot.envelope.hololake.fixes.clone(),
minimum_version: snapshot
.envelope
.hololake
.compatibility
.minimum_version
.clone(),
data_migration_required: snapshot
.envelope
.hololake
.compatibility
.data_migration_required,
compatibility_notes: snapshot.envelope.hololake.compatibility.notes.clone(),
restart_message: snapshot.envelope.hololake.restart.message.clone(),
rollback_supported: snapshot.envelope.hololake.rollback.supported,
previous_version: snapshot.envelope.hololake.rollback.previous_version.clone(),
expires_at_unix_ms,
})
}
fn authorize_candidate_at(
root: &Path,
input: &ConfirmReleaseInstallInput,
) -> Result<ReleaseCandidateRecord, String> {
validate_machine_id(&input.candidate_id)?;
if input.confirmation_token.len() < 32 || input.confirmation_token.len() > 256 {
return Err("HOLOLAKE_RELEASE_CONFIRMATION_TOKEN_INVALID".into());
}
let _lock = lock(root)?;
let record: ReleaseCandidateRecord = read_json(&candidate_path(root, &input.candidate_id))?;
if record.schema != CANDIDATE_SCHEMA || record.candidate_id != input.candidate_id {
return Err("HOLOLAKE_RELEASE_CANDIDATE_INVALID".into());
}
if record.confirmation_token_sha256 != sha256_hex(input.confirmation_token.as_bytes()) {
return Err("HOLOLAKE_RELEASE_CANDIDATE_NOT_AUTHORIZED".into());
}
if record.state != "PENDING_HUMAN_CONFIRMATION" || now_unix_ms()? > record.expires_at_unix_ms {
return Err("HOLOLAKE_RELEASE_CANDIDATE_EXPIRED_OR_CONSUMED".into());
}
Ok(record)
}
fn require_same_candidate(
record: &ReleaseCandidateRecord,
snapshot: &UpdateSnapshot,
) -> Result<(), String> {
if record.release_id != snapshot.envelope.release_id
|| record.current_version != snapshot.current_version
|| record.version != snapshot.envelope.version
|| record.raw_broadcast_sha256 != snapshot.raw_broadcast_sha256
|| record.download_url != snapshot.download_url
|| record.package_sha256 != snapshot.package_sha256
|| record.package_size != snapshot.package_size
{
return Err("HOLOLAKE_RELEASE_BROADCAST_CHANGED_RECONFIRM_REQUIRED".into());
}
Ok(())
}
fn mark_candidate_installing_at(root: &Path, candidate_id: &str) -> Result<(), String> {
let _lock = lock(root)?;
let path = candidate_path(root, candidate_id);
let mut record: ReleaseCandidateRecord = read_json(&path)?;
if record.state != "PENDING_HUMAN_CONFIRMATION" {
return Err("HOLOLAKE_RELEASE_CANDIDATE_EXPIRED_OR_CONSUMED".into());
}
record.state = "INSTALLING".into();
write_json_atomic(&path, &record, "CANDIDATE")
}
fn release_update_root(app: &AppHandle) -> Result<PathBuf, String> {
let root = app
.path()
.app_data_dir()
.map_err(|error| format!("HOLOLAKE_RELEASE_STORAGE_UNAVAILABLE: {error}"))?
.join("release-update-v1");
fs::create_dir_all(&root)
.map_err(|error| format!("HOLOLAKE_RELEASE_STORAGE_UNAVAILABLE: {error}"))?;
root.canonicalize()
.map_err(|error| format!("HOLOLAKE_RELEASE_STORAGE_UNAVAILABLE: {error}"))
}
fn candidate_path(root: &Path, candidate_id: &str) -> PathBuf {
root.join(format!("{candidate_id}.json"))
}
fn lock(root: &Path) -> Result<std::fs::File, String> {
let lock = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.open(root.join("release.lock"))
.map_err(|error| format!("HOLOLAKE_RELEASE_LOCK_FAILED: {error}"))?;
lock.lock_exclusive()
.map_err(|error| format!("HOLOLAKE_RELEASE_LOCK_FAILED: {error}"))?;
Ok(lock)
}
fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, String> {
let metadata = fs::symlink_metadata(path)
.map_err(|error| format!("HOLOLAKE_RELEASE_CANDIDATE_READ_FAILED: {error}"))?;
if !metadata.is_file() || metadata.file_type().is_symlink() {
return Err("HOLOLAKE_RELEASE_CANDIDATE_INVALID".into());
}
serde_json::from_slice(
&fs::read(path)
.map_err(|error| format!("HOLOLAKE_RELEASE_CANDIDATE_READ_FAILED: {error}"))?,
)
.map_err(|error| format!("HOLOLAKE_RELEASE_CANDIDATE_INVALID: {error}"))
}
fn write_json_atomic<T: Serialize>(path: &Path, value: &T, label: &str) -> Result<(), String> {
let parent = path
.parent()
.ok_or_else(|| format!("HOLOLAKE_RELEASE_{label}_PATH_INVALID"))?;
let temporary = parent.join(format!(".{label}-{}.tmp", Uuid::new_v4()));
let bytes = serde_json::to_vec_pretty(value)
.map_err(|error| format!("HOLOLAKE_RELEASE_{label}_INVALID: {error}"))?;
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&temporary)
.map_err(|error| format!("HOLOLAKE_RELEASE_{label}_WRITE_FAILED: {error}"))?;
file.write_all(&bytes)
.and_then(|_| file.sync_all())
.map_err(|error| format!("HOLOLAKE_RELEASE_{label}_WRITE_FAILED: {error}"))?;
fs::rename(&temporary, path)
.map_err(|error| format!("HOLOLAKE_RELEASE_{label}_WRITE_FAILED: {error}"))
}
fn validate_machine_id(value: &str) -> Result<(), String> {
if value.is_empty()
|| value.len() > 128
|| !value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
{
return Err("HOLOLAKE_RELEASE_CANDIDATE_ID_INVALID".into());
}
Ok(())
}
fn validate_sha256(value: &str) -> Result<(), String> {
if value.len() != 64
|| !value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return Err("HOLOLAKE_RELEASE_PACKAGE_SHA256_INVALID".into());
}
Ok(())
}
fn now_unix_ms() -> Result<u128, String> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.map_err(|error| format!("HOLOLAKE_RELEASE_CLOCK_INVALID: {error}"))
}
fn sha256_hex(bytes: &[u8]) -> String {
digest(&SHA256, bytes)
.as_ref()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn snapshot() -> UpdateSnapshot {
UpdateSnapshot {
current_version: "0.1.0".into(),
envelope: ReleaseEnvelope {
schema: "hololake.release-broadcast/v1".into(),
release_id: "release-1".into(),
version: "0.2.0".into(),
notes: "First signed update".into(),
platforms: serde_json::Map::new(),
hololake: HoloLakeMetadata {
features: vec!["Personal channel".into()],
fixes: vec![],
compatibility: CompatibilityMetadata {
minimum_version: "0.1.0".into(),
data_migration_required: false,
notes: None,
},
restart: RestartMetadata {
required: true,
automatic_allowed: false,
message: Some("Restart yourself".into()),
},
rollback: RollbackMetadata {
supported: true,
health_receipt_required: true,
previous_version: Some("0.1.0".into()),
},
},
},
raw_broadcast_sha256: "1".repeat(64),
download_url: "https://release.guanghu.test/HoloLake.tar.gz".into(),
package_sha256: "2".repeat(64),
package_size: 42,
}
}
#[test]
fn installation_requires_the_exact_short_lived_human_candidate() {
let root = TempDir::new().unwrap();
let candidate = create_candidate_at(root.path(), &snapshot()).unwrap();
let wrong = authorize_candidate_at(
root.path(),
&ConfirmReleaseInstallInput {
candidate_id: candidate.candidate_id.clone(),
confirmation_token: "wrong-confirmation-token-long-enough-123".into(),
},
)
.unwrap_err();
assert_eq!(wrong, "HOLOLAKE_RELEASE_CANDIDATE_NOT_AUTHORIZED");
let authorized = authorize_candidate_at(
root.path(),
&ConfirmReleaseInstallInput {
candidate_id: candidate.candidate_id,
confirmation_token: candidate.confirmation_token,
},
)
.unwrap();
assert_eq!(authorized.version, "0.2.0");
}
#[test]
fn any_broadcast_or_package_change_requires_a_new_confirmation() {
let root = TempDir::new().unwrap();
let original = snapshot();
let candidate = create_candidate_at(root.path(), &original).unwrap();
let record = authorize_candidate_at(
root.path(),
&ConfirmReleaseInstallInput {
candidate_id: candidate.candidate_id,
confirmation_token: candidate.confirmation_token,
},
)
.unwrap();
let mut changed = snapshot();
changed.package_sha256 = "3".repeat(64);
assert_eq!(
require_same_candidate(&record, &changed).unwrap_err(),
"HOLOLAKE_RELEASE_BROADCAST_CHANGED_RECONFIRM_REQUIRED"
);
}
}

View file

@ -5,7 +5,7 @@ import './design-tokens.css'
import './styles.css'
type ThemeId = 'night' | 'dawn' | 'nebula' | 'candle' | 'clear'
type Panel = 'channel' | 'receipts' | 'settings' | null
type Panel = 'channel' | 'receipts' | 'updates' | 'settings' | null
interface HomeStatus {
schema: string
@ -63,6 +63,29 @@ interface RepositoryCandidate {
inspection: RepositoryInspection
}
interface ReleaseCandidate {
candidateId: string
confirmationToken: string
releaseId: string
currentVersion: string
version: string
notes: string
features: string[]
fixes: string[]
minimumVersion: string
dataMigrationRequired: boolean
compatibilityNotes?: string
restartMessage?: string
rollbackSupported: boolean
previousVersion?: string
expiresAtUnixMs: number
}
interface ReleaseCheckReceipt {
state: string
candidate?: ReleaseCandidate
}
const themes: Array<{ id: ThemeId; name: string }> = [
{ id: 'night', name: '夜湖星光' },
{ id: 'dawn', name: '晨湖曦光' },
@ -111,6 +134,9 @@ function HoloLakeApp() {
const [repositoryCandidate, setRepositoryCandidate] = useState<RepositoryCandidate | null>(null)
const [repositoryBusy, setRepositoryBusy] = useState(false)
const [repositoryMessage, setRepositoryMessage] = useState('')
const [releaseCandidate, setReleaseCandidate] = useState<ReleaseCandidate | null>(null)
const [releaseBusy, setReleaseBusy] = useState(false)
const [releaseMessage, setReleaseMessage] = useState('')
const [message, setMessage] = useState('')
const refreshStatus = useCallback(async () => {
@ -188,6 +214,44 @@ function HoloLakeApp() {
}
}
const checkUpdate = async () => {
setReleaseBusy(true)
setReleaseMessage('')
try {
const receipt = await invoke<ReleaseCheckReceipt>('check_hololake_update')
setReleaseCandidate(receipt.candidate || null)
if (!receipt.candidate) setReleaseMessage('当前已经是最新版本。')
} catch (error) {
const reason = String(error)
setReleaseCandidate(null)
setReleaseMessage(reason.includes('UNPROVISIONED_NO_NETWORK_REQUEST')
? '京东主控的正式发布地址和更新公钥还没有登记。HoloLake 保持离线,不会向任何上游检查更新。'
: `更新检查没有完成:${reason}`)
} finally {
setReleaseBusy(false)
}
}
const confirmUpdateInstall = async () => {
if (!releaseCandidate) return
setReleaseBusy(true)
setReleaseMessage('正在重新核对广播并验证安装包;完成后由你手动重启 HoloLake。')
try {
await invoke('confirm_hololake_update_install', {
input: {
candidateId: releaseCandidate.candidateId,
confirmationToken: releaseCandidate.confirmationToken,
},
})
setReleaseCandidate(null)
setReleaseMessage('签名安装包已验证并安装。请在准备好后手动重启 HoloLake。')
} catch (error) {
setReleaseMessage(`更新没有安装:${String(error)}`)
} finally {
setReleaseBusy(false)
}
}
const issueInvitation = async () => {
setMessage('')
try {
@ -270,10 +334,10 @@ function HoloLakeApp() {
<span className={`status-light ${status.codeRepositoryMountCount > 0 ? 'ready' : 'quiet'}`} aria-hidden="true" />
<div><h2></h2><p>{status.codeRepositoryMountCount > 0 ? `已验证 · ${status.codeRepositoryMountCount} 个仓库` : '等待人类确认绑定'}</p></div>
</article>
<article className="status-item">
<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>
</article>
</button>
</section>
</main>
@ -316,6 +380,27 @@ function HoloLakeApp() {
{repositoryMessage && <p className="panel-message" aria-live="polite">{repositoryMessage}</p>}
{receipts.length ? <ol className="receipt-list">{receipts.map((receipt) => <li key={receipt.sequence}><span>{receipt.sequence.toString().padStart(2, '0')}</span><div><b>{receipt.kind === 'REPOSITORY_BINDING_REVALIDATED' ? '仓库绑定已复核' : '远端对象读取已验证'}</b><small>{new Date(Number(receipt.observedAtUnixMs)).toLocaleString('zh-CN')} · {receipt.eventHash.slice(0, 12)}</small></div></li>)}</ol> : <div className="empty-state compact"><i /><b></b><span></span></div>}
</>}
{panel === 'updates' && <>
<p className="panel-kicker">广</p>
<h2 id="panel-title"></h2>
<p className="panel-intro">HoloLake </p>
{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>
<dl className="candidate-facts">
<div><dt></dt><dd>{releaseCandidate.dataMigrationRequired ? '需要' : '不需要'}</dd></div>
<div><dt></dt><dd>{releaseCandidate.minimumVersion}</dd></div>
<div><dt></dt><dd></dd></div>
<div><dt></dt><dd>{releaseCandidate.rollbackSupported ? `支持 · ${releaseCandidate.previousVersion || '上一版本'}` : '不支持'}</dd></div>
</dl>
<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>}
{releaseMessage && <p className="panel-message" aria-live="polite">{releaseMessage}</p>}
<p className="boundary-note"></p>
</>}
{panel === 'settings' && <>
<p className="panel-kicker"></p>
<h2 id="panel-title"></h2>

View file

@ -61,13 +61,15 @@ button:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 3px
.hero-actions { display: flex; align-items: center; gap: 14px; margin-top: 37px; }
.primary-action, .secondary-action { min-height: 46px; border-radius: 999px; padding: 0 22px; display: inline-flex; align-items: center; justify-content: center; gap: 12px; cursor: pointer; transition: transform 180ms ease, background 180ms ease, box-shadow 180ms ease; }
.primary-action:disabled, .secondary-action:disabled, .repository-picker:disabled, .text-action:disabled { opacity: .55; cursor: wait; transform: none; }
.primary-action { border: 0; color: var(--button-primary-text); background: var(--button-primary-bg); box-shadow: var(--button-primary-shadow); font-weight: 620; }
.primary-action { border: 0; color: var(--button-primary-text); background: var(--button-primary-bg); box-shadow: var(--button-primary-shadow); font-weight: 620; white-space: nowrap; }
.secondary-action { color: var(--button-secondary-text); border: 1px solid var(--button-secondary-edge); background: var(--button-secondary-bg); backdrop-filter: blur(18px); }
.primary-action:hover, .secondary-action:hover { transform: translateY(-2px); }
.secondary-action:hover { background: var(--primitive-glass-hover); }
.status-cluster { display: grid; gap: 30px; width: min(100%, 330px); justify-self: end; }
.status-item { display: grid; grid-template-columns: 10px 1fr; align-items: start; gap: 15px; padding: 5px 0; }
.status-button { width: 100%; border: 0; color: inherit; background: transparent; text-align: left; cursor: pointer; }
.status-button:hover h2 { color: var(--accent-light); }
.status-item h2 { margin: 0 0 8px; color: var(--content-secondary); font-size: 13px; font-weight: 590; letter-spacing: .06em; }
.status-item p { margin: 0; color: var(--content-muted); opacity: .72; font-size: 11.5px; line-height: 1.5; letter-spacing: .04em; }
.status-light { width: 6px; height: 6px; margin-top: 5px; flex: 0 0 auto; border-radius: 50%; background: var(--state-quiet); box-shadow: 0 0 14px currentColor; }
@ -126,6 +128,18 @@ button:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 3px
.candidate-actions { display: flex; align-items: center; gap: 16px; margin-top: 18px; }
.candidate-actions .primary-action { min-height: 42px; padding-inline: 18px; font-size: 12px; }
.text-action { border: 0; color: var(--content-muted); background: transparent; font-size: 11px; cursor: pointer; }
.update-state-card { display: flex; align-items: flex-start; gap: 15px; margin: 30px 0; padding: 20px 22px; border-radius: 20px; background: var(--primitive-glass); box-shadow: inset 0 1px var(--primitive-glass-top); }
.update-state-card div { display: grid; gap: 6px; }
.update-state-card b { color: var(--content-secondary); font-size: 12.5px; }
.update-state-card span { color: var(--content-faint); font-size: 10.5px; line-height: 1.6; }
.release-card { margin-top: 27px; }
.release-version { display: grid; grid-template-columns: 1fr auto; align-items: baseline; gap: 6px 16px; }
.release-version span { color: var(--content-faint); font-size: 10px; letter-spacing: .12em; }
.release-version b { grid-row: 1 / 3; grid-column: 2; color: var(--accent-light); font-size: 30px; font-weight: 480; }
.release-version small { color: var(--content-muted); font-size: 11px; }
.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; }
.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; }