feat(desktop): remove Electron data on Windows uninstall without a custom page
Product dropped Harness-home cleanup from the uninstaller, so the opt-in page, its bilingual dialog, the uninstall.ini home record and the overlap check go away. The uninstaller now always removes the Electron user-data directory, the %APPDATA% product directory and the updater cache through the native helper; the Harness home is never touched. Silent uninstalls remove the same data, upgrades retain it, and a locked file is logged instead of stopping the uninstall. Electron derives user data from the scoped package name, so the removal targets %APPDATA%\@deepseek-ai\dsh-desktop and removes the empty scope directory afterwards. The previous fixture used an unscoped name and never reached that directory; the fixture now uses a scoped package name. The native uninstall smoke runs end to end on the development host after two test fixes: the interactive uninstaller was launched hidden, which made every control invisible to the text search, and the Chinese finish text matched "已经从" instead of MUI's "已从你". The native remover regression is renamed data-cleanup-test.exe because UAC installer detection demanded elevation from the unmanifested uninstall-data-test.exe.
This commit is contained in:
1 parent
dbbecd2461
commit
93a65a9a94
25 files changed
+139
-558
No files matched your search
@@ -1,31 +0,0 @@
|
||||
# Agent Note: Opt-in Windows uninstall data removal
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-18-windows-opt-in-uninstall-data.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Desktop and CLI share conversations, credentials and settings in the Harness home. Counting profile directories cannot establish exclusive Desktop ownership: CLI profiles are created on demand. Browser storage and updater downloads also live outside that home.
|
||||
|
||||
## Decision
|
||||
|
||||
Windows uninstallation preserves data by default. The user can select desktop browser data and update caches, only `profiles/desktop`, or the entire Harness home. Whole-home selection requires a native warning with the actual path and CLI impact, defaults to Cancel, and forces the desktop child selection. Removing the parent selection restores the child's earlier independent state. Silent and upgrade uninstalls never opt into data removal.
|
||||
|
||||
The last packaged Desktop launch writes its resolved home to an INI file under Electron user data. A length field detects truncated NSIS reads. Missing legacy records select the default home; an invalid record cannot authorize home deletion. The native remover rejects protected roots and installation overlap, holds directory handles against replacement during traversal, and unlinks descendant reparse points without traversing their targets. Failure stops uninstallation and discloses possible partial deletion. This remains a current-user operation, not an all-users purge.
|
||||
|
||||
The [earlier proposal](../../rejected/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.md) is superseded by explicit selection rather than unconditional external-data removal. macOS cleanup is outside this implementation. Existing Desktop packaging and profile ownership decisions remain applicable.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**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.
|
||||
|
||||
**Always retain the home and automatically delete external caches.** Browser storage includes drafts and preferences; users choose whether to remove it. Users can also explicitly choose to discard all shared data.
|
||||
|
||||
**Use electron-builder's delete-app-data switch.** It lacks the selection hierarchy, shared-home warning and updater-cache inventory; cleanup belongs to the interactive custom page.
|
||||
|
||||
## Consequences
|
||||
|
||||
Desktop-only cleanup can retain Office runtimes and shared data. The complete-home option deletes every file under its displayed root, including user additions. Other historical homes, arbitrary plugin outputs and user projects outside selected roots are not discovered or deleted.
|
||||
|
||||
Qualification uses isolated product identities. Source regressions cover INI persistence and template adaptation; native fixtures cover retention, selective removal, junction targets and upgrade/silent behavior. The development host's Application Control blocks the unsigned test uninstaller, so native execution, installed release inventory and high-DPI visual acceptance remain unverified on that host. Compilation alone is not release qualification.
|
||||
@@ -1,31 +0,0 @@
|
||||
# Agent Note: Windows 卸载时按需清除数据
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-18-windows-opt-in-uninstall-data.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Desktop 和 CLI 共享 Harness 主目录中的会话、凭据和设置。配置档案数量不能证明数据由 Desktop 独占:CLI 配置档案按需创建。浏览器存储和更新下载还位于主目录之外。
|
||||
|
||||
## Decision
|
||||
|
||||
Windows 卸载默认保留数据。用户可以选择桌面浏览器数据与更新缓存、仅 `profiles/desktop` 或整个 Harness 主目录。选择整个主目录需要原生警告确认,显示实际路径及 CLI 影响,默认选择取消,并强制勾选桌面子项。取消父项勾选会恢复子项先前的独立状态。静默卸载和升级不会选择清除数据。
|
||||
|
||||
最近一次打包 Desktop 启动将解析后的主目录写入 Electron 用户数据中的 INI 文件。长度字段检测 NSIS 读取截断。旧安装缺少记录时选择默认主目录;无效记录不能授权删除主目录。原生清理拒绝受保护的根目录及与安装目录重叠的路径,遍历时持有目录句柄防止路径被替换,遇到后代重解析点只移除链接,不遍历目标。失败会停止卸载,并说明可能已经删除部分数据。此操作仅针对当前用户,不清理所有用户。
|
||||
|
||||
[旧提案](../../rejected/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md)中无条件删除外部数据的方案由显式选择替代。macOS 清理不属于本次实现。现有 Desktop 打包及配置档案归属决策仍然适用。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**只有 desktop 配置档案时删除整个主目录。** 配置档案是否存在不能证明 CLI 或重新安装时不需要共享数据。
|
||||
|
||||
**始终保留主目录并自动删除外部缓存。** 浏览器存储包含草稿和偏好,应由用户选择是否删除。用户也可以明确选择丢弃全部共享数据。
|
||||
|
||||
**使用 electron-builder 的 delete-app-data 开关。** 它没有父子选择、共享主目录警告和更新缓存清单;清理由交互式自定义页面控制。
|
||||
|
||||
## Consequences
|
||||
|
||||
仅清理桌面配置档案可能保留 Office 运行环境及共享数据。整个主目录选项会删除显示根目录中的所有文件,包括用户自行添加的文件。不会发现或删除其他历史主目录、任意插件输出和所选根目录之外的用户项目。
|
||||
|
||||
验收使用独立产品身份。源码回归覆盖 INI 持久化和模板适配;原生夹具覆盖保留、分项删除、目录链接目标及升级和静默行为。开发主机的 Application Control 阻止未签名的测试卸载器,因此该主机尚未验证原生执行、已安装发布产物清单和高 DPI 视觉效果。仅编译通过不等于发布验收通过。
|
||||
+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-opt-in-uninstall-data.md
|
||||
2026-09-18-windows-opt-in-uninstall-data.md: c18892dd3d470ccb684f2614f9119660ddd68423
|
||||
2026-09-18-windows-opt-in-uninstall-data.zh.md: 4185b4dd8cec0b634d8159b7c5bf27674c3820a5
|
||||
# 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: 8ceeeecf640a3dbbf236706807b3cb0d70479430
|
||||
2026-09-18-windows-uninstall-retains-harness-home.zh.md: c83a724e1609e79743aab05d2e4689b783d52dca
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# 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 is written to the uninstall log and does not stop uninstallation.
|
||||
|
||||
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 so that Uninstall in the Start menu context menu launches the uninstaller directly.
|
||||
|
||||
## 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
@@ -0,0 +1,31 @@
|
||||
# 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`,使开始菜单右键菜单中的“卸载”直接启动卸载程序。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**在自定义卸载页上按需删除,包括桌面配置档案和整个主目录。** 桌面配置档案、会话和凭据属于与 CLI 共享的主目录,为重装而保留;剩余的 Electron 数据只包含界面偏好和缓存,不值得让用户理解并做出选择,也不值得为卸载程序记录主目录路径并维护双语原生对话框。
|
||||
|
||||
**当桌面是唯一配置档案时删除整个主目录。** 配置档案的存在不能证明共享数据是否被 CLI 或将来的重装需要。
|
||||
|
||||
**沿用上游默认,保留 Electron 数据。** Chromium 缓存会增长到数百 MB,正是用户卸载后反馈的残余。
|
||||
|
||||
**使用 electron-builder 的 `deleteAppDataOnUninstall`。** 上游的 `RMDir /r` 不遵守归属规则、不支持长路径并会进入链接目录;改由原生辅助程序负责删除。
|
||||
|
||||
## 结果
|
||||
|
||||
重装恢复会话、设置和插件,但不恢复界面布局偏好。主目录下的桌面配置档案及其包和锁文件保留。其他历史主目录和用户项目不会被发现或删除。
|
||||
|
||||
验收使用带作用域包名的隔离产品身份。源码回归覆盖模板适配;原生夹具覆盖主目录保留、用户数据、作用域目录和更新器缓存的删除、junction 目标、静默与升级行为。标准卸载页面的高 DPI 视觉验收仍未完成。
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
# 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/rejected/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.md
|
||||
2026-09-08-desktop-uninstall-preserve-dsh-home.md: 3db48b639c7b4d5c7b1b33b513052d1b798ee80e
|
||||
2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md: cb46112f876e88bedc97e280c853fc5757797d55
|
||||
@@ -1,95 +0,0 @@
|
||||
# Agent Note: Uninstall Desktop while preserving the Harness home
|
||||
|
||||
Status: rejected — [Windows uses opt-in cleanup](../../implemented/feature/2026-09-18-windows-opt-in-uninstall-data.md); unconditional external-data deletion is not the chosen default, and macOS cleanup remains outside the implemented scope.
|
||||
|
||||
English | [中文](2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Users need Desktop uninstallation to remove its application files and external application state while retaining the complete Harness home. Removing an application directory does not account for browser storage, cached installers, or native updater state. Browser storage also contains unsent drafts and UI preferences, so removing it changes more than disk cache usage.
|
||||
|
||||
The [packaging configuration](../../../../apps/desktop/electron-builder.config.mjs), [desktop entry](../../../../apps/desktop/src/main.ts), and [update coordinator](../../../../apps/desktop/src/update-coordinator.ts) are the inspection inputs. Source inspection identifies cleanup candidates; installed-artifact observation must establish the complete supported inventory before implementation is accepted. No uninstall experiment has yet established an exhaustive Windows or macOS inventory.
|
||||
|
||||
## Proposal
|
||||
|
||||
Provide an uninstall operation whose successful result leaves the complete default `~/.dsh` and any configured `DSH_HOME` intact, removes Desktop-owned files outside those homes, and removes the selected application installation. Preserve plugin files, package-manager state, sessions, settings, and credentials within the retained homes. Do not launch or repair the Desktop profile during cleanup.
|
||||
|
||||
Windows Control Panel and Settings uninstallation will run this operation through NSIS. macOS will expose a localized **Uninstall and keep Harness data** action backed by a signed cleanup helper. Finder drag-to-Trash alone cannot invoke this operation; document that limitation and provide the same helper as a signed standalone uninstaller for users who already removed the application. A plain drag operation must not be advertised as complete cleanup.
|
||||
|
||||
User-created projects, separately installed CLI software, user-saved downloads and exports, arbitrary third-party plugin/tool outputs, and OS-maintained audit/security records are outside application cleanup. Do not search the disk for names containing `dsh` or `DeepSeek`. The success promise concerns application-owned state, not deletion of user work or erasure of OS history.
|
||||
|
||||
### 1. Establish ownership and actual paths
|
||||
|
||||
Use disposable Windows and macOS accounts to capture filesystem and relevant registration differences across installation, first launch, draft editing, plugin installation, update download, update installation, and uninstallation. Inspect the packaged `package.json`, macOS `Info.plist`, `app-update.yml`, and runtime `app.getPath()` values. Include failed/interrupted updates. Record discovered owners and cleanup locations beside the desktop implementation, with fixture evidence rather than a dynamically accepted list of arbitrary absolute paths.
|
||||
|
||||
| Candidate | Windows | macOS | Required treatment |
|
||||
|---|---|---|---|
|
||||
| Electron user/session data | Runtime `userData` and `sessionData`, normally under `%APPDATA%` | Runtime `userData` and `sessionData`, normally under `~/Library/Application Support` | Remove browser storage, preferences, cache, and `.updaterId`; disclose loss of unsent drafts. |
|
||||
| Installer/update cache | `%LOCALAPPDATA%/<updaterCacheDirName>` | `~/Library/Caches/<updaterCacheDirName>` | Remove cached installers, ZIPs, blockmaps, pending downloads, and metadata; include the NSIS installer copy created during installation. |
|
||||
| Native updater state | Any additional paths proved by the installed NSIS flow | App-specific Squirrel and `<appId>.ShipIt` cache/state/log directories | Stop the owned updater job and remove its remaining state after it exits. |
|
||||
| Other native application state | App-owned files/registrations proved by observation | App-specific preferences, saved state, logs, or diagnostics proved by observation | Add only established ownership; an Electron path API alone does not prove a file is created. |
|
||||
| Application and integration | Installation directory, installed shortcuts, installation/uninstallation registration | Selected `.app` and helper-owned integration | Use the native uninstall flow and validate the selected installation identity. |
|
||||
| Harness home | Default `.dsh` and configured `DSH_HOME` | Default `.dsh` and configured `DSH_HOME` | Preserve the complete trees, including Desktop-only subdirectories. |
|
||||
|
||||
Create one small application-owned cleanup inventory, consumed by packaging and cleanup implementations. Derive identity and updater cache names from the release metadata. The same inventory must explain test expectations. Verify any existing distributed release before adding a legacy path; do not introduce migrations for unpublished development layouts.
|
||||
|
||||
### 2. Implement shared preservation and cleanup rules
|
||||
|
||||
Resolve targets for the installation owner, not the elevated administrator's home. Preserve the default home even when an override is active, and protect configured homes recorded by Desktop as well as the current override. Validate any persisted record at this file boundary; it may protect a location but may never authorize deletion of an arbitrary location.
|
||||
|
||||
Reject deletion of a protected home, its ancestor, or its contents. Account for case-insensitive paths, symlinks, Windows junctions/reparse points, and path replacement during cleanup. Do not follow a link into another tree. An installation or cache location containing a protected home is a conflict: stop before recursive removal and report the remaining path. Keep these checks in the actual native removal path, not only in a preview.
|
||||
|
||||
Stop new windows, updates, and plugin mutations; finish or safely stop active operations; stop the owned Host/process tree; then wait for exit and released files before removal. Do not kill processes solely by executable name. Perform an ownership/path preflight before destructive steps. Treat missing targets as success, and support retry after partial cleanup. Report locked or inaccessible paths explicitly; a queued reboot deletion or partial result is not complete cleanup.
|
||||
|
||||
Keep `.dsh` free of cleanup journals or receipts. Any temporary helper/state must live in a private temporary directory, avoid executable-search-path lookup, and have a bounded self-cleanup path. Verify that cleanup does not recreate Electron user data after it has been removed. Cancellation before removal leaves the installation usable.
|
||||
|
||||
### 3. Windows implementation
|
||||
|
||||
Keep the assisted NSIS installer (`oneClick: false`). Extend its custom uninstall hooks and removal ordering rather than replacing the complete installer. Do not depend on `deleteAppDataOnUninstall` to cover updater caches, custom paths, or protected homes. Apply preservation checks before the default installation-directory removal as well as before extra cleanup.
|
||||
|
||||
Normal Control Panel, Settings, direct uninstaller, and silent uninstall must share the same cleanup rules and useful exit status. Automatic update/replacement must skip destructive user-state cleanup; validate the actual electron-updater flags and NSIS update condition with a real upgrade. A failed upgrade must retain browser data and `.dsh`.
|
||||
|
||||
Per-user uninstall cleans that installation owner's Desktop state. All-user uninstall must identify each affected local user profile and clean its Desktop-owned state while preserving every Harness home, under the required Windows privileges. Do not resolve all users through one `$APPDATA` expansion. If an affected profile is inaccessible or another installation still owns a shared path, report that condition instead of deleting shared state or claiming complete cleanup. Include this distinction in the uninstall UI and silent result.
|
||||
|
||||
Let NSIS remove the application, its installed shortcuts, and installation registration. Explicitly remove the application-owned cache/user-data inventory, including the cached copy of the installer. Preserve existing signing requirements for installer, uninstaller, and any added executable. Add a build-time check that custom hooks and the cleanup inventory are actually packaged.
|
||||
|
||||
### 4. macOS implementation
|
||||
|
||||
Add the localized uninstall action to the Electron-owned menu/recovery UI so it remains available when the Host cannot boot. Before proceeding, show that `.dsh` is retained and unsent drafts/UI preferences outside it are deleted. Complete cleanup must include removal of the selected application bundle; if only moving it to Trash, report that disk space remains until Trash is emptied.
|
||||
|
||||
Use a signed helper that can finish after Electron and the Host exit. Verify bundle identity and the selected path before removing it; handle `/Applications`, `~/Applications`, and a relocated writable bundle. Obtain authorization only for paths requiring it. Coordinate with Squirrel/ShipIt, remove only this application's updater jobs/state, and delete the discovered external application directories and preferences. Include helper staging files in completion verification.
|
||||
|
||||
Package the same cleanup implementation as a signed, notarized standalone uninstaller for the already-deleted-app case. It must not require the removed bundle or a working `.dsh` profile. A read-only mounted DMG is not an installed writable application; provide an actionable result rather than attempting to alter it. For a shared application installation, account for affected users and privileges as on Windows; report any state that could not be cleaned.
|
||||
|
||||
### 5. Verification and documentation
|
||||
|
||||
Add focused tests for target ownership, protected-home overlap, link/reparse-point traversal, identity mismatch, arbitrary-path rejection, custom/absent `DSH_HOME`, another installation sharing data, and inaccessible profiles. Exercise cancellation, active Host/update/plugin processes, interruption between removals, retries, and self-cleanup without using the developer's real home. Follow the [CI reliability workflow](../../../skills/dsh-ci-test-reliability/SKILL.md) when implementing process and filesystem tests.
|
||||
|
||||
Run installed-artifact e2e checks on Windows and both shipped macOS architectures. Cover first installation without updates, one downloaded/installed update, failed update, per-user/shared installation, relocated application, and standalone macOS cleanup after drag deletion. Compare filesystem/registration inventories independently of the uninstaller's success response. Hash retained `.dsh` content after the app and CLI are quiescent and before removal; require identical content, paths, and link entries afterward. Do not require preserved runtime links to resolve after their application target is removed.
|
||||
|
||||
Add owner-local expected output for uninstall UI and failure results. Add a keyless recorded-session scenario proving an existing conversation survives uninstall/reinstall; keep purely installer/UI expectations outside the top-level Session tree. Record the required real-server/model GUI GIF for the implementation PR. Update the [Desktop README](../../../../apps/desktop/README.md), its Chinese counterpart, user instructions, locale dictionaries, JSDoc, and this proposal's lifecycle when implementation ships. Select focused checks through [dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md); signed packaging and real OS uninstall evidence are required, not replaceable by unit tests.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the default uninstallers.** They do not establish removal of application-owned state outside the application directory and therefore do not meet the requested outcome.
|
||||
|
||||
**Move all Electron and update data under `.dsh`.** This can simplify file placement but preserves caches and browser state instead of cleaning them, and does not by itself cover Squirrel/native state or existing external files. It is not the cleanup solution proposed here.
|
||||
|
||||
**Detect Finder deletion with a permanent background watcher.** This adds a resident component that itself needs removal and cannot reliably cover deletion while it is stopped. Use an explicit uninstall operation.
|
||||
|
||||
**Delete `.dsh/desktop` or `.dsh/profiles/desktop`.** These paths contain Desktop-specific state, but the requested retention rule preserves the whole Harness home rather than only conversations.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- A successful supported uninstall removes the selected application, its owned registrations, and every external application-owned path established by the installed-artifact inventory; no cleanup helper or updater process remains.
|
||||
- Default and configured Harness homes retain identical content and links after quiescence. User projects and independent CLI installations remain intact.
|
||||
- Windows Control Panel/Settings and silent uninstall satisfy the same retention rule. An actual automatic upgrade retains browser state and Harness data.
|
||||
- macOS's explicit and standalone uninstallers satisfy the cleanup rule. Documentation accurately distinguishes them from Finder drag deletion and from merely moving files to Trash.
|
||||
- Every unremoved owned path is reported with a retry/recovery action. Unavailable privileges, an inaccessible user profile, and shared ownership never produce a false full-success result.
|
||||
- Fresh install and uninstall/reinstall tests prove no untracked outside-home application state survives and that retained conversations can be reopened. Native OS inventories, not only mocked API calls, establish completion.
|
||||
|
||||
## Risks
|
||||
|
||||
Deleting browser storage loses unsent drafts and UI preferences. Whole-home preservation deliberately retains Desktop plugins, pnpm state, and possibly dangling links until reinstall repairs them. Custom data paths and shared installations can prevent complete removal without additional privileges or path relocation; the operation must report that limitation rather than violate retention.
|
||||
|
||||
OS-owned security/audit history and user-created files cannot be covered by a promise of erasing all traces. The [Desktop packaging decision](../../implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) and [bundled runtime/external plugin decision](../../implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md) remain independent authorities for packaging and data ownership; this proposal supersedes neither and archives no active note.
|
||||
@@ -1,95 +0,0 @@
|
||||
# Agent Note: 卸载 Desktop 并保留 Harness 主目录
|
||||
|
||||
Status: rejected — [Windows 使用按需清理](../../implemented/feature/2026-09-18-windows-opt-in-uninstall-data.zh.md);默认不无条件删除外部数据,macOS 清理仍不在已实现范围内。
|
||||
|
||||
[English](2026-09-08-desktop-uninstall-preserve-dsh-home.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
用户需要 Desktop 卸载删除应用文件及外部应用状态,同时保留完整的 Harness 主目录。删除应用目录并不能覆盖浏览器存储、安装器缓存或原生更新器状态。浏览器存储还包含未发送草稿和界面偏好,因此清理它不只是释放磁盘缓存。
|
||||
|
||||
[打包配置](../../../../apps/desktop/electron-builder.config.mjs)、[桌面入口](../../../../apps/desktop/src/main.ts)和[更新协调器](../../../../apps/desktop/src/update-coordinator.ts)是代码检查依据。源码检查用于识别清理候选项;实现验收前,必须通过已安装产物的观察确定受支持的完整清单。目前尚未通过卸载实验建立完整的 Windows 或 macOS 清单。
|
||||
|
||||
## 提案
|
||||
|
||||
提供卸载操作:成功后保留完整的默认 `~/.dsh` 和已配置的 `DSH_HOME`,删除这些主目录之外由 Desktop 拥有的文件,并移除选定的应用安装。保留主目录内的插件文件、包管理器状态、会话、设置和凭据。清理期间不得启动或修复 Desktop profile。
|
||||
|
||||
Windows 控制面板和设置中的卸载将通过 NSIS 执行此操作。macOS 将提供本地化的**卸载并保留 Harness 数据**操作,由签名的清理辅助程序完成;单纯通过 Finder 拖进废纸篓不能调用此操作,文档必须说明这一限制,并将同一辅助程序作为签名的独立卸载器提供给已经删除应用的用户。不得把单纯拖拽宣传为完整清理。
|
||||
|
||||
用户创建的项目、单独安装的 CLI(命令行界面)软件、用户保存的下载文件和导出文件、任意第三方插件或工具的输出,以及操作系统维护的审计和安全记录,不属于应用清理范围。不得按文件名包含 `dsh` 或 `DeepSeek` 全盘搜索。成功承诺针对应用拥有的状态,不包括删除用户工作成果或抹除系统历史。
|
||||
|
||||
### 1. 确定归属和实际路径
|
||||
|
||||
使用一次性 Windows 和 macOS 账户,记录安装、首次启动、编辑草稿、安装插件、下载更新、安装更新及卸载前后的文件系统和相关注册信息差异。检查打包后的 `package.json`、macOS `Info.plist`、`app-update.yml` 和运行时 `app.getPath()` 值。覆盖更新失败和中断。将发现的归属与清理位置记录在桌面实现附近,提供 fixture(测试前置数据)证据,不采用动态接收任意绝对路径的清单。
|
||||
|
||||
| 候选项 | Windows | macOS | 处理要求 |
|
||||
|---|---|---|---|
|
||||
| Electron 用户及会话数据 | 运行时 `userData` 和 `sessionData`,通常位于 `%APPDATA%` 下 | 运行时 `userData` 和 `sessionData`,通常位于 `~/Library/Application Support` 下 | 删除浏览器存储、偏好、缓存和 `.updaterId`;说明未发送草稿会丢失。 |
|
||||
| 安装器及更新缓存 | `%LOCALAPPDATA%/<updaterCacheDirName>` | `~/Library/Caches/<updaterCacheDirName>` | 删除安装器副本、ZIP、blockmap、待处理下载和元数据;包括 NSIS 安装期间创建的安装器缓存副本。 |
|
||||
| 原生更新器状态 | 通过实际安装的 NSIS 流程证实的其他路径 | 应用专属的 Squirrel 和 `<appId>.ShipIt` 缓存、状态及日志目录 | 停止所属更新任务,等待退出后删除残留状态。 |
|
||||
| 其他原生应用状态 | 观察证实由应用拥有的文件和注册信息 | 观察证实的应用专属偏好、窗口恢复状态、日志或诊断数据 | 只加入归属已确定的项目;存在 Electron 路径 API 不足以证明文件会产生。 |
|
||||
| 应用及系统集成 | 安装目录、安装的快捷方式、安装和卸载注册信息 | 选定的 `.app` 及辅助程序拥有的系统集成 | 使用原生卸载流程,并验证选定安装的身份。 |
|
||||
| Harness 主目录 | 默认 `.dsh` 和已配置的 `DSH_HOME` | 默认 `.dsh` 和已配置的 `DSH_HOME` | 保留完整目录树,包括 Desktop 专属子目录。 |
|
||||
|
||||
建立一份精简的应用清理清单,由打包和清理实现共同使用。应用身份与更新缓存名称从发布元数据推导。同一清单必须对应测试期望。加入旧路径前先核查实际已分发版本,不为未发布的开发布局增加迁移。
|
||||
|
||||
### 2. 实现共用的保留与清理规则
|
||||
|
||||
按安装所属用户解析目标,不能使用提权管理员的主目录。即使覆盖路径已启用,也保留默认主目录;同时保护 Desktop 已记录的配置主目录和当前覆盖路径。持久化记录在文件边界进行校验;记录可以保护位置,但绝不能授权删除任意位置。
|
||||
|
||||
拒绝删除受保护主目录、其祖先目录或其内容。处理路径大小写不敏感、符号链接、Windows junction/reparse point,以及清理期间路径被替换的情况。不得沿链接进入其他目录树。如果安装或缓存目录包含受保护主目录,则视为冲突:递归删除前停止,并报告剩余路径。这些检查必须位于实际原生删除流程中,不能只放在预览阶段。
|
||||
|
||||
阻止新窗口、更新和插件变更;完成或安全停止正在进行的操作;停止所属 Host 和进程树;等待进程退出并释放文件后再删除。不得仅凭可执行文件名终止进程。破坏性步骤前先检查归属和路径。目标不存在视为成功,部分清理后允许重试。明确报告被占用或无法访问的路径;已安排重启后删除或部分完成,不等于完整清理。
|
||||
|
||||
不得在 `.dsh` 写入清理日志或回执。临时辅助程序和状态必须位于私有临时目录,避免通过可执行文件搜索路径寻找程序,并具有有界的自行清理流程。验证清理不会在删除后重新创建 Electron 用户数据。删除开始前取消操作应保持安装可用。
|
||||
|
||||
### 3. Windows 实现
|
||||
|
||||
保留向导式 NSIS 安装器(`oneClick: false`)。扩展自定义卸载钩子和删除顺序,不替换整个安装器。不得依赖 `deleteAppDataOnUninstall` 覆盖更新缓存、自定义路径或主目录保护。在默认安装目录删除和额外清理前都应用保留检查。
|
||||
|
||||
正常控制面板、设置、直接运行卸载器和静默卸载必须共用清理规则,并返回有用的退出状态。自动更新或替换安装必须跳过破坏性的用户状态清理;通过实际升级验证 electron-updater 参数和 NSIS 更新条件。升级失败必须保留浏览器数据和 `.dsh`。
|
||||
|
||||
按用户安装的卸载清理该安装所属用户的 Desktop 状态。全用户卸载必须识别每个受影响的本地用户配置目录,在具备所需 Windows 权限的情况下清理其中由 Desktop 拥有的状态,并保留每个 Harness 主目录。不能用一次 `$APPDATA` 展开代表所有用户。如果受影响的用户目录无法访问,或另一个安装仍拥有共享路径,应报告该情况,不得删除共享状态或宣称完整清理。卸载界面和静默执行结果都要体现这一区别。
|
||||
|
||||
由 NSIS 删除应用、安装的快捷方式和安装注册信息。显式删除清单中的应用缓存和用户数据,包括缓存的安装器副本。维持安装器、卸载器及新增可执行文件的签名要求。增加构建期检查,证明自定义钩子和清理清单确实进入产物。
|
||||
|
||||
### 4. macOS 实现
|
||||
|
||||
在 Electron 拥有的菜单和恢复界面加入本地化卸载操作,保证 Host 无法启动时仍可使用。执行前说明保留 `.dsh`,并删除主目录外的未发送草稿和界面偏好。完整清理必须包括移除选定应用包;如果只是移入废纸篓,应说明清空废纸篓前仍占用磁盘。
|
||||
|
||||
使用签名的辅助程序,在 Electron 和 Host 退出后完成清理。删除前验证应用包身份和选定路径;覆盖 `/Applications`、`~/Applications` 及移动后的可写应用包。仅对需要权限的路径请求授权。协调 Squirrel/ShipIt,只移除本应用的更新任务和状态,再删除已识别的外部应用目录及偏好。完成验证必须包括辅助程序的临时文件。
|
||||
|
||||
把同一清理实现打包为签名、公证的独立卸载器,支持应用已被删除的情况。它不能依赖已删除的应用包或可启动的 `.dsh` profile。只读挂载的 DMG 不属于已安装的可写应用;应给出可操作的结果,不能尝试修改它。共享应用安装和 Windows 一样需要处理受影响用户及权限,并报告未能清理的状态。
|
||||
|
||||
### 5. 验证和文档
|
||||
|
||||
增加针对性测试,覆盖目标归属、受保护主目录重叠、链接和 reparse point 遍历、身份不匹配、任意路径拒绝、自定义或未设置的 `DSH_HOME`、另一安装共享数据,以及无法访问的用户配置目录。覆盖取消、活跃 Host/更新/插件进程、删除步骤间中断、重试及自行清理,禁止使用开发者真实主目录。实现进程和文件系统测试时遵循 [CI 可靠性工作流](../../../skills/dsh-ci-test-reliability/SKILL.md)。
|
||||
|
||||
在 Windows 和两个已发布 macOS 架构上执行已安装产物的 e2e 检查。覆盖首次安装且未更新、下载并安装一次更新、更新失败、按用户或共享安装、移动应用,以及拖拽删除后的独立 macOS 清理。独立比较文件系统和注册信息清单,不以卸载器的成功响应代替验证。应用和 CLI 静止后、删除前对保留的 `.dsh` 内容计算哈希;要求删除后内容、路径和链接条目完全一致。应用目标被删除后,不要求保留的运行时链接仍可解析。
|
||||
|
||||
为卸载界面和失败结果增加归属模块旁的预期输出。增加无密钥的录制会话场景,证明已有会话能跨卸载和重装保留;纯安装器或界面预期不放入顶层 Session 树。为实现 PR(Pull Request)录制规则要求的真实服务器和模型 GUI GIF。实现交付时更新 [Desktop README](../../../../apps/desktop/README.zh.md)、英文对应文档、用户说明、语言字典、JSDoc 和本提案的生命周期。通过 [dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md) 选择针对性检查;签名打包和真实操作系统卸载证据为必需项,不能由单元测试替代。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**维持默认卸载器。** 它们不能证明应用目录之外的应用状态已被移除,因此不满足要求。
|
||||
|
||||
**把全部 Electron 和更新数据移到 `.dsh`。** 这能简化文件位置,但会保留缓存和浏览器状态而不是清理它们,而且自身不能覆盖 Squirrel/原生状态或已有的外部文件。本提案不采用它作为清理方案。
|
||||
|
||||
**通过常驻后台监视器检测 Finder 删除。** 这会增加一个本身也需要卸载的常驻组件,并且不能可靠覆盖监视器停止期间发生的删除。采用显式卸载操作。
|
||||
|
||||
**删除 `.dsh/desktop` 或 `.dsh/profiles/desktop`。** 这些路径包含 Desktop 专属状态,但要求保留的是整个 Harness 主目录,而不只是会话。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 受支持的卸载成功后,选定应用、它拥有的注册信息,以及已安装产物清单确定的每个外部应用路径均已移除;没有清理辅助程序或更新器进程残留。
|
||||
- 静止后,默认和已配置 Harness 主目录的内容及链接完全保留。用户项目和独立 CLI 安装保持完整。
|
||||
- Windows 控制面板、设置及静默卸载满足同一保留规则。实际自动升级保留浏览器状态和 Harness 数据。
|
||||
- macOS 显式操作和独立卸载器满足清理规则。文档准确区分它们与 Finder 拖拽删除,以及仅移入废纸篓的情况。
|
||||
- 每个未移除的所属路径都报告重试或恢复方式。权限不足、用户配置目录不可访问和共享归属均不得产生虚假的完整成功结果。
|
||||
- 首次安装及卸载重装测试证明,没有未纳入清单的主目录外应用状态残留,且能重新打开保留的会话。通过原生操作系统清单验证完成,不能只依赖模拟 API 调用。
|
||||
|
||||
## 风险
|
||||
|
||||
删除浏览器存储会丢失未发送草稿和界面偏好。完整保留主目录意味着有意保留 Desktop 插件、pnpm 状态,以及可能在重装修复前悬空的链接。自定义数据路径和共享安装可能需要额外权限或移动路径才能完整删除;操作必须报告限制,不能违反保留规则。
|
||||
|
||||
操作系统拥有的安全和审计历史,以及用户创建的文件,不能被“抹除所有痕迹”的承诺覆盖。[Desktop 打包决策](../../implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)和[内置运行时与外部插件决策](../../implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)继续分别拥有打包和数据归属规则;本提案不取代这两份记录,也不归档任何活跃记录。
|
||||
@@ -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: f508d875194bb59756cd3f8a6e55c6ec3478468b
|
||||
README.zh.md: 0fc85b3d6bdd3dd344d7a20cfcb257fe34addfaa
|
||||
README.md: efb980ca5ce7837bce033aea4e2c4a6ced1cd2cb
|
||||
README.zh.md: cca51447e6ea6e3987a16a62b4c25a90554bf2e2
|
||||
@@ -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 offers unchecked options for desktop browser data/update caches, the desktop plugin profile, and the complete Harness home. Selecting the complete home requires a warning confirmation and includes the desktop profile; cancelling restores its independent selection. Silent uninstalls and upgrades retain data, including when passed the legacy `--delete-app-data` flag. Packaged Desktop records its last launched home in Electron user data as `uninstall.ini`; older installations without a record offer the default home. Linked roots, protected Windows folders, and overlap with retained data stop cleanup. Locked files report failure, possibly after partial removal. The native wizard declares DPI awareness and uses Microsoft YaHei UI for Chinese. Installation records `InstallLocation` on the Windows uninstall entry so that Uninstall in the Start menu context menu launches this uninstaller instead of opening the installed-apps list. These options are 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. 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 is reported in the uninstall log and does not stop uninstallation. Installation records `InstallLocation` on the Windows uninstall entry so that Uninstall in the Start menu context menu launches this uninstaller instead of opening the installed-apps list. 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. Omit `--compile-only` to run the native retention/removal checks. Hosts with Application Control may reject the unsigned test uninstaller; 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 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.
|
||||
|
||||
### 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 卸载程序提供默认不勾选的清理项:桌面浏览器数据与更新缓存、桌面插件配置档案、整个 Harness 主目录。选择整个主目录需要确认警告,并包含桌面配置档案;取消后恢复其独立选择。静默卸载和升级保留数据,即使传入旧的 `--delete-app-data` 参数。打包后的 Desktop 在 Electron 用户数据中的 `uninstall.ini` 记录最近启动使用的主目录;没有记录的旧安装提供默认主目录。链接根目录、受保护的 Windows 目录或与保留数据重叠的路径会阻止清理。文件占用会报告失败,此时可能已删除部分内容。原生向导声明 DPI 感知,中文使用微软雅黑 UI。安装时在 Windows 卸载注册项上记录 `InstallLocation`,使开始菜单右键菜单中的“卸载”直接启动此卸载程序,而不是打开已安装应用列表。此功能仅适用于 Windows。
|
||||
Windows 卸载程序会随应用一起删除 Electron 用户数据目录(`%APPDATA%` 下按包作用域嵌套的浏览器存储与缓存)、`%APPDATA%` 下的产品目录,以及 `%LOCALAPPDATA%` 下的更新下载缓存。Harness 主目录(`~/.dsh` 或 `DSH_HOME`:会话、设置、凭据、插件)不会被触碰,重新安装后可以继续使用。静默卸载删除相同的数据;升级保留数据。删除通过原生辅助程序执行:它拒绝受保护的 Windows 目录和与安装目录重叠的路径,遇到重解析点只解除链接而不进入目标;文件被占用时会记录到卸载日志,不会中止卸载。安装时在 Windows 卸载注册项上记录 `InstallLocation`,使开始菜单右键菜单中的“卸载”直接启动此卸载程序,而不是打开已安装应用列表。卸载程序声明 DPI 感知,中文使用微软雅黑 UI。此行为仅适用于 Windows。
|
||||
|
||||
使用 `node apps/desktop/scripts/test-windows-installer.mjs --uninstall-only --compile-only` 编译独立的中英文夹具。省略 `--compile-only` 可运行原生保留与删除检查。启用 Application Control 的主机可能拒绝未签名的测试卸载器;编译通过不代表已安装卸载行为或高 DPI 视觉验收通过。
|
||||
使用 `node apps/desktop/scripts/test-windows-installer.mjs --uninstall-only --compile-only` 以带作用域的包名编译独立的中英文夹具。省略 `--compile-only` 可对预置数据运行原生的交互、静默与升级检查。编译本身不能证明已安装卸载行为或高 DPI 视觉验收。
|
||||
|
||||
### Windows EV 签名
|
||||
|
||||
|
||||
@@ -1,27 +1,7 @@
|
||||
; NSIS owns installer localization independently of the Electron application locale.
|
||||
!ifdef BUILD_UNINSTALLER
|
||||
LangString UNINSTALL_DATA_TITLE ${LANG_ENGLISH} "Choose local data to remove"
|
||||
LangString UNINSTALL_DATA_TITLE ${LANG_SIMPCHINESE} "选择要清除的本机数据"
|
||||
LangString UNINSTALL_DATA_SUBTITLE ${LANG_ENGLISH} "All data is kept by default for future installations."
|
||||
LangString UNINSTALL_DATA_SUBTITLE ${LANG_SIMPCHINESE} "默认保留所有数据,便于重新安装后继续使用。"
|
||||
LangString UNINSTALL_DATA_INTRO ${LANG_ENGLISH} "The application will be uninstalled. You can also remove the following data:"
|
||||
LangString UNINSTALL_DATA_INTRO ${LANG_SIMPCHINESE} "应用程序将被卸载。你也可以选择清除以下数据:"
|
||||
LangString UNINSTALL_LOCAL ${LANG_ENGLISH} "Remove desktop application data and caches"
|
||||
LangString UNINSTALL_LOCAL ${LANG_SIMPCHINESE} "清除桌面应用本地数据和缓存"
|
||||
LangString UNINSTALL_LOCAL_DETAIL ${LANG_ENGLISH} "Includes browser storage and downloaded updates."
|
||||
LangString UNINSTALL_LOCAL_DETAIL ${LANG_SIMPCHINESE} "包括浏览器存储和自动更新下载缓存。"
|
||||
LangString UNINSTALL_ALL ${LANG_ENGLISH} "Remove all Harness data (also affects the CLI)"
|
||||
LangString UNINSTALL_ALL ${LANG_SIMPCHINESE} "清除全部 Harness 数据(也会影响命令行版 CLI)"
|
||||
LangString UNINSTALL_DESKTOP ${LANG_ENGLISH} "Remove desktop plugins and configuration"
|
||||
LangString UNINSTALL_DESKTOP ${LANG_SIMPCHINESE} "清除桌面插件和配置"
|
||||
LangString UNINSTALL_DESKTOP_DETAIL ${LANG_ENGLISH} "Selected alone, keeps sessions and shared settings."
|
||||
LangString UNINSTALL_DESKTOP_DETAIL ${LANG_SIMPCHINESE} "单独选择时,保留会话及共享设置。"
|
||||
LangString UNINSTALL_ALL_WARNING ${LANG_ENGLISH} "Uninstalling will permanently delete all sessions, settings, API keys, plugins and runtimes, including data shared with the CLI. This cannot be undone.$\r$\n$\r$\nFolder: $UnHome"
|
||||
LangString UNINSTALL_ALL_WARNING ${LANG_SIMPCHINESE} "卸载时将永久删除所有会话、设置、API 密钥、插件及运行环境,也会影响命令行版(CLI)。此操作无法撤销。$\r$\n$\r$\n将删除的数据目录:$UnHome"
|
||||
LangString UNINSTALL_DATA_FAILED ${LANG_ENGLISH} "Could not safely remove all selected data. Some files may already have been removed. Close programs using this folder and retry, or keep the data.$\r$\n$\r$\nFolder: $UnTarget"
|
||||
LangString UNINSTALL_DATA_FAILED ${LANG_SIMPCHINESE} "无法安全清除所选数据,部分文件可能已经删除。请关闭占用此目录的程序后重试,或保留数据。$\r$\n$\r$\n目录:$UnTarget"
|
||||
LangString UNINSTALL_HOME_CONFLICT ${LANG_ENGLISH} "This folder overlaps retained Harness data, or the data location could not be verified. Uninstallation has stopped. Move your Harness data to a separate folder and launch Desktop again before retrying.$\r$\n$\r$\nFolder: $UnTarget"
|
||||
LangString UNINSTALL_HOME_CONFLICT ${LANG_SIMPCHINESE} "此目录与需要保留的 Harness 数据重叠,或无法确认数据位置,卸载已停止。请将 Harness 数据移到独立目录并重新启动 Desktop 后重试。$\r$\n$\r$\n目录:$UnTarget"
|
||||
LangString UNINSTALL_DATA_FAILED ${LANG_ENGLISH} "Could not remove all local application data. Remaining files: $UnTarget"
|
||||
LangString UNINSTALL_DATA_FAILED ${LANG_SIMPCHINESE} "未能清除全部本机应用数据,剩余文件位于:$UnTarget"
|
||||
!endif
|
||||
LangString INSTALLER_INSTALL ${LANG_ENGLISH} "Install now"
|
||||
LangString INSTALLER_INSTALL ${LANG_SIMPCHINESE} "立即安装"
|
||||
|
||||
@@ -1,112 +1,16 @@
|
||||
; User data removal is opt-in and never runs for silent or upgrade uninstalls.
|
||||
!include nsDialogs.nsh
|
||||
!include WinMessages.nsh
|
||||
|
||||
Var UnLocal
|
||||
Var UnAll
|
||||
Var UnDesktop
|
||||
Var UnPreviousDesktop
|
||||
Var UnLocalControl
|
||||
Var UnAllControl
|
||||
Var UnDesktopControl
|
||||
; Electron user data and updater downloads leave with the application; the Harness home is never touched.
|
||||
!include FileFunc.nsh
|
||||
|
||||
Function un.DataInit
|
||||
StrCpy $UnLocal 0
|
||||
StrCpy $UnAll 0
|
||||
StrCpy $UnDesktop 0
|
||||
StrCpy $UnPreviousDesktop 0
|
||||
StrCpy $UnHome "$PROFILE\.dsh"
|
||||
StrCpy $1 "$APPDATA\${PRODUCT_FILENAME}\uninstall.ini"
|
||||
!ifdef APP_PACKAGE_NAME
|
||||
${IfNot} ${FileExists} "$1"
|
||||
StrCpy $1 "$APPDATA\${APP_PACKAGE_NAME}\uninstall.ini"
|
||||
${EndIf}
|
||||
!endif
|
||||
ReadINIStr $0 "$1" "Harness" "Home"
|
||||
${If} $0 != ""
|
||||
StrCpy $UnHome $0
|
||||
ReadINIStr $2 "$1" "Harness" "HomeLength"
|
||||
StrLen $3 $UnHome
|
||||
${If} $2 != $3
|
||||
StrCpy $UnHome ""
|
||||
${EndIf}
|
||||
${ElseIf} ${FileExists} "$1"
|
||||
StrCpy $UnHome ""
|
||||
${EndIf}
|
||||
InitPluginsDir
|
||||
File "/oname=$PLUGINSDIR\window-frame.dll" "${INSTALLER_BUILD_DIR}\window-frame.dll"
|
||||
FunctionEnd
|
||||
|
||||
Function un.DataPage
|
||||
${If} ${isUpdated}
|
||||
Abort
|
||||
${EndIf}
|
||||
!insertmacro MUI_HEADER_TEXT "$(UNINSTALL_DATA_TITLE)" "$(UNINSTALL_DATA_SUBTITLE)"
|
||||
nsDialogs::Create 1018
|
||||
Pop $0
|
||||
${If} $0 == error
|
||||
Abort
|
||||
${EndIf}
|
||||
${NSD_CreateLabel} 0 0 100% 20u "$(UNINSTALL_DATA_INTRO)"
|
||||
Pop $0
|
||||
${NSD_CreateCheckbox} 0 26u 100% 14u "$(UNINSTALL_LOCAL)"
|
||||
Pop $UnLocalControl
|
||||
${NSD_SetState} $UnLocalControl $UnLocal
|
||||
${NSD_CreateLabel} 9u 39u 95% 20u "$(UNINSTALL_LOCAL_DETAIL)"
|
||||
Pop $0
|
||||
${NSD_CreateCheckbox} 0 66u 100% 14u "$(UNINSTALL_ALL)"
|
||||
Pop $UnAllControl
|
||||
${NSD_SetState} $UnAllControl $UnAll
|
||||
${NSD_OnClick} $UnAllControl un.AllDataChanged
|
||||
${NSD_CreateCheckbox} 14u 82u 95% 14u "$(UNINSTALL_DESKTOP)"
|
||||
Pop $UnDesktopControl
|
||||
${NSD_SetState} $UnDesktopControl $UnDesktop
|
||||
${If} $UnHome == ""
|
||||
EnableWindow $UnAllControl 0
|
||||
EnableWindow $UnDesktopControl 0
|
||||
${EndIf}
|
||||
${If} $UnAll == 1
|
||||
EnableWindow $UnDesktopControl 0
|
||||
${EndIf}
|
||||
${NSD_CreateLabel} 23u 95u 90% 25u "$(UNINSTALL_DESKTOP_DETAIL)"
|
||||
Pop $0
|
||||
nsDialogs::Show
|
||||
FunctionEnd
|
||||
|
||||
Function un.AllDataChanged
|
||||
Pop $0
|
||||
${NSD_GetState} $UnAllControl $UnAll
|
||||
${If} $UnAll == 1
|
||||
MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$(UNINSTALL_ALL_WARNING)" /SD IDCANCEL IDOK confirmed
|
||||
StrCpy $UnAll 0
|
||||
${NSD_SetState} $UnAllControl 0
|
||||
Return
|
||||
confirmed:
|
||||
${NSD_GetState} $UnDesktopControl $UnPreviousDesktop
|
||||
StrCpy $UnDesktop 1
|
||||
${NSD_SetState} $UnDesktopControl 1
|
||||
EnableWindow $UnDesktopControl 0
|
||||
${Else}
|
||||
StrCpy $UnDesktop $UnPreviousDesktop
|
||||
${NSD_SetState} $UnDesktopControl $UnPreviousDesktop
|
||||
EnableWindow $UnDesktopControl 1
|
||||
${EndIf}
|
||||
FunctionEnd
|
||||
|
||||
Function un.DataPageLeave
|
||||
${NSD_GetState} $UnLocalControl $UnLocal
|
||||
${NSD_GetState} $UnAllControl $UnAll
|
||||
${NSD_GetState} $UnDesktopControl $UnDesktop
|
||||
FunctionEnd
|
||||
|
||||
; The helper refuses unsafe roots and never descends into reparse points.
|
||||
; The helper refuses unsafe roots and never descends into reparse points; locked files are reported, not fatal.
|
||||
Function un.RemoveData
|
||||
System::Call '$PLUGINSDIR\window-frame.dll::UninstallRemoveData(w "$UnTarget", w "$INSTDIR") i.r0 ?c'
|
||||
${If} $0 != 0
|
||||
DetailPrint "$(UNINSTALL_DATA_FAILED)"
|
||||
MessageBox MB_OK|MB_ICONEXCLAMATION "$(UNINSTALL_DATA_FAILED)" /SD IDOK
|
||||
SetErrorLevel 2
|
||||
Abort
|
||||
${EndIf}
|
||||
FunctionEnd
|
||||
|
||||
@@ -114,43 +18,17 @@ Function un.CleanData
|
||||
${If} ${isUpdated}
|
||||
Return
|
||||
${EndIf}
|
||||
${If} $UnAll != 1
|
||||
StrCpy $UnTarget $INSTDIR
|
||||
Call un.PreserveHome
|
||||
${EndIf}
|
||||
${If} ${Silent}
|
||||
Return
|
||||
${EndIf}
|
||||
${If} $UnAll == 1
|
||||
StrCpy $UnTarget $UnHome
|
||||
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
|
||||
${ElseIf} $UnDesktop == 1
|
||||
StrCpy $UnTarget "$UnHome\profiles\desktop"
|
||||
Call un.RemoveData
|
||||
${EndIf}
|
||||
${If} $UnLocal == 1
|
||||
StrCpy $UnTarget "$APPDATA\${PRODUCT_FILENAME}"
|
||||
Call un.PreserveHome
|
||||
Call un.RemoveData
|
||||
!ifdef APP_PACKAGE_NAME
|
||||
StrCpy $UnTarget "$APPDATA\${APP_PACKAGE_NAME}"
|
||||
Call un.PreserveHome
|
||||
Call un.RemoveData
|
||||
!endif
|
||||
StrCpy $UnTarget "$LOCALAPPDATA\${DSH_UPDATER_CACHE_NAME}"
|
||||
Call un.PreserveHome
|
||||
Call un.RemoveData
|
||||
${EndIf}
|
||||
FunctionEnd
|
||||
|
||||
Function un.PreserveHome
|
||||
${If} $UnAll == 1
|
||||
Return
|
||||
${EndIf}
|
||||
System::Call '$PLUGINSDIR\window-frame.dll::UninstallPathsOverlap(w "$UnTarget", w "$UnHome") i.r0 ?c'
|
||||
${If} $0 != 0
|
||||
MessageBox MB_OK|MB_ICONEXCLAMATION "$(UNINSTALL_HOME_CONFLICT)" /SD IDOK
|
||||
SetErrorLevel 2
|
||||
Abort
|
||||
${EndIf}
|
||||
${GetParent} $UnTarget $0
|
||||
${If} $0 != $APPDATA
|
||||
RMDir $0
|
||||
${EndIf}
|
||||
!endif
|
||||
StrCpy $UnTarget "$LOCALAPPDATA\${DSH_UPDATER_CACHE_NAME}"
|
||||
Call un.RemoveData
|
||||
FunctionEnd
|
||||
@@ -21,13 +21,6 @@ extern "C" __declspec(dllexport) DWORD __cdecl UninstallRemoveData(LPCWSTR path,
|
||||
return uninstall_data::Remove(path, installation);
|
||||
}
|
||||
|
||||
// Invalid paths cannot establish separation from retained Harness data.
|
||||
extern "C" __declspec(dllexport) BOOL __cdecl UninstallPathsOverlap(LPCWSTR left, LPCWSTR right) {
|
||||
std::wstring a, b;
|
||||
return !uninstall_data::Normalize(left, a) || !uninstall_data::Normalize(right, b)
|
||||
|| uninstall_data::Contains(a, b) || uninstall_data::Contains(b, a);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
!define INSTALLER_SOURCE_DIR "${__FILEDIR__}\..\installer"
|
||||
!define /ifndef INSTALLER_BUILD_DIR "${__FILEDIR__}\..\.desktop-build\targets\win-x64\installer-ui"
|
||||
!ifdef BUILD_UNINSTALLER
|
||||
Var UnHome
|
||||
Var UnTarget
|
||||
!endif
|
||||
|
||||
@@ -86,11 +85,6 @@ ManifestDPIAware true
|
||||
Page custom InstallerWelcome InstallerWelcomeLeave
|
||||
!macroend
|
||||
|
||||
!macro customUnWelcomePage
|
||||
!insertmacro MUI_UNPAGE_WELCOME
|
||||
UninstPage custom un.DataPage un.DataPageLeave
|
||||
!macroend
|
||||
|
||||
!macro customUnInit
|
||||
Call un.DataInit
|
||||
!macroend
|
||||
|
||||
@@ -20,9 +20,10 @@ $compileLines = @('@echo off', ('call "{0}" >nul' -f $vcvars), 'if errorlevel 1
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Native installer helper compilation failed.' }
|
||||
if ($TestProgress -or $CompileProgressOnly) {
|
||||
$cleanupSource = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../tests/windows-uninstall-data.cpp'))
|
||||
$cleanupExecutable = Join-Path $output 'uninstall-data-test.exe'
|
||||
$cleanupScript = Join-Path $output 'compile-uninstall-data-test.cmd'
|
||||
[IO.File]::WriteAllLines($cleanupScript, @('@echo off', ('call "{0}" >nul' -f $vcvars), 'if errorlevel 1 exit /b %errorlevel%', ('cl /nologo /MT /W4 /WX /EHsc "{0}" /Fo"{1}" /Fe"{2}" /link shell32.lib ole32.lib uuid.lib advapi32.lib' -f $cleanupSource, (Join-Path $output 'uninstall-data-test.obj'), $cleanupExecutable)), [Text.Encoding]::Default)
|
||||
# UAC installer detection demands elevation from unmanifested executables named after installation.
|
||||
$cleanupExecutable = Join-Path $output 'data-cleanup-test.exe'
|
||||
$cleanupScript = Join-Path $output 'compile-data-cleanup-test.cmd'
|
||||
[IO.File]::WriteAllLines($cleanupScript, @('@echo off', ('call "{0}" >nul' -f $vcvars), 'if errorlevel 1 exit /b %errorlevel%', ('cl /nologo /MT /W4 /WX /EHsc "{0}" /Fo"{1}" /Fe"{2}" /link shell32.lib ole32.lib uuid.lib advapi32.lib' -f $cleanupSource, (Join-Path $output 'data-cleanup-test.obj'), $cleanupExecutable)), [Text.Encoding]::Default)
|
||||
& $env:ComSpec /d /c $cleanupScript
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Uninstall data test compilation failed.' }
|
||||
if ($TestProgress) {
|
||||
|
||||
@@ -21,6 +21,8 @@ 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}`
|
||||
const outputRoot = join(appRoot, '.desktop-build', 'installer-tests')
|
||||
await mkdir(outputRoot, { recursive: true })
|
||||
const output = await mkdtemp(join(outputRoot, 'run-'))
|
||||
@@ -98,7 +100,7 @@ SectionEnd
|
||||
const include = join(languageOutput, 'include.nsh')
|
||||
await writeFile(include, `!define INSTALLER_BUILD_DIR "${join(output, 'ui')}"\n!define INSTALLER_STRINGS_FILE "${strings}"\n!include "${join(appRoot, 'scripts', 'installer.nsh')}"\n`)
|
||||
await build({ projectDir: appRoot, prepackaged: payload, targets: Platform.WINDOWS.createTarget(['nsis'], Arch.x64), publish: 'never',
|
||||
config: { ...config, productName, extraMetadata: { ...config.extraMetadata, name: `harness-installer-test-${id}` },
|
||||
config: { ...config, productName, extraMetadata: { ...config.extraMetadata, name: packageName },
|
||||
artifactName: 'installer-test.exe', directories: { output: languageOutput },
|
||||
nsis: { ...config.nsis, guid, include, installerLanguages: [language] }, beforeBuild: undefined, afterPack: undefined, afterSign: undefined, artifactBuildCompleted: undefined },
|
||||
})
|
||||
@@ -107,7 +109,7 @@ SectionEnd
|
||||
join(appRoot, 'tests', process.argv.includes('--uninstall-only') ? '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', `harness-installer-test-${id}`] : []], childOptions)
|
||||
...process.argv.includes('--uninstall-only') ? ['-Language', languageId, '-PackageName', packageName] : []], childOptions)
|
||||
process.stdout.write(`${language}\n${result.stdout}`)
|
||||
}
|
||||
succeeded = true
|
||||
|
||||
@@ -15,9 +15,9 @@ export function directoryInstallSection(source: string): string
|
||||
export function directoryInstallerExits(source: string): string
|
||||
|
||||
/**
|
||||
* Keep user-data removal exclusive to the interactive custom page.
|
||||
* 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 implicit data removal.
|
||||
* @returns Long-path uninstaller without the upstream RMDir data removal.
|
||||
*/
|
||||
export function directoryUninstaller(source: string): string
|
||||
|
||||
|
||||
@@ -85,9 +85,9 @@ export function installWindowsDirectoryInstaller() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep data removal in the opt-in custom UI, including for legacy command-line flags.
|
||||
* Keep data removal in the native helper, which refuses unsafe roots and never follows links; the legacy flag is ignored.
|
||||
* @param {string} source - Pinned upstream uninstaller source.
|
||||
* @returns Uninstaller with long-path application removal and no implicit data removal.
|
||||
* @returns Uninstaller with long-path application removal and no upstream RMDir data removal.
|
||||
*/
|
||||
export function directoryUninstaller(source) {
|
||||
const start = source.indexOf(' Var /GLOBAL isDeleteAppData\n')
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
type MenuItemConstructorOptions,
|
||||
} from 'electron'
|
||||
import { resolveDesktopPaths } from './paths.ts'
|
||||
import { recordUninstallLocation } from './uninstall-location.ts'
|
||||
import { DesktopProjectManager } from './project-manager.ts'
|
||||
import { DesktopHostProcess, DesktopHostUncleanExitError } from './host-process.ts'
|
||||
import { installDesktopDirectoryPicker } from './directory-picker.ts'
|
||||
@@ -235,7 +234,6 @@ async function main(): Promise<void> {
|
||||
const resources = runtimeResources()
|
||||
const paths = resolveDesktopPaths()
|
||||
const development = !app.isPackaged
|
||||
if (process.platform === 'win32' && !development) await recordUninstallLocation(app.getPath('userData'), paths.profile)
|
||||
const activeProject = paths.profile
|
||||
const manager = new DesktopProjectManager(paths, resources)
|
||||
let quitting = false
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
/** Records the active Harness home for the Windows uninstaller without loading plugins. */
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
/**
|
||||
* Persist the home used by this Desktop launch in the existing Electron user-data directory.
|
||||
* @param userData - Electron's application data directory.
|
||||
* @param profile - Absolute reserved desktop profile directory.
|
||||
* @returns Completion of the UTF-16 INI write consumed by NSIS.
|
||||
*/
|
||||
export async function recordUninstallLocation(userData: string, profile: string): Promise<void> {
|
||||
const home = dirname(dirname(profile))
|
||||
if (/[\r\n\0]/.test(home)) throw new Error('Desktop uninstall location contains an unsupported character')
|
||||
await writeFile(join(userData, 'uninstall.ini'), `\uFEFF[Harness]\r\nHome=${home}\r\nHomeLength=${home.length}\r\n`, 'utf16le')
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
{
|
||||
"defaultCleanupState": 0,
|
||||
"cases": ["preserve", "desktop", "all", "local", "silent", "upgrade"]
|
||||
"cases": ["interactive", "silent", "upgrade"]
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
/** The uninstaller reads the last launched home even when its environment differs. */
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
import { recordUninstallLocation } from '../src/uninstall-location.ts'
|
||||
|
||||
it('records Unicode custom homes as a Windows-readable INI and replaces a previous launch', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'desktop-uninstall-'))
|
||||
try {
|
||||
await recordUninstallLocation(root, join(root, '旧目录', 'profiles', 'desktop'))
|
||||
const home = join(root, '新目录')
|
||||
await recordUninstallLocation(root, join(home, 'profiles', 'desktop'))
|
||||
expect(await readFile(join(root, 'uninstall.ini'), 'utf16le')).toBe(`\uFEFF[Harness]\r\nHome=${home}\r\nHomeLength=${home.length}\r\n`)
|
||||
await expect(recordUninstallLocation(root, join(root, 'bad\nHome=other', 'profiles', 'desktop'))).rejects.toThrow('unsupported character')
|
||||
expect(await readFile(join(root, 'uninstall.ini'), 'utf16le')).toContain(home)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -18,15 +18,15 @@ 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 home = root + L"\\home";
|
||||
const std::wstring data = root + L"\\data";
|
||||
assert(CreateDirectoryW(root.c_str(), nullptr));
|
||||
assert(CreateDirectoryW(installation.c_str(), nullptr));
|
||||
assert(CreateDirectoryW(home.c_str(), nullptr));
|
||||
assert(CreateDirectoryW((home + L"\\profiles").c_str(), nullptr));
|
||||
const std::wstring desktop = home + L"\\profiles\\desktop";
|
||||
assert(CreateDirectoryW(desktop.c_str(), nullptr));
|
||||
File(home + L"\\session.jsonl");
|
||||
File(desktop + L"\\plugin.json");
|
||||
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);
|
||||
@@ -35,16 +35,16 @@ int wmain(int argc, wchar_t** argv) {
|
||||
assert(SUCCEEDED(SHGetKnownFolderPath(FOLDERID_Profile, 0, nullptr, &profile)));
|
||||
assert(uninstall_data::Remove(profile, installation.c_str()) == ERROR_ACCESS_DENIED);
|
||||
CoTaskMemFree(profile);
|
||||
HANDLE held = CreateFileW((desktop + L"\\plugin.json").c_str(), GENERIC_READ, FILE_SHARE_READ,
|
||||
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(desktop.c_str(), installation.c_str()) == ERROR_SHARING_VIOLATION);
|
||||
assert(uninstall_data::Remove(cache.c_str(), installation.c_str()) == ERROR_SHARING_VIOLATION);
|
||||
CloseHandle(held);
|
||||
assert(uninstall_data::Remove(desktop.c_str(), installation.c_str()) == ERROR_SUCCESS);
|
||||
assert(GetFileAttributesW((home + L"\\session.jsonl").c_str()) != INVALID_FILE_ATTRIBUTES);
|
||||
assert(uninstall_data::Remove(desktop.c_str(), installation.c_str()) == ERROR_SUCCESS);
|
||||
assert(uninstall_data::Remove(home.c_str(), installation.c_str()) == ERROR_SUCCESS);
|
||||
assert(GetFileAttributesW(home.c_str()) == INVALID_FILE_ATTRIBUTES);
|
||||
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(RemoveDirectoryW(installation.c_str()));
|
||||
assert(RemoveDirectoryW(root.c_str()));
|
||||
}
|
||||
@@ -7,17 +7,17 @@ $ErrorActionPreference = 'Stop'
|
||||
$OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory)
|
||||
. (Join-Path $PSScriptRoot 'windows-installer-ui.ps1')
|
||||
[InstallerCapture]::Initialize()
|
||||
$copy = @{}
|
||||
$expected = Get-Content (Join-Path $PSScriptRoot 'expected/windows-uninstall.json') -Raw | ConvertFrom-Json
|
||||
Get-Content (Join-Path $PSScriptRoot '../installer/strings.nsh') -Encoding UTF8 | ForEach-Object {
|
||||
if ($_ -match ('^LangString (UNINSTALL_\w+) \$\{LANG_' + $Language + '\} "(.*)"$')) { $copy[$Matches[1]] = $Matches[2] }
|
||||
}
|
||||
$roaming = [Environment]::GetFolderPath('ApplicationData')
|
||||
$local = [Environment]::GetFolderPath('LocalApplicationData')
|
||||
$installPath = Join-Path $OutputDirectory 'Uninstall App'
|
||||
$homePath = Join-Path $OutputDirectory 'Harness Home'
|
||||
$desktopPath = Join-Path $homePath 'profiles/desktop'
|
||||
$externalPath = Join-Path $OutputDirectory 'External Project'
|
||||
$localPath = Join-Path ([Environment]::GetFolderPath('ApplicationData')) $ProductName
|
||||
$cachePath = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) ($PackageName + '-updater')
|
||||
$productDataPath = Join-Path $roaming $ProductName
|
||||
# Electron nests user data under the package scope; electron-builder strips the separator from the updater cache name.
|
||||
$scopePath = Join-Path $roaming $PackageName.Split('/')[0]
|
||||
$userDataPath = Join-Path $roaming ($PackageName -replace '/', '\')
|
||||
$cachePath = Join-Path $local (($PackageName -replace '[/\\?<>:*|"]', '').ToLowerInvariant() + '-updater')
|
||||
$uninstaller = Join-Path $OutputDirectory 'uninstall-copy.exe'
|
||||
$process = $null
|
||||
function Wait-Text([string]$Text) {
|
||||
@@ -30,14 +30,6 @@ function Wait-Text([string]$Text) {
|
||||
} while ($timer.Elapsed.TotalSeconds -lt 20)
|
||||
throw "Missing '$Text': $([InstallerCapture]::VisibleText($process.Id))"
|
||||
}
|
||||
function State([IntPtr]$Control) { return [InstallerCapture]::SendMessage($Control, 0xF0, [IntPtr]::Zero, [IntPtr]::Zero).ToInt32() }
|
||||
function Wait-State([IntPtr]$Control, [int]$Expected) {
|
||||
$timer = [Diagnostics.Stopwatch]::StartNew()
|
||||
while ((State $Control) -ne $Expected) {
|
||||
if ($timer.Elapsed.TotalSeconds -gt 10) { throw "Checkbox state did not become $Expected" }
|
||||
Start-Sleep -Milliseconds 25
|
||||
}
|
||||
}
|
||||
function Wait-Exit {
|
||||
if (-not $process.WaitForExit(30000)) { $process.Kill(); $process.WaitForExit(); throw 'Uninstaller timed out' }
|
||||
if ($process.ExitCode -ne 0) { throw "Uninstall failed: $($process.ExitCode)" }
|
||||
@@ -51,72 +43,47 @@ 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() }
|
||||
foreach ($path in @($desktopPath, $localPath, $cachePath, $externalPath)) { New-Item -ItemType Directory -Force -Path $path | Out-Null }
|
||||
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 $desktopPath 'linked-project'))) {
|
||||
New-Item -ItemType Junction -Path (Join-Path $desktopPath 'linked-project') -Target $externalPath | Out-Null
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $userDataPath 'linked-project'))) {
|
||||
New-Item -ItemType Junction -Path (Join-Path $userDataPath 'linked-project') -Target $externalPath | Out-Null
|
||||
}
|
||||
[IO.File]::WriteAllText((Join-Path $homePath 'session.txt'), 'keep session')
|
||||
[IO.File]::WriteAllText((Join-Path $desktopPath 'plugin.txt'), 'desktop plugin')
|
||||
[IO.File]::WriteAllText((Join-Path $localPath 'draft.txt'), 'unsent draft')
|
||||
[IO.File]::WriteAllText((Join-Path $productDataPath 'state.txt'), 'product data')
|
||||
[IO.File]::WriteAllText((Join-Path $userDataPath 'Local State'), 'chromium profile')
|
||||
[IO.File]::WriteAllText((Join-Path $cachePath 'download.txt'), 'update')
|
||||
[IO.File]::WriteAllText((Join-Path $localPath 'uninstall.ini'), "[Harness]`r`nHome=$homePath`r`nHomeLength=$($homePath.Length)`r`n", [Text.Encoding]::Unicode)
|
||||
Copy-Item -LiteralPath (Join-Path $installPath ('Uninstall ' + $ProductName + '.exe')) -Destination $uninstaller -Force
|
||||
$arguments = '_?=' + $installPath
|
||||
if ($mode -eq 'silent') { $arguments = '/S --delete-app-data ' + $arguments }
|
||||
if ($mode -eq 'silent') { $arguments = '/S ' + $arguments }
|
||||
if ($mode -eq 'upgrade') { $arguments = '/S --updated ' + $arguments }
|
||||
$process = Start-Process -FilePath $uninstaller -ArgumentList $arguments -PassThru -WindowStyle Hidden
|
||||
if ($mode -notin @('silent', 'upgrade')) {
|
||||
$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) }
|
||||
$welcome = Wait-Text $welcomeText
|
||||
$window = [InstallerCapture]::TopLevel($welcome)
|
||||
[void][InstallerCapture]::SaveNative($window, (Join-Path $OutputDirectory 'uninstall-welcome.png'), $false)
|
||||
[InstallerCapture]::Click([InstallerCapture]::GetDlgItem($window, 1))
|
||||
$all = Wait-Text $copy.UNINSTALL_ALL
|
||||
$desktop = Wait-Text $copy.UNINSTALL_DESKTOP
|
||||
$local = Wait-Text $copy.UNINSTALL_LOCAL
|
||||
if ((State $all) -ne $expected.defaultCleanupState -or (State $desktop) -ne $expected.defaultCleanupState -or (State $local) -ne $expected.defaultCleanupState) { throw 'Data removal defaults differ from the expected output' }
|
||||
[void][InstallerCapture]::SaveNative($window, (Join-Path $OutputDirectory 'uninstall-options.png'), $false)
|
||||
if ($mode -eq 'desktop' -or $mode -eq 'all') {
|
||||
[InstallerCapture]::Click($desktop)
|
||||
Wait-State $desktop 1
|
||||
[InstallerCapture]::Click($all)
|
||||
$warning = Wait-Text ($copy.UNINSTALL_ALL_WARNING.Split('$')[0])
|
||||
$dialog = [InstallerCapture]::TopLevel($warning)
|
||||
[void][InstallerCapture]::SaveNative($dialog, (Join-Path $OutputDirectory 'uninstall-warning.png'), $false)
|
||||
# Rejecting all-data cleanup must preserve the independently selected child.
|
||||
[InstallerCapture]::Click([InstallerCapture]::GetDlgItem($dialog, 2))
|
||||
Wait-State $all 0
|
||||
if ((State $desktop) -ne 1) { throw 'Cancel changed the independent desktop selection' }
|
||||
if ($mode -eq 'all') {
|
||||
[InstallerCapture]::Click($all)
|
||||
$warning = Wait-Text ($copy.UNINSTALL_ALL_WARNING.Split('$')[0])
|
||||
$dialog = [InstallerCapture]::TopLevel($warning)
|
||||
[InstallerCapture]::Click([InstallerCapture]::GetDlgItem($dialog, 1))
|
||||
Wait-State $desktop 1
|
||||
}
|
||||
}
|
||||
if ($mode -eq 'local') { [InstallerCapture]::Click($local); Wait-State $local 1 }
|
||||
[InstallerCapture]::Click([InstallerCapture]::GetDlgItem($window, 1))
|
||||
$finishedText = if ($Language -eq 'ENGLISH') { 'has been uninstalled' } else { -join [char[]]@(0x5DF2, 0x7ECF, 0x4ECE) }
|
||||
$finishedText = if ($Language -eq 'ENGLISH') { 'has been uninstalled' } else { -join [char[]]@(0x5DF2, 0x4ECE, 0x4F60) }
|
||||
[void](Wait-Text $finishedText)
|
||||
[InstallerCapture]::Click([InstallerCapture]::GetDlgItem($window, 1))
|
||||
}
|
||||
Wait-Exit
|
||||
$retained = $mode -eq 'upgrade'
|
||||
if (Test-Path -LiteralPath $installPath) { throw 'Application directory remained' }
|
||||
if ([IO.File]::ReadAllText((Join-Path $externalPath 'keep.txt')) -ne 'outside data root') { throw 'Cleanup followed a junction into a project' }
|
||||
if ((Test-Path (Join-Path $homePath 'session.txt')) -ne ($mode -ne 'all')) { throw "Session retention failed: $mode" }
|
||||
if ((Test-Path (Join-Path $desktopPath 'plugin.txt')) -ne ($mode -notin @('all', 'desktop'))) { throw "Profile retention failed: $mode" }
|
||||
if ((Test-Path (Join-Path $localPath 'draft.txt')) -ne ($mode -ne 'local')) { throw "Local data retention failed: $mode" }
|
||||
if ((Test-Path (Join-Path $cachePath 'download.txt')) -ne ($mode -ne 'local')) { throw "Update cache retention failed: $mode" }
|
||||
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 $cachePath 'download.txt')) -ne $retained) { throw "Update cache retention failed: $mode" }
|
||||
Write-Output "passed: $mode"
|
||||
}
|
||||
} finally {
|
||||
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 @(@($localPath, [Environment]::GetFolderPath('ApplicationData')), @($cachePath, [Environment]::GetFolderPath('LocalApplicationData')))) {
|
||||
foreach ($pair in @(@($productDataPath, $roaming), @($scopePath, $roaming), @($cachePath, $local))) {
|
||||
$target = [IO.Path]::GetFullPath($pair[0])
|
||||
if ([IO.Path]::GetDirectoryName($target) -ne $pair[1] -or -not ([IO.Path]::GetFileName($target).StartsWith('Harness Installer Test ') -or [IO.Path]::GetFileName($target).StartsWith('harness-installer-test-'))) { throw 'Unexpected fixture cleanup path' }
|
||||
if ([IO.Path]::GetDirectoryName($target) -ne $pair[1] -or -not ([IO.Path]::GetFileName($target).StartsWith('Harness Installer Test ') -or [IO.Path]::GetFileName($target).StartsWith('@harness-installer-test'))) { throw 'Unexpected fixture cleanup path' }
|
||||
if (Test-Path -LiteralPath $target) { Remove-Item -LiteralPath $target -Recurse -Force }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user