Просмотр исходного кода

fix(desktop): align installer progress with worker stages

winewill 1 месяц назад
Родитель
Сommit
6cc34319e5

+ 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: df61a25acfe769212e6192937a480a65ba4d132a
-2026-09-10-windows-native-installer-pages.zh.md: d1c465d1ec84c0535fd764851bc54830e6d627c5
+2026-09-10-windows-native-installer-pages.md: b0201cd2fb93122bd0894295988c324c4fd70f57
+2026-09-10-windows-native-installer-pages.zh.md: 6a8232d070ffe069710f1cd86faaaf8108aae668

+ 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. Stock NSIS and extraction plugins reuse the progress control, so the estimate retains its displayed maximum and caps at 95% until success. The frame 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 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.
 
-The native installer regression uses a unique product identity and private installation directory to verify path rejection, folder selection, launch choices, upgrade, running-process preservation, hidden stock progress, and uninstall. Its screenshots and expected behavior belong to Desktop tests rather than recorded Session snapshots. Signed release qualification still requires the configured certificate and token, and actual Windows update artifacts.
+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.

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

+ 1 - 0
THIRD_PARTY_NOTICES.md

@@ -116,6 +116,7 @@ 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: da4bcf805c95f25198399c0ac8695a011c4bdc01
-README.zh.md: b462269c226c75d6f1e9c2a9924ecae32804b3f6
+README.md: 27988b195c6b3611cda3d5fc04b8a33a4e67a951
+README.zh.md: d3bf48afc9ccfc7b10d90f1e96df5f8a83aedff4

+ 1 - 1
apps/desktop/README.md

@@ -157,7 +157,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. Estimated progress retains its displayed maximum across internal resets and stays below completion until 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. 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.
 
 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

