Jelajahi Sumber

fix(desktop): preserve installer dependencies and validate keyboard navigation

winewill 2 minggu lalu
induk
melakukan
9255b08ed3

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-10-windows-native-installer-pages.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-windows-native-installer-pages.md
-2026-09-10-windows-native-installer-pages.md: 26a15b6eb929658b125539b2fe5ea05976c3e857
-2026-09-10-windows-native-installer-pages.zh.md: c28c48ee43230800ed8392b7dc0886bef07499a2
+2026-09-10-windows-native-installer-pages.md: e56d46b19320815354df17741c0af770196f7fcb
+2026-09-10-windows-native-installer-pages.zh.md: ce302609b596262215e0ec63d6b76a72a35557d9

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-10-windows-native-installer-pages.md

@@ -14,7 +14,7 @@ The installer adds custom welcome, progress, and completion pages through electr
 
 NSIS native controls preserve directory editing, folder selection, checkbox state, and keyboard interaction. An x86 Win32/GDI+ helper retains DWM shadows and draws installation progress on the UI thread while the stock installation worker runs. Stock page visibility is suppressed even when NSIS shows the page after MUI's callback. Windows 11 supplies the outer corner radius; Windows 10 retains its supported frame appearance.
 
-Installation is per-user. Paths are validated before installation writes, and a running application is left running while setup exits after a native acknowledgement. Completion launches only when selected. Silent updates retain the existing electron-builder command-line behavior. The installation engine does not add transactional rollback or take ownership of first-launch profile preparation.
+Installation is per-user. Welcome-page leave validation reads the current edit control for mouse and keyboard navigation; the debounced inline hint is not an installation authority. Running-process checks match the affected executable path, leaving other installations independent. Completion-page leave honors the launch checkbox for both mouse and keyboard navigation. Electron-builder resolves the registered directory before custom initialization, so silent updates without `/D=` retain that directory. The installation engine does not add transactional rollback or take ownership of first-launch profile preparation.
 
 ## Alternatives considered
 
@@ -26,6 +26,6 @@ Installation is per-user. Paths are validated before installation writes, and a
 
 ## Consequences
 
-Windows packaging additionally requires the x86 Visual C++ compiler and Windows SDK. The helper is signed by the same signer as other Windows artifacts. Progress is an estimate derived from stock NSIS progress, not a remaining-time promise.
+Windows packaging additionally requires the x86 Visual C++ compiler and Windows SDK. The helper is signed by the same signer as other Windows artifacts. The preparation hook returns true on every platform: electron-builder treats a falsy return as external dependency ownership and omits its production node_modules collection. Progress is an estimate derived from stock NSIS progress, not a remaining-time promise.
 
 The native installer regression uses a unique product identity and private installation directory to verify path rejection, folder selection, launch choices, upgrade, running-process preservation, hidden stock progress, and uninstall. Its screenshots and expected behavior belong to Desktop tests rather than recorded Session snapshots. Signed release qualification still requires the configured certificate and token, and actual Windows update artifacts.

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-10-windows-native-installer-pages.zh.md

@@ -14,7 +14,7 @@ Windows 安装界面需要符合品牌设计的亮暗页面,同时避免引入
 
 NSIS 原生控件保留目录编辑、文件夹选择、复选状态和键盘交互。x86 Win32/GDI+ 辅助库保留 DWM 阴影,并在原生安装工作线程运行期间,由界面线程绘制安装进度。即使 NSIS 在 MUI 回调之后重新显示页面,原生页面仍保持隐藏。Windows 11 提供外框圆角半径;Windows 10 保留其支持的窗口外观。
 
-安装面向当前用户。安装写入前校验路径;应用运行中时保持应用运行,安装程序在用户确认系统提示后退出。完成时仅在勾选启动的情况下启动应用。静默更新保留现有 electron-builder 命令行行为。安装引擎不新增事务回滚,也不接管首次启动的配置档案准备。
+安装面向当前用户。欢迎页离开校验在鼠标和键盘导航时均读取当前编辑框;防抖显示的行内提示不决定实际安装路径。进程检查匹配受影响的可执行文件路径,使其他安装保持独立。完成页离开回调在鼠标和键盘导航时均遵循启动复选框。electron-builder 在自定义初始化之前解析已登记目录,因此不带 `/D=` 的静默更新会保留该目录。安装引擎不新增事务回滚,也不接管首次启动的配置档案准备。
 
 ## Alternatives considered
 
