fix(installer): 管理员判定误报(真机)+ 安装时真正关闭旧版本进程
## 一、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。
This commit is contained in:
1 parent
67eadd7fe6
commit
23ba8a1830
1 file changed
+160
-55
+160
-55
@@ -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 缩放基准值。栈:基准像素值 → 返回缩放后的像素值。
|
||||
|
||||
Reference in new issue
Block a user