From 70556ed45f49bcfd19e180313690a9bb395acf4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B0=E6=9C=94?= <565183519@qq.com> Date: Thu, 13 Aug 2026 14:11:48 +0800 Subject: [PATCH] feat(hololake): add human-confirmed signed updates --- .../docs/ARCHITECTURE.md | 6 + .../hololake-native-desktop/foundation.json | 5 + .../scripts/foundation.test.mjs | 4 + .../src-tauri/src/lib.rs | 3 + .../src-tauri/src/release_trust.rs | 20 +- .../src-tauri/src/release_update.rs | 581 ++++++++++++++++++ .../hololake-native-desktop/src/main.tsx | 91 ++- .../hololake-native-desktop/src/styles.css | 16 +- 8 files changed, 721 insertions(+), 5 deletions(-) create mode 100644 product-source/hololake-native-desktop/src-tauri/src/release_update.rs diff --git a/product-source/hololake-native-desktop/docs/ARCHITECTURE.md b/product-source/hololake-native-desktop/docs/ARCHITECTURE.md index 0285240..e9414ec 100644 --- a/product-source/hololake-native-desktop/docs/ARCHITECTURE.md +++ b/product-source/hololake-native-desktop/docs/ARCHITECTURE.md @@ -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. diff --git a/product-source/hololake-native-desktop/foundation.json b/product-source/hololake-native-desktop/foundation.json index f218369..5b7c19a 100644 --- a/product-source/hololake-native-desktop/foundation.json +++ b/product-source/hololake-native-desktop/foundation.json @@ -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, diff --git a/product-source/hololake-native-desktop/scripts/foundation.test.mjs b/product-source/hololake-native-desktop/scripts/foundation.test.mjs index 636a9fb..b054a8d 100644 --- a/product-source/hololake-native-desktop/scripts/foundation.test.mjs +++ b/product-source/hololake-native-desktop/scripts/foundation.test.mjs @@ -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', () => { diff --git a/product-source/hololake-native-desktop/src-tauri/src/lib.rs b/product-source/hololake-native-desktop/src-tauri/src/lib.rs index 015f0f0..d387bdf 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/lib.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/lib.rs @@ -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, diff --git a/product-source/hololake-native-desktop/src-tauri/src/release_trust.rs b/product-source/hololake-native-desktop/src-tauri/src/release_trust.rs index 3a402b5..aeb301f 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/release_trust.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/release_trust.rs @@ -33,6 +33,24 @@ enum ValidatedReleaseTrust { }, } +pub(crate) struct ProvisionedReleaseTrust { + pub endpoints: Vec, + pub allowed_release_host: String, +} + +pub(crate) fn provisioned_release_trust() -> Result, 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( .pubkey(public_key) .build(); app.plugin(updater)?; - app.updater_builder().endpoints(endpoints)?.build()?; + let _ = app.updater_builder().endpoints(endpoints)?.build()?; Ok(()) } } diff --git a/product-source/hololake-native-desktop/src-tauri/src/release_update.rs b/product-source/hololake-native-desktop/src-tauri/src/release_update.rs new file mode 100644 index 0000000..94eaba7 --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/release_update.rs @@ -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, + hololake: HoloLakeMetadata, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct HoloLakeMetadata { + features: Vec, + #[serde(default)] + fixes: Vec, + 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, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RestartMetadata { + required: bool, + automatic_allowed: bool, + message: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RollbackMetadata { + supported: bool, + health_receipt_required: bool, + previous_version: Option, +} + +#[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, + fixes: Vec, + minimum_version: String, + data_migration_required: bool, + compatibility_notes: Option, + restart_message: Option, + rollback_supported: bool, + previous_version: Option, + expires_at_unix_ms: u128, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReleaseCheckReceipt { + schema: &'static str, + state: &'static str, + candidate: Option, +} + +#[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 { + 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 { + 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 { + 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::(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 { + 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 { + 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 { + 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 { + 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 Deserialize<'de>>(path: &Path) -> Result { + 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(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 { + 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" + ); + } +} diff --git a/product-source/hololake-native-desktop/src/main.tsx b/product-source/hololake-native-desktop/src/main.tsx index 247e6d0..4b227a6 100644 --- a/product-source/hololake-native-desktop/src/main.tsx +++ b/product-source/hololake-native-desktop/src/main.tsx @@ -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(null) const [repositoryBusy, setRepositoryBusy] = useState(false) const [repositoryMessage, setRepositoryMessage] = useState('') + const [releaseCandidate, setReleaseCandidate] = useState(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('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() { 0 ? 'ready' : 'quiet'}`} aria-hidden="true" />

代码通道仓库

{status.codeRepositoryMountCount > 0 ? `已验证 · ${status.codeRepositoryMountCount} 个仓库` : '等待人类确认绑定'}

-
+
+ @@ -316,6 +380,27 @@ function HoloLakeApp() { {repositoryMessage &&

{repositoryMessage}

} {receipts.length ?
    {receipts.map((receipt) =>
  1. {receipt.sequence.toString().padStart(2, '0')}
    {receipt.kind === 'REPOSITORY_BINDING_REVALIDATED' ? '仓库绑定已复核' : '远端对象读取已验证'}{new Date(Number(receipt.observedAtUnixMs)).toLocaleString('zh-CN')} · {receipt.eventHash.slice(0, 12)}
  2. )}
:
湖面还没有新回执完成代码仓库绑定或读取后,这里会出现可核验记录。
} } + {panel === 'updates' && <> +

光湖签名广播

+

更新由你决定何时进入

+

HoloLake 不接收开源上游自动推送。只有登记到本机的光湖主控地址和公钥可以参与更新,并且检查、安装、重启是三件分开的事。

+ {releaseCandidate ?
+
新版本{releaseCandidate.version}当前 {releaseCandidate.currentVersion}
+

{releaseCandidate.notes}

+
    {releaseCandidate.features.map((feature) =>
  • {feature}
  • )}{releaseCandidate.fixes.map((fix) =>
  • {fix}
  • )}
+
+
数据迁移
{releaseCandidate.dataMigrationRequired ? '需要' : '不需要'}
+
最低版本
{releaseCandidate.minimumVersion}
+
重启
安装后由你手动完成
+
回滚声明
{releaseCandidate.rollbackSupported ? `支持 · ${releaseCandidate.previousVersion || '上一版本'}` : '不支持'}
+
+

安装前会再次读取同一广播,验证下载域名、Tauri 签名、文件大小和 SHA-256;内容变化就停止并要求重新确认。

+ +
:
{status.updateState.startsWith('READY') ? '主控信任根已登记' : '更新网络保持关闭'}{status.updateState.startsWith('READY') ? '可以由你手动检查签名广播' : '没有正式地址和公钥时,不发起网络请求'}
} + {!releaseCandidate && } + {releaseMessage &&

{releaseMessage}

} +

当前工程仍把持久回滚执行器列为生产签名发布前硬门;在它完成前不会宣称公共自动更新链路可以正式启用。

+ } {panel === 'settings' && <>

湖面设置

选择你看见光湖的方式

diff --git a/product-source/hololake-native-desktop/src/styles.css b/product-source/hololake-native-desktop/src/styles.css index c6a9f21..82a21e9 100644 --- a/product-source/hololake-native-desktop/src/styles.css +++ b/product-source/hololake-native-desktop/src/styles.css @@ -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; }