Ver Fonte

fix(desktop): track extraction in directory installer

winewill há 1 semana atrás
pai
commit
4fa238bb5f

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

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

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

@@ -14,7 +14,7 @@ The installer adds custom welcome, progress, and completion pages through electr
 
 NSIS native controls preserve directory editing, folder selection, checkbox state, and keyboard interaction. An x86 Win32/GDI+ helper retains DWM shadows and draws installation progress on the UI thread while the stock installation worker runs. Stock page visibility is suppressed even when NSIS shows the page after MUI's callback. Windows 11 supplies the outer corner radius; Windows 10 retains its supported frame appearance.
 
-Installation is per-user. Welcome-page leave validation reads the current edit control for mouse and keyboard navigation; the debounced inline hint is not an installation authority. Running-process checks match the affected executable path, leaving other installations independent. Completion-page leave honors the launch checkbox for both mouse and keyboard navigation. Electron-builder resolves the registered directory before custom initialization, so silent updates without `/D=` retain that directory. The installation engine does not add transactional rollback or take ownership of first-launch profile preparation.
+Installation is per-user. Welcome-page leave validation reads the current edit control for mouse and keyboard navigation; the debounced inline hint is not an installation authority. Running-process checks match the affected executable path, leaving other installations independent. Completion-page leave honors the launch checkbox for both mouse and keyboard navigation. Electron-builder resolves the registered directory before custom initialization, so silent updates without `/D=` retain that directory. Directory staging and promotion retain the release installer’s rollback behavior; first-launch profile preparation remains outside the installer.
 
 ## Alternatives considered
 
@@ -26,6 +26,6 @@ Installation is per-user. Welcome-page leave validation reads the current edit c
 
 ## Consequences
 
-Windows packaging additionally requires the x86 Visual C++ compiler and Windows SDK. The helper is signed by the same signer as other Windows artifacts. The preparation hook returns true on every platform: electron-builder treats a falsy return as external dependency ownership and omits its production node_modules collection. The pinned app-builder-lib patch adds optional extraction/copy notifications and a copy hook on the stock worker. Stage callbacks preserve NSIS registers, stack, and error flags. The helper reads the percentage emitted by Nsis7z into a private hidden detail label, avoiding the shared NSIS instruction counter and the plugin callback’s truncated 32-bit byte counts. Windows IFileOperation owns recursive copying and reports completed work through its progress sink; copy failures return to the builder’s retry loop. The helper queues every top-level payload item, including hidden files, and checks both the operation result and the aborted flag. It does not implement recursive file copying. Preparation, registration, and cleanup retain bounded estimates. Stage weights express completed work, not remaining time. Displayed progress never regresses when a source resets or revises its total, and captions follow the displayed stage. NSIS success authorizes a 600 ms fill animation and a brief 100% frame, with a 750 ms transition target; timers cannot authorize success. The frame stays hidden until branded controls are ready and is initialized once; page transitions preserve its position. Finish hides the window before creating the installed process directly under the current user. An explicitly elevated installer retains shell-mediated launch. Launch failure restores the finish page. Application startup time is independent of installer dismissal.
+Windows packaging additionally requires the x86 Visual C++ compiler and Windows SDK. The helper is signed before embedding by the same signer as other Windows artifacts. The preparation hook returns true on every platform so electron-builder collects production dependencies. The directory installer owns staging, promotion, registration, and recovery. Its extraction hook invokes the pinned 7-Zip executable with a dedicated progress pipe and a separate diagnostic file. The child inherits only its standard streams and joins a kill-on-close job at creation, so installer termination also stops extraction. Only a zero exit code permits promotion. The helper parses percentages across pipe-read boundaries; it does not implement archive extraction or recursive copying. Preparation, promotion, registration, and cleanup retain bounded estimates. Stage weights express completed work, not remaining time. Displayed progress never regresses, and captions follow the displayed stage. NSIS success authorizes a 600 ms fill animation and a brief 100% frame, with a 750 ms transition target; timers cannot authorize success. The frame stays hidden until branded controls are ready and is initialized once; page transitions preserve its position. Finish hides the window before creating the installed process directly under the current user. An explicitly elevated installer retains shell-mediated launch. Launch failure restores the finish page. Application startup time is independent of installer dismissal.
 
-The native installer regression uses a unique product identity and private installation directory to verify path rejection, folder selection, launch choices, upgrade, running-process preservation, hidden stock progress, first-show readiness, and uninstall. Deterministic native progress tests cover fast and slow copy, stalls, revised totals, short cleanup, and success between UI ticks. Native file tests cover recursive copying, Unicode paths, hidden files, replacement, and locked-file failure followed by retry. Its screenshots and expected behavior belong to Desktop tests rather than recorded Session snapshots. Signed release qualification still requires the configured certificate and token, and actual Windows update artifacts.
+The native installer regression uses a unique product identity and private installation directory to verify path rejection, folder selection, launch choices, upgrade, running-process preservation, hidden stock progress, first-show readiness, and uninstall. Deterministic native progress tests cover fast and slow extraction, stalls, source resets, fragmented progress tokens, short cleanup, and success between UI ticks. Directory tests exercise extraction through the helper, long paths, new installation, replacement, locked-file recovery, missing staged directories, broken archives, and cancellation. Screenshots and expected behavior belong to Desktop tests rather than recorded Session snapshots. Signed release qualification still requires the configured certificate and token, and actual Windows update artifacts.

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

@@ -14,7 +14,7 @@ Windows 安装界面需要符合品牌设计的亮暗页面,同时避免引入
 
 NSIS 原生控件保留目录编辑、文件夹选择、复选状态和键盘交互。x86 Win32/GDI+ 辅助库保留 DWM 阴影,并在原生安装工作线程运行期间,由界面线程绘制安装进度。即使 NSIS 在 MUI 回调之后重新显示页面,原生页面仍保持隐藏。Windows 11 提供外框圆角半径;Windows 10 保留其支持的窗口外观。
 
-安装面向当前用户。欢迎页离开校验在鼠标和键盘导航时均读取当前编辑框;防抖显示的行内提示不决定实际安装路径。进程检查匹配受影响的可执行文件路径,使其他安装保持独立。完成页离开回调在鼠标和键盘导航时均遵循启动复选框。electron-builder 在自定义初始化之前解析已登记目录,因此不带 `/D=` 的静默更新会保留该目录。安装引擎不新增事务回滚,也不接管首次启动的配置档案准备
+安装面向当前用户。欢迎页离开校验在鼠标和键盘导航时均读取当前编辑框;防抖显示的行内提示不决定实际安装路径。进程检查匹配受影响的可执行文件路径,使其他安装保持独立。完成页离开回调在鼠标和键盘导航时均遵循启动复选框。electron-builder 在自定义初始化之前解析已登记目录,因此不带 `/D=` 的静默更新会保留该目录。目录暂存和替换保留发布安装器的回滚行为;首次启动的配置档案准备仍不属于安装程序
 
 ## Alternatives considered
 
@@ -26,6 +26,6 @@ NSIS 原生控件保留目录编辑、文件夹选择、复选状态和键盘交
 
 ## Consequences
 
-Windows 打包额外要求 x86 Visual C++ 编译器和 Windows SDK。辅助库使用与其他 Windows 产物相同的签名器。准备钩子在所有平台返回 true:electron-builder 会将假值视为依赖由外部管理,从而跳过生产 node_modules 的收集。锁定版本的 app-builder-lib 补丁在原生工作线程上增加可选的解压和复制通知,以及复制钩子。阶段回调保留 NSIS 寄存器、栈和错误标志。辅助库读取 Nsis7z 输出到私有隐藏详情标签的百分比,避开 NSIS 共用的指令计数器,以及插件回调中被截断为 32 位的字节数。Windows IFileOperation 负责递归复制,并通过进度接收器报告已完成工作量;复制失败会返回构建器的重试循环。辅助库将所有顶层载荷条目加入队列,包括隐藏文件,并检查操作结果和中止标志,不自行实现递归文件复制。准备、注册和清理仍使用有界估算。阶段权重表示已完成工作量,而非剩余时间。进度来源重置或修订总量时,显示进度不回退,文案跟随显示阶段。NSIS 成功信号允许执行 600 毫秒补满动画并短暂显示 100%,切换目标时长为 750 毫秒;计时器不能宣告成功。窗口框架在品牌控件准备完成前保持隐藏,且只初始化一次,页面切换保留其位置。点击完成后先隐藏窗口,再以当前用户直接创建已安装应用的进程;显式提权的安装程序保留通过用户桌面启动的方式。启动失败会恢复完成页。应用启动耗时与安装窗口关闭分别处理。
+Windows 打包额外要求 x86 Visual C++ 编译器和 Windows SDK。辅助库在嵌入前使用与其他 Windows 产物相同的签名器签名。准备钩子在所有平台返回 true,使 electron-builder 收集生产依赖。目录安装器负责暂存、替换、注册和恢复。其解压钩子通过独立进度管道和单独的诊断文件调用锁定版本的 7-Zip 可执行文件。子进程仅继承标准流句柄,并在创建时加入关闭即终止的 Job,因此终止安装程序也会停止解压。只有退出码为零才允许替换目录。辅助库跨管道读取边界解析百分比,不自行实现压缩包解压或递归复制。准备、替换、注册和清理仍使用有界估算。阶段权重表示已完成工作量,而非剩余时间。显示进度不回退,文案跟随显示阶段。NSIS 成功信号允许执行 600 毫秒补满动画并短暂显示 100%,切换目标时长为 750 毫秒;计时器不能宣告成功。窗口框架在品牌控件准备完成前保持隐藏,且只初始化一次,页面切换保留其位置。点击完成后先隐藏窗口,再以当前用户直接创建已安装应用的进程;显式提权的安装程序保留通过用户桌面启动的方式。启动失败会恢复完成页。应用启动耗时与安装窗口关闭分别处理。
 
