From 1c7285cea9845e00aa732f81c8361eb2d5e1cb94 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 1 Nov 2026 21:50:08 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat(cli):=20GUI=20=E4=BA=8C=E8=BF=9B?= =?UTF-8?q?=E5=88=B6=E5=86=85=E5=BB=BA=20CLI=20=E5=AD=90=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E5=88=86=E5=8F=91=EF=BC=8Cull=20=E7=BA=B3=E5=85=A5=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E8=B5=84=E4=BA=A7=EF=BC=88M82=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一个二进制两种用法:两个 GUI 的 main.rs 在进 UI 之前分发子命令,命中就把控制权交给 unlucky_cli::run —— 不建窗口、不探后端、不扫字体、不 dlopen 图形库。 分发判据只有一条,子命令表不重复:crates/cli 的解析器是唯一真相源。 - 首个 token 是裸词 → 子命令位置,交 CLI(未知子命令由 CLI 报错 + 提示 --help,退出 1, 不静默开窗) - 首个 token 是 flag → 只有 CLI 解析出真命令才算 CLI 调用;--data-dir X 解析成 Welcome、 --screenshot / --renderer / --diff 等 GUI 参数解析失败,一律仍走 GUI(CI 脚本零改动) 顺带:unlucky-launcher --version 以前会直接开窗,现在走 CLI 分发报版本。 packaging:ull 作为独立裸资产随每个槽位发布(make_artifacts.py + release.yml 的 assets 数组与 SHA256SUMS glob),矩阵完整性检查改成 GUI/CLI 各查一次。 app-iced 的 x11 / wayland / linux-theme-detection 收进 target 专属的 cfg(target_os) 段。 门禁实跑:cargo fmt --all --check 通过;cargo clippy --workspace --all-targets -D warnings 通过(exit 0);cargo test --workspace 通过(51 个测试二进制 / 1369 个用例,含本次新增的 5 + 5 条分发判据用例)。 --- .github/workflows/release.yml | 17 ++-- Cargo.lock | 2 + README.md | 59 +++++++++++--- README.zh-CN.md | 52 +++++++++--- crates/app-iced/Cargo.toml | 23 +++++- crates/app-iced/src/main.rs | 147 +++++++++++++++++++++++++++++++--- crates/app/Cargo.toml | 5 ++ crates/app/src/main.rs | 130 +++++++++++++++++++++++++++++- packaging/README.md | 30 +++++-- packaging/make_artifacts.py | 31 ++++++- packaging/release-notes.md | 14 +++- 11 files changed, 457 insertions(+), 53 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d2870b0..bbf467b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -281,16 +281,20 @@ jobs: set -euo pipefail ls -l dist # 六槽位全齐才发版:缺任意一个就 fail(宁可不出 Release,也不出少一档的 Release)。 + # 每个槽位都要 GUI 与 CLI 两个资产——只按「槽位名出现过」判会被其中一个掩盖。 for slot in windows-x64 windows-arm64 linux-x64 linux-arm64 macos-arm64 macos-x64; do - if ! compgen -G "dist/*-$slot*" >/dev/null; then - echo "::error::矩阵缺产物:$slot" - exit 1 - fi + for prefix in unlucky-launcher ull; do + if ! compgen -G "dist/$prefix-*-$slot*" >/dev/null; then + echo "::error::矩阵缺产物:$prefix / $slot" + exit 1 + fi + done done # 各平台任务各写一份 SHA256SUMS.txt,合并后只有一份是真的 —— 在 Release 侧重算。 + # GUI 与 CLI 都要进清单:漏了 ull 就是「发了但没有校验和」。 cd dist rm -f SHA256SUMS.txt - sha256sum unlucky-launcher-* > SHA256SUMS.txt + sha256sum unlucky-launcher-* ull-* > SHA256SUMS.txt sha256sum -c SHA256SUMS.txt cat SHA256SUMS.txt @@ -344,7 +348,8 @@ jobs: esac # Windows 单 exe 装不下 LICENSE/NOTICE/README.md,这三份与产物并列挂到 Release 上, # 保证每个下载平台的许可条款都随包分发。 - assets=(dist/unlucky-launcher-* dist/SHA256SUMS.txt LICENSE NOTICE README.md) + # `ull` 是独立资产(不在 tar.gz 里):headless 用户下一个小文件直接跑,不用解档。 + assets=(dist/unlucky-launcher-* dist/ull-* dist/SHA256SUMS.txt LICENSE NOTICE README.md) if gh release view "$TAG" >/dev/null 2>&1; then # 同 tag 重跑(补丁 / 重编):Release 已存在 → 覆盖资产 + 刷新说明, # 不再 `create`(那会因已存在而失败,把整条流水线在最后一步染红)。 diff --git a/Cargo.lock b/Cargo.lock index 537d306..61545ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6892,6 +6892,7 @@ dependencies = [ "slint", "slint-build", "unlucky-agent", + "unlucky-cli", "unlucky-core", ] @@ -6901,6 +6902,7 @@ version = "0.1.0" dependencies = [ "iced", "image", + "unlucky-cli", "unlucky-design", ] diff --git a/README.md b/README.md index 2a5fe04..534ed1c 100644 --- a/README.md +++ b/README.md @@ -43,10 +43,14 @@ Two tracks. They are not the same product yet, so read the headings before downl already contain them. - **UI**: Slint + Metro. This is the shipped GUI — the instance, download, account, Java, log and settings pages all run on the core engine (the in-app AI chat panel is a scripted placeholder). -- **CLI `ull`** is built from the same tree at the same version — but the `v0.1.0` assets ship - only the GUI binary. If you want the CLI, build it yourself: - `cargo build --release --bin ull`. Commands: `instances`, `install`, `launch`, `accounts`, - `sessions`, `logs`, `diag`, `config`, `java`, `agent`. +- **CLI in the same binary**: `unlucky-launcher instances list`, `unlucky-launcher launch `, + `unlucky-launcher logs ` and the rest of the command set run before any UI is built — no + window, no renderer probe, no font scan. Commands: `instances`, `install`, `launch`, `accounts`, + `sessions`, `logs`, `diag`, `config`, `java`, `agent`, plus `--help` / `--version`. +- **A second, lighter artifact `ull`** (`cargo build --release --bin ull`, zero UI dependencies): the + same command set from a binary that links no graphics stack at all — not even fontconfig. Servers, + CI containers and Termux get `ull`; desktops get `unlucky-launcher` and one binary covers both GUI + and CLI. Both are release assets from the next tagged release (the `v0.1.0` assets predate this). - Windows ARM64 and Linux ARM64 slots are wired into the release pipeline; the next tagged release builds them. The `v0.1.0` assets predate those two slots. @@ -71,9 +75,42 @@ Binaries in the workspace, so you can tell them apart: | Binary | Crate | State | |--------|-------|-------| -| `unlucky-launcher` | `crates/app` (Slint) | released in v0.1.0; still the usable GUI | -| `unlucky-launcher-iced` | `crates/app-iced` (iced 0.14) | under construction; skeleton only, not shipped | -| `ull` | `crates/cli` | built from the same tree at the same version; not in the v0.1.0 assets | +| `unlucky-launcher` | `crates/app` (Slint) | released in v0.1.0; still the usable GUI; also runs every CLI subcommand | +| `unlucky-launcher-iced` | `crates/app-iced` (iced 0.14) | under construction; skeleton only, not shipped; same subcommand dispatch | +| `ull` | `crates/cli` | the zero-UI build of the same command set, shipped as its own asset | + +`unlucky-launcher` and `ull` are not duplicates. The GUI binary needs the graphics stack when it +opens a window (it `dlopen`s X11 / Wayland / GL, and links `libfontconfig` at load); `ull` needs +nothing beyond glibc, so it runs on a server, a CI container or Termux where no window can exist. +Pick `ull` when there is no desktop, `unlucky-launcher ` when there is. + +### Subcommand dispatch + +`unlucky-launcher ` first asks "is this a CLI call?" before any UI code runs: + +```bash +unlucky-launcher # GUI +unlucky-launcher --software-renderer # GUI, CPU renderer (unchanged) +unlucky-launcher instances list # CLI, no window +unlucky-launcher --json launch 1.20.1 # CLI, global options before the command +unlucky-launcher --version # CLI, prints version + OS summary +``` + +The rule is deliberately narrow, so nothing that used to work changed meaning: + +- The first argument is a bare word (not `-…`) → it sits in the subcommand position, so the CLI gets + it. GUI flags never appear there: `--screenshot` / `--diff` / `--data-dir` take their values + *after* the flag. +- The first argument is a flag → the line is a CLI call only if `crates/cli`'s parser also produces a + real command (`--help`, `--version`, `--json instances list`). `--data-dir X` alone parses to "no + subcommand", so it still opens the GUI; every GUI-only flag (`--software-renderer`, + `--screenshot*`, `--renderer`, `--theme`, `--diff`, `--force-state`, …) fails CLI parsing, so it + still opens the GUI. + +**Unknown subcommands are an error, not a GUI launch**: `unlucky-launcher lst` prints +"未知命令 `lst`", hints `ull --help`, and exits 1. Silently opening a window would tell a script +that its typo took effect. The subcommand table itself lives in exactly one place — +`crates/cli/src/cli/parse.rs` — so both GUI binaries and `ull` accept the same set by construction. ## Module map @@ -121,11 +158,13 @@ cargo build --release # binaries land in target What the build produces: -- `unlucky-launcher` — the Slint UI (the released one) +- `unlucky-launcher` — the Slint UI (the released one). It is also the CLI: the same binary answers + every subcommand listed above. - `unlucky-launcher-iced` — the iced UI skeleton. The name is temporary: it can only become `unlucky-launcher` at the flip, because two crates in one workspace cannot emit the same binary - name into the same target directory. -- `ull` — the CLI + name into the same target directory. Same subcommand dispatch. +- `ull` — the CLI as a standalone, UI-free binary. Same command set, no graphics stack, smaller, and + the right artifact for a machine that has no display at all. Rendering switches: diff --git a/README.zh-CN.md b/README.zh-CN.md index f4cebe2..d1f0260 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -37,9 +37,14 @@ Unlucky Launcher(ULL)是一个用 Rust 写的免费开源 Minecraft: Java unix 归档里本来就装着。 - **UI**:Slint + Metro。这是已交付的图形界面——实例、下载、账户、Java、日志、设置 各页都由 core 引擎驱动(应用内 AI 对话面板仍是脚本化的占位)。 -- **CLI `ull`** 与 v0.1.0 同源同版本,但发布资产里只有 GUI 单文件——要用它请自己构建: - `cargo build --release --bin ull`。命令:`instances`、`install`、`launch`、`accounts`、`sessions`、 - `logs`、`diag`、`config`、`java`、`agent`。 +- **同一个二进制就是 CLI**:`unlucky-launcher instances list`、`unlucky-launcher launch <实例>`、 + `unlucky-launcher logs ` 等命令在任何 UI 代码之前分流——不开窗、不探渲染后端、不扫字体。 + 命令集:`instances`、`install`、`launch`、`accounts`、`sessions`、`logs`、`diag`、`config`、 + `java`、`agent`,外加 `--help` / `--version`。 +- **另有一个更轻的 `ull`**(`cargo build --release --bin ull`,零 UI 依赖):同一套命令,但二进制不 + 链接任何图形栈——连 fontconfig 都不需要。服务器、CI 容器、Termux 拿 `ull`;有桌面的机器拿 + `unlucky-launcher`,一个文件同时管 GUI 与命令行。两者都从下一个 tag 起进发布资产 + (`v0.1.0` 的资产早于这个变化)。 - Windows ARM64 与 Linux ARM64 两个槽位已接进发布流水线,随下一个 tag 发布;`v0.1.0` 的资产早于 这两个槽位。 @@ -61,9 +66,38 @@ Unlucky Launcher(ULL)是一个用 Rust 写的免费开源 Minecraft: Java | 二进制 | crate | 状态 | |--------|-------|------| -| `unlucky-launcher` | `crates/app`(Slint) | v0.1.0 已发布,仍是当前能用的图形界面 | -| `unlucky-launcher-iced` | `crates/app-iced`(iced 0.14) | 施工中,仅骨架,不随发布包分发 | -| `ull` | `crates/cli` | 与发布版同源同版本构建;未随 v0.1.0 资产分发 | +| `unlucky-launcher` | `crates/app`(Slint) | v0.1.0 已发布,仍是当前能用的图形界面;同时支持全部 CLI 子命令 | +| `unlucky-launcher-iced` | `crates/app-iced`(iced 0.14) | 施工中,仅骨架,不随发布包分发;子命令分发同款 | +| `ull` | `crates/cli` | 同一套命令的零 UI 构建,作为独立资产随发布分发 | + +`unlucky-launcher` 与 `ull` 不是重复品:开窗时 GUI 二进制要用图形栈(X11 / Wayland / GL 走 +`dlopen`,启动时直接链接 `libfontconfig`);`ull` 除了 glibc 什么都不需要,所以在没有窗口的服务器、 +CI 容器、Termux 上跑得起来。没有桌面就用 `ull`,有桌面就用 `unlucky-launcher <子命令>`。 + +### 子命令分发 + +`unlucky-launcher <参数>` 在跑任何 UI 代码之前先问一句「这是不是一次 CLI 调用」: + +```bash +unlucky-launcher # GUI +unlucky-launcher --software-renderer # GUI,CPU 渲染(行为不变) +unlucky-launcher instances list # CLI,不开窗 +unlucky-launcher --json launch 1.20.1 # CLI,全局选项排在子命令前面 +unlucky-launcher --version # CLI,输出版本 + 系统摘要 +``` + +判据刻意收得很窄,所以原本能用的用法一条都没变: + +- 第一个参数是裸词(不以 `-` 开头)→ 它就在子命令的位置上,交给 CLI。GUI 的 flag 永远不会出现在 + 那个位置:`--screenshot` / `--diff` / `--data-dir` 的取值都排在 flag 之后。 +- 第一个参数是 flag → 只有当 `crates/cli` 的解析器也解析出一条真命令时才算 CLI 调用 + (`--help`、`--version`、`--json instances list`)。单独 `--data-dir X` 解析成「没有子命令」, + 仍然开 GUI;GUI 专属参数(`--software-renderer`、`--screenshot*`、`--renderer`、`--theme`、 + `--diff`、`--force-state` 等)在 CLI 里一律解析失败,也仍然开 GUI。 + +**未知子命令是错误,不是开窗**:`unlucky-launcher lst` 打印「未知命令 `lst`」、提示 `ull --help`, +以 1 退出。静默开窗会让脚本以为自己那个拼错的参数生效了。子命令表只有一处定义 +(`crates/cli/src/cli/parse.rs`),所以两个 GUI 二进制和 `ull` 认的命令集天然一致。 ## 模块地图 @@ -110,10 +144,10 @@ cargo build --release # 三个 bin 都落在 ta 编出来的三个 bin: -- `unlucky-launcher`——Slint 版(已发布的那个) +- `unlucky-launcher`——Slint 版(已发布的那个)。它同时也是 CLI:同一个二进制认上面列的全部子命令。 - `unlucky-launcher-iced`——iced 版骨架。名字是临时的:只有翻牌时才能改成 `unlucky-launcher`, - 因为同一个工作区里两个 crate 不能往同一个 target 目录里产出同名 bin。 -- `ull`——CLI + 因为同一个工作区里两个 crate 不能往同一个 target 目录里产出同名 bin。子命令分发同款。 +- `ull`——CLI 的独立无 UI 构建:同一套命令、不链接图形栈、体积更小,给完全没有显示设备的机器用。 渲染开关: diff --git a/crates/app-iced/Cargo.toml b/crates/app-iced/Cargo.toml index 5cd25da..8e60213 100644 --- a/crates/app-iced/Cargo.toml +++ b/crates/app-iced/Cargo.toml @@ -34,14 +34,31 @@ iced = { version = "=0.14.0", default-features = false, features = [ "advanced", "svg", "thread-pool", - "x11", - "wayland", - "linux-theme-detection", ] } # 截图落 PNG。只开 png 编码器,不要默认全套 codec(体积)。 image = { version = "0.25", default-features = false, features = ["png"] } +# M82:同一个二进制也带 CLI 子命令(`unlucky-launcher-iced instances list` …), +# 进 UI 之前分发,命中就把控制权交给 `unlucky_cli::run`。依赖方向 app-iced → cli → +# core/agent 不违反 core 零 UI 铁律(core 的依赖树里没有 cli)。零 UI 依赖的 `ull` +# 仍在 `crates/cli`,给没有图形库的机器用——两者不是重复品。 +unlucky-cli = { path = "../cli", version = "0.1" } + +# X11 / Wayland 后端与 Linux 主题检测只在 Linux 上启用:macOS 没有 X11/Wayland(走 Metal + +# 系统外观),Windows 走 DX12,这三个 feature 在那边没有意义。同一个包在两处写 features 是 +# **取并集**(Cargo 的 feature 解析是加法),所以 Linux 上拿到的仍是上面 5 个 + 这 3 个, +# Windows / macOS 只拿上面那 5 个(`cargo tree --target` 可复核)。 +# 诚实边界:这**不**解决「iced 版至今没在 macOS 上编过」这件事——`iced_winit` 已把 `mundy` +# 放在 `cfg(target_os = "linux")` 段里,这三个 feature 在非 Linux 上本来就不拉依赖,这里只是 +# 让本仓库的依赖图按平台自述清楚,不再把 Linux 专属能力写成全平台事实。 +[target.'cfg(target_os = "linux")'.dependencies] +iced = { version = "=0.14.0", default-features = false, features = [ + "x11", + "wayland", + "linux-theme-detection", +] } + [features] default = ["gpu"] # 硬件渲染后端(wgpu→Vulkan/DX12)。关闭后只剩 tiny-skia(CPU),用于体积基线与无 GPU 机器。 diff --git a/crates/app-iced/src/main.rs b/crates/app-iced/src/main.rs index b55a913..2a4a46d 100644 --- a/crates/app-iced/src/main.rs +++ b/crates/app-iced/src/main.rs @@ -1,9 +1,19 @@ //! 入口。所有实际逻辑在 lib(`unlucky_app_iced`)里: //! -//! - `--diff `:只读两张 PNG 做逐像素对比(D30 的 GPU/CPU 几何一致性检查),不开窗; -//! - 其余参数:开窗跑骨架,`--screenshot` 出图后退出。 +//! 一个二进制两种用法(M82),分发在进 UI 之前完成: //! -//! 退出码:`0` 成功 / `1` 截图有差异(diff 模式)/ `2` 参数或运行错误。 +//! - 被子命令命中(`instances` / `install` / `launch` / `accounts` / `sessions` / `logs` / +//! `diag` / `config` / `java` / `agent`,或 `--help` / `--version`)→ 直接转给 +//! `unlucky_cli::run`,**不初始化任何 UI**(不 probe 后端、不扫字体、不碰图形库)。 +//! 子命令表不在这里抄一份:唯一真相源是 `crates/cli` 的解析器(见 [`is_cli_invocation`])。 +//! - 其余参数(`--software-renderer` / `--renderer` / `--theme` / `--screenshot*` / +//! `--diff` …):开窗跑骨架,`--screenshot` 出图后退出;`--diff ` 只读两张 PNG +//! 做逐像素对比(D30 的 GPU/CPU 几何一致性检查),不开窗。 +//! +//! 零 UI 依赖的 `ull` 仍然保留(D18):它是唯一连 fontconfig 都不需要的产物, +//! 给服务器 / CI / Termux 这类没有图形库的机器用。 +//! +//! 退出码:`0` 成功 / `1` 截图有差异(diff 模式)或 CLI 用户错误 / `2` 参数或运行错误。 use std::process::ExitCode; @@ -11,8 +21,13 @@ use unlucky_app_iced::shot; use unlucky_app_iced::Options; fn main() -> ExitCode { - let options = match Options::from_env() { - Ok(options) => options, + let args: Vec = std::env::args().skip(1).collect(); + if let Some(code) = run_cli_if_requested(&args) { + return code; + } + + match run_gui() { + Ok(code) => code, Err(message) => { eprintln!("[cli] {message}"); eprintln!( @@ -20,9 +35,54 @@ fn main() -> ExitCode { [--theme=light|dark] [--screenshot ] [--screenshot-theme=light|dark] \ [--scale-factor ] [--diff ]" ); - return ExitCode::from(2); + ExitCode::from(2) } - }; + } +} + +/// 子命令命中 CLI 就跑 CLI 并以它的退出码收尾;返回 `None` 表示「这不是 CLI 调用,去开窗」。 +/// +/// 这条路径上**不构造窗口、不探后端、不扫字体、不 dlopen 任何图形库**:服务器上 +/// `unlucky-launcher-iced launch …` 的耗时因此与首帧无关(实测见 packaging/README.md §7)。 +fn run_cli_if_requested(args: &[String]) -> Option { + if !is_cli_invocation(args) { + return None; + } + let mut stdout = std::io::stdout(); + let mut stderr = std::io::stderr(); + let code = unlucky_cli::run(args, &mut stdout, &mut stderr); + let _ = std::io::Write::flush(&mut stdout); + let _ = std::io::Write::flush(&mut stderr); + Some(ExitCode::from(u8::try_from(code).unwrap_or(2))) +} + +/// 这条命令行是 CLI 调用还是 GUI 启动? +/// +/// 判据只有一条,**子命令表不在这里重复**:`crates/cli` 的解析器就是唯一真相源, +/// 所以新增子命令不需要动这个文件(Slint 版 `crates/app/src/main.rs` 是同一份判据)。 +/// +/// - 首个 token 是裸词(不以 `-` 开头)→ 一定是子命令位置:GUI 不接受位置参数, +/// 而 `--diff` / `--screenshot` 的取值都在 flag 之后。未知子命令也走这条路, +/// 由 CLI 自己报「未知命令 + `--help`」并以 1 退出(静默落到 GUI 会让脚本用户困惑)。 +/// - 首个 token 是 flag → 只有 CLI 自己解析出一条真命令才算 CLI 调用:`--help` / `--version` / +/// `--json instances list` 走 CLI;`--data-dir X` 解析成 `Welcome`(没有子命令)、 +/// `--renderer` / `--screenshot` / `--diff` 等 GUI 参数干脆解析失败,一律仍走 GUI。 +/// 注意「解析失败」在这里是 GUI,不是 CLI:`--screenshot out.png` 也会解析失败,把它判成 +/// CLI 就会把截图脚本打死(这是本函数据以正确的那条边界)。 +fn is_cli_invocation(args: &[String]) -> bool { + match args.first() { + None => false, + Some(first) if !first.starts_with('-') => true, + Some(_) => match unlucky_cli::cli::parse(args) { + Ok(invocation) => !matches!(invocation.command, unlucky_cli::cli::Command::Welcome), + Err(_) => false, + }, + } +} + +/// 开窗路径:解析 GUI 参数 → (`--diff` 走比对)→ 起 UI。 +fn run_gui() -> Result { + let options = Options::from_env()?; if let Some((left, right)) = options.diff.clone() { let scan = i32::try_from(options.diff_scan).unwrap_or(i32::MAX); @@ -30,23 +90,86 @@ fn main() -> ExitCode { Ok(diff) => { eprintln!("{}", diff.describe()); if diff.is_identical() { - ExitCode::SUCCESS + Ok(ExitCode::SUCCESS) } else { - ExitCode::from(1) + Ok(ExitCode::from(1)) } } Err(message) => { eprintln!("[diff] {message}"); - ExitCode::from(2) + Ok(ExitCode::from(2)) } }; } match unlucky_app_iced::run(options) { - Ok(()) => ExitCode::SUCCESS, + Ok(()) => Ok(ExitCode::SUCCESS), Err(error) => { eprintln!("[run] {error}"); - ExitCode::FAILURE + Ok(ExitCode::FAILURE) } } } + +#[cfg(test)] +mod tests { + use super::is_cli_invocation; + + fn cli(args: &[&str]) -> bool { + is_cli_invocation(&args.iter().map(|arg| (*arg).to_owned()).collect::>()) + } + + #[test] + fn no_args_starts_the_gui() { + assert!(!cli(&[])); + } + + #[test] + fn gui_flags_keep_working() { + // CI 与验收脚本在用这些参数,一条都不能被当成子命令吃掉。 + assert!(!cli(&["--software-renderer"])); + assert!(!cli(&["--screenshot", "out.png", "--data-dir", "/d/tmp/x"])); + assert!(!cli(&["--renderer=gpu", "--theme=dark"])); + assert!(!cli(&["--scale-factor", "1.0"])); + assert!(!cli(&["--force-state", "hover"])); + assert!(!cli(&["--diff", "a.png", "b.png"])); + // 只有 --data-dir 时 CLI 解析成 Welcome(没有子命令),仍是 GUI 启动。 + assert!(!cli(&["--data-dir", "/d/tmp/x"])); + } + + #[test] + fn known_subcommands_go_to_the_cli() { + for args in [ + vec!["instances", "list"], + vec!["install", "1.20.1", "--dry-run"], + vec!["launch", "1.20.1", "--dry-run"], + vec!["accounts", "list"], + vec!["sessions", "list"], + vec!["logs", "abc", "--tail", "10"], + vec!["diag", "abc"], + vec!["config", "get"], + vec!["java", "list"], + vec!["agent", "config"], + // 全局选项可以排在子命令前面 + vec!["--json", "instances", "list"], + ] { + assert!(cli(&args), "{args:?} 应该交给 CLI"); + } + } + + #[test] + fn help_and_version_are_cli_too() { + // iced 版以前不认 --version(会直接开窗),现在由 CLI 报版本并退出。 + assert!(cli(&["--version"])); + assert!(cli(&["--help"])); + assert!(cli(&["-h"])); + } + + #[test] + fn unknown_subcommand_is_left_to_the_cli_to_reject() { + // 语义定案(README 两版同款):未知子命令 → CLI 报「未知命令 + ull --help」并以 1 退出, + // 不静默落到 GUI(否则脚本用户会以为参数生效了)。 + assert!(cli(&["lst"])); + assert!(cli(&["intsances", "list"])); + } +} diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 6f6022c..1fb2cde 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -26,6 +26,11 @@ serde_json.workspace = true # (core 的快照/事件流翻译成 src/dto 的类型)落位时不必再动 workspace 契约。 unlucky-core = { path = "../core", version = "0.1" } unlucky-agent = { path = "../agent", version = "0.1" } +# M82:同一个二进制也带 CLI 子命令(`unlucky-launcher instances list` …)。 +# 依赖方向 app → cli → core/agent 不违反 core 零 UI 铁律(guards.yml 的 core-no-ui 只查 +# `cargo tree -p unlucky-core`,core 不依赖 cli)。零 UI 依赖的 `ull` 仍在 `crates/cli`, +# 给没有图形库的机器用——两者不是重复品。 +unlucky-cli = { path = "../cli", version = "0.1" } [build-dependencies] slint-build.workspace = true diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index a4eda55..cd52129 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -13,7 +13,13 @@ //! - 全部走 `core::setting` 的 schema(`crate::settings_bridge`),AI 可写并且**热切** //! (tick 里 1s 一次 mtime 比对);系统主题由 `core::platform::theme_probe` 对齐 + 兜底轮询。 //! -//! 架构分层(本文件只有一个职责:把窗口拉起来): +//! 一个二进制两种用法(M82):`unlucky-launcher` 不带子命令时起 GUI;带子命令 +//! (`instances` / `install` / `launch` / `accounts` / `sessions` / `logs` / `diag` / +//! `config` / `java` / `agent`)时直接转给 `unlucky_cli::run`,**不初始化任何 UI**。 +//! 子命令表不在这里抄一份——唯一真相源是 `crates/cli` 的解析器(见 [`is_cli_invocation`])。 +//! 零 UI 依赖的 `ull` 仍然保留:它连 fontconfig 都不需要,给服务器 / CI / Termux 用。 +//! +//! 架构分层(本文件只有一个职责:分发 + 把窗口拉起来): //! - `state.rs` / `prefs.rs` / `router.rs` / `dialogs.rs` / `toast.rs`:纯 Rust 状态(可单测,无 Slint) //! - `dto/`:跨边界 DTO 与事件/指令 //! - `conv.rs`:状态 → Slint 视图模型 @@ -31,6 +37,8 @@ // 会把噪音藏进实现细节,这里统一放开并在 P1 接线时逐个转正(届时删掉本行即可暴露真死码)。 #![allow(missing_docs, dead_code)] +use std::process::ExitCode; + mod appearance_bind; mod backend; mod bg_image; @@ -56,7 +64,22 @@ mod wizard; slint::include_modules!(); -fn main() -> Result<(), slint::PlatformError> { +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + if let Some(code) = run_cli_if_requested(&args) { + return code; + } + match run_gui() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("[run] {error}"); + ExitCode::FAILURE + } + } +} + +/// 把窗口拉起来(分发之后的唯一路径)。 +fn run_gui() -> Result<(), slint::PlatformError> { apply_renderer_flag(); let app = ui_bind::AppUi::with_options(MainWindow::new()?, screenshot::app_options()); @@ -73,6 +96,46 @@ fn main() -> Result<(), slint::PlatformError> { app.run() } +/// 子命令命中 CLI 就跑 CLI 并以它的退出码收尾;返回 `None` 表示「这不是 CLI 调用,去开窗」。 +/// +/// 这条路径上**不构造窗口、不探后端、不扫字体、不 dlopen 任何图形库**:服务器上 +/// `unlucky-launcher launch …` 的耗时因此与首帧无关(实测见 packaging/README.md §7)。 +fn run_cli_if_requested(args: &[String]) -> Option { + if !is_cli_invocation(args) { + return None; + } + let mut stdout = std::io::stdout(); + let mut stderr = std::io::stderr(); + let code = unlucky_cli::run(args, &mut stdout, &mut stderr); + let _ = std::io::Write::flush(&mut stdout); + let _ = std::io::Write::flush(&mut stderr); + Some(ExitCode::from(u8::try_from(code).unwrap_or(2))) +} + +/// 这条命令行是 CLI 调用还是 GUI 启动? +/// +/// 判据只有一条,**子命令表不在这里重复**:`crates/cli` 的解析器就是唯一真相源, +/// 所以新增子命令不需要动这个文件(iced 版 `crates/app-iced/src/main.rs` 是同一份判据)。 +/// +/// - 首个 token 是裸词(不以 `-` 开头)→ 一定是子命令位置:GUI 不接受位置参数, +/// 而 `--diff` / `--screenshot` 的取值都在 flag 之后。未知子命令也走这条路, +/// 由 CLI 自己报「未知命令 + `--help`」并以 1 退出(静默落到 GUI 会让脚本用户困惑)。 +/// - 首个 token 是 flag → 只有 CLI 自己解析出一条真命令才算 CLI 调用:`--help` / `--version` / +/// `--json instances list` 走 CLI;`--data-dir X` 解析成 `Welcome`(没有子命令)、 +/// `--software-renderer` / `--screenshot` / `--diff` 等 GUI 参数干脆解析失败,一律仍走 GUI。 +/// 注意「解析失败」在这里是 GUI,不是 CLI:`--screenshot out.png` 也会解析失败,把它判成 +/// CLI 就会把截图脚本打死(这是本函数据以正确的那条边界)。 +fn is_cli_invocation(args: &[String]) -> bool { + match args.first() { + None => false, + Some(first) if !first.starts_with('-') => true, + Some(_) => match unlucky_cli::cli::parse(args) { + Ok(invocation) => !matches!(invocation.command, unlucky_cli::cli::Command::Welcome), + Err(_) => false, + }, + } +} + /// Win7 老机 / 无可用 OpenGL 驱动时的软渲染兜底开关(01c §6 的 Slint 核心卖点)。 fn apply_renderer_flag() { let software = std::env::args() @@ -82,3 +145,66 @@ fn apply_renderer_flag() { std::env::set_var("SLINT_BACKEND", "winit-software"); } } + +#[cfg(test)] +mod tests { + use super::is_cli_invocation; + + fn cli(args: &[&str]) -> bool { + is_cli_invocation(&args.iter().map(|arg| (*arg).to_owned()).collect::>()) + } + + #[test] + fn no_args_starts_the_gui() { + assert!(!cli(&[])); + } + + #[test] + fn gui_flags_keep_working() { + // CI 脚本与人工验收都在用这些参数,一条都不能被当成子命令吃掉。 + assert!(!cli(&["--software-renderer"])); + assert!(!cli(&["--screenshot", "out.png", "--data-dir", "/d/tmp/x"])); + assert!(!cli(&["--screenshot-theme", "dark"])); + assert!(!cli(&["--renderer=gpu", "--theme=dark"])); + assert!(!cli(&["--diff", "a.png", "b.png"])); + assert!(!cli(&["--mock"])); + // 只有 --data-dir 时 CLI 解析成 Welcome(没有子命令),仍是 GUI 启动。 + assert!(!cli(&["--data-dir", "/d/tmp/x"])); + } + + #[test] + fn known_subcommands_go_to_the_cli() { + for args in [ + vec!["instances", "list"], + vec!["install", "1.20.1", "--dry-run"], + vec!["launch", "1.20.1", "--dry-run"], + vec!["accounts", "list"], + vec!["sessions", "list"], + vec!["logs", "abc", "--tail", "10"], + vec!["diag", "abc"], + vec!["config", "get"], + vec!["java", "list"], + vec!["agent", "config"], + // 全局选项可以排在子命令前面 + vec!["--json", "instances", "list"], + ] { + assert!(cli(&args), "{args:?} 应该交给 CLI"); + } + } + + #[test] + fn help_and_version_are_cli_too() { + // GUI 以前不认 --version(会直接开窗),现在由 CLI 报版本并退出。 + assert!(cli(&["--version"])); + assert!(cli(&["--help"])); + assert!(cli(&["-h"])); + } + + #[test] + fn unknown_subcommand_is_left_to_the_cli_to_reject() { + // 语义定案(README 两版同款):未知子命令 → CLI 报「未知命令 + ull --help」并以 1 退出, + // 不静默落到 GUI(否则脚本用户会以为参数生效了)。 + assert!(cli(&["lst"])); + assert!(cli(&["intsances", "list"])); + } +} diff --git a/packaging/README.md b/packaging/README.md index 7d53747..a48b407 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -14,6 +14,7 @@ | `linux-arm64` | `unlucky-launcher--linux-arm64.tar.gz` | 单二进制归档(原生 ARM64 runner 构建) | 同上 | | `macos-arm64` | `unlucky-launcher--macos-arm64.tar.gz` | 单二进制归档 | 同上 | | `macos-x64` | `unlucky-launcher--macos-x64.tar.gz` | 单二进制归档 | 同上 | +| 每槽位 | `ull--<槽位>[.exe]` | **裸 CLI 文件**(独立资产,不进 tar.gz) | 零 UI 依赖的 `ull` 本体,进同一份 `SHA256SUMS.txt` | | 全平台 | `SHA256SUMS.txt` | 校验清单 | 每行 `␠␠<文件名>`(`sha256sum -c` 可直吃) | 约定细节: @@ -22,6 +23,10 @@ 归档里额外放三份合规文本是许可义务要求,不是运行时依赖;Windows 因为"一个 exe 装不下文本", 把 `LICENSE` / `NOTICE` / `README.md` 作为 Release 的并列资产分发,条款同样随下载走。 - 归档解压后是**一个顶层目录** `unlucky-launcher--<槽位>/`,用户不会解压出一堆散文件。 +- **`ull` 是并列的裸资产,不塞进 tar.gz**:headless 用户要的就是「下一个小文件直接跑」;归档里放 + LICENSE/NOTICE/README.md 是许可义务,而这三份本来就作为并列 Release 资产分发,裸 `ull` 不缺条款。 +- `unlucky-launcher` 本体也认全部 CLI 子命令(M82),`ull` 不是第二个界面,而是**不链接图形栈**的 + 同一套命令(差异见 §4 与 §7.6)——两者都不是重复品。 - `` 不带前导 `v`(tag 是 `v0.1.0`,文件名是 `0.1.0`)——由脚本统一裁剪。 - UNIX 归档内 `mtime`/`uid`/`gid` 归零、权限固定(二进制 0755、文本 0644), 同源构建的 sha256 不受打包时刻影响。 @@ -148,6 +153,11 @@ Windows ARM64 原生库(Mojang/LWJGL 只发 x64/x86),实际游玩仍走系 Debian/Ubuntu 一行:`sudo apt install libxkbcommon0 fontconfig libgl1 libegl1 libxcb1 libx11-6 libwayland-client0`。 运行期**不需要** `-dev` 包、不需要 Java(Java 是启动游戏时才由启动器自己管理的东西)。 +上表是**开窗**要用的库,不是进程启动时就要的:实测(§7.1)除 `libfontconfig.so.1` 外它们**都不在** ELF +的 `DT_NEEDED` 里,由 `dlopen` 在创建窗口那一刻才加载。所以同一台没有 X11 / Wayland / GL 的机器上 +`unlucky-launcher instances list` 照样跑得起来(GUI 二进制先分发后开窗),而 `ull` 连 fontconfig 都不需要。 +arm64 侧是同一套(同一份依赖树、同一个 winit/femtovg 组合)。 + ### Linux arm64(aarch64) 依赖与 x64 完全同一套(`libxkbcommon` / `fontconfig` / `libGL` / `libxcb` / `libwayland-client`), @@ -233,7 +243,7 @@ LTO 也就只有一个线程可跑(`fat` 本身只管"跨 crate 全量参与" |------|---------| | `ull.exe --version` | 输出 `ull 0.1.0(unlucky-cli)` + `os: Windows 11 · 10.0.26100(x86_64)· 满足最低要求`,exit 0 —— 版本与 workspace `0.1.0` 一致 | | `unlucky-launcher.exe --software-renderer --screenshot … --data-dir …` | exit 0,产出 1180×760 PNG(3,588,298 B);看图确认 Metro 实例页(侧栏/搜索/空态/AI 助手面板)正常渲染 → **软渲染路径真的能出图**,不依赖 GL | -| `unlucky-launcher.exe --version` | **无输出**,8 秒后被 timeout 杀掉(exit 124)——GUI 目前不解析 `--version`,会直接开窗口。已作为 finding 上报,见 §10-1 | +| `unlucky-launcher.exe --version` | **无输出**,8 秒后被 timeout 杀掉(exit 124)——GUI 当时不解析 `--version`,会直接开窗口。M82 后已修(走 CLI 分发,见 §10-1)| ### 7.3 打包验证(本机 Windows 槽位真跑) @@ -268,6 +278,13 @@ CI 的 MSVC `+crt-static` 产物同理(静态 CRT);`release.yml` 里用 `d 总之不允许"悄悄要用户装可再发行包"。 注意:本机只装了 GNU 工具链,MSVC 那个槽位的产物本身**没有在本机验证过**,由 CI 首跑验证。 +Linux 侧同一件事的对照(M82 实测:`readelf -d` + `ldd` + `nm -D`,容器内 Debian 12 glibc 2.36):Slint +GUI 的 ELF `DT_NEEDED` 只有 `libfontconfig.so.1` / `libgcc_s.so.1` / `libm.so.6` / `libc.so.6` / +`ld-linux-x86-64.so.2`;`libX11` / `libxcb` / `libxkbcommon` / `libwayland-client` / `libGL` / `libEGL` +**都不在**导入表里,而是以字符串形式由 `dlopen` 在开窗那一刻加载(`nm -D` 可见 `U dlopen@GLIBC_2.34` / +`dlsym` / `dlclose`)。所以 §4 那张「开窗要的库」不是启动前提,缺了也不影响进程起来跑 CLI; +`ull` 的 `DT_NEEDED` 连 fontconfig 都没有,只剩 glibc 那一组。 + ### 7.5 arm64 槽位的本机验证(脚本层取证,不假装跑得动 arm64 二进制) 本机没有 ARM 机器,也不具备 MSVC ARM64 链接库组合,所以只做**能真实证明的东西**(`python` 直接加载 @@ -322,16 +339,15 @@ experimental / not part of this release matrix)——改一边必须同步另 ## 10. 已知边界 / 后续 -1. **GUI 无 `--version`**:`unlucky-launcher.exe` 目前只解析 `--software-renderer` / `--screenshot*` / - `--data-dir` / `--mock`,`--version` 会当成普通参数忽略并直接开窗口。版本自证目前靠: - ①产物名里的 `` ②`ull --version`(同 workspace 版本)③tag 交叉校验。 - 加一个 `--version` 分支属于 `crates/app` 范围,已作为 finding 上报,不在本 mission 内改。 +1. **GUI 的 `--version` 已解决**(M82):`unlucky-launcher --version` 现在命中子命令分发,输出与 + `ull --version` 同一条版本 + 系统摘要后退出,不再开窗。§7.2 里「无输出、8 秒后被 timeout 杀掉」那行 + 是**改之前**的实测记录,留作对照。版本自证的另外两条(产物名里的 ``、tag 交叉校验)不变。 2. **签名/公证**:Windows 无 Authenticode、macOS 无 codesign/notarize(都要证书/开发者账号), 当前以 SHA256SUMS + Release 说明作为完整性参考。 3. **macOS `.app` bundle / `.dmg`**:与单文件承诺冲突,暂不做;若用户要 Dock 图标再单开 mission。 4. **Linux musl 静态版**:可彻底摆脱 glibc 地板,但 Slint 在 musl 上需额外验证,未纳入本矩阵。 -5. **`ull` CLI 未随包**:本次矩阵只发 GUI 单文件(用户核心卖点是启动器本体);headless 用户可从 - 源码 `cargo build --release --bin ull`,或在后续把 `ull` 加进同一矩阵(脚本已按槽位参数化,改动很小)。 +5. **`ull` 已随包**(M82 起):每个槽位与 GUI 并列发一份裸 `ull`,进同一份 `SHA256SUMS.txt`,矩阵 + 完整性检查按「GUI 与 CLI 各自都要有」分开查(`release.yml`)。等 CI 首跑确认这条在真流水线上有效。 6. **本机 GNU 链接器的已知无害告警**:`cargo build --release`(GNU 工具链)会打一行 `warning: linker stderr: corrupt .drectve at end of def file`,来自 MinGW `ld` 对 `.drectve` 段的 老式抱怨,不影响产物(体积/导入表/冒烟均已实测正常);CI 的 MSVC 槽位不会出现这条。 diff --git a/packaging/make_artifacts.py b/packaging/make_artifacts.py index b71f69b..69eb11a 100644 --- a/packaging/make_artifacts.py +++ b/packaging/make_artifacts.py @@ -9,6 +9,7 @@ Release matrix (see `packaging/README.md` for the full contract): Linux arm64 unlucky-launcher--linux-arm64.tar.gz macOS arm64 unlucky-launcher--macos-arm64.tar.gz macOS x64 unlucky-launcher--macos-x64.tar.gz + every slot ull--[.exe] (bare CLI binary, no archive) all SHA256SUMS.txt `--version` is taken from the cargo workspace so a tag can never drift away from @@ -17,6 +18,15 @@ The archives carry the launcher binary plus LICENSE / NOTICE / README.md, which what the license obligations ask for; Windows ships the bare `.exe` (the single-file selling point) and the same three documents are attached to the Release next to it. +The CLI ships as its **own bare asset** on every platform rather than inside the tar.gz: +the point of `ull` is a small single file you download and run on a machine with no +display, and an archive it has to be extracted from adds a step for that user. The +launcher is the archive because it needs the license texts beside it; the Release +carries LICENSE / NOTICE / README.md anyway, so a bare `ull` loses nothing. + +The launcher binary also answers every CLI subcommand, so `ull` is not a second +interface — it is the version of it that links no graphics stack at all. + Slot and build directory are overridable because a cross build's binaries live in `target//release` and the build host (x64) is not the artifact (arm64): CI passes both from its matrix, and the executable header is read back so a binary can @@ -189,6 +199,16 @@ def write_sums(out_dir: Path, assets: list[Path]) -> Path: return sums +def stage_cli(out_dir: Path, version: str, slot: str, cli: Path) -> Path: + """Copy the CLI out as its own bare asset (`ull--[.exe]`).""" + name = f"{CLI_BIN}-{version}-{slot}" + (".exe" if cli.suffix == ".exe" else "") + asset = out_dir / name + shutil.copy2(cli, asset) + # 裸文件下载即可运行:Windows 宿主机上源文件没有 x 位,这里补上(UNIX 侧才有意义)。 + asset.chmod(0o755) + return asset + + def stage_archive(out_dir: Path, tag: str, version: str, launcher: Path) -> Path: """tar.gz with one top-level dir holding the binary + compliance docs.""" stem = f"unlucky-launcher-{version}-{tag}" @@ -259,10 +279,15 @@ def main() -> int: shutil.copy2(launcher, asset) else: asset = stage_archive(out_dir, slot, version, launcher) + # CLI 与 GUI 同一矩阵槽位、各自独立资产:架构同样回读文件头校验,不给 arm64 槽位混进 x64 的 ull。 + cli = binary(CLI_BIN, build_dir, family) + verify_arch(cli, slot) + cli_asset = stage_cli(out_dir, version, slot, cli) - sums = write_sums(out_dir, [asset]) - size_mb = asset.stat().st_size / (1024 * 1024) - print(f"artifact: {asset.name} {size_mb:.2f} MiB sha256={sha256(asset)}") + sums = write_sums(out_dir, [asset, cli_asset]) + for item in (asset, cli_asset): + size_mb = item.stat().st_size / (1024 * 1024) + print(f"artifact: {item.name} {size_mb:.2f} MiB sha256={sha256(item)}") print(f"sums: {sums.name}") return 0 diff --git a/packaging/release-notes.md b/packaging/release-notes.md index 97558ae..a91038f 100644 --- a/packaging/release-notes.md +++ b/packaging/release-notes.md @@ -19,6 +19,18 @@ No WebView, no bundled runtime, no JRE download needed to start the launcher its this release next to the `.exe` (a lone exe cannot carry them), so the license terms travel with the download on every platform. +Every slot also ships the command line as its own small asset — `ull-{{VERSION}}-` +(`.exe` on Windows): + +```bash +./ull-{{VERSION}}-linux-x64 instances list # servers / CI / Termux: no graphics stack needed +unlucky-launcher instances list # desktops: the GUI binary answers the same commands +``` + +`ull` runs `instances`, `install`, `launch`, `accounts`, `sessions`, `logs`, `diag`, `config`, +`java` and `agent`, and links no graphics library at all. The GUI binary accepts the same set +before it ever opens a window, so on a machine with a desktop you do not need `ull`. + ## Support matrix | Platform | Level | Runtime requirements | @@ -31,7 +43,7 @@ with the download on every platform. | Linux arm64, X11 | first class | glibc 2.35+ (built on `ubuntu-22.04-arm`, same floor as x64), `libxkbcommon`, `fontconfig`, `libGL`, `libxcb` | | Linux arm64, Wayland | supported | the above plus `wayland-client` | | Windows 8.1/8/7 | unsupported | baseline was raised to Win10 (decision D21) | -| Android — Termux + X11 | experimental, not promised | CLI only (build from source), GUI unverified; not part of this release matrix | +| Android — Termux + X11 | experimental, not promised | CLI only (the `ull` asset, or build from source), GUI unverified; not part of this release matrix | Both arm64 rows are **formally released slots** (the assets ship in this release and in `SHA256SUMS.txt`), not the experimental Termux path: Linux arm64 is built and smoke-tested on a From 0d4628f85e0a1158990cd05be2f1db4a092883ff Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 1 Nov 2026 22:45:48 +0800 Subject: [PATCH 2/7] =?UTF-8?q?docs(packaging):=20=E5=A6=82=E5=AE=9E?= =?UTF-8?q?=E8=AE=B0=E4=B8=8B=20M82=20=E7=9A=84=E4=BD=93=E7=A7=AF/?= =?UTF-8?q?=E9=A6=96=E5=B8=A7=E6=B2=A1=E6=9C=89=E9=87=8D=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本机当时三个 worker 同时在编,全量 release profile(codegen-units=1 + fat LTO)要 重跑 400+ crate,这两个数留给空闲窗口补。写清结构性判断(分发在 MainWindow::new() 与后端探测之前,GUI 路径只多一次 argv 解析)与它的性质:不是实测。 --- packaging/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packaging/README.md b/packaging/README.md index a48b407..a6f622d 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -357,6 +357,9 @@ experimental / not part of this release matrix)——改一边必须同步另 本机能做且已做的是脚本层取证(见 §7.5):宿主判定注入(`win32/ARM64`、`linux/aarch64` 等全槽位)、 可执行文件头架构识别(真 x64 exe + 合成 PE/ELF/Mach-O 头)、以及把真 exe 的 PE 机器码改成 ARM64 当 stub 走完命名/校验/归档全流程 —— 不假装能在 x64 上执行 arm64 二进制。 +8. **M82 的体积/首帧没重量**:改的时候本机三个 worker 同时在编(全量 release profile 要 400+ crate), + 这两个数留给空闲窗口补。结构性判断(**不是实测**):分发发生在 `MainWindow::new()` 与后端探测之前, + GUI 路径只多一次 argv 解析,所以首帧成本没有增加的理由;CLI 路径的耗时则天然与首帧无关。 ## 11. CI 运行时冒烟(Linux 真渲染一帧) From fecff989e729e7cf6fc9b9b413524884d5d2c489 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 00:41:19 +0800 Subject: [PATCH 3/7] =?UTF-8?q?docs:=20=E4=BF=AE=E6=8E=89=E3=80=8C?= =?UTF-8?q?=E6=8A=8A=E6=B2=A1=E9=87=8F=E7=9A=84=E4=B8=9C=E8=A5=BF=E6=A0=87?= =?UTF-8?q?=E6=88=90=E5=AE=9E=E6=B5=8B=E3=80=8D=E7=9A=84=E5=9B=9B=E5=A4=84?= =?UTF-8?q?=E5=BC=95=E7=94=A8=EF=BC=88M82=20=E5=A4=8D=E5=AE=A1=20P2?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 两个 GUI main.rs:把「不 dlopen 任何图形库」改成「不依赖任何图形库(缺失时不影响)」, 并把「实测见 §7」换成「§7.4 是依赖取证;耗时是结构性判断,没重量(§10-8)」 - packaging/README §1:§7.6(不存在)→ §7.4 - packaging/README §4:实测引用 §7.1 → §7.4(ELF 取证在 §7.4) - packaging/README §7.4 标题补 ELF DT_NEEDED,并记下复审在 bookworm-slim 里的真跑结果 - packaging/release-notes:ull 示例前补 chmod +x(Release 资产不带可执行位) --- crates/app-iced/src/main.rs | 5 +++-- crates/app/src/main.rs | 5 +++-- packaging/README.md | 11 ++++++++--- packaging/release-notes.md | 1 + 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/crates/app-iced/src/main.rs b/crates/app-iced/src/main.rs index 2a4a46d..1965a58 100644 --- a/crates/app-iced/src/main.rs +++ b/crates/app-iced/src/main.rs @@ -42,8 +42,9 @@ fn main() -> ExitCode { /// 子命令命中 CLI 就跑 CLI 并以它的退出码收尾;返回 `None` 表示「这不是 CLI 调用,去开窗」。 /// -/// 这条路径上**不构造窗口、不探后端、不扫字体、不 dlopen 任何图形库**:服务器上 -/// `unlucky-launcher-iced launch …` 的耗时因此与首帧无关(实测见 packaging/README.md §7)。 +/// 这条路径上**不构造窗口、不探后端、不扫字体、不依赖任何图形库**(这些库缺失时同一条命令照样 +/// 跑完,实测见 packaging/README.md §7.4)。`unlucky-launcher-iced launch …` 的耗时因此与首帧无关—— +/// 这句是结构性判断,**没有重量**(原因见 §10-8)。 fn run_cli_if_requested(args: &[String]) -> Option { if !is_cli_invocation(args) { return None; diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index cd52129..644af3f 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -98,8 +98,9 @@ fn run_gui() -> Result<(), slint::PlatformError> { /// 子命令命中 CLI 就跑 CLI 并以它的退出码收尾;返回 `None` 表示「这不是 CLI 调用,去开窗」。 /// -/// 这条路径上**不构造窗口、不探后端、不扫字体、不 dlopen 任何图形库**:服务器上 -/// `unlucky-launcher launch …` 的耗时因此与首帧无关(实测见 packaging/README.md §7)。 +/// 这条路径上**不构造窗口、不探后端、不扫字体、不依赖任何图形库**(这些库缺失时同一条命令照样 +/// 跑完,实测见 packaging/README.md §7.4)。`unlucky-launcher launch …` 的耗时因此与首帧无关—— +/// 这句是结构性判断,**没有重量**(原因见 §10-8)。 fn run_cli_if_requested(args: &[String]) -> Option { if !is_cli_invocation(args) { return None; diff --git a/packaging/README.md b/packaging/README.md index a6f622d..eb9b13e 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -26,7 +26,7 @@ - **`ull` 是并列的裸资产,不塞进 tar.gz**:headless 用户要的就是「下一个小文件直接跑」;归档里放 LICENSE/NOTICE/README.md 是许可义务,而这三份本来就作为并列 Release 资产分发,裸 `ull` 不缺条款。 - `unlucky-launcher` 本体也认全部 CLI 子命令(M82),`ull` 不是第二个界面,而是**不链接图形栈**的 - 同一套命令(差异见 §4 与 §7.6)——两者都不是重复品。 + 同一套命令(差异见 §4 与 §7.4)——两者都不是重复品。 - `` 不带前导 `v`(tag 是 `v0.1.0`,文件名是 `0.1.0`)——由脚本统一裁剪。 - UNIX 归档内 `mtime`/`uid`/`gid` 归零、权限固定(二进制 0755、文本 0644), 同源构建的 sha256 不受打包时刻影响。 @@ -153,7 +153,7 @@ Windows ARM64 原生库(Mojang/LWJGL 只发 x64/x86),实际游玩仍走系 Debian/Ubuntu 一行:`sudo apt install libxkbcommon0 fontconfig libgl1 libegl1 libxcb1 libx11-6 libwayland-client0`。 运行期**不需要** `-dev` 包、不需要 Java(Java 是启动游戏时才由启动器自己管理的东西)。 -上表是**开窗**要用的库,不是进程启动时就要的:实测(§7.1)除 `libfontconfig.so.1` 外它们**都不在** ELF +上表是**开窗**要用的库,不是进程启动时就要的:实测(§7.4)除 `libfontconfig.so.1` 外它们**都不在** ELF 的 `DT_NEEDED` 里,由 `dlopen` 在创建窗口那一刻才加载。所以同一台没有 X11 / Wayland / GL 的机器上 `unlucky-launcher instances list` 照样跑得起来(GUI 二进制先分发后开窗),而 `ull` 连 fontconfig 都不需要。 arm64 侧是同一套(同一份依赖树、同一个 winit/femtovg 组合)。 @@ -264,7 +264,7 @@ reviewer-packaging 在 M46 review 里连续调 `stage_archive()` 两次 → ** `00000000`,归档内单顶层目录且含 `LICENSE`/`NOTICE`/`README.md`。 即"同源构建 → 同 sha256"在 tar 路径上成立,不随打包时刻漂移(见 §1 的约定细节)。 -### 7.4 运行时依赖证据(PE 导入表) +### 7.4 运行时依赖证据(PE 导入表 / ELF `DT_NEEDED`) `objdump -p target/release/unlucky-launcher.exe` 共 42 条 DLL 导入记录、33 个不同名字(大小写各记一次),全部是 Windows 自带: `KERNEL32/USER32/GDI32/ADVAPI32/SHELL32/SHLWAPI/ole32/oleaut32/combase/comctl32/dwmapi/dwrite/imm32/ @@ -285,6 +285,11 @@ GUI 的 ELF `DT_NEEDED` 只有 `libfontconfig.so.1` / `libgcc_s.so.1` / `libm.so `dlsym` / `dlclose`)。所以 §4 那张「开窗要的库」不是启动前提,缺了也不影响进程起来跑 CLI; `ull` 的 `DT_NEEDED` 连 fontconfig 都没有,只剩 glibc 那一组。 +**补一次真跑(M82 复审在 `debian:bookworm-slim` 上做的)**:镜像里 X11 / wayland / GL / EGL / xkbcommon / +libxcb 条目数 = 0,只把 fontconfig 那 7 个 `.so` 放进 `LD_LIBRARY_PATH`,跑**改动后**的二进制 → `--version` +与 `--help` exit 0、`instances list --json` 合法 JSON exit 0、`lst` exit 1 且带提示;`readelf -d` 复核仍是 +恰好 5 条 `DT_NEEDED`(无 `libstdc++`、无任何图形库),`ull` 4 条 —— 即「能跑完子命令」是实测,不是推论。 + ### 7.5 arm64 槽位的本机验证(脚本层取证,不假装跑得动 arm64 二进制) 本机没有 ARM 机器,也不具备 MSVC ARM64 链接库组合,所以只做**能真实证明的东西**(`python` 直接加载 diff --git a/packaging/release-notes.md b/packaging/release-notes.md index a91038f..e12d452 100644 --- a/packaging/release-notes.md +++ b/packaging/release-notes.md @@ -23,6 +23,7 @@ Every slot also ships the command line as its own small asset — `ull-{{VERSION (`.exe` on Windows): ```bash +chmod +x ull-{{VERSION}}-linux-x64 # Release 资产不带可执行位,先补上 ./ull-{{VERSION}}-linux-x64 instances list # servers / CI / Termux: no graphics stack needed unlucky-launcher instances list # desktops: the GUI binary answers the same commands ``` From c9e6236a870d27b694f972d1eb59f14be046f185 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 01:19:10 +0800 Subject: [PATCH 4/7] =?UTF-8?q?refactor(cli):=20=E5=88=A4=E6=8D=AE?= =?UTF-8?q?=E6=94=B6=E6=95=9B=E5=88=B0=20unlucky=5Fcli::cli=EF=BC=8Cbanner?= =?UTF-8?q?=20=E6=8C=89=E8=A2=AB=E8=B0=83=E7=94=A8=E7=9A=84=E4=BA=8C?= =?UTF-8?q?=E8=BF=9B=E5=88=B6=E5=90=8D=E8=87=AA=E7=A7=B0=EF=BC=88M82=20?= =?UTF-8?q?=E5=A4=8D=E5=AE=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 去重:`looks_like_cli(args) -> bool` 落在 `unlucky_cli::cli`,两个 GUI 的 main.rs 只调用它, 判据从两份变一份。cli crate 里**不**出现 GUI 的 flag 表——语义仍是「首个 token 是裸词就接管; 首个 token 是 flag 时只有解析出真命令才接管,解析失败不接管」。原来两个 main.rs 里的 5+5 条 单测合并成 cli crate 的一份(用例集按复审要求原样搬,含 --screenshot/--renderer/--diff/ --mock/--force-state/--scale-factor、--help/-h/--version/-V、10 个已知子命令、--json 前置、 未知子命令),两个 main.rs 的旧测试已删除。 banner:新增 cli::program 模块,从 argv[0] 的 basename 取程序名(剥目录、剥 .exe、空值回落 ull),把帮助/版本/错误提示里独立出现的 `ull` 换成它——`unlucky-launcher --version` 不再自称 `ull`,也不会把人指去敲 `ull --help`。`run()` 签名未改;`ull` 自己跑时输出逐字不变(替换是 恒等),打包脚本的 `ull --version` 冒烟不受影响。 --- README.md | 4 +- README.zh-CN.md | 4 +- crates/app-iced/src/main.rs | 95 ++--------------------- crates/app/src/main.rs | 95 ++--------------------- crates/cli/src/cli/mod.rs | 92 ++++++++++++++++++++++ crates/cli/src/cli/program.rs | 131 ++++++++++++++++++++++++++++++++ crates/cli/src/commands/meta.rs | 23 ++++-- crates/cli/src/output.rs | 9 ++- crates/cli/tests/output_test.rs | 12 ++- 9 files changed, 275 insertions(+), 190 deletions(-) create mode 100644 crates/cli/src/cli/program.rs diff --git a/README.md b/README.md index 534ed1c..2ce85fa 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,9 @@ Binaries in the workspace, so you can tell them apart: `unlucky-launcher` and `ull` are not duplicates. The GUI binary needs the graphics stack when it opens a window (it `dlopen`s X11 / Wayland / GL, and links `libfontconfig` at load); `ull` needs nothing beyond glibc, so it runs on a server, a CI container or Termux where no window can exist. -Pick `ull` when there is no desktop, `unlucky-launcher ` when there is. +Pick `ull` when there is no desktop, `unlucky-launcher ` when there is. Both print the same +CLI text and name themselves after the binary you invoked: `unlucky-launcher --help` starts with +`unlucky-launcher ·`, `ull --help` starts with `ull ·`. ### Subcommand dispatch diff --git a/README.zh-CN.md b/README.zh-CN.md index d1f0260..6f4298d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -72,7 +72,9 @@ Unlucky Launcher(ULL)是一个用 Rust 写的免费开源 Minecraft: Java `unlucky-launcher` 与 `ull` 不是重复品:开窗时 GUI 二进制要用图形栈(X11 / Wayland / GL 走 `dlopen`,启动时直接链接 `libfontconfig`);`ull` 除了 glibc 什么都不需要,所以在没有窗口的服务器、 -CI 容器、Termux 上跑得起来。没有桌面就用 `ull`,有桌面就用 `unlucky-launcher <子命令>`。 +CI 容器、Termux 上跑得起来。没有桌面就用 `ull`,有桌面就用 `unlucky-launcher <子命令>`。两者打出来的 +CLI 文案是同一套,自称的名字跟着**你敲的那个二进制**走:`unlucky-launcher --help` 首行是 +`unlucky-launcher ·`,`ull --help` 首行是 `ull ·`。 ### 子命令分发 diff --git a/crates/app-iced/src/main.rs b/crates/app-iced/src/main.rs index 1965a58..d6a434a 100644 --- a/crates/app-iced/src/main.rs +++ b/crates/app-iced/src/main.rs @@ -5,7 +5,8 @@ //! - 被子命令命中(`instances` / `install` / `launch` / `accounts` / `sessions` / `logs` / //! `diag` / `config` / `java` / `agent`,或 `--help` / `--version`)→ 直接转给 //! `unlucky_cli::run`,**不初始化任何 UI**(不 probe 后端、不扫字体、不碰图形库)。 -//! 子命令表不在这里抄一份:唯一真相源是 `crates/cli` 的解析器(见 [`is_cli_invocation`])。 +//! 子命令表不在这里抄一份:唯一真相源是 `crates/cli` 的解析器 +//! (`unlucky_cli::cli::looks_like_cli`,本文件只调用)。 //! - 其余参数(`--software-renderer` / `--renderer` / `--theme` / `--screenshot*` / //! `--diff` …):开窗跑骨架,`--screenshot` 出图后退出;`--diff ` 只读两张 PNG //! 做逐像素对比(D30 的 GPU/CPU 几何一致性检查),不开窗。 @@ -45,8 +46,11 @@ fn main() -> ExitCode { /// 这条路径上**不构造窗口、不探后端、不扫字体、不依赖任何图形库**(这些库缺失时同一条命令照样 /// 跑完,实测见 packaging/README.md §7.4)。`unlucky-launcher-iced launch …` 的耗时因此与首帧无关—— /// 这句是结构性判断,**没有重量**(原因见 §10-8)。 +/// +/// 「这条命令行算不算 CLI 调用」的判据不在这里重复:`unlucky_cli::cli::looks_like_cli` 是唯一 +/// 一处(M82 复审去重),两个 GUI 二进制都只调它,改语义不会漂。 fn run_cli_if_requested(args: &[String]) -> Option { - if !is_cli_invocation(args) { + if !unlucky_cli::cli::looks_like_cli(args) { return None; } let mut stdout = std::io::stdout(); @@ -57,30 +61,6 @@ fn run_cli_if_requested(args: &[String]) -> Option { Some(ExitCode::from(u8::try_from(code).unwrap_or(2))) } -/// 这条命令行是 CLI 调用还是 GUI 启动? -/// -/// 判据只有一条,**子命令表不在这里重复**:`crates/cli` 的解析器就是唯一真相源, -/// 所以新增子命令不需要动这个文件(Slint 版 `crates/app/src/main.rs` 是同一份判据)。 -/// -/// - 首个 token 是裸词(不以 `-` 开头)→ 一定是子命令位置:GUI 不接受位置参数, -/// 而 `--diff` / `--screenshot` 的取值都在 flag 之后。未知子命令也走这条路, -/// 由 CLI 自己报「未知命令 + `--help`」并以 1 退出(静默落到 GUI 会让脚本用户困惑)。 -/// - 首个 token 是 flag → 只有 CLI 自己解析出一条真命令才算 CLI 调用:`--help` / `--version` / -/// `--json instances list` 走 CLI;`--data-dir X` 解析成 `Welcome`(没有子命令)、 -/// `--renderer` / `--screenshot` / `--diff` 等 GUI 参数干脆解析失败,一律仍走 GUI。 -/// 注意「解析失败」在这里是 GUI,不是 CLI:`--screenshot out.png` 也会解析失败,把它判成 -/// CLI 就会把截图脚本打死(这是本函数据以正确的那条边界)。 -fn is_cli_invocation(args: &[String]) -> bool { - match args.first() { - None => false, - Some(first) if !first.starts_with('-') => true, - Some(_) => match unlucky_cli::cli::parse(args) { - Ok(invocation) => !matches!(invocation.command, unlucky_cli::cli::Command::Welcome), - Err(_) => false, - }, - } -} - /// 开窗路径:解析 GUI 参数 → (`--diff` 走比对)→ 起 UI。 fn run_gui() -> Result { let options = Options::from_env()?; @@ -111,66 +91,3 @@ fn run_gui() -> Result { } } } - -#[cfg(test)] -mod tests { - use super::is_cli_invocation; - - fn cli(args: &[&str]) -> bool { - is_cli_invocation(&args.iter().map(|arg| (*arg).to_owned()).collect::>()) - } - - #[test] - fn no_args_starts_the_gui() { - assert!(!cli(&[])); - } - - #[test] - fn gui_flags_keep_working() { - // CI 与验收脚本在用这些参数,一条都不能被当成子命令吃掉。 - assert!(!cli(&["--software-renderer"])); - assert!(!cli(&["--screenshot", "out.png", "--data-dir", "/d/tmp/x"])); - assert!(!cli(&["--renderer=gpu", "--theme=dark"])); - assert!(!cli(&["--scale-factor", "1.0"])); - assert!(!cli(&["--force-state", "hover"])); - assert!(!cli(&["--diff", "a.png", "b.png"])); - // 只有 --data-dir 时 CLI 解析成 Welcome(没有子命令),仍是 GUI 启动。 - assert!(!cli(&["--data-dir", "/d/tmp/x"])); - } - - #[test] - fn known_subcommands_go_to_the_cli() { - for args in [ - vec!["instances", "list"], - vec!["install", "1.20.1", "--dry-run"], - vec!["launch", "1.20.1", "--dry-run"], - vec!["accounts", "list"], - vec!["sessions", "list"], - vec!["logs", "abc", "--tail", "10"], - vec!["diag", "abc"], - vec!["config", "get"], - vec!["java", "list"], - vec!["agent", "config"], - // 全局选项可以排在子命令前面 - vec!["--json", "instances", "list"], - ] { - assert!(cli(&args), "{args:?} 应该交给 CLI"); - } - } - - #[test] - fn help_and_version_are_cli_too() { - // iced 版以前不认 --version(会直接开窗),现在由 CLI 报版本并退出。 - assert!(cli(&["--version"])); - assert!(cli(&["--help"])); - assert!(cli(&["-h"])); - } - - #[test] - fn unknown_subcommand_is_left_to_the_cli_to_reject() { - // 语义定案(README 两版同款):未知子命令 → CLI 报「未知命令 + ull --help」并以 1 退出, - // 不静默落到 GUI(否则脚本用户会以为参数生效了)。 - assert!(cli(&["lst"])); - assert!(cli(&["intsances", "list"])); - } -} diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 644af3f..03789a8 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -16,7 +16,8 @@ //! 一个二进制两种用法(M82):`unlucky-launcher` 不带子命令时起 GUI;带子命令 //! (`instances` / `install` / `launch` / `accounts` / `sessions` / `logs` / `diag` / //! `config` / `java` / `agent`)时直接转给 `unlucky_cli::run`,**不初始化任何 UI**。 -//! 子命令表不在这里抄一份——唯一真相源是 `crates/cli` 的解析器(见 [`is_cli_invocation`])。 +//! 子命令表不在这里抄一份——唯一真相源是 `crates/cli` 的解析器(见 +//! `unlucky_cli::cli::looks_like_cli`)。 //! 零 UI 依赖的 `ull` 仍然保留:它连 fontconfig 都不需要,给服务器 / CI / Termux 用。 //! //! 架构分层(本文件只有一个职责:分发 + 把窗口拉起来): @@ -101,8 +102,11 @@ fn run_gui() -> Result<(), slint::PlatformError> { /// 这条路径上**不构造窗口、不探后端、不扫字体、不依赖任何图形库**(这些库缺失时同一条命令照样 /// 跑完,实测见 packaging/README.md §7.4)。`unlucky-launcher launch …` 的耗时因此与首帧无关—— /// 这句是结构性判断,**没有重量**(原因见 §10-8)。 +/// +/// 「这条命令行算不算 CLI 调用」的判据不在这里重复:`unlucky_cli::cli::looks_like_cli` 是唯一 +/// 一处(M82 复审去重),两个 GUI 二进制都只调它,改语义不会漂。 fn run_cli_if_requested(args: &[String]) -> Option { - if !is_cli_invocation(args) { + if !unlucky_cli::cli::looks_like_cli(args) { return None; } let mut stdout = std::io::stdout(); @@ -113,30 +117,6 @@ fn run_cli_if_requested(args: &[String]) -> Option { Some(ExitCode::from(u8::try_from(code).unwrap_or(2))) } -/// 这条命令行是 CLI 调用还是 GUI 启动? -/// -/// 判据只有一条,**子命令表不在这里重复**:`crates/cli` 的解析器就是唯一真相源, -/// 所以新增子命令不需要动这个文件(iced 版 `crates/app-iced/src/main.rs` 是同一份判据)。 -/// -/// - 首个 token 是裸词(不以 `-` 开头)→ 一定是子命令位置:GUI 不接受位置参数, -/// 而 `--diff` / `--screenshot` 的取值都在 flag 之后。未知子命令也走这条路, -/// 由 CLI 自己报「未知命令 + `--help`」并以 1 退出(静默落到 GUI 会让脚本用户困惑)。 -/// - 首个 token 是 flag → 只有 CLI 自己解析出一条真命令才算 CLI 调用:`--help` / `--version` / -/// `--json instances list` 走 CLI;`--data-dir X` 解析成 `Welcome`(没有子命令)、 -/// `--software-renderer` / `--screenshot` / `--diff` 等 GUI 参数干脆解析失败,一律仍走 GUI。 -/// 注意「解析失败」在这里是 GUI,不是 CLI:`--screenshot out.png` 也会解析失败,把它判成 -/// CLI 就会把截图脚本打死(这是本函数据以正确的那条边界)。 -fn is_cli_invocation(args: &[String]) -> bool { - match args.first() { - None => false, - Some(first) if !first.starts_with('-') => true, - Some(_) => match unlucky_cli::cli::parse(args) { - Ok(invocation) => !matches!(invocation.command, unlucky_cli::cli::Command::Welcome), - Err(_) => false, - }, - } -} - /// Win7 老机 / 无可用 OpenGL 驱动时的软渲染兜底开关(01c §6 的 Slint 核心卖点)。 fn apply_renderer_flag() { let software = std::env::args() @@ -146,66 +126,3 @@ fn apply_renderer_flag() { std::env::set_var("SLINT_BACKEND", "winit-software"); } } - -#[cfg(test)] -mod tests { - use super::is_cli_invocation; - - fn cli(args: &[&str]) -> bool { - is_cli_invocation(&args.iter().map(|arg| (*arg).to_owned()).collect::>()) - } - - #[test] - fn no_args_starts_the_gui() { - assert!(!cli(&[])); - } - - #[test] - fn gui_flags_keep_working() { - // CI 脚本与人工验收都在用这些参数,一条都不能被当成子命令吃掉。 - assert!(!cli(&["--software-renderer"])); - assert!(!cli(&["--screenshot", "out.png", "--data-dir", "/d/tmp/x"])); - assert!(!cli(&["--screenshot-theme", "dark"])); - assert!(!cli(&["--renderer=gpu", "--theme=dark"])); - assert!(!cli(&["--diff", "a.png", "b.png"])); - assert!(!cli(&["--mock"])); - // 只有 --data-dir 时 CLI 解析成 Welcome(没有子命令),仍是 GUI 启动。 - assert!(!cli(&["--data-dir", "/d/tmp/x"])); - } - - #[test] - fn known_subcommands_go_to_the_cli() { - for args in [ - vec!["instances", "list"], - vec!["install", "1.20.1", "--dry-run"], - vec!["launch", "1.20.1", "--dry-run"], - vec!["accounts", "list"], - vec!["sessions", "list"], - vec!["logs", "abc", "--tail", "10"], - vec!["diag", "abc"], - vec!["config", "get"], - vec!["java", "list"], - vec!["agent", "config"], - // 全局选项可以排在子命令前面 - vec!["--json", "instances", "list"], - ] { - assert!(cli(&args), "{args:?} 应该交给 CLI"); - } - } - - #[test] - fn help_and_version_are_cli_too() { - // GUI 以前不认 --version(会直接开窗),现在由 CLI 报版本并退出。 - assert!(cli(&["--version"])); - assert!(cli(&["--help"])); - assert!(cli(&["-h"])); - } - - #[test] - fn unknown_subcommand_is_left_to_the_cli_to_reject() { - // 语义定案(README 两版同款):未知子命令 → CLI 报「未知命令 + ull --help」并以 1 退出, - // 不静默落到 GUI(否则脚本用户会以为参数生效了)。 - assert!(cli(&["lst"])); - assert!(cli(&["intsances", "list"])); - } -} diff --git a/crates/cli/src/cli/mod.rs b/crates/cli/src/cli/mod.rs index aee3416..9fa99ce 100644 --- a/crates/cli/src/cli/mod.rs +++ b/crates/cli/src/cli/mod.rs @@ -9,9 +9,101 @@ pub mod help; pub mod model; pub mod parse; +pub mod program; pub use model::{ AccountsAddArgs, AccountsAddKind, AgentServeArgs, Command, ConfigGetArgs, ConfigSetArgs, DiagArgs, Globals, InstallArgs, Invocation, LaunchArgs, LogsArgs, }; pub use parse::parse; + +/// 这条命令行是 CLI 调用,还是「不该由 CLI 接管」(GUI 启动 / 只有全局选项)? +/// +/// GUI 二进制(`unlucky-launcher` / `unlucky-launcher-iced`)用它做分发判据,`ull` 自己的 +/// `main` 不用——**判据只有这一处**,两个 GUI 各调一次,改语义不会漂。 +/// +/// 规则(刻意不列 GUI 的 flag 表:cli crate 不该知道 GUI 有什么参数): +/// +/// - 首个 token 是裸词(不以 `-` 开头)→ 一定在子命令位置(GUI 不接受位置参数,`--diff` / +/// `--screenshot` 的取值都排在 flag 之后)→ 接管。未知子命令也在这里,由 CLI 自己报错。 +/// - 首个 token 是 flag → 只有当 [`parse`] 解析出一条**真命令**时才算 CLI 调用:`--help` / +/// `--version` / `--json instances list` 接管;`--data-dir X` 解析成 [`Command::Welcome`] +/// (没有子命令)、GUI 专属参数(`--screenshot` / `--renderer` / `--diff` …)根本解析失败, +/// 两种情况都不接管。**「解析失败」必须留在调用方**:`--screenshot out.png` 也会让本解析器 +/// 报错,把它判成 CLI 就会让 GUI 的截图通道失效。 +#[must_use] +pub fn looks_like_cli(args: &[String]) -> bool { + match args.first() { + None => false, + Some(first) if !first.starts_with('-') => true, + Some(_) => match parse(args) { + Ok(invocation) => !matches!(invocation.command, Command::Welcome), + Err(_) => false, + }, + } +} + +#[cfg(test)] +mod tests { + use super::looks_like_cli; + + fn cli(args: &[&str]) -> bool { + looks_like_cli(&args.iter().map(|arg| (*arg).to_owned()).collect::>()) + } + + #[test] + fn no_args_is_not_a_cli_call() { + assert!(!cli(&[])); + } + + #[test] + fn gui_flags_are_never_taken_over() { + // CI 与验收脚本在用这些参数,一条都不能被当成子命令吃掉。 + assert!(!cli(&["--software-renderer"])); + assert!(!cli(&["--screenshot", "out.png", "--data-dir", "/d/tmp/x"])); + assert!(!cli(&["--screenshot-theme", "dark"])); + assert!(!cli(&["--renderer=gpu", "--theme=dark"])); + assert!(!cli(&["--scale-factor", "1.0"])); + assert!(!cli(&["--force-state", "hover"])); + assert!(!cli(&["--diff", "a.png", "b.png"])); + assert!(!cli(&["--mock"])); + // 只有 --data-dir 时解析成 Welcome(没有子命令),仍是 GUI 启动。 + assert!(!cli(&["--data-dir", "/d/tmp/x"])); + } + + #[test] + fn known_subcommands_are_taken_over() { + for args in [ + vec!["instances", "list"], + vec!["install", "1.20.1", "--dry-run"], + vec!["launch", "1.20.1", "--dry-run"], + vec!["accounts", "list"], + vec!["sessions", "list"], + vec!["logs", "abc", "--tail", "10"], + vec!["diag", "abc"], + vec!["config", "get"], + vec!["java", "list"], + vec!["agent", "config"], + // 全局选项可以排在子命令前面 + vec!["--json", "instances", "list"], + ] { + assert!(cli(&args), "{args:?} 应该交给 CLI"); + } + } + + #[test] + fn help_and_version_are_taken_over() { + assert!(cli(&["--version"])); + assert!(cli(&["--help"])); + assert!(cli(&["-h"])); + assert!(cli(&["-V"])); + } + + #[test] + fn unknown_subcommands_are_left_to_the_cli_to_reject() { + // 语义定案(README 两版同款):未知子命令 → CLI 报「未知命令 + <名字> --help」并以 1 退出, + // 不静默落到 GUI(否则脚本用户会以为参数生效了)。 + assert!(cli(&["lst"])); + assert!(cli(&["intsances", "list"])); + } +} diff --git a/crates/cli/src/cli/program.rs b/crates/cli/src/cli/program.rs new file mode 100644 index 0000000..c5c230b --- /dev/null +++ b/crates/cli/src/cli/program.rs @@ -0,0 +1,131 @@ +//! 程序名:CLI 输出里自称什么,取决于**被调用的那个二进制**叫什么。 +//! +//! GUI 二进制(`unlucky-launcher`)与 `ull` 共用同一套命令行(M82),所以 banner / 用法行 / +//! 「`<名字> --help`」这类提示不能写死 `ull` —— 用户敲的是 `unlucky-launcher`,把他指去敲 +//! 一个可能不存在的名字只会让人困惑。 +//! +//! 取不到(argv[0] 为空,某些 exec 场景)就回落 [`FALLBACK`],输出因此与老版本逐字相同。 + +/// 取不到 argv[0] 时的回落名(也是 `crates/cli` 产出的那个 bin)。 +pub const FALLBACK: &str = "ull"; + +/// 从 argv[0] 求程序名:剥目录、剥 `.exe`、空值回落。 +/// +/// Windows 上是 `C:\…\unlucky-launcher.exe`,unix 上是 `./target/release/unlucky-launcher`, +/// 两种都要得到 `unlucky-launcher`。 +#[must_use] +pub fn name_in(argv0: Option<&str>) -> String { + let Some(raw) = argv0 else { + return FALLBACK.to_owned(); + }; + let base = raw.rsplit(['/', '\\']).next().unwrap_or(raw); + let base = strip_exe(base); + if base.is_empty() { + FALLBACK.to_owned() + } else { + base.to_owned() + } +} + +fn strip_exe(name: &str) -> &str { + match name + .strip_suffix(".exe") + .or_else(|| name.strip_suffix(".EXE")) + { + Some(stem) => stem, + None => name, + } +} + +/// 当前进程的程序名。 +#[must_use] +pub fn name() -> String { + let argv0 = std::env::args_os() + .next() + .map(|arg| arg.to_string_lossy().into_owned()); + name_in(argv0.as_deref()) +} + +/// 把文本里**独立出现**的 `ull` 换成程序名;`program` 就是 `ull` 时原样返回。 +/// +/// 只换两侧都不是 ASCII 字母数字的 `ull`:`ull --help` 里的换,`null` / `pull` 里的不换。 +/// 帮助文案因此不用为了改名重写成动态字符串。 +#[must_use] +pub fn rename(text: &str, program: &str) -> String { + if program == FALLBACK || !text.contains(FALLBACK) { + return text.to_owned(); + } + let mut out = String::with_capacity(text.len() + program.len()); + let mut copied = 0; + let mut index = 0; + while let Some(found) = text[index..].find(FALLBACK) { + let start = index + found; + let end = start + FALLBACK.len(); + let before_ok = !text[..start] + .chars() + .next_back() + .is_some_and(|ch| ch.is_ascii_alphanumeric()); + let after_ok = !text[end..] + .chars() + .next() + .is_some_and(|ch| ch.is_ascii_alphanumeric()); + if before_ok && after_ok { + out.push_str(&text[copied..start]); + out.push_str(program); + copied = end; + } + index = end; + } + out.push_str(&text[copied..]); + out +} + +#[cfg(test)] +mod tests { + use super::{name_in, rename, FALLBACK}; + + #[test] + fn argv0_is_reduced_to_a_bare_name() { + assert_eq!( + name_in(Some(r"C:\Program Files\ULL\unlucky-launcher.exe")), + "unlucky-launcher" + ); + assert_eq!( + name_in(Some("./target/release/unlucky-launcher")), + "unlucky-launcher" + ); + assert_eq!(name_in(Some("ull")), "ull"); + assert_eq!(name_in(Some("/usr/local/bin/ull.exe")), "ull"); + } + + #[test] + fn empty_or_missing_argv0_falls_back() { + assert_eq!(name_in(None), FALLBACK); + assert_eq!(name_in(Some("")), FALLBACK); + assert_eq!(name_in(Some("/usr/bin/")), FALLBACK); + } + + #[test] + fn rename_only_touches_standalone_occurrences() { + assert_eq!( + rename("ull --help 查看命令列表", "unlucky-launcher"), + "unlucky-launcher --help 查看命令列表" + ); + // 别的词里的 ull 不动(防止把 null/pull 改成 n/p + 名字) + assert_eq!( + rename("value is null, pull it", "unlucky-launcher"), + "value is null, pull it" + ); + // 中文紧邻不算字母数字,照换 + assert_eq!( + rename("用法:ull [全局选项]", "unlucky-launcher"), + "用法:unlucky-launcher [全局选项]" + ); + } + + #[test] + fn rename_is_identity_for_ull() { + let text = "ull --help"; + assert_eq!(rename(text, FALLBACK), text); + } +} diff --git a/crates/cli/src/commands/meta.rs b/crates/cli/src/commands/meta.rs index d49425c..6ad1799 100644 --- a/crates/cli/src/commands/meta.rs +++ b/crates/cli/src/commands/meta.rs @@ -2,7 +2,7 @@ use serde_json::json; -use crate::cli::help; +use crate::cli::{help, program}; use crate::os; use crate::output::Report; @@ -12,18 +12,20 @@ pub const LAUNCHER_NAME: &str = "UnluckyLauncher"; /// 版本 + 系统摘要(`ull --version`,D19 §5-2 要求附带 os 摘要)。 /// /// 探测走 `core::platform::os_info::current()`(进程内缓存),未达基线时附带升级指引。 +/// 自称的名字取 argv[0](M82):GUI 二进制调进来时是 `unlucky-launcher`,`ull` 自己跑时仍是 `ull`。 #[must_use] pub fn version() -> Report { let info = os::detect(); + let invoked_as = program::name(); let data = json!({ - "name": "ull", + "name": invoked_as, "package": env!("CARGO_PKG_NAME"), "version": env!("CARGO_PKG_VERSION"), "os": os::to_json(&info), }); let mut human = vec![ format!( - "ull {}({})", + "{invoked_as} {}({})", env!("CARGO_PKG_VERSION"), env!("CARGO_PKG_NAME") ), @@ -38,20 +40,29 @@ pub fn version() -> Report { pub fn welcome() -> Report { let version_report = version(); let mut human = version_report.human; + let text = general_help(); human.push(String::new()); - human.extend(help::general().lines().map(str::to_string)); + human.extend(text.lines().map(str::to_string)); let mut data = version_report.data; - data["help"] = json!(help::general()); + data["help"] = json!(text); Report::new(data, human) } /// 帮助(`ull help <命令>` 给该命令,未知主题退回总帮助)。 #[must_use] pub fn help(topic: Option<&str>) -> Report { - let text = topic.and_then(help::topic).unwrap_or_else(help::general); + let text = match topic.and_then(help::topic) { + Some(text) => program::rename(text, &program::name()), + None => general_help(), + }; let data = json!({ "topic": topic, "text": text, }); Report::new(data, text.lines().map(str::to_string).collect()) } + +/// 总帮助,按被调用的二进制名自述(`ull` 自己跑时逐字不变)。 +fn general_help() -> String { + program::rename(help::general(), &program::name()) +} diff --git a/crates/cli/src/output.rs b/crates/cli/src/output.rs index 28aee0f..a7f5fed 100644 --- a/crates/cli/src/output.rs +++ b/crates/cli/src/output.rs @@ -101,13 +101,18 @@ pub fn print_error( writeln!(out, "{}", serde_json::to_string_pretty(&error.to_json())?)?; return Ok(()); } - writeln!(err, "错误: {}", error.message())?; + writeln!(err, "错误: {}", rename_program(error.message()))?; if let Some(hint) = error.hint() { - writeln!(err, "提示: {hint}")?; + writeln!(err, "提示: {}", rename_program(hint))?; } Ok(()) } +/// 文案里的 `ull` 按被调用的二进制名显示(M82):`unlucky-launcher lst` 不该把人指去敲 `ull`。 +fn rename_program(text: &str) -> String { + crate::cli::program::rename(text, &crate::cli::program::name()) +} + /// 长任务的实时进度行(**只进 stderr**,不参与 [`Report`])。 /// /// 命令的正经输出仍然全部走 [`Report`](stdout、可断言的形状);下载/安装这类分钟级任务 diff --git a/crates/cli/tests/output_test.rs b/crates/cli/tests/output_test.rs index a9df87c..862842c 100644 --- a/crates/cli/tests/output_test.rs +++ b/crates/cli/tests/output_test.rs @@ -11,7 +11,13 @@ fn version_json_has_os_summary() { assert_eq!(code, 0); let value = parse_json(&stdout); assert_eq!(value["ok"], true); - assert_eq!(value["data"]["name"], "ull"); + // M82:自称的名字跟着被调用的二进制走(test harness 里是测试二进制名,`ull` 里是 `ull`); + // argv[0] → 名字的映射由 cli::program 的单测钉死。 + assert_eq!( + value["data"]["name"], + unlucky_cli::cli::program::name(), + "{stdout}" + ); assert_eq!(value["data"]["version"], env!("CARGO_PKG_VERSION")); // D19/D21:os 对象来自 core::platform::os_info(family + 两档 support)。 assert!(value["data"]["os"]["family"].is_string()); @@ -23,7 +29,9 @@ fn version_json_has_os_summary() { fn welcome_without_subcommand_prints_version_and_help() { let (code, stdout, _) = run_cli(&[]); assert_eq!(code, 0); - assert!(stdout.contains("ull "), "{stdout}"); + // banner 用被调用的二进制名(M82),不写死 `ull`。 + let banner = format!("{} ", unlucky_cli::cli::program::name()); + assert!(stdout.starts_with(&banner), "{stdout}"); assert!(stdout.contains("用法:"), "{stdout}"); let (code, stdout, _) = run_cli(&["--json"]); From 2f8bbd1746236261e1648dca88a4516d2586dcc7 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 05:18:40 +0800 Subject: [PATCH 5/7] =?UTF-8?q?fix(cli):=20=E7=A8=8B=E5=BA=8F=E5=90=8D?= =?UTF-8?q?=E5=8F=AA=E6=9B=BF=E6=8D=A2=E6=A8=A1=E6=9D=BF=E5=8D=A0=E4=BD=8D?= =?UTF-8?q?=E7=AC=A6=EF=BC=8C=E4=B8=8D=E5=86=8D=E6=94=B9=E5=86=99=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=95=B0=E6=8D=AE=EF=BC=88M82=20r2=20=E5=A4=8D?= =?UTF-8?q?=E5=AE=A1=20P2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r1 把 `rename` 套在**插值后的整条文本**上,于是用户数据被改坏:账户名恰好是 `ull` 会变成 `unlucky-launcher`,路径 `D:/tmp/ull/.ULL/...` 会变成 `D:/tmp/unlucky-launcher/.ULL/...`, 回显的子命令 `ull` 也会被改名。 改成模板占位符:文案里写 `%prog%`,**只在渲染时**(print_report / print_error / 帮助)替换成 argv[0] 的 basename。占位符只存在于静态模板里,插值进来的账户名 / 路径 / 子命令碰不到它。 用 `%prog%` 而不是 `{prog}`:这些模板要活在 `format!` 里,`{prog}` 会被 format! 当成参数名 编译失败。 同时补上 r1 漏的反方向:**stdout 的正常输出**(Report.human 与 warnings)现在也做替换, 以前只有 stderr 的错误做了,两边不一致。 回归测试:`user_data_that_says_ull_is_never_rewritten` 断言「账户名 `ull`」「路径 `\ull\`」 「未知命令 `ull`」在 fill 之后逐字不变。 占位符替换覆盖 21 个文件的 63 处文案;测试夹具里形如 `ull-mcp-diagnose-` 的临时目录名 已回滚为字面量(那不是文案)。 --- crates/cli/src/cli/help.rs | 38 ++++----- crates/cli/src/cli/parse.rs | 11 +-- crates/cli/src/cli/parse/accounts.rs | 2 +- crates/cli/src/cli/parse/agent.rs | 2 +- crates/cli/src/cli/program.rs | 81 ++++++++----------- crates/cli/src/commands/accounts.rs | 4 +- crates/cli/src/commands/agent/mod.rs | 2 +- crates/cli/src/commands/config.rs | 2 +- crates/cli/src/commands/identity.rs | 10 +-- crates/cli/src/commands/install/loader.rs | 2 +- .../src/commands/install/loader_processors.rs | 2 +- crates/cli/src/commands/install/mod.rs | 4 +- crates/cli/src/commands/install/vanilla.rs | 4 +- crates/cli/src/commands/instances.rs | 2 +- crates/cli/src/commands/java.rs | 4 +- crates/cli/src/commands/launch.rs | 2 +- crates/cli/src/commands/meta.rs | 4 +- crates/cli/src/commands/mod.rs | 8 +- crates/cli/src/commands/sessions.rs | 2 +- crates/cli/src/output.rs | 18 +++-- 20 files changed, 99 insertions(+), 105 deletions(-) diff --git a/crates/cli/src/cli/help.rs b/crates/cli/src/cli/help.rs index 5f8b5a0..290a562 100644 --- a/crates/cli/src/cli/help.rs +++ b/crates/cli/src/cli/help.rs @@ -7,10 +7,10 @@ #[must_use] pub const fn general() -> &'static str { "\ -ull · Unlucky-Launcher 命令行(D18) +%prog% · Unlucky-Launcher 命令行(D18) 用法: - ull [全局选项] <命令> [参数] + %prog% [全局选项] <命令> [参数] 命令: instances list 列出实例与已安装版本 @@ -55,14 +55,14 @@ pub fn topic(name: &str) -> Option<&'static str> { match name { "instances" => Some( "\ -ull instances list +%prog% instances list 列出 profiles.json 里的实例与 versions/ 下已安装的版本。 --json 输出 {\"data\":{\"instances\":[…],\"installed_versions\":[…],\"data_root\":…}}", ), "install" => Some( "\ -ull install [--loader X] [--loader-version V] [--name N] [--dry-run] +%prog% install [--loader X] [--loader-version V] [--name N] [--dry-run] 解析版本清单与继承链 → 组下载计划 → 落盘(versions/ assets/ libraries/)→ 登记实例 (profiles.json + config/instances/.json)。已存在且校验通过的文件会跳过, @@ -81,7 +81,7 @@ versions/<原版>-<加载器>-<版本>/<…>.json(inheritsFrom 原版 + 主类 ), "launch" => Some( "\ -ull launch [--dry-run] [--account ] [--java ] [--username ] +%prog% launch [--dry-run] [--account ] [--java ] [--username ] [--max-memory ] 可以是实例 id、实例名,或直接是版本 id。 @@ -92,10 +92,10 @@ accessToken 一律脱敏(输出里出现的是 )。", ), "accounts" => Some( "\ -ull accounts list -ull accounts add offline -ull accounts add msa -ull accounts remove +%prog% accounts list +%prog% accounts add offline +%prog% accounts add msa +%prog% accounts remove 账户存在 <数据根>/.ULL/data/auth/:accounts.json(可公开元数据)与 credentials.dat(凭据) **分文件**存放,先写凭据后写元数据、原子写、凭据文件 POSIX 侧 0600。 @@ -104,25 +104,25 @@ add msa 走微软 device-code(用户码打在 stderr,去 https://www.microso 需要先配置 ULL_MSA_CLIENT_ID(Azure 公共客户端 id)。 authlib-injector 账户的密码登录留给 GUI(密码进 argv 会泄露,CLI 不开这条通道)。", ), - "sessions" => Some("ull sessions list [--limit N] 列出会话索引(按开始时间倒序,缺省 20 条)"), - "logs" => Some("ull logs [--tail N] 允许唯一前缀;输出会话 JSONL 末尾 N 条事件(缺省 200)"), + "sessions" => Some("%prog% sessions list [--limit N] 列出会话索引(按开始时间倒序,缺省 20 条)"), + "logs" => Some("%prog% logs [--tail N] 允许唯一前缀;输出会话 JSONL 末尾 N 条事件(缺省 200)"), "diag" => Some( - "ull diag [--tail N] 把会话的退出信号与日志喂给规则引擎(内嵌规则集),输出结构化诊断", + "%prog% diag [--tail N] 把会话的退出信号与日志喂给规则引擎(内嵌规则集),输出结构化诊断", ), "config" => Some( "\ -ull config get [] [--show-secret] -ull config set +%prog% config get [] [--show-secret] +%prog% config set 读写 <数据根>/.ULL/config/launcher.json,走 core::setting 的 schema(类型/枚举校验、 原子写)。敏感键 get 时脱敏,--show-secret 才给明文;值先按 JSON 解析,失败按字符串。", ), "java" => Some( "\ -ull java list 探测 JAVA_HOME / PATH / 托管目录 / 实例覆盖里的 Java +%prog% java list 探测 JAVA_HOME / PATH / 托管目录 / 实例覆盖里的 Java 并打印每个 JDK 的实际落点(managed 项带目录) -ull java install [--vendor X] 安装托管 JDK(D13/D23):回退链查版本 → 下载 → 解压 → 验证 -ull java vendors 列出四家发行版的镜像/支持版本/当前优先级顺序 +%prog% java install [--vendor X] 安装托管 JDK(D13/D23):回退链查版本 → 下载 → 解压 → 验证 +%prog% java vendors 列出四家发行版的镜像/支持版本/当前优先级顺序 落点(D23 三模式,随 core.data_mode 与设置键 java.installRoot 走): portable(默认) <数据根>/.ULL/data/java-managed/jdk--/ @@ -137,8 +137,8 @@ ull java vendors 列出四家发行版的镜像/支持版 ), "agent" => Some( "\ -ull agent serve [--stdio] [--port N] [--host H] [--allow-write|--readonly] -ull agent config [--port N] +%prog% agent serve [--stdio] [--port N] [--host H] [--allow-write|--readonly] +%prog% agent config [--port N] 把 agent 的工具注册表挂成 **MCP server**(02 文档 §1 的第二形态:同一注册表、同一安全策略、 同一审计)。默认走 Streamable HTTP,只绑回环地址(127.0.0.1,默认端口 47823); diff --git a/crates/cli/src/cli/parse.rs b/crates/cli/src/cli/parse.rs index 1c1b4d6..0e8e349 100644 --- a/crates/cli/src/cli/parse.rs +++ b/crates/cli/src/cli/parse.rs @@ -92,7 +92,8 @@ fn parse_command(rest: &[String], globals: &Globals) -> Result accounts::parse_accounts(tail), "agent" => agent::parse_agent(tail), other => { - Err(CliError::usage(format!("未知命令 `{other}`")).with_hint("ull --help 查看命令列表")) + Err(CliError::usage(format!("未知命令 `{other}`")) + .with_hint("%prog% --help 查看命令列表")) } } } @@ -194,7 +195,7 @@ fn parse_diag(tail: &[String]) -> Result { fn parse_config(tail: &[String]) -> Result { let Some(sub) = tail.first().map(String::as_str) else { return Err(CliError::usage("config 需要子命令 get|set") - .with_hint("ull config get / ull config set ")); + .with_hint("%prog% config get / %prog% config set ")); }; let rest = tail.get(1..).unwrap_or_default(); match sub { @@ -212,7 +213,7 @@ fn parse_config(tail: &[String]) -> Result { let parsed = parse_flags(rest, &[], &[])?; if parsed.positionals.len() != 2 { return Err(CliError::usage( - "config set 需要键路径与值:ull config set ", + "config set 需要键路径与值:%prog% config set ", )); } Ok(Command::ConfigSet(ConfigSetArgs { @@ -262,8 +263,8 @@ fn parse_java(tail: &[String]) -> Result { fn require_single_positional(parsed: &ParsedArgs, usage: &str) -> Result { match parsed.positionals.as_slice() { [only] => Ok(only.clone()), - [] => Err(CliError::usage(format!("缺少参数:ull {usage}"))), - _ => Err(CliError::usage(format!("参数过多:ull {usage}"))), + [] => Err(CliError::usage(format!("缺少参数:%prog% {usage}"))), + _ => Err(CliError::usage(format!("参数过多:%prog% {usage}"))), } } diff --git a/crates/cli/src/cli/parse/accounts.rs b/crates/cli/src/cli/parse/accounts.rs index c24b96f..b8f7389 100644 --- a/crates/cli/src/cli/parse/accounts.rs +++ b/crates/cli/src/cli/parse/accounts.rs @@ -41,7 +41,7 @@ fn parse_add(rest: &[String]) -> Result { let Some(kind) = rest.first().map(String::as_str) else { return Err( CliError::usage("accounts add 需要轨道:offline|msa").with_hint( - "ull accounts add offline <玩家名> / ull accounts add msa(设备码交互式登录)", + "%prog% accounts add offline <玩家名> / %prog% accounts add msa(设备码交互式登录)", ), ); }; diff --git a/crates/cli/src/cli/parse/agent.rs b/crates/cli/src/cli/parse/agent.rs index 74df2f4..ef39915 100644 --- a/crates/cli/src/cli/parse/agent.rs +++ b/crates/cli/src/cli/parse/agent.rs @@ -16,7 +16,7 @@ use super::{ensure_no_positionals, parse_flags}; pub fn parse_agent(tail: &[String]) -> Result { let Some(sub) = tail.first().map(String::as_str) else { return Err(CliError::usage("agent 需要子命令 serve|config") - .with_hint("ull agent serve / ull agent config")); + .with_hint("%prog% agent serve / %prog% agent config")); }; let rest = tail.get(1..).unwrap_or_default(); match sub { diff --git a/crates/cli/src/cli/program.rs b/crates/cli/src/cli/program.rs index c5c230b..d6f6d5c 100644 --- a/crates/cli/src/cli/program.rs +++ b/crates/cli/src/cli/program.rs @@ -4,7 +4,18 @@ //! 「`<名字> --help`」这类提示不能写死 `ull` —— 用户敲的是 `unlucky-launcher`,把他指去敲 //! 一个可能不存在的名字只会让人困惑。 //! -//! 取不到(argv[0] 为空,某些 exec 场景)就回落 [`FALLBACK`],输出因此与老版本逐字相同。 +//! 做法(M82 r2 定案):文案里放**占位符**,渲染时替换;**绝不对插值后的整条文本做词替换**。 +//! 上一版按「独立词 `ull`」替换整条消息,于是用户数据被改坏过:账户名叫 `ull` 的会变成 +//! `unlucky-launcher`,路径 `D:/tmp/ull/.ULL` 会变成 `D:/tmp/unlucky-launcher/.ULL`。 +//! 占位符只在模板里出现,插值进来的用户名/路径碰不到它。 +//! +//! 取不到 argv[0](某些 exec 场景)就回落 [`FALLBACK`],输出因此与老版本逐字相同。 + +/// 文案里的程序名占位符。 +/// +/// 用 `%prog%` 而不是 `{prog}`:这些模板要活在 `format!` 里,`{prog}` 会被 `format!` 当成 +/// 参数名直接编译失败,而 `%prog%` 原样穿过去,到渲染时才替换。 +pub const PLACEHOLDER: &str = "%prog%"; /// 取不到 argv[0] 时的回落名(也是 `crates/cli` 产出的那个 bin)。 pub const FALLBACK: &str = "ull"; @@ -46,43 +57,21 @@ pub fn name() -> String { name_in(argv0.as_deref()) } -/// 把文本里**独立出现**的 `ull` 换成程序名;`program` 就是 `ull` 时原样返回。 +/// 把模板里的 [`PLACEHOLDER`] 换成程序名。 /// -/// 只换两侧都不是 ASCII 字母数字的 `ull`:`ull --help` 里的换,`null` / `pull` 里的不换。 -/// 帮助文案因此不用为了改名重写成动态字符串。 +/// **只认占位符**:文本里其它位置的 `ull`(账户名、路径段、回显的子命令)原样保留——这是 +/// 与「按词替换整条文本」的分界线,也是本函数唯一要保证的事。 #[must_use] -pub fn rename(text: &str, program: &str) -> String { - if program == FALLBACK || !text.contains(FALLBACK) { +pub fn fill(text: &str) -> String { + if !text.contains(PLACEHOLDER) { return text.to_owned(); } - let mut out = String::with_capacity(text.len() + program.len()); - let mut copied = 0; - let mut index = 0; - while let Some(found) = text[index..].find(FALLBACK) { - let start = index + found; - let end = start + FALLBACK.len(); - let before_ok = !text[..start] - .chars() - .next_back() - .is_some_and(|ch| ch.is_ascii_alphanumeric()); - let after_ok = !text[end..] - .chars() - .next() - .is_some_and(|ch| ch.is_ascii_alphanumeric()); - if before_ok && after_ok { - out.push_str(&text[copied..start]); - out.push_str(program); - copied = end; - } - index = end; - } - out.push_str(&text[copied..]); - out + text.replace(PLACEHOLDER, &name()) } #[cfg(test)] mod tests { - use super::{name_in, rename, FALLBACK}; + use super::{fill, name_in, FALLBACK, PLACEHOLDER}; #[test] fn argv0_is_reduced_to_a_bare_name() { @@ -106,26 +95,26 @@ mod tests { } #[test] - fn rename_only_touches_standalone_occurrences() { + fn fill_replaces_only_the_placeholder() { assert_eq!( - rename("ull --help 查看命令列表", "unlucky-launcher"), - "unlucky-launcher --help 查看命令列表" - ); - // 别的词里的 ull 不动(防止把 null/pull 改成 n/p + 名字) - assert_eq!( - rename("value is null, pull it", "unlucky-launcher"), - "value is null, pull it" - ); - // 中文紧邻不算字母数字,照换 - assert_eq!( - rename("用法:ull [全局选项]", "unlucky-launcher"), - "用法:unlucky-launcher [全局选项]" + fill(&format!("{PLACEHOLDER} --help 查看命令列表")), + format!("{} --help 查看命令列表", super::name()) ); + // 没有占位符就原样返回(不碰任何词)。 + assert_eq!(fill("ull --help"), "ull --help"); } #[test] - fn rename_is_identity_for_ull() { - let text = "ull --help"; - assert_eq!(rename(text, FALLBACK), text); + fn user_data_that_says_ull_is_never_rewritten() { + // 回归:账户名恰好是 `ull` / 路径里有 `\ull\` / 回显的子命令是 `ull`, + // 一个字节都不能被改成程序名(M82 r1 的真缺陷)。 + for text in [ + "找不到账户 `ull`", + r"索引文件:D:/tmp/ull\.ULL\data\agent\sessions\index.jsonl", + "未知命令 `ull`", + "过滤词 null 与 pull 也不算", + ] { + assert_eq!(fill(text), text, "{text} 被改了"); + } } } diff --git a/crates/cli/src/commands/accounts.rs b/crates/cli/src/commands/accounts.rs index 9f133d6..b4fffe0 100644 --- a/crates/cli/src/commands/accounts.rs +++ b/crates/cli/src/commands/accounts.rs @@ -28,8 +28,8 @@ pub fn list(ctx: &Ctx) -> Result { let mut human = Vec::new(); if store.is_empty() { human.push("还没有账户。".to_string()); - human.push(" 离线:ull accounts add offline <玩家名>".to_string()); - human.push(" 微软:ull accounts add msa(设备码交互式登录)".to_string()); + human.push(" 离线:%prog% accounts add offline <玩家名>".to_string()); + human.push(" 微软:%prog% accounts add msa(设备码交互式登录)".to_string()); } else { human.push(format!("账户({} 个):", store.accounts().len())); for meta in store.accounts() { diff --git a/crates/cli/src/commands/agent/mod.rs b/crates/cli/src/commands/agent/mod.rs index 59edf29..06088e7 100644 --- a/crates/cli/src/commands/agent/mod.rs +++ b/crates/cli/src/commands/agent/mod.rs @@ -122,7 +122,7 @@ pub fn config_report(ctx: &Ctx, port: Option) -> Result { human.push("Cursor(.cursor/mcp.json):".to_string()); human.extend(pretty_lines(&samples.cursor)); human.push( - "启动:ull agent serve(默认 Streamable HTTP) / ull agent serve --stdio".to_string(), + "启动:%prog% agent serve(默认 Streamable HTTP) / %prog% agent serve --stdio".to_string(), ); Ok(Report::new(data, human)) } diff --git a/crates/cli/src/commands/config.rs b/crates/cli/src/commands/config.rs index f6598f1..316cf53 100644 --- a/crates/cli/src/commands/config.rs +++ b/crates/cli/src/commands/config.rs @@ -53,7 +53,7 @@ fn get_one(ctx: &Ctx, key: &str, show_secret: bool) -> Result let Some(value) = store.effective(key) else { return Err( CliError::user("unknown_key", format!("配置键 `{key}` 不在 schema 里")) - .with_hint("`ull config get`(不带键)可以看到全部生效键"), + .with_hint("`%prog% config get`(不带键)可以看到全部生效键"), ); }; diff --git a/crates/cli/src/commands/identity.rs b/crates/cli/src/commands/identity.rs index 3594e79..3944f17 100644 --- a/crates/cli/src/commands/identity.rs +++ b/crates/cli/src/commands/identity.rs @@ -59,7 +59,7 @@ pub fn resolve( format!("有 {} 个账户,需要指定用哪个", many.len()), ) .with_hint(format!( - "ull launch --account <名字>;候选:{}", + "%prog% launch --account <名字>;候选:{}", names_of(&store) ))) } @@ -75,7 +75,7 @@ pub fn resolve( "已选中账户 {},`--username` 只用于没有账户时的离线占位", meta.display_name() )) - .with_hint("去掉 --username,或用 `ull accounts add offline <名字>` 建账户")); + .with_hint("去掉 --username,或用 `%prog% accounts add offline <名字>` 建账户")); } let mut notes = Vec::new(); @@ -103,7 +103,7 @@ fn offline_fallback(username: Option<&str>) -> Identity { }, account: None, notes: vec![ - "没有账户,按离线占位启动(ull accounts add offline <名字> 可建账户)".to_string(), + "没有账户,按离线占位启动(%prog% accounts add offline <名字> 可建账户)".to_string(), ], } } @@ -120,7 +120,7 @@ fn microsoft_identity( "account_credentials", format!("账户 {} 的微软凭据缺失或类型不符", meta.display_name()), ) - .with_hint("重新登录:ull accounts add msa")); + .with_hint("重新登录:%prog% accounts add msa")); }; if dry_run { @@ -193,7 +193,7 @@ fn names_of(store: &AccountStore) -> String { fn unknown_account(key: &str, store: &AccountStore) -> CliError { let hint = if store.is_empty() { - "还没有账户:ull accounts add offline <名字> 或 ull accounts add msa".to_string() + "还没有账户:%prog% accounts add offline <名字> 或 %prog% accounts add msa".to_string() } else { format!("可用账户:{}", names_of(store)) }; diff --git a/crates/cli/src/commands/install/loader.rs b/crates/cli/src/commands/install/loader.rs index 9e979f6..4a7fb7c 100644 --- a/crates/cli/src/commands/install/loader.rs +++ b/crates/cli/src/commands/install/loader.rs @@ -44,7 +44,7 @@ pub(super) fn run(ctx: &Ctx, args: &InstallArgs, kind: LoaderKind) -> Result --loader fabric", + "install 需要版本 id:%prog% install --loader fabric", )); } diff --git a/crates/cli/src/commands/install/loader_processors.rs b/crates/cli/src/commands/install/loader_processors.rs index 338bf49..60d1347 100644 --- a/crates/cli/src/commands/install/loader_processors.rs +++ b/crates/cli/src/commands/install/loader_processors.rs @@ -107,7 +107,7 @@ pub(super) fn execute( ), ) .with_hint(format!( - "`ull java install {}` 装一个托管 JDK,或把 JAVA_HOME 指向可用的 JDK(托管目录 {})", + "`%prog% java install {}` 装一个托管 JDK,或把 JAVA_HOME 指向可用的 JDK(托管目录 {})", plan.java_requirement.minimum, managed.display() ))); diff --git a/crates/cli/src/commands/install/mod.rs b/crates/cli/src/commands/install/mod.rs index b9a63b5..2d3553f 100644 --- a/crates/cli/src/commands/install/mod.rs +++ b/crates/cli/src/commands/install/mod.rs @@ -113,7 +113,7 @@ pub(super) fn ensure_name_free(ctx: &Ctx, name: &str, version: &str) -> Result<( "instance_exists", format!("同名实例已存在:{name}(绑定版本 {})", profile.version_id), ) - .with_hint("用 --name 换个名字,或 `ull instances list` 看看现有实例")), + .with_hint("用 --name 换个名字,或 `%prog% instances list` 看看现有实例")), None => Ok(()), } } @@ -145,7 +145,7 @@ pub(super) fn failure_error(report: &InstallReport, ctx: &Ctx) -> CliError { } } CliError::user(code, message).with_hint(format!( - "重跑 `ull install` 会续传并跳过已就绪文件;游戏根 {}", + "重跑 `%prog% install` 会续传并跳过已就绪文件;游戏根 {}", ctx.game().root().display() )) } diff --git a/crates/cli/src/commands/install/vanilla.rs b/crates/cli/src/commands/install/vanilla.rs index e01d8e2..b0a074a 100644 --- a/crates/cli/src/commands/install/vanilla.rs +++ b/crates/cli/src/commands/install/vanilla.rs @@ -37,7 +37,7 @@ pub(super) fn run(ctx: &Ctx, args: &InstallArgs) -> Result { let version = args.version.trim().to_string(); if version.is_empty() { return Err(CliError::usage( - "install 需要版本 id:ull install ", + "install 需要版本 id:%prog% install ", )); } let name = args @@ -139,7 +139,7 @@ pub(super) fn plan_and_install( format!("版本 {version} 装不全:{reasons}"), ) .with_hint( - "旧版(pre-1.6)清单兜底待补;可先用官方启动器装好该版本再用 `ull launch`", + "旧版(pre-1.6)清单兜底待补;可先用官方启动器装好该版本再用 `%prog% launch`", )); } let sink = report::progress_sink(progress, json_progress, plan.file_count()); diff --git a/crates/cli/src/commands/instances.rs b/crates/cli/src/commands/instances.rs index a5021e0..95a059d 100644 --- a/crates/cli/src/commands/instances.rs +++ b/crates/cli/src/commands/instances.rs @@ -59,7 +59,7 @@ pub fn list(ctx: &Ctx) -> Result { ctx.data.launcher_home.display() )]; if body.is_empty() { - human.push(" (空)用 `ull install ` 登记第一个实例".to_string()); + human.push(" (空)用 `%prog% install ` 登记第一个实例".to_string()); } human.extend(body); if installed.is_empty() { diff --git a/crates/cli/src/commands/java.rs b/crates/cli/src/commands/java.rs index 6e35269..1ab3622 100644 --- a/crates/cli/src/commands/java.rs +++ b/crates/cli/src/commands/java.rs @@ -111,7 +111,7 @@ pub fn list(ctx: &Ctx) -> Result { let mut human = vec![format!("Java {} 个", candidates.len())]; if candidates.is_empty() { human.push( - "(没找到可用 Java)用 `ull java install 21` 装一个,或设 JAVA_HOME / `ull launch --java `" + "(没找到可用 Java)用 `%prog% java install 21` 装一个,或设 JAVA_HOME / `%prog% launch --java `" .to_string(), ); } @@ -209,7 +209,7 @@ pub fn install(ctx: &Ctx, major: u32, vendor: Option) -> Result CliError { fn instance_hint(profiles: &ProfileList) -> String { if profiles.profiles.is_empty() { - "用 `ull instances list` 查看实例,或 `ull install ` 登记一个".to_string() + "用 `%prog% instances list` 查看实例,或 `%prog% install ` 登记一个".to_string() } else { let names: Vec<&str> = profiles .profiles diff --git a/crates/cli/src/commands/meta.rs b/crates/cli/src/commands/meta.rs index 6ad1799..49dafa9 100644 --- a/crates/cli/src/commands/meta.rs +++ b/crates/cli/src/commands/meta.rs @@ -52,7 +52,7 @@ pub fn welcome() -> Report { #[must_use] pub fn help(topic: Option<&str>) -> Report { let text = match topic.and_then(help::topic) { - Some(text) => program::rename(text, &program::name()), + Some(text) => program::fill(text), None => general_help(), }; let data = json!({ @@ -64,5 +64,5 @@ pub fn help(topic: Option<&str>) -> Report { /// 总帮助,按被调用的二进制名自述(`ull` 自己跑时逐字不变)。 fn general_help() -> String { - program::rename(help::general(), &program::name()) + program::fill(help::general()) } diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index e6e385e..b39ca94 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -75,14 +75,14 @@ pub(crate) fn map_auth_error(error: unlucky_core::auth::AuthError) -> CliError { let hint = match &error { AuthError::Canceled => Some("登录被取消,可以重跑一次".to_string()), AuthError::Timeout { .. } => { - Some("重新执行 `ull accounts add msa` 拿新的用户码".to_string()) + Some("重新执行 `%prog% accounts add msa` 拿新的用户码".to_string()) } - AuthError::CredentialExpired => Some("重新登录:ull accounts add msa".to_string()), + AuthError::CredentialExpired => Some("重新登录:%prog% accounts add msa".to_string()), AuthError::Unsupported(_) => { Some("微软登录需要设置 ULL_MSA_CLIENT_ID(Azure 公共客户端 id)".to_string()) } - AuthError::Conflict { .. } => Some("用 `ull accounts list` 查看已有账户".to_string()), - AuthError::NotFound { .. } => Some("用 `ull accounts list` 查看账户名".to_string()), + AuthError::Conflict { .. } => Some("用 `%prog% accounts list` 查看已有账户".to_string()), + AuthError::NotFound { .. } => Some("用 `%prog% accounts list` 查看账户名".to_string()), AuthError::Denied { code, .. } if code == "no_profile" || code == "no_license" => { Some("去 minecraft.net 检查该账号的 Java 版拥有权与档案".to_string()) } diff --git a/crates/cli/src/commands/sessions.rs b/crates/cli/src/commands/sessions.rs index a125914..e8865c0 100644 --- a/crates/cli/src/commands/sessions.rs +++ b/crates/cli/src/commands/sessions.rs @@ -57,7 +57,7 @@ pub fn list(ctx: &Ctx, limit: usize) -> Result { index.display() )]; if shown.is_empty() { - human.push(" (空)先用 `ull launch <实例>` 跑一局".to_string()); + human.push(" (空)先用 `%prog% launch <实例>` 跑一局".to_string()); } for entry in &shown { human.push(format!( diff --git a/crates/cli/src/output.rs b/crates/cli/src/output.rs index a7f5fed..6affb07 100644 --- a/crates/cli/src/output.rs +++ b/crates/cli/src/output.rs @@ -77,11 +77,11 @@ pub fn print_report( } if !quiet { for line in &outcome.report.human { - writeln!(out, "{line}")?; + writeln!(out, "{}", fill_program(line))?; } } for warning in &outcome.warnings { - writeln!(err, "提示: {warning}")?; + writeln!(err, "提示: {}", fill_program(warning))?; } Ok(()) } @@ -101,16 +101,20 @@ pub fn print_error( writeln!(out, "{}", serde_json::to_string_pretty(&error.to_json())?)?; return Ok(()); } - writeln!(err, "错误: {}", rename_program(error.message()))?; + writeln!(err, "错误: {}", fill_program(error.message()))?; if let Some(hint) = error.hint() { - writeln!(err, "提示: {}", rename_program(hint))?; + writeln!(err, "提示: {}", fill_program(hint))?; } Ok(()) } -/// 文案里的 `ull` 按被调用的二进制名显示(M82):`unlucky-launcher lst` 不该把人指去敲 `ull`。 -fn rename_program(text: &str) -> String { - crate::cli::program::rename(text, &crate::cli::program::name()) +/// 渲染文案里的程序名占位符(M82)。 +/// +/// **只替换占位符**,不对整条文本做词替换:消息里插值进来的账户名 / 路径 / 回显的子命令 +/// 一个字节都不动(r1 曾把账户名 `ull` 改成程序名、把 `D:/tmp/ull/.ULL` 改成 +/// `D:/tmp/unlucky-launcher/.ULL`)。 +fn fill_program(text: &str) -> String { + crate::cli::program::fill(text) } /// 长任务的实时进度行(**只进 stderr**,不参与 [`Report`])。 From 8ca1e1e965794710ae5c98b40af6cf429bd62a90 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 07:10:25 +0800 Subject: [PATCH 6/7] =?UTF-8?q?fix(cli):=20=E7=A8=8B=E5=BA=8F=E5=90=8D?= =?UTF-8?q?=E5=8D=A0=E4=BD=8D=E7=AC=A6=E5=9C=A8=E6=9E=84=E9=80=A0=E6=97=B6?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=EF=BC=8C=E4=B8=A4=E6=9D=A1=E5=87=BA=E5=8F=A3?= =?UTF-8?q?=EF=BC=88=E4=BA=BA=E7=B1=BB=E5=8F=AF=E8=AF=BB=20/=20--json?= =?UTF-8?q?=EF=BC=89=E4=B8=8D=E5=86=8D=E5=88=86=E5=8F=89=EF=BC=88M82=20r3?= =?UTF-8?q?=20=E5=A4=8D=E5=AE=A1=20P2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r2 只在人类可读通道渲染 `%prog%`,JSON 两条出口(错误信封的 message/hint、Report 的 warnings)直接序列化未渲染文本,于是 `--json lst` 吐出 `"hint":"%prog% --help 查看命令列表"` ——连 `ull` 自己的 JSON 都被这轮改动弄坏了。 改成**构造时渲染**,一个入口管住所有出口: - `CliError::usage/user/internal/with_hint` 统一过 `render()`(错误只有这一处成形); - `Report::new` 渲染 human 行,`Outcome::with_warnings` 渲染 warnings; - `print_report` / `print_error` 不再各自渲染(渲染点只有一个,不会再有第三条出口漏掉)。 回归测试三条:`json_error_envelope_never_leaks_the_placeholder`、 `json_warnings_never_leak_the_placeholder`、`human_lines_render_the_placeholder_too` (后者同时断言「账户名恰好叫 ull」这种用户数据不被改)。 --- crates/cli/src/error.rs | 16 ++++++--- crates/cli/src/output.rs | 72 +++++++++++++++++++++++++++++++++++----- 2 files changed, 75 insertions(+), 13 deletions(-) diff --git a/crates/cli/src/error.rs b/crates/cli/src/error.rs index ca2f138..9416e32 100644 --- a/crates/cli/src/error.rs +++ b/crates/cli/src/error.rs @@ -56,6 +56,14 @@ pub struct CliError { hint: Option, } +/// 把模板里的程序名占位符渲染掉(M82 r3)。 +/// +/// 渲染放在**构造时**,而不是打印时:错误有两条出口(人类可读 stderr、`--json` 信封), +/// 只在打印处渲染会漏掉 JSON 那条(r2 就是这么漏的,`%prog%` 直接出现在了 JSON 里)。 +fn render(text: impl Into) -> String { + crate::cli::program::fill(&text.into()) +} + impl CliError { /// 用法错误(参数不对)。 #[must_use] @@ -63,7 +71,7 @@ impl CliError { Self { kind: ErrorKind::User, code: "usage", - message: message.into(), + message: render(message), hint: None, } } @@ -74,7 +82,7 @@ impl CliError { Self { kind: ErrorKind::User, code, - message: message.into(), + message: render(message), hint: None, } } @@ -85,7 +93,7 @@ impl CliError { Self { kind: ErrorKind::Internal, code, - message: message.into(), + message: render(message), hint: None, } } @@ -99,7 +107,7 @@ impl CliError { /// 补一句提示。 #[must_use] pub fn with_hint(mut self, hint: impl Into) -> Self { - self.hint = Some(hint.into()); + self.hint = Some(render(hint)); self } diff --git a/crates/cli/src/output.rs b/crates/cli/src/output.rs index 6affb07..f08ba20 100644 --- a/crates/cli/src/output.rs +++ b/crates/cli/src/output.rs @@ -20,10 +20,13 @@ pub struct Report { } impl Report { - /// 构造。 + /// 构造(人类可读行在这里就把程序名占位符渲染掉,见 [`crate::cli::program::PLACEHOLDER`])。 #[must_use] pub fn new(data: Value, human: Vec) -> Self { - Self { data, human } + Self { + data, + human: human.into_iter().map(|line| fill_program(&line)).collect(), + } } } @@ -46,10 +49,16 @@ impl Outcome { } } - /// 带上环境提示。 + /// 带上环境提示(同样在构造时渲染占位符,JSON 与人类可读两条出口才不会分叉)。 #[must_use] pub fn with_warnings(report: Report, warnings: Vec) -> Self { - Self { report, warnings } + Self { + report, + warnings: warnings + .into_iter() + .map(|line| fill_program(&line)) + .collect(), + } } } @@ -77,11 +86,11 @@ pub fn print_report( } if !quiet { for line in &outcome.report.human { - writeln!(out, "{}", fill_program(line))?; + writeln!(out, "{line}")?; } } for warning in &outcome.warnings { - writeln!(err, "提示: {}", fill_program(warning))?; + writeln!(err, "提示: {warning}")?; } Ok(()) } @@ -101,9 +110,9 @@ pub fn print_error( writeln!(out, "{}", serde_json::to_string_pretty(&error.to_json())?)?; return Ok(()); } - writeln!(err, "错误: {}", fill_program(error.message()))?; + writeln!(err, "错误: {}", error.message())?; if let Some(hint) = error.hint() { - writeln!(err, "提示: {}", fill_program(hint))?; + writeln!(err, "提示: {hint}")?; } Ok(()) } @@ -134,7 +143,10 @@ pub fn progress_line(text: &str) { #[cfg(test)] mod tests { - use super::progress_line; + use super::{print_report, progress_line, Outcome, Report}; + use crate::cli::program::{self, PLACEHOLDER}; + use crate::error::CliError; + use serde_json::json; #[test] fn progress_line_never_panics() { @@ -142,4 +154,46 @@ mod tests { progress_line("测试进度 1/2"); progress_line(""); } + + #[test] + fn json_error_envelope_never_leaks_the_placeholder() { + // r2 的回归:JSON 分支直接序列化未渲染的 message/hint,`%prog%` 原样漏给脚本。 + let error = CliError::usage(format!("{PLACEHOLDER} 需要一个值")) + .with_hint(format!("{PLACEHOLDER} --help 查看命令列表")); + let envelope = serde_json::to_string(&error.to_json()).expect("序列化"); + assert!(!envelope.contains(PLACEHOLDER), "{envelope}"); + assert!(envelope.contains(&program::name()), "{envelope}"); + } + + #[test] + fn json_warnings_never_leak_the_placeholder() { + // warnings 是第二条 JSON 出口(以前连人类可读那条都没走到)。 + let report = Report::new( + json!({ "ok": true }), + vec![format!("{PLACEHOLDER} instances list 看看现有实例")], + ); + let outcome = + Outcome::with_warnings(report, vec![format!("用 {PLACEHOLDER} install 装版本")]); + let mut out: Vec = Vec::new(); + let mut err: Vec = Vec::new(); + print_report(&outcome, true, false, &mut out, &mut err).expect("写 JSON"); + let text = String::from_utf8_lossy(&out).into_owned(); + assert!(!text.contains(PLACEHOLDER), "{text}"); + assert!(text.contains(&program::name()), "{text}"); + } + + #[test] + fn human_lines_render_the_placeholder_too() { + let report = Report::new( + json!({}), + vec![format!("用 `{PLACEHOLDER} install` 登记实例")], + ); + assert_eq!( + report.human[0], + format!("用 `{} install` 登记实例", program::name()) + ); + // 用户数据不受影响:账户名恰好叫 ull 的那种 + let report = Report::new(json!({}), vec!["找不到账户 `ull`".to_string()]); + assert_eq!(report.human[0], "找不到账户 `ull`"); + } } From fb3d3f46c9a1f92f21f6d782b970e38b7443a9dc Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 08:01:21 +0800 Subject: [PATCH 7/7] =?UTF-8?q?docs(readme):=20=E6=9C=AA=E7=9F=A5=E5=AD=90?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E7=9A=84=E6=8F=90=E7=A4=BA=E6=94=B9=E6=88=90?= =?UTF-8?q?=20`unlucky-launcher=20--help`=EF=BC=88M82=20r4=20=E5=A4=8D?= =?UTF-8?q?=E5=AE=A1=20P2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两版 README 的「未知子命令是错误,不是开窗」一段写的是提示 `ull --help`,与真实输出相反: GUI 二进制跑出来是 `unlucky-launcher --help`(只有 `ull` 自己跑才是 `ull --help`), 而且同文 87/77 行已经写着「自称的名字跟着你敲的那个二进制走」——正文自相矛盾。 只改这两处;全文其余 `ull` 都是指 `ull` 那个产物本身(对照表、§ 说明、`ull --help` 的对比句), 按语境保留。 --- README.md | 4 ++-- README.zh-CN.md | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 2ce85fa..f21f1bf 100644 --- a/README.md +++ b/README.md @@ -110,8 +110,8 @@ The rule is deliberately narrow, so nothing that used to work changed meaning: still opens the GUI. **Unknown subcommands are an error, not a GUI launch**: `unlucky-launcher lst` prints -"未知命令 `lst`", hints `ull --help`, and exits 1. Silently opening a window would tell a script -that its typo took effect. The subcommand table itself lives in exactly one place — +"未知命令 `lst`", hints `unlucky-launcher --help`, and exits 1. Silently opening a window would tell +a script that its typo took effect. The subcommand table itself lives in exactly one place — `crates/cli/src/cli/parse.rs` — so both GUI binaries and `ull` accept the same set by construction. ## Module map diff --git a/README.zh-CN.md b/README.zh-CN.md index 6f4298d..64c5959 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -97,9 +97,9 @@ unlucky-launcher --version # CLI,输出版本 + 系统摘要 仍然开 GUI;GUI 专属参数(`--software-renderer`、`--screenshot*`、`--renderer`、`--theme`、 `--diff`、`--force-state` 等)在 CLI 里一律解析失败,也仍然开 GUI。 -**未知子命令是错误,不是开窗**:`unlucky-launcher lst` 打印「未知命令 `lst`」、提示 `ull --help`, -以 1 退出。静默开窗会让脚本以为自己那个拼错的参数生效了。子命令表只有一处定义 -(`crates/cli/src/cli/parse.rs`),所以两个 GUI 二进制和 `ull` 认的命令集天然一致。 +**未知子命令是错误,不是开窗**:`unlucky-launcher lst` 打印「未知命令 `lst`」、提示 +`unlucky-launcher --help`,以 1 退出。静默开窗会让脚本以为自己那个拼错的参数生效了。子命令表只有 +一处定义(`crates/cli/src/cli/parse.rs`),所以两个 GUI 二进制和 `ull` 认的命令集天然一致。 ## 模块地图