diff --git a/console/installer/installer.nsi b/console/installer/installer.nsi index 41e9a5f..aa9fadf 100644 --- a/console/installer/installer.nsi +++ b/console/installer/installer.nsi @@ -174,6 +174,43 @@ Var dragging !include "common.nsh" !include "multiUser.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 "installer.nsh" @@ -359,86 +396,110 @@ FunctionEnd ; 3. 它的宏体自带 Var /GLOBAL CmdPath / PowerShellPath,一个脚本只能插一次, ; 而这一处已经被 un.checkAppRunning 占掉;卸载器侧的 Call 又只能指向 un. 函数。 ; -; 现在这套:按**映像名**找(tasklist + findstr 锚定整行,不依赖 PowerShell / WMI / 用户名), -; 先温和关(taskkill 不带 /F = 给主窗口发 WM_CLOSE,等同用户点右上角,Electron 能正常收尾), -; 还在就强杀,并且带 /T 把它拉起的 scoreboard.exe 子进程一起收 —— 子进程抓着 $INSTDIR 里的文件, -; 只关父进程照样「文件被占用」。真的关不掉才出声,提示框是 topmost 的,并且以非 0 退出, -; 绝不静默往下装(覆盖正被占用的文件,装出来的东西是坏的)。 +; ⚠️ 为什么探测/关闭一律用 nsProcess 插件,而不是 cmd+tasklist|findstr+taskkill(真机复验的教训): +; 上一版就是那么写的,在真机上把 `/S` 静默安装**挂死了 7 分钟以上不结束**(同一台机器、同一个包, +; 改动前 18 秒 exit 0)。原因是那一套要起外部进程 cmd.exe,再经管道接 tasklist / findstr, +; 而 nsExec::Exec **没有任何超时**:子进程只要不退出(非交互/服务会话里 tasklist 起不来、 +; 管道那头没人收),安装器就永远停在那一句上 —— 静默装机没有窗口,现场只能看到「进程还在、没反应」。 +; wine 的 tasklist 是瞬间返回的,所以本机怎么测都不复现。 +; nsProcess 是**进程内**插件(内部就是 Toolhelp 快照),不起 shell、不走管道、不等待外部进程, +; 只可能返回一个错误码,不可能挂住;上游模板自己也带着它(include/nsProcess.nsh)。 +; +; 关闭顺序:① 先温和关应用本体(_CloseProcess = 给主窗口发 WM_CLOSE,等同用户点右上角, +; Electron 能自己收尾并带走它拉起的服务端)→ ② 还活着就强杀(_KillProcess) +; → ③ 再单独收一遍 scoreboard.exe 子进程(父进程被强杀时它会变成孤儿,还抓着 $INSTDIR 里的文件) +; → ④ 真关不掉:交互给 topmost 重试框、静默以非 0 退出,两种都绝不静默往下装 +; (覆盖正被占用的文件,装出来的东西是坏的)。所有等待都是「固定次数 × 固定间隔」,全程有界。 ; 两侧(安装/卸载)共用这份宏体:宏是在**插入处**展开的,所以 ${GetProcessInfo} 会按当时的 -; BUILD_UNINSTALLER 自动选 _GetProcessInfo / un._GetProcessInfo, -; 插进 un.CheckAppRunningImpl 的那一次自然走 un. 版本($pid 也由上游 include 声明)。 -!macro FindAppProbe OUT - 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}\""` - Pop ${OUT} -!macroend +; BUILD_UNINSTALLER 自动选 _GetProcessInfo / un._GetProcessInfo。 +!ifndef nsProcess::FindProcess + !include "nsProcess.nsh" +!endif !macro CloseRunningApp Push $0 Push $R1 Push $R2 - ${GetProcessInfo} 0 $pid $1 $2 $3 $4 ; $pid = 本进程;$3 = 本进程的映像名(下面第 ③ 条要用) - !insertmacro FindAppProbe $R1 - ${If} $R1 != 0 - Goto cra_done ; 旧版本没在跑,什么都不用做 + ; 本进程映像名:万一安装包被改名成跟应用同名,下面「按名字」关/杀就会打到安装器自己, + ; 所以只有确认本进程名跟应用名不同才动手(同上游那句 ${if} $3 != "${APP_EXECUTABLE_FILENAME}")。 + ${GetProcessInfo} 0 $pid $1 $2 $3 $4 + ${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} - DetailPrint "$(appClosing)" - ; ① 温和关:不带 /F 的 taskkill = 给应用主窗口发 WM_CLOSE,等同用户点右上角。 - ; Electron 收到后能自己收尾(它拉起的 scoreboard.exe 也会跟着退),这条最干净。 - nsExec::Exec `"$SYSDIR\taskkill.exe" /IM "${APP_EXECUTABLE_FILENAME}" /FI "PID ne $pid"` - Pop $0 - StrCpy $R2 0 - cra_wait: ; 一步步等它真的退出:进程没了窗口句柄也还要一会儿才放手 - Sleep 800 - !insertmacro FindAppProbe $R1 - ${If} $R1 != 0 - Goto cra_done - ${EndIf} - IntOp $R2 $R2 + 1 - ${If} $R2 < 6 - Goto cra_wait - ${EndIf} - ; ② 强杀:/T 把它拉起的 scoreboard.exe 子进程一起收 —— 子进程抓着 $INSTDIR 里的文件, - ; 只关父进程照样「文件被占用」。 - nsExec::Exec `"$SYSDIR\taskkill.exe" /F /T /IM "${APP_EXECUTABLE_FILENAME}" /FI "PID ne $pid"` - Pop $0 - Sleep 800 - !insertmacro FindAppProbe $R1 - ${If} $R1 != 0 - Goto cra_done + + cra_server: + ${nsProcess::FindProcess} "scoreboard.exe" $R1 + ${If} $R1 == 0 + DetailPrint "$(appClosing)" + ${nsProcess::KillProcess} "scoreboard.exe" $0 + StrCpy $R2 0 + cra_srv: + Sleep 500 + ${nsProcess::FindProcess} "scoreboard.exe" $R1 + ${If} $R1 != 0 + Goto cra_done + ${EndIf} + IntOp $R2 $R2 + 1 + ${If} $R2 < 12 + Goto cra_srv + ${EndIf} + cra_srv_ask: + ${nsProcess::FindProcess} "scoreboard.exe" $R1 + ${If} $R1 != 0 + Goto cra_done + ${EndIf} + ${IfNot} ${Silent} + MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION|MB_TOPMOST "$(appCannotBeClosed)" IDRETRY cra_srv_ask + ${EndIf} + DetailPrint "$(appCannotBeClosed)" + SetErrorLevel 0x666668 + Quit ${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: + ${nsProcess::Unload} Pop $R2 Pop $R1 Pop $0 @@ -1591,13 +1652,22 @@ Function DoInstall ${endIf} ${endif} - ; 覆盖安装:先跑旧卸载器(它自己会带 --updated / --keep-shortcuts) - !insertmacro uninstallOldVersion SHELL_CONTEXT - !insertmacro handleUninstallResult SHELL_CONTEXT - ${if} $installMode == "all" - !insertmacro uninstallOldVersion HKEY_CURRENT_USER - !insertmacro handleUninstallResult HKEY_CURRENT_USER - ${endIf} + ; 覆盖安装:先跑旧卸载器(它自己会带 --updated / --keep-shortcuts)。 + ; ⚠️ 静默安装(/S)整段跳过:旧版本装的那个卸载器会为它自己再要一次 UAC 提权 + ; (老版本的提权判定有 bug,即使当前进程已经提权它也要再弹一次),无人值守时那个请求 + ; 要么没人点、要么被系统直接取消(1223 = ERROR_CANCELLED)—— 真机 /S 就是这么挂住的 + ; (旧卸载器重试 5 轮后 handleUninstallResult 弹一个没有 /SD 的框,安装器就永远等在那儿)。 + ; 我们是就地覆盖同一个安装目录,文件锁由上面的 CloseRunningApp 负责解开, + ; 旧文件/旧快捷方式会被新安装直接覆盖,所以静默安装跳过它、继续往下装是安全的; + ; 交互安装照旧(用户在场,那个提权框有人点,语义与上游完全一致)。 + ${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