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

+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"]));
}
}