From 506dfbd66981564d5f6f087bc7c88b0849ca3dd4 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Mon, 27 Apr 2026 22:06:57 -0500 Subject: [PATCH] working install and uninstall flow in Vagrant --- .cargo/config.toml | 2 + .gitignore | 2 + Cargo.toml | 1 - README.md | 18 +- build.rs | 47 ++ scripts/run-windows-vm-smoke.ps1 | 108 ++++- scripts/windows-vm/Abort-SmokeDiagnostics.ps1 | 115 +++++ .../windows-vm/Approve-InstallerDialogs.ps1 | 26 - .../Invoke-InteractiveInstaller.ps1 | 150 +++++- .../Start-InteractiveSelfInstall.ps1 | 444 ++++++++++++++--- src/main.rs | 424 +++++++++-------- src/ui.rs | 344 ++++++++++++++ src/win.rs | 98 +--- ui/Covenant.Setup.Ui/Covenant.Setup.Ui.csproj | 12 + ui/Covenant.Setup.Ui/Program.cs | 445 ++++++++++++++++++ ui/Covenant.Setup.Ui/app.manifest | 11 + 16 files changed, 1857 insertions(+), 390 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 scripts/windows-vm/Abort-SmokeDiagnostics.ps1 delete mode 100644 scripts/windows-vm/Approve-InstallerDialogs.ps1 create mode 100644 src/ui.rs create mode 100644 ui/Covenant.Setup.Ui/Covenant.Setup.Ui.csproj create mode 100644 ui/Covenant.Setup.Ui/Program.cs create mode 100644 ui/Covenant.Setup.Ui/app.manifest diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..ac2b23f --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/.gitignore b/.gitignore index ab1763f..c4f2643 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ /target /.vagrant/ /vm/self-test/payload/ +**/bin/ +**/obj/ diff --git a/Cargo.toml b/Cargo.toml index 85cfe5f..8f2aeb3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,6 @@ windows = { version = "0.62.2", features = [ "Win32_System_RestartManager", "Win32_System_Registry", "Win32_System_Threading", - "Win32_UI_Controls", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", ] } diff --git a/README.md b/README.md index f9896e5..7722bd1 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ Its current shape is: - Uninstalls in reverse order and purges declared registry/path namespaces - Registers the installed app in Windows Installed Apps / Add-Remove Programs - Creates an installed uninstaller executable in the app root +- Uses a C# WinForms presentation process for GUI progress and prompts +- Sends GUI state over named-pipe IPC from the Rust engine to the C# UI - Uses Win32 APIs through the `windows` crate with unsafe isolated in [`src/win.rs`](C:\Users\jasonross\workspace\covenant-setup\src\win.rs) - Logs every unsafe boundary transition @@ -44,6 +46,8 @@ Current output: That installer is a single executable. The manifest and payload files are embedded into the binary and extracted to a temporary working directory at runtime. +The Rust build publishes a self-contained C# WinForms UI helper and embeds it into the Rust executable. Building the installer therefore requires the .NET SDK in addition to Rust/Cargo, but the packaged installer does not require a .NET runtime to be preinstalled on the target machine. + ## Install and Uninstall Model Direct engine commands: @@ -87,13 +91,15 @@ Current automatic behavior: Current GUI behavior includes: -- native message-box prompts for confirmation and completion +- C# WinForms prompts for confirmation and completion - a progress window with: - progress bar - current operation text - scrolling operations log - reboot prompt when uninstall requires reboot to finish some cleanup +The Rust install engine owns all business logic and file/registry mutations. The C# process is presentation-only and receives JSON messages over a Windows named pipe. + ### TUI Current TUI behavior includes: @@ -124,11 +130,11 @@ The sample manifest lives at [`examples/install.toml`](C:\Users\jasonross\worksp ## Current Limitations -- The GUI layer is currently implemented through a PowerShell-hosted WinForms progress window rather than a native Rust GUI framework +- The GUI helper is embedded as a self-contained C# WinForms executable, which makes the installer binary substantially larger - The installer is not yet generating branded/custom themed installer screens - The manifest schema is still MVP-level and does not cover all production installer concerns - Script execution logs the script invocation; internal script mutations are not observed beyond declared purge coverage -- The packager currently embeds payload as JSON-appended data; this is functional but not yet optimized for large payloads or tamper-resistance +- The packager currently embeds payload in an appended raw bundle; this is functional but not yet compressed, signed, or tamper-resistant - No signing, MSI generation, compression, delta updates, or patching pipeline exists yet - No automated test suite has been added yet for end-to-end installer scenarios @@ -139,6 +145,7 @@ The codebase currently builds and formats successfully with: ```powershell cargo fmt cargo check +cargo build --release ``` Interactive GUI/TUI flows now have a Windows VM smoke harness for packaged installer behavior, while broader automated coverage is still limited. @@ -155,6 +162,8 @@ The self-install manifest used for this path lives at [`vm/self-test/install.tom - `HKCU\Software\CovenantSetupSelfTest\InstallRoot` - `Desktop\Covenant Setup Self Test.lnk` +It then immediately invokes the installed uninstaller with the generated journal and verifies that the install root, payload, journal, uninstaller, shortcut, application registry key, and Installed Apps registration are removed. + Run the smoke test from the repo root: ```powershell @@ -169,7 +178,8 @@ Notes: - The harness opens `vmconnect.exe` after `vagrant up` so the guest desktop stays visible during the install. - The default Vagrant synced folder is disabled to avoid SMB credential prompts; the harness uploads the installer and guest scripts over WinRM instead. - The guest install is launched through an interactive scheduled task because WinRM sessions are not desktop-visible. -- The packaged installer now has a hidden automation mode that suppresses blocking GUI message boxes while leaving the progress window visible for the VM smoke test. +- The packaged installer now has a hidden automation mode that suppresses blocking GUI prompts while leaving the C# progress window visible for the VM smoke test. +- The guest scripts write a trace bundle under `dist\vagrant-self-test\trace` after each smoke run. It includes `guest-events.jsonl`, scheduler/process/event snapshots, Rust heartbeat files named `installer-heartbeat-*.jsonl`, and C# UI pipe logs named `csharp-ui-pipe-*.jsonl`. - The Windows box should auto-log the `vagrant` user into the desktop session for the visual install path to appear. - Set `COVENANT_HYPERV_SWITCH` to the Hyper-V virtual switch name you want Vagrant to use. - The harness writes its verification artifact to `dist\vagrant-self-test\guest-result.json`. diff --git a/build.rs b/build.rs index f0d8063..62cafd4 100644 --- a/build.rs +++ b/build.rs @@ -1,6 +1,53 @@ use embed_manifest::embed_manifest; +use std::env; +use std::path::PathBuf; +use std::process::Command; fn main() { + publish_csharp_ui(); embed_manifest(embed_manifest::new_manifest("Comctl32")) .expect("unable to embed application manifest"); } + +fn publish_csharp_ui() { + println!("cargo:rerun-if-changed=ui/Covenant.Setup.Ui/Covenant.Setup.Ui.csproj"); + println!("cargo:rerun-if-changed=ui/Covenant.Setup.Ui/Program.cs"); + println!("cargo:rerun-if-changed=ui/Covenant.Setup.Ui/app.manifest"); + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + let out_dir = PathBuf::from(env::var_os("OUT_DIR").unwrap()); + let project = manifest_dir.join("ui/Covenant.Setup.Ui/Covenant.Setup.Ui.csproj"); + let publish_dir = out_dir.join("csharp-ui"); + let dotnet_home = out_dir.join("dotnet-home"); + let status = Command::new("dotnet") + .env("DOTNET_CLI_HOME", &dotnet_home) + .env("DOTNET_SKIP_FIRST_TIME_EXPERIENCE", "1") + .env("DOTNET_CLI_TELEMETRY_OPTOUT", "1") + .arg("publish") + .arg(&project) + .arg("--nologo") + .arg("--configuration") + .arg("Release") + .arg("--runtime") + .arg("win-x64") + .arg("--self-contained") + .arg("true") + .arg("-p:PublishSingleFile=true") + .arg("-p:IncludeNativeLibrariesForSelfExtract=true") + .arg("-p:PublishTrimmed=false") + .arg("-p:DebugType=none") + .arg("-p:DebugSymbols=false") + .arg("-o") + .arg(&publish_dir) + .status() + .expect("failed to launch dotnet publish for C# UI"); + if !status.success() { + panic!("dotnet publish failed for C# UI with {status}"); + } + + let ui_exe = publish_dir.join("Covenant.Setup.Ui.exe"); + if !ui_exe.exists() { + panic!("C# UI publish did not produce {}", ui_exe.display()); + } + println!("cargo:rustc-env=COVENANT_SETUP_UI_EXE={}", ui_exe.display()); +} diff --git a/scripts/run-windows-vm-smoke.ps1 b/scripts/run-windows-vm-smoke.ps1 index f0fcd1e..9f6c7a5 100644 --- a/scripts/run-windows-vm-smoke.ps1 +++ b/scripts/run-windows-vm-smoke.ps1 @@ -65,6 +65,59 @@ function Invoke-Vagrant { Invoke-Tool -FilePath "vagrant" -Arguments $Arguments } +function Save-GuestTraceBundle { + param( + [Parameter(Mandatory)][string]$GuestTracePath, + [Parameter(Mandatory)][string]$GuestZipPath, + [Parameter(Mandatory)][string]$LocalTracePath + ) + + try { + New-Item -ItemType Directory -Force -Path $LocalTracePath | Out-Null + $guestTraceLiteral = Convert-ToSingleQuotedPowerShellLiteral -Value $GuestTracePath + $guestZipLiteral = Convert-ToSingleQuotedPowerShellLiteral -Value $GuestZipPath + $command = @( + "`$tracePath = $guestTraceLiteral" + "`$zipPath = $guestZipLiteral" + "if (-not (Test-Path -LiteralPath `$tracePath)) { Write-Output '__COVENANT_TRACE_EMPTY__'; exit 0 }" + "New-Item -ItemType File -Force -Path (Join-Path `$tracePath '.keep') | Out-Null" + "Remove-Item -LiteralPath `$zipPath -Force -ErrorAction SilentlyContinue" + "Compress-Archive -Path (Join-Path `$tracePath '*') -DestinationPath `$zipPath -Force" + "Write-Output '__COVENANT_TRACE_B64_START__'" + "[Convert]::ToBase64String([IO.File]::ReadAllBytes(`$zipPath))" + "Write-Output '__COVENANT_TRACE_B64_END__'" + ) -join "; " + + $output = Invoke-Vagrant -Arguments @("winrm", "-s", "powershell", "-c", $command) + $lines = @($output | ForEach-Object { $_.ToString().Trim() }) + if ($lines -contains "__COVENANT_TRACE_EMPTY__") { + Write-Warning "Guest trace path did not exist: $GuestTracePath" + return + } + + $start = [Array]::IndexOf($lines, "__COVENANT_TRACE_B64_START__") + $end = [Array]::IndexOf($lines, "__COVENANT_TRACE_B64_END__") + if ($start -lt 0 -or $end -le $start) { + Write-Warning "Guest trace bundle markers were not found in WinRM output." + return + } + + $base64 = (($lines[($start + 1)..($end - 1)]) -join "").Trim() + if (-not $base64) { + Write-Warning "Guest trace bundle was empty." + return + } + + $zipPath = Join-Path $LocalTracePath "guest-trace.zip" + [IO.File]::WriteAllBytes($zipPath, [Convert]::FromBase64String($base64)) + Expand-Archive -LiteralPath $zipPath -DestinationPath $LocalTracePath -Force + Write-Host "Trace bundle: $LocalTracePath" + } + catch { + Write-Warning "Failed to collect guest trace bundle: $($_.Exception.Message)" + } +} + function Open-HyperVViewer { param([Parameter(Mandatory)][string]$VmName) @@ -91,6 +144,38 @@ function Invoke-Process { } } +function Assert-PackagedInstallerBundle { + param([Parameter(Mandatory)][string]$InstallerPath) + + $magic = [Text.Encoding]::ASCII.GetBytes("COVENANT_SETUP_BUNDLE_V1") + $stream = [IO.File]::Open( + $InstallerPath, + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]::ReadWrite) + try { + if ($stream.Length -lt $magic.Length) { + throw "Packaged installer is too small to contain the embedded bundle marker: $InstallerPath" + } + + $null = $stream.Seek(-1 * $magic.Length, [IO.SeekOrigin]::End) + $actual = [byte[]]::new($magic.Length) + $read = $stream.Read($actual, 0, $actual.Length) + if ($read -ne $magic.Length) { + throw "Could not read embedded bundle marker from packaged installer: $InstallerPath" + } + + for ($i = 0; $i -lt $magic.Length; $i++) { + if ($actual[$i] -ne $magic[$i]) { + throw "Packaged installer is missing the embedded bundle marker: $InstallerPath" + } + } + } + finally { + $stream.Dispose() + } +} + $repoRoot = Split-Path -Parent $PSScriptRoot $releaseExe = Join-Path $repoRoot "target\release\covenant-setup.exe" $payloadRoot = Join-Path $repoRoot "vm\self-test\payload" @@ -99,18 +184,23 @@ $manifestPathAbs = Resolve-RepoPath -RepoRoot $repoRoot -Path $ManifestPath $outputRootAbs = Resolve-RepoPath -RepoRoot $repoRoot -Path $OutputRoot $installerPath = Join-Path $outputRootAbs "covenant-setup-installer.exe" $resultPath = Join-Path $outputRootAbs "guest-result.json" +$traceRootAbs = Join-Path $outputRootAbs "trace" $guestRoot = "C:\Users\vagrant\AppData\Local\Temp\covenant-setup-smoke" $guestInstallerPath = Join-Path $guestRoot "covenant-setup-installer.exe" $guestResultPath = Join-Path $guestRoot "guest-result.json" +$guestTraceRoot = Join-Path $guestRoot "trace" +$guestTraceZipPath = Join-Path $guestRoot "trace.zip" $guestScriptRoot = Join-Path $guestRoot "scripts" -$guestCommand = "& '$guestScriptRoot\Start-InteractiveSelfInstall.ps1' -InstallerPath '$guestInstallerPath' -ResultPath '$guestResultPath' -ScriptRoot '$guestScriptRoot'" +$guestCommand = "& '$guestScriptRoot\Start-InteractiveSelfInstall.ps1' -InstallerPath '$guestInstallerPath' -ResultPath '$guestResultPath' -ScriptRoot '$guestScriptRoot' -TracePath '$guestTraceRoot'" Assert-Command -Name "cargo" +Assert-Command -Name "dotnet" Assert-Command -Name "vagrant" New-Item -ItemType Directory -Force -Path $payloadRoot | Out-Null New-Item -ItemType Directory -Force -Path $outputRootAbs | Out-Null Remove-Item -LiteralPath $resultPath -Force -ErrorAction SilentlyContinue +Remove-Item -LiteralPath $traceRootAbs -Recurse -Force -ErrorAction SilentlyContinue try { if (-not $SkipBuild) { @@ -131,6 +221,7 @@ try { if (-not (Test-Path -LiteralPath $installerPath)) { throw "Packaged installer not found at $installerPath" } + Assert-PackagedInstallerBundle -InstallerPath $installerPath if (-not $SkipVmBoot) { Invoke-Vagrant -Arguments @("up", "--provider", $Provider) @@ -157,8 +248,15 @@ try { Invoke-Vagrant -Arguments @("winrm", "-s", "powershell", "-c", $waitForShellCommand) Invoke-Vagrant -Arguments @("winrm", "-s", "powershell", "-c", "New-Item -ItemType Directory -Force -Path '$guestRoot' | Out-Null; New-Item -ItemType Directory -Force -Path '$guestScriptRoot' | Out-Null") + $clearGuestTraceCommand = @( + "try {" + "if (Test-Path -LiteralPath '$guestTraceRoot') { Remove-Item -LiteralPath '$guestTraceRoot' -Recurse -Force -ErrorAction SilentlyContinue }" + "if (Test-Path -LiteralPath '$guestTraceZipPath') { Remove-Item -LiteralPath '$guestTraceZipPath' -Force -ErrorAction SilentlyContinue }" + "} catch { Write-Warning `$_.Exception.Message }" + "exit 0" + ) -join "; " + Invoke-Vagrant -Arguments @("winrm", "-s", "powershell", "-c", $clearGuestTraceCommand) Invoke-Vagrant -Arguments @("upload", $installerPath, $guestInstallerPath) - Invoke-Vagrant -Arguments @("upload", (Join-Path $repoRoot "scripts\windows-vm\Approve-InstallerDialogs.ps1"), (Join-Path $guestScriptRoot "Approve-InstallerDialogs.ps1")) Invoke-Vagrant -Arguments @("upload", (Join-Path $repoRoot "scripts\windows-vm\Invoke-InteractiveInstaller.ps1"), (Join-Path $guestScriptRoot "Invoke-InteractiveInstaller.ps1")) Invoke-Vagrant -Arguments @("upload", (Join-Path $repoRoot "scripts\windows-vm\Start-InteractiveSelfInstall.ps1"), (Join-Path $guestScriptRoot "Start-InteractiveSelfInstall.ps1")) @@ -180,9 +278,13 @@ try { Write-Host "Smoke test passed." Write-Host "Installer: $installerPath" Write-Host "Result JSON: $resultPath" - Write-Host "InstallRoot: $($result.installRoot)" + Write-Host "Trace: $traceRootAbs" + Write-Host "InstallRoot: $($result.installRoot) (installed, then removed)" + Write-Host "Uninstall: exit $($result.uninstallExitCode), verified=$($result.uninstallVerified)" } finally { + Save-GuestTraceBundle -GuestTracePath $guestTraceRoot -GuestZipPath $guestTraceZipPath -LocalTracePath $traceRootAbs + if ($DestroyAfter) { try { Invoke-Vagrant -Arguments @("destroy", "-f") diff --git a/scripts/windows-vm/Abort-SmokeDiagnostics.ps1 b/scripts/windows-vm/Abort-SmokeDiagnostics.ps1 new file mode 100644 index 0000000..8085d31 --- /dev/null +++ b/scripts/windows-vm/Abort-SmokeDiagnostics.ps1 @@ -0,0 +1,115 @@ +param( + [string]$TracePath = "C:\Users\vagrant\AppData\Local\Temp\covenant-setup-smoke\trace", + [string]$ZipPath = "C:\Users\vagrant\AppData\Local\Temp\covenant-setup-smoke\trace-abort.zip", + [string]$TaskNamePrefix = "CovenantSetupSelfInstall" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Continue" + +function Write-TraceEvent { + param( + [Parameter(Mandatory)][string]$Phase, + [object]$Detail = $null + ) + + try { + New-Item -ItemType Directory -Force -Path $TracePath | Out-Null + $event = [ordered]@{ + time = (Get-Date).ToUniversalTime().ToString("o") + pid = $PID + script = Split-Path -Leaf $PSCommandPath + phase = $Phase + detail = $Detail + } + $event | ConvertTo-Json -Depth 12 -Compress | Add-Content -LiteralPath (Join-Path $TracePath "guest-events.jsonl") -Encoding UTF8 + } + catch { + Write-Warning "Failed to write trace event '$Phase': $($_.Exception.Message)" + } +} + +function Write-DiagnosticFile { + param( + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][scriptblock]$Capture + ) + + $path = Join-Path $TracePath $Name + Write-TraceEvent -Phase "abort_diagnostic_file_start" -Detail @{ name = $Name } + try { + $value = & $Capture + $value | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $path -Encoding UTF8 + Write-TraceEvent -Phase "abort_diagnostic_file_finish" -Detail @{ name = $Name } + } + catch { + [ordered]@{ + error = $_.Exception.Message + type = $_.Exception.GetType().FullName + } | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $path -Encoding UTF8 + Write-TraceEvent -Phase "abort_diagnostic_file_error" -Detail @{ + name = $Name + error = $_.Exception.Message + type = $_.Exception.GetType().FullName + } + } +} + +New-Item -ItemType Directory -Force -Path $TracePath | Out-Null +Write-TraceEvent -Phase "abort_requested" + +Write-DiagnosticFile -Name "abort-processes.json" -Capture { + Get-CimInstance Win32_Process | + Where-Object { + $_.Name -match "covenant|Covenant|powershell|pwsh" -or + $_.CommandLine -match "CovenantSetup|Invoke-InteractiveInstaller|Start-InteractiveSelfInstall" + } | + Select-Object ProcessId, ParentProcessId, Name, CommandLine, CreationDate +} +Write-DiagnosticFile -Name "abort-windows.json" -Capture { + Get-Process | + Where-Object { $_.MainWindowHandle -ne 0 -or $_.ProcessName -match "covenant|Covenant|powershell|pwsh" } | + Select-Object Id, ProcessName, MainWindowTitle, MainWindowHandle, StartTime +} +Write-DiagnosticFile -Name "abort-scheduled-tasks.json" -Capture { + Get-ScheduledTask -TaskName "$TaskNamePrefix*" -ErrorAction SilentlyContinue | + Select-Object TaskName, State, TaskPath, Actions, Triggers +} +Write-DiagnosticFile -Name "abort-scheduled-task-info.json" -Capture { + Get-ScheduledTask -TaskName "$TaskNamePrefix*" -ErrorAction SilentlyContinue | + ForEach-Object { Get-ScheduledTaskInfo -TaskName $_.TaskName -ErrorAction SilentlyContinue } | + Select-Object TaskName, LastRunTime, LastTaskResult, NextRunTime, NumberOfMissedRuns +} +Write-DiagnosticFile -Name "abort-application-events.json" -Capture { + Get-WinEvent -FilterHashtable @{ LogName = "Application"; StartTime = (Get-Date).AddHours(-2) } -MaxEvents 200 -ErrorAction SilentlyContinue | + Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message +} +Write-DiagnosticFile -Name "abort-system-events.json" -Capture { + Get-WinEvent -FilterHashtable @{ LogName = "System"; StartTime = (Get-Date).AddHours(-2) } -MaxEvents 200 -ErrorAction SilentlyContinue | + Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message +} + +Get-Process -Name "covenant-setup-installer", "covenant-setup", "covenant-setup-uninstall", "Covenant.Setup.Ui" -ErrorAction SilentlyContinue | + Stop-Process -Force -ErrorAction SilentlyContinue + +Get-ScheduledTask -TaskName "$TaskNamePrefix*" -ErrorAction SilentlyContinue | + Stop-ScheduledTask -ErrorAction SilentlyContinue +Get-ScheduledTask -TaskName "$TaskNamePrefix*" -ErrorAction SilentlyContinue | + Unregister-ScheduledTask -Confirm:$false -ErrorAction SilentlyContinue + +$scriptProcesses = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + $_.ProcessId -ne $PID -and + $_.CommandLine -match "Invoke-InteractiveInstaller|Start-InteractiveSelfInstall" + } +foreach ($process in $scriptProcesses) { + Stop-Process -Id $process.ProcessId -Force -ErrorAction SilentlyContinue +} + +Write-TraceEvent -Phase "abort_cleanup_complete" + +Remove-Item -LiteralPath $ZipPath -Force -ErrorAction SilentlyContinue +Compress-Archive -Path (Join-Path $TracePath "*") -DestinationPath $ZipPath -Force +Write-Output "__COVENANT_TRACE_B64_START__" +[Convert]::ToBase64String([IO.File]::ReadAllBytes($ZipPath)) +Write-Output "__COVENANT_TRACE_B64_END__" diff --git a/scripts/windows-vm/Approve-InstallerDialogs.ps1 b/scripts/windows-vm/Approve-InstallerDialogs.ps1 deleted file mode 100644 index b7199ce..0000000 --- a/scripts/windows-vm/Approve-InstallerDialogs.ps1 +++ /dev/null @@ -1,26 +0,0 @@ -[CmdletBinding()] -param( - [Parameter(Mandatory)][int]$InstallerProcessId, - [string]$WindowTitle = "covenant-setup", - [int]$PollMilliseconds = 500 -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = "Stop" - -Add-Type -AssemblyName Microsoft.VisualBasic -Add-Type -AssemblyName System.Windows.Forms - -while ($true) { - $process = Get-Process -Id $InstallerProcessId -ErrorAction SilentlyContinue - if (-not $process) { - break - } - - if ([Microsoft.VisualBasic.Interaction]::AppActivate($WindowTitle)) { - Start-Sleep -Milliseconds 200 - [System.Windows.Forms.SendKeys]::SendWait("{ENTER}") - } - - Start-Sleep -Milliseconds $PollMilliseconds -} diff --git a/scripts/windows-vm/Invoke-InteractiveInstaller.ps1 b/scripts/windows-vm/Invoke-InteractiveInstaller.ps1 index a111538..bad0065 100644 --- a/scripts/windows-vm/Invoke-InteractiveInstaller.ps1 +++ b/scripts/windows-vm/Invoke-InteractiveInstaller.ps1 @@ -2,16 +2,120 @@ param( [Parameter(Mandatory)][string]$InstallerPath, [Parameter(Mandatory)][string]$ResultPath, - [int]$TimeoutSeconds = 600 + [int]$TimeoutSeconds = 600, + [string]$TracePath = $(Join-Path (Split-Path -Parent $ResultPath) "trace"), + [string]$OperationName = "installer", + [string]$InstallerArgumentsBase64 = "", + [string[]]$InstallerArguments = @("--headed", "--automation") ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" +function Write-TraceEvent { + param( + [Parameter(Mandatory)][string]$Phase, + [object]$Detail = $null + ) + + try { + New-Item -ItemType Directory -Force -Path $TracePath | Out-Null + $event = [ordered]@{ + time = (Get-Date).ToUniversalTime().ToString("o") + pid = $PID + script = Split-Path -Leaf $PSCommandPath + phase = $Phase + detail = $Detail + } + $event | ConvertTo-Json -Depth 12 -Compress | Add-Content -LiteralPath (Join-Path $TracePath "guest-events.jsonl") -Encoding UTF8 + } + catch { + Write-Warning "Failed to write trace event '$Phase': $($_.Exception.Message)" + } +} + +function Write-DiagnosticFile { + param( + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][scriptblock]$Capture + ) + + $path = Join-Path $TracePath $Name + try { + $value = & $Capture + $value | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $path -Encoding UTF8 + } + catch { + [ordered]@{ + error = $_.Exception.Message + type = $_.Exception.GetType().FullName + } | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $path -Encoding UTF8 + } +} + +function Export-InstallerDiagnostics { + param( + [Parameter(Mandatory)][string]$Reason, + [System.Diagnostics.Process]$InstallerProcess = $null + ) + + Write-TraceEvent -Phase "installer_diagnostics_start" -Detail @{ reason = $Reason; installerPid = $(if ($InstallerProcess) { $InstallerProcess.Id } else { $null }) } + Write-DiagnosticFile -Name "interactive-context.json" -Capture { + [ordered]@{ + reason = $Reason + computerName = $env:COMPUTERNAME + userName = $env:USERNAME + sessionName = $env:SESSIONNAME + installerPath = $InstallerPath + installerArgs = $InstallerArguments + operationName = $OperationName + resultPath = $ResultPath + tracePath = $TracePath + timeoutSeconds = $TimeoutSeconds + installerPid = $(if ($InstallerProcess) { $InstallerProcess.Id } else { $null }) + installerExited = $(if ($InstallerProcess) { $InstallerProcess.HasExited } else { $null }) + } + } + Write-DiagnosticFile -Name "interactive-processes.json" -Capture { + Get-CimInstance Win32_Process | + Where-Object { $_.Name -match 'covenant|Covenant|powershell|pwsh|dotnet' } | + Select-Object ProcessId, ParentProcessId, Name, CommandLine, CreationDate + } + Write-DiagnosticFile -Name "interactive-windows.json" -Capture { + Get-Process | + Where-Object { $_.MainWindowHandle -ne 0 -or $_.ProcessName -match 'covenant|Covenant|powershell|pwsh' } | + Select-Object Id, ProcessName, MainWindowTitle, MainWindowHandle, StartTime + } + Write-DiagnosticFile -Name "interactive-application-events.json" -Capture { + Get-WinEvent -FilterHashtable @{ LogName = "Application"; StartTime = (Get-Date).AddHours(-2) } -MaxEvents 200 -ErrorAction SilentlyContinue | + Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message + } + Write-TraceEvent -Phase "installer_diagnostics_finish" -Detail @{ reason = $Reason } +} + $installer = $null $startedAt = Get-Date try { + if (-not [string]::IsNullOrWhiteSpace($InstallerArgumentsBase64)) { + $argumentsJson = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($InstallerArgumentsBase64)) + $decodedArguments = ConvertFrom-Json -InputObject $argumentsJson + $InstallerArguments = @() + foreach ($argument in $decodedArguments) { + $InstallerArguments += [string]$argument + } + } + + New-Item -ItemType Directory -Force -Path $TracePath | Out-Null + Write-TraceEvent -Phase "interactive_installer_start" -Detail @{ + installerPath = $InstallerPath + installerArgs = $InstallerArguments + operationName = $OperationName + resultPath = $ResultPath + tracePath = $TracePath + timeoutSeconds = $TimeoutSeconds + } + if (-not (Test-Path -LiteralPath $InstallerPath)) { throw "Installer not found: $InstallerPath" } @@ -22,17 +126,47 @@ try { } Remove-Item -LiteralPath $ResultPath -Force -ErrorAction SilentlyContinue - $installer = Start-Process -FilePath $InstallerPath -ArgumentList @("--headed", "--automation") -PassThru + $env:COVENANT_SETUP_TRACE_DIR = $TracePath + Write-TraceEvent -Phase "trace_environment_set" -Detail @{ name = "COVENANT_SETUP_TRACE_DIR"; value = $TracePath } - if (-not $installer.WaitForExit($TimeoutSeconds * 1000)) { - Stop-Process -Id $installer.Id -Force -ErrorAction SilentlyContinue - throw "Installer timed out after $TimeoutSeconds seconds." + $installer = Start-Process -FilePath $InstallerPath -ArgumentList $InstallerArguments -PassThru + Write-TraceEvent -Phase "installer_process_started" -Detail @{ pid = $installer.Id; operationName = $OperationName } + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $lastPoll = Get-Date "2000-01-01" + while (-not $installer.HasExited) { + if ((Get-Date) -ge $deadline) { + Export-InstallerDiagnostics -Reason "installer_timeout" -InstallerProcess $installer + Stop-Process -Id $installer.Id -Force -ErrorAction SilentlyContinue + throw "Installer timed out after $TimeoutSeconds seconds." + } + + if (((Get-Date) - $lastPoll).TotalSeconds -ge 10) { + $lastPoll = Get-Date + $installer.Refresh() + Write-TraceEvent -Phase "installer_still_running" -Detail @{ + pid = $installer.Id + operationName = $OperationName + elapsedSeconds = [int]((Get-Date) - $startedAt).TotalSeconds + responding = $installer.Responding + mainWindow = $installer.MainWindowTitle + } + } + + Start-Sleep -Seconds 2 + $installer.Refresh() } + Write-TraceEvent -Phase "installer_process_exited" -Detail @{ pid = $installer.Id; exitCode = $installer.ExitCode; operationName = $OperationName } + Export-InstallerDiagnostics -Reason "installer_exit" -InstallerProcess $installer + $result = [ordered]@{ success = ($installer.ExitCode -eq 0) exitCode = [int]$installer.ExitCode installerPath = $InstallerPath + installerArgs = $InstallerArguments + operationName = $OperationName + tracePath = $TracePath startedAt = $startedAt.ToString("o") finishedAt = (Get-Date).ToString("o") } @@ -43,9 +177,15 @@ try { } } catch { + Write-TraceEvent -Phase "interactive_installer_error" -Detail @{ + error = $_.Exception.Message + type = $_.Exception.GetType().FullName + } + Export-InstallerDiagnostics -Reason "interactive_installer_error" -InstallerProcess $installer $failure = [ordered]@{ success = $false error = $_.Exception.Message + tracePath = $TracePath startedAt = $startedAt.ToString("o") finishedAt = (Get-Date).ToString("o") } diff --git a/scripts/windows-vm/Start-InteractiveSelfInstall.ps1 b/scripts/windows-vm/Start-InteractiveSelfInstall.ps1 index 9c4933e..3553945 100644 --- a/scripts/windows-vm/Start-InteractiveSelfInstall.ps1 +++ b/scripts/windows-vm/Start-InteractiveSelfInstall.ps1 @@ -4,7 +4,8 @@ param( [Parameter(Mandatory)][string]$ResultPath, [string]$ScriptRoot = $PSScriptRoot, [int]$TimeoutSeconds = 600, - [string]$TaskNamePrefix = "CovenantSetupSelfInstall" + [string]$TaskNamePrefix = "CovenantSetupSelfInstall", + [string]$TracePath = $(Join-Path (Split-Path -Parent $ResultPath) "trace") ) Set-StrictMode -Version Latest @@ -16,16 +17,310 @@ function Quote-TaskArgument { return '"' + $Value.Replace('"', '""') + '"' } +function Write-TraceEvent { + param( + [Parameter(Mandatory)][string]$Phase, + [object]$Detail = $null + ) + + try { + New-Item -ItemType Directory -Force -Path $TracePath | Out-Null + $event = [ordered]@{ + time = (Get-Date).ToUniversalTime().ToString("o") + pid = $PID + script = Split-Path -Leaf $PSCommandPath + phase = $Phase + detail = $Detail + } + $event | ConvertTo-Json -Depth 12 -Compress | Add-Content -LiteralPath (Join-Path $TracePath "guest-events.jsonl") -Encoding UTF8 + } + catch { + Write-Warning "Failed to write trace event '$Phase': $($_.Exception.Message)" + } +} + +function Write-DiagnosticFile { + param( + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][scriptblock]$Capture + ) + + $path = Join-Path $TracePath $Name + Write-TraceEvent -Phase "diagnostic_file_start" -Detail @{ name = $Name } + try { + $value = & $Capture + $value | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $path -Encoding UTF8 + Write-TraceEvent -Phase "diagnostic_file_finish" -Detail @{ name = $Name } + } + catch { + [ordered]@{ + error = $_.Exception.Message + type = $_.Exception.GetType().FullName + } | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $path -Encoding UTF8 + Write-TraceEvent -Phase "diagnostic_file_error" -Detail @{ + name = $Name + error = $_.Exception.Message + type = $_.Exception.GetType().FullName + } + } +} + +function Convert-DateTimeForTrace { + param([object]$Value) + + if ($null -eq $Value) { + return $null + } + + if ($Value -is [DateTime] -and $Value -eq [DateTime]::MinValue) { + return $null + } + + try { + return ([DateTime]$Value).ToString("o") + } + catch { + return [string]$Value + } +} + +function Export-SmokeDiagnostics { + param([Parameter(Mandatory)][string]$Reason) + + Write-TraceEvent -Phase "diagnostics_start" -Detail @{ reason = $Reason } + New-Item -ItemType Directory -Force -Path $TracePath | Out-Null + + Write-DiagnosticFile -Name "guest-context.json" -Capture { + [ordered]@{ + reason = $Reason + computerName = $env:COMPUTERNAME + userName = $env:USERNAME + installerPath = $InstallerPath + resultPath = $ResultPath + tracePath = $TracePath + taskName = $taskName + installRoot = $installRoot + } + } + Write-DiagnosticFile -Name "processes.json" -Capture { + Get-CimInstance Win32_Process | + Where-Object { $_.Name -match 'covenant|Covenant|powershell|pwsh|dotnet' } | + Select-Object ProcessId, ParentProcessId, Name, CommandLine, CreationDate + } + Write-DiagnosticFile -Name "scheduled-task.json" -Capture { + [ordered]@{ + task = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue + info = Get-ScheduledTaskInfo -TaskName $taskName -ErrorAction SilentlyContinue + } + } + Write-DiagnosticFile -Name "scheduled-task-events.json" -Capture { + Get-WinEvent -LogName "Microsoft-Windows-TaskScheduler/Operational" -MaxEvents 300 -ErrorAction SilentlyContinue | + Where-Object { $_.Message -like "*$taskName*" } | + Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message + } + Write-DiagnosticFile -Name "application-events.json" -Capture { + Get-WinEvent -FilterHashtable @{ LogName = "Application"; StartTime = (Get-Date).AddHours(-2) } -MaxEvents 200 -ErrorAction SilentlyContinue | + Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message + } + Write-DiagnosticFile -Name "system-events.json" -Capture { + Get-WinEvent -FilterHashtable @{ LogName = "System"; StartTime = (Get-Date).AddHours(-2) } -MaxEvents 200 -ErrorAction SilentlyContinue | + Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message + } + Write-DiagnosticFile -Name "install-root-files.json" -Capture { + if (Test-Path -LiteralPath $installRoot) { + Get-ChildItem -LiteralPath $installRoot -Force -Recurse | + Select-Object FullName, Length, LastWriteTimeUtc, Attributes + } + else { + [ordered]@{ exists = $false; path = $installRoot } + } + } + Write-DiagnosticFile -Name "registry-state.json" -Capture { + if (Test-Path -LiteralPath $registryPath) { + Get-ItemProperty -LiteralPath $registryPath + } + else { + [ordered]@{ exists = $false; path = $registryPath } + } + } + Write-DiagnosticFile -Name "result-file.json" -Capture { + if (Test-Path -LiteralPath $ResultPath) { + Get-Content -LiteralPath $ResultPath -Raw + } + else { + [ordered]@{ exists = $false; path = $ResultPath } + } + } + Write-TraceEvent -Phase "diagnostics_finish" -Detail @{ reason = $Reason } +} + +function Invoke-InteractiveOperation { + param( + [Parameter(Mandatory)][string]$TaskName, + [Parameter(Mandatory)][string]$ExecutablePath, + [Parameter(Mandatory)][string]$RunResultPath, + [Parameter(Mandatory)][string]$OperationName, + [string[]]$Arguments = @() + ) + + $script:taskName = $TaskName + Remove-Item -LiteralPath $RunResultPath -Force -ErrorAction SilentlyContinue + + # The task is started manually below. Keep the trigger far enough out that it + # cannot fire a second copy while the smoke harness is collecting diagnostics. + $trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddDays(1) + $actionArgumentItems = @( + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-File", (Quote-TaskArgument -Value $interactiveScript), + "-InstallerPath", (Quote-TaskArgument -Value $ExecutablePath), + "-ResultPath", (Quote-TaskArgument -Value $RunResultPath), + "-TimeoutSeconds", $TimeoutSeconds, + "-TracePath", (Quote-TaskArgument -Value $TracePath), + "-OperationName", (Quote-TaskArgument -Value $OperationName) + ) + if ($Arguments.Count -gt 0) { + $argumentsJson = ConvertTo-Json -InputObject $Arguments -Compress + $argumentsBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($argumentsJson)) + $actionArgumentItems += "-InstallerArgumentsBase64" + $actionArgumentItems += $argumentsBase64 + } + $actionArguments = $actionArgumentItems -join " " + $action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument $actionArguments + $principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable + + try { + Register-ScheduledTask ` + -TaskName $TaskName ` + -Action $action ` + -Trigger $trigger ` + -Settings $settings ` + -Principal $principal ` + -Force | Out-Null + Write-TraceEvent -Phase "scheduled_task_registered" -Detail @{ + taskName = $TaskName + operationName = $OperationName + executablePath = $ExecutablePath + executableArgs = $Arguments + actionArguments = $actionArguments + runResultPath = $RunResultPath + } + + Start-ScheduledTask -TaskName $TaskName + Write-TraceEvent -Phase "scheduled_task_started" -Detail @{ taskName = $TaskName; operationName = $OperationName } + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds + 120) + $lastPoll = Get-Date "2000-01-01" + while ((Get-Date) -lt $deadline) { + if (Test-Path -LiteralPath $RunResultPath) { + Write-TraceEvent -Phase "result_file_observed" -Detail @{ + taskName = $TaskName + operationName = $OperationName + runResultPath = $RunResultPath + } + break + } + + if (((Get-Date) - $lastPoll).TotalSeconds -ge 10) { + $lastPoll = Get-Date + $taskInfo = Get-ScheduledTaskInfo -TaskName $TaskName -ErrorAction SilentlyContinue + $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + Write-TraceEvent -Phase "waiting_for_interactive_task" -Detail @{ + taskName = $TaskName + operationName = $OperationName + state = $(if ($task) { $task.State.ToString() } else { $null }) + lastRunTime = $(if ($taskInfo) { Convert-DateTimeForTrace -Value $taskInfo.LastRunTime } else { $null }) + lastTaskResult = $(if ($taskInfo) { $taskInfo.LastTaskResult } else { $null }) + nextRunTime = $(if ($taskInfo) { Convert-DateTimeForTrace -Value $taskInfo.NextRunTime } else { $null }) + } + } + + Start-Sleep -Seconds 2 + } + + if (-not (Test-Path -LiteralPath $RunResultPath)) { + Export-SmokeDiagnostics -Reason "${OperationName}_task_timeout" + $taskInfo = Get-ScheduledTaskInfo -TaskName $TaskName + throw "Timed out waiting for $OperationName interactive task. LastTaskResult=$($taskInfo.LastTaskResult)" + } + + $runResult = Get-Content -LiteralPath $RunResultPath -Raw | ConvertFrom-Json + Write-TraceEvent -Phase "interactive_task_result_read" -Detail @{ + taskName = $TaskName + operationName = $OperationName + result = $runResult + } + if (-not $runResult.success) { + Export-SmokeDiagnostics -Reason "${OperationName}_task_failed" + throw "Interactive $OperationName task failed: $($runResult.error)" + } + + return $runResult + } + finally { + Write-TraceEvent -Phase "scheduled_task_unregister" -Detail @{ taskName = $TaskName; operationName = $OperationName } + Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue + } +} + +function Wait-ForUninstallCleanup { + param([int]$TimeoutSeconds = 60) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $lastPoll = Get-Date "2000-01-01" + while ((Get-Date) -lt $deadline) { + $remaining = [ordered]@{ + installRoot = Test-Path -LiteralPath $installRoot + installedExe = Test-Path -LiteralPath $installedExe + journalPath = Test-Path -LiteralPath $journalPath + uninstallExe = Test-Path -LiteralPath $uninstallExe + shortcutPath = Test-Path -LiteralPath $shortcutPath + registryPath = Test-Path -LiteralPath $registryPath + uninstallRegistryPath = Test-Path -LiteralPath $uninstallRegistryPath + } + + if (-not ($remaining.installRoot -or $remaining.installedExe -or $remaining.journalPath -or $remaining.uninstallExe -or $remaining.shortcutPath -or $remaining.registryPath -or $remaining.uninstallRegistryPath)) { + Write-TraceEvent -Phase "uninstall_cleanup_observed" -Detail $remaining + return + } + + if (((Get-Date) - $lastPoll).TotalSeconds -ge 5) { + $lastPoll = Get-Date + Write-TraceEvent -Phase "waiting_for_uninstall_cleanup" -Detail $remaining + } + + Start-Sleep -Seconds 1 + } + + throw "Uninstall cleanup did not complete within $TimeoutSeconds seconds." +} + $installRoot = Join-Path $env:LOCALAPPDATA "CovenantSetupSelfTest" $shortcutPath = Join-Path ([Environment]::GetFolderPath("Desktop")) "Covenant Setup Self Test.lnk" $registryPath = "HKCU:\Software\CovenantSetupSelfTest" $journalPath = Join-Path $installRoot "journal.json" $installedExe = Join-Path $installRoot "bin\covenant-setup.exe" $uninstallExe = Join-Path $installRoot "covenant-setup-uninstall.exe" -$taskName = "{0}-{1}" -f $TaskNamePrefix, ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds()) +$uninstallRegistryPath = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\Covenant_Setup_Self_Test" +$taskStamp = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() +$installTaskName = "{0}-Install-{1}" -f $TaskNamePrefix, $taskStamp +$uninstallTaskName = "{0}-Uninstall-{1}" -f $TaskNamePrefix, $taskStamp +$taskName = $installTaskName $interactiveScript = Join-Path $ScriptRoot "Invoke-InteractiveInstaller.ps1" +$installRunResultPath = $null +$uninstallRunResultPath = $null try { + Remove-Item -LiteralPath $TracePath -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Force -Path $TracePath | Out-Null + Write-TraceEvent -Phase "self_install_start" -Detail @{ + installerPath = $InstallerPath + resultPath = $ResultPath + tracePath = $TracePath + timeoutSeconds = $TimeoutSeconds + } + if (-not (Test-Path -LiteralPath $InstallerPath)) { throw "Installer not found in guest: $InstallerPath" } @@ -39,51 +334,24 @@ try { New-Item -ItemType Directory -Force -Path $resultDir | Out-Null } Remove-Item -LiteralPath $ResultPath -Force -ErrorAction SilentlyContinue + $installRunResultPath = Join-Path $resultDir "install-run-result.json" + $uninstallRunResultPath = Join-Path $resultDir "uninstall-run-result.json" + Remove-Item -LiteralPath $installRunResultPath -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $uninstallRunResultPath -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $installRoot -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $shortcutPath -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $registryPath -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $uninstallRegistryPath -Recurse -Force -ErrorAction SilentlyContinue + Write-TraceEvent -Phase "self_install_cleaned_previous_state" - $trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) - $actionArguments = @( - "-NoProfile", - "-ExecutionPolicy", "Bypass", - "-File", (Quote-TaskArgument -Value $interactiveScript), - "-InstallerPath", (Quote-TaskArgument -Value $InstallerPath), - "-ResultPath", (Quote-TaskArgument -Value $ResultPath), - "-TimeoutSeconds", $TimeoutSeconds - ) -join " " - $action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument $actionArguments - $principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Highest - $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable - - Register-ScheduledTask ` - -TaskName $taskName ` - -Action $action ` - -Trigger $trigger ` - -Settings $settings ` - -Principal $principal ` - -Force | Out-Null - - Start-ScheduledTask -TaskName $taskName - - $deadline = (Get-Date).AddSeconds($TimeoutSeconds + 120) - while ((Get-Date) -lt $deadline) { - if (Test-Path -LiteralPath $ResultPath) { - break - } - Start-Sleep -Seconds 2 - } - - if (-not (Test-Path -LiteralPath $ResultPath)) { - $taskInfo = Get-ScheduledTaskInfo -TaskName $taskName - throw "Timed out waiting for interactive installer task. LastTaskResult=$($taskInfo.LastTaskResult)" - } - - $runResult = Get-Content -LiteralPath $ResultPath -Raw | ConvertFrom-Json - if (-not $runResult.success) { - throw "Interactive installer task failed: $($runResult.error)" - } + $installRunResult = Invoke-InteractiveOperation ` + -TaskName $installTaskName ` + -ExecutablePath $InstallerPath ` + -RunResultPath $installRunResultPath ` + -OperationName "install" ` + -Arguments @("--headed", "--automation") + Write-TraceEvent -Phase "verification_start" if (-not (Test-Path -LiteralPath $installedExe)) { throw "Installed executable missing: $installedExe" } @@ -114,34 +382,90 @@ try { throw "Journal did not record the packaged payload copy." } + $uninstallRunResult = Invoke-InteractiveOperation ` + -TaskName $uninstallTaskName ` + -ExecutablePath $uninstallExe ` + -RunResultPath $uninstallRunResultPath ` + -OperationName "uninstall" ` + -Arguments @("--headed", "--automation", "uninstall", $journalPath) + + Write-TraceEvent -Phase "uninstall_verification_start" + Wait-ForUninstallCleanup -TimeoutSeconds 60 + if (Test-Path -LiteralPath $installedExe) { + throw "Installed executable still exists after uninstall: $installedExe" + } + if (Test-Path -LiteralPath $journalPath) { + throw "Journal still exists after uninstall: $journalPath" + } + if (Test-Path -LiteralPath $uninstallExe) { + throw "Installed uninstaller still exists after uninstall: $uninstallExe" + } + if (Test-Path -LiteralPath $shortcutPath) { + throw "Desktop shortcut still exists after uninstall: $shortcutPath" + } + if (Test-Path -LiteralPath $registryPath) { + throw "Registry key still exists after uninstall: $registryPath" + } + if (Test-Path -LiteralPath $uninstallRegistryPath) { + throw "Installed Apps registry key still exists after uninstall: $uninstallRegistryPath" + } + if (Test-Path -LiteralPath $installRoot) { + throw "Install root still exists after uninstall: $installRoot" + } + $verification = [ordered]@{ - success = $true - exitCode = [int]$runResult.exitCode - installerPath = $InstallerPath - installRoot = $installRoot - installedExe = $installedExe - journalPath = $journalPath - uninstallExe = $uninstallExe - shortcutPath = $shortcutPath - registryPath = $registryPath + success = $true + exitCode = 0 + installExitCode = [int]$installRunResult.exitCode + uninstallExitCode = [int]$uninstallRunResult.exitCode + installerPath = $InstallerPath + installRoot = $installRoot + installedExe = $installedExe + journalPath = $journalPath + uninstallExe = $uninstallExe + shortcutPath = $shortcutPath + registryPath = $registryPath + uninstallRegistryPath = $uninstallRegistryPath + tracePath = $TracePath + installRunResultPath = $installRunResultPath + uninstallRunResultPath = $uninstallRunResultPath journalActionTypes = $journalActionTypes - taskName = $taskName - startedAt = $runResult.startedAt - finishedAt = $runResult.finishedAt + installTaskName = $installTaskName + uninstallTaskName = $uninstallTaskName + installStartedAt = $installRunResult.startedAt + installFinishedAt = $installRunResult.finishedAt + uninstallStartedAt = $uninstallRunResult.startedAt + uninstallFinishedAt = $uninstallRunResult.finishedAt + uninstallVerified = $true } $verification | ConvertTo-Json | Set-Content -LiteralPath $ResultPath -Encoding UTF8 + Write-TraceEvent -Phase "self_install_success" -Detail $verification } catch { + Write-TraceEvent -Phase "self_install_error" -Detail @{ + error = $_.Exception.Message + type = $_.Exception.GetType().FullName + } + Export-SmokeDiagnostics -Reason "self_install_error" $failure = [ordered]@{ - success = $false - error = $_.Exception.Message - taskName = $taskName - installRoot = $installRoot - resultPath = $ResultPath + success = $false + error = $_.Exception.Message + taskName = $taskName + installTaskName = $installTaskName + uninstallTaskName = $uninstallTaskName + installRoot = $installRoot + resultPath = $ResultPath + installRunResultPath = $installRunResultPath + uninstallRunResultPath = $uninstallRunResultPath + tracePath = $TracePath } $failure | ConvertTo-Json | Set-Content -LiteralPath $ResultPath -Encoding UTF8 exit 1 } finally { - Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue + foreach ($taskToRemove in @($installTaskName, $uninstallTaskName)) { + if ($taskToRemove) { + Unregister-ScheduledTask -TaskName $taskToRemove -Confirm:$false -ErrorAction SilentlyContinue + } + } } diff --git a/src/main.rs b/src/main.rs index 75c2dfd..11bc104 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ #![windows_subsystem = "windows"] +mod ui; mod win; use clap::{ArgAction, Parser, Subcommand}; @@ -20,6 +21,7 @@ use std::sync::{ use std::thread; use std::time::Duration; use thiserror::Error; +use ui::GuiProgress; const EXIT_ELEVATION_REQUIRED: i32 = 33; const EXIT_OPERATION_FAILED: i32 = 1; @@ -198,6 +200,18 @@ struct EmbeddedBundle { files: Vec, } +#[derive(Debug, Serialize, Deserialize)] +struct EmbeddedBundleIndex { + metadata: PackagedApp, + files: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct EmbeddedFileIndexEntry { + relative_path: String, + len: u64, +} + trait MutationTracker { fn record(&mut self, action: JournalAction); fn finish(self, app_name: String, manifest_path: Option, purge: PurgeSpec) -> Journal; @@ -385,70 +399,15 @@ impl Drop for TuiProgress { } } -struct GuiProgress { - state_path: PathBuf, - log_path: PathBuf, - total_steps: usize, -} - -impl GuiProgress { - fn start(title: &str, initial_message: &str, total_steps: usize) -> Result { - let root = std::env::temp_dir().join("covenant-setup-ui"); - fs::create_dir_all(&root)?; - let stamp = unique_ticks(); - let state_path = root.join(format!("state-{stamp}.json")); - let log_path = root.join(format!("log-{stamp}.txt")); - fs::write(&log_path, b"")?; - write_progress_state(&state_path, title, initial_message, 0, total_steps, false)?; - spawn_gui_progress_window(&state_path, &log_path, title)?; - Ok(Self { - state_path, - log_path, - total_steps, - }) - } - - fn advance(&mut self, current_step: usize, message: &str) -> Result<(), AppError> { - append_progress_log(&self.log_path, message)?; - write_progress_state( - &self.state_path, - "", - message, - current_step, - self.total_steps, - false, - )?; - Ok(()) - } - - fn finish(&mut self, message: &str) -> Result<(), AppError> { - write_progress_state( - &self.state_path, - "", - message, - self.total_steps, - self.total_steps, - true, - )?; - Ok(()) - } -} - -impl Drop for GuiProgress { - fn drop(&mut self) { - let _ = write_progress_state( - &self.state_path, - "", - "Complete", - self.total_steps, - self.total_steps, - true, - ); - } -} - fn main() { let args: Vec<_> = std::env::args_os().collect(); + trace_event( + "process_start", + json!({ + "pid": process::id(), + "args": args.iter().map(|arg| arg.to_string_lossy().to_string()).collect::>() + }), + ); if is_bundled_runtime_invocation(&args) { let logger = Logger { json: false, @@ -460,7 +419,7 @@ fn main() { Ok(()) => 0, Err(AppError::Message(ref message)) if message == "__elevated_relaunch__" => 0, Err(err) => { - let _ = win::gui_report_error(&err.to_string(), &logger); + let _ = ui::report_error(&err.to_string()); logger.error(err, EXIT_OPERATION_FAILED); EXIT_OPERATION_FAILED } @@ -506,6 +465,7 @@ fn run(cli: Cli, logger: &Logger) -> Result<(), AppError> { &journal, cli.elevate, select_ui(UiPhase::Uninstall, preferences, logger)?, + preferences.automation, logger, ), Commands::Cleanup { @@ -517,6 +477,7 @@ fn run(cli: Cli, logger: &Logger) -> Result<(), AppError> { install_root, app_name, select_ui(UiPhase::Cleanup, preferences, logger)?, + preferences.automation, logger, ), } @@ -614,7 +575,35 @@ fn collect_bundle_files_recursive( } fn append_embedded_bundle(exe_target: &Path, bundle: &EmbeddedBundle) -> Result<(), AppError> { - let payload = serde_json::to_vec(bundle)?; + let index = EmbeddedBundleIndex { + metadata: PackagedApp { + app_name: bundle.metadata.app_name.clone(), + manifest: bundle.metadata.manifest.clone(), + }, + files: bundle + .files + .iter() + .map(|file| EmbeddedFileIndexEntry { + relative_path: file.relative_path.clone(), + len: file.data.len() as u64, + }) + .collect(), + }; + let index_bytes = serde_json::to_vec(&index)?; + let mut payload = Vec::with_capacity( + std::mem::size_of::() + + index_bytes.len() + + bundle + .files + .iter() + .map(|file| file.data.len()) + .sum::(), + ); + payload.write_all(&(index_bytes.len() as u64).to_le_bytes())?; + payload.write_all(&index_bytes)?; + for file in &bundle.files { + payload.write_all(&file.data)?; + } let mut file = fs::OpenOptions::new().append(true).open(exe_target)?; file.write_all(&payload)?; file.write_all(&(payload.len() as u64).to_le_bytes())?; @@ -646,7 +635,54 @@ fn read_embedded_bundle(exe_path: &Path) -> Result, AppEr )); } let payload_offset = size_offset - payload_len; - let bundle: EmbeddedBundle = serde_json::from_slice(&bytes[payload_offset..size_offset])?; + let payload = &bytes[payload_offset..size_offset]; + if payload.len() < std::mem::size_of::() { + return Err(AppError::Message("Embedded payload is too short".into())); + } + let index_len = u64::from_le_bytes( + payload[..std::mem::size_of::()] + .try_into() + .map_err(|_| AppError::Message("Invalid embedded index length".into()))?, + ) as usize; + let index_offset = std::mem::size_of::(); + let data_offset = index_offset + .checked_add(index_len) + .ok_or_else(|| AppError::Message("Embedded index length overflow".into()))?; + if data_offset > payload.len() { + return Err(AppError::Message( + "Embedded index length exceeds payload size".into(), + )); + } + let index: EmbeddedBundleIndex = serde_json::from_slice(&payload[index_offset..data_offset])?; + let EmbeddedBundleIndex { + metadata, + files: index_files, + } = index; + let mut cursor = data_offset; + let mut files = Vec::with_capacity(index_files.len()); + for entry in index_files { + let len = entry.len as usize; + let end = cursor + .checked_add(len) + .ok_or_else(|| AppError::Message("Embedded file length overflow".into()))?; + if end > payload.len() { + return Err(AppError::Message(format!( + "Embedded file exceeds payload size: {}", + entry.relative_path + ))); + } + files.push(EmbeddedFile { + relative_path: entry.relative_path, + data: payload[cursor..end].to_vec(), + }); + cursor = end; + } + if cursor != payload.len() { + return Err(AppError::Message( + "Embedded payload has trailing bytes after file data".into(), + )); + } + let bundle = EmbeddedBundle { metadata, files }; Ok(Some(bundle)) } @@ -687,36 +723,45 @@ fn run_bundled_installer( preferences: UiPreferences, logger: &Logger, ) -> Result<(), AppError> { + trace_event("bundled_installer_start", json!({})); let exe = std::env::current_exe()?; let bundle = read_embedded_bundle(&exe)? .ok_or_else(|| AppError::Message("No embedded package found in installer".into()))?; let extraction_root = extract_embedded_bundle(&exe, &bundle)?; + trace_event( + "bundled_installer_extracted", + json!({"exe": exe, "extraction_root": extraction_root}), + ); let metadata = bundle.metadata; let manifest_path = extraction_root.join(metadata.manifest.clone()); - let journal_path = exe - .parent() - .ok_or_else(|| AppError::Message("Packaged installer has no parent directory".into()))? - .join("journal.json"); - match mode { RuntimeMode::Bundled => { let ui_mode = select_ui(UiPhase::Install, preferences, logger)?; + trace_event( + "bundled_installer_ui_selected", + json!({"ui_mode": ui_mode_name(ui_mode), "automation": preferences.automation}), + ); if ui_mode == UiMode::Gui && !preferences.automation - && !win::gui_confirm_install(&metadata.app_name, logger)? + && !ui::confirm_install(&metadata.app_name)? { return Ok(()); } - match install(&manifest_path, Some(journal_path), true, ui_mode, logger) { + match install(&manifest_path, None, true, ui_mode, logger) { Ok(()) => { + trace_event("bundled_installer_install_ok", json!({})); if ui_mode == UiMode::Gui && !preferences.automation { - win::gui_report_success(&metadata.app_name, logger)?; + ui::report_success(&metadata.app_name)?; } Ok(()) } Err(err) => { + trace_event( + "bundled_installer_install_error", + json!({"error": err.to_string()}), + ); if ui_mode == UiMode::Gui && !preferences.automation { - win::gui_report_error(&err.to_string(), logger)?; + ui::report_error(&err.to_string())?; } Err(err) } @@ -733,6 +778,10 @@ fn install( logger: &Logger, ) -> Result<(), AppError> { let manifest: InstallManifest = toml::from_str(&fs::read_to_string(manifest_path)?)?; + trace_event( + "install_start", + json!({"manifest": manifest_path, "app_name": &manifest.app_name}), + ); let app_name = manifest.app_name.clone(); let _progress = start_tui_progress(ui_mode, format!("Installing {} ", manifest.app_name)); let mut gui_progress = start_gui_progress( @@ -750,6 +799,10 @@ fn install( let resolver = win::PathResolver::new(&effective_logger)?; let requires_admin = manifest_requires_admin(&manifest, &resolver)?; ensure_elevation_if_needed(requires_admin, elevate, &effective_logger)?; + trace_event( + "install_elevation_checked", + json!({"requires_admin": requires_admin, "elevate": elevate}), + ); let runtime = build_install_runtime( &manifest, manifest_path, @@ -932,6 +985,10 @@ fn install( fs::create_dir_all(parent)?; } fs::write(&runtime.journal_path, serde_json::to_vec_pretty(&journal)?)?; + trace_event( + "install_journal_written", + json!({"journal": runtime.journal_path, "actions": journal.actions.len()}), + ); effective_logger.result( "ok", json!({"journal":runtime.journal_path,"actions":journal.actions.len()}), @@ -947,6 +1004,10 @@ fn install( })(); if let Err(err) = &result { + trace_event( + "install_error", + json!({"app_name": app_name, "error": err.to_string()}), + ); let _ = fail_gui_progress( &mut gui_progress, &format!("{app_name} installation failed: {err}"), @@ -960,9 +1021,14 @@ fn uninstall( journal_path: &Path, elevate: bool, ui_mode: UiMode, + automation: bool, logger: &Logger, ) -> Result<(), AppError> { let journal: Journal = serde_json::from_str(&fs::read_to_string(journal_path)?)?; + trace_event( + "uninstall_start", + json!({"journal": journal_path, "app_name": &journal.app_name}), + ); let app_name = journal.app_name.clone(); let _progress = start_tui_progress(ui_mode, format!("Uninstalling {} ", journal.app_name)); let mut gui_progress = start_gui_progress( @@ -980,6 +1046,10 @@ fn uninstall( let resolver = win::PathResolver::new(&effective_logger)?; let requires_admin = journal_requires_admin(&journal, &resolver)?; ensure_elevation_if_needed(requires_admin, elevate, &effective_logger)?; + trace_event( + "uninstall_elevation_checked", + json!({"requires_admin": requires_admin, "elevate": elevate}), + ); let current_exe = std::env::current_exe().ok(); let mut deferred_self_delete: Option = None; let mut deferred_uninstall_registry: Vec<(RegistryRoot, String)> = Vec::new(); @@ -1082,6 +1152,7 @@ fn uninstall( path.parent(), &journal.app_name, ui_mode, + automation, &effective_logger, )?; } else { @@ -1089,16 +1160,21 @@ fn uninstall( &mut gui_progress, &format!("{} uninstalled successfully!", journal.app_name), )?; - if ui_mode == UiMode::Gui { - win::gui_report_uninstall_success(&journal.app_name, &effective_logger)?; + if ui_mode == UiMode::Gui && !automation { + ui::report_uninstall_success(&journal.app_name)?; } } effective_logger.result("ok", json!({"journal":journal_path})); + trace_event("uninstall_ok", json!({"journal": journal_path})); Ok(()) })(); if let Err(err) = &result { + trace_event( + "uninstall_error", + json!({"app_name": app_name, "error": err.to_string()}), + ); let _ = fail_gui_progress( &mut gui_progress, &format!("{app_name} uninstall failed: {err}"), @@ -1113,8 +1189,13 @@ fn cleanup( install_root: Option, app_name: String, ui_mode: UiMode, + automation: bool, logger: &Logger, ) -> Result<(), AppError> { + trace_event( + "cleanup_start", + json!({"target_exe": &target_exe, "install_root": &install_root, "app_name": &app_name}), + ); let effective_logger = if ui_mode == UiMode::Tui { logger.quiet_clone() } else { @@ -1140,13 +1221,13 @@ fn cleanup( } } reboot_required |= schedule_helper_self_cleanup(&effective_logger)?; - if ui_mode == UiMode::Gui { + if ui_mode == UiMode::Gui && !automation { if reboot_required { - if win::gui_prompt_uninstall_reboot(&app_name, &effective_logger)? { + if ui::prompt_uninstall_reboot(&app_name)? { spawn_reboot(&effective_logger)?; } } else { - win::gui_report_uninstall_success(&app_name, &effective_logger)?; + ui::report_uninstall_success(&app_name)?; } } else if ui_mode == UiMode::Tui { if reboot_required { @@ -1169,12 +1250,18 @@ fn ensure_elevation_if_needed( logger: &Logger, ) -> Result<(), AppError> { if !required || win::is_elevated(logger)? { + trace_event( + "elevation_ok", + json!({"required": required, "relaunch": relaunch}), + ); return Ok(()); } if relaunch { + trace_event("elevation_relaunch", json!({})); win::relaunch_as_admin(logger)?; return Err(AppError::Message("__elevated_relaunch__".into())); } + trace_event("elevation_required_error", json!({})); Err(AppError::Message( "Elevation required for requested operation".into(), )) @@ -1297,6 +1384,7 @@ fn spawn_cleanup_helper( install_root: Option<&Path>, app_name: &str, ui_mode: UiMode, + automation: bool, logger: &Logger, ) -> Result<(), AppError> { let current_exe = std::env::current_exe()?; @@ -1315,6 +1403,11 @@ fn spawn_cleanup_helper( command.creation_flags(CREATE_NO_WINDOW); if ui_mode == UiMode::Tui { command.arg("--headless"); + } else if ui_mode == UiMode::Gui { + command.arg("--headed"); + } + if automation { + command.arg("--automation"); } command.arg("cleanup"); command.arg("--target-exe"); @@ -1401,14 +1494,31 @@ fn select_ui( }) } +fn ui_mode_name(ui_mode: UiMode) -> &'static str { + match ui_mode { + UiMode::None => "none", + UiMode::Gui => "gui", + UiMode::Tui => "tui", + } +} + fn start_gui_progress( ui_mode: UiMode, title: &str, app_name: &str, total_steps: usize, ) -> Result, AppError> { + trace_event( + "gui_progress_start", + json!({ + "ui_mode": ui_mode_name(ui_mode), + "title": title, + "app_name": app_name, + "total_steps": total_steps.max(1) + }), + ); if ui_mode == UiMode::Gui { - Ok(Some(GuiProgress::start( + Ok(Some(ui::GuiProgress::start( title, &format!("{title}"), total_steps.max(1), @@ -1424,6 +1534,10 @@ fn advance_gui_progress( current_step: usize, message: &str, ) -> Result<(), AppError> { + trace_event( + "progress", + json!({"current_step": current_step, "message": message}), + ); if let Some(progress) = gui_progress.as_mut() { progress.advance(current_step, message)?; } @@ -1434,6 +1548,7 @@ fn finish_gui_progress( gui_progress: &mut Option, message: &str, ) -> Result<(), AppError> { + trace_event("progress_finish", json!({"message": message})); if let Some(progress) = gui_progress.as_mut() { progress.finish(message)?; } @@ -1444,6 +1559,7 @@ fn fail_gui_progress( gui_progress: &mut Option, message: &str, ) -> Result<(), AppError> { + trace_event("progress_fail", json!({"message": message})); if let Some(progress) = gui_progress.as_mut() { progress.finish(message)?; } @@ -1460,7 +1576,8 @@ fn append_gui_shell_output( if let Some(progress) = gui_progress.as_mut() { let text = String::from_utf8_lossy(bytes); for line in text.lines().filter(|line| !line.trim().is_empty()) { - append_progress_log(&progress.log_path, line)?; + trace_event("script_output", json!({"line": line})); + progress.log(line)?; } } Ok(()) @@ -1500,121 +1617,32 @@ fn schedule_helper_self_cleanup(logger: &Logger) -> Result { Ok(true) } -fn write_progress_state( - state_path: &Path, - title: &str, - message: &str, - current_step: usize, - total_steps: usize, - complete: bool, -) -> Result<(), AppError> { - let progress = if total_steps == 0 { - 0 - } else { - ((current_step.min(total_steps) * 100) / total_steps) as u64 +pub(crate) fn trace_event(phase: &str, detail: impl Serialize) { + let Ok(root) = std::env::var("COVENANT_SETUP_TRACE_DIR") else { + return; }; - fs::write( - state_path, - serde_json::to_vec(&json!({ - "title": title, - "message": message, - "progress": progress, - "complete": complete - }))?, - )?; - Ok(()) -} + if root.trim().is_empty() { + return; + } -fn append_progress_log(log_path: &Path, line: &str) -> Result<(), AppError> { - let mut file = fs::OpenOptions::new().append(true).open(log_path)?; - writeln!(file, "{line}")?; - Ok(()) -} - -fn spawn_gui_progress_window( - state_path: &Path, - log_path: &Path, - title: &str, -) -> Result<(), AppError> { - let state_path_ps = powershell_single_quote(&state_path.to_string_lossy()); - let log_path_ps = powershell_single_quote(&log_path.to_string_lossy()); - let title_ps = powershell_single_quote(title); - let script = format!(r#" -Add-Type -AssemblyName System.Windows.Forms -Add-Type -AssemblyName System.Drawing -$statePath = '{state_path_ps}' -$logPath = '{log_path_ps}' -$windowTitle = '{title_ps}' -$form = New-Object Windows.Forms.Form -$form.Text = $windowTitle -$form.Size = New-Object Drawing.Size(720,420) -$form.StartPosition = 'CenterScreen' -$label = New-Object Windows.Forms.Label -$label.Location = New-Object Drawing.Point(12,12) -$label.Size = New-Object Drawing.Size(680,24) -$label.Text = $windowTitle -$bar = New-Object Windows.Forms.ProgressBar -$bar.Location = New-Object Drawing.Point(12,44) -$bar.Size = New-Object Drawing.Size(680,24) -$bar.Minimum = 0 -$bar.Maximum = 100 -$output = New-Object Windows.Forms.TextBox -$output.Location = New-Object Drawing.Point(12,80) -$output.Size = New-Object Drawing.Size(680,288) -$output.Multiline = $true -$output.ScrollBars = 'Vertical' -$output.ReadOnly = $true -$output.Font = New-Object Drawing.Font('Consolas',9) -$timer = New-Object Windows.Forms.Timer -$timer.Interval = 250 -$timer.Add_Tick(({{ - try {{ - if (Test-Path $statePath) {{ - $state = Get-Content -LiteralPath $statePath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop - if ($state.title) {{ $form.Text = $state.title }} - $label.Text = $state.message - $bar.Value = [Math]::Max(0, [Math]::Min(100, [int]$state.progress)) - if ([bool]$state.complete) {{ - $timer.Stop() - $form.Close() - }} - }} - }} catch {{ - # Ignore transient reads while Rust updates the state file. - }} - try {{ - if (Test-Path $logPath) {{ - $text = Get-Content -LiteralPath $logPath -Raw -ErrorAction Stop - if ($output.Text -ne $text) {{ - $output.Text = $text - $output.SelectionStart = $output.Text.Length - $output.ScrollToCaret() - }} - }} - }} catch {{ - # Ignore transient reads while Rust updates the log file. - }} -}}).GetNewClosure()) -$form.Controls.Add($label) -$form.Controls.Add($bar) -$form.Controls.Add($output) -$timer.Start() -[void]$form.ShowDialog() -"#); - let script_path = state_path.with_extension("ps1"); - fs::write(&script_path, &script)?; - let mut command = Command::new("powershell.exe"); - command.creation_flags(CREATE_NO_WINDOW); - command.arg("-STA"); - command.arg("-NoProfile"); - command.arg("-ExecutionPolicy"); - command.arg("Bypass"); - command.arg("-WindowStyle"); - command.arg("Hidden"); - command.arg("-File"); - command.arg(&script_path); - command.spawn()?; - Ok(()) + let root = PathBuf::from(root); + if fs::create_dir_all(&root).is_err() { + return; + } + let path = root.join(format!("installer-heartbeat-{}.jsonl", process::id())); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or_default(); + let event = json!({ + "time_unix_ms": timestamp, + "pid": process::id(), + "phase": phase, + "detail": detail + }); + if let Ok(mut file) = fs::OpenOptions::new().create(true).append(true).open(path) { + let _ = writeln!(file, "{event}"); + } } fn unique_ticks() -> u128 { @@ -1735,6 +1763,10 @@ fn execute_script( working_directory: Option<&Path>, gui_progress: &mut Option, ) -> Result<(), AppError> { + trace_event( + "script_start", + json!({"command": &script.command, "args": &script.args, "working_directory": working_directory}), + ); let command_path = absolutize(manifest_dir, &script.command); let command = if command_path.exists() { command_path @@ -1751,6 +1783,10 @@ fn execute_script( append_gui_shell_output(gui_progress, &output.stdout)?; append_gui_shell_output(gui_progress, &output.stderr)?; let status = output.status; + trace_event( + "script_exit", + json!({"command": &script.command, "status": status.code()}), + ); if !status.success() { return Err(AppError::Message(format!( "Script failed: {} ({status})", diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..6681337 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,344 @@ +use crate::AppError; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::fs::{self, OpenOptions}; +use std::io::{BufRead, BufReader, Write}; +use std::os::windows::process::CommandExt; +use std::path::PathBuf; +use std::process::{self, Child, Command}; +use std::thread; +use std::time::{Duration, Instant}; + +const CREATE_NO_WINDOW: u32 = 0x0800_0000; +const UI_EXE_BYTES: &[u8] = include_bytes!(env!("COVENANT_SETUP_UI_EXE")); + +pub struct GuiProgress { + session: CSharpUiSession, + total_steps: usize, +} + +impl GuiProgress { + pub fn start(title: &str, initial_message: &str, total_steps: usize) -> Result { + let mut session = CSharpUiSession::start()?; + session.send(&json!({ + "type": "init", + "title": title, + "message": initial_message, + "total_steps": total_steps.max(1), + }))?; + Ok(Self { + session, + total_steps: total_steps.max(1), + }) + } + + pub fn advance(&mut self, current_step: usize, message: &str) -> Result<(), AppError> { + self.session.send(&json!({ + "type": "progress", + "current_step": current_step, + "total_steps": self.total_steps, + "message": message, + })) + } + + pub fn log(&mut self, message: &str) -> Result<(), AppError> { + self.session.send(&json!({ + "type": "log", + "message": message, + })) + } + + pub fn finish(&mut self, message: &str) -> Result<(), AppError> { + self.session.send(&json!({ + "type": "finish", + "message": message, + })) + } +} + +pub fn confirm_install(app_name: &str) -> Result { + crate::trace_event("ui_prompt_confirm_install", json!({"app_name": app_name})); + let result = prompt( + "covenant-setup", + &format!("Install {app_name} now?"), + PromptButtons::OkCancel, + PromptIcon::Information, + )?; + Ok(matches!(result, PromptResult::Ok)) +} + +pub fn report_success(app_name: &str) -> Result<(), AppError> { + crate::trace_event("ui_prompt_report_success", json!({"app_name": app_name})); + let _ = prompt( + "covenant-setup", + &format!("{app_name} installation completed successfully"), + PromptButtons::Ok, + PromptIcon::Information, + )?; + Ok(()) +} + +pub fn report_error(message: &str) -> Result<(), AppError> { + crate::trace_event("ui_prompt_report_error", json!({"message": message})); + let _ = prompt( + "covenant-setup", + message, + PromptButtons::Ok, + PromptIcon::Error, + )?; + Ok(()) +} + +pub fn report_uninstall_success(app_name: &str) -> Result<(), AppError> { + crate::trace_event( + "ui_prompt_report_uninstall_success", + json!({"app_name": app_name}), + ); + let _ = prompt( + "covenant-setup", + &format!("{app_name} uninstalled successfully!"), + PromptButtons::Ok, + PromptIcon::Information, + )?; + Ok(()) +} + +pub fn prompt_uninstall_reboot(app_name: &str) -> Result { + crate::trace_event("ui_prompt_uninstall_reboot", json!({"app_name": app_name})); + let result = prompt( + "covenant-setup", + &format!( + "{app_name} uninstalled sucessfully! Some files from the program still remain on your computer. To complete removal of these files, restart your computer now." + ), + PromptButtons::YesNo, + PromptIcon::Information, + )?; + Ok(matches!(result, PromptResult::Yes)) +} + +fn prompt( + title: &str, + message: &str, + buttons: PromptButtons, + icon: PromptIcon, +) -> Result { + let mut session = CSharpUiSession::start()?; + let id = format!("prompt-{}", unique_suffix()); + session.send(&json!({ + "type": "prompt", + "id": id, + "title": title, + "message": message, + "buttons": buttons.as_str(), + "icon": icon.as_str(), + }))?; + let response: PromptResponse = session.read()?; + if response.message_type != "prompt_response" || response.id.as_deref() != Some(&id) { + return Err(AppError::Message("Unexpected UI prompt response".into())); + } + let result = PromptResult::from_str(response.result.as_deref().unwrap_or("none"))?; + crate::trace_event( + "ui_prompt_response", + json!({"id": id, "result": response.result}), + ); + Ok(result) +} + +enum PromptButtons { + Ok, + OkCancel, + YesNo, +} + +impl PromptButtons { + fn as_str(&self) -> &'static str { + match self { + Self::Ok => "ok", + Self::OkCancel => "ok_cancel", + Self::YesNo => "yes_no", + } + } +} + +enum PromptIcon { + Information, + Error, +} + +impl PromptIcon { + fn as_str(&self) -> &'static str { + match self { + Self::Information => "information", + Self::Error => "error", + } + } +} + +enum PromptResult { + Ok, + Cancel, + Yes, + No, + None, +} + +impl PromptResult { + fn from_str(value: &str) -> Result { + match value { + "ok" => Ok(Self::Ok), + "cancel" => Ok(Self::Cancel), + "yes" => Ok(Self::Yes), + "no" => Ok(Self::No), + "none" => Ok(Self::None), + other => Err(AppError::Message(format!( + "Unknown UI prompt response: {other}" + ))), + } + } +} + +struct CSharpUiSession { + child: Child, + reader: BufReader, + writer: fs::File, + exe_path: PathBuf, + closed: bool, +} + +impl CSharpUiSession { + fn start() -> Result { + let pipe_name = format!("covenant-setup-ui-{}-{}", process::id(), unique_suffix()); + crate::trace_event("ui_start", json!({"pipe_name": pipe_name})); + let exe_path = extract_ui_exe()?; + crate::trace_event("ui_extracted", json!({"exe_path": &exe_path})); + let mut child = Command::new(&exe_path) + .creation_flags(CREATE_NO_WINDOW) + .arg("--pipe") + .arg(&pipe_name) + .spawn()?; + crate::trace_event( + "ui_spawned", + json!({"pid": child.id(), "exe_path": &exe_path}), + ); + let pipe_path = format!(r"\\.\pipe\{pipe_name}"); + let pipe = connect_pipe(&pipe_path, &mut child)?; + crate::trace_event("ui_pipe_connected", json!({"pipe_path": pipe_path})); + let writer = pipe.try_clone()?; + Ok(Self { + child, + reader: BufReader::new(pipe), + writer, + exe_path, + closed: false, + }) + } + + fn send(&mut self, value: &T) -> Result<(), AppError> { + let value = serde_json::to_value(value)?; + crate::trace_event("ui_pipe_send", message_summary(&value)); + let bytes = serde_json::to_vec(&value)?; + self.writer.write_all(&bytes)?; + self.writer.write_all(b"\n")?; + self.writer.flush()?; + Ok(()) + } + + fn read Deserialize<'de>>(&mut self) -> Result { + let mut line = String::new(); + let bytes = self.reader.read_line(&mut line)?; + if bytes == 0 { + return Err(AppError::Message("UI pipe closed before response".into())); + } + let value: Value = serde_json::from_str(&line)?; + crate::trace_event("ui_pipe_receive", message_summary(&value)); + Ok(serde_json::from_value(value)?) + } +} + +impl Drop for CSharpUiSession { + fn drop(&mut self) { + if !self.closed { + crate::trace_event("ui_close_send", json!({"pid": self.child.id()})); + let _ = self.send(&json!({"type": "close"})); + self.closed = true; + } + for _ in 0..20 { + if self.child.try_wait().ok().flatten().is_some() { + crate::trace_event("ui_exited", json!({"pid": self.child.id()})); + let _ = fs::remove_file(&self.exe_path); + return; + } + thread::sleep(Duration::from_millis(50)); + } + let _ = self.child.kill(); + let _ = self.child.wait(); + crate::trace_event("ui_killed", json!({"pid": self.child.id()})); + let _ = fs::remove_file(&self.exe_path); + } +} + +#[derive(Deserialize)] +struct PromptResponse { + #[serde(rename = "type")] + message_type: String, + id: Option, + result: Option, +} + +fn connect_pipe(pipe_path: &str, child: &mut Child) -> Result { + let deadline = Instant::now() + Duration::from_secs(15); + crate::trace_event("ui_pipe_connect_wait", json!({"pipe_path": pipe_path})); + loop { + match OpenOptions::new().read(true).write(true).open(pipe_path) { + Ok(file) => return Ok(file), + Err(err) => { + if let Some(status) = child.try_wait()? { + crate::trace_event( + "ui_pipe_connect_child_exited", + json!({"pipe_path": pipe_path, "status": status.to_string()}), + ); + return Err(AppError::Message(format!( + "C# UI exited before pipe connection: {status}" + ))); + } + if Instant::now() >= deadline { + crate::trace_event( + "ui_pipe_connect_timeout", + json!({"pipe_path": pipe_path, "error": err.to_string()}), + ); + return Err(AppError::Message(format!( + "Timed out connecting to C# UI pipe {pipe_path}: {err}" + ))); + } + thread::sleep(Duration::from_millis(50)); + } + } + } +} + +fn extract_ui_exe() -> Result { + let root = std::env::temp_dir().join("covenant-setup-ui"); + fs::create_dir_all(&root)?; + let path = root.join(format!( + "Covenant.Setup.Ui-{}-{}.exe", + process::id(), + unique_suffix() + )); + fs::write(&path, UI_EXE_BYTES)?; + Ok(path) +} + +fn message_summary(value: &Value) -> Value { + json!({ + "type": value.get("type").and_then(Value::as_str), + "id": value.get("id").and_then(Value::as_str), + "message": value.get("message").and_then(Value::as_str), + }) +} + +fn unique_suffix() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or_default() +} diff --git a/src/win.rs b/src/win.rs index b6cf52a..e89e375 100644 --- a/src/win.rs +++ b/src/win.rs @@ -29,15 +29,11 @@ use windows::Win32::System::RestartManager::{ RM_PROCESS_INFO, RmEndSession, RmGetList, RmRegisterResources, RmStartSession, }; use windows::Win32::System::Threading::{GetCurrentProcess, GetCurrentProcessId, OpenProcessToken}; -use windows::Win32::UI::Controls::{ - TASKDIALOG_COMMON_BUTTON_FLAGS, TDCBF_CANCEL_BUTTON, TDCBF_NO_BUTTON, TDCBF_OK_BUTTON, - TDCBF_YES_BUTTON, TaskDialog, -}; use windows::Win32::UI::Shell::{ FOLDERID_Desktop, FOLDERID_LocalAppData, FOLDERID_ProgramFilesX64, IShellLinkW, KNOWN_FOLDER_FLAG, SHGetKnownFolderPath, ShellExecuteW, ShellLink, }; -use windows::Win32::UI::WindowsAndMessaging::{IDOK, IDYES, SW_SHOW}; +use windows::Win32::UI::WindowsAndMessaging::SW_SHOW; use windows::core::{Interface, PCWSTR, PWSTR, w}; pub struct PathResolver { @@ -188,90 +184,6 @@ pub fn relaunch_as_admin(logger: &Logger) -> Result<(), AppError> { Ok(()) } -pub fn message_box( - title: &str, - body: &str, - buttons: TASKDIALOG_COMMON_BUTTON_FLAGS, - icon: PCWSTR, - logger: &Logger, -) -> Result { - let mut button = 0i32; - let title_w = Utf16Arg::from_str(title); - let body_w = Utf16Arg::from_str(body); - logger.unsafe_enter("TaskDialog", json!({"title":title})); - unsafe { - TaskDialog( - Some(HWND::default()), - None, - PCWSTR(title_w.as_ptr()), - PCWSTR::null(), - PCWSTR(body_w.as_ptr()), - buttons, - icon, - Some(&mut button), - )? - }; - logger.unsafe_exit("TaskDialog", json!({"result": button})); - Ok(button) -} - -pub fn gui_confirm_install(app_name: &str, logger: &Logger) -> Result { - let result = message_box( - "covenant-setup", - &format!("Install {app_name} now?"), - TDCBF_OK_BUTTON | TDCBF_CANCEL_BUTTON, - td_information_icon(), - logger, - )?; - Ok(result == IDOK.0) -} - -pub fn gui_report_success(app_name: &str, logger: &Logger) -> Result<(), AppError> { - let _ = message_box( - "covenant-setup", - &format!("{app_name} installation completed successfully"), - TDCBF_OK_BUTTON, - td_information_icon(), - logger, - )?; - Ok(()) -} - -pub fn gui_report_error(message: &str, logger: &Logger) -> Result<(), AppError> { - let _ = message_box( - "covenant-setup", - message, - TDCBF_OK_BUTTON, - td_error_icon(), - logger, - )?; - Ok(()) -} - -pub fn gui_report_uninstall_success(app_name: &str, logger: &Logger) -> Result<(), AppError> { - let _ = message_box( - "covenant-setup", - &format!("{app_name} uninstalled successfully!"), - TDCBF_OK_BUTTON, - td_information_icon(), - logger, - )?; - Ok(()) -} - -pub fn gui_prompt_uninstall_reboot(app_name: &str, logger: &Logger) -> Result { - let result = message_box( - "covenant-setup", - &format!( - "{app_name} uninstalled sucessfully! Some files from the program still remain on your computer. To complete removal of these files, restart your computer now." - ), - TDCBF_YES_BUTTON | TDCBF_NO_BUTTON, - td_information_icon(), - logger, - )?; - Ok(result == IDYES.0) -} - pub fn create_directory_recursive(path: &Path, logger: &Logger) -> Result<(), AppError> { if path.as_os_str().is_empty() || path.exists() { return Ok(()); @@ -614,14 +526,6 @@ fn root_hkey(root: RegistryRoot) -> HKEY { } } -fn td_information_icon() -> PCWSTR { - PCWSTR(std::ptr::without_provenance(0xFFFD)) -} - -fn td_error_icon() -> PCWSTR { - PCWSTR(std::ptr::without_provenance(0xFFFE)) -} - fn win32_ok(status: WIN32_ERROR, operation: &str) -> Result<(), AppError> { if status == ERROR_SUCCESS { Ok(()) diff --git a/ui/Covenant.Setup.Ui/Covenant.Setup.Ui.csproj b/ui/Covenant.Setup.Ui/Covenant.Setup.Ui.csproj new file mode 100644 index 0000000..9ee17f8 --- /dev/null +++ b/ui/Covenant.Setup.Ui/Covenant.Setup.Ui.csproj @@ -0,0 +1,12 @@ + + + WinExe + net8.0-windows + true + enable + enable + Covenant.Setup.Ui + Covenant.Setup.Ui + app.manifest + + diff --git a/ui/Covenant.Setup.Ui/Program.cs b/ui/Covenant.Setup.Ui/Program.cs new file mode 100644 index 0000000..1540b34 --- /dev/null +++ b/ui/Covenant.Setup.Ui/Program.cs @@ -0,0 +1,445 @@ +using System.IO.Pipes; +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Covenant.Setup.Ui; + +internal static class Program +{ + [STAThread] + private static void Main(string[] args) + { + var pipeName = ReadPipeName(args); + UiTrace.Write("process_start", new { ProcessId = Environment.ProcessId, PipeName = pipeName }); + if (string.IsNullOrWhiteSpace(pipeName)) + { + UiTrace.Write("missing_pipe_argument"); + MessageBox.Show("Missing named pipe argument.", "covenant-setup", MessageBoxButtons.OK, MessageBoxIcon.Error); + return; + } + + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + Application.Run(new InstallerUiForm(pipeName)); + } + + private static string? ReadPipeName(string[] args) + { + for (var i = 0; i < args.Length - 1; i++) + { + if (string.Equals(args[i], "--pipe", StringComparison.OrdinalIgnoreCase)) + { + return args[i + 1]; + } + } + + return null; + } +} + +internal sealed class InstallerUiForm : Form +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private readonly string _pipeName; + private readonly Label _statusLabel; + private readonly ProgressBar _progressBar; + private readonly TextBox _logBox; + private readonly Button _closeButton; + private StreamWriter? _writer; + private readonly object _writerLock = new(); + private bool _closeRequested; + + public InstallerUiForm(string pipeName) + { + _pipeName = pipeName; + + Text = "covenant-setup"; + StartPosition = FormStartPosition.CenterScreen; + ClientSize = new Size(720, 420); + MinimumSize = new Size(560, 320); + Font = new Font("Segoe UI", 9F); + + _statusLabel = new Label + { + AutoEllipsis = true, + Location = new Point(12, 12), + Size = new Size(ClientSize.Width - 24, 24), + Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right, + Text = "Preparing..." + }; + + _progressBar = new ProgressBar + { + Location = new Point(12, 44), + Size = new Size(ClientSize.Width - 24, 24), + Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right, + Minimum = 0, + Maximum = 100 + }; + + _logBox = new TextBox + { + Location = new Point(12, 80), + Size = new Size(ClientSize.Width - 24, ClientSize.Height - 128), + Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right, + Multiline = true, + ScrollBars = ScrollBars.Vertical, + ReadOnly = true, + Font = new Font("Consolas", 9F) + }; + + _closeButton = new Button + { + Text = "Close", + Enabled = false, + Size = new Size(88, 28), + Location = new Point(ClientSize.Width - 100, ClientSize.Height - 40), + Anchor = AnchorStyles.Bottom | AnchorStyles.Right + }; + _closeButton.Click += (_, _) => Close(); + + Controls.Add(_statusLabel); + Controls.Add(_progressBar); + Controls.Add(_logBox); + Controls.Add(_closeButton); + + Shown += (_, _) => _ = Task.Run(RunPipeLoop); + FormClosing += (_, args) => + { + if (!_closeButton.Enabled && !_closeRequested) + { + args.Cancel = true; + } + }; + } + + private void RunPipeLoop() + { + try + { + UiTrace.Write("pipe_server_create", new { PipeName = _pipeName }); + using var pipe = new NamedPipeServerStream( + _pipeName, + PipeDirection.InOut, + 1, + PipeTransmissionMode.Byte, + PipeOptions.None); + UiTrace.Write("pipe_wait_for_connection", new { PipeName = _pipeName }); + pipe.WaitForConnection(); + UiTrace.Write("pipe_connected", new { PipeName = _pipeName }); + + using var reader = new StreamReader(pipe, new UTF8Encoding(false), detectEncodingFromByteOrderMarks: false, bufferSize: 4096, leaveOpen: true); + using var writer = new StreamWriter(pipe, new UTF8Encoding(false), bufferSize: 4096, leaveOpen: true) + { + AutoFlush = true, + NewLine = "\n" + }; + + lock (_writerLock) + { + _writer = writer; + } + + string? line; + while ((line = reader.ReadLine()) is not null) + { + UiTrace.Write("pipe_receive", SafeMessageSummary(line)); + if (!HandleMessage(line)) + { + break; + } + } + } + catch (Exception ex) + { + UiTrace.Write("pipe_error", new { ex.Message, ex.GetType().FullName, ex.StackTrace }); + BeginInvokeSafe(() => + { + AppendLog("UI pipe error: " + ex.Message); + _closeButton.Enabled = true; + }); + } + finally + { + lock (_writerLock) + { + _writer = null; + } + UiTrace.Write("pipe_loop_exit"); + } + } + + private bool HandleMessage(string line) + { + var message = JsonSerializer.Deserialize(line, JsonOptions); + if (message?.Type is null) + { + return true; + } + + switch (message.Type) + { + case "init": + BeginInvokeSafe(() => + { + Text = message.Title ?? "covenant-setup"; + _statusLabel.Text = message.Message ?? Text; + _progressBar.Value = 0; + }); + return true; + + case "progress": + BeginInvokeSafe(() => ApplyProgress(message)); + return true; + + case "log": + BeginInvokeSafe(() => AppendLog(message.Message ?? string.Empty)); + return true; + + case "finish": + UiTrace.Write("finish_message", new { message.Message }); + BeginInvokeSafe(() => + { + _statusLabel.Text = message.Message ?? "Complete"; + _progressBar.Value = 100; + _closeButton.Enabled = true; + _closeRequested = true; + Close(); + }); + return true; + + case "prompt": + UiTrace.Write("prompt_show_requested", new { message.Id, message.Title, message.Buttons, message.Icon }); + var result = ShowPrompt(message); + UiTrace.Write("prompt_response", new { message.Id, Result = result }); + WriteResponse(new UiResponse + { + Type = "prompt_response", + Id = message.Id, + Result = result + }); + return true; + + case "close": + UiTrace.Write("close_message"); + BeginInvokeSafe(() => + { + _closeRequested = true; + Close(); + }); + return false; + + default: + return true; + } + } + + private void ApplyProgress(UiMessage message) + { + if (!string.IsNullOrWhiteSpace(message.Message)) + { + _statusLabel.Text = message.Message; + AppendLog(message.Message); + } + + var total = Math.Max(1, message.TotalSteps ?? 1); + var current = Math.Max(0, Math.Min(total, message.CurrentStep ?? 0)); + _progressBar.Value = Math.Max(0, Math.Min(100, current * 100 / total)); + } + + private string ShowPrompt(UiMessage message) + { + if (InvokeRequired) + { + return (string)Invoke(new Func(() => ShowPrompt(message))); + } + + var buttons = message.Buttons switch + { + "ok_cancel" => MessageBoxButtons.OKCancel, + "yes_no" => MessageBoxButtons.YesNo, + _ => MessageBoxButtons.OK + }; + var icon = message.Icon switch + { + "error" => MessageBoxIcon.Error, + "warning" => MessageBoxIcon.Warning, + _ => MessageBoxIcon.Information + }; + + var result = MessageBox.Show( + this, + message.Message ?? string.Empty, + message.Title ?? "covenant-setup", + buttons, + icon); + UiTrace.Write("prompt_closed", new { message.Id, Result = result.ToString() }); + + return result switch + { + DialogResult.OK => "ok", + DialogResult.Cancel => "cancel", + DialogResult.Yes => "yes", + DialogResult.No => "no", + _ => "none" + }; + } + + private void WriteResponse(UiResponse response) + { + lock (_writerLock) + { + _writer?.WriteLine(JsonSerializer.Serialize(response, JsonOptions)); + UiTrace.Write("pipe_send", new { response.Type, response.Id, response.Result }); + } + } + + private void BeginInvokeSafe(Action action) + { + if (IsDisposed) + { + return; + } + + try + { + BeginInvoke(action); + } + catch (InvalidOperationException) + { + } + } + + private void AppendLog(string line) + { + if (string.IsNullOrWhiteSpace(line)) + { + return; + } + + if (_logBox.TextLength > 0) + { + _logBox.AppendText(Environment.NewLine); + } + _logBox.AppendText(line); + _logBox.SelectionStart = _logBox.TextLength; + _logBox.ScrollToCaret(); + } + + private static object SafeMessageSummary(string line) + { + try + { + using var document = JsonDocument.Parse(line); + var root = document.RootElement; + return new + { + Type = root.TryGetProperty("type", out var type) ? type.GetString() : null, + Id = root.TryGetProperty("id", out var id) ? id.GetString() : null, + Message = root.TryGetProperty("message", out var message) ? message.GetString() : null + }; + } + catch + { + return new { RawLength = line.Length }; + } + } +} + +internal static class UiTrace +{ + private static readonly object Lock = new(); + private static readonly string? TracePath = CreateTracePath(); + + public static void Write(string phase, object? detail = null) + { + if (TracePath is null) + { + return; + } + + try + { + var line = JsonSerializer.Serialize(new + { + time = DateTimeOffset.UtcNow.ToString("o"), + pid = Environment.ProcessId, + process = Process.GetCurrentProcess().ProcessName, + phase, + detail + }) + Environment.NewLine; + lock (Lock) + { + File.AppendAllText(TracePath, line, Encoding.UTF8); + } + } + catch + { + } + } + + private static string? CreateTracePath() + { + try + { + var root = Environment.GetEnvironmentVariable("COVENANT_SETUP_TRACE_DIR"); + if (string.IsNullOrWhiteSpace(root)) + { + return null; + } + + Directory.CreateDirectory(root); + return Path.Combine(root, $"csharp-ui-pipe-{Environment.ProcessId}.jsonl"); + } + catch + { + return null; + } + } +} + +internal sealed class UiMessage +{ + [JsonPropertyName("type")] + public string? Type { get; set; } + + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("title")] + public string? Title { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("current_step")] + public int? CurrentStep { get; set; } + + [JsonPropertyName("total_steps")] + public int? TotalSteps { get; set; } + + [JsonPropertyName("buttons")] + public string? Buttons { get; set; } + + [JsonPropertyName("icon")] + public string? Icon { get; set; } +} + +internal sealed class UiResponse +{ + [JsonPropertyName("type")] + public string? Type { get; set; } + + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("result")] + public string? Result { get; set; } +} diff --git a/ui/Covenant.Setup.Ui/app.manifest b/ui/Covenant.Setup.Ui/app.manifest new file mode 100644 index 0000000..9376958 --- /dev/null +++ b/ui/Covenant.Setup.Ui/app.manifest @@ -0,0 +1,11 @@ + + + + + + + + + + +