-原生安装回归使用独立产品身份和私有安装目录,验证路径拒绝、文件夹选择、启动选项、升级、运行中进程保留、原生进度条隐藏、首次显示时就绪和卸载。确定性的原生进度测试覆盖快速和慢速复制、停滞、总量修订、短暂清理,以及两次界面刷新之间成功的情况。原生文件测试覆盖递归复制、Unicode 路径、隐藏文件、覆盖,以及文件占用失败后重试。截图和预期行为归属 Desktop 测试,不放入录制 Session 快照。签名发布仍需使用已配置的证书、Token 和真实 Windows 更新产物进行验证。
+原生安装回归使用独立产品身份和私有安装目录,验证路径拒绝、文件夹选择、启动选项、升级、运行中进程保留、原生进度条隐藏、首次显示时就绪和卸载。确定性的原生进度测试覆盖快速和慢速解压、停滞、进度来源重置、分片进度文本、短暂清理,以及两次界面刷新之间成功的情况。目录测试通过辅助库执行解压,覆盖长路径、新装、替换、文件占用恢复、暂存目录缺失、损坏压缩包和取消。截图和预期行为归属 Desktop 测试,不放入录制 Session 快照。签名发布仍需使用已配置的证书、Token 和真实 Windows 更新产物进行验证。

+ 0 - 1
THIRD_PARTY_NOTICES.md

@@ -116,7 +116,6 @@ pnpm applies local patches to the following packages at install time, so shipped
 
 - `@electron/osx-sign@1.3.3` — [`patches/@electron__osx-sign@1.3.3.patch`](patches/@electron__osx-sign@1.3.3.patch)
 - `@yao-pkg/pkg@6.21.0` — [`patches/@yao-pkg__pkg@6.21.0.patch`](patches/@yao-pkg__pkg@6.21.0.patch)
-- `app-builder-lib@26.15.3` — [`patches/app-builder-lib@26.15.3.patch`](patches/app-builder-lib@26.15.3.patch)
 - `node-pty@1.2.0-beta.15` — [`patches/node-pty@1.2.0-beta.15.patch`](patches/node-pty@1.2.0-beta.15.patch)
 
 ## Official Claude Code platform payloads

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write apps/desktop/README.md
-README.md: 20505dc8be4d6f2767f9d6917e8c83e130d10991
-README.zh.md: c0978a6539dd1f96885413facdc03739aebf3e09
+README.md: f58fe9d3597b62fac34417a743208729e3ca1130
+README.zh.md: 34bf101f93bce1ea8020e318cb683083eea6b8a2

+ 2 - 2
apps/desktop/README.md

@@ -161,11 +161,11 @@ The command requires `DSH_DESKTOP_APP_ID` and the normal build dependencies, inc
 
 The Windows installer uses native NSIS pages with light and dark palettes, system shadows, an editable installation directory, and a finish page whose launch checkbox is selected by default. Installation is restricted to the current user. Clicking Install or pressing Enter validates the current path; new destinations must be empty, and nonempty destinations must be registered installations. Running executables at the affected installation path produce a native prompt and remain running; same-named applications in other directories do not block installation. Silent updates wait up to ten seconds for the affected application to exit, then stop with exit code 2 if it is still running.
 
-The theme follows Windows at startup; `/THEME=light`, `/THEME=dark`, and `/THEME=auto` select a palette explicitly. The window appears after its branded controls are ready. Progress uses extraction feedback and Windows Shell copy-work estimates; preparation, registration, and cleanup retain bounded estimates. The weighted percentage does not predict remaining time. After NSIS reports success, the bar fills over 600 ms and displays 100% briefly before the finish page appears; the transition targets 750 ms. The finish page preserves the window position. Finish dismisses the installer before launching the installed executable; a launch failure restores the page for retry. Electron-builder owns extraction, existing-version removal, registry entries, shortcuts, and uninstallers; installation failures do not promise full transactional rollback. First-launch profile preparation remains a separate Desktop operation.
+The theme follows Windows at startup; `/THEME=light`, `/THEME=dark`, and `/THEME=auto` select a palette explicitly. The window appears after its branded controls are ready. Progress reads the pinned 7-Zip extractor’s percentage; directory promotion, registration, and cleanup retain bounded estimates. The weighted percentage does not predict remaining time. After NSIS reports success, the bar fills over 600 ms and displays 100% briefly before the finish page appears; the transition targets 750 ms. The finish page preserves the window position. Finish dismisses the installer before launching the installed executable; a launch failure restores the page for retry. Directory replacement and failure recovery follow the installation flow described above. First-launch profile preparation remains a separate Desktop operation.
 
 Windows packaging compiles an x86 Win32/GDI+ helper with Visual C++ Build Tools and a Windows SDK; signed builds sign this helper through the configured Windows signer. The preparation hook leaves production dependency collection to electron-builder on every platform. The [installer decision](../../.agents/notes/implemented/architecture/2026-09-10-windows-native-installer-pages.md) records the NSIS integration and release checks.
 
-Run `pnpm --dir apps/desktop run test:installer` from the repository root on an interactive Windows x64 desktop to build and exercise a small native test payload through the production installer configuration. Each run uses a unique product identity, installs into its own directory, uninstalls it, and retains screenshots and results under `.desktop-build/installer-tests/`.
+Run `pnpm --dir apps/desktop run test:installer` from the repository root on an interactive Windows x64 desktop to build and exercise a small native test payload through the production installer configuration. Each run uses a unique product identity, installs into its own directory, uninstalls it, and retains screenshots and results under `.desktop-build/installer-tests/`. 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.
 
 ### Windows EV signing
 

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

@@ -161,11 +161,11 @@ pnpm run package:desktop:win:x64:unsigned
 
 Windows 安装程序使用原生 NSIS 页面,提供亮暗配色、系统阴影、可编辑的安装目录,以及默认勾选立即启动的完成页。安装仅面向当前用户。点击安装或按 Enter 均校验当前路径;新安装位置必须为空,非空位置必须是已登记的安装目录。受影响安装路径中的程序运行时显示系统提示,并保持应用运行;其他目录中的同名应用不阻止安装。静默更新最多等待受影响应用退出十秒,若仍在运行则以退出码 2 结束。
 
-主题在启动时跟随 Windows;可用 `/THEME=light`、`/THEME=dark` 和 `/THEME=auto` 显式选择配色。窗口在品牌控件准备完成后显示。进度使用解压反馈和 Windows Shell 的复制工作量估算;准备、注册和清理仍使用有界估算。加权百分比不代表剩余时间。NSIS 报告成功后,进度条用 600 毫秒补满并短暂显示 100%,再显示完成页;切换目标时长为 750 毫秒。完成页保留窗口位置。点击完成后,安装程序先隐藏窗口,再启动已安装的可执行文件;启动失败会恢复页面以供重试。electron-builder 负责解压、移除已有版本、注册表、快捷方式和卸载程序;安装失败不承诺完整的事务回滚。首次启动的配置档案准备仍属于独立的 Desktop 操作。
+主题在启动时跟随 Windows;可用 `/THEME=light`、`/THEME=dark` 和 `/THEME=auto` 显式选择配色。窗口在品牌控件准备完成后显示。进度读取锁定版本的 7-Zip 解压器百分比;目录替换、注册和清理仍使用有界估算。加权百分比不代表剩余时间。NSIS 报告成功后,进度条用 600 毫秒补满并短暂显示 100%,再显示完成页;切换目标时长为 750 毫秒。完成页保留窗口位置。点击完成后,安装程序先隐藏窗口,再启动已安装的可执行文件;启动失败会恢复页面以供重试。目录替换和失败恢复遵循上文描述的安装流程。首次启动的配置档案准备仍属于独立的 Desktop 操作。
 
 Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI+ 辅助库;签名构建通过已配置的 Windows 签名器对该库签名。准备钩子在所有平台上均由 electron-builder 继续负责收集生产依赖。[安装界面决策](../../.agents/notes/implemented/architecture/2026-09-10-windows-native-installer-pages.zh.md)记录 NSIS 接入方式和发布验证要求。
 
