feat(hololake): add persistent signed update rollback
This commit is contained in:
parent
70556ed45f
commit
73eb8bc1ca
9 changed files with 727 additions and 15 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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!())
|
||||
|
|
|
|||
|
|
@ -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, ¤t_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), ¤t_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(¤t, &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), ¤t_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(¤t)?;
|
||||
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(¤t)
|
||||
.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(¤t)?;
|
||||
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(¤t, &failed)
|
||||
.map_err(|error| format!("HOLOLAKE_RELEASE_ROLLBACK_STAGE_FAILED: {error}"))?;
|
||||
let copied = trusted_command("/usr/bin/ditto")
|
||||
.arg(backup)
|
||||
.arg(¤t)
|
||||
.output();
|
||||
let copied = match copied {
|
||||
Ok(output) => output,
|
||||
Err(error) => {
|
||||
restore_staged_current(&failed, ¤t)?;
|
||||
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, ¤t)?;
|
||||
return Err(error);
|
||||
}
|
||||
let restored = verify_bundle_signature(¤t);
|
||||
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, ¤t)?;
|
||||
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(¤t).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, ¤t).unwrap();
|
||||
assert!(current.join("original").exists());
|
||||
assert!(!current.join("partial").exists());
|
||||
assert!(!staged.exists());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
1
product-source/hololake-native-desktop/src/vite-env.d.ts
vendored
Normal file
1
product-source/hololake-native-desktop/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
Loading…
Reference in a new issue