fix(installer): 静默安装挂死 —— 旧版卸载器在无人值守会话里弹了没人能点的模态框

真机挂死(/S 原地升级 600 秒超时)的根因不是探测方式,而是旧版本卸载器那一步:
CheckAdmin 修好后静默 /S 从「按用户」变成「为所有用户」,于是 DoInstall 里
uninstallOldVersion 真的去跑上次装的卸载器;那个旧卸载器自己又要一次 UAC,无人值守
会话里被系统取消(1223=ERROR_CANCELLED),重试 5 轮后上游 handleUninstallResult 弹出
MessageBox(无 /SD)→ 模态框没人点 → 永久挂住。
真机取证:枚举该进程窗口得到隐藏的 #32770「Failed to uninstall old application files…: 1223」
且线程处于 Wait: UserRequest;另用探针给 GetProcessInfo / nsProcess / 令牌查询 / netsh
逐步计时(真机全部 ≤234ms),排除「cmd|tasklist 无超时挂住」这条猜测。

改法:
- 接管上游的两个卸载失败钩子(放在 !include "installUtil.nsh" 之前):静默只 DetailPrint 并
  继续覆盖安装;交互才 MB_OK|MB_ICONEXCLAMATION|MB_TOPMOST + SetErrorLevel 2 + Quit
- 静默安装整段跳过旧卸载器(旧卸载器随时可能弹出无人能点的 UAC,唯一能保证有界行为的做法);
  交互路径保留上游行为
- 保留上一轮的 nsProcess 化(不再起 cmd|tasklist|findstr)