-在有交互式桌面的 Windows x64 上,从仓库根目录运行 `pnpm --dir apps/desktop run test:installer`,可将小型原生测试载荷接入正式安装配置并执行验证。每次运行使用独立产品身份,安装到自己的目录后卸载,并将截图和结果保留在 `.desktop-build/installer-tests/` 下。
+在有交互式桌面的 Windows x64 上,从仓库根目录运行 `pnpm --dir apps/desktop run test:installer`,可将小型原生测试载荷接入正式安装配置并执行验证。每次运行使用独立产品身份,安装到自己的目录后卸载,并将截图和结果保留在 `.desktop-build/installer-tests/` 下。可选的 `--signed` 标志使用下文的 Windows EV 配置,在嵌入前对测试程序和辅助库签名;它不会启用更新源。
 
 ### Windows EV 签名
 

+ 26 - 0
apps/desktop/installer/extract-progress.h

@@ -0,0 +1,26 @@
+// 7-Zip emits an ASCII percentage at the start of each refreshed progress line.
+#pragma once
+#include <cstddef>
+
+struct ExtractionProgress {
+    unsigned value = 0;
+    unsigned number = 0;
+    unsigned digits = 0;
+    bool start = true;
+
+    void Read(const char* data, std::size_t length) {
+        for (std::size_t i = 0; i < length; ++i) {
+            const char c = data[i];
+            if (c == '\r' || c == '\n' || c == '\b') {
+                start = true; number = 0; digits = 0;
+            } else if (start && c == ' ' && digits == 0) {
+                continue;
+            } else if (start && c >= '0' && c <= '9' && digits < 3) {
+                number = number * 10 + c - '0'; ++digits;
+            } else {
+                if (start && c == '%' && digits && number <= 100 && number > value) value = number;
+                start = false;
+            }
+        }
+    }
+};

+ 95 - 0
apps/desktop/installer/extract.h

@@ -0,0 +1,95 @@
+// The pinned 7-Zip executable owns extraction; this pipe reader publishes its work percentage.
+#pragma once
+#include <string>
+#include <vector>
+#include "extract-progress.h"
+
+struct InstallerHandle {
+    HANDLE value = nullptr;
+    InstallerHandle() = default;
+    explicit InstallerHandle(HANDLE handle) : value(handle) {}
+    InstallerHandle(const InstallerHandle&) = delete;
+    InstallerHandle& operator=(const InstallerHandle&) = delete;
+    ~InstallerHandle() { Close(); }
+    void Close() {
+        if (value && value != INVALID_HANDLE_VALUE) CloseHandle(value);
+        value = nullptr;
+    }
+};
+
+struct InstallerAttributes {
+    std::vector<unsigned char> storage;
+    LPPROC_THREAD_ATTRIBUTE_LIST list = nullptr;
+    ~InstallerAttributes() { if (list) DeleteProcThreadAttributeList(list); }
+    bool Initialize() {
+        SIZE_T size = 0;
+        InitializeProcThreadAttributeList(nullptr, 2, 0, &size);
+        storage.resize(size);
+        auto* candidate = reinterpret_cast<LPPROC_THREAD_ATTRIBUTE_LIST>(storage.data());
+        if (!InitializeProcThreadAttributeList(candidate, 2, 0, &size)) return false;
+        list = candidate;
+        return true;
+    }
+};
+
+static DWORD ExtractApplication(HWND parent, LPCWSTR tool, LPCWSTR archive, LPCWSTR destination, LPCWSTR log) {
+    SECURITY_ATTRIBUTES security = {sizeof(security), nullptr, TRUE};
+    InstallerHandle input, output;
+    if (!CreatePipe(&input.value, &output.value, &security, 0)) return GetLastError();
+    if (!SetHandleInformation(input.value, HANDLE_FLAG_INHERIT, 0)) return GetLastError();
+    InstallerHandle errors(CreateFileW(log, GENERIC_WRITE, FILE_SHARE_READ, &security, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr));
+    if (errors.value == INVALID_HANDLE_VALUE) return GetLastError();
+    InstallerHandle nullInput(CreateFileW(L"NUL", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, &security, OPEN_EXISTING, 0, nullptr));
+    if (nullInput.value == INVALID_HANDLE_VALUE) return GetLastError();
+    InstallerHandle job(CreateJobObjectW(nullptr, nullptr));
+    if (!job.value) return GetLastError();
+    JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits = {};
+    limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
+    if (!SetInformationJobObject(job.value, JobObjectExtendedLimitInformation, &limits, sizeof(limits))) return GetLastError();
+    InstallerAttributes attributes;
+    if (!attributes.Initialize()) return GetLastError();
+    HANDLE inherited[] = {nullInput.value, output.value, errors.value};
+    if (!UpdateProcThreadAttribute(attributes.list, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, inherited, sizeof(inherited), nullptr, nullptr) ||
+        !UpdateProcThreadAttribute(attributes.list, 0, PROC_THREAD_ATTRIBUTE_JOB_LIST, &job.value, sizeof(job.value), nullptr, nullptr)) return GetLastError();
+    STARTUPINFOEXW startup = {};
+    startup.StartupInfo.cb = sizeof(startup);
+    startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
+    startup.StartupInfo.hStdInput = nullInput.value;
+    startup.StartupInfo.hStdOutput = output.value;
+    startup.StartupInfo.hStdError = errors.value;
+    startup.lpAttributeList = attributes.list;
+    // A final dot prevents a destination's trailing backslash from escaping its closing quote.
+    std::wstring command = L"\"" + std::wstring(tool) + L"\" x -y -bso0 -bse2 -bsp1 -bb0 \"-o" + destination + L"\\.\" \"" + archive + L"\"";
+    PROCESS_INFORMATION child = {};
+    if (!CreateProcessW(tool, &command[0], nullptr, nullptr, TRUE,
+        EXTENDED_STARTUPINFO_PRESENT | CREATE_NO_WINDOW, nullptr, nullptr, &startup.StartupInfo, &child)) return GetLastError();
+    InstallerHandle process(child.hProcess), thread(child.hThread);
+    output.Close();
+    errors.Close();
+    ExtractionProgress progress;
+    char buffer[4096];
+    DWORD count = 0;
+    DWORD readError = ERROR_SUCCESS;
+    while (true) {
+        if (!ReadFile(input.value, buffer, sizeof(buffer), &count, nullptr)) { readError = GetLastError(); break; }
+        if (!count) break;
+        progress.Read(buffer, count);
+        if (parent) SetPropW(parent, L"HarnessInstaller.ExtractProgress", reinterpret_cast<HANDLE>(static_cast<UINT_PTR>(progress.value)));
+    }
+    if (readError != ERROR_BROKEN_PIPE && readError != ERROR_SUCCESS) {
+        TerminateJobObject(job.value, readError);
+        WaitForSingleObject(process.value, INFINITE);
+        return readError;
+    }
+    if (WaitForSingleObject(process.value, INFINITE) != WAIT_OBJECT_0) return GetLastError();
+    DWORD result;
+    if (!GetExitCodeProcess(process.value, &result)) return GetLastError();
+    if (result == 0 && parent) SetPropW(parent, L"HarnessInstaller.ExtractProgress", reinterpret_cast<HANDLE>(100));
+    return result;
+}
+
+// Runs on the NSIS worker; only an exit code of zero permits directory promotion.
+extern "C" __declspec(dllexport) DWORD __cdecl InstallerExtract(HWND parent, LPCWSTR tool, LPCWSTR archive, LPCWSTR destination, LPCWSTR log) {
+    try { return ExtractApplication(parent, tool, archive, destination, log); }
+    catch (const std::bad_alloc&) { return ERROR_NOT_ENOUGH_MEMORY; }
+}

