; ============================================================================ ; ykxmz 教室电脑手表遥控 · 被控端安装包 ; ; 用 Inno Setup 6 编译: ; "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" installer\ykxmz.iss ; ; ── 设计说明 ── ; ; 尽量用 Inno 自带的机制,不手搓: ; * 许可协议页 -> LicenseFile(三份文档合并成一份,点"我接受"才能继续) ; * 开机自启 -> [Tasks](标准勾选框) ; * 装完启动 -> [Run] postinstall(标准勾选框) ; * 安装目录 -> DisableDirPage=no(让用户自己选,默认 Program Files) ; * 卸载 -> [UninstallRun] 调被控端自己的 --uninstall ; 只有"填 6 位配对码"这一页 Inno 没有现成的,用标准的 ; CreateInputQueryPage —— 那是 Inno 的官方 API,不算自己造轮子。 ; ; ── 用户确定的设置 ── ; 安装位置 默认 C:\Program Files\ykxmz,安装时可改 ; 数据目录 能写 exe 旁就写,不能就转 ProgramData(程序内部处理) ; 三份文档 放安装包里,统一接受才能下一步 ; 配对码 安装向导里填 6 位 ; 快捷方式 不建开始菜单、不建桌面 ; 开机自启 默认勾上 ; 装完启动 立即静默启动 ; 卸载 一并删掉,什么都不留 ; ============================================================================ #define MyAppName "ykxmz" #define MyAppVersion "2.1.0" #define MyAppPublisher "小麦子" #define MyAppExeName "ykxmz.exe" [Setup] AppId={{8F3C1D42-7A5E-4B91-9C6D-2E8F4A1B7C30} AppName={#MyAppName} AppVersion={#MyAppVersion} AppVerName={#MyAppName} {#MyAppVersion} AppPublisher={#MyAppPublisher} VersionInfoVersion={#MyAppVersion} ; 默认装到 Program Files\ykxmz,但安装向导里有一页让用户自己改 DefaultDirName={autopf}\ykxmz DisableDirPage=no ; 三份文档合并成一份许可页:看完点「我接受」才能继续 LicenseFile=license-combined.txt ; 不建开始菜单组(用户明确不要) DisableProgramGroupPage=yes AllowNoIcons=yes ; 装到 Program Files 和改防火墙都要管理员 PrivilegesRequired=admin ArchitecturesAllowed=x64compatible ArchitecturesInstallIn64BitMode=x64compatible ; 安装/升级前由 PrepareToInstall 自己强杀正在运行的被控端。 ; ; 为什么不用 Inno 自带的关程序机制(默认开着): ; 它只会礼貌地"请求"对方退出,遇上以管理员身份启动的常驻进程 ; 关不掉,然后弹出"安装程序无法自动关闭所有应用程序", ; 静默模式下这个消息框被压成"终止",**安装直接回滚** —— ; 用户看到的现象是"装了没反应/还是旧版本"。 ; (2026-10 实测踩到:旧实例在跑,新版本装不上,配对码也没写进去。) ; ; 我们自己在 PrepareToInstall 里 taskkill /F,干净利落。 ; 关掉 Inno 自带的那套,免得它先弹框把安装拦下来。 CloseApplications=no RestartApplications=no OutputDir=..\dist OutputBaseFilename=ykxmz-setup-{#MyAppVersion} SetupIconFile=..\windows\app.ico UninstallDisplayIcon={app}\{#MyAppExeName} UninstallDisplayName={#MyAppName} Compression=lzma2/max SolidCompression=yes WizardStyle=modern ShowLanguageDialog=no DisableWelcomePage=no [Languages] Name: "chinese"; MessagesFile: "compiler:Default.isl" [Tasks] ; 用户要的:默认勾上开机自启(取消勾选也允许) Name: "autostart"; Description: "开机自动启动(推荐给教室电脑)"; \ GroupDescription: "启动选项:"; Flags: checkedonce [Files] ; 被控端整个目录(ykxmz.exe + _internal) Source: "..\dist_exe\ykxmz\*"; DestDir: "{app}"; \ Flags: recursesubdirs createallsubdirs ignoreversion ; 三份文档也放一份到安装目录,装完还能翻 Source: "..\docs\LICENSE.txt"; DestDir: "{app}\docs"; Flags: ignoreversion Source: "..\docs\BEFORE-INSTALL.txt"; DestDir: "{app}\docs"; Flags: ignoreversion Source: "..\docs\AFTER-INSTALL.txt"; DestDir: "{app}\docs"; Flags: ignoreversion [Dirs] ; ⚠ 这一节很关键 —— 不配的话装完一运行就报 WinError 5 拒绝访问。 ; ; 被控端的配置存在 %ProgramData%\ykxmz\data 下(exe 旁边写不进去时的回退)。 ; 安装包是管理员跑的,它建的目录默认只有管理员能改; ; 之后普通用户(老师的账号)跑被控端时: ; 能新建文件,但覆盖不了管理员建的那份 settings.json ; 结果就是"装完打开报权限错误"。 ; ; Permissions: users-modify 把所有用户的写权限打开,才是真的能用。 ; 注意末尾不带反斜杠,否则 Inno 会给它单独建一层同名子目录。 Name: "{commonappdata}\ykxmz"; Permissions: users-modify Name: "{commonappdata}\ykxmz\data"; Permissions: users-modify [InstallDelete] ; 早期版本把配置写在程序目录下的 data\ 里。 ; 现在数据统一放 ProgramData(见 config.py 的 data_dir), ; 这里把旧的那份清掉 —— 留着的话,两份配置并存, ; 以后排查问题会被误导("我明明改了配对码怎么没变")。 Type: filesandordirs; Name: "{app}\data" [Run] ; 收尾三步,顺序很重要:先写配对码,再设开机自启,最后才启动。 ; ; ⚠ 为什么放在这里,而不是 [Code] 的 CurStepChanged(ssPostInstall): ; 实测这个 Inno 版本的 ssPostInstall 回调**不会触发** ; (日志显示回调进来了、CurStep=1,但 CurStep <> ssPostInstall 成立; ; 而 Ord(ssPostInstall) 又报类型不匹配,没法打出来对比)。 ; 放在那里 --code 永远不执行,配对码永远是空的。 ; Run 段是安装完成后必然执行的,靠得住。 ; ; {code:GetPairingCode} 是 Inno 的写法:调同名 Pascal 函数取值。 ; Check 函数返回 False 就跳过这一条。 ; 1. 写配对码 Filename: "{app}\{#MyAppExeName}"; Parameters: "--code {code:GetPairingCode}"; \ Flags: runhidden; Check: HasPairingCode ; 1b. 写中转服务器(向导里留空就跳过)。 ; 用户要求"安装时就能设中转服务器" —— 装的时候是第一次设置, ; 最该一次填好,不然装完还得去双击 exe 找设置窗口。 ; ; 用了 --relay-url 而不是 --relay: ; --relay 会连接一次然后退出(人工排查用), ; 放在这条命令链里会让后面的 --silent 跑不到 ; --relay-url 只写配置,不连不问,适合安装流程 ; ; 地址两侧的引号是必须的:URL 里有 "//" 和可能的 ":", ; 不加引号 Inno 会按空格切参数。 Filename: "{app}\{#MyAppExeName}"; Parameters: "--relay-url ""{code:GetRelayUrl}"""; \ Flags: runhidden; Check: HasRelay ; 2. 开机自启 Filename: "{app}\{#MyAppExeName}"; Parameters: "--autostart on"; \ Flags: runhidden; Tasks: autostart ; 3. 启动。 ; 非静默:标准 postinstall 勾选框,用户可以取消。 ; 静默: /VERYSILENT 下 postinstall 那条被 skipifsilent 跳过,所以单独来一条。 ; ; ⚠ 都用 nowait —— --silent 是常驻守护进程,等它退出等于永远不返回。 ; 代价是安装器和它是父子关系:用"等整个进程树"的方式调安装器会卡住 ; (PowerShell 的 Start-Process -Wait 就是)。 ; 学校批量部署请用 cmd: ; cmd /c ykxmz-setup-2.1.0.exe /VERYSILENT /CODE=123456 ; cmd 只等安装器本身,不等孙子进程,不会卡。 Filename: "{app}\{#MyAppExeName}"; Parameters: "--silent"; \ Description: "立即启动(后台运行,不显示窗口)"; \ Flags: nowait postinstall skipifsilent Filename: "{app}\{#MyAppExeName}"; Parameters: "--silent"; \ Flags: nowait runhidden; Check: WizardSilent ; 让用户看一眼装完须知 Filename: "{app}\docs\AFTER-INSTALL.txt"; \ Description: "打开「安装后须知」"; \ Flags: postinstall shellexec unchecked skipifsilent [UninstallDelete] ; 被控端会自己删数据目录,这里兜底再清一遍安装目录里的残留。 ; {commonappdata}\ykxmz 也要清 —— 安装时 [Dirs] 建了它, ; 卸载时**不会自动删**([Dirs] 建的目录不归 [UninstallDelete] 管), ; 不写会留个空目录在那儿。 Type: filesandordirs; Name: "{app}\data" Type: filesandordirs; Name: "{app}\docs" Type: filesandordirs; Name: "{app}\_internal" Type: filesandordirs; Name: "{commonappdata}\ykxmz" ; Inno 默认只在目录空时删它,而卸载器自己的临时文件 ; (_unins.tmp 之类)经常让目录"非空",于是留下一个空壳。 ; 显式写 dirifempty 兜底。 Type: dirifempty; Name: "{app}" [Code] var CodePage: TInputQueryWizardPage; ForcedCode: String; ForcedRelay: String; { 把字符串里的非数字滤掉。 } function OnlyDigits(const raw: String): String; var i: Integer; ch: Char; begin Result := ''; for i := 1 to Length(raw) do begin ch := raw[i]; if (ch >= '0') and (ch <= '9') then Result := Result + ch; end; end; { 配对码 + 中转服务器那一页。 Inno 没有现成的输入页,用官方的 CreateInputQueryPage(不算自己造轮子)。 为什么要在这里也能填中转服务器(用户要求): 装的时候是"第一次设置",那时候最该一次把地址填好 —— 不然装完还得让人去双击 exe 找设置窗口,很容易漏。 装完之后照样能改:双击 exe 打开设置窗口就有这一栏。 } procedure InitializeWizard(); begin { 静默部署可以带 /CODE=123456 /RELAY=https://... 直接把值传进来。 注意:Inno 的 [Code] 段连**注释里的花括号**都会当常量去解析, 所以这里不能直接写 param 那种写法,否则编译器报语法错误。 } ForcedCode := OnlyDigits(ExpandConstant('{param:CODE|}')); ForcedRelay := Trim(ExpandConstant('{param:RELAY|}')); CodePage := CreateInputQueryPage(wpSelectTasks, '配对码和中转服务器', '给这台电脑定一个 6 位配对码', '手表上要输这一串数字才能连上这台电脑。自己定个好记的,' + '比如 789113。' + #13#10 + #13#10 + '中转服务器是**跨网络**才需要的(比如在办公室管教室)。' + '只在一个 WiFi 里用就留空,那样手表和电脑直接连,最快。'); CodePage.Add('6 位配对码:', False); CodePage.Add('中转服务器(可以不填):', False); if ForcedCode <> '' then CodePage.Values[0] := ForcedCode; if ForcedRelay <> '' then CodePage.Values[1] := ForcedRelay; end; { 规范化中转地址。规则必须和 run_agent.py 的 normalize_relay_url() 一致: 去首尾空白 -> 没协议头就补 https:// -> 去末尾斜杠 两边不一致会出现"向导里显示一个样、文件里存另一个样"的怪事。 ⚠ 不用 LowerCase:Inno Pascal 各版本支持不一致, 直接四种写法都比一遍最保险。 } function NormalizeRelay(raw: String): String; var s: String; begin s := Trim(raw); if s = '' then begin Result := ''; Exit; end; { 先削末尾斜杠再补协议头 —— 顺序反了会把 https:// 的斜杠也削掉 } while (Length(s) > 0) and (s[Length(s)] = '/') do Delete(s, Length(s), 1); if (Pos('http://', s) <> 1) and (Pos('HTTP://', s) <> 1) and (Pos('https://', s) <> 1) and (Pos('HTTPS://', s) <> 1) then s := 'https://' + s; Result := s; end; { 校验:必须是 6 位数字。 注意 —— **静默模式下这一页不会显示**,值是空的, 这时候绝不能拦(否则 /VERYSILENT 安装必然失败,我第一版就栽在这)。 } function NextButtonClick(CurPageID: Integer): Boolean; var raw: String; digits: String; begin Result := True; if CurPageID <> CodePage.ID then Exit; if WizardSilent then Exit; raw := CodePage.Values[0]; digits := OnlyDigits(raw); if (Length(digits) <> 6) and (raw <> '') then begin MsgBox('配对码要 6 位数字。' + #13#10 + #13#10 + '你现在填的是:' + raw + #13#10 + '(过滤掉非数字后剩 ' + IntToStr(Length(digits)) + ' 位)' + #13#10 + #13#10 + '重新填一下,或者直接点「取消」退出安装。', mbError, MB_OK); Result := False; Exit; end; { 规范化,后面 [Run] 阶段用得到 } CodePage.Values[0] := digits; { ---- 中转地址的检查 ---- 只做最基本的:不许有空格、不许有明显不对的字符。 不做"能不能连上"的检查 —— 装的时候那台服务器可能还没起, 卡在这里会让用户装不下去。地址写错了装完还能改。 } raw := Trim(CodePage.Values[1]); if raw <> '' then begin if Pos(' ', raw) > 0 then begin MsgBox('中转服务器地址里不能有空格。' + #13#10 + #13#10 + '你填的是:' + raw + #13#10 + #13#10 + '只填域名就行,比如 relay.你的域名.com' + #13#10 + '(协议头 https:// 不写也会自动加上)', mbError, MB_OK); Result := False; Exit; end; if Pos('.', raw) = 0 then begin MsgBox('中转服务器地址看着不像个域名。' + #13#10 + #13#10 + '你填的是:' + raw + #13#10 + #13#10 + '应该形如 relay.你的域名.com。' + #13#10 + '只在一个 WiFi 里用的话,这一栏留空即可。', mbError, MB_OK); Result := False; Exit; end; end; { 规范化后再写回,让用户看到最终会用哪个地址 } CodePage.Values[1] := NormalizeRelay(raw); end; { 最终用的配对码:命令行 /CODE 优先,其次是向导里填的。 两个都没有就返回空串 —— 那就跳过 --code,让被控端自己生成一个。 } function EffectiveCode(): String; begin Result := ForcedCode; if Result = '' then Result := OnlyDigits(CodePage.Values[0]); if Length(Result) <> 6 then Result := ''; end; { 收尾命令现在放在 [Run] 段(见那里的注释)。 原来放在 CurStepChanged(ssPostInstall) 里 —— 实测这个 Inno 版本的 ssPostInstall **根本不会触发**,所以 --code 从来没执行过, 配对码永远是空的。留这个函数只是为了以后有人想改回来时能看到这段历史。 } procedure CurStepChanged(CurStep: TSetupStep); begin { 故意什么都不做。真要恢复旧写法,先确认 ssPostInstall 会触发。 } end; { ===== 给 [Run] 段用的取值 / 判断函数 ===== } { 配对码。配 HasPairingCode 一起用 —— 空串时那条命令会被跳过。 } function GetPairingCode(Param: String): String; begin Result := EffectiveCode(); end; { 有配对码才跑那条 --code 命令 } function HasPairingCode(): Boolean; begin Result := EffectiveCode() <> ''; end; { 最终用的中转地址:命令行 /RELAY 优先,其次是向导里填的。 空的就返回空串 —— 那条命令会被 HasRelay 跳过。 } function EffectiveRelay(): String; begin Result := NormalizeRelay(ForcedRelay); if Result = '' then Result := NormalizeRelay(CodePage.Values[1]); end; { 中转服务器。配 HasRelay 一起用 —— 留空时那条命令会被跳过, 这样"只在一个 WiFi 里用"的人不会多写一个空地址进配置。 } function GetRelayUrl(Param: String): String; begin Result := EffectiveRelay(); end; { 填了中转地址才跑那条 --relay-url 命令 } function HasRelay(): Boolean; begin Result := EffectiveRelay() <> ''; end; { 卸载前先停掉正在跑的进程,不然文件删不干净。 必须在这里做,**不能**改到 UninstallRun 区段去: UninstallRun 是在文件都删完之后才跑的, 那时候"停进程"已经没意义了(文件早没了)。 必须带 --keep-dir: InitializeUninstall 在卸载器开始删文件**之前**执行。 不带 --keep-dir 的话被控端会安排一个后台 rmdir 删安装目录, 而卸载器紧接着也在删同一个目录 —— 两边抢文件会**永久卡住**。 (2026-10 实测:从控制面板卸载无限等待。两个原因叠加: 这一行没带 --keep-dir;以及被控端里 input() 在无 stdin 时阻塞。) } { 安装开始前强杀正在运行的被控端。 必须自己杀,不能指望 Inno 自带的关程序机制 —— 它关不掉以管理员身份启动的常驻进程,然后弹框,静默模式下 被压成"终止",导致整个安装回滚。 taskkill /F /IM ykxmz.exe 只会杀别的实例; 这个进程本身是 setup 的辅助进程,名字不是 ykxmz.exe,不受影响。 返回空串 = 没问题,可以继续安装。 } function PrepareToInstall(var NeedsRestart: Boolean): String; var ResultCode: Integer; begin Result := ''; NeedsRestart := False; if Exec('taskkill.exe', '/F /IM ykxmz.exe', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then begin { 0 = 杀掉了;128 = 本来就没有在跑。都算正常。 } end; { 给它一点时间释放文件句柄,否则覆盖 exe 还是会"拒绝访问" } Sleep(1200); end; { 卸载前先停掉正在跑的进程,不然文件删不干净。 三个参数缺一不可,每个都对应一个实测踩过的卡死/残留问题: * 必须在这里做,**不能**改到 UninstallRun 区段去 —— UninstallRun 是在文件都删完之后才跑的, 那时候"停进程"已经没意义了(文件早没了)。 * 必须带 --keep-dir —— InitializeUninstall 在卸载器开始删文件**之前**执行。 不带的话被控端会安排一个后台 rmdir 删安装目录, 而卸载器紧接着也在删同一个目录 —— 两边抢文件**永久卡住**。 * 必须带 --quiet —— 卸载器是 runhidden 调的,进程没有控制台。 被控端里的 attach_console() 会 AllocConsole + open("CONIN$"), 在无控制台的隐藏进程里**永久阻塞**;input() 也因为没 stdin 卡住。 实测卡住的进程只有 1 个线程、0.16 秒 CPU,就是停在这一步。 --quiet 让它彻底不碰控制台,结果写进 uninstall.log。 实测:不带 --quiet 无限卡住,带了 0.6 秒返回。 } function InitializeUninstall(): Boolean; var ResultCode: Integer; begin Result := True; Exec(ExpandConstant('{app}\{#MyAppExeName}'), '--uninstall --keep-dir --quiet', ExpandConstant('{app}'), SW_HIDE, ewWaitUntilTerminated, ResultCode); end;