fix(desktop): harden installer rendering lifecycle and tests
This commit is contained in:
1 parent
be205ad819
commit
69207c4629
9 files changed
+121
-57
No files matched your search
@@ -54,8 +54,8 @@
|
||||
en: ac31ebdd9de54f34
|
||||
zh: 921e534d5c4b3a16
|
||||
/deepseek-harness-desktop/windows-installer-interface:
|
||||
en: 23c57225f3de2350
|
||||
zh: b65b5363791f4c03
|
||||
en: 9b90a2651a6d2b52
|
||||
zh: 86d4d9c4358aad80
|
||||
/deepseek-harness-desktop/updates:
|
||||
en: b83a37681e23a3cc
|
||||
zh: e11ab8381fa4362d
|
||||
|
||||
@@ -255,7 +255,7 @@ The theme follows Windows at startup; `/THEME=light`, `/THEME=dark`, and `/THEME
|
||||
|
||||
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. 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] [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.
|
||||
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.
|
||||
|
||||
|
||||
@@ -255,7 +255,7 @@ Windows 安装程序使用原生 NSIS 页面,提供亮暗配色、系统阴影
|
||||
|
||||
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/` 下。检查包含末尾带分隔符的已登记路径升级,以及磁盘根目录拒绝。原生绘制检查覆盖 100% 和 200% 缩放下的浅色/深色配色、绘制到 100% 的完成状态、标题栏按钮的悬停/按下颜色和松开触发与取消、最小化恢复后的窗口位置和尺寸,以及 GDI 资源释放;`render-results.log` 中的重绘耗时仅供诊断,不作为 CI 性能阈值。运行生成的 `ui/render-test.exe <brand.bmp> <dpi> [light|dark] [interactive-title]` 并指定标题,可在不安装载荷的情况下查看真实进度页。可选的 `--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)"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -71,8 +74,8 @@ Function InstallerResolveTheme
|
||||
StrCpy $InstallerPrimary 0xFFF9FAFB
|
||||
StrCpy $InstallerPrimaryHover 0xFFE9ECF2
|
||||
StrCpy $InstallerPrimaryPressed 0xFFD4DBE9
|
||||
StrCpy $InstallerControlHover 0xFF303034
|
||||
StrCpy $InstallerControlPressed 0xFF45454B
|
||||
StrCpy $InstallerControlHover ${INSTALLER_DARK_CONTROL_HOVER}
|
||||
StrCpy $InstallerControlPressed ${INSTALLER_DARK_CONTROL_PRESSED}
|
||||
StrCpy $InstallerTrack 0xFF61666B
|
||||
StrCpy $InstallerButtonText 0x15110F
|
||||
StrCpy $InstallerBorder 0xFF61666B
|
||||
@@ -86,7 +89,7 @@ Function InstallerResolveTheme
|
||||
StrCpy $InstallerPrimaryHover ${INSTALLER_PRIMARY_HOVER}
|
||||
StrCpy $InstallerPrimaryPressed ${INSTALLER_PRIMARY_PRESSED}
|
||||
StrCpy $InstallerControlHover ${INSTALLER_CONTROL_HOVER}
|
||||
StrCpy $InstallerControlPressed 0xFFD4DBE9
|
||||
StrCpy $InstallerControlPressed ${INSTALLER_CONTROL_PRESSED}
|
||||
StrCpy $InstallerTrack ${INSTALLER_TRACK_COLOR}
|
||||
StrCpy $InstallerButtonText 0xFFFFFF
|
||||
StrCpy $InstallerBorder 0xFFBBC0C8
|
||||
|
||||
@@ -119,6 +119,8 @@ struct ProgressPage {
|
||||
HGDIOBJ previousBitmap;
|
||||
UINT pressedCommand;
|
||||
UINT hoveredCommand;
|
||||
ARGB controlHover;
|
||||
ARGB controlPressed;
|
||||
WCHAR captions[5][128];
|
||||
|
||||
~ProgressPage() {
|
||||
@@ -173,10 +175,8 @@ static void PaintCaptionButtons(Graphics& graphics, const ProgressPage& page) {
|
||||
for (UINT command : {SC_MINIMIZE, SC_CLOSE}) {
|
||||
const int x = command == SC_MINIMIZE ? 504 : 548;
|
||||
if (page.hoveredCommand == command && (!page.pressedCommand || page.pressedCommand == command)) {
|
||||
// Match the secondary caption controls in theme.nsh and pages.nsh.
|
||||
const bool pressed = page.pressedCommand == command;
|
||||
SolidBrush fill(page.dark ? (pressed ? Color(255, 69, 69, 75) : Color(255, 48, 48, 52))
|
||||
: (pressed ? Color(255, 212, 219, 233) : Color(255, 238, 240, 242)));
|
||||
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);
|
||||
@@ -253,9 +253,11 @@ static LRESULT CALLBACK ProgressProc(HWND window, UINT message, WPARAM wparam, L
|
||||
if (message == WM_CANCELMODE && GetCapture() == window) ReleaseCapture();
|
||||
return 0;
|
||||
}
|
||||
if (message == WM_PAINT && page) {
|
||||
PAINTSTRUCT paint;
|
||||
HDC dc = BeginPaint(window, &paint);
|
||||
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);
|
||||
{
|
||||
Graphics graphics(page->buffer);
|
||||
graphics.ScaleTransform(page->dpi / 96.0f, page->dpi / 96.0f);
|
||||
@@ -285,7 +287,7 @@ static LRESULT CALLBACK ProgressProc(HWND window, UINT message, WPARAM wparam, L
|
||||
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);
|
||||
EndPaint(window, &paint);
|
||||
if (!printing) EndPaint(window, &paint);
|
||||
if (page->progress.value == 100) SetPropW(GetParent(window), L"HarnessInstaller.CompletedPercent", reinterpret_cast<HANDLE>(100));
|
||||
return 0;
|
||||
}
|
||||
@@ -297,8 +299,9 @@ 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.
|
||||
// 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 = {};
|
||||
@@ -311,17 +314,14 @@ 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)) {
|
||||
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) {
|
||||
if (!page->brand || page->brand->GetLastStatus() != Ok) {
|
||||
delete page; return nullptr;
|
||||
}
|
||||
const int size = MulDiv(600, dpi, 96);
|
||||
@@ -340,7 +340,7 @@ extern "C" __declspec(dllexport) HWND __cdecl InstallerShowProgress(HWND parent,
|
||||
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->GetLastStatus() != Ok) { delete page; return nullptr; }
|
||||
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);
|
||||
@@ -354,9 +354,16 @@ extern "C" __declspec(dllexport) HWND __cdecl InstallerShowProgress(HWND parent,
|
||||
}
|
||||
delete page->brand;
|
||||
page->brand = nullptr;
|
||||
HWND window = CreateWindowExW(0, type.lpszClassName, L"", WS_CHILD | WS_VISIBLE,
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -83,8 +83,4 @@ foreach ($asset in @('brand', 'brand-2x', 'brand-dark', 'brand-dark-2x', 'uninst
|
||||
} finally { $bitmap.Dispose() }
|
||||
} finally { $image.Dispose() }
|
||||
}
|
||||
if ($TestProgress) {
|
||||
& $renderExecutable (Join-Path $output 'brand-2x.bmp') 192
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Installer rendering regression failed.' }
|
||||
}
|
||||
Write-Output "Prepared native installer resources: $output"
|
||||
@@ -72,10 +72,18 @@ try {
|
||||
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 result = await execute(renderTest, [join(output, 'ui', brand), String(dpi), theme], childOptions)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
extern "C" __declspec(dllimport) HRESULT __cdecl InstallerApplyFrame(HWND window);
|
||||
extern "C" __declspec(dllimport) HWND __cdecl InstallerShowProgress(HWND parent, HWND source,
|
||||
BOOL dark, UINT dpi, const WCHAR* brand, const WCHAR* preparing, const WCHAR* extracting,
|
||||
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);
|
||||
|
||||
@@ -19,14 +19,20 @@ static LRESULT CALLBACK WindowProc(HWND window, UINT message, WPARAM wparam, LPA
|
||||
}
|
||||
|
||||
int wmain(int argc, wchar_t** argv) {
|
||||
if (argc < 3) {
|
||||
std::fprintf(stderr, "Usage: render-test.exe <brand.bmp> <dpi> [light|dark] [interactive-title]\n");
|
||||
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 = argc > 3 && wcscmp(argv[3], L"dark") == 0;
|
||||
const bool interactive = argc > 4;
|
||||
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;
|
||||
@@ -34,19 +40,30 @@ int wmain(int argc, wchar_t** argv) {
|
||||
type.lpszClassName = L"HarnessInstallerRenderTest";
|
||||
type.hCursor = LoadCursorW(nullptr, IDC_ARROW);
|
||||
if (!RegisterClassW(&type)) return 3;
|
||||
HWND window = CreateWindowW(type.lpszClassName, interactive ? argv[4] : L"Installer rendering test",
|
||||
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, 0, 0, 1, 1, window, nullptr, type.hInstance, nullptr);
|
||||
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), argv[1],
|
||||
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; }
|
||||
@@ -70,33 +87,64 @@ int wmain(int argc, wchar_t** argv) {
|
||||
}
|
||||
RECT originalBounds, restoredBounds;
|
||||
GetWindowRect(window, &originalBounds);
|
||||
const auto captionPixel = [&](int x) {
|
||||
UpdateWindow(progress);
|
||||
HDC dc = GetDC(progress);
|
||||
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));
|
||||
ReleaseDC(progress, dc);
|
||||
SelectObject(dc, previous);
|
||||
DeleteObject(bitmap);
|
||||
DeleteDC(dc);
|
||||
return pixel;
|
||||
};
|
||||
const COLORREF normalColor = dark ? RGB(21, 21, 23) : RGB(255, 255, 255);
|
||||
const COLORREF hoverColor = dark ? RGB(48, 48, 52) : RGB(238, 240, 242);
|
||||
const COLORREF pressedColor = dark ? RGB(69, 69, 75) : RGB(212, 219, 233);
|
||||
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 = captionStates && captionPixel(x) == normalColor;
|
||||
captionStates = expectPixel(x, normalColor) && captionStates;
|
||||
SendMessageW(progress, WM_MOUSEMOVE, 0, point);
|
||||
captionStates = captionStates && captionPixel(x) == hoverColor;
|
||||
captionStates = expectPixel(x, hoverColor) && captionStates;
|
||||
SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, point);
|
||||
captionStates = captionStates && captionPixel(x) == pressedColor;
|
||||
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 = captionStates && captionPixel(x) == normalColor;
|
||||
captionStates = expectPixel(x, normalColor) && captionStates;
|
||||
SendMessageW(progress, WM_MOUSEMOVE, MK_LBUTTON, point);
|
||||
captionStates = captionStates && captionPixel(x) == pressedColor;
|
||||
captionStates = expectPixel(x, pressedColor) && captionStates;
|
||||
SendMessageW(progress, WM_CANCELMODE, 0, 0);
|
||||
captionStates = captionStates && captionPixel(x) == hoverColor;
|
||||
captionStates = expectPixel(x, hoverColor) && captionStates;
|
||||
SendMessageW(progress, WM_MOUSELEAVE, 0, 0);
|
||||
captionStates = captionStates && captionPixel(x) == normalColor;
|
||||
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;
|
||||
@@ -129,7 +177,8 @@ int wmain(int argc, wchar_t** argv) {
|
||||
QueryPerformanceFrequency(&frequency);
|
||||
std::vector<double> samples;
|
||||
// Full repaint is the exposure/restore path; fixture startup is excluded.
|
||||
for (int frame = 0; frame < 300; ++frame) {
|
||||
constexpr int frameCount = 300;
|
||||
for (int frame = 0; frame < frameCount; ++frame) {
|
||||
InvalidateRect(progress, nullptr, FALSE);
|
||||
QueryPerformanceCounter(&started);
|
||||
UpdateWindow(progress);
|
||||
@@ -144,8 +193,9 @@ int wmain(int argc, wchar_t** argv) {
|
||||
}
|
||||
std::printf("]\n");
|
||||
std::sort(samples.begin(), samples.end());
|
||||
std::printf("dpi=%d dark=%d frames=300 mean_ms=%.3f p50_ms=%.3f p95_ms=%.3f max_ms=%.3f\n",
|
||||
dpi, dark, total / samples.size(), samples[150], samples[285], samples.back());
|
||||
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] = {};
|
||||
@@ -154,7 +204,7 @@ int wmain(int argc, wchar_t** argv) {
|
||||
DestroyWindow(progress);
|
||||
const DWORD finalGdi = GetGuiResources(GetCurrentProcess(), GR_GDIOBJECTS);
|
||||
DestroyWindow(window);
|
||||
std::printf("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",
|
||||
completed, finalCaption, captionStates, buttonRelease, closeRelease, minimized, restored, initialGdi, finalGdi);
|
||||
return completed && finalCaption && captionStates && buttonRelease && closeRelease && minimized && restored && finalGdi <= initialGdi ? 0 : 6;
|
||||
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