@@ -26,6 +26,6 @@ NSIS 原生控件保留目录编辑、文件夹选择、复选状态和键盘交
 
 ## Consequences
 
-Windows 打包额外要求 x86 Visual C++ 编译器和 Windows SDK。辅助库使用与其他 Windows 产物相同的签名器。进度来自原生 NSIS 进度估算,不承诺剩余时间。
+Windows 打包额外要求 x86 Visual C++ 编译器和 Windows SDK。辅助库使用与其他 Windows 产物相同的签名器。准备钩子在所有平台返回 true:electron-builder 会将假值视为依赖由外部管理,从而跳过生产 node_modules 的收集。进度来自原生 NSIS 进度估算,不承诺剩余时间。
 
 原生安装回归使用独立产品身份和私有安装目录,验证路径拒绝、文件夹选择、启动选项、升级、运行中进程保留、原生进度条隐藏和卸载。截图和预期行为归属 Desktop 测试,不放入录制 Session 快照。签名发布仍需使用已配置的证书、Token 和真实 Windows 更新产物进行验证。

+ 2 - 2
apps/desktop/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write apps/desktop/README.md
-README.md: 8f0f14169ce4b11a78be2d2eb0a418f85f342730
-README.zh.md: 53bab9f794d7cfa9bb14b5fc8196c953c3d9beea
+README.md: 6ab3a3678063afbad4b9fd5b1df62b6bca746eae
+README.zh.md: b9d4ced5ca9a15697cecf77338fee09f92acd28f

+ 2 - 2
apps/desktop/README.md

@@ -155,11 +155,11 @@ The command requires `DSH_DESKTOP_APP_ID` and the normal build dependencies, inc
 
 ### Windows installer interface
 
-The Windows installer uses native NSIS pages with light and dark palettes, system shadows, an editable installation directory, and a finish page whose launch checkbox is selected by default. Installation is restricted to the current user. The directory picker and typed paths share validation; new destinations must be empty, and nonempty destinations must be registered installations. Running applications produce a native prompt and leave the application running.
+The Windows installer uses native NSIS pages with light and dark palettes, system shadows, an editable installation directory, and a finish page whose launch checkbox is selected by default. Installation is restricted to the current user. Clicking Install or pressing Enter validates the current path; new destinations must be empty, and nonempty destinations must be registered installations. Running executables at the affected installation path produce a native prompt and remain running; same-named applications in other directories do not block installation. Silent updates wait up to ten seconds for the affected application to exit, then stop with exit code 2 if it is still running.
 
 The theme follows Windows at startup; `/THEME=light`, `/THEME=dark`, and `/THEME=auto` select a palette explicitly. The progress page reads the stock installation progress as an estimate. Electron-builder owns extraction, existing-version removal, registry entries, shortcuts, and uninstallers; installation failures do not promise full transactional rollback. First-launch profile preparation remains a separate Desktop operation.
 
-Windows packaging compiles an x86 Win32/GDI+ helper with Visual C++ Build Tools and a Windows SDK; signed builds sign this helper through the configured Windows signer. The [installer decision](../../.agents/notes/implemented/architecture/2026-09-10-windows-native-installer-pages.md) records the NSIS integration and release checks.
+Windows packaging compiles an x86 Win32/GDI+ helper with Visual C++ Build Tools and a Windows SDK; signed builds sign this helper through the configured Windows signer. The preparation hook leaves production dependency collection to electron-builder on every platform. The [installer decision](../../.agents/notes/implemented/architecture/2026-09-10-windows-native-installer-pages.md) records the NSIS integration and release checks.
 
 Run `pnpm --dir apps/desktop run test:installer` from the repository root on an interactive Windows x64 desktop to build and exercise a small native test payload through the production installer configuration. Each run uses a unique product identity, installs into its own directory, uninstalls it, and retains screenshots and results under `.desktop-build/installer-tests/`.
 

+ 2 - 2
apps/desktop/README.zh.md

@@ -155,11 +155,11 @@ pnpm run package:desktop:win:x64:unsigned
 
 ### Windows 安装界面
 
