Files
ykxmz/installer/ykxmz.iss
T
xmz7891 4295b482a1 2.0.0:安装时可设中转服务器 + 手表端新增设置页 + 中转默认留空
按用户定的版本规矩,这一轮既有新内容又有重构,所以升第二位:
1.9.0 -> 2.0.0(最后一位清 0)。

== 一、安装时就能设中转服务器(用户要求)==

安装向导那一页现在是两个输入框:配对码 + 中转服务器。
装的时候是"第一次设置",最该一次把地址填好,不然装完还得去
双击 exe 找设置窗口,很容易漏。

只填域名就行(自动补 https://),留空 = 只走局域网。
批量静默部署同样支持:
    ykxmz-setup-2.0.0.exe /VERYSILENT /CODE=246810 /RELAY=relay.你的域名

为此新增了一个 --relay-url 参数:
    --relay       连接一次、打印状态、然后退出(人工排查用)
    --relay-url   只写进配置,不连接、不退出(安装流程用)
用错那个的话,安装包的命令链会在中间就 return,后面的 --silent
根本跑不到 —— 所以必须分开。

== 二、手表端新增「设置」页(用户要求)==

用户原话:"安卓端加一个设置,各种设置就在这里面调"。
以前设置散在首页(只有一个"中转设置"按钮),现在统一收进设置页:
    * 中转服务器(填写 / 一键清空)
    * 记住的配对码(显示 / 清除)
    * 已配对的电脑(看数量 / 清空,带二次确认)
    * 关于(版本号从 APK 读,不会和代码对不上)

== 三、中转地址默认留空 + 一次性迁移(用户报的问题)==

用户原话:"安卓端为什么还是默认填的我的中转站,以后改成手动填"。

代码里的默认值早就是空的了,但用户看到的还是那个地址 ——
因为**老版本 1.5.0 把作者的域名硬编码成了默认值**,第一次运行就
存进了 SharedPreferences。装新 APK 不会清偏好,所以那个值一直留着。
用户其实从来没"选"过这个地址,是程序硬塞的。

所以加了 migrateLegacyRelay():存的值如果正好等于当时内置的域名,
就当成没设过、清掉。只清完全相等的,用户手填的不会动。

== 四、两个测试自身的毛病 ==

* test_lan_optimize 那条"没有把旧公网域名当默认值"太粗 ——
  它查的是源码里任何位置,于是迁移注释里引用那行老代码就把它判红了。
  改成先剥掉注释再查(断言要测的是"有没有把它当默认值")。

* test_image_modes 偶发红:它开真实 Tk 全屏窗口,在全量套件里跑
  偶尔起不来(单独跑 19/19 全过)。加了有界重试。
  偶发红的测试会让"全量通过"失去意义。

== 五、新增防复发断言 ==

_tests/test_relay_default_blank.py(15 项,回归阶段 3a3b):
    * 默认值必须是空串
    * 除了迁移清单,源码里不许有写死的中转域名
    * 迁移必须真的被调用(不是只定义)
    * 迁移必须是 remove,不能换成另一个硬编码值
    * Windows 端的默认也得是空

理由:用户说过"这个中转站我就自己用,不对外" —— 值得用断言钉死。

== 六、顺手修的 ==

* release_all.py 崩在 Gitee 那一步:Gitee 的
  GET /releases/tags/{tag} 在"标签存在、但还没发行版"时返回的是
  **HTTP 200 + JSON null**(不是 404),于是 .get("id") 直接
  AttributeError。发 1.9.0 时踩到,已改成容忍 None。

* installer/build_installer.py 里 license-combined.txt 是从
  docs/ 三份文档重新生成的,所以光改生成物没用(下次构建又变回去)。
  改成在 build_license() 里统一注入当前版本。

* ExE 文件属性里的版本(version_info.txt)从 1.7.1 起就一直没跟上。

全量回归 43 个阶段全部通过。
2026-10-07 23:02:44 +08:00

458 lines
18 KiB
Plaintext
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.
; ============================================================================
; 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.0.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.0.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;