fix(installer): 静默安装落到 /D= 而不是记忆目录 + 静默路径绕开 UAC 插件

真机实测发现:`安装包.exe /S /currentuser /D=D:\tmp\sbsilent` 退出码 0、耗时 12 秒,
但 /D= 目录是空的 —— 安装其实**落到了「安装目录记忆」里的那个目录**。

(我最初的判断「DoInstall 从未被调用」是错的:给 Section/DoInstall 打 trace 后实测整条链
从头跑到尾,只是 $INSTDIR 被 customInit 用注册表记忆值盖掉了;空目录是 .onInit 的
SetOutPath 先建出来的假象。)

- **/D= 优先级最高**:.onInit 先取 GetDParameter,customInit 之后再盖回 $INSTDIR
  (/D= 不进 ${GetParameters},必须走 StdUtils.GetAllParameters)
- **静默入口**:Section "install" 在 ${Silent} 时 Call DoInstall + quitSuccess;
  DoInstall 加一次性守卫 $instStarted,Tick 与 Section 谁先到谁干活,绝不装两遍
- **静默不弹 UAC**:per-machine 分支在静默时改用查进程令牌,非管理员退回按用户安装
- **${UAC_IsAdmin} 全局换成 CheckAdmin**(按 BUILD_UNINSTALLER 分流成 un.CheckAdmin):
  构建期生成的 installer-custom.nsh 里 netsh 放行前那句判断原来走 UAC 插件,
  无页窗口时不返回,是静默跑不通的最后一关
- 脚本不带 BOM(BOM 会被拼进大脚本中间),本机用 -INPUTCHARSET UTF8 编译

