diff --git a/_build/add_verify_stage.py b/_build/add_verify_stage.py new file mode 100644 index 0000000..bae65fe --- /dev/null +++ b/_build/add_verify_stage.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +r'''给 verify_all.ps1 追加阶段 —— **不能用 PowerShell heredoc**。 + +== 为什么单独写这个脚本 == + +我这一轮在 PowerShell 里用 `python -c @"..."@` 写 Python 代码 +去改 verify_all.ps1,结果 **`$stageOut` 和 `$root` 被 PowerShell +当成变量给展开了**(`@"..."@` 是插值字符串),写进去变成: + + = & python (Join-Path "_tests\check_version_consistency.py") 2>&1 + Show-Fails + Record "版本号一致性" (0 -eq 0) + +整个脚本直接语法错。**同一个坑踩了两次**(3a7 和 3a8)。 + +教训:凡是给 .ps1 加含 `$` 的内容,一律**写 Python 脚本文件再跑**, +不要用 `python -c @"..."@`。 + +用法: + python _build/add_verify_stage.py <锚点文字> <阶段号> <标题> <脚本相对路径> +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SUITE = ROOT / "_tests" / "verify_all.ps1" + + +def main() -> int: + if len(sys.argv) < 6: + print(__doc__) + return 1 + anchor_text, stage_no, title, script_rel, record_name = sys.argv[1:6] + + raw = SUITE.read_bytes() + had_bom = raw.startswith(b"\xef\xbb\xbf") + text = raw.decode("utf-8-sig") + + if script_rel in text: + print(" 已经注册过了(%s 里提到了 %s)" % (SUITE.name, script_rel)) + return 0 + + lines = text.splitlines() + idx = None + for i, ln in enumerate(lines): + if ln.startswith("Record") and anchor_text in ln: + idx = i + break + if idx is None: + print(" ✗ 找不到锚点(Record 行含 %r)" % anchor_text) + print(" 现有 Record 行:") + for i, ln in enumerate(lines): + if ln.startswith("Record"): + print(" %d: %s" % (i, ln)) + return 1 + + block = [ + "", + "# ---------------------------------------------------------------- %s" + % stage_no, + 'Banner "%s"' % title, + "$stageOut = & python (Join-Path $root \"%s\") 2>&1" + % script_rel.replace("/", "\\"), + " Show-Fails $stageOut", + 'Record "%s" ($LASTEXITCODE -eq 0)' % record_name, + ] + lines[idx + 1:idx + 1] = block + + out = "\n".join(lines) + "\n" + SUITE.write_bytes((b"\xef\xbb\xbf" if had_bom else b"") + out.encode("utf-8")) + print(" ✓ 已在「%s」后面插入阶段 %s" % (anchor_text, stage_no)) + print(" (BOM 保留:%s)" % had_bom) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/bump_version_generic.py b/_build/bump_version_generic.py index 5a2332e..37c42a1 100644 --- a/_build/bump_version_generic.py +++ b/_build/bump_version_generic.py @@ -83,9 +83,27 @@ def main() -> int: changed += 1 # ---- Windows EXE 版本信息 ---- - if swap(ROOT / "windows" / "version_info.txt", - [(f'"{OLD}.0"', f'"{NEW}.0"')], - f"EXE 文件版本 {NEW}.0"): + # ⚠ 原来用的是 f'"{OLD}.0"',一旦 OLD 和文件里对不上就静默跳过 + # (version_info.txt 就是这么从 1.7.1 一直漏到现在的)。 + # 改成正则匹配任意四段版本。 + import re as _re + _vi = ROOT / "windows" / "version_info.txt" + if _vi.is_file(): + _raw = _vi.read_bytes() + _bom = _raw.startswith(b"\xef\xbb\xbf") + _txt = _raw.decode("utf-8-sig") + _new = _re.sub( + r'(StringStruct\("(?:File|Product)Version",\\s*")\\d+\\.\\d+\\.\\d+\\.\\d+("\))', + r'\\g<1>' + NEW + r'.0\\g<2>', _txt) + if _new != _txt: + _vi.write_bytes((b"\xef\xbb\xbf" if _bom else b"") + + _new.replace("\\r\\n", "\\n").encode("utf-8")) + print(f" version_info.txt: EXE 文件版本 {NEW}.0") + changed += 1 + else: + print(" [跳过] version_info.txt") + + if False: changed += 1 # ---- __init__ ---- @@ -100,6 +118,22 @@ def main() -> int: f"VERSION={NEW}"): changed += 1 + # ---- 安装包版本 ---- + # ⚠ 原来漏了这三处,导致 version_info.txt 从 1.7.1 起就一直没跟上 + # (用户在"文件属性 -> 详细信息"里看到一个错三个版本的号)。 + if swap(ROOT / "installer" / "ykxmz.iss", + [(f'#define MyAppVersion "{OLD}"', + f'#define MyAppVersion "{NEW}"'), + (f'ykxmz-setup-{OLD}.exe', f'ykxmz-setup-{NEW}.exe')], + f"安装包版本 {NEW}"): + changed += 1 + + # ---- 安装许可页上的版本 ---- + if swap(ROOT / "installer" / "license-combined.txt", + [(f"版本:{OLD}", f"版本:{NEW}")], + f"许可页版本 {NEW}"): + changed += 1 + # ---- 三份文档 ---- for path in sorted((ROOT / "docs").glob("*.txt")): raw = path.read_bytes() diff --git a/_build/fix_license_version.py b/_build/fix_license_version.py new file mode 100644 index 0000000..9d2d054 --- /dev/null +++ b/_build/fix_license_version.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +r'''修 license-combined.txt 里那份**没过期的**协议版本号。 + +这个文件是三份文档拼起来的(许可协议 + 安装前必读 + 版本记录), +每份都有自己的头部版本号和落款。实测第一份的头部还写着 1.7.1: + + 第 6 行: '版本:1.7.1' <- 头部,要改 + 第 153 行: ' 软件:ykxmz 1.7.1' <- 落款,要改 + 第 540 行: ' 1.7.1' <- **版本记录**(变更日志), + 历史条目,**不能改** + +前两次我用的整体 replace 都不够精确: + * 第一次 `text.replace('版本:1.7.1', ...)` 报了成功,但改完 + 又被后一步覆盖掉了(顺序问题) + * 而且整体 replace 会连带把版本记录里的历史条目也改掉 + +所以这次**按行号精确改**:只动 6 和 153。 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +FILE = ROOT / "installer" / "license-combined.txt" +NEW = "1.9.0" + + +def main() -> int: + raw = FILE.read_bytes() + had_bom = raw.startswith(b"\xef\xbb\xbf") + text = raw.decode("utf-8-sig") + lines = text.splitlines() + + changed = [] + # 按内容精确匹配,避免行号漂移 + for i, ln in enumerate(lines): + s = ln.strip() + # 头部:版本:X.Y.Z + if s.startswith("版本:") and s != ("版本:" + NEW): + old = s + lines[i] = ln.replace(s, "版本:" + NEW) + changed.append((i + 1, old, lines[i].strip())) + # 落款:软件:ykxmz X.Y.Z + elif s.startswith("软件:ykxmz ") and s != ("软件:ykxmz " + NEW): + old = s + lines[i] = ln.replace(s, "软件:ykxmz " + NEW) + changed.append((i + 1, old, lines[i].strip())) + + if not changed: + print(" ✓ 没有要改的") + return 0 + + print(" ── 改了这些行 ──") + for n, old, new in changed: + print(" 第 %3d 行: %r -> %r" % (n, old, new)) + + out = "\n".join(lines) + "\n" + FILE.write_bytes((b"\xef\xbb\xbf" if had_bom else b"") + + out.encode("utf-8")) + + # 复核:版本记录那一段不许动 + after = FILE.read_text(encoding="utf-8-sig") + if "八、版本记录" not in after: + print(" ✗ 版本记录那一段不见了!") + return 1 + seg = after.split("八、版本记录", 1)[1] + if " 1.7.1" not in seg: + print(" ⚠ 版本记录里的历史条目 1.7.1 被改掉了 —— 那是不该动的") + return 1 + print(" ✓ 版本记录里的历史条目(1.7.1)保持原样") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/package_remote.py b/_build/package_remote.py index d659101..2dd7044 100644 --- a/_build/package_remote.py +++ b/_build/package_remote.py @@ -17,7 +17,7 @@ import zipfile from pathlib import Path ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote") -VERSION = "1.8.2" +VERSION = "1.9.0" NAME = f"ykxmz-{VERSION}" AGENT_SRC = ROOT / "dist_exe" / "ykxmz" diff --git a/_tests/check_version_consistency.py b/_tests/check_version_consistency.py new file mode 100644 index 0000000..e904763 --- /dev/null +++ b/_tests/check_version_consistency.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- +r'''检查全仓的产品版本号是不是**一致**的。 + +== 为什么需要这个 == + +版本号散落在至少 8 个地方,靠脚本 + 人工替换,很容易漏。 +实测就漏了两个: + + windows/version_info.txt 写着 1.7.1(EXE 属性里显示的版本) + installer/license-combined.txt 写着 1.7.1 + +漏了不会报错 —— 只是用户在"文件属性 → 详细信息"里看到一个 +对不上的版本号,或者安装时许可页写着旧版本。很难发现。 + +== 判定规则(用户定的)== + +只查**产品版本号**,不碰这两类"看着像版本号但其实不是"的: + + * HTTP User-Agent "LanRemote/1.4.0" —— 协议标识, + 20 多个测试在断言它,动它要同步改一大片 + * _tests / _build 里当测试数据或历史记录用的版本串 + +真正要一致的: + windows/lanremote/__init__.py __version__ + windows/version_info.txt FileVersion + android/AndroidManifest.xml versionName + android/build_apk.ps1 APK 文件名 + installer/ykxmz.iss MyAppVersion + installer/license-combined.txt 许可页上写的版本 + relay/relay.py VERSION + _build/package_remote.py VERSION +''' +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +# (文件, 正则, 说明) +SPOTS = [ + ("windows/lanremote/__init__.py", r'__version__\s*=\s*"([^"]+)"', + "被控端 __version__"), + ("windows/version_info.txt", r'StringStruct\("FileVersion",\s*"([^"]+)"\)', + "EXE 文件属性里的版本"), + ("android/AndroidManifest.xml", r'versionName="([^"]+)"', + "手表 App 版本"), + ("android/build_apk.ps1", r'ykxmz-watch-([\d.]+)\.apk', + "APK 文件名"), + ("installer/ykxmz.iss", r'MyAppVersion\s+"([^"]+)"', + "安装包版本"), + ("installer/license-combined.txt", r'版本:\s*([\d.]+)', + "安装许可页上的版本"), + ("relay/relay.py", r'VERSION\s*=\s*"([^"]+)"', + "中转服务端版本"), + ("_build/package_remote.py", r'VERSION\s*=\s*"([^"]+)"', + "打包脚本版本"), +] + + +def norm(v: str) -> str: + """把 1.9.0.0 这种四段式归一成三段(Windows 的文件版本要求四段)。""" + parts = v.strip().split(".") + while len(parts) > 3 and parts[-1] == "0": + parts.pop() + return ".".join(parts[:3]) + + +def main() -> int: + found = {} + problems = [] + + for rel, pat, note in SPOTS: + p = ROOT / rel + if not p.is_file(): + problems.append("%s 不存在(应该改路径或删掉这条)" % rel) + continue + try: + text = p.read_text(encoding="utf-8-sig") + except Exception as exc: # noqa: BLE001 + problems.append("%s 读不了:%s" % (rel, exc)) + continue + m = re.search(pat, text) + if not m: + problems.append("%s 里找不到版本号(正则:%s)" % (rel, pat)) + continue + found[rel] = (m.group(1), note) + + if not found: + print(" 一个版本号都没找到 —— 正则可能全过时了") + return 1 + + versions = {norm(v): v for v, _n in found.values()} + print(" ── 各处的版本号 ──") + for rel, (v, note) in found.items(): + print(" %-40s %-10s %s" % (rel, v, note)) + + print() + if len(versions) == 1: + only = list(versions.values())[0] + print(" ✓ 全部一致:%s" % only) + else: + print(" ✗ **版本号不一致**:") + for rel, (v, _n) in found.items(): + print(" %-40s %s" % (rel, v)) + print() + print(" 版本号散落在多处,用脚本统一改:") + print(" python _build\\bump_version_generic.py <旧> <新>") + print(" 但那个脚本**漏了** installer/ykxmz.iss 和") + print(" installer/license-combined.txt,改完要手动补。") + return 1 + + if problems: + print(" ⚠ 另外还有 %d 个地方要处理:" % len(problems)) + for x in problems: + print(" %s" % x) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_tests/test_apk_contents.py b/_tests/test_apk_contents.py index daec745..40415a8 100644 --- a/_tests/test_apk_contents.py +++ b/_tests/test_apk_contents.py @@ -23,7 +23,7 @@ def _latest_apk() -> Path: (ROOT / "dist").glob("*watch*.apk"), key=lambda p: p.stat().st_mtime, reverse=True) return candidates[0] if candidates else \ - ROOT / "dist" / "ykxmz-watch-1.8.2.apk" + ROOT / "dist" / "ykxmz-watch-1.9.0.apk" APK = _latest_apk() diff --git a/_tests/test_package_remote.py b/_tests/test_package_remote.py index 501d000..f1eb582 100644 --- a/_tests/test_package_remote.py +++ b/_tests/test_package_remote.py @@ -26,9 +26,9 @@ ROOT = Path(__file__).resolve().parent.parent # 被控端 -> 一个 zip # 服务器端 -> 一个 zip # 控制端 -> 直接给 APK(不打 zip) -AGENT_ZIP = ROOT / "dist" / "ykxmz-agent-1.8.2.zip" -SERVER_ZIP = ROOT / "dist" / "ykxmz-server-1.8.2.zip" -WATCH_APK = ROOT / "dist" / "ykxmz-watch-1.8.2.apk" +AGENT_ZIP = ROOT / "dist" / "ykxmz-agent-1.9.0.zip" +SERVER_ZIP = ROOT / "dist" / "ykxmz-server-1.9.0.zip" +WATCH_APK = ROOT / "dist" / "ykxmz-watch-1.9.0.apk" TOOLCHAIN = Path(r"C:\Users\shu\Desktop\deepseekagent\starklink\toolchain") JAVAC = TOOLCHAIN / "jdk17" / "bin" / "javac.exe" JAVA = TOOLCHAIN / "jdk17" / "bin" / "java.exe" @@ -89,7 +89,7 @@ def main() -> int: for label, path in (("被控端", agent_dir / "README-agent.txt"), ("服务器端", relay_dir / "README-server.txt"), ("控制端(旁边那份)", - ROOT / "dist" / "ykxmz-watch-1.8.2-说明.txt")): + ROOT / "dist" / "ykxmz-watch-1.9.0-说明.txt")): raw = path.read_bytes() if path.is_file() else b"" check(f"{label}说明有 UTF-8 BOM", raw[:3] == b"\xef\xbb\xbf", raw[:3]) diff --git a/_tests/verify_all.ps1 b/_tests/verify_all.ps1 index 6b157ce..a6662b8 100644 --- a/_tests/verify_all.ps1 +++ b/_tests/verify_all.ps1 @@ -413,6 +413,12 @@ $stageOut = & python (Join-Path $root "_tests\check_python_syntax.py") 2>&1 Show-Fails $stageOut Record "Python 语法检查" ($LASTEXITCODE -eq 0) +# ---------------------------------------------------------------- 3a8 +Banner "3a8/7 版本号一致性(防漏改)" +$stageOut = & python (Join-Path $root "_tests\check_version_consistency.py") 2>&1 + Show-Fails $stageOut +Record "版本号一致性" ($LASTEXITCODE -eq 0) + # ---------------------------------------------------------------- 3a5 Banner "3a5/7 局域网/中转对比基准(防测试自己骗人)" # 这个基准一度是红的,而且报告里写着「这是中转链路的产品 bug」—— diff --git a/android/AndroidManifest.xml b/android/AndroidManifest.xml index 6480db1..d729125 100644 --- a/android/AndroidManifest.xml +++ b/android/AndroidManifest.xml @@ -2,7 +2,7 @@ + android:versionName="1.9.0"> diff --git a/android/build_apk.ps1 b/android/build_apk.ps1 index e0db09a..287a185 100644 --- a/android/build_apk.ps1 +++ b/android/build_apk.ps1 @@ -27,7 +27,7 @@ $d8 = Join-Path $bt "d8.bat" $work = Join-Path $android "build" $outDir = Join-Path $root "dist" -$apkName = "ykxmz-watch-1.8.2.apk" +$apkName = "ykxmz-watch-1.9.0.apk" function Step($text) { Write-Host "`n=== $text ===" -ForegroundColor Cyan } @@ -65,7 +65,7 @@ $unsigned = Join-Path $work "app-unsigned.apk" --min-sdk-version 24 ` --target-sdk-version 34 ` --version-code 5 ` - --version-name "1.8.2" ` + --version-name "1.9.0" ` --auto-add-overlay if ($LASTEXITCODE -ne 0) { throw "aapt2 link 失败" } diff --git a/docs/AFTER-INSTALL.txt b/docs/AFTER-INSTALL.txt index 6bfb1bb..abe0cba 100644 --- a/docs/AFTER-INSTALL.txt +++ b/docs/AFTER-INSTALL.txt @@ -3,7 +3,7 @@ ======================================================== 软件名称:ykxmz -版本:1.8.2 +版本:1.9.0 作者:小麦子 装好之后看这一页:怎么确认它在跑、怎么改设置、 @@ -197,7 +197,7 @@ 八、版本记录 -------------------------------------------------------- - 1.8.2 + 1.9.0 * 下载改走 Cloudflare 边缘缓存 —— 安装包和 APK 快了几十倍 * 跨网络更稳:隧道**两种协议同时跑**(QUIC + HTTP/2, 合计 10 条连接),一种协议犯病另一种还在服务 @@ -218,6 +218,6 @@ -------------------------------------------------------- 作者:小麦子 - 软件:ykxmz 1.8.2 + 软件:ykxmz 1.9.0 许可条款见《许可协议》(LICENSE.txt) -------------------------------------------------------- diff --git a/docs/BEFORE-INSTALL.txt b/docs/BEFORE-INSTALL.txt index c20e749..e13b806 100644 --- a/docs/BEFORE-INSTALL.txt +++ b/docs/BEFORE-INSTALL.txt @@ -3,7 +3,7 @@ ======================================================== 软件名称:ykxmz -版本:1.8.2 +版本:1.9.0 作者:小麦子 请你**在安装之前**花三分钟读完这一页。里面有几条很要紧的 @@ -164,6 +164,6 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成: -------------------------------------------------------- 作者:小麦子 - 软件:ykxmz 1.8.2 + 软件:ykxmz 1.9.0 继续安装,即表示你已阅读并同意《许可协议》。 -------------------------------------------------------- diff --git a/docs/项目规矩.md b/docs/项目规矩.md index bd69272..71e4e4f 100644 --- a/docs/项目规矩.md +++ b/docs/项目规矩.md @@ -60,8 +60,26 @@ ## 三、发版必须更新版本号 > 用户原话:「自行更新版号」 +> 后来又补了怎么加的规定(见下表) -每次上传都要升版本号。版本号散落在多处,**用脚本统一改**: +每次上传都要升版本号。**怎么加按改动性质定**: + +| 改动性质 | 怎么加 | 例子 | +|---|---|---| +| **只修 bug** | 最后一位 +1 | 1.8.2 → **1.8.3** | +| **加了新内容 / 重构** | 第二位 +1,**最后一位清 0** | 1.8.2 → **1.9.0** | + +⚠️ **一轮里只要同时有"新东西"和"修 bug",就按新东西算**(升第二位)。 +比如某一轮既加了设置窗口的中转服务器字段(新功能)、 +又把面板切换重构成 `showOnly()`(重构)、还修了 502(bug)—— +那就是 **1.9.0**,不是 1.8.3。 + +**为什么这条重要**:下载站是按 URL 缓存的 +(`/download/ykxmz-setup-1.8.2.exe` 带 `max-age=604800, immutable`)。 +**内容变了但不换 URL,缓存就会失效一次** —— 实测重新打包 server.zip +之后第一次请求 `cf-cache-status=MISS`。升版本号正好把 URL 换掉。 + +版本号散落在多处,**用脚本统一改**: ```powershell python _build\bump_180.py # 改版本号时把里面的 OLD/NEW 改掉 diff --git a/installer/build_installer.py b/installer/build_installer.py index 4f35972..dc5a5bd 100644 --- a/installer/build_installer.py +++ b/installer/build_installer.py @@ -32,7 +32,22 @@ def build_license() -> None: """三份文档拼成一份,给 Inno 的许可页用。 每份之间加分隔线,免得读起来像一份文档。 + + ⚠ 这里**顺手把版本号注入进去**,不让它依赖源文档里写的是什么。 + + 为什么:源文档里的版本号是硬编码的,实测 docs/LICENSE.txt 一直 + 停在 1.7.1(升级脚本只替换当时那个版本号,比如 1.8.2 -> + 1.9.0,而 LICENSE.txt 里写的是 1.7.1,就永远匹配不上)。 + 结果安装时许可页上显示的是 1.7.1。 + + 改成在这里统一注入之后,源文档写什么都不影响: + 版本:X.Y.Z -> 版本:<当前版本> + 软件:ykxmz X.Y.Z -> 软件:ykxmz <当前版本> + + ⚠ 只改**头部和落款**这两个格式,不改"八、版本记录"里的历史条目 + —— 那是变更日志,本来就是一堆旧版本号。 """ + version = current_version() parts = [] for index, name in enumerate(DOCS): path = ROOT / "docs" / name @@ -40,6 +55,23 @@ def build_license() -> None: print(f" [警告] 缺少 {name}") continue text = path.read_text(encoding="utf-8-sig").strip() + + # 把"版本:x.y.z"和"软件:ykxmz x.y.z"换成当前版本, + # 但跳过"版本记录"那一段(历史日志)。 + out_lines = [] + in_history = False + for line in text.splitlines(): + stripped = line.strip() + if "版本记录" in stripped: + in_history = True + if not in_history: + if re.match(r"^版本:\d+\.\d+\.\d+$", stripped): + line = line.replace(stripped, f"版本:{version}") + elif re.match(r"^软件:ykxmz \d+\.\d+\.\d+$", stripped): + line = line.replace(stripped, f"软件:ykxmz {version}") + out_lines.append(line) + text = "\n".join(out_lines) + if index: parts.append("") parts.append("=" * 70) @@ -49,7 +81,8 @@ def build_license() -> None: # 装许可页的正文要 UTF-8 **带 BOM**,否则 Inno 在中文系统上会显示乱码 COMBINED.write_bytes(b"\xef\xbb\xbf" + body.encode("utf-8")) - print(f" 合并三份文档 -> {COMBINED.name}({len(body)} 字符)") + print(f" 合并三份文档 -> {COMBINED.name}" + f"({len(body)} 字符,版本注入为 {version})") def sync_version(version: str) -> None: diff --git a/installer/license-combined.txt b/installer/license-combined.txt index bc479ff..b2420a7 100644 --- a/installer/license-combined.txt +++ b/installer/license-combined.txt @@ -3,7 +3,7 @@ ======================================================== 软件名称:ykxmz(远程管理工具) -版本:1.7.1 +版本:1.9.0 作者:小麦子 生效日期:2026 年 10 月 5 日 @@ -150,7 +150,7 @@ -------------------------------------------------------- 作者:小麦子 - 软件:ykxmz 1.7.1 + 软件:ykxmz 1.9.0 如果你不同意本协议的任何一条,请不要安装或使用本软件。 -------------------------------------------------------- @@ -162,7 +162,7 @@ ======================================================== 软件名称:ykxmz -版本:1.8.2 +版本:1.9.0 作者:小麦子 请你**在安装之前**花三分钟读完这一页。里面有几条很要紧的 @@ -323,7 +323,7 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成: -------------------------------------------------------- 作者:小麦子 - 软件:ykxmz 1.8.2 + 软件:ykxmz 1.9.0 继续安装,即表示你已阅读并同意《许可协议》。 -------------------------------------------------------- @@ -334,7 +334,7 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成: ======================================================== 软件名称:ykxmz -版本:1.8.2 +版本:1.9.0 作者:小麦子 装好之后看这一页:怎么确认它在跑、怎么改设置、 @@ -528,7 +528,7 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成: 八、版本记录 -------------------------------------------------------- - 1.8.2 + 1.9.0 * 下载改走 Cloudflare 边缘缓存 —— 安装包和 APK 快了几十倍 * 跨网络更稳:隧道**两种协议同时跑**(QUIC + HTTP/2, 合计 10 条连接),一种协议犯病另一种还在服务 @@ -549,6 +549,6 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成: -------------------------------------------------------- 作者:小麦子 - 软件:ykxmz 1.8.2 + 软件:ykxmz 1.9.0 许可条款见《许可协议》(LICENSE.txt) -------------------------------------------------------- diff --git a/installer/ykxmz.iss b/installer/ykxmz.iss index 79c2d4b..ad3909e 100644 --- a/installer/ykxmz.iss +++ b/installer/ykxmz.iss @@ -1,352 +1,352 @@ -; ============================================================================ -; 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.2" -#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.2.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; +; ============================================================================ +; 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.9.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.9.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; diff --git a/relay/relay.py b/relay/relay.py index 47f93a8..1721558 100644 --- a/relay/relay.py +++ b/relay/relay.py @@ -59,7 +59,7 @@ from collections import deque from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path -VERSION = "1.8.2" +VERSION = "1.9.0" # ---------------- 参数(都能用环境变量覆盖)---------------- diff --git a/windows/lanremote/__init__.py b/windows/lanremote/__init__.py index 81d68f6..6948f31 100644 --- a/windows/lanremote/__init__.py +++ b/windows/lanremote/__init__.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- """局域网遥控 · Windows 被控端。""" -__version__ = "1.8.2" +__version__ = "1.9.0" APP_NAME = "ykxmz" diff --git a/windows/version_info.txt b/windows/version_info.txt index 8a8da10..150f0ed 100644 --- a/windows/version_info.txt +++ b/windows/version_info.txt @@ -27,12 +27,12 @@ VSVersionInfo( [ StringStruct("CompanyName", "ykxmz"), StringStruct("FileDescription", "ykxmz"), - StringStruct("FileVersion", "1.7.1.0"), + StringStruct("FileVersion", "1.9.0.0"), StringStruct("InternalName", "ykxmz"), StringStruct("LegalCopyright", "ykxmz"), StringStruct("OriginalFilename", "ykxmz.exe"), StringStruct("ProductName", "ykxmz"), - StringStruct("ProductVersion", "1.7.1.0"), + StringStruct("ProductVersion", "1.9.0.0"), ], ) ]