diff --git a/apps/maple-agent/Cargo.lock b/apps/maple-agent/Cargo.lock index e47c1f9a6..3471b4f14 100644 --- a/apps/maple-agent/Cargo.lock +++ b/apps/maple-agent/Cargo.lock @@ -5766,7 +5766,7 @@ dependencies = [ [[package]] name = "maple-agent" -version = "0.0.14" +version = "0.0.15" dependencies = [ "agent-client-protocol", "anyhow", @@ -5818,7 +5818,7 @@ dependencies = [ [[package]] name = "maple-agent-app" -version = "0.0.14" +version = "0.0.15" dependencies = [ "ashpd", "axum", @@ -5880,7 +5880,7 @@ dependencies = [ [[package]] name = "maple-agent-updater" -version = "0.0.14" +version = "0.0.15" dependencies = [ "anyhow", "base64 0.22.1", @@ -5910,7 +5910,7 @@ dependencies = [ [[package]] name = "maple-billing" -version = "0.0.14" +version = "0.0.15" dependencies = [ "reqwest 0.13.4", "serde", @@ -7688,7 +7688,7 @@ dependencies = [ [[package]] name = "pi-agent-core" -version = "0.0.14" +version = "0.0.15" dependencies = [ "async-trait", "futures-util", @@ -7701,7 +7701,7 @@ dependencies = [ [[package]] name = "pi-ai" -version = "0.0.14" +version = "0.0.15" dependencies = [ "async-trait", "bytes", @@ -7718,7 +7718,7 @@ dependencies = [ [[package]] name = "pi-coding-agent" -version = "0.0.14" +version = "0.0.15" dependencies = [ "async-trait", "base64 0.22.1", @@ -10111,7 +10111,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "store-bench" -version = "0.0.14" +version = "0.0.15" dependencies = [ "anyhow", "maple-agent", diff --git a/apps/maple-agent/Cargo.toml b/apps/maple-agent/Cargo.toml index dcc12975c..87713db38 100644 --- a/apps/maple-agent/Cargo.toml +++ b/apps/maple-agent/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/maple-agent", "crates/maple-billing", "crates/maple-agent-upd [workspace.package] edition = "2024" -version = "0.0.14" +version = "0.0.15" license = "MIT" [workspace.dependencies] diff --git a/apps/maple-agent/app/src/main.rs b/apps/maple-agent/app/src/main.rs index 19f05f407..e3470a1f6 100644 --- a/apps/maple-agent/app/src/main.rs +++ b/apps/maple-agent/app/src/main.rs @@ -296,7 +296,10 @@ fn main() { root, profile::PROFILE, ) - .map_err(|error| error.to_string()) + .map_err(|error| { + internal_exit_code = error.windows_installer_exit_code(); + error.to_string() + }) }), ) } else if !cli.internal_windows_installer_prepare.is_empty() { @@ -315,7 +318,10 @@ fn main() { profile::PROFILE, generation, ) - .map_err(|error| error.to_string()) + .map_err(|error| { + internal_exit_code = error.windows_installer_exit_code(); + error.to_string() + }) }), ) } else if let Some(generation) = &cli.internal_windows_installer_activate { diff --git a/apps/maple-agent/crates/maple-agent-updater/examples/windows_signature_probe.rs b/apps/maple-agent/crates/maple-agent-updater/examples/windows_signature_probe.rs index a8bac7c35..87ad9770e 100644 --- a/apps/maple-agent/crates/maple-agent-updater/examples/windows_signature_probe.rs +++ b/apps/maple-agent/crates/maple-agent-updater/examples/windows_signature_probe.rs @@ -1,6 +1,11 @@ //! Manual, read-only historical Authenticode interoperability probe. The selected //! executable is never launched; this is not installed-update qualification. +#[cfg(windows)] +use maple_agent_updater::installer::InstallError; +#[cfg(windows)] +type Result = std::result::Result; + #[cfg(windows)] #[allow(dead_code)] #[path = "../src/installer/windows_bundle/authenticode.rs"] diff --git a/apps/maple-agent/crates/maple-agent-updater/src/installer.rs b/apps/maple-agent/crates/maple-agent-updater/src/installer.rs index 3388058f9..1ac2f63c7 100644 --- a/apps/maple-agent/crates/maple-agent-updater/src/installer.rs +++ b/apps/maple-agent/crates/maple-agent-updater/src/installer.rs @@ -105,6 +105,8 @@ pub enum InstallError { Invalid(&'static str), #[error("The update does not have the required Maple code signature and identity")] CodeSignature, + #[error("Windows could not verify Maple's code signature (WinTrust error 0x{status:08X})")] + WindowsCodeSignature { status: u32 }, #[error("The update could not be installed: {0}")] Io(#[from] io::Error), #[error("Windows activation failed during {stage}: {source}")] @@ -127,6 +129,26 @@ impl InstallError { } } + /// Report a failed preflight/preparation through NSIS without losing a + /// native error's high bits. Every category remains a nonzero failure; + /// activation keeps its existing separately decoded stage contract. + pub fn windows_installer_exit_code(&self) -> i32 { + match self { + Self::WindowsCodeSignature { status } if *status != 0 => *status as i32, + Self::CodeSignature | Self::WindowsCodeSignature { .. } => 0x4E00_0001, + Self::Busy => 0x4E00_0002, + Self::Invalid(_) => 0x4E00_0003, + Self::NotifyOnly(_) => 0x4E00_0004, + Self::Io(error) => error + .raw_os_error() + .filter(|code| *code != 0) + .unwrap_or(0x4E00_0005), + Self::WindowsActivation { .. } => { + self.windows_activation_exit_code().unwrap_or(0x4E00_0003) + } + } + } + /// Encode only an activation failure, without paths, SIDs or error text. /// The original typed source remains available to direct callers. pub fn windows_activation_exit_code(&self) -> Option { @@ -150,6 +172,92 @@ type Result = std::result::Result; mod windows_activation_error_tests { use super::*; + #[test] + fn installer_status_retains_full_native_bits_including_negative_trust_errors() { + for status in [1, 0x800B_0109, 0x8009_2013, u32::MAX] { + let error = InstallError::WindowsCodeSignature { status }; + let code = error.windows_installer_exit_code(); + assert_eq!(code as u32, status); + assert_ne!(code, 0); + } + for code in [5, 32, 65_536, i32::MIN, i32::MAX, -1] { + let error = InstallError::Io(io::Error::from_raw_os_error(code)); + assert_eq!(error.windows_installer_exit_code(), code); + } + } + + #[test] + fn installer_status_fallbacks_never_return_success_or_change_activation_status() { + for (error, code) in [ + (InstallError::CodeSignature, 0x4E00_0001), + ( + InstallError::WindowsCodeSignature { status: 0 }, + 0x4E00_0001, + ), + (InstallError::Busy, 0x4E00_0002), + (InstallError::Invalid("public fixture"), 0x4E00_0003), + (InstallError::NotifyOnly("public fixture"), 0x4E00_0004), + ( + InstallError::Io(io::Error::from(io::ErrorKind::PermissionDenied)), + 0x4E00_0005, + ), + ( + InstallError::Io(io::Error::from_raw_os_error(0)), + 0x4E00_0005, + ), + ] { + assert_eq!(error.windows_installer_exit_code(), code); + assert_ne!(code, 0); + } + for source in [ + InstallError::WindowsCodeSignature { + status: 0x800B_0109, + }, + InstallError::Io(io::Error::from_raw_os_error(32)), + InstallError::Invalid("public fixture"), + ] { + let error = InstallError::WindowsActivation { + stage: WindowsActivationStage::GenerationVerification, + source: Box::new(source), + }; + assert_eq!( + Some(error.windows_installer_exit_code()), + error.windows_activation_exit_code() + ); + assert_ne!(error.windows_installer_exit_code(), 0); + } + } + + #[test] + fn windows_signature_status_survives_activation_context_without_changing_error_policy() { + for status in [0x800B_0109, 0x8009_2013, 0x800B_0100, u32::MAX] { + let source = InstallError::WindowsCodeSignature { status }; + assert_eq!( + source.to_string(), + format!( + "Windows could not verify Maple's code signature (WinTrust error 0x{status:08X})" + ) + ); + assert!(!source.requires_manual_installation()); + let error = InstallError::WindowsActivation { + stage: WindowsActivationStage::BootstrapIdentity, + source: Box::new(source), + }; + assert!(error.to_string().contains("bootstrap-identity")); + assert!(error.to_string().contains(&format!("0x{status:08X}"))); + // The existing bounded activation status is not repurposed to + // truncate or disguise a full WinTrust DWORD. + assert_eq!(error.windows_activation_exit_code(), Some(0x4D04_0000)); + assert!(!error.requires_manual_installation()); + let InstallError::WindowsActivation { source, .. } = error else { + unreachable!() + }; + assert!( + matches!(*source, InstallError::WindowsCodeSignature { status: actual } if actual == status) + ); + } + } + #[test] fn activation_status_preserves_fixed_stage_and_original_native_error() { for (stage, number, label) in [ diff --git a/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle.rs b/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle.rs index b04d28eb6..eb003c95c 100644 --- a/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle.rs +++ b/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle.rs @@ -452,15 +452,15 @@ fn verify_identity_metadata(directory: &Path, bundle: &Bundle) -> Result Result<()> { let executable = verify_identity_metadata(directory, bundle)?; #[cfg(windows)] - if !match purpose { + match purpose { IdentityPurpose::Admission => { - authenticode::verify_or_unsigned(&executable, bundle.profile == "dev") + authenticode::verify_or_unsigned_detailed(&executable, bundle.profile == "dev")?; } IdentityPurpose::Startup => { - authenticode::verify_startup_or_unsigned(&executable, bundle.profile == "dev") + if !authenticode::verify_startup_or_unsigned(&executable, bundle.profile == "dev") { + return Err(InstallError::CodeSignature); + } } - } { - return Err(InstallError::CodeSignature); } #[cfg(not(windows))] let _ = (purpose, executable); @@ -1168,10 +1168,10 @@ fn trusted_installer(executable: &Path, profile: &str) -> Result Result< .ok_or(InstallError::Invalid("Windows uninstall parent"))?; windows_fs::validate_path(root)?; registered_root(root, profile)?; - if pe_build_info(&executable)?["profile"] != profile - || !authenticode::verify_or_unsigned(&executable, profile == "dev") - { + if pe_build_info(&executable)?["profile"] != profile { return Err(InstallError::CodeSignature); } + authenticode::verify_or_unsigned_detailed(&executable, profile == "dev")?; let lock = open_install_lock(root)?; lock.try_lock_exclusive().map_err(|_| InstallError::Busy)?; let owner = installer_owner()?; diff --git a/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle/authenticode.rs b/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle/authenticode.rs index 0afe15c5d..44a887d4e 100644 --- a/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle/authenticode.rs +++ b/apps/maple-agent/crates/maple-agent-updater/src/installer/windows_bundle/authenticode.rs @@ -40,6 +40,8 @@ use windows_sys::Win32::{ }, }; +use super::{InstallError, Result}; + const EXPECTED_PUBLISHER: &str = "Mutiny Wallet Inc. dba OpenSecret"; const MAX_NAME_UNITS: u32 = 512; @@ -300,7 +302,7 @@ fn pe_has_no_certificate(reader: &mut (impl Read + Seek)) -> bool { /// Windows may retrieve certificate-chain/revocation material under its trust /// policy; an unavailable or untrusted chain fails closed. pub(super) fn verify(path: &Path) -> bool { - verify_for_purpose(path, false, Purpose::Admission) + verify_or_unsigned(path, false) } /// Only the caller's embedded Dev profile may opt into unsigned local builds. @@ -308,7 +310,7 @@ pub(super) fn verify(path: &Path) -> bool { /// including when unsigned Dev files are permitted. Production updates call /// `verify`, and never enter this exception. pub(super) fn verify_or_unsigned(path: &Path, allow_unsigned: bool) -> bool { - verify_for_purpose(path, allow_unsigned, Purpose::Admission) + verify_or_unsigned_detailed(path, allow_unsigned).is_ok() } /// Reauthenticate an already admitted installed executable at normal startup. @@ -316,33 +318,34 @@ pub(super) fn verify_or_unsigned(path: &Path, allow_unsigned: bool) -> bool { /// retrieval; it still requires Windows trust, exact publisher and timestamp. /// Installation and update mutation must continue to use the admission APIs. pub(super) fn verify_startup_or_unsigned(path: &Path, allow_unsigned: bool) -> bool { - verify_for_purpose(path, allow_unsigned, Purpose::Startup) + verify_for_purpose(path, allow_unsigned, Purpose::Startup).is_ok() +} + +/// Preserve the native trust failure for installer diagnostics. The boolean +/// admission APIs use the same result and accept only `Ok(())`. +pub(super) fn verify_or_unsigned_detailed(path: &Path, allow_unsigned: bool) -> Result<()> { + verify_for_purpose(path, allow_unsigned, Purpose::Admission) } -fn verify_for_purpose(path: &Path, allow_unsigned: bool, purpose: Purpose) -> bool { +fn verify_for_purpose(path: &Path, allow_unsigned: bool, purpose: Purpose) -> Result<()> { if !path.is_absolute() { - return false; + return Err(InstallError::CodeSignature); } let mut wide: Vec = path.as_os_str().encode_wide().collect(); if wide.is_empty() || wide.len() > 32_766 || wide.contains(&0) { - return false; + return Err(InstallError::CodeSignature); } wide.push(0); // Deny concurrent writes/deletion while the same handle is authenticated. // The caller separately validates every staging ancestor and archive hash. - let Ok(file) = OpenOptions::new() + let file = OpenOptions::new() .read(true) .share_mode(FILE_SHARE_READ) .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) - .open(path) - else { - return false; - }; - let Ok(metadata) = file.metadata() else { - return false; - }; + .open(path)?; + let metadata = file.metadata()?; if !metadata.is_file() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { - return false; + return Err(InstallError::CodeSignature); } let mut info = WINTRUST_FILE_INFO { cbStruct: size_of::() as u32, @@ -364,19 +367,21 @@ fn verify_for_purpose(path: &Path, allow_unsigned: bool, purpose: Purpose) -> bo (status, GetLastError()) }; if allow_unsigned && status == TRUST_E_NOSIGNATURE && reason == TRUST_E_NOSIGNATURE as u32 { - return pe_has_no_certificate(&mut &file) - && no_catalog_membership(catalog_memberships(&file)); - } - // WinVerifyTrust returns LONG, not HRESULT. Only exactly zero is success. - if status != 0 || state.0.hWVTStateData.is_null() { - return false; + return if pe_has_no_certificate(&mut &file) + && no_catalog_membership(catalog_memberships(&file)) + { + Ok(()) + } else { + Err(InstallError::CodeSignature) + }; } + verified_trust_state(status, state.0.hWVTStateData)?; // SAFETY: provider pointers are owned by the successful state and remain // valid until its guard closes it. No provider-owned memory is modified. unsafe { let provider = WTHelperProvDataFromStateData(state.0.hWVTStateData); if provider.is_null() || (*provider).dwError != 0 { - return false; + return Err(InstallError::CodeSignature); } let signer = WTHelperGetProvSignerFromChain(provider, 0, 0, 0); if signer.is_null() @@ -384,11 +389,11 @@ fn verify_for_purpose(path: &Path, allow_unsigned: bool, purpose: Purpose) -> bo || (*signer).csCertChain == 0 || (*signer).csCounterSigners == 0 { - return false; + return Err(InstallError::CodeSignature); } let certificate = WTHelperGetProvCertFromChain(signer, 0); if certificate.is_null() || (*certificate).dwError != 0 || (*certificate).pCert.is_null() { - return false; + return Err(InstallError::CodeSignature); } let count = CertGetNameStringW( (*certificate).pCert, @@ -399,7 +404,7 @@ fn verify_for_purpose(path: &Path, allow_unsigned: bool, purpose: Purpose) -> bo 0, ); if !(2..=MAX_NAME_UNITS).contains(&count) { - return false; + return Err(InstallError::CodeSignature); } let mut name = vec![0u16; count as usize]; if CertGetNameStringW( @@ -412,13 +417,31 @@ fn verify_for_purpose(path: &Path, allow_unsigned: bool, purpose: Purpose) -> bo ) != count || !publisher_matches(&name) { - return false; + return Err(InstallError::CodeSignature); } let timestamp = WTHelperGetProvSignerFromChain(provider, 0, 1, 0); - valid_timestamp(timestamp) + if valid_timestamp(timestamp) { + Ok(()) + } else { + Err(InstallError::CodeSignature) + } } } +// WinVerifyTrust returns LONG, not HRESULT. Retain all returned bits for +// diagnostics; neither positive status values nor a missing state mean success. +fn verified_trust_state(status: i32, state_data: *mut c_void) -> Result<()> { + if status != 0 { + return Err(InstallError::WindowsCodeSignature { + status: status as u32, + }); + } + if state_data.is_null() { + return Err(InstallError::CodeSignature); + } + Ok(()) +} + unsafe fn valid_timestamp(timestamp: *const CRYPT_PROVIDER_SGNR) -> bool { // SAFETY: this pointer, when non-null, comes from the live verified provider // state. Bounds come from that provider, not from serialized app metadata. @@ -439,6 +462,40 @@ unsafe fn valid_timestamp(timestamp: *const CRYPT_PROVIDER_SGNR) -> bool { mod tests { use super::*; + #[test] + fn native_trust_status_preserves_every_failure_bit_and_requires_verified_state() { + let state = std::ptr::dangling_mut::(); + assert!(verified_trust_state(0, state).is_ok()); + assert!(matches!( + verified_trust_state(0, std::ptr::null_mut()), + Err(InstallError::CodeSignature) + )); + for status in [1, -1, 0x800B_0109_u32 as i32, 0x8009_2013_u32 as i32] { + for state in [state, std::ptr::null_mut()] { + let result = verified_trust_state(status, state); + assert!(matches!( + result, + Err(InstallError::WindowsCodeSignature { status: actual }) if actual == status as u32 + )); + } + } + } + + #[test] + fn native_detailed_admission_preserves_file_error_and_boolean_apis_refuse_it() { + let temporary = tempfile::tempdir().unwrap(); + let missing = temporary.path().join("missing-signed-application.exe"); + for allow_unsigned in [false, true] { + let error = verify_or_unsigned_detailed(&missing, allow_unsigned).unwrap_err(); + assert!( + matches!(error, InstallError::Io(error) if error.kind() == std::io::ErrorKind::NotFound) + ); + assert!(!verify_or_unsigned(&missing, allow_unsigned)); + } + assert!(!verify(&missing)); + assert!(!verify_startup_or_unsigned(&missing, false)); + } + #[test] fn catalog_presence_and_lookup_errors_never_enable_unsigned_dev() { assert!(no_catalog_membership([CatalogLookup::Absent; 2])); diff --git a/apps/maple-agent/docs/desktop-builds.md b/apps/maple-agent/docs/desktop-builds.md index 32c416a81..ee6a1863d 100644 --- a/apps/maple-agent/docs/desktop-builds.md +++ b/apps/maple-agent/docs/desktop-builds.md @@ -112,6 +112,13 @@ Closing a successfully completed interactive installer opens Maple. Silent the profile's Maple icon. Normal GUI startup also refreshes the icon for this profile's existing Start menu shortcuts and installed-app entry, including upgrades from 0.0.12, while preserving the stable launcher and shortcut targets. +An installation refusal identifies the installer phase and available native +error code, including the full WinTrust status for certificate failures and the +Win32 code for operating-system failures. Known errors include a short +explanation; include the complete message when reporting a failed install. +Silent installs retain nonzero failure statuses without opening an error +dialog. Existing signature and storage checks still apply; administrator mode +is not a bypass. macOS packages contain a DMG and application archive; drag the chosen Agent app into Applications. Both Agent profiles can coexist with Maple Research. Linux packages contain diff --git a/apps/maple-agent/scripts/test_windows_release.py b/apps/maple-agent/scripts/test_windows_release.py index 5f2a5c71c..ac428c309 100644 --- a/apps/maple-agent/scripts/test_windows_release.py +++ b/apps/maple-agent/scripts/test_windows_release.py @@ -550,22 +550,31 @@ def test_nsis_helper_launch_errors_cannot_reuse_a_previous_success_exit_code(sel ) with self.subTest(command=command): # NSIS leaves the output variable undefined on launch error. - # Test its error flag before ever reading a stale zero in $0. + # Test its error flag before ever reading a stale child status. self.assertEqual(instructions[index - 1], 'ClearErrors') self.assertEqual(instructions[index + 1], 'IfErrors ' + destination) child = {'refused': (22, 24), 'failed': (41,), 'refuse': (52,)}[destination] - self.assertIn(instructions[index + 2], ['StrCpy $FailureCode ' + str(code) for code in child]) - self.assertEqual(instructions[index + 3], 'StrCmp $0 0 0 ' + destination) + following = instructions[index + 2:] + compare = next(index for index, line in enumerate(following) if line.startswith('StrCmp ')) + metadata = following[:compare] + self.assertTrue(all(line.startswith('StrCpy ') for line in metadata)) + output = command.rsplit(' ', 1)[1] + self.assertNotIn(output, [line.split(maxsplit=2)[1] for line in metadata]) + self.assertEqual([line for line in metadata if line.startswith('StrCpy $FailureCode ')], + [next(line for line in metadata if line in ['StrCpy $FailureCode ' + str(code) for code in child])]) + self.assertEqual(following[compare], 'StrCmp ' + output + ' 0 0 ' + destination) def test_nsis_exit_codes_identify_probe_phase_and_launch_vs_child_failures(self): - source = (SCRIPTS / "windows-installer.nsi").read_text() + source = "\n".join(line for line in (SCRIPTS / "windows-installer.nsi").read_text().splitlines() + if not line.lstrip().startswith(';')) codes = {int(value) for value in re.findall(r'StrCpy \$FailureCode ([0-9]+)', source)} self.assertEqual(codes, set(release.NSIS_FAILURE_PHASES)) for flag, launch, child in (("installer-preflight", 21, 22), ("installer-prepare", 23, 24), ("installer-activate", 40, 41), ("uninstall-cleanup", 51, 52)): with self.subTest(flag=flag): - pattern = (r'StrCpy \$FailureCode ' + str(launch) + r'\s+ClearErrors\s+ExecWait [^\n]*--internal-windows-' + flag - + r'[^\n]*\n\s+IfErrors \w+\s+StrCpy \$FailureCode ' + str(child)) + metadata = r'(?:\s+StrCpy (?!\$FailureCode)[^\n]+)*' + pattern = (r'StrCpy \$FailureCode ' + str(launch) + metadata + r'\s+ClearErrors\s+ExecWait [^\n]*--internal-windows-' + flag + + r'[^\n]*\n\s+IfErrors \w+' + metadata + r'\s+StrCpy \$FailureCode ' + str(child)) self.assertRegex(source, pattern) self.assertRegex(source, r'ClearErrors\s+GetDLLVersion [^\n]+\n\s+IfErrors unsupported') self.assertIn('SetErrorLevel 0', source) @@ -1282,7 +1291,7 @@ def test_nsis_actual_numeric_branch_passes_only_known_activation_failures(self): instructions = [line.strip() for line in branch.splitlines() if line.strip() and not line.lstrip().startswith(";")] def propagated(phase, code): - registers = {"$FailureCode": str(phase), "$0": str(code)} + registers = {"$FailureCode": str(phase), "$NativeStatus": str(code)} index = 0 def value(token): return registers.get(token, token) diff --git a/apps/maple-agent/scripts/windows-installer.nsi b/apps/maple-agent/scripts/windows-installer.nsi index 77e88879a..ce39dd954 100644 --- a/apps/maple-agent/scripts/windows-installer.nsi +++ b/apps/maple-agent/scripts/windows-installer.nsi @@ -37,6 +37,12 @@ UninstPage uninstConfirm UninstPage instfiles Var FailureCode +Var FailureStage +Var NativeStatus +Var NativeStatusAvailable +Var NativeActivationStage +Var NativeWin32Error +Var FailureAdvice Function .onInit SetShellVarContext current @@ -73,11 +79,15 @@ Var PreviousDisplayVersion Var HadRegistration Section "Install" + StrCpy $NativeStatus 0 + StrCpy $NativeStatusAvailable 0 + StrCpy $NativeActivationStage 0 ; The temporary copy is the same verified signed application PE. Its private ; early command performs no GUI, account, store or backend initialization. ; All destination/DACL/reparse checks happen before any installed file or ; registry changes. Unsigned Prod previews deliberately stop here. StrCpy $FailureCode 20 + StrCpy $FailureStage "extracting the installation checker" SetErrorLevel 20 ClearErrors InitPluginsDir @@ -85,23 +95,33 @@ Section "Install" File /oname=maple-installer-check.exe "${PAYLOAD}\maple-agent.exe" IfErrors refused StrCpy $FailureCode 21 + StrCpy $FailureStage "starting installation checks" + StrCpy $NativeStatusAvailable 0 ClearErrors - ExecWait '"$PLUGINSDIR\maple-installer-check.exe" --internal-windows-installer-preflight "$INSTDIR"' $0 + ExecWait '"$PLUGINSDIR\maple-installer-check.exe" --internal-windows-installer-preflight "$INSTDIR"' $NativeStatus IfErrors refused + StrCpy $NativeStatusAvailable 1 StrCpy $FailureCode 22 - StrCmp $0 0 0 refused + StrCpy $FailureStage "checking the installation" + StrCmp $NativeStatus 0 0 refused ; The signed helper durably records the manual operation, creates a private ; root, bootstraps only a fresh install and repairs incomplete non-current ; generation files. A retry must never overwrite a live or valid rollback ; generation, nor remain stuck with an interrupted NSIS file copy. StrCpy $FailureCode 23 + StrCpy $FailureStage "starting installation preparation" + StrCpy $NativeStatusAvailable 0 ClearErrors - ExecWait '"$PLUGINSDIR\maple-installer-check.exe" --internal-windows-installer-prepare "$INSTDIR" ${GENERATION}' $0 + ExecWait '"$PLUGINSDIR\maple-installer-check.exe" --internal-windows-installer-prepare "$INSTDIR" ${GENERATION}' $NativeStatus IfErrors refused + StrCpy $NativeStatusAvailable 1 StrCpy $FailureCode 24 - StrCmp $0 0 0 refused + StrCpy $FailureStage "preparing the installation" + StrCmp $NativeStatus 0 0 refused StrCpy $FailureCode 31 + StrCpy $FailureStage "checking installation registration" + StrCpy $NativeStatusAvailable 0 StrCpy $HadRegistration 0 ClearErrors ReadRegStr $PreviousInstallLocation HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${BUNDLE_ID}" "InstallLocation" @@ -113,6 +133,7 @@ unregistered: ; Never overwrite the stable bootstrap or an existing generation. All new ; generation bytes arrive before the native helper commits current.json. StrCpy $FailureCode 30 + StrCpy $FailureStage "copying application files" SetOutPath "$INSTDIR" IfFileExists "$INSTDIR\maple-agent.exe" bootstrap_ready ClearErrors @@ -135,6 +156,7 @@ bootstrap_ready: IfErrors failed uninstaller_ready: StrCpy $FailureCode 31 + StrCpy $FailureStage "registering the application" CreateDirectory "$SMPROGRAMS\${DISPLAY_NAME}" CreateShortcut "$SMPROGRAMS\${DISPLAY_NAME}\${DISPLAY_NAME}.lnk" "$INSTDIR\maple-agent.exe" CreateShortcut "$SMPROGRAMS\${DISPLAY_NAME}\Uninstall.lnk" "$INSTDIR\uninstall.exe" @@ -149,11 +171,15 @@ uninstaller_ready: WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${BUNDLE_ID}" "NoRepair" 1 IfErrors failed StrCpy $FailureCode 40 + StrCpy $FailureStage "starting installation activation" + StrCpy $NativeStatusAvailable 0 ClearErrors - ExecWait '"$INSTDIR\maple-agent.exe" --internal-windows-installer-activate ${GENERATION}' $0 + ExecWait '"$INSTDIR\maple-agent.exe" --internal-windows-installer-activate ${GENERATION}' $NativeStatus IfErrors failed + StrCpy $NativeStatusAvailable 1 StrCpy $FailureCode 41 - StrCmp $0 0 0 failed + StrCpy $FailureStage "activating the installation" + StrCmp $NativeStatus 0 0 failed SetErrorLevel 0 Goto done failed: @@ -161,13 +187,15 @@ failed: ; ordinary failures at 41; only the fixed marker and stages 1..10 can carry ; an activation stage plus its bounded Win32 error to an unattended caller. StrCmp $FailureCode 41 0 activation_diagnostic_done - IntOp $1 $0 & 0xFF000000 + IntOp $1 $NativeStatus & 0xFF000000 IntCmp $1 0x4D000000 0 activation_diagnostic_done activation_diagnostic_done - IntOp $1 $0 >> 16 + IntOp $1 $NativeStatus >> 16 IntOp $1 $1 & 0xFF IntCmp $1 1 0 activation_diagnostic_done 0 IntCmp $1 10 0 0 activation_diagnostic_done - StrCpy $FailureCode $0 + StrCpy $NativeActivationStage $1 + IntOp $NativeWin32Error $NativeStatus & 0xFFFF + StrCpy $FailureCode $NativeStatus activation_diagnostic_done: ; The native activation failure leaves the old pointer unchanged. Restore ; registration truth; user data and an earlier generation remain untouched. @@ -178,12 +206,82 @@ restore_registration: WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${BUNDLE_ID}" "InstallLocation" "$PreviousInstallLocation" WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${BUNDLE_ID}" "DisplayVersion" "$PreviousDisplayVersion" refused: - MessageBox MB_OK|MB_ICONSTOP "Installation could not be completed safely. Close the application and retry from a verified installer." /SD IDOK + StrCpy $1 "Installation stopped while $FailureStage.$\r$\n$\r$\nInstaller stage: $FailureCode" + StrCmp $NativeStatusAvailable 1 0 show_error + IntFmt $2 "0x%08X" $NativeStatus + StrCpy $1 "$1$\r$\nWindows process exit code: $2" + StrCmp $NativeActivationStage 0 describe_error + StrCpy $1 "$1$\r$\nNative activation stage: $NativeActivationStage" + StrCmp $NativeWin32Error 0 describe_error + StrCpy $1 "$1$\r$\nWindows error: $NativeWin32Error" +describe_error: + Call DescribeInstallerError + StrCpy $1 "$1$\r$\n$\r$\n$FailureAdvice" +show_error: + MessageBox MB_OK|MB_ICONSTOP "$1$\r$\n$\r$\nPlease include these details when contacting Maple support." /SD IDOK SetErrorLevel $FailureCode Abort done: SectionEnd +Function DescribeInstallerError + StrCpy $FailureAdvice "Windows could not complete this installation." + StrCpy $2 $NativeStatus + StrCmp $NativeActivationStage 0 classify + StrCpy $2 $NativeWin32Error +classify: + IntCmp $2 0x800B0109 untrusted + IntCmp $2 0x800B010A untrusted + IntCmp $2 0x80092013 revocation + IntCmp $2 0x80092012 revocation + IntCmp $2 0x800B0101 certificate_date + IntCmp $2 0x80096010 signature + IntCmp $2 0x800B0100 signature + IntCmp $2 0x4E000001 signature + IntCmp $2 0x4E000002 busy + IntCmp $2 0x4E000003 validation + IntCmp $2 0x4E000004 manual + IntCmp $2 0x4E000005 storage + IntCmp $2 5 access + IntCmp $2 32 locked + IntCmp $2 33 locked + IntCmp $2 0xC0000135 component + Goto done +untrusted: + StrCpy $FailureAdvice "Windows could not establish trust in the signing certificate. Check Windows Update and internet access, then retry." + Goto done +revocation: + StrCpy $FailureAdvice "Windows could not check the signing certificate's revocation status. Check your internet connection, then retry." + Goto done +certificate_date: + StrCpy $FailureAdvice "Windows reported a certificate validity problem. Check the computer's date and time and Windows Update." + Goto done +signature: + StrCpy $FailureAdvice "The application signature or identity could not be verified. Download a new installer from Maple's official download page." + Goto done +busy: + StrCpy $FailureAdvice "Another Maple installation or update is active. Let it finish before retrying." + Goto done +validation: + StrCpy $FailureAdvice "The installation folder or application files did not pass validation. For a first installation, choose a new empty folder. Otherwise contact Maple support." + Goto done +manual: + StrCpy $FailureAdvice "Maple could not safely replace this installation. Contact Maple support with these details." + Goto done +storage: + StrCpy $FailureAdvice "Windows could not read or write installation files. Contact Maple support with these details." + Goto done +access: + StrCpy $FailureAdvice "Windows denied access to the installation folder. Choose a folder owned by your user account, or contact Maple support." + Goto done +locked: + StrCpy $FailureAdvice "An installation file is in use. Close ${DISPLAY_NAME} and its background host, then retry." + Goto done +component: + StrCpy $FailureAdvice "Windows could not load a required system component. Check Windows Update and contact Maple support with this code." +done: +FunctionEnd + Function .onInstSuccess ; The success callback runs when the completed installer closes. Silent ; updates/reinstalls stay quiet, and failed or cancelled installs never call