From 23ba8a1830dd3fe8500b5c8c25914f814fe25fe0 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 13 Sep 2026 18:16:27 +0800 Subject: [PATCH] =?UTF-8?q?fix(installer):=20=E7=AE=A1=E7=90=86=E5=91=98?= =?UTF-8?q?=E5=88=A4=E5=AE=9A=E8=AF=AF=E6=8A=A5=EF=BC=88=E7=9C=9F=E6=9C=BA?= =?UTF-8?q?=EF=BC=89+=20=E5=AE=89=E8=A3=85=E6=97=B6=E7=9C=9F=E6=AD=A3?= =?UTF-8?q?=E5=85=B3=E9=97=AD=E6=97=A7=E7=89=88=E6=9C=AC=E8=BF=9B=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 一、CheckAdmin 在真机永远判「非提权」 真凶:GetTokenInformation 的 ReturnLength 传了 NULL(`p 0`)。真机 Windows 直接返回 ERROR_INVALID_PARAMETER(80) → rc=0 → $gIsAdmin 恒为 0 → 明明已经 UAC 提权,P3 选 「为所有用户安装」仍弹「当前账户没有管理员权限…已改为仅为我安装」。wine 不校验该参数, 所以本地怎么点都复现不出来。 真机探针取证:NULL → rc=0 err=80 val=0;改传真实指针 → rc=1 val=1。 - ReturnLength 改用 System::Call 分配的真指针;取值寄存器写 .R9(写 .0 插件不认, $0 会保持空串导致判定恒真);查询失败按非提权并把原始值落到 %TEMP% 便于取证 - un.onInit 里那句判定原先恒为真 → 每次卸载/升级白弹一次 UAC,一并修正 ## 二、安装时没有关闭在跑的旧版本 根因两个:① 上游 CHECK_APP_RUNNING 的 MessageBox 被我们的自绘窗口压在下面(复现时可见 305×82 黑矩形),用户看不见但流程被阻塞,于是旧程序一直没被关掉;② 上游探测只按 $INSTDIR 前缀或 %USERNAME% 过滤,绿色版/别的账户实例一律漏掉。 改为自写 CloseRunningApp(安装/卸载共用):tasklist+findstr 按映像名找 → 温和 taskkill → 强制 /F /T /IM(连带 scoreboard.exe 子进程)→ 兜底 /F /IM → 仍关不掉时交互给置顶重试框、 静默直接非 0 退出。两条路都绝不静默往下一路装完。 回归(wine):拖动精确;原生控件 IsWindowVisible=0;P3→P4→P5 翻页;退出无 StdUtils 报错; /S /currentuser /D=... 落对目录且卸载器为 MZ。 --- console/installer/installer.nsi | 215 ++++++++++++++++++++++++-------- 1 file changed, 160 insertions(+), 55 deletions(-) diff --git a/console/installer/installer.nsi b/console/installer/installer.nsi index 3b61287..41e9a5f 100644 --- a/console/installer/installer.nsi +++ b/console/installer/installer.nsi @@ -4,6 +4,9 @@ ; 这是 electron-builder 的「自定义 installer 脚本」(package.json 的 build.nsis.script)。 ; 只换 UI 层:安装/卸载/升级/快捷方式/注册表/防火墙全部复用 electron-builder 模板里的宏, ; 一个字节的安装逻辑都不重写 —— 现场装机成功率不被这次改版动摇。 +; 唯一被换掉的是「关闭在跑的旧版本」那一步(上游 CHECK_APP_RUNNING 的提示框在自绘窗口下 +; 用户根本看不见,真机上表现为「旧程序一直没被关掉」),换法见下面 CloseRunningApp 的注释。 +; 提权判断也一样:${UAC_IsAdmin} 换成了进程令牌查询,理由见 CheckAdmin 上面的注释。 ; ; 为什么整窗去边框、铺满位图: ; 设计要求是 760×480 的固定版面(左侧品牌栏 / 顶部标题栏 / 右侧内容区 / 底部按钮条)。 @@ -141,12 +144,12 @@ Var gBh Var tmpName Var tmpPath Var tmpTxt -Var /GLOBAL IsPowerShellAvailable Var artReady Var licOk ; 许可页勾选 Var runApp ; 完成页「立即运行」 Var modeAll ; 安装模式单选:1=为所有用户,0=仅为我 Var instStarted ; 安装流程是否已经跑起来(一次性,见 DoInstall 开头的守卫) +Var gAdminDbg ; CheckAdmin 的原始值(判定为非提权时写进 %TEMP% 便于现场定位) Var gIsAdmin ; CheckAdmin 的结果:1 = 当前进程已提权 Var dragCx ; 拖动锚点:按下瞬间的光标屏幕坐标 Var dragCy @@ -272,10 +275,19 @@ RequestExecutionLevel highest ; 用户中途取消/关窗时也把定时器收掉(先于 .onGUIEnd 跑,靠 $timerOn 幂等,不会重复销毁) !define MUI_CUSTOMFUNCTION_ABORT "AbortCleanup" -; 当前进程是否已提权(结果放 $gIsAdmin)。 -; 静默安装要靠它决定「为所有用户」还是退一步「仅为我安装」,而这条路上一个 UAC 插件调用都不能有: -; 静默模式没有页窗口,UAC::_ 会卡住不返回(本机 wine 实测),真机上 UAC_RunElevated 会弹框等人点 —— -; 无人值守时那两件事都等于装不完。查令牌问的是同一个问题,且不依赖任何窗口。 +; 当前进程是否已提权(结果放 $gIsAdmin)。判据只有一个:进程令牌的 TokenElevation 标志。 +; 为什么不用 ${UAC_IsAdmin}(UAC 插件):静默安装那条约路上一个插件调用都不能有 —— +; 没有页窗口时 UAC::_ 会卡住不返回(本机 wine 实测),真机上 UAC_RunElevated 会弹框等人点, +; 无人值守装机就永远装不完。查令牌问的是同一个问题,且不依赖任何窗口。 +; 为什么不用 IsUserAnAdmin / 组成员判定:那是「账户在不在管理员组」,在 UAC 拆分令牌下 +; 受限进程照样回答「是」,答不了「这个进程提没提权」——真机上要判的正是后者。 +; +; ⚠️ 真机踩过的坑(2026-09-13,主人真机复验「明明弹过 UAC 却说没有管理员权限」): +; GetTokenInformation 的 ReturnLength 参数传 NULL 时,Windows 直接返回失败 +; (ERROR_INVALID_PARAMETER = 80),于是 $gIsAdmin 永远停在 0 —— 提权态被误判成非提权, +; 选「为所有用户安装」就被降级成「仅为我安装」。wine 不校验这个参数(真机上失败、wine 里成功), +; 所以本机怎么点都复现不出来,只有真机才暴露。必须给一个真实可写的 ReturnLength。 +; 真机原始值:NULL → rc=0 err=80 val=0;给真指针 → rc=1 err=0 val=1。 Function CheckAdmin Push $0 Push $R1 @@ -285,24 +297,44 @@ Function CheckAdmin Push $R5 Push $R6 Push $R7 + Push $R8 + Push $R9 StrCpy $gIsAdmin 0 System::Call 'kernel32::GetCurrentProcess() p .R1' System::Call '*(p 0) p .R2' System::Call 'advapi32::OpenProcessToken(p R1, i 0x0008, p R2) i .R3' ; TOKEN_QUERY + System::Call 'kernel32::GetLastError() i .R8' + StrCpy $gAdminDbg "OpenProcessToken=$R3 err=$R8" ${If} $R3 != 0 System::Call '*$R2(p .R4)' - System::Call '*(i 0) p .R5' - System::Call 'advapi32::GetTokenInformation(p R4, i 20, p R5, i 4, p 0) i .R6' ; TokenElevation - ${If} $R6 != 0 - System::Call '*$R5(i .R7)' - ${If} $R7 != 0 + System::Call '*(i 0, i 0) p .R5' ; TOKEN_ELEVATION(一个 DWORD) + System::Call '*(i 0) p .R6' ; ReturnLength:必须给真指针,见上面真机那条 + System::Call 'advapi32::GetTokenInformation(p R4, i 20, p R5, i 4, p R6) i .R7' + System::Call 'kernel32::GetLastError() i .R8' + ; 取值寄存器必须写 .R9 这种「大写 R + 数字」的形式:System 插件不认 .0, + ; 那样 $0 会保持调用前的原样(常常是空串),而空串跟 "0" 的字符串比较是「不相等」, + ; 判定就变成恒真了(真机探针上实测到过 val= 空串)。 + System::Call '*$R5(i .R9)' ; TokenIsElevated:0 = 非提权,非 0 = 已提权 + StrCpy $gAdminDbg "$gAdminDbg | TokenElevation rc=$R7 err=$R8 val=$R9" ; err 只在 rc=0 时有意义 + ; 查询本身失败时按非提权处理(与旧行为一致:宁可退一步装成「仅为我安装」,也不假装管理员) + ${If} $R7 != 0 + ${If} $R9 != 0 StrCpy $gIsAdmin 1 ${EndIf} ${EndIf} System::Call 'kernel32::CloseHandle(p R4)' System::Free $R5 + System::Free $R6 ${EndIf} System::Free $R2 + ; 判定为「非提权」时把原始值落到 %TEMP%,真机上判定再出问题时能直接看出是哪一步不对 + ${If} $gIsAdmin == 0 + FileOpen $R9 "$TEMP\Scoreboard-Console-elevation.txt" w + FileWrite $R9 "not elevated: $gAdminDbg$\r$\n" + FileClose $R9 + ${EndIf} + Pop $R9 + Pop $R8 Pop $R7 Pop $R6 Pop $R5 @@ -313,47 +345,103 @@ Function CheckAdmin Pop $0 FunctionEnd -; 「应用还在跑吗」的检查。不查的话覆盖安装时 CopyFiles 会一路失败, +; 「旧版本还在跑吗」的检查与关闭。不查的话覆盖安装时文件写入会一路失败, ; 用户最后只看到一个「无法关闭,重试/取消」的框,根本不知道要先关程序。 -; 逻辑取自 allowOnlyOneInstallerInstance.nsh 的 CHECK_APP_RUNNING,但不能直接插那个宏: -; 1. 它的宏体带 Var /GLOBAL CmdPath / PowerShellPath,一个脚本只能插一次, -; 而那一次已经被 un.checkAppRunning 占掉(我们 include 了 uninstaller.nsh); -; 2. 卸载器侧的 Call 只能指向 un. 开头的函数,安装器又调不了 un. 函数, -; 所以两边各要一个入口,共用下面这一份宏体。 -; 另外 $CmdPath / $PowerShellPath / $IsPowerShellAvailable 都由 un.checkAppRunning -; 那次展开声明(所以本文件里这一整段必须在 !include "uninstaller.nsh" 之后)。 -!macro AppRunningCheck - StrCpy $CmdPath "$SYSDIR\cmd.exe" - StrCpy $PowerShellPath "$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" - !insertmacro ProbePowerShell - !insertmacro _CHECK_APP_RUNNING +; +; 为什么不直接用上游 allowOnlyOneInstallerInstance.nsh 的 CHECK_APP_RUNNING(真机踩过的坑): +; 1. 它先弹 MessageBox 问「点确定就关掉」。我们的主窗口是无边框铺满的自绘窗口, +; 那个框既没有父窗口、也不是 topmost,实测被压在自绘层底下(wine 里干脆在那儿画一块黑矩形), +; 用户看不见、点不到,整条安装流程却卡在那句模态框上 —— 现场表现就是「旧程序一直没被关掉」; +; 2. 它探测进程的两条路都不适合现场:PowerShell 那条按 $INSTDIR 前缀过滤进程路径 +; (绿色版、换过目录跑的实例一律漏掉;WMI 不可用时还会静默判成「没在跑」, +; 于是安装器照常往下装、文件被占用),tasklist 那条按 %USERNAME% 过滤 +; (提权安装器跑在另一个账户下就漏); +; 3. 它的宏体自带 Var /GLOBAL CmdPath / PowerShellPath,一个脚本只能插一次, +; 而这一处已经被 un.checkAppRunning 占掉;卸载器侧的 Call 又只能指向 un. 函数。 +; +; 现在这套:按**映像名**找(tasklist + findstr 锚定整行,不依赖 PowerShell / WMI / 用户名), +; 先温和关(taskkill 不带 /F = 给主窗口发 WM_CLOSE,等同用户点右上角,Electron 能正常收尾), +; 还在就强杀,并且带 /T 把它拉起的 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 -; IS_POWERSHELL_AVAILABLE 的宏体自带 Var /GLOBAL IsPowerShellAvailable, -; 两边各插一次会报「already declared」,所以那句 Var 已经提到文件开头的变量区, -; 探测逻辑原样抄在这里(十几行,抄一份比去改上游模板安全)。 -!macro ProbePowerShell - ; 先用一个必然返回 7 的命令确认这是「真的」PowerShell。 - ; 上游模板直接问 Get-Command Get-CimInstance,但假壳(wine 自带的 powershell.exe 就是, - ; 任何参数都 exit 0)会让这个探测和后面那条 Get-CimInstance 查询一律「成功」, - ; 于是安装器误判「程序正在运行」,去杀一个不存在的进程、卡在重试框里。 - ; 这类假壳在现场也可能出现(安全软件挂的 shell 代理),所以宁可多花一次子进程。 - nsExec::Exec `"$PowerShellPath" -C "exit 7"` +!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 ; 旧版本没在跑,什么都不用做 + ${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 - ${if} $0 != 7 - StrCpy $IsPowerShellAvailable 1 - ${else} - nsExec::Exec `"$PowerShellPath" -C "if (Get-Command Get-CimInstance -ErrorAction SilentlyContinue) { exit 0 } else { exit 1 }"` + 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 + ${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 - ${if} $0 == 0 - nsExec::Exec `"$PowerShellPath" -C "if ((Get-ExecutionPolicy -Scope Process) -eq 'Restricted') { exit 1 } else { exit 0 }"` - Pop $0 - ${endIf} - ${if} $0 != 0 - StrCpy $0 1 - ${endIf} - StrCpy $IsPowerShellAvailable $0 - ${endIf} + ${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: + Pop $R2 + Pop $R1 + Pop $0 !macroend ; ── 卸载段 ───────────────────────────────────────────────────────────────── @@ -406,10 +494,11 @@ Function un._GetProcessInfo FunctionEnd Function un.CheckAppRunningImpl - !insertmacro AppRunningCheck + !insertmacro CloseRunningApp FunctionEnd -; 卸载器侧的提权判断(同一套令牌查询,只是名字要带 un. 前缀) +; 卸载器侧的提权判断:与安装器侧 CheckAdmin 逐行同一套(ReturnLength 那个真机坑见上面的长注释), +; 只是名字必须带 un. 前缀 —— 卸载器里的 Call 只能指向 un. 开头的函数。 Function un.CheckAdmin Push $0 Push $R1 @@ -419,24 +508,40 @@ Function un.CheckAdmin Push $R5 Push $R6 Push $R7 + Push $R8 + Push $R9 StrCpy $gIsAdmin 0 System::Call 'kernel32::GetCurrentProcess() p .R1' System::Call '*(p 0) p .R2' - System::Call 'advapi32::OpenProcessToken(p R1, i 0x0008, p R2) i .R3' + System::Call 'advapi32::OpenProcessToken(p R1, i 0x0008, p R2) i .R3' ; TOKEN_QUERY + System::Call 'kernel32::GetLastError() i .R8' + StrCpy $gAdminDbg "OpenProcessToken=$R3 err=$R8" ${If} $R3 != 0 System::Call '*$R2(p .R4)' - System::Call '*(i 0) p .R5' - System::Call 'advapi32::GetTokenInformation(p R4, i 20, p R5, i 4, p 0) i .R6' - ${If} $R6 != 0 - System::Call '*$R5(i .R7)' - ${If} $R7 != 0 + System::Call '*(i 0, i 0) p .R5' ; TOKEN_ELEVATION + System::Call '*(i 0) p .R6' ; ReturnLength:真机上传 NULL 会被拒(ERROR_INVALID_PARAMETER) + System::Call 'advapi32::GetTokenInformation(p R4, i 20, p R5, i 4, p R6) i .R7' + System::Call 'kernel32::GetLastError() i .R8' + System::Call '*$R5(i .R9)' + StrCpy $gAdminDbg "$gAdminDbg | TokenElevation rc=$R7 err=$R8 val=$R9" + ${If} $R7 != 0 + ${If} $R9 != 0 StrCpy $gIsAdmin 1 ${EndIf} ${EndIf} System::Call 'kernel32::CloseHandle(p R4)' System::Free $R5 + System::Free $R6 ${EndIf} System::Free $R2 + ; 判定为「非提权」时把原始值落到 %TEMP%,真机上判定再出问题时能直接看出是哪一步不对 + ${If} $gIsAdmin == 0 + FileOpen $R9 "$TEMP\Scoreboard-Console-elevation.txt" w + FileWrite $R9 "not elevated: $gAdminDbg$\r$\n" + FileClose $R9 + ${EndIf} + Pop $R9 + Pop $R8 Pop $R7 Pop $R6 Pop $R5 @@ -453,7 +558,7 @@ FunctionEnd ; ── 通用函数 ─────────────────────────────────────────────────────────────── Function CheckAppRunningImpl - !insertmacro AppRunningCheck + !insertmacro CloseRunningApp FunctionEnd ; 按 DPI 缩放基准值。栈:基准像素值 → 返回缩放后的像素值。