验证(wine):非静默 P1→P5 走完且目录/卸载器齐全;静默 /S /currentuser /D=C:\v4 → exit 0、
文件与真卸载器都在 C:\v4;再静默卸载 → 目录消失、注册表零残留;/S /D=... 亦落在 /D=。
真机由主代理复验。
This commit is contained in:
AskaEth committed 2026-09-13 15:55:40 +08:00
1 parent 9fee6f2da5
commit a1aeed3c55
1 file changed
+134 -17
+134 -17
View File
@@ -145,7 +145,9 @@ Var artReady
Var licOk ; 许可页勾选 Var licOk ; 许可页勾选
Var runApp ; 完成页「立即运行」 Var runApp ; 完成页「立即运行」
Var modeAll ; 安装模式单选:1=为所有用户,0=仅为我 Var modeAll ; 安装模式单选:1=为所有用户,0=仅为我
Var instStarted ; 安装是否已经开工(Tick 里触发,只触发一次) Var instStarted ; 安装流程是否已经跑起来(一次性,见 DoInstall 开头的守卫)
Var gIsAdmin ; CheckAdmin 的结果:1 = 当前进程已提权
Var gDparam ; 命令行 /D= 指定的安装目录(要在 customInit 的记忆目录之后盖回去)
Var keepShortcuts ; 快捷方式保留策略,由安装流程写定(上游在安装段里声明,我们提前到全局) Var keepShortcuts ; 快捷方式保留策略,由安装流程写定(上游在安装段里声明,我们提前到全局)
Var dragging Var dragging
@@ -255,6 +257,47 @@ Var dragging
; 2) MUI_INSERT 是在第一个 MUI_LANGUAGE 处展开的,界面初始化钩子必须在那之前定义。 ; 2) MUI_INSERT 是在第一个 MUI_LANGUAGE 处展开的,界面初始化钩子必须在那之前定义。
!define MUI_CUSTOMFUNCTION_GUIINIT "GuiInit" !define MUI_CUSTOMFUNCTION_GUIINIT "GuiInit"
; 当前进程是否已提权(结果放 $gIsAdmin)。
; 静默安装要靠它决定「为所有用户」还是退一步「仅为我安装」,而这条路上一个 UAC 插件调用都不能有:
; 静默模式没有页窗口,UAC::_ 会卡住不返回(本机 wine 实测),真机上 UAC_RunElevated 会弹框等人点 ——
; 无人值守时那两件事都等于装不完。查令牌问的是同一个问题,且不依赖任何窗口。
Function CheckAdmin
Push $0
Push $R1
Push $R2
Push $R3
Push $R4
Push $R5
Push $R6
Push $R7
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
${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
StrCpy $gIsAdmin 1
${EndIf}
${EndIf}
System::Call 'kernel32::CloseHandle(p R4)'
System::Free $R5
${EndIf}
System::Free $R2
Pop $R7
Pop $R6
Pop $R5
Pop $R4
Pop $R3
Pop $R2
Pop $R1
Pop $0
FunctionEnd
; 「应用还在跑吗」的检查。不查的话覆盖安装时 CopyFiles 会一路失败, ; 「应用还在跑吗」的检查。不查的话覆盖安装时 CopyFiles 会一路失败,
; 用户最后只看到一个「无法关闭,重试/取消」的框,根本不知道要先关程序。 ; 用户最后只看到一个「无法关闭,重试/取消」的框,根本不知道要先关程序。
; 逻辑取自 allowOnlyOneInstallerInstance.nsh 的 CHECK_APP_RUNNING,但不能直接插那个宏: ; 逻辑取自 allowOnlyOneInstallerInstance.nsh 的 CHECK_APP_RUNNING,但不能直接插那个宏:
@@ -315,6 +358,25 @@ Var dragging
; 顺带这也让 un.onInit 里的 initMultiUser 走到「卸载器需要提权」的那条分支, ; 顺带这也让 un.onInit 里的 initMultiUser 走到「卸载器需要提权」的那条分支,
; 与上游卸载器编译结果一致。 ; 与上游卸载器编译结果一致。
; ── 提权判断:把 ${UAC_IsAdmin} 换成自己的实现 ─────────────────────────────
; 理由只有一个:静默安装 / 静默卸载都不能碰 UAC 插件。没有页窗口时 UAC::_ 在本机 wine 下
; 直接不返回(真机上 UAC_RunElevated 也会弹一个没人点的框)—— 无人值守装机就卡在那儿装不完。
; 问的是同一个问题(进程令牌是否已提权),换掉之后连构建期生成的 installer-custom.nsh
; (netsh 放行/回收前后那段判断)也一起受益,不用去改生成器。
; 必须放在 !define BUILD_UNINSTALLER 之前:下面按那个 define 决定叫哪一个实现
; (卸载器里的 Call 只能指向 un. 开头的函数)。
!undef UAC_IsAdmin
!macroundef _UAC_IsAdmin
!define UAC_IsAdmin `"" UAC_IsAdmin ""`
!macro _UAC_IsAdmin _a _b _t _f
!ifdef BUILD_UNINSTALLER
Call un.CheckAdmin
!else
Call CheckAdmin
!endif
StrCmp $gIsAdmin "1" `${_t}` `${_f}`
!macroend
!macro customCheckAppRunning !macro customCheckAppRunning
Call un.CheckAppRunningImpl Call un.CheckAppRunningImpl
!macroend !macroend
@@ -331,11 +393,48 @@ FunctionEnd
Function un.CheckAppRunningImpl Function un.CheckAppRunningImpl
!insertmacro AppRunningCheck !insertmacro AppRunningCheck
FunctionEnd FunctionEnd
; 卸载器侧的提权判断(同一套令牌查询,只是名字要带 un. 前缀)
Function un.CheckAdmin
Push $0
Push $R1
Push $R2
Push $R3
Push $R4
Push $R5
Push $R6
Push $R7
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'
${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
StrCpy $gIsAdmin 1
${EndIf}
${EndIf}
System::Call 'kernel32::CloseHandle(p R4)'
System::Free $R5
${EndIf}
System::Free $R2
Pop $R7
Pop $R6
Pop $R5
Pop $R4
Pop $R3
Pop $R2
Pop $R1
Pop $0
FunctionEnd
!undef BUILD_UNINSTALLER !undef BUILD_UNINSTALLER
!insertmacro addLangs !insertmacro addLangs
; ── 通用函数 ─────────────────────────────────────────────────────────────── ; ── 通用函数 ───────────────────────────────────────────────────────────────
Function CheckAppRunningImpl Function CheckAppRunningImpl
@@ -530,7 +629,6 @@ Function Tick
; 我们挂在 StdUtils 定时器上的悬停/进度刷新全都收不到回调(实测:P4 上按钮不悬停、 ; 我们挂在 StdUtils 定时器上的悬停/进度刷新全都收不到回调(实测:P4 上按钮不悬停、
; 定时器不触发)。改成自绘页 + 定时器驱动,消息泵一直是通的。 ; 定时器不触发)。改成自绘页 + 定时器驱动,消息泵一直是通的。
${If} $instStarted == 0 ${If} $instStarted == 0
StrCpy $instStarted 1
Call DoInstall Call DoInstall
GetDlgItem $0 $HWNDPARENT 1 GetDlgItem $0 $HWNDPARENT 1
SendMessage $0 ${BM_CLICK} 0 0 SendMessage $0 ${BM_CLICK} 0 0
@@ -1197,10 +1295,21 @@ Function .onInit
${AndIfNot} ${UAC_IsInnerInstance} ${AndIfNot} ${UAC_IsInnerInstance}
!insertmacro ALLOW_ONLY_ONE_INSTALLER_INSTANCE !insertmacro ALLOW_ONLY_ONE_INSTALLER_INSTANCE
${EndIf} ${EndIf}
; 命令行 /D= 的优先级最高:用户明说了装哪,就不该被「上次装到哪」的记忆改掉。
; initMultiUser 内部已经把 /D= 应用过一次,但紧随其后的 customInit(构建期生成)会拿
; 注册表里的记忆目录盖掉 $INSTDIR —— 这里先把 /D= 的值留一份,customInit 之后盖回去。
; 现场装机脚本就是靠 /D= 指定目录的(安装包.exe /S /currentuser /D=...),
; 记忆把它顶掉的话,脚本会以为装到了指定目录,其实文件落在别处。
; 注意 /D= 不在 ${GetParameters} 里(NSIS 自己把它从命令行里摘掉了),
; 所以用上游 multiUser.nsh 的 GetDParameter —— 它走 StdUtils.GetAllParameters 拿原始命令行再手工解析。
!insertmacro GetDParameter $gDparam
!insertmacro initMultiUser !insertmacro initMultiUser
!ifmacrodef customInit !ifmacrodef customInit
!insertmacro customInit !insertmacro customInit
!endif !endif
${If} $gDparam != ""
StrCpy $INSTDIR $gDparam
${EndIf}
StrCpy $runApp 1 StrCpy $runApp 1
FunctionEnd FunctionEnd
@@ -1238,6 +1347,13 @@ FunctionEnd
; 安装流程本体。放在函数里而不是 Section 里,是为了让 P4 能是自绘页(原因见 Tick)。 ; 安装流程本体。放在函数里而不是 Section 里,是为了让 P4 能是自绘页(原因见 Tick)。
; 顺序逐行对照 electron-builder 的 templates/nsis/installSection.nsh。 ; 顺序逐行对照 electron-builder 的 templates/nsis/installSection.nsh。
Function DoInstall Function DoInstall
; 一次性守卫:交互安装由 P4 的定时器触发,静默安装由安装段触发,
; 两条路都从这里进,谁先到谁干活,绝不可能装两遍。
${If} $instStarted == 1
Return
${EndIf}
StrCpy $instStarted 1
; 静默安装没有 UI 可选安装模式,per-machine 这一趟要在这里补提权。 ; 静默安装没有 UI 可选安装模式,per-machine 这一趟要在这里补提权。
; 比上游多一个 $installMode == "all" 的条件:上游只在「已有 per-machine 安装」时提权, ; 比上游多一个 $installMode == "all" 的条件:上游只在「已有 per-machine 安装」时提权,
; 而我们把默认模式改成了「为所有用户」,全新静默安装(/S)就会默认往 Program Files 写, ; 而我们把默认模式改成了「为所有用户」,全新静默安装(/S)就会默认往 Program Files 写,
@@ -1246,21 +1362,17 @@ Function DoInstall
!ifndef ONE_CLICK !ifndef ONE_CLICK
${if} $installMode == "all" ${if} $installMode == "all"
${andIf} ${Silent} ${andIf} ${Silent}
${ifNot} ${UAC_IsAdmin} ; 静默安装**绝不弹 UAC**:无人值守时没人点那个框,弹出即等于装不完。
ShowWindow $HWNDPARENT ${SW_HIDE} ; 所以这里完全不碰 UAC 插件(提权与判断都不做),只查一眼令牌:
!insertmacro UAC_RunElevated ; 已经是管理员 → 按「为所有用户」继续,装到 Program Files 并放行防火墙;
${Switch} $0 ; 不是管理员 → 退回「仅为我安装」(%LOCALAPPDATA%\Programs\...),
${Case} 0 ; 不需要提权也能装完,退出码仍然是 0。
${Break} ; 交互安装不提权的事在 P3 离开时按 multiUserUi 的范式处理,与这里无关。
${Case} 1223 ;用户取消了 UAC Call CheckAdmin
${Break} ${if} $gIsAdmin == 1
${Default}
MessageBox mb_IconStop|mb_TopMost|mb_SetForeground "Unable to elevate, error $0"
${Break}
${EndSwitch}
Quit
${else}
!insertmacro setInstallModePerAllUsers !insertmacro setInstallModePerAllUsers
${else}
!insertmacro setInstallModePerUser
${endIf} ${endIf}
${endIf} ${endIf}
!endif !endif
@@ -1368,6 +1480,11 @@ FunctionEnd
; 之所以不把静默安装也塞进 .onInit:实测那样会在 wine 里挂死(.onInit 阶段还没有 ; 之所以不把静默安装也塞进 .onInit:实测那样会在 wine 里挂死(.onInit 阶段还没有
; 页对话框,DoInstall 里的界面相关动作没有宿主)。段这条路是上游验证过的。 ; 页对话框,DoInstall 里的界面相关动作没有宿主)。段这条路是上游验证过的。
Section "install" INSTALL_SECTION_ID Section "install" INSTALL_SECTION_ID
; 静默安装(/S):没有任何页面会被创建,P4 的定时器永远不会触发,所以这里就是唯一入口。
; NSIS 在静默模式下照常执行 Section、只是不显示页面 —— 和上游 electron-builder 安装器
; 「段在 instfiles 页上执行」是同一条路(区别只是我们把 instfiles 页 Abort 掉了)。
; 交互安装时这一支不执行:那时安装段根本不会被走到(instfiles 页被 Abort),
; 安装由 P4 的定时器调 DoInstall(DoInstall 自带一次性守卫,不会装两遍)。
${If} ${Silent} ${If} ${Silent}
Call DoInstall Call DoInstall
!insertmacro quitSuccess !insertmacro quitSuccess