Procházet zdrojové kódy

fix(desktop): track installer work and complete progress smoothly

winewill před 2 týdny
rodič
revize
188f72cd58

+ 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: b0201cd2fb93122bd0894295988c324c4fd70f57
-2026-09-10-windows-native-installer-pages.zh.md: 6a8232d070ffe069710f1cd86faaaf8108aae668
+2026-09-10-windows-native-installer-pages.md: 318b6164acd0cde7e254e338b65fc3e5470f81c5
+2026-09-10-windows-native-installer-pages.zh.md: 2775c6e6f06aad3a93e4201babf4085e02d8d7dd

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

@@ -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 three optional extraction/copy notifications while preserving the stock worker. Stage callbacks preserve NSIS registers, stack, and error flags. The helper ignores the reused internal progress control, starts at zero, and bounds a monotonic time estimate within each real worker stage. Temporary-file cleanup receives its own progress interval; no timer marks installation complete. 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 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.
 
-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. A deterministic native progress test covers slow copy, cleanup, and stale stage notifications. 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 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.

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

@@ -26,6 +26,6 @@ NSIS 原生控件保留目录编辑、文件夹选择、复选状态和键盘交
 
 ## Consequences
 
-Windows 打包额外要求 x86 Visual C++ 编译器和 Windows SDK。辅助库使用与其他 Windows 产物相同的签名器。准备钩子在所有平台返回 true:electron-builder 会将假值视为依赖由外部管理,从而跳过生产 node_modules 的收集。锁定版本的 app-builder-lib 补丁增加三个可选解压和复制通知,保留原生工作线程。阶段回调保留 NSIS 寄存器、栈和错误标志。辅助库忽略内部复用的进度控件,从零开始,在实际工作阶段内限制单调的时间估算值。临时文件清理拥有独立的进度区间;计时器不会宣告安装完成。窗口框架在品牌控件准备完成前保持隐藏,且只初始化一次,页面切换保留其位置。点击完成后先隐藏窗口,再以当前用户直接创建已安装应用的进程;显式提权的安装程序保留通过用户桌面启动的方式。启动失败会恢复完成页。应用启动耗时与安装窗口关闭分别处理。
+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 毫秒;计时器不能宣告成功。窗口框架在品牌控件准备完成前保持隐藏,且只初始化一次,页面切换保留其位置。点击完成后先隐藏窗口,再以当前用户直接创建已安装应用的进程;显式提权的安装程序保留通过用户桌面启动的方式。启动失败会恢复完成页。应用启动耗时与安装窗口关闭分别处理。
 
-原生安装回归使用独立产品身份和私有安装目录,验证路径拒绝、文件夹选择、启动选项、升级、运行中进程保留、原生进度条隐藏、首次显示时就绪和卸载。确定性的原生进度测试覆盖慢速复制、清理和过期阶段通知。截图和预期行为归属 Desktop 测试,不放入录制 Session 快照。签名发布仍需使用已配置的证书、Token 和真实 Windows 更新产物进行验证。
+原生安装回归使用独立产品身份和私有安装目录,验证路径拒绝、文件夹选择、启动选项、升级、运行中进程保留、原生进度条隐藏、首次显示时就绪和卸载。确定性的原生进度测试覆盖快速和慢速复制、停滞、总量修订、短暂清理,以及两次界面刷新之间成功的情况。原生文件测试覆盖递归复制、Unicode 路径、隐藏文件、覆盖,以及文件占用失败后重试。截图和预期行为归属 Desktop 测试,不放入录制 Session 快照。签名发布仍需使用已配置的证书、Token 和真实 Windows 更新产物进行验证。

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

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

+ 1 - 1
apps/desktop/README.md

@@ -161,7 +161,7 @@ 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. Estimated progress starts at zero and advances within the current extraction, copy, registration, or cleanup stage; it never reaches completion before installation succeeds. 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 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.
 
 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.
 

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

@@ -161,7 +161,7 @@ pnpm run package:desktop:win:x64:unsigned
 
 Windows 安装程序使用原生 NSIS 页面,提供亮暗配色、系统阴影、可编辑的安装目录,以及默认勾选立即启动的完成页。安装仅面向当前用户。点击安装或按 Enter 均校验当前路径;新安装位置必须为空,非空位置必须是已登记的安装目录。受影响安装路径中的程序运行时显示系统提示,并保持应用运行;其他目录中的同名应用不阻止安装。静默更新最多等待受影响应用退出十秒,若仍在运行则以退出码 2 结束。
 