+ 0 - 90
apps/desktop/installer/file-copy.h

@@ -1,90 +0,0 @@
-// Windows Shell owns recursive copying, overwrite semantics, and progress accounting.
-#pragma once
-#include <shobjidl.h>
-#include <shellapi.h>
-#include <wrl.h>
-#include <string>
-#include <memory>
-
-using Microsoft::WRL::ComPtr;
-
-class CopyProgress final : public Microsoft::WRL::RuntimeClass<
-        Microsoft::WRL::RuntimeClassFlags<Microsoft::WRL::ClassicCom>, IFileOperationProgressSink> {
-    HWND parent;
-public:
-    explicit CopyProgress(HWND window) : parent(window) {}
-    IFACEMETHODIMP StartOperations() override { return S_OK; }
-    IFACEMETHODIMP FinishOperations(HRESULT) override { return S_OK; }
-    IFACEMETHODIMP UpdateProgress(UINT total, UINT done) override {
-        if (total && parent) SetPropW(parent, L"HarnessInstaller.CopyProgress",
-            reinterpret_cast<HANDLE>(static_cast<UINT_PTR>(10000ULL * done / total)));
-        return S_OK;
-    }
-    IFACEMETHODIMP PreRenameItem(DWORD, IShellItem*, LPCWSTR) override { return S_OK; }
-    IFACEMETHODIMP PostRenameItem(DWORD, IShellItem*, LPCWSTR, HRESULT, IShellItem*) override { return S_OK; }
-    IFACEMETHODIMP PreMoveItem(DWORD, IShellItem*, IShellItem*, LPCWSTR) override { return S_OK; }
-    IFACEMETHODIMP PostMoveItem(DWORD, IShellItem*, IShellItem*, LPCWSTR, HRESULT, IShellItem*) override { return S_OK; }
-    IFACEMETHODIMP PreCopyItem(DWORD, IShellItem*, IShellItem*, LPCWSTR) override { return S_OK; }
-    IFACEMETHODIMP PostCopyItem(DWORD, IShellItem*, IShellItem*, LPCWSTR, HRESULT, IShellItem*) override { return S_OK; }
-    IFACEMETHODIMP PreDeleteItem(DWORD, IShellItem*) override { return S_OK; }
-    IFACEMETHODIMP PostDeleteItem(DWORD, IShellItem*, HRESULT, IShellItem*) override { return S_OK; }
-    IFACEMETHODIMP PreNewItem(DWORD, IShellItem*, LPCWSTR) override { return S_OK; }
-    IFACEMETHODIMP PostNewItem(DWORD, IShellItem*, LPCWSTR, LPCWSTR, DWORD, HRESULT, IShellItem*) override { return S_OK; }
-    IFACEMETHODIMP ResetTimer() override { return S_OK; }
-    IFACEMETHODIMP PauseTimer() override { return S_OK; }
-    IFACEMETHODIMP ResumeTimer() override { return S_OK; }
-};
-
-static HRESULT CopyApplication(HWND parent, LPCWSTR source, LPCWSTR destination) {
-    ComPtr<IFileOperation> operation;
-    HRESULT result = CoCreateInstance(CLSID_FileOperation, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&operation));
-    if (FAILED(result)) return result;
-    result = operation->SetOperationFlags(FOF_SILENT | FOF_NOCONFIRMATION | FOF_NOCONFIRMMKDIR |
-                                         FOF_NOERRORUI | FOFX_EARLYFAILURE);
-    if (FAILED(result)) return result;
-    result = operation->SetOwnerWindow(parent);
-    if (FAILED(result)) return result;
-    auto progress = Microsoft::WRL::Make<CopyProgress>(parent);
-    if (!progress) return E_OUTOFMEMORY;
-    DWORD cookie;
-    result = operation->Advise(progress.Get(), &cookie);
-    if (FAILED(result)) return result;
-    ComPtr<IShellItem> target;
-    result = SHCreateItemFromParsingName(destination, nullptr, IID_PPV_ARGS(&target));
-    if (FAILED(result)) return result;
-    const std::wstring prefix = std::wstring(source) + L"\\";
-    WIN32_FIND_DATAW entry;
-    HANDLE search = FindFirstFileW((prefix + L"*").c_str(), &entry);
-    if (search == INVALID_HANDLE_VALUE) return HRESULT_FROM_WIN32(GetLastError());
-    std::unique_ptr<void, decltype(&FindClose)> searchOwner(search, FindClose);
-    bool found = false;
-    do {
-        if (wcscmp(entry.cFileName, L".") == 0 || wcscmp(entry.cFileName, L"..") == 0) continue;
-        found = true;
-        ComPtr<IShellItem> item;
-        result = SHCreateItemFromParsingName((prefix + entry.cFileName).c_str(), nullptr, IID_PPV_ARGS(&item));
-        if (SUCCEEDED(result)) result = operation->CopyItem(item.Get(), target.Get(), nullptr, nullptr);
-        if (FAILED(result)) break;
-    } while (FindNextFileW(search, &entry));
-    const DWORD enumerationError = GetLastError();
-    if (FAILED(result)) return result;
-    if (enumerationError != ERROR_NO_MORE_FILES) return HRESULT_FROM_WIN32(enumerationError);
-    if (!found) return HRESULT_FROM_WIN32(ERROR_FILE_NOT_FOUND);
-    result = operation->PerformOperations();
-    if (FAILED(result)) return result;
-    BOOL aborted = FALSE;
-    result = operation->GetAnyOperationsAborted(&aborted);
-    if (FAILED(result)) return result;
-    return aborted ? HRESULT_FROM_WIN32(ERROR_CANCELLED) : S_OK;
-}
-
-// Called synchronously by the NSIS installation worker; failures return to its retry loop.
-extern "C" __declspec(dllexport) HRESULT __cdecl InstallerCopyFiles(HWND parent, LPCWSTR source, LPCWSTR destination) {
-    const HRESULT initialized = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
-    if (FAILED(initialized)) return initialized;
-    HRESULT result;
-    try { result = CopyApplication(parent, source, destination); }
-    catch (const std::bad_alloc&) { result = E_OUTOFMEMORY; }
-    CoUninitialize();
-    return result;
-}

+ 5 - 5
apps/desktop/installer/progress.h

@@ -1,4 +1,4 @@
-// Real worker fractions drive the long stages; short stages retain bounded estimates.
+// 7-Zip work drives extraction; directory promotion, registration, and cleanup use bounded estimates.
 #pragma once
 #include <algorithm>
 #include <cmath>
@@ -28,9 +28,9 @@ struct InstallProgress {
             stage = nextStage;
             stageStarted = now;
         }
