Files
ykxmz/installer/ykxmz.iss
T
xmz7891 49fdec1da8 1.8.0:隧道稳定性大修 + 中转管理后台重做
版本号 1.7.1 -> 1.8.0(8 个文件,13 处)。
写了个 bump_180.py 统一处理 —— 版本号散落在
Python / Java manifest / PowerShell / Inno Setup / 测试里,
手动改必漏;而且 .ps1 必须保留 UTF-8 BOM。

==================== 一、隧道稳定性(用户撞上 1033/502/530)====================

1) 4G 链路会**静默断开 TCP 连接**(不发 FIN/RST,双方都以为还活着),
   Linux 默认要 2 小时才发现 -> 改成 keepalive 30 秒 + 每 10 秒探测。
   实测 60 秒内发现并重连。

2) 隧道连接数 2 -> 4。只剩 1 条连接时跨机房回源会失败
   (实测可用率 0/12,提到 4 条后 12/12)。

3) 掉电重启后恢复慢:原来开机 2 分 10 秒公网才可用。
   根因是 systemd-networkd-wait-online 等一个不归它管的网络
   (wlan0 是 NetworkManager 管的),干等 2 分钟超时失败,
   把 network-online.target 一起拖住。
   mask 掉它之后:启动 17.3 秒,掉电重启 24 秒公网可用。

4) 看门狗两次翻车都是我造成的,都修了:
   a) 我先加了"确认故障立刻重启",但隧道重建 4 条连接要 30~60 秒,
      MIN_RESTART_GAP=45 导致**还没建齐就被下一次重启打断**,
      死循环,每 50 秒重启一次 -> 加 150 秒宽限期
   b) 宽限期又带来"链路持续差时什么都不做"的问题
      -> 增加"本次重启后建齐过吗"的判断:
         建齐过又垮 = 真故障,立刻重启
         从没建齐 = 还在努力,继续等

5) 顺手修:看门狗状态文件里的变量在函数定义前被引用,
   set -u 下报 "BUILT: 未绑定的变量"。

==================== 二、中转管理后台重做 ====================

原来的管理页是几百行内联在 relay.py 的 f-string 里,
改一处样式要在一堆转义花括号里翻。拆成独立模块 relay/admin.py。

新设计(对齐官网那套白色简洁):
  吸顶顶栏 -> 公网地址(最大权重,这是用户最需要复制的)
  -> 四个数字卡 -> 设备表 -> 工具卡(体检 + 下载)
  空状态给图标+引导文案;窄屏表格自动变卡片。
  relay.py 从 2089 行缩到 1500 行左右。

修了三个我自己引入的 bug:
  a) 响应头类型写错,浏览器把整页 HTML 当纯文本显示
     (用户看到的是"一屏源码")。教训:200 不等于页面能看,
     当时只验了状态码和字节数。
  b) 顶栏用了固定 height:62px,装不下"标题+副标题+版本信息"两行,
     内容被顶出容器 -> 标题看不见、徽章压在字上。
  c) do_HEAD 没处理 /download/ 路径,HEAD 一个 APK 返回
     Content-Length: 0 + text/plain,下载器会误判成空文件。
     同时发现 _head_info 里对 bytes 调 .encode() —— 加 _as_bytes() 兜住。

==================== 三、其它 ====================

* 设备记录只增不减:实测一天攒 75 条僵尸,/v1/health 从几 KB 涨到 20KB。
  加 DEVICE_TTL(6 小时)自动清,占用的配对码一起释放
  (管理页曾显示"配对码占用 131"但一台设备都没有)。
* HEAD 请求支持(原来一律 501)。
* 官网新增产品展示:手表界面预览(颜色/布局与真实手表一致)、
  电脑端真实截图、两种连接方式对比图。
* 加了一份 docs/免费托管方案.md(含一个纠错:上一版推荐的
  ClawCloud Run 已在 2026-05-11 关停)。

==================== 验收 ====================

全量回归            33 个阶段全部通过
安装包完整周期      20/20(静默装入 7 秒、卸载 0.6 秒、无残留)
网页响应头          12/12
公网可用率          26/30 = 86%(底层 4G 丢包 30%,是这个上限)
发布产物            安装包 14.37MB / 服务端 0.20MB / 手表 0.19MB
2026-10-07 11:47:12 +08:00

353 lines
14 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 "1.8.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
; 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-1.8.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;
{ 把字符串里的非数字滤掉。 }
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。 }
procedure InitializeWizard();
begin
{ 静默部署可以带 /CODE=123456 直接把码传进来(电教批量装用这个)。
注意:Inno 的 [Code] 段连**注释里的花括号**都会当常量去解析,
所以这里不能直接写 param 那种写法,否则编译器报语法错误。 }
ForcedCode := OnlyDigits(ExpandConstant('{param:CODE|}'));
CodePage := CreateInputQueryPage(wpSelectTasks,
'配对码',
'给这台电脑定一个 6 位配对码',
'手表上要输这一串数字才能连上这台电脑。自己定个好记的,' +
'比如 789113。装完之后也可以在被控端窗口里改。');
CodePage.Add('6 位配对码:', False);
if ForcedCode <> '' then
CodePage.Values[0] := ForcedCode;
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;
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;
{ 卸载前先停掉正在跑的进程,不然文件删不干净。
必须在这里做,**不能**改到 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;