-主题在启动时跟随 Windows;可用 `/THEME=light`、`/THEME=dark` 和 `/THEME=auto` 显式选择配色。窗口在品牌控件准备完成后显示。估算进度从零开始,在当前解压、复制、注册或清理阶段内推进;安装成功前不会显示完成。完成页保留窗口位置。点击完成后,安装程序先隐藏窗口,再启动已安装的可执行文件;启动失败会恢复页面以供重试。electron-builder 负责解压、移除已有版本、注册表、快捷方式和卸载程序;安装失败不承诺完整的事务回滚。首次启动的配置档案准备仍属于独立的 Desktop 操作。
+主题在启动时跟随 Windows;可用 `/THEME=light`、`/THEME=dark` 和 `/THEME=auto` 显式选择配色。窗口在品牌控件准备完成后显示。进度使用解压反馈和 Windows Shell 的复制工作量估算;准备、注册和清理仍使用有界估算。加权百分比不代表剩余时间。NSIS 报告成功后,进度条用 600 毫秒补满并短暂显示 100%,再显示完成页;切换目标时长为 750 毫秒。完成页保留窗口位置。点击完成后,安装程序先隐藏窗口,再启动已安装的可执行文件;启动失败会恢复页面以供重试。electron-builder 负责解压、移除已有版本、注册表、快捷方式和卸载程序;安装失败不承诺完整的事务回滚。首次启动的配置档案准备仍属于独立的 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 接入方式和发布验证要求。
 

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

@@ -0,0 +1,90 @@
+// 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;
+}

+ 4 - 0
apps/desktop/installer/lifecycle.nsh

@@ -79,6 +79,10 @@ Function .onInstFailed
 FunctionEnd
 
 Function InstallerFinish
+    System::Call '$PLUGINSDIR\window-frame.dll::InstallerFinishProgress(p $InstallerProgressWindow) i.r0 ?c'
+    ${If} $0 == 0
+        Quit
+    ${EndIf}
     System::Call 'user32::DestroyWindow(p $InstallerProgressWindow)'
     StrCpy $InstallerPhase "success"
     Call InstallerCreate

+ 45 - 16
apps/desktop/installer/progress.h

@@ -1,4 +1,4 @@
-// Stage estimates never announce the next operation before the worker reaches it.
+// Real worker fractions drive the long stages; short stages retain bounded estimates.
 #pragma once
 #include <algorithm>
 #include <cmath>
