Merge pull request #6566 from deepseek-harness/fix/windows-installer-rendering
fix(desktop): improve Windows installer rendering and caption controls
This commit is contained in:
commit
b064af6396
10 files changed
+425
-44
No files matched your search
@@ -54,8 +54,8 @@
|
||||
en: ac31ebdd9de54f34
|
||||
zh: 921e534d5c4b3a16
|
||||
/deepseek-harness-desktop/windows-installer-interface:
|
||||
en: 8cb4b7babbd8714d
|
||||
zh: b4aebc1d2d152e01
|
||||
en: 9b90a2651a6d2b52
|
||||
zh: 86d4d9c4358aad80
|
||||
/deepseek-harness-desktop/updates:
|
||||
en: b83a37681e23a3cc
|
||||
zh: e11ab8381fa4362d
|
||||
|
||||
@@ -253,9 +253,9 @@ The Windows installer uses native NSIS pages with light and dark palettes, syste
|
||||
|
||||
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. When the welcome page first appears, the installer moves above ordinary windows once and flashes its taskbar button if another window has focus; it does not remain always on top. 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 checks](packaging/README.md#runtime-file-selection). 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.
|
||||
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 progress page keeps one opaque pixel buffer for its lifetime, draws static artwork once, and copies repainted pixels with GDI. The preparation hook leaves production dependency collection to electron-builder on every platform.
|
||||
|
||||
Run `pnpm --dir apps/desktop run test:installer` from the repository root on an interactive Windows x64 desktop to build and exercise a small native test payload through the production installer configuration. Each run uses a unique product identity and sequentially exercises English-only and Chinese-only installer variants, selecting test labels from the displayed welcome button. Both variants install into private directories and uninstall after testing; screenshots and results remain under `.desktop-build/installer-tests/`. The checks include upgrades to registered paths with trailing separators and rejection of drive roots. The optional `--signed` flag uses the [Windows EV configuration](windows-signing/README.md#signing-configuration) to sign test executables and the helper before embedding them; it does not enable an update feed.
|
||||
Run `pnpm --dir apps/desktop run test:installer` from the repository root on an interactive Windows x64 desktop to build and exercise a small native test payload through the production installer configuration. Each run uses a unique product identity and sequentially exercises English-only and Chinese-only installer variants, selecting test labels from the displayed welcome button. Both variants install into private directories and uninstall after testing; screenshots and results remain under `.desktop-build/installer-tests/`. The checks include upgrades to registered paths with trailing separators and rejection of drive roots. The renderer receives caption colors from the NSIS theme; pixel checks print into an off-screen bitmap and also run with the progress child hidden. Native rendering checks cover light/dark palettes at 100% and 200% scale, the painted 100% completion state, caption-button hover/pressed colors and release/cancellation, minimize/restore geometry, and GDI resource release; repaint timings are diagnostics in `render-results.log`, not CI performance limits. Run the generated `ui/render-test.exe <brand.bmp> <dpi> <light|dark> <hover-argb> <pressed-argb> [interactive-title]` with a title to inspect the real progress page without installing a payload. The optional `--signed` flag uses the [Windows EV configuration](windows-signing/README.md#signing-configuration) to sign test executables and the helper before embedding them; it does not enable an update feed.
|
||||
|
||||
The Windows uninstaller removes the Electron user-data directory (browser storage and caches under `%APPDATA%`, nested under the package scope), the `%APPDATA%` product directory, and the updater download cache under `%LOCALAPPDATA%` together with the application, then removes ordinary empty scope directories below `%APPDATA%`. The Harness home (`~/.dsh` or `DSH_HOME`: sessions, settings, credentials, plugins) is never touched, and a `DSH_HOME` published as a Windows environment variable additionally protects any target overlapping it. Silent uninstalls remove the same data; an uninstaller started with `--updated` or `/KEEP_APP_DATA`, as electron-builder does for in-place updates and for replacing an older installation from another directory, retains it. Removal runs through the native helper, which refuses protected Windows folders and paths overlapping the installation or the home, requires a fixed local drive, leaves a linked root or ancestor in place, unlinks reparse points without entering their targets, clears read-only attributes, and keeps deleting siblings after a locked file; residue never stops uninstallation and is not reported. Installation records `InstallLocation` on the Windows uninstall entry as standard inventory metadata; on Windows 11, Uninstall in the Start menu context menu opens the installed-apps list for every Win32 application, and only MSIX packages uninstall directly from there. The uninstaller declares DPI awareness and uses Microsoft YaHei UI for Chinese. This behavior is Windows-only.
|
||||
|
||||
|
||||
@@ -253,9 +253,9 @@ Windows 安装程序使用原生 NSIS 页面,提供亮暗配色、系统阴影
|
||||
|
||||
主题在启动时跟随 Windows;可用 `/THEME=light`、`/THEME=dark` 和 `/THEME=auto` 显式选择配色。窗口在品牌控件准备完成后显示。欢迎页首次出现时,安装窗口会一次性移到普通窗口前方;若焦点在其他窗口,任务栏按钮会闪烁提示,但安装窗口不会始终置顶。进度读取锁定版本的 7-Zip 解压器百分比;目录替换、注册和清理仍使用有界估算。加权百分比不代表剩余时间。NSIS 报告成功后,进度条用 600 毫秒补满并短暂显示 100%,再显示完成页;切换目标时长为 750 毫秒。完成页保留窗口位置。点击完成后,安装程序先隐藏窗口,再启动已安装的可执行文件;启动失败会恢复页面以供重试。目录替换和失败恢复遵循[安装检查](packaging/README.zh.md#runtime-file-selection)。首次启动的配置档案准备仍属于独立的 Desktop 操作。
|
||||
|
||||
Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI+ 辅助库;签名构建通过已配置的 Windows 签名器对该库签名。准备钩子在所有平台上均由 electron-builder 继续负责收集生产依赖。
|
||||
Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI+ 辅助库;签名构建通过已配置的 Windows 签名器对该库签名。进度页在其生命周期内保留一个不透明像素缓冲,只绘制一次静态图像,并通过 GDI 复制重绘的像素。准备钩子在所有平台上均由 electron-builder 继续负责收集生产依赖。
|
||||
|
||||
在有交互式桌面的 Windows x64 上,从仓库根目录运行 `pnpm --dir apps/desktop run test:installer`,可将小型原生测试载荷接入正式安装配置并执行验证。每次运行使用独立产品身份,依次验证仅英文和仅中文的安装器变体,并根据实际显示的欢迎页按钮选择测试文案。两个变体均安装到私有目录并在测试后卸载;截图和结果保留在 `.desktop-build/installer-tests/` 下。检查包含末尾带分隔符的已登记路径升级,以及磁盘根目录拒绝。可选的 `--signed` 标志使用[Windows EV 配置](windows-signing/README.zh.md#signing-configuration),在嵌入前对测试程序和辅助库签名;它不会启用更新源。
|
||||
在有交互式桌面的 Windows x64 上,从仓库根目录运行 `pnpm --dir apps/desktop run test:installer`,可将小型原生测试载荷接入正式安装配置并执行验证。每次运行使用独立产品身份,依次验证仅英文和仅中文的安装器变体,并根据实际显示的欢迎页按钮选择测试文案。两个变体均安装到私有目录并在测试后卸载;截图和结果保留在 `.desktop-build/installer-tests/` 下。检查包含末尾带分隔符的已登记路径升级,以及磁盘根目录拒绝。绘制器接收 NSIS 主题传入的标题栏颜色;像素检查绘制到离屏位图,并在隐藏进度子窗口时验证。原生绘制检查覆盖 100% 和 200% 缩放下的浅色/深色配色、绘制到 100% 的完成状态、标题栏按钮的悬停/按下颜色和松开触发与取消、最小化恢复后的窗口位置和尺寸,以及 GDI 资源释放;`render-results.log` 中的重绘耗时仅供诊断,不作为 CI 性能阈值。运行生成的 `ui/render-test.exe <brand.bmp> <dpi> <light|dark> <hover-argb> <pressed-argb> [interactive-title]` 并指定标题,可在不安装载荷的情况下查看真实进度页。可选的 `--signed` 标志使用[Windows EV 配置](windows-signing/README.zh.md#signing-configuration),在嵌入前对测试程序和辅助库签名;它不会启用更新源。
|
||||
|
||||
Windows 卸载程序会随应用一起删除 Electron 用户数据目录(`%APPDATA%` 下按包作用域嵌套的浏览器存储与缓存)、`%APPDATA%` 下的产品目录,以及 `%LOCALAPPDATA%` 下的更新下载缓存,随后移除 `%APPDATA%` 之下普通且已空的作用域目录。Harness 主目录(`~/.dsh` 或 `DSH_HOME`:会话、设置、凭据、插件)不会被触碰;以 Windows 环境变量发布的 `DSH_HOME` 还会保护所有与其重叠的目标。静默卸载删除相同的数据;以 `--updated` 或 `/KEEP_APP_DATA` 启动的卸载程序保留数据,electron-builder 在原地更新和从其他目录替换旧安装时正是这样启动它。删除通过原生辅助程序执行:它拒绝受保护的 Windows 目录以及与安装目录或主目录重叠的路径,要求固定的本地驱动器,链接的根目录或祖先目录原样保留,遇到重解析点只解除链接而不进入目标,清除只读属性,并在遇到被占用文件后继续删除其余兄弟项;残余不会中止卸载,也不会提示。安装时在 Windows 卸载注册项上记录 `InstallLocation` 作为标准的清单元数据;在 Windows 11 上,开始菜单右键菜单中的“卸载”对所有 Win32 应用都会打开已安装应用列表,只有 MSIX 包能从那里直接卸载。卸载程序声明 DPI 感知,中文使用微软雅黑 UI。此行为仅适用于 Windows。
|
||||
|
||||
|
||||
@@ -62,7 +62,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)", w "$(INSTALLER_PROGRESS_EXTRACT)", w "$(INSTALLER_PROGRESS_COPY)", w "$(INSTALLER_PROGRESS_REGISTER)", w "$(INSTALLER_PROGRESS_CLEAN)") p.s ?c'
|
||||
System::Call '$PLUGINSDIR\window-frame.dll::InstallerShowProgress(p $HWNDPARENT, p $mui.InstFilesPage.ProgressBar, i r0, i $InstallerDpi, i $InstallerControlHover, i $InstallerControlPressed, 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)"
|
||||
|
||||
@@ -18,6 +18,8 @@ Var InstallerEditFrame
|
||||
Var InstallerBrowse
|
||||
Var InstallerLaunch
|
||||
Var InstallerExpanded
|
||||
Var InstallerMinimizeButton
|
||||
Var InstallerCloseButton
|
||||
!include "${__FILEDIR__}\path.nsh"
|
||||
!include "${__FILEDIR__}\drawing.nsh"
|
||||
|
||||
@@ -59,11 +61,13 @@ Function InstallerCreate
|
||||
!insertmacro InstallerControlColors $4
|
||||
${NSD_CreateButton} 0 0 0 0 "−"
|
||||
Pop $4
|
||||
StrCpy $InstallerMinimizeButton $4
|
||||
!insertmacro InstallerPlace $4 504 8 40 32
|
||||
${NSD_OnClick} $4 InstallerMinimize
|
||||
${NSD_OnNotify} $4 InstallerPaintButton
|
||||
${NSD_CreateButton} 0 0 0 0 "×"
|
||||
Pop $4
|
||||
StrCpy $InstallerCloseButton $4
|
||||
!insertmacro InstallerPlace $4 548 8 40 32
|
||||
${NSD_OnClick} $4 InstallerClose
|
||||
${NSD_OnNotify} $4 InstallerPaintButton
|
||||
@@ -321,6 +325,13 @@ Function InstallerPaintButton
|
||||
${If} $R1 != 0
|
||||
StrCpy $R3 $InstallerControlHover
|
||||
${EndIf}
|
||||
${If} $R0 == $InstallerMinimizeButton
|
||||
${OrIf} $R0 == $InstallerCloseButton
|
||||
IntOp $R1 $R9 & 1
|
||||
${If} $R1 != 0
|
||||
StrCpy $R3 $InstallerControlPressed
|
||||
${EndIf}
|
||||
${EndIf}
|
||||
${EndIf}
|
||||
System::Call 'gdiplus::GdipCreateSolidFill(i R3, *p .R1)'
|
||||
System::Call 'gdiplus::GdipCreateFromHDC(p R4, *p .R5)'
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
!define INSTALLER_PRIMARY_HOVER 0xFF2D3135
|
||||
!define INSTALLER_PRIMARY_PRESSED 0xFF000000
|
||||
!define INSTALLER_CONTROL_HOVER 0xFFEEF0F2
|
||||
!define INSTALLER_CONTROL_PRESSED 0xFFD4DBE9
|
||||
!define INSTALLER_DARK_CONTROL_HOVER 0xFF303034
|
||||
!define INSTALLER_DARK_CONTROL_PRESSED 0xFF45454B
|
||||
!define INSTALLER_TRACK_COLOR 0xFFE9ECF2
|
||||
!define INSTALLER_TEXT_COLORREF 0x15110F
|
||||
|
||||
@@ -35,6 +38,7 @@ Var InstallerPrimary
|
||||
Var InstallerPrimaryHover
|
||||
Var InstallerPrimaryPressed
|
||||
Var InstallerControlHover
|
||||
Var InstallerControlPressed
|
||||
Var InstallerTrack
|
||||
Var InstallerButtonText
|
||||
Var InstallerBorder
|
||||
@@ -70,7 +74,8 @@ Function InstallerResolveTheme
|
||||
StrCpy $InstallerPrimary 0xFFF9FAFB
|
||||
StrCpy $InstallerPrimaryHover 0xFFE9ECF2
|
||||
StrCpy $InstallerPrimaryPressed 0xFFD4DBE9
|
||||
StrCpy $InstallerControlHover 0xFF303034
|
||||
StrCpy $InstallerControlHover ${INSTALLER_DARK_CONTROL_HOVER}
|
||||
StrCpy $InstallerControlPressed ${INSTALLER_DARK_CONTROL_PRESSED}
|
||||
StrCpy $InstallerTrack 0xFF61666B
|
||||
StrCpy $InstallerButtonText 0x15110F
|
||||
StrCpy $InstallerBorder 0xFF61666B
|
||||
@@ -84,6 +89,7 @@ Function InstallerResolveTheme
|
||||
StrCpy $InstallerPrimaryHover ${INSTALLER_PRIMARY_HOVER}
|
||||
StrCpy $InstallerPrimaryPressed ${INSTALLER_PRIMARY_PRESSED}
|
||||
StrCpy $InstallerControlHover ${INSTALLER_CONTROL_HOVER}
|
||||
StrCpy $InstallerControlPressed ${INSTALLER_CONTROL_PRESSED}
|
||||
StrCpy $InstallerTrack ${INSTALLER_TRACK_COLOR}
|
||||
StrCpy $InstallerButtonText 0xFFFFFF
|
||||
StrCpy $InstallerBorder 0xFFBBC0C8
|
||||
|
||||
@@ -113,7 +113,24 @@ struct ProgressPage {
|
||||
UINT dpi;
|
||||
ULONG_PTR gdiplus;
|
||||
Image* brand;
|
||||
Bitmap* buffer;
|
||||
HDC bufferDc;
|
||||
HBITMAP bufferBitmap;
|
||||
HGDIOBJ previousBitmap;
|
||||
UINT pressedCommand;
|
||||
UINT hoveredCommand;
|
||||
ARGB controlHover;
|
||||
ARGB controlPressed;
|
||||
WCHAR captions[5][128];
|
||||
|
||||
~ProgressPage() {
|
||||
delete buffer;
|
||||
delete brand;
|
||||
if (previousBitmap) SelectObject(bufferDc, previousBitmap);
|
||||
if (bufferBitmap) DeleteObject(bufferBitmap);
|
||||
if (bufferDc) DeleteDC(bufferDc);
|
||||
if (gdiplus) GdiplusShutdown(gdiplus);
|
||||
}
|
||||
};
|
||||
|
||||
// NSIS shows its page after MUI's SHOW callback; keep its controls off screen.
|
||||
@@ -139,6 +156,45 @@ static void FillProgress(Graphics& graphics, Brush& brush, REAL width) {
|
||||
graphics.FillPath(&brush, &path);
|
||||
}
|
||||
|
||||
static UINT CaptionCommand(const ProgressPage& page, LPARAM position) {
|
||||
const int x = static_cast<short>(LOWORD(position));
|
||||
const int y = static_cast<short>(HIWORD(position));
|
||||
if (y < 0 || y >= MulDiv(48, page.dpi, 96) || x < MulDiv(504, page.dpi, 96) || x >= MulDiv(600, page.dpi, 96)) return 0;
|
||||
return x >= MulDiv(548, page.dpi, 96) ? SC_CLOSE : SC_MINIMIZE;
|
||||
}
|
||||
|
||||
static void PaintCaptionButtons(Graphics& graphics, const ProgressPage& page) {
|
||||
SolidBrush background(page.dark ? Color(255, 21, 21, 23) : Color(255, 255, 255, 255));
|
||||
graphics.FillRectangle(&background, 504, 0, 96, 48);
|
||||
SolidBrush ink(page.dark ? Color(255, 255, 255, 255) : Color(255, 15, 17, 21));
|
||||
FontFamily family(L"Microsoft YaHei UI");
|
||||
Font font(&family, 16, FontStyleRegular, UnitPixel);
|
||||
StringFormat centered;
|
||||
centered.SetAlignment(StringAlignmentCenter);
|
||||
centered.SetLineAlignment(StringAlignmentCenter);
|
||||
for (UINT command : {SC_MINIMIZE, SC_CLOSE}) {
|
||||
const int x = command == SC_MINIMIZE ? 504 : 548;
|
||||
if (page.hoveredCommand == command && (!page.pressedCommand || page.pressedCommand == command)) {
|
||||
const bool pressed = page.pressedCommand == command;
|
||||
SolidBrush fill(Color(pressed ? page.controlPressed : page.controlHover));
|
||||
GraphicsPath path;
|
||||
path.AddArc(x, 8, 20, 20, 180, 90);
|
||||
path.AddArc(x + 20, 8, 20, 20, 270, 90);
|
||||
path.AddArc(x + 20, 20, 20, 20, 0, 90);
|
||||
path.AddArc(x, 20, 20, 20, 90, 90);
|
||||
path.CloseFigure();
|
||||
graphics.FillPath(&fill, &path);
|
||||
}
|
||||
graphics.DrawString(command == SC_MINIMIZE ? L"\x2212" : L"\x00d7", -1,
|
||||
&font, RectF(static_cast<REAL>(x), 8, 40, 32), ¢ered, &ink);
|
||||
}
|
||||
}
|
||||
|
||||
static void InvalidateCaptionButtons(HWND window, const ProgressPage& page) {
|
||||
RECT bounds{MulDiv(504, page.dpi, 96), 0, MulDiv(600, page.dpi, 96), MulDiv(48, page.dpi, 96)};
|
||||
InvalidateRect(window, &bounds, FALSE);
|
||||
}
|
||||
|
||||
static LRESULT CALLBACK ProgressProc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) {
|
||||
auto* page = reinterpret_cast<ProgressPage*>(GetWindowLongPtrW(window, GWLP_USERDATA));
|
||||
if (message == WM_CREATE) {
|
||||
@@ -148,29 +204,68 @@ static LRESULT CALLBACK ProgressProc(HWND window, UINT message, WPARAM wparam, L
|
||||
}
|
||||
if (message == WM_ERASEBKGND) return 1;
|
||||
if (message == WM_TIMER) { InvalidateRect(window, nullptr, FALSE); return 0; }
|
||||
if (message == WM_LBUTTONDOWN && page) {
|
||||
const int x = LOWORD(lparam) * 96 / page->dpi;
|
||||
const int y = HIWORD(lparam) * 96 / page->dpi;
|
||||
if (y < 48) {
|
||||
HWND parent = GetParent(window);
|
||||
if (x >= 548) PostMessageW(parent, WM_CLOSE, 0, 0);
|
||||
else if (x >= 504) ShowWindow(parent, SW_MINIMIZE);
|
||||
else { ReleaseCapture(); SendMessageW(parent, WM_NCLBUTTONDOWN, HTCAPTION, 0); }
|
||||
if (message == WM_MOUSEMOVE && page) {
|
||||
TRACKMOUSEEVENT tracking{sizeof(tracking), TME_LEAVE, window, 0};
|
||||
TrackMouseEvent(&tracking);
|
||||
const UINT hovered = CaptionCommand(*page, lparam);
|
||||
if (page->hoveredCommand != hovered) {
|
||||
page->hoveredCommand = hovered;
|
||||
InvalidateCaptionButtons(window, *page);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (message == WM_PAINT && page) {
|
||||
PAINTSTRUCT paint;
|
||||
HDC dc = BeginPaint(window, &paint);
|
||||
if (message == WM_MOUSELEAVE && page) {
|
||||
page->hoveredCommand = 0;
|
||||
InvalidateCaptionButtons(window, *page);
|
||||
return 0;
|
||||
}
|
||||
if (message == WM_LBUTTONDOWN && page) {
|
||||
const int x = static_cast<short>(LOWORD(lparam));
|
||||
const int y = static_cast<short>(HIWORD(lparam));
|
||||
page->pressedCommand = CaptionCommand(*page, lparam);
|
||||
if (page->pressedCommand) {
|
||||
page->hoveredCommand = page->pressedCommand;
|
||||
SetCapture(window);
|
||||
InvalidateCaptionButtons(window, *page);
|
||||
}
|
||||
else if (x >= 0 && x < MulDiv(504, page->dpi, 96) && y >= 0 && y < MulDiv(48, page->dpi, 96)) {
|
||||
HWND parent = GetParent(window);
|
||||
ReleaseCapture(); SendMessageW(parent, WM_NCLBUTTONDOWN, HTCAPTION, 0);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (message == WM_LBUTTONUP && page) {
|
||||
const UINT command = page->pressedCommand;
|
||||
page->pressedCommand = 0;
|
||||
page->hoveredCommand = CaptionCommand(*page, lparam);
|
||||
InvalidateCaptionButtons(window, *page);
|
||||
const bool activate = command && GetCapture() == window && CaptionCommand(*page, lparam) == command;
|
||||
if (GetCapture() == window) ReleaseCapture();
|
||||
if (activate) {
|
||||
if (command == SC_CLOSE) PostMessageW(GetParent(window), WM_CLOSE, 0, 0);
|
||||
else ShowWindow(GetParent(window), SW_MINIMIZE);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if ((message == WM_CAPTURECHANGED || message == WM_CANCELMODE) && page) {
|
||||
page->pressedCommand = 0;
|
||||
InvalidateCaptionButtons(window, *page);
|
||||
if (message == WM_CANCELMODE && GetCapture() == window) ReleaseCapture();
|
||||
return 0;
|
||||
}
|
||||
if ((message == WM_PAINT || message == WM_PRINTCLIENT) && page) {
|
||||
PAINTSTRUCT paint = {};
|
||||
const bool printing = message == WM_PRINTCLIENT;
|
||||
HDC dc = printing ? reinterpret_cast<HDC>(wparam) : BeginPaint(window, &paint);
|
||||
if (printing) GetClientRect(window, &paint.rcPaint);
|
||||
{
|
||||
Bitmap buffer(MulDiv(600, page->dpi, 96), MulDiv(600, page->dpi, 96), PixelFormat32bppPARGB);
|
||||
Graphics graphics(&buffer);
|
||||
Graphics graphics(page->buffer);
|
||||
graphics.ScaleTransform(page->dpi / 96.0f, page->dpi / 96.0f);
|
||||
graphics.Clear(page->dark ? Color(255, 21, 21, 23) : Color(255, 255, 255, 255));
|
||||
graphics.SetSmoothingMode(SmoothingModeAntiAlias);
|
||||
graphics.SetInterpolationMode(InterpolationModeHighQualityBicubic);
|
||||
graphics.SetPixelOffsetMode(PixelOffsetModeHalf);
|
||||
graphics.DrawImage(page->brand, Rect(0, 174, 600, 196));
|
||||
PaintCaptionButtons(graphics, *page);
|
||||
SolidBrush background(page->dark ? Color(255, 21, 21, 23) : Color(255, 255, 255, 255));
|
||||
graphics.FillRectangle(&background, 48, 480, 504, 56);
|
||||
const int stage = static_cast<int>(reinterpret_cast<INT_PTR>(GetPropW(GetParent(window), L"HarnessInstaller.Stage")));
|
||||
const double fraction = reinterpret_cast<UINT_PTR>(GetPropW(GetParent(window), L"HarnessInstaller.ExtractProgress")) / 100.0;
|
||||
page->progress.Advance(stage, fraction, GetTickCount64());
|
||||
@@ -188,28 +283,25 @@ static LRESULT CALLBACK ProgressProc(HWND window, UINT message, WPARAM wparam, L
|
||||
wsprintfW(caption, page->captions[page->progress.CaptionStage()], percent);
|
||||
SetWindowTextW(window, caption);
|
||||
graphics.DrawString(caption, -1, &font, RectF(48, 512, 504, 22), ¢ered, &ink);
|
||||
Font controls(&family, 16, FontStyleRegular, UnitPixel);
|
||||
graphics.DrawString(L"\x2212", -1, &controls, RectF(504, 8, 40, 32), ¢ered, &ink);
|
||||
graphics.DrawString(L"\x00d7", -1, &controls, RectF(548, 8, 40, 32), ¢ered, &ink);
|
||||
Graphics screen(dc);
|
||||
screen.DrawImage(&buffer, 0, 0);
|
||||
}
|
||||
EndPaint(window, &paint);
|
||||
BitBlt(dc, paint.rcPaint.left, paint.rcPaint.top,
|
||||
paint.rcPaint.right - paint.rcPaint.left, paint.rcPaint.bottom - paint.rcPaint.top,
|
||||
page->bufferDc, paint.rcPaint.left, paint.rcPaint.top, SRCCOPY);
|
||||
if (!printing) 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) {
|
||||
KillTimer(window, 1);
|
||||
delete page->brand;
|
||||
GdiplusShutdown(page->gdiplus);
|
||||
delete page;
|
||||
}
|
||||
return DefWindowProcW(window, message, wparam, lparam);
|
||||
}
|
||||
|
||||
// Runs on the NSIS UI thread; the stock installation section runs on its worker.
|
||||
// Caption colors are opaque ARGB from NSIS; failure preserves the stock page's visibility.
|
||||
extern "C" __declspec(dllexport) HWND __cdecl InstallerShowProgress(HWND parent, HWND source,
|
||||
BOOL dark, UINT dpi, const WCHAR* brand, const WCHAR* preparing, const WCHAR* extracting,
|
||||
BOOL dark, UINT dpi, ARGB controlHover, ARGB controlPressed, const WCHAR* brand, const WCHAR* preparing, const WCHAR* extracting,
|
||||
const WCHAR* copying, const WCHAR* registering, const WCHAR* cleaning) {
|
||||
HINSTANCE module = GetModuleHandleW(nullptr);
|
||||
WNDCLASSW type = {};
|
||||
@@ -222,22 +314,56 @@ 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; }
|
||||
HWND stockPage = GetParent(source);
|
||||
if (!SetWindowSubclass(stockPage, HiddenPageProc, 1, 0)) {
|
||||
GdiplusShutdown(page->gdiplus); delete page; return nullptr;
|
||||
}
|
||||
ShowWindow(stockPage, SW_HIDE);
|
||||
page->dark = dark != FALSE;
|
||||
page->dpi = dpi;
|
||||
page->controlHover = controlHover;
|
||||
page->controlPressed = controlPressed;
|
||||
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;
|
||||
if (!page->brand || page->brand->GetLastStatus() != Ok) {
|
||||
delete page; return nullptr;
|
||||
}
|
||||
HWND window = CreateWindowExW(0, type.lpszClassName, L"", WS_CHILD | WS_VISIBLE,
|
||||
const int size = MulDiv(600, dpi, 96);
|
||||
BITMAPINFO info = {};
|
||||
info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
|
||||
info.bmiHeader.biWidth = size;
|
||||
info.bmiHeader.biHeight = -size;
|
||||
info.bmiHeader.biPlanes = 1;
|
||||
info.bmiHeader.biBitCount = 32;
|
||||
info.bmiHeader.biCompression = BI_RGB;
|
||||
void* pixels = nullptr;
|
||||
page->bufferDc = CreateCompatibleDC(nullptr);
|
||||
page->bufferBitmap = CreateDIBSection(nullptr, &info, DIB_RGB_COLORS, &pixels, nullptr, 0);
|
||||
if (!page->bufferDc || !page->bufferBitmap) { delete page; return nullptr; }
|
||||
page->previousBitmap = SelectObject(page->bufferDc, page->bufferBitmap);
|
||||
if (!page->previousBitmap) { delete page; return nullptr; }
|
||||
// Opaque pixels are shared by GDI+ drawing and the window's GDI blit.
|
||||
page->buffer = new Bitmap(size, size, size * 4, PixelFormat32bppPARGB, static_cast<BYTE*>(pixels));
|
||||
if (!page->buffer || page->buffer->GetLastStatus() != Ok) { delete page; return nullptr; }
|
||||
{
|
||||
// Static artwork stays in the buffer for this page's lifetime.
|
||||
Graphics graphics(page->buffer);
|
||||
graphics.ScaleTransform(dpi / 96.0f, dpi / 96.0f);
|
||||
graphics.Clear(page->dark ? Color(255, 21, 21, 23) : Color(255, 255, 255, 255));
|
||||
graphics.SetSmoothingMode(SmoothingModeAntiAlias);
|
||||
graphics.SetInterpolationMode(InterpolationModeHighQualityBicubic);
|
||||
graphics.SetPixelOffsetMode(PixelOffsetModeHalf);
|
||||
graphics.DrawImage(page->brand, Rect(0, 174, 600, 196));
|
||||
PaintCaptionButtons(graphics, *page);
|
||||
}
|
||||
delete page->brand;
|
||||
page->brand = nullptr;
|
||||
HWND window = CreateWindowExW(0, type.lpszClassName, L"", WS_CHILD,
|
||||
0, 0, MulDiv(600, dpi, 96), MulDiv(600, dpi, 96), parent, nullptr, module, page);
|
||||
if (!window) { delete page->brand; GdiplusShutdown(page->gdiplus); delete page; }
|
||||
if (!window) { delete page; return nullptr; }
|
||||
// Keep the stock page available until the replacement is fully constructed.
|
||||
HWND stockPage = GetParent(source);
|
||||
if (!SetWindowSubclass(stockPage, HiddenPageProc, 1, 0)) {
|
||||
DestroyWindow(window); return nullptr;
|
||||
}
|
||||
ShowWindow(stockPage, SW_HIDE);
|
||||
ShowWindow(window, SW_SHOW);
|
||||
return window;
|
||||
}
|
||||
|
||||
@@ -268,6 +394,8 @@ extern "C" __declspec(dllexport) BOOL __cdecl InstallerFinishProgress(HWND windo
|
||||
static LRESULT CALLBACK FrameProc(HWND window, UINT message, WPARAM wparam,
|
||||
LPARAM lparam, UINT_PTR id, DWORD_PTR) {
|
||||
if (message == WM_NCCALCSIZE && wparam) return 0;
|
||||
// Activation must not repaint the standard frame over the custom client area.
|
||||
if (message == WM_NCACTIVATE) return DefSubclassProc(window, message, wparam, -1);
|
||||
if (message == WM_NCHITTEST) {
|
||||
const LRESULT hit = DefSubclassProc(window, message, wparam, lparam);
|
||||
// The installer has a fixed size; its page provides the drag area.
|
||||
@@ -291,7 +419,8 @@ extern "C" __declspec(dllexport) HRESULT __cdecl InstallerApplyFrame(HWND window
|
||||
ShowWindow(child, SW_HIDE);
|
||||
}
|
||||
}
|
||||
SetWindowLongW(window, GWL_STYLE, GetWindowLongW(window, GWL_STYLE) | WS_THICKFRAME);
|
||||
// DWM retains native transitions; WM_NCCALCSIZE removes the visible caption.
|
||||
SetWindowLongW(window, GWL_STYLE, GetWindowLongW(window, GWL_STYLE) | WS_THICKFRAME | WS_CAPTION);
|
||||
const DWMNCRENDERINGPOLICY policy = DWMNCRP_ENABLED;
|
||||
HRESULT result = DwmSetWindowAttribute(window, DWMWA_NCRENDERING_POLICY, &policy, sizeof(policy));
|
||||
if (FAILED(result)) return result;
|
||||
|
||||
@@ -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 ole32.lib shell32.lib uuid.lib advapi32.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 gdi32.lib comctl32.lib dwmapi.lib gdiplus.lib ole32.lib shell32.lib uuid.lib advapi32.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.' }
|
||||
@@ -50,6 +50,12 @@ if ($TestProgress -or $CompileProgressOnly) {
|
||||
& $presentationExecutable
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Installer presentation regression failed.' }
|
||||
}
|
||||
$renderSource = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../tests/windows-installer-render.cpp'))
|
||||
$renderExecutable = Join-Path $output 'render-test.exe'
|
||||
$renderScript = Join-Path $output 'compile-render-test.cmd'
|
||||
[IO.File]::WriteAllLines($renderScript, @('@echo off', ('call "{0}" >nul' -f $vcvars), 'if errorlevel 1 exit /b %errorlevel%', ('cl /nologo /MT /W4 /WX /EHsc "{0}" /Fo"{1}" /Fe"{2}" /link "{3}" user32.lib gdi32.lib' -f $renderSource, (Join-Path $output 'render-test.obj'), $renderExecutable, (Join-Path $output 'window-frame.lib'))), [Text.Encoding]::Default)
|
||||
& $env:ComSpec /d /c $renderScript
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Installer render test compilation failed.' }
|
||||
$reportSource = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../tests/windows-installer-extract-report.cpp'))
|
||||
$reportExecutable = Join-Path $output 'extract-report-test.exe'
|
||||
$reportScript = Join-Path $output 'compile-extract-report-test.cmd'
|
||||
|
||||
@@ -58,10 +58,12 @@ try {
|
||||
join(appRoot, 'scripts', 'prepare-windows-installer.ps1'), '-OutputDirectory', join(output, 'ui'), '-CompileProgressOnly'], childOptions)
|
||||
const progressTest = join(output, 'ui', 'progress-test.exe')
|
||||
const presentationTest = join(output, 'ui', 'presentation-test.exe')
|
||||
const renderTest = join(output, 'ui', 'render-test.exe')
|
||||
const cleanupTest = join(output, 'ui', 'data-cleanup-test.exe')
|
||||
if (sign) {
|
||||
await sign({ path: progressTest, hash: 'sha256', isNest: false })
|
||||
await sign({ path: presentationTest, hash: 'sha256', isNest: false })
|
||||
await sign({ path: renderTest, hash: 'sha256', isNest: false })
|
||||
await sign({ path: cleanupTest, hash: 'sha256', isNest: false })
|
||||
await sign({ path: join(output, 'ui', 'window-frame.dll'), hash: 'sha256', isNest: false })
|
||||
installWindowsNsisBootstrapSigner({ sign })
|
||||
@@ -69,6 +71,23 @@ try {
|
||||
if (!uninstallOnly) {
|
||||
await execute(progressTest, [], childOptions)
|
||||
await execute(presentationTest, [], childOptions)
|
||||
const renderResults = []
|
||||
const themeSource = await readFile(join(appRoot, 'installer', 'theme.nsh'), 'utf8')
|
||||
const captionColor = (name) => {
|
||||
const match = themeSource.match(new RegExp(`^!define ${name} (0x[0-9A-Fa-f]{8})\\r?$`, 'm'))
|
||||
if (!match) throw new Error(`Missing installer caption color: ${name}`)
|
||||
return match[1]
|
||||
}
|
||||
for (const dpi of [96, 192]) {
|
||||
for (const theme of ['light', 'dark']) {
|
||||
const brand = theme === 'dark' ? 'brand-dark-2x.bmp' : 'brand-2x.bmp'
|
||||
const prefix = theme === 'dark' ? 'INSTALLER_DARK_CONTROL' : 'INSTALLER_CONTROL'
|
||||
const result = await execute(renderTest, [join(output, 'ui', brand), String(dpi), theme,
|
||||
captionColor(`${prefix}_HOVER`), captionColor(`${prefix}_PRESSED`)], childOptions)
|
||||
renderResults.push(result.stdout)
|
||||
}
|
||||
}
|
||||
await writeFile(join(output, 'render-results.log'), renderResults.join(''))
|
||||
}
|
||||
await execute(cleanupTest, [join(output, 'ui', `cleanup-${id}`)], childOptions)
|
||||
const payloadSource = join(output, 'payload.nsi')
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
// Exercises the shipped progress window without extracting or installing a payload.
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#define UNICODE
|
||||
#define NOMINMAX
|
||||
#include <windows.h>
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <vector>
|
||||
|
||||
extern "C" __declspec(dllimport) HRESULT __cdecl InstallerApplyFrame(HWND window);
|
||||
extern "C" __declspec(dllimport) HWND __cdecl InstallerShowProgress(HWND parent, HWND source,
|
||||
BOOL dark, UINT dpi, DWORD controlHover, DWORD controlPressed, const WCHAR* brand, const WCHAR* preparing, const WCHAR* extracting,
|
||||
const WCHAR* copying, const WCHAR* registering, const WCHAR* cleaning);
|
||||
extern "C" __declspec(dllimport) BOOL __cdecl InstallerFinishProgress(HWND window);
|
||||
|
||||
static LRESULT CALLBACK WindowProc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) {
|
||||
if (message == WM_DESTROY) { PostQuitMessage(0); return 0; }
|
||||
return DefWindowProcW(window, message, wparam, lparam);
|
||||
}
|
||||
|
||||
int wmain(int argc, wchar_t** argv) {
|
||||
if (argc < 6) {
|
||||
std::fprintf(stderr, "Usage: render-test.exe <brand.bmp> <dpi> <light|dark> <hover-argb> <pressed-argb> [interactive-title]\n");
|
||||
return 1;
|
||||
}
|
||||
const int dpi = _wtoi(argv[2]);
|
||||
if (dpi < 96 || dpi > 384) return 2;
|
||||
const bool dark = wcscmp(argv[3], L"dark") == 0;
|
||||
if (!dark && wcscmp(argv[3], L"light") != 0) return 2;
|
||||
wchar_t* end = nullptr;
|
||||
const DWORD hoverArgb = wcstoul(argv[4], &end, 0);
|
||||
if (!*argv[4] || *end || (hoverArgb >> 24) != 255) return 2;
|
||||
const DWORD pressedArgb = wcstoul(argv[5], &end, 0);
|
||||
if (!*argv[5] || *end || (pressedArgb >> 24) != 255) return 2;
|
||||
const bool interactive = argc > 6;
|
||||
SetProcessDPIAware();
|
||||
WNDCLASSW type = {};
|
||||
type.lpfnWndProc = WindowProc;
|
||||
type.hInstance = GetModuleHandleW(nullptr);
|
||||
type.lpszClassName = L"HarnessInstallerRenderTest";
|
||||
type.hCursor = LoadCursorW(nullptr, IDC_ARROW);
|
||||
if (!RegisterClassW(&type)) return 3;
|
||||
HWND window = CreateWindowW(type.lpszClassName, interactive ? argv[6] : L"Installer rendering test",
|
||||
WS_POPUP | WS_SYSMENU | WS_MINIMIZEBOX, 100, 100, MulDiv(600, dpi, 96), MulDiv(600, dpi, 96),
|
||||
nullptr, nullptr, type.hInstance, nullptr);
|
||||
if (!window || FAILED(InstallerApplyFrame(window))) return 4;
|
||||
HWND stock = CreateWindowW(L"STATIC", L"", WS_CHILD | WS_VISIBLE, 0, 0, 1, 1, window, nullptr, type.hInstance, nullptr);
|
||||
HWND source = CreateWindowW(L"STATIC", L"", WS_CHILD, 0, 0, 1, 1, stock, nullptr, type.hInstance, nullptr);
|
||||
SetPropW(window, L"HarnessInstaller.Stage", reinterpret_cast<HANDLE>(1));
|
||||
SetPropW(window, L"HarnessInstaller.ExtractProgress", reinterpret_cast<HANDLE>(50));
|
||||
const auto createProgress = [&]() {
|
||||
return InstallerShowProgress(window, source, dark, static_cast<UINT>(dpi), hoverArgb, pressedArgb, argv[1],
|
||||
L"Preparing %d%%", L"Extracting %d%%", L"Copying %d%%", L"Registering %d%%", L"Cleaning %d%%");
|
||||
};
|
||||
ShowWindow(window, SW_SHOWNOACTIVATE);
|
||||
bool clientFrame = true;
|
||||
for (int x : {8, 300, 580}) {
|
||||
POINT point{MulDiv(x, dpi, 96), MulDiv(24, dpi, 96)};
|
||||
ClientToScreen(window, &point);
|
||||
clientFrame = SendMessageW(window, WM_NCHITTEST, 0, MAKELPARAM(point.x, point.y)) == HTCLIENT && clientFrame;
|
||||
}
|
||||
HWND failed = InstallerShowProgress(window, source, dark, static_cast<UINT>(dpi), hoverArgb, pressedArgb, L"",
|
||||
L"Preparing %d%%", L"Extracting %d%%", L"Copying %d%%", L"Registering %d%%", L"Cleaning %d%%");
|
||||
ShowWindow(stock, SW_SHOW);
|
||||
const bool failurePreservesStock = !failed && IsWindowVisible(stock);
|
||||
if (failed) DestroyWindow(failed);
|
||||
// Warm retained font and window-animation resources before checking page-owned GDI objects.
|
||||
HWND warmup = createProgress();
|
||||
if (!warmup) { DestroyWindow(window); return 5; }
|
||||
UpdateWindow(warmup);
|
||||
ShowWindow(window, SW_MINIMIZE);
|
||||
ShowWindow(window, SW_RESTORE);
|
||||
UpdateWindow(warmup);
|
||||
DestroyWindow(warmup);
|
||||
const DWORD initialGdi = GetGuiResources(GetCurrentProcess(), GR_GDIOBJECTS);
|
||||
HWND progress = createProgress();
|
||||
if (!progress) { DestroyWindow(window); return 5; }
|
||||
UpdateWindow(progress);
|
||||
if (interactive) {
|
||||
MSG message;
|
||||
BOOL result;
|
||||
while ((result = GetMessageW(&message, nullptr, 0, 0)) > 0) {
|
||||
TranslateMessage(&message);
|
||||
DispatchMessageW(&message);
|
||||
}
|
||||
return result == 0 ? 0 : 7;
|
||||
}
|
||||
RECT originalBounds, restoredBounds;
|
||||
GetWindowRect(window, &originalBounds);
|
||||
const auto captionPixel = [&](int x) -> COLORREF {
|
||||
// Print into a private bitmap: visible-window clipping must not affect pixel assertions.
|
||||
HDC dc = CreateCompatibleDC(nullptr);
|
||||
BITMAPINFO info = {};
|
||||
info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
|
||||
info.bmiHeader.biWidth = MulDiv(600, dpi, 96);
|
||||
info.bmiHeader.biHeight = -MulDiv(600, dpi, 96);
|
||||
info.bmiHeader.biPlanes = 1;
|
||||
info.bmiHeader.biBitCount = 32;
|
||||
void* pixels = nullptr;
|
||||
HBITMAP bitmap = CreateDIBSection(nullptr, &info, DIB_RGB_COLORS, &pixels, nullptr, 0);
|
||||
if (!dc || !bitmap) {
|
||||
if (dc) DeleteDC(dc);
|
||||
if (bitmap) DeleteObject(bitmap);
|
||||
return CLR_INVALID;
|
||||
}
|
||||
HGDIOBJ previous = SelectObject(dc, bitmap);
|
||||
if (!previous) { DeleteObject(bitmap); DeleteDC(dc); return CLR_INVALID; }
|
||||
SendMessageW(progress, WM_PRINTCLIENT, reinterpret_cast<WPARAM>(dc), PRF_CLIENT);
|
||||
const COLORREF pixel = GetPixel(dc, MulDiv(x + 8, dpi, 96), MulDiv(24, dpi, 96));
|
||||
SelectObject(dc, previous);
|
||||
DeleteObject(bitmap);
|
||||
DeleteDC(dc);
|
||||
return pixel;
|
||||
};
|
||||
const COLORREF normalColor = dark ? RGB(21, 21, 23) : RGB(255, 255, 255);
|
||||
const auto colorref = [](DWORD argb) { return RGB((argb >> 16) & 255, (argb >> 8) & 255, argb & 255); };
|
||||
const COLORREF hoverColor = colorref(hoverArgb);
|
||||
const COLORREF pressedColor = colorref(pressedArgb);
|
||||
const auto expectPixel = [&](int x, COLORREF expected) {
|
||||
const COLORREF actual = captionPixel(x);
|
||||
if (actual != CLR_INVALID && actual == expected) return true;
|
||||
std::fprintf(stderr, "caption pixel x=%d dpi=%d expected=0x%08lx actual=0x%08lx\n", x, dpi, expected, actual);
|
||||
return false;
|
||||
};
|
||||
bool captionStates = true;
|
||||
ShowWindow(progress, SW_HIDE);
|
||||
for (int x : {504, 548}) {
|
||||
const LPARAM point = MAKELPARAM(MulDiv(x + 20, dpi, 96), MulDiv(24, dpi, 96));
|
||||
captionStates = expectPixel(x, normalColor) && captionStates;
|
||||
SendMessageW(progress, WM_MOUSEMOVE, 0, point);
|
||||
captionStates = expectPixel(x, hoverColor) && captionStates;
|
||||
SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, point);
|
||||
captionStates = expectPixel(x, pressedColor) && captionStates;
|
||||
const int other = x == 504 ? 548 : 504;
|
||||
SendMessageW(progress, WM_MOUSEMOVE, MK_LBUTTON, MAKELPARAM(MulDiv(other + 20, dpi, 96), MulDiv(24, dpi, 96)));
|
||||
captionStates = expectPixel(other, normalColor) && captionStates;
|
||||
captionStates = expectPixel(x, normalColor) && captionStates;
|
||||
SendMessageW(progress, WM_MOUSEMOVE, MK_LBUTTON, MAKELPARAM(-1, -1));
|
||||
captionStates = expectPixel(x, normalColor) && captionStates;
|
||||
SendMessageW(progress, WM_MOUSEMOVE, MK_LBUTTON, point);
|
||||
captionStates = expectPixel(x, pressedColor) && captionStates;
|
||||
SendMessageW(progress, WM_CANCELMODE, 0, 0);
|
||||
captionStates = expectPixel(x, hoverColor) && captionStates;
|
||||
SendMessageW(progress, WM_MOUSELEAVE, 0, 0);
|
||||
captionStates = expectPixel(x, normalColor) && captionStates;
|
||||
}
|
||||
ShowWindow(progress, SW_SHOW);
|
||||
const LPARAM minimizePoint = MAKELPARAM(MulDiv(524, dpi, 96), MulDiv(24, dpi, 96));
|
||||
SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, minimizePoint);
|
||||
bool buttonRelease = !IsIconic(window) && GetCapture() == progress;
|
||||
SendMessageW(progress, WM_LBUTTONUP, 0, MAKELPARAM(-1, -1));
|
||||
buttonRelease = buttonRelease && !IsIconic(window) && GetCapture() != progress;
|
||||
SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, minimizePoint);
|
||||
ReleaseCapture();
|
||||
SendMessageW(progress, WM_LBUTTONUP, 0, minimizePoint);
|
||||
buttonRelease = buttonRelease && !IsIconic(window);
|
||||
SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, minimizePoint);
|
||||
SendMessageW(progress, WM_CANCELMODE, 0, 0);
|
||||
SendMessageW(progress, WM_LBUTTONUP, 0, minimizePoint);
|
||||
buttonRelease = buttonRelease && !IsIconic(window) && GetCapture() != progress;
|
||||
SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, minimizePoint);
|
||||
SendMessageW(progress, WM_LBUTTONUP, 0, minimizePoint);
|
||||
const bool minimized = IsIconic(window) != FALSE;
|
||||
ShowWindow(window, SW_RESTORE);
|
||||
GetWindowRect(window, &restoredBounds);
|
||||
const bool restored = !IsIconic(window) && EqualRect(&originalBounds, &restoredBounds);
|
||||
const LPARAM closePoint = MAKELPARAM(MulDiv(568, dpi, 96), MulDiv(24, dpi, 96));
|
||||
MSG closeMessage;
|
||||
SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, closePoint);
|
||||
bool closeRelease = !PeekMessageW(&closeMessage, window, WM_CLOSE, WM_CLOSE, PM_REMOVE);
|
||||
SendMessageW(progress, WM_LBUTTONUP, 0, minimizePoint);
|
||||
closeRelease = closeRelease && !PeekMessageW(&closeMessage, window, WM_CLOSE, WM_CLOSE, PM_REMOVE);
|
||||
SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, closePoint);
|
||||
SendMessageW(progress, WM_LBUTTONUP, 0, closePoint);
|
||||
closeRelease = closeRelease && PeekMessageW(&closeMessage, window, WM_CLOSE, WM_CLOSE, PM_REMOVE);
|
||||
LARGE_INTEGER frequency, started, finished;
|
||||
QueryPerformanceFrequency(&frequency);
|
||||
std::vector<double> samples;
|
||||
// Full repaint is the exposure/restore path; fixture startup is excluded.
|
||||
constexpr int frameCount = 300;
|
||||
for (int frame = 0; frame < frameCount; ++frame) {
|
||||
InvalidateRect(progress, nullptr, FALSE);
|
||||
QueryPerformanceCounter(&started);
|
||||
UpdateWindow(progress);
|
||||
QueryPerformanceCounter(&finished);
|
||||
samples.push_back(1000.0 * (finished.QuadPart - started.QuadPart) / frequency.QuadPart);
|
||||
}
|
||||
double total = 0;
|
||||
for (double sample : samples) total += sample;
|
||||
std::printf("samples_ms=[");
|
||||
for (size_t index = 0; index < samples.size(); ++index) {
|
||||
std::printf("%s%.3f", index ? "," : "", samples[index]);
|
||||
}
|
||||
std::printf("]\n");
|
||||
std::sort(samples.begin(), samples.end());
|
||||
std::printf("fixture=standalone-progress dpi=%d dark=%d frames=%d screen=%dx%d foreground=%d mean_ms=%.3f p50_ms=%.3f p95_ms=%.3f max_ms=%.3f\n",
|
||||
dpi, dark, frameCount, GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN), GetForegroundWindow() == window,
|
||||
total / samples.size(), samples[samples.size() / 2], samples[samples.size() * 95 / 100], samples.back());
|
||||
const bool completed = InstallerFinishProgress(progress) &&
|
||||
GetPropW(window, L"HarnessInstaller.CompletedPercent") == reinterpret_cast<HANDLE>(100);
|
||||
WCHAR caption[160] = {};
|
||||
GetWindowTextW(progress, caption, ARRAYSIZE(caption));
|
||||
const bool finalCaption = wcscmp(caption, L"Extracting 100%") == 0;
|
||||
DestroyWindow(progress);
|
||||
const DWORD finalGdi = GetGuiResources(GetCurrentProcess(), GR_GDIOBJECTS);
|
||||
DestroyWindow(window);
|
||||
std::printf("client_frame=%d stock_preserved=%d completed=%d final_caption=%d caption_states=%d button_release=%d close_release=%d minimized=%d restored=%d gdi_before=%lu gdi_after=%lu\n",
|
||||
clientFrame, failurePreservesStock, completed, finalCaption, captionStates, buttonRelease, closeRelease, minimized, restored, initialGdi, finalGdi);
|
||||
return clientFrame && failurePreservesStock && completed && finalCaption && captionStates && buttonRelease && closeRelease && minimized && restored && finalGdi <= initialGdi ? 0 : 6;
|
||||
}
|
||||
Reference in new issue
Block a user