diff --git a/console/installer/installer.nsi b/console/installer/installer.nsi index 3a2d901..3b61287 100644 --- a/console/installer/installer.nsi +++ b/console/installer/installer.nsi @@ -131,6 +131,7 @@ Var stBrowse Var stFinish Var uiPage ; 0=无页面,1..5=当前页;定时器靠它判断还能碰哪些控件 Var timerId +Var timerOn ; 定时器是否已经创建过(销毁时只认这个哨兵,不靠 id 猜) Var gDpi ; 系统 DPI(LOGPIXELSX) Var gArt ; 本次要用的素材目录($PLUGINSDIR\1x 或 \2x) Var gCx @@ -147,6 +148,10 @@ Var runApp ; 完成页「立即运行」 Var modeAll ; 安装模式单选:1=为所有用户,0=仅为我 Var instStarted ; 安装流程是否已经跑起来(一次性,见 DoInstall 开头的守卫) Var gIsAdmin ; CheckAdmin 的结果:1 = 当前进程已提权 +Var dragCx ; 拖动锚点:按下瞬间的光标屏幕坐标 +Var dragCy +Var dragWx ; 拖动锚点:按下瞬间的窗口左上角 +Var dragWy Var gDparam ; 命令行 /D= 指定的安装目录(要在 customInit 的记忆目录之后盖回去) Var keepShortcuts ; 快捷方式保留策略,由安装流程写定(上游在安装段里声明,我们提前到全局) Var dragging @@ -242,6 +247,14 @@ Var dragging !endif !macroend +; ── 执行级别 ─────────────────────────────────────────────────────────────── +; highest(= 清单里的 highestAvailable),不是硬 admin: +; 管理员账户的机器:一启动就弹 UAC、之后**全程**是提权态 —— 不再有「装到一半才提权」 +; 那种闪退/卡住,防火墙规则也必然放行得掉; +; 受限账户的机器:不弹 UAC,按普通权限启动,走下面的降级路径装成「仅为我安装」, +; 至少装得上;硬 admin 在那种机器上直接起不来。 +RequestExecutionLevel highest + ; ── 编译期替 electron-builder 补的两处 ────────────────────────────────────── ; 1) 自定义脚本下 electron-builder 不做 BUILD_UNINSTALLER 那一趟,UNINSTALLER_OUT_FILE ; 被留成空值,而 include/installer.nsh 里有一条无条件的 @@ -256,6 +269,8 @@ Var dragging ; 2) MUI_INSERT 是在第一个 MUI_LANGUAGE 处展开的,界面初始化钩子必须在那之前定义。 !define MUI_CUSTOMFUNCTION_GUIINIT "GuiInit" +; 用户中途取消/关窗时也把定时器收掉(先于 .onGUIEnd 跑,靠 $timerOn 幂等,不会重复销毁) +!define MUI_CUSTOMFUNCTION_ABORT "AbortCleanup" ; 当前进程是否已提权(结果放 $gIsAdmin)。 ; 静默安装要靠它决定「为所有用户」还是退一步「仅为我安装」,而这条路上一个 UAC 插件调用都不能有: @@ -593,6 +608,38 @@ Function HoverBtn 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 @@ -637,34 +684,31 @@ Function Tick !insertmacro Track $hFinish $stFinish "btn-finish" ${BTN_PRIMARY_X} ${BTN_Y} ${BTN_W} ${BTN_H} ${EndIf} - ; 标题栏拖动。用系统拖动循环(HTCAPTION)而不是自己算位移:跟手、贴边、最大化吸附都免费。 - ; 判定用「屏幕级的左键状态」,所以必须把标题栏按钮那块从拖动区里扣掉, - ; 否则点最小化/关闭会连带开始拖窗(POC 踩过)。 - ${If} $gCy >= 0 - ${AndIf} $gCy < ${TB_H} - ${AndIf} $gCx >= 0 - ${AndIf} $gCx < ${TB_MIN_X} - System::Call 'user32::GetAsyncKeyState(i 1) i .R3' - IntOp $R3 $R3 & 0x8000 + ; 标题栏拖动:按下时记锚点(光标屏幕坐标 + 窗口左上角),之后按位移 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 - ${If} $dragging != 1 - StrCpy $dragging 1 - System::Call '*(i, i) p .R4' - System::Call 'user32::GetCursorPos(p R4)' - System::Call '*$R4(i .R5, i .R6)' - System::Free $R4 - IntOp $R5 $R5 & 0xFFFF - IntOp $R6 $R6 & 0xFFFF - IntOp $R6 $R6 << 16 - IntOp $R5 $R5 | $R6 - System::Call 'user32::ReleaseCapture()' - System::Call 'user32::SendMessageW(p $HWNDPARENT, i ${WM_NCLBUTTONDOWN}, p 2, p $R5)' - ${EndIf} + Push true + Call DragApply ${Else} StrCpy $dragging 0 ${EndIf} ${Else} - StrCpy $dragging 0 + ${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 @@ -749,6 +793,14 @@ Function PickDpi 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 @@ -758,8 +810,11 @@ Function NewPage Abort ${EndIf} ; 悬停/拖动全靠这个 60ms 轮询(POC 结论:比窗口子类化稳,代价是光标移动要等一个 tick)。 - ; 建在这里而不是 .onGUIInit:StdUtils 的定时器要挂在页对话框上才跑得起来。 - ${If} $timerId == 0 + ; 建在页面里而不是 .onGUIInit:StdUtils 的定时器要挂在页对话框上才跑得起来。 + ; ⚠️ 全程只建一个:它挂在 StdUtils 的隐藏窗口上,不随页面消失,而 NewPage 每页都会跑 —— + ; 不加哨兵就会越翻页越多(越跑越卡,退出时还会漏掉没销毁的那些)。 + ${If} $timerOn == 0 + StrCpy $timerOn 1 ${StdUtils.TimerCreate} $timerId Tick 60 ${EndIf} Push ${WIN_W} @@ -769,6 +824,22 @@ Function NewPage 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 @@ -1154,37 +1225,24 @@ Function PageDirLeave StrCpy $INSTDIR "$INSTDIR\${APP_FILENAME}" ${EndIf} + ; 兜底:万一路径是空的(记忆为空 + 默认值也没算出来),给一个合法的按用户目录, + ; 绝不把空 $INSTDIR 带进安装流程(空路径是「点了没反应 / 装到莫名其妙的地方」的经典来源)。 + ${If} $INSTDIR == "" + StrCpy $INSTDIR "$LOCALAPPDATA\Programs\${APP_FILENAME}" + ${EndIf} + ${If} $modeAll == 1 - ; 「为所有用户安装」:照抄 multiUserUi.nsh 的提权范式。 - ; 提权成功后是**另一个进程**在跑整个向导(它已经是管理员,会重新走到这一页并直接接着往下), - ; 外层进程在 ${Case} 0 里 Quit,避免两个进程同时写同一份文件。 - ${IfNot} ${UAC_IsAdmin} - ShowWindow $HWNDPARENT ${SW_HIDE} - !insertmacro UAC_RunElevated - ${Switch} $0 - ${Case} 0 - ${If} $1 = 1 - Quit - ${EndIf} - ${If} $1 = 3 - ${OrIf} $2 = 0x666666 - MessageBox mb_IconStop|mb_TopMost|mb_SetForeground "$(loginWithAdminAccount)" - ${EndIf} - ${Break} - ${Case} 1223 ;用户取消了 UAC - ${Break} - ${Case} 1062 - MessageBox mb_IconStop|mb_TopMost|mb_SetForeground "登录服务未运行,无法提权。" - ${Break} - ${Default} - MessageBox mb_IconStop|mb_TopMost|mb_SetForeground "提权失败,错误码 $0" - ${Break} - ${EndSwitch} - ShowWindow $HWNDPARENT ${SW_SHOW} - BringToFront - Abort ; 留在本页:提权没成时让用户改选「仅为我安装」 - ${Else} + ; 「为所有用户安装」。执行级别是 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 @@ -1279,10 +1337,28 @@ FunctionEnd ; ── .onInit ──────────────────────────────────────────────────────────────── ; 逐项对照 electron-builder 的 templates/nsis/installer.nsi,只去掉 setInstallSectionSpaceRequired ; (那是给组件页算体积用的,我们没有组件页)。 +; 安装器 GUI 结束时收尾(正常点「完成」退出、以及中途关窗都走这里)。 +; 不销毁的话 StdUtils 插件在卸载时会弹「Plug-in unloaded before all timers were destroyed」。 +; 只认哨兵 $timerOn:对没建过 / 已销毁的 id 调 TimerDestroy 本身也是一种报错源。 +Function AbortCleanup + ${If} $timerOn == 1 + StrCpy $timerOn 0 + ${StdUtils.TimerDestroy} $R0 $timerId + StrCpy $timerId 0 + ${EndIf} +FunctionEnd + +Function .onGUIEnd + ${If} $timerOn == 1 + StrCpy $timerOn 0 + ${StdUtils.TimerDestroy} $R0 $timerId + StrCpy $timerId 0 + ${EndIf} +FunctionEnd + Function .onInit - ; 显式赋 "0":下面 NewPage 里用 $timerId == 0 判断「建过没有」, - ; 而 LogicLib 的 == 是字符串比较,空串跟 0 不相等,会一次都不建(踩过) StrCpy $timerId 0 + StrCpy $timerOn 0 StrCpy $uiPage 0 StrCpy $instStarted 0 SetOutPath $INSTDIR