@@ -7,26 +7,55 @@
 struct InstallProgress {
     int stage = 0;
     double value = 0;
+    double target = 0;
+    double from = 0;
     std::uint64_t stageStarted;
-    std::uint64_t updated;
+    std::uint64_t animationStarted;
+    bool succeeded = false;
 
-    explicit InstallProgress(std::uint64_t now) : stageStarted(now), updated(now) {}
+    explicit InstallProgress(std::uint64_t now) : stageStarted(now), animationStarted(now) {}
 
-    void Advance(int nextStage, std::uint64_t now) {
-        if (nextStage > stage && nextStage <= 4) {
+    void Tick(std::uint64_t now) {
+        const double duration = succeeded ? 600.0 : 250.0;
+        const double fraction = std::min(1.0, (now - animationStarted) / duration);
+        value = from + (target - from) * fraction;
+    }
+
+    void Advance(int nextStage, double fraction, std::uint64_t now) {
+        Tick(now);
+        if (succeeded || nextStage < stage || nextStage > 4) return;
+        if (nextStage > stage) {
             stage = nextStage;
             stageStarted = now;
         }
-        const double boundaries[] = {0, 2, 22, 92, 94, 100};
-        const double seconds[] = {3, 33, 120, 1, 11};
-        const double elapsed = (now - stageStarted) / 1000.0;
-        // File copy and cleanup expose no byte callbacks. Approach the stage limit
-        // gradually, leaving completion to the worker rather than a timer.
-        const double ratio = elapsed / seconds[stage];
-        const double fraction = ratio <= 0.95 ? ratio : 1 - 0.05 * std::exp(-(ratio - 0.95));
-        const double target = boundaries[stage] + (boundaries[stage + 1] - boundaries[stage]) * fraction;
-        const double bounded = std::min(boundaries[stage + 1] - 0.1, target);
-        value = std::min(std::max(value, bounded), value + (now - updated) / 1000.0 * 10);
-        updated = 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;
+            fraction = 1 - std::exp(-static_cast<double>(now - stageStarted) / (seconds * 1000));
+        }
+        const double next = boundaries[stage] + (boundaries[stage + 1] - boundaries[stage]) *
+            std::max(0.0, std::min(1.0, fraction));
+        if (next > target) {
+            from = value;
+            target = next;
+            animationStarted = now;
+        }
+    }
+
+    // Only the successful NSIS finish callback may authorize 100%.
+    void Complete(std::uint64_t now) {
+        if (succeeded) return;
+        Tick(now);
+        succeeded = true;
+        from = value;
+        target = 100;
+        animationStarted = now;
+    }
+
+    int CaptionStage() const {
+        const double boundaries[] = {0, 2, 25, 92, 94};
+        int caption = 0;
+        while (caption < stage && value >= boundaries[caption + 1]) ++caption;
+        return caption;
     }
 };

+ 40 - 3
apps/desktop/installer/window-frame.cpp

@@ -11,6 +11,7 @@
 #include <new>
 #include <algorithm>
 #include "progress.h"
+#include "file-copy.h"
 
 using namespace Gdiplus;
 
@@ -48,6 +49,7 @@ extern "C" __declspec(dllexport) int __cdecl InstallerFindProcess(LPCWSTR execut
 
 struct ProgressPage {
     InstallProgress progress{GetTickCount64()};
+    HWND extractionDetail;
     bool dark;
     UINT dpi;
     ULONG_PTR gdiplus;
@@ -83,7 +85,7 @@ static LRESULT CALLBACK ProgressProc(HWND window, UINT message, WPARAM wparam, L
     if (message == WM_CREATE) {
         page = static_cast<ProgressPage*>(reinterpret_cast<CREATESTRUCTW*>(lparam)->lpCreateParams);
         SetWindowLongPtrW(window, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(page));
-        SetTimer(window, 1, 100, nullptr);
+        SetTimer(window, 1, 16, nullptr);
     }
     if (message == WM_ERASEBKGND) return 1;
     if (message == WM_TIMER) { InvalidateRect(window, nullptr, FALSE); return 0; }
@@ -109,7 +111,16 @@ 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")));
-            page->progress.Advance(stage, GetTickCount64());
+            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;
+            }
+            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));
             SolidBrush ink(page->dark ? Color(255, 255, 255, 255) : Color(255, 15, 17, 21));
@@ -121,7 +132,7 @@ static LRESULT CALLBACK ProgressProc(HWND window, UINT message, WPARAM wparam, L
             centered.SetAlignment(StringAlignmentCenter);
             centered.SetLineAlignment(StringAlignmentCenter);
             WCHAR caption[160];
-            wsprintfW(caption, page->captions[page->progress.stage], percent);
+            wsprintfW(caption, page->captions[page->progress.CaptionStage()], percent);
             SetWindowTextW(window, caption);
             graphics.DrawString(caption, -1, &font, RectF(48, 512, 504, 22), &centered, &ink);
             Font controls(&family, 16, FontStyleRegular, UnitPixel);
@@ -131,6 +142,7 @@ static LRESULT CALLBACK ProgressProc(HWND window, UINT message, WPARAM wparam, L
             screen.DrawImage(&buffer, 0, 0);
         }
         EndPaint(window, &paint);
+        if (page->progress.value == 100) SetPropW(GetParent(window), L"HarnessInstaller.CompletedPercent", reinterpret_cast<HANDLE>(100));
         return 0;
     }
     if (message == WM_NCDESTROY && page) {
@@ -163,6 +175,7 @@ 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);
@@ -176,6 +189,30 @@ extern "C" __declspec(dllexport) HWND __cdecl InstallerShowProgress(HWND parent,
     return window;
 }
 
+// NSIS has already reported success. Pump the UI for the bounded final animation,
+// including a painted 100% frame, before constructing the interactive finish page.
+extern "C" __declspec(dllexport) BOOL __cdecl InstallerFinishProgress(HWND window) {
+    auto* page = reinterpret_cast<ProgressPage*>(GetWindowLongPtrW(window, GWLP_USERDATA));
+    if (!page) return FALSE;
+    const ULONGLONG started = GetTickCount64();
+    SetPropW(GetParent(window), L"HarnessInstaller.Succeeded", reinterpret_cast<HANDLE>(1));
+    page->progress.Complete(started);
+    while (IsWindow(window) && GetTickCount64() - started < 750) {
+        MSG message;
+        if (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) {
+            if (message.message == WM_QUIT) { PostQuitMessage(static_cast<int>(message.wParam)); return FALSE; }
+            TranslateMessage(&message);
+            DispatchMessageW(&message);
+        } else {
+            MsgWaitForMultipleObjectsEx(0, nullptr, 16, QS_ALLINPUT, MWMO_INPUTAVAILABLE);
+        }
+    }
+    if (!IsWindow(window)) return FALSE;
+    InvalidateRect(window, nullptr, FALSE);
+    UpdateWindow(window);
+    return TRUE;
+}
+
 static LRESULT CALLBACK FrameProc(HWND window, UINT message, WPARAM wparam,
                                  LPARAM lparam, UINT_PTR id, DWORD_PTR) {
     if (message == WM_NCCALCSIZE && wparam) return 0;

+ 27 - 1
apps/desktop/scripts/installer.nsh

@@ -82,7 +82,7 @@
   Page custom InstallerFinish InstallerFinishLeave
 !macroend
 
-; The pinned builder's extraction hooks only publish stages; its worker owns all file operations.
+; The pinned builder's worker publishes stages around its extraction and copy operations.
 !macro InstallerPublishStage Stage
   ; Extraction owns the stack and error flag across these callbacks.
   Push $0
@@ -105,10 +105,36 @@
   !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}
+  System::Store /NOUNLOAD "S"
+  System::Call /NOUNLOAD '$PLUGINSDIR\window-frame.dll::InstallerCopyFiles(p $HWNDPARENT, w "${Source}", w "${Destination}") i.s ?c'
+  System::Store "L"
+  Pop $1
+  ${If} $1 < 0
+  ${OrIf} $0 == 1
+    SetErrors
+  ${Else}
+    ClearErrors
+  ${EndIf}
+  Pop $1
+  Pop $0
+!macroend
+
 !macro customInstallerFilesReady
   !insertmacro InstallerPublishStage 3
 !macroend

+ 11 - 1
apps/desktop/scripts/prepare-windows-installer.ps1

@@ -14,7 +14,7 @@ $visualStudio = & $vswhere -latest -products '*' -requires Microsoft.VisualStudi
 if (-not $visualStudio) { throw 'Visual Studio C++ Build Tools are missing.' }
 $vcvars = Join-Path $visualStudio 'VC/Auxiliary/Build/vcvars32.bat'
 $compileScript = Join-Path $output 'compile-frame.cmd'
-$compileLines = @('@echo off', ('call "{0}" >nul' -f $vcvars), 'if errorlevel 1 exit /b %errorlevel%', ('cl /nologo /LD /MT /O1 /W4 /WX /EHsc "{0}" /Fo"{1}" /link /OUT:"{2}" /IMPLIB:"{3}" user32.lib comctl32.lib dwmapi.lib gdiplus.lib' -f $source, (Join-Path $output 'window-frame.obj'), $library, (Join-Path $output 'window-frame.lib')))
+$compileLines = @('@echo off', ('call "{0}" >nul' -f $vcvars), 'if errorlevel 1 exit /b %errorlevel%', ('cl /nologo /LD /MT /O1 /W4 /WX /EHsc "{0}" /Fo"{1}" /link /OUT:"{2}" /IMPLIB:"{3}" user32.lib comctl32.lib dwmapi.lib gdiplus.lib ole32.lib shell32.lib uuid.lib' -f $source, (Join-Path $output 'window-frame.obj'), $library, (Join-Path $output 'window-frame.lib')))
 [IO.File]::WriteAllLines($compileScript, $compileLines, [Text.Encoding]::Default)
 & $env:ComSpec /d /c $compileScript
 if ($LASTEXITCODE -ne 0) { throw 'Native installer helper compilation failed.' }
@@ -27,6 +27,16 @@ if ($TestProgress) {
     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.' }
 }
 Add-Type -AssemblyName System.Drawing
 foreach ($asset in @('brand', 'brand-2x', 'brand-dark', 'brand-dark-2x', 'uninstaller-sidebar')) {

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

@@ -50,6 +50,9 @@ 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 } })
   const include = join(output, 'include.nsh')
   await writeFile(include, `!define INSTALLER_BUILD_DIR "${join(output, 'ui')}"\n!include "${join(appRoot, 'scripts', 'installer.nsh')}"\n`)

