1364 lines
55 KiB
PowerShell
1364 lines
55 KiB
PowerShell
[CmdletBinding()]
|
|
param(
|
|
[ValidateRange(1024, 65535)]
|
|
[int]$Port = 9353,
|
|
|
|
[string]$Output,
|
|
|
|
[string]$Screenshot,
|
|
|
|
[string]$ExchangeDirectory,
|
|
|
|
[string]$NodePath,
|
|
|
|
[ValidateRange(10, 120)]
|
|
[int]$LaunchTimeoutSeconds = 30,
|
|
|
|
[ValidateRange(60, 900)]
|
|
[int]$HarnessTimeoutSeconds = 180,
|
|
|
|
[switch]$StaticAudit
|
|
)
|
|
|
|
Set-StrictMode -Version Latest
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
$requestedPort = $Port
|
|
$requestedOutput = $Output
|
|
$requestedScreenshot = $Screenshot
|
|
$requestedExchangeDirectory = $ExchangeDirectory
|
|
$requestedNodePath = $NodePath
|
|
$requestedLaunchTimeout = $LaunchTimeoutSeconds
|
|
$requestedHarnessTimeout = $HarnessTimeoutSeconds
|
|
$sharedScript = Join-Path $PSScriptRoot 'Invoke-LegacyPackageInputSmoke.ps1'
|
|
if (-not [IO.File]::Exists($sharedScript)) {
|
|
throw [InvalidOperationException]::new('The shared package input safety script is missing.')
|
|
}
|
|
|
|
# Reuse the exact Debug AppX package, DryRun, loopback CDP, package identity,
|
|
# process ownership, Tornado isolation and neutral normal-close guards. This
|
|
# definitions-only import launches nothing.
|
|
. $sharedScript `
|
|
-BuildFlavor DebugAppX `
|
|
-Profile ReadOnly `
|
|
-Port $requestedPort `
|
|
-Output $requestedOutput `
|
|
-Screenshot $requestedScreenshot `
|
|
-NodePath $requestedNodePath `
|
|
-LaunchTimeoutSeconds $requestedLaunchTimeout `
|
|
-HarnessTimeoutSeconds $requestedHarnessTimeout `
|
|
-DefinitionsOnly
|
|
|
|
$Port = $requestedPort
|
|
$Output = $requestedOutput
|
|
$Screenshot = $requestedScreenshot
|
|
$ExchangeDirectory = $requestedExchangeDirectory
|
|
$NodePath = $requestedNodePath
|
|
$LaunchTimeoutSeconds = $requestedLaunchTimeout
|
|
$HarnessTimeoutSeconds = $requestedHarnessTimeout
|
|
$HarnessPath = Join-Path $PSScriptRoot 'Test-LegacyPackageAppearanceSmoke.mjs'
|
|
|
|
Add-Type -TypeDefinition @'
|
|
using System;
|
|
using System.Runtime.InteropServices;
|
|
using System.Threading;
|
|
|
|
public static class LegacyAppearanceInputNative
|
|
{
|
|
[StructLayout(LayoutKind.Sequential)]
|
|
private struct MouseInput
|
|
{
|
|
public int Dx;
|
|
public int Dy;
|
|
public uint MouseData;
|
|
public uint Flags;
|
|
public uint Time;
|
|
public UIntPtr ExtraInfo;
|
|
}
|
|
|
|
[StructLayout(LayoutKind.Sequential)]
|
|
private struct KeyboardInput
|
|
{
|
|
public ushort VirtualKey;
|
|
public ushort ScanCode;
|
|
public uint Flags;
|
|
public uint Time;
|
|
public UIntPtr ExtraInfo;
|
|
}
|
|
|
|
[StructLayout(LayoutKind.Sequential)]
|
|
private struct HardwareInput
|
|
{
|
|
public uint Message;
|
|
public ushort ParameterLow;
|
|
public ushort ParameterHigh;
|
|
}
|
|
|
|
[StructLayout(LayoutKind.Explicit)]
|
|
private struct InputUnion
|
|
{
|
|
[FieldOffset(0)] public MouseInput Mouse;
|
|
[FieldOffset(0)] public KeyboardInput Keyboard;
|
|
[FieldOffset(0)] public HardwareInput Hardware;
|
|
}
|
|
|
|
[StructLayout(LayoutKind.Sequential)]
|
|
private struct Input
|
|
{
|
|
public uint Type;
|
|
public InputUnion Union;
|
|
}
|
|
|
|
[DllImport("user32.dll")]
|
|
private static extern uint SendInput(
|
|
uint inputCount,
|
|
Input[] inputs,
|
|
int inputSize);
|
|
|
|
[DllImport("user32.dll")]
|
|
private static extern short GetAsyncKeyState(int virtualKey);
|
|
|
|
[DllImport("user32.dll")]
|
|
private static extern IntPtr GetForegroundWindow();
|
|
|
|
[DllImport("user32.dll")]
|
|
private static extern IntPtr GetAncestor(IntPtr window, uint flags);
|
|
|
|
[DllImport("user32.dll")]
|
|
private static extern bool IsWindow(IntPtr window);
|
|
|
|
private const uint InputKeyboard = 1;
|
|
private const uint KeyUp = 0x0002;
|
|
private const uint GetAncestorRoot = 2;
|
|
private const ushort VirtualKeyHome = 0x24;
|
|
private const ushort VirtualKeyDown = 0x28;
|
|
private const ushort VirtualKeyEnter = 0x0D;
|
|
|
|
public static int InputStructureBytes()
|
|
{
|
|
return Marshal.SizeOf(typeof(Input));
|
|
}
|
|
|
|
public static int ExpectedInputStructureBytes()
|
|
{
|
|
return IntPtr.Size == 8 ? 40 : 28;
|
|
}
|
|
|
|
public static void SendClosedSelectKeys(IntPtr expectedRoot, int optionIndex)
|
|
{
|
|
if (expectedRoot == IntPtr.Zero || !IsWindow(expectedRoot) ||
|
|
optionIndex < 0 || optionIndex > 2 ||
|
|
GetAncestor(GetForegroundWindow(), GetAncestorRoot) != expectedRoot)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The closed appearance select keyboard contract is invalid.");
|
|
}
|
|
|
|
AssertReleased(VirtualKeyHome);
|
|
AssertReleased(VirtualKeyDown);
|
|
AssertReleased(VirtualKeyEnter);
|
|
ushort held = 0;
|
|
try
|
|
{
|
|
SendKey(VirtualKeyHome, false);
|
|
held = VirtualKeyHome;
|
|
Thread.Sleep(45);
|
|
SendKey(VirtualKeyHome, true);
|
|
held = 0;
|
|
Thread.Sleep(60);
|
|
|
|
for (int index = 0; index < optionIndex; index++)
|
|
{
|
|
AssertForeground(expectedRoot);
|
|
SendKey(VirtualKeyDown, false);
|
|
held = VirtualKeyDown;
|
|
Thread.Sleep(45);
|
|
SendKey(VirtualKeyDown, true);
|
|
held = 0;
|
|
Thread.Sleep(60);
|
|
}
|
|
|
|
AssertForeground(expectedRoot);
|
|
SendKey(VirtualKeyEnter, false);
|
|
held = VirtualKeyEnter;
|
|
Thread.Sleep(45);
|
|
SendKey(VirtualKeyEnter, true);
|
|
held = 0;
|
|
Thread.Sleep(300);
|
|
AssertForeground(expectedRoot);
|
|
}
|
|
finally
|
|
{
|
|
if (held != 0) SendKey(held, true);
|
|
AssertReleased(VirtualKeyHome);
|
|
AssertReleased(VirtualKeyDown);
|
|
AssertReleased(VirtualKeyEnter);
|
|
}
|
|
}
|
|
|
|
private static void AssertForeground(IntPtr expectedRoot)
|
|
{
|
|
if (GetAncestor(GetForegroundWindow(), GetAncestorRoot) != expectedRoot)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The exact package window lost foreground during appearance input.");
|
|
}
|
|
}
|
|
|
|
private static void AssertReleased(ushort virtualKey)
|
|
{
|
|
if ((GetAsyncKeyState(virtualKey) & 0x8000) != 0)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"An appearance input key remained held.");
|
|
}
|
|
}
|
|
|
|
private static void SendKey(ushort virtualKey, bool keyUp)
|
|
{
|
|
int inputSize = InputStructureBytes();
|
|
if (inputSize != ExpectedInputStructureBytes())
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The Win32 INPUT structure size is not ABI-compatible.");
|
|
}
|
|
Input input = new Input {
|
|
Type = InputKeyboard,
|
|
Union = new InputUnion {
|
|
Keyboard = new KeyboardInput {
|
|
VirtualKey = virtualKey,
|
|
Flags = keyUp ? KeyUp : 0
|
|
}
|
|
}
|
|
};
|
|
if (SendInput(1, new Input[] { input }, inputSize) != 1)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Windows did not accept the one-shot appearance key input.");
|
|
}
|
|
}
|
|
}
|
|
'@
|
|
|
|
$script:Phase = 'preflight'
|
|
$script:ApplicationProcess = $null
|
|
$script:ApplicationWasLaunched = $false
|
|
$script:CurrentApplicationClosed = $true
|
|
$script:AppearanceRequestCalls = 0
|
|
$script:AppearanceAcknowledgementCalls = 0
|
|
$script:BaselineSettingsState = $null
|
|
$script:BaselineSettingsBytes = $null
|
|
$script:BaselineRecoveryPath = $null
|
|
$script:BaselineRecoveryCreated = $false
|
|
$script:BaselineRecoverySha256 = $null
|
|
$script:BaselineRestored = $false
|
|
$script:BaselineRestoreAttempted = $false
|
|
|
|
function Test-ClosedAppearanceValue(
|
|
[string]$Kind,
|
|
[object]$Value) {
|
|
if ($Value -isnot [string]) { return $false }
|
|
switch -CaseSensitive ($Kind) {
|
|
'colorTheme' { return @('system', 'light', 'dark') -ccontains [string]$Value }
|
|
'viewMode' { return @('automatic', 'compact', 'cards') -ccontains [string]$Value }
|
|
'startWorkspace' { return @('stockCut', 'lastWorkspace') -ccontains [string]$Value }
|
|
default { return $false }
|
|
}
|
|
}
|
|
|
|
function New-Appearance(
|
|
[string]$ColorTheme,
|
|
[string]$ViewMode,
|
|
[string]$StartWorkspace) {
|
|
if (-not (Test-ClosedAppearanceValue 'colorTheme' $ColorTheme) -or
|
|
-not (Test-ClosedAppearanceValue 'viewMode' $ViewMode) -or
|
|
-not (Test-ClosedAppearanceValue 'startWorkspace' $StartWorkspace)) {
|
|
Throw-SafeFailure 'An appearance projection is outside the closed schema.'
|
|
}
|
|
return [pscustomobject][ordered]@{
|
|
colorTheme = $ColorTheme
|
|
viewMode = $ViewMode
|
|
startWorkspace = $StartWorkspace
|
|
}
|
|
}
|
|
|
|
function Test-AppearanceEqual([object]$Left, [object]$Right) {
|
|
return $null -ne $Left -and $null -ne $Right -and
|
|
[string]$Left.colorTheme -ceq [string]$Right.colorTheme -and
|
|
[string]$Left.viewMode -ceq [string]$Right.viewMode -and
|
|
[string]$Left.startWorkspace -ceq [string]$Right.startWorkspace
|
|
}
|
|
|
|
function Get-ContextHash(
|
|
[object]$SceneDirectory,
|
|
[object]$ResourceDirectory,
|
|
[object]$BackgroundDirectory,
|
|
[object]$NavigationExpanded) {
|
|
$context = [ordered]@{
|
|
sceneDirectory = $SceneDirectory
|
|
resourceDirectory = $ResourceDirectory
|
|
backgroundDirectory = $BackgroundDirectory
|
|
navigationExpanded = $NavigationExpanded
|
|
}
|
|
$bytes = $Utf8.GetBytes(($context | ConvertTo-Json -Compress))
|
|
return Get-ByteArraySha256 $bytes
|
|
}
|
|
|
|
function Assert-SettingsScalarTypes([object]$Value) {
|
|
foreach ($name in @('sceneDirectory', 'resourceDirectory', 'backgroundDirectory')) {
|
|
$candidate = $Value.$name
|
|
if ($null -ne $candidate -and $candidate -isnot [string]) {
|
|
Throw-SafeFailure 'The operator settings folder projection is not a string or null.'
|
|
}
|
|
}
|
|
if ($Value.navigationExpanded -isnot [bool]) {
|
|
Throw-SafeFailure 'The operator navigation setting is not Boolean.'
|
|
}
|
|
}
|
|
|
|
function Get-OperatorSettingsFileState {
|
|
if (-not [IO.File]::Exists($OperatorSettingsPath)) {
|
|
return [pscustomobject][ordered]@{
|
|
exists = $false
|
|
length = 0
|
|
sha256 = $null
|
|
schemaVersion = 0
|
|
contextSha256 = Get-ContextHash $null $null $null $true
|
|
appearance = New-Appearance 'system' 'automatic' 'stockCut'
|
|
}
|
|
}
|
|
|
|
$item = Get-Item -LiteralPath $OperatorSettingsPath -Force
|
|
if ($item.PSIsContainer -or
|
|
($item.Attributes -band ([IO.FileAttributes]::ReparsePoint -bor
|
|
[IO.FileAttributes]::Device)) -ne 0 -or
|
|
$item.Length -le 0 -or $item.Length -gt 16KB) {
|
|
Throw-SafeFailure 'The operator settings file is not a bounded regular file.'
|
|
}
|
|
$bytes = [IO.File]::ReadAllBytes($OperatorSettingsPath)
|
|
if ($bytes.Length -ne $item.Length) {
|
|
Throw-SafeFailure 'The operator settings file changed while it was read.'
|
|
}
|
|
try {
|
|
$raw = $StrictUtf8.GetString($bytes)
|
|
$value = $raw | ConvertFrom-Json -ErrorAction Stop
|
|
}
|
|
catch {
|
|
Throw-SafeFailure 'The operator settings file is not strict UTF-8 JSON.'
|
|
}
|
|
if ($null -eq $value -or
|
|
(ConvertTo-FiniteNumber $value.schemaVersion 'Operator settings schema version') `
|
|
-notin @(1, 2)) {
|
|
Throw-SafeFailure 'The operator settings schema version is unsupported.'
|
|
}
|
|
$schemaVersion = [int]$value.schemaVersion
|
|
$expectedNames = if ($schemaVersion -eq 1) {
|
|
@('schemaVersion', 'sceneDirectory', 'resourceDirectory',
|
|
'backgroundDirectory', 'navigationExpanded')
|
|
}
|
|
else {
|
|
@('schemaVersion', 'sceneDirectory', 'resourceDirectory',
|
|
'backgroundDirectory', 'navigationExpanded', 'colorTheme',
|
|
'viewMode', 'startWorkspace')
|
|
}
|
|
Assert-ExactJsonPropertyNames $value $expectedNames 'Operator settings JSON'
|
|
foreach ($name in $expectedNames) {
|
|
$escapedName = [regex]::Escape($name)
|
|
if ([regex]::Matches($raw, '"' + $escapedName + '"\s*:').Count -ne 1) {
|
|
Throw-SafeFailure 'The operator settings JSON contains a duplicate property.'
|
|
}
|
|
}
|
|
Assert-SettingsScalarTypes $value
|
|
$appearance = if ($schemaVersion -eq 1) {
|
|
New-Appearance 'system' 'automatic' 'stockCut'
|
|
}
|
|
else {
|
|
New-Appearance `
|
|
([string]$value.colorTheme) `
|
|
([string]$value.viewMode) `
|
|
([string]$value.startWorkspace)
|
|
}
|
|
return [pscustomobject][ordered]@{
|
|
exists = $true
|
|
length = $bytes.Length
|
|
sha256 = Get-ByteArraySha256 $bytes
|
|
schemaVersion = $schemaVersion
|
|
contextSha256 = Get-ContextHash `
|
|
$value.sceneDirectory `
|
|
$value.resourceDirectory `
|
|
$value.backgroundDirectory `
|
|
$value.navigationExpanded
|
|
appearance = $appearance
|
|
}
|
|
}
|
|
|
|
function Wait-OperatorSettingsAppearance(
|
|
[object]$ExpectedAppearance,
|
|
[string]$ExpectedContextSha256,
|
|
[datetime]$Deadline) {
|
|
while ([datetime]::UtcNow -lt $Deadline) {
|
|
try {
|
|
$state = Get-OperatorSettingsFileState
|
|
if ($state.exists -eq $true -and $state.schemaVersion -eq 2 -and
|
|
[string]$state.contextSha256 -ceq $ExpectedContextSha256 -and
|
|
(Test-AppearanceEqual $state.appearance $ExpectedAppearance)) {
|
|
return $state
|
|
}
|
|
}
|
|
catch {
|
|
# The native atomic save can briefly move between generations. The
|
|
# bounded wait only observes; it never reissues input.
|
|
}
|
|
Start-Sleep -Milliseconds 50
|
|
}
|
|
Throw-SafeFailure 'The expected appearance settings file was not observed after one input.'
|
|
}
|
|
|
|
function Initialize-ExactBaselineRecovery([string]$RecoveryPath) {
|
|
$script:BaselineSettingsState = Get-OperatorSettingsFileState
|
|
$script:BaselineSettingsBytes = $null
|
|
$script:BaselineRecoveryPath = $RecoveryPath
|
|
if ($script:BaselineSettingsState.exists -eq $true) {
|
|
$bytes = [IO.File]::ReadAllBytes($OperatorSettingsPath)
|
|
if ((Get-ByteArraySha256 $bytes) -cne
|
|
[string]$script:BaselineSettingsState.sha256) {
|
|
Throw-SafeFailure 'The operator settings baseline changed before backup.'
|
|
}
|
|
$stream = [IO.FileStream]::new(
|
|
$RecoveryPath,
|
|
[IO.FileMode]::CreateNew,
|
|
[IO.FileAccess]::Write,
|
|
[IO.FileShare]::None)
|
|
try {
|
|
$stream.Write($bytes, 0, $bytes.Length)
|
|
$stream.Flush($true)
|
|
}
|
|
finally { $stream.Dispose() }
|
|
$script:BaselineSettingsBytes = $bytes
|
|
$script:BaselineRecoveryCreated = $true
|
|
$script:BaselineRecoverySha256 = Get-ByteArraySha256 $bytes
|
|
}
|
|
else {
|
|
$marker = $Utf8.GetBytes(
|
|
'{"schemaVersion":1,"operatorSettingsOriginallyAbsent":true}' + "`n")
|
|
$stream = [IO.FileStream]::new(
|
|
$RecoveryPath,
|
|
[IO.FileMode]::CreateNew,
|
|
[IO.FileAccess]::Write,
|
|
[IO.FileShare]::None)
|
|
try {
|
|
$stream.Write($marker, 0, $marker.Length)
|
|
$stream.Flush($true)
|
|
}
|
|
finally { $stream.Dispose() }
|
|
$script:BaselineRecoveryCreated = $true
|
|
$script:BaselineRecoverySha256 = Get-ByteArraySha256 $marker
|
|
}
|
|
}
|
|
|
|
function Restore-ExactBaselineSettings {
|
|
if ($script:BaselineRestoreAttempted) {
|
|
Throw-SafeFailure 'The exact operator settings restoration is one-shot and was already attempted.'
|
|
}
|
|
$script:BaselineRestoreAttempted = $true
|
|
if (@(Get-LegacyApplicationProcesses).Count -ne 0) {
|
|
Throw-SafeFailure 'The exact operator settings baseline cannot be restored while the app runs.'
|
|
}
|
|
$directory = [IO.Path]::GetDirectoryName($OperatorSettingsPath)
|
|
if ([string]::IsNullOrWhiteSpace($directory)) {
|
|
Throw-SafeFailure 'The operator settings restore directory is invalid.'
|
|
}
|
|
if ($script:BaselineSettingsState.exists -eq $true) {
|
|
if (-not $script:BaselineRecoveryCreated -or
|
|
-not [IO.File]::Exists($script:BaselineRecoveryPath)) {
|
|
Throw-SafeFailure 'The exact operator settings recovery copy is missing.'
|
|
}
|
|
$recoveryItem = Get-Item -LiteralPath $script:BaselineRecoveryPath -Force
|
|
$recoveryBytes = [IO.File]::ReadAllBytes($script:BaselineRecoveryPath)
|
|
if ($recoveryItem.PSIsContainer -or
|
|
($recoveryItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or
|
|
$recoveryBytes.Length -ne $recoveryItem.Length -or
|
|
(Get-ByteArraySha256 $recoveryBytes) -cne
|
|
[string]$script:BaselineSettingsState.sha256) {
|
|
Throw-SafeFailure 'The exact operator settings recovery copy changed.'
|
|
}
|
|
if (-not [IO.Directory]::Exists($directory)) {
|
|
[void][IO.Directory]::CreateDirectory($directory)
|
|
}
|
|
$temporaryPath = Join-Path $directory (
|
|
'.runtime-folders.local.json.' + [Guid]::NewGuid().ToString('N') + '.restore.tmp')
|
|
try {
|
|
$stream = [IO.FileStream]::new(
|
|
$temporaryPath,
|
|
[IO.FileMode]::CreateNew,
|
|
[IO.FileAccess]::Write,
|
|
[IO.FileShare]::None)
|
|
try {
|
|
$stream.Write($recoveryBytes, 0, $recoveryBytes.Length)
|
|
$stream.Flush($true)
|
|
}
|
|
finally { $stream.Dispose() }
|
|
if ([IO.File]::Exists($OperatorSettingsPath)) {
|
|
$targetItem = Get-Item -LiteralPath $OperatorSettingsPath -Force
|
|
if ($targetItem.PSIsContainer -or
|
|
($targetItem.Attributes -band ([IO.FileAttributes]::ReparsePoint -bor
|
|
[IO.FileAttributes]::Device)) -ne 0) {
|
|
Throw-SafeFailure 'The operator settings restore target became unsafe.'
|
|
}
|
|
[IO.File]::Replace($temporaryPath, $OperatorSettingsPath, $null, $true)
|
|
}
|
|
else {
|
|
[IO.File]::Move($temporaryPath, $OperatorSettingsPath)
|
|
}
|
|
$temporaryPath = $null
|
|
}
|
|
finally {
|
|
if ($null -ne $temporaryPath -and [IO.File]::Exists($temporaryPath)) {
|
|
[IO.File]::Delete($temporaryPath)
|
|
}
|
|
}
|
|
}
|
|
elseif ([IO.File]::Exists($OperatorSettingsPath)) {
|
|
if (-not $script:BaselineRecoveryCreated -or
|
|
-not [IO.File]::Exists($script:BaselineRecoveryPath) -or
|
|
(Get-FileHash -Algorithm SHA256 -LiteralPath `
|
|
$script:BaselineRecoveryPath).Hash -cne
|
|
[string]$script:BaselineRecoverySha256) {
|
|
Throw-SafeFailure 'The original-absence recovery marker changed.'
|
|
}
|
|
$targetItem = Get-Item -LiteralPath $OperatorSettingsPath -Force
|
|
if ($targetItem.PSIsContainer -or
|
|
($targetItem.Attributes -band ([IO.FileAttributes]::ReparsePoint -bor
|
|
[IO.FileAttributes]::Device)) -ne 0) {
|
|
Throw-SafeFailure 'The newly created operator settings restore target became unsafe.'
|
|
}
|
|
[IO.File]::Delete($OperatorSettingsPath)
|
|
}
|
|
|
|
$restored = Get-OperatorSettingsFileState
|
|
if ($restored.exists -ne $script:BaselineSettingsState.exists -or
|
|
($restored.exists -eq $true -and
|
|
[string]$restored.sha256 -cne [string]$script:BaselineSettingsState.sha256)) {
|
|
Throw-SafeFailure 'The exact original operator settings bytes were not restored.'
|
|
}
|
|
if (-not $script:BaselineSettingsState.exists -and
|
|
$script:BaselineRecoveryCreated -and
|
|
-not [IO.File]::Exists($OperatorSettingsPath) -and
|
|
(Get-FileHash -Algorithm SHA256 -LiteralPath `
|
|
$script:BaselineRecoveryPath).Hash -cne
|
|
[string]$script:BaselineRecoverySha256) {
|
|
Throw-SafeFailure 'The original-absence recovery marker changed before cleanup.'
|
|
}
|
|
if ($script:BaselineRecoveryCreated -and
|
|
[IO.File]::Exists($script:BaselineRecoveryPath)) {
|
|
[IO.File]::Delete($script:BaselineRecoveryPath)
|
|
$script:BaselineRecoveryCreated = $false
|
|
}
|
|
$script:BaselineRestored = $true
|
|
return $restored
|
|
}
|
|
|
|
function Get-AlternateAppearance([object]$Baseline) {
|
|
return New-Appearance `
|
|
$(if ([string]$Baseline.colorTheme -cne 'dark') { 'dark' } else { 'light' }) `
|
|
$(if ([string]$Baseline.viewMode -cne 'cards') { 'cards' } else { 'compact' }) `
|
|
$(if ([string]$Baseline.startWorkspace -cne 'lastWorkspace') {
|
|
'lastWorkspace'
|
|
}
|
|
else { 'stockCut' })
|
|
}
|
|
|
|
function Get-SequenceAppearance(
|
|
[int]$Sequence,
|
|
[object]$Initial,
|
|
[object]$Final,
|
|
[switch]$After) {
|
|
$color = [string]$Initial.colorTheme
|
|
$view = [string]$Initial.viewMode
|
|
$start = [string]$Initial.startWorkspace
|
|
if ($Sequence -ge 2 -and $After) { $color = [string]$Final.colorTheme }
|
|
if ($Sequence -ge 3) { $color = [string]$Final.colorTheme }
|
|
if ($Sequence -ge 3 -and $After) { $view = [string]$Final.viewMode }
|
|
if ($Sequence -ge 4) {
|
|
$color = [string]$Final.colorTheme
|
|
$view = [string]$Final.viewMode
|
|
}
|
|
if ($Sequence -ge 4 -and $After) { $start = [string]$Final.startWorkspace }
|
|
return New-Appearance $color $view $start
|
|
}
|
|
|
|
function Write-AppearanceAcknowledgement(
|
|
[string]$Path,
|
|
[object]$Request,
|
|
[object]$SettingsState) {
|
|
$acknowledgement = [ordered]@{
|
|
schemaVersion = 1
|
|
phase = [string]$Request.phase
|
|
sequence = [int]$Request.sequence
|
|
token = [string]$Request.token
|
|
result = 'PASS'
|
|
operation = [string]$Request.operation
|
|
targetId = [string]$Request.targetId
|
|
appearanceKey = if ($null -eq $Request.appearanceKey) { $null } else {
|
|
[string]$Request.appearanceKey
|
|
}
|
|
targetValue = if ($null -eq $Request.targetValue) { $null } else {
|
|
[string]$Request.targetValue
|
|
}
|
|
optionIndex = if ($null -eq $Request.optionIndex) { $null } else {
|
|
[int]$Request.optionIndex
|
|
}
|
|
packageIdentityValidated = $true
|
|
dryRunValidated = $true
|
|
tornadoConnectionCount = 0
|
|
inputRetryCount = 0
|
|
mouseDownCalls = 1
|
|
mouseUpCalls = 1
|
|
homeKeyCalls = [int]$Request.homeKeyCalls
|
|
arrowDownKeyCalls = [int]$Request.arrowDownKeyCalls
|
|
enterKeyCalls = [int]$Request.enterKeyCalls
|
|
settings = [ordered]@{
|
|
exists = [bool]$SettingsState.exists
|
|
length = [int64]$SettingsState.length
|
|
sha256 = [string]$SettingsState.sha256
|
|
schemaVersion = [int]$SettingsState.schemaVersion
|
|
contextSha256 = [string]$SettingsState.contextSha256
|
|
appearance = $SettingsState.appearance
|
|
}
|
|
}
|
|
$bytes = $Utf8.GetBytes(($acknowledgement | ConvertTo-Json -Depth 8 -Compress) + "`n")
|
|
$temporaryPath = Join-Path ([IO.Path]::GetDirectoryName($Path)) (
|
|
([IO.Path]::GetFileName($Path)) + '.' + [Guid]::NewGuid().ToString('N') + '.tmp')
|
|
try {
|
|
$stream = [IO.FileStream]::new(
|
|
$temporaryPath,
|
|
[IO.FileMode]::CreateNew,
|
|
[IO.FileAccess]::Write,
|
|
[IO.FileShare]::None)
|
|
try {
|
|
$stream.Write($bytes, 0, $bytes.Length)
|
|
$stream.Flush($true)
|
|
}
|
|
finally { $stream.Dispose() }
|
|
[IO.File]::Move($temporaryPath, $Path)
|
|
$temporaryPath = $null
|
|
}
|
|
finally {
|
|
if ($null -ne $temporaryPath -and [IO.File]::Exists($temporaryPath)) {
|
|
[IO.File]::Delete($temporaryPath)
|
|
}
|
|
}
|
|
return [pscustomobject]@{
|
|
Bytes = $bytes
|
|
Sha256 = Get-ByteArraySha256 $bytes
|
|
}
|
|
}
|
|
|
|
function Assert-RequestAppearance([object]$Value, [object]$Expected, [string]$Label) {
|
|
Assert-ExactJsonPropertyNames $Value @(
|
|
'colorTheme', 'viewMode', 'startWorkspace') $Label
|
|
if (-not (Test-AppearanceEqual $Value $Expected)) {
|
|
Throw-SafeFailure "$Label does not match the closed phase progression."
|
|
}
|
|
}
|
|
|
|
function Invoke-ExactAppearanceRequest(
|
|
[string]$RequestPath,
|
|
[string]$AcknowledgementPath,
|
|
[int]$ExpectedSequence,
|
|
[string]$ExpectedPhase,
|
|
[object]$PhaseInitial,
|
|
[object]$PhaseFinal,
|
|
[Diagnostics.Process]$Application,
|
|
[string]$ExpectedApplicationPath) {
|
|
$script:AppearanceRequestCalls++
|
|
if ($script:AppearanceRequestCalls -ne $ExpectedSequence -or
|
|
[IO.File]::Exists($AcknowledgementPath)) {
|
|
Throw-SafeFailure 'The appearance input one-shot budget changed.'
|
|
}
|
|
$requestEvidence = Wait-ReadSmallJsonEvidenceFile `
|
|
$RequestPath `
|
|
'Appearance input request' `
|
|
([datetime]::UtcNow.AddSeconds(3))
|
|
$request = $requestEvidence.Value
|
|
Assert-ExactJsonPropertyNames $request @(
|
|
'schemaVersion', 'phase', 'sequence', 'token', 'targetUrl',
|
|
'operation', 'targetId', 'appearanceKey', 'targetValue',
|
|
'optionIndex', 'expectedBefore', 'expectedAfter', 'point',
|
|
'viewport', 'devicePixelRatio', 'mouseDownCalls', 'mouseUpCalls',
|
|
'homeKeyCalls', 'arrowDownKeyCalls', 'enterKeyCalls',
|
|
'inputRetryCount') 'Appearance input request'
|
|
Assert-ExactJsonPropertyNames $request.viewport @('width', 'height') `
|
|
'Appearance input viewport'
|
|
$token = [string]$request.token
|
|
if ((ConvertTo-FiniteNumber $request.schemaVersion 'Appearance input schema') -ne 1 -or
|
|
(ConvertTo-FiniteNumber $request.sequence 'Appearance input sequence') -ne
|
|
$ExpectedSequence -or
|
|
$request.phase -isnot [string] -or [string]$request.phase -cne $ExpectedPhase -or
|
|
$request.targetUrl -isnot [string] -or
|
|
[string]$request.targetUrl -cne
|
|
'https://legacy-parity.mbn.local/index.html' -or
|
|
$request.token -isnot [string] -or $token -cnotmatch '\A[0-9A-F]{32}\z' -or
|
|
(ConvertTo-FiniteNumber $request.inputRetryCount 'Appearance input retry count') -ne 0 -or
|
|
(ConvertTo-FiniteNumber $request.mouseDownCalls 'Appearance mouse-down count') -ne 1 -or
|
|
(ConvertTo-FiniteNumber $request.mouseUpCalls 'Appearance mouse-up count') -ne 1) {
|
|
Throw-SafeFailure 'The appearance input request violates the common closed contract.'
|
|
}
|
|
$expectedBefore = Get-SequenceAppearance `
|
|
$ExpectedSequence $PhaseInitial $PhaseFinal
|
|
$expectedAfter = Get-SequenceAppearance `
|
|
$ExpectedSequence $PhaseInitial $PhaseFinal -After
|
|
Assert-RequestAppearance $request.expectedBefore $expectedBefore `
|
|
'Appearance expected-before projection'
|
|
Assert-RequestAppearance $request.expectedAfter $expectedAfter `
|
|
'Appearance expected-after projection'
|
|
|
|
$plan = switch ($ExpectedSequence) {
|
|
1 { [pscustomobject]@{
|
|
Operation = 'application-click'; TargetId = 'workspace-settings-tab'
|
|
AppearanceKey = $null; TargetValue = $null; OptionIndex = $null
|
|
}; break }
|
|
2 { [pscustomobject]@{
|
|
Operation = 'select-appearance-option'; TargetId = 'operator-color-theme'
|
|
AppearanceKey = 'colorTheme'; TargetValue = [string]$PhaseFinal.colorTheme
|
|
OptionIndex = @('system', 'light', 'dark').IndexOf(
|
|
[string]$PhaseFinal.colorTheme)
|
|
}; break }
|
|
3 { [pscustomobject]@{
|
|
Operation = 'select-appearance-option'; TargetId = 'operator-view-mode'
|
|
AppearanceKey = 'viewMode'; TargetValue = [string]$PhaseFinal.viewMode
|
|
OptionIndex = @('automatic', 'compact', 'cards').IndexOf(
|
|
[string]$PhaseFinal.viewMode)
|
|
}; break }
|
|
4 { [pscustomobject]@{
|
|
Operation = 'select-appearance-option'; TargetId = 'operator-start-workspace'
|
|
AppearanceKey = 'startWorkspace'; TargetValue =
|
|
[string]$PhaseFinal.startWorkspace
|
|
OptionIndex = @('stockCut', 'lastWorkspace').IndexOf(
|
|
[string]$PhaseFinal.startWorkspace)
|
|
}; break }
|
|
default { Throw-SafeFailure 'The appearance input sequence is outside its four-step plan.' }
|
|
}
|
|
$actualAppearanceKey = if ($null -eq $request.appearanceKey) { $null } else {
|
|
[string]$request.appearanceKey
|
|
}
|
|
$actualTargetValue = if ($null -eq $request.targetValue) { $null } else {
|
|
[string]$request.targetValue
|
|
}
|
|
$actualOptionIndex = if ($null -eq $request.optionIndex) { $null } else {
|
|
[int](ConvertTo-FiniteNumber $request.optionIndex 'Appearance option index')
|
|
}
|
|
$homeCount = ConvertTo-FiniteNumber $request.homeKeyCalls 'Appearance Home count'
|
|
$downCount = ConvertTo-FiniteNumber $request.arrowDownKeyCalls `
|
|
'Appearance ArrowDown count'
|
|
$enterCount = ConvertTo-FiniteNumber $request.enterKeyCalls 'Appearance Enter count'
|
|
if ($request.operation -isnot [string] -or
|
|
[string]$request.operation -cne $Plan.Operation -or
|
|
$request.targetId -isnot [string] -or
|
|
[string]$request.targetId -cne $Plan.TargetId -or
|
|
$actualAppearanceKey -cne $Plan.AppearanceKey -or
|
|
$actualTargetValue -cne $Plan.TargetValue -or
|
|
$actualOptionIndex -ne $Plan.OptionIndex -or
|
|
$homeCount -ne $(if ($ExpectedSequence -eq 1) { 0 } else { 1 }) -or
|
|
$downCount -ne $(if ($ExpectedSequence -eq 1) { 0 } else { $Plan.OptionIndex }) -or
|
|
$enterCount -ne $(if ($ExpectedSequence -eq 1) { 0 } else { 1 })) {
|
|
Throw-SafeFailure 'The appearance input request is outside the exact control plan.'
|
|
}
|
|
|
|
$viewportWidth = ConvertTo-FiniteNumber $request.viewport.width `
|
|
'Appearance viewport width'
|
|
$viewportHeight = ConvertTo-FiniteNumber $request.viewport.height `
|
|
'Appearance viewport height'
|
|
$devicePixelRatio = ConvertTo-FiniteNumber $request.devicePixelRatio `
|
|
'Appearance device pixel ratio'
|
|
if ($viewportWidth -lt 1 -or $viewportWidth -gt 16384 -or
|
|
$viewportHeight -lt 1 -or $viewportHeight -gt 16384 -or
|
|
$devicePixelRatio -lt 0.25 -or $devicePixelRatio -gt 5) {
|
|
Throw-SafeFailure 'The appearance viewport contract is invalid.'
|
|
}
|
|
$point = ConvertTo-ExactInputPoint `
|
|
$request.point $viewportWidth $viewportHeight 'Appearance input point'
|
|
$beforeFile = Get-OperatorSettingsFileState
|
|
if ([string]$beforeFile.contextSha256 -cne
|
|
[string]$script:BaselineSettingsState.contextSha256 -or
|
|
-not (Test-AppearanceEqual $beforeFile.appearance $expectedBefore)) {
|
|
Throw-SafeFailure 'The operator settings file changed before physical appearance input.'
|
|
}
|
|
|
|
Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath
|
|
Assert-NoTornadoConnection $Application.Id
|
|
[LegacyPackageInputNative]::SendApplicationClick(
|
|
$Application.MainWindowHandle,
|
|
$Application.Id,
|
|
30001,
|
|
$point.X,
|
|
$point.Y,
|
|
$viewportWidth,
|
|
$viewportHeight,
|
|
$devicePixelRatio,
|
|
$false)
|
|
if ($ExpectedSequence -gt 1) {
|
|
[LegacyPackageInputNative]::AcquireOwnedForeground(
|
|
$Application.MainWindowHandle,
|
|
$Application.MainWindowHandle,
|
|
$Application.Id,
|
|
30001)
|
|
[LegacyAppearanceInputNative]::SendClosedSelectKeys(
|
|
$Application.MainWindowHandle,
|
|
[int]$Plan.OptionIndex)
|
|
}
|
|
Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath
|
|
Assert-NoTornadoConnection $Application.Id
|
|
|
|
$afterFile = if ($ExpectedSequence -eq 1) {
|
|
$observed = Get-OperatorSettingsFileState
|
|
if ($observed.exists -ne $beforeFile.exists -or
|
|
[string]$observed.sha256 -cne [string]$beforeFile.sha256) {
|
|
Throw-SafeFailure 'Opening Settings unexpectedly changed the settings file.'
|
|
}
|
|
$observed
|
|
}
|
|
else {
|
|
Wait-OperatorSettingsAppearance `
|
|
$expectedAfter `
|
|
([string]$script:BaselineSettingsState.contextSha256) `
|
|
([datetime]::UtcNow.AddSeconds(15))
|
|
}
|
|
|
|
$script:AppearanceAcknowledgementCalls++
|
|
if ($script:AppearanceAcknowledgementCalls -ne $ExpectedSequence) {
|
|
Throw-SafeFailure 'The appearance acknowledgement one-shot budget changed.'
|
|
}
|
|
$acknowledgement = Write-AppearanceAcknowledgement `
|
|
$AcknowledgementPath $request $afterFile
|
|
return [pscustomobject][ordered]@{
|
|
sequence = $ExpectedSequence
|
|
operation = [string]$Plan.Operation
|
|
targetId = [string]$Plan.TargetId
|
|
requestPath = $RequestPath
|
|
requestSha256 = $requestEvidence.Sha256
|
|
acknowledgementPath = $AcknowledgementPath
|
|
acknowledgementSha256 = $acknowledgement.Sha256
|
|
resultingSettingsSha256 = [string]$afterFile.sha256
|
|
}
|
|
}
|
|
|
|
function Invoke-AppearanceHarnessUnderWatch(
|
|
[string]$PhaseName,
|
|
[object]$ExpectedInitial,
|
|
[object]$ExpectedFinal,
|
|
[string]$PhaseOutput,
|
|
[string]$PhaseScreenshot,
|
|
[string]$PhaseExchangeDirectory,
|
|
[Diagnostics.Process]$Application,
|
|
[string]$ExpectedApplicationPath) {
|
|
$arguments = @(
|
|
$HarnessPath,
|
|
'--phase', $PhaseName,
|
|
'--port', [string]$Port,
|
|
'--output', $PhaseOutput,
|
|
'--screenshot', $PhaseScreenshot,
|
|
'--exchange-directory', $PhaseExchangeDirectory,
|
|
'--expected-color-theme', [string]$ExpectedInitial.colorTheme,
|
|
'--expected-view-mode', [string]$ExpectedInitial.viewMode,
|
|
'--expected-start-workspace', [string]$ExpectedInitial.startWorkspace,
|
|
'--final-color-theme', [string]$ExpectedFinal.colorTheme,
|
|
'--final-view-mode', [string]$ExpectedFinal.viewMode,
|
|
'--final-start-workspace', [string]$ExpectedFinal.startWorkspace,
|
|
'--timeout-ms', [string]($HarnessTimeoutSeconds * 1000)
|
|
) | ForEach-Object { ConvertTo-QuotedProcessArgument ([string]$_) }
|
|
$startInfo = New-Object Diagnostics.ProcessStartInfo
|
|
$startInfo.FileName = $NodePath
|
|
$startInfo.WorkingDirectory = $RepositoryRoot
|
|
$startInfo.Arguments = $arguments -join ' '
|
|
$startInfo.UseShellExecute = $false
|
|
$startInfo.CreateNoWindow = $true
|
|
$startInfo.RedirectStandardInput = $true
|
|
try { $node = [Diagnostics.Process]::Start($startInfo) }
|
|
catch { Throw-SafeFailure 'The appearance input helper could not be started.' }
|
|
if ($null -eq $node) { Throw-SafeFailure 'The appearance input helper did not start.' }
|
|
|
|
$script:AppearanceRequestCalls = 0
|
|
$script:AppearanceAcknowledgementCalls = 0
|
|
$nextSequence = 1
|
|
$exchangeEvidence = New-Object 'Collections.Generic.List[object]'
|
|
$deadline = [datetime]::UtcNow.AddSeconds($HarnessTimeoutSeconds + 30)
|
|
$monitoringFailure = $null
|
|
try {
|
|
while (-not $node.HasExited) {
|
|
if ([datetime]::UtcNow -ge $deadline) {
|
|
Throw-SafeFailure 'The appearance helper exceeded its global deadline.'
|
|
}
|
|
Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath
|
|
Assert-NoTornadoConnection $Application.Id
|
|
if ($nextSequence -le 4) {
|
|
$requestPath = Join-Path $PhaseExchangeDirectory (
|
|
$nextSequence.ToString('00') + '.request.json')
|
|
$acknowledgementPath = Join-Path $PhaseExchangeDirectory (
|
|
$nextSequence.ToString('00') + '.ack.json')
|
|
if ([IO.File]::Exists($requestPath)) {
|
|
$exchange = Invoke-ExactAppearanceRequest `
|
|
$requestPath `
|
|
$acknowledgementPath `
|
|
$nextSequence `
|
|
$PhaseName `
|
|
$ExpectedInitial `
|
|
$ExpectedFinal `
|
|
$Application `
|
|
$ExpectedApplicationPath
|
|
[void]$exchangeEvidence.Add($exchange)
|
|
$nextSequence++
|
|
}
|
|
}
|
|
Start-Sleep -Milliseconds 75
|
|
$node.Refresh()
|
|
}
|
|
if ($node.ExitCode -ne 0 -or $nextSequence -ne 5 -or
|
|
$exchangeEvidence.Count -ne 4 -or
|
|
$script:AppearanceRequestCalls -ne 4 -or
|
|
$script:AppearanceAcknowledgementCalls -ne 4) {
|
|
Throw-SafeFailure 'The appearance helper did not complete four physical inputs.'
|
|
}
|
|
Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath
|
|
Assert-NoTornadoConnection $Application.Id
|
|
}
|
|
catch { $monitoringFailure = $_.Exception.Message }
|
|
|
|
if ($null -ne $monitoringFailure) {
|
|
$stopped = $node.HasExited
|
|
if (-not $stopped) {
|
|
try {
|
|
$node.StandardInput.WriteLine('CANCEL')
|
|
$node.StandardInput.Flush()
|
|
$stopped = $node.WaitForExit(5000)
|
|
}
|
|
catch { $stopped = $false }
|
|
}
|
|
if (-not $stopped) {
|
|
try {
|
|
# Only the bounded Node helper is stopped. The package is never
|
|
# force-terminated and no appearance input is retried.
|
|
$node.Kill()
|
|
$stopped = $node.WaitForExit(10000)
|
|
}
|
|
catch { $stopped = $false }
|
|
}
|
|
Throw-SafeFailure (
|
|
"$monitoringFailure The appearance helper was stopped if possible; " +
|
|
'the packaged app was left open and no input was retried.')
|
|
}
|
|
return [pscustomobject]@{
|
|
ExitCode = $node.ExitCode
|
|
Exchanges = @($exchangeEvidence.ToArray())
|
|
}
|
|
}
|
|
|
|
function Read-AppearancePhaseEvidence(
|
|
[string]$Path,
|
|
[string]$ScreenshotPath,
|
|
[string]$ExpectedPhase,
|
|
[object]$ExpectedInitial,
|
|
[object]$ExpectedFinal) {
|
|
try {
|
|
$item = Get-Item -LiteralPath $Path -Force
|
|
if ($item.PSIsContainer -or
|
|
($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or
|
|
$item.Length -le 0 -or $item.Length -gt 512KB) {
|
|
Throw-SafeFailure 'Appearance phase evidence is not a bounded regular file.'
|
|
}
|
|
$bytes = [IO.File]::ReadAllBytes($Path)
|
|
if ($bytes.Length -ne $item.Length) {
|
|
Throw-SafeFailure 'Appearance phase evidence changed while it was read.'
|
|
}
|
|
$value = $StrictUtf8.GetString($bytes) | ConvertFrom-Json -ErrorAction Stop
|
|
$sealed = [pscustomobject]@{
|
|
Value = $value
|
|
Sha256 = Get-ByteArraySha256 $bytes
|
|
}
|
|
}
|
|
catch [InvalidOperationException] { throw }
|
|
catch { Throw-SafeFailure 'Appearance phase evidence is not strict bounded JSON.' }
|
|
$value = $sealed.Value
|
|
if ([int]$value.schemaVersion -ne 1 -or
|
|
[string]$value.profile -cne 'operator-appearance-persistence' -or
|
|
[string]$value.phase -cne $ExpectedPhase -or
|
|
[string]$value.result -cne 'PASS' -or $null -ne $value.error -or
|
|
[string]$value.target.expectedUrl -cne
|
|
'https://legacy-parity.mbn.local/index.html' -or
|
|
[int]$value.target.port -ne $Port -or
|
|
[string]$value.target.url -cne
|
|
'https://legacy-parity.mbn.local/index.html' -or
|
|
[string]$value.safety.requiredMode -cne 'dryRun' -or
|
|
[string]$value.safety.requiredPhase -cne 'idle' -or
|
|
[int]$value.safety.inputRetryCount -ne 0 -or
|
|
$value.safety.appCloseRequested -ne $false -or
|
|
$value.safety.playoutIntentIssued -ne $false -or
|
|
@($value.safety.blockedOutboundMessages).Count -ne 0 -or
|
|
@($value.safety.stateViolations).Count -ne 0 -or
|
|
-not (Test-AppearanceEqual $value.expectedInitial $ExpectedInitial) -or
|
|
-not (Test-AppearanceEqual $value.expectedFinal $ExpectedFinal) -or
|
|
@($value.exchanges).Count -ne 4 -or
|
|
@($value.selections).Count -ne 3 -or
|
|
($ExpectedPhase -ceq 'verify-restore' -and
|
|
$value.persistenceVerifiedBeforeInput -ne $true) -or
|
|
($ExpectedPhase -ceq 'verify-restore' -and
|
|
$value.startWorkspaceBehaviorVerifiedBeforeInput -ne $true) -or
|
|
($ExpectedPhase -ceq 'mutate' -and
|
|
($value.persistenceVerifiedBeforeInput -ne $false -or
|
|
$value.startWorkspaceBehaviorVerifiedBeforeInput -ne $false))) {
|
|
Throw-SafeFailure 'The appearance phase evidence violates its sealed contract.'
|
|
}
|
|
$selectionKeys = @('colorTheme', 'viewMode', 'startWorkspace')
|
|
for ($selectionIndex = 0;
|
|
$selectionIndex -lt $selectionKeys.Count;
|
|
$selectionIndex++) {
|
|
$selection = $value.selections[$selectionIndex]
|
|
$selectionKey = $selectionKeys[$selectionIndex]
|
|
$expectedTarget = [string]$ExpectedFinal.$selectionKey
|
|
$expectedDownCount = switch -CaseSensitive ($selectionKey) {
|
|
'colorTheme' { @('system', 'light', 'dark').IndexOf($expectedTarget); break }
|
|
'viewMode' { @('automatic', 'compact', 'cards').IndexOf($expectedTarget); break }
|
|
'startWorkspace' {
|
|
@('stockCut', 'lastWorkspace').IndexOf($expectedTarget); break
|
|
}
|
|
}
|
|
if ([string]$selection.appearanceKey -cne $selectionKey -or
|
|
[string]$selection.targetValue -cne $expectedTarget -or
|
|
$selection.trustedChange -ne $true -or
|
|
[int]$selection.physicalKeyCounts.home -ne 1 -or
|
|
[int]$selection.physicalKeyCounts.arrowDown -ne $expectedDownCount -or
|
|
[int]$selection.physicalKeyCounts.enter -ne 1 -or
|
|
[int]$selection.physicalKeyCounts.total -ne (2 + $expectedDownCount) -or
|
|
[int]$selection.outboundIntentCount -ne 1 -or
|
|
$selection.restartRequired -ne $false) {
|
|
Throw-SafeFailure 'An appearance selection lacks trusted physical input evidence.'
|
|
}
|
|
}
|
|
$phaseExchanges = @($value.exchanges)
|
|
for ($exchangeIndex = 0;
|
|
$exchangeIndex -lt $phaseExchanges.Count;
|
|
$exchangeIndex++) {
|
|
$exchange = $phaseExchanges[$exchangeIndex]
|
|
if ($exchange.physicalInput.pointerDown.isTrusted -ne $true -or
|
|
$exchange.physicalInput.pointerUp.isTrusted -ne $true -or
|
|
[string]$exchange.physicalInput.pointerDown.pointerType -cne 'mouse' -or
|
|
[string]$exchange.physicalInput.pointerUp.pointerType -cne 'mouse') {
|
|
Throw-SafeFailure 'An appearance exchange lacks trusted pointer evidence.'
|
|
}
|
|
if ($exchangeIndex -gt 0) {
|
|
$selection = $value.selections[$exchangeIndex - 1]
|
|
if ($exchange.physicalInput.change.isTrusted -ne $true -or
|
|
[string]$exchange.physicalInput.change.type -cne 'change' -or
|
|
[string]$exchange.physicalInput.change.value -cne
|
|
[string]$selection.targetValue -or
|
|
[int]$exchange.physicalKeyCounts.home -ne
|
|
[int]$selection.physicalKeyCounts.home -or
|
|
[int]$exchange.physicalKeyCounts.arrowDown -ne
|
|
[int]$selection.physicalKeyCounts.arrowDown -or
|
|
[int]$exchange.physicalKeyCounts.enter -ne
|
|
[int]$selection.physicalKeyCounts.enter) {
|
|
Throw-SafeFailure 'An appearance exchange lacks its trusted change/key seal.'
|
|
}
|
|
}
|
|
}
|
|
$screenshot = Get-Item -LiteralPath $ScreenshotPath -Force
|
|
if ($screenshot.PSIsContainer -or
|
|
($screenshot.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or
|
|
$screenshot.Length -le 0 -or
|
|
[int64]$value.screenshot.bytes -ne $screenshot.Length -or
|
|
[string]$value.screenshot.sha256 -cne
|
|
(Get-FileHash -Algorithm SHA256 -LiteralPath $ScreenshotPath).Hash) {
|
|
Throw-SafeFailure 'The appearance phase screenshot is not sealed exactly.'
|
|
}
|
|
return [pscustomobject]@{
|
|
Value = $value
|
|
Sha256 = $sealed.Sha256
|
|
ScreenshotSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $ScreenshotPath).Hash
|
|
}
|
|
}
|
|
|
|
function Start-ExactAppearanceApplication(
|
|
[pscustomobject]$Registration,
|
|
[int]$LaunchOrdinal) {
|
|
if ($LaunchOrdinal -notin @(1, 2)) {
|
|
Throw-SafeFailure 'The appearance launch ordinal is outside its two-launch budget.'
|
|
}
|
|
Assert-NoExistingApplication
|
|
Assert-PortIsFree $Port
|
|
$application = Start-PackagedDryRunApplication $Registration $Port
|
|
[void](Wait-ExactLoopbackListener `
|
|
$Port `
|
|
$application `
|
|
([datetime]::UtcNow.AddSeconds($LaunchTimeoutSeconds)))
|
|
Assert-NoTornadoConnection $application.Id
|
|
$script:ApplicationProcess = $application
|
|
$script:ApplicationWasLaunched = $true
|
|
$script:CurrentApplicationClosed = $false
|
|
return $application
|
|
}
|
|
|
|
function Close-ExactAppearanceApplication(
|
|
[Diagnostics.Process]$Application,
|
|
[string]$ExpectedPath) {
|
|
if ($script:CloseMainWindowCalls -ne 0 -or $script:PrimaryInvokeCalls -ne 0) {
|
|
Throw-SafeFailure 'The per-launch normal-close budget was not reset.'
|
|
}
|
|
Invoke-ExactNormalClose $Application $ExpectedPath
|
|
Wait-PortReleased $Port ([datetime]::UtcNow.AddSeconds(15))
|
|
if (@(Get-LegacyApplicationProcesses).Count -ne 0 -or
|
|
$script:CloseMainWindowCalls -ne 1 -or $script:PrimaryInvokeCalls -ne 1) {
|
|
Throw-SafeFailure 'The exact appearance package did not complete one normal close.'
|
|
}
|
|
$script:CurrentApplicationClosed = $true
|
|
$script:ApplicationWasLaunched = $false
|
|
$script:CloseMainWindowCalls = 0
|
|
$script:PrimaryInvokeCalls = 0
|
|
}
|
|
|
|
function Write-FinalAppearanceEvidence([string]$Path, [object]$Value) {
|
|
$bytes = $Utf8.GetBytes(($Value | ConvertTo-Json -Depth 10) + "`n")
|
|
$stream = [IO.FileStream]::new(
|
|
$Path,
|
|
[IO.FileMode]::CreateNew,
|
|
[IO.FileAccess]::Write,
|
|
[IO.FileShare]::None)
|
|
try {
|
|
$stream.Write($bytes, 0, $bytes.Length)
|
|
$stream.Flush($true)
|
|
}
|
|
finally { $stream.Dispose() }
|
|
}
|
|
|
|
if ($StaticAudit) {
|
|
[pscustomobject][ordered]@{
|
|
result = 'PASS'
|
|
profile = 'operator-appearance-persistence'
|
|
packageFlavor = 'DebugAppX'
|
|
launchCount = 2
|
|
phaseCount = 2
|
|
physicalSettingsClickCount = 2
|
|
physicalSelectCount = 6
|
|
inputRetryCount = 0
|
|
rawBaselineRecovery = $true
|
|
packageForceTerminationAllowed = $false
|
|
sendInputClickAvailable = $null -ne
|
|
[LegacyPackageInputNative].GetMethod('SendApplicationClick')
|
|
sendInputSelectKeysAvailable = $null -ne
|
|
[LegacyAppearanceInputNative].GetMethod('SendClosedSelectKeys')
|
|
inputStructureBytes = [LegacyAppearanceInputNative]::InputStructureBytes()
|
|
expectedInputStructureBytes =
|
|
[LegacyAppearanceInputNative]::ExpectedInputStructureBytes()
|
|
inputStructureSizeValidated =
|
|
[LegacyAppearanceInputNative]::InputStructureBytes() -eq
|
|
[LegacyAppearanceInputNative]::ExpectedInputStructureBytes()
|
|
harnessPath = $HarnessPath
|
|
harnessSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $HarnessPath).Hash
|
|
} | ConvertTo-Json -Compress
|
|
return
|
|
}
|
|
|
|
$timestamp = [datetime]::UtcNow.ToString('yyyyMMddTHHmmssfffZ')
|
|
$Output = Resolve-EvidencePath $Output "legacy-package-appearance-$timestamp.json"
|
|
$Screenshot = Resolve-EvidencePath $Screenshot "legacy-package-appearance-$timestamp.png"
|
|
if ([string]::IsNullOrWhiteSpace($ExchangeDirectory)) {
|
|
$ExchangeDirectory = [IO.Path]::ChangeExtension($Output, $null) + '.exchanges'
|
|
}
|
|
elseif ([IO.Path]::IsPathRooted($ExchangeDirectory)) {
|
|
$ExchangeDirectory = [IO.Path]::GetFullPath($ExchangeDirectory)
|
|
}
|
|
else {
|
|
$ExchangeDirectory = [IO.Path]::GetFullPath((Join-Path $RepositoryRoot $ExchangeDirectory))
|
|
}
|
|
$NodePath = Resolve-NodeExecutable $NodePath
|
|
|
|
$phaseOneDirectory = Join-Path $ExchangeDirectory 'phase-01-input'
|
|
$phaseTwoDirectory = Join-Path $ExchangeDirectory 'phase-02-input'
|
|
$phaseOneOutput = Join-Path $ExchangeDirectory 'phase-01.json'
|
|
$phaseTwoOutput = Join-Path $ExchangeDirectory 'phase-02.json'
|
|
$phaseOneScreenshot = Join-Path $ExchangeDirectory 'phase-01.png'
|
|
$recoveryPath = Join-Path $ExchangeDirectory '.operator-settings-baseline.recovery'
|
|
|
|
try {
|
|
if (-not [IO.File]::Exists($HarnessPath)) {
|
|
Throw-SafeFailure 'The tracked appearance harness is missing.'
|
|
}
|
|
Assert-OutputPathAvailable $Output 'Output'
|
|
Assert-OutputPathAvailable $Screenshot 'Screenshot'
|
|
Assert-OutputPathAvailable $ExchangeDirectory 'Exchange directory'
|
|
[void][IO.Directory]::CreateDirectory($ExchangeDirectory)
|
|
[void][IO.Directory]::CreateDirectory($phaseOneDirectory)
|
|
[void][IO.Directory]::CreateDirectory($phaseTwoDirectory)
|
|
if (@(Get-ChildItem -LiteralPath $phaseOneDirectory -Force).Count -ne 0 -or
|
|
@(Get-ChildItem -LiteralPath $phaseTwoDirectory -Force).Count -ne 0) {
|
|
Throw-SafeFailure 'A new appearance exchange directory is not empty.'
|
|
}
|
|
|
|
Assert-LocalConfigurationIsDryRun
|
|
Assert-NoUnsafeEnvironment
|
|
$registration = Get-ExactDevelopmentPackage
|
|
Assert-NoExistingApplication
|
|
Assert-PortIsFree $Port
|
|
Initialize-ExactBaselineRecovery $recoveryPath
|
|
$baselineAppearance = $script:BaselineSettingsState.appearance
|
|
$alternateAppearance = Get-AlternateAppearance $baselineAppearance
|
|
|
|
$script:Phase = 'first package launch and physical mutation'
|
|
$firstApplication = Start-ExactAppearanceApplication $registration 1
|
|
$firstProcessId = $firstApplication.Id
|
|
$firstHarness = Invoke-AppearanceHarnessUnderWatch `
|
|
'mutate' `
|
|
$baselineAppearance `
|
|
$alternateAppearance `
|
|
$phaseOneOutput `
|
|
$phaseOneScreenshot `
|
|
$phaseOneDirectory `
|
|
$firstApplication `
|
|
$registration.Executable
|
|
$firstEvidence = Read-AppearancePhaseEvidence `
|
|
$phaseOneOutput `
|
|
$phaseOneScreenshot `
|
|
'mutate' `
|
|
$baselineAppearance `
|
|
$alternateAppearance
|
|
$mutatedSettings = Get-OperatorSettingsFileState
|
|
if ($mutatedSettings.schemaVersion -ne 2 -or
|
|
[string]$mutatedSettings.contextSha256 -cne
|
|
[string]$script:BaselineSettingsState.contextSha256 -or
|
|
-not (Test-AppearanceEqual $mutatedSettings.appearance $alternateAppearance)) {
|
|
Throw-SafeFailure 'The alternate appearance was not persisted before relaunch.'
|
|
}
|
|
|
|
$script:Phase = 'first normal close'
|
|
Close-ExactAppearanceApplication $firstApplication $registration.Executable
|
|
|
|
$script:Phase = 'second package launch and physical restoration'
|
|
$secondApplication = Start-ExactAppearanceApplication $registration 2
|
|
$secondProcessId = $secondApplication.Id
|
|
$secondHarness = Invoke-AppearanceHarnessUnderWatch `
|
|
'verify-restore' `
|
|
$alternateAppearance `
|
|
$baselineAppearance `
|
|
$phaseTwoOutput `
|
|
$Screenshot `
|
|
$phaseTwoDirectory `
|
|
$secondApplication `
|
|
$registration.Executable
|
|
$secondEvidence = Read-AppearancePhaseEvidence `
|
|
$phaseTwoOutput `
|
|
$Screenshot `
|
|
'verify-restore' `
|
|
$alternateAppearance `
|
|
$baselineAppearance
|
|
$logicalRestored = Get-OperatorSettingsFileState
|
|
if ($logicalRestored.schemaVersion -ne 2 -or
|
|
[string]$logicalRestored.contextSha256 -cne
|
|
[string]$script:BaselineSettingsState.contextSha256 -or
|
|
-not (Test-AppearanceEqual $logicalRestored.appearance $baselineAppearance)) {
|
|
Throw-SafeFailure 'The baseline appearance was not restored by physical input.'
|
|
}
|
|
|
|
$script:Phase = 'second normal close'
|
|
Close-ExactAppearanceApplication $secondApplication $registration.Executable
|
|
|
|
$script:Phase = 'exact raw settings restoration'
|
|
$exactRestored = Restore-ExactBaselineSettings
|
|
$script:Phase = 'seal final evidence'
|
|
$finalEvidence = [ordered]@{
|
|
schemaVersion = 1
|
|
profile = 'operator-appearance-persistence'
|
|
result = 'PASS'
|
|
packageFullName = $PackageFullName
|
|
packageMode = $PackageModeLabel
|
|
playoutMode = 'DryRun'
|
|
cdpAddress = '127.0.0.1'
|
|
cdpPort = $Port
|
|
processIds = @($firstProcessId, $secondProcessId)
|
|
launchCount = 2
|
|
normalCloseCount = 2
|
|
tornadoPortConnections = 0
|
|
inputRetryCount = 0
|
|
baseline = [ordered]@{
|
|
existed = [bool]$script:BaselineSettingsState.exists
|
|
sha256 = [string]$script:BaselineSettingsState.sha256
|
|
schemaVersion = [int]$script:BaselineSettingsState.schemaVersion
|
|
contextSha256 = [string]$script:BaselineSettingsState.contextSha256
|
|
appearance = $baselineAppearance
|
|
}
|
|
alternate = $alternateAppearance
|
|
mutation = [ordered]@{
|
|
physicalSelectCount = 3
|
|
exchangeCount = $firstHarness.Exchanges.Count
|
|
phaseEvidence = $phaseOneOutput
|
|
phaseEvidenceSha256 = $firstEvidence.Sha256
|
|
screenshot = $phaseOneScreenshot
|
|
screenshotSha256 = $firstEvidence.ScreenshotSha256
|
|
}
|
|
relaunch = [ordered]@{
|
|
persistedBeforeInput =
|
|
[bool]$secondEvidence.Value.persistenceVerifiedBeforeInput
|
|
physicalRestoreSelectCount = 3
|
|
exchangeCount = $secondHarness.Exchanges.Count
|
|
phaseEvidence = $phaseTwoOutput
|
|
phaseEvidenceSha256 = $secondEvidence.Sha256
|
|
}
|
|
restoration = [ordered]@{
|
|
logicalAppearanceRestored = $true
|
|
exactOriginalBytesRestored = $true
|
|
finalExists = [bool]$exactRestored.exists
|
|
finalSha256 = [string]$exactRestored.sha256
|
|
recoveryCopyRemoved = -not [IO.File]::Exists($recoveryPath)
|
|
}
|
|
screenshot = $Screenshot
|
|
screenshotSha256 = $secondEvidence.ScreenshotSha256
|
|
exchangeDirectory = $ExchangeDirectory
|
|
}
|
|
Write-FinalAppearanceEvidence $Output $finalEvidence
|
|
$script:Phase = 'complete'
|
|
[pscustomobject]@{
|
|
result = 'PASS'
|
|
packageFullName = $PackageFullName
|
|
packageMode = $PackageModeLabel
|
|
playoutMode = 'DryRun'
|
|
launchCount = 2
|
|
physicalSelectCount = 6
|
|
persistenceVerified = $true
|
|
exactOriginalSettingsRestored = $true
|
|
output = $Output
|
|
outputSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $Output).Hash
|
|
screenshot = $Screenshot
|
|
screenshotSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $Screenshot).Hash
|
|
exchangeDirectory = $ExchangeDirectory
|
|
}
|
|
}
|
|
catch {
|
|
$message = $_.Exception.Message
|
|
$running = $false
|
|
try { $running = @(Get-LegacyApplicationProcesses).Count -gt 0 }
|
|
catch { $running = $false }
|
|
|
|
if (-not $running -and $null -ne $script:BaselineSettingsState -and
|
|
-not $script:BaselineRestored -and -not $script:BaselineRestoreAttempted) {
|
|
try { [void](Restore-ExactBaselineSettings) }
|
|
catch {
|
|
Throw-SafeFailure (
|
|
"Appearance smoke stopped during '$($script:Phase)': $message " +
|
|
'The app is closed, but exact settings restoration also failed. ' +
|
|
"Preserve the recovery evidence at '$ExchangeDirectory'.")
|
|
}
|
|
}
|
|
if ($running) {
|
|
Throw-SafeFailure (
|
|
"Appearance smoke stopped during '$($script:Phase)': $message " +
|
|
'The application was intentionally left open; no input retry, close retry, ' +
|
|
'forced termination, or settings-file overwrite was attempted. The exact ' +
|
|
"recovery copy remains under '$ExchangeDirectory'.")
|
|
}
|
|
if ($script:BaselineRestoreAttempted -and -not $script:BaselineRestored) {
|
|
Throw-SafeFailure (
|
|
"Appearance smoke stopped during '$($script:Phase)': $message " +
|
|
'The one-shot settings restoration had an unclear or failed result and was not ' +
|
|
"retried. Preserve the recovery evidence at '$ExchangeDirectory'.")
|
|
}
|
|
throw
|
|
}
|