-Windows 安装程序使用原生 NSIS 页面,提供亮暗配色、系统阴影、可编辑的安装目录,以及默认勾选立即启动的完成页。安装仅面向当前用户。目录选择器与手动输入共用路径校验;新安装位置必须为空,非空位置必须是已登记的安装目录。应用运行中时显示系统提示,并保持应用运行。
+Windows 安装程序使用原生 NSIS 页面,提供亮暗配色、系统阴影、可编辑的安装目录,以及默认勾选立即启动的完成页。安装仅面向当前用户。点击安装或按 Enter 均校验当前路径;新安装位置必须为空,非空位置必须是已登记的安装目录。受影响安装路径中的程序运行时显示系统提示,并保持应用运行;其他目录中的同名应用不阻止安装。静默更新最多等待受影响应用退出十秒,若仍在运行则以退出码 2 结束。
 
 主题在启动时跟随 Windows;可用 `/THEME=light`、`/THEME=dark` 和 `/THEME=auto` 显式选择配色。进度页读取原生安装进度,显示估算值。electron-builder 负责解压、移除已有版本、注册表、快捷方式和卸载程序;安装失败不承诺完整的事务回滚。首次启动的配置档案准备仍属于独立的 Desktop 操作。
 
-Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI+ 辅助库;签名构建通过已配置的 Windows 签名器对该库签名。[安装界面决策](../../.agents/notes/implemented/architecture/2026-09-10-windows-native-installer-pages.zh.md)记录 NSIS 接入方式和发布验证要求。
+Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI+ 辅助库;签名构建通过已配置的 Windows 签名器对该库签名。准备钩子在所有平台上均由 electron-builder 继续负责收集生产依赖。[安装界面决策](../../.agents/notes/implemented/architecture/2026-09-10-windows-native-installer-pages.zh.md)记录 NSIS 接入方式和发布验证要求。
 
 在有交互式桌面的 Windows x64 上,从仓库根目录运行 `pnpm --dir apps/desktop run test:installer`,可将小型原生测试载荷接入正式安装配置并执行验证。每次运行使用独立产品身份,安装到自己的目录后卸载,并将截图和结果保留在 `.desktop-build/installer-tests/` 下。
 

+ 1 - 1
apps/desktop/electron-builder.config.d.mts

@@ -27,7 +27,7 @@ export interface DesktopElectronBuilderConfig {
     readonly allowToChangeInstallationDirectory: false
     readonly installerLanguages: readonly ['en_US', 'zh_CN']
   }
-  readonly beforeBuild: () => Promise<void>
+  readonly beforeBuild: () => Promise<boolean>
   readonly artifactBuildCompleted: (artifact: { readonly file: string }) => Promise<void> | undefined
   readonly publish: readonly [{ readonly provider: 'generic', readonly url: string }] | null
 }

+ 6 - 3
apps/desktop/electron-builder.config.mjs