+ 1 - 0
apps/desktop/tests/expected/windows-installer.json

@@ -5,6 +5,7 @@
     "enter-validates-current-path-and-unchecked-launch",
     "completion-preserves-window-position",
     "welcome-ready-before-first-show",
+    "successful-install-paints-100-before-finish",
     "registered-directory-and-checked-launch",
     "launch-failure-retry-and-prompt-dismissal",
     "progress-remains-monotonic-across-native-resets",

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

@@ -0,0 +1,49 @@
+; 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

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

@@ -0,0 +1,46 @@
+// 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;
+}

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

@@ -3,25 +3,59 @@
 #include <cassert>
 
 int main() {
-    InstallProgress progress(0);
-    progress.Advance(0, 100);
-    assert(progress.value < 2);
-    progress.Advance(1, 200);
-    assert(progress.value < 2);
-    for (std::uint64_t now = 300; now <= 30200; now += 100) progress.Advance(1, now);
-    assert(progress.value > 20 && progress.value < 22);
-    progress.Advance(2, 30300);
-    double previous = progress.value;
-    for (std::uint64_t now = 30400; now <= 150300; now += 100) {
-        progress.Advance(2, now);
-        assert(progress.value >= previous && progress.value - previous <= 1.01);
-        assert(progress.value < 92);
-        previous = progress.value;
+    // 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);
+            }
+        }
+        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);
+        progress.Advance(3, 0, now + 500);
+        progress.Advance(4, 0, now + 600);
+        progress.Advance(4, 0, now + 1000000);
+        progress.Tick(now + 1000250);
+        assert(progress.value < 100);
+        assert(progress.CaptionStage() == 4);
+    }
+
+    // A stalled copy 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);
+    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(3, 0, 53250);
+        progress.Advance(4, 0, 53500);
+        const auto done = 53500 + cleanup;
+        progress.Complete(done);
+        double previous = progress.value;
+        for (std::uint64_t elapsed = 0; elapsed < 600; elapsed += 16) {
+            progress.Advance(4, 1, done + elapsed);
+            assert(progress.value >= previous && progress.value < 100);
+            previous = progress.value;
+        }
+        progress.Tick(done + 600);
+        assert(progress.value == 100);
+        progress.Complete(done + 1000);
+        assert(progress.value == 100);
     }
