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