diff --git a/docs/platforms.md b/docs/platforms.md index 2b01b1d3..a376ab7b 100644 --- a/docs/platforms.md +++ b/docs/platforms.md @@ -28,6 +28,7 @@ | 用途 | 路径 | |------|------| | 源码(本整理包) | `Codex-Dream-Skin/windows/` | +| 安装后的受管运行时 | `%LOCALAPPDATA%\CodexDreamSkin\engine` | | 状态 / 日志 | `%LOCALAPPDATA%\CodexDreamSkin` | | Codex 配置 | `%USERPROFILE%\.codex\config.toml` | | 默认 CDP 端口 | 首选 `9335`,冲突时自动选空闲口(Mac 包默认从 `9341` 起) | @@ -74,7 +75,7 @@ Windows 启动、失败回滚与恢复重开均从已注册的 `OpenAI.Codex` ### 平台差异 - macOS 的选图脚本会把这些字段写入主题库,可通过 `--appearance`、`--focus-x`、`--focus-y`、`--safe-area`、`--task-mode` 设置。 -- Windows 安装会初始化 `%LOCALAPPDATA%\CodexDreamSkin\active-theme`、`themes` 与 `images`,并把「桥本有菜」同时设为首次默认和可切换的已保存主题。系统托盘支持更换背景、保存当前主题、从「已保存主题」切换、暂停和恢复;图片与 `theme.json` 保存在主题仓库中,不写进 Codex 的 `config.toml`。安装会保留用户已有的 `appearanceTheme`;仅在识别到旧版精确托管的浅色三元组时按备份迁移。 +- Windows 安装会把运行所需的 `assets/` 与 `scripts/` 原子复制到 `%LOCALAPPDATA%\CodexDreamSkin\engine`,所有快捷方式均指向该受管副本,因此安装后可移动或删除源码目录。安装还会初始化 `active-theme`、`themes` 与 `images`,并把「桥本有菜」同时设为首次默认和可切换的已保存主题。系统托盘支持更换背景、保存当前主题、从「已保存主题」切换、暂停和恢复;图片与 `theme.json` 保存在主题仓库中,不写进 Codex 的 `config.toml`。安装会保留用户已有的 `appearanceTheme`;仅在识别到旧版精确托管的浅色三元组时按备份迁移。 - Windows 渲染器仍支持在注入前用 `window.__CODEX_DREAM_SKIN_CONFIG__` 提供内存级可选覆盖(形状同上,颜色覆盖使用 `palette.accent`),但普通用户应优先使用持久化主题仓库与托盘。 ## 预设与图片类型 diff --git a/windows/CHANGELOG.md b/windows/CHANGELOG.md index 14f1263d..0fe2b9cb 100644 --- a/windows/CHANGELOG.md +++ b/windows/CHANGELOG.md @@ -4,6 +4,7 @@ ### 新增 +- Windows 安装器会先校验并原子复制运行所需的 `assets/` 与 `scripts/` 到 `%LOCALAPPDATA%\CodexDreamSkin\engine`,启动、恢复和托盘快捷方式统一指向该受管副本;安装完成后可移动或删除源码克隆。重装前若旧托盘仍在运行,安装器会明确要求退出,避免新旧脚本混用。 - 渲染层支持通用自适应图像主题:本地 Canvas 采样图像亮度、主色、焦点和比例,为壁纸层提供自适应色彩与构图建议;支持 `appearance: auto | light | dark`、`art.focusX/focusY`(`0..1`)、`art.safeArea: auto | left | right | center | none`、`art.taskMode: auto | ambient | banner | off`。外观壳仍由显式主题或原生外观信号决定。 - 显式外观与艺术元数据优先于分析结果;超宽图默认任务横幅,普通比例图默认环境背景,`off` 可关闭任务页图像。分析完全在渲染器本地完成,不上传图片。 - Windows 发行 payload 直接读取受管 `theme.json`,完整支持与 macOS 一致的外观、焦点、安全区和任务页模式契约,不再依赖预先设置的 renderer 全局变量。 diff --git a/windows/SKILL.md b/windows/SKILL.md index e7cbc449..61bac3df 100644 --- a/windows/SKILL.md +++ b/windows/SKILL.md @@ -9,8 +9,8 @@ Apply a reversible renderer skin through Chromium DevTools Protocol while launch ## Workflow -1. Install Node.js 22 or newer, close Codex, then run `scripts/install-dream-skin.ps1` once to preserve the user's native appearance settings, seed the Arina Hashimoto theme, and create launch/restore/tray shortcuts. -2. Run `scripts/start-dream-skin.ps1`. The shortcut asks before restarting an already-open Codex app; CLI callers must explicitly add `-RestartExisting`. +1. Install Node.js 22 or newer, close Codex, then run `scripts/install-dream-skin.ps1` once. The installer preserves the user's native appearance settings, seeds the Arina Hashimoto theme, copies the runtime to `%LOCALAPPDATA%\CodexDreamSkin\engine`, and creates launch/restore/tray shortcuts that do not depend on the source checkout. +2. Use the `Codex Dream Skin` shortcut, or run `%LOCALAPPDATA%\CodexDreamSkin\engine\scripts\start-dream-skin.ps1`. The shortcut asks before restarting an already-open Codex app; CLI callers must explicitly add `-RestartExisting`. 3. Run `scripts/verify-dream-skin.ps1 -ScreenshotPath ` after launch. Treat a missing continuous wallpaper, home shell, native composer, sidebar layer, or injection marker as failure. The native suggestion count is responsive and may be two to four. 4. Inspect the screenshot against `references/qa-inventory.md`. Verify both the home screen and a normal task before signing off. 5. Run `scripts/restore-dream-skin.ps1` to remove the live skin, close the saved CDP session, and reopen Codex normally. Add `-RestoreBaseTheme` to restore only saved appearance keys, `-RecoverConfigBackup` for explicit byte-for-byte recovery of a damaged config, or `-Uninstall` to delete shortcuts. A completed config restore archives that install's backup so a later install captures a fresh baseline. @@ -33,6 +33,7 @@ Apply a reversible renderer skin through Chromium DevTools Protocol while launch - Loopback prevents LAN exposure, but Chromium CDP has no same-user authentication. Run only trusted local software while the skin is active, and use restore to close the debug session when it is no longer needed. - Preserve `config.toml` as strict UTF-8. Never use encoding-dependent whole-file PowerShell reads/writes, silently transcode UTF-16, or overwrite a file that changed after it was read. Ambiguous TOML shapes must fail before writing rather than receive a best-effort rewrite. - Keep install/start/restore/verify serialized with the per-user operation lock in `common-windows.ps1`. +- Treat `%LOCALAPPDATA%\CodexDreamSkin\engine` as an installer-managed runtime. Exit the Dream Skin tray before reinstalling so the installer can replace that runtime atomically and update every shortcut to the same copy. ## Checks @@ -45,7 +46,7 @@ node --check assets\renderer-inject.js ## Resources - `scripts/injector.mjs`: CDP connection, renderer injection, verification, screenshot, and removal. -- `scripts/common-windows.ps1`: Store-package discovery, Node validation, port ownership, state, and process identity safety. +- `scripts/common-windows.ps1`: Store-package discovery, Node validation, managed runtime installation, port ownership, state, and process identity safety. - `scripts/config-utf8.ps1`: atomic UTF-8 configuration backup, selective restore, and explicit recovery. - `assets/dream-skin.css`: full visual layer. - `assets/renderer-inject.js`: idempotent DOM integration and cleanup. @@ -55,4 +56,4 @@ node --check assets\renderer-inject.js - `scripts/tray-dream-skin.ps1`: Windows Forms tray for apply, pause, import, save, switch, and complete restore. - `references/qa-inventory.md`: required functional and visual signoff coverage. - `references/runtime-notes.md`: troubleshooting and update behavior. -- `tests/run-tests.ps1`: configuration, state, recovery, payload, and CDP validation regression checks. +- `tests/run-tests.ps1`: managed runtime, configuration, state, recovery, payload, and CDP validation regression checks. Use `-EngineOnly` for the source-independence contract. diff --git a/windows/scripts/common-windows.ps1 b/windows/scripts/common-windows.ps1 index 843331d1..5b37bee7 100644 --- a/windows/scripts/common-windows.ps1 +++ b/windows/scripts/common-windows.ps1 @@ -48,6 +48,196 @@ function Test-DreamSkinPathWithin { } } +function Get-DreamSkinRuntimeEnginePaths { + param([string]$StateRoot = (Join-Path $env:LOCALAPPDATA 'CodexDreamSkin')) + $root = Join-Path ([System.IO.Path]::GetFullPath($StateRoot)) 'engine' + $scripts = Join-Path $root 'scripts' + return [pscustomobject]@{ + Root = $root + Scripts = $scripts + Start = Join-Path $scripts 'start-dream-skin.ps1' + Restore = Join-Path $scripts 'restore-dream-skin.ps1' + Tray = Join-Path $scripts 'tray-dream-skin.ps1' + } +} + +function Test-DreamSkinTrayActive { + $sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value + $mutex = [System.Threading.Mutex]::new($false, "Local\CodexDreamSkin.$sid.Tray") + $acquired = $false + try { + try { $acquired = $mutex.WaitOne(0) } catch [System.Threading.AbandonedMutexException] { + $acquired = $true + } + if ($acquired) { + $mutex.ReleaseMutex() + $acquired = $false + return $false + } + return $true + } finally { + if ($acquired) { try { $mutex.ReleaseMutex() } catch {} } + $mutex.Dispose() + } +} + +function Assert-DreamSkinRuntimeTree { + param([Parameter(Mandatory = $true)][string]$Path) + $root = [System.IO.Path]::GetFullPath($Path) + if (-not (Test-Path -LiteralPath $root -PathType Container)) { + throw "Dream Skin runtime directory does not exist: $root" + } + if (-not (Get-Command Assert-DreamSkinNoReparseComponents -ErrorAction SilentlyContinue)) { + throw 'Dream Skin managed-path validation is unavailable.' + } + Assert-DreamSkinNoReparseComponents -Path $root + foreach ($item in Get-ChildItem -LiteralPath $root -Recurse -Force -ErrorAction Stop) { + if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Dream Skin runtime contains a junction or symbolic link: $($item.FullName)" + } + } +} + +function Remove-DreamSkinRuntimeTree { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$StateRoot + ) + $fullPath = [System.IO.Path]::GetFullPath($Path) + $fullStateRoot = [System.IO.Path]::GetFullPath($StateRoot) + if (-not (Test-DreamSkinPathWithin -Path $fullPath -Root $fullStateRoot)) { + throw "Refusing to remove a runtime path outside the Dream Skin state root: $fullPath" + } + if (-not (Test-Path -LiteralPath $fullPath)) { return } + Assert-DreamSkinRuntimeTree -Path $fullPath + Remove-Item -LiteralPath $fullPath -Recurse -Force -ErrorAction Stop +} + +function Install-DreamSkinRuntimeEngine { + param( + [Parameter(Mandatory = $true)][string]$SkillRoot, + [Parameter(Mandatory = $true)][string]$StateRoot + ) + if (-not (Get-Command Ensure-DreamSkinManagedDirectory -ErrorAction SilentlyContinue)) { + throw 'Dream Skin managed-directory validation is unavailable.' + } + + $sourceRoot = [System.IO.Path]::GetFullPath($SkillRoot) + $fullStateRoot = [System.IO.Path]::GetFullPath($StateRoot) + $engine = Get-DreamSkinRuntimeEnginePaths -StateRoot $fullStateRoot + $required = @( + 'assets\dream-reference.jpg', + 'assets\dream-skin.css', + 'assets\renderer-inject.js', + 'assets\theme.json', + 'scripts\common-windows.ps1', + 'scripts\config-utf8.ps1', + 'scripts\image-metadata.mjs', + 'scripts\injector.mjs', + 'scripts\install-dream-skin.ps1', + 'scripts\restore-dream-skin.ps1', + 'scripts\start-dream-skin.ps1', + 'scripts\theme-windows.ps1', + 'scripts\tray-dream-skin.ps1', + 'scripts\verify-dream-skin.ps1' + ) + foreach ($relative in $required) { + if (-not (Test-Path -LiteralPath (Join-Path $sourceRoot $relative) -PathType Leaf)) { + throw "Dream Skin runtime source is incomplete: $relative" + } + } + foreach ($directoryName in @('assets', 'scripts')) { + $sourceDirectory = Join-Path $sourceRoot $directoryName + if ((Test-DreamSkinPathEqual -Left $fullStateRoot -Right $sourceDirectory) -or + (Test-DreamSkinPathWithin -Path $fullStateRoot -Root $sourceDirectory)) { + throw "Dream Skin state root cannot be created inside its runtime source: $fullStateRoot" + } + Assert-DreamSkinRuntimeTree -Path $sourceDirectory + } + + Ensure-DreamSkinManagedDirectory -Path $fullStateRoot -Root $fullStateRoot + $token = [guid]::NewGuid().ToString('N') + $stagingRoot = Join-Path $fullStateRoot ".engine-staging-$token" + $backupRoot = Join-Path $fullStateRoot ".engine-backup-$token" + Ensure-DreamSkinManagedDirectory -Path $stagingRoot -Root $fullStateRoot + + try { + foreach ($directoryName in @('assets', 'scripts')) { + Copy-Item -LiteralPath (Join-Path $sourceRoot $directoryName) -Destination $stagingRoot ` + -Recurse -Force -ErrorAction Stop + } + Assert-DreamSkinRuntimeTree -Path $stagingRoot + foreach ($relative in $required) { + if (-not (Test-Path -LiteralPath (Join-Path $stagingRoot $relative) -PathType Leaf)) { + throw "Staged Dream Skin runtime is incomplete: $relative" + } + } + + $sourcePrefix = $sourceRoot.TrimEnd('\') + '\' + $sourceFiles = @( + Get-ChildItem -LiteralPath (Join-Path $sourceRoot 'assets'), (Join-Path $sourceRoot 'scripts') ` + -Recurse -File -Force -ErrorAction Stop + ) + $stagedFiles = @( + Get-ChildItem -LiteralPath (Join-Path $stagingRoot 'assets'), (Join-Path $stagingRoot 'scripts') ` + -Recurse -File -Force -ErrorAction Stop + ) + if ($sourceFiles.Count -ne $stagedFiles.Count) { + throw 'Staged Dream Skin runtime file count does not match its source.' + } + foreach ($sourceFile in $sourceFiles) { + $relative = $sourceFile.FullName.Substring($sourcePrefix.Length) + $stagedFile = Join-Path $stagingRoot $relative + if (-not (Test-Path -LiteralPath $stagedFile -PathType Leaf) -or + (Get-FileHash -Algorithm SHA256 -LiteralPath $sourceFile.FullName).Hash -cne + (Get-FileHash -Algorithm SHA256 -LiteralPath $stagedFile).Hash) { + throw "Staged Dream Skin runtime failed hash verification: $relative" + } + } + + $hasBackup = $false + if (Test-Path -LiteralPath $engine.Root) { + Assert-DreamSkinRuntimeTree -Path $engine.Root + Move-Item -LiteralPath $engine.Root -Destination $backupRoot -ErrorAction Stop + $hasBackup = $true + } + try { + Move-Item -LiteralPath $stagingRoot -Destination $engine.Root -ErrorAction Stop + } catch { + $installError = $_.Exception.Message + if ($hasBackup -and -not (Test-Path -LiteralPath $engine.Root)) { + try { + Move-Item -LiteralPath $backupRoot -Destination $engine.Root -ErrorAction Stop + $hasBackup = $false + } catch { + throw "Dream Skin runtime update failed and its previous engine could not be restored. Backup preserved at ${backupRoot}: $installError" + } + } + throw + } + if ($hasBackup) { + try { Remove-DreamSkinRuntimeTree -Path $backupRoot -StateRoot $fullStateRoot } catch { + try { + Write-Warning "Installed the new runtime but could not remove its previous backup: $($_.Exception.Message)" + } catch { + # Cleanup must never make a committed runtime update look unsuccessful. + } + } + } + return Get-DreamSkinRuntimeEnginePaths -StateRoot $fullStateRoot + } finally { + if (Test-Path -LiteralPath $stagingRoot) { + try { Remove-DreamSkinRuntimeTree -Path $stagingRoot -StateRoot $fullStateRoot } catch { + try { + Write-Warning "Could not remove the staged Dream Skin runtime: $($_.Exception.Message)" + } catch { + # Cleanup must never mask the runtime installation result. + } + } + } + } +} + function Test-DreamSkinCommandLineToken { param([string]$CommandLine, [string]$Token) if (-not $CommandLine -or -not $Token) { return $false } diff --git a/windows/scripts/install-dream-skin.ps1 b/windows/scripts/install-dream-skin.ps1 index d7967a5f..659885c0 100644 --- a/windows/scripts/install-dream-skin.ps1 +++ b/windows/scripts/install-dream-skin.ps1 @@ -35,7 +35,11 @@ try { (Get-DreamSkinCodexProcesses -Codex $savedPathCandidate).Count -gt 0) { throw 'The saved Codex path is still running but no longer matches a registered Store package. Close it manually before installing.' } - $null = Initialize-DreamSkinThemeStore -SkillRoot $SkillRoot -StateRoot $StateRoot + if (Test-DreamSkinTrayActive) { + throw 'Exit the Codex Dream Skin tray before reinstalling so every shortcut can move to the new runtime safely.' + } + $engine = Install-DreamSkinRuntimeEngine -SkillRoot $SkillRoot -StateRoot $StateRoot + $null = Initialize-DreamSkinThemeStore -SkillRoot $engine.Root -StateRoot $StateRoot $ConfigPath = Join-Path $HOME '.codex\config.toml' $BackupPath = Join-Path $StateRoot 'config.before-dream-skin.toml' Install-DreamSkinBaseTheme -ConfigPath $ConfigPath -BackupPath $BackupPath @@ -45,16 +49,16 @@ try { $desktop = [Environment]::GetFolderPath('Desktop') $startMenu = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs' $powershell = (Get-Command powershell.exe -ErrorAction Stop).Source - $startScript = Join-Path $PSScriptRoot 'start-dream-skin.ps1' - $restoreScript = Join-Path $PSScriptRoot 'restore-dream-skin.ps1' - $trayScript = Join-Path $PSScriptRoot 'tray-dream-skin.ps1' + $startScript = $engine.Start + $restoreScript = $engine.Restore + $trayScript = $engine.Tray $portArgument = if ($PortExplicit) { " -Port $Port" } else { '' } foreach ($folder in @($desktop, $startMenu)) { $shortcut = $shell.CreateShortcut((Join-Path $folder 'Codex Dream Skin.lnk')) $shortcut.TargetPath = $powershell $shortcut.Arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$startScript`"$portArgument -PromptRestart" - $shortcut.WorkingDirectory = $SkillRoot + $shortcut.WorkingDirectory = $engine.Root $shortcut.Description = 'Launch the official Codex app with Codex Dream Skin' $shortcut.Save() } @@ -62,7 +66,7 @@ try { $restore = $shell.CreateShortcut((Join-Path $desktop 'Codex Dream Skin - Restore.lnk')) $restore.TargetPath = $powershell $restore.Arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$restoreScript`"$portArgument -RestoreBaseTheme -PromptRestart" - $restore.WorkingDirectory = $SkillRoot + $restore.WorkingDirectory = $engine.Root $restore.Description = 'Restore the official Codex appearance and close the CDP session' $restore.Save() @@ -70,7 +74,7 @@ try { $tray = $shell.CreateShortcut((Join-Path $folder 'Codex Dream Skin - Tray.lnk')) $tray.TargetPath = $powershell $tray.Arguments = "-NoProfile -STA -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$trayScript`"$portArgument" - $tray.WorkingDirectory = $SkillRoot + $tray.WorkingDirectory = $engine.Root $tray.Description = 'Open Codex Dream Skin status and theme controls in the system tray' $tray.Save() } @@ -80,7 +84,7 @@ try { } if ($NoShortcuts) { - Write-Host 'Codex Dream Skin base theme installed. Run start-dream-skin.ps1 to launch it.' + Write-Host "Codex Dream Skin base theme installed at $($engine.Root). Run $($engine.Start) to launch it." } else { Write-Host 'Codex Dream Skin installed. The launch shortcut asks before restarting an open Codex window.' } diff --git a/windows/tests/run-tests.ps1 b/windows/tests/run-tests.ps1 index fb54821b..0fc882a3 100644 --- a/windows/tests/run-tests.ps1 +++ b/windows/tests/run-tests.ps1 @@ -1,5 +1,5 @@ [CmdletBinding()] -param() +param([switch]$EngineOnly) $ErrorActionPreference = 'Stop' $Root = Split-Path -Parent $PSScriptRoot @@ -10,6 +10,155 @@ $temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) "codex-dream-skin-t New-Item -ItemType Directory -Path $temporaryRoot | Out-Null try { + $runtimeSourceName = 'runtime source ' + (-join @([char]0x6D4B, [char]0x8BD5)) + $runtimeSourceRoot = Join-Path $temporaryRoot $runtimeSourceName + $runtimeStateRoot = Join-Path $temporaryRoot 'runtime-state' + New-Item -ItemType Directory -Path $runtimeSourceRoot | Out-Null + foreach ($directoryName in @('assets', 'scripts')) { + Copy-Item -LiteralPath (Join-Path $Root $directoryName) -Destination $runtimeSourceRoot ` + -Recurse -Force -ErrorAction Stop + } + + $engine = Install-DreamSkinRuntimeEngine -SkillRoot $runtimeSourceRoot -StateRoot $runtimeStateRoot + $sourcePrefix = $runtimeSourceRoot.TrimEnd('\') + '\' + $runtimeSourceFiles = @( + Get-ChildItem -LiteralPath (Join-Path $runtimeSourceRoot 'assets'), (Join-Path $runtimeSourceRoot 'scripts') ` + -Recurse -File -Force + ) + $runtimeEngineFiles = @( + Get-ChildItem -LiteralPath (Join-Path $engine.Root 'assets'), (Join-Path $engine.Root 'scripts') ` + -Recurse -File -Force + ) + if ($runtimeSourceFiles.Count -ne $runtimeEngineFiles.Count -or + -not (Test-DreamSkinPathWithin -Path $engine.Start -Root $runtimeStateRoot) -or + -not (Test-DreamSkinPathWithin -Path $engine.Restore -Root $runtimeStateRoot) -or + -not (Test-DreamSkinPathWithin -Path $engine.Tray -Root $runtimeStateRoot)) { + throw 'Installed runtime paths are incomplete or still point outside the managed state root.' + } + foreach ($sourceFile in $runtimeSourceFiles) { + $relative = $sourceFile.FullName.Substring($sourcePrefix.Length) + $installedFile = Join-Path $engine.Root $relative + if (-not (Test-Path -LiteralPath $installedFile -PathType Leaf) -or + (Get-FileHash -Algorithm SHA256 -LiteralPath $sourceFile.FullName).Hash -cne + (Get-FileHash -Algorithm SHA256 -LiteralPath $installedFile).Hash) { + throw "Installed runtime hash does not match its source: $relative" + } + } + + [System.IO.File]::WriteAllText((Join-Path $engine.Root 'stale-runtime.txt'), 'stale') + [System.IO.File]::WriteAllText((Join-Path $runtimeSourceRoot 'scripts\runtime-update.test'), 'updated') + $realRuntimeCleanup = (Get-Command Remove-DreamSkinRuntimeTree -CommandType Function).ScriptBlock + $previousWarningPreference = $WarningPreference + $runtimeCleanupFailure = @{ Triggered = $false } + try { + $WarningPreference = 'Stop' + function Remove-DreamSkinRuntimeTree { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$StateRoot + ) + if ([System.IO.Path]::GetFileName($Path) -like '.engine-backup-*') { + $runtimeCleanupFailure.Triggered = $true + throw 'forced runtime backup cleanup failure' + } + & $realRuntimeCleanup -Path $Path -StateRoot $StateRoot + } + + $runtimeUpdateReportedFailure = $false + try { + $engine = Install-DreamSkinRuntimeEngine -SkillRoot $runtimeSourceRoot -StateRoot $runtimeStateRoot + } catch { + $runtimeUpdateReportedFailure = $true + } + if (-not $runtimeCleanupFailure.Triggered -or $runtimeUpdateReportedFailure -or + (Test-Path -LiteralPath (Join-Path $engine.Root 'stale-runtime.txt')) -or + (Read-DreamSkinUtf8File -Path (Join-Path $engine.Root 'scripts\runtime-update.test')) -cne 'updated') { + throw 'Runtime reinstall did not commit cleanly when old-engine cleanup failed.' + } + } finally { + $WarningPreference = $previousWarningPreference + Set-Item -Path Function:\Remove-DreamSkinRuntimeTree -Value $realRuntimeCleanup + } + foreach ($runtimeBackup in Get-ChildItem -LiteralPath $runtimeStateRoot -Directory -Force | + Where-Object { $_.Name -like '.engine-backup-*' }) { + Remove-DreamSkinRuntimeTree -Path $runtimeBackup.FullName -StateRoot $runtimeStateRoot + } + + $invalidRuntimeRoot = Join-Path $temporaryRoot 'invalid-runtime-source' + New-Item -ItemType Directory -Path $invalidRuntimeRoot | Out-Null + foreach ($directoryName in @('assets', 'scripts')) { + Copy-Item -LiteralPath (Join-Path $runtimeSourceRoot $directoryName) -Destination $invalidRuntimeRoot ` + -Recurse -Force -ErrorAction Stop + } + Remove-Item -LiteralPath (Join-Path $invalidRuntimeRoot 'scripts\start-dream-skin.ps1') -Force + $invalidRuntimeRejected = $false + try { + $null = Install-DreamSkinRuntimeEngine -SkillRoot $invalidRuntimeRoot -StateRoot $runtimeStateRoot + } catch { + $invalidRuntimeRejected = $true + } + if (-not $invalidRuntimeRejected -or + -not (Test-Path -LiteralPath $engine.Start -PathType Leaf) -or + -not (Test-Path -LiteralPath (Join-Path $engine.Root 'scripts\runtime-update.test') -PathType Leaf) -or + @(Get-ChildItem -LiteralPath $runtimeStateRoot -Force | Where-Object { + $_.Name -like '.engine-staging-*' -or $_.Name -like '.engine-backup-*' + }).Count -ne 0) { + throw 'An invalid runtime source changed the installed engine or left transaction artifacts.' + } + + $nestedStateRoot = Join-Path $runtimeSourceRoot 'scripts\nested-state' + $nestedStateRejected = $false + try { + $null = Install-DreamSkinRuntimeEngine -SkillRoot $runtimeSourceRoot -StateRoot $nestedStateRoot + } catch { + $nestedStateRejected = $true + } + if (-not $nestedStateRejected -or (Test-Path -LiteralPath $nestedStateRoot)) { + throw 'Runtime install allowed its state root to recurse into the copied source tree.' + } + + $installSource = Read-DreamSkinUtf8File -Path (Join-Path $Root 'scripts\install-dream-skin.ps1') + $trayGuardIndex = $installSource.IndexOf('if (Test-DreamSkinTrayActive)', [System.StringComparison]::Ordinal) + $engineInstallIndex = $installSource.IndexOf('$engine = Install-DreamSkinRuntimeEngine', [System.StringComparison]::Ordinal) + if ($trayGuardIndex -lt 0 -or $engineInstallIndex -le $trayGuardIndex) { + throw 'Installer does not reject an active source-bound tray before replacing the runtime engine.' + } + foreach ($requiredShortcutBinding in @( + '$startScript = $engine.Start', + '$restoreScript = $engine.Restore', + '$trayScript = $engine.Tray', + '$shortcut.WorkingDirectory = $engine.Root', + '$restore.WorkingDirectory = $engine.Root', + '$tray.WorkingDirectory = $engine.Root' + )) { + if (-not $installSource.Contains($requiredShortcutBinding)) { + throw "Installer shortcut still depends on its source checkout: $requiredShortcutBinding" + } + } + + Remove-Item -LiteralPath $runtimeSourceRoot -Recurse -Force + foreach ($installedScript in Get-ChildItem -LiteralPath $engine.Scripts -Filter '*.ps1' -File) { + $tokens = $null + $parseErrors = $null + [System.Management.Automation.Language.Parser]::ParseFile( + $installedScript.FullName, [ref]$tokens, [ref]$parseErrors + ) | Out-Null + if ($parseErrors.Count -gt 0) { + throw "Installed runtime script failed to parse after its source checkout was removed: $($installedScript.Name)" + } + } + if (-not (Test-Path -LiteralPath $engine.Start -PathType Leaf) -or + -not (Test-Path -LiteralPath $engine.Restore -PathType Leaf) -or + -not (Test-Path -LiteralPath $engine.Tray -PathType Leaf)) { + throw 'Installed launch, restore, or tray entry point disappeared with the source checkout.' + } + Remove-Item -LiteralPath $invalidRuntimeRoot, $runtimeStateRoot -Recurse -Force + + if ($EngineOnly) { + Write-Host 'PASS: managed runtime staging, replacement, invalid-source guard, and source-independent shortcuts.' + return + } + $atomicReplacePath = Join-Path $temporaryRoot 'atomic-replace.txt' [System.IO.File]::WriteAllText($atomicReplacePath, 'before') Write-DreamSkinUtf8FileAtomically -Path $atomicReplacePath -Content 'after'