-    progress.Advance(1, 150400);
-    assert(progress.stage == 2 && progress.value >= previous);
-    progress.Advance(3, 150500);
-    progress.Advance(4, 150600);
-    for (std::uint64_t now = 150700; now <= 300600; now += 100) progress.Advance(4, now);
-    assert(progress.value > 98 && progress.value < 100);
 }

+ 19 - 1
apps/desktop/tests/windows-installer-smoke.ps1

@@ -59,7 +59,21 @@ function Dismiss([Diagnostics.Process]$Process, [string]$Text) {
     }
 }
 function Finish-Setup([Diagnostics.Process]$Process, [bool]$Launch, [string]$Theme, [string]$Bounds) {
-    [void](Wait-Control $Process $copy.INSTALLER_FINISH)
+    $timer = [Diagnostics.Stopwatch]::StartNew()
+    $previous = 0
+    $window = [InstallerCapture]::Find($Process.Id)
+    while ([InstallerCapture]::FindButton($Process.Id, $copy.INSTALLER_FINISH) -eq [IntPtr]::Zero) {
+        if ($Process.HasExited -or $timer.Elapsed.TotalSeconds -gt 30) { throw 'Installer did not complete' }
+        $visible = [InstallerCapture]::VisibleText($Process.Id)
+        if ($visible -match 'HarnessInstallerProgress[^\r\n]*?(\d+)%') {
+            $percent = [int]$Matches[1]
+            if ($percent -lt $previous) { throw 'Installation progress went backwards' }
+            if ($percent -eq 100 -and [InstallerCapture]::GetProp($window, 'HarnessInstaller.Succeeded') -eq [IntPtr]::Zero) { throw 'Installation showed 100% before success' }
+            $previous = $percent
+        }
+        Start-Sleep -Milliseconds 25
+    }
+    if ([InstallerCapture]::GetProp($window, 'HarnessInstaller.CompletedPercent').ToInt32() -ne 100) { throw 'Finish page replaced an incomplete progress bar' }
     $checkbox = Wait-Control $Process $copy.INSTALLER_LAUNCH
     $state = [InstallerCapture]::SendMessage($checkbox, 0xF0, [IntPtr]::Zero, [IntPtr]::Zero).ToInt32()
     if ($state -ne $expected.launchCheckboxState) { throw 'Unexpected launch checkbox default' }
@@ -105,6 +119,9 @@ 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'))
@@ -126,6 +143,7 @@ try {
     $results.Add('enter-validates-current-path-and-unchecked-launch')
     $results.Add('completion-preserves-window-position')
     $results.Add('welcome-ready-before-first-show')
+    $results.Add('successful-install-paints-100-before-finish')
 
     $process = Start-Setup dark ''
     Click-Control $process $copy.INSTALLER_CHOOSE_PATH

+ 17 - 3
patches/app-builder-lib@26.15.3.patch

@@ -1,17 +1,31 @@
 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,9 @@
+@@ -96,3 +96,13 @@
    SetOutPath "$PLUGINSDIR\7z-out"
+-  Nsis7z::Extract "${FILE}"
 +  !ifmacrodef customInstallerExtractStart
 +    !insertmacro customInstallerExtractStart
 +  !endif
-   Nsis7z::Extract "${FILE}"
++  !ifmacrodef customInstallerExtract
++    !insertmacro customInstallerExtract "${FILE}"
++  !else
++    Nsis7z::Extract "${FILE}"
++  !endif
 +  !ifmacrodef customInstallerCopyStart
 +    !insertmacro customInstallerCopyStart
 +  !endif
    Pop $R0
-@@ -137,2 +143,5 @@
+@@ -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

+ 6 - 6
pnpm-lock.yaml

@@ -11,7 +11,7 @@ overrides:
 patchedDependencies:
   '@electron/osx-sign@1.3.3': 280e7b2816caf01423097d24e79107a66afeb563a348630ce9928ae8499a5861
   '@yao-pkg/pkg@6.21.0': 28edd2180c36691c481522ef81f6f6614505f45e491aad542ac4663d4e6b3ff8
-  app-builder-lib@26.15.3: 3ddc3eeae0ba15e63d2d40fc58f39b1e4773fe76c52d3ad410138f05b56c294e
+  app-builder-lib@26.15.3: ab374976503fcb79920dcade30b5212f6a65f1ee4b64f3eaeffd3accdf41afcc
   node-pty@1.2.0-beta.15: b40ae545608897914bd25fb009c97eeac478c34e8a910298ddcb01b746534bb0
 
 importers:
@@ -520,7 +520,7 @@ importers:
         version: 7.8.0
       app-builder-lib:
         specifier: 26.15.3
-        version: 26.15.3(patch_hash=3ddc3eeae0ba15e63d2d40fc58f39b1e4773fe76c52d3ad410138f05b56c294e)(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3)
+        version: 26.15.3(patch_hash=ab374976503fcb79920dcade30b5212f6a65f1ee4b64f3eaeffd3accdf41afcc)(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3)
       electron:
         specifier: ^44.0.0
         version: 44.0.0
@@ -21193,7 +21193,7 @@ snapshots:
 
   anynum@1.0.0: {}
 
-  app-builder-lib@26.15.3(patch_hash=3ddc3eeae0ba15e63d2d40fc58f39b1e4773fe76c52d3ad410138f05b56c294e)(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3):
+  app-builder-lib@26.15.3(patch_hash=ab374976503fcb79920dcade30b5212f6a65f1ee4b64f3eaeffd3accdf41afcc)(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 +21884,7 @@ snapshots:
 
   dmg-builder@26.15.3(electron-builder-squirrel-windows@26.15.3):
     dependencies:
-      app-builder-lib: 26.15.3(patch_hash=3ddc3eeae0ba15e63d2d40fc58f39b1e4773fe76c52d3ad410138f05b56c294e)(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3)
+      app-builder-lib: 26.15.3(patch_hash=ab374976503fcb79920dcade30b5212f6a65f1ee4b64f3eaeffd3accdf41afcc)(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 +21951,7 @@ snapshots:
 
   electron-builder-squirrel-windows@26.15.3(dmg-builder@26.15.3):
     dependencies:
-      app-builder-lib: 26.15.3(patch_hash=3ddc3eeae0ba15e63d2d40fc58f39b1e4773fe76c52d3ad410138f05b56c294e)(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3)
+      app-builder-lib: 26.15.3(patch_hash=ab374976503fcb79920dcade30b5212f6a65f1ee4b64f3eaeffd3accdf41afcc)(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 +21960,7 @@ snapshots:
 
   electron-builder@26.15.3(electron-builder-squirrel-windows@26.15.3):
     dependencies:
-      app-builder-lib: 26.15.3(patch_hash=3ddc3eeae0ba15e63d2d40fc58f39b1e4773fe76c52d3ad410138f05b56c294e)(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3)
+      app-builder-lib: 26.15.3(patch_hash=ab374976503fcb79920dcade30b5212f6a65f1ee4b64f3eaeffd3accdf41afcc)(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