From fefedfd64157d85d2c220d152c6d79b28f309900 Mon Sep 17 00:00:00 2001 From: BayesWang <827130441@qq.com> Date: Tue, 21 Jul 2026 19:55:58 +0800 Subject: [PATCH 01/18] fix(packaging): preserve models during Windows upgrades --- apps/desktop/tauri.conf.json | 1 + apps/desktop/windows/installer-hooks.nsh | 19 +- apps/desktop/windows/installer.nsi | 988 ++++++++++++++++++ .../windows/model-retention-policy.nsh | 39 + docs/release.md | 5 +- docs/user-guide.md | 14 + tooling/tests/test_desktop_packaging.py | 284 ++++- 7 files changed, 1337 insertions(+), 13 deletions(-) create mode 100644 apps/desktop/windows/installer.nsi create mode 100644 apps/desktop/windows/model-retention-policy.nsh diff --git a/apps/desktop/tauri.conf.json b/apps/desktop/tauri.conf.json index 18ca925..4f6b5f9 100644 --- a/apps/desktop/tauri.conf.json +++ b/apps/desktop/tauri.conf.json @@ -47,6 +47,7 @@ "installMode": "currentUser", "installerIcon": "icons/icon.ico", "installerHooks": "windows/installer-hooks.nsh", + "template": "windows/installer.nsi", "languages": ["SimpChinese", "English"] } } diff --git a/apps/desktop/windows/installer-hooks.nsh b/apps/desktop/windows/installer-hooks.nsh index cd7635f..a0cb0af 100644 --- a/apps/desktop/windows/installer-hooks.nsh +++ b/apps/desktop/windows/installer-hooks.nsh @@ -1,9 +1,16 @@ -!macro NSIS_HOOK_PREUNINSTALL - MessageBox MB_ICONEXCLAMATION|MB_OKCANCEL|MB_DEFBUTTON2 "卸载 CaptionNest 将同时删除本机下载的语音识别模型。模型文件可能占用数 GB 空间,删除后无法恢复。$\r$\n$\r$\n选择“确定”继续卸载,选择“取消”保留应用和模型。" /SD IDOK IDOK continue_uninstall - Abort - continue_uninstall: -!macroend +!include "${__FILEDIR__}\model-retention-policy.nsh" + +!ifndef CAPTIONNEST_MODELS_DIR + !define CAPTIONNEST_MODELS_DIR "$LOCALAPPDATA\io.github.coconilu.captionnest\models" +!endif !macro NSIS_HOOK_POSTUNINSTALL - RMDir /r "$LOCALAPPDATA\io.github.coconilu.captionnest\models" + ; Tauri's uninstall confirmation page owns the user decision. Its built-in + ; "delete app data" checkbox is unchecked by default and is skipped for + ; passive, silent, and /UPDATE runs. Mirror the same guard here so models are + ; never removed merely because an older program version is being replaced. + ${If} $DeleteAppDataCheckboxState = 1 + ${AndIf} $UpdateMode <> 1 + RMDir /r "${CAPTIONNEST_MODELS_DIR}" + ${EndIf} !macroend diff --git a/apps/desktop/windows/installer.nsi b/apps/desktop/windows/installer.nsi new file mode 100644 index 0000000..f13e413 --- /dev/null +++ b/apps/desktop/windows/installer.nsi @@ -0,0 +1,988 @@ +; Based on Tauri CLI 2.11.4's official installer template: +; https://github.com/tauri-apps/tauri/blob/tauri-cli-v2.11.4/crates/tauri-bundler/src/bundle/windows/nsis/installer.nsi +; CaptionNest customization: upgrades default to an in-place install so a +; vulnerable older uninstaller cannot delete app-managed recognition models. + +Unicode true +ManifestDPIAware true +; Add in `dpiAwareness` `PerMonitorV2` to manifest for Windows 10 1607+ (note this should not affect lower versions since they should be able to ignore this and pick up `dpiAware` `true` set by `ManifestDPIAware true`) +; Currently undocumented on NSIS's website but is in the Docs folder of source tree, see +; https://github.com/kichik/nsis/blob/5fc0b87b819a9eec006df4967d08e522ddd651c9/Docs/src/attributes.but#L286-L300 +; https://github.com/tauri-apps/tauri/pull/10106 +ManifestDPIAwareness PerMonitorV2 + +!if "{{compression}}" == "none" + SetCompress off +!else + ; Set the compression algorithm. We default to LZMA. + SetCompressor /SOLID "{{compression}}" +!endif + +; Keep above !include to stay ahead of any plugin command +; see https://github.com/tauri-apps/tauri/pull/15422#discussion_r3289239624 +{{#if signed_plugins_path}} +!addplugindir "{{signed_plugins_path}}" +{{/if}} + +!include MUI2.nsh +!include FileFunc.nsh +!include x64.nsh +!include WordFunc.nsh +!include "utils.nsh" +!include "FileAssociation.nsh" +!include "Win\COM.nsh" +!include "Win\Propkey.nsh" +!include "StrFunc.nsh" +${StrCase} +${StrLoc} + +{{#if installer_hooks}} +!include "{{installer_hooks}}" +{{/if}} + +!define WEBVIEW2APPGUID "{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" + +!define MANUFACTURER "{{manufacturer}}" +!define PRODUCTNAME "{{product_name}}" +!define VERSION "{{version}}" +!define VERSIONWITHBUILD "{{version_with_build}}" +!define HOMEPAGE "{{homepage}}" +!define INSTALLMODE "{{install_mode}}" +!define LICENSE "{{license}}" +!define INSTALLERICON "{{installer_icon}}" +!define SIDEBARIMAGE "{{sidebar_image}}" +!define HEADERIMAGE "{{header_image}}" +!define UNINSTALLERICON "{{uninstaller_icon}}" +!define UNINSTALLERHEADERIMAGE "{{uninstaller_header_image}}" +!define MAINBINARYNAME "{{main_binary_name}}" +!define MAINBINARYSRCPATH "{{main_binary_path}}" +!define BUNDLEID "{{bundle_id}}" +!define COPYRIGHT "{{copyright}}" +!define OUTFILE "{{out_file}}" +!define ARCH "{{arch}}" +!define ADDITIONALPLUGINSPATH "{{additional_plugins_path}}" +!define ALLOWDOWNGRADES "{{allow_downgrades}}" +!define DISPLAYLANGUAGESELECTOR "{{display_language_selector}}" +!define INSTALLWEBVIEW2MODE "{{install_webview2_mode}}" +!define WEBVIEW2INSTALLERARGS "{{webview2_installer_args}}" +!define WEBVIEW2BOOTSTRAPPERPATH "{{webview2_bootstrapper_path}}" +!define WEBVIEW2INSTALLERPATH "{{webview2_installer_path}}" +!define MINIMUMWEBVIEW2VERSION "{{minimum_webview2_version}}" +!define UNINSTKEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCTNAME}" +!define MANUKEY "Software\${MANUFACTURER}" +!define MANUPRODUCTKEY "${MANUKEY}\${PRODUCTNAME}" +!define UNINSTALLERSIGNCOMMAND "{{uninstaller_sign_cmd}}" +!define ESTIMATEDSIZE "{{estimated_size}}" +!define STARTMENUFOLDER "{{start_menu_folder}}" + +Var PassiveMode +Var UpdateMode +Var NoShortcutMode +Var WixMode +Var OldMainBinaryName + +Name "${PRODUCTNAME}" +BrandingText "${COPYRIGHT}" +OutFile "${OUTFILE}" + +; We don't actually use this value as default install path, +; it's just for nsis to append the product name folder in the directory selector +; https://nsis.sourceforge.io/Reference/InstallDir +!define PLACEHOLDER_INSTALL_DIR "placeholder\${PRODUCTNAME}" +InstallDir "${PLACEHOLDER_INSTALL_DIR}" + +VIProductVersion "${VERSIONWITHBUILD}" +VIAddVersionKey "ProductName" "${PRODUCTNAME}" +VIAddVersionKey "FileDescription" "${PRODUCTNAME}" +VIAddVersionKey "LegalCopyright" "${COPYRIGHT}" +VIAddVersionKey "FileVersion" "${VERSION}" +VIAddVersionKey "ProductVersion" "${VERSION}" + +# additional plugins +!addplugindir "${ADDITIONALPLUGINSPATH}" + +; Uninstaller signing command +!if "${UNINSTALLERSIGNCOMMAND}" != "" + !uninstfinalize '${UNINSTALLERSIGNCOMMAND}' +!endif + +; Handle install mode, `perUser`, `perMachine` or `both` +!if "${INSTALLMODE}" == "perMachine" + RequestExecutionLevel admin +!endif + +!if "${INSTALLMODE}" == "currentUser" + RequestExecutionLevel user +!endif + +!if "${INSTALLMODE}" == "both" + !define MULTIUSER_MUI + !define MULTIUSER_INSTALLMODE_INSTDIR "${PRODUCTNAME}" + !define MULTIUSER_INSTALLMODE_COMMANDLINE + !if "${ARCH}" == "x64" + !define MULTIUSER_USE_PROGRAMFILES64 + !else if "${ARCH}" == "arm64" + !define MULTIUSER_USE_PROGRAMFILES64 + !endif + !define MULTIUSER_INSTALLMODE_DEFAULT_REGISTRY_KEY "${UNINSTKEY}" + !define MULTIUSER_INSTALLMODE_DEFAULT_REGISTRY_VALUENAME "CurrentUser" + !define MULTIUSER_INSTALLMODEPAGE_SHOWUSERNAME + !define MULTIUSER_INSTALLMODE_FUNCTION RestorePreviousInstallLocation + !define MULTIUSER_EXECUTIONLEVEL Highest + !include MultiUser.nsh +!endif + +; Installer icon +!if "${INSTALLERICON}" != "" + !define MUI_ICON "${INSTALLERICON}" +!endif + +; Installer sidebar image +!if "${SIDEBARIMAGE}" != "" + !define MUI_WELCOMEFINISHPAGE_BITMAP "${SIDEBARIMAGE}" +!endif + +; Enable header images for installer and uninstaller pages when either image is configured. +!if "${HEADERIMAGE}" != "" + !define MUI_HEADERIMAGE +!else if "${UNINSTALLERHEADERIMAGE}" != "" + !define MUI_HEADERIMAGE +!endif + +; Installer header image +!if "${HEADERIMAGE}" != "" + !define MUI_HEADERIMAGE_BITMAP "${HEADERIMAGE}" +!endif + +; Uninstaller header image +!if "${UNINSTALLERHEADERIMAGE}" != "" + !define MUI_HEADERIMAGE_UNBITMAP "${UNINSTALLERHEADERIMAGE}" +!endif + +; Uninstaller icon +!if "${UNINSTALLERICON}" != "" + !define MUI_UNICON "${UNINSTALLERICON}" +!endif + +; Define registry key to store installer language +!define MUI_LANGDLL_REGISTRY_ROOT "HKCU" +!define MUI_LANGDLL_REGISTRY_KEY "${MANUPRODUCTKEY}" +!define MUI_LANGDLL_REGISTRY_VALUENAME "Installer Language" + +; Installer pages, must be ordered as they appear +; 1. Welcome Page +!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive +!insertmacro MUI_PAGE_WELCOME + +; 2. License Page (if defined) +!if "${LICENSE}" != "" + !define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive + !insertmacro MUI_PAGE_LICENSE "${LICENSE}" +!endif + +; 3. Install mode (if it is set to `both`) +!if "${INSTALLMODE}" == "both" + !define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive + !insertmacro MULTIUSER_PAGE_INSTALLMODE +!endif + +; 4. Custom page to ask user if he wants to reinstall/uninstall +; only if a previous installation was detected +Var ReinstallPageCheck +Page custom PageReinstall PageLeaveReinstall +Function PageReinstall + ; Uninstall previous WiX installation if exists. + ; + ; A WiX installer stores the installation info in registry + ; using a UUID and so we have to loop through all keys under + ; `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall` + ; and check if `DisplayName` and `Publisher` keys match ${PRODUCTNAME} and ${MANUFACTURER} + ; + ; This has a potential issue that there maybe another installation that matches + ; our ${PRODUCTNAME} and ${MANUFACTURER} but wasn't installed by our WiX installer, + ; however, this should be fine since the user will have to confirm the uninstallation + ; and they can chose to abort it if doesn't make sense. + StrCpy $0 0 + wix_loop: + EnumRegKey $1 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" $0 + StrCmp $1 "" wix_loop_done ; Exit loop if there is no more keys to loop on + IntOp $0 $0 + 1 + ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$1" "DisplayName" + ReadRegStr $R1 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$1" "Publisher" + StrCmp "$R0$R1" "${PRODUCTNAME}${MANUFACTURER}" 0 wix_loop + ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$1" "UninstallString" + ${StrCase} $R1 $R0 "L" + ${StrLoc} $R0 $R1 "msiexec" ">" + StrCmp $R0 0 0 wix_loop_done + StrCpy $WixMode 1 + StrCpy $R6 "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$1" + Goto compare_version + wix_loop_done: + + ; Check if there is an existing installation, if not, abort the reinstall page + ReadRegStr $R0 SHCTX "${UNINSTKEY}" "" + ReadRegStr $R1 SHCTX "${UNINSTKEY}" "UninstallString" + ${IfThen} "$R0$R1" == "" ${|} Abort ${|} + + ; Compare this installar version with the existing installation + ; and modify the messages presented to the user accordingly + compare_version: + StrCpy $R4 "$(older)" + ${If} $WixMode = 1 + ReadRegStr $R0 HKLM "$R6" "DisplayVersion" + ${Else} + ReadRegStr $R0 SHCTX "${UNINSTKEY}" "DisplayVersion" + ${EndIf} + ${IfThen} $R0 == "" ${|} StrCpy $R4 "$(unknown)" ${|} + + nsis_tauri_utils::SemverCompare "${VERSION}" $R0 + Pop $R0 + ; Reinstalling the same version + ${If} $R0 = 0 + StrCpy $R1 "$(alreadyInstalledLong)" + StrCpy $R2 "$(addOrReinstall)" + StrCpy $R3 "$(uninstallApp)" + !insertmacro MUI_HEADER_TEXT "$(alreadyInstalled)" "$(chooseMaintenanceOption)" + ; Upgrading + ${ElseIf} $R0 = 1 + StrCpy $R1 "$(olderOrUnknownVersionInstalled)" + StrCpy $R2 "$(uninstallBeforeInstalling)" + StrCpy $R3 "$(dontUninstall)" + !insertmacro MUI_HEADER_TEXT "$(alreadyInstalled)" "$(choowHowToInstall)" + ; Downgrading + ${ElseIf} $R0 = -1 + StrCpy $R1 "$(newerVersionInstalled)" + StrCpy $R2 "$(uninstallBeforeInstalling)" + !if "${ALLOWDOWNGRADES}" == "true" + StrCpy $R3 "$(dontUninstall)" + !else + StrCpy $R3 "$(dontUninstallDowngrade)" + !endif + !insertmacro MUI_HEADER_TEXT "$(alreadyInstalled)" "$(choowHowToInstall)" + ${Else} + Abort + ${EndIf} + + ; Skip showing the page if passive + ; + ; Note that we don't call this earlier at the begining + ; of this function because we need to populate some variables + ; related to current installed version if detected and whether + ; we are downgrading or not. + ${If} $PassiveMode = 1 + Call PageLeaveReinstall + ${Else} + nsDialogs::Create 1018 + Pop $R4 + ${IfThen} $(^RTL) = 1 ${|} nsDialogs::SetRTL $(^RTL) ${|} + + ${NSD_CreateLabel} 0 0 100% 24u $R1 + Pop $R1 + + ${NSD_CreateRadioButton} 30u 50u -30u 8u $R2 + Pop $R2 + ${NSD_OnClick} $R2 PageReinstallUpdateSelection + + ${NSD_CreateRadioButton} 30u 70u -30u 8u $R3 + Pop $R3 + ; Disable this radio button if downgrading and downgrades are disabled + !if "${ALLOWDOWNGRADES}" == "false" + ${IfThen} $R0 = -1 ${|} EnableWindow $R3 0 ${|} + !endif + ${NSD_OnClick} $R3 PageReinstallUpdateSelection + + ; An in-place install is the safe default for upgrades. In particular, the + ; first fixed installer must not execute an older uninstaller whose custom + ; hook deleted recognition models unconditionally. + !insertmacro CAPTIONNEST_SET_REINSTALL_DEFAULT $R0 $ReinstallPageCheck + ${If} $ReinstallPageCheck = 2 + SendMessage $R3 ${BM_SETCHECK} ${BST_CHECKED} 0 + ${NSD_SetFocus} $R3 + ${Else} + SendMessage $R2 ${BM_SETCHECK} ${BST_CHECKED} 0 + ${NSD_SetFocus} $R2 + ${EndIf} + + nsDialogs::Show + ${EndIf} +FunctionEnd +Function PageReinstallUpdateSelection + ${NSD_GetState} $R2 $R1 + ${If} $R1 == ${BST_CHECKED} + StrCpy $ReinstallPageCheck 1 + ${Else} + StrCpy $ReinstallPageCheck 2 + ${EndIf} +FunctionEnd +Function PageLeaveReinstall + ; If migrating from Wix, always uninstall + ${If} $WixMode = 1 + Goto reinst_uninstall + ${EndIf} + + ; In update mode, always proceeds without uninstalling + ${If} $UpdateMode = 1 + Goto reinst_done + ${EndIf} + + ; Silent and passive installs cannot collect an explicit data-deletion + ; decision, so they always install in place and preserve existing models. + !insertmacro CAPTIONNEST_SKIP_UNINSTALL_FOR_UNATTENDED $PassiveMode reinst_done + + ${NSD_GetState} $R2 $R1 + + ; $R0 holds whether same(0)/upgrading(1)/downgrading(-1) version + ; $R1 holds the radio buttons state: + ; 1 => first choice was selected + ; 0 => second choice was selected + ${If} $R0 = 0 ; Same version, proceed + ${If} $R1 = 1 ; User chose to add/reinstall + Goto reinst_done + ${Else} ; User chose to uninstall + Goto reinst_uninstall + ${EndIf} + ${ElseIf} $R0 = 1 ; Upgrading + ${If} $R1 = 1 ; User chose to uninstall + Goto reinst_uninstall + ${Else} + Goto reinst_done ; User chose NOT to uninstall + ${EndIf} + ${ElseIf} $R0 = -1 ; Downgrading + ${If} $R1 = 1 ; User chose to uninstall + Goto reinst_uninstall + ${Else} + Goto reinst_done ; User chose NOT to uninstall + ${EndIf} + ${EndIf} + + reinst_uninstall: + HideWindow + ClearErrors + + ${If} $WixMode = 1 + ReadRegStr $R1 HKLM "$R6" "UninstallString" + ExecWait '$R1' $0 + ${Else} + ReadRegStr $4 SHCTX "${MANUPRODUCTKEY}" "" + ReadRegStr $R1 SHCTX "${UNINSTKEY}" "UninstallString" + ${IfThen} $UpdateMode = 1 ${|} StrCpy $R1 "$R1 /UPDATE" ${|} ; append /UPDATE + ${IfThen} $PassiveMode = 1 ${|} StrCpy $R1 "$R1 /P" ${|} ; append /P + StrCpy $R1 "$R1 _?=$4" ; append uninstall directory + ExecWait '$R1' $0 + ${EndIf} + + BringToFront + + ${IfThen} ${Errors} ${|} StrCpy $0 2 ${|} ; ExecWait failed, set fake exit code + + ${If} $0 <> 0 + ${OrIf} ${FileExists} "$INSTDIR\${MAINBINARYNAME}.exe" + ; User cancelled wix uninstaller? return to select un/reinstall page + ${If} $WixMode = 1 + ${AndIf} $0 = 1602 + Abort + ${EndIf} + + ; User cancelled NSIS uninstaller? return to select un/reinstall page + ${If} $0 = 1 + Abort + ${EndIf} + + ; Other erros? show generic error message and return to select un/reinstall page + MessageBox MB_ICONEXCLAMATION "$(unableToUninstall)" + Abort + ${EndIf} + reinst_done: +FunctionEnd + +; 5. Choose install directory page +!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive +!insertmacro MUI_PAGE_DIRECTORY + +; 6. Start menu shortcut page +Var AppStartMenuFolder +!if "${STARTMENUFOLDER}" != "" + !define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive + !define MUI_STARTMENUPAGE_DEFAULTFOLDER "${STARTMENUFOLDER}" +!else + !define MUI_PAGE_CUSTOMFUNCTION_PRE Skip +!endif +!insertmacro MUI_PAGE_STARTMENU Application $AppStartMenuFolder + +; 7. Installation page +!insertmacro MUI_PAGE_INSTFILES + +; 8. Finish page +; +; Don't auto jump to finish page after installation page, +; because the installation page has useful info that can be used debug any issues with the installer. +!define MUI_FINISHPAGE_NOAUTOCLOSE +; Use show readme button in the finish page as a button create a desktop shortcut +!define MUI_FINISHPAGE_SHOWREADME +!define MUI_FINISHPAGE_SHOWREADME_TEXT "$(createDesktop)" +!define MUI_FINISHPAGE_SHOWREADME_FUNCTION CreateOrUpdateDesktopShortcut +; Show run app after installation. +!define MUI_FINISHPAGE_RUN +!define MUI_FINISHPAGE_RUN_FUNCTION RunMainBinary +!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive +!insertmacro MUI_PAGE_FINISH + +Function RunMainBinary + nsis_tauri_utils::RunAsUser "$INSTDIR\${MAINBINARYNAME}.exe" "" +FunctionEnd + +; Uninstaller Pages +; 1. Confirm uninstall page +Var DeleteAppDataCheckbox +Var DeleteAppDataCheckboxState +!define /ifndef WS_EX_LAYOUTRTL 0x00400000 +!define MUI_PAGE_CUSTOMFUNCTION_SHOW un.ConfirmShow +Function un.ConfirmShow ; Add add a `Delete app data` check box + ; $1 inner dialog HWND + ; $2 window DPI + ; $3 style + ; $4 x + ; $5 y + ; $6 width + ; $7 height + FindWindow $1 "#32770" "" $HWNDPARENT ; Find inner dialog + System::Call "user32::GetDpiForWindow(p r1) i .r2" + ${If} $(^RTL) = 1 + StrCpy $3 "${__NSD_CheckBox_EXSTYLE} | ${WS_EX_LAYOUTRTL}" + IntOp $4 50 * $2 + ${Else} + StrCpy $3 "${__NSD_CheckBox_EXSTYLE}" + IntOp $4 0 * $2 + ${EndIf} + IntOp $5 100 * $2 + IntOp $6 400 * $2 + IntOp $7 25 * $2 + IntOp $4 $4 / 96 + IntOp $5 $5 / 96 + IntOp $6 $6 / 96 + IntOp $7 $7 / 96 + System::Call 'user32::CreateWindowEx(i r3, w "${__NSD_CheckBox_CLASS}", w "$(deleteAppData)", i ${__NSD_CheckBox_STYLE}, i r4, i r5, i r6, i r7, p r1, i0, i0, i0) i .s' + Pop $DeleteAppDataCheckbox + SendMessage $HWNDPARENT ${WM_GETFONT} 0 0 $1 + SendMessage $DeleteAppDataCheckbox ${WM_SETFONT} $1 1 +FunctionEnd +!define MUI_PAGE_CUSTOMFUNCTION_LEAVE un.ConfirmLeave +Function un.ConfirmLeave + SendMessage $DeleteAppDataCheckbox ${BM_GETCHECK} 0 0 $DeleteAppDataCheckboxState +FunctionEnd +!define MUI_PAGE_CUSTOMFUNCTION_PRE un.SkipIfPassive +!insertmacro MUI_UNPAGE_CONFIRM + +; 2. Uninstalling Page +!insertmacro MUI_UNPAGE_INSTFILES + +;Languages +{{#each languages}} +!insertmacro MUI_LANGUAGE "{{this}}" +{{/each}} +!insertmacro MUI_RESERVEFILE_LANGDLL +{{#each language_files}} + !include "{{this}}" +{{/each}} + +Function .onInit + ${GetOptions} $CMDLINE "/P" $PassiveMode + ${IfNot} ${Errors} + StrCpy $PassiveMode 1 + ${EndIf} + + ${GetOptions} $CMDLINE "/NS" $NoShortcutMode + ${IfNot} ${Errors} + StrCpy $NoShortcutMode 1 + ${EndIf} + + ${GetOptions} $CMDLINE "/UPDATE" $UpdateMode + ${IfNot} ${Errors} + StrCpy $UpdateMode 1 + ${EndIf} + + !if "${DISPLAYLANGUAGESELECTOR}" == "true" + !insertmacro MUI_LANGDLL_DISPLAY + !endif + + !insertmacro SetContext + + ${If} $INSTDIR == "${PLACEHOLDER_INSTALL_DIR}" + ; Set default install location + !if "${INSTALLMODE}" == "perMachine" + ${If} ${RunningX64} + !if "${ARCH}" == "x64" + StrCpy $INSTDIR "$PROGRAMFILES64\${PRODUCTNAME}" + !else if "${ARCH}" == "arm64" + StrCpy $INSTDIR "$PROGRAMFILES64\${PRODUCTNAME}" + !else + StrCpy $INSTDIR "$PROGRAMFILES\${PRODUCTNAME}" + !endif + ${Else} + StrCpy $INSTDIR "$PROGRAMFILES\${PRODUCTNAME}" + ${EndIf} + !else if "${INSTALLMODE}" == "currentUser" + StrCpy $INSTDIR "$LOCALAPPDATA\${PRODUCTNAME}" + !endif + + Call RestorePreviousInstallLocation + ${EndIf} + + + !if "${INSTALLMODE}" == "both" + !insertmacro MULTIUSER_INIT + !endif +FunctionEnd + + +Section EarlyChecks + ; Abort silent installer if downgrades is disabled + !if "${ALLOWDOWNGRADES}" == "false" + ${If} ${Silent} + ; If downgrading + ${If} $R0 = -1 + System::Call 'kernel32::AttachConsole(i -1)i.r0' + ${If} $0 <> 0 + System::Call 'kernel32::GetStdHandle(i -11)i.r0' + System::call 'kernel32::SetConsoleTextAttribute(i r0, i 0x0004)' ; set red color + FileWrite $0 "$(silentDowngrades)" + ${EndIf} + Abort + ${EndIf} + ${EndIf} + !endif + +SectionEnd + +Section WebView2 + ; Check if Webview2 is already installed and skip this section + ${If} ${RunningX64} + ReadRegStr $4 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\${WEBVIEW2APPGUID}" "pv" + ${Else} + ReadRegStr $4 HKLM "SOFTWARE\Microsoft\EdgeUpdate\Clients\${WEBVIEW2APPGUID}" "pv" + ${EndIf} + ${If} $4 == "" + ReadRegStr $4 HKCU "SOFTWARE\Microsoft\EdgeUpdate\Clients\${WEBVIEW2APPGUID}" "pv" + ${EndIf} + + ${If} $4 == "" + ; Webview2 installation + ; + ; Skip if updating + ${If} $UpdateMode <> 1 + !if "${INSTALLWEBVIEW2MODE}" == "downloadBootstrapper" + Delete "$TEMP\MicrosoftEdgeWebview2Setup.exe" + DetailPrint "$(webview2Downloading)" + NSISdl::download "https://go.microsoft.com/fwlink/p/?LinkId=2124703" "$TEMP\MicrosoftEdgeWebview2Setup.exe" + Pop $0 + ${If} $0 == "success" + DetailPrint "$(webview2DownloadSuccess)" + ${Else} + DetailPrint "$(webview2DownloadError)" + Abort "$(webview2AbortError)" + ${EndIf} + StrCpy $6 "$TEMP\MicrosoftEdgeWebview2Setup.exe" + Goto install_webview2 + !endif + + !if "${INSTALLWEBVIEW2MODE}" == "embedBootstrapper" + Delete "$TEMP\MicrosoftEdgeWebview2Setup.exe" + File "/oname=$TEMP\MicrosoftEdgeWebview2Setup.exe" "${WEBVIEW2BOOTSTRAPPERPATH}" + DetailPrint "$(installingWebview2)" + StrCpy $6 "$TEMP\MicrosoftEdgeWebview2Setup.exe" + Goto install_webview2 + !endif + + !if "${INSTALLWEBVIEW2MODE}" == "offlineInstaller" + Delete "$TEMP\MicrosoftEdgeWebView2RuntimeInstaller.exe" + File "/oname=$TEMP\MicrosoftEdgeWebView2RuntimeInstaller.exe" "${WEBVIEW2INSTALLERPATH}" + DetailPrint "$(installingWebview2)" + StrCpy $6 "$TEMP\MicrosoftEdgeWebView2RuntimeInstaller.exe" + Goto install_webview2 + !endif + + Goto webview2_done + + install_webview2: + DetailPrint "$(installingWebview2)" + ; $6 holds the path to the webview2 installer + ExecWait "$6 ${WEBVIEW2INSTALLERARGS} /install" $1 + ${If} $1 = 0 + DetailPrint "$(webview2InstallSuccess)" + ${Else} + DetailPrint "$(webview2InstallError)" + Abort "$(webview2AbortError)" + ${EndIf} + webview2_done: + ${EndIf} + ${Else} + !if "${MINIMUMWEBVIEW2VERSION}" != "" + ${VersionCompare} "${MINIMUMWEBVIEW2VERSION}" "$4" $R0 + ${If} $R0 = 1 + update_webview: + DetailPrint "$(installingWebview2)" + ${If} ${RunningX64} + ReadRegStr $R1 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate" "path" + ${Else} + ReadRegStr $R1 HKLM "SOFTWARE\Microsoft\EdgeUpdate" "path" + ${EndIf} + ${If} $R1 == "" + ReadRegStr $R1 HKCU "SOFTWARE\Microsoft\EdgeUpdate" "path" + ${EndIf} + ${If} $R1 != "" + ; Chromium updater docs: https://source.chromium.org/chromium/chromium/src/+/main:docs/updater/user_manual.md + ; Modified from "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\Microsoft EdgeWebView\ModifyPath" + ExecWait `"$R1" /install appguid=${WEBVIEW2APPGUID}&needsadmin=true` $1 + ${If} $1 = 0 + DetailPrint "$(webview2InstallSuccess)" + ${Else} + MessageBox MB_ICONEXCLAMATION|MB_ABORTRETRYIGNORE "$(webview2InstallError)" IDIGNORE ignore IDRETRY update_webview + Quit + ignore: + ${EndIf} + ${EndIf} + ${EndIf} + !endif + ${EndIf} +SectionEnd + +Section Install + SetOutPath $INSTDIR + + !ifmacrodef NSIS_HOOK_PREINSTALL + !insertmacro NSIS_HOOK_PREINSTALL + !endif + + !insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}" + + ; Copy main executable + File "${MAINBINARYSRCPATH}" + + ; Copy resources + {{#each resources_dirs}} + CreateDirectory "$INSTDIR\\{{this}}" + {{/each}} + {{#each resources}} + File /a "/oname={{this.[1]}}" "{{no-escape @key}}" + {{/each}} + + ; Copy external binaries + {{#each binaries}} + File /a "/oname={{this}}" "{{no-escape @key}}" + {{/each}} + + ; Create file associations + {{#each file_associations as |association| ~}} + {{#each association.ext as |ext| ~}} + !insertmacro APP_ASSOCIATE "{{ext}}" "{{or association.name ext}}" "{{association-description association.description ext}}" "$INSTDIR\${MAINBINARYNAME}.exe,0" "Open with ${PRODUCTNAME}" "$INSTDIR\${MAINBINARYNAME}.exe $\"%1$\"" + {{/each}} + {{/each}} + + ; Register deep links + {{#each deep_link_protocols as |protocol| ~}} + WriteRegStr SHCTX "Software\Classes\\{{protocol}}" "URL Protocol" "" + WriteRegStr SHCTX "Software\Classes\\{{protocol}}" "" "URL:${BUNDLEID} protocol" + WriteRegStr SHCTX "Software\Classes\\{{protocol}}\DefaultIcon" "" "$\"$INSTDIR\${MAINBINARYNAME}.exe$\",0" + WriteRegStr SHCTX "Software\Classes\\{{protocol}}\shell\open\command" "" "$\"$INSTDIR\${MAINBINARYNAME}.exe$\" $\"%1$\"" + {{/each}} + + ; Create uninstaller + WriteUninstaller "$INSTDIR\uninstall.exe" + + ; Save $INSTDIR in registry for future installations + WriteRegStr SHCTX "${MANUPRODUCTKEY}" "" $INSTDIR + + !if "${INSTALLMODE}" == "both" + ; Save install mode to be selected by default for the next installation such as updating + ; or when uninstalling + WriteRegStr SHCTX "${UNINSTKEY}" $MultiUser.InstallMode 1 + !endif + + ; Remove old main binary if it doesn't match new main binary name + ReadRegStr $OldMainBinaryName SHCTX "${UNINSTKEY}" "MainBinaryName" + ${If} $OldMainBinaryName != "" + ${AndIf} $OldMainBinaryName != "${MAINBINARYNAME}.exe" + Delete "$INSTDIR\$OldMainBinaryName" + ${EndIf} + + ; Save current MAINBINARYNAME for future updates + WriteRegStr SHCTX "${UNINSTKEY}" "MainBinaryName" "${MAINBINARYNAME}.exe" + + ; Registry information for add/remove programs + WriteRegStr SHCTX "${UNINSTKEY}" "DisplayName" "${PRODUCTNAME}" + WriteRegStr SHCTX "${UNINSTKEY}" "DisplayIcon" "$\"$INSTDIR\${MAINBINARYNAME}.exe$\"" + WriteRegStr SHCTX "${UNINSTKEY}" "DisplayVersion" "${VERSION}" + WriteRegStr SHCTX "${UNINSTKEY}" "Publisher" "${MANUFACTURER}" + WriteRegStr SHCTX "${UNINSTKEY}" "InstallLocation" "$\"$INSTDIR$\"" + WriteRegStr SHCTX "${UNINSTKEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\"" + WriteRegDWORD SHCTX "${UNINSTKEY}" "NoModify" "1" + WriteRegDWORD SHCTX "${UNINSTKEY}" "NoRepair" "1" + + ${GetSize} "$INSTDIR" "/M=uninstall.exe /S=0K /G=0" $0 $1 $2 + IntOp $0 $0 + ${ESTIMATEDSIZE} + IntFmt $0 "0x%08X" $0 + WriteRegDWORD SHCTX "${UNINSTKEY}" "EstimatedSize" "$0" + + !if "${HOMEPAGE}" != "" + WriteRegStr SHCTX "${UNINSTKEY}" "URLInfoAbout" "${HOMEPAGE}" + WriteRegStr SHCTX "${UNINSTKEY}" "URLUpdateInfo" "${HOMEPAGE}" + WriteRegStr SHCTX "${UNINSTKEY}" "HelpLink" "${HOMEPAGE}" + !endif + + ; Create start menu shortcut + !insertmacro MUI_STARTMENU_WRITE_BEGIN Application + Call CreateOrUpdateStartMenuShortcut + !insertmacro MUI_STARTMENU_WRITE_END + + ; Create desktop shortcut for silent and passive installers + ; because finish page will be skipped + ${If} $PassiveMode = 1 + ${OrIf} ${Silent} + Call CreateOrUpdateDesktopShortcut + ${EndIf} + + !ifmacrodef NSIS_HOOK_POSTINSTALL + !insertmacro NSIS_HOOK_POSTINSTALL + !endif + + ; Auto close this page for passive mode + ${If} $PassiveMode = 1 + SetAutoClose true + ${EndIf} +SectionEnd + +Function .onInstSuccess + ; Check for `/R` flag only in silent and passive installers because + ; GUI installer has a toggle for the user to (re)start the app + ${If} $PassiveMode = 1 + ${OrIf} ${Silent} + ${GetOptions} $CMDLINE "/R" $R0 + ${IfNot} ${Errors} + ${GetOptions} $CMDLINE "/ARGS" $R0 + nsis_tauri_utils::RunAsUser "$INSTDIR\${MAINBINARYNAME}.exe" "$R0" + ${EndIf} + ${EndIf} +FunctionEnd + +Function un.onInit + !insertmacro SetContext + + !if "${INSTALLMODE}" == "both" + !insertmacro MULTIUSER_UNINIT + !endif + + !insertmacro MUI_UNGETLANGUAGE + + ${GetOptions} $CMDLINE "/P" $PassiveMode + ${IfNot} ${Errors} + StrCpy $PassiveMode 1 + ${EndIf} + + ${GetOptions} $CMDLINE "/UPDATE" $UpdateMode + ${IfNot} ${Errors} + StrCpy $UpdateMode 1 + ${EndIf} +FunctionEnd + +Section Uninstall + + !ifmacrodef NSIS_HOOK_PREUNINSTALL + !insertmacro NSIS_HOOK_PREUNINSTALL + !endif + + !insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}" + + ; Delete the app directory and its content from disk + ; Copy main executable + Delete "$INSTDIR\${MAINBINARYNAME}.exe" + + ; Delete resources + {{#each resources}} + Delete "$INSTDIR\\{{this.[1]}}" + {{/each}} + + ; Delete external binaries + {{#each binaries}} + Delete "$INSTDIR\\{{this}}" + {{/each}} + + ; Delete app associations + {{#each file_associations as |association| ~}} + {{#each association.ext as |ext| ~}} + !insertmacro APP_UNASSOCIATE "{{ext}}" "{{or association.name ext}}" + {{/each}} + {{/each}} + + ; Delete deep links + {{#each deep_link_protocols as |protocol| ~}} + ReadRegStr $R7 SHCTX "Software\Classes\\{{protocol}}\shell\open\command" "" + ${If} $R7 == "$\"$INSTDIR\${MAINBINARYNAME}.exe$\" $\"%1$\"" + DeleteRegKey SHCTX "Software\Classes\\{{protocol}}" + ${EndIf} + {{/each}} + + + ; Delete uninstaller + Delete "$INSTDIR\uninstall.exe" + + {{#each resources_ancestors}} + RMDir /REBOOTOK "$INSTDIR\\{{this}}" + {{/each}} + RMDir "$INSTDIR" + + ; Remove shortcuts if not updating + ${If} $UpdateMode <> 1 + !insertmacro DeleteAppUserModelId + + ; Remove start menu shortcut + !insertmacro MUI_STARTMENU_GETFOLDER Application $AppStartMenuFolder + !insertmacro IsShortcutTarget "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + Pop $0 + ${If} $0 = 1 + !insertmacro UnpinShortcut "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" + Delete "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" + RMDir "$SMPROGRAMS\$AppStartMenuFolder" + ${EndIf} + !insertmacro IsShortcutTarget "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + Pop $0 + ${If} $0 = 1 + !insertmacro UnpinShortcut "$SMPROGRAMS\${PRODUCTNAME}.lnk" + Delete "$SMPROGRAMS\${PRODUCTNAME}.lnk" + ${EndIf} + + ; Remove desktop shortcuts + !insertmacro IsShortcutTarget "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + Pop $0 + ${If} $0 = 1 + !insertmacro UnpinShortcut "$DESKTOP\${PRODUCTNAME}.lnk" + Delete "$DESKTOP\${PRODUCTNAME}.lnk" + ${EndIf} + ${EndIf} + + ; Remove registry information for add/remove programs + !if "${INSTALLMODE}" == "both" + DeleteRegKey SHCTX "${UNINSTKEY}" + !else if "${INSTALLMODE}" == "perMachine" + DeleteRegKey HKLM "${UNINSTKEY}" + !else + DeleteRegKey HKCU "${UNINSTKEY}" + !endif + + ; Removes the Autostart entry for ${PRODUCTNAME} from the HKCU Run key if it exists. + ; This ensures the program does not launch automatically after uninstallation if it exists. + ; If it doesn't exist, it does nothing. + ; We do this when not updating (to preserve the registry value on updates) + ${If} $UpdateMode <> 1 + DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "${PRODUCTNAME}" + ${EndIf} + + ; Delete app data if the checkbox is selected + ; and if not updating + ${If} $DeleteAppDataCheckboxState = 1 + ${AndIf} $UpdateMode <> 1 + ; Clear the install location $INSTDIR from registry + DeleteRegKey SHCTX "${MANUPRODUCTKEY}" + DeleteRegKey /ifempty SHCTX "${MANUKEY}" + + ; Clear the install language from registry + DeleteRegValue HKCU "${MANUPRODUCTKEY}" "Installer Language" + DeleteRegKey /ifempty HKCU "${MANUPRODUCTKEY}" + DeleteRegKey /ifempty HKCU "${MANUKEY}" + + SetShellVarContext current + RmDir /r "$APPDATA\${BUNDLEID}" + RmDir /r "$LOCALAPPDATA\${BUNDLEID}" + ${EndIf} + + !ifmacrodef NSIS_HOOK_POSTUNINSTALL + !insertmacro NSIS_HOOK_POSTUNINSTALL + !endif + + ; Auto close if passive mode or updating + ${If} $PassiveMode = 1 + ${OrIf} $UpdateMode = 1 + SetAutoClose true + ${EndIf} +SectionEnd + +Function RestorePreviousInstallLocation + ReadRegStr $4 SHCTX "${MANUPRODUCTKEY}" "" + StrCmp $4 "" +2 0 + StrCpy $INSTDIR $4 +FunctionEnd + +Function Skip + Abort +FunctionEnd + +Function SkipIfPassive + ${IfThen} $PassiveMode = 1 ${|} Abort ${|} +FunctionEnd +Function un.SkipIfPassive + ${IfThen} $PassiveMode = 1 ${|} Abort ${|} +FunctionEnd + +Function CreateOrUpdateStartMenuShortcut + ; We used to use product name as MAINBINARYNAME + ; migrate old shortcuts to target the new MAINBINARYNAME + StrCpy $R0 0 + + !insertmacro IsShortcutTarget "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" "$INSTDIR\$OldMainBinaryName" + Pop $0 + ${If} $0 = 1 + !insertmacro SetShortcutTarget "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + StrCpy $R0 1 + ${EndIf} + + !insertmacro IsShortcutTarget "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\$OldMainBinaryName" + Pop $0 + ${If} $0 = 1 + !insertmacro SetShortcutTarget "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + StrCpy $R0 1 + ${EndIf} + + ${If} $R0 = 1 + Return + ${EndIf} + + ; Skip creating shortcut if in update mode or no shortcut mode + ; but always create if migrating from wix + ${If} $WixMode = 0 + ${If} $UpdateMode = 1 + ${OrIf} $NoShortcutMode = 1 + Return + ${EndIf} + ${EndIf} + + !if "${STARTMENUFOLDER}" != "" + CreateDirectory "$SMPROGRAMS\$AppStartMenuFolder" + CreateShortcut "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + !insertmacro SetLnkAppUserModelId "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" + !else + CreateShortcut "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + !insertmacro SetLnkAppUserModelId "$SMPROGRAMS\${PRODUCTNAME}.lnk" + !endif +FunctionEnd + +Function CreateOrUpdateDesktopShortcut + ; We used to use product name as MAINBINARYNAME + ; migrate old shortcuts to target the new MAINBINARYNAME + !insertmacro IsShortcutTarget "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\$OldMainBinaryName" + Pop $0 + ${If} $0 = 1 + !insertmacro SetShortcutTarget "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + Return + ${EndIf} + + ; Skip creating shortcut if in update mode or no shortcut mode + ; but always create if migrating from wix + ${If} $WixMode = 0 + ${If} $UpdateMode = 1 + ${OrIf} $NoShortcutMode = 1 + Return + ${EndIf} + ${EndIf} + + CreateShortcut "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + !insertmacro SetLnkAppUserModelId "$DESKTOP\${PRODUCTNAME}.lnk" +FunctionEnd diff --git a/apps/desktop/windows/model-retention-policy.nsh b/apps/desktop/windows/model-retention-policy.nsh new file mode 100644 index 0000000..84e5c3e --- /dev/null +++ b/apps/desktop/windows/model-retention-policy.nsh @@ -0,0 +1,39 @@ +!ifndef CAPTIONNEST_MODEL_RETENTION_POLICY +!define CAPTIONNEST_MODEL_RETENTION_POLICY + +; Tauri uses 1 for an older installed version and 2 for the second radio +; button (install in place). This default prevents a vulnerable old +; uninstaller from running during the first upgrade to a fixed version. +!macro CAPTIONNEST_SET_REINSTALL_DEFAULT VersionComparison Selection + ${If} ${Selection} == "" + ${If} ${VersionComparison} = 1 + StrCpy ${Selection} 2 + ${Else} + StrCpy ${Selection} 1 + ${EndIf} + ${EndIf} +!macroend + +; Keep the decision itself independent of UI so the exact policy can be +; exercised by a silent NSIS harness without displaying installer windows. +!macro CAPTIONNEST_SKIP_UNINSTALL_FOR_MODE SilentMode PassiveMode DoneLabel + ${If} ${SilentMode} = 1 + Goto ${DoneLabel} + ${EndIf} + ${If} ${PassiveMode} = 1 + Goto ${DoneLabel} + ${EndIf} +!macroend + +; Unattended installs cannot collect a destructive data-deletion decision. +; They therefore install in place instead of invoking the old uninstaller. +Var CaptionNestSilentMode +!macro CAPTIONNEST_SKIP_UNINSTALL_FOR_UNATTENDED PassiveMode DoneLabel + StrCpy $CaptionNestSilentMode 0 + IfSilent 0 +2 + StrCpy $CaptionNestSilentMode 1 + !insertmacro CAPTIONNEST_SKIP_UNINSTALL_FOR_MODE \ + $CaptionNestSilentMode ${PassiveMode} ${DoneLabel} +!macroend + +!endif diff --git a/docs/release.md b/docs/release.md index 76cd3cd..8e08e95 100644 --- a/docs/release.md +++ b/docs/release.md @@ -190,7 +190,10 @@ npm --prefix apps/web run lint | 模型下载 | 写入应用数据目录,重启仍可检测 | | 任务处理 | 得到唯一双语 SRT | | 退出 | sidecar 进程随主程序退出 | -| 卸载 | 程序文件删除;用户模型/数据的保留策略需在卸载说明中明确 | +| 覆盖升级 | 安装器默认选择“不卸载旧版本”并直接覆盖,保留 `$LOCALAPPDATA\io.github.coconilu.captionnest\models`;这也避免从 `0.2.8` 或更早版本升级时执行旧版的缺陷卸载钩子 | +| 静默/被动/`/UPDATE` | 不显示删除应用数据选择,默认保留模型 | +| 卸载并保留 | 不勾选 Tauri 卸载页的“删除应用数据”,删除程序文件但保留模型 | +| 完整卸载并删除 | 仅在用户明确勾选“删除应用数据”后删除模型及同一应用数据目录;取消卸载不做清理 | ## 签名与自动更新边界 diff --git a/docs/user-guide.md b/docs/user-guide.md index e0609bb..dea4523 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -86,6 +86,20 @@ flowchart LR 下载前应用会检查磁盘空间。下载中断不会覆盖已可用模型;缺失或损坏时可重新下载。模型许可证由模型发布者决定,下载前应查看对应模型卡。 +### Windows 升级与卸载 + +CaptionNest 把 Faster-Whisper 模型保存在当前用户的应用数据目录中,程序升级不需要重新下载模型。 + +| 操作 | 安装/卸载界面选择 | 模型结果 | +|---|---|---| +| 使用新版安装包覆盖升级 | “不卸载旧版本”是默认选项,直接继续安装 | 保留,升级后继续识别为已下载 | +| 静默、被动或更新模式安装 | 无需选择 | 默认保留 | +| 完整卸载但保留模型 | 不勾选“删除应用数据” | 保留,之后重装仍可复用 | +| 完整卸载并删除模型 | 明确勾选“删除应用数据”后确认卸载 | 删除模型和 CaptionNest 应用数据 | +| 取消卸载 | 在卸载确认页选择“取消” | 应用和模型都不变 | + +从 `0.2.8` 或更早版本首次升级到包含本修复的版本时,旧卸载器仍带有错误的无条件模型清理逻辑。新版安装器因此默认选择“不卸载旧版本”直接覆盖;请保留这个默认项,不要主动改选“先卸载再安装”。安装完成后,新卸载器会按上表的“删除应用数据”选择处理模型。 + ## 常见问题 | 现象 | 建议 | diff --git a/tooling/tests/test_desktop_packaging.py b/tooling/tests/test_desktop_packaging.py index 316b0aa..f0c8b31 100644 --- a/tooling/tests/test_desktop_packaging.py +++ b/tooling/tests/test_desktop_packaging.py @@ -1,18 +1,22 @@ from __future__ import annotations import json +import os +import shutil +import subprocess from pathlib import Path +import pytest + PROJECT_ROOT = Path(__file__).resolve().parents[2] -def test_nsis_uninstall_warns_before_removing_app_managed_models() -> None: +def _installer_hooks() -> tuple[str, str]: config = json.loads( (PROJECT_ROOT / "apps" / "desktop" / "tauri.conf.json").read_text( encoding="utf-8" ) ) - identifier = config["identifier"] hooks_path = ( PROJECT_ROOT / "apps" @@ -20,11 +24,279 @@ def test_nsis_uninstall_warns_before_removing_app_managed_models() -> None: / config["bundle"]["windows"]["nsis"]["installerHooks"] ) hooks = hooks_path.read_text(encoding="utf-8") + return config["identifier"], hooks + + +def _installer_template() -> str: + config = json.loads( + (PROJECT_ROOT / "apps" / "desktop" / "tauri.conf.json").read_text( + encoding="utf-8" + ) + ) + template_path = ( + PROJECT_ROOT + / "apps" + / "desktop" + / config["bundle"]["windows"]["nsis"]["template"] + ) + return template_path.read_text(encoding="utf-8") + + +def _makensis_path() -> Path | None: + executable = shutil.which("makensis") or shutil.which("makensis.exe") + if executable: + return Path(executable) + local_app_data = os.environ.get("LOCALAPPDATA") + if local_app_data: + candidate = Path(local_app_data) / "tauri" / "NSIS" / "makensis.exe" + if candidate.is_file(): + return candidate + return None - assert "NSIS_HOOK_PREUNINSTALL" in hooks - assert "MB_OKCANCEL" in hooks - assert "/SD IDOK IDOK continue_uninstall" in hooks - assert "Abort" in hooks + +def _nsis_path(path: Path) -> str: + resolved = str(path.resolve()) + assert '"' not in resolved + assert "$" not in resolved + return resolved + + +def _run_uninstall_policy_harness( + tmp_path: Path, + *, + delete_app_data: bool, + update_mode: bool, +) -> bool: + makensis = _makensis_path() + if os.name != "nt" or makensis is None: + pytest.skip("NSIS lifecycle harness requires Windows and makensis") + + models_dir = tmp_path / "models" + marker = models_dir / "model.marker" + models_dir.mkdir() + marker.write_text("existing-model", encoding="utf-8") + + hooks_path = PROJECT_ROOT / "apps" / "desktop" / "windows" / "installer-hooks.nsh" + harness = tmp_path / "model-retention-harness.nsi" + harness.write_text( + "\n".join( + [ + "Unicode true", + "!include LogicLib.nsh", + "Var DeleteAppDataCheckboxState", + "Var UpdateMode", + f'!define CAPTIONNEST_MODELS_DIR "{_nsis_path(models_dir)}"', + f'!include "{_nsis_path(hooks_path)}"', + 'Name "CaptionNest model retention harness"', + f'OutFile "{_nsis_path(tmp_path / "harness.exe")}"', + "RequestExecutionLevel user", + "SilentInstall silent", + "Section", + f" StrCpy $DeleteAppDataCheckboxState {int(delete_app_data)}", + f" StrCpy $UpdateMode {int(update_mode)}", + " !insertmacro NSIS_HOOK_POSTUNINSTALL", + "SectionEnd", + "", + ] + ), + encoding="utf-8", + ) + compiled = subprocess.run( + [str(makensis), "/V2", str(harness)], + cwd=tmp_path, + capture_output=True, + text=True, + check=False, + timeout=30, + ) + assert compiled.returncode == 0, compiled.stdout + compiled.stderr + executed = subprocess.run( + [str(tmp_path / "harness.exe"), "/S"], + cwd=tmp_path, + capture_output=True, + text=True, + check=False, + timeout=15, + ) + assert executed.returncode == 0, executed.stdout + executed.stderr + return marker.exists() + + +def _run_upgrade_policy_harness( + tmp_path: Path, + *, + silent: bool, + passive: bool, + explicit_uninstall: bool, +) -> bool: + makensis = _makensis_path() + if os.name != "nt" or makensis is None: + pytest.skip("NSIS lifecycle harness requires Windows and makensis") + + models_dir = tmp_path / "models" + marker = models_dir / "model.marker" + models_dir.mkdir() + marker.write_text("existing-model", encoding="utf-8") + + policy_path = ( + PROJECT_ROOT + / "apps" + / "desktop" + / "windows" + / "model-retention-policy.nsh" + ) + harness = tmp_path / "upgrade-policy-harness.nsi" + harness.write_text( + "\n".join( + [ + "Unicode true", + "!include LogicLib.nsh", + f'!include "{_nsis_path(policy_path)}"', + "Var PassiveMode", + "Var SimulatedSilentMode", + "Var ReinstallPageCheck", + 'Name "CaptionNest upgrade policy harness"', + f'OutFile "{_nsis_path(tmp_path / "upgrade-harness.exe")}"', + "RequestExecutionLevel user", + "SilentInstall silent", + "Section", + f" StrCpy $PassiveMode {int(passive)}", + f" StrCpy $SimulatedSilentMode {int(silent)}", + " StrCpy $R0 1", + ' StrCpy $ReinstallPageCheck ""', + " !insertmacro CAPTIONNEST_SET_REINSTALL_DEFAULT $R0 $ReinstallPageCheck", + *( + [" StrCpy $ReinstallPageCheck 1"] + if explicit_uninstall + else [] + ), + ( + " !insertmacro CAPTIONNEST_SKIP_UNINSTALL_FOR_MODE " + "$SimulatedSilentMode $PassiveMode upgrade_done" + ), + " ${If} $ReinstallPageCheck = 1", + f' RMDir /r "{_nsis_path(models_dir)}"', + " ${EndIf}", + " upgrade_done:", + "SectionEnd", + "", + ] + ), + encoding="utf-8", + ) + compiled = subprocess.run( + [str(makensis), "/V2", str(harness)], + cwd=tmp_path, + capture_output=True, + text=True, + check=False, + timeout=30, + ) + assert compiled.returncode == 0, compiled.stdout + compiled.stderr + executed = subprocess.run( + [str(tmp_path / "upgrade-harness.exe"), "/S"], + cwd=tmp_path, + capture_output=True, + text=True, + check=False, + timeout=15, + ) + assert executed.returncode == 0, executed.stdout + executed.stderr + return marker.exists() + + +def test_nsis_uninstall_reuses_tauri_delete_app_data_decision() -> None: + identifier, hooks = _installer_hooks() + + assert "NSIS_HOOK_PREUNINSTALL" not in hooks + assert "MessageBox" not in hooks assert "NSIS_HOOK_POSTUNINSTALL" in hooks + assert "$DeleteAppDataCheckboxState = 1" in hooks + assert "$UpdateMode <> 1" in hooks assert f"$LOCALAPPDATA\\{identifier}\\models" in hooks assert f"$LOCALAPPDATA\\{identifier}\"" not in hooks + + +def test_nsis_upgrade_defaults_preserve_models_before_old_uninstaller_runs() -> None: + template = _installer_template() + + upgrade_choice = template.split( + "; An in-place install is the safe default for upgrades.", 1 + )[1].split("nsDialogs::Show", 1)[0] + assert ( + "!insertmacro CAPTIONNEST_SET_REINSTALL_DEFAULT $R0 $ReinstallPageCheck" + in upgrade_choice + ) + assert "${NSD_SetFocus} $R3" in upgrade_choice + + leave_reinstall = template.split("Function PageLeaveReinstall", 1)[1].split( + "FunctionEnd", 1 + )[0] + assert leave_reinstall.index("$UpdateMode = 1") < leave_reinstall.index( + "CAPTIONNEST_SKIP_UNINSTALL_FOR_UNATTENDED" + ) < leave_reinstall.index("${NSD_GetState}") + assert "StrCpy $R1 \"$R1 /UPDATE\"" in template + + policy = ( + PROJECT_ROOT + / "apps" + / "desktop" + / "windows" + / "model-retention-policy.nsh" + ).read_text(encoding="utf-8") + assert "${If} ${VersionComparison} = 1" in policy + assert "StrCpy ${Selection} 2" in policy + assert "IfSilent 0 +2" in policy + assert "CAPTIONNEST_SKIP_UNINSTALL_FOR_MODE" in policy + assert "${If} ${PassiveMode} = 1" in policy + + +@pytest.mark.parametrize( + ("silent", "passive", "explicit_uninstall", "marker_survives"), + [ + pytest.param(False, False, False, True, id="normal-upgrade-default"), + pytest.param(True, False, False, True, id="silent-upgrade"), + pytest.param(False, True, False, True, id="passive-upgrade"), + pytest.param(False, False, True, False, id="explicit-old-uninstall"), + ], +) +def test_nsis_upgrade_control_flow_protects_marker_from_old_uninstaller( + tmp_path: Path, + silent: bool, + passive: bool, + explicit_uninstall: bool, + marker_survives: bool, +) -> None: + assert ( + _run_upgrade_policy_harness( + tmp_path, + silent=silent, + passive=passive, + explicit_uninstall=explicit_uninstall, + ) + is marker_survives + ) + + +@pytest.mark.parametrize( + ("delete_app_data", "update_mode", "marker_survives"), + [ + pytest.param(True, True, True, id="update-mode-forces-keep"), + pytest.param(False, False, True, id="explicit-keep"), + pytest.param(True, False, False, id="explicit-delete"), + ], +) +def test_nsis_model_marker_follows_uninstall_decision( + tmp_path: Path, + delete_app_data: bool, + update_mode: bool, + marker_survives: bool, +) -> None: + assert ( + _run_uninstall_policy_harness( + tmp_path, + delete_app_data=delete_app_data, + update_mode=update_mode, + ) + is marker_survives + ) From 6eb2fbc2b892d4d4d41a835b17b195b88828267e Mon Sep 17 00:00:00 2001 From: BayesWang <827130441@qq.com> Date: Tue, 21 Jul 2026 20:28:45 +0800 Subject: [PATCH 02/18] test: exercise installer model retention on Windows --- .github/workflows/ci.yml | 168 ++++++++ scripts/test-installer-model-retention.ps1 | 454 +++++++++++++++++++++ tooling/tests/test_desktop_packaging.py | 117 ++++++ tooling/tests/test_release_workflow.py | 60 ++- 4 files changed, 797 insertions(+), 2 deletions(-) create mode 100644 scripts/test-installer-model-retention.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9372a75..f296e62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,11 +51,16 @@ jobs: node-version: '24' cache: npm cache-dependency-path: apps/web/package-lock.json + - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + with: + python-version: '3.12' + enable-cache: true - uses: dtolnay/rust-toolchain@stable with: targets: x86_64-pc-windows-msvc - run: npm ci working-directory: apps/web + - run: uv sync --project apps/sidecar --extra dev --locked - name: Parse PowerShell build scripts shell: pwsh run: | @@ -86,5 +91,168 @@ jobs: The Windows Release workflow replaces this file with evidence generated from the exact PyAV/FFmpeg wheel bundled into the installer. '@ | Set-Content -Encoding utf8 tooling\packaging\dist\FFMPEG_BUILD_INFO.txt + - name: Build NSIS packaging test fixture + shell: pwsh + run: | + $Tauri = (Resolve-Path 'apps\web\node_modules\.bin\tauri.cmd').Path + $Arguments = @( + 'build', + '--config', 'apps\desktop\tauri.conf.json', + '--target', 'x86_64-pc-windows-msvc', + '--bundles', 'nsis', + '--ci', + '--no-sign' + ) + & $Tauri @Arguments + if ($LASTEXITCODE -ne 0) { throw 'Unable to build the NSIS packaging test fixture.' } + - name: Test Windows NSIS packaging policies + shell: pwsh + env: + CAPTIONNEST_REQUIRE_NSIS_TESTS: '1' + run: uv run --project apps/sidecar --extra dev pytest tooling/tests/test_desktop_packaging.py - run: cargo fmt --manifest-path apps/desktop/Cargo.toml --check - run: cargo check --manifest-path apps/desktop/Cargo.toml --target x86_64-pc-windows-msvc --locked + + installer-lifecycle: + runs-on: windows-latest + timeout-minutes: 180 + env: + RELEASE_PYTHON_VERSION: '3.12' + VCPKG_DEFAULT_BINARY_CACHE: ${{ github.workspace }}\tooling\packaging\build\vcpkg-binary-cache + steps: + - uses: actions/checkout@v7 + + - name: Restore LGPL media wheel cache + id: restore_media_wheel_cache + uses: actions/cache/restore@v6 + with: + path: tooling/packaging/dist/media-wheel + key: >- + media-wheel-${{ runner.os }}-${{ runner.arch }}-python-${{ env.RELEASE_PYTHON_VERSION }}-${{ hashFiles('scripts/build-media-wheel.ps1', 'tooling/packaging/media-runtime/vcpkg.json') }} + + - name: Check out pinned vcpkg baseline + if: steps.restore_media_wheel_cache.outputs.cache-hit != 'true' + uses: actions/checkout@v7 + with: + repository: microsoft/vcpkg + ref: db4723bd0a99eab031f1a3dee4336dca43049c87 + path: tooling/packaging/build/vcpkg + + - uses: actions/setup-node@v7 + with: + node-version: '24' + cache: npm + cache-dependency-path: apps/web/package-lock.json + + - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + with: + python-version: ${{ env.RELEASE_PYTHON_VERSION }} + enable-cache: true + + - uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-pc-windows-msvc + + - name: Restore vcpkg binary cache + if: steps.restore_media_wheel_cache.outputs.cache-hit != 'true' + id: restore_vcpkg_cache + uses: actions/cache/restore@v6 + with: + path: tooling/packaging/build/vcpkg-binary-cache + key: vcpkg-windows-x64-${{ hashFiles('tooling/packaging/media-runtime/vcpkg.json') }} + + - name: Bootstrap pinned vcpkg + if: steps.restore_media_wheel_cache.outputs.cache-hit != 'true' + shell: pwsh + run: | + New-Item -ItemType Directory -Force $env:VCPKG_DEFAULT_BINARY_CACHE | Out-Null + & .\tooling\packaging\build\vcpkg\bootstrap-vcpkg.bat '-disableMetrics' + if ($LASTEXITCODE -ne 0) { throw 'Unable to bootstrap pinned vcpkg.' } + + - run: npm ci + working-directory: apps/web + + - run: uv sync --project apps/sidecar --extra asr --extra desktop --extra dev --locked + + - name: Select lifecycle Python runtime + shell: pwsh + run: | + $Python = (Resolve-Path 'apps\sidecar\.venv\Scripts\python.exe').Path + "CAPTIONNEST_BUILD_PYTHON=$Python" | Add-Content -LiteralPath $env:GITHUB_ENV + + - name: Build LGPL media wheel + if: steps.restore_media_wheel_cache.outputs.cache-hit != 'true' + shell: pwsh + run: | + .\scripts\build-media-wheel.ps1 ` + -VcpkgRoot 'tooling\packaging\build\vcpkg' ` + -PythonExecutable $env:CAPTIONNEST_BUILD_PYTHON + + - name: Install and verify LGPL media wheel + shell: pwsh + run: | + .\scripts\install-media-wheel.ps1 ` + -PythonExecutable $env:CAPTIONNEST_BUILD_PYTHON + + - name: Build exact-HEAD release-chain NSIS installer + shell: pwsh + run: | + .\scripts\build-desktop.ps1 -PythonExecutable $env:CAPTIONNEST_BUILD_PYTHON + $Exact = @( + Get-ChildItem ` + -LiteralPath 'apps\desktop\target\x86_64-pc-windows-msvc\release\bundle\nsis' ` + -Filter 'CaptionNest_0.2.8_x64-setup.exe' ` + -File + ) + if ($Exact.Count -ne 1) { throw "Expected one exact-HEAD installer; found $($Exact.Count)." } + $ExactCopy = Join-Path $env:RUNNER_TEMP 'CaptionNest_0.2.8_exact_HEAD_x64-setup.exe' + Copy-Item -LiteralPath $Exact[0].FullName -Destination $ExactCopy + "CAPTIONNEST_CURRENT_INSTALLER=$ExactCopy" | Add-Content -LiteralPath $env:GITHUB_ENV + + - name: Build higher-version upgrade fixture from exact HEAD + shell: pwsh + run: | + $Tauri = (Resolve-Path 'apps\web\node_modules\.bin\tauri.cmd').Path + Push-Location 'apps\desktop' + try { + $Arguments = @( + 'build', + '--config', '{"version":"0.2.9"}', + '--target', 'x86_64-pc-windows-msvc', + '--bundles', 'nsis', + '--ci', + '--no-sign', + '--ignore-version-mismatches' + ) + & $Tauri @Arguments + if ($LASTEXITCODE -ne 0) { throw 'Unable to build the upgrade fixture.' } + } finally { + Pop-Location + } + $Upgrade = @( + Get-ChildItem ` + -LiteralPath 'apps\desktop\target\x86_64-pc-windows-msvc\release\bundle\nsis' ` + -Filter 'CaptionNest_0.2.9_x64-setup.exe' ` + -File + ) + if ($Upgrade.Count -ne 1) { throw "Expected one upgrade installer; found $($Upgrade.Count)." } + "CAPTIONNEST_UPGRADE_INSTALLER=$($Upgrade[0].FullName)" | Add-Content -LiteralPath $env:GITHUB_ENV + + - name: Download and verify affected installer + shell: pwsh + run: | + $OldInstaller = Join-Path $env:RUNNER_TEMP 'CaptionNest_0.2.8_affected_x64-setup.exe' + Invoke-WebRequest ` + -Uri 'https://github.com/coconilu/captionnest/releases/download/v0.2.8/CaptionNest_0.2.8_x64-setup.exe' ` + -OutFile $OldInstaller + "CAPTIONNEST_OLD_INSTALLER=$OldInstaller" | Add-Content -LiteralPath $env:GITHUB_ENV + + - name: Exercise isolated installer and model lifecycle + shell: pwsh + run: | + .\scripts\test-installer-model-retention.ps1 ` + -OldInstallerPath $env:CAPTIONNEST_OLD_INSTALLER ` + -CurrentInstallerPath $env:CAPTIONNEST_CURRENT_INSTALLER ` + -UpgradeInstallerPath $env:CAPTIONNEST_UPGRADE_INSTALLER ` + -ExpectedVersion '0.2.8' ` + -UpgradeExpectedVersion '0.2.9' diff --git a/scripts/test-installer-model-retention.ps1 b/scripts/test-installer-model-retention.ps1 new file mode 100644 index 0000000..6ed6233 --- /dev/null +++ b/scripts/test-installer-model-retention.ps1 @@ -0,0 +1,454 @@ +param( + [Parameter(Mandatory = $true)] + [string]$OldInstallerPath, + [Parameter(Mandatory = $true)] + [string]$CurrentInstallerPath, + [Parameter(Mandatory = $true)] + [string]$UpgradeInstallerPath, + [string]$ExpectedVersion = '0.2.8', + [string]$UpgradeExpectedVersion = '0.2.9', + [string]$OldInstallerSha256 = '8c8a48778c420a99a342e79974d3edb6e315858dd13dbcfe7546ceb0bcc176d6' +) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +if ($env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted') { + throw 'This destructive installer lifecycle test is restricted to a disposable GitHub-hosted runner.' +} +if (-not $env:RUNNER_TEMP -or -not $env:LOCALAPPDATA) { + throw 'RUNNER_TEMP and LOCALAPPDATA must be available.' +} + +$OldInstaller = (Resolve-Path -LiteralPath $OldInstallerPath).Path +$CurrentInstaller = (Resolve-Path -LiteralPath $CurrentInstallerPath).Path +$UpgradeInstaller = (Resolve-Path -LiteralPath $UpgradeInstallerPath).Path +$RunnerTemp = [IO.Path]::GetFullPath($env:RUNNER_TEMP).TrimEnd('\') +$EvidenceRoot = Join-Path $RunnerTemp 'captionnest-installer-lifecycle' +$InstallRoot = Join-Path $env:LOCALAPPDATA 'CaptionNest' +$AppDataRoot = Join-Path $env:LOCALAPPDATA 'io.github.coconilu.captionnest' +$ModelsRoot = Join-Path $AppDataRoot 'models' +$MarkerPath = Join-Path $ModelsRoot 'small\model.bin' +$UninstallKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\CaptionNest' +$ManufacturerKey = 'HKCU:\Software\CaptionNest contributors\CaptionNest' +$script:OwnedProcesses = @() + +function Assert-DisposableRunnerState { + $ResolvedEvidence = [IO.Path]::GetFullPath($EvidenceRoot) + if (-not $ResolvedEvidence.StartsWith("$RunnerTemp\", [StringComparison]::OrdinalIgnoreCase)) { + throw "Evidence path escaped RUNNER_TEMP: $ResolvedEvidence" + } + foreach ($Path in @($InstallRoot, $AppDataRoot, $UninstallKey, $ManufacturerKey)) { + if (Test-Path -LiteralPath $Path) { + throw "Refusing to run because CaptionNest state already exists: $Path" + } + } + $Existing = @(Get-Process -Name 'captionnest', 'captionnest-sidecar' -ErrorAction SilentlyContinue) + if ($Existing.Count -gt 0) { + throw 'Refusing to run while CaptionNest processes already exist.' + } +} + +function Assert-OldInstallerIdentity { + $Actual = (Get-FileHash -LiteralPath $OldInstaller -Algorithm SHA256).Hash.ToLowerInvariant() + if ($Actual -ne $OldInstallerSha256.ToLowerInvariant()) { + throw "Affected installer SHA-256 mismatch. Expected $OldInstallerSha256, got $Actual." + } +} + +function Wait-ProcessExit { + param( + [Parameter(Mandatory = $true)]$Process, + [int]$TimeoutSeconds = 180, + [int[]]$AllowedExitCodes = @(0) + ) + if (-not $Process.WaitForExit($TimeoutSeconds * 1000)) { + & taskkill.exe @('/PID', $Process.Id.ToString(), '/T', '/F') | Out-Null + throw "Process $($Process.Id) timed out after $TimeoutSeconds seconds." + } + if ($Process.ExitCode -notin $AllowedExitCodes) { + throw "Process $($Process.Id) exited with $($Process.ExitCode)." + } +} + +function Start-OwnedProcess { + param( + [Parameter(Mandatory = $true)][string]$FilePath, + [string[]]$ArgumentList = @() + ) + $Process = Start-Process -FilePath $FilePath -ArgumentList $ArgumentList -PassThru + $script:OwnedProcesses += $Process + return $Process +} + +function Invoke-Installer { + param( + [Parameter(Mandatory = $true)][string]$Path, + [string[]]$Arguments = @() + ) + $Process = Start-OwnedProcess -FilePath $Path -ArgumentList $Arguments + Wait-ProcessExit -Process $Process +} + +function Get-InstalledVersion { + if (-not (Test-Path -LiteralPath $UninstallKey)) { + return $null + } + return (Get-ItemProperty -LiteralPath $UninstallKey).DisplayVersion +} + +function Assert-InstalledVersion { + param([string]$Version = $ExpectedVersion) + $Actual = Get-InstalledVersion + if ($Actual -ne $Version) { + throw "Expected installed version $Version, got '$Actual'." + } +} + +function Write-ModelFixture { + $ModelRoot = Join-Path $ModelsRoot 'small' + New-Item -ItemType Directory -Path $ModelRoot -Force | Out-Null + $Payload = [Text.Encoding]::UTF8.GetBytes('test') + foreach ($Name in @('config.json', 'model.bin', 'tokenizer.json')) { + [IO.File]::WriteAllBytes((Join-Path $ModelRoot $Name), $Payload) + } + $Hash = (Get-FileHash -LiteralPath (Join-Path $ModelRoot 'model.bin') -Algorithm SHA256).Hash.ToLowerInvariant() + $Manifest = [ordered]@{ + manifest_version = 1 + repo_id = 'Systran/faster-whisper-small' + revision = '536b0662742c02347bc0e980a01041f333bce120' + files = [ordered]@{ + 'config.json' = [ordered]@{ size = 4 } + 'model.bin' = [ordered]@{ size = 4; sha256 = $Hash } + 'tokenizer.json' = [ordered]@{ size = 4 } + } + } + $Manifest | ConvertTo-Json -Depth 6 | Set-Content ` + -LiteralPath (Join-Path $ModelRoot '.captionnest-model-manifest.json') ` + -Encoding utf8NoBOM +} + +function Assert-ModelPresent { + if (-not (Test-Path -LiteralPath $MarkerPath -PathType Leaf)) { + throw "Recognition model marker was deleted: $MarkerPath" + } +} + +function Assert-ModelAbsent { + if (Test-Path -LiteralPath $MarkerPath) { + throw "Recognition model marker was unexpectedly retained: $MarkerPath" + } +} + +function Get-UiElement { + param( + [Parameter(Mandatory = $true)]$Root, + [Parameter(Mandatory = $true)]$Condition, + [int]$TimeoutSeconds = 30 + ) + $Deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) + do { + $Element = $Root.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, + $Condition + ) + if ($null -ne $Element) { return $Element } + Start-Sleep -Milliseconds 200 + } while ([DateTime]::UtcNow -lt $Deadline) + throw 'Timed out waiting for an installer UI element.' +} + +function Get-ProcessWindow { + param([Parameter(Mandatory = $true)]$Process) + $Deadline = [DateTime]::UtcNow.AddSeconds(30) + do { + $Process.Refresh() + if ($Process.MainWindowHandle -ne 0) { + return [System.Windows.Automation.AutomationElement]::FromHandle( + $Process.MainWindowHandle + ) + } + Start-Sleep -Milliseconds 200 + } while ([DateTime]::UtcNow -lt $Deadline) + throw "Process $($Process.Id) did not expose an interactive window." +} + +function Invoke-UiElement { + param([Parameter(Mandatory = $true)]$Element) + $Pattern = $Element.GetCurrentPattern( + [System.Windows.Automation.InvokePattern]::Pattern + ) + $Pattern.Invoke() +} + +function Set-UiCheckbox { + param( + [Parameter(Mandatory = $true)]$Checkbox, + [Parameter(Mandatory = $true)][bool]$Checked + ) + $Pattern = $Checkbox.GetCurrentPattern( + [System.Windows.Automation.TogglePattern]::Pattern + ) + $IsChecked = $Pattern.Current.ToggleState -eq ` + [System.Windows.Automation.ToggleState]::On + if ($IsChecked -ne $Checked) { $Pattern.Toggle() } +} + +function Get-ButtonCondition { + param([string]$AutomationId) + $TypeCondition = [System.Windows.Automation.PropertyCondition]::new( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::Button + ) + $IdCondition = [System.Windows.Automation.PropertyCondition]::new( + [System.Windows.Automation.AutomationElement]::AutomationIdProperty, + $AutomationId + ) + return [System.Windows.Automation.AndCondition]::new($TypeCondition, $IdCondition) +} + +function Complete-GuiUpgradeWithDefault { + $Process = Start-OwnedProcess -FilePath $UpgradeInstaller + $Window = Get-ProcessWindow -Process $Process + $NextCondition = Get-ButtonCondition -AutomationId '1' + Invoke-UiElement (Get-UiElement -Root $Window -Condition $NextCondition) + + $RadioCondition = [System.Windows.Automation.PropertyCondition]::new( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::RadioButton + ) + $Deadline = [DateTime]::UtcNow.AddSeconds(30) + do { + $Radios = @($Window.FindAll( + [System.Windows.Automation.TreeScope]::Descendants, + $RadioCondition + )) + if ($Radios.Count -eq 2) { break } + Start-Sleep -Milliseconds 200 + } while ([DateTime]::UtcNow -lt $Deadline) + if ($Radios.Count -ne 2) { throw 'The reinstall page did not expose two choices.' } + $Selections = @($Radios | ForEach-Object { + $_.GetCurrentPattern( + [System.Windows.Automation.SelectionItemPattern]::Pattern + ).Current.IsSelected + }) + if ($Selections[0] -or -not $Selections[1]) { + throw "GUI upgrade default was not in-place: $($Selections -join ',')." + } + Invoke-UiElement (Get-UiElement -Root $Window -Condition $NextCondition) + + $Deadline = [DateTime]::UtcNow.AddSeconds(180) + while (-not $Process.HasExited -and [DateTime]::UtcNow -lt $Deadline) { + $Window = Get-ProcessWindow -Process $Process + $CheckboxCondition = [System.Windows.Automation.PropertyCondition]::new( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::CheckBox + ) + foreach ($Checkbox in @($Window.FindAll( + [System.Windows.Automation.TreeScope]::Descendants, + $CheckboxCondition + ))) { + Set-UiCheckbox -Checkbox $Checkbox -Checked $false + } + $Next = $Window.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, + $NextCondition + ) + if ($null -ne $Next -and $Next.Current.IsEnabled) { + Invoke-UiElement $Next + } + Start-Sleep -Milliseconds 500 + $Process.Refresh() + } + Wait-ProcessExit -Process $Process -TimeoutSeconds 5 +} + +function Invoke-CurrentUninstallerGui { + param( + [ValidateSet('cancel', 'keep', 'delete')][string]$Decision + ) + $Uninstaller = Join-Path $InstallRoot 'uninstall.exe' + $Process = Start-OwnedProcess -FilePath $Uninstaller + $Window = Get-ProcessWindow -Process $Process + $CheckboxCondition = [System.Windows.Automation.PropertyCondition]::new( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::CheckBox + ) + $Checkbox = Get-UiElement -Root $Window -Condition $CheckboxCondition + if ($Decision -eq 'cancel') { + Invoke-UiElement (Get-UiElement -Root $Window -Condition (Get-ButtonCondition '2')) + Wait-ProcessExit -Process $Process -AllowedExitCodes @(0, 1) + return + } + Set-UiCheckbox -Checkbox $Checkbox -Checked ($Decision -eq 'delete') + Invoke-UiElement (Get-UiElement -Root $Window -Condition (Get-ButtonCondition '1')) + $Deadline = [DateTime]::UtcNow.AddSeconds(180) + while (-not $Process.HasExited -and [DateTime]::UtcNow -lt $Deadline) { + $Process.Refresh() + if ($Process.MainWindowHandle -ne 0) { + $Window = [System.Windows.Automation.AutomationElement]::FromHandle( + $Process.MainWindowHandle + ) + $Next = $Window.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, + (Get-ButtonCondition '1') + ) + if ($null -ne $Next -and $Next.Current.IsEnabled) { + Invoke-UiElement $Next + } + } + Start-Sleep -Milliseconds 500 + } + Wait-ProcessExit -Process $Process -TimeoutSeconds 5 +} + +function Assert-InstalledAppAndModelReady { + $App = Join-Path $InstallRoot 'captionnest.exe' + $Sidecar = Join-Path $InstallRoot 'captionnest-sidecar.exe' + foreach ($Path in @($App, $Sidecar)) { + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "Installed product file is missing: $Path" + } + } + + $AppProcess = Start-OwnedProcess -FilePath $App + [void](Get-ProcessWindow -Process $AppProcess) + $Deadline = [DateTime]::UtcNow.AddSeconds(30) + do { + $SidecarProcess = @(Get-Process -Name 'captionnest-sidecar' -ErrorAction SilentlyContinue) + if ($SidecarProcess.Count -gt 0) { break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $Deadline) + if ($SidecarProcess.Count -eq 0) { throw 'Installed desktop app did not start its sidecar.' } + $AppProcess.CloseMainWindow() | Out-Null + if (-not $AppProcess.WaitForExit(10000)) { $AppProcess.Kill() } + $Deadline = [DateTime]::UtcNow.AddSeconds(10) + do { + $DesktopSidecars = @( + Get-Process -Name 'captionnest-sidecar' -ErrorAction SilentlyContinue + ) + if ($DesktopSidecars.Count -eq 0) { break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $Deadline) + foreach ($DesktopSidecar in $DesktopSidecars) { + & taskkill.exe @('/PID', $DesktopSidecar.Id.ToString(), '/T', '/F') | Out-Null + } + + $PortListener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0) + $PortListener.Start() + $Port = ([Net.IPEndPoint]$PortListener.LocalEndpoint).Port + $PortListener.Stop() + $Token = [Guid]::NewGuid().ToString('N') + [Guid]::NewGuid().ToString('N') + $PreviousToken = $env:CAPTIONNEST_SESSION_TOKEN + try { + $env:CAPTIONNEST_SESSION_TOKEN = $Token + $ApiProcess = Start-OwnedProcess -FilePath $Sidecar -ArgumentList @( + '--host', '127.0.0.1', '--port', $Port.ToString(), '--data-dir', $AppDataRoot + ) + $Headers = @{ 'X-CaptionNest-Session' = $Token } + $Deadline = [DateTime]::UtcNow.AddSeconds(30) + do { + try { + $Response = Invoke-RestMethod ` + -Uri "http://127.0.0.1:$Port/api/models" ` + -Headers $Headers ` + -TimeoutSec 2 + break + } catch { + Start-Sleep -Milliseconds 250 + } + } while ([DateTime]::UtcNow -lt $Deadline) + $Small = @($Response.items | Where-Object { $_.id -eq 'small' }) + if ($Small.Count -ne 1 -or $Small[0].status -ne 'ready') { + throw 'Installed sidecar did not report the retained small model as ready.' + } + & taskkill.exe @('/PID', $ApiProcess.Id.ToString(), '/T', '/F') | Out-Null + } finally { + $env:CAPTIONNEST_SESSION_TOKEN = $PreviousToken + } +} + +function Remove-OwnedCaptionNestState { + foreach ($ProcessName in @('captionnest', 'captionnest-sidecar')) { + Get-Process -Name $ProcessName -ErrorAction SilentlyContinue | ForEach-Object { + & taskkill.exe @('/PID', $_.Id.ToString(), '/T', '/F') | Out-Null + } + } + foreach ($Path in @($InstallRoot, $AppDataRoot)) { + if (Test-Path -LiteralPath $Path) { + Remove-Item -LiteralPath $Path -Recurse -Force + } + } + Remove-Item -LiteralPath $UninstallKey, $ManufacturerKey -Recurse -Force -ErrorAction SilentlyContinue +} + +function Install-AffectedVersionWithModel { + Invoke-Installer -Path $OldInstaller -Arguments @('/S') + Write-ModelFixture + Assert-ModelPresent +} + +function Test-UpgradeMode { + param( + [Parameter(Mandatory = $true)][string]$Name, + [string[]]$Arguments = @(), + [switch]$Gui + ) + Write-Host "LIFECYCLE: upgrade-$Name" + Install-AffectedVersionWithModel + if ($Gui) { + Complete-GuiUpgradeWithDefault + } else { + Invoke-Installer -Path $UpgradeInstaller -Arguments $Arguments + } + Assert-InstalledVersion -Version $UpgradeExpectedVersion + Assert-ModelPresent + Assert-InstalledAppAndModelReady + Invoke-Installer -Path (Join-Path $InstallRoot 'uninstall.exe') -Arguments @('/S') + Assert-ModelPresent + Remove-OwnedCaptionNestState +} + +Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes +Assert-DisposableRunnerState +Assert-OldInstallerIdentity +New-Item -ItemType Directory -Path $EvidenceRoot -Force | Out-Null + +try { + Write-Host 'LIFECYCLE: affected-explicit-uninstall' + Install-AffectedVersionWithModel + Invoke-Installer -Path (Join-Path $InstallRoot 'uninstall.exe') -Arguments @('/S') + Assert-ModelAbsent + Remove-OwnedCaptionNestState + + Test-UpgradeMode -Name 'gui-default' -Gui + Test-UpgradeMode -Name 'silent' -Arguments @('/S') + Test-UpgradeMode -Name 'passive' -Arguments @('/P') + Test-UpgradeMode -Name 'update' -Arguments @('/UPDATE', '/P') + + Write-Host 'LIFECYCLE: current-uninstall-cancel-keep-delete' + Invoke-Installer -Path $CurrentInstaller -Arguments @('/S') + Write-ModelFixture + Invoke-CurrentUninstallerGui -Decision cancel + Assert-InstalledVersion + Assert-ModelPresent + Invoke-CurrentUninstallerGui -Decision keep + Assert-ModelPresent + Remove-OwnedCaptionNestState + + Invoke-Installer -Path $CurrentInstaller -Arguments @('/S') + Write-ModelFixture + Invoke-CurrentUninstallerGui -Decision delete + Assert-ModelAbsent + Write-Host 'All isolated CaptionNest installer lifecycle scenarios passed.' -ForegroundColor Green +} finally { + foreach ($Process in @($script:OwnedProcesses)) { + if ($null -ne $Process -and -not $Process.HasExited) { + & taskkill.exe @('/PID', $Process.Id.ToString(), '/T', '/F') | Out-Null + } + } + Remove-OwnedCaptionNestState + if (Test-Path -LiteralPath $EvidenceRoot) { + Remove-Item -LiteralPath $EvidenceRoot -Recurse -Force + } +} diff --git a/tooling/tests/test_desktop_packaging.py b/tooling/tests/test_desktop_packaging.py index f0c8b31..53227e5 100644 --- a/tooling/tests/test_desktop_packaging.py +++ b/tooling/tests/test_desktop_packaging.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import json import os import shutil @@ -9,6 +10,81 @@ import pytest PROJECT_ROOT = Path(__file__).resolve().parents[2] +TAURI_CLI_VERSION = "2.11.4" +TAURI_INSTALLER_BASELINE_SHA256 = ( + "20f4ecc730defb71f1342eaeaec4021df13be3d843abba0effe88ea5835fa079" +) + +CAPTIONNEST_TEMPLATE_HEADER = """; Based on Tauri CLI 2.11.4's official installer template: +; https://github.com/tauri-apps/tauri/blob/tauri-cli-v2.11.4/crates/tauri-bundler/src/bundle/windows/nsis/installer.nsi +; CaptionNest customization: upgrades default to an in-place install so a +; vulnerable older uninstaller cannot delete app-managed recognition models. + +""" + +CAPTIONNEST_UPGRADE_DEFAULT = ( + " ; An in-place install is the safe default for upgrades. In particular, " + "the\n" + " ; first fixed installer must not execute an older uninstaller whose " + "custom\n" + " ; hook deleted recognition models unconditionally.\n" + " !insertmacro CAPTIONNEST_SET_REINSTALL_DEFAULT $R0 " + "$ReinstallPageCheck\n" + " ${If} $ReinstallPageCheck = 2\n" + " SendMessage $R3 ${BM_SETCHECK} ${BST_CHECKED} 0\n" + " ${NSD_SetFocus} $R3\n" + " ${Else}\n" + " SendMessage $R2 ${BM_SETCHECK} ${BST_CHECKED} 0\n" + " ${NSD_SetFocus} $R2\n" + " ${EndIf}\n\n" +) + +TAURI_UPGRADE_DEFAULT = """ ; Check the first radio button if this the first time + ; we enter this page or if the second button wasn't + ; selected the last time we were on this page + ${If} $ReinstallPageCheck <> 2 + SendMessage $R2 ${BM_SETCHECK} ${BST_CHECKED} 0 + ${Else} + SendMessage $R3 ${BM_SETCHECK} ${BST_CHECKED} 0 + ${EndIf} + + ${NSD_SetFocus} $R2 +""" + +CAPTIONNEST_UNATTENDED_UPGRADE = ( + " ; Silent and passive installs cannot collect an explicit data-deletion\n" + " ; decision, so they always install in place and preserve existing models.\n" + " !insertmacro CAPTIONNEST_SKIP_UNINSTALL_FOR_UNATTENDED $PassiveMode " + "reinst_done\n\n" +) + +CAPTIONNEST_REINSTALL_STATE_ORDER = """Function PageLeaveReinstall + ; If migrating from Wix, always uninstall + ${If} $WixMode = 1 + Goto reinst_uninstall + ${EndIf} + + ; In update mode, always proceeds without uninstalling + ${If} $UpdateMode = 1 + Goto reinst_done + ${EndIf} + + ${NSD_GetState} $R2 $R1 +""" + +TAURI_REINSTALL_STATE_ORDER = """Function PageLeaveReinstall + ${NSD_GetState} $R2 $R1 + + ; If migrating from Wix, always uninstall + ${If} $WixMode = 1 + Goto reinst_uninstall + ${EndIf} + + ; In update mode, always proceeds without uninstalling + ${If} $UpdateMode = 1 + Goto reinst_done + ${EndIf} +""" def _installer_hooks() -> tuple[str, str]: @@ -69,6 +145,8 @@ def _run_uninstall_policy_harness( ) -> bool: makensis = _makensis_path() if os.name != "nt" or makensis is None: + if os.environ.get("CAPTIONNEST_REQUIRE_NSIS_TESTS") == "1": + pytest.fail("required Windows NSIS lifecycle harness is unavailable") pytest.skip("NSIS lifecycle harness requires Windows and makensis") models_dir = tmp_path / "models" @@ -131,6 +209,8 @@ def _run_upgrade_policy_harness( ) -> bool: makensis = _makensis_path() if os.name != "nt" or makensis is None: + if os.environ.get("CAPTIONNEST_REQUIRE_NSIS_TESTS") == "1": + pytest.fail("required Windows NSIS lifecycle harness is unavailable") pytest.skip("NSIS lifecycle harness requires Windows and makensis") models_dir = tmp_path / "models" @@ -251,6 +331,43 @@ def test_nsis_upgrade_defaults_preserve_models_before_old_uninstaller_runs() -> assert "${If} ${PassiveMode} = 1" in policy +def test_custom_nsis_template_matches_offline_tauri_baseline() -> None: + template = _installer_template() + assert template.count(CAPTIONNEST_TEMPLATE_HEADER) == 1 + assert template.count(CAPTIONNEST_UPGRADE_DEFAULT) == 1 + assert template.count(CAPTIONNEST_UNATTENDED_UPGRADE) == 1 + + normalized = template.replace(CAPTIONNEST_TEMPLATE_HEADER, "", 1) + normalized = normalized.replace( + CAPTIONNEST_UPGRADE_DEFAULT, + TAURI_UPGRADE_DEFAULT, + 1, + ) + normalized = normalized.replace(CAPTIONNEST_UNATTENDED_UPGRADE, "", 1) + assert normalized.count(CAPTIONNEST_REINSTALL_STATE_ORDER) == 1 + normalized = normalized.replace( + CAPTIONNEST_REINSTALL_STATE_ORDER, + TAURI_REINSTALL_STATE_ORDER, + 1, + ) + assert ( + hashlib.sha256(normalized.encode("utf-8")).hexdigest() + == TAURI_INSTALLER_BASELINE_SHA256 + ) + + lock = json.loads( + (PROJECT_ROOT / "apps" / "web" / "package-lock.json").read_text( + encoding="utf-8" + ) + ) + packages = lock["packages"] + assert packages["node_modules/@tauri-apps/cli"]["version"] == TAURI_CLI_VERSION + assert ( + packages["node_modules/@tauri-apps/cli-win32-x64-msvc"]["version"] + == TAURI_CLI_VERSION + ) + + @pytest.mark.parametrize( ("silent", "passive", "explicit_uninstall", "marker_survives"), [ diff --git a/tooling/tests/test_release_workflow.py b/tooling/tests/test_release_workflow.py index 01ebe24..eb86e83 100644 --- a/tooling/tests/test_release_workflow.py +++ b/tooling/tests/test_release_workflow.py @@ -175,11 +175,67 @@ def test_javascript_actions_and_project_runtime_use_node_24() -> None: } assert targeted_refs == expected_refs - assert combined.count("# v8.3.2") == 2 - assert re.findall(r"node-version:\s+'(\d+)'", combined) == ["24", "24", "24"] + assert combined.count("# v8.3.2") == 4 + assert re.findall(r"node-version:\s+'(\d+)'", combined) == [ + "24", + "24", + "24", + "24", + ] assert "node-version: '22'" not in combined +def test_windows_ci_runs_required_nsis_packaging_regressions() -> None: + workflow = (ROOT / ".github" / "workflows" / "ci.yml").read_text( + encoding="utf-8" + ) + desktop = workflow.split("desktop-check:", 1)[1] + + assert "astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990" in desktop + assert "uv sync --project apps/sidecar --extra dev --locked" in desktop + assert "Build NSIS packaging test fixture" in desktop + assert "Test Windows NSIS packaging policies" in desktop + assert "CAPTIONNEST_REQUIRE_NSIS_TESTS: '1'" in desktop + assert "pytest tooling/tests/test_desktop_packaging.py" in desktop + + +def test_windows_ci_exercises_affected_and_exact_head_installer_lifecycle() -> None: + workflow = (ROOT / ".github" / "workflows" / "ci.yml").read_text( + encoding="utf-8" + ) + lifecycle = workflow.split("installer-lifecycle:", 1)[1] + script = (ROOT / "scripts" / "test-installer-model-retention.ps1").read_text( + encoding="utf-8" + ) + + assert "timeout-minutes: 180" in lifecycle + assert "Restore LGPL media wheel cache" in lifecycle + assert "db4723bd0a99eab031f1a3dee4336dca43049c87" in lifecycle + assert "install-media-wheel.ps1" in lifecycle + assert "build-desktop.ps1" in lifecycle + assert "Build higher-version upgrade fixture from exact HEAD" in lifecycle + assert "'{\"version\":\"0.2.9\"}'" in lifecycle + assert "--ignore-version-mismatches" in lifecycle + assert "CAPTIONNEST_CURRENT_INSTALLER=$ExactCopy" in lifecycle + assert "CAPTIONNEST_UPGRADE_INSTALLER" in lifecycle + assert "releases/download/v0.2.8/CaptionNest_0.2.8_x64-setup.exe" in lifecycle + assert "test-installer-model-retention.ps1" in lifecycle + assert "-UpgradeInstallerPath $env:CAPTIONNEST_UPGRADE_INSTALLER" in lifecycle + assert "RUNNER_ENVIRONMENT -ne 'github-hosted'" in script + assert "Affected installer SHA-256 mismatch" in script + for marker in ( + "affected-explicit-uninstall", + "Test-UpgradeMode -Name 'gui-default'", + "@('/S')", + "@('/P')", + "@('/UPDATE', '/P')", + "current-uninstall-cancel-keep-delete", + "Invoke-RestMethod", + "status -ne 'ready'", + ): + assert marker in script + + def test_attestation_is_pinned_minimally_permitted_and_fail_closed() -> None: workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text( encoding="utf-8" From 5ee0bdac644cfa75f3049f044cbbf44c0d9d8d81 Mon Sep 17 00:00:00 2001 From: BayesWang <827130441@qq.com> Date: Tue, 21 Jul 2026 20:39:29 +0800 Subject: [PATCH 03/18] ci: pass upgrade config by file --- .github/workflows/ci.yml | 6 +++++- tooling/tests/test_release_workflow.py | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f296e62..a650d25 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -213,11 +213,15 @@ jobs: shell: pwsh run: | $Tauri = (Resolve-Path 'apps\web\node_modules\.bin\tauri.cmd').Path + $UpgradeConfig = Join-Path $env:RUNNER_TEMP 'captionnest-upgrade-config.json' + @{ version = '0.2.9' } | + ConvertTo-Json | + Set-Content -LiteralPath $UpgradeConfig -Encoding utf8NoBOM Push-Location 'apps\desktop' try { $Arguments = @( 'build', - '--config', '{"version":"0.2.9"}', + '--config', $UpgradeConfig, '--target', 'x86_64-pc-windows-msvc', '--bundles', 'nsis', '--ci', diff --git a/tooling/tests/test_release_workflow.py b/tooling/tests/test_release_workflow.py index eb86e83..a1a6a79 100644 --- a/tooling/tests/test_release_workflow.py +++ b/tooling/tests/test_release_workflow.py @@ -214,7 +214,9 @@ def test_windows_ci_exercises_affected_and_exact_head_installer_lifecycle() -> N assert "install-media-wheel.ps1" in lifecycle assert "build-desktop.ps1" in lifecycle assert "Build higher-version upgrade fixture from exact HEAD" in lifecycle - assert "'{\"version\":\"0.2.9\"}'" in lifecycle + assert "captionnest-upgrade-config.json" in lifecycle + assert "@{ version = '0.2.9' }" in lifecycle + assert "'--config', $UpgradeConfig" in lifecycle assert "--ignore-version-mismatches" in lifecycle assert "CAPTIONNEST_CURRENT_INSTALLER=$ExactCopy" in lifecycle assert "CAPTIONNEST_UPGRADE_INSTALLER" in lifecycle From f3d0c972de632b74df321f27e2acc98aafd2f441 Mon Sep 17 00:00:00 2001 From: BayesWang <827130441@qq.com> Date: Tue, 21 Jul 2026 20:49:49 +0800 Subject: [PATCH 04/18] ci: preserve Tauri build root for upgrade fixture --- .github/workflows/ci.yml | 28 +++++++++++--------------- tooling/tests/test_release_workflow.py | 1 + 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a650d25..527af30 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -217,22 +217,18 @@ jobs: @{ version = '0.2.9' } | ConvertTo-Json | Set-Content -LiteralPath $UpgradeConfig -Encoding utf8NoBOM - Push-Location 'apps\desktop' - try { - $Arguments = @( - 'build', - '--config', $UpgradeConfig, - '--target', 'x86_64-pc-windows-msvc', - '--bundles', 'nsis', - '--ci', - '--no-sign', - '--ignore-version-mismatches' - ) - & $Tauri @Arguments - if ($LASTEXITCODE -ne 0) { throw 'Unable to build the upgrade fixture.' } - } finally { - Pop-Location - } + $Arguments = @( + 'build', + '--config', 'apps\desktop\tauri.conf.json', + '--config', $UpgradeConfig, + '--target', 'x86_64-pc-windows-msvc', + '--bundles', 'nsis', + '--ci', + '--no-sign', + '--ignore-version-mismatches' + ) + & $Tauri @Arguments + if ($LASTEXITCODE -ne 0) { throw 'Unable to build the upgrade fixture.' } $Upgrade = @( Get-ChildItem ` -LiteralPath 'apps\desktop\target\x86_64-pc-windows-msvc\release\bundle\nsis' ` diff --git a/tooling/tests/test_release_workflow.py b/tooling/tests/test_release_workflow.py index a1a6a79..7ac88e2 100644 --- a/tooling/tests/test_release_workflow.py +++ b/tooling/tests/test_release_workflow.py @@ -216,6 +216,7 @@ def test_windows_ci_exercises_affected_and_exact_head_installer_lifecycle() -> N assert "Build higher-version upgrade fixture from exact HEAD" in lifecycle assert "captionnest-upgrade-config.json" in lifecycle assert "@{ version = '0.2.9' }" in lifecycle + assert "'--config', 'apps\\desktop\\tauri.conf.json'" in lifecycle assert "'--config', $UpgradeConfig" in lifecycle assert "--ignore-version-mismatches" in lifecycle assert "CAPTIONNEST_CURRENT_INSTALLER=$ExactCopy" in lifecycle From e8ed40e77c6ca2f89596d6c3654db915d99f1d05 Mon Sep 17 00:00:00 2001 From: BayesWang <827130441@qq.com> Date: Tue, 21 Jul 2026 21:03:41 +0800 Subject: [PATCH 05/18] test: make installer cleanup race-safe --- scripts/test-installer-model-retention.ps1 | 31 +++++++++++++++++++--- tooling/tests/test_release_workflow.py | 4 +++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/scripts/test-installer-model-retention.ps1 b/scripts/test-installer-model-retention.ps1 index 6ed6233..8a0002d 100644 --- a/scripts/test-installer-model-retention.ps1 +++ b/scripts/test-installer-model-retention.ps1 @@ -368,16 +368,41 @@ function Assert-InstalledAppAndModelReady { } } +function Remove-OwnedDirectoryWithRetry { + param( + [Parameter(Mandatory = $true)][string]$Path, + [int]$TimeoutSeconds = 30 + ) + $Deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) + do { + if (-not (Test-Path -LiteralPath $Path)) { return } + try { + Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop + } catch { + # NSIS can still be deleting children after its launcher exits. + # Retry the owned disposable-runner path until it is stable/absent. + if ([DateTime]::UtcNow -ge $Deadline) { + throw "Unable to remove owned CaptionNest path '$Path': $($_.Exception.Message)" + } + Start-Sleep -Milliseconds 250 + } + } while ([DateTime]::UtcNow -lt $Deadline) + if (Test-Path -LiteralPath $Path) { + throw "Owned CaptionNest path still exists after $TimeoutSeconds seconds: $Path" + } +} + function Remove-OwnedCaptionNestState { foreach ($ProcessName in @('captionnest', 'captionnest-sidecar')) { Get-Process -Name $ProcessName -ErrorAction SilentlyContinue | ForEach-Object { & taskkill.exe @('/PID', $_.Id.ToString(), '/T', '/F') | Out-Null + if (-not $_.WaitForExit(10000)) { + throw "Owned process $($_.Id) did not exit during cleanup." + } } } foreach ($Path in @($InstallRoot, $AppDataRoot)) { - if (Test-Path -LiteralPath $Path) { - Remove-Item -LiteralPath $Path -Recurse -Force - } + Remove-OwnedDirectoryWithRetry -Path $Path } Remove-Item -LiteralPath $UninstallKey, $ManufacturerKey -Recurse -Force -ErrorAction SilentlyContinue } diff --git a/tooling/tests/test_release_workflow.py b/tooling/tests/test_release_workflow.py index 7ac88e2..865b59e 100644 --- a/tooling/tests/test_release_workflow.py +++ b/tooling/tests/test_release_workflow.py @@ -226,6 +226,10 @@ def test_windows_ci_exercises_affected_and_exact_head_installer_lifecycle() -> N assert "-UpgradeInstallerPath $env:CAPTIONNEST_UPGRADE_INSTALLER" in lifecycle assert "RUNNER_ENVIRONMENT -ne 'github-hosted'" in script assert "Affected installer SHA-256 mismatch" in script + assert "function Remove-OwnedDirectoryWithRetry" in script + assert "[int]$TimeoutSeconds = 30" in script + assert "Owned CaptionNest path still exists" in script + assert "did not exit during cleanup" in script for marker in ( "affected-explicit-uninstall", "Test-UpgradeMode -Name 'gui-default'", From 6b4787ee222151ec15b5e0687b38f07a894a88cd Mon Sep 17 00:00:00 2001 From: BayesWang <827130441@qq.com> Date: Tue, 21 Jul 2026 21:19:14 +0800 Subject: [PATCH 06/18] test: explicitly confirm affected uninstall --- scripts/test-installer-model-retention.ps1 | 70 +++++++++++++++++++++- tooling/tests/test_release_workflow.py | 4 ++ 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/scripts/test-installer-model-retention.ps1 b/scripts/test-installer-model-retention.ps1 index 8a0002d..386292a 100644 --- a/scripts/test-installer-model-retention.ps1 +++ b/scripts/test-installer-model-retention.ps1 @@ -135,8 +135,13 @@ function Assert-ModelPresent { } function Assert-ModelAbsent { + $Deadline = [DateTime]::UtcNow.AddSeconds(30) + do { + if (-not (Test-Path -LiteralPath $MarkerPath)) { return } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $Deadline) if (Test-Path -LiteralPath $MarkerPath) { - throw "Recognition model marker was unexpectedly retained: $MarkerPath" + throw "Recognition model marker was retained after explicit deletion: $MarkerPath" } } @@ -302,6 +307,45 @@ function Invoke-CurrentUninstallerGui { Wait-ProcessExit -Process $Process -TimeoutSeconds 5 } +function Invoke-AffectedUninstallerGuiConfirm { + $Uninstaller = Join-Path $InstallRoot 'uninstall.exe' + $Process = Start-OwnedProcess -FilePath $Uninstaller + $Window = Get-ProcessWindow -Process $Process + $MainWindowHandle = $Process.MainWindowHandle + $CheckboxCondition = [System.Windows.Automation.PropertyCondition]::new( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::CheckBox + ) + $Checkbox = Get-UiElement -Root $Window -Condition $CheckboxCondition + Set-UiCheckbox -Checkbox $Checkbox -Checked $true + Invoke-UiElement (Get-UiElement -Root $Window -Condition (Get-ButtonCondition '1')) + + $Confirmed = $false + $Deadline = [DateTime]::UtcNow.AddSeconds(30) + do { + $Popup = [CaptionNestNativeMethods]::GetLastActivePopup($MainWindowHandle) + if ($Popup -ne [IntPtr]::Zero -and $Popup -ne $MainWindowHandle) { + $OkButton = [CaptionNestNativeMethods]::GetDlgItem($Popup, 1) + if ($OkButton -ne [IntPtr]::Zero) { + [void][CaptionNestNativeMethods]::SendMessage( + $OkButton, + 0x00F5, + [IntPtr]::Zero, + [IntPtr]::Zero + ) + $Confirmed = $true + break + } + } + Start-Sleep -Milliseconds 200 + } while ([DateTime]::UtcNow -lt $Deadline) + if (-not $Confirmed) { + throw 'Affected uninstaller did not expose its explicit deletion confirmation.' + } + Wait-ProcessExit -Process $Process + Assert-ModelAbsent +} + function Assert-InstalledAppAndModelReady { $App = Join-Path $InstallRoot 'captionnest.exe' $Sidecar = Join-Path $InstallRoot 'captionnest-sidecar.exe' @@ -435,6 +479,27 @@ function Test-UpgradeMode { } Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes +Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; + +public static class CaptionNestNativeMethods +{ + [DllImport("user32.dll")] + public static extern IntPtr GetLastActivePopup(IntPtr window); + + [DllImport("user32.dll")] + public static extern IntPtr GetDlgItem(IntPtr dialog, int itemId); + + [DllImport("user32.dll")] + public static extern IntPtr SendMessage( + IntPtr window, + uint message, + IntPtr wordParameter, + IntPtr longParameter + ); +} +'@ Assert-DisposableRunnerState Assert-OldInstallerIdentity New-Item -ItemType Directory -Path $EvidenceRoot -Force | Out-Null @@ -442,8 +507,7 @@ New-Item -ItemType Directory -Path $EvidenceRoot -Force | Out-Null try { Write-Host 'LIFECYCLE: affected-explicit-uninstall' Install-AffectedVersionWithModel - Invoke-Installer -Path (Join-Path $InstallRoot 'uninstall.exe') -Arguments @('/S') - Assert-ModelAbsent + Invoke-AffectedUninstallerGuiConfirm Remove-OwnedCaptionNestState Test-UpgradeMode -Name 'gui-default' -Gui diff --git a/tooling/tests/test_release_workflow.py b/tooling/tests/test_release_workflow.py index 865b59e..eb3548d 100644 --- a/tooling/tests/test_release_workflow.py +++ b/tooling/tests/test_release_workflow.py @@ -226,6 +226,10 @@ def test_windows_ci_exercises_affected_and_exact_head_installer_lifecycle() -> N assert "-UpgradeInstallerPath $env:CAPTIONNEST_UPGRADE_INSTALLER" in lifecycle assert "RUNNER_ENVIRONMENT -ne 'github-hosted'" in script assert "Affected installer SHA-256 mismatch" in script + assert "function Invoke-AffectedUninstallerGuiConfirm" in script + assert "Set-UiCheckbox -Checkbox $Checkbox -Checked $true" in script + assert "GetLastActivePopup" in script + assert "Affected uninstaller did not expose its explicit deletion confirmation" in script assert "function Remove-OwnedDirectoryWithRetry" in script assert "[int]$TimeoutSeconds = 30" in script assert "Owned CaptionNest path still exists" in script From 279b9ce4b8a2169ce75363f1c200b9451dc350dd Mon Sep 17 00:00:00 2001 From: BayesWang <827130441@qq.com> Date: Tue, 21 Jul 2026 21:34:36 +0800 Subject: [PATCH 07/18] test: discover delegated NSIS windows --- scripts/test-installer-model-retention.ps1 | 78 +++++++++++++++++----- tooling/tests/test_release_workflow.py | 4 ++ 2 files changed, 67 insertions(+), 15 deletions(-) diff --git a/scripts/test-installer-model-retention.ps1 b/scripts/test-installer-model-retention.ps1 index 386292a..4a14e83 100644 --- a/scripts/test-installer-model-retention.ps1 +++ b/scripts/test-installer-model-retention.ps1 @@ -166,6 +166,10 @@ function Get-UiElement { function Get-ProcessWindow { param([Parameter(Mandatory = $true)]$Process) $Deadline = [DateTime]::UtcNow.AddSeconds(30) + $WindowCondition = [System.Windows.Automation.PropertyCondition]::new( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::Window + ) do { $Process.Refresh() if ($Process.MainWindowHandle -ne 0) { @@ -173,9 +177,39 @@ function Get-ProcessWindow { $Process.MainWindowHandle ) } + $Candidates = @( + [System.Windows.Automation.AutomationElement]::RootElement.FindAll( + [System.Windows.Automation.TreeScope]::Children, + $WindowCondition + ) | Where-Object { + -not $_.Current.IsOffscreen -and $_.Current.Name -like '*CaptionNest*' + } + ) + if ($Candidates.Count -gt 1) { + $Names = $Candidates | ForEach-Object { $_.Current.Name } + throw "Multiple CaptionNest GUI windows matched: $($Names -join '; ')." + } + if ($Candidates.Count -eq 1) { return $Candidates[0] } Start-Sleep -Milliseconds 200 } while ([DateTime]::UtcNow -lt $Deadline) - throw "Process $($Process.Id) did not expose an interactive window." + throw "No CaptionNest GUI window appeared for launcher process $($Process.Id)." +} + +function Wait-NativeWindowClosed { + param( + [Parameter(Mandatory = $true)][IntPtr]$WindowHandle, + [int]$TimeoutSeconds = 180 + ) + $Deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) + while ( + [CaptionNestNativeMethods]::IsWindow($WindowHandle) -and + [DateTime]::UtcNow -lt $Deadline + ) { + Start-Sleep -Milliseconds 250 + } + if ([CaptionNestNativeMethods]::IsWindow($WindowHandle)) { + throw "CaptionNest GUI window did not close within $TimeoutSeconds seconds." + } } function Invoke-UiElement { @@ -215,6 +249,7 @@ function Get-ButtonCondition { function Complete-GuiUpgradeWithDefault { $Process = Start-OwnedProcess -FilePath $UpgradeInstaller $Window = Get-ProcessWindow -Process $Process + $WindowHandle = [IntPtr]$Window.Current.NativeWindowHandle $NextCondition = Get-ButtonCondition -AutomationId '1' Invoke-UiElement (Get-UiElement -Root $Window -Condition $NextCondition) @@ -243,8 +278,11 @@ function Complete-GuiUpgradeWithDefault { Invoke-UiElement (Get-UiElement -Root $Window -Condition $NextCondition) $Deadline = [DateTime]::UtcNow.AddSeconds(180) - while (-not $Process.HasExited -and [DateTime]::UtcNow -lt $Deadline) { - $Window = Get-ProcessWindow -Process $Process + while ( + [CaptionNestNativeMethods]::IsWindow($WindowHandle) -and + [DateTime]::UtcNow -lt $Deadline + ) { + $Window = [System.Windows.Automation.AutomationElement]::FromHandle($WindowHandle) $CheckboxCondition = [System.Windows.Automation.PropertyCondition]::new( [System.Windows.Automation.AutomationElement]::ControlTypeProperty, [System.Windows.Automation.ControlType]::CheckBox @@ -263,9 +301,9 @@ function Complete-GuiUpgradeWithDefault { Invoke-UiElement $Next } Start-Sleep -Milliseconds 500 - $Process.Refresh() } - Wait-ProcessExit -Process $Process -TimeoutSeconds 5 + Wait-NativeWindowClosed -WindowHandle $WindowHandle -TimeoutSeconds 5 + if (-not $Process.HasExited) { Wait-ProcessExit -Process $Process -TimeoutSeconds 5 } } function Invoke-CurrentUninstallerGui { @@ -275,6 +313,7 @@ function Invoke-CurrentUninstallerGui { $Uninstaller = Join-Path $InstallRoot 'uninstall.exe' $Process = Start-OwnedProcess -FilePath $Uninstaller $Window = Get-ProcessWindow -Process $Process + $WindowHandle = [IntPtr]$Window.Current.NativeWindowHandle $CheckboxCondition = [System.Windows.Automation.PropertyCondition]::new( [System.Windows.Automation.AutomationElement]::ControlTypeProperty, [System.Windows.Automation.ControlType]::CheckBox @@ -282,18 +321,21 @@ function Invoke-CurrentUninstallerGui { $Checkbox = Get-UiElement -Root $Window -Condition $CheckboxCondition if ($Decision -eq 'cancel') { Invoke-UiElement (Get-UiElement -Root $Window -Condition (Get-ButtonCondition '2')) - Wait-ProcessExit -Process $Process -AllowedExitCodes @(0, 1) + Wait-NativeWindowClosed -WindowHandle $WindowHandle + if (-not $Process.HasExited) { + Wait-ProcessExit -Process $Process -AllowedExitCodes @(0, 1) + } return } Set-UiCheckbox -Checkbox $Checkbox -Checked ($Decision -eq 'delete') Invoke-UiElement (Get-UiElement -Root $Window -Condition (Get-ButtonCondition '1')) $Deadline = [DateTime]::UtcNow.AddSeconds(180) - while (-not $Process.HasExited -and [DateTime]::UtcNow -lt $Deadline) { - $Process.Refresh() - if ($Process.MainWindowHandle -ne 0) { - $Window = [System.Windows.Automation.AutomationElement]::FromHandle( - $Process.MainWindowHandle - ) + while ( + [CaptionNestNativeMethods]::IsWindow($WindowHandle) -and + [DateTime]::UtcNow -lt $Deadline + ) { + $Window = [System.Windows.Automation.AutomationElement]::FromHandle($WindowHandle) + if ($null -ne $Window) { $Next = $Window.FindFirst( [System.Windows.Automation.TreeScope]::Descendants, (Get-ButtonCondition '1') @@ -304,14 +346,15 @@ function Invoke-CurrentUninstallerGui { } Start-Sleep -Milliseconds 500 } - Wait-ProcessExit -Process $Process -TimeoutSeconds 5 + Wait-NativeWindowClosed -WindowHandle $WindowHandle -TimeoutSeconds 5 + if (-not $Process.HasExited) { Wait-ProcessExit -Process $Process -TimeoutSeconds 5 } } function Invoke-AffectedUninstallerGuiConfirm { $Uninstaller = Join-Path $InstallRoot 'uninstall.exe' $Process = Start-OwnedProcess -FilePath $Uninstaller $Window = Get-ProcessWindow -Process $Process - $MainWindowHandle = $Process.MainWindowHandle + $MainWindowHandle = [IntPtr]$Window.Current.NativeWindowHandle $CheckboxCondition = [System.Windows.Automation.PropertyCondition]::new( [System.Windows.Automation.AutomationElement]::ControlTypeProperty, [System.Windows.Automation.ControlType]::CheckBox @@ -342,7 +385,8 @@ function Invoke-AffectedUninstallerGuiConfirm { if (-not $Confirmed) { throw 'Affected uninstaller did not expose its explicit deletion confirmation.' } - Wait-ProcessExit -Process $Process + Wait-NativeWindowClosed -WindowHandle $MainWindowHandle + if (-not $Process.HasExited) { Wait-ProcessExit -Process $Process } Assert-ModelAbsent } @@ -485,6 +529,10 @@ using System.Runtime.InteropServices; public static class CaptionNestNativeMethods { + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool IsWindow(IntPtr window); + [DllImport("user32.dll")] public static extern IntPtr GetLastActivePopup(IntPtr window); diff --git a/tooling/tests/test_release_workflow.py b/tooling/tests/test_release_workflow.py index eb3548d..8b85e66 100644 --- a/tooling/tests/test_release_workflow.py +++ b/tooling/tests/test_release_workflow.py @@ -229,6 +229,10 @@ def test_windows_ci_exercises_affected_and_exact_head_installer_lifecycle() -> N assert "function Invoke-AffectedUninstallerGuiConfirm" in script assert "Set-UiCheckbox -Checkbox $Checkbox -Checked $true" in script assert "GetLastActivePopup" in script + assert "AutomationElement]::RootElement.FindAll" in script + assert "Multiple CaptionNest GUI windows matched" in script + assert "No CaptionNest GUI window appeared" in script + assert "function Wait-NativeWindowClosed" in script assert "Affected uninstaller did not expose its explicit deletion confirmation" in script assert "function Remove-OwnedDirectoryWithRetry" in script assert "[int]$TimeoutSeconds = 30" in script From 45ae43cd7c542bc5fff9fb3eac688d53bbbdef6a Mon Sep 17 00:00:00 2001 From: BayesWang <827130441@qq.com> Date: Tue, 21 Jul 2026 21:52:08 +0800 Subject: [PATCH 08/18] test: enumerate headless NSIS windows natively --- scripts/test-installer-model-retention.ps1 | 95 ++++++++++++++++++---- tooling/tests/test_release_workflow.py | 5 +- 2 files changed, 85 insertions(+), 15 deletions(-) diff --git a/scripts/test-installer-model-retention.ps1 b/scripts/test-installer-model-retention.ps1 index 4a14e83..361c8c1 100644 --- a/scripts/test-installer-model-retention.ps1 +++ b/scripts/test-installer-model-retention.ps1 @@ -166,10 +166,7 @@ function Get-UiElement { function Get-ProcessWindow { param([Parameter(Mandatory = $true)]$Process) $Deadline = [DateTime]::UtcNow.AddSeconds(30) - $WindowCondition = [System.Windows.Automation.PropertyCondition]::new( - [System.Windows.Automation.AutomationElement]::ControlTypeProperty, - [System.Windows.Automation.ControlType]::Window - ) + $LastNewWindows = @() do { $Process.Refresh() if ($Process.MainWindowHandle -ne 0) { @@ -177,22 +174,35 @@ function Get-ProcessWindow { $Process.MainWindowHandle ) } - $Candidates = @( - [System.Windows.Automation.AutomationElement]::RootElement.FindAll( - [System.Windows.Automation.TreeScope]::Children, - $WindowCondition - ) | Where-Object { - -not $_.Current.IsOffscreen -and $_.Current.Name -like '*CaptionNest*' + $LastNewWindows = @( + [CaptionNestNativeMethods]::EnumerateTopLevelWindows() | Where-Object { + $_.Handle.ToInt64() -notin $script:BaselineWindowHandles } ) + $Candidates = @($LastNewWindows | Where-Object { + $_.Title -like '*CaptionNest*' -or $_.ClassName -eq '#32770' + }) if ($Candidates.Count -gt 1) { - $Names = $Candidates | ForEach-Object { $_.Current.Name } - throw "Multiple CaptionNest GUI windows matched: $($Names -join '; ')." + $Details = $Candidates | ForEach-Object { + "title='$($_.Title)' class='$($_.ClassName)' pid=$($_.ProcessId) visible=$($_.Visible)" + } + throw "Multiple CaptionNest GUI windows matched: $($Details -join '; ')." + } + if ($Candidates.Count -eq 1) { + return [System.Windows.Automation.AutomationElement]::FromHandle( + $Candidates[0].Handle + ) } - if ($Candidates.Count -eq 1) { return $Candidates[0] } Start-Sleep -Milliseconds 200 } while ([DateTime]::UtcNow -lt $Deadline) - throw "No CaptionNest GUI window appeared for launcher process $($Process.Id)." + $Diagnostics = @($LastNewWindows | Select-Object -First 20 | ForEach-Object { + "title='$($_.Title)' class='$($_.ClassName)' pid=$($_.ProcessId) visible=$($_.Visible)" + }) + if ($Diagnostics.Count -eq 0) { $Diagnostics = @('') } + throw ( + "No CaptionNest GUI window appeared for launcher process $($Process.Id). " + + "New top-level windows: $($Diagnostics -join '; ')." + ) } function Wait-NativeWindowClosed { @@ -525,10 +535,39 @@ function Test-UpgradeMode { Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes Add-Type -TypeDefinition @' using System; +using System.Collections.Generic; using System.Runtime.InteropServices; +using System.Text; + +public sealed class CaptionNestWindowInfo +{ + public IntPtr Handle { get; set; } + public string Title { get; set; } + public string ClassName { get; set; } + public uint ProcessId { get; set; } + public bool Visible { get; set; } +} public static class CaptionNestNativeMethods { + private delegate bool EnumWindowsCallback(IntPtr window, IntPtr parameter); + + [DllImport("user32.dll")] + private static extern bool EnumWindows(EnumWindowsCallback callback, IntPtr parameter); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetWindowText(IntPtr window, StringBuilder text, int count); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetClassName(IntPtr window, StringBuilder text, int count); + + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool IsWindowVisible(IntPtr window); + [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] public static extern bool IsWindow(IntPtr window); @@ -546,8 +585,36 @@ public static class CaptionNestNativeMethods IntPtr wordParameter, IntPtr longParameter ); + + public static CaptionNestWindowInfo[] EnumerateTopLevelWindows() + { + var windows = new List(); + EnumWindows((window, parameter) => + { + var title = new StringBuilder(512); + var className = new StringBuilder(256); + GetWindowText(window, title, title.Capacity); + GetClassName(window, className, className.Capacity); + uint processId; + GetWindowThreadProcessId(window, out processId); + windows.Add(new CaptionNestWindowInfo + { + Handle = window, + Title = title.ToString().Replace("\r", " ").Replace("\n", " "), + ClassName = className.ToString(), + ProcessId = processId, + Visible = IsWindowVisible(window) + }); + return true; + }, IntPtr.Zero); + return windows.ToArray(); + } } '@ +$script:BaselineWindowHandles = @( + [CaptionNestNativeMethods]::EnumerateTopLevelWindows() | + ForEach-Object { $_.Handle.ToInt64() } +) Assert-DisposableRunnerState Assert-OldInstallerIdentity New-Item -ItemType Directory -Path $EvidenceRoot -Force | Out-Null diff --git a/tooling/tests/test_release_workflow.py b/tooling/tests/test_release_workflow.py index 8b85e66..e1fb697 100644 --- a/tooling/tests/test_release_workflow.py +++ b/tooling/tests/test_release_workflow.py @@ -229,9 +229,12 @@ def test_windows_ci_exercises_affected_and_exact_head_installer_lifecycle() -> N assert "function Invoke-AffectedUninstallerGuiConfirm" in script assert "Set-UiCheckbox -Checkbox $Checkbox -Checked $true" in script assert "GetLastActivePopup" in script - assert "AutomationElement]::RootElement.FindAll" in script + assert "EnumerateTopLevelWindows" in script + assert "BaselineWindowHandles" in script + assert "ClassName -eq '#32770'" in script assert "Multiple CaptionNest GUI windows matched" in script assert "No CaptionNest GUI window appeared" in script + assert "New top-level windows:" in script assert "function Wait-NativeWindowClosed" in script assert "Affected uninstaller did not expose its explicit deletion confirmation" in script assert "function Remove-OwnedDirectoryWithRetry" in script From 8422eeb1964870759a9b3edd2d98d9bbdadf1493 Mon Sep 17 00:00:00 2001 From: BayesWang <827130441@qq.com> Date: Tue, 21 Jul 2026 22:07:39 +0800 Subject: [PATCH 09/18] test: drive headless NSIS controls natively --- scripts/test-installer-model-retention.ps1 | 209 ++++++++++++++------- tooling/tests/test_release_workflow.py | 7 +- 2 files changed, 144 insertions(+), 72 deletions(-) diff --git a/scripts/test-installer-model-retention.ps1 b/scripts/test-installer-model-retention.ps1 index 361c8c1..e85a144 100644 --- a/scripts/test-installer-model-retention.ps1 +++ b/scripts/test-installer-model-retention.ps1 @@ -256,59 +256,90 @@ function Get-ButtonCondition { return [System.Windows.Automation.AndCondition]::new($TypeCondition, $IdCondition) } +function Get-NativeControlsByType { + param( + [Parameter(Mandatory = $true)][IntPtr]$WindowHandle, + [Parameter(Mandatory = $true)][int[]]$ButtonTypes, + [int]$TimeoutSeconds = 30 + ) + $Deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) + do { + $Controls = @( + [CaptionNestNativeMethods]::EnumerateChildWindows($WindowHandle) | + Where-Object { + $_.ClassName -eq 'Button' -and + [int]($_.Style -band 0xF) -in $ButtonTypes + } + ) + if ($Controls.Count -gt 0) { return $Controls } + Start-Sleep -Milliseconds 200 + } while ([DateTime]::UtcNow -lt $Deadline) + throw "Timed out waiting for native NSIS controls of type $($ButtonTypes -join ',')." +} + +function Invoke-NativeButton { + param( + [Parameter(Mandatory = $true)][IntPtr]$WindowHandle, + [Parameter(Mandatory = $true)][int]$ControlId + ) + $Button = [CaptionNestNativeMethods]::GetDlgItem($WindowHandle, $ControlId) + if ($Button -eq [IntPtr]::Zero) { + throw "NSIS button $ControlId was not found." + } + [void][CaptionNestNativeMethods]::SendMessage( + $Button, 0x00F5, [IntPtr]::Zero, [IntPtr]::Zero + ) +} + +function Set-NativeCheckbox { + param( + [Parameter(Mandatory = $true)]$Control, + [Parameter(Mandatory = $true)][bool]$Checked + ) + $State = if ($Checked) { 1 } else { 0 } + [void][CaptionNestNativeMethods]::SendMessage( + $Control.Handle, 0x00F1, [IntPtr]$State, [IntPtr]::Zero + ) +} + function Complete-GuiUpgradeWithDefault { $Process = Start-OwnedProcess -FilePath $UpgradeInstaller $Window = Get-ProcessWindow -Process $Process $WindowHandle = [IntPtr]$Window.Current.NativeWindowHandle - $NextCondition = Get-ButtonCondition -AutomationId '1' - Invoke-UiElement (Get-UiElement -Root $Window -Condition $NextCondition) + Invoke-NativeButton -WindowHandle $WindowHandle -ControlId 1 - $RadioCondition = [System.Windows.Automation.PropertyCondition]::new( - [System.Windows.Automation.AutomationElement]::ControlTypeProperty, - [System.Windows.Automation.ControlType]::RadioButton - ) - $Deadline = [DateTime]::UtcNow.AddSeconds(30) - do { - $Radios = @($Window.FindAll( - [System.Windows.Automation.TreeScope]::Descendants, - $RadioCondition - )) - if ($Radios.Count -eq 2) { break } - Start-Sleep -Milliseconds 200 - } while ([DateTime]::UtcNow -lt $Deadline) + $Radios = @(Get-NativeControlsByType -WindowHandle $WindowHandle -ButtonTypes @(4, 9)) if ($Radios.Count -ne 2) { throw 'The reinstall page did not expose two choices.' } $Selections = @($Radios | ForEach-Object { - $_.GetCurrentPattern( - [System.Windows.Automation.SelectionItemPattern]::Pattern - ).Current.IsSelected + [CaptionNestNativeMethods]::SendMessage( + $_.Handle, 0x00F0, [IntPtr]::Zero, [IntPtr]::Zero + ).ToInt64() -eq 1 }) if ($Selections[0] -or -not $Selections[1]) { throw "GUI upgrade default was not in-place: $($Selections -join ',')." } - Invoke-UiElement (Get-UiElement -Root $Window -Condition $NextCondition) + Invoke-NativeButton -WindowHandle $WindowHandle -ControlId 1 $Deadline = [DateTime]::UtcNow.AddSeconds(180) while ( [CaptionNestNativeMethods]::IsWindow($WindowHandle) -and [DateTime]::UtcNow -lt $Deadline ) { - $Window = [System.Windows.Automation.AutomationElement]::FromHandle($WindowHandle) - $CheckboxCondition = [System.Windows.Automation.PropertyCondition]::new( - [System.Windows.Automation.AutomationElement]::ControlTypeProperty, - [System.Windows.Automation.ControlType]::CheckBox - ) - foreach ($Checkbox in @($Window.FindAll( - [System.Windows.Automation.TreeScope]::Descendants, - $CheckboxCondition - ))) { - Set-UiCheckbox -Checkbox $Checkbox -Checked $false + foreach ($Checkbox in @( + [CaptionNestNativeMethods]::EnumerateChildWindows($WindowHandle) | + Where-Object { + $_.ClassName -eq 'Button' -and + [int]($_.Style -band 0xF) -in @(2, 3, 5, 6) + } + )) { + Set-NativeCheckbox -Control $Checkbox -Checked $false } - $Next = $Window.FindFirst( - [System.Windows.Automation.TreeScope]::Descendants, - $NextCondition - ) - if ($null -ne $Next -and $Next.Current.IsEnabled) { - Invoke-UiElement $Next + $Next = [CaptionNestNativeMethods]::GetDlgItem($WindowHandle, 1) + if ( + $Next -ne [IntPtr]::Zero -and + [CaptionNestNativeMethods]::IsWindowEnabled($Next) + ) { + Invoke-NativeButton -WindowHandle $WindowHandle -ControlId 1 } Start-Sleep -Milliseconds 500 } @@ -324,35 +355,34 @@ function Invoke-CurrentUninstallerGui { $Process = Start-OwnedProcess -FilePath $Uninstaller $Window = Get-ProcessWindow -Process $Process $WindowHandle = [IntPtr]$Window.Current.NativeWindowHandle - $CheckboxCondition = [System.Windows.Automation.PropertyCondition]::new( - [System.Windows.Automation.AutomationElement]::ControlTypeProperty, - [System.Windows.Automation.ControlType]::CheckBox + $Checkboxes = @( + Get-NativeControlsByType -WindowHandle $WindowHandle -ButtonTypes @(2, 3, 5, 6) ) - $Checkbox = Get-UiElement -Root $Window -Condition $CheckboxCondition + if ($Checkboxes.Count -ne 1) { + throw "Expected one uninstall data checkbox; found $($Checkboxes.Count)." + } + $Checkbox = $Checkboxes[0] if ($Decision -eq 'cancel') { - Invoke-UiElement (Get-UiElement -Root $Window -Condition (Get-ButtonCondition '2')) + Invoke-NativeButton -WindowHandle $WindowHandle -ControlId 2 Wait-NativeWindowClosed -WindowHandle $WindowHandle if (-not $Process.HasExited) { Wait-ProcessExit -Process $Process -AllowedExitCodes @(0, 1) } return } - Set-UiCheckbox -Checkbox $Checkbox -Checked ($Decision -eq 'delete') - Invoke-UiElement (Get-UiElement -Root $Window -Condition (Get-ButtonCondition '1')) + Set-NativeCheckbox -Control $Checkbox -Checked ($Decision -eq 'delete') + Invoke-NativeButton -WindowHandle $WindowHandle -ControlId 1 $Deadline = [DateTime]::UtcNow.AddSeconds(180) while ( [CaptionNestNativeMethods]::IsWindow($WindowHandle) -and [DateTime]::UtcNow -lt $Deadline ) { - $Window = [System.Windows.Automation.AutomationElement]::FromHandle($WindowHandle) - if ($null -ne $Window) { - $Next = $Window.FindFirst( - [System.Windows.Automation.TreeScope]::Descendants, - (Get-ButtonCondition '1') - ) - if ($null -ne $Next -and $Next.Current.IsEnabled) { - Invoke-UiElement $Next - } + $Next = [CaptionNestNativeMethods]::GetDlgItem($WindowHandle, 1) + if ( + $Next -ne [IntPtr]::Zero -and + [CaptionNestNativeMethods]::IsWindowEnabled($Next) + ) { + Invoke-NativeButton -WindowHandle $WindowHandle -ControlId 1 } Start-Sleep -Milliseconds 500 } @@ -365,13 +395,14 @@ function Invoke-AffectedUninstallerGuiConfirm { $Process = Start-OwnedProcess -FilePath $Uninstaller $Window = Get-ProcessWindow -Process $Process $MainWindowHandle = [IntPtr]$Window.Current.NativeWindowHandle - $CheckboxCondition = [System.Windows.Automation.PropertyCondition]::new( - [System.Windows.Automation.AutomationElement]::ControlTypeProperty, - [System.Windows.Automation.ControlType]::CheckBox + $Checkboxes = @( + Get-NativeControlsByType -WindowHandle $MainWindowHandle -ButtonTypes @(2, 3, 5, 6) ) - $Checkbox = Get-UiElement -Root $Window -Condition $CheckboxCondition - Set-UiCheckbox -Checkbox $Checkbox -Checked $true - Invoke-UiElement (Get-UiElement -Root $Window -Condition (Get-ButtonCondition '1')) + if ($Checkboxes.Count -ne 1) { + throw "Expected one affected-uninstall data checkbox; found $($Checkboxes.Count)." + } + Set-NativeCheckbox -Control $Checkboxes[0] -Checked $true + Invoke-NativeButton -WindowHandle $MainWindowHandle -ControlId 1 $Confirmed = $false $Deadline = [DateTime]::UtcNow.AddSeconds(30) @@ -546,6 +577,8 @@ public sealed class CaptionNestWindowInfo public string ClassName { get; set; } public uint ProcessId { get; set; } public bool Visible { get; set; } + public int ControlId { get; set; } + public long Style { get; set; } } public static class CaptionNestNativeMethods @@ -555,6 +588,13 @@ public static class CaptionNestNativeMethods [DllImport("user32.dll")] private static extern bool EnumWindows(EnumWindowsCallback callback, IntPtr parameter); + [DllImport("user32.dll")] + private static extern bool EnumChildWindows( + IntPtr parent, + EnumWindowsCallback callback, + IntPtr parameter + ); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern int GetWindowText(IntPtr window, StringBuilder text, int count); @@ -568,6 +608,15 @@ public static class CaptionNestNativeMethods [return: MarshalAs(UnmanagedType.Bool)] private static extern bool IsWindowVisible(IntPtr window); + [DllImport("user32.dll")] + public static extern bool IsWindowEnabled(IntPtr window); + + [DllImport("user32.dll")] + private static extern int GetDlgCtrlID(IntPtr window); + + [DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW")] + private static extern IntPtr GetWindowLongPtr(IntPtr window, int index); + [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] public static extern bool IsWindow(IntPtr window); @@ -591,24 +640,42 @@ public static class CaptionNestNativeMethods var windows = new List(); EnumWindows((window, parameter) => { - var title = new StringBuilder(512); - var className = new StringBuilder(256); - GetWindowText(window, title, title.Capacity); - GetClassName(window, className, className.Capacity); - uint processId; - GetWindowThreadProcessId(window, out processId); - windows.Add(new CaptionNestWindowInfo - { - Handle = window, - Title = title.ToString().Replace("\r", " ").Replace("\n", " "), - ClassName = className.ToString(), - ProcessId = processId, - Visible = IsWindowVisible(window) - }); + windows.Add(DescribeWindow(window)); + return true; + }, IntPtr.Zero); + return windows.ToArray(); + } + + public static CaptionNestWindowInfo[] EnumerateChildWindows(IntPtr parent) + { + var windows = new List(); + EnumChildWindows(parent, (window, parameter) => + { + windows.Add(DescribeWindow(window)); return true; }, IntPtr.Zero); return windows.ToArray(); } + + private static CaptionNestWindowInfo DescribeWindow(IntPtr window) + { + var title = new StringBuilder(512); + var className = new StringBuilder(256); + GetWindowText(window, title, title.Capacity); + GetClassName(window, className, className.Capacity); + uint processId; + GetWindowThreadProcessId(window, out processId); + return new CaptionNestWindowInfo + { + Handle = window, + Title = title.ToString().Replace("\r", " ").Replace("\n", " "), + ClassName = className.ToString(), + ProcessId = processId, + Visible = IsWindowVisible(window), + ControlId = GetDlgCtrlID(window), + Style = GetWindowLongPtr(window, -16).ToInt64() + }; + } } '@ $script:BaselineWindowHandles = @( diff --git a/tooling/tests/test_release_workflow.py b/tooling/tests/test_release_workflow.py index e1fb697..6acaa47 100644 --- a/tooling/tests/test_release_workflow.py +++ b/tooling/tests/test_release_workflow.py @@ -227,7 +227,7 @@ def test_windows_ci_exercises_affected_and_exact_head_installer_lifecycle() -> N assert "RUNNER_ENVIRONMENT -ne 'github-hosted'" in script assert "Affected installer SHA-256 mismatch" in script assert "function Invoke-AffectedUninstallerGuiConfirm" in script - assert "Set-UiCheckbox -Checkbox $Checkbox -Checked $true" in script + assert "Set-NativeCheckbox -Control $Checkboxes[0] -Checked $true" in script assert "GetLastActivePopup" in script assert "EnumerateTopLevelWindows" in script assert "BaselineWindowHandles" in script @@ -235,6 +235,11 @@ def test_windows_ci_exercises_affected_and_exact_head_installer_lifecycle() -> N assert "Multiple CaptionNest GUI windows matched" in script assert "No CaptionNest GUI window appeared" in script assert "New top-level windows:" in script + assert "EnumerateChildWindows" in script + assert "GetDlgCtrlID" in script + assert "GetWindowLongPtr" in script + assert "0x00F1" in script + assert "0x00F5" in script assert "function Wait-NativeWindowClosed" in script assert "Affected uninstaller did not expose its explicit deletion confirmation" in script assert "function Remove-OwnedDirectoryWithRetry" in script From 146dfd79f4f37255a0e5a7ee02942724ab1bfc2e Mon Sep 17 00:00:00 2001 From: BayesWang <827130441@qq.com> Date: Tue, 21 Jul 2026 22:09:49 +0800 Subject: [PATCH 10/18] test: remove UIA from NSIS lifecycle --- scripts/test-installer-model-retention.ps1 | 159 +++++++++++---------- tooling/tests/test_release_workflow.py | 6 +- 2 files changed, 85 insertions(+), 80 deletions(-) diff --git a/scripts/test-installer-model-retention.ps1 b/scripts/test-installer-model-retention.ps1 index e85a144..ebc9e25 100644 --- a/scripts/test-installer-model-retention.ps1 +++ b/scripts/test-installer-model-retention.ps1 @@ -145,24 +145,6 @@ function Assert-ModelAbsent { } } -function Get-UiElement { - param( - [Parameter(Mandatory = $true)]$Root, - [Parameter(Mandatory = $true)]$Condition, - [int]$TimeoutSeconds = 30 - ) - $Deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) - do { - $Element = $Root.FindFirst( - [System.Windows.Automation.TreeScope]::Descendants, - $Condition - ) - if ($null -ne $Element) { return $Element } - Start-Sleep -Milliseconds 200 - } while ([DateTime]::UtcNow -lt $Deadline) - throw 'Timed out waiting for an installer UI element.' -} - function Get-ProcessWindow { param([Parameter(Mandatory = $true)]$Process) $Deadline = [DateTime]::UtcNow.AddSeconds(30) @@ -170,9 +152,7 @@ function Get-ProcessWindow { do { $Process.Refresh() if ($Process.MainWindowHandle -ne 0) { - return [System.Windows.Automation.AutomationElement]::FromHandle( - $Process.MainWindowHandle - ) + return [IntPtr]$Process.MainWindowHandle } $LastNewWindows = @( [CaptionNestNativeMethods]::EnumerateTopLevelWindows() | Where-Object { @@ -189,9 +169,7 @@ function Get-ProcessWindow { throw "Multiple CaptionNest GUI windows matched: $($Details -join '; ')." } if ($Candidates.Count -eq 1) { - return [System.Windows.Automation.AutomationElement]::FromHandle( - $Candidates[0].Handle - ) + return $Candidates[0].Handle } Start-Sleep -Milliseconds 200 } while ([DateTime]::UtcNow -lt $Deadline) @@ -222,44 +200,25 @@ function Wait-NativeWindowClosed { } } -function Invoke-UiElement { - param([Parameter(Mandatory = $true)]$Element) - $Pattern = $Element.GetCurrentPattern( - [System.Windows.Automation.InvokePattern]::Pattern - ) - $Pattern.Invoke() -} - -function Set-UiCheckbox { - param( - [Parameter(Mandatory = $true)]$Checkbox, - [Parameter(Mandatory = $true)][bool]$Checked - ) - $Pattern = $Checkbox.GetCurrentPattern( - [System.Windows.Automation.TogglePattern]::Pattern +function Get-NativeChildDiagnostics { + param([Parameter(Mandatory = $true)][IntPtr]$WindowHandle) + $Items = @( + [CaptionNestNativeMethods]::EnumerateChildWindows($WindowHandle) | + Select-Object -First 20 | + ForEach-Object { + "text='$($_.Title)' class='$($_.ClassName)' id=$($_.ControlId) " + + "style=0x$('{0:X}' -f $_.Style) visible=$($_.Visible)" + } ) - $IsChecked = $Pattern.Current.ToggleState -eq ` - [System.Windows.Automation.ToggleState]::On - if ($IsChecked -ne $Checked) { $Pattern.Toggle() } -} - -function Get-ButtonCondition { - param([string]$AutomationId) - $TypeCondition = [System.Windows.Automation.PropertyCondition]::new( - [System.Windows.Automation.AutomationElement]::ControlTypeProperty, - [System.Windows.Automation.ControlType]::Button - ) - $IdCondition = [System.Windows.Automation.PropertyCondition]::new( - [System.Windows.Automation.AutomationElement]::AutomationIdProperty, - $AutomationId - ) - return [System.Windows.Automation.AndCondition]::new($TypeCondition, $IdCondition) + if ($Items.Count -eq 0) { return '' } + return $Items -join '; ' } function Get-NativeControlsByType { param( [Parameter(Mandatory = $true)][IntPtr]$WindowHandle, [Parameter(Mandatory = $true)][int[]]$ButtonTypes, + [Parameter(Mandatory = $true)][string]$Description, [int]$TimeoutSeconds = 30 ) $Deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) @@ -274,17 +233,23 @@ function Get-NativeControlsByType { if ($Controls.Count -gt 0) { return $Controls } Start-Sleep -Milliseconds 200 } while ([DateTime]::UtcNow -lt $Deadline) - throw "Timed out waiting for native NSIS controls of type $($ButtonTypes -join ',')." + $Diagnostics = Get-NativeChildDiagnostics -WindowHandle $WindowHandle + throw ( + "Timed out waiting for $Description (button types $($ButtonTypes -join ',')). " + + "Native child controls: $Diagnostics" + ) } function Invoke-NativeButton { param( [Parameter(Mandatory = $true)][IntPtr]$WindowHandle, - [Parameter(Mandatory = $true)][int]$ControlId + [Parameter(Mandatory = $true)][int]$ControlId, + [Parameter(Mandatory = $true)][string]$Description ) $Button = [CaptionNestNativeMethods]::GetDlgItem($WindowHandle, $ControlId) if ($Button -eq [IntPtr]::Zero) { - throw "NSIS button $ControlId was not found." + $Diagnostics = Get-NativeChildDiagnostics -WindowHandle $WindowHandle + throw "$Description button $ControlId was not found. Native child controls: $Diagnostics" } [void][CaptionNestNativeMethods]::SendMessage( $Button, 0x00F5, [IntPtr]::Zero, [IntPtr]::Zero @@ -294,21 +259,30 @@ function Invoke-NativeButton { function Set-NativeCheckbox { param( [Parameter(Mandatory = $true)]$Control, - [Parameter(Mandatory = $true)][bool]$Checked + [Parameter(Mandatory = $true)][bool]$Checked, + [Parameter(Mandatory = $true)][string]$Description ) $State = if ($Checked) { 1 } else { 0 } [void][CaptionNestNativeMethods]::SendMessage( $Control.Handle, 0x00F1, [IntPtr]$State, [IntPtr]::Zero ) + $Actual = [CaptionNestNativeMethods]::SendMessage( + $Control.Handle, 0x00F0, [IntPtr]::Zero, [IntPtr]::Zero + ).ToInt64() + if ($Actual -ne $State) { + throw "$Description checkbox state was $Actual after requesting $State." + } } function Complete-GuiUpgradeWithDefault { $Process = Start-OwnedProcess -FilePath $UpgradeInstaller - $Window = Get-ProcessWindow -Process $Process - $WindowHandle = [IntPtr]$Window.Current.NativeWindowHandle - Invoke-NativeButton -WindowHandle $WindowHandle -ControlId 1 + $WindowHandle = Get-ProcessWindow -Process $Process + Invoke-NativeButton -WindowHandle $WindowHandle -ControlId 1 -Description 'upgrade welcome next' - $Radios = @(Get-NativeControlsByType -WindowHandle $WindowHandle -ButtonTypes @(4, 9)) + $Radios = @(Get-NativeControlsByType ` + -WindowHandle $WindowHandle ` + -ButtonTypes @(4, 9) ` + -Description 'upgrade reinstall radio choices') if ($Radios.Count -ne 2) { throw 'The reinstall page did not expose two choices.' } $Selections = @($Radios | ForEach-Object { [CaptionNestNativeMethods]::SendMessage( @@ -318,7 +292,7 @@ function Complete-GuiUpgradeWithDefault { if ($Selections[0] -or -not $Selections[1]) { throw "GUI upgrade default was not in-place: $($Selections -join ',')." } - Invoke-NativeButton -WindowHandle $WindowHandle -ControlId 1 + Invoke-NativeButton -WindowHandle $WindowHandle -ControlId 1 -Description 'upgrade choice next' $Deadline = [DateTime]::UtcNow.AddSeconds(180) while ( @@ -332,14 +306,20 @@ function Complete-GuiUpgradeWithDefault { [int]($_.Style -band 0xF) -in @(2, 3, 5, 6) } )) { - Set-NativeCheckbox -Control $Checkbox -Checked $false + Set-NativeCheckbox ` + -Control $Checkbox ` + -Checked $false ` + -Description 'upgrade finish option' } $Next = [CaptionNestNativeMethods]::GetDlgItem($WindowHandle, 1) if ( $Next -ne [IntPtr]::Zero -and [CaptionNestNativeMethods]::IsWindowEnabled($Next) ) { - Invoke-NativeButton -WindowHandle $WindowHandle -ControlId 1 + Invoke-NativeButton ` + -WindowHandle $WindowHandle ` + -ControlId 1 ` + -Description 'upgrade page next' } Start-Sleep -Milliseconds 500 } @@ -353,25 +333,36 @@ function Invoke-CurrentUninstallerGui { ) $Uninstaller = Join-Path $InstallRoot 'uninstall.exe' $Process = Start-OwnedProcess -FilePath $Uninstaller - $Window = Get-ProcessWindow -Process $Process - $WindowHandle = [IntPtr]$Window.Current.NativeWindowHandle + $WindowHandle = Get-ProcessWindow -Process $Process $Checkboxes = @( - Get-NativeControlsByType -WindowHandle $WindowHandle -ButtonTypes @(2, 3, 5, 6) + Get-NativeControlsByType ` + -WindowHandle $WindowHandle ` + -ButtonTypes @(2, 3, 5, 6) ` + -Description 'current uninstall data checkbox' ) if ($Checkboxes.Count -ne 1) { throw "Expected one uninstall data checkbox; found $($Checkboxes.Count)." } $Checkbox = $Checkboxes[0] if ($Decision -eq 'cancel') { - Invoke-NativeButton -WindowHandle $WindowHandle -ControlId 2 + Invoke-NativeButton ` + -WindowHandle $WindowHandle ` + -ControlId 2 ` + -Description 'current uninstall cancel' Wait-NativeWindowClosed -WindowHandle $WindowHandle if (-not $Process.HasExited) { Wait-ProcessExit -Process $Process -AllowedExitCodes @(0, 1) } return } - Set-NativeCheckbox -Control $Checkbox -Checked ($Decision -eq 'delete') - Invoke-NativeButton -WindowHandle $WindowHandle -ControlId 1 + Set-NativeCheckbox ` + -Control $Checkbox ` + -Checked ($Decision -eq 'delete') ` + -Description "current uninstall $Decision" + Invoke-NativeButton ` + -WindowHandle $WindowHandle ` + -ControlId 1 ` + -Description "current uninstall $Decision confirm" $Deadline = [DateTime]::UtcNow.AddSeconds(180) while ( [CaptionNestNativeMethods]::IsWindow($WindowHandle) -and @@ -382,7 +373,10 @@ function Invoke-CurrentUninstallerGui { $Next -ne [IntPtr]::Zero -and [CaptionNestNativeMethods]::IsWindowEnabled($Next) ) { - Invoke-NativeButton -WindowHandle $WindowHandle -ControlId 1 + Invoke-NativeButton ` + -WindowHandle $WindowHandle ` + -ControlId 1 ` + -Description "current uninstall $Decision finish" } Start-Sleep -Milliseconds 500 } @@ -393,16 +387,24 @@ function Invoke-CurrentUninstallerGui { function Invoke-AffectedUninstallerGuiConfirm { $Uninstaller = Join-Path $InstallRoot 'uninstall.exe' $Process = Start-OwnedProcess -FilePath $Uninstaller - $Window = Get-ProcessWindow -Process $Process - $MainWindowHandle = [IntPtr]$Window.Current.NativeWindowHandle + $MainWindowHandle = Get-ProcessWindow -Process $Process $Checkboxes = @( - Get-NativeControlsByType -WindowHandle $MainWindowHandle -ButtonTypes @(2, 3, 5, 6) + Get-NativeControlsByType ` + -WindowHandle $MainWindowHandle ` + -ButtonTypes @(2, 3, 5, 6) ` + -Description 'affected uninstall data checkbox' ) if ($Checkboxes.Count -ne 1) { throw "Expected one affected-uninstall data checkbox; found $($Checkboxes.Count)." } - Set-NativeCheckbox -Control $Checkboxes[0] -Checked $true - Invoke-NativeButton -WindowHandle $MainWindowHandle -ControlId 1 + Set-NativeCheckbox ` + -Control $Checkboxes[0] ` + -Checked $true ` + -Description 'affected uninstall explicit deletion' + Invoke-NativeButton ` + -WindowHandle $MainWindowHandle ` + -ControlId 1 ` + -Description 'affected uninstall confirm' $Confirmed = $false $Deadline = [DateTime]::UtcNow.AddSeconds(30) @@ -563,7 +565,6 @@ function Test-UpgradeMode { Remove-OwnedCaptionNestState } -Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes Add-Type -TypeDefinition @' using System; using System.Collections.Generic; diff --git a/tooling/tests/test_release_workflow.py b/tooling/tests/test_release_workflow.py index 6acaa47..3b901c7 100644 --- a/tooling/tests/test_release_workflow.py +++ b/tooling/tests/test_release_workflow.py @@ -227,7 +227,7 @@ def test_windows_ci_exercises_affected_and_exact_head_installer_lifecycle() -> N assert "RUNNER_ENVIRONMENT -ne 'github-hosted'" in script assert "Affected installer SHA-256 mismatch" in script assert "function Invoke-AffectedUninstallerGuiConfirm" in script - assert "Set-NativeCheckbox -Control $Checkboxes[0] -Checked $true" in script + assert "affected uninstall explicit deletion" in script assert "GetLastActivePopup" in script assert "EnumerateTopLevelWindows" in script assert "BaselineWindowHandles" in script @@ -240,6 +240,10 @@ def test_windows_ci_exercises_affected_and_exact_head_installer_lifecycle() -> N assert "GetWindowLongPtr" in script assert "0x00F1" in script assert "0x00F5" in script + assert "function Get-NativeChildDiagnostics" in script + assert "Native child controls:" in script + assert "checkbox state was" in script + assert "UIAutomationClient" not in script assert "function Wait-NativeWindowClosed" in script assert "Affected uninstaller did not expose its explicit deletion confirmation" in script assert "function Remove-OwnedDirectoryWithRetry" in script From 65dfcbd9695279fff044e7fa70585884209b30df Mon Sep 17 00:00:00 2001 From: BayesWang <827130441@qq.com> Date: Tue, 21 Jul 2026 22:25:13 +0800 Subject: [PATCH 11/18] test: advance native NSIS language chooser --- scripts/test-installer-model-retention.ps1 | 47 ++++++++++++++++++++-- tooling/tests/test_release_workflow.py | 6 +++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/scripts/test-installer-model-retention.ps1 b/scripts/test-installer-model-retention.ps1 index ebc9e25..af0a1e4 100644 --- a/scripts/test-installer-model-retention.ps1 +++ b/scripts/test-installer-model-retention.ps1 @@ -151,7 +151,10 @@ function Get-ProcessWindow { $LastNewWindows = @() do { $Process.Refresh() - if ($Process.MainWindowHandle -ne 0) { + if ( + $Process.MainWindowHandle -ne 0 -and + [CaptionNestNativeMethods]::IsWindow([IntPtr]$Process.MainWindowHandle) + ) { return [IntPtr]$Process.MainWindowHandle } $LastNewWindows = @( @@ -274,9 +277,45 @@ function Set-NativeCheckbox { } } +function Get-CaptionNestInteractiveWindow { + param([Parameter(Mandatory = $true)]$Process) + $WindowHandle = Get-ProcessWindow -Process $Process + $LanguageSelectors = @( + [CaptionNestNativeMethods]::EnumerateChildWindows($WindowHandle) | + Where-Object { $_.ClassName -eq 'ComboBox' -and $_.ControlId -eq 1002 } + ) + if ($LanguageSelectors.Count -gt 1) { + $Diagnostics = Get-NativeChildDiagnostics -WindowHandle $WindowHandle + throw "Multiple NSIS language selectors matched. Native child controls: $Diagnostics" + } + if ($LanguageSelectors.Count -eq 1) { + Invoke-NativeButton ` + -WindowHandle $WindowHandle ` + -ControlId 1 ` + -Description 'language selector default OK' + $Deadline = [DateTime]::UtcNow.AddSeconds(30) + do { + if (-not [CaptionNestNativeMethods]::IsWindow($WindowHandle)) { + return Get-ProcessWindow -Process $Process + } + $RemainingSelectors = @( + [CaptionNestNativeMethods]::EnumerateChildWindows($WindowHandle) | + Where-Object { + $_.ClassName -eq 'ComboBox' -and $_.ControlId -eq 1002 + } + ) + if ($RemainingSelectors.Count -eq 0) { return $WindowHandle } + Start-Sleep -Milliseconds 200 + } while ([DateTime]::UtcNow -lt $Deadline) + $Diagnostics = Get-NativeChildDiagnostics -WindowHandle $WindowHandle + throw "Language selector did not advance. Native child controls: $Diagnostics" + } + return $WindowHandle +} + function Complete-GuiUpgradeWithDefault { $Process = Start-OwnedProcess -FilePath $UpgradeInstaller - $WindowHandle = Get-ProcessWindow -Process $Process + $WindowHandle = Get-CaptionNestInteractiveWindow -Process $Process Invoke-NativeButton -WindowHandle $WindowHandle -ControlId 1 -Description 'upgrade welcome next' $Radios = @(Get-NativeControlsByType ` @@ -333,7 +372,7 @@ function Invoke-CurrentUninstallerGui { ) $Uninstaller = Join-Path $InstallRoot 'uninstall.exe' $Process = Start-OwnedProcess -FilePath $Uninstaller - $WindowHandle = Get-ProcessWindow -Process $Process + $WindowHandle = Get-CaptionNestInteractiveWindow -Process $Process $Checkboxes = @( Get-NativeControlsByType ` -WindowHandle $WindowHandle ` @@ -387,7 +426,7 @@ function Invoke-CurrentUninstallerGui { function Invoke-AffectedUninstallerGuiConfirm { $Uninstaller = Join-Path $InstallRoot 'uninstall.exe' $Process = Start-OwnedProcess -FilePath $Uninstaller - $MainWindowHandle = Get-ProcessWindow -Process $Process + $MainWindowHandle = Get-CaptionNestInteractiveWindow -Process $Process $Checkboxes = @( Get-NativeControlsByType ` -WindowHandle $MainWindowHandle ` diff --git a/tooling/tests/test_release_workflow.py b/tooling/tests/test_release_workflow.py index 3b901c7..91744a6 100644 --- a/tooling/tests/test_release_workflow.py +++ b/tooling/tests/test_release_workflow.py @@ -244,6 +244,12 @@ def test_windows_ci_exercises_affected_and_exact_head_installer_lifecycle() -> N assert "Native child controls:" in script assert "checkbox state was" in script assert "UIAutomationClient" not in script + assert "function Get-CaptionNestInteractiveWindow" in script + assert "ClassName -eq 'ComboBox'" in script + assert "ControlId -eq 1002" in script + assert "language selector default OK" in script + assert "RemainingSelectors.Count -eq 0" in script + assert "Language selector did not advance" in script assert "function Wait-NativeWindowClosed" in script assert "Affected uninstaller did not expose its explicit deletion confirmation" in script assert "function Remove-OwnedDirectoryWithRetry" in script From 967a825cfbdbf0fea52eaa585b2627bbdcb07ec4 Mon Sep 17 00:00:00 2001 From: BayesWang <827130441@qq.com> Date: Tue, 21 Jul 2026 22:42:55 +0800 Subject: [PATCH 12/18] test: select native NSIS language choice --- scripts/test-installer-model-retention.ps1 | 42 +++++++++++++++++++++- tooling/tests/test_release_workflow.py | 6 ++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/scripts/test-installer-model-retention.ps1 b/scripts/test-installer-model-retention.ps1 index af0a1e4..01055a3 100644 --- a/scripts/test-installer-model-retention.ps1 +++ b/scripts/test-installer-model-retention.ps1 @@ -280,8 +280,23 @@ function Set-NativeCheckbox { function Get-CaptionNestInteractiveWindow { param([Parameter(Mandatory = $true)]$Process) $WindowHandle = Get-ProcessWindow -Process $Process + $Deadline = [DateTime]::UtcNow.AddSeconds(30) + do { + $InitialControls = @( + [CaptionNestNativeMethods]::EnumerateChildWindows($WindowHandle) + ) + $PrimaryButton = [CaptionNestNativeMethods]::GetDlgItem($WindowHandle, 1) + if ($InitialControls.Count -gt 0 -and $PrimaryButton -ne [IntPtr]::Zero) { + break + } + Start-Sleep -Milliseconds 200 + } while ([DateTime]::UtcNow -lt $Deadline) + if ($InitialControls.Count -eq 0 -or $PrimaryButton -eq [IntPtr]::Zero) { + $Diagnostics = Get-NativeChildDiagnostics -WindowHandle $WindowHandle + throw "CaptionNest GUI page did not become ready. Native child controls: $Diagnostics" + } $LanguageSelectors = @( - [CaptionNestNativeMethods]::EnumerateChildWindows($WindowHandle) | + $InitialControls | Where-Object { $_.ClassName -eq 'ComboBox' -and $_.ControlId -eq 1002 } ) if ($LanguageSelectors.Count -gt 1) { @@ -289,6 +304,31 @@ function Get-CaptionNestInteractiveWindow { throw "Multiple NSIS language selectors matched. Native child controls: $Diagnostics" } if ($LanguageSelectors.Count -eq 1) { + $LanguageSelector = $LanguageSelectors[0] + $LanguageCount = [CaptionNestNativeMethods]::SendMessage( + $LanguageSelector.Handle, 0x0146, [IntPtr]::Zero, [IntPtr]::Zero + ).ToInt64() + if ($LanguageCount -le 0) { + $Diagnostics = Get-NativeChildDiagnostics -WindowHandle $WindowHandle + throw "NSIS language selector contained $LanguageCount choices. Native child controls: $Diagnostics" + } + + $SelectedLanguage = [CaptionNestNativeMethods]::SendMessage( + $LanguageSelector.Handle, 0x0147, [IntPtr]::Zero, [IntPtr]::Zero + ).ToInt64() + if ($SelectedLanguage -eq -1) { + [void][CaptionNestNativeMethods]::SendMessage( + $LanguageSelector.Handle, 0x014E, [IntPtr]::Zero, [IntPtr]::Zero + ) + $SelectedLanguage = [CaptionNestNativeMethods]::SendMessage( + $LanguageSelector.Handle, 0x0147, [IntPtr]::Zero, [IntPtr]::Zero + ).ToInt64() + } + if ($SelectedLanguage -lt 0 -or $SelectedLanguage -ge $LanguageCount) { + $Diagnostics = Get-NativeChildDiagnostics -WindowHandle $WindowHandle + throw "NSIS language selector did not retain a valid selection (selected=$SelectedLanguage, count=$LanguageCount). Native child controls: $Diagnostics" + } + Invoke-NativeButton ` -WindowHandle $WindowHandle ` -ControlId 1 ` diff --git a/tooling/tests/test_release_workflow.py b/tooling/tests/test_release_workflow.py index 91744a6..795fa83 100644 --- a/tooling/tests/test_release_workflow.py +++ b/tooling/tests/test_release_workflow.py @@ -248,6 +248,12 @@ def test_windows_ci_exercises_affected_and_exact_head_installer_lifecycle() -> N assert "ClassName -eq 'ComboBox'" in script assert "ControlId -eq 1002" in script assert "language selector default OK" in script + assert "InitialControls.Count -gt 0" in script + assert "0x0146" in script + assert "0x0147" in script + assert "0x014E" in script + assert "NSIS language selector did not retain a valid selection" in script + assert "CaptionNest GUI page did not become ready" in script assert "RemainingSelectors.Count -eq 0" in script assert "Language selector did not advance" in script assert "function Wait-NativeWindowClosed" in script From 0efe0ccd43674294a729083f74af1c0072c0db7c Mon Sep 17 00:00:00 2001 From: BayesWang <827130441@qq.com> Date: Tue, 21 Jul 2026 22:59:31 +0800 Subject: [PATCH 13/18] test: finish affected NSIS uninstall flow --- scripts/test-installer-model-retention.ps1 | 21 +++++++++++++++++++-- tooling/tests/test_release_workflow.py | 1 + 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/scripts/test-installer-model-retention.ps1 b/scripts/test-installer-model-retention.ps1 index 01055a3..ee72fc9 100644 --- a/scripts/test-installer-model-retention.ps1 +++ b/scripts/test-installer-model-retention.ps1 @@ -507,8 +507,25 @@ function Invoke-AffectedUninstallerGuiConfirm { if (-not $Confirmed) { throw 'Affected uninstaller did not expose its explicit deletion confirmation.' } - Wait-NativeWindowClosed -WindowHandle $MainWindowHandle - if (-not $Process.HasExited) { Wait-ProcessExit -Process $Process } + $Deadline = [DateTime]::UtcNow.AddSeconds(180) + while ( + [CaptionNestNativeMethods]::IsWindow($MainWindowHandle) -and + [DateTime]::UtcNow -lt $Deadline + ) { + $Finish = [CaptionNestNativeMethods]::GetDlgItem($MainWindowHandle, 1) + if ( + $Finish -ne [IntPtr]::Zero -and + [CaptionNestNativeMethods]::IsWindowEnabled($Finish) + ) { + Invoke-NativeButton ` + -WindowHandle $MainWindowHandle ` + -ControlId 1 ` + -Description 'affected uninstall finish' + } + Start-Sleep -Milliseconds 500 + } + Wait-NativeWindowClosed -WindowHandle $MainWindowHandle -TimeoutSeconds 5 + if (-not $Process.HasExited) { Wait-ProcessExit -Process $Process -TimeoutSeconds 5 } Assert-ModelAbsent } diff --git a/tooling/tests/test_release_workflow.py b/tooling/tests/test_release_workflow.py index 795fa83..8451b69 100644 --- a/tooling/tests/test_release_workflow.py +++ b/tooling/tests/test_release_workflow.py @@ -258,6 +258,7 @@ def test_windows_ci_exercises_affected_and_exact_head_installer_lifecycle() -> N assert "Language selector did not advance" in script assert "function Wait-NativeWindowClosed" in script assert "Affected uninstaller did not expose its explicit deletion confirmation" in script + assert "affected uninstall finish" in script assert "function Remove-OwnedDirectoryWithRetry" in script assert "[int]$TimeoutSeconds = 30" in script assert "Owned CaptionNest path still exists" in script From 7535d98cf01012652346bb96927a1d081fcf6238 Mon Sep 17 00:00:00 2001 From: BayesWang <827130441@qq.com> Date: Tue, 21 Jul 2026 23:01:22 +0800 Subject: [PATCH 14/18] test: gate affected uninstall finish click --- scripts/test-installer-model-retention.ps1 | 54 +++++++++++++++++++--- tooling/tests/test_release_workflow.py | 4 ++ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/scripts/test-installer-model-retention.ps1 b/scripts/test-installer-model-retention.ps1 index ee72fc9..bc06c37 100644 --- a/scripts/test-installer-model-retention.ps1 +++ b/scripts/test-installer-model-retention.ps1 @@ -480,6 +480,16 @@ function Invoke-AffectedUninstallerGuiConfirm { -Control $Checkboxes[0] ` -Checked $true ` -Description 'affected uninstall explicit deletion' + $ConfirmButtonHandle = [CaptionNestNativeMethods]::GetDlgItem($MainWindowHandle, 1) + $ConfirmButtonInfo = @( + [CaptionNestNativeMethods]::EnumerateChildWindows($MainWindowHandle) | + Where-Object { $_.Handle -eq $ConfirmButtonHandle } + ) + if ($ConfirmButtonHandle -eq [IntPtr]::Zero -or $ConfirmButtonInfo.Count -ne 1) { + $Diagnostics = Get-NativeChildDiagnostics -WindowHandle $MainWindowHandle + throw "Affected uninstaller confirm button was not uniquely identifiable. Native child controls: $Diagnostics" + } + $ConfirmButtonTitle = $ConfirmButtonInfo[0].Title Invoke-NativeButton ` -WindowHandle $MainWindowHandle ` -ControlId 1 ` @@ -507,24 +517,56 @@ function Invoke-AffectedUninstallerGuiConfirm { if (-not $Confirmed) { throw 'Affected uninstaller did not expose its explicit deletion confirmation.' } + $SawNonReadyFinish = $false + $ClickedFinish = $false $Deadline = [DateTime]::UtcNow.AddSeconds(180) while ( [CaptionNestNativeMethods]::IsWindow($MainWindowHandle) -and [DateTime]::UtcNow -lt $Deadline ) { $Finish = [CaptionNestNativeMethods]::GetDlgItem($MainWindowHandle, 1) - if ( - $Finish -ne [IntPtr]::Zero -and - [CaptionNestNativeMethods]::IsWindowEnabled($Finish) - ) { + if ($Finish -eq [IntPtr]::Zero -or -not [CaptionNestNativeMethods]::IsWindowEnabled($Finish)) { + $SawNonReadyFinish = $true + Start-Sleep -Milliseconds 200 + continue + } + $FinishInfo = @( + [CaptionNestNativeMethods]::EnumerateChildWindows($MainWindowHandle) | + Where-Object { $_.Handle -eq $Finish } + ) + $CompletionTextChanged = ( + $FinishInfo.Count -eq 1 -and + $FinishInfo[0].Title -ne $ConfirmButtonTitle + ) + $CompletionControlChanged = $Finish -ne $ConfirmButtonHandle + if ($SawNonReadyFinish -or $CompletionTextChanged -or $CompletionControlChanged) { Invoke-NativeButton ` -WindowHandle $MainWindowHandle ` -ControlId 1 ` -Description 'affected uninstall finish' + $ClickedFinish = $true + break } - Start-Sleep -Milliseconds 500 + Start-Sleep -Milliseconds 200 + } + if ( + [CaptionNestNativeMethods]::IsWindow($MainWindowHandle) -and + -not $ClickedFinish + ) { + $Diagnostics = Get-NativeChildDiagnostics -WindowHandle $MainWindowHandle + throw "Affected uninstaller did not reach a distinct enabled completion state. Native child controls: $Diagnostics" + } + $Deadline = [DateTime]::UtcNow.AddSeconds(5) + while ( + [CaptionNestNativeMethods]::IsWindow($MainWindowHandle) -and + [DateTime]::UtcNow -lt $Deadline + ) { + Start-Sleep -Milliseconds 200 + } + if ([CaptionNestNativeMethods]::IsWindow($MainWindowHandle)) { + $Diagnostics = Get-NativeChildDiagnostics -WindowHandle $MainWindowHandle + throw "Affected uninstaller remained open after its completion button was clicked. Native child controls: $Diagnostics" } - Wait-NativeWindowClosed -WindowHandle $MainWindowHandle -TimeoutSeconds 5 if (-not $Process.HasExited) { Wait-ProcessExit -Process $Process -TimeoutSeconds 5 } Assert-ModelAbsent } diff --git a/tooling/tests/test_release_workflow.py b/tooling/tests/test_release_workflow.py index 8451b69..ba661ef 100644 --- a/tooling/tests/test_release_workflow.py +++ b/tooling/tests/test_release_workflow.py @@ -259,6 +259,10 @@ def test_windows_ci_exercises_affected_and_exact_head_installer_lifecycle() -> N assert "function Wait-NativeWindowClosed" in script assert "Affected uninstaller did not expose its explicit deletion confirmation" in script assert "affected uninstall finish" in script + assert "SawNonReadyFinish" in script + assert "CompletionTextChanged" in script + assert "did not reach a distinct enabled completion state" in script + assert "remained open after its completion button was clicked" in script assert "function Remove-OwnedDirectoryWithRetry" in script assert "[int]$TimeoutSeconds = 30" in script assert "Owned CaptionNest path still exists" in script From 109c7e43c002215a6138838b14bed4b460e2c915 Mon Sep 17 00:00:00 2001 From: BayesWang <827130441@qq.com> Date: Tue, 21 Jul 2026 23:51:38 +0800 Subject: [PATCH 15/18] test: bound native NSIS UI actions --- scripts/test-installer-model-retention.ps1 | 211 +++++++++++++++++---- tooling/tests/test_release_workflow.py | 9 + 2 files changed, 180 insertions(+), 40 deletions(-) diff --git a/scripts/test-installer-model-retention.ps1 b/scripts/test-installer-model-retention.ps1 index bc06c37..9e51183 100644 --- a/scripts/test-installer-model-retention.ps1 +++ b/scripts/test-installer-model-retention.ps1 @@ -210,13 +210,52 @@ function Get-NativeChildDiagnostics { Select-Object -First 20 | ForEach-Object { "text='$($_.Title)' class='$($_.ClassName)' id=$($_.ControlId) " + - "style=0x$('{0:X}' -f $_.Style) visible=$($_.Visible)" + "style=0x$('{0:X}' -f $_.Style) visible=$($_.Visible) enabled=$($_.Enabled)" } ) if ($Items.Count -eq 0) { return '' } return $Items -join '; ' } +function Get-NativeWindowSignature { + param([Parameter(Mandatory = $true)][IntPtr]$WindowHandle) + return ( + @([CaptionNestNativeMethods]::EnumerateChildWindows($WindowHandle)) | + Sort-Object { $_.Handle.ToInt64() } | + ForEach-Object { + "$($_.Handle.ToInt64())|$($_.ClassName)|$($_.ControlId)|$($_.Title)|" + + "$($_.Style)|$($_.Visible)|$($_.Enabled)" + } + ) -join ';' +} + +function Invoke-NativeControlMessage { + param( + [Parameter(Mandatory = $true)][IntPtr]$ControlHandle, + [Parameter(Mandatory = $true)][uint32]$Message, + [IntPtr]$WordParameter = [IntPtr]::Zero, + [IntPtr]$LongParameter = [IntPtr]::Zero, + [Parameter(Mandatory = $true)][IntPtr]$WindowHandle, + [Parameter(Mandatory = $true)][string]$Description + ) + $Result = [IntPtr]::Zero + $Succeeded = [CaptionNestNativeMethods]::SendMessageTimeout( + $ControlHandle, + $Message, + $WordParameter, + $LongParameter, + 0x0002, + 2000, + [ref]$Result + ) + if ($Succeeded -eq [IntPtr]::Zero) { + $NativeError = [Runtime.InteropServices.Marshal]::GetLastWin32Error() + $Diagnostics = Get-NativeChildDiagnostics -WindowHandle $WindowHandle + throw "$Description native message 0x$('{0:X}' -f $Message) failed or timed out (Win32=$NativeError). Native child controls: $Diagnostics" + } + return $Result +} + function Get-NativeControlsByType { param( [Parameter(Mandatory = $true)][IntPtr]$WindowHandle, @@ -254,24 +293,36 @@ function Invoke-NativeButton { $Diagnostics = Get-NativeChildDiagnostics -WindowHandle $WindowHandle throw "$Description button $ControlId was not found. Native child controls: $Diagnostics" } - [void][CaptionNestNativeMethods]::SendMessage( + Write-Host "GUI-ACTION: $Description dispatch" + if (-not [CaptionNestNativeMethods]::PostMessage( $Button, 0x00F5, [IntPtr]::Zero, [IntPtr]::Zero - ) + )) { + $NativeError = [Runtime.InteropServices.Marshal]::GetLastWin32Error() + $Diagnostics = Get-NativeChildDiagnostics -WindowHandle $WindowHandle + throw "$Description button dispatch failed (Win32=$NativeError). Native child controls: $Diagnostics" + } + Write-Host "GUI-ACTION: $Description dispatched" } function Set-NativeCheckbox { param( [Parameter(Mandatory = $true)]$Control, + [Parameter(Mandatory = $true)][IntPtr]$WindowHandle, [Parameter(Mandatory = $true)][bool]$Checked, [Parameter(Mandatory = $true)][string]$Description ) $State = if ($Checked) { 1 } else { 0 } - [void][CaptionNestNativeMethods]::SendMessage( - $Control.Handle, 0x00F1, [IntPtr]$State, [IntPtr]::Zero - ) - $Actual = [CaptionNestNativeMethods]::SendMessage( - $Control.Handle, 0x00F0, [IntPtr]::Zero, [IntPtr]::Zero - ).ToInt64() + [void](Invoke-NativeControlMessage ` + -ControlHandle $Control.Handle ` + -Message 0x00F1 ` + -WordParameter ([IntPtr]$State) ` + -WindowHandle $WindowHandle ` + -Description "$Description checkbox set") + $Actual = (Invoke-NativeControlMessage ` + -ControlHandle $Control.Handle ` + -Message 0x00F0 ` + -WindowHandle $WindowHandle ` + -Description "$Description checkbox readback").ToInt64() if ($Actual -ne $State) { throw "$Description checkbox state was $Actual after requesting $State." } @@ -305,24 +356,32 @@ function Get-CaptionNestInteractiveWindow { } if ($LanguageSelectors.Count -eq 1) { $LanguageSelector = $LanguageSelectors[0] - $LanguageCount = [CaptionNestNativeMethods]::SendMessage( - $LanguageSelector.Handle, 0x0146, [IntPtr]::Zero, [IntPtr]::Zero - ).ToInt64() + $LanguageCount = (Invoke-NativeControlMessage ` + -ControlHandle $LanguageSelector.Handle ` + -Message 0x0146 ` + -WindowHandle $WindowHandle ` + -Description 'language selector choice count').ToInt64() if ($LanguageCount -le 0) { $Diagnostics = Get-NativeChildDiagnostics -WindowHandle $WindowHandle throw "NSIS language selector contained $LanguageCount choices. Native child controls: $Diagnostics" } - $SelectedLanguage = [CaptionNestNativeMethods]::SendMessage( - $LanguageSelector.Handle, 0x0147, [IntPtr]::Zero, [IntPtr]::Zero - ).ToInt64() + $SelectedLanguage = (Invoke-NativeControlMessage ` + -ControlHandle $LanguageSelector.Handle ` + -Message 0x0147 ` + -WindowHandle $WindowHandle ` + -Description 'language selector current choice').ToInt64() if ($SelectedLanguage -eq -1) { - [void][CaptionNestNativeMethods]::SendMessage( - $LanguageSelector.Handle, 0x014E, [IntPtr]::Zero, [IntPtr]::Zero - ) - $SelectedLanguage = [CaptionNestNativeMethods]::SendMessage( - $LanguageSelector.Handle, 0x0147, [IntPtr]::Zero, [IntPtr]::Zero - ).ToInt64() + [void](Invoke-NativeControlMessage ` + -ControlHandle $LanguageSelector.Handle ` + -Message 0x014E ` + -WindowHandle $WindowHandle ` + -Description 'language selector choose index zero') + $SelectedLanguage = (Invoke-NativeControlMessage ` + -ControlHandle $LanguageSelector.Handle ` + -Message 0x0147 ` + -WindowHandle $WindowHandle ` + -Description 'language selector choice readback').ToInt64() } if ($SelectedLanguage -lt 0 -or $SelectedLanguage -ge $LanguageCount) { $Diagnostics = Get-NativeChildDiagnostics -WindowHandle $WindowHandle @@ -344,7 +403,10 @@ function Get-CaptionNestInteractiveWindow { $_.ClassName -eq 'ComboBox' -and $_.ControlId -eq 1002 } ) - if ($RemainingSelectors.Count -eq 0) { return $WindowHandle } + if ($RemainingSelectors.Count -eq 0) { + Write-Host 'GUI-ACTION: language selector transition observed' + return $WindowHandle + } Start-Sleep -Milliseconds 200 } while ([DateTime]::UtcNow -lt $Deadline) $Diagnostics = Get-NativeChildDiagnostics -WindowHandle $WindowHandle @@ -362,22 +424,36 @@ function Complete-GuiUpgradeWithDefault { -WindowHandle $WindowHandle ` -ButtonTypes @(4, 9) ` -Description 'upgrade reinstall radio choices') + Write-Host 'GUI-ACTION: upgrade welcome transition observed' if ($Radios.Count -ne 2) { throw 'The reinstall page did not expose two choices.' } $Selections = @($Radios | ForEach-Object { - [CaptionNestNativeMethods]::SendMessage( - $_.Handle, 0x00F0, [IntPtr]::Zero, [IntPtr]::Zero - ).ToInt64() -eq 1 + (Invoke-NativeControlMessage ` + -ControlHandle $_.Handle ` + -Message 0x00F0 ` + -WindowHandle $WindowHandle ` + -Description 'upgrade reinstall radio readback').ToInt64() -eq 1 }) if ($Selections[0] -or -not $Selections[1]) { throw "GUI upgrade default was not in-place: $($Selections -join ',')." } + $LastClickedSignature = Get-NativeWindowSignature -WindowHandle $WindowHandle Invoke-NativeButton -WindowHandle $WindowHandle -ControlId 1 -Description 'upgrade choice next' + $ObservedChoiceTransition = $false $Deadline = [DateTime]::UtcNow.AddSeconds(180) while ( [CaptionNestNativeMethods]::IsWindow($WindowHandle) -and [DateTime]::UtcNow -lt $Deadline ) { + $CurrentSignature = Get-NativeWindowSignature -WindowHandle $WindowHandle + if ($CurrentSignature -eq $LastClickedSignature) { + Start-Sleep -Milliseconds 200 + continue + } + if (-not $ObservedChoiceTransition) { + Write-Host 'GUI-ACTION: upgrade choice transition observed' + $ObservedChoiceTransition = $true + } foreach ($Checkbox in @( [CaptionNestNativeMethods]::EnumerateChildWindows($WindowHandle) | Where-Object { @@ -387,6 +463,7 @@ function Complete-GuiUpgradeWithDefault { )) { Set-NativeCheckbox ` -Control $Checkbox ` + -WindowHandle $WindowHandle ` -Checked $false ` -Description 'upgrade finish option' } @@ -399,11 +476,17 @@ function Complete-GuiUpgradeWithDefault { -WindowHandle $WindowHandle ` -ControlId 1 ` -Description 'upgrade page next' + $LastClickedSignature = $CurrentSignature } - Start-Sleep -Milliseconds 500 + Start-Sleep -Milliseconds 200 + } + if ([CaptionNestNativeMethods]::IsWindow($WindowHandle)) { + $Diagnostics = Get-NativeChildDiagnostics -WindowHandle $WindowHandle + throw "GUI upgrade did not close within 180 seconds. Native child controls: $Diagnostics" } Wait-NativeWindowClosed -WindowHandle $WindowHandle -TimeoutSeconds 5 if (-not $Process.HasExited) { Wait-ProcessExit -Process $Process -TimeoutSeconds 5 } + Write-Host 'GUI-ACTION: GUI upgrade window closed' } function Invoke-CurrentUninstallerGui { @@ -432,35 +515,68 @@ function Invoke-CurrentUninstallerGui { if (-not $Process.HasExited) { Wait-ProcessExit -Process $Process -AllowedExitCodes @(0, 1) } + Write-Host 'GUI-ACTION: current uninstall cancel window closed' return } Set-NativeCheckbox ` -Control $Checkbox ` + -WindowHandle $WindowHandle ` -Checked ($Decision -eq 'delete') ` -Description "current uninstall $Decision" + $ConfirmButtonHandle = [CaptionNestNativeMethods]::GetDlgItem($WindowHandle, 1) + $ConfirmButtonInfo = @( + [CaptionNestNativeMethods]::EnumerateChildWindows($WindowHandle) | + Where-Object { $_.Handle -eq $ConfirmButtonHandle } + ) + if ($ConfirmButtonHandle -eq [IntPtr]::Zero -or $ConfirmButtonInfo.Count -ne 1) { + $Diagnostics = Get-NativeChildDiagnostics -WindowHandle $WindowHandle + throw "Current uninstaller confirm button was not uniquely identifiable. Native child controls: $Diagnostics" + } + $ConfirmButtonTitle = $ConfirmButtonInfo[0].Title Invoke-NativeButton ` -WindowHandle $WindowHandle ` -ControlId 1 ` -Description "current uninstall $Decision confirm" + $SawNonReadyFinish = $false + $ClickedFinish = $false $Deadline = [DateTime]::UtcNow.AddSeconds(180) while ( [CaptionNestNativeMethods]::IsWindow($WindowHandle) -and [DateTime]::UtcNow -lt $Deadline ) { - $Next = [CaptionNestNativeMethods]::GetDlgItem($WindowHandle, 1) - if ( - $Next -ne [IntPtr]::Zero -and - [CaptionNestNativeMethods]::IsWindowEnabled($Next) - ) { + $Finish = [CaptionNestNativeMethods]::GetDlgItem($WindowHandle, 1) + if ($Finish -eq [IntPtr]::Zero -or -not [CaptionNestNativeMethods]::IsWindowEnabled($Finish)) { + $SawNonReadyFinish = $true + Start-Sleep -Milliseconds 200 + continue + } + $FinishInfo = @( + [CaptionNestNativeMethods]::EnumerateChildWindows($WindowHandle) | + Where-Object { $_.Handle -eq $Finish } + ) + $CompletionTextChanged = ( + $FinishInfo.Count -eq 1 -and + $FinishInfo[0].Title -ne $ConfirmButtonTitle + ) + $CompletionControlChanged = $Finish -ne $ConfirmButtonHandle + if ($SawNonReadyFinish -or $CompletionTextChanged -or $CompletionControlChanged) { + Write-Host "GUI-ACTION: current uninstall $Decision completion state observed" Invoke-NativeButton ` -WindowHandle $WindowHandle ` -ControlId 1 ` -Description "current uninstall $Decision finish" + $ClickedFinish = $true + break } - Start-Sleep -Milliseconds 500 + Start-Sleep -Milliseconds 200 + } + if ([CaptionNestNativeMethods]::IsWindow($WindowHandle) -and -not $ClickedFinish) { + $Diagnostics = Get-NativeChildDiagnostics -WindowHandle $WindowHandle + throw "Current uninstaller $Decision did not reach a distinct enabled completion state. Native child controls: $Diagnostics" } Wait-NativeWindowClosed -WindowHandle $WindowHandle -TimeoutSeconds 5 if (-not $Process.HasExited) { Wait-ProcessExit -Process $Process -TimeoutSeconds 5 } + Write-Host "GUI-ACTION: current uninstall $Decision window closed" } function Invoke-AffectedUninstallerGuiConfirm { @@ -478,6 +594,7 @@ function Invoke-AffectedUninstallerGuiConfirm { } Set-NativeCheckbox ` -Control $Checkboxes[0] ` + -WindowHandle $MainWindowHandle ` -Checked $true ` -Description 'affected uninstall explicit deletion' $ConfirmButtonHandle = [CaptionNestNativeMethods]::GetDlgItem($MainWindowHandle, 1) @@ -502,12 +619,10 @@ function Invoke-AffectedUninstallerGuiConfirm { if ($Popup -ne [IntPtr]::Zero -and $Popup -ne $MainWindowHandle) { $OkButton = [CaptionNestNativeMethods]::GetDlgItem($Popup, 1) if ($OkButton -ne [IntPtr]::Zero) { - [void][CaptionNestNativeMethods]::SendMessage( - $OkButton, - 0x00F5, - [IntPtr]::Zero, - [IntPtr]::Zero - ) + Invoke-NativeButton ` + -WindowHandle $Popup ` + -ControlId 1 ` + -Description 'affected uninstall deletion confirmation OK' $Confirmed = $true break } @@ -540,6 +655,7 @@ function Invoke-AffectedUninstallerGuiConfirm { ) $CompletionControlChanged = $Finish -ne $ConfirmButtonHandle if ($SawNonReadyFinish -or $CompletionTextChanged -or $CompletionControlChanged) { + Write-Host 'GUI-ACTION: affected uninstall completion state observed' Invoke-NativeButton ` -WindowHandle $MainWindowHandle ` -ControlId 1 ` @@ -568,6 +684,7 @@ function Invoke-AffectedUninstallerGuiConfirm { throw "Affected uninstaller remained open after its completion button was clicked. Native child controls: $Diagnostics" } if (-not $Process.HasExited) { Wait-ProcessExit -Process $Process -TimeoutSeconds 5 } + Write-Host 'GUI-ACTION: affected uninstall window closed' Assert-ModelAbsent } @@ -716,6 +833,7 @@ public sealed class CaptionNestWindowInfo public string ClassName { get; set; } public uint ProcessId { get; set; } public bool Visible { get; set; } + public bool Enabled { get; set; } public int ControlId { get; set; } public long Style { get; set; } } @@ -766,14 +884,26 @@ public static class CaptionNestNativeMethods [DllImport("user32.dll")] public static extern IntPtr GetDlgItem(IntPtr dialog, int itemId); - [DllImport("user32.dll")] - public static extern IntPtr SendMessage( + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool PostMessage( IntPtr window, uint message, IntPtr wordParameter, IntPtr longParameter ); + [DllImport("user32.dll", SetLastError = true)] + public static extern IntPtr SendMessageTimeout( + IntPtr window, + uint message, + IntPtr wordParameter, + IntPtr longParameter, + uint flags, + uint timeoutMilliseconds, + out IntPtr result + ); + public static CaptionNestWindowInfo[] EnumerateTopLevelWindows() { var windows = new List(); @@ -811,6 +941,7 @@ public static class CaptionNestNativeMethods ClassName = className.ToString(), ProcessId = processId, Visible = IsWindowVisible(window), + Enabled = IsWindowEnabled(window), ControlId = GetDlgCtrlID(window), Style = GetWindowLongPtr(window, -16).ToInt64() }; diff --git a/tooling/tests/test_release_workflow.py b/tooling/tests/test_release_workflow.py index ba661ef..6384d7e 100644 --- a/tooling/tests/test_release_workflow.py +++ b/tooling/tests/test_release_workflow.py @@ -240,6 +240,15 @@ def test_windows_ci_exercises_affected_and_exact_head_installer_lifecycle() -> N assert "GetWindowLongPtr" in script assert "0x00F1" in script assert "0x00F5" in script + assert "PostMessage" in script + assert "SendMessageTimeout" in script + assert "0x0002" in script + assert "2000" in script + assert "[CaptionNestNativeMethods]::SendMessage(" not in script + assert "failed or timed out" in script + assert "GUI-ACTION:" in script + assert "upgrade choice transition observed" in script + assert "completion state observed" in script assert "function Get-NativeChildDiagnostics" in script assert "Native child controls:" in script assert "checkbox state was" in script From f799c03e22c9b78e33ba298d1a99c13d26822832 Mon Sep 17 00:00:00 2001 From: BayesWang <827130441@qq.com> Date: Wed, 22 Jul 2026 00:11:46 +0800 Subject: [PATCH 16/18] test: dispatch NSIS dialog commands asynchronously --- scripts/test-installer-model-retention.ps1 | 71 ++++++++++++++++++++-- tooling/tests/test_release_workflow.py | 10 ++- 2 files changed, 75 insertions(+), 6 deletions(-) diff --git a/scripts/test-installer-model-retention.ps1 b/scripts/test-installer-model-retention.ps1 index 9e51183..e46b14d 100644 --- a/scripts/test-installer-model-retention.ps1 +++ b/scripts/test-installer-model-retention.ps1 @@ -289,13 +289,35 @@ function Invoke-NativeButton { [Parameter(Mandatory = $true)][string]$Description ) $Button = [CaptionNestNativeMethods]::GetDlgItem($WindowHandle, $ControlId) - if ($Button -eq [IntPtr]::Zero) { + $MatchingButtons = @( + [CaptionNestNativeMethods]::EnumerateChildWindows($WindowHandle) | + Where-Object { + $_.ClassName -eq 'Button' -and $_.ControlId -eq $ControlId + } + ) + if ( + $Button -eq [IntPtr]::Zero -or + $MatchingButtons.Count -ne 1 -or + $MatchingButtons[0].Handle -ne $Button + ) { + $Diagnostics = Get-NativeChildDiagnostics -WindowHandle $WindowHandle + throw "$Description button $ControlId was not uniquely identifiable. Native child controls: $Diagnostics" + } + if (-not [CaptionNestNativeMethods]::IsWindowEnabled($Button)) { + $Diagnostics = Get-NativeChildDiagnostics -WindowHandle $WindowHandle + throw "$Description button $ControlId was not enabled. Native child controls: $Diagnostics" + } + $ParentDialog = [CaptionNestNativeMethods]::GetParent($Button) + if ($ParentDialog -eq [IntPtr]::Zero -or -not [CaptionNestNativeMethods]::IsWindow($ParentDialog)) { $Diagnostics = Get-NativeChildDiagnostics -WindowHandle $WindowHandle - throw "$Description button $ControlId was not found. Native child controls: $Diagnostics" + throw "$Description button $ControlId parent dialog was unavailable. Native child controls: $Diagnostics" } Write-Host "GUI-ACTION: $Description dispatch" if (-not [CaptionNestNativeMethods]::PostMessage( - $Button, 0x00F5, [IntPtr]::Zero, [IntPtr]::Zero + $ParentDialog, + 0x0111, + [IntPtr]($ControlId -band 0xFFFF), + $Button )) { $NativeError = [Runtime.InteropServices.Marshal]::GetLastWin32Error() $Diagnostics = Get-NativeChildDiagnostics -WindowHandle $WindowHandle @@ -440,13 +462,36 @@ function Complete-GuiUpgradeWithDefault { Invoke-NativeButton -WindowHandle $WindowHandle -ControlId 1 -Description 'upgrade choice next' $ObservedChoiceTransition = $false + $LastClickWasCompletion = $false + $CompletionClickAttempts = 0 + $LastClickAt = [DateTime]::UtcNow $Deadline = [DateTime]::UtcNow.AddSeconds(180) while ( [CaptionNestNativeMethods]::IsWindow($WindowHandle) -and [DateTime]::UtcNow -lt $Deadline ) { + $Controls = @([CaptionNestNativeMethods]::EnumerateChildWindows($WindowHandle)) $CurrentSignature = Get-NativeWindowSignature -WindowHandle $WindowHandle if ($CurrentSignature -eq $LastClickedSignature) { + if ( + $LastClickWasCompletion -and + $CompletionClickAttempts -lt 2 -and + [DateTime]::UtcNow -ge $LastClickAt.AddSeconds(2) + ) { + Invoke-NativeButton ` + -WindowHandle $WindowHandle ` + -ControlId 1 ` + -Description 'upgrade finish retry' + $CompletionClickAttempts += 1 + $LastClickAt = [DateTime]::UtcNow + } elseif ( + $LastClickWasCompletion -and + $CompletionClickAttempts -eq 2 -and + [DateTime]::UtcNow -ge $LastClickAt.AddSeconds(5) + ) { + $Diagnostics = Get-NativeChildDiagnostics -WindowHandle $WindowHandle + throw "GUI upgrade finish page did not transition after one re-dispatch. Native child controls: $Diagnostics" + } Start-Sleep -Milliseconds 200 continue } @@ -455,7 +500,7 @@ function Complete-GuiUpgradeWithDefault { $ObservedChoiceTransition = $true } foreach ($Checkbox in @( - [CaptionNestNativeMethods]::EnumerateChildWindows($WindowHandle) | + $Controls | Where-Object { $_.ClassName -eq 'Button' -and [int]($_.Style -band 0xF) -in @(2, 3, 5, 6) @@ -472,11 +517,24 @@ function Complete-GuiUpgradeWithDefault { $Next -ne [IntPtr]::Zero -and [CaptionNestNativeMethods]::IsWindowEnabled($Next) ) { + $IsCompletionPage = @( + $Controls | Where-Object { + $_.ControlId -eq 1201 -and $_.Visible + } + ).Count -eq 1 + $ActionDescription = if ($IsCompletionPage) { + 'upgrade finish' + } else { + 'upgrade page next' + } Invoke-NativeButton ` -WindowHandle $WindowHandle ` -ControlId 1 ` - -Description 'upgrade page next' + -Description $ActionDescription $LastClickedSignature = $CurrentSignature + $LastClickWasCompletion = $IsCompletionPage + $CompletionClickAttempts = if ($IsCompletionPage) { 1 } else { 0 } + $LastClickAt = [DateTime]::UtcNow } Start-Sleep -Milliseconds 200 } @@ -884,6 +942,9 @@ public static class CaptionNestNativeMethods [DllImport("user32.dll")] public static extern IntPtr GetDlgItem(IntPtr dialog, int itemId); + [DllImport("user32.dll")] + public static extern IntPtr GetParent(IntPtr window); + [DllImport("user32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] public static extern bool PostMessage( diff --git a/tooling/tests/test_release_workflow.py b/tooling/tests/test_release_workflow.py index 6384d7e..d3ee9df 100644 --- a/tooling/tests/test_release_workflow.py +++ b/tooling/tests/test_release_workflow.py @@ -239,8 +239,12 @@ def test_windows_ci_exercises_affected_and_exact_head_installer_lifecycle() -> N assert "GetDlgCtrlID" in script assert "GetWindowLongPtr" in script assert "0x00F1" in script - assert "0x00F5" in script + assert "0x0111" in script + assert "0x00F5" not in script assert "PostMessage" in script + assert "GetParent" in script + assert "ControlId -eq $ControlId" in script + assert "button $ControlId was not enabled" in script assert "SendMessageTimeout" in script assert "0x0002" in script assert "2000" in script @@ -248,6 +252,10 @@ def test_windows_ci_exercises_affected_and_exact_head_installer_lifecycle() -> N assert "failed or timed out" in script assert "GUI-ACTION:" in script assert "upgrade choice transition observed" in script + assert "ControlId -eq 1201" in script + assert "upgrade finish retry" in script + assert "CompletionClickAttempts -lt 2" in script + assert "did not transition after one re-dispatch" in script assert "completion state observed" in script assert "function Get-NativeChildDiagnostics" in script assert "Native child controls:" in script From 554263c4127e498983247825b4b0497063336462 Mon Sep 17 00:00:00 2001 From: BayesWang <827130441@qq.com> Date: Wed, 22 Jul 2026 00:28:09 +0800 Subject: [PATCH 17/18] test: diagnose installed sidecar readiness --- scripts/test-installer-model-retention.ps1 | 74 +++++++++++++++++++--- tooling/tests/test_release_workflow.py | 5 ++ 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/scripts/test-installer-model-retention.ps1 b/scripts/test-installer-model-retention.ps1 index e46b14d..58baf05 100644 --- a/scripts/test-installer-model-retention.ps1 +++ b/scripts/test-installer-model-retention.ps1 @@ -74,9 +74,24 @@ function Wait-ProcessExit { function Start-OwnedProcess { param( [Parameter(Mandatory = $true)][string]$FilePath, - [string[]]$ArgumentList = @() + [string[]]$ArgumentList = @(), + [string]$WorkingDirectory, + [string]$RedirectStandardOutput, + [string]$RedirectStandardError ) - $Process = Start-Process -FilePath $FilePath -ArgumentList $ArgumentList -PassThru + $Parameters = @{ + FilePath = $FilePath + ArgumentList = $ArgumentList + PassThru = $true + } + if ($WorkingDirectory) { $Parameters.WorkingDirectory = $WorkingDirectory } + if ($RedirectStandardOutput) { + $Parameters.RedirectStandardOutput = $RedirectStandardOutput + } + if ($RedirectStandardError) { + $Parameters.RedirectStandardError = $RedirectStandardError + } + $Process = Start-Process @Parameters $script:OwnedProcesses += $Process return $Process } @@ -775,7 +790,13 @@ function Assert-InstalledAppAndModelReady { Start-Sleep -Milliseconds 250 } while ([DateTime]::UtcNow -lt $Deadline) foreach ($DesktopSidecar in $DesktopSidecars) { - & taskkill.exe @('/PID', $DesktopSidecar.Id.ToString(), '/T', '/F') | Out-Null + $DesktopSidecar.Refresh() + if (-not $DesktopSidecar.HasExited) { + & taskkill.exe @('/PID', $DesktopSidecar.Id.ToString(), '/T', '/F') | Out-Null + if (-not $DesktopSidecar.WaitForExit(10000)) { + throw "Desktop sidecar $($DesktopSidecar.Id) did not exit before API validation." + } + } } $PortListener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0) @@ -786,26 +807,59 @@ function Assert-InstalledAppAndModelReady { $PreviousToken = $env:CAPTIONNEST_SESSION_TOKEN try { $env:CAPTIONNEST_SESSION_TOKEN = $Token - $ApiProcess = Start-OwnedProcess -FilePath $Sidecar -ArgumentList @( - '--host', '127.0.0.1', '--port', $Port.ToString(), '--data-dir', $AppDataRoot - ) + $ApiStdout = Join-Path $EvidenceRoot "sidecar-$Port.stdout.log" + $ApiStderr = Join-Path $EvidenceRoot "sidecar-$Port.stderr.log" + $ApiProcess = Start-OwnedProcess ` + -FilePath $Sidecar ` + -ArgumentList @( + '--host', '127.0.0.1', '--port', $Port.ToString(), '--data-dir', $AppDataRoot + ) ` + -WorkingDirectory (Split-Path -Parent $Sidecar) ` + -RedirectStandardOutput $ApiStdout ` + -RedirectStandardError $ApiStderr $Headers = @{ 'X-CaptionNest-Session' = $Token } + $Response = $null + $Small = @() + $LastApiError = '' $Deadline = [DateTime]::UtcNow.AddSeconds(30) do { + $ApiProcess.Refresh() + if ($ApiProcess.HasExited) { break } try { $Response = Invoke-RestMethod ` -Uri "http://127.0.0.1:$Port/api/models" ` -Headers $Headers ` -TimeoutSec 2 - break + $Small = @($Response.items | Where-Object { $_.id -eq 'small' }) + if ($Small.Count -eq 1 -and $Small[0].status -eq 'ready') { break } + $LastApiError = "small statuses=$(@($Small | ForEach-Object { $_.status }) -join ',')" } catch { - Start-Sleep -Milliseconds 250 + $LastApiError = $_.Exception.Message } + Start-Sleep -Milliseconds 250 } while ([DateTime]::UtcNow -lt $Deadline) - $Small = @($Response.items | Where-Object { $_.id -eq 'small' }) if ($Small.Count -ne 1 -or $Small[0].status -ne 'ready') { - throw 'Installed sidecar did not report the retained small model as ready.' + $ApiProcess.Refresh() + $ExitState = if ($ApiProcess.HasExited) { + "exited with $($ApiProcess.ExitCode)" + } else { + 'still running' + } + $StdoutTail = if (Test-Path -LiteralPath $ApiStdout) { + (Get-Content -LiteralPath $ApiStdout -Tail 20 -ErrorAction SilentlyContinue) -join ' ' + } else { '' } + $StderrTail = if (Test-Path -LiteralPath $ApiStderr) { + (Get-Content -LiteralPath $ApiStderr -Tail 20 -ErrorAction SilentlyContinue) -join ' ' + } else { '' } + $StdoutTail = $StdoutTail.Replace($Token, '') + $StderrTail = $StderrTail.Replace($Token, '') + throw ( + 'Installed sidecar did not report the retained small model as ready. ' + + "Process=$ExitState; last API state='$LastApiError'; " + + "stdout='$StdoutTail'; stderr='$StderrTail'." + ) } + Write-Host 'APP-ACTION: installed sidecar reported retained small model ready' & taskkill.exe @('/PID', $ApiProcess.Id.ToString(), '/T', '/F') | Out-Null } finally { $env:CAPTIONNEST_SESSION_TOKEN = $PreviousToken diff --git a/tooling/tests/test_release_workflow.py b/tooling/tests/test_release_workflow.py index d3ee9df..ecd63d8 100644 --- a/tooling/tests/test_release_workflow.py +++ b/tooling/tests/test_release_workflow.py @@ -293,6 +293,11 @@ def test_windows_ci_exercises_affected_and_exact_head_installer_lifecycle() -> N "current-uninstall-cancel-keep-delete", "Invoke-RestMethod", "status -ne 'ready'", + "ApiProcess.HasExited", + "last API state", + "RedirectStandardError", + "", + "installed sidecar reported retained small model ready", ): assert marker in script From 3ffb05117ffd2c18896155bbfa148ebbd2f9b891 Mon Sep 17 00:00:00 2001 From: BayesWang <827130441@qq.com> Date: Wed, 22 Jul 2026 00:46:18 +0800 Subject: [PATCH 18/18] test: require current uninstall completion evidence --- scripts/test-installer-model-retention.ps1 | 45 ++++++++++++++++++---- tooling/tests/test_release_workflow.py | 6 +++ 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/scripts/test-installer-model-retention.ps1 b/scripts/test-installer-model-retention.ps1 index 58baf05..869bc67 100644 --- a/scripts/test-installer-model-retention.ps1 +++ b/scripts/test-installer-model-retention.ps1 @@ -606,6 +606,7 @@ function Invoke-CurrentUninstallerGui { throw "Current uninstaller confirm button was not uniquely identifiable. Native child controls: $Diagnostics" } $ConfirmButtonTitle = $ConfirmButtonInfo[0].Title + $ConfirmPageSignature = Get-NativeWindowSignature -WindowHandle $WindowHandle Invoke-NativeButton ` -WindowHandle $WindowHandle ` -ControlId 1 ` @@ -623,17 +624,37 @@ function Invoke-CurrentUninstallerGui { Start-Sleep -Milliseconds 200 continue } + $Controls = @([CaptionNestNativeMethods]::EnumerateChildWindows($WindowHandle)) $FinishInfo = @( - [CaptionNestNativeMethods]::EnumerateChildWindows($WindowHandle) | + $Controls | Where-Object { $_.Handle -eq $Finish } ) - $CompletionTextChanged = ( + $CompletionButtonEvidence = ( $FinishInfo.Count -eq 1 -and - $FinishInfo[0].Title -ne $ConfirmButtonTitle + $FinishInfo[0].Title -match '(?i)finish|close' ) - $CompletionControlChanged = $Finish -ne $ConfirmButtonHandle - if ($SawNonReadyFinish -or $CompletionTextChanged -or $CompletionControlChanged) { - Write-Host "GUI-ACTION: current uninstall $Decision completion state observed" + $CompletionTexts = @( + $Controls | Where-Object { + $_.Visible -and + $_.ClassName -eq 'Static' -and + $_.Title -match '(?i)\b(completed|complete|finished|successful|successfully)\b' + } + ) + $RealPageTransition = ( + $SawNonReadyFinish -or + $Finish -ne $ConfirmButtonHandle -or + $FinishInfo[0].Title -ne $ConfirmButtonTitle -or + (Get-NativeWindowSignature -WindowHandle $WindowHandle) -ne $ConfirmPageSignature + ) + if ( + $RealPageTransition -and + $CompletionButtonEvidence -and + $CompletionTexts.Count -gt 0 + ) { + Write-Host ( + "GUI-ACTION: current uninstall $Decision completion state observed " + + "button='$($FinishInfo[0].Title)' text='$($CompletionTexts[0].Title)'" + ) Invoke-NativeButton ` -WindowHandle $WindowHandle ` -ControlId 1 ` @@ -647,7 +668,17 @@ function Invoke-CurrentUninstallerGui { $Diagnostics = Get-NativeChildDiagnostics -WindowHandle $WindowHandle throw "Current uninstaller $Decision did not reach a distinct enabled completion state. Native child controls: $Diagnostics" } - Wait-NativeWindowClosed -WindowHandle $WindowHandle -TimeoutSeconds 5 + $Deadline = [DateTime]::UtcNow.AddSeconds(5) + while ( + [CaptionNestNativeMethods]::IsWindow($WindowHandle) -and + [DateTime]::UtcNow -lt $Deadline + ) { + Start-Sleep -Milliseconds 200 + } + if ([CaptionNestNativeMethods]::IsWindow($WindowHandle)) { + $Diagnostics = Get-NativeChildDiagnostics -WindowHandle $WindowHandle + throw "Current uninstaller $Decision remained open after completion command. Native child controls: $Diagnostics" + } if (-not $Process.HasExited) { Wait-ProcessExit -Process $Process -TimeoutSeconds 5 } Write-Host "GUI-ACTION: current uninstall $Decision window closed" } diff --git a/tooling/tests/test_release_workflow.py b/tooling/tests/test_release_workflow.py index ecd63d8..d40360e 100644 --- a/tooling/tests/test_release_workflow.py +++ b/tooling/tests/test_release_workflow.py @@ -257,6 +257,12 @@ def test_windows_ci_exercises_affected_and_exact_head_installer_lifecycle() -> N assert "CompletionClickAttempts -lt 2" in script assert "did not transition after one re-dispatch" in script assert "completion state observed" in script + assert "CompletionButtonEvidence" in script + assert "ConfirmPageSignature" in script + assert "RealPageTransition" in script + assert "(?i)finish|close" in script + assert "(?i)\\b(completed|complete|finished|successful|successfully)\\b" in script + assert "remained open after completion command" in script assert "function Get-NativeChildDiagnostics" in script assert "Native child controls:" in script assert "checkbox state was" in script