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
+455 -51

No files matched your search

+9 -4
View File
@@ -281,16 +281,20 @@ jobs:
set -euo pipefail set -euo pipefail
ls -l dist ls -l dist
# 六槽位全齐才发版:缺任意一个就 fail(宁可不出 Release,也不出少一档的 Release)。 # 六槽位全齐才发版:缺任意一个就 fail(宁可不出 Release,也不出少一档的 Release)。
# 每个槽位都要 GUI 与 CLI 两个资产——只按「槽位名出现过」判会被其中一个掩盖。
for slot in windows-x64 windows-arm64 linux-x64 linux-arm64 macos-arm64 macos-x64; do for slot in windows-x64 windows-arm64 linux-x64 linux-arm64 macos-arm64 macos-x64; do
if ! compgen -G "dist/*-$slot*" >/dev/null; then for prefix in unlucky-launcher ull; do
echo "::error::矩阵缺产物:$slot" if ! compgen -G "dist/$prefix-*-$slot*" >/dev/null; then
echo "::error::矩阵缺产物:$prefix / $slot"
exit 1 exit 1
fi fi
done done
done
# 各平台任务各写一份 SHA256SUMS.txt,合并后只有一份是真的 —— 在 Release 侧重算。 # 各平台任务各写一份 SHA256SUMS.txt,合并后只有一份是真的 —— 在 Release 侧重算。
# GUI 与 CLI 都要进清单:漏了 ull 就是「发了但没有校验和」。
cd dist cd dist
rm -f SHA256SUMS.txt rm -f SHA256SUMS.txt
sha256sum unlucky-launcher-* > SHA256SUMS.txt sha256sum unlucky-launcher-* ull-* > SHA256SUMS.txt
sha256sum -c SHA256SUMS.txt sha256sum -c SHA256SUMS.txt
cat SHA256SUMS.txt cat SHA256SUMS.txt
@@ -344,7 +348,8 @@ jobs:
esac esac
# Windows 单 exe 装不下 LICENSE/NOTICE/README.md,这三份与产物并列挂到 Release 上, # 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 if gh release view "$TAG" >/dev/null 2>&1; then
# 同 tag 重跑(补丁 / 重编):Release 已存在 → 覆盖资产 + 刷新说明, # 同 tag 重跑(补丁 / 重编):Release 已存在 → 覆盖资产 + 刷新说明,
# 不再 `create`(那会因已存在而失败,把整条流水线在最后一步染红)。 # 不再 `create`(那会因已存在而失败,把整条流水线在最后一步染红)。
Generated
+2
View File
@@ -6892,6 +6892,7 @@ dependencies = [
"slint", "slint",
"slint-build", "slint-build",
"unlucky-agent", "unlucky-agent",
"unlucky-cli",
"unlucky-core", "unlucky-core",
] ]
@@ -6901,6 +6902,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"iced", "iced",
"image", "image",
"unlucky-cli",
"unlucky-design", "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. already contain them.
- **UI**: Slint + Metro. This is the shipped GUI — the instance, download, account, Java, log and - **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). 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 - **CLI in the same binary**: `unlucky-launcher instances list`, `unlucky-launcher launch <instance>`,
only the GUI binary. If you want the CLI, build it yourself: `unlucky-launcher logs <sid>` and the rest of the command set run before any UI is built — no
`cargo build --release --bin ull`. Commands: `instances`, `install`, `launch`, `accounts`, window, no renderer probe, no font scan. Commands: `instances`, `install`, `launch`, `accounts`,
`sessions`, `logs`, `diag`, `config`, `java`, `agent`. `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 - 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. 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 | | Binary | Crate | State |
|--------|-------|-------| |--------|-------|-------|
| `unlucky-launcher` | `crates/app` (Slint) | released in v0.1.0; still the usable GUI | | `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 | | `unlucky-launcher-iced` | `crates/app-iced` (iced 0.14) | under construction; skeleton only, not shipped; same subcommand dispatch |
| `ull` | `crates/cli` | built from the same tree at the same version; not in the v0.1.0 assets | | `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 ## Module map
@@ -121,11 +158,13 @@ cargo build --release # binaries land in target
What the build produces: 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-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 `unlucky-launcher` at the flip, because two crates in one workspace cannot emit the same binary
name into the same target directory. name into the same target directory. Same subcommand dispatch.
- `ull` — the CLI - `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: Rendering switches:
+43 -9
View File
@@ -37,9 +37,14 @@ Unlucky Launcher(ULL)是一个用 Rust 写的免费开源 Minecraft: Java
unix 归档里本来就装着。 unix 归档里本来就装着。
- **UI**:Slint + Metro。这是已交付的图形界面——实例、下载、账户、Java、日志、设置 - **UI**:Slint + Metro。这是已交付的图形界面——实例、下载、账户、Java、日志、设置
各页都由 core 引擎驱动(应用内 AI 对话面板仍是脚本化的占位)。 各页都由 core 引擎驱动(应用内 AI 对话面板仍是脚本化的占位)。
- **CLI `ull`** 与 v0.1.0 同源同版本,但发布资产里只有 GUI 单文件——要用它请自己构建: - **同一个二进制就是 CLI**:`unlucky-launcher instances list`、`unlucky-launcher launch <实例>`、
`cargo build --release --bin ull`。命令:`instances`、`install`、`launch`、`accounts`、`sessions`、 `unlucky-launcher logs <sid>` 等命令在任何 UI 代码之前分流——不开窗、不探渲染后端、不扫字体。
`logs`、`diag`、`config`、`java`、`agent`。 命令集:`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` 的资产早于 - Windows ARM64 与 Linux ARM64 两个槽位已接进发布流水线,随下一个 tag 发布;`v0.1.0` 的资产早于
这两个槽位。 这两个槽位。
@@ -61,9 +66,38 @@ Unlucky Launcher(ULL)是一个用 Rust 写的免费开源 Minecraft: Java
| 二进制 | crate | 状态 | | 二进制 | crate | 状态 |
|--------|-------|------| |--------|-------|------|
| `unlucky-launcher` | `crates/app`(Slint) | v0.1.0 已发布,仍是当前能用的图形界面 | | `unlucky-launcher` | `crates/app`(Slint) | v0.1.0 已发布,仍是当前能用的图形界面;同时支持全部 CLI 子命令 |
| `unlucky-launcher-iced` | `crates/app-iced`(iced 0.14) | 施工中,仅骨架,不随发布包分发 | | `unlucky-launcher-iced` | `crates/app-iced`(iced 0.14) | 施工中,仅骨架,不随发布包分发;子命令分发同款 |
| `ull` | `crates/cli` | 与发布版同源同版本构建;未随 v0.1.0 资产分发 | | `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: 编出来的三个 bin:
- `unlucky-launcher`——Slint 版(已发布的那个) - `unlucky-launcher`——Slint 版(已发布的那个)。它同时也是 CLI:同一个二进制认上面列的全部子命令。
- `unlucky-launcher-iced`——iced 版骨架。名字是临时的:只有翻牌时才能改成 `unlucky-launcher`, - `unlucky-launcher-iced`——iced 版骨架。名字是临时的:只有翻牌时才能改成 `unlucky-launcher`,
因为同一个工作区里两个 crate 不能往同一个 target 目录里产出同名 bin。 因为同一个工作区里两个 crate 不能往同一个 target 目录里产出同名 bin。子命令分发同款。
- `ull`——CLI - `ull`——CLI 的独立无 UI 构建:同一套命令、不链接图形栈、体积更小,给完全没有显示设备的机器用。
渲染开关: 渲染开关:
+20 -3
View File
@@ -34,14 +34,31 @@ iced = { version = "=0.14.0", default-features = false, features = [
"advanced", "advanced",
"svg", "svg",
"thread-pool", "thread-pool",
"x11",
"wayland",
"linux-theme-detection",
] } ] }
# 截图落 PNG。只开 png 编码器,不要默认全套 codec(体积)。 # 截图落 PNG。只开 png 编码器,不要默认全套 codec(体积)。
image = { version = "0.25", default-features = false, features = ["png"] } 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] [features]
default = ["gpu"] default = ["gpu"]
# 硬件渲染后端(wgpu→Vulkan/DX12)。关闭后只剩 tiny-skia(CPU),用于体积基线与无 GPU 机器。 # 硬件渲染后端(wgpu→Vulkan/DX12)。关闭后只剩 tiny-skia(CPU),用于体积基线与无 GPU 机器。
+135 -12
View File
@@ -1,9 +1,19 @@
//! 入口。所有实际逻辑在 lib(`unlucky_app_iced`)里: //! 入口。所有实际逻辑在 lib(`unlucky_app_iced`)里:
//! //!
//! - `--diff <a> <b>`:只读两张 PNG 做逐像素对比(D30 的 GPU/CPU 几何一致性检查),不开窗; //! 一个二进制两种用法(M82),分发在进 UI 之前完成:
//! - 其余参数:开窗跑骨架,`--screenshot` 出图后退出。
//! //!
//! 退出码:`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; use std::process::ExitCode;
@@ -11,8 +21,13 @@ use unlucky_app_iced::shot;
use unlucky_app_iced::Options; use unlucky_app_iced::Options;
fn main() -> ExitCode { fn main() -> ExitCode {
let options = match Options::from_env() { let args: Vec<String> = std::env::args().skip(1).collect();
Ok(options) => options, if let Some(code) = run_cli_if_requested(&args) {
return code;
}
match run_gui() {
Ok(code) => code,
Err(message) => { Err(message) => {
eprintln!("[cli] {message}"); eprintln!("[cli] {message}");
eprintln!( eprintln!(
@@ -20,9 +35,54 @@ fn main() -> ExitCode {
[--theme=light|dark] [--screenshot <png>] [--screenshot-theme=light|dark] \ [--theme=light|dark] [--screenshot <png>] [--screenshot-theme=light|dark] \
[--scale-factor <f32>] [--diff <a.png> <b.png>]" [--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() { if let Some((left, right)) = options.diff.clone() {
let scan = i32::try_from(options.diff_scan).unwrap_or(i32::MAX); let scan = i32::try_from(options.diff_scan).unwrap_or(i32::MAX);
@@ -30,23 +90,86 @@ fn main() -> ExitCode {
Ok(diff) => { Ok(diff) => {
eprintln!("{}", diff.describe()); eprintln!("{}", diff.describe());
if diff.is_identical() { if diff.is_identical() {
ExitCode::SUCCESS Ok(ExitCode::SUCCESS)
} else { } else {
ExitCode::from(1) Ok(ExitCode::from(1))
} }
} }
Err(message) => { Err(message) => {
eprintln!("[diff] {message}"); eprintln!("[diff] {message}");
ExitCode::from(2) Ok(ExitCode::from(2))
} }
}; };
} }
match unlucky_app_iced::run(options) { match unlucky_app_iced::run(options) {
Ok(()) => ExitCode::SUCCESS, Ok(()) => Ok(ExitCode::SUCCESS),
Err(error) => { Err(error) => {
eprintln!("[run] {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 契约。 # (core 的快照/事件流翻译成 src/dto 的类型)落位时不必再动 workspace 契约。
unlucky-core = { path = "../core", version = "0.1" } unlucky-core = { path = "../core", version = "0.1" }
unlucky-agent = { path = "../agent", 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] [build-dependencies]
slint-build.workspace = true slint-build.workspace = true
+128 -2
View File
@@ -13,7 +13,13 @@
//! - 全部走 `core::setting` 的 schema(`crate::settings_bridge`),AI 可写并且**热切** //! - 全部走 `core::setting` 的 schema(`crate::settings_bridge`),AI 可写并且**热切**
//! (tick 里 1s 一次 mtime 比对);系统主题由 `core::platform::theme_probe` 对齐 + 兜底轮询。 //! (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) //! - `state.rs` / `prefs.rs` / `router.rs` / `dialogs.rs` / `toast.rs`:纯 Rust 状态(可单测,无 Slint)
//! - `dto/`:跨边界 DTO 与事件/指令 //! - `dto/`:跨边界 DTO 与事件/指令
//! - `conv.rs`:状态 → Slint 视图模型 //! - `conv.rs`:状态 → Slint 视图模型
@@ -31,6 +37,8 @@
// 会把噪音藏进实现细节,这里统一放开并在 P1 接线时逐个转正(届时删掉本行即可暴露真死码)。 // 会把噪音藏进实现细节,这里统一放开并在 P1 接线时逐个转正(届时删掉本行即可暴露真死码)。
#![allow(missing_docs, dead_code)] #![allow(missing_docs, dead_code)]
use std::process::ExitCode;
mod appearance_bind; mod appearance_bind;
mod backend; mod backend;
mod bg_image; mod bg_image;
@@ -56,7 +64,22 @@ mod wizard;
slint::include_modules!(); 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(); apply_renderer_flag();
let app = ui_bind::AppUi::with_options(MainWindow::new()?, screenshot::app_options()); let app = ui_bind::AppUi::with_options(MainWindow::new()?, screenshot::app_options());
@@ -73,6 +96,46 @@ fn main() -> Result<(), slint::PlatformError> {
app.run() 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 核心卖点)。 /// Win7 老机 / 无可用 OpenGL 驱动时的软渲染兜底开关(01c §6 的 Slint 核心卖点)。
fn apply_renderer_flag() { fn apply_renderer_flag() {
let software = std::env::args() let software = std::env::args()
@@ -82,3 +145,66 @@ fn apply_renderer_flag() {
std::env::set_var("SLINT_BACKEND", "winit-software"); 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 构建) | 同上 | | `linux-arm64` | `unlucky-launcher-<ver>-linux-arm64.tar.gz` | 单二进制归档(原生 ARM64 runner 构建) | 同上 |
| `macos-arm64` | `unlucky-launcher-<ver>-macos-arm64.tar.gz` | 单二进制归档 | 同上 | | `macos-arm64` | `unlucky-launcher-<ver>-macos-arm64.tar.gz` | 单二进制归档 | 同上 |
| `macos-x64` | `unlucky-launcher-<ver>-macos-x64.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` 可直吃) | | 全平台 | `SHA256SUMS.txt` | 校验清单 | 每行 `<sha256>␠␠<文件名>`(`sha256sum -c` 可直吃) |
约定细节: 约定细节:
@@ -22,6 +23,10 @@
归档里额外放三份合规文本是许可义务要求,不是运行时依赖;Windows 因为"一个 exe 装不下文本", 归档里额外放三份合规文本是许可义务要求,不是运行时依赖;Windows 因为"一个 exe 装不下文本",
把 `LICENSE` / `NOTICE` / `README.md` 作为 Release 的并列资产分发,条款同样随下载走。 把 `LICENSE` / `NOTICE` / `README.md` 作为 Release 的并列资产分发,条款同样随下载走。
- 归档解压后是**一个顶层目录** `unlucky-launcher-<ver>-<槽位>/`,用户不会解压出一堆散文件。 - 归档解压后是**一个顶层目录** `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`)——由脚本统一裁剪。 - `<ver>` 不带前导 `v`(tag 是 `v0.1.0`,文件名是 `0.1.0`)——由脚本统一裁剪。
- UNIX 归档内 `mtime`/`uid`/`gid` 归零、权限固定(二进制 0755、文本 0644), - UNIX 归档内 `mtime`/`uid`/`gid` 归零、权限固定(二进制 0755、文本 0644),
同源构建的 sha256 不受打包时刻影响。 同源构建的 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`。 Debian/Ubuntu 一行:`sudo apt install libxkbcommon0 fontconfig libgl1 libegl1 libxcb1 libx11-6 libwayland-client0`。
运行期**不需要** `-dev` 包、不需要 Java(Java 是启动游戏时才由启动器自己管理的东西)。 运行期**不需要** `-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) ### Linux arm64(aarch64)
依赖与 x64 完全同一套(`libxkbcommon` / `fontconfig` / `libGL` / `libxcb` / `libwayland-client`), 依赖与 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` 一致 | | `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 --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 槽位真跑) ### 7.3 打包验证(本机 Windows 槽位真跑)
@@ -268,6 +278,13 @@ CI 的 MSVC `+crt-static` 产物同理(静态 CRT);`release.yml` 里用 `d
总之不允许"悄悄要用户装可再发行包"。 总之不允许"悄悄要用户装可再发行包"。
注意:本机只装了 GNU 工具链,MSVC 那个槽位的产物本身**没有在本机验证过**,由 CI 首跑验证。 注意:本机只装了 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 二进制) ### 7.5 arm64 槽位的本机验证(脚本层取证,不假装跑得动 arm64 二进制)
本机没有 ARM 机器,也不具备 MSVC ARM64 链接库组合,所以只做**能真实证明的东西**(`python` 直接加载 本机没有 ARM 机器,也不具备 MSVC ARM64 链接库组合,所以只做**能真实证明的东西**(`python` 直接加载
@@ -322,16 +339,15 @@ experimental / not part of this release matrix)——改一边必须同步另
## 10. 已知边界 / 后续 ## 10. 已知边界 / 后续
1. **GUI 无 `--version`**:`unlucky-launcher.exe` 目前只解析 `--software-renderer` / `--screenshot*` / 1. **GUI 的 `--version` 已解决**(M82):`unlucky-launcher --version` 现在命中子命令分发,输出与
`--data-dir` / `--mock`,`--version` 会当成普通参数忽略并直接开窗口。版本自证目前靠: `ull --version` 同一条版本 + 系统摘要后退出,不再开窗。§7.2 里「无输出、8 秒后被 timeout 杀掉」那行
①产物名里的 `<ver>` ②`ull --version`(同 workspace 版本)③tag 交叉校验。 是**改之前**的实测记录,留作对照。版本自证的另外两条(产物名里的 `<ver>`、tag 交叉校验)不变。
加一个 `--version` 分支属于 `crates/app` 范围,已作为 finding 上报,不在本 mission 内改。
2. **签名/公证**:Windows 无 Authenticode、macOS 无 codesign/notarize(都要证书/开发者账号), 2. **签名/公证**:Windows 无 Authenticode、macOS 无 codesign/notarize(都要证书/开发者账号),
当前以 SHA256SUMS + Release 说明作为完整性参考。 当前以 SHA256SUMS + Release 说明作为完整性参考。
3. **macOS `.app` bundle / `.dmg`**:与单文件承诺冲突,暂不做;若用户要 Dock 图标再单开 mission。 3. **macOS `.app` bundle / `.dmg`**:与单文件承诺冲突,暂不做;若用户要 Dock 图标再单开 mission。
4. **Linux musl 静态版**:可彻底摆脱 glibc 地板,但 Slint 在 musl 上需额外验证,未纳入本矩阵。 4. **Linux musl 静态版**:可彻底摆脱 glibc 地板,但 Slint 在 musl 上需额外验证,未纳入本矩阵。
5. **`ull` CLI 未随包**:本次矩阵只发 GUI 单文件(用户核心卖点是启动器本体);headless 用户可从 5. **`ull` 已随包**(M82 起):每个槽位与 GUI 并列发一份裸 `ull`,进同一份 `SHA256SUMS.txt`,矩阵
源码 `cargo build --release --bin ull`,或在后续把 `ull` 加进同一矩阵(脚本已按槽位参数化,改动很小)。 完整性检查按「GUI 与 CLI 各自都要有」分开查(`release.yml`)。等 CI 首跑确认这条在真流水线上有效。
6. **本机 GNU 链接器的已知无害告警**:`cargo build --release`(GNU 工具链)会打一行 6. **本机 GNU 链接器的已知无害告警**:`cargo build --release`(GNU 工具链)会打一行
`warning: linker stderr: corrupt .drectve at end of def file`,来自 MinGW `ld` 对 `.drectve` 段的 `warning: linker stderr: corrupt .drectve at end of def file`,来自 MinGW `ld` 对 `.drectve` 段的
老式抱怨,不影响产物(体积/导入表/冒烟均已实测正常);CI 的 MSVC 槽位不会出现这条。 老式抱怨,不影响产物(体积/导入表/冒烟均已实测正常);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 Linux arm64 unlucky-launcher-<ver>-linux-arm64.tar.gz
macOS arm64 unlucky-launcher-<ver>-macos-arm64.tar.gz macOS arm64 unlucky-launcher-<ver>-macos-arm64.tar.gz
macOS x64 unlucky-launcher-<ver>-macos-x64.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 all SHA256SUMS.txt
`--version` is taken from the cargo workspace so a tag can never drift away from `--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 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. 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 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 `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 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 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: 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.""" """tar.gz with one top-level dir holding the binary + compliance docs."""
stem = f"unlucky-launcher-{version}-{tag}" stem = f"unlucky-launcher-{version}-{tag}"
@@ -259,10 +279,15 @@ def main() -> int:
shutil.copy2(launcher, asset) shutil.copy2(launcher, asset)
else: else:
asset = stage_archive(out_dir, slot, version, launcher) 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]) sums = write_sums(out_dir, [asset, cli_asset])
size_mb = asset.stat().st_size / (1024 * 1024) for item in (asset, cli_asset):
print(f"artifact: {asset.name} {size_mb:.2f} MiB sha256={sha256(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}") print(f"sums: {sums.name}")
return 0 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 this release next to the `.exe` (a lone exe cannot carry them), so the license terms travel
with the download on every platform. 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 ## Support matrix
| Platform | Level | Runtime requirements | | 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, 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` | | Linux arm64, Wayland | supported | the above plus `wayland-client` |
| Windows 8.1/8/7 | unsupported | baseline was raised to Win10 (decision D21) | | 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 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 `SHA256SUMS.txt`), not the experimental Termux path: Linux arm64 is built and smoke-tested on a