-        const double boundaries[] = {0, 2, 25, 92, 94, 99};
-        if (stage == 0 || stage >= 3) {
-            const double seconds = stage == 0 ? 3.0 : stage == 3 ? 1.0 : 11.0;
+        const double boundaries[] = {0, 2, 94, 96, 98, 99};
+        if (stage != 1) {
+            const double seconds = stage == 0 ? 3.0 : stage == 4 ? 11.0 : 1.0;
             fraction = 1 - std::exp(-static_cast<double>(now - stageStarted) / (seconds * 1000));
         }
         const double next = boundaries[stage] + (boundaries[stage + 1] - boundaries[stage]) *
@@ -53,7 +53,7 @@ struct InstallProgress {
     }
 
     int CaptionStage() const {
-        const double boundaries[] = {0, 2, 25, 92, 94};
+        const double boundaries[] = {0, 2, 94, 96, 98};
         int caption = 0;
         while (caption < stage && value >= boundaries[caption + 1]) ++caption;
         return caption;

+ 2 - 2
apps/desktop/installer/strings.nsh

@@ -7,8 +7,8 @@ LangString INSTALLER_NATIVE_PROGRESS ${LANG_ENGLISH} "Preparing installation…
 LangString INSTALLER_NATIVE_PROGRESS ${LANG_SIMPCHINESE} "正在准备安装… %d%%"
 LangString INSTALLER_PROGRESS_EXTRACT ${LANG_ENGLISH} "Extracting files… %d%%"
 LangString INSTALLER_PROGRESS_EXTRACT ${LANG_SIMPCHINESE} "正在解压文件… %d%%"
-LangString INSTALLER_PROGRESS_COPY ${LANG_ENGLISH} "Copying files… %d%%"
-LangString INSTALLER_PROGRESS_COPY ${LANG_SIMPCHINESE} "正在复制文件… %d%%"
+LangString INSTALLER_PROGRESS_COPY ${LANG_ENGLISH} "Installing files… %d%%"
+LangString INSTALLER_PROGRESS_COPY ${LANG_SIMPCHINESE} "正在安装文件… %d%%"
 LangString INSTALLER_PROGRESS_REGISTER ${LANG_ENGLISH} "Finishing installation… %d%%"
 LangString INSTALLER_PROGRESS_REGISTER ${LANG_SIMPCHINESE} "正在完成安装… %d%%"
 LangString INSTALLER_PROGRESS_CLEAN ${LANG_ENGLISH} "Cleaning temporary files… %d%%"

+ 2 - 12
apps/desktop/installer/window-frame.cpp

@@ -11,7 +11,7 @@
 #include <new>
 #include <algorithm>
 #include "progress.h"
-#include "file-copy.h"
+#include "extract.h"
 
 using namespace Gdiplus;
 
@@ -49,7 +49,6 @@ extern "C" __declspec(dllexport) int __cdecl InstallerFindProcess(LPCWSTR execut
 
 struct ProgressPage {
     InstallProgress progress{GetTickCount64()};
-    HWND extractionDetail;
     bool dark;
     UINT dpi;
     ULONG_PTR gdiplus;
@@ -111,15 +110,7 @@ static LRESULT CALLBACK ProgressProc(HWND window, UINT message, WPARAM wparam, L
             graphics.SetSmoothingMode(SmoothingModeAntiAlias);
             graphics.DrawImage(page->brand, Rect(0, 174, 600, 196));
             const int stage = static_cast<int>(reinterpret_cast<INT_PTR>(GetPropW(GetParent(window), L"HarnessInstaller.Stage")));
-            double fraction = 0;
-            if (stage == 1) {
-                WCHAR detail[128] = {};
-                int extracted = 0;
-                GetWindowTextW(page->extractionDetail, detail, ARRAYSIZE(detail));
-                if (swscanf_s(detail, L"HarnessExtract:%d%%", &extracted) == 1) fraction = extracted / 100.0;
-            } else if (stage == 2) {
-                fraction = reinterpret_cast<UINT_PTR>(GetPropW(GetParent(window), L"HarnessInstaller.CopyProgress")) / 10000.0;
-            }
+            const double fraction = reinterpret_cast<UINT_PTR>(GetPropW(GetParent(window), L"HarnessInstaller.ExtractProgress")) / 100.0;
             page->progress.Advance(stage, fraction, GetTickCount64());
             const int percent = static_cast<int>(page->progress.value);
             SolidBrush track(page->dark ? Color(255, 97, 102, 107) : Color(255, 233, 236, 242));
@@ -175,7 +166,6 @@ extern "C" __declspec(dllexport) HWND __cdecl InstallerShowProgress(HWND parent,
     }
     ShowWindow(stockPage, SW_HIDE);
     page->dark = dark != FALSE;
-    page->extractionDetail = GetDlgItem(stockPage, 1006);
     page->dpi = dpi;
     const WCHAR* captions[] = {preparing, extracting, copying, registering, cleaning};
     for (int i = 0; i < 5; ++i) lstrcpynW(page->captions[i], captions[i], 128);

+ 13 - 3
apps/desktop/scripts/installer-directories.nsh

@@ -7,9 +7,13 @@ Var dshOldMoved
 Var dshNewMoved
 
 !macro dshExtractPayload FILE
-  nsExec::ExecToStack '"$PLUGINSDIR\dsh-7za.exe" x -y -bd -bb0 "-o$INSTDIR" "${FILE}"'
-  Pop $R0
-  Pop $R1
+  !ifmacrodef customInstallerExtract
+    !insertmacro customInstallerExtract "${FILE}"
+  !else
+    nsExec::ExecToStack '"$PLUGINSDIR\dsh-7za.exe" x -y -bd -bb0 "-o$INSTDIR" "${FILE}"'
+    Pop $R0
+    Pop $R1
+  !endif
   ${If} $R0 != 0
     DetailPrint $R1
     Call dshRollbackDirectories
@@ -85,6 +89,9 @@ Function dshRollbackDirectories
 FunctionEnd
 
 Function dshPromoteDirectories
+  !ifmacrodef InstallerPublishStage
+    !insertmacro InstallerPublishStage 2
+  !endif
   ; SetOutPath opens a directory handle; release it before either rename.
   SetOutPath $PLUGINSDIR
   ClearErrors
@@ -109,6 +116,9 @@ Function dshPromoteDirectories
   ${EndIf}
   StrCpy $dshNewMoved "1"
   SetOutPath $dshFinalDirectory
+  !ifmacrodef InstallerPublishStage
+    !insertmacro InstallerPublishStage 3
+  !endif
   ClearErrors
 FunctionEnd
 

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

@@ -82,7 +82,7 @@
   Page custom InstallerFinish InstallerFinishLeave
 !macroend
 
-; The pinned builder's worker publishes stages around its extraction and copy operations.
+; Installation work publishes stage changes without disturbing the NSIS caller.
 !macro InstallerPublishStage Stage
   ; Extraction owns the stack and error flag across these callbacks.
   Push $0
@@ -101,42 +101,22 @@
   Pop $0
 !macroend
 
-!macro customInstallerExtractStart
-  !insertmacro InstallerPublishStage 1
-!macroend
-
 !macro customInstallerExtract Archive
-  ; Private telemetry in the hidden detail label avoids the reused NSIS instruction counter.
-  Nsis7z::ExtractWithDetails "${Archive}" "HarnessExtract:%s"
-!macroend
-
-!macro customInstallerCopyStart
-  !insertmacro InstallerPublishStage 2
-!macroend
-
-!macro customInstallerCopyFiles Source Destination
-  Push $0
-  Push $1
-  StrCpy $0 0
-  ${If} ${Errors}
-    StrCpy $0 1
-  ${EndIf}
+  !insertmacro InstallerPublishStage 1
   System::Store /NOUNLOAD "S"
-  System::Call /NOUNLOAD '$PLUGINSDIR\window-frame.dll::InstallerCopyFiles(p $HWNDPARENT, w "${Source}", w "${Destination}") i.s ?c'
+  System::Call /NOUNLOAD '$PLUGINSDIR\window-frame.dll::InstallerExtract(p $HWNDPARENT, w "$PLUGINSDIR\dsh-7za.exe", w "${Archive}", w "$INSTDIR", w "$PLUGINSDIR\extract.log") i.s ?c'
   System::Store "L"
-  Pop $1
-  ${If} $1 < 0
-  ${OrIf} $0 == 1
-    SetErrors
-  ${Else}
-    ClearErrors
+  Pop $R0
+  StrCpy $R1 "$R0"
+  ${If} $R0 != 0
+    Push $0
+    FileOpen $0 "$PLUGINSDIR\extract.log" r
+    ${IfNot} ${Errors}
+      FileRead $0 $R1
+      FileClose $0
+    ${EndIf}
+    Pop $0
   ${EndIf}
-  Pop $1
-  Pop $0
-!macroend
-
-!macro customInstallerFilesReady
-  !insertmacro InstallerPublishStage 3
 !macroend
 
 !macro customCheckAppRunning
@@ -180,10 +160,8 @@
   ${If} ${Errors}
     StrCpy $0 1
   ${EndIf}
-  ; Finish can launch the app while NSIS removes its remaining plugin directory.
   !insertmacro InstallerPublishStage 4
   !insertmacro dshFinishDirectories
-  RMDir /r "$PLUGINSDIR\7z-out"
   ${If} $0 == 1
     SetErrors
   ${Else}

+ 6 - 14
apps/desktop/scripts/prepare-windows-installer.ps1

@@ -1,6 +1,6 @@
 <# Compile the x86 DWM helper and raster assets embedded in the NSIS installer. #>
 [CmdletBinding()]
-param([string]$OutputDirectory, [switch]$TestProgress)
+param([string]$OutputDirectory, [switch]$TestProgress, [switch]$CompileProgressOnly)
 $ErrorActionPreference = 'Stop'
 $installerRoot = Join-Path $PSScriptRoot '../installer'
 if (-not $OutputDirectory) { $OutputDirectory = Join-Path $PSScriptRoot '../.desktop-build/targets/win-x64/installer-ui' }
@@ -18,25 +18,17 @@ $compileLines = @('@echo off', ('call "{0}" >nul' -f $vcvars), 'if errorlevel 1
 [IO.File]::WriteAllLines($compileScript, $compileLines, [Text.Encoding]::Default)
 & $env:ComSpec /d /c $compileScript
 if ($LASTEXITCODE -ne 0) { throw 'Native installer helper compilation failed.' }
-if ($TestProgress) {
+if ($TestProgress -or $CompileProgressOnly) {
     $testSource = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../tests/windows-installer-progress.cpp'))
     $testExecutable = Join-Path $output 'progress-test.exe'
     $testScript = Join-Path $output 'compile-progress-test.cmd'
     [IO.File]::WriteAllLines($testScript, @('@echo off', ('call "{0}" >nul' -f $vcvars), 'if errorlevel 1 exit /b %errorlevel%', ('cl /nologo /MT /W4 /WX /EHsc "{0}" /Fo"{1}" /Fe"{2}"' -f $testSource, (Join-Path $output 'progress-test.obj'), $testExecutable)), [Text.Encoding]::Default)
     & $env:ComSpec /d /c $testScript
     if ($LASTEXITCODE -ne 0) { throw 'Progress test compilation failed.' }
-    & $testExecutable
-    if ($LASTEXITCODE -ne 0) { throw 'Progress timeline regression failed.' }
-    $copySource = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../tests/windows-installer-copy.cpp'))
-    $copyExecutable = Join-Path $output 'copy-test.exe'
-    $copyScript = Join-Path $output 'compile-copy-test.cmd'
-    [IO.File]::WriteAllLines($copyScript, @('@echo off', ('call "{0}" >nul' -f $vcvars), 'if errorlevel 1 exit /b %errorlevel%', ('cl /nologo /std:c++17 /MT /W4 /WX /EHsc "{0}" /Fo"{1}" /Fe"{2}" /link user32.lib ole32.lib shell32.lib uuid.lib' -f $copySource, (Join-Path $output 'copy-test.obj'), $copyExecutable)), [Text.Encoding]::Default)
-    & $env:ComSpec /d /c $copyScript
-    if ($LASTEXITCODE -ne 0) { throw 'Copy test compilation failed.' }
-    $copyRoot = Join-Path $output ('copy-test-' + [Guid]::NewGuid().ToString('N'))
-    New-Item -ItemType Directory $copyRoot | Out-Null
-    & $copyExecutable $copyRoot
-    if ($LASTEXITCODE -ne 0) { throw 'Shell copy regression failed.' }
+    if ($TestProgress) {
+        & $testExecutable
+        if ($LASTEXITCODE -ne 0) { throw 'Progress timeline regression failed.' }
+    }
 }
 Add-Type -AssemblyName System.Drawing
 foreach ($asset in @('brand', 'brand-2x', 'brand-dark', 'brand-dark-2x', 'uninstaller-sidebar')) {

+ 2 - 0
apps/desktop/scripts/smoke-installer-directories.ps1

@@ -2,6 +2,7 @@
 param(
   [Parameter(Mandatory)][string]$Makensis,
   [Parameter(Mandatory)][string]$SevenZip,
+  [string]$FrameLibrary,
   [scriptblock]$SignExecutable
 )
 $ErrorActionPreference = 'Stop'
@@ -42,6 +43,7 @@ try {
     $probe = Join-Path $caseRoot 'probe.exe'
     $caseArchive = if ($mode -eq 'broken') { $brokenArchive } else { $archive }
     $compileArgs = @('/V2', "/DOUTPUT_FILE=$probe", "/DPAYLOAD_FILE=$caseArchive", "/DTARGET_DIR=$target", "/DDSH_SEVENZIP_PATH=$SevenZip")
+    if ($FrameLibrary) { $compileArgs += "/DSOURCE_DLL=$FrameLibrary" }
     if ($mode -eq 'missing-stage') { $compileArgs += '/DMISSING_STAGE' }
     if ($mode -eq 'cancelled') { $compileArgs += '/DCANCELLED' }
     Invoke-Checked $Makensis ($compileArgs + $fixture)

+ 20 - 5
apps/desktop/scripts/test-windows-installer.mjs

@@ -6,6 +6,7 @@ import { createRequire } from 'node:module'
 import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { promisify } from 'node:util'
+import { createWindowsTokenSigner, installWindowsNsisBootstrapSigner, scrubWindowsSigningEnvironment } from './windows-sign.mjs'
 
 if (process.platform !== 'win32' || process.arch !== 'x64') {
   throw new Error('Installer UI checks require an interactive Windows x64 desktop')
@@ -24,6 +25,12 @@ const output = await mkdtemp(join(outputRoot, 'run-'))
 const payload = join(output, 'payload')
 await mkdir(join(payload, 'resources'), { recursive: true })
 const previousEnvironment = { ...process.env }
+const sign = process.argv.includes('--signed') ? createWindowsTokenSigner({
+  certificateFile: process.env.DSH_DESKTOP_WINDOWS_CER_FILE,
+  signTool: process.env.DSH_DESKTOP_WINDOWS_SIGNTOOL,
+  tokenPin: process.env.DSH_DESKTOP_WINDOWS_TOKEN_PIN,
+  keyContainer: process.env.DSH_DESKTOP_WINDOWS_KEY_CONTAINER,
+}) : undefined
 try {
   Object.assign(process.env, {
     DSH_DESKTOP_APP_ID: `com.deepseek.harness.installertest.n${id}`,
@@ -31,10 +38,16 @@ try {
     DSH_DESKTOP_UNSIGNED: '1', CSC_IDENTITY_AUTO_DISCOVERY: 'false', ELECTRON_BUILDER_7Z_FILTER: 'BCJ',
   })
   const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs')
-  const { scrubWindowsSigningEnvironment } = await import('./windows-sign.mjs')
   const childOptions = { env: scrubWindowsSigningEnvironment(process.env), windowsHide: true, maxBuffer: 8 * 1024 * 1024 }
   await execute('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File',
-    join(appRoot, 'scripts', 'prepare-windows-installer.ps1'), '-OutputDirectory', join(output, 'ui'), '-TestProgress'], childOptions)
+    join(appRoot, 'scripts', 'prepare-windows-installer.ps1'), '-OutputDirectory', join(output, 'ui'), '-CompileProgressOnly'], childOptions)
+  const progressTest = join(output, 'ui', 'progress-test.exe')
+  if (sign) {
+    await sign({ path: progressTest, hash: 'sha256', isNest: false })
+    await sign({ path: join(output, 'ui', 'window-frame.dll'), hash: 'sha256', isNest: false })
+    installWindowsNsisBootstrapSigner({ sign })
+  }
+  await execute(progressTest, [], childOptions)
   const payloadSource = join(output, 'payload.nsi')
   await writeFile(payloadSource, `Unicode true
 RequestExecutionLevel user
@@ -50,13 +63,15 @@ Section
 SectionEnd
 `)
   const compiler = await getMakeNsisPath()
-  const copySmoke = join(output, 'copy-smoke.exe')
-  await execute(compiler.path, ['/V2', `/DOUTPUT_FILE=${copySmoke}`, `/DSOURCE_DLL=${join(output, 'ui', 'window-frame.dll')}`,
-    join(appRoot, 'tests', 'fixtures', 'installer-copy-smoke.nsi')], { ...childOptions, env: { ...childOptions.env, ...compiler.env } })
   await execute(compiler.path, ['/V2', payloadSource], { ...childOptions, env: { ...childOptions.env, ...compiler.env } })
+  if (sign) await sign({ path: join(payload, `${productName}.exe`), hash: 'sha256', isNest: false })
   const include = join(output, 'include.nsh')
   await writeFile(include, `!define INSTALLER_BUILD_DIR "${join(output, 'ui')}"\n!include "${join(appRoot, 'scripts', 'installer.nsh')}"\n`)
   const config = createElectronBuilderConfig()
+  if (sign) {
+    config.win.forceCodeSigning = true
+    config.win.signtoolOptions.sign = sign
+  }
   await build({ projectDir: appRoot, prepackaged: payload, targets: Platform.WINDOWS.createTarget(['nsis'], Arch.x64), publish: 'never',
     config: { ...config, productName, artifactName: 'installer-test.exe', directories: { output },
       nsis: { ...config.nsis, guid, include }, beforeBuild: undefined, afterPack: undefined, afterSign: undefined, artifactBuildCompleted: undefined },

+ 0 - 49
apps/desktop/tests/fixtures/installer-copy-smoke.nsi

@@ -1,49 +0,0 @@
-; Exercises the production copy hook's NSIS register, stack, and error-flag behavior.
-Unicode true
-RequestExecutionLevel user
-SilentInstall silent
-Name "Desktop installer copy smoke"
-OutFile "${OUTPUT_FILE}"
-!include "..\..\scripts\installer.nsh"
-
-Section
-  InitPluginsDir
-  File "/oname=$PLUGINSDIR\window-frame.dll" "${SOURCE_DLL}"
-  CreateDirectory "$PLUGINSDIR\source"
-  CreateDirectory "$PLUGINSDIR\target"
-  FileOpen $0 "$PLUGINSDIR\source\payload.txt" w
-  FileWrite $0 "payload"
-  FileClose $0
-  StrCpy $0 "zero"
-  StrCpy $1 "one"
-  StrCpy $R0 "output directory"
-  StrCpy $R1 "retry counter"
-  Push "stack sentinel"
-  ClearErrors
-  !insertmacro customInstallerCopyFiles "$PLUGINSDIR\source" "$PLUGINSDIR\target"
-  IfErrors failed
-  StrCmp $0 "zero" 0 failed
-  StrCmp $1 "one" 0 failed
-  StrCmp $R0 "output directory" 0 failed
-  StrCmp $R1 "retry counter" 0 failed
-  Pop $2
-  StrCmp $2 "stack sentinel" 0 failed
-  FileOpen $2 "$PLUGINSDIR\target\payload.txt" r
-  FileRead $2 $3
-  FileClose $2
-  StrCmp $3 "payload" 0 failed
-
-  SetErrors
-  !insertmacro customInstallerCopyFiles "$PLUGINSDIR\source" "$PLUGINSDIR\target"
-  IfErrors +2
-    Goto failed
-  ClearErrors
-  !insertmacro customInstallerCopyFiles "$PLUGINSDIR\missing" "$PLUGINSDIR\target"
-  IfErrors +2
-    Goto failed
-  SetErrorLevel 0
-  Quit
-  failed:
-  SetErrorLevel 1
-  Quit
-SectionEnd

+ 8 - 1
apps/desktop/tests/fixtures/installer-directory-smoke.nsi

@@ -10,10 +10,17 @@ LangString decompressionFailed ${LANG_ENGLISH} "Payload extraction failed"
 !macro installApplicationFiles
   !insertmacro dshExtractPayload "${PAYLOAD_FILE}"
 !macroend
-!include "..\..\scripts\installer-directories.nsh"
+!ifdef SOURCE_DLL
+  !include "..\..\scripts\installer.nsh"
+!else
+  !include "..\..\scripts\installer-directories.nsh"
+!endif
 
 Section
   InitPluginsDir
+  !ifdef SOURCE_DLL
+    File "/oname=$PLUGINSDIR\window-frame.dll" "${SOURCE_DLL}"
+  !endif
   StrCpy $INSTDIR "${TARGET_DIR}"
   !insertmacro dshStageApplication
   !ifdef CANCELLED

+ 0 - 46
apps/desktop/tests/windows-installer-copy.cpp

@@ -1,46 +0,0 @@
-// Exercise Shell copying against private files, including a held destination handle.
-#define WIN32_LEAN_AND_MEAN
-#define NOMINMAX
-#define UNICODE
-#include <windows.h>
-#include <filesystem>
-#include <fstream>
-#include <stdexcept>
-#include "../installer/file-copy.h"
-
-static void Check(bool condition) {
-    if (!condition) throw std::runtime_error("Installer copy regression failed");
-}
-
-int wmain(int argc, wchar_t** argv) {
-    if (argc != 2) return 2;
-    const std::filesystem::path root(argv[1]);
-    // The runner allocates this root exclusively and retains it on test failure.
-    const auto source = root / L"source";
-    const auto target = root / L"target";
-    std::filesystem::create_directories(source / L"nested" / L"empty");
-    std::filesystem::create_directories(target / L"nested");
-    const auto filename = std::filesystem::path(L"\x6d4b\x8bd5 file.bin");
-    const std::string data(8 * 1024 * 1024, 'x');
-    std::ofstream(source / L"nested" / filename, std::ios::binary) << data;
-    std::ofstream(source / L"hidden.txt") << "hidden";
-    Check(SetFileAttributesW((source / L"hidden.txt").c_str(), FILE_ATTRIBUTE_HIDDEN) != FALSE);
-    std::ofstream(target / L"nested" / filename) << "old";
-    HWND window = CreateWindowExW(0, L"STATIC", L"", 0, 0, 0, 0, 0, HWND_MESSAGE, nullptr, nullptr, nullptr);
-    Check(window != nullptr);
-    HANDLE locked = CreateFileW((target / L"nested" / filename).c_str(), GENERIC_READ, 0, nullptr, OPEN_EXISTING, 0, nullptr);
-    Check(locked != INVALID_HANDLE_VALUE);
-    const HRESULT blocked = InstallerCopyFiles(window, source.c_str(), target.c_str());
-    CloseHandle(locked);
-    Check(FAILED(blocked));
-    Check(SUCCEEDED(InstallerCopyFiles(window, source.c_str(), target.c_str())));
-    std::ifstream copied(target / L"nested" / filename, std::ios::binary);
-    Check(std::string(std::istreambuf_iterator<char>(copied), {}) == data);
-    copied.close();
-    Check(std::filesystem::is_directory(target / L"nested" / L"empty"));
-    Check(std::filesystem::file_size(target / L"hidden.txt") == 6);
-    Check(reinterpret_cast<UINT_PTR>(GetPropW(window, L"HarnessInstaller.CopyProgress")) > 0);
-    Check(FAILED(InstallerCopyFiles(window, (root / L"missing").c_str(), target.c_str())));
-    DestroyWindow(window);
-    return 0;
-}

+ 36 - 20
apps/desktop/tests/windows-installer-progress.cpp

@@ -1,27 +1,43 @@
 // Deterministic user-visible progress scenarios; no wall-clock sleeps or file operations.
 #include "../installer/progress.h"
+#include "../installer/extract-progress.h"
 #include <cassert>
+#include <cstring>
 
 int main() {
+    // Progress tokens can cross pipe reads; filenames and stale redraws cannot inflate them.
+    const char* stream = "  0%\b\b\b  4%\r 50% 14 - filename99%\r 9%\r 101%\r 100%";
+    for (std::size_t block : {1U, 2U, 7U, 128U}) {
+        ExtractionProgress parsed;
+        const auto length = std::strlen(stream);
+        for (std::size_t offset = 0; offset < length; offset += block) {
+            parsed.Read(stream + offset, std::min(block, length - offset));
+        }
+        assert(parsed.value == 100);
+    }
+    ExtractionProgress invalid;
+    const char* noise = "file 99%\r1234%\r101%\r%";
+    invalid.Read(noise, std::strlen(noise));
+    assert(invalid.value == 0);
+
     // Identical work fractions give the same result on fast and slow disks.
     for (std::uint64_t interval : {1000ULL, 10000ULL}) {
         InstallProgress progress(0);
         std::uint64_t now = 0;
         double previous = 0;
-        for (int stage : {1, 2}) {
-            for (int step = 0; step <= 10; ++step) {
-                now += interval;
-                progress.Advance(stage, step / 10.0, now);
-                progress.Tick(now + 250);
-                assert(progress.value >= previous && progress.value < 100);
-                previous = progress.value;
-                if (stage == 2 && step == 5) assert(progress.value == 58.5);
-            }
+        for (int step = 0; step <= 10; ++step) {
+            now += interval;
+            progress.Advance(1, step / 10.0, now);
+            progress.Tick(now + 250);
+            assert(progress.value >= previous && progress.value < 100);
+            previous = progress.value;
+            if (step == 5) assert(progress.value == 48);
         }
-        assert(progress.value == 92);
-        progress.Advance(2, 0.2, now + 300); // Retry or a revised Shell work total.
-        progress.Advance(1, 1, now + 400);
-        assert(progress.stage == 2 && progress.value == 92);
+        assert(progress.value == 94);
+        progress.Advance(1, 0.2, now + 300);
+        progress.Advance(0, 1, now + 400);
+        assert(progress.stage == 1 && progress.value == 94);
+        progress.Advance(2, 0, now + 450);
         progress.Advance(3, 0, now + 500);
         progress.Advance(4, 0, now + 600);
         progress.Advance(4, 0, now + 1000000);
@@ -30,19 +46,19 @@ int main() {
         assert(progress.CaptionStage() == 4);
     }
 
-    // A stalled copy does not creep toward completion merely because time passes.
+    // A stalled extractor does not creep toward completion merely because time passes.
     InstallProgress stalled(0);
-    stalled.Advance(2, 0.5, 0);
-    stalled.Advance(2, 0.5, 1000000);
-    assert(stalled.value == 58.5);
+    stalled.Advance(1, 0.5, 0);
+    stalled.Advance(1, 0.5, 1000000);
+    assert(stalled.value == 48);
     assert(!stalled.succeeded);
 
     // Replay the recording's short cleanup and also a worker finishing between UI ticks.
     for (std::uint64_t cleanup : {0ULL, 2200ULL}) {
         InstallProgress progress(0);
-        progress.Advance(1, 1, 25000);
-        progress.Advance(2, 0, 25300);
-        progress.Advance(2, 0.99, 53000);
+        progress.Advance(1, 0.5, 25000);
+        progress.Advance(1, 1, 53000);
+        progress.Advance(2, 0, 53100);
         progress.Advance(3, 0, 53250);
         progress.Advance(4, 0, 53500);
         const auto done = 53500 + cleanup;

+ 3 - 5
apps/desktop/tests/windows-installer-smoke.ps1

@@ -119,9 +119,6 @@ function Run-Silent([string]$Arguments, [int]$Code) {
     if ($process.ExitCode -ne $Code) { throw "Silent setup returned $($process.ExitCode), expected $Code" }
 }
 try {
-    $copySmoke = Start-Process -FilePath (Join-Path $OutputDirectory 'copy-smoke.exe') -PassThru -WindowStyle Hidden
-    $processes.Add($copySmoke)
-    if (-not $copySmoke.WaitForExit(60000) -or $copySmoke.ExitCode -ne 0) { throw 'NSIS copy hook did not preserve registers, stack, or error flags' }
     $process = Start-Setup light
     $window = [InstallerCapture]::Find($process.Id)
     [void][InstallerCapture]::Save($window, (Join-Path $OutputDirectory 'light-welcome.png'))
@@ -172,7 +169,8 @@ try {
     $window = [InstallerCapture]::Find($process.Id)
     $source = [InstallerCapture]::FindClass($window, 'msctls_progress32')
     if ($source -eq [IntPtr]::Zero) { throw 'Stock progress source is missing' }
-    # The running-app dialog holds the worker while native range/position resets are replayed.
+    # Directory staging finishes before the running-app prompt; promotion has not started.
+    if ([InstallerCapture]::GetProp($window, 'HarnessInstaller.Stage').ToInt32() -ne 1) { throw 'Running-app prompt reached the wrong installation stage' }
     $previous = [InstallerCapture]::Progress($window)
     foreach ($sample in @(@(100, 95), @(100, 59), @(1000, 0), @(1000, 950), @(100, 59), @(100, 100))) {
         [void][InstallerCapture]::SendMessage($source, 0x406, [IntPtr]::Zero, [IntPtr]$sample[0])
@@ -181,7 +179,7 @@ try {
         if ($percent -lt $previous -or $percent -ge 100) { throw "Progress regressed or completed before success: $previous -> $percent" }
         $previous = $percent
     }
-    if ($previous -ge 2) { throw 'Internal progress escaped the preparation stage' }
+    if ($previous -gt 94) { throw 'Internal progress escaped the extraction stage' }
     $results.Add('progress-remains-monotonic-across-native-resets')
     [void][InstallerCapture]::Save([InstallerCapture]::Find($process.Id), (Join-Path $OutputDirectory 'dark-progress.png'))
     Dismiss $process $copy.INSTALLER_RUNNING

+ 0 - 33
patches/app-builder-lib@26.15.3.patch

@@ -1,33 +0,0 @@
-diff --git a/templates/nsis/include/extractAppPackage.nsh b/templates/nsis/include/extractAppPackage.nsh
---- a/templates/nsis/include/extractAppPackage.nsh
-+++ b/templates/nsis/include/extractAppPackage.nsh
-@@ -96,3 +96,13 @@
-   SetOutPath "$PLUGINSDIR\7z-out"
--  Nsis7z::Extract "${FILE}"
-+  !ifmacrodef customInstallerExtractStart
-+    !insertmacro customInstallerExtractStart
-+  !endif
-+  !ifmacrodef customInstallerExtract
-+    !insertmacro customInstallerExtract "${FILE}"
-+  !else
-+    Nsis7z::Extract "${FILE}"
-+  !endif
-+  !ifmacrodef customInstallerCopyStart
-+    !insertmacro customInstallerCopyStart
-+  !endif
-   Pop $R0
-@@ -107,3 +117,7 @@
-     # Attempt to copy files in atomic way
--    CopyFiles /SILENT "$PLUGINSDIR\7z-out\*" $OUTDIR
-+    !ifmacrodef customInstallerCopyFiles
-+      !insertmacro customInstallerCopyFiles "$PLUGINSDIR\7z-out" "$OUTDIR"
-+    !else
-+      CopyFiles /SILENT "$PLUGINSDIR\7z-out\*" $OUTDIR
-+    !endif
-     IfErrors 0 DoneExtract7za
-@@ -137,2 +151,5 @@
-   DoneExtract7za:
-+  !ifmacrodef customInstallerFilesReady
-+    !insertmacro customInstallerFilesReady
-+  !endif
- !macroend

+ 5 - 6
pnpm-lock.yaml

@@ -11,7 +11,6 @@ overrides:
 patchedDependencies:
   '@electron/osx-sign@1.3.3': 280e7b2816caf01423097d24e79107a66afeb563a348630ce9928ae8499a5861
   '@yao-pkg/pkg@6.21.0': 28edd2180c36691c481522ef81f6f6614505f45e491aad542ac4663d4e6b3ff8
-  app-builder-lib@26.15.3: ab374976503fcb79920dcade30b5212f6a65f1ee4b64f3eaeffd3accdf41afcc
   node-pty@1.2.0-beta.15: b40ae545608897914bd25fb009c97eeac478c34e8a910298ddcb01b746534bb0
 
 importers:
@@ -520,7 +519,7 @@ importers:
         version: 7.8.0
       app-builder-lib:
         specifier: 26.15.3
-        version: 26.15.3(patch_hash=ab374976503fcb79920dcade30b5212f6a65f1ee4b64f3eaeffd3accdf41afcc)(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3)
+        version: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3)
       electron:
         specifier: ^44.0.0
         version: 44.0.0
@@ -21193,7 +21192,7 @@ snapshots:
 
   anynum@1.0.0: {}
 
-  app-builder-lib@26.15.3(patch_hash=ab374976503fcb79920dcade30b5212f6a65f1ee4b64f3eaeffd3accdf41afcc)(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3):
+  app-builder-lib@26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3):
     dependencies:
       '@electron/asar': 3.4.1
       '@electron/fuses': 1.8.0
@@ -21884,7 +21883,7 @@ snapshots:
 
   dmg-builder@26.15.3(electron-builder-squirrel-windows@26.15.3):
     dependencies:
-      app-builder-lib: 26.15.3(patch_hash=ab374976503fcb79920dcade30b5212f6a65f1ee4b64f3eaeffd3accdf41afcc)(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3)
+      app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3)
       builder-util: 26.15.3
       fs-extra: 10.1.0
       js-yaml: 4.3.1
@@ -21951,7 +21950,7 @@ snapshots:
 
   electron-builder-squirrel-windows@26.15.3(dmg-builder@26.15.3):
     dependencies:
-      app-builder-lib: 26.15.3(patch_hash=ab374976503fcb79920dcade30b5212f6a65f1ee4b64f3eaeffd3accdf41afcc)(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3)
+      app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3)
       builder-util: 26.15.3
       electron-winstaller: 5.4.0
     transitivePeerDependencies:
@@ -21960,7 +21959,7 @@ snapshots:
 
   electron-builder@26.15.3(electron-builder-squirrel-windows@26.15.3):
     dependencies:
-      app-builder-lib: 26.15.3(patch_hash=ab374976503fcb79920dcade30b5212f6a65f1ee4b64f3eaeffd3accdf41afcc)(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3)
+      app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3)
       builder-util: 26.15.3
       builder-util-runtime: 9.7.0
       chalk: 4.1.2

+ 0 - 1
pnpm-workspace.yaml

@@ -82,5 +82,4 @@ minimumReleaseAgeExclude:
 patchedDependencies:
   '@electron/osx-sign@1.3.3': patches/@electron__osx-sign@1.3.3.patch
   '@yao-pkg/pkg@6.21.0': patches/@yao-pkg__pkg@6.21.0.patch
-  app-builder-lib@26.15.3: patches/app-builder-lib@26.15.3.patch
   node-pty@1.2.0-beta.15: patches/node-pty@1.2.0-beta.15.patch