Files
Scoreboard/console/installer/installer.nsi
T
AskaEth a57df78f99 fix(installer): 安装位置识别与模式切换目录(真机四因根除)
主人真机复验:① 不能像原版那样自动识别「已安装程序的安装位置」;② 选「为所有用户安装」却装到
%LOCALAPPDATA% 而不是 Program Files。

真机打点(instrumented 副本 + WM_COMMAND/BM_CLICK/WM_SETTEXT 驱动)读出四个根因:
1. 切换安装模式单选钮只改状态位图,**不同步输入框**;
2. setInstallModePerAllUsers/PerUser 会按记忆/默认**重算 $INSTDIR**,把「框里定下的值」
   (用户手改、浏览选择、记忆)**无条件冲掉** —— 这是两条症状的共同根因
   (打点:after-normalize=[D:\SBCustom\Scoreboard Console] → after-mode=[C:\Program Files (x86)\…]);
3. 记忆读取 HKCU 优先、**不看安装模式** → 一条更早的用户级记忆顶掉刚装好的机器级目录;
4. 无人值守会话里 $LOCALAPPDATA 解析成 C:\ProgramData →「仅为我」默认成了 C:\ProgramData\Programs\…。

改法:
- 新增 ModeDefaultDir:「为所有用户」→ $PROGRAMFILES64\${APP_FILENAME}(本包是 x64);
  「仅为我」→ SHGetKnownFolderPath(FOLDERID_UserProgramFiles),失败才退回 $LOCALAPPDATA\Programs;
- 新增 PathTouched(框里的文本 != 程序设定的值 = 用户手改过)与 SyncPathToMode:
  切模式时**只有用户没改过**才把框与 $INSTDIR 同步成该模式默认目录(不冲掉用户刚填的);
- PageDirLeave 用 $pathKeep 保住路径,跑完上游两个宏后再恢复 —— 只让宏改模式与 SHELL_CONTEXT,不再改路径;
- .onInit 在 customInit 之后**按当前模式**重读自家记忆键(all → HKLM,否则 HKCU)。

真机六条全过(含落盘复核):进 P3 显示记忆目录;点「为所有用户」→ C:\Program Files\Scoreboard_Console;
点「仅为我」→ %LOCALAPPDATA%\Programs\…;点回去再正确切换;手塞自定义路径以它为准;改过后再切模式不被冲掉。
静默无回归:/S 6.6s exit=0 落记忆目录;/S /D= 6.4s exit=0 且 /D= 优先。
wine 回归:人手速度拖动、原生控件隐藏、P3→P5 自动翻页、✕ 退出无 StdUtils 报错、旧版在跑时 /S 关掉旧进程。
2026-09-13 23:32:00 +08:00