@@ -66,14 +66,17 @@ export function createElectronBuilderConfig(
     electronDist: buildPaths.electron,
     electronFuses: { runAsNode: true },
     beforeBuild: async () => {
-      if (resolvedPlatform !== 'win32') return
+      if (resolvedPlatform !== 'win32') return true
       await promisify(execFile)('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File',
-        fileURLToPath(new URL('./scripts/prepare-windows-installer.ps1', import.meta.url))], {
-        env: scrubWindowsSigningEnvironment(process.env), windowsHide: true,
+        fileURLToPath(new URL('./scripts/prepare-windows-installer.ps1', import.meta.url)),
+        '-OutputDirectory', join(buildPaths.root, 'installer-ui')], {
+        env: scrubWindowsSigningEnvironment(env), windowsHide: true,
       })
       if (windowsSigner !== undefined) {
         await windowsSigner({ path: join(buildPaths.root, 'installer-ui', 'window-frame.dll'), hash: 'sha256', isNest: false })
       }
+      // A falsy result tells electron-builder to omit its production node_modules collection.
+      return true
     },
     files: [
       'lib/*.js',

+ 17 - 14
apps/desktop/installer/pages.nsh

@@ -198,25 +198,28 @@ FunctionEnd
 
 Function InstallerStart
     Pop $0
-    ${If} $InstallerPhase == "success"
-        ${NSD_GetState} $InstallerLaunch $0
-        ${If} $0 == ${BST_CHECKED}
-            StrCpy $0 ""
-            ${If} ${isUpdated}
-                StrCpy $0 "--updated"
-            ${EndIf}
-            ${StdUtils.ExecShellAsUser} $1 "$launchLink" "open" "$0"
-        ${EndIf}
-        SendMessage $HWNDPARENT ${WM_NOTIFY_OUTER_NEXT} 1 0
-        Return
-    ${EndIf}
+    SendMessage $HWNDPARENT ${WM_NOTIFY_OUTER_NEXT} 1 0
+FunctionEnd
+
+; Page leave callbacks also run when Enter activates NSIS's hidden default button.
+Function InstallerWelcomeLeave
     ${NSD_GetText} $InstallerEdit $InstallerPath
     Call InstallerPreflight
     ${If} $InstallerError != ""
         MessageBox MB_OK|MB_ICONEXCLAMATION "$InstallerError"
-        Return
+        Abort
+    ${EndIf}
+FunctionEnd
+
+Function InstallerFinishLeave
+    ${NSD_GetState} $InstallerLaunch $0
+    ${If} $0 == ${BST_CHECKED}
+        StrCpy $0 ""
+        ${If} ${isUpdated}
+            StrCpy $0 "--updated"
+        ${EndIf}
+        ${StdUtils.ExecShellAsUser} $1 "$launchLink" "open" "$0"
     ${EndIf}
-    SendMessage $HWNDPARENT ${WM_NOTIFY_OUTER_NEXT} 1 0
 FunctionEnd
 
 Function InstallerExpandPath

+ 33 - 0
apps/desktop/installer/window-frame.cpp

@@ -2,6 +2,7 @@
 #define WIN32_LEAN_AND_MEAN
 #define UNICODE
 #include <windows.h>
+#include <tlhelp32.h>
 #include <objidl.h>
 #include <commctrl.h>
 #include <dwmapi.h>
@@ -10,6 +11,38 @@
 
 using namespace Gdiplus;
 
+// Match the affected executable, not another user's or directory's same-named application.
+// Returns 0 while running, 1 when absent, and -1 if the process list cannot be read.
+extern "C" __declspec(dllexport) int __cdecl InstallerFindProcess(LPCWSTR executable) {
+    WCHAR target[32768];
+    DWORD length = GetLongPathNameW(executable, target, ARRAYSIZE(target));
+    LPCWSTR expected = length > 0 && length < ARRAYSIZE(target) ? target : executable;
+    LPCWSTR filename = wcsrchr(expected, L'\\');
+    filename = filename ? filename + 1 : expected;
+    HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
+    if (snapshot == INVALID_HANDLE_VALUE) return -1;
+    PROCESSENTRY32W entry = {};
+    entry.dwSize = sizeof(entry);
+    int result = 1;
+    BOOL present = Process32FirstW(snapshot, &entry);
+    while (present) {
+        if (_wcsicmp(entry.szExeFile, filename) == 0) {
+            HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, entry.th32ProcessID);
+            if (process) {
+                WCHAR path[32768];
+                DWORD count = ARRAYSIZE(path);
+                if (QueryFullProcessImageNameW(process, 0, path, &count) && _wcsicmp(path, expected) == 0) result = 0;
+                CloseHandle(process);
+                if (result == 0) break;
+            }
+        }
+        present = Process32NextW(snapshot, &entry);
+    }
+    if (!present && GetLastError() != ERROR_NO_MORE_FILES) result = -1;
+    CloseHandle(snapshot);
+    return result;
+}
+
 struct ProgressPage {
     HWND source;
     bool dark;

+ 13 - 5
apps/desktop/scripts/installer.nsh

@@ -70,7 +70,7 @@
 !macroend
 
 !macro customWelcomePage
-  Page custom InstallerWelcome
+  Page custom InstallerWelcome InstallerWelcomeLeave
 !macroend
 
 !macro customPageAfterChangeDir
@@ -79,17 +79,21 @@
 !macroend
 
 !macro customFinishPage
-  Page custom InstallerFinish
+  Page custom InstallerFinish InstallerFinishLeave
 !macroend
 
 !macro customCheckAppRunning
-  ${nsProcess::FindProcess} "${APP_EXECUTABLE_FILENAME}" $R0
+  !ifdef BUILD_UNINSTALLER
+    InitPluginsDir
+    File "/oname=$PLUGINSDIR\window-frame.dll" "${INSTALLER_BUILD_DIR}\window-frame.dll"
+  !endif
+  System::Call '$PLUGINSDIR\window-frame.dll::InstallerFindProcess(w "$INSTDIR\${APP_EXECUTABLE_FILENAME}") i.R0 ?c'
   ${If} $R0 == 0
     ${If} ${isUpdated}
       StrCpy $R1 0
       ${DoWhile} $R0 == 0
         Sleep 250
-        ${nsProcess::FindProcess} "${APP_EXECUTABLE_FILENAME}" $R0
+        System::Call '$PLUGINSDIR\window-frame.dll::InstallerFindProcess(w "$INSTDIR\${APP_EXECUTABLE_FILENAME}") i.R0 ?c'
         IntOp $R1 $R1 + 1
         ${If} $R1 >= 40
           ${ExitDo}
@@ -102,7 +106,11 @@
       Quit
     ${EndIf}
   ${EndIf}
-  ${nsProcess::Unload}
+  ${If} $R0 < 0
+    MessageBox MB_OK|MB_ICONEXCLAMATION "$(INSTALLER_UI_ERROR)" /SD IDOK
+    SetErrorLevel 2
+    Quit
+  ${EndIf}
 !macroend
 
 !ifndef BUILD_UNINSTALLER

+ 4 - 3
apps/desktop/scripts/test-windows-installer.mjs

@@ -15,7 +15,8 @@ const appRoot = fileURLToPath(new URL('..', import.meta.url))
 const require = createRequire(import.meta.url)
 const { build, Platform, Arch } = require('electron-builder')
 const { getMakeNsisPath } = require('app-builder-lib/out/toolsets/windows.js')
-const id = randomUUID().replaceAll('-', '')
+const guid = randomUUID()
+const id = guid.replaceAll('-', '')
 const productName = `Harness Installer Test ${id.slice(0, 8)}`
 const outputRoot = join(appRoot, '.desktop-build', 'installer-tests')
 await mkdir(outputRoot, { recursive: true })
@@ -55,11 +56,11 @@ SectionEnd
   const config = createElectronBuilderConfig()
   await build({ projectDir: appRoot, prepackaged: payload, targets: Platform.WINDOWS.createTarget(['nsis'], Arch.x64), publish: 'never',
     config: { ...config, productName, artifactName: 'installer-test.exe', directories: { output },
-      nsis: { ...config.nsis, include }, beforeBuild: undefined, afterPack: undefined, afterSign: undefined, artifactBuildCompleted: undefined },
+      nsis: { ...config.nsis, guid, include }, beforeBuild: undefined, afterPack: undefined, afterSign: undefined, artifactBuildCompleted: undefined },
   })
   const result = await execute('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File',
     join(appRoot, 'tests', 'windows-installer-smoke.ps1'), '-Installer', join(output, 'installer-test.exe'),
-    '-ProductName', productName, '-OutputDirectory', output], childOptions)
+    '-ProductName', productName, '-RegistryKey', guid, '-OutputDirectory', output], childOptions)
   process.stdout.write(result.stdout)
 } finally {
   for (const name of Object.keys(process.env)) if (!(name in previousEnvironment)) delete process.env[name]

+ 4 - 3
apps/desktop/tests/expected/windows-installer.json

@@ -2,10 +2,11 @@
   "launchCheckboxState": 1,
   "nativeProgressVisible": false,
   "cases": [
-    "install-path-picker-and-unchecked-launch",
-    "upgrade-and-checked-launch",
+    "enter-validates-current-path-and-unchecked-launch",
+    "registered-directory-and-checked-launch",
     "running-app-preserved-and-native-progress-hidden",
-    "silent-update-and-invalid-destination-rejection",
+    "silent-update-retains-directory-and-ignores-unrelated-process",
+    "invalid-destination-rejection",
     "uninstall"
   ]
 }

