fix(desktop): harden the Windows uninstall data removal after review
Pinned directories are now opened with listing access so the handle takes part in share checks and a rename or replacement of the directory under the helper fails; a metadata-only handle did not participate. A DSH_HOME published as an environment variable protects overlapping targets, and /KEEP_APP_DATA retains data alongside --updated, matching the flags electron-builder passes to an older uninstaller. The remover clears read-only attributes, keeps deleting siblings after a locked file, and removes only ordinary empty scope parents below %APPDATA% instead of an NSIS RMDir that would also unlink a junction. The redundant un.DataInit extraction is gone. The native regression now runs from the installer test driver and covers the protected root, rename blocking, read-only and locked files and parent removal; the uninstaller smoke uses a per-run scope, polls for the temp-copy handoff, asserts InstallLocation after each silent install, and adds /KEEP_APP_DATA and DSH_HOME-inside cases. The Agent Note keeps the topic's 2026-09-08 filename, states the amendment in the decision, and defers macOS.
This commit is contained in:
1 parent
685ed95f17
commit
0981cd5924
21 files changed
+249
-136
No files matched your search
+3
-3
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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/feature/2026-09-18-windows-uninstall-retains-harness-home.md
|
||||
2026-09-18-windows-uninstall-retains-harness-home.md: 8bcd327a72eff8e877b9456705ae62e99ee539ad
|
||||
2026-09-18-windows-uninstall-retains-harness-home.zh.md: 6b59f7b9331759e3627048b07015e99d8f43abbc
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.md
|
||||
2026-09-08-desktop-uninstall-preserve-dsh-home.md: 921d9681aa6a26c39e8861d2156dc868237f5b37
|
||||
2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md: cb9187bb6d7c3f619dc083fcc3b36cca09494515
|
||||
@@ -0,0 +1,39 @@
|
||||
# Agent Note: Windows uninstall retains the Harness home
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Desktop and CLI share conversations, credentials, settings and plugins in the Harness home. Electron keeps browser storage and caches under `%APPDATA%`, and the updater keeps downloaded installers under `%LOCALAPPDATA%`; the upstream NSIS template leaves both behind unless a command-line flag is passed. Users who uninstall expect the application to leave no residue, while users who reinstall expect their conversations back.
|
||||
|
||||
## Decision
|
||||
|
||||
Windows uninstallation removes the Electron user-data directory, the `%APPDATA%` product directory and the updater cache together with the application, and never touches the Harness home. This is the 2026-09-08 proposal in amended form: Windows only, without the installed-artifact inventory programme, and without a user choice. No custom uninstaller page exists; silent uninstallation removes the same data. An uninstaller started with `--updated` or `/KEEP_APP_DATA` retains it, which covers electron-builder's in-place update and its replacement of an older installation from another directory, so updates keep UI preferences. A `DSH_HOME` published as a Windows environment variable protects every target overlapping it; the native helper cannot see a home configured only in a shell profile.
|
||||
|
||||
Electron derives its user-data directory from the scoped package name, so the removal targets `%APPDATA%\@deepseek-ai\dsh-desktop` and afterwards removes ordinary empty parents below `%APPDATA%`, never a linked or populated scope directory. Removal runs through the native helper in `window-frame.dll`, which rejects protected Windows folders and paths overlapping the installation or the protected home, holds directory handles with listing access during traversal so the directory cannot be renamed or replaced underneath it, unlinks reparse points without entering their targets, clears the read-only attribute, and keeps deleting siblings after a locked file. It requires a fixed local drive and leaves a linked root or ancestor in place. A failure leaves residue and does not stop or report on uninstallation; the uninstaller has no custom copy.
|
||||
|
||||
Installation also records `InstallLocation` on the Windows uninstall entry as standard inventory metadata. Launching the uninstaller directly from the Start menu context menu is unavailable to Win32 installers on Windows 11; only MSIX packages uninstall from there, so that request is outside this decision.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Opt-in removal on a custom uninstaller page, including the desktop profile and the whole home.** The desktop profile, sessions and credentials belong to the home shared with the CLI and stay in place for reinstallation; the remaining Electron data holds only UI preferences and caches, which does not justify a choice the user must understand, a recorded home path for the uninstaller, and a bilingual native dialog.
|
||||
|
||||
**Delete the whole home when desktop is the only profile.** Profile presence does not establish whether shared data is needed by CLI or a future reinstall.
|
||||
|
||||
**Leave Electron data in place, matching the upstream default.** Chromium caches grow to hundreds of megabytes and are the residue users report after uninstalling.
|
||||
|
||||
**Use electron-builder's `deleteAppDataOnUninstall`.** The upstream `RMDir /r` follows no ownership rules, ignores long paths and enters linked directories; the native helper owns removal instead.
|
||||
|
||||
**Establish the cleanup inventory by observing disposable accounts across install, update and uninstall.** The three roots follow from Electron's `userData` derivation and electron-builder's updater cache name, both fixed by configuration; observation would confirm what the configuration already states.
|
||||
|
||||
## Deferred
|
||||
|
||||
macOS cleanup (a signed helper behind an **Uninstall and keep Harness data** action, since drag-to-Trash cannot run application code) remains open under #3540 and is not decided here.
|
||||
|
||||
## Consequences
|
||||
|
||||
Reinstallation restores conversations, settings and plugins but not UI layout preferences. The desktop profile under the home remains with its packages and lock files. Other historical homes and user projects are not discovered or deleted. Roaming profiles on network or removable drives are left in place.
|
||||
|
||||
Qualification uses isolated product identities with a per-run scoped package name. Source regressions cover template adaptation; the native remover regression covers refused roots, the protected home, rename blocking while pinned, read-only and locked files, and empty-parent removal; the uninstaller fixtures cover interactive, silent, `--updated`, `/KEEP_APP_DATA`, and a `DSH_HOME` inside the Electron directory, with a junction to an external project inside the removed data. The standard uninstaller pages render correctly at 200% scaling on the development host.
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
# Agent Note: Windows 卸载保留 Harness 主目录
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-08-desktop-uninstall-preserve-dsh-home.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Desktop 和 CLI 在 Harness 主目录中共享会话、凭据、设置和插件。Electron 把浏览器存储和缓存放在 `%APPDATA%` 下,更新器把下载的安装包放在 `%LOCALAPPDATA%` 下;上游 NSIS 模板在没有命令行参数时会把两者都留下。卸载的用户期望应用不留残余,重装的用户期望会话仍在。
|
||||
|
||||
## 决策
|
||||
|
||||
Windows 卸载随应用一起删除 Electron 用户数据目录、`%APPDATA%` 下的产品目录和更新器缓存,从不触碰 Harness 主目录。这是 2026-09-08 提案的修正形式:仅限 Windows,不含已安装产物清单方案,也不让用户选择。卸载程序没有自定义页面;静默卸载删除相同的数据。以 `--updated` 或 `/KEEP_APP_DATA` 启动的卸载程序保留数据,这覆盖了 electron-builder 的原地更新和从其他目录替换旧安装两种情况,因此更新保留界面偏好。以 Windows 环境变量发布的 `DSH_HOME` 会保护所有与其重叠的目标;原生辅助程序看不到只在 shell 配置文件中设置的主目录。
|
||||
|
||||
Electron 根据带作用域的包名派生用户数据目录,因此删除目标是 `%APPDATA%\@deepseek-ai\dsh-desktop`,随后移除 `%APPDATA%` 之下普通且已空的父目录,从不移除链接或非空的作用域目录。删除通过 `window-frame.dll` 中的原生辅助程序执行:它拒绝受保护的 Windows 目录以及与安装目录或受保护主目录重叠的路径,遍历期间以列举权限持有目录句柄,使目录无法在其下被改名或替换,遇到重解析点只解除链接而不进入目标,清除只读属性,并在遇到被占用文件后继续删除其余兄弟项。它要求固定的本地驱动器,链接的根目录或祖先目录会原样保留。失败只留下残余,不中止也不提示卸载;卸载程序没有任何自定义文案。
|
||||
|
||||
安装时还会在 Windows 卸载注册项上记录 `InstallLocation` 作为标准的清单元数据。Windows 11 上 Win32 安装包无法让开始菜单右键菜单中的“卸载”直接启动卸载程序,只有 MSIX 包能做到,因此该需求不在本决策范围内。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**在自定义卸载页上按需删除,包括桌面配置档案和整个主目录。** 桌面配置档案、会话和凭据属于与 CLI 共享的主目录,为重装而保留;剩余的 Electron 数据只包含界面偏好和缓存,不值得让用户理解并做出选择,也不值得为卸载程序记录主目录路径并维护双语原生对话框。
|
||||
|
||||
**当桌面是唯一配置档案时删除整个主目录。** 配置档案的存在不能证明共享数据是否被 CLI 或将来的重装需要。
|
||||
|
||||
**沿用上游默认,保留 Electron 数据。** Chromium 缓存会增长到数百 MB,正是用户卸载后反馈的残余。
|
||||
|
||||
**使用 electron-builder 的 `deleteAppDataOnUninstall`。** 上游的 `RMDir /r` 不遵守归属规则、不支持长路径并会进入链接目录;改由原生辅助程序负责删除。
|
||||
|
||||
**通过在一次性账户上观察安装、更新和卸载来建立清理清单。** 三个根目录由 Electron 的 `userData` 派生规则和 electron-builder 的更新器缓存名决定,两者都由配置固定;观察只会确认配置已经说明的事实。
|
||||
|
||||
## 推迟事项
|
||||
|
||||
macOS 清理(由签名辅助程序支撑的“**卸载并保留 Harness 数据**”操作,因为拖到废纸篓无法运行应用代码)仍在 #3540 下开放,本决策不涉及。
|
||||
|
||||
## 结果
|
||||
|
||||
重装恢复会话、设置和插件,但不恢复界面布局偏好。主目录下的桌面配置档案及其包和锁文件保留。其他历史主目录和用户项目不会被发现或删除。位于网络或可移动驱动器上的漫游配置文件原样保留。
|
||||
|
||||
验收使用每次运行唯一的带作用域包名的隔离产品身份。源码回归覆盖模板适配;原生删除器回归覆盖被拒绝的根目录、受保护的主目录、持有期间阻止改名、只读与被占用文件,以及空父目录移除;卸载夹具覆盖交互、静默、`--updated`、`/KEEP_APP_DATA`,以及位于 Electron 目录内的 `DSH_HOME`,被删除的数据内含指向外部项目的 junction。标准卸载页面在开发机 200% 缩放下渲染正确。
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
# Agent Note: Windows uninstall retains the Harness home
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-18-windows-uninstall-retains-harness-home.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Desktop and CLI share conversations, credentials, settings and plugins in the Harness home. Electron keeps browser storage and caches under `%APPDATA%`, and the updater keeps downloaded installers under `%LOCALAPPDATA%`; the upstream NSIS template leaves both behind unless a command-line flag is passed. Users who uninstall expect the application to leave no residue, while users who reinstall expect their conversations back.
|
||||
|
||||
## Decision
|
||||
|
||||
Windows uninstallation removes the Electron user-data directory, the `%APPDATA%` product directory and the updater cache together with the application, and never touches the Harness home. No custom uninstaller page exists; silent uninstallation removes the same data, and upgrade uninstallation retains it so automatic updates keep UI preferences. Electron derives its user-data directory from the scoped package name, so the removal targets `%APPDATA%\@deepseek-ai\dsh-desktop` and removes the empty scope directory afterwards. Removal runs through the native helper in `window-frame.dll`, which rejects protected Windows folders and paths overlapping the installation, holds directory handles during traversal and unlinks reparse points without entering their targets. A failure leaves residue and does not stop or report on uninstallation; the uninstaller has no custom copy.
|
||||
|
||||
The 2026-09-08 proposal to keep the home and remove external application state is accepted for Windows in this form; its macOS helper and installed-artifact inventory programme were not adopted. Installation also records `InstallLocation` on the Windows uninstall entry as standard inventory metadata. Launching the uninstaller directly from the Start menu context menu is unavailable to Win32 installers on Windows 11; only MSIX packages uninstall from there, so that request is outside this decision.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Opt-in removal on a custom uninstaller page, including the desktop profile and the whole home.** The desktop profile, sessions and credentials belong to the home shared with the CLI and stay in place for reinstallation; the remaining Electron data holds only UI preferences and caches, which does not justify a choice the user must understand, a recorded home path for the uninstaller, and a bilingual native dialog.
|
||||
|
||||
**Delete the whole home when desktop is the only profile.** Profile presence does not establish whether shared data is needed by CLI or a future reinstall.
|
||||
|
||||
**Leave Electron data in place, matching the upstream default.** Chromium caches grow to hundreds of megabytes and are the residue users report after uninstalling.
|
||||
|
||||
**Use electron-builder's `deleteAppDataOnUninstall`.** The upstream `RMDir /r` follows no ownership rules, ignores long paths and enters linked directories; the native helper owns removal instead.
|
||||
|
||||
## Consequences
|
||||
|
||||
Reinstallation restores conversations, settings and plugins but not UI layout preferences. The desktop profile under the home remains with its packages and lock files. Other historical homes and user projects are not discovered or deleted.
|
||||
|
||||
Qualification uses isolated product identities with a scoped package name. Source regressions cover template adaptation; native fixtures cover retention of the home, removal of user data, the scope directory and the updater cache, junction targets, silent and upgrade behavior. High-DPI visual acceptance of the standard uninstaller pages remains unverified.
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
# Agent Note: Windows 卸载保留 Harness 主目录
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-18-windows-uninstall-retains-harness-home.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Desktop 和 CLI 在 Harness 主目录中共享会话、凭据、设置和插件。Electron 把浏览器存储和缓存放在 `%APPDATA%` 下,更新器把下载的安装包放在 `%LOCALAPPDATA%` 下;上游 NSIS 模板在没有命令行参数时会把两者都留下。卸载的用户期望应用不留残余,重装的用户期望会话仍在。
|
||||
|
||||
## 决策
|
||||
|
||||
Windows 卸载随应用一起删除 Electron 用户数据目录、`%APPDATA%` 下的产品目录和更新器缓存,从不触碰 Harness 主目录。卸载程序没有自定义页面;静默卸载删除相同的数据,升级卸载保留数据,使自动更新保留界面偏好。Electron 根据带作用域的包名派生用户数据目录,因此删除目标是 `%APPDATA%\@deepseek-ai\dsh-desktop`,随后移除已空的作用域目录。删除通过 `window-frame.dll` 中的原生辅助程序执行:它拒绝受保护的 Windows 目录和与安装目录重叠的路径,遍历期间持有目录句柄,遇到重解析点只解除链接而不进入目标。失败只留下残余,不中止也不提示卸载;卸载程序没有任何自定义文案。
|
||||
|
||||
2026-09-08 的提案(保留主目录、删除外部应用状态)以这一形式在 Windows 上被采纳;其中的 macOS 辅助程序和已安装产物清单方案未被采纳。安装时还会在 Windows 卸载注册项上记录 `InstallLocation` 作为标准的清单元数据。Windows 11 上 Win32 安装包无法让开始菜单右键菜单中的“卸载”直接启动卸载程序,只有 MSIX 包能做到,因此该需求不在本决策范围内。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**在自定义卸载页上按需删除,包括桌面配置档案和整个主目录。** 桌面配置档案、会话和凭据属于与 CLI 共享的主目录,为重装而保留;剩余的 Electron 数据只包含界面偏好和缓存,不值得让用户理解并做出选择,也不值得为卸载程序记录主目录路径并维护双语原生对话框。
|
||||
|
||||
**当桌面是唯一配置档案时删除整个主目录。** 配置档案的存在不能证明共享数据是否被 CLI 或将来的重装需要。
|
||||
|
||||
**沿用上游默认,保留 Electron 数据。** Chromium 缓存会增长到数百 MB,正是用户卸载后反馈的残余。
|
||||
|
||||
**使用 electron-builder 的 `deleteAppDataOnUninstall`。** 上游的 `RMDir /r` 不遵守归属规则、不支持长路径并会进入链接目录;改由原生辅助程序负责删除。
|
||||
|
||||
## 结果
|
||||
|
||||
重装恢复会话、设置和插件,但不恢复界面布局偏好。主目录下的桌面配置档案及其包和锁文件保留。其他历史主目录和用户项目不会被发现或删除。
|
||||
|
||||
验收使用带作用域包名的隔离产品身份。源码回归覆盖模板适配;原生夹具覆盖主目录保留、用户数据、作用域目录和更新器缓存的删除、junction 目标、静默与升级行为。标准卸载页面的高 DPI 视觉验收仍未完成。
|
||||
@@ -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: 82e710ded1dc043be330faea3ffee65308037f9a
|
||||
README.zh.md: 08ee231846dc6172aab94c4c2d03666f9256ec2e
|
||||
README.md: 0b22cc7325cea78e11df197dc3ccdc0210e21b32
|
||||
README.zh.md: 1d4d807c4b263726ee58fc3c3a0066d2eb15bbb7
|
||||
@@ -240,9 +240,9 @@ Windows packaging compiles an x86 Win32/GDI+ helper with Visual C++ Build Tools
|
||||
|
||||
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 and sequentially exercises English-only and Chinese-only installer variants, selecting test labels from the displayed welcome button. Both variants install into private directories and uninstall after testing; screenshots and results remain under `.desktop-build/installer-tests/`. The checks include upgrades to registered paths with trailing separators and rejection of drive roots. The optional `--signed` flag uses the Windows EV configuration below to sign test executables and the helper before embedding them; it does not enable an update feed.
|
||||
|
||||
The Windows uninstaller removes the Electron user-data directory (browser storage and caches under `%APPDATA%`, nested under the package scope), the `%APPDATA%` product directory, and the updater download cache under `%LOCALAPPDATA%` together with the application. The Harness home (`~/.dsh` or `DSH_HOME`: sessions, settings, credentials, plugins) is never touched, so a reinstall continues where the user left off. Silent uninstalls remove the same data; upgrades retain it. Removal runs through the native helper, which refuses protected Windows folders and paths overlapping the installation and unlinks reparse points without entering their targets; a locked file leaves residue without stopping uninstallation. Installation records `InstallLocation` on the Windows uninstall entry as standard inventory metadata; on Windows 11, Uninstall in the Start menu context menu opens the installed-apps list for every Win32 application, and only MSIX packages uninstall directly from there. The uninstaller declares DPI awareness and uses Microsoft YaHei UI for Chinese. This behavior is Windows-only.
|
||||
The Windows uninstaller removes the Electron user-data directory (browser storage and caches under `%APPDATA%`, nested under the package scope), the `%APPDATA%` product directory, and the updater download cache under `%LOCALAPPDATA%` together with the application, then removes ordinary empty scope directories below `%APPDATA%`. The Harness home (`~/.dsh` or `DSH_HOME`: sessions, settings, credentials, plugins) is never touched, and a `DSH_HOME` published as a Windows environment variable additionally protects any target overlapping it. Silent uninstalls remove the same data; an uninstaller started with `--updated` or `/KEEP_APP_DATA`, as electron-builder does for in-place updates and for replacing an older installation from another directory, retains it. Removal runs through the native helper, which refuses protected Windows folders and paths overlapping the installation or the home, requires a fixed local drive, leaves a linked root or ancestor in place, unlinks reparse points without entering their targets, clears read-only attributes, and keeps deleting siblings after a locked file; residue never stops uninstallation and is not reported. Installation records `InstallLocation` on the Windows uninstall entry as standard inventory metadata; on Windows 11, Uninstall in the Start menu context menu opens the installed-apps list for every Win32 application, and only MSIX packages uninstall directly from there. The uninstaller declares DPI awareness and uses Microsoft YaHei UI for Chinese. This behavior is Windows-only.
|
||||
|
||||
Use `node apps/desktop/scripts/test-windows-installer.mjs --uninstall-only --compile-only` to compile isolated English and Chinese fixtures with a scoped package name. Omit `--compile-only` to run the native interactive, silent and upgrade checks against seeded data. Compilation does not establish installed-uninstall behavior or high-DPI visual acceptance.
|
||||
Use `node apps/desktop/scripts/test-windows-installer.mjs --uninstall-only --compile-only` to compile isolated English and Chinese fixtures with a per-run scoped package name. Omit `--compile-only` to run the native remover regression and the interactive, silent, `--updated`, `/KEEP_APP_DATA` and `DSH_HOME`-inside-Electron-data checks against seeded data. Compilation does not establish installed-uninstall behavior.
|
||||
|
||||
### Windows EV signing
|
||||
|
||||
|
||||
@@ -240,9 +240,9 @@ Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI
|
||||
|
||||
在有交互式桌面的 Windows x64 上,从仓库根目录运行 `pnpm --dir apps/desktop run test:installer`,可将小型原生测试载荷接入正式安装配置并执行验证。每次运行使用独立产品身份,依次验证仅英文和仅中文的安装器变体,并根据实际显示的欢迎页按钮选择测试文案。两个变体均安装到私有目录并在测试后卸载;截图和结果保留在 `.desktop-build/installer-tests/` 下。检查包含末尾带分隔符的已登记路径升级,以及磁盘根目录拒绝。可选的 `--signed` 标志使用下文的 Windows EV 配置,在嵌入前对测试程序和辅助库签名;它不会启用更新源。
|
||||
|
||||
Windows 卸载程序会随应用一起删除 Electron 用户数据目录(`%APPDATA%` 下按包作用域嵌套的浏览器存储与缓存)、`%APPDATA%` 下的产品目录,以及 `%LOCALAPPDATA%` 下的更新下载缓存。Harness 主目录(`~/.dsh` 或 `DSH_HOME`:会话、设置、凭据、插件)不会被触碰,重新安装后可以继续使用。静默卸载删除相同的数据;升级保留数据。删除通过原生辅助程序执行:它拒绝受保护的 Windows 目录和与安装目录重叠的路径,遇到重解析点只解除链接而不进入目标;文件被占用时会留下残余,不会中止卸载。安装时在 Windows 卸载注册项上记录 `InstallLocation` 作为标准的清单元数据;在 Windows 11 上,开始菜单右键菜单中的“卸载”对所有 Win32 应用都会打开已安装应用列表,只有 MSIX 包能从那里直接卸载。卸载程序声明 DPI 感知,中文使用微软雅黑 UI。此行为仅适用于 Windows。
|
||||
Windows 卸载程序会随应用一起删除 Electron 用户数据目录(`%APPDATA%` 下按包作用域嵌套的浏览器存储与缓存)、`%APPDATA%` 下的产品目录,以及 `%LOCALAPPDATA%` 下的更新下载缓存,随后移除 `%APPDATA%` 之下普通且已空的作用域目录。Harness 主目录(`~/.dsh` 或 `DSH_HOME`:会话、设置、凭据、插件)不会被触碰;以 Windows 环境变量发布的 `DSH_HOME` 还会保护所有与其重叠的目标。静默卸载删除相同的数据;以 `--updated` 或 `/KEEP_APP_DATA` 启动的卸载程序保留数据,electron-builder 在原地更新和从其他目录替换旧安装时正是这样启动它。删除通过原生辅助程序执行:它拒绝受保护的 Windows 目录以及与安装目录或主目录重叠的路径,要求固定的本地驱动器,链接的根目录或祖先目录原样保留,遇到重解析点只解除链接而不进入目标,清除只读属性,并在遇到被占用文件后继续删除其余兄弟项;残余不会中止卸载,也不会提示。安装时在 Windows 卸载注册项上记录 `InstallLocation` 作为标准的清单元数据;在 Windows 11 上,开始菜单右键菜单中的“卸载”对所有 Win32 应用都会打开已安装应用列表,只有 MSIX 包能从那里直接卸载。卸载程序声明 DPI 感知,中文使用微软雅黑 UI。此行为仅适用于 Windows。
|
||||
|
||||
使用 `node apps/desktop/scripts/test-windows-installer.mjs --uninstall-only --compile-only` 以带作用域的包名编译独立的中英文夹具。省略 `--compile-only` 可对预置数据运行原生的交互、静默与升级检查。编译本身不能证明已安装卸载行为或高 DPI 视觉验收。
|
||||
使用 `node apps/desktop/scripts/test-windows-installer.mjs --uninstall-only --compile-only` 以每次运行唯一的带作用域包名编译独立的中英文夹具。省略 `--compile-only` 可对预置数据运行原生删除器回归,以及交互、静默、`--updated`、`/KEEP_APP_DATA` 和 `DSH_HOME` 位于 Electron 数据内的检查。编译本身不能证明已安装卸载行为。
|
||||
|
||||
### Windows EV 签名
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
namespace uninstall_data {
|
||||
struct Handles {
|
||||
std::vector<HANDLE> values;
|
||||
Handles() = default;
|
||||
Handles(const Handles&) = delete;
|
||||
Handles& operator=(const Handles&) = delete;
|
||||
~Handles() { for (HANDLE value : values) CloseHandle(value); }
|
||||
};
|
||||
|
||||
@@ -36,8 +39,9 @@ inline bool Normalize(LPCWSTR input, std::wstring& result) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Listing access takes part in share checks, so the open handle refuses the DELETE access a rename or removal needs.
|
||||
inline DWORD Pin(const std::wstring& path, Handles& handles) {
|
||||
HANDLE handle = CreateFileW((L"\\\\?\\" + path).c_str(), FILE_READ_ATTRIBUTES,
|
||||
HANDLE handle = CreateFileW((L"\\\\?\\" + path).c_str(), FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING,
|
||||
FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, nullptr);
|
||||
if (handle == INVALID_HANDLE_VALUE) return GetLastError();
|
||||
@@ -49,6 +53,14 @@ inline DWORD Pin(const std::wstring& path, Handles& handles) {
|
||||
return ERROR_SUCCESS;
|
||||
}
|
||||
|
||||
inline DWORD Unlink(const std::wstring& extended, DWORD attributes) {
|
||||
if (attributes & FILE_ATTRIBUTE_READONLY) SetFileAttributesW(extended.c_str(), FILE_ATTRIBUTE_NORMAL);
|
||||
const BOOL removed = (attributes & FILE_ATTRIBUTE_DIRECTORY)
|
||||
? RemoveDirectoryW(extended.c_str()) : DeleteFileW(extended.c_str());
|
||||
return removed ? ERROR_SUCCESS : GetLastError();
|
||||
}
|
||||
|
||||
// Every sibling is attempted; the first error is returned after the whole tree has been visited.
|
||||
inline DWORD RemoveTree(const std::wstring& path) {
|
||||
const std::wstring extended = L"\\\\?\\" + path;
|
||||
DWORD attributes = GetFileAttributesW(extended.c_str());
|
||||
@@ -56,10 +68,8 @@ inline DWORD RemoveTree(const std::wstring& path) {
|
||||
DWORD error = GetLastError();
|
||||
return error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND ? ERROR_SUCCESS : error;
|
||||
}
|
||||
if (!(attributes & FILE_ATTRIBUTE_DIRECTORY)) {
|
||||
return DeleteFileW(extended.c_str()) ? ERROR_SUCCESS : GetLastError();
|
||||
}
|
||||
if (!(attributes & FILE_ATTRIBUTE_REPARSE_POINT)) {
|
||||
DWORD first = ERROR_SUCCESS;
|
||||
if ((attributes & FILE_ATTRIBUTE_DIRECTORY) && !(attributes & FILE_ATTRIBUTE_REPARSE_POINT)) {
|
||||
Handles pinned;
|
||||
DWORD error = Pin(path, pinned);
|
||||
if (error) return error;
|
||||
@@ -72,22 +82,30 @@ inline DWORD RemoveTree(const std::wstring& path) {
|
||||
do {
|
||||
if (wcscmp(entry.cFileName, L".") == 0 || wcscmp(entry.cFileName, L"..") == 0) continue;
|
||||
error = RemoveTree(path + L"\\" + entry.cFileName);
|
||||
if (error) { FindClose(search); return error; }
|
||||
if (error && !first) first = error;
|
||||
} while (FindNextFileW(search, &entry));
|
||||
error = GetLastError();
|
||||
FindClose(search);
|
||||
if (error != ERROR_NO_MORE_FILES) return error;
|
||||
if (error != ERROR_NO_MORE_FILES && !first) first = error;
|
||||
}
|
||||
}
|
||||
return RemoveDirectoryW(extended.c_str()) ? ERROR_SUCCESS : GetLastError();
|
||||
DWORD error = Unlink(extended, attributes);
|
||||
return first ? first : error;
|
||||
}
|
||||
|
||||
// Never select a shell folder, its ancestor, Windows/Program Files descendants,
|
||||
// or a path overlapping the installation. Linked descendants are unlinked only.
|
||||
inline DWORD Remove(LPCWSTR input, LPCWSTR installation) {
|
||||
// a path overlapping the installation, or a path overlapping the protected root
|
||||
// (the configured Harness home). Linked descendants are unlinked only.
|
||||
inline DWORD Remove(LPCWSTR input, LPCWSTR installation, LPCWSTR protectedRoot) {
|
||||
std::wstring path, install;
|
||||
if (!Normalize(input, path) || !Normalize(installation, install)) return ERROR_INVALID_NAME;
|
||||
if (Contains(path, install) || Contains(install, path)) return ERROR_ACCESS_DENIED;
|
||||
if (protectedRoot && *protectedRoot) {
|
||||
std::wstring home;
|
||||
// An unusable protected root cannot establish separation from retained data.
|
||||
if (!Normalize(protectedRoot, home)) return ERROR_INVALID_NAME;
|
||||
if (Contains(path, home) || Contains(home, path)) return ERROR_ACCESS_DENIED;
|
||||
}
|
||||
struct ComScope {
|
||||
HRESULT result = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||
~ComScope() { if (SUCCEEDED(result)) CoUninitialize(); }
|
||||
@@ -108,6 +126,7 @@ inline DWORD Remove(LPCWSTR input, LPCWSTR installation) {
|
||||
PWSTR value = nullptr;
|
||||
HRESULT result = SHGetKnownFolderPath(*id, KF_FLAG_DONT_VERIFY, nullptr, &value);
|
||||
if (FAILED(result)) return ERROR_ACCESS_DENIED;
|
||||
// Known-folder paths are already full paths; a redirected folder on another volume must stay protected.
|
||||
const std::wstring folder(value);
|
||||
CoTaskMemFree(value);
|
||||
if (Contains(path, folder)) return ERROR_ACCESS_DENIED;
|
||||
@@ -129,4 +148,20 @@ inline DWORD Remove(LPCWSTR input, LPCWSTR installation) {
|
||||
}
|
||||
return RemoveTree(path);
|
||||
}
|
||||
|
||||
// Remove the ordinary, empty directories between a removed path and its stop ancestor.
|
||||
// A linked or populated parent ends the walk; the stop directory itself is never removed.
|
||||
inline DWORD RemoveEmptyParents(LPCWSTR input, LPCWSTR stopInput) {
|
||||
std::wstring path, stop;
|
||||
if (!Normalize(input, path) || !Normalize(stopInput, stop)) return ERROR_INVALID_NAME;
|
||||
if (!Contains(stop, path) || _wcsicmp(stop.c_str(), path.c_str()) == 0) return ERROR_ACCESS_DENIED;
|
||||
for (size_t end = path.rfind(L'\\'); end != std::wstring::npos && end > stop.size(); end = path.rfind(L'\\', end - 1)) {
|
||||
const std::wstring parent = L"\\\\?\\" + path.substr(0, end);
|
||||
const DWORD attributes = GetFileAttributesW(parent.c_str());
|
||||
if (attributes == INVALID_FILE_ATTRIBUTES) return GetLastError();
|
||||
if (!(attributes & FILE_ATTRIBUTE_DIRECTORY) || (attributes & FILE_ATTRIBUTE_REPARSE_POINT)) return ERROR_ACCESS_DENIED;
|
||||
if (!RemoveDirectoryW(parent.c_str())) return GetLastError();
|
||||
}
|
||||
return ERROR_SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,35 @@
|
||||
; Electron user data and updater downloads leave with the application; the Harness home is never touched.
|
||||
!include FileFunc.nsh
|
||||
Var UnTarget
|
||||
Var UnHome
|
||||
|
||||
Function un.DataInit
|
||||
InitPluginsDir
|
||||
File "/oname=$PLUGINSDIR\window-frame.dll" "${INSTALLER_BUILD_DIR}\window-frame.dll"
|
||||
FunctionEnd
|
||||
|
||||
; The helper refuses unsafe roots and never descends into reparse points; a locked file leaves residue and never fails the uninstall.
|
||||
; The helper refuses unsafe roots, the installation and the Harness home, and never descends into reparse points;
|
||||
; a locked file leaves residue and never fails the uninstall. customCheckAppRunning extracts window-frame.dll first.
|
||||
Function un.RemoveData
|
||||
System::Call '$PLUGINSDIR\window-frame.dll::UninstallRemoveData(w "$UnTarget", w "$INSTDIR") i ?c'
|
||||
System::Call '$PLUGINSDIR\window-frame.dll::UninstallRemoveData(w "$UnTarget", w "$INSTDIR", w "$UnHome") ?c'
|
||||
FunctionEnd
|
||||
|
||||
Function un.CleanData
|
||||
${If} ${isUpdated}
|
||||
Return
|
||||
${EndIf}
|
||||
; The installer runs an older uninstaller with /KEEP_APP_DATA when replacing it from another directory.
|
||||
${GetParameters} $R0
|
||||
ClearErrors
|
||||
${GetOptions} $R0 "/KEEP_APP_DATA" $R1
|
||||
${IfNot} ${Errors}
|
||||
Return
|
||||
${EndIf}
|
||||
; Only a DSH_HOME published as a Windows environment variable is visible here.
|
||||
ReadEnvStr $UnHome DSH_HOME
|
||||
ClearErrors
|
||||
StrCpy $UnTarget "$APPDATA\${PRODUCT_FILENAME}"
|
||||
Call un.RemoveData
|
||||
!ifdef APP_PACKAGE_NAME
|
||||
; Electron derives user data from the package name; a scoped name nests it one directory deeper.
|
||||
StrCpy $UnTarget "$APPDATA\${APP_PACKAGE_NAME}"
|
||||
Call un.RemoveData
|
||||
${GetParent} $UnTarget $0
|
||||
${If} $0 != $APPDATA
|
||||
RMDir $0
|
||||
${EndIf}
|
||||
System::Call '$PLUGINSDIR\window-frame.dll::UninstallRemoveEmptyParents(w "$UnTarget", w "$APPDATA") ?c'
|
||||
!endif
|
||||
StrCpy $UnTarget "$LOCALAPPDATA\${DSH_UPDATER_CACHE_NAME}"
|
||||
Call un.RemoveData
|
||||
|
||||
@@ -16,9 +16,14 @@
|
||||
|
||||
using namespace Gdiplus;
|
||||
|
||||
// Returns a Win32 error code; no cleanup request may traverse a linked root.
|
||||
extern "C" __declspec(dllexport) DWORD __cdecl UninstallRemoveData(LPCWSTR path, LPCWSTR installation) {
|
||||
return uninstall_data::Remove(path, installation);
|
||||
// Returns a Win32 error code; no cleanup request may traverse a linked root or touch the protected root.
|
||||
extern "C" __declspec(dllexport) DWORD __cdecl UninstallRemoveData(LPCWSTR path, LPCWSTR installation, LPCWSTR protectedRoot) {
|
||||
return uninstall_data::Remove(path, installation, protectedRoot);
|
||||
}
|
||||
|
||||
// Returns a Win32 error code; only ordinary empty directories strictly below the stop directory are removed.
|
||||
extern "C" __declspec(dllexport) DWORD __cdecl UninstallRemoveEmptyParents(LPCWSTR path, LPCWSTR stop) {
|
||||
return uninstall_data::RemoveEmptyParents(path, stop);
|
||||
}
|
||||
|
||||
// Match the affected executable, not another user's or directory's same-named application.
|
||||
|
||||
@@ -82,10 +82,6 @@ ManifestDPIAware true
|
||||
Page custom InstallerWelcome InstallerWelcomeLeave
|
||||
!macroend
|
||||
|
||||
!macro customUnInit
|
||||
Call un.DataInit
|
||||
!macroend
|
||||
|
||||
!macro customUnInstall
|
||||
Call un.CleanData
|
||||
!macroend
|
||||
|
||||
@@ -21,8 +21,9 @@ const { getMakeNsisPath } = require('app-builder-lib/out/toolsets/windows.js')
|
||||
const guid = randomUUID()
|
||||
const id = guid.replaceAll('-', '')
|
||||
const productName = `Harness Installer Test ${id.slice(0, 8)}`
|
||||
// Scoped like the shipped package so Electron user data nests under the scope directory.
|
||||
const packageName = `@harness-installer-test/app-${id}`
|
||||
// Scoped like the shipped package so Electron user data nests under a scope directory; the scope is unique per run.
|
||||
const packageName = `@harness-installer-test-${id.slice(0, 8)}/app-${id}`
|
||||
const uninstallOnly = process.argv.includes('--uninstall-only')
|
||||
const outputRoot = join(appRoot, '.desktop-build', 'installer-tests')
|
||||
await mkdir(outputRoot, { recursive: true })
|
||||
const output = await mkdtemp(join(outputRoot, 'run-'))
|
||||
@@ -57,16 +58,19 @@ try {
|
||||
join(appRoot, 'scripts', 'prepare-windows-installer.ps1'), '-OutputDirectory', join(output, 'ui'), '-CompileProgressOnly'], childOptions)
|
||||
const progressTest = join(output, 'ui', 'progress-test.exe')
|
||||
const presentationTest = join(output, 'ui', 'presentation-test.exe')
|
||||
const cleanupTest = join(output, 'ui', 'data-cleanup-test.exe')
|
||||
if (sign) {
|
||||
await sign({ path: progressTest, hash: 'sha256', isNest: false })
|
||||
await sign({ path: presentationTest, hash: 'sha256', isNest: false })
|
||||
await sign({ path: cleanupTest, hash: 'sha256', isNest: false })
|
||||
await sign({ path: join(output, 'ui', 'window-frame.dll'), hash: 'sha256', isNest: false })
|
||||
installWindowsNsisBootstrapSigner({ sign })
|
||||
}
|
||||
if (!process.argv.includes('--uninstall-only')) {
|
||||
if (!uninstallOnly) {
|
||||
await execute(progressTest, [], childOptions)
|
||||
await execute(presentationTest, [], childOptions)
|
||||
}
|
||||
await execute(cleanupTest, [join(output, 'ui', `cleanup-${id}`)], childOptions)
|
||||
const payloadSource = join(output, 'payload.nsi')
|
||||
await writeFile(payloadSource, `Unicode true
|
||||
RequestExecutionLevel user
|
||||
@@ -106,10 +110,10 @@ SectionEnd
|
||||
})
|
||||
if (process.argv.includes('--compile-only')) continue
|
||||
const result = await execute('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File',
|
||||
join(appRoot, 'tests', process.argv.includes('--uninstall-only') ? 'windows-uninstall-smoke.ps1' : 'windows-installer-smoke.ps1'),
|
||||
join(appRoot, 'tests', uninstallOnly ? 'windows-uninstall-smoke.ps1' : 'windows-installer-smoke.ps1'),
|
||||
'-Installer', join(languageOutput, 'installer-test.exe'),
|
||||
'-ProductName', productName, '-RegistryKey', guid, '-OutputDirectory', languageOutput,
|
||||
...process.argv.includes('--uninstall-only') ? ['-Language', languageId, '-PackageName', packageName] : []], childOptions)
|
||||
...uninstallOnly ? ['-Language', languageId, '-PackageName', packageName] : []], childOptions)
|
||||
process.stdout.write(`${language}\n${result.stdout}`)
|
||||
}
|
||||
succeeded = true
|
||||
|
||||
@@ -17,7 +17,7 @@ export function directoryInstallerExits(source: string): string
|
||||
/**
|
||||
* Keep user-data removal in the native helper, which refuses unsafe roots and never follows links.
|
||||
* @param source - Pinned upstream uninstaller source.
|
||||
* @returns Long-path uninstaller without the upstream RMDir data removal.
|
||||
* @returns Uninstaller with long-path application removal and no upstream RMDir data removal.
|
||||
*/
|
||||
export function directoryUninstaller(source: string): string
|
||||
|
||||
|
||||
@@ -85,13 +85,14 @@ export function installWindowsDirectoryInstaller() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep data removal in the native helper, which refuses unsafe roots and never follows links; the legacy flag is ignored.
|
||||
* Keep user-data removal in the native helper, which refuses unsafe roots and never follows links.
|
||||
* @param {string} source - Pinned upstream uninstaller source.
|
||||
* @returns Uninstaller with long-path application removal and no upstream RMDir data removal.
|
||||
* @returns {string} Uninstaller with long-path application removal and no upstream RMDir data removal.
|
||||
*/
|
||||
export function directoryUninstaller(source) {
|
||||
const start = source.indexOf(' Var /GLOBAL isDeleteAppData\n')
|
||||
const end = source.indexOf(' DeleteRegKey SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY}"', start)
|
||||
const normalized = source.replaceAll('\r\n', '\n')
|
||||
const start = normalized.indexOf(' Var /GLOBAL isDeleteAppData\n')
|
||||
const end = normalized.indexOf(' DeleteRegKey SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY}"', start)
|
||||
if (start < 0 || end < 0) throw new Error('NSIS uninstaller data removal template changed')
|
||||
return replaceOnce(source.slice(0, start) + source.slice(end), 'RMDir /r $INSTDIR', 'RMDir /r "\\\\?\\$INSTDIR"')
|
||||
return replaceOnce(normalized.slice(0, start) + normalized.slice(end), 'RMDir /r $INSTDIR', 'RMDir /r "\\\\?\\$INSTDIR"')
|
||||
}
|
||||
@@ -8,7 +8,6 @@
|
||||
"welcome-presented-on-first-show",
|
||||
"successful-install-paints-100-before-finish",
|
||||
"registered-directory-with-trailing-separators",
|
||||
"uninstall-entry-install-location",
|
||||
"registered-directory-and-checked-launch",
|
||||
"launch-failure-retry-and-prompt-dismissal",
|
||||
"progress-remains-monotonic-across-native-resets",
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"cases": ["interactive", "silent", "upgrade"]
|
||||
"cases": ["interactive", "silent", "upgrade", "replaced", "home-inside"]
|
||||
}
|
||||
@@ -9,13 +9,15 @@ const require = createRequire(import.meta.url)
|
||||
const section = readFileSync(join(dirname(require.resolve('app-builder-lib/package.json')),
|
||||
'templates/nsis/installSection.nsh'), 'utf8')
|
||||
|
||||
it('leaves data cleanup to the interactive page while retaining application removal and registration cleanup', () => {
|
||||
it('keeps data cleanup out of the upstream template while retaining application removal and registration cleanup', () => {
|
||||
const source = readFileSync(join(dirname(require.resolve('app-builder-lib/package.json')), 'templates/nsis/uninstaller.nsh'), 'utf8')
|
||||
const adapted = directoryUninstaller(source)
|
||||
expect(adapted).not.toContain('--delete-app-data')
|
||||
expect(adapted).not.toContain('RMDir /r "$APPDATA')
|
||||
expect(adapted).toContain('!insertmacro customUnInstall')
|
||||
expect(adapted).toContain('DeleteRegKey SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY}"')
|
||||
expect(adapted).toContain('DeleteRegKey SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY_2}"')
|
||||
expect(directoryUninstaller(source.replaceAll('\n', '\r\n'))).toBe(adapted)
|
||||
expect(adapted).toContain('RMDir /r "\\\\?\\$INSTDIR"')
|
||||
expect(() => directoryUninstaller(source.replace(' Var /GLOBAL isDeleteAppData\n', ''))).toThrow('template changed')
|
||||
expect(() => directoryUninstaller(source.replace(' DeleteRegKey SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY}"', ''))).toThrow('template changed')
|
||||
|
||||
@@ -170,9 +170,6 @@ try {
|
||||
throw 'Trailing separators changed the registered installation directory'
|
||||
}
|
||||
$results.Add('registered-directory-with-trailing-separators')
|
||||
$uninstallEntry = Get-ItemProperty ('HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\' + $RegistryKey)
|
||||
if ($uninstallEntry.InstallLocation.TrimEnd('\') -ne $installPath) { throw 'Uninstall entry lacks InstallLocation' }
|
||||
$results.Add('uninstall-entry-install-location')
|
||||
$timer = [Diagnostics.Stopwatch]::StartNew()
|
||||
do {
|
||||
$app = Get-Process -Name $ProductName -ErrorAction SilentlyContinue
|
||||
|
||||
@@ -6,45 +6,86 @@
|
||||
#include <cassert>
|
||||
#include "../installer/uninstall-data.h"
|
||||
|
||||
static void File(const std::wstring& path) {
|
||||
HANDLE file = CreateFileW(path.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
static void File(const std::wstring& path, DWORD attributes = FILE_ATTRIBUTE_NORMAL) {
|
||||
HANDLE file = CreateFileW(path.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_NEW, attributes, nullptr);
|
||||
assert(file != INVALID_HANDLE_VALUE);
|
||||
DWORD count;
|
||||
assert(WriteFile(file, "retained", 8, &count, nullptr));
|
||||
CloseHandle(file);
|
||||
}
|
||||
|
||||
static bool Exists(const std::wstring& path) { return GetFileAttributesW(path.c_str()) != INVALID_FILE_ATTRIBUTES; }
|
||||
|
||||
int wmain(int argc, wchar_t** argv) {
|
||||
assert(argc == 2);
|
||||
const std::wstring root = argv[1];
|
||||
const std::wstring installation = root + L"\\application";
|
||||
const std::wstring data = root + L"\\data";
|
||||
const std::wstring cache = data + L"\\Local Storage\\leveldb";
|
||||
assert(CreateDirectoryW(root.c_str(), nullptr));
|
||||
assert(CreateDirectoryW(installation.c_str(), nullptr));
|
||||
assert(CreateDirectoryW(data.c_str(), nullptr));
|
||||
assert(CreateDirectoryW((data + L"\\Local Storage").c_str(), nullptr));
|
||||
const std::wstring cache = data + L"\\Local Storage\\leveldb";
|
||||
assert(CreateDirectoryW(cache.c_str(), nullptr));
|
||||
File(data + L"\\Local State");
|
||||
File(cache + L"\\CURRENT");
|
||||
assert(uninstall_data::Remove(root.c_str(), installation.c_str()) == ERROR_ACCESS_DENIED);
|
||||
assert(uninstall_data::Remove(L"C:\\", installation.c_str()) == ERROR_INVALID_NAME);
|
||||
assert(uninstall_data::Remove(L"relative", installation.c_str()) == ERROR_INVALID_NAME);
|
||||
assert(uninstall_data::Remove(L"C:\\bad*", installation.c_str()) == ERROR_INVALID_NAME);
|
||||
File(cache + L"\\LOCK", FILE_ATTRIBUTE_READONLY);
|
||||
File(cache + L"\\MANIFEST");
|
||||
|
||||
// Refused roots: the test root contains the installation; shell roots and malformed paths are rejected.
|
||||
assert(uninstall_data::Remove(root.c_str(), installation.c_str(), nullptr) == ERROR_ACCESS_DENIED);
|
||||
assert(uninstall_data::Remove(L"C:\\", installation.c_str(), nullptr) == ERROR_INVALID_NAME);
|
||||
assert(uninstall_data::Remove(L"relative", installation.c_str(), nullptr) == ERROR_INVALID_NAME);
|
||||
assert(uninstall_data::Remove(L"C:\\bad*", installation.c_str(), nullptr) == ERROR_INVALID_NAME);
|
||||
PWSTR profile = nullptr;
|
||||
assert(SUCCEEDED(SHGetKnownFolderPath(FOLDERID_Profile, 0, nullptr, &profile)));
|
||||
assert(uninstall_data::Remove(profile, installation.c_str()) == ERROR_ACCESS_DENIED);
|
||||
assert(uninstall_data::Remove(profile, installation.c_str(), nullptr) == ERROR_ACCESS_DENIED);
|
||||
CoTaskMemFree(profile);
|
||||
|
||||
// A protected root overlapping the target in either direction, or an unusable one, stops removal.
|
||||
assert(uninstall_data::Remove(cache.c_str(), installation.c_str(), data.c_str()) == ERROR_ACCESS_DENIED);
|
||||
assert(uninstall_data::Remove(data.c_str(), installation.c_str(), cache.c_str()) == ERROR_ACCESS_DENIED);
|
||||
assert(uninstall_data::Remove(cache.c_str(), installation.c_str(), L"relative\\home") == ERROR_INVALID_NAME);
|
||||
assert(Exists(cache + L"\\CURRENT"));
|
||||
|
||||
// A pinned directory cannot be renamed or replaced while the helper holds it.
|
||||
{
|
||||
uninstall_data::Handles pinned;
|
||||
assert(uninstall_data::Pin(cache, pinned) == ERROR_SUCCESS);
|
||||
assert(!MoveFileExW(cache.c_str(), (data + L"\\renamed").c_str(), 0));
|
||||
assert(GetLastError() == ERROR_SHARING_VIOLATION || GetLastError() == ERROR_ACCESS_DENIED);
|
||||
}
|
||||
assert(Exists(cache));
|
||||
|
||||
// A locked file returns its error but every sibling, including a read-only one, is still removed.
|
||||
HANDLE held = CreateFileW((cache + L"\\CURRENT").c_str(), GENERIC_READ, FILE_SHARE_READ,
|
||||
nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
assert(held != INVALID_HANDLE_VALUE);
|
||||
assert(uninstall_data::Remove(cache.c_str(), installation.c_str()) == ERROR_SHARING_VIOLATION);
|
||||
assert(uninstall_data::Remove(cache.c_str(), installation.c_str(), nullptr) == ERROR_SHARING_VIOLATION);
|
||||
assert(Exists(cache + L"\\CURRENT"));
|
||||
assert(!Exists(cache + L"\\LOCK"));
|
||||
assert(!Exists(cache + L"\\MANIFEST"));
|
||||
CloseHandle(held);
|
||||
assert(uninstall_data::Remove(cache.c_str(), installation.c_str()) == ERROR_SUCCESS);
|
||||
assert(GetFileAttributesW((data + L"\\Local State").c_str()) != INVALID_FILE_ATTRIBUTES);
|
||||
assert(uninstall_data::Remove(cache.c_str(), installation.c_str()) == ERROR_SUCCESS);
|
||||
assert(uninstall_data::Remove(data.c_str(), installation.c_str()) == ERROR_SUCCESS);
|
||||
assert(GetFileAttributesW(data.c_str()) == INVALID_FILE_ATTRIBUTES);
|
||||
assert(uninstall_data::Remove(cache.c_str(), installation.c_str(), nullptr) == ERROR_SUCCESS);
|
||||
assert(Exists(data + L"\\Local State"));
|
||||
assert(uninstall_data::Remove(cache.c_str(), installation.c_str(), nullptr) == ERROR_SUCCESS);
|
||||
|
||||
// Empty ordinary parents below the stop directory are removed; the stop directory and populated parents stay.
|
||||
const std::wstring scope = root + L"\\@scope";
|
||||
const std::wstring package = scope + L"\\package";
|
||||
assert(CreateDirectoryW(scope.c_str(), nullptr));
|
||||
assert(CreateDirectoryW(package.c_str(), nullptr));
|
||||
assert(uninstall_data::RemoveEmptyParents(package.c_str(), root.c_str()) == ERROR_DIR_NOT_EMPTY);
|
||||
assert(Exists(scope));
|
||||
assert(RemoveDirectoryW(package.c_str()));
|
||||
assert(uninstall_data::RemoveEmptyParents(root.c_str(), root.c_str()) == ERROR_ACCESS_DENIED);
|
||||
assert(uninstall_data::RemoveEmptyParents(package.c_str(), root.c_str()) == ERROR_SUCCESS);
|
||||
assert(!Exists(scope));
|
||||
assert(Exists(root));
|
||||
|
||||
// A protected root elsewhere does not block removal.
|
||||
assert(uninstall_data::Remove(data.c_str(), installation.c_str(), (root + L"\\elsewhere").c_str()) == ERROR_SUCCESS);
|
||||
assert(!Exists(data));
|
||||
assert(RemoveDirectoryW(installation.c_str()));
|
||||
assert(RemoveDirectoryW(root.c_str()));
|
||||
}
|
||||
@@ -35,6 +35,12 @@ function Wait-Exit {
|
||||
if ($process.ExitCode -ne 0) { throw "Uninstall failed: $($process.ExitCode)" }
|
||||
$process.Dispose()
|
||||
$script:process = $null
|
||||
# The NSIS uninstaller hands its section to a temporary copy; the launched process exits before removal completes.
|
||||
$timer = [Diagnostics.Stopwatch]::StartNew()
|
||||
while (Test-Path -LiteralPath $installPath) {
|
||||
if ($timer.Elapsed.TotalSeconds -gt 20) { throw 'Application directory remained' }
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
}
|
||||
try {
|
||||
foreach ($mode in $expected.cases) {
|
||||
@@ -43,6 +49,8 @@ try {
|
||||
if (-not $setup.WaitForExit(30000)) { $setup.Kill(); $setup.WaitForExit(); throw 'Setup timed out' }
|
||||
if ($setup.ExitCode -ne 0) { throw "Setup failed: $($setup.ExitCode)" }
|
||||
} finally { $setup.Dispose() }
|
||||
$entry = Get-ItemProperty ('HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\' + $RegistryKey)
|
||||
if ($entry.InstallLocation.TrimEnd('\') -ne $installPath) { throw 'Uninstall entry lacks InstallLocation' }
|
||||
foreach ($path in @($homePath, $productDataPath, $userDataPath, $cachePath, $externalPath)) { New-Item -ItemType Directory -Force -Path $path | Out-Null }
|
||||
[IO.File]::WriteAllText((Join-Path $externalPath 'keep.txt'), 'outside data root')
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $userDataPath 'linked-project'))) {
|
||||
@@ -54,8 +62,11 @@ try {
|
||||
[IO.File]::WriteAllText((Join-Path $cachePath 'download.txt'), 'update')
|
||||
Copy-Item -LiteralPath (Join-Path $installPath ('Uninstall ' + $ProductName + '.exe')) -Destination $uninstaller -Force
|
||||
$arguments = '_?=' + $installPath
|
||||
if ($mode -eq 'silent') { $arguments = '/S ' + $arguments }
|
||||
if ($mode -eq 'upgrade') { $arguments = '/S --updated ' + $arguments }
|
||||
if ($mode -in @('silent', 'home-inside')) { $arguments = '/S ' + $arguments }
|
||||
# electron-builder's installUtil.nsh runs an older uninstaller with exactly these flags when replacing it.
|
||||
if ($mode -eq 'upgrade') { $arguments = '/S /KEEP_APP_DATA --updated /currentuser ' + $arguments }
|
||||
if ($mode -eq 'replaced') { $arguments = '/S /KEEP_APP_DATA /currentuser ' + $arguments }
|
||||
if ($mode -eq 'home-inside') { $env:DSH_HOME = Join-Path $userDataPath 'home' } else { Remove-Item Env:DSH_HOME -ErrorAction SilentlyContinue }
|
||||
$process = Start-Process -FilePath $uninstaller -ArgumentList $arguments -PassThru -WindowStyle ($(if ($mode -eq 'interactive') { 'Normal' } else { 'Hidden' }))
|
||||
if ($mode -eq 'interactive') {
|
||||
$welcomeText = if ($Language -eq 'ENGLISH') { 'Welcome' } else { -join [char[]]@(0x6B22, 0x8FCE) }
|
||||
@@ -68,17 +79,19 @@ try {
|
||||
[InstallerCapture]::Click([InstallerCapture]::GetDlgItem($window, 1))
|
||||
}
|
||||
Wait-Exit
|
||||
$retained = $mode -eq 'upgrade'
|
||||
if (Test-Path -LiteralPath $installPath) { throw 'Application directory remained' }
|
||||
$retained = $mode -in @('upgrade', 'replaced')
|
||||
# A DSH_HOME inside the Electron directory protects that directory (and its scope) but not the other roots.
|
||||
$userDataRetained = $retained -or $mode -eq 'home-inside'
|
||||
if ([IO.File]::ReadAllText((Join-Path $externalPath 'keep.txt')) -ne 'outside data root') { throw 'Cleanup followed a junction into a project' }
|
||||
if (-not (Test-Path (Join-Path $homePath 'session.txt'))) { throw "Harness home retention failed: $mode" }
|
||||
if ((Test-Path (Join-Path $productDataPath 'state.txt')) -ne $retained) { throw "Product data retention failed: $mode" }
|
||||
if ((Test-Path -LiteralPath $userDataPath) -ne $retained) { throw "Electron user data retention failed: $mode" }
|
||||
if ((Test-Path -LiteralPath $scopePath) -ne $retained) { throw "Package scope directory retention failed: $mode" }
|
||||
if ((Test-Path (Join-Path $userDataPath 'Local State')) -ne $userDataRetained) { throw "Electron user data retention failed: $mode" }
|
||||
if ((Test-Path -LiteralPath $scopePath) -ne $userDataRetained) { throw "Package scope directory retention failed: $mode" }
|
||||
if ((Test-Path (Join-Path $cachePath 'download.txt')) -ne $retained) { throw "Update cache retention failed: $mode" }
|
||||
Write-Output "passed: $mode"
|
||||
}
|
||||
} finally {
|
||||
Remove-Item Env:DSH_HOME -ErrorAction SilentlyContinue
|
||||
if ($process) { if (-not $process.HasExited) { $process.Kill(); $process.WaitForExit() }; $process.Dispose() }
|
||||
# Only unique test identities below known application-data parents are removed.
|
||||
foreach ($pair in @(@($productDataPath, $roaming), @($scopePath, $roaming), @($cachePath, $local))) {
|
||||
|
||||
Reference in new issue
Block a user