1868 lines
78 KiB
NSIS
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
; =============================================================================
; 赛事积分榜 安装器 —— 完全自绘的五页向导
;
; 这是 electron-builder 的「自定义 installer 脚本」(package.json 的 build.nsis.script)。
; 只换 UI 层:安装/卸载/升级/快捷方式/注册表/防火墙全部复用 electron-builder 模板里的宏,
; 一个字节的安装逻辑都不重写 —— 现场装机成功率不被这次改版动摇。
; 唯一被换掉的是「关闭在跑的旧版本」那一步(上游 CHECK_APP_RUNNING 的提示框在自绘窗口下
; 用户根本看不见,真机上表现为「旧程序一直没被关掉」),换法见下面 CloseRunningApp 的注释。
; 提权判断也一样:${UAC_IsAdmin} 换成了进程令牌查询,理由见 CheckAdmin 上面的注释。
;
; 为什么整窗去边框、铺满位图:
; 设计要求是 760×480 的固定版面(左侧品牌栏 / 顶部标题栏 / 右侧内容区 / 底部按钮条)。
; 系统标题栏和原生按钮条在不同 Windows 版本上高度都不一样,要对齐只能自己画。
; 所以:去掉窗口边框 → 内层对话框铺满整个客户区 → 位图当画布 → 标准按钮条被盖住,
; 但仍然是「程序化翻页」的工具(点我们的按钮 = 点被盖住的系统按钮)。
;
; 页面流程:P1 欢迎 → P2 许可协议 → P3 安装位置与安装模式 → P4 正在安装 → P5 完成。
; P1/P2/P3/P5 是 Page custom(nsDialogs 自建内层对话框,POC 已验证);
; P4 必须是**真正的** instfiles 页,原因有两个,都不是偏好问题:
; 1. 安装段(Section)只在 instfiles 页上执行。不用 instfiles 页就得把整段安装逻辑塞进
; 一个函数,静默安装、取消中断、进度刷新全得自己重写 —— 那才是真正的风险源;
; 2. WriteUninstaller / 卸载段的执行时机也依赖这条路径。
; 代价是 instfiles 页自带的内层对话框(原生进度条 + 详情列表)只能藏起来,
; 在上面再盖一层我们自己的对话框。
;
; 高 DPI(现场是 4K@200%):
; ManifestDPIAware true + 运行时读 LOGPIXELSX,所有坐标 = 1x 设计值 × dpi / 96,
; 一个绝对像素值都不许写死在控件上;素材出 1x/2x 两套,dpi ≥ 144 用 2x,
; 并且给 LoadImage 传目标 cx/cy,让系统在 125%/150% 这类中间档位上兜底缩放。
; 字号不加倍:NSIS 的 CreateFont 自己会按系统 DPI 把点数换成像素,再乘一次就双倍了。
; =============================================================================
; ── 素材位置 ────────────────────────────────────────────────────────────────
; 走 ${PROJECT_DIR}(electron-builder 注入的绝对路径)而不是相对路径:
; electron-builder 是把脚本喂给 makensis 的 stdin、并把 makensis 的工作目录设成它的模板目录,
; 相对路径落点不确定。
!define ART_SRC "${PROJECT_DIR}\installer\art"
; ── 版面常量 ────────────────────────────────────────────────────────────────
; 全部是 art-gen/gen_art.py 里的 1x 设计值,改素材就要回来改这里(两边必须一致)。
!define WIN_W 760
!define WIN_H 480
!define TB_H 40
!define SIDE_W 240
!define BRAND_H 440
!define PG_X 240
!define PG_Y 40
!define PG_W 520
!define PG_H 440
!define PAD_L 32
!define CONTENT_W 456
!define BTN_W 108
!define BTN_H 36
!define BTN_Y 416
!define BTN_PRIMARY_X 620
!define BTN_SECONDARY_X 500
!define BROWSE_W 96
!define BROWSE_H 34
!define TB_MIN_X 672
!define TB_CLOSE_X 716
!define TB_BTN_W 44
; 页面内控件 → 窗口坐标 = 页内坐标 + (PG_X, PG_Y),这里直接写成窗口坐标
!define LIC_EDIT_X 273
!define LIC_EDIT_Y 237
!define LIC_EDIT_H 174
!define LIC_EDIT_W 454
!define LIC_CHK_X 272
!define LIC_CHK_Y 432
!define PATH_EDIT_X 273
!define PATH_EDIT_Y 151
!define PATH_EDIT_W 357
!define PATH_EDIT_H 32
!define BROWSE_X 632
!define BROWSE_Y 150
!define RADIO1_X 272
!define RADIO1_Y 260
!define RADIO2_X 272
!define RADIO2_Y 300
!define SIZE_TXT_Y 336
!define STAGE_X 272
!define STAGE_Y 156
!define STAGE_W 456
!define STAGE_H 18
; 每一段进度(stage-N.bmp)至少显示多久(毫秒):见 Stage 里的注释 ——
; 耗时几乎全在「解压」那一段,不给个下限的话后几段肉眼根本看不见。
!define STAGE_DWELL 400
!define PROG_X 272
!define PROG_Y 180
!define PROG_W 456
!define PROG_H 8
!define FIN_CHK_X 272
!define FIN_CHK_Y 338
!define FIN_PATH_Y 376
; 颜色取自 gen_art.py 的配色常量(SetCtlColors 收 RRGGBB,不是 BGR)
!define CLR_TXT "0x16233a"
!define CLR_DIM "0x5a6b85"
!define CLR_BG "0xf6f9fd"
; ── 变量 ───────────────────────────────────────────────────────────────────
; electron-builder 的模板(assistedInstaller / installSection)引用了这些变量,
; 平时由 installer.nsi 主模板声明;我们用自定义脚本,得自己声明,名字必须一致。
Var newStartMenuLink
Var oldStartMenuLink
Var newDesktopLink
Var oldDesktopLink
Var oldShortcutName
Var oldMenuDirectory
Var appExe
Var launchLink
; 自绘界面状态
Var hPage ; 当前页的内层对话框
Var hBg
Var hBrand
Var hTitleBar
Var hMin
Var hClose
Var hNext
Var hBack
Var hCancel
Var hBrowse
Var hFinish
Var hCheck
Var hRadio1
Var hRadio2
Var hPath
Var hLicEdit
Var hStage
Var hProg
Var stMin
Var stClose
Var stNext
Var stBack
Var stCancel
Var stBrowse
Var stFinish
Var uiPage ; 0=无页面,1..5=当前页;定时器靠它判断还能碰哪些控件
Var timerOn ; tick 定时器当前是不是活的(建/销毁都认这个哨兵,幂等)
Var stageT0 ; 上一段进度的时刻(GetTickCount),用来保证每段都看得见
Var gDpi ; 系统 DPI(LOGPIXELSX)
Var gArt ; 本次要用的素材目录($PLUGINSDIR\1x 或 \2x)
Var gCx
Var gCy
Var gBw ; Track 宏把基准宽高放这里,转交给 SetBmp
Var gBh
Var tmpName
Var tmpPath
Var tmpTxt
Var artReady
Var licOk ; 许可页勾选
Var runApp ; 完成页「立即运行」
Var modeAll ; 安装模式单选:1=为所有用户,0=仅为我
Var instStarted ; 安装流程是否已经跑起来(一次性,见 DoInstall 开头的守卫)
Var instDone ; 安装流程是否已经跑完(tick 里据此才去点「下一步」翻到完成页)
Var gAdminDbg ; CheckAdmin 的原始值(判定为非提权时写进 %TEMP% 便于现场定位)
Var gIsAdmin ; CheckAdmin 的结果:1 = 当前进程已提权
Var dragCx ; 拖动锚点:按下瞬间的光标屏幕坐标
Var dragCy
Var dragWx ; 拖动锚点:按下瞬间的窗口左上角
Var dragWy
Var gDparam ; 命令行 /D= 指定的安装目录(要在 customInit 的记忆目录之后盖回去)
Var keepShortcuts ; 快捷方式保留策略,由安装流程写定(上游在安装段里声明,我们提前到全局)
Var dragging
Var pathSet ; 我们最后一次写进「安装位置」输入框的值(用来判断用户有没有手改过)
Var pathKeep ; 计算路径时临时存一份(上游那两个宏会重写 $INSTDIR,用它盖回来)
; ── include ────────────────────────────────────────────────────────────────
; MUI2 不是为了它的页面,是为了 LangString / MUI_LANGUAGE:
; electron-builder 注入的 messages.nsh(以及 assistedMessages.nsh)用的是 MUI 的 LangString,
; 没有 MUI2 直接编译不过。我们的页面全是 Page custom,一个 MUI_PAGE_* 都不插。
!define MUI_DISABLE_INSERT_LANGUAGE_AFTER_PAGES_WARNING
!include "MUI2.nsh"
!include "nsDialogs.nsh"
!include "WinMessages.nsh"
!include "LogicLib.nsh"
!include "FileFunc.nsh"
!include "StrContains.nsh"
!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"
; 安装模式初值逻辑(照抄 templates/nsis/assistedInstaller.nsh 的 initMultiUser):
; 上次怎么装的还怎么装;命令行 --allusers / --currentuser 压过它;
; 都没有时看 INSTALL_MODE_PER_ALL_USERS_DEFAULT(= package.json 的 selectPerMachineByDefault)。
; 我们不 include assistedInstaller.nsh(它会插一堆 MUI 原生页面),所以这个宏得自己带一份,
; 否则 uninstaller.nsh 的 un.onInit 里那句 !insertmacro initMultiUser 直接找不到宏。
!macro initMultiUser
!ifdef INSTALL_MODE_PER_ALL_USERS
!insertmacro setInstallModePerAllUsers
!else
; 静默安装跳过「内层进程 / 非管理员」这道判断:它俩走 UAC 插件的 UAC::_,
; 而静默模式下没有页窗口,插件会在这一句上不返回(本机 wine 实测卡死 60 秒)。
; 静默安装要提权时由安装流程开头那段($installMode == "all" + ${Silent})自己处理。
${IfNot} ${Silent}
${If} ${UAC_IsInnerInstance}
${AndIfNot} ${UAC_IsAdmin}
SetErrorLevel 0x666666
Quit
${EndIf}
${EndIf}
!ifndef MULTIUSER_INIT_TEXT_ADMINREQUIRED
!define MULTIUSER_INIT_TEXT_ADMINREQUIRED "$(^Caption) requires administrator privileges."
!endif
!ifndef MULTIUSER_INIT_TEXT_POWERREQUIRED
!define MULTIUSER_INIT_TEXT_POWERREQUIRED "$(^Caption) requires at least Power User privileges."
!endif
!ifndef MULTIUSER_INIT_TEXT_ALLUSERSNOTPOSSIBLE
!define MULTIUSER_INIT_TEXT_ALLUSERSNOTPOSSIBLE "Your user account does not have sufficient privileges to install $(^Name) for all users of this computer."
!endif
StrCpy $hasPerMachineInstallation "0"
StrCpy $hasPerUserInstallation "0"
ReadRegStr $perMachineInstallationFolder HKLM "${INSTALL_REGISTRY_KEY}" InstallLocation
${if} $perMachineInstallationFolder != ""
StrCpy $hasPerMachineInstallation "1"
${endif}
ReadRegStr $perUserInstallationFolder HKCU "${INSTALL_REGISTRY_KEY}" InstallLocation
${if} $perUserInstallationFolder != ""
StrCpy $hasPerUserInstallation "1"
${endif}
${GetParameters} $R0
${GetOptions} $R0 "/allusers" $R1
${IfNot} ${Errors}
StrCpy $hasPerMachineInstallation "1"
StrCpy $hasPerUserInstallation "0"
${EndIf}
${GetOptions} $R0 "/currentuser" $R1
${IfNot} ${Errors}
StrCpy $hasPerMachineInstallation "0"
StrCpy $hasPerUserInstallation "1"
${EndIf}
${if} $hasPerUserInstallation == "1"
${andif} $hasPerMachineInstallation == "0"
!insertmacro setInstallModePerUser
${elseif} $hasPerUserInstallation == "0"
${andif} $hasPerMachineInstallation == "1"
!insertmacro setInstallModePerAllUsers
${else}
!ifdef INSTALL_MODE_PER_ALL_USERS
!insertmacro setInstallModePerAllUsers
!else
!ifdef INSTALL_MODE_PER_ALL_USERS_DEFAULT
!insertmacro setInstallModePerAllUsers
!else
!insertmacro setInstallModePerUser
!endif
!endif
${endif}
!endif
!macroend
; ── 执行级别 ───────────────────────────────────────────────────────────────
; highest(= 清单里的 highestAvailable),不是硬 admin:
; 管理员账户的机器:一启动就弹 UAC、之后**全程**是提权态 —— 不再有「装到一半才提权」
; 那种闪退/卡住,防火墙规则也必然放行得掉;
; 受限账户的机器:不弹 UAC,按普通权限启动,走下面的降级路径装成「仅为我安装」,
; 至少装得上;硬 admin 在那种机器上直接起不来。
RequestExecutionLevel highest
; ── 编译期替 electron-builder 补的两处 ──────────────────────────────────────
; 1) 自定义脚本下 electron-builder 不做 BUILD_UNINSTALLER 那一趟,UNINSTALLER_OUT_FILE
; 被留成空值,而 include/installer.nsh 里有一条无条件的
; File "/oname=${UNINSTALL_FILENAME}" "${UNINSTALLER_OUT_FILE}"
; 空路径直接编译失败。真卸载器由安装段末尾的 WriteUninstaller 现写,
; 这里顶一个占位文件上去只为让编译过去(安装时会被立刻删掉再重写,见安装段)。
!ifdef UNINSTALLER_OUT_FILE
!define /redef UNINSTALLER_OUT_FILE "${PROJECT_DIR}\installer\uninstaller-stub.txt"
!else
!define UNINSTALLER_OUT_FILE "${PROJECT_DIR}\installer\uninstaller-stub.txt"
!endif
; 2) MUI_INSERT 是在第一个 MUI_LANGUAGE 处展开的,界面初始化钩子必须在那之前定义。
!define MUI_CUSTOMFUNCTION_GUIINIT "GuiInit"
; 用户中途取消/关窗时也把定时器收掉(先于 .onGUIEnd 跑,靠 $timerOn 幂等,不会重复销毁)
!define MUI_CUSTOMFUNCTION_ABORT "AbortCleanup"
; 当前进程是否已提权(结果放 $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
Push $R2
Push $R3
Push $R4
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, 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
Pop $R4
Pop $R3
Pop $R2
Pop $R1
Pop $0
FunctionEnd
; 「旧版本还在跑吗」的检查与关闭。不查的话覆盖安装时文件写入会一路失败,
; 用户最后只看到一个「无法关闭,重试/取消」的框,根本不知道要先关程序。
;
; 为什么不直接用上游 allowOnlyOneInstallerInstance.nsh 的 CHECK_APP_RUNNING(真机踩过的坑):
; 1. 它先弹 MessageBox 问「点确定就关掉」。我们的主窗口是无边框铺满的自绘窗口,
; 那个框既没有父窗口、也不是 topmost,实测被压在自绘层底下(wine 里干脆在那儿画一块黑矩形),
; 用户看不见、点不到,整条安装流程却卡在那句模态框上 —— 现场表现就是「旧程序一直没被关掉」;
; 2. 它探测进程的两条路都不适合现场:PowerShell 那条按 $INSTDIR 前缀过滤进程路径
; (绿色版、换过目录跑的实例一律漏掉;WMI 不可用时还会静默判成「没在跑」,
; 于是安装器照常往下装、文件被占用),tasklist 那条按 %USERNAME% 过滤
; (提权安装器跑在另一个账户下就漏);
; 3. 它的宏体自带 Var /GLOBAL CmdPath / PowerShellPath,一个脚本只能插一次,
; 而这一处已经被 un.checkAppRunning 占掉;卸载器侧的 Call 又只能指向 un. 函数。
;
; ⚠️ 为什么探测/关闭一律用 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。
!ifndef nsProcess::FindProcess
!include "nsProcess.nsh"
!endif
!macro CloseRunningApp
Push $0
Push $R1
Push $R2
; 本进程映像名:万一安装包被改名成跟应用同名,下面「按名字」关/杀就会打到安装器自己,
; 所以只有确认本进程名跟应用名不同才动手(同上游那句 ${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}
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}
cra_done:
${nsProcess::Unload}
Pop $R2
Pop $R1
Pop $0
!macroend
; ── 卸载段 ─────────────────────────────────────────────────────────────────
; 卸载逻辑与 un.onInit 直接用 electron-builder 的模板(uninstaller.nsh),一个字节都不改:
; 升级、快捷方式回收、防火墙规则回收的行为与上游完全一致。
; 卸载器的界面仍是原生外观 —— 素材只有安装器那一套、5 张页面位图全带「安装」字样,
; 硬套上去只会更难看。
;
; ⚠️ 为什么这里要自己给 BUILD_UNINSTALLER:
; electron-builder 平时编两趟(安装器一趟、卸载器一趟),靠 BUILD_UNINSTALLER 这个
; define 区分两边的代码路径(典型的是 getProcessInfo.nsh:安装器定义 _GetProcessInfo、
; 卸载器定义 un._GetProcessInfo)。自定义脚本只有一趟编译,安装器和卸载器的代码同处
; 一个文件里,所以只能给「卸载器这一段」自己划一个作用域:
; · 定义 BUILD_UNINSTALLER → 让 un. 侧拿到 Call un._GetProcessInfo(否则卸载器里的
; Call 不许指向非 un. 函数,直接编译不过);
; · include 完之后 !undef,安装器侧的代码照旧走 _GetProcessInfo。
; 顺带这也让 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
Call un.CheckAppRunningImpl
!macroend
!define BUILD_UNINSTALLER
!include "uninstaller.nsh"
; getProcessInfo.nsh 的 un._GetProcessInfo 只在 BUILD_UNINSTALLER 那一趟里定义;
; 我们的单趟编译里补一个,宏体用的还是上游那份 FUNC_GETPROCESSINFO。
Function un._GetProcessInfo
!insertmacro FUNC_GETPROCESSINFO
FunctionEnd
Function un.CheckAppRunningImpl
!insertmacro CloseRunningApp
FunctionEnd
; 卸载器侧的提权判断:与安装器侧 CheckAdmin 逐行同一套(ReturnLength 那个真机坑见上面的长注释),
; 只是名字必须带 un. 前缀 —— 卸载器里的 Call 只能指向 un. 开头的函数。
Function un.CheckAdmin
Push $0
Push $R1
Push $R2
Push $R3
Push $R4
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, 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
Pop $R4
Pop $R3
Pop $R2
Pop $R1
Pop $0
FunctionEnd
!undef BUILD_UNINSTALLER
!insertmacro addLangs
; ── 通用函数 ───────────────────────────────────────────────────────────────
Function CheckAppRunningImpl
!insertmacro CloseRunningApp
FunctionEnd
; 按 DPI 缩放基准值。栈:基准像素值 → 返回缩放后的像素值。
; 只用 $R8/$R9:所有调用方都用 $R0..$R7 放自己的临时值,S 不能踩它们。
Function S
Exch $R9
Push $R8
IntOp $R8 $R9 * $gDpi
IntOp $R8 $R8 / 96
StrCpy $R9 $R8
Pop $R8
Exch $R9
FunctionEnd
; 贴位图。栈:句柄 文件名 消息 基准宽 基准高。
; 文件名只有名字(如 base.bmp),目录由 $gArt 决定;cx/cy 用控件的目标像素尺寸,
; 于是 2x 素材在 150% 这类非整数倍下也能由系统缩到刚好。
Function SetBmp
Pop $R4 ; 文件名
Pop $R3 ; 句柄
Pop $R2 ; 消息
Pop $R1 ; 基准宽
Pop $R0 ; 基准高
StrCpy $tmpPath "$gArt\$R4"
Push $R1
Call S
Pop $R5 ; 目标宽
Push $R0
Call S
Pop $R6 ; 目标高
System::Call 'user32::LoadImageW(p 0, w "$tmpPath", i 0, i $R5, i $R6, i 0x0010) p .R0'
${If} $R0 == 0
Return ; 素材没加载出来就保持原图,宁可画面旧一点也不画成空白
${EndIf}
System::Call 'user32::SendMessageW(p $R3, i $R2, p 0, p $R0)'
; 立即重画:安装段执行期间消息泵不一定马上转到,进度不能等到空闲才更新
System::Call 'user32::InvalidateRect(p $R3, p 0, i 1)'
System::Call 'user32::UpdateWindow(p $R3)'
FunctionEnd
; 建控件。栈顺序见宏;类名/样式是编译期文本,坐标是 1x 设计值。
!macro NewCtl CLASS STYLE BX BY BW BH OUT
Push ${BX}
Call S
Pop $R0
Push ${BY}
Call S
Pop $R1
Push ${BW}
Call S
Pop $R2
Push ${BH}
Call S
Pop $R3
nsDialogs::CreateControl "${CLASS}" "${STYLE}" 0 $R0 $R1 $R2 $R3 ""
Pop ${OUT}
!macroend
; 贴位图(参数按 1x 设计值写,内部转成像素)
!macro SetBmpBase HWND ONAME W H
Push ${H}
Push ${W}
Push ${BM_SETIMAGE}
Push ${HWND}
Push ${ONAME}
Call SetBmp
!macroend
!macro SetBmpStatic HWND ONAME W H
Push ${H}
Push ${W}
Push ${STM_SETIMAGE}
Push ${HWND}
Push ${ONAME}
Call SetBmp
!macroend
; 位图按钮:创建 + 贴常态图
!macro NewBmpBtn BX BY BW BH OUT ONAME
!insertmacro NewCtl "Button" "${BS_BITMAP}|${WS_CHILD}|${WS_VISIBLE}" ${BX} ${BY} ${BW} ${BH} ${OUT}
!insertmacro SetBmpBase ${OUT} ${ONAME} ${BW} ${BH}
!macroend
; 悬停跟踪:栈里带齐「基准矩形 + 句柄 + 当前状态 + 位图基名」。
; 状态机 0=常态 1=悬停 2=按下,和 art/ 的三态位图一一对应。
!macro Track HWND STATE ONAME BX BY BW BH
StrCpy $gBw ${BW}
StrCpy $gBh ${BH}
Push ${BX}
Push ${BY}
Push ${BW}
Push ${BH}
Push ${HWND}
Push ${STATE}
Push "${ONAME}"
Call HoverBtn
Pop ${STATE}
!macroend
; 光标位置换算 + 命中测试 → 需要时换位图。栈:基准x 基准y 基准宽 基准高 句柄 状态 位图基名(base)。
; 用 $R0..$R6 干活、$R7 存结果 —— SetBmp 只踩 $R0..$R6,所以 $R7 能活到返回。
Function HoverBtn
Pop $R0 ; 位图基名
Pop $R1 ; 当前状态
Pop $R2 ; 句柄
Pop $R3 ; 基准高
Pop $R4 ; 基准宽
Pop $R5 ; 基准 y
Pop $R6 ; 基准 x
Push $R6
Call S
Pop $R6 ; x
Push $R5
Call S
Pop $R5 ; y
Push $R4
Call S
Pop $R4 ; w
Push $R3
Call S
Pop $R3 ; h
; 右/下边界(命中测试用半开区间)
IntOp $R4 $R4 + $R6
IntOp $R3 $R3 + $R5
StrCpy $R7 0
${If} $gCx >= $R6
${AndIf} $gCx < $R4
${AndIf} $gCy >= $R5
${AndIf} $gCy < $R3
StrCpy $R7 1
System::Call 'user32::GetAsyncKeyState(i 1) i .R8'
IntOp $R8 $R8 & 0x8000
${If} $R8 != 0
StrCpy $R7 2
${EndIf}
${EndIf}
${If} $R7 != $R1
${If} $R7 == 0
StrCpy $tmpName "$R0-normal.bmp"
${ElseIf} $R7 == 1
StrCpy $tmpName "$R0-hover.bmp"
${Else}
StrCpy $tmpName "$R0-pressed.bmp"
${EndIf}
Push $gBh
Push $gBw
Push ${BM_SETIMAGE}
Push $R2
Push $tmpName
Call SetBmp
${EndIf}
Push $R7
FunctionEnd
; 起拖:记下锚点(光标屏幕坐标 + 窗口左上角)
Function DragStart
Pop $R0
StrCpy $dragging 1
System::Call '*(i, i) p .R1'
System::Call 'user32::GetCursorPos(p R1)'
System::Call '*$R1(i .R2, i .R3)'
System::Free $R1
StrCpy $dragCx $R2
StrCpy $dragCy $R3
System::Call '*(i, i, i, i) p .R4'
System::Call 'user32::GetWindowRect(p $HWNDPARENT, p R4)'
System::Call '*$R4(i .R5, i .R6)'
System::Free $R4
StrCpy $dragWx $R5
StrCpy $dragWy $R6
FunctionEnd
; 拖动中:窗口位置 = 锚点窗口位置 + 光标位移
Function DragApply
Pop $R0
System::Call '*(i, i) p .R1'
System::Call 'user32::GetCursorPos(p R1)'
System::Call '*$R1(i .R2, i .R3)'
System::Free $R1
IntOp $R2 $R2 - $dragCx
IntOp $R3 $R3 - $dragCy
IntOp $R2 $R2 + $dragWx
IntOp $R3 $R3 + $dragWy
System::Call 'user32::SetWindowPos(p $HWNDPARENT, p 0, i $R2, i $R3, i 0, i 0, i 0x0015)'
FunctionEnd
; 定时器回调:光标 → 客户区坐标 → 逐控件做悬停/按下 → 标题栏拖动。
; 不做窗口子类化(POC 结论):60ms 轮询足够跟手,也没有子类化回调崩掉整个安装器的风险。
Function Tick
System::Call '*(i, i) p .R0'
System::Call 'user32::GetCursorPos(p R0)'
System::Call 'user32::ScreenToClient(p $HWNDPARENT, p R0)'
System::Call '*$R0(i .R1, i .R2)'
System::Free $R0
StrCpy $gCx $R1
StrCpy $gCy $R2
${If} $uiPage == 0
Return
${EndIf}
; 标题栏两个按钮每页都在
!insertmacro Track $hMin $stMin "tb-min" ${TB_MIN_X} 0 ${TB_BTN_W} ${TB_H}
!insertmacro Track $hClose $stClose "tb-close" ${TB_CLOSE_X} 0 ${TB_BTN_W} ${TB_H}
${If} $uiPage == 1
!insertmacro Track $hNext $stNext "btn-next" ${BTN_PRIMARY_X} ${BTN_Y} ${BTN_W} ${BTN_H}
!insertmacro Track $hCancel $stCancel "btn-cancel" ${BTN_SECONDARY_X} ${BTN_Y} ${BTN_W} ${BTN_H}
${ElseIf} $uiPage == 2
!insertmacro Track $hNext $stNext "btn-next" ${BTN_PRIMARY_X} ${BTN_Y} ${BTN_W} ${BTN_H}
!insertmacro Track $hCancel $stCancel "btn-cancel" ${BTN_SECONDARY_X} ${BTN_Y} ${BTN_W} ${BTN_H}
${ElseIf} $uiPage == 3
!insertmacro Track $hNext $stNext "btn-next" ${BTN_PRIMARY_X} ${BTN_Y} ${BTN_W} ${BTN_H}
!insertmacro Track $hBack $stBack "btn-back" ${BTN_SECONDARY_X} ${BTN_Y} ${BTN_W} ${BTN_H}
!insertmacro Track $hBrowse $stBrowse "btn-browse" ${BROWSE_X} ${BROWSE_Y} ${BROWSE_W} ${BROWSE_H}
${ElseIf} $uiPage == 4
!insertmacro Track $hCancel $stCancel "btn-cancel" ${BTN_SECONDARY_X} ${BTN_Y} ${BTN_W} ${BTN_H}
; 安装在这里开工:等第一个 tick(窗口已经画完)再动手,然后完工后翻到 P5。
; 为什么不用 NSIS 自带的 instfiles 页:那一页在跑安装时只泵它自己的消息,
; 我们挂在 StdUtils 定时器上的悬停/进度刷新全都收不到回调(实测:P4 上按钮不悬停、
; 定时器不触发)。改成自绘页 + 定时器驱动,消息泵一直是通的。
${If} $instStarted == 0
Call DoInstall
${ElseIf} $instDone == 1
; 安装已经跑完,但页面还停在「正在安装」(真机复验:卡在最后一段不翻页)。
; 翻页靠点被藏起来的「下一步」,这一下有可能没生效,所以每个 tick 重试一次,
; 直到页面真的不再是这一页 —— 翻页成功后 $uiPage 立刻变了,不会多点到下一页去。
; ⚠️ 必须等 $instDone:安装途中的 Sleep 会把消息泵转起来,那时候也会跑进 Tick,
; 没有这道门就会在装到一半时去点「下一步」。
GetDlgItem $0 $HWNDPARENT 1
SendMessage $0 ${BM_CLICK} 0 0
${EndIf}
${ElseIf} $uiPage == 5
!insertmacro Track $hFinish $stFinish "btn-finish" ${BTN_PRIMARY_X} ${BTN_Y} ${BTN_W} ${BTN_H}
${EndIf}
; 标题栏拖动:按下时记锚点(光标屏幕坐标 + 窗口左上角),之后按位移 SetWindowPos。
; 不用系统的 WM_NCLBUTTONDOWN / HTCAPTION 移动循环 —— 它把移动交给 DefWindowProc,
; 真机上(窗口已经没有 WS_CAPTION 了)实测拖不动;自己算不依赖系统行为,wine 与真机一致。
; 两点关键:
; 1. 命中判定只管「起拖」;已经在拖的时候只要左键还按着就继续跟 —— 光标一旦甩出标题栏
; (窗口还没跟上来的那一瞬间)就停,正是「拖一下就断」的经典写法错误;
; 2. 起拖区里要扣掉标题栏按钮(x >= TB_MIN_X),否则点最小化/关闭会连带开始拖窗。
System::Call 'user32::GetAsyncKeyState(i 1) i .R3'
IntOp $R3 $R3 & 0x8000
${If} $dragging == 1
${If} $R3 != 0
Push true
Call DragApply
${Else}
StrCpy $dragging 0
${EndIf}
${Else}
${If} $gCy >= 0
${AndIf} $gCy < ${TB_H}
${AndIf} $gCx >= 0
${AndIf} $gCx < ${TB_MIN_X}
${AndIf} $R3 != 0
Push true
Call DragStart
${EndIf}
${EndIf}
FunctionEnd
; ── 素材嵌入 ───────────────────────────────────────────────────────────────
; 逐个文件写 File 而不是 File /r:素材缺一张就在编译期报错,
; 而不是运行期画出一块空白(装机现场没有回头补素材的机会)。
!macro ArtFiles SUB
SetOutPath "$PLUGINSDIR\${SUB}"
File /oname=base.bmp "${ART_SRC}\${SUB}\base.bmp"
File /oname=brand.bmp "${ART_SRC}\${SUB}\brand.bmp"
File /oname=titlebar.bmp "${ART_SRC}\${SUB}\titlebar.bmp"
File /oname=page-welcome.bmp "${ART_SRC}\${SUB}\page-welcome.bmp"
File /oname=page-license.bmp "${ART_SRC}\${SUB}\page-license.bmp"
File /oname=page-dir.bmp "${ART_SRC}\${SUB}\page-dir.bmp"
File /oname=page-progress.bmp "${ART_SRC}\${SUB}\page-progress.bmp"
File /oname=page-finish.bmp "${ART_SRC}\${SUB}\page-finish.bmp"
File /oname=btn-next-normal.bmp "${ART_SRC}\${SUB}\btn-next-normal.bmp"
File /oname=btn-next-hover.bmp "${ART_SRC}\${SUB}\btn-next-hover.bmp"
File /oname=btn-next-pressed.bmp "${ART_SRC}\${SUB}\btn-next-pressed.bmp"
File /oname=btn-install-normal.bmp "${ART_SRC}\${SUB}\btn-install-normal.bmp"
File /oname=btn-install-hover.bmp "${ART_SRC}\${SUB}\btn-install-hover.bmp"
File /oname=btn-install-pressed.bmp "${ART_SRC}\${SUB}\btn-install-pressed.bmp"
File /oname=btn-finish-normal.bmp "${ART_SRC}\${SUB}\btn-finish-normal.bmp"
File /oname=btn-finish-hover.bmp "${ART_SRC}\${SUB}\btn-finish-hover.bmp"
File /oname=btn-finish-pressed.bmp "${ART_SRC}\${SUB}\btn-finish-pressed.bmp"
File /oname=btn-cancel-normal.bmp "${ART_SRC}\${SUB}\btn-cancel-normal.bmp"
File /oname=btn-cancel-hover.bmp "${ART_SRC}\${SUB}\btn-cancel-hover.bmp"
File /oname=btn-cancel-pressed.bmp "${ART_SRC}\${SUB}\btn-cancel-pressed.bmp"
File /oname=btn-back-normal.bmp "${ART_SRC}\${SUB}\btn-back-normal.bmp"
File /oname=btn-back-hover.bmp "${ART_SRC}\${SUB}\btn-back-hover.bmp"
File /oname=btn-back-pressed.bmp "${ART_SRC}\${SUB}\btn-back-pressed.bmp"
File /oname=btn-browse-normal.bmp "${ART_SRC}\${SUB}\btn-browse-normal.bmp"
File /oname=btn-browse-hover.bmp "${ART_SRC}\${SUB}\btn-browse-hover.bmp"
File /oname=btn-browse-pressed.bmp "${ART_SRC}\${SUB}\btn-browse-pressed.bmp"
File /oname=tb-min-normal.bmp "${ART_SRC}\${SUB}\tb-min-normal.bmp"
File /oname=tb-min-hover.bmp "${ART_SRC}\${SUB}\tb-min-hover.bmp"
File /oname=tb-min-pressed.bmp "${ART_SRC}\${SUB}\tb-min-pressed.bmp"
File /oname=tb-close-normal.bmp "${ART_SRC}\${SUB}\tb-close-normal.bmp"
File /oname=tb-close-hover.bmp "${ART_SRC}\${SUB}\tb-close-hover.bmp"
File /oname=tb-close-pressed.bmp "${ART_SRC}\${SUB}\tb-close-pressed.bmp"
File /oname=check-off.bmp "${ART_SRC}\${SUB}\check-off.bmp"
File /oname=check-on.bmp "${ART_SRC}\${SUB}\check-on.bmp"
File /oname=radio-off.bmp "${ART_SRC}\${SUB}\radio-off.bmp"
File /oname=radio-on.bmp "${ART_SRC}\${SUB}\radio-on.bmp"
File /oname=progress-0.bmp "${ART_SRC}\${SUB}\progress-0.bmp"
File /oname=progress-1.bmp "${ART_SRC}\${SUB}\progress-1.bmp"
File /oname=progress-2.bmp "${ART_SRC}\${SUB}\progress-2.bmp"
File /oname=progress-3.bmp "${ART_SRC}\${SUB}\progress-3.bmp"
File /oname=progress-4.bmp "${ART_SRC}\${SUB}\progress-4.bmp"
File /oname=progress-5.bmp "${ART_SRC}\${SUB}\progress-5.bmp"
File /oname=stage-1.bmp "${ART_SRC}\${SUB}\stage-1.bmp"
File /oname=stage-2.bmp "${ART_SRC}\${SUB}\stage-2.bmp"
File /oname=stage-3.bmp "${ART_SRC}\${SUB}\stage-3.bmp"
File /oname=stage-4.bmp "${ART_SRC}\${SUB}\stage-4.bmp"
File /oname=stage-5.bmp "${ART_SRC}\${SUB}\stage-5.bmp"
!macroend
; 只解当前 DPI 需要的那一套:两套都解要写 26MB 临时文件,首次显示会明显卡一下。
Function EnsureArt
${If} $artReady == 1
Return
${EndIf}
StrCpy $artReady 1
InitPluginsDir
${If} $gDpi >= 144
StrCpy $gArt "$PLUGINSDIR\2x"
!insertmacro ArtFiles 2x
${Else}
StrCpy $gArt "$PLUGINSDIR\1x"
!insertmacro ArtFiles 1x
${EndIf}
FunctionEnd
; 系统 DPI。ManifestDPIAware 之后进程不会被人为放大,只能自己按这个数缩放版面。
Function PickDpi
System::Call 'user32::GetDC(p 0) p .R0'
System::Call 'gdi32::GetDeviceCaps(p R0, i 88) i .R1' ; LOGPIXELSX
System::Call 'user32::ReleaseDC(p 0, p R0)'
${If} $R1 < 96
StrCpy $R1 96
${EndIf}
StrCpy $gDpi $R1
FunctionEnd
; 藏一个外层对话框上的原生控件(控件不存在就跳过,$R7 是本宏专用的临时变量)
!macro HideStd ID
GetDlgItem $R7 $HWNDPARENT ${ID}
${If} $R7 != 0
ShowWindow $R7 ${SW_HIDE}
${EndIf}
!macroend
; 内层对话框铺满整个客户区:MUI 外层框架(页眉 / 品牌字 / 原生按钮条)全被盖住,
; 自绘位图才有完整的 760×480 画布。
Function NewPage
nsDialogs::Create 1018
Pop $hPage
${If} $hPage == error
Abort
${EndIf}
; 悬停/拖动/阶段刷新全靠这个 tick 轮询(POC 结论:比窗口子类化稳,代价是光标移动要等一个 tick)。
; ⚠️ 定时器用 nsDialogs 自带的 ${NSD_CreateTimer},**不要**用 StdUtils::TimerCreate:
; 真机实测(构建机 aeiva)StdUtils 那个不管请求 4ms / 20ms / 60ms / 250ms,
; 一律按 ~1500ms 的心跳跑(几个定时器还完全同步),wine 里也一样 ——
; 后果就是「拖不动窗口」(人手一次拖动不到 1.5 秒,tick 根本采不到按下状态)、
; 悬停要一秒多才反应。nsDialogs 的定时器实测就是请求的 60ms。
; 它挂在**当前页的内层对话框**上(页没了定时器也没了),所以每页(=每次 NewPage)都要建一次。
${NSD_CreateTimer} Tick 60
StrCpy $timerOn 1
Push ${WIN_W}
Call S
Pop $R0
Push ${WIN_H}
Call S
Pop $R1
System::Call 'user32::SetWindowPos(p $hPage, p 0, i 0, i 0, i $R0, i $R1, i 0x0004)'
; 把外层对话框上的原生控件**显式隐藏**。内层对话框只是盖在它们上面,鼠标划过那块区域时
; 原生按钮会自己重绘、从自绘层底下透出来(真机实测:Next/Back/Cancel 会闪出来)。
; NSIS 每页都重建内层对话框,所以每页(=每次 NewPage)都要重新藏一遍。
; ⚠️ 藏掉之后翻页仍然靠 SendMessage BM_CLICK 点那个被藏起来的 Next —— 隐藏窗口照样收得到
; 消息,本机实测翻页正常(这条一旦不成立,整套翻页就断了,所以改了要立刻验证)。
!insertmacro HideStd 1 ; Next
!insertmacro HideStd 2 ; Cancel
!insertmacro HideStd 3 ; Back
!insertmacro HideStd 1028 ; 底部品牌字底
!insertmacro HideStd 1256 ; 底部品牌字
!insertmacro HideStd 1034 ; 页眉底
!insertmacro HideStd 1035 ; 分隔线(标准)
!insertmacro HideStd 1045 ; 分隔线(满窗)
!insertmacro HideStd 1037 ; 页眉标题
!insertmacro HideStd 1038 ; 页眉副标题
!insertmacro HideStd 1046 ; 页眉图标
; 上一页的句柄全部作废:定时器靠这组变量判断「还能碰谁」
StrCpy $hBg 0
StrCpy $hBrand 0
StrCpy $hTitleBar 0
StrCpy $hMin 0
StrCpy $hClose 0
StrCpy $hNext 0
StrCpy $hBack 0
StrCpy $hCancel 0
StrCpy $hBrowse 0
StrCpy $hFinish 0
StrCpy $hCheck 0
StrCpy $hStage 0
StrCpy $hProg 0
StrCpy $stMin 0
StrCpy $stClose 0
StrCpy $stNext 0
StrCpy $stBack 0
StrCpy $stCancel 0
StrCpy $stBrowse 0
StrCpy $stFinish 0
FunctionEnd
; 全窗共用的底:背景 → 品牌栏 → 页面位图 → 标题栏 → 标题栏按钮。
; 栈:页面位图名(空字符串 = 该页不贴页面位图,比如安装中页自己控制进度)
Function Chrome
Pop $tmpName
!insertmacro NewCtl "Static" "${SS_BITMAP}|${WS_CHILD}|${WS_VISIBLE}" 0 0 ${WIN_W} ${WIN_H} $hBg
!insertmacro SetBmpStatic $hBg "base.bmp" ${WIN_W} ${WIN_H}
!insertmacro NewCtl "Static" "${SS_BITMAP}|${WS_CHILD}|${WS_VISIBLE}" 0 ${TB_H} ${SIDE_W} ${BRAND_H} $hBrand
!insertmacro SetBmpStatic $hBrand "brand.bmp" ${SIDE_W} ${BRAND_H}
${If} $tmpName != ""
!insertmacro NewCtl "Static" "${SS_BITMAP}|${WS_CHILD}|${WS_VISIBLE}" ${PG_X} ${PG_Y} ${PG_W} ${PG_H} $hProg
!insertmacro SetBmpStatic $hProg "$tmpName" ${PG_W} ${PG_H}
${EndIf}
!insertmacro NewCtl "Static" "${SS_BITMAP}|${WS_CHILD}|${WS_VISIBLE}" 0 0 ${WIN_W} ${TB_H} $hTitleBar
!insertmacro SetBmpStatic $hTitleBar "titlebar.bmp" ${WIN_W} ${TB_H}
!insertmacro NewBmpBtn ${TB_MIN_X} 0 ${TB_BTN_W} ${TB_H} $hMin "tb-min-normal.bmp"
!insertmacro NewBmpBtn ${TB_CLOSE_X} 0 ${TB_BTN_W} ${TB_H} $hClose "tb-close-normal.bmp"
${NSD_OnClick} $hMin OnMin
${NSD_OnClick} $hClose OnClose
FunctionEnd
; 自绘文字标签:对话框默认底色是灰的,不显式设色就会在页面上贴出一块灰,
; 所以一律用「底色 = 内容区底色」把标签融进位图。
; 栈(后压先弹):文本 字号 基准高 基准宽 基准y 基准x
!macro AddLabel BX BY BW BH SIZE TEXT
Push ${BX}
Push ${BY}
Push ${BW}
Push ${BH}
Push ${SIZE}
Push "${TEXT}"
Call Label
!macroend
Function Label
Pop $R0
Pop $R1
Pop $R2
Pop $R3
Pop $R4
Pop $R5
Push $R5
Call S
Pop $R5
Push $R4
Call S
Pop $R4
Push $R3
Call S
Pop $R3
Push $R2
Call S
Pop $R2
nsDialogs::CreateControl "Static" "${SS_LEFT}|${SS_CENTERIMAGE}|${WS_CHILD}|${WS_VISIBLE}" 0 $R5 $R4 $R3 $R2 ""
Pop $R6
SetCtlColors $R6 "${CLR_TXT}" "${CLR_BG}"
CreateFont $R7 "Microsoft YaHei UI" "$R1" "400"
SendMessage $R6 ${WM_SETFONT} $R7 1
${NSD_SetText} $R6 "$R0"
FunctionEnd
; 栈(后压先弹):样式 基准高 基准宽 基准y 基准x → 返回控件句柄
!macro NewEdit STYLE BX BY BW BH OUT
Push ${BH}
Push ${BW}
Push ${BY}
Push ${BX}
Push "${STYLE}"
Call MakeEdit
Pop ${OUT}
!macroend
Function MakeEdit
Pop $R5
Pop $R0
Pop $R1
Pop $R2
Pop $R3
Push $R0
Call S
Pop $R0
Push $R1
Call S
Pop $R1
Push $R2
Call S
Pop $R2
Push $R3
Call S
Pop $R3
nsDialogs::CreateControl "Edit" "$R5" 0 $R0 $R1 $R2 $R3 ""
Pop $R6
SetCtlColors $R6 "${CLR_TXT}" "0xffffff"
CreateFont $R7 "Microsoft YaHei UI" "12" "400"
SendMessage $R6 ${WM_SETFONT} $R7 1
Push $R6
FunctionEnd
; ── 按钮动作 ───────────────────────────────────────────────────────────────
; 标准按钮条被自绘层盖住了,但按钮还在:点它等于点系统按钮,翻页/取消都由 NSIS 自己走。
Function GoNext
GetDlgItem $0 $HWNDPARENT 1
SendMessage $0 ${BM_CLICK} 0 0
FunctionEnd
Function GoBack
GetDlgItem $0 $HWNDPARENT 3
SendMessage $0 ${BM_CLICK} 0 0
FunctionEnd
Function GoCancel
GetDlgItem $0 $HWNDPARENT 2
SendMessage $0 ${BM_CLICK} 0 0
FunctionEnd
Function OnMin
; 最小化走系统命令:自绘窗口没有系统按钮,但 ShowWindow 一样有效
System::Call 'user32::ShowWindow(p $HWNDPARENT, i 6)' ; SW_MINIMIZE
FunctionEnd
Function OnClose
Call GoCancel
FunctionEnd
Function OnLicCheck
; 用 != 1 而不是 == 0:LogicLib 的 == 是字符串比较,$licOk 初值是空串,"" == 0 为假
${If} $licOk != 1
StrCpy $licOk 1
!insertmacro SetBmpBase $hCheck "check-on.bmp" 16 16
${Else}
StrCpy $licOk 0
!insertmacro SetBmpBase $hCheck "check-off.bmp" 16 16
${EndIf}
FunctionEnd
Function OnLicNext
${If} $licOk != 1
; 没同意就不让走:这是许可页唯一的硬性要求
MessageBox MB_OK|MB_ICONINFORMATION|MB_TOPMOST "请先勾选「我已阅读并同意」再继续。"
Return
${EndIf}
Call GoNext
FunctionEnd
; 当前安装模式对应的「默认目录」(结果放 $R0)。
; 「为所有用户」= 公共目录:本包是 x64 应用,所以优先 $PROGRAMFILES64(真机期望就是 C:\Program Files\…),
; 32 位系统上退回 $PROGRAMFILES;「仅为我」= %LOCALAPPDATA%\Programs\…(与上游 setInstallModePerUser 一致)。
Function ModeDefaultDir
${If} $modeAll == 1
StrCpy $R0 "$PROGRAMFILES"
!ifdef APP_64
${If} ${RunningX64}
StrCpy $R0 "$PROGRAMFILES64"
${EndIf}
!endif
StrCpy $R0 "$R0\${APP_FILENAME}"
${Else}
; 与上游 setInstallModePerUser 同源:优先问 shell 要「用户程序目录」(FOLDERID_UserProgramFiles)。
; 不能直接用 $LOCALAPPDATA:在提权/无人值守(session 0,没有加载用户配置)的会话里,
; NSIS 的 $LOCALAPPDATA 会解析成 C:\ProgramData —— 真机实测就是装到了
; 「C:\ProgramData\Programs\Scoreboard_Console」,那不是用户的程序目录。
StrCpy $R0 "$LOCALAPPDATA\Programs"
Push $0
Push $1
Push $2
StrCpy $2 0
System::Call 'SHELL32::SHGetKnownFolderPath(g "${FOLDERID_UserProgramFiles}", i ${KF_FLAG_CREATE}, p 0, *p .r2) i .r1'
${If} $1 == 0
System::Call 'KERNEL32::lstrcpynW(w .r0, p r2, i ${NSIS_MAX_STRLEN}) p'
StrCpy $R0 "$0"
${EndIf}
${If} $2 != 0
System::Call 'OLE32::CoTaskMemFree(p r2)'
${EndIf}
Pop $2
Pop $1
Pop $0
StrCpy $R0 "$R0\${APP_FILENAME}"
${EndIf}
FunctionEnd
; 用户有没有手改过「安装位置」框?结果放 $R9(1 = 改过)。
; 判据是「框里的文本 != 我们最后一次写进去的值」——不需要额外的控件通知,浏览/手输都盖得住。
Function PathTouched
${NSD_GetText} $hPath $R1
StrCpy $R9 0
${If} $R1 != $pathSet
StrCpy $R9 1
${EndIf}
FunctionEnd
; 切模式时把路径与输入框同步到该模式的默认目录。
; 策略(说得通的理由):**用户没手改过**才跟着模式变;用户改过就保持他填的,
; 免得「改完路径再点一下单选」把用户刚填的东西悄悄冲掉。
; 之前这里只改单选状态位图、不动框 —— 于是「选了为所有用户,框里还是用户目录」,
; 真机复验的两条症状(记忆目录看不出、选公共安装却像在装用户目录)都是它引起的。
Function SyncPathToMode
Call PathTouched
${If} $R9 == 0
Call ModeDefaultDir
StrCpy $INSTDIR $R0
${If} $hPath != 0
${NSD_SetText} $hPath "$INSTDIR"
StrCpy $pathSet "$INSTDIR"
${EndIf}
${EndIf}
FunctionEnd
Function OnModeUser
StrCpy $modeAll 0
!insertmacro SetBmpBase $hRadio1 "radio-on.bmp" 16 16
!insertmacro SetBmpBase $hRadio2 "radio-off.bmp" 16 16
Call SyncPathToMode
FunctionEnd
Function OnModeAll
StrCpy $modeAll 1
!insertmacro SetBmpBase $hRadio1 "radio-off.bmp" 16 16
!insertmacro SetBmpBase $hRadio2 "radio-on.bmp" 16 16
Call SyncPathToMode
FunctionEnd
; 「浏览…」:系统文件夹选择框没有 NSIS 包装,用 System::Call 直接调 shell32。
; BROWSEINFO = { hwndOwner, pidlRoot, pszDisplayName, lpszTitle, ulFlags, lpfn, lParam, iImage }
; ulFlags = BIF_RETURNONLYFSDIRS(0x1) | BIF_NEWDIALOGSTYLE(0x40)
Function OnBrowse
System::Call 'ole32::CoInitialize(p 0)'
StrCpy $R6 $HWNDPARENT
System::Alloc 260
Pop $R7
System::Alloc 32
Pop $R0
System::Call '*$R0(p $R6, p 0, p $R7, p 0, i 0x41, p 0, p 0, i 0)'
System::Call 'shell32::SHBrowseForFolder(p R0) p .R1'
${If} $R1 != 0
System::Call 'shell32::SHGetPathFromIDList(p R1, t .R2)'
System::Call 'ole32::CoTaskMemFree(p R1)'
${If} $R2 != ""
StrCpy $tmpTxt $R2
${NSD_SetText} $hPath "$tmpTxt"
; 浏览选出来的目录也算用户改过:别让切模式把它冲掉
StrCpy $pathSet ""
${EndIf}
${EndIf}
System::Free $R7
System::Free $R0
FunctionEnd
Function OnFinish
${If} $runApp == 1
Call StartAppNow
${EndIf}
; 结束安装器。这里踩过两个坑:页面回调里 Quit 不生效(窗口不退、反而被推去下一页),
; 对主窗口发 WM_CLOSE 也不生效;能真正退出的只有点系统那个「取消」按钮。
; 先把错误码归零,别让「正常装完」的退出码看起来像被取消。
SetErrorLevel 0
GetDlgItem $0 $HWNDPARENT 2
SendMessage $0 ${BM_CLICK} 0 0
FunctionEnd
Function OnFinCheck
${If} $runApp != 1
StrCpy $runApp 1
!insertmacro SetBmpBase $hCheck "check-on.bmp" 16 16
${Else}
StrCpy $runApp 0
!insertmacro SetBmpBase $hCheck "check-off.bmp" 16 16
${EndIf}
FunctionEnd
; ── 进度分段 ───────────────────────────────────────────────────────────────
; app-64.7z 是一次 Nsis7z::Extract 调用,拿不到百分比;所以进度按「宏观步骤」分段
; (解压 → 注册表 → 快捷方式 → 防火墙 → 收尾),不画假进度条。
; NSIS 的 Call 不收参数,用法统一走这个宏
!macro StageCall N
Push ${N}
Call Stage
!macroend
; 栈:段号 1..5
Function Stage
Pop $R0
${If} ${Silent}
Return
${EndIf}
${If} $uiPage != 4
Return
${EndIf}
; 每一段至少显示 ${STAGE_DWELL} 毫秒再换下一段。
; 真机复验原话:「进度条只显示 0% 和 100%,中间的输出会在几帧内突然跳完」——
; 因为耗时几乎全在「解压」那一段,剩下几段每段不到 1 毫秒,肉眼只能看到头和尾。
; 段本身是真做过的,这里只是保证它**看得见**(四段加起来最多多花 1.6 秒)。
System::Call 'kernel32::GetTickCount() i .R1'
IntOp $R1 $R1 - $stageT0
${If} $R1 < ${STAGE_DWELL}
IntOp $R1 ${STAGE_DWELL} - $R1
Sleep $R1
${EndIf}
System::Call 'kernel32::GetTickCount() i .R1'
StrCpy $stageT0 $R1
; 段号先搬进全局变量:SetBmp 会踩掉 $R0..$R7(它的局部值都放那儿)
StrCpy $tmpTxt $R0
StrCpy $tmpPath "stage-$tmpTxt.bmp"
Push ${STAGE_H}
Push ${STAGE_W}
Push ${STM_SETIMAGE}
Push $hStage
Push $tmpPath
Call SetBmp
StrCpy $tmpPath "progress-$tmpTxt.bmp"
Push ${PROG_H}
Push ${PROG_W}
Push ${STM_SETIMAGE}
Push $hProg
Push $tmpPath
Call SetBmp
FunctionEnd
; ── P1 欢迎 ────────────────────────────────────────────────────────────────
Function PageWelcome
${If} ${Silent}
Abort
${EndIf}
StrCpy $uiPage 1
Call NewPage
Push "page-welcome.bmp"
Call Chrome
!insertmacro NewBmpBtn ${BTN_SECONDARY_X} ${BTN_Y} ${BTN_W} ${BTN_H} $hCancel "btn-cancel-normal.bmp"
!insertmacro NewBmpBtn ${BTN_PRIMARY_X} ${BTN_Y} ${BTN_W} ${BTN_H} $hNext "btn-next-normal.bmp"
${NSD_OnClick} $hCancel OnClose
${NSD_OnClick} $hNext GoNext
nsDialogs::Show
FunctionEnd
; ── P2 许可协议 ────────────────────────────────────────────────────────────
; 中文摘要烘焙在 page-license.bmp 里;英文原文(LICENSE)用真编辑框显示,
; 因为它比框大一截、必须能滚 —— 位图放不下整篇协议。
Function PageLicense
${If} ${Silent}
Abort
${EndIf}
StrCpy $uiPage 2
Call NewPage
Push "page-license.bmp"
Call Chrome
; 先按可写建,灌完文本再设只读:以只读样式创建的编辑框会被刷成灰底,
; 后期用 EM_SETREADONLY 设的只读框底色还是白的(SetCtlColors 再兜一层)。
!insertmacro NewEdit "${ES_MULTILINE}|${ES_AUTOVSCROLL}|${WS_VSCROLL}|${WS_CHILD}|${WS_VISIBLE}" ${LIC_EDIT_X} ${LIC_EDIT_Y} ${LIC_EDIT_W} ${LIC_EDIT_H} $hLicEdit
File /oname=$PLUGINSDIR\license.txt "${PROJECT_DIR}\..\LICENSE"
FileOpen $9 "$PLUGINSDIR\license.txt" r
lic_loop:
ClearErrors
FileRead $9 $8
IfErrors lic_done
SendMessage $hLicEdit ${EM_REPLACESEL} 1 "STR:$8$\r$\n"
Goto lic_loop
lic_done:
FileClose $9
; FileRead + EM_REPLACESEL 会把插入点一路推到末尾、视图跟着滚到底;
; 用户进这一页看到的应该是协议开头,所以显式回到顶部
SendMessage $hLicEdit ${EM_SETSEL} 0 0
SendMessage $hLicEdit ${WM_VSCROLL} 6 0 ; SB_TOP
SendMessage $hLicEdit ${EM_SETREADONLY} 1 0
!insertmacro NewBmpBtn 272 432 16 16 $hCheck "check-off.bmp"
${If} $licOk == 1
!insertmacro SetBmpBase $hCheck "check-on.bmp" 16 16
${EndIf}
!insertmacro AddLabel 296 430 300 20 12 "我已阅读并同意上述条款"
${NSD_OnClick} $hCheck OnLicCheck
!insertmacro NewBmpBtn ${BTN_SECONDARY_X} ${BTN_Y} ${BTN_W} ${BTN_H} $hCancel "btn-cancel-normal.bmp"
!insertmacro NewBmpBtn ${BTN_PRIMARY_X} ${BTN_Y} ${BTN_W} ${BTN_H} $hNext "btn-next-normal.bmp"
${NSD_OnClick} $hCancel OnClose
${NSD_OnClick} $hNext OnLicNext
nsDialogs::Show
FunctionEnd
Function PageLicenseLeave
StrCpy $uiPage 0
FunctionEnd
; ── P3 安装位置 + 安装模式 ─────────────────────────────────────────────────
Function PageDir
${If} ${Silent}
Abort
${EndIf}
StrCpy $uiPage 3
Call NewPage
Push "page-dir.bmp"
Call Chrome
!insertmacro NewEdit "${ES_AUTOHSCROLL}|${WS_CHILD}|${WS_VISIBLE}" ${PATH_EDIT_X} ${PATH_EDIT_Y} ${PATH_EDIT_W} ${PATH_EDIT_H} $hPath
; 进页面时框里显示的就是当前 $INSTDIR:initMultiUser + customInit 已经把「上次装到哪」
; (我们自己的记忆键,HKCU 优先、再 HKLM)读进来了,所以这里就是「记忆目录」。
; $pathSet 记下我们写的这份值,之后 SyncPathToMode / PageDirLeave 靠它判断用户有没有手改过。
${NSD_SetText} $hPath "$INSTDIR"
StrCpy $pathSet "$INSTDIR"
!insertmacro NewBmpBtn ${BROWSE_X} ${BROWSE_Y} ${BROWSE_W} ${BROWSE_H} $hBrowse "btn-browse-normal.bmp"
${NSD_OnClick} $hBrowse OnBrowse
!insertmacro NewBmpBtn ${RADIO1_X} ${RADIO1_Y} 16 16 $hRadio1 "radio-off.bmp"
!insertmacro NewBmpBtn ${RADIO2_X} ${RADIO2_Y} 16 16 $hRadio2 "radio-off.bmp"
${NSD_OnClick} $hRadio1 OnModeUser
${NSD_OnClick} $hRadio2 OnModeAll
; 初始勾选跟随 $installMode:initMultiUser 已经按「上次装法 / 命令行 / 默认」定好了模式
${If} $installMode == "all"
StrCpy $modeAll 1
!insertmacro SetBmpBase $hRadio2 "radio-on.bmp" 16 16
${Else}
StrCpy $modeAll 0
!insertmacro SetBmpBase $hRadio1 "radio-on.bmp" 16 16
${EndIf}
; 位图上的「需要空间 约 260 MB」是设计稿的占位数字,用真实体积盖掉
!ifdef ESTIMATED_SIZE
IntOp $R0 ${ESTIMATED_SIZE} / 1024
StrCpy $tmpTxt "需要空间 约 $R0 MB"
!insertmacro AddLabel ${PG_X} ${SIZE_TXT_Y} ${CONTENT_W} 20 11 "$tmpTxt"
!endif
!insertmacro NewBmpBtn ${BTN_SECONDARY_X} ${BTN_Y} ${BTN_W} ${BTN_H} $hBack "btn-back-normal.bmp"
!insertmacro NewBmpBtn ${BTN_PRIMARY_X} ${BTN_Y} ${BTN_W} ${BTN_H} $hNext "btn-next-normal.bmp"
${NSD_OnClick} $hBack GoBack
${NSD_OnClick} $hNext GoNext
nsDialogs::Show
FunctionEnd
Function PageDirLeave
StrCpy $uiPage 0
; 手改过就以输入框为准(浏览按钮也是写回这个框)
${NSD_GetText} $hPath $R1
${If} $R1 != ""
StrCpy $INSTDIR $R1
${EndIf}
StrCpy $R2 $INSTDIR 1 -1
${If} $R2 == "\"
StrCpy $INSTDIR $INSTDIR -1
${EndIf}
; 和 electron-builder 的目录页同一套规矩:路径必须以应用目录名结尾,
; 否则用户填 D:\Games 会把整个文件树倒进 D:\Games 而不是 D:\Games\Scoreboard_Console
${StrContains} $R3 "${APP_FILENAME}" $INSTDIR
${If} $R3 == ""
StrCpy $INSTDIR "$INSTDIR\${APP_FILENAME}"
${EndIf}
; 兜底:万一路径是空的(记忆为空 + 默认值也没算出来),按当前模式给一个合法默认目录,
; 绝不把空 $INSTDIR 带进安装流程(空路径是「点了没反应 / 装到莫名其妙的地方」的经典来源)。
; 走 ModeDefaultDir 而不是直接拼 $LOCALAPPDATA —— 后者在无人值守会话里会解析成 C:\ProgramData。
${If} $INSTDIR == ""
Call ModeDefaultDir
StrCpy $INSTDIR "$R0"
${EndIf}
; 先把定下来的路径存一份:下面这两个上游宏会按注册表记忆/模式默认**重新算 $INSTDIR**,
; 而这一页的规矩是「框里是什么就装什么」(记忆、模式默认、用户手改、/D= 都已经体现在框里了)。
StrCpy $pathKeep "$INSTDIR"
${If} $modeAll == 1
; 「为所有用户安装」。执行级别是 highest,所以**管理员账户的机器上启动时就提过权了**,
; 这里不需要(也不该)再走一次 UAC_RunElevated:中途提权会把窗口藏起来、让用户看见
; 「闪一下没反应」,真机上就是这么坏的(前几轮修过)。
; 受限账户的机器上 highest 拿不到管理员权限 —— 这时明确告知并降级成「仅为我安装」继续,
; 绝不停在这一页。
${If} ${UAC_IsAdmin}
!insertmacro setInstallModePerAllUsers
${Else}
MessageBox mb_IconExclamation|mb_TopMost|mb_SetForeground "当前账户没有管理员权限,无法「为所有用户安装」,已改为「仅为我安装」继续。"
StrCpy $modeAll 0
!insertmacro setInstallModePerUser
${EndIf}
${Else}
!insertmacro setInstallModePerUser
${EndIf}
; 盖回框里定下的路径($installMode / SHELL_CONTEXT 已经在宏里设好了,这里只保路径)
StrCpy $INSTDIR "$pathKeep"
FunctionEnd
; ── P4 正在安装 ────────────────────────────────────────────────────────────
; 自绘页:分段进度由 DoInstall 里的 Stage 调用推动(7z 一次解压拿不到百分比)。
; 不用 NSIS 自带的 instfiles 页,原因见 Tick 里的注释。
Function PageInstall
${If} ${Silent}
Abort
${EndIf}
StrCpy $uiPage 4
Call NewPage
Push "page-progress.bmp"
Call Chrome
!insertmacro NewCtl "Static" "${SS_BITMAP}|${WS_CHILD}|${WS_VISIBLE}" ${STAGE_X} ${STAGE_Y} ${STAGE_W} ${STAGE_H} $hStage
!insertmacro SetBmpStatic $hStage "stage-1.bmp" ${STAGE_W} ${STAGE_H}
!insertmacro NewCtl "Static" "${SS_BITMAP}|${WS_CHILD}|${WS_VISIBLE}" ${PROG_X} ${PROG_Y} ${PROG_W} ${PROG_H} $hProg
!insertmacro SetBmpStatic $hProg "progress-0.bmp" ${PROG_W} ${PROG_H}
!insertmacro NewBmpBtn ${BTN_SECONDARY_X} ${BTN_Y} ${BTN_W} ${BTN_H} $hCancel "btn-cancel-normal.bmp"
${NSD_OnClick} $hCancel OnClose
nsDialogs::Show
FunctionEnd
Function PageInstallLeave
StrCpy $uiPage 0
FunctionEnd
; ── P5 完成 ────────────────────────────────────────────────────────────────
Function PageFinish
${If} ${Silent}
Abort
${EndIf}
StrCpy $uiPage 5
Call NewPage
Push "page-finish.bmp"
Call Chrome
!insertmacro NewBmpBtn ${FIN_CHK_X} ${FIN_CHK_Y} 16 16 $hCheck "check-off.bmp"
${If} $runApp == 1
!insertmacro SetBmpBase $hCheck "check-on.bmp" 16 16
${EndIf}
!insertmacro AddLabel 296 336 300 20 13 "立即运行 赛事积分榜"
${NSD_OnClick} $hCheck OnFinCheck
; 台上的实际安装目录(位图里写的是设计稿的占位路径)
StrCpy $tmpTxt "安装位置:$INSTDIR"
!insertmacro AddLabel ${PG_X} ${FIN_PATH_Y} ${CONTENT_W} 18 11 "$tmpTxt"
!insertmacro NewBmpBtn ${BTN_PRIMARY_X} ${BTN_Y} ${BTN_W} ${BTN_H} $hFinish "btn-finish-normal.bmp"
${NSD_OnClick} $hFinish OnFinish
nsDialogs::Show
FunctionEnd
Function PageFinishLeave
StrCpy $uiPage 0
FunctionEnd
; ── 界面初始化 ─────────────────────────────────────────────────────────────
; 去系统边框 + 固定 760×480(按 DPI 缩放)+ 居中。
; MUI 的外层框架本身用不上,但它是 $HWNDPARENT、是标准按钮条的宿主,
; 所以不能不要,只能去边框、盖住、把标准按钮当程序化翻页的工具。
Function GuiInit
Call PickDpi
System::Call 'user32::GetWindowLong(p $HWNDPARENT, i -16) i .R0'
IntOp $R0 $R0 & 0xFF30FFFF ; 清 WS_CAPTION / BORDER / DLGFRAME / SYSMENU / MIN / MAX
System::Call 'user32::SetWindowLong(p $HWNDPARENT, i -16, i R0)'
Push ${WIN_W}
Call S
Pop $R0
Push ${WIN_H}
Call S
Pop $R1
System::Call 'user32::GetSystemMetrics(i 0) i .R2'
System::Call 'user32::GetSystemMetrics(i 1) i .R3'
IntOp $R2 $R2 - $R0
IntOp $R2 $R2 / 2
IntOp $R3 $R3 - $R1
IntOp $R3 $R3 / 2
${If} $R2 < 0
StrCpy $R2 0
${EndIf}
${If} $R3 < 0
StrCpy $R3 0
${EndIf}
System::Call 'user32::SetWindowPos(p $HWNDPARENT, p 0, i $R2, i $R3, i $R0, i $R1, i 0x0014)'
Call EnsureArt
FunctionEnd
; ── .onInit ────────────────────────────────────────────────────────────────
; 逐项对照 electron-builder 的 templates/nsis/installer.nsi,只去掉 setInstallSectionSpaceRequired
; (那是给组件页算体积用的,我们没有组件页)。
; 安装器 GUI 结束时收尾(正常点「完成」退出、以及中途关窗都走这里)。
; 定时器是 nsDialogs 建在当前页对话框上的,页被销毁时它自己也随窗口没了;
; 这里显式再收一次只是为了「页面还在、窗口要关」的那条路,靠 $timerOn 幂等,重复调用无害。
Function KillTick
${If} $timerOn == 1
StrCpy $timerOn 0
${NSD_KillTimer} Tick
${EndIf}
FunctionEnd
Function AbortCleanup
Call KillTick
FunctionEnd
Function .onGUIEnd
Call KillTick
FunctionEnd
Function .onInit
StrCpy $timerOn 0
StrCpy $timerOn 0
StrCpy $uiPage 0
StrCpy $instStarted 0
StrCpy $instDone 0
SetOutPath $INSTDIR
${LogSet} on
!insertmacro check64BitAndSetRegView
; 静默安装跳过单实例检查:这个宏的头一句是 BringToFront,而静默模式压根没有窗口,
; 实测在这种情况下会挂死(wine 下 45 秒不返回 —— 安装器就卡在这儿永远装不完)。
; 交互安装照常查:那才是「用户重复双击」真正会发生的地方。
${IfNot} ${Silent}
${AndIfNot} ${UAC_IsInnerInstance}
!insertmacro ALLOW_ONLY_ONE_INSTALLER_INSTANCE
${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
!ifmacrodef customInit
!insertmacro customInit
!endif
; 记忆目录要跟**安装模式**对齐,这才是「原版安装器」的行为:initMultiUser 读的就是该模式对应的键
; (为所有用户 → HKLM,仅为我 → HKCU)。构建期生成的 customInit 是「HKCU 的自家记忆优先」,
; 于是一条更早的用户级记忆会把刚装好的机器级目录顶掉 —— 真机实测:机器上最后一次装在
; C:\Program Files\Scoreboard_Console,安装位置页却填着更早的 D:\Program Files\Scoreboard_Console。
; 这里按当前模式再读一遍自家记忆键,有值就盖回去;两个 hive 都空时保留 initMultiUser/customInit 的结果。
${If} $installMode == "all"
ReadRegStr $R9 HKLM "${SCOREBOARD_INSTALL_KEY}" "InstallLocation"
${If} $R9 != ""
StrCpy $INSTDIR $R9
${EndIf}
${Else}
ReadRegStr $R9 HKCU "${SCOREBOARD_INSTALL_KEY}" "InstallLocation"
${If} $R9 != ""
StrCpy $INSTDIR $R9
${EndIf}
${EndIf}
${If} $gDparam != ""
StrCpy $INSTDIR $gDparam
${EndIf}
StrCpy $runApp 1
FunctionEnd
; ── 页面序列 ───────────────────────────────────────────────────────────────
Page custom PageWelcome
Page custom PageLicense PageLicenseLeave
Page custom PageDir PageDirLeave
Page custom PageInstall PageInstallLeave
Page custom PageFinish PageFinishLeave
; 空跑的 instfiles 页(放在页面序列最后):NSIS 要求「有安装段就必须有 instfiles 页」,
; 否则 -WX 下直接报 warning 8000 编译不过。在 PRE 回调里 Abort 掉、页面不会显示,
; 而 P5 的「完成」是直接结束安装器(见 OnFinish),根本走不到这一页。
PageEx instfiles
Caption " "
PageCallbacks SkipInstfiles "" ""
PageExEnd
Function SkipInstfiles
Abort
FunctionEnd
; ── 安装流程 ───────────────────────────────────────────────────────────────
; 「立即运行」用 electron-builder 的 StartApp 宏(common.nsh):它内部走 ExecShellAsUser,
; 以**登录用户**身份启动 —— 「为所有用户安装」那一趟进程是提权过的,直接 Exec 会让应用
; 继承管理员令牌,装完跑起来的会是个管理员身份的客户端(数据目录/开机自启都跑到别人家去)。
; 这个宏里带一句 Var /GLOBAL startAppArgs,整个脚本只能插入一次,所以包成一个函数给两处调。
Function StartAppNow
HideWindow
!insertmacro StartApp
FunctionEnd
; 安装流程本体。放在函数里而不是 Section 里,是为了让 P4 能是自绘页(原因见 Tick)。
; 顺序逐行对照 electron-builder 的 templates/nsis/installSection.nsh。
Function DoInstall
; 一次性守卫:交互安装由 P4 的定时器触发,静默安装由安装段触发,
; 两条路都从这里进,谁先到谁干活,绝不可能装两遍。
${If} $instStarted == 1
Return
${EndIf}
StrCpy $instStarted 1
; 进度分段的时间基准:第一段从开工这一刻算起,保证 stage-1 也至少显示 STAGE_DWELL 毫秒
System::Call 'kernel32::GetTickCount() i .R0'
StrCpy $stageT0 $R0
; 静默安装没有 UI 可选安装模式,per-machine 这一趟要在这里补提权。
; 比上游多一个 $installMode == "all" 的条件:上游只在「已有 per-machine 安装」时提权,
; 而我们把默认模式改成了「为所有用户」,全新静默安装(/S)就会默认往 Program Files 写,
; 不提权必然写不进去 —— 现场用脚本装机时这一步比交互装机更要紧。
!ifndef INSTALL_MODE_PER_ALL_USERS
!ifndef ONE_CLICK
${if} $installMode == "all"
${andIf} ${Silent}
; 静默安装**绝不弹 UAC**:无人值守时没人点那个框,弹出即等于装不完。
; 所以这里完全不碰 UAC 插件(提权与判断都不做),只查一眼令牌:
; 已经是管理员 → 按「为所有用户」继续,装到 Program Files 并放行防火墙;
; 不是管理员 → 退回「仅为我安装」(%LOCALAPPDATA%\Programs\...),
; 不需要提权也能装完,退出码仍然是 0。
; 交互安装不提权的事在 P3 离开时按 multiUserUi 的范式处理,与这里无关。
Call CheckAdmin
${if} $gIsAdmin == 1
!insertmacro setInstallModePerAllUsers
${else}
!insertmacro setInstallModePerUser
${endIf}
${endIf}
!endif
!endif
InitPluginsDir
${IfNot} ${Silent}
SetDetailsPrint none
${endif}
StrCpy $appExe "$INSTDIR\${APP_EXECUTABLE_FILENAME}"
# must be called before uninstallOldVersion
!insertmacro setLinkVars
; 非 one-click 时只在最外层进程里查:提权出来的内层进程和用户看到的可能是同一个应用,
; 两个进程同时抢着关它会互相打架(上游 installer.nsh / installSection.nsh 就是这么分的)。
; 静默安装没有窗口,那句 UAC_IsInnerInstance 走插件、会卡住不返回,所以直接查。
${If} ${Silent}
Call CheckAppRunningImpl
${Else}
${ifNot} ${UAC_IsInnerInstance}
Call CheckAppRunningImpl
${endif}
${EndIf}
StrCpy $keepShortcuts "false"
!insertMacro setIsTryToKeepShortcuts
${if} $isTryToKeepShortcuts == "true"
ReadRegStr $R1 SHELL_CONTEXT "${INSTALL_REGISTRY_KEY}" KeepShortcuts
${if} $R1 == "true"
${andIf} ${FileExists} "$appExe"
StrCpy $keepShortcuts "true"
${endIf}
${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
!insertmacro StageCall 1
!insertmacro installApplicationFiles
!insertmacro StageCall 2
!insertmacro registryAddInstallInfo
!insertmacro StageCall 3
!insertmacro addStartMenuLink $keepShortcuts
!insertmacro addDesktopLink $keepShortcuts
${if} ${FileExists} "$newStartMenuLink"
StrCpy $launchLink "$newStartMenuLink"
${else}
StrCpy $launchLink "$INSTDIR\${APP_EXECUTABLE_FILENAME}"
${endif}
!ifmacrodef registerFileAssociations
!insertmacro registerFileAssociations
!endif
!insertmacro StageCall 4
!ifmacrodef customInstall
!insertmacro customInstall
!endif
!insertmacro StageCall 5
StrCpy $instDone 1
; 真卸载器在这里现写。先删掉 installApplicationFiles 落下来的那个占位文件,
; 免得「万一没写成功、$INSTDIR 里却留着一个假卸载器」这种最坏情况。
Delete "$INSTDIR\${UNINSTALL_FILENAME}"
WriteUninstaller "$INSTDIR\${UNINSTALL_FILENAME}"
!ifdef ONE_CLICK
!ifdef RUN_AFTER_FINISH
${ifNot} ${Silent}
${orIf} ${isForceRun}
Call StartAppNow
${endIf}
!else
${if} ${isForceRun}
Call StartAppNow
${endIf}
!endif
!insertmacro quitSuccess
!else
; 交互安装的「立即运行」在 P5 上处理;静默安装只能靠 --force-run
${if} ${isForceRun}
${andIf} ${Silent}
Call StartAppNow
${endIf}
!endif
FunctionEnd
; 安装段的两条路:
; 交互安装:安装段根本不会执行(页面序列里的 instfiles 页被 Abort 掉了),
; 安装流程由 P4 的定时器调 DoInstall 跑;
; 静默安装(/S):没有页面可走,段就是唯一入口 —— 这一步走的是 electron-builder
; 原生的「段在 instfiles 页上执行」,和上游同一条路。
; 之所以不把静默安装也塞进 .onInit:实测那样会在 wine 里挂死(.onInit 阶段还没有
; 页对话框,DoInstall 里的界面相关动作没有宿主)。段这条路是上游验证过的。
Section "install" INSTALL_SECTION_ID
; 静默安装(/S):没有任何页面会被创建,P4 的定时器永远不会触发,所以这里就是唯一入口。
; NSIS 在静默模式下照常执行 Section、只是不显示页面 —— 和上游 electron-builder 安装器
; 「段在 instfiles 页上执行」是同一条路(区别只是我们把 instfiles 页 Abort 掉了)。
; 交互安装时这一支不执行:那时安装段根本不会被走到(instfiles 页被 Abort),
; 安装由 P4 的定时器调 DoInstall(DoInstall 自带一次性守卫,不会装两遍)。
${If} ${Silent}
Call DoInstall
!insertmacro quitSuccess
${EndIf}
SectionEnd