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

@ -28,4 +28,6 @@ This core does not run a model, configure an API, acquire a persona lease, mutat
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.
Before an update replaces the application, the runtime verifies and keeps one bounded last-known-good application bundle with its bundle identifier, Team ID and CDHash. The next startup requires a human health confirmation; until that decision, another update is blocked. A human may restore the verified previous bundle without automatic restart, and cleanup is confined to HoloLake-owned recovery paths and the exact current application parent.
The rollback executor is implemented, but production updater activation remains blocked until the JD controller publishes the exact trust endpoint and public key, the signed release pipeline is evidenced, and the public macOS build is Apple-notarized.

View file

@ -1,7 +1,7 @@
{
"schema": "hololake.native-desktop-foundation/v1",
"record_id": "HLP-NATIVE-DESKTOP-FOUNDATION-001",
"state": "TAURI_STAGE_ONE_HOME_WITH_DIRECT_LOCAL_BROKER_DYNAMIC_ROUTING_AND_PNCC_READ_ONLY_CORE",
"state": "TAURI_STAGE_ONE_HOME_WITH_DIRECT_LOCAL_BROKER_DYNAMIC_ROUTING_PNCC_AND_SIGNED_RELEASE_RECOVERY",
"canonical_shell": "TAURI_V2_RUST_REACT",
"product_ui_implementation_started": true,
"selected_visual_direction_present": true,
@ -18,8 +18,8 @@
"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",
"release_persistent_rollback_executor_implemented": true,
"release_production_activation_state": "BLOCKED_PENDING_JD_TRUST_SIGNED_PIPELINE_AND_APPLE_NOTARIZATION",
"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

@ -43,7 +43,11 @@ test('release activation remains explicitly human controlled', () => {
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)
assert.equal(foundation.release_persistent_rollback_executor_implemented, true)
assert.equal(
foundation.release_production_activation_state,
'BLOCKED_PENDING_JD_TRUST_SIGNED_PIPELINE_AND_APPLE_NOTARIZATION',
)
})
test('unprovisioned builds cannot emit updater artifacts or expose updater IPC', () => {

View file

@ -18,6 +18,7 @@ pub struct HoloLakeHomeStatus {
pub code_repository_mount_count: usize,
pub pncc_receipt_count: usize,
pub update_state: &'static str,
pub release_recovery_state: String,
pub automatic_upstream_updates: bool,
pub mcp_role: &'static str,
}
@ -38,6 +39,7 @@ pub fn get_hololake_home_status(
code_repository_mount_count: mounted_repository_count_at(&mount_root)?,
pncc_receipt_count: projection_event_count_at(&projection_root)?,
update_state: release_trust_state()?,
release_recovery_state: crate::release_update::release_recovery_state(&app)?,
automatic_upstream_updates: false,
mcp_role: "DISCOVERY_RECOVERY_COMPATIBILITY_ONLY",
})

View file

@ -23,6 +23,9 @@ pub fn run() {
home_status::get_hololake_home_status,
release_update::check_hololake_update,
release_update::confirm_hololake_update_install,
release_update::get_hololake_release_recovery_status,
release_update::confirm_hololake_release_health,
release_update::rollback_hololake_update,
direct_local_session::issue_direct_local_discovery_ticket,
direct_local_session::open_direct_local_session,
direct_local_session::resume_direct_local_session,
@ -39,6 +42,9 @@ pub fn run() {
let broker = direct_local_broker::start(app.handle())?;
app.manage(broker);
release_trust::install_updater_if_provisioned(app.handle())?;
if let Err(error) = release_update::observe_release_startup(app.handle()) {
eprintln!("HoloLake update recovery requires maintenance: {error}");
}
Ok(())
})
.run(tauri::generate_context!())

View file

@ -9,6 +9,7 @@ use std::fs::{self, OpenOptions};
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::{AppHandle, Manager};
use tauri_plugin_updater::{Update, UpdaterExt};
@ -16,7 +17,9 @@ use uuid::Uuid;
const CANDIDATE_SCHEMA: &str = "hololake.release-candidate/v1";
const RECEIPT_SCHEMA: &str = "hololake.release-install-receipt/v1";
const RECOVERY_SCHEMA: &str = "hololake.release-recovery/v1";
const CANDIDATE_TTL_MS: u128 = 30 * 60 * 1000;
const EXPECTED_BUNDLE_IDENTIFIER: &str = "world.guanghu.hololake";
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
@ -36,6 +39,30 @@ struct ReleaseCandidateRecord {
expires_at_unix_ms: u128,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct ReleaseRecoveryRecord {
schema: String,
state: String,
release_id: String,
previous_version: String,
installed_version: String,
backup_bundle_path: String,
backup_identifier: String,
backup_team_identifier: String,
backup_cdhash: String,
failed_bundle_path: Option<String>,
observed_at_unix_ms: u128,
receipt_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct CodeSignatureEvidence {
identifier: String,
team_identifier: String,
cdhash: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ConfirmReleaseInstallInput {
@ -143,10 +170,35 @@ pub struct ReleaseInstallReceipt {
receipt_id: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReleaseRecoveryStatus {
pub schema: &'static str,
pub state: String,
pub previous_version: Option<String>,
pub installed_version: Option<String>,
pub backup_ready: bool,
pub human_action_required: bool,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReleaseRecoveryReceipt {
pub schema: &'static str,
pub state: String,
pub previous_version: String,
pub installed_version: String,
pub automatic_restart: bool,
pub 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 root = release_update_root(&app)?;
let _install_lease = install_lease(&root)?;
ensure_no_unresolved_recovery_at(&root)?;
let update = app
.updater_builder()
.endpoints(trust.endpoints.clone())
@ -164,7 +216,6 @@ pub async fn check_hololake_update(app: AppHandle) -> Result<ReleaseCheckReceipt
});
};
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,
@ -181,6 +232,8 @@ pub async fn confirm_hololake_update_install(
let trust = provisioned_release_trust()?
.ok_or_else(|| "HOLOLAKE_RELEASE_TRUST_UNPROVISIONED_NO_NETWORK_REQUEST".to_string())?;
let root = release_update_root(&app)?;
let _install_lease = install_lease(&root)?;
ensure_no_unresolved_recovery_at(&root)?;
let record = authorize_candidate_at(&root, &input)?;
let update = app
.updater_builder()
@ -203,6 +256,32 @@ pub async fn confirm_hololake_update_install(
{
return Err("HOLOLAKE_RELEASE_PACKAGE_DIGEST_OR_SIZE_MISMATCH".into());
}
let current_bundle = current_app_bundle_path()?;
let backup = prepare_last_known_good_at(&root, &current_bundle)?;
let mut recovery = ReleaseRecoveryRecord {
schema: RECOVERY_SCHEMA.into(),
state: "LAST_KNOWN_GOOD_VERIFIED_INSTALLING".into(),
release_id: snapshot.envelope.release_id.clone(),
previous_version: snapshot.current_version.clone(),
installed_version: snapshot.envelope.version.clone(),
backup_bundle_path: backup.0.to_string_lossy().into_owned(),
backup_identifier: backup.1.identifier,
backup_team_identifier: backup.1.team_identifier,
backup_cdhash: backup.1.cdhash,
failed_bundle_path: None,
observed_at_unix_ms: now_unix_ms()?,
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(&recovery_path(&root), &recovery, "RECOVERY")?;
let receipt = ReleaseInstallReceipt {
schema: RECEIPT_SCHEMA,
state: "SIGNED_PACKAGE_VERIFIED_INSTALL_STARTED_RESTART_REQUIRED",
@ -230,13 +309,142 @@ pub async fn confirm_hololake_update_install(
&receipt,
"RECEIPT",
)?;
mark_candidate_installing_at(&root, &record.candidate_id)?;
update
.install(&bytes)
.map_err(|error| format!("HOLOLAKE_RELEASE_INSTALL_FAILED: {error}"))?;
if let Err(error) = mark_candidate_installing_at(&root, &record.candidate_id) {
recovery.state = "INSTALL_FAILED_BACKUP_RETAINED".into();
recovery.observed_at_unix_ms = now_unix_ms()?;
write_json_atomic(&recovery_path(&root), &recovery, "RECOVERY")?;
return Err(error);
}
if let Err(error) = update.install(&bytes) {
recovery.state = "INSTALL_FAILED_BACKUP_RETAINED".into();
recovery.observed_at_unix_ms = now_unix_ms()?;
write_json_atomic(&recovery_path(&root), &recovery, "RECOVERY")?;
return Err(format!("HOLOLAKE_RELEASE_INSTALL_FAILED: {error}"));
}
recovery.state = "INSTALL_REPLACED_AWAITING_MANUAL_RESTART".into();
recovery.observed_at_unix_ms = now_unix_ms()?;
write_json_atomic(&recovery_path(&root), &recovery, "RECOVERY")?;
Ok(receipt)
}
#[tauri::command]
pub fn get_hololake_release_recovery_status(
app: AppHandle,
) -> Result<ReleaseRecoveryStatus, String> {
release_recovery_status_at(&release_update_root(&app)?)
}
#[tauri::command]
pub fn confirm_hololake_release_health(app: AppHandle) -> Result<ReleaseRecoveryReceipt, String> {
let root = release_update_root(&app)?;
let _lock = lock(&root)?;
let path = recovery_path(&root);
let mut record: ReleaseRecoveryRecord = read_json(&path)?;
if !matches!(
record.state.as_str(),
"AWAITING_HUMAN_HEALTH_CONFIRMATION"
| "ROLLED_BACK_AWAITING_HUMAN_ACKNOWLEDGEMENT"
| "INSTALL_FAILED_BACKUP_RETAINED"
| "INSTALL_INTERRUPTED_BACKUP_RETAINED"
) {
return Err("HOLOLAKE_RELEASE_HEALTH_CONFIRMATION_NOT_AVAILABLE".into());
}
let running_version = app.package_info().version.to_string();
let expected = if record.state == "AWAITING_HUMAN_HEALTH_CONFIRMATION" {
&record.installed_version
} else {
&record.previous_version
};
if &running_version != expected {
return Err("HOLOLAKE_RELEASE_RUNNING_VERSION_MISMATCH".into());
}
remove_owned_bundle_path(&root, Path::new(&record.backup_bundle_path))?;
if let Some(failed) = record.failed_bundle_path.as_deref() {
remove_owned_failed_bundle(Path::new(failed), &current_app_parent()?)?;
}
record.state = match record.state.as_str() {
"AWAITING_HUMAN_HEALTH_CONFIRMATION" => "HEALTH_CONFIRMED_BACKUP_REMOVED",
"ROLLED_BACK_AWAITING_HUMAN_ACKNOWLEDGEMENT" => "ROLLBACK_CONFIRMED_BACKUP_REMOVED",
_ => "INSTALL_FAILURE_ACKNOWLEDGED_BACKUP_REMOVED",
}
.into();
record.observed_at_unix_ms = now_unix_ms()?;
write_json_atomic(&path, &record, "RECOVERY")?;
Ok(recovery_receipt(&record))
}
#[tauri::command]
pub fn rollback_hololake_update(app: AppHandle) -> Result<ReleaseRecoveryReceipt, String> {
let root = release_update_root(&app)?;
let _lock = lock(&root)?;
let path = recovery_path(&root);
let mut record: ReleaseRecoveryRecord = read_json(&path)?;
if record.state != "AWAITING_HUMAN_HEALTH_CONFIRMATION" {
return Err("HOLOLAKE_RELEASE_ROLLBACK_NOT_AVAILABLE".into());
}
if app.package_info().version.to_string() != record.installed_version {
return Err("HOLOLAKE_RELEASE_RUNNING_VERSION_MISMATCH".into());
}
let backup = validated_backup_path(&root, &record)?;
let current = current_app_bundle_path()?;
let failed = replace_bundle_with_backup(&current, &backup, &record)?;
record.failed_bundle_path = Some(failed.to_string_lossy().into_owned());
record.state = "ROLLBACK_REPLACED_AWAITING_MANUAL_RESTART".into();
record.observed_at_unix_ms = now_unix_ms()?;
write_json_atomic(&path, &record, "RECOVERY")?;
Ok(recovery_receipt(&record))
}
pub(crate) fn observe_release_startup(app: &AppHandle) -> Result<(), String> {
let root = release_update_root(app)?;
let path = recovery_path(&root);
if !path.exists() {
return Ok(());
}
let _lock = lock(&root)?;
let mut record: ReleaseRecoveryRecord = read_json(&path)?;
let version = app.package_info().version.to_string();
let next = match record.state.as_str() {
"INSTALL_REPLACED_AWAITING_MANUAL_RESTART" if version == record.installed_version => {
Some("AWAITING_HUMAN_HEALTH_CONFIRMATION")
}
"ROLLBACK_REPLACED_AWAITING_MANUAL_RESTART" if version == record.previous_version => {
if let Some(failed) = record.failed_bundle_path.as_deref() {
remove_owned_failed_bundle(Path::new(failed), &current_app_parent()?)?;
record.failed_bundle_path = None;
}
Some("ROLLED_BACK_AWAITING_HUMAN_ACKNOWLEDGEMENT")
}
"LAST_KNOWN_GOOD_VERIFIED_INSTALLING" if version == record.previous_version => {
Some("INSTALL_INTERRUPTED_BACKUP_RETAINED")
}
"LAST_KNOWN_GOOD_VERIFIED_INSTALLING" if version == record.installed_version => {
Some("AWAITING_HUMAN_HEALTH_CONFIRMATION")
}
"AWAITING_HUMAN_HEALTH_CONFIRMATION" if version == record.previous_version => {
Some("ROLLED_BACK_AWAITING_HUMAN_ACKNOWLEDGEMENT")
}
_ => None,
};
if let Some(next) = next {
record.state = next.into();
record.observed_at_unix_ms = now_unix_ms()?;
write_json_atomic(&path, &record, "RECOVERY")?;
}
Ok(())
}
pub(crate) fn release_recovery_state(app: &AppHandle) -> Result<String, String> {
let root = release_update_root(app)?;
Ok(release_recovery_state_at(&root))
}
fn release_recovery_state_at(root: &Path) -> String {
release_recovery_status_at(root)
.map(|status| status.state)
.unwrap_or_else(|_| "RECOVERY_STATE_UNREADABLE_ACTION_REQUIRED".into())
}
struct UpdateSnapshot {
current_version: String,
envelope: ReleaseEnvelope,
@ -392,6 +600,323 @@ fn mark_candidate_installing_at(root: &Path, candidate_id: &str) -> Result<(), S
write_json_atomic(&path, &record, "CANDIDATE")
}
fn ensure_no_unresolved_recovery_at(root: &Path) -> Result<(), String> {
let path = recovery_path(root);
if !path.exists() {
return Ok(());
}
let record: ReleaseRecoveryRecord = read_json(&path)?;
if matches!(
record.state.as_str(),
"HEALTH_CONFIRMED_BACKUP_REMOVED"
| "ROLLBACK_CONFIRMED_BACKUP_REMOVED"
| "INSTALL_FAILURE_ACKNOWLEDGED_BACKUP_REMOVED"
) {
Ok(())
} else {
Err("HOLOLAKE_RELEASE_RECOVERY_ACTION_REQUIRED_BEFORE_NEXT_UPDATE".into())
}
}
fn release_recovery_status_at(root: &Path) -> Result<ReleaseRecoveryStatus, String> {
let path = recovery_path(root);
if !path.exists() {
return Ok(ReleaseRecoveryStatus {
schema: RECOVERY_SCHEMA,
state: "NONE".into(),
previous_version: None,
installed_version: None,
backup_ready: false,
human_action_required: false,
});
}
let record: ReleaseRecoveryRecord = read_json(&path)?;
let human_action_required = matches!(
record.state.as_str(),
"AWAITING_HUMAN_HEALTH_CONFIRMATION"
| "ROLLED_BACK_AWAITING_HUMAN_ACKNOWLEDGEMENT"
| "INSTALL_FAILED_BACKUP_RETAINED"
| "INSTALL_INTERRUPTED_BACKUP_RETAINED"
);
let backup_ready = Path::new(&record.backup_bundle_path).exists();
Ok(ReleaseRecoveryStatus {
schema: RECOVERY_SCHEMA,
state: record.state,
previous_version: Some(record.previous_version),
installed_version: Some(record.installed_version),
backup_ready,
human_action_required,
})
}
fn recovery_receipt(record: &ReleaseRecoveryRecord) -> ReleaseRecoveryReceipt {
ReleaseRecoveryReceipt {
schema: RECOVERY_SCHEMA,
state: record.state.clone(),
previous_version: record.previous_version.clone(),
installed_version: record.installed_version.clone(),
automatic_restart: false,
receipt_id: sha256_hex(
format!(
"{}\n{}\n{}\n{}",
record.receipt_id, record.state, record.previous_version, record.installed_version
)
.as_bytes(),
),
}
}
fn prepare_last_known_good_at(
root: &Path,
current_bundle: &Path,
) -> Result<(PathBuf, CodeSignatureEvidence), String> {
let current = current_bundle
.canonicalize()
.map_err(|error| format!("HOLOLAKE_RELEASE_CURRENT_BUNDLE_UNAVAILABLE: {error}"))?;
if current.extension().and_then(|value| value.to_str()) != Some("app") {
return Err("HOLOLAKE_RELEASE_CURRENT_BUNDLE_INVALID".into());
}
let current_evidence = verify_bundle_signature(&current)?;
if current_evidence.identifier != EXPECTED_BUNDLE_IDENTIFIER
|| current_evidence.team_identifier.is_empty()
{
return Err("HOLOLAKE_RELEASE_CURRENT_BUNDLE_IDENTITY_INVALID".into());
}
let next_root = root.join(format!(".last-known-good-next-{}", Uuid::new_v4()));
fs::create_dir(&next_root)
.map_err(|error| format!("HOLOLAKE_RELEASE_BACKUP_CREATE_FAILED: {error}"))?;
let next_bundle = next_root.join("HoloLake.app");
let copy = trusted_command("/usr/bin/ditto")
.arg(&current)
.arg(&next_bundle)
.output();
let copy = match copy {
Ok(output) => output,
Err(error) => {
let _ = fs::remove_dir_all(&next_root);
return Err(format!("HOLOLAKE_RELEASE_BACKUP_COPY_FAILED: {error}"));
}
};
if let Err(error) = require_command_success(copy, "HOLOLAKE_RELEASE_BACKUP_COPY") {
let _ = fs::remove_dir_all(&next_root);
return Err(error);
}
let copied_evidence = match verify_bundle_signature(&next_bundle) {
Ok(evidence) => evidence,
Err(error) => {
let _ = fs::remove_dir_all(&next_root);
return Err(error);
}
};
if copied_evidence != current_evidence {
let _ = fs::remove_dir_all(&next_root);
return Err("HOLOLAKE_RELEASE_BACKUP_SIGNATURE_DRIFT".into());
}
let final_root = root.join("last-known-good");
let previous_root = root.join(format!(".last-known-good-previous-{}", Uuid::new_v4()));
if final_root.exists() {
fs::rename(&final_root, &previous_root)
.map_err(|error| format!("HOLOLAKE_RELEASE_BACKUP_ROTATE_FAILED: {error}"))?;
}
if let Err(error) = fs::rename(&next_root, &final_root) {
if previous_root.exists() {
let _ = fs::rename(&previous_root, &final_root);
}
let _ = fs::remove_dir_all(&next_root);
return Err(format!("HOLOLAKE_RELEASE_BACKUP_ACTIVATE_FAILED: {error}"));
}
if previous_root.exists() {
fs::remove_dir_all(&previous_root)
.map_err(|error| format!("HOLOLAKE_RELEASE_BACKUP_ROTATE_FAILED: {error}"))?;
}
Ok((final_root.join("HoloLake.app"), copied_evidence))
}
fn validated_backup_path(root: &Path, record: &ReleaseRecoveryRecord) -> Result<PathBuf, String> {
let backup = PathBuf::from(&record.backup_bundle_path)
.canonicalize()
.map_err(|error| format!("HOLOLAKE_RELEASE_BACKUP_UNAVAILABLE: {error}"))?;
let owned_root = root
.join("last-known-good")
.canonicalize()
.map_err(|error| format!("HOLOLAKE_RELEASE_BACKUP_UNAVAILABLE: {error}"))?;
if backup != owned_root.join("HoloLake.app") {
return Err("HOLOLAKE_RELEASE_BACKUP_BOUNDARY_INVALID".into());
}
let evidence = verify_bundle_signature(&backup)?;
if evidence.identifier != record.backup_identifier
|| evidence.team_identifier != record.backup_team_identifier
|| evidence.cdhash != record.backup_cdhash
{
return Err("HOLOLAKE_RELEASE_BACKUP_SIGNATURE_MISMATCH".into());
}
Ok(backup)
}
fn replace_bundle_with_backup(
current: &Path,
backup: &Path,
record: &ReleaseRecoveryRecord,
) -> Result<PathBuf, String> {
let current = current
.canonicalize()
.map_err(|error| format!("HOLOLAKE_RELEASE_CURRENT_BUNDLE_UNAVAILABLE: {error}"))?;
let current_evidence = verify_bundle_signature(&current)?;
if current_evidence.identifier != EXPECTED_BUNDLE_IDENTIFIER
|| current_evidence.team_identifier != record.backup_team_identifier
{
return Err("HOLOLAKE_RELEASE_CURRENT_BUNDLE_IDENTITY_INVALID".into());
}
let parent = current
.parent()
.ok_or("HOLOLAKE_RELEASE_CURRENT_BUNDLE_PATH_INVALID")?;
let failed = parent.join(format!(".HoloLake.failed-{}.app", Uuid::new_v4()));
fs::rename(&current, &failed)
.map_err(|error| format!("HOLOLAKE_RELEASE_ROLLBACK_STAGE_FAILED: {error}"))?;
let copied = trusted_command("/usr/bin/ditto")
.arg(backup)
.arg(&current)
.output();
let copied = match copied {
Ok(output) => output,
Err(error) => {
restore_staged_current(&failed, &current)?;
return Err(format!("HOLOLAKE_RELEASE_ROLLBACK_COPY_FAILED: {error}"));
}
};
if let Err(error) = require_command_success(copied, "HOLOLAKE_RELEASE_ROLLBACK_COPY") {
restore_staged_current(&failed, &current)?;
return Err(error);
}
let restored = verify_bundle_signature(&current);
if restored.as_ref()
!= Ok(&CodeSignatureEvidence {
identifier: record.backup_identifier.clone(),
team_identifier: record.backup_team_identifier.clone(),
cdhash: record.backup_cdhash.clone(),
})
{
restore_staged_current(&failed, &current)?;
return Err("HOLOLAKE_RELEASE_ROLLBACK_SIGNATURE_MISMATCH".into());
}
Ok(failed)
}
fn restore_staged_current(staged: &Path, current: &Path) -> Result<(), String> {
if current.exists() {
fs::remove_dir_all(current)
.map_err(|error| format!("HOLOLAKE_RELEASE_ROLLBACK_RECOVERY_FAILED: {error}"))?;
}
fs::rename(staged, current)
.map_err(|error| format!("HOLOLAKE_RELEASE_ROLLBACK_RECOVERY_FAILED: {error}"))
}
fn remove_owned_bundle_path(root: &Path, bundle: &Path) -> Result<(), String> {
let expected = root.join("last-known-good").join("HoloLake.app");
if bundle != expected {
return Err("HOLOLAKE_RELEASE_BACKUP_BOUNDARY_INVALID".into());
}
let owner = root.join("last-known-good");
if owner.exists() {
fs::remove_dir_all(&owner)
.map_err(|error| format!("HOLOLAKE_RELEASE_BACKUP_REMOVE_FAILED: {error}"))?;
}
Ok(())
}
fn remove_owned_failed_bundle(bundle: &Path, expected_parent: &Path) -> Result<(), String> {
let name = bundle
.file_name()
.and_then(|value| value.to_str())
.ok_or("HOLOLAKE_RELEASE_FAILED_BUNDLE_BOUNDARY_INVALID")?;
if !name.starts_with(".HoloLake.failed-") || !name.ends_with(".app") {
return Err("HOLOLAKE_RELEASE_FAILED_BUNDLE_BOUNDARY_INVALID".into());
}
if bundle.parent() != Some(expected_parent) {
return Err("HOLOLAKE_RELEASE_FAILED_BUNDLE_BOUNDARY_INVALID".into());
}
if bundle.exists() {
fs::remove_dir_all(bundle)
.map_err(|error| format!("HOLOLAKE_RELEASE_FAILED_BUNDLE_REMOVE_FAILED: {error}"))?;
}
Ok(())
}
fn verify_bundle_signature(bundle: &Path) -> Result<CodeSignatureEvidence, String> {
let verify = trusted_command("/usr/bin/codesign")
.args(["--verify", "--deep", "--strict", "--verbose=4"])
.arg(bundle)
.output()
.map_err(|error| format!("HOLOLAKE_RELEASE_CODESIGN_VERIFY_FAILED: {error}"))?;
require_command_success(verify, "HOLOLAKE_RELEASE_CODESIGN_VERIFY")?;
let display = trusted_command("/usr/bin/codesign")
.args(["-dv", "--verbose=4"])
.arg(bundle)
.output()
.map_err(|error| format!("HOLOLAKE_RELEASE_CODESIGN_READ_FAILED: {error}"))?;
let text = String::from_utf8_lossy(&display.stderr);
let value = |name: &str| {
text.lines()
.find_map(|line| line.strip_prefix(&format!("{name}=")))
.map(str::to_owned)
.ok_or_else(|| format!("HOLOLAKE_RELEASE_CODESIGN_{name}_MISSING"))
};
Ok(CodeSignatureEvidence {
identifier: value("Identifier")?,
team_identifier: value("TeamIdentifier")?,
cdhash: value("CDHash")?,
})
}
fn current_app_bundle_path() -> Result<PathBuf, String> {
let executable = std::env::current_exe()
.map_err(|error| format!("HOLOLAKE_RELEASE_CURRENT_EXECUTABLE_UNAVAILABLE: {error}"))?
.canonicalize()
.map_err(|error| format!("HOLOLAKE_RELEASE_CURRENT_EXECUTABLE_UNAVAILABLE: {error}"))?;
let contents = executable
.parent()
.and_then(Path::parent)
.ok_or("HOLOLAKE_RELEASE_CURRENT_BUNDLE_PATH_INVALID")?;
let bundle = contents
.parent()
.ok_or("HOLOLAKE_RELEASE_CURRENT_BUNDLE_PATH_INVALID")?;
if contents.file_name().and_then(|value| value.to_str()) != Some("Contents")
|| bundle.extension().and_then(|value| value.to_str()) != Some("app")
{
return Err("HOLOLAKE_RELEASE_CURRENT_BUNDLE_PATH_INVALID".into());
}
Ok(bundle.to_path_buf())
}
fn current_app_parent() -> Result<PathBuf, String> {
let bundle = current_app_bundle_path()?;
let parent = bundle
.parent()
.ok_or("HOLOLAKE_RELEASE_CURRENT_BUNDLE_PATH_INVALID")?
.to_path_buf();
Ok(parent)
}
fn trusted_command(program: &str) -> Command {
let mut command = Command::new(program);
command
.env_clear()
.env("PATH", "/usr/bin:/bin")
.env("LANG", "C");
command
}
fn require_command_success(output: Output, label: &str) -> Result<(), String> {
if output.status.success() {
Ok(())
} else {
Err(format!(
"{label}_FAILED: {}",
String::from_utf8_lossy(&output.stderr).trim()
))
}
}
fn release_update_root(app: &AppHandle) -> Result<PathBuf, String> {
let root = app
.path()
@ -408,6 +933,10 @@ fn candidate_path(root: &Path, candidate_id: &str) -> PathBuf {
root.join(format!("{candidate_id}.json"))
}
fn recovery_path(root: &Path) -> PathBuf {
root.join("recovery.json")
}
fn lock(root: &Path) -> Result<std::fs::File, String> {
let lock = OpenOptions::new()
.read(true)
@ -422,6 +951,21 @@ fn lock(root: &Path) -> Result<std::fs::File, String> {
Ok(lock)
}
fn install_lease(root: &Path) -> Result<std::fs::File, String> {
let lease = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.open(root.join("install.lock"))
.map_err(|error| format!("HOLOLAKE_RELEASE_INSTALL_LEASE_FAILED: {error}"))?;
lease
.try_lock_exclusive()
.map_err(|_| "HOLOLAKE_RELEASE_INSTALL_ALREADY_IN_PROGRESS".to_string())?;
Ok(lease)
}
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}"))?;
@ -578,4 +1122,88 @@ mod tests {
"HOLOLAKE_RELEASE_BROADCAST_CHANGED_RECONFIRM_REQUIRED"
);
}
#[test]
fn unresolved_recovery_blocks_the_next_update_until_human_resolution() {
let root = TempDir::new().unwrap();
let backup = root.path().join("last-known-good/HoloLake.app");
fs::create_dir_all(&backup).unwrap();
let recovery = ReleaseRecoveryRecord {
schema: RECOVERY_SCHEMA.into(),
state: "AWAITING_HUMAN_HEALTH_CONFIRMATION".into(),
release_id: "release-1".into(),
previous_version: "0.1.0".into(),
installed_version: "0.2.0".into(),
backup_bundle_path: backup.to_string_lossy().into_owned(),
backup_identifier: EXPECTED_BUNDLE_IDENTIFIER.into(),
backup_team_identifier: "TEAM".into(),
backup_cdhash: "cdhash".into(),
failed_bundle_path: None,
observed_at_unix_ms: now_unix_ms().unwrap(),
receipt_id: "receipt".into(),
};
write_json_atomic(&recovery_path(root.path()), &recovery, "RECOVERY").unwrap();
assert_eq!(
ensure_no_unresolved_recovery_at(root.path()).unwrap_err(),
"HOLOLAKE_RELEASE_RECOVERY_ACTION_REQUIRED_BEFORE_NEXT_UPDATE"
);
let status = release_recovery_status_at(root.path()).unwrap();
assert_eq!(status.state, "AWAITING_HUMAN_HEALTH_CONFIRMATION");
assert!(status.backup_ready);
assert!(status.human_action_required);
}
#[test]
fn failed_bundle_cleanup_is_confined_to_the_exact_current_parent() {
let root = TempDir::new().unwrap();
let parent = root.path().join("Applications");
let elsewhere = root.path().join("elsewhere");
fs::create_dir_all(&parent).unwrap();
fs::create_dir_all(&elsewhere).unwrap();
let misleading = elsewhere.join(".HoloLake.failed-test.app");
fs::create_dir(&misleading).unwrap();
assert_eq!(
remove_owned_failed_bundle(&misleading, &parent).unwrap_err(),
"HOLOLAKE_RELEASE_FAILED_BUNDLE_BOUNDARY_INVALID"
);
assert!(misleading.exists());
}
#[test]
fn unreadable_recovery_record_keeps_the_app_available_but_locks_updates() {
let root = TempDir::new().unwrap();
fs::write(recovery_path(root.path()), b"not-json").unwrap();
assert_eq!(
release_recovery_state_at(root.path()),
"RECOVERY_STATE_UNREADABLE_ACTION_REQUIRED"
);
assert!(ensure_no_unresolved_recovery_at(root.path()).is_err());
}
#[test]
fn only_one_install_operation_can_hold_the_release_lease() {
let root = TempDir::new().unwrap();
let first = install_lease(root.path()).unwrap();
assert_eq!(
install_lease(root.path()).unwrap_err(),
"HOLOLAKE_RELEASE_INSTALL_ALREADY_IN_PROGRESS"
);
drop(first);
assert!(install_lease(root.path()).is_ok());
}
#[test]
fn partial_rollback_copy_is_removed_before_the_running_bundle_is_restored() {
let root = TempDir::new().unwrap();
let current = root.path().join("HoloLake.app");
let staged = root.path().join(".HoloLake.failed-test.app");
fs::create_dir(&current).unwrap();
fs::write(current.join("partial"), b"partial").unwrap();
fs::create_dir(&staged).unwrap();
fs::write(staged.join("original"), b"original").unwrap();
restore_staged_current(&staged, &current).unwrap();
assert!(current.join("original").exists());
assert!(!current.join("partial").exists());
assert!(!staged.exists());
}
}

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" />