@@ -157,7 +157,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` 显式选择配色。窗口在品牌控件准备完成后显示。估算进度从零开始,在当前解压、复制、注册或清理阶段内推进;安装成功前不会显示完成。完成页保留窗口位置。点击完成后,安装程序先隐藏窗口,再启动已安装的可执行文件;启动失败会恢复页面以供重试。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 接入方式和发布验证要求。
 

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

@@ -1,7 +1,8 @@
 Var InstallerProgressWindow
 
 Function InstallerGuiInit
-    System::Call 'user32::SetWindowLongW(p $HWNDPARENT, i -16, i 0x900A0000)'
+    HideWindow
+    System::Call 'user32::SetWindowLongW(p $HWNDPARENT, i -16, i 0x800A0000)'
     System::Call 'user32::GetDC(p $HWNDPARENT) p.r0'
     System::Call 'gdi32::GetDeviceCaps(p r0, i 88) i.s'
     Pop $InstallerDpi
@@ -60,7 +61,7 @@ Function InstallerProgressShow
         StrCpy $0 1
         StrCpy $1 "$PLUGINSDIR\brand-dark-2x.bmp"
     ${EndIf}
-    System::Call '$PLUGINSDIR\window-frame.dll::InstallerShowProgress(p $HWNDPARENT, p $mui.InstFilesPage.ProgressBar, i r0, i $InstallerDpi, w r1, w "$(INSTALLER_NATIVE_PROGRESS)") p.s ?c'
+    System::Call '$PLUGINSDIR\window-frame.dll::InstallerShowProgress(p $HWNDPARENT, p $mui.InstFilesPage.ProgressBar, i r0, i $InstallerDpi, w r1, w "$(INSTALLER_NATIVE_PROGRESS)", w "$(INSTALLER_PROGRESS_EXTRACT)", w "$(INSTALLER_PROGRESS_COPY)", w "$(INSTALLER_PROGRESS_REGISTER)", w "$(INSTALLER_PROGRESS_CLEAN)") p.s ?c'
     Pop $InstallerProgressWindow
     ${If} $InstallerProgressWindow == 0
         MessageBox MB_OK|MB_ICONSTOP "$(INSTALLER_UI_ERROR)"
@@ -68,6 +69,7 @@ Function InstallerProgressShow
         Quit
     ${EndIf}
     System::Call 'user32::SetPropW(p $HWNDPARENT, w "HarnessInstaller.Ready", p 1)'
+    ShowWindow $HWNDPARENT 5
 FunctionEnd
 
 Function .onInstFailed

+ 2 - 0
apps/desktop/installer/pages.nsh

@@ -165,6 +165,8 @@ Function InstallerCreate
     ${NSD_OnNotify} $InstallerButton InstallerPaintButton
     Call InstallerRender
     System::Call 'user32::SetPropW(p $HWNDPARENT, w "HarnessInstaller.Ready", p 1)'
+    ShowWindow $InstallerDialog 5
+    ShowWindow $HWNDPARENT 5
     nsDialogs::Show
     ${NSD_KillTimer} InstallerValidateEditedPath
     ${NSD_FreeImage} $InstallerImage

+ 32 - 0
apps/desktop/installer/progress.h

@@ -0,0 +1,32 @@
+// Stage estimates never announce the next operation before the worker reaches it.
+#pragma once
+#include <algorithm>
+#include <cmath>
+#include <cstdint>
+
+struct InstallProgress {
+    int stage = 0;
+    double value = 0;
+    std::uint64_t stageStarted;
+    std::uint64_t updated;
+
+    explicit InstallProgress(std::uint64_t now) : stageStarted(now), updated(now) {}
+
+    void Advance(int nextStage, std::uint64_t now) {
+        if (nextStage > stage && nextStage <= 4) {
+            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;
+    }
+};

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

@@ -3,8 +3,16 @@ LangString INSTALLER_INSTALL ${LANG_ENGLISH} "Install now"
 LangString INSTALLER_INSTALL ${LANG_SIMPCHINESE} "立即安装"
 LangString INSTALLER_FINISH ${LANG_ENGLISH} "Finish"
 LangString INSTALLER_FINISH ${LANG_SIMPCHINESE} "完成"
-LangString INSTALLER_NATIVE_PROGRESS ${LANG_ENGLISH} "Installed %d%%"
-LangString INSTALLER_NATIVE_PROGRESS ${LANG_SIMPCHINESE} "已安装 %d%%"
+LangString INSTALLER_NATIVE_PROGRESS ${LANG_ENGLISH} "Preparing installation… %d%%"
+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_REGISTER ${LANG_ENGLISH} "Finishing installation… %d%%"
+LangString INSTALLER_PROGRESS_REGISTER ${LANG_SIMPCHINESE} "正在完成安装… %d%%"
+LangString INSTALLER_PROGRESS_CLEAN ${LANG_ENGLISH} "Cleaning temporary files… %d%%"
+LangString INSTALLER_PROGRESS_CLEAN ${LANG_SIMPCHINESE} "正在清理临时文件… %d%%"
 LangString INSTALLER_CHOOSE_PATH ${LANG_ENGLISH} "Choose location"
 LangString INSTALLER_CHOOSE_PATH ${LANG_SIMPCHINESE} "选择安装位置"
 LangString INSTALLER_BROWSE ${LANG_ENGLISH} "Browse"

+ 14 - 20
apps/desktop/installer/window-frame.cpp

@@ -10,6 +10,7 @@
 #include <gdiplus.h>
 #include <new>
 #include <algorithm>
+#include "progress.h"
 
 using namespace Gdiplus;
 
@@ -46,13 +47,12 @@ extern "C" __declspec(dllexport) int __cdecl InstallerFindProcess(LPCWSTR execut
 }
 
 struct ProgressPage {
-    HWND source;
-    int percent = 20;
+    InstallProgress progress{GetTickCount64()};
     bool dark;
     UINT dpi;
     ULONG_PTR gdiplus;
     Image* brand;
-    WCHAR caption[128];
+    WCHAR captions[5][128];
 };
 
 // NSIS shows its page after MUI's SHOW callback; keep its controls off screen.
@@ -67,6 +67,8 @@ static LRESULT CALLBACK HiddenPageProc(HWND window, UINT message, WPARAM wparam,
 }
 
 static void FillProgress(Graphics& graphics, Brush& brush, REAL width) {
+    if (width <= 0) return;
+    if (width < 4) { graphics.FillRectangle(&brush, 64.0f, 482.0f, width, 6.0f); return; }
     GraphicsPath path;
     path.AddArc(64.0f, 482.0f, 4.0f, 4.0f, 180.0f, 90.0f);
     path.AddArc(64.0f + width - 4, 482.0f, 4.0f, 4.0f, 270.0f, 90.0f);
@@ -106,29 +108,20 @@ static LRESULT CALLBACK ProgressProc(HWND window, UINT message, WPARAM wparam, L
             graphics.Clear(page->dark ? Color(255, 21, 21, 23) : Color(255, 255, 255, 255));
             graphics.SetSmoothingMode(SmoothingModeAntiAlias);
             graphics.DrawImage(page->brand, Rect(0, 174, 600, 196));
-            PBRANGE range = {};
-            SendMessageW(page->source, PBM_GETRANGE, FALSE, reinterpret_cast<LPARAM>(&range));
-            const LRESULT position = SendMessageW(page->source, PBM_GETPOS, 0, 0);
-            const LONGLONG span = static_cast<LONGLONG>(range.iHigh) - range.iLow;
-            // NSIS and extraction plugins reuse/reset the same control for local work.
-            // Retain the displayed maximum; only the success page confirms completion.
-            if (span > 0) {
-                const LONGLONG value = 20 + 75 * (static_cast<LONGLONG>(position) - range.iLow) / span;
-                const int bounded = static_cast<int>(std::max<LONGLONG>(20, std::min<LONGLONG>(95, value)));
-                page->percent = std::max(page->percent, bounded);
-            }
-            const int percent = page->percent;
+            const int stage = static_cast<int>(reinterpret_cast<INT_PTR>(GetPropW(GetParent(window), L"HarnessInstaller.Stage")));
+            page->progress.Advance(stage, 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));
             FillProgress(graphics, track, 472.0f);
-            FillProgress(graphics, ink, 472.0f * percent / 100);
+            FillProgress(graphics, ink, 472.0f * static_cast<REAL>(page->progress.value) / 100);
             FontFamily family(L"Microsoft YaHei UI");
             Font font(&family, 14, FontStyleRegular, UnitPixel);
             StringFormat centered;
             centered.SetAlignment(StringAlignmentCenter);
             centered.SetLineAlignment(StringAlignmentCenter);
             WCHAR caption[160];
-            wsprintfW(caption, page->caption, percent);
+            wsprintfW(caption, page->captions[page->progress.stage], percent);
             SetWindowTextW(window, caption);
             graphics.DrawString(caption, -1, &font, RectF(48, 512, 504, 22), &centered, &ink);
             Font controls(&family, 16, FontStyleRegular, UnitPixel);
@@ -151,7 +144,8 @@ static LRESULT CALLBACK ProgressProc(HWND window, UINT message, WPARAM wparam, L
 
 // Runs on the NSIS UI thread; the stock installation section runs on its worker.
 extern "C" __declspec(dllexport) HWND __cdecl InstallerShowProgress(HWND parent, HWND source,
-        BOOL dark, UINT dpi, const WCHAR* brand, const WCHAR* caption) {
+        BOOL dark, UINT dpi, const WCHAR* brand, const WCHAR* preparing, const WCHAR* extracting,
+        const WCHAR* copying, const WCHAR* registering, const WCHAR* cleaning) {
     HINSTANCE module = GetModuleHandleW(nullptr);
     WNDCLASSW type = {};
     type.lpfnWndProc = ProgressProc;
@@ -163,7 +157,6 @@ extern "C" __declspec(dllexport) HWND __cdecl InstallerShowProgress(HWND parent,
     if (!page) return nullptr;
     GdiplusStartupInput startup;
     if (GdiplusStartup(&page->gdiplus, &startup, nullptr) != Ok) { delete page; return nullptr; }
-    page->source = source;
     HWND stockPage = GetParent(source);
     if (!SetWindowSubclass(stockPage, HiddenPageProc, 1, 0)) {
         GdiplusShutdown(page->gdiplus); delete page; return nullptr;
@@ -171,7 +164,8 @@ extern "C" __declspec(dllexport) HWND __cdecl InstallerShowProgress(HWND parent,
     ShowWindow(stockPage, SW_HIDE);
     page->dark = dark != FALSE;
     page->dpi = dpi;
-    lstrcpynW(page->caption, caption, 128);
+    const WCHAR* captions[] = {preparing, extracting, copying, registering, cleaning};
+    for (int i = 0; i < 5; ++i) lstrcpynW(page->captions[i], captions[i], 128);
     page->brand = new Image(brand);
     if (page->brand->GetLastStatus() != Ok) {
         delete page->brand; GdiplusShutdown(page->gdiplus); delete page; return nullptr;

+ 45 - 0
apps/desktop/scripts/installer.nsh

@@ -82,6 +82,37 @@
   Page custom InstallerFinish InstallerFinishLeave
 !macroend
 
+; The pinned builder's extraction hooks only publish stages; its worker owns all file operations.
+!macro InstallerPublishStage Stage
+  ; Extraction owns the stack and error flag across these callbacks.
+  Push $0
+  StrCpy $0 0
+  ${If} ${Errors}
+    StrCpy $0 1
+  ${EndIf}
+  System::Store /NOUNLOAD "S"
+  System::Call /NOUNLOAD 'user32::SetPropW(p $HWNDPARENT, w "HarnessInstaller.Stage", p ${Stage})'
+  System::Store "L"
+  ${If} $0 == 1
+    SetErrors
+  ${Else}
+    ClearErrors
+  ${EndIf}
+  Pop $0
+!macroend
+
+!macro customInstallerExtractStart
+  !insertmacro InstallerPublishStage 1
+!macroend
+
+!macro customInstallerCopyStart
+  !insertmacro InstallerPublishStage 2
+!macroend
+
+!macro customInstallerFilesReady
+  !insertmacro InstallerPublishStage 3
+!macroend
+
 !macro customCheckAppRunning
   !ifdef BUILD_UNINSTALLER
     InitPluginsDir
@@ -118,5 +149,19 @@
 !endif
 
 !macro customInstall
+  Push $0
+  StrCpy $0 0
+  ${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}
+    ClearErrors
+  ${EndIf}
+  Pop $0
 !macroend

+ 11 - 1
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)
+param([string]$OutputDirectory, [switch]$TestProgress)
 $ErrorActionPreference = 'Stop'
 $installerRoot = Join-Path $PSScriptRoot '../installer'
 if (-not $OutputDirectory) { $OutputDirectory = Join-Path $PSScriptRoot '../.desktop-build/targets/win-x64/installer-ui' }
@@ -18,6 +18,16 @@ $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) {
+    $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.' }
+}
 Add-Type -AssemblyName System.Drawing
 foreach ($asset in @('brand', 'brand-2x', 'brand-dark', 'brand-dark-2x')) {
     $image = [Drawing.Image]::FromFile((Join-Path $installerRoot "assets/$asset.png"))

+ 1 - 1
apps/desktop/scripts/test-windows-installer.mjs

@@ -34,7 +34,7 @@ try {
   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')], childOptions)
+    join(appRoot, 'scripts', 'prepare-windows-installer.ps1'), '-OutputDirectory', join(output, 'ui'), '-TestProgress'], childOptions)
   const payloadSource = join(output, 'payload.nsi')
   await writeFile(payloadSource, `Unicode true
 RequestExecutionLevel user

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