真机实测(构建机 SSH):/S 原地升级 exit=0 / 7.2s(改前 600s 超时),目录 85 文件、卸载器重写且为 MZ;
先跑起旧版(含 scoreboard.exe 共 6 进程)再 /S → exit=0 / 9.9s、running_after=0;
交互窗口起得来不挂、原生控件可见性全 0。wine 回归全过(拖动/原生控件/翻页/退出无 StdUtils 报错/静默卸载)。
This commit is contained in:
AskaEth committed 2026-09-13 19:55:21 +08:00
1 parent 23ba8a1830
commit f09cb07a23
1 file changed
+148 -78
+148 -78
View File
@@ -174,6 +174,43 @@ Var dragging
!include "common.nsh" !include "common.nsh"
!include "multiUser.nsh" !include "multiUser.nsh"
!include "allowOnlyOneInstallerInstance.nsh" !include "allowOnlyOneInstallerInstance.nsh"
; ── 「旧版本卸载失败」的处理:替换掉上游那个会把静默安装挂死的框 ──────────────
; 上游 installUtil.nsh 的 handleUninstallResult 在旧卸载器返回非 0 时弹
; MessageBox MB_OK|MB_ICONEXCLAMATION "$(uninstallFailed): $R0"
; 这个框**没有 /SD** —— 静默安装里没人能点,进程就一直等在那句上。
; 真机实例(2026-09-13):/S 原地升级时旧卸载器要为自己再要一次 UAC 提权,无人值守的会话里
; 那个请求直接被系统取消($R0=1223 = ERROR_CANCELLED),于是走到这个框:
; 安装器进程活着、没有任何窗口、十分钟也不结束(用窗口枚举才看到那个隐藏的模态框)。
; 上游给这两个 rootKey 各留了一个替换钩子(customUnInstallCheck / customUnInstallCheckCurrentUser),
; 定义它们即可整段接管,下面就是我们的处理:
; 静默安装:只记日志、继续装 —— 我们是就地覆盖同一个目录、文件锁由 CloseRunningApp 负责,
; 旧文件会被新安装覆盖,绝不能因为「旧卸载器自己没能提权」把人挂住;
; 交互安装:仍然按上游语义中止,但提示框是 topmost 的(自绘窗口会把普通框压在下面)。
; 必须放在 !include "installUtil.nsh" **之前**:那两个钩子是在 compile 那个函数时用 !ifmacrodef 判定的。
!macro customUnInstallCheck
${If} $R0 != 0
DetailPrint `旧版本卸载器返回 $R0(多为无人值守时无法提权),安装将直接覆盖旧文件继续。`
${IfNot} ${Silent}
MessageBox MB_OK|MB_ICONEXCLAMATION|MB_TOPMOST "$(uninstallFailed): $R0"
SetErrorLevel 2
Quit
${EndIf}
${EndIf}
!macroend
!macro customUnInstallCheckCurrentUser
; 与上面同一套:HKEY_CURRENT_USER 那份旧安装(早年装过 per-user 版本时才会有)
${If} $R0 != 0
DetailPrint `旧版本(当前用户)卸载器返回 $R0,安装将直接覆盖旧文件继续。`
${IfNot} ${Silent}
MessageBox MB_OK|MB_ICONEXCLAMATION|MB_TOPMOST "$(uninstallFailed): $R0"
SetErrorLevel 2
Quit
${EndIf}
${EndIf}
!macroend
!include "installUtil.nsh" !include "installUtil.nsh"
!include "installer.nsh" !include "installer.nsh"
@@ -359,86 +396,110 @@ FunctionEnd
; 3. 它的宏体自带 Var /GLOBAL CmdPath / PowerShellPath,一个脚本只能插一次, ; 3. 它的宏体自带 Var /GLOBAL CmdPath / PowerShellPath,一个脚本只能插一次,
; 而这一处已经被 un.checkAppRunning 占掉;卸载器侧的 Call 又只能指向 un. 函数。 ; 而这一处已经被 un.checkAppRunning 占掉;卸载器侧的 Call 又只能指向 un. 函数。
; ;
; 现在这套:按**映像名**找(tasklist + findstr 锚定整行,不依赖 PowerShell / WMI / 用户名), ; ⚠️ 为什么探测/关闭一律用 nsProcess 插件,而不是 cmd+tasklist|findstr+taskkill(真机复验的教训):
; 先温和关(taskkill 不带 /F = 给主窗口发 WM_CLOSE,等同用户点右上角,Electron 能正常收尾), ; 上一版就是那么写的,在真机上把 `/S` 静默安装**挂死了 7 分钟以上不结束**(同一台机器、同一个包,
; 还在就强杀,并且带 /T 把它拉起的 scoreboard.exe 子进程一起收 —— 子进程抓着 $INSTDIR 里的文件, ; 改动前 18 秒 exit 0)。原因是那一套要起外部进程 cmd.exe,再经管道接 tasklist / findstr,
; 只关父进程照样「文件被占用」。真的关不掉才出声,提示框是 topmost 的,并且以非 0 退出, ; 而 nsExec::Exec **没有任何超时**:子进程只要不退出(非交互/服务会话里 tasklist 起不来、
; 绝不静默往下装(覆盖正被占用的文件,装出来的东西是坏的)。 ; 管道那头没人收),安装器就永远停在那一句上 —— 静默装机没有窗口,现场只能看到「进程还在、没反应」。
; wine 的 tasklist 是瞬间返回的,所以本机怎么测都不复现。
; nsProcess 是**进程内**插件(内部就是 Toolhelp 快照),不起 shell、不走管道、不等待外部进程,
; 只可能返回一个错误码,不可能挂住;上游模板自己也带着它(include/nsProcess.nsh)。
;
; 关闭顺序:① 先温和关应用本体(_CloseProcess = 给主窗口发 WM_CLOSE,等同用户点右上角,
; Electron 能自己收尾并带走它拉起的服务端)→ ② 还活着就强杀(_KillProcess)
; → ③ 再单独收一遍 scoreboard.exe 子进程(父进程被强杀时它会变成孤儿,还抓着 $INSTDIR 里的文件)
; → ④ 真关不掉:交互给 topmost 重试框、静默以非 0 退出,两种都绝不静默往下装
; (覆盖正被占用的文件,装出来的东西是坏的)。所有等待都是「固定次数 × 固定间隔」,全程有界。
; 两侧(安装/卸载)共用这份宏体:宏是在**插入处**展开的,所以 ${GetProcessInfo} 会按当时的 ; 两侧(安装/卸载)共用这份宏体:宏是在**插入处**展开的,所以 ${GetProcessInfo} 会按当时的
; BUILD_UNINSTALLER 自动选 _GetProcessInfo / un._GetProcessInfo, ; BUILD_UNINSTALLER 自动选 _GetProcessInfo / un._GetProcessInfo。
; 插进 un.CheckAppRunningImpl 的那一次自然走 un. 版本($pid 也由上游 include 声明)。 !ifndef nsProcess::FindProcess
!macro FindAppProbe OUT !include "nsProcess.nsh"
nsExec::Exec `"$SYSDIR\cmd.exe" /C tasklist /FI "IMAGENAME eq ${APP_EXECUTABLE_FILENAME}" /FO CSV /NH | "$SYSDIR\findstr.exe" /B /I /C:"\"${APP_EXECUTABLE_FILENAME}\""` !endif
Pop ${OUT}
!macroend
!macro CloseRunningApp !macro CloseRunningApp
Push $0 Push $0
Push $R1 Push $R1
Push $R2 Push $R2
${GetProcessInfo} 0 $pid $1 $2 $3 $4 ; $pid = 本进程;$3 = 本进程的映像名(下面第 ③ 条要用) ; 本进程映像名:万一安装包被改名成跟应用同名,下面「按名字」关/杀就会打到安装器自己,
!insertmacro FindAppProbe $R1 ; 所以只有确认本进程名跟应用名不同才动手(同上游那句 ${if} $3 != "${APP_EXECUTABLE_FILENAME}")。
${If} $R1 != 0 ${GetProcessInfo} 0 $pid $1 $2 $3 $4
Goto cra_done ; 旧版本没在跑,什么都不用做 ${nsProcess::FindProcess} "${APP_EXECUTABLE_FILENAME}" $R1
${If} $R1 == 0
${If} $3 != "${APP_EXECUTABLE_FILENAME}"
DetailPrint "$(appClosing)"
${nsProcess::CloseProcess} "${APP_EXECUTABLE_FILENAME}" $0
${EndIf}
StrCpy $R2 0
cra_close: ; 给它时间自己收尾(也等文件句柄释放)
Sleep 700
${nsProcess::FindProcess} "${APP_EXECUTABLE_FILENAME}" $R1
${If} $R1 != 0
Goto cra_server
${EndIf}
IntOp $R2 $R2 + 1
${If} $R2 < 5
Goto cra_close
${EndIf}
${If} $3 != "${APP_EXECUTABLE_FILENAME}"
${nsProcess::KillProcess} "${APP_EXECUTABLE_FILENAME}" $0
${EndIf}
StrCpy $R2 0
cra_kill:
Sleep 700
${nsProcess::FindProcess} "${APP_EXECUTABLE_FILENAME}" $R1
${If} $R1 != 0
Goto cra_server
${EndIf}
IntOp $R2 $R2 + 1
${If} $R2 < 8
Goto cra_kill
${EndIf}
; 关不掉。交互安装:topmost 的重试框,点「重试」重新探测、点「取消」以非 0 退出;
; 静默安装没人点框,直接非 0 退出。两种都不往下装。
cra_ask:
${nsProcess::FindProcess} "${APP_EXECUTABLE_FILENAME}" $R1
${If} $R1 != 0
Goto cra_server
${EndIf}
${IfNot} ${Silent}
MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION|MB_TOPMOST "$(appCannotBeClosed)" IDRETRY cra_ask
${EndIf}
DetailPrint "$(appCannotBeClosed)"
SetErrorLevel 0x666667
Quit
${EndIf} ${EndIf}
DetailPrint "$(appClosing)"
; ① 温和关:不带 /F 的 taskkill = 给应用主窗口发 WM_CLOSE,等同用户点右上角。 cra_server:
; Electron 收到后能自己收尾(它拉起的 scoreboard.exe 也会跟着退),这条最干净。 ${nsProcess::FindProcess} "scoreboard.exe" $R1
nsExec::Exec `"$SYSDIR\taskkill.exe" /IM "${APP_EXECUTABLE_FILENAME}" /FI "PID ne $pid"` ${If} $R1 == 0
Pop $0 DetailPrint "$(appClosing)"
StrCpy $R2 0 ${nsProcess::KillProcess} "scoreboard.exe" $0
cra_wait: ; 一步步等它真的退出:进程没了窗口句柄也还要一会儿才放手 StrCpy $R2 0
Sleep 800 cra_srv:
!insertmacro FindAppProbe $R1 Sleep 500
${If} $R1 != 0 ${nsProcess::FindProcess} "scoreboard.exe" $R1
Goto cra_done ${If} $R1 != 0
${EndIf} Goto cra_done
IntOp $R2 $R2 + 1 ${EndIf}
${If} $R2 < 6 IntOp $R2 $R2 + 1
Goto cra_wait ${If} $R2 < 12
${EndIf} Goto cra_srv
; ② 强杀:/T 把它拉起的 scoreboard.exe 子进程一起收 —— 子进程抓着 $INSTDIR 里的文件, ${EndIf}
; 只关父进程照样「文件被占用」。 cra_srv_ask:
nsExec::Exec `"$SYSDIR\taskkill.exe" /F /T /IM "${APP_EXECUTABLE_FILENAME}" /FI "PID ne $pid"` ${nsProcess::FindProcess} "scoreboard.exe" $R1
Pop $0 ${If} $R1 != 0
Sleep 800 Goto cra_done
!insertmacro FindAppProbe $R1 ${EndIf}
${If} $R1 != 0 ${IfNot} ${Silent}
Goto cra_done MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION|MB_TOPMOST "$(appCannotBeClosed)" IDRETRY cra_srv_ask
${EndIf}
DetailPrint "$(appCannotBeClosed)"
SetErrorLevel 0x666668
Quit
${EndIf} ${EndIf}
; ③ 上面两条都带 /T /FI,而少数环境(wine 的 taskkill 就是)不认这两个选项、整条命令直接报错不执行,
; 所以再补一条只带 /F /IM 的。它没有过滤条件,因此**只在本进程名跟应用名不同**时才敢用
; (否则等于让安装器把自己杀掉)——正是上游那句 ${if} $3 != "${APP_EXECUTABLE_FILENAME}" 的意思。
${If} $3 != "${APP_EXECUTABLE_FILENAME}"
nsExec::Exec `"$SYSDIR\taskkill.exe" /F /IM "${APP_EXECUTABLE_FILENAME}"`
Pop $0
${EndIf}
StrCpy $R2 0
cra_wait2:
Sleep 800
!insertmacro FindAppProbe $R1
${If} $R1 != 0
Goto cra_done
${EndIf}
IntOp $R2 $R2 + 1
${If} $R2 < 8
Goto cra_wait2
${EndIf}
; ④ 三轮都没关掉。交互安装:一个 topmost 的重试框(自绘窗口把普通对话框盖住过,见上面第 1 条),
; 点「重试」重新探测、点「取消」以非 0 退出;静默安装没人点框,直接非 0 退出。
; 两种都不往下装 —— 覆盖正被占用的文件,装出来的东西是坏的。
cra_retry:
!insertmacro FindAppProbe $R1
${If} $R1 != 0
Goto cra_done
${EndIf}
${IfNot} ${Silent}
MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION|MB_TOPMOST "$(appCannotBeClosed)" IDRETRY cra_retry
${EndIf}
DetailPrint "$(appCannotBeClosed)"
SetErrorLevel 0x666667
Quit
cra_done: cra_done:
${nsProcess::Unload}
Pop $R2 Pop $R2
Pop $R1 Pop $R1
Pop $0 Pop $0
@@ -1591,13 +1652,22 @@ Function DoInstall
${endIf} ${endIf}
${endif} ${endif}
; 覆盖安装:先跑旧卸载器(它自己会带 --updated / --keep-shortcuts) ; 覆盖安装:先跑旧卸载器(它自己会带 --updated / --keep-shortcuts)。
!insertmacro uninstallOldVersion SHELL_CONTEXT ; ⚠️ 静默安装(/S)整段跳过:旧版本装的那个卸载器会为它自己再要一次 UAC 提权
!insertmacro handleUninstallResult SHELL_CONTEXT ; (老版本的提权判定有 bug,即使当前进程已经提权它也要再弹一次),无人值守时那个请求
${if} $installMode == "all" ; 要么没人点、要么被系统直接取消(1223 = ERROR_CANCELLED)—— 真机 /S 就是这么挂住的
!insertmacro uninstallOldVersion HKEY_CURRENT_USER ; (旧卸载器重试 5 轮后 handleUninstallResult 弹一个没有 /SD 的框,安装器就永远等在那儿)。
!insertmacro handleUninstallResult HKEY_CURRENT_USER ; 我们是就地覆盖同一个安装目录,文件锁由上面的 CloseRunningApp 负责解开,
${endIf} ; 旧文件/旧快捷方式会被新安装直接覆盖,所以静默安装跳过它、继续往下装是安全的;
; 交互安装照旧(用户在场,那个提权框有人点,语义与上游完全一致)。
${IfNot} ${Silent}
!insertmacro uninstallOldVersion SHELL_CONTEXT
!insertmacro handleUninstallResult SHELL_CONTEXT
${if} $installMode == "all"
!insertmacro uninstallOldVersion HKEY_CURRENT_USER
!insertmacro handleUninstallResult HKEY_CURRENT_USER
${endIf}
${EndIf}
SetOutPath $INSTDIR SetOutPath $INSTDIR