+ 46 - 0
apps/desktop/tests/installer-packaging.spec.ts

@@ -0,0 +1,46 @@
+import { tmpdir } from 'node:os'
+import { Arch, Platform } from 'electron-builder'
+import { Packager } from 'app-builder-lib'
+import { describe, expect, it, vi } from 'vitest'
+
+const { execute } = vi.hoisted(() => ({ execute: vi.fn(async () => undefined) }))
+vi.mock('node:child_process', async (importOriginal) => {
+  const original = await importOriginal<typeof import('node:child_process')>()
+  const { promisify } = await import('node:util')
+  return { ...original, execFile: Object.assign(vi.fn(), { [promisify.custom]: execute }) }
+})
+
+describe('installer preparation preserves application dependencies', () => {
+  it.each(['win32', 'darwin'] as const)('keeps electron-builder responsible for node_modules on %s', async (platform) => {
+    execute.mockClear()
+    const env = {
+      DSH_DESKTOP_APP_ID: 'com.example.installer',
+      DSH_DESKTOP_TARGET_PLATFORM: platform,
+      DSH_DESKTOP_TARGET_ARCH: 'x64',
+      DSH_DESKTOP_UNSIGNED: platform === 'win32' ? '1' : '0',
+      DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Example Company (TEAMID1234)',
+      DSH_DESKTOP_MACOS_TEAM_ID: 'TEAMID1234',
+      APPLE_KEYCHAIN_PROFILE: 'installer-test',
+      DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com',
+    }
+    for (const [name, value] of Object.entries(env)) vi.stubEnv(name, value)
+    try {
+      const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs')
+      const config = createElectronBuilderConfig(env, platform, 'x64')
+      const packager = new Packager({ projectDir: tmpdir() })
+      // A foreign source-build target avoids rebuilding modules; the real dependency ownership decision still runs.
+      Object.defineProperties(packager, {
+        config: { value: { beforeBuild: config.beforeBuild, buildDependenciesFromSource: true } },
+        framework: { value: { isNpmRebuildRequired: true, version: '42.0.0' } },
+        appInfo: { value: { type: 'module' } },
+      })
+      vi.spyOn(packager, 'getWorkspaceRoot').mockResolvedValue(tmpdir())
+      await packager.installAppDependencies(process.platform === 'win32' ? Platform.LINUX : Platform.WINDOWS, Arch.x64)
+      expect(packager.areNodeModulesHandledExternally).toBe(false)
+      expect(execute).toHaveBeenCalledTimes(platform === 'win32' ? 1 : 0)
+    } finally {
+      vi.unstubAllEnvs()
+      vi.restoreAllMocks()
+    }
+  })
+})

+ 34 - 8
apps/desktop/tests/windows-installer-smoke.ps1

@@ -1,6 +1,7 @@
 <# Native installation checks use a unique product identity and a private directory. #>
 [CmdletBinding()]
 param([Parameter(Mandatory)][string]$Installer, [Parameter(Mandatory)][string]$ProductName,
+    [Parameter(Mandatory)][string]$RegistryKey,
     [Parameter(Mandatory)][string]$OutputDirectory)
 $ErrorActionPreference = 'Stop'
 . (Join-Path $PSScriptRoot 'windows-installer-ui.ps1')
@@ -28,7 +29,9 @@ function Wait-Control([Diagnostics.Process]$Process, [string]$Text, [switch]$Dia
     throw "Missing '$Text': $([InstallerCapture]::VisibleText($Process.Id))"
 }
 function Start-Setup([string]$Theme, [string]$Path = $installPath) {
-    $process = Start-Process -FilePath $Installer -ArgumentList ('/THEME=' + $Theme + ' /D=' + $Path) -PassThru -WindowStyle Hidden
+    $arguments = '/THEME=' + $Theme
+    if ($Path) { $arguments += ' /D=' + $Path }
+    $process = Start-Process -FilePath $Installer -ArgumentList $arguments -PassThru -WindowStyle Hidden
     $processes.Add($process)
     $timer = [Diagnostics.Stopwatch]::StartNew()
     do {
@@ -69,7 +72,13 @@ function Finish-Setup([Diagnostics.Process]$Process, [bool]$Launch, [string]$The
     }
     $window = [InstallerCapture]::Find($Process.Id)
     [void][InstallerCapture]::Save($window, (Join-Path $OutputDirectory ($Theme + '-finish.png')))
-    Click-Control $Process $copy.INSTALLER_FINISH
+    if ($Launch) {
+        $finish = Wait-Control $Process $copy.INSTALLER_FINISH
+        [void][InstallerCapture]::SendMessage($window, 0x28, $finish, [IntPtr]1)
+        [void][InstallerCapture]::PostMessage($finish, 0x100, [IntPtr]13, [IntPtr]::Zero)
+    } else {
+        Click-Control $Process $copy.INSTALLER_FINISH
+    }
     if (-not $Process.WaitForExit(10000) -or $Process.ExitCode -ne 0) { throw 'Finish did not exit successfully' }
 }
 function Run-Silent([string]$Arguments, [int]$Code) {
@@ -87,16 +96,19 @@ try {
     [void][InstallerCapture]::Save($window, (Join-Path $OutputDirectory 'light-path.png'))
     Click-Control $process $copy.INSTALLER_BROWSE
     Dismiss $process $copy.INSTALLER_CHOOSE_PATH
+    [void][InstallerCapture]::SendMessage($window, 0x28, $edit, [IntPtr]1)
     [void][InstallerCapture]::SendMessage($edit, 0xC, [IntPtr]::Zero, 'C:\Windows\Harness Installer Test')
-    Click-Control $process $copy.INSTALLER_INSTALL
+    [void][InstallerCapture]::PostMessage($edit, 0x100, [IntPtr]13, [IntPtr]::Zero)
     Dismiss $process $copy.INSTALLER_PATH_INVALID
     [void][InstallerCapture]::SendMessage($edit, 0xC, [IntPtr]::Zero, $installPath)
     Click-Control $process $copy.INSTALLER_INSTALL
     Finish-Setup $process $false light
     if (-not (Test-Path -LiteralPath $appPath) -or (Test-Path -LiteralPath (Join-Path $installPath 'launched.txt'))) { throw 'Unchecked launch behavior failed' }
-    $results.Add('install-path-picker-and-unchecked-launch')
+    $results.Add('enter-validates-current-path-and-unchecked-launch')
 
-    $process = Start-Setup dark
+    $process = Start-Setup dark ''
+    Click-Control $process $copy.INSTALLER_CHOOSE_PATH
+    [void](Wait-Control $process $installPath)
     [void][InstallerCapture]::Save([InstallerCapture]::Find($process.Id), (Join-Path $OutputDirectory 'dark-welcome.png'))
     Click-Control $process $copy.INSTALLER_INSTALL
     Finish-Setup $process $true dark
@@ -108,7 +120,7 @@ try {
     } while ($timer.Elapsed.TotalSeconds -lt 15)
     if (-not $app -or $app.Path -ne $appPath) { throw 'Finish did not launch the installed test application' }
     $processes.Add($app)
-    $results.Add('upgrade-and-checked-launch')
+    $results.Add('registered-directory-and-checked-launch')
 
     $process = Start-Setup dark
     Click-Control $process $copy.INSTALLER_INSTALL
@@ -123,14 +135,28 @@ try {
     if (-not $app.WaitForExit(10000)) { throw 'Test application did not exit' }
     $results.Add('running-app-preserved-and-native-progress-hidden')
 
-    Run-Silent ('/S --updated /D=' + $installPath) 0
+    $otherPath = Join-Path $OutputDirectory 'Other Installation'
+    New-Item -ItemType Directory -Path $otherPath | Out-Null
+    $otherApp = Join-Path $otherPath ($ProductName + '.exe')
+    Copy-Item -LiteralPath $appPath -Destination $otherApp
+    $otherProcess = Start-Process -FilePath $otherApp -PassThru -WindowStyle Hidden
+    $processes.Add($otherProcess)
+    [void](Wait-Control $otherProcess 'Installer test application is running.' -Dialog)
+    Run-Silent '/S --updated' 0
+    if ($otherProcess.HasExited) { throw 'Unrelated installation was stopped' }
+    Dismiss $otherProcess 'Installer test application is running.'
+    if (-not $otherProcess.WaitForExit(10000)) { throw 'Unrelated test application did not exit' }
+    if (-not (Test-Path -LiteralPath $appPath)) { throw 'Silent update moved the registered installation' }
+    $registration = Get-ItemProperty ('HKCU:\Software\' + $RegistryKey)
+    if ($registration.InstallLocation.TrimEnd('\') -ne $installPath) { throw 'Silent update changed InstallLocation' }
+    $results.Add('silent-update-retains-directory-and-ignores-unrelated-process')
     Run-Silent ('/S /allusers /D=' + $installPath) 2
     $foreign = Join-Path $OutputDirectory 'Foreign App'
     New-Item -ItemType Directory -Path $foreign | Out-Null
     Set-Content -LiteralPath (Join-Path $foreign 'keep.txt') -Value 'preserved'
     Run-Silent ('/S /D=' + $foreign) 2
     if ((Get-Content -LiteralPath (Join-Path $foreign 'keep.txt')) -ne 'preserved') { throw 'Foreign directory changed' }
-    $results.Add('silent-update-and-invalid-destination-rejection')
+    $results.Add('invalid-destination-rejection')
 } finally {
     foreach ($process in $processes) {
         if (-not $process.HasExited) { $process.Kill(); $process.WaitForExit() }