@@ -4,6 +4,7 @@
   "cases": [
     "enter-validates-current-path-and-unchecked-launch",
     "completion-preserves-window-position",
+    "welcome-ready-before-first-show",
     "registered-directory-and-checked-launch",
     "launch-failure-retry-and-prompt-dismissal",
     "progress-remains-monotonic-across-native-resets",

+ 27 - 0
apps/desktop/tests/windows-installer-progress.cpp

@@ -0,0 +1,27 @@
+// Deterministic user-visible progress scenarios; no wall-clock sleeps or file operations.
+#include "../installer/progress.h"
+#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;
+    }
+    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);
+}

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

@@ -31,14 +31,15 @@ function Wait-Control([Diagnostics.Process]$Process, [string]$Text, [switch]$Dia
 function Start-Setup([string]$Theme, [string]$Path = $installPath) {
     $arguments = '/THEME=' + $Theme
     if ($Path) { $arguments += ' /D=' + $Path }
-    $process = Start-Process -FilePath $Installer -ArgumentList $arguments -PassThru -WindowStyle Hidden
+    $process = Start-Process -FilePath $Installer -ArgumentList $arguments -PassThru -WindowStyle Normal
     $processes.Add($process)
     $timer = [Diagnostics.Stopwatch]::StartNew()
     do {
         if ($process.HasExited) { throw "Setup exited: $($process.ExitCode)" }
+        if ([InstallerCapture]::HasIncompleteWindow($process.Id)) { throw 'Installer appeared before its page was ready' }
         $window = [InstallerCapture]::Find($process.Id)
         if ($window -ne [IntPtr]::Zero) { break }
-        Start-Sleep -Milliseconds 25
+        Start-Sleep -Milliseconds 5
     } while ($timer.Elapsed.TotalSeconds -lt 30)
     [InstallerCapture]::Reveal($window)
     [void](Wait-Control $process $copy.INSTALLER_INSTALL)
@@ -71,6 +72,7 @@ function Finish-Setup([Diagnostics.Process]$Process, [bool]$Launch, [string]$The
         }
     }
     $window = [InstallerCapture]::Find($Process.Id)
+    if ([InstallerCapture]::GetProp($window, 'HarnessInstaller.Stage').ToInt32() -ne 4) { throw 'Installation did not reach cleanup' }
     if ([InstallerCapture]::Bounds($window) -ne $Bounds) { throw 'Completion page moved or resized the installer' }
     [void][InstallerCapture]::Save($window, (Join-Path $OutputDirectory ($Theme + '-finish.png')))
     if ($Launch) {
@@ -123,6 +125,7 @@ try {
     if (-not (Test-Path -LiteralPath $appPath) -or (Test-Path -LiteralPath (Join-Path $installPath 'launched.txt'))) { throw 'Unchecked launch behavior failed' }
     $results.Add('enter-validates-current-path-and-unchecked-launch')
     $results.Add('completion-preserves-window-position')
+    $results.Add('welcome-ready-before-first-show')
 
     $process = Start-Setup dark ''
     Click-Control $process $copy.INSTALLER_CHOOSE_PATH
@@ -160,7 +163,7 @@ try {
         if ($percent -lt $previous -or $percent -ge 100) { throw "Progress regressed or completed before success: $previous -> $percent" }
         $previous = $percent
     }
-    if ($previous -le 20) { throw 'Progress never advanced' }
+    if ($previous -ge 2) { throw 'Internal progress escaped the preparation 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

+ 15 - 1
apps/desktop/tests/windows-installer-ui.ps1

@@ -16,7 +16,7 @@ public static class InstallerCapture {
     [DllImport("user32.dll")] static extern bool EnumChildWindows(IntPtr parent, WindowCallback callback, IntPtr data);
     [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr window);
     [DllImport("user32.dll")] public static extern bool IsWindow(IntPtr window);
-    [DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern IntPtr GetProp(IntPtr window, string name);
+    [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern IntPtr GetProp(IntPtr window, string name);
     [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr window, out uint process);
     [DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern int GetWindowText(IntPtr window, StringBuilder text, int count);
     [DllImport("user32.dll")] static extern bool GetWindowRect(IntPtr window, out Rect rect);
@@ -51,6 +51,20 @@ public static class InstallerCapture {
             throw new InvalidOperationException("Could not move window");
     }
 
+    public static bool HasIncompleteWindow(int process) {
+        bool incomplete = false;
+        EnumWindows(delegate(IntPtr window, IntPtr unused) {
+            uint owner;
+            GetWindowThreadProcessId(window, out owner);
+            var title = new StringBuilder(256);
+            GetWindowText(window, title, title.Capacity);
+            if (owner == process && title.ToString().Contains(ProductName) && IsWindowVisible(window)
+                && GetProp(window, "HarnessInstaller.Ready") == IntPtr.Zero) incomplete = true;
+            return !incomplete;
+        }, IntPtr.Zero);
+        return incomplete;
+    }
+
     public static IntPtr FindClass(IntPtr parent, string name) {
         IntPtr result = IntPtr.Zero;
         EnumChildWindows(parent, delegate(IntPtr child, IntPtr unused) {

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

@@ -0,0 +1,19 @@
+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 @@
+   SetOutPath "$PLUGINSDIR\7z-out"
++  !ifmacrodef customInstallerExtractStart
++    !insertmacro customInstallerExtractStart
++  !endif
+   Nsis7z::Extract "${FILE}"
++  !ifmacrodef customInstallerCopyStart
++    !insertmacro customInstallerCopyStart
++  !endif
+   Pop $R0
+@@ -137,2 +143,5 @@
+   DoneExtract7za:
++  !ifmacrodef customInstallerFilesReady
++    !insertmacro customInstallerFilesReady
++  !endif
+ !macroend

+ 6 - 5
pnpm-lock.yaml

@@ -11,6 +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
   node-pty@1.2.0-beta.15: b40ae545608897914bd25fb009c97eeac478c34e8a910298ddcb01b746534bb0
 
 importers:
@@ -519,7 +520,7 @@ importers:
         version: 7.8.0
       app-builder-lib:
         specifier: 26.15.3
-        version: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3)
+        version: 26.15.3(patch_hash=3ddc3eeae0ba15e63d2d40fc58f39b1e4773fe76c52d3ad410138f05b56c294e)(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3)
       electron:
         specifier: ^44.0.0
         version: 44.0.0
@@ -21192,7 +21193,7 @@ snapshots:
 
   anynum@1.0.0: {}
 
-  app-builder-lib@26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3):
+  app-builder-lib@26.15.3(patch_hash=3ddc3eeae0ba15e63d2d40fc58f39b1e4773fe76c52d3ad410138f05b56c294e)(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3):
     dependencies:
       '@electron/asar': 3.4.1
       '@electron/fuses': 1.8.0
@@ -21883,7 +21884,7 @@ snapshots:
 
   dmg-builder@26.15.3(electron-builder-squirrel-windows@26.15.3):
     dependencies:
-      app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3)
+      app-builder-lib: 26.15.3(patch_hash=3ddc3eeae0ba15e63d2d40fc58f39b1e4773fe76c52d3ad410138f05b56c294e)(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
@@ -21950,7 +21951,7 @@ snapshots:
 
   electron-builder-squirrel-windows@26.15.3(dmg-builder@26.15.3):
     dependencies:
-      app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3)
+      app-builder-lib: 26.15.3(patch_hash=3ddc3eeae0ba15e63d2d40fc58f39b1e4773fe76c52d3ad410138f05b56c294e)(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3)
       builder-util: 26.15.3
       electron-winstaller: 5.4.0
     transitivePeerDependencies:
@@ -21959,7 +21960,7 @@ snapshots:
 
   electron-builder@26.15.3(electron-builder-squirrel-windows@26.15.3):
     dependencies:
-      app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3)
+      app-builder-lib: 26.15.3(patch_hash=3ddc3eeae0ba15e63d2d40fc58f39b1e4773fe76c52d3ad410138f05b56c294e)(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

+ 1 - 0
pnpm-workspace.yaml

@@ -82,4 +82,5 @@ 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