diff --git a/scripts/Invoke-LegacyPackageAppearanceSmoke.ps1 b/scripts/Invoke-LegacyPackageAppearanceSmoke.ps1 new file mode 100644 index 0000000..2b30b12 --- /dev/null +++ b/scripts/Invoke-LegacyPackageAppearanceSmoke.ps1 @@ -0,0 +1,1363 @@ +[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 +} diff --git a/scripts/Invoke-LegacyPackageInputSmoke.ps1 b/scripts/Invoke-LegacyPackageInputSmoke.ps1 index 09a89c5..62c1c3d 100644 --- a/scripts/Invoke-LegacyPackageInputSmoke.ps1 +++ b/scripts/Invoke-LegacyPackageInputSmoke.ps1 @@ -68,6 +68,8 @@ public static class LegacyPackageInputNative public int ScreenTop; public int Width; public int Height; + public double CssScaleX; + public double CssScaleY; } [StructLayout(LayoutKind.Sequential)] @@ -391,13 +393,11 @@ public static class LegacyPackageInputNative Point source = CssPointToScreen( baseline, sourceCssX, - sourceCssY, - devicePixelRatio); + sourceCssY); Point target = CssPointToScreen( baseline, targetCssX, - targetCssY, - devicePixelRatio); + targetCssY); if (source.X == target.X && source.Y == target.Y) { throw new InvalidOperationException("The Windows drag source and target are identical."); @@ -429,7 +429,7 @@ public static class LegacyPackageInputNative source, true, false); Point threshold = new Point { - X = source.X + Math.Max(8, (int)Math.Round(12.0 * devicePixelRatio)), + X = source.X + Math.Max(8, (int)Math.Round(12.0 * baseline.CssScaleX)), Y = source.Y }; AssertPointOwnedByWindow(window, threshold); @@ -566,11 +566,11 @@ public static class LegacyPackageInputNative viewportCssHeight, devicePixelRatio, baseline); Point rangeStart = CssPointToScreen( - baseline, rangeStartCssX, rangeStartCssY, devicePixelRatio); + baseline, rangeStartCssX, rangeStartCssY); Point rangeEnd = CssPointToScreen( - baseline, rangeEndCssX, rangeEndCssY, devicePixelRatio); + baseline, rangeEndCssX, rangeEndCssY); Point restore = CssPointToScreen( - baseline, restoreCssX, restoreCssY, devicePixelRatio); + baseline, restoreCssX, restoreCssY); if ((rangeStart.X == rangeEnd.X && rangeStart.Y == rangeEnd.Y) || (rangeEnd.X == restore.X && rangeEnd.Y == restore.Y)) { @@ -710,7 +710,7 @@ public static class LegacyPackageInputNative 150, window, processId, forbiddenPort, viewportCssWidth, viewportCssHeight, devicePixelRatio, baseline); - Point point = CssPointToScreen(baseline, cssX, cssY, devicePixelRatio); + Point point = CssPointToScreen(baseline, cssX, cssY); AssertPointOwnedByWindow(window, point); bool leftButtonDown = false; try @@ -784,7 +784,7 @@ public static class LegacyPackageInputNative 150, window, processId, forbiddenPort, viewportCssWidth, viewportCssHeight, devicePixelRatio, baseline); - Point point = CssPointToScreen(baseline, cssX, cssY, devicePixelRatio); + Point point = CssPointToScreen(baseline, cssX, cssY); AssertPointOwnedByWindow(window, point); bool leftButtonDown = false; try @@ -1056,16 +1056,31 @@ public static class LegacyPackageInputNative throw new InvalidOperationException("The package client rectangle is invalid."); } - int expectedWidth = (int)Math.Round(viewportCssWidth * devicePixelRatio); - int expectedHeight = (int)Math.Round(viewportCssHeight * devicePixelRatio); - if (expectedWidth <= 0 || expectedHeight <= 0 || - Math.Abs(client.Right - expectedWidth) > ClientPixelTolerance || - Math.Abs(client.Bottom - expectedHeight) > ClientPixelTolerance) + int deviceWidth = (int)Math.Round(viewportCssWidth * devicePixelRatio); + int deviceHeight = (int)Math.Round(viewportCssHeight * devicePixelRatio); + int logicalWidth = (int)Math.Round(viewportCssWidth); + int logicalHeight = (int)Math.Round(viewportCssHeight); + bool deviceMatches = deviceWidth > 0 && deviceHeight > 0 && + Math.Abs(client.Right - deviceWidth) <= ClientPixelTolerance && + Math.Abs(client.Bottom - deviceHeight) <= ClientPixelTolerance; + bool logicalMatches = logicalWidth > 0 && logicalHeight > 0 && + Math.Abs(client.Right - logicalWidth) <= ClientPixelTolerance && + Math.Abs(client.Bottom - logicalHeight) <= ClientPixelTolerance; + if (!deviceMatches && !logicalMatches) { throw new InvalidOperationException( "The browser viewport does not match the package client rectangle."); } + double cssScaleX = client.Right / viewportCssWidth; + double cssScaleY = client.Bottom / viewportCssHeight; + if (!IsFinitePositive(cssScaleX) || !IsFinitePositive(cssScaleY) || + Math.Abs(cssScaleX - cssScaleY) > 0.02) + { + throw new InvalidOperationException( + "The package client CSS coordinate scale is invalid."); + } + Point origin = new Point { X = 0, Y = 0 }; if (!ClientToScreen(window, ref origin)) { @@ -1075,7 +1090,9 @@ public static class LegacyPackageInputNative ScreenLeft = origin.X, ScreenTop = origin.Y, Width = client.Right, - Height = client.Bottom + Height = client.Bottom, + CssScaleX = cssScaleX, + CssScaleY = cssScaleY }; } @@ -1321,8 +1338,7 @@ public static class LegacyPackageInputNative private static Point CssPointToScreen( ClientGeometry geometry, double cssX, - double cssY, - double devicePixelRatio) + double cssY) { if (Double.IsNaN(cssX) || Double.IsInfinity(cssX) || Double.IsNaN(cssY) || Double.IsInfinity(cssY) || cssX < 0 || cssY < 0) @@ -1330,8 +1346,8 @@ public static class LegacyPackageInputNative throw new InvalidOperationException("A CSS input point is invalid."); } - int x = (int)Math.Round(cssX * devicePixelRatio); - int y = (int)Math.Round(cssY * devicePixelRatio); + int x = (int)Math.Round(cssX * geometry.CssScaleX); + int y = (int)Math.Round(cssY * geometry.CssScaleY); if (x < 0 || x >= geometry.Width || y < 0 || y >= geometry.Height) { throw new InvalidOperationException("A CSS input point is outside the package client area."); @@ -1787,7 +1803,11 @@ function Assert-DebugAppXMatchesLatestPackage([string]$InstallLocation) { 'MBN_STOCK_WEBVIEW.LegacyApplication.dll', 'MBN_STOCK_WEBVIEW.LegacyBridge.dll', 'MBN_STOCK_WEBVIEW.LegacyParityApp.dll', - 'Web/app.js' + 'Web/app.js', + 'Web/index.html', + 'Web/playout-ui.js', + 'Web/styles.css', + 'Web/workspace-navigation.js' ) foreach ($relativePath in $expectedFiles) { $entry = $archive.GetEntry($relativePath) diff --git a/scripts/Invoke-LegacyPackageLocalStateSmoke.ps1 b/scripts/Invoke-LegacyPackageLocalStateSmoke.ps1 index 640cbe1..700ce02 100644 --- a/scripts/Invoke-LegacyPackageLocalStateSmoke.ps1 +++ b/scripts/Invoke-LegacyPackageLocalStateSmoke.ps1 @@ -257,8 +257,10 @@ public static class LegacyLocalStateNative $ExpectedSourceSha256 = '1EE76BC464FB1C44C8B6546E99CDC21C726C0E2C24AD344F5C19AE41BFC0D2F2' -$ExpectedInitialDestinationSha256 = +$ExpectedFreshDestinationSha256 = '13C07BA64587549EDA9C1A694F3DFA19875CB1F888F02C9E071F17C47739E087' +$ExpectedImportedDestinationSha256 = + '2575C2420800670B0022EBEE819BA3D9640DB14E9552426DF46075D079F1F697' $ExpectedInitialPairLine = $Utf8.GetString([Convert]::FromBase64String( 'MV5LUlgxMDDsp4DsiJgs7L2U7Iqk7ZS8IOyngOyImF5eXl5eXl4=')) $ComparisonFileName = $Utf8.GetString([Convert]::FromBase64String( @@ -304,6 +306,7 @@ $ExpectedExchangePlan = @( $script:LocalInputRequestCalls = 0 $script:LocalInputAcknowledgementCalls = 0 $script:BaselineSettingsState = $null +$script:DestinationBaselineMode = $null $script:InitialDestinationState = $null $script:FirstImportedDestinationState = $null $script:LastObservedFileState = $null @@ -399,19 +402,30 @@ function Assert-LocalFilePreflight { $state = Get-LocalFileState if ([string]$state.source.sha256 -cne $ExpectedSourceSha256 -or [int]$state.source.rowCount -ne 8 -or - [string]$state.destination.sha256 -cne $ExpectedInitialDestinationSha256 -or - [int]$state.destination.rowCount -ne 1 -or [string]$state.destination.firstPair -cne $ExpectedInitialPairIdentity) { Throw-SafeFailure ` - ('The original 8-row source or exact current one-row baseline changed; ' + + ('The original 8-row source or known comparison baseline changed; ' + 'no import was started.') } - $initialBytes = [IO.File]::ReadAllBytes($DestinationComparisonPath) - if ($Cp949.GetString($initialBytes).TrimEnd("`r", "`n") -cne - $ExpectedInitialPairLine) { - Throw-SafeFailure 'The current one-row comparison baseline is not the expected known pair.' + $isFresh = + [string]$state.destination.sha256 -ceq $ExpectedFreshDestinationSha256 -and + [int]$state.destination.rowCount -eq 1 + $isAlreadyImported = + [string]$state.destination.sha256 -ceq $ExpectedImportedDestinationSha256 -and + [int]$state.destination.rowCount -eq 9 + if (-not $isFresh -and -not $isAlreadyImported) { + Throw-SafeFailure ` + 'The comparison destination is neither the known fresh nor imported baseline.' + } + if ($isFresh) { + $initialBytes = [IO.File]::ReadAllBytes($DestinationComparisonPath) + if ($Cp949.GetString($initialBytes).TrimEnd("`r", "`n") -cne + $ExpectedInitialPairLine) { + Throw-SafeFailure 'The current one-row comparison baseline is not the expected known pair.' + } } $script:BaselineSettingsState = $state.settings + $script:DestinationBaselineMode = if ($isFresh) { 'fresh' } else { 'alreadyImported' } $script:InitialDestinationState = $state.destination $script:LastObservedFileState = $state return $state @@ -755,14 +769,14 @@ function Invoke-LocalStateExchange( Throw-SafeFailure 'The original read-only comparison source changed during the smoke.' } if ($Sequence -lt 9 -and - [string]$files.destination.sha256 -cne $ExpectedInitialDestinationSha256) { + (-not (Test-JsonEquivalent $files.destination $script:InitialDestinationState))) { Throw-SafeFailure 'The comparison destination changed before the authorized Yes input.' } if ($Sequence -eq 10) { if ([int]$files.destination.rowCount -ne 9 -or [string]$files.destination.firstPair -cne $ExpectedInitialPairIdentity -or - [string]$files.destination.sha256 -ceq $ExpectedInitialDestinationSha256) { - Throw-SafeFailure 'The first authorized import is not an appended nine-row file.' + [string]$files.destination.sha256 -cne $ExpectedImportedDestinationSha256) { + Throw-SafeFailure 'The first authorized import did not retain the known nine-row result.' } $script:FirstImportedDestinationState = $files.destination } @@ -916,7 +930,13 @@ function Assert-LocalHarnessEvidence( [string]$ExchangePath) { $sealed = Read-LocalHarnessEvidenceFile $EvidencePath $value = $sealed.Value - if ([int]$value.schemaVersion -ne 1 -or + $baselineMode = [string]$value.files.initialDestinationMode + $expectedFirstAdded = if ($baselineMode -ceq 'fresh') { 8 } else { 0 } + $expectedFirstSkipped = if ($baselineMode -ceq 'fresh') { 0 } else { 8 } + $expectedInitialPairCount = if ($baselineMode -ceq 'fresh') { 1 } else { 9 } + if (($baselineMode -cne 'fresh' -and $baselineMode -cne 'alreadyImported') -or + $baselineMode -cne $script:DestinationBaselineMode -or + [int]$value.schemaVersion -ne 1 -or [string]$value.profile -cne 'local-settings-comparison-import' -or [string]$value.result -cne 'PASS' -or $null -ne $value.error -or [string]$value.target.expectedUrl -cne @@ -936,11 +956,18 @@ function Assert-LocalHarnessEvidence( -not (Test-ExactStringSequence @($value.settings.pickerKinds) @( 'design', 'resource', 'background')) -or $value.comparison.initialPairPreserved -ne $true -or + [int]$value.comparison.initialPairCount -ne $expectedInitialPairCount -or $value.comparison.secondImportIdempotent -ne $true -or - [int]$value.comparison.afterFirstImport.receipt.addedPairCount -ne 8 -or + [int]$value.comparison.afterFirstImport.receipt.addedPairCount -ne + $expectedFirstAdded -or + [int]$value.comparison.afterFirstImport.receipt.skippedDuplicateCount -ne + $expectedFirstSkipped -or [int]$value.comparison.afterFirstImport.pairCount -ne 9 -or [int]$value.comparison.afterSecondImport.receipt.addedPairCount -ne 0 -or + [int]$value.comparison.afterSecondImport.receipt.skippedDuplicateCount -ne 8 -or [int]$value.comparison.afterSecondImport.pairCount -ne 9 -or + [string]$value.files.destinationAfterFirstImport.sha256 -cne + $ExpectedImportedDestinationSha256 -or [string]$value.files.destinationAfterFirstImport.sha256 -cne [string]$value.files.destinationAfterSecondImport.sha256 -or @($value.exchanges).Count -ne 13) { @@ -1007,8 +1034,10 @@ if ($StaticAudit) { inputRetryCount = 0 expectedSourceSha256 = $ExpectedSourceSha256 expectedSourceRows = 8 - expectedInitialDestinationSha256 = $ExpectedInitialDestinationSha256 - expectedInitialDestinationRows = 1 + expectedFreshDestinationSha256 = $ExpectedFreshDestinationSha256 + expectedFreshDestinationRows = 1 + expectedImportedDestinationSha256 = $ExpectedImportedDestinationSha256 + expectedImportedDestinationRows = 9 sendInputClickAvailable = $null -ne [LegacyPackageInputNative].GetMethod('SendApplicationClick') sendInputEscapeAvailable = $null -ne @@ -1085,8 +1114,8 @@ try { Throw-SafeFailure 'Final local files do not match the sealed cancel/idempotence result.' } - # This authorized import changed user-local comparison data. Avoid a second - # activation; normal close is the only remaining app action. + # The authorized import may have appended the fresh baseline or confirmed + # the existing imported baseline. Normal close is the only remaining action. $script:Phase = 'normal close' Invoke-ExactNormalClose $script:ApplicationProcess $registration.Executable Wait-PortReleased $Port ([datetime]::UtcNow.AddSeconds(15)) @@ -1106,7 +1135,8 @@ try { localInputRequestCalls = $script:LocalInputRequestCalls localInputAcknowledgementCalls = $script:LocalInputAcknowledgementCalls settingsPickerCancelCount = 3 - comparisonImportAddedCount = 8 + comparisonBaselineMode = $script:DestinationBaselineMode + comparisonImportAddedCount = if ($script:DestinationBaselineMode -ceq 'fresh') { 8 } else { 0 } comparisonSecondImportAddedCount = 0 output = $Output outputSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $Output).Hash diff --git a/scripts/Test-LegacyPackageAppearanceSmoke.mjs b/scripts/Test-LegacyPackageAppearanceSmoke.mjs new file mode 100644 index 0000000..a7ddda1 --- /dev/null +++ b/scripts/Test-LegacyPackageAppearanceSmoke.mjs @@ -0,0 +1,998 @@ +import fs from "node:fs"; +import path from "node:path"; +import { createHash, randomBytes } from "node:crypto"; + +const exactTargetUrl = "https://legacy-parity.mbn.local/index.html"; +const allowedOutboundTypes = new Set([ + "ready", + "select-tab", + "set-operator-appearance" +]); +const forbiddenCdpMethods = new Set([ + "Browser.close", + "Page.close", + "Runtime.terminateExecution", + "Target.closeTarget", + "Input.dispatchMouseEvent", + "Input.dispatchKeyEvent", + "Input.insertText" +]); +const appearanceValues = Object.freeze({ + colorTheme: Object.freeze(["system", "light", "dark"]), + viewMode: Object.freeze(["automatic", "compact", "cards"]), + startWorkspace: Object.freeze(["stockCut", "lastWorkspace"]) +}); +const controls = Object.freeze({ + settings: Object.freeze({ + id: "workspace-settings-tab", + expression: 'document.getElementById("workspace-settings-tab")' + }), + colorTheme: Object.freeze({ + id: "operator-color-theme", + expression: 'document.getElementById("operator-color-theme")' + }), + viewMode: Object.freeze({ + id: "operator-view-mode", + expression: 'document.getElementById("operator-view-mode")' + }), + startWorkspace: Object.freeze({ + id: "operator-start-workspace", + expression: 'document.getElementById("operator-start-workspace")' + }) +}); + +class AppearanceFailure extends Error { + constructor(category, message) { + super(`${category}: ${message}`); + this.name = "AppearanceFailure"; + this.category = category; + } +} + +function failKnown(message) { + throw new AppearanceFailure("KNOWN_FAILURE", message); +} + +function failUnknown(message) { + throw new AppearanceFailure("OUTCOME_UNKNOWN", message); +} + +function parseClosedValue(values, name, allowed) { + const value = values.get(name); + if (!allowed.includes(value)) failKnown(`--${name} is outside the closed value set.`); + return value; +} + +function parseArguments(argv) { + const accepted = new Set([ + "phase", + "port", + "output", + "screenshot", + "exchange-directory", + "expected-color-theme", + "expected-view-mode", + "expected-start-workspace", + "final-color-theme", + "final-view-mode", + "final-start-workspace", + "timeout-ms" + ]); + if (argv.length === 0 || argv.length % 2 !== 0) { + failKnown( + "Usage: node scripts/Test-LegacyPackageAppearanceSmoke.mjs " + + "--phase mutate|verify-restore --port --output " + + "--screenshot --exchange-directory " + + "--expected-color-theme --expected-view-mode " + + "--expected-start-workspace --final-color-theme " + + "--final-view-mode --final-start-workspace " + + "[--timeout-ms ]"); + } + const values = new Map(); + for (let index = 0; index < argv.length; index += 2) { + const rawName = argv[index]; + const value = argv[index + 1]; + if (!rawName?.startsWith("--") || !value) failKnown("Arguments must be --name value pairs."); + const name = rawName.slice(2); + if (!accepted.has(name) || values.has(name)) failKnown(`Invalid argument --${name}.`); + values.set(name, value); + } + for (const required of [ + "phase", "port", "output", "screenshot", "exchange-directory", + "expected-color-theme", "expected-view-mode", "expected-start-workspace", + "final-color-theme", "final-view-mode", "final-start-workspace" + ]) { + if (!values.has(required)) failKnown(`--${required} is required.`); + } + const phase = values.get("phase"); + if (!new Set(["mutate", "verify-restore"]).has(phase)) { + failKnown("--phase must be mutate or verify-restore."); + } + const port = Number(values.get("port")); + const timeoutMilliseconds = Number(values.get("timeout-ms") || "120000"); + if (!Number.isInteger(port) || port < 1024 || port > 65_535) { + failKnown("--port must be from 1024 through 65535."); + } + if (!Number.isInteger(timeoutMilliseconds) || + timeoutMilliseconds < 30_000 || timeoutMilliseconds > 900_000) { + failKnown("--timeout-ms must be from 30000 through 900000."); + } + const outputPath = path.resolve(values.get("output")); + const screenshotPath = path.resolve(values.get("screenshot")); + const exchangeDirectory = path.resolve(values.get("exchange-directory")); + if (!path.isAbsolute(values.get("output")) || + path.extname(outputPath).toLowerCase() !== ".json" || + !path.isAbsolute(values.get("screenshot")) || + path.extname(screenshotPath).toLowerCase() !== ".png" || + !path.isAbsolute(values.get("exchange-directory"))) { + failKnown("Output, screenshot and exchange paths must be absolute and typed correctly."); + } + if (new Set([ + outputPath.toLowerCase(), screenshotPath.toLowerCase(), exchangeDirectory.toLowerCase() + ]).size !== 3) { + failKnown("Evidence paths must be distinct."); + } + if (fs.existsSync(outputPath) || fs.existsSync(screenshotPath) || + !fs.existsSync(exchangeDirectory) || + !fs.statSync(exchangeDirectory).isDirectory() || + fs.readdirSync(exchangeDirectory).length !== 0) { + failKnown("Evidence is never overwritten and the exchange directory must be empty."); + } + const expected = Object.freeze({ + colorTheme: parseClosedValue( + values, "expected-color-theme", appearanceValues.colorTheme), + viewMode: parseClosedValue(values, "expected-view-mode", appearanceValues.viewMode), + startWorkspace: parseClosedValue( + values, "expected-start-workspace", appearanceValues.startWorkspace) + }); + const final = Object.freeze({ + colorTheme: parseClosedValue(values, "final-color-theme", appearanceValues.colorTheme), + viewMode: parseClosedValue(values, "final-view-mode", appearanceValues.viewMode), + startWorkspace: parseClosedValue( + values, "final-start-workspace", appearanceValues.startWorkspace) + }); + if (Object.keys(expected).some(key => expected[key] === final[key])) { + failKnown("Every phase must physically change all three appearance values."); + } + return { + phase, + port, + timeoutMilliseconds, + outputPath, + screenshotPath, + exchangeDirectory, + expected, + final + }; +} + +const configuration = parseArguments(process.argv.slice(2)); +const hardDeadline = Date.now() + configuration.timeoutMilliseconds; +let socket = null; +let nextRpcId = 1; +let nextExchangeSequence = 1; +let probeInstalled = false; +let cancellationRequested = false; +const pendingRpc = new Map(); +const evidence = { + schemaVersion: 1, + profile: "operator-appearance-persistence", + phase: configuration.phase, + result: "RUNNING", + startedAt: new Date().toISOString(), + completedAt: null, + target: { + expectedUrl: exactTargetUrl, + port: configuration.port, + id: null, + url: null + }, + expectedInitial: configuration.expected, + expectedFinal: configuration.final, + safety: { + requiredMode: "dryRun", + requiredPhase: "idle", + inputRetryCount: 0, + appCloseRequested: false, + playoutIntentIssued: false, + allowedOutboundTypes: [...allowedOutboundTypes], + observedOutboundTypes: [], + blockedOutboundMessages: [], + stateViolations: [] + }, + persistenceVerifiedBeforeInput: false, + startWorkspaceBehaviorVerifiedBeforeInput: false, + selections: [], + exchanges: [], + checkpoints: [], + finalSnapshot: null, + screenshot: null, + error: null +}; + +process.stdin.setEncoding("utf8"); +process.stdin.on("data", chunk => { + if (String(chunk).split(/\r?\n/u).includes("CANCEL")) cancellationRequested = true; +}); +if (typeof process.stdin.unref === "function") process.stdin.unref(); + +function assertDeadline(label) { + if (cancellationRequested) failUnknown(`The wrapper cancelled ${label}; no input was retried.`); + if (Date.now() >= hardDeadline) { + failUnknown(`The global deadline expired ${label}; no input was retried.`); + } +} + +async function sleep(milliseconds) { + assertDeadline("before waiting"); + await new Promise(resolve => setTimeout( + resolve, + Math.max(0, Math.min(milliseconds, hardDeadline - Date.now())))); + assertDeadline("after waiting"); +} + +function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex").toUpperCase(); +} + +function exactAppearance(value) { + return value ? { + colorTheme: value.colorTheme, + viewMode: value.viewMode, + startWorkspace: value.startWorkspace + } : null; +} + +function appearanceEquals(left, right) { + return Boolean(left && right && + left.colorTheme === right.colorTheme && + left.viewMode === right.viewMode && + left.startWorkspace === right.startWorkspace); +} + +async function discoverTarget() { + const discoveryUrl = `http://127.0.0.1:${configuration.port}/json/list`; + const deadline = Math.min(hardDeadline, Date.now() + 15_000); + while (Date.now() < deadline) { + try { + const response = await fetch(discoveryUrl, { cache: "no-store" }); + if (response.ok) { + const targets = await response.json(); + const exact = targets.filter(target => + target?.type === "page" && target.url === exactTargetUrl); + if (exact.length > 1) failKnown("More than one exact package WebView target exists."); + if (exact.length === 1) return exact[0]; + } + } catch (error) { + if (error instanceof AppearanceFailure) throw error; + } + await sleep(100); + } + failKnown("The exact package target was not found."); +} + +function validateEndpoint(target) { + if (!target?.id || !target.webSocketDebuggerUrl) failKnown("The target has no debugger URL."); + const endpoint = new URL(target.webSocketDebuggerUrl); + if (endpoint.protocol !== "ws:" || endpoint.hostname !== "127.0.0.1" || + Number(endpoint.port) !== configuration.port || + endpoint.pathname !== `/devtools/page/${target.id}`) { + failKnown("The debugger endpoint is not the exact loopback page endpoint."); + } + return endpoint; +} + +async function connect(endpoint) { + if (typeof WebSocket !== "function") failKnown("This Node runtime has no WebSocket API."); + socket = new WebSocket(endpoint.href); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new AppearanceFailure( + "OUTCOME_UNKNOWN", "CDP socket open timed out.")), 5_000); + socket.addEventListener("open", () => { clearTimeout(timer); resolve(); }, { once: true }); + socket.addEventListener("error", () => { clearTimeout(timer); reject(new AppearanceFailure( + "OUTCOME_UNKNOWN", "CDP socket failed to open.")); }, { once: true }); + }); + socket.addEventListener("message", event => { + const message = JSON.parse(String(event.data)); + if (!Object.hasOwn(message, "id")) return; + const pending = pendingRpc.get(message.id); + if (!pending) return; + pendingRpc.delete(message.id); + clearTimeout(pending.timer); + if (message.error) pending.reject(new AppearanceFailure( + "KNOWN_FAILURE", `${pending.method} failed: ${message.error.message || "CDP error"}`)); + else pending.resolve(message.result); + }); +} + +async function rpc(method, params = {}, timeoutMilliseconds = 10_000) { + assertDeadline(`before ${method}`); + if (forbiddenCdpMethods.has(method)) failKnown(`CDP method ${method} is forbidden.`); + if (!socket || socket.readyState !== WebSocket.OPEN) failUnknown("CDP socket is not open."); + const id = nextRpcId++; + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pendingRpc.delete(id); + reject(new AppearanceFailure( + "OUTCOME_UNKNOWN", `${method} timed out; no input was retried.`)); + }, Math.min(timeoutMilliseconds, Math.max(1, hardDeadline - Date.now()))); + pendingRpc.set(id, { resolve, reject, timer, method }); + socket.send(JSON.stringify({ id, method, params })); + }); +} + +async function evaluate(expression) { + const result = await rpc("Runtime.evaluate", { + expression, + awaitPromise: true, + returnByValue: true + }); + if (result?.exceptionDetails) failKnown("A read-only page inspection failed."); + return result?.result?.value; +} + +async function installProbe() { + const token = `appearance-${Date.now()}-${randomBytes(8).toString("hex")}`; + const installed = await evaluate(`(() => { + if (!window.chrome?.webview) throw new Error("WebView2 bridge is unavailable."); + if (globalThis.__legacyAppearanceProbe) throw new Error("Appearance probe already exists."); + const probe = { + token: ${JSON.stringify(token)}, + states: [], + outboundMessages: [], + blockedOutboundMessages: [], + stateViolations: [], + inputEvents: [], + originalPostMessage: window.chrome.webview.postMessage, + postMessageWrapper: null, + stateListener: null, + inputListener: null + }; + const allowed = new Set(${JSON.stringify([...allowedOutboundTypes])}); + probe.stateListener = event => { + if (event.data?.type !== "state" || !event.data.payload) return; + const state = event.data.payload; + probe.states.push(structuredClone(state)); + if (probe.states.length > 100) probe.states.shift(); + const playout = state.playout; + const reasons = []; + if (!playout) reasons.push("missing-playout"); + else { + if (playout.mode !== "dryRun") reasons.push("mode"); + if (playout.phase !== "idle") reasons.push("phase"); + if (playout.isConnected === true) reasons.push("connected"); + if (playout.preparedCode != null) reasons.push("prepared-code"); + if (playout.onAirCode != null) reasons.push("on-air-code"); + if (playout.outcomeUnknown === true) reasons.push("outcome-unknown"); + if (playout.isPlayCompletionPending === true) reasons.push("play-pending"); + if (playout.isTakeOutCompletionPending === true) reasons.push("takeout-pending"); + if (playout.isBusy === true) reasons.push("playout-busy"); + if (playout.refreshActive === true) reasons.push("refresh-active"); + } + if (reasons.length > 0) probe.stateViolations.push({ + at: new Date().toISOString(), + revision: state.revision ?? null, + reasons + }); + }; + probe.inputListener = event => { + const element = event.target instanceof Element ? event.target : null; + const controlled = element?.closest?.( + "#workspace-settings-tab, #operator-color-theme, " + + "#operator-view-mode, #operator-start-workspace"); + if (!controlled) return; + probe.inputEvents.push({ + sequence: probe.inputEvents.length + 1, + type: event.type, + targetId: controlled.id || null, + isTrusted: event.isTrusted === true, + pointerType: event.pointerType || null, + button: Number.isInteger(event.button) ? event.button : null, + buttons: Number.isInteger(event.buttons) ? event.buttons : null, + key: typeof event.key === "string" ? event.key : null, + value: controlled instanceof HTMLSelectElement ? controlled.value : null, + at: new Date().toISOString() + }); + if (probe.inputEvents.length > 150) probe.inputEvents.shift(); + }; + probe.postMessageWrapper = message => { + const type = typeof message?.type === "string" ? message.type : null; + const record = { + sequence: probe.outboundMessages.length + 1, + type, + appearance: type === "set-operator-appearance" ? { + colorTheme: message?.payload?.colorTheme ?? null, + viewMode: message?.payload?.viewMode ?? null, + startWorkspace: message?.payload?.startWorkspace ?? null + } : null, + at: new Date().toISOString() + }; + probe.outboundMessages.push(record); + if (!type || !allowed.has(type)) { + probe.blockedOutboundMessages.push(record); + return; + } + return probe.originalPostMessage.call(window.chrome.webview, message); + }; + try { + window.chrome.webview.postMessage = probe.postMessageWrapper; + if (window.chrome.webview.postMessage !== probe.postMessageWrapper) { + throw new Error("The outbound safety gate could not be installed."); + } + globalThis.__legacyAppearanceProbe = probe; + window.chrome.webview.addEventListener("message", probe.stateListener); + ["pointerdown", "pointerup", "keydown", "keyup", "input", "change"].forEach(type => + document.addEventListener(type, probe.inputListener, true)); + window.chrome.webview.postMessage({ type: "ready", payload: {} }); + } catch (error) { + window.chrome.webview.postMessage = probe.originalPostMessage; + delete globalThis.__legacyAppearanceProbe; + throw error; + } + return probe.token; + })()`); + if (installed !== token) failKnown("The safety probe was not installed exactly once."); + probeInstalled = true; +} + +async function removeProbe() { + if (!probeInstalled || !socket || socket.readyState !== WebSocket.OPEN) return; + const removed = await evaluate(`(() => { + const probe = globalThis.__legacyAppearanceProbe; + if (!probe) return false; + window.chrome.webview.removeEventListener("message", probe.stateListener); + ["pointerdown", "pointerup", "keydown", "keyup", "input", "change"].forEach(type => + document.removeEventListener(type, probe.inputListener, true)); + if (window.chrome.webview.postMessage !== probe.postMessageWrapper) { + throw new Error("The outbound safety gate identity changed."); + } + window.chrome.webview.postMessage = probe.originalPostMessage; + delete globalThis.__legacyAppearanceProbe; + return true; + })()`); + if (removed !== true) failUnknown("The safety probe was not restored exactly once."); + probeInstalled = false; +} + +async function readSnapshot() { + return await evaluate(`(() => { + const probe = globalThis.__legacyAppearanceProbe; + const state = probe?.states?.at(-1) || null; + const html = document.documentElement; + const body = document.body; + const active = document.activeElement; + const appearance = state?.operatorSettings ? { + colorTheme: state.operatorSettings.colorTheme, + viewMode: state.operatorSettings.viewMode, + startWorkspace: state.operatorSettings.startWorkspace + } : null; + return { + state: state ? { + revision: state.revision, + isBusy: state.isBusy === true, + playout: structuredClone(state.playout), + operatorSettings: state.operatorSettings ? { + revision: state.operatorSettings.revision, + colorTheme: state.operatorSettings.colorTheme, + viewMode: state.operatorSettings.viewMode, + startWorkspace: state.operatorSettings.startWorkspace, + restartRequired: state.operatorSettings.restartRequired === true, + message: state.operatorSettings.message || "", + messageKind: state.operatorSettings.messageKind || null + } : null + } : null, + appearance, + safety: { + wrapped: probe?.postMessageWrapper != null && + window.chrome?.webview?.postMessage === probe.postMessageWrapper, + outboundMessages: (probe?.outboundMessages || []).map(value => ({ ...value })), + blockedOutboundMessages: (probe?.blockedOutboundMessages || []).map(value => ({ ...value })), + stateViolations: (probe?.stateViolations || []).map(value => ({ + ...value, + reasons: [...value.reasons] + })), + inputEvents: (probe?.inputEvents || []).map(value => ({ ...value })) + }, + dom: { + url: location.href, + readyState: document.readyState, + bodyBusy: body.getAttribute("aria-busy"), + connectionText: (document.getElementById("playout-connection-state")?.textContent || "").trim(), + settingsVisible: document.getElementById("settings-workspace")?.hidden === false && + document.getElementById("settings-workspace")?.inert !== true, + settingsCurrent: document.getElementById("workspace-settings-tab") + ?.getAttribute("aria-current") || null, + activeWorkspace: document.getElementById("workspace-region") + ?.dataset?.activeWorkspace || null, + activeElementId: active?.id || null, + selects: { + colorTheme: document.getElementById("operator-color-theme")?.value ?? null, + viewMode: document.getElementById("operator-view-mode")?.value ?? null, + startWorkspace: document.getElementById("operator-start-workspace")?.value ?? null + }, + htmlAppearance: { + colorTheme: html.dataset.colorTheme || null, + viewMode: html.dataset.viewMode || null, + startWorkspace: html.dataset.startWorkspace || null + }, + bodyAppearance: { + colorTheme: body.dataset.colorTheme || null, + viewMode: body.dataset.viewMode || null, + startWorkspace: body.dataset.startWorkspace || null + }, + computed: { + colorScheme: getComputedStyle(html).colorScheme || null, + bodyBackground: getComputedStyle(body).backgroundColor || null + }, + legacyDialogHidden: document.getElementById("legacy-dialog")?.hidden === true, + namedModalHidden: document.getElementById("named-playlist-modal")?.hidden === true, + namedConfirmationHidden: + document.getElementById("named-playlist-confirmation")?.hidden === true + } + }; + })()`); +} + +function updateSafety(snapshot) { + evidence.safety.observedOutboundTypes = + snapshot?.safety?.outboundMessages?.map(row => row.type) || []; + evidence.safety.blockedOutboundMessages = + snapshot?.safety?.blockedOutboundMessages || []; + evidence.safety.stateViolations = snapshot?.safety?.stateViolations || []; + evidence.safety.playoutIntentIssued = evidence.safety.observedOutboundTypes.some(type => + ["prepare-playout", "take-in", "next-playout", "take-out", + "choose-background", "toggle-background"].includes(type)); +} + +function assertSafety(snapshot, label, allowBusy = false) { + updateSafety(snapshot); + if (!snapshot?.state?.playout || snapshot.safety.wrapped !== true) { + failUnknown(`The native DryRun state or safety gate is missing at ${label}.`); + } + if (snapshot.safety.blockedOutboundMessages.length > 0) { + failKnown(`An unapproved outbound message was blocked at ${label}.`); + } + if (snapshot.safety.stateViolations.length > 0) { + failUnknown(`DryRun left its safe state at ${label}.`); + } + const playout = snapshot.state.playout; + if (playout.mode !== "dryRun" || playout.phase !== "idle" || + playout.isConnected === true || playout.preparedCode != null || + playout.onAirCode != null || playout.outcomeUnknown === true || + playout.isPlayCompletionPending === true || + playout.isTakeOutCompletionPending === true || playout.isBusy === true || + playout.refreshActive === true) { + failUnknown(`Playout is not exact DryRun IDLE at ${label}.`); + } + if (!allowBusy && snapshot.state.isBusy === true) { + failKnown(`The operator UI is busy at ${label}.`); + } + if (snapshot.dom.url !== exactTargetUrl || snapshot.dom.readyState !== "complete" || + snapshot.dom.connectionText !== "DRY RUN" || + snapshot.dom.legacyDialogHidden !== true || + snapshot.dom.namedModalHidden !== true || + snapshot.dom.namedConfirmationHidden !== true) { + failKnown(`The exact package document is not ready at ${label}.`); + } +} + +async function waitFor(label, predicate, { allowBusy = false, allowMissingState = false } = {}) { + if (allowMissingState && label !== "DryRun IDLE preflight") { + failKnown("Only the input-free initial preflight may await the first native state."); + } + const deadline = Math.min(hardDeadline, Date.now() + 45_000); + let last = null; + while (Date.now() < deadline) { + last = await readSnapshot(); + if (!last?.state?.playout && allowMissingState) { + if (last?.safety?.wrapped !== true || last?.dom?.url !== exactTargetUrl || + last?.dom?.readyState !== "complete" || last?.dom?.connectionText !== "DRY RUN") { + failUnknown("The exact DryRun document changed before the first state."); + } + updateSafety(last); + if (evidence.safety.blockedOutboundMessages.length > 0 || + evidence.safety.stateViolations.length > 0) { + failUnknown("The safety gate observed a violation before initial state."); + } + await sleep(100); + continue; + } + assertSafety(last, label, allowBusy); + if (predicate(last)) return last; + await sleep(100); + } + failUnknown(`Timed out waiting for ${label}; no input was retried.`); +} + +function assertAppearanceSnapshot(snapshot, expected, label, requireNeutralLoad = false) { + if (!appearanceEquals(snapshot.appearance, expected) || + !appearanceEquals(snapshot.dom.selects, expected) || + !appearanceEquals(snapshot.dom.htmlAppearance, expected) || + !appearanceEquals(snapshot.dom.bodyAppearance, expected) || + snapshot.state.operatorSettings?.restartRequired !== false) { + failKnown(`The native and rendered appearance do not agree at ${label}.`); + } + if (requireNeutralLoad && new Set(["warning", "error"]) + .has(snapshot.state.operatorSettings?.messageKind)) { + failKnown("The existing operator settings could not be loaded cleanly before input."); + } +} + +async function geometryFor(control) { + const value = await evaluate(`(() => { + const element = ${control.expression}; + if (!element) return null; + element.scrollIntoView({ block: "nearest", inline: "nearest" }); + const rect = element.getBoundingClientRect(); + return { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + width: rect.width, + height: rect.height, + viewport: { + width: document.documentElement.clientWidth, + height: document.documentElement.clientHeight + }, + devicePixelRatio: window.devicePixelRatio, + disabled: element.disabled === true, + hidden: element.hidden === true || element.closest("[hidden]") != null, + optionValues: element instanceof HTMLSelectElement + ? [...element.options].map(option => option.value) : null + }; + })()`); + if (!value || value.disabled || value.hidden || value.width <= 0 || value.height <= 0 || + !Number.isFinite(value.x) || !Number.isFinite(value.y) || + value.x < 0 || value.y < 0 || + value.x >= value.viewport.width || value.y >= value.viewport.height || + value.devicePixelRatio < 0.25 || value.devicePixelRatio > 5) { + failKnown(`#${control.id} is not a unique visible physical-input target.`); + } + return value; +} + +function exchangePath(sequence, suffix) { + return path.join(configuration.exchangeDirectory, + `${String(sequence).padStart(2, "0")}.${suffix}.json`); +} + +function readSmallJson(filePath, label) { + const stat = fs.statSync(filePath); + if (!stat.isFile() || stat.size <= 0 || stat.size > 64 * 1024) { + failKnown(`${label} is empty or too large.`); + } + const bytes = fs.readFileSync(filePath); + let value; + try { value = JSON.parse(bytes.toString("utf8")); } + catch { failKnown(`${label} is not strict JSON.`); } + return { value, bytes, sha256: sha256(bytes) }; +} + +async function waitForTrustedInput(control, startIndex, targetValue) { + const deadline = Math.min(hardDeadline, Date.now() + 10_000); + while (Date.now() < deadline) { + const events = await evaluate(`(() => + (globalThis.__legacyAppearanceProbe?.inputEvents || []) + .slice(${Number(startIndex)}).map(value => ({ ...value })))()`); + const relevant = events.filter(event => event.targetId === control.id); + const down = relevant.find(event => event.type === "pointerdown"); + const up = relevant.find(event => event.type === "pointerup"); + if (down && up) { + if (down.sequence >= up.sequence || down.isTrusted !== true || up.isTrusted !== true || + down.pointerType !== "mouse" || up.pointerType !== "mouse" || + down.button !== 0 || up.button !== 0 || down.buttons !== 1 || up.buttons !== 0) { + failKnown(`#${control.id} did not receive one trusted mouse down/up pair.`); + } + if (targetValue === null) return { pointerDown: down, pointerUp: up, change: null }; + const change = relevant.find(event => + event.type === "change" && event.isTrusted === true && event.value === targetValue); + const trustedKeyDowns = relevant.filter(event => + event.type === "keydown" && event.isTrusted === true); + // A native HTML Expand -
Category
+
@@ -288,17 +267,16 @@

설정 폴더

Res -

종목 목록 등 UI 설정과, MmoneyCoder.ini가 있으면 다음 시작부터 적용할 DB 프로필의 위치입니다.

- 앱 기본 설정 폴더 + 앱 기본값 확인 중 - 앱 기본값 +
- + type="button">변경 +
@@ -312,36 +290,62 @@
-

운영 배경 폴더

+

배경 폴더

VRV/JPG/PNG
-

F2에서 선택할 수 있는 검증된 VRV/JPG/PNG 배경의 기준·허용 위치입니다.

- 앱 기본 배경 폴더 + 자동 확인 중 - 앱 기본값 +
- + type="button">변경 +
+
+
+

화면

+
+
+ + + +
+
+
-
-

시작 화면

-

앱을 열었을 때의 작업 공간 표시 방식을 정합니다.

-
+

메뉴