feat(cli): GUI 二进制内建 CLI 子命令分发,ull 纳入发布资产(M82)

一个二进制两种用法:两个 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 条分发判据用例)。
This commit is contained in:
AskaEth committed 2026-11-02 08:08:09 +08:00
1 parent ef3212021a
commit 1c7285cea9
11 files changed
+457 -53

No files matched your search

+11 -6
View File
@@ -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`(那会因已存在而失败,把整条流水线在最后一步染红)。
Generated
+2
View File
@@ -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",
]
+49 -10
View File
@@ -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 <instance>`,
`unlucky-launcher logs <sid>` 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 <command>` when there is.
### Subcommand dispatch
`unlucky-launcher <args>` 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:
+43 -9
View File
@@ -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 <sid>` 等命令在任何 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 构建:同一套命令、不链接图形栈、体积更小,给完全没有显示设备的机器用。
渲染开关:
+20 -3
View File
@@ -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 机器。
+135 -12
View File
@@ -1,9 +1,19 @@
//! 入口。所有实际逻辑在 lib(`unlucky_app_iced`)里:
//!
//! - `--diff <a> <b>`:只读两张 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 <a> <b>` 只读两张 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<String> = 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 <png>] [--screenshot-theme=light|dark] \
[--scale-factor <f32>] [--diff <a.png> <b.png>]"
);
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<ExitCode> {
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<ExitCode, String> {
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::<Vec<_>>())
}
#[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"]));
}
}
+5
View File
@@ -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
+128 -2
View File
@@ -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<String> = 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<ExitCode> {
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::<Vec<_>>())
}
#[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"]));
}
}
+23 -7
View File
@@ -14,6 +14,7 @@
| `linux-arm64` | `unlucky-launcher-<ver>-linux-arm64.tar.gz` | 单二进制归档(原生 ARM64 runner 构建) | 同上 |
| `macos-arm64` | `unlucky-launcher-<ver>-macos-arm64.tar.gz` | 单二进制归档 | 同上 |
| `macos-x64` | `unlucky-launcher-<ver>-macos-x64.tar.gz` | 单二进制归档 | 同上 |
| 每槽位 | `ull-<ver>-<槽位>[.exe]` | **裸 CLI 文件**(独立资产,不进 tar.gz) | 零 UI 依赖的 `ull` 本体,进同一份 `SHA256SUMS.txt` |
| 全平台 | `SHA256SUMS.txt` | 校验清单 | 每行 `<sha256>␠␠<文件名>`(`sha256sum -c` 可直吃) |
约定细节:
@@ -22,6 +23,10 @@
归档里额外放三份合规文本是许可义务要求,不是运行时依赖;Windows 因为"一个 exe 装不下文本",
把 `LICENSE` / `NOTICE` / `README.md` 作为 Release 的并列资产分发,条款同样随下载走。
- 归档解压后是**一个顶层目录** `unlucky-launcher-<ver>-<槽位>/`,用户不会解压出一堆散文件。
- **`ull` 是并列的裸资产,不塞进 tar.gz**:headless 用户要的就是「下一个小文件直接跑」;归档里放
LICENSE/NOTICE/README.md 是许可义务,而这三份本来就作为并列 Release 资产分发,裸 `ull` 不缺条款。
- `unlucky-launcher` 本体也认全部 CLI 子命令(M82),`ull` 不是第二个界面,而是**不链接图形栈**的
同一套命令(差异见 §4 与 §7.6)——两者都不是重复品。
- `<ver>` 不带前导 `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` 会当成普通参数忽略并直接开窗口。版本自证目前靠:
①产物名里的 `<ver>` ②`ull --version`(同 workspace 版本)③tag 交叉校验。
加一个 `--version` 分支属于 `crates/app` 范围,已作为 finding 上报,不在本 mission 内改。
1. **GUI 的 `--version` 已解决**(M82):`unlucky-launcher --version` 现在命中子命令分发,输出与
`ull --version` 同一条版本 + 系统摘要后退出,不再开窗。§7.2 里「无输出、8 秒后被 timeout 杀掉」那行
是**改之前**的实测记录,留作对照。版本自证的另外两条(产物名里的 `<ver>`、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 槽位不会出现这条。
+28 -3
View File
@@ -9,6 +9,7 @@ Release matrix (see `packaging/README.md` for the full contract):
Linux arm64 unlucky-launcher-<ver>-linux-arm64.tar.gz
macOS arm64 unlucky-launcher-<ver>-macos-arm64.tar.gz
macOS x64 unlucky-launcher-<ver>-macos-x64.tar.gz
every slot ull-<ver>-<slot>[.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/<triple>/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-<ver>-<slot>[.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
+13 -1
View File
@@ -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}}-<slot>`
(`.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