Merge branch 'feat/cli-unify-launcher-binary-subcommands-wi'

This commit is contained in:
AskaEth committed 2026-11-02 08:08:20 +08:00
commit 00f132d8ce
34 files changed
+677 -121

No files matched your search

+11 -6
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
exit 1 echo "::error::矩阵缺产物:$prefix / $slot"
fi exit 1
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",
] ]
+51 -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,44 @@ 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. Both print the same
CLI text and name themselves after the binary you invoked: `unlucky-launcher --help` starts with
`unlucky-launcher ·`, `ull --help` starts with `ull ·`.
### Subcommand dispatch
`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 `unlucky-launcher --help`, and exits 1. Silently opening a window would tell
a script that its typo took effect. The subcommand table itself lives in exactly one place —
`crates/cli/src/cli/parse.rs` — so both GUI binaries and `ull` accept the same set by construction.
## Module map ## Module map
@@ -121,11 +160,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:
+45 -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,40 @@ 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 <子命令>`。两者打出来的
CLI 文案是同一套,自称的名字跟着**你敲的那个二进制**走:`unlucky-launcher --help` 首行是
`unlucky-launcher ·`,`ull --help` 首行是 `ull ·`。
### 子命令分发
`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`」、提示
`unlucky-launcher --help`,以 1 退出。静默开窗会让脚本以为自己那个拼错的参数生效了。子命令表只有
一处定义(`crates/cli/src/cli/parse.rs`),所以两个 GUI 二进制和 `ull` 认的命令集天然一致。
## 模块地图 ## 模块地图
@@ -110,10 +146,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 机器。
+53 -12
View File
@@ -1,9 +1,20 @@
//! 入口。所有实际逻辑在 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` 的解析器
//! (`unlucky_cli::cli::looks_like_cli`,本文件只调用)。
//! - 其余参数(`--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 +22,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 +36,34 @@ 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 调用,去开窗」。
///
/// 这条路径上**不构造窗口、不探后端、不扫字体、不依赖任何图形库**(这些库缺失时同一条命令照样
/// 跑完,实测见 packaging/README.md §7.4)。`unlucky-launcher-iced launch …` 的耗时因此与首帧无关——
/// 这句是结构性判断,**没有重量**(原因见 §10-8)。
///
/// 「这条命令行算不算 CLI 调用」的判据不在这里重复:`unlucky_cli::cli::looks_like_cli` 是唯一
/// 一处(M82 复审去重),两个 GUI 二进制都只调它,改语义不会漂。
fn run_cli_if_requested(args: &[String]) -> Option<ExitCode> {
if !unlucky_cli::cli::looks_like_cli(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)))
}
/// 开窗路径:解析 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 +71,23 @@ 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)
} }
} }
} }
+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
+46 -2
View File
@@ -13,7 +13,14 @@
//! - 全部走 `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` 的解析器(见
//! `unlucky_cli::cli::looks_like_cli`)。
//! 零 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 +38,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 +65,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 +97,26 @@ fn main() -> Result<(), slint::PlatformError> {
app.run() app.run()
} }
/// 子命令命中 CLI 就跑 CLI 并以它的退出码收尾;返回 `None` 表示「这不是 CLI 调用,去开窗」。
///
/// 这条路径上**不构造窗口、不探后端、不扫字体、不依赖任何图形库**(这些库缺失时同一条命令照样
/// 跑完,实测见 packaging/README.md §7.4)。`unlucky-launcher launch …` 的耗时因此与首帧无关——
/// 这句是结构性判断,**没有重量**(原因见 §10-8)。
///
/// 「这条命令行算不算 CLI 调用」的判据不在这里重复:`unlucky_cli::cli::looks_like_cli` 是唯一
/// 一处(M82 复审去重),两个 GUI 二进制都只调它,改语义不会漂。
fn run_cli_if_requested(args: &[String]) -> Option<ExitCode> {
if !unlucky_cli::cli::looks_like_cli(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)))
}
/// 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()
+19 -19
View File
@@ -7,10 +7,10 @@
#[must_use] #[must_use]
pub const fn general() -> &'static str { pub const fn general() -> &'static str {
"\ "\
ull · Unlucky-Launcher 命令行(D18) %prog% · Unlucky-Launcher 命令行(D18)
用法: 用法:
ull [全局选项] <命令> [参数] %prog% [全局选项] <命令> [参数]
命令: 命令:
instances list 列出实例与已安装版本 instances list 列出实例与已安装版本
@@ -55,14 +55,14 @@ pub fn topic(name: &str) -> Option<&'static str> {
match name { match name {
"instances" => Some( "instances" => Some(
"\ "\
ull instances list %prog% instances list
列出 profiles.json 里的实例与 versions/ 下已安装的版本。 列出 profiles.json 里的实例与 versions/ 下已安装的版本。
--json 输出 {\"data\":{\"instances\":[…],\"installed_versions\":[…],\"data_root\":…}}", --json 输出 {\"data\":{\"instances\":[…],\"installed_versions\":[…],\"data_root\":…}}",
), ),
"install" => Some( "install" => Some(
"\ "\
ull install <version> [--loader X] [--loader-version V] [--name N] [--dry-run] %prog% install <version> [--loader X] [--loader-version V] [--name N] [--dry-run]
解析版本清单与继承链 → 组下载计划 → 落盘(versions/ assets/ libraries/)→ 登记实例 解析版本清单与继承链 → 组下载计划 → 落盘(versions/ assets/ libraries/)→ 登记实例
(profiles.json + config/instances/<id>.json)。已存在且校验通过的文件会跳过, (profiles.json + config/instances/<id>.json)。已存在且校验通过的文件会跳过,
@@ -81,7 +81,7 @@ versions/<原版>-<加载器>-<版本>/<…>.json(inheritsFrom 原版 + 主类
), ),
"launch" => Some( "launch" => Some(
"\ "\
ull launch <instance> [--dry-run] [--account <name>] [--java <path>] [--username <name>] %prog% launch <instance> [--dry-run] [--account <name>] [--java <path>] [--username <name>]
[--max-memory <MB>] [--max-memory <MB>]
<instance> 可以是实例 id、实例名,或直接是版本 id。 <instance> 可以是实例 id、实例名,或直接是版本 id。
@@ -92,10 +92,10 @@ accessToken 一律脱敏(输出里出现的是 <redacted:token>)。",
), ),
"accounts" => Some( "accounts" => Some(
"\ "\
ull accounts list %prog% accounts list
ull accounts add offline <name> %prog% accounts add offline <name>
ull accounts add msa %prog% accounts add msa
ull accounts remove <name> %prog% accounts remove <name>
账户存在 <数据根>/.ULL/data/auth/:accounts.json(可公开元数据)与 credentials.dat(凭据) 账户存在 <数据根>/.ULL/data/auth/:accounts.json(可公开元数据)与 credentials.dat(凭据)
**分文件**存放,先写凭据后写元数据、原子写、凭据文件 POSIX 侧 0600。 **分文件**存放,先写凭据后写元数据、原子写、凭据文件 POSIX 侧 0600。
@@ -104,25 +104,25 @@ add msa 走微软 device-code(用户码打在 stderr,去 https://www.microso
需要先配置 ULL_MSA_CLIENT_ID(Azure 公共客户端 id)。 需要先配置 ULL_MSA_CLIENT_ID(Azure 公共客户端 id)。
authlib-injector 账户的密码登录留给 GUI(密码进 argv 会泄露,CLI 不开这条通道)。", authlib-injector 账户的密码登录留给 GUI(密码进 argv 会泄露,CLI 不开这条通道)。",
), ),
"sessions" => Some("ull sessions list [--limit N] 列出会话索引(按开始时间倒序,缺省 20 条)"), "sessions" => Some("%prog% sessions list [--limit N] 列出会话索引(按开始时间倒序,缺省 20 条)"),
"logs" => Some("ull logs <sid> [--tail N] <sid> 允许唯一前缀;输出会话 JSONL 末尾 N 条事件(缺省 200)"), "logs" => Some("%prog% logs <sid> [--tail N] <sid> 允许唯一前缀;输出会话 JSONL 末尾 N 条事件(缺省 200)"),
"diag" => Some( "diag" => Some(
"ull diag <sid> [--tail N] 把会话的退出信号与日志喂给规则引擎(内嵌规则集),输出结构化诊断", "%prog% diag <sid> [--tail N] 把会话的退出信号与日志喂给规则引擎(内嵌规则集),输出结构化诊断",
), ),
"config" => Some( "config" => Some(
"\ "\
ull config get [<key>] [--show-secret] %prog% config get [<key>] [--show-secret]
ull config set <key> <value> %prog% config set <key> <value>
读写 <数据根>/.ULL/config/launcher.json,走 core::setting 的 schema(类型/枚举校验、 读写 <数据根>/.ULL/config/launcher.json,走 core::setting 的 schema(类型/枚举校验、
原子写)。敏感键 get 时脱敏,--show-secret 才给明文;值先按 JSON 解析,失败按字符串。", 原子写)。敏感键 get 时脱敏,--show-secret 才给明文;值先按 JSON 解析,失败按字符串。",
), ),
"java" => Some( "java" => Some(
"\ "\
ull java list 探测 JAVA_HOME / PATH / 托管目录 / 实例覆盖里的 Java %prog% java list 探测 JAVA_HOME / PATH / 托管目录 / 实例覆盖里的 Java
并打印每个 JDK 的实际落点(managed 项带目录) 并打印每个 JDK 的实际落点(managed 项带目录)
ull java install <major> [--vendor X] 安装托管 JDK(D13/D23):回退链查版本 → 下载 → 解压 → 验证 %prog% java install <major> [--vendor X] 安装托管 JDK(D13/D23):回退链查版本 → 下载 → 解压 → 验证
ull java vendors 列出四家发行版的镜像/支持版本/当前优先级顺序 %prog% java vendors 列出四家发行版的镜像/支持版本/当前优先级顺序
落点(D23 三模式,随 core.data_mode 与设置键 java.installRoot 走): 落点(D23 三模式,随 core.data_mode 与设置键 java.installRoot 走):
portable(默认) <数据根>/.ULL/data/java-managed/jdk-<major>-<vendor>/ portable(默认) <数据根>/.ULL/data/java-managed/jdk-<major>-<vendor>/
@@ -137,8 +137,8 @@ ull java vendors 列出四家发行版的镜像/支持版
), ),
"agent" => Some( "agent" => Some(
"\ "\
ull agent serve [--stdio] [--port N] [--host H] [--allow-write|--readonly] %prog% agent serve [--stdio] [--port N] [--host H] [--allow-write|--readonly]
ull agent config [--port N] %prog% agent config [--port N]
把 agent 的工具注册表挂成 **MCP server**(02 文档 §1 的第二形态:同一注册表、同一安全策略、 把 agent 的工具注册表挂成 **MCP server**(02 文档 §1 的第二形态:同一注册表、同一安全策略、
同一审计)。默认走 Streamable HTTP,只绑回环地址(127.0.0.1,默认端口 47823); 同一审计)。默认走 Streamable HTTP,只绑回环地址(127.0.0.1,默认端口 47823);
+92
View File
@@ -9,9 +9,101 @@
pub mod help; pub mod help;
pub mod model; pub mod model;
pub mod parse; pub mod parse;
pub mod program;
pub use model::{ pub use model::{
AccountsAddArgs, AccountsAddKind, AgentServeArgs, Command, ConfigGetArgs, ConfigSetArgs, AccountsAddArgs, AccountsAddKind, AgentServeArgs, Command, ConfigGetArgs, ConfigSetArgs,
DiagArgs, Globals, InstallArgs, Invocation, LaunchArgs, LogsArgs, DiagArgs, Globals, InstallArgs, Invocation, LaunchArgs, LogsArgs,
}; };
pub use parse::parse; pub use parse::parse;
/// 这条命令行是 CLI 调用,还是「不该由 CLI 接管」(GUI 启动 / 只有全局选项)?
///
/// GUI 二进制(`unlucky-launcher` / `unlucky-launcher-iced`)用它做分发判据,`ull` 自己的
/// `main` 不用——**判据只有这一处**,两个 GUI 各调一次,改语义不会漂。
///
/// 规则(刻意不列 GUI 的 flag 表:cli crate 不该知道 GUI 有什么参数):
///
/// - 首个 token 是裸词(不以 `-` 开头)→ 一定在子命令位置(GUI 不接受位置参数,`--diff` /
/// `--screenshot` 的取值都排在 flag 之后)→ 接管。未知子命令也在这里,由 CLI 自己报错。
/// - 首个 token 是 flag → 只有当 [`parse`] 解析出一条**真命令**时才算 CLI 调用:`--help` /
/// `--version` / `--json instances list` 接管;`--data-dir X` 解析成 [`Command::Welcome`]
/// (没有子命令)、GUI 专属参数(`--screenshot` / `--renderer` / `--diff` …)根本解析失败,
/// 两种情况都不接管。**「解析失败」必须留在调用方**:`--screenshot out.png` 也会让本解析器
/// 报错,把它判成 CLI 就会让 GUI 的截图通道失效。
#[must_use]
pub fn looks_like_cli(args: &[String]) -> bool {
match args.first() {
None => false,
Some(first) if !first.starts_with('-') => true,
Some(_) => match parse(args) {
Ok(invocation) => !matches!(invocation.command, Command::Welcome),
Err(_) => false,
},
}
}
#[cfg(test)]
mod tests {
use super::looks_like_cli;
fn cli(args: &[&str]) -> bool {
looks_like_cli(&args.iter().map(|arg| (*arg).to_owned()).collect::<Vec<_>>())
}
#[test]
fn no_args_is_not_a_cli_call() {
assert!(!cli(&[]));
}
#[test]
fn gui_flags_are_never_taken_over() {
// CI 与验收脚本在用这些参数,一条都不能被当成子命令吃掉。
assert!(!cli(&["--software-renderer"]));
assert!(!cli(&["--screenshot", "out.png", "--data-dir", "/d/tmp/x"]));
assert!(!cli(&["--screenshot-theme", "dark"]));
assert!(!cli(&["--renderer=gpu", "--theme=dark"]));
assert!(!cli(&["--scale-factor", "1.0"]));
assert!(!cli(&["--force-state", "hover"]));
assert!(!cli(&["--diff", "a.png", "b.png"]));
assert!(!cli(&["--mock"]));
// 只有 --data-dir 时解析成 Welcome(没有子命令),仍是 GUI 启动。
assert!(!cli(&["--data-dir", "/d/tmp/x"]));
}
#[test]
fn known_subcommands_are_taken_over() {
for args in [
vec!["instances", "list"],
vec!["install", "1.20.1", "--dry-run"],
vec!["launch", "1.20.1", "--dry-run"],
vec!["accounts", "list"],
vec!["sessions", "list"],
vec!["logs", "abc", "--tail", "10"],
vec!["diag", "abc"],
vec!["config", "get"],
vec!["java", "list"],
vec!["agent", "config"],
// 全局选项可以排在子命令前面
vec!["--json", "instances", "list"],
] {
assert!(cli(&args), "{args:?} 应该交给 CLI");
}
}
#[test]
fn help_and_version_are_taken_over() {
assert!(cli(&["--version"]));
assert!(cli(&["--help"]));
assert!(cli(&["-h"]));
assert!(cli(&["-V"]));
}
#[test]
fn unknown_subcommands_are_left_to_the_cli_to_reject() {
// 语义定案(README 两版同款):未知子命令 → CLI 报「未知命令 + <名字> --help」并以 1 退出,
// 不静默落到 GUI(否则脚本用户会以为参数生效了)。
assert!(cli(&["lst"]));
assert!(cli(&["intsances", "list"]));
}
}
+6 -5
View File
@@ -92,7 +92,8 @@ fn parse_command(rest: &[String], globals: &Globals) -> Result<Command, CliError
"accounts" => accounts::parse_accounts(tail), "accounts" => accounts::parse_accounts(tail),
"agent" => agent::parse_agent(tail), "agent" => agent::parse_agent(tail),
other => { other => {
Err(CliError::usage(format!("未知命令 `{other}`")).with_hint("ull --help 查看命令列表")) Err(CliError::usage(format!("未知命令 `{other}`"))
.with_hint("%prog% --help 查看命令列表"))
} }
} }
} }
@@ -194,7 +195,7 @@ fn parse_diag(tail: &[String]) -> Result<Command, CliError> {
fn parse_config(tail: &[String]) -> Result<Command, CliError> { fn parse_config(tail: &[String]) -> Result<Command, CliError> {
let Some(sub) = tail.first().map(String::as_str) else { let Some(sub) = tail.first().map(String::as_str) else {
return Err(CliError::usage("config 需要子命令 get|set") return Err(CliError::usage("config 需要子命令 get|set")
.with_hint("ull config get <key> / ull config set <key> <value>")); .with_hint("%prog% config get <key> / %prog% config set <key> <value>"));
}; };
let rest = tail.get(1..).unwrap_or_default(); let rest = tail.get(1..).unwrap_or_default();
match sub { match sub {
@@ -212,7 +213,7 @@ fn parse_config(tail: &[String]) -> Result<Command, CliError> {
let parsed = parse_flags(rest, &[], &[])?; let parsed = parse_flags(rest, &[], &[])?;
if parsed.positionals.len() != 2 { if parsed.positionals.len() != 2 {
return Err(CliError::usage( return Err(CliError::usage(
"config set 需要键路径与值:ull config set <key> <value>", "config set 需要键路径与值:%prog% config set <key> <value>",
)); ));
} }
Ok(Command::ConfigSet(ConfigSetArgs { Ok(Command::ConfigSet(ConfigSetArgs {
@@ -262,8 +263,8 @@ fn parse_java(tail: &[String]) -> Result<Command, CliError> {
fn require_single_positional(parsed: &ParsedArgs, usage: &str) -> Result<String, CliError> { fn require_single_positional(parsed: &ParsedArgs, usage: &str) -> Result<String, CliError> {
match parsed.positionals.as_slice() { match parsed.positionals.as_slice() {
[only] => Ok(only.clone()), [only] => Ok(only.clone()),
[] => Err(CliError::usage(format!("缺少参数:ull {usage}"))), [] => Err(CliError::usage(format!("缺少参数:%prog% {usage}"))),
_ => Err(CliError::usage(format!("参数过多:ull {usage}"))), _ => Err(CliError::usage(format!("参数过多:%prog% {usage}"))),
} }
} }
+1 -1
View File
@@ -41,7 +41,7 @@ fn parse_add(rest: &[String]) -> Result<Command, CliError> {
let Some(kind) = rest.first().map(String::as_str) else { let Some(kind) = rest.first().map(String::as_str) else {
return Err( return Err(
CliError::usage("accounts add 需要轨道:offline|msa").with_hint( CliError::usage("accounts add 需要轨道:offline|msa").with_hint(
"ull accounts add offline <玩家名> / ull accounts add msa(设备码交互式登录)", "%prog% accounts add offline <玩家名> / %prog% accounts add msa(设备码交互式登录)",
), ),
); );
}; };
+1 -1
View File
@@ -16,7 +16,7 @@ use super::{ensure_no_positionals, parse_flags};
pub fn parse_agent(tail: &[String]) -> Result<Command, CliError> { pub fn parse_agent(tail: &[String]) -> Result<Command, CliError> {
let Some(sub) = tail.first().map(String::as_str) else { let Some(sub) = tail.first().map(String::as_str) else {
return Err(CliError::usage("agent 需要子命令 serve|config") return Err(CliError::usage("agent 需要子命令 serve|config")
.with_hint("ull agent serve / ull agent config")); .with_hint("%prog% agent serve / %prog% agent config"));
}; };
let rest = tail.get(1..).unwrap_or_default(); let rest = tail.get(1..).unwrap_or_default();
match sub { match sub {
+120
View File
@@ -0,0 +1,120 @@
//! 程序名:CLI 输出里自称什么,取决于**被调用的那个二进制**叫什么。
//!
//! GUI 二进制(`unlucky-launcher`)与 `ull` 共用同一套命令行(M82),所以 banner / 用法行 /
//! 「`<名字> --help`」这类提示不能写死 `ull` —— 用户敲的是 `unlucky-launcher`,把他指去敲
//! 一个可能不存在的名字只会让人困惑。
//!
//! 做法(M82 r2 定案):文案里放**占位符**,渲染时替换;**绝不对插值后的整条文本做词替换**。
//! 上一版按「独立词 `ull`」替换整条消息,于是用户数据被改坏过:账户名叫 `ull` 的会变成
//! `unlucky-launcher`,路径 `D:/tmp/ull/.ULL` 会变成 `D:/tmp/unlucky-launcher/.ULL`。
//! 占位符只在模板里出现,插值进来的用户名/路径碰不到它。
//!
//! 取不到 argv[0](某些 exec 场景)就回落 [`FALLBACK`],输出因此与老版本逐字相同。
/// 文案里的程序名占位符。
///
/// 用 `%prog%` 而不是 `{prog}`:这些模板要活在 `format!` 里,`{prog}` 会被 `format!` 当成
/// 参数名直接编译失败,而 `%prog%` 原样穿过去,到渲染时才替换。
pub const PLACEHOLDER: &str = "%prog%";
/// 取不到 argv[0] 时的回落名(也是 `crates/cli` 产出的那个 bin)。
pub const FALLBACK: &str = "ull";
/// 从 argv[0] 求程序名:剥目录、剥 `.exe`、空值回落。
///
/// Windows 上是 `C:\…\unlucky-launcher.exe`,unix 上是 `./target/release/unlucky-launcher`,
/// 两种都要得到 `unlucky-launcher`。
#[must_use]
pub fn name_in(argv0: Option<&str>) -> String {
let Some(raw) = argv0 else {
return FALLBACK.to_owned();
};
let base = raw.rsplit(['/', '\\']).next().unwrap_or(raw);
let base = strip_exe(base);
if base.is_empty() {
FALLBACK.to_owned()
} else {
base.to_owned()
}
}
fn strip_exe(name: &str) -> &str {
match name
.strip_suffix(".exe")
.or_else(|| name.strip_suffix(".EXE"))
{
Some(stem) => stem,
None => name,
}
}
/// 当前进程的程序名。
#[must_use]
pub fn name() -> String {
let argv0 = std::env::args_os()
.next()
.map(|arg| arg.to_string_lossy().into_owned());
name_in(argv0.as_deref())
}
/// 把模板里的 [`PLACEHOLDER`] 换成程序名。
///
/// **只认占位符**:文本里其它位置的 `ull`(账户名、路径段、回显的子命令)原样保留——这是
/// 与「按词替换整条文本」的分界线,也是本函数唯一要保证的事。
#[must_use]
pub fn fill(text: &str) -> String {
if !text.contains(PLACEHOLDER) {
return text.to_owned();
}
text.replace(PLACEHOLDER, &name())
}
#[cfg(test)]
mod tests {
use super::{fill, name_in, FALLBACK, PLACEHOLDER};
#[test]
fn argv0_is_reduced_to_a_bare_name() {
assert_eq!(
name_in(Some(r"C:\Program Files\ULL\unlucky-launcher.exe")),
"unlucky-launcher"
);
assert_eq!(
name_in(Some("./target/release/unlucky-launcher")),
"unlucky-launcher"
);
assert_eq!(name_in(Some("ull")), "ull");
assert_eq!(name_in(Some("/usr/local/bin/ull.exe")), "ull");
}
#[test]
fn empty_or_missing_argv0_falls_back() {
assert_eq!(name_in(None), FALLBACK);
assert_eq!(name_in(Some("")), FALLBACK);
assert_eq!(name_in(Some("/usr/bin/")), FALLBACK);
}
#[test]
fn fill_replaces_only_the_placeholder() {
assert_eq!(
fill(&format!("{PLACEHOLDER} --help 查看命令列表")),
format!("{} --help 查看命令列表", super::name())
);
// 没有占位符就原样返回(不碰任何词)。
assert_eq!(fill("ull --help"), "ull --help");
}
#[test]
fn user_data_that_says_ull_is_never_rewritten() {
// 回归:账户名恰好是 `ull` / 路径里有 `\ull\` / 回显的子命令是 `ull`,
// 一个字节都不能被改成程序名(M82 r1 的真缺陷)。
for text in [
"找不到账户 `ull`",
r"索引文件:D:/tmp/ull\.ULL\data\agent\sessions\index.jsonl",
"未知命令 `ull`",
"过滤词 null 与 pull 也不算",
] {
assert_eq!(fill(text), text, "{text} 被改了");
}
}
}
+2 -2
View File
@@ -28,8 +28,8 @@ pub fn list(ctx: &Ctx) -> Result<Report, CliError> {
let mut human = Vec::new(); let mut human = Vec::new();
if store.is_empty() { if store.is_empty() {
human.push("还没有账户。".to_string()); human.push("还没有账户。".to_string());
human.push(" 离线:ull accounts add offline <玩家名>".to_string()); human.push(" 离线:%prog% accounts add offline <玩家名>".to_string());
human.push(" 微软:ull accounts add msa(设备码交互式登录)".to_string()); human.push(" 微软:%prog% accounts add msa(设备码交互式登录)".to_string());
} else { } else {
human.push(format!("账户({} 个):", store.accounts().len())); human.push(format!("账户({} 个):", store.accounts().len()));
for meta in store.accounts() { for meta in store.accounts() {
+1 -1
View File
@@ -122,7 +122,7 @@ pub fn config_report(ctx: &Ctx, port: Option<u16>) -> Result<Report, CliError> {
human.push("Cursor(.cursor/mcp.json):".to_string()); human.push("Cursor(.cursor/mcp.json):".to_string());
human.extend(pretty_lines(&samples.cursor)); human.extend(pretty_lines(&samples.cursor));
human.push( human.push(
"启动:ull agent serve(默认 Streamable HTTP) / ull agent serve --stdio".to_string(), "启动:%prog% agent serve(默认 Streamable HTTP) / %prog% agent serve --stdio".to_string(),
); );
Ok(Report::new(data, human)) Ok(Report::new(data, human))
} }
+1 -1
View File
@@ -53,7 +53,7 @@ fn get_one(ctx: &Ctx, key: &str, show_secret: bool) -> Result<Report, CliError>
let Some(value) = store.effective(key) else { let Some(value) = store.effective(key) else {
return Err( return Err(
CliError::user("unknown_key", format!("配置键 `{key}` 不在 schema 里")) CliError::user("unknown_key", format!("配置键 `{key}` 不在 schema 里"))
.with_hint("`ull config get`(不带键)可以看到全部生效键"), .with_hint("`%prog% config get`(不带键)可以看到全部生效键"),
); );
}; };
+5 -5
View File
@@ -59,7 +59,7 @@ pub fn resolve(
format!("有 {} 个账户,需要指定用哪个", many.len()), format!("有 {} 个账户,需要指定用哪个", many.len()),
) )
.with_hint(format!( .with_hint(format!(
"ull launch <instance> --account <名字>;候选:{}", "%prog% launch <instance> --account <名字>;候选:{}",
names_of(&store) names_of(&store)
))) )))
} }
@@ -75,7 +75,7 @@ pub fn resolve(
"已选中账户 {},`--username` 只用于没有账户时的离线占位", "已选中账户 {},`--username` 只用于没有账户时的离线占位",
meta.display_name() meta.display_name()
)) ))
.with_hint("去掉 --username,或用 `ull accounts add offline <名字>` 建账户")); .with_hint("去掉 --username,或用 `%prog% accounts add offline <名字>` 建账户"));
} }
let mut notes = Vec::new(); let mut notes = Vec::new();
@@ -103,7 +103,7 @@ fn offline_fallback(username: Option<&str>) -> Identity {
}, },
account: None, account: None,
notes: vec![ notes: vec![
"没有账户,按离线占位启动(ull accounts add offline <名字> 可建账户)".to_string(), "没有账户,按离线占位启动(%prog% accounts add offline <名字> 可建账户)".to_string(),
], ],
} }
} }
@@ -120,7 +120,7 @@ fn microsoft_identity(
"account_credentials", "account_credentials",
format!("账户 {} 的微软凭据缺失或类型不符", meta.display_name()), format!("账户 {} 的微软凭据缺失或类型不符", meta.display_name()),
) )
.with_hint("重新登录:ull accounts add msa")); .with_hint("重新登录:%prog% accounts add msa"));
}; };
if dry_run { if dry_run {
@@ -193,7 +193,7 @@ fn names_of(store: &AccountStore) -> String {
fn unknown_account(key: &str, store: &AccountStore) -> CliError { fn unknown_account(key: &str, store: &AccountStore) -> CliError {
let hint = if store.is_empty() { let hint = if store.is_empty() {
"还没有账户:ull accounts add offline <名字> 或 ull accounts add msa".to_string() "还没有账户:%prog% accounts add offline <名字> 或 %prog% accounts add msa".to_string()
} else { } else {
format!("可用账户:{}", names_of(store)) format!("可用账户:{}", names_of(store))
}; };
+1 -1
View File
@@ -44,7 +44,7 @@ pub(super) fn run(ctx: &Ctx, args: &InstallArgs, kind: LoaderKind) -> Result<Rep
let version = args.version.trim().to_string(); let version = args.version.trim().to_string();
if version.is_empty() { if version.is_empty() {
return Err(CliError::usage( return Err(CliError::usage(
"install 需要版本 id:ull install <version> --loader fabric", "install 需要版本 id:%prog% install <version> --loader fabric",
)); ));
} }
@@ -107,7 +107,7 @@ pub(super) fn execute(
), ),
) )
.with_hint(format!( .with_hint(format!(
"`ull java install {}` 装一个托管 JDK,或把 JAVA_HOME 指向可用的 JDK(托管目录 {})", "`%prog% java install {}` 装一个托管 JDK,或把 JAVA_HOME 指向可用的 JDK(托管目录 {})",
plan.java_requirement.minimum, plan.java_requirement.minimum,
managed.display() managed.display()
))); )));
+2 -2
View File
@@ -113,7 +113,7 @@ pub(super) fn ensure_name_free(ctx: &Ctx, name: &str, version: &str) -> Result<(
"instance_exists", "instance_exists",
format!("同名实例已存在:{name}(绑定版本 {})", profile.version_id), format!("同名实例已存在:{name}(绑定版本 {})", profile.version_id),
) )
.with_hint("用 --name 换个名字,或 `ull instances list` 看看现有实例")), .with_hint("用 --name 换个名字,或 `%prog% instances list` 看看现有实例")),
None => Ok(()), None => Ok(()),
} }
} }
@@ -145,7 +145,7 @@ pub(super) fn failure_error(report: &InstallReport, ctx: &Ctx) -> CliError {
} }
} }
CliError::user(code, message).with_hint(format!( CliError::user(code, message).with_hint(format!(
"重跑 `ull install` 会续传并跳过已就绪文件;游戏根 {}", "重跑 `%prog% install` 会续传并跳过已就绪文件;游戏根 {}",
ctx.game().root().display() ctx.game().root().display()
)) ))
} }
+2 -2
View File
@@ -37,7 +37,7 @@ pub(super) fn run(ctx: &Ctx, args: &InstallArgs) -> Result<Report, CliError> {
let version = args.version.trim().to_string(); let version = args.version.trim().to_string();
if version.is_empty() { if version.is_empty() {
return Err(CliError::usage( return Err(CliError::usage(
"install 需要版本 id:ull install <version>", "install 需要版本 id:%prog% install <version>",
)); ));
} }
let name = args let name = args
@@ -139,7 +139,7 @@ pub(super) fn plan_and_install(
format!("版本 {version} 装不全:{reasons}"), format!("版本 {version} 装不全:{reasons}"),
) )
.with_hint( .with_hint(
"旧版(pre-1.6)清单兜底待补;可先用官方启动器装好该版本再用 `ull launch`", "旧版(pre-1.6)清单兜底待补;可先用官方启动器装好该版本再用 `%prog% launch`",
)); ));
} }
let sink = report::progress_sink(progress, json_progress, plan.file_count()); let sink = report::progress_sink(progress, json_progress, plan.file_count());
+1 -1
View File
@@ -59,7 +59,7 @@ pub fn list(ctx: &Ctx) -> Result<Report, CliError> {
ctx.data.launcher_home.display() ctx.data.launcher_home.display()
)]; )];
if body.is_empty() { if body.is_empty() {
human.push(" (空)用 `ull install <version>` 登记第一个实例".to_string()); human.push(" (空)用 `%prog% install <version>` 登记第一个实例".to_string());
} }
human.extend(body); human.extend(body);
if installed.is_empty() { if installed.is_empty() {
+2 -2
View File
@@ -111,7 +111,7 @@ pub fn list(ctx: &Ctx) -> Result<Report, CliError> {
let mut human = vec![format!("Java {} 个", candidates.len())]; let mut human = vec![format!("Java {} 个", candidates.len())];
if candidates.is_empty() { if candidates.is_empty() {
human.push( human.push(
"(没找到可用 Java)用 `ull java install 21` 装一个,或设 JAVA_HOME / `ull launch --java <path>`" "(没找到可用 Java)用 `%prog% java install 21` 装一个,或设 JAVA_HOME / `%prog% launch --java <path>`"
.to_string(), .to_string(),
); );
} }
@@ -209,7 +209,7 @@ pub fn install(ctx: &Ctx, major: u32, vendor: Option<JdkVendor>) -> Result<Repor
for note in installed.chain.degradations() { for note in installed.chain.degradations() {
human.push(format!("降级:{note}")); human.push(format!("降级:{note}"));
} }
human.push("`ull launch` 会自动优先用它".to_string()); human.push("`%prog% launch` 会自动优先用它".to_string());
Ok(Report::new(data, human)) Ok(Report::new(data, human))
} }
+1 -1
View File
@@ -330,7 +330,7 @@ fn map_launch_error(error: &LaunchError, ctx: &Ctx) -> CliError {
fn instance_hint(profiles: &ProfileList) -> String { fn instance_hint(profiles: &ProfileList) -> String {
if profiles.profiles.is_empty() { if profiles.profiles.is_empty() {
"用 `ull instances list` 查看实例,或 `ull install <version>` 登记一个".to_string() "用 `%prog% instances list` 查看实例,或 `%prog% install <version>` 登记一个".to_string()
} else { } else {
let names: Vec<&str> = profiles let names: Vec<&str> = profiles
.profiles .profiles
+17 -6
View File
@@ -2,7 +2,7 @@
use serde_json::json; use serde_json::json;
use crate::cli::help; use crate::cli::{help, program};
use crate::os; use crate::os;
use crate::output::Report; use crate::output::Report;
@@ -12,18 +12,20 @@ pub const LAUNCHER_NAME: &str = "UnluckyLauncher";
/// 版本 + 系统摘要(`ull --version`,D19 §5-2 要求附带 os 摘要)。 /// 版本 + 系统摘要(`ull --version`,D19 §5-2 要求附带 os 摘要)。
/// ///
/// 探测走 `core::platform::os_info::current()`(进程内缓存),未达基线时附带升级指引。 /// 探测走 `core::platform::os_info::current()`(进程内缓存),未达基线时附带升级指引。
/// 自称的名字取 argv[0](M82):GUI 二进制调进来时是 `unlucky-launcher`,`ull` 自己跑时仍是 `ull`。
#[must_use] #[must_use]
pub fn version() -> Report { pub fn version() -> Report {
let info = os::detect(); let info = os::detect();
let invoked_as = program::name();
let data = json!({ let data = json!({
"name": "ull", "name": invoked_as,
"package": env!("CARGO_PKG_NAME"), "package": env!("CARGO_PKG_NAME"),
"version": env!("CARGO_PKG_VERSION"), "version": env!("CARGO_PKG_VERSION"),
"os": os::to_json(&info), "os": os::to_json(&info),
}); });
let mut human = vec![ let mut human = vec![
format!( format!(
"ull {}({})", "{invoked_as} {}({})",
env!("CARGO_PKG_VERSION"), env!("CARGO_PKG_VERSION"),
env!("CARGO_PKG_NAME") env!("CARGO_PKG_NAME")
), ),
@@ -38,20 +40,29 @@ pub fn version() -> Report {
pub fn welcome() -> Report { pub fn welcome() -> Report {
let version_report = version(); let version_report = version();
let mut human = version_report.human; let mut human = version_report.human;
let text = general_help();
human.push(String::new()); human.push(String::new());
human.extend(help::general().lines().map(str::to_string)); human.extend(text.lines().map(str::to_string));
let mut data = version_report.data; let mut data = version_report.data;
data["help"] = json!(help::general()); data["help"] = json!(text);
Report::new(data, human) Report::new(data, human)
} }
/// 帮助(`ull help <命令>` 给该命令,未知主题退回总帮助)。 /// 帮助(`ull help <命令>` 给该命令,未知主题退回总帮助)。
#[must_use] #[must_use]
pub fn help(topic: Option<&str>) -> Report { pub fn help(topic: Option<&str>) -> Report {
let text = topic.and_then(help::topic).unwrap_or_else(help::general); let text = match topic.and_then(help::topic) {
Some(text) => program::fill(text),
None => general_help(),
};
let data = json!({ let data = json!({
"topic": topic, "topic": topic,
"text": text, "text": text,
}); });
Report::new(data, text.lines().map(str::to_string).collect()) Report::new(data, text.lines().map(str::to_string).collect())
} }
/// 总帮助,按被调用的二进制名自述(`ull` 自己跑时逐字不变)。
fn general_help() -> String {
program::fill(help::general())
}
+4 -4
View File
@@ -75,14 +75,14 @@ pub(crate) fn map_auth_error(error: unlucky_core::auth::AuthError) -> CliError {
let hint = match &error { let hint = match &error {
AuthError::Canceled => Some("登录被取消,可以重跑一次".to_string()), AuthError::Canceled => Some("登录被取消,可以重跑一次".to_string()),
AuthError::Timeout { .. } => { AuthError::Timeout { .. } => {
Some("重新执行 `ull accounts add msa` 拿新的用户码".to_string()) Some("重新执行 `%prog% accounts add msa` 拿新的用户码".to_string())
} }
AuthError::CredentialExpired => Some("重新登录:ull accounts add msa".to_string()), AuthError::CredentialExpired => Some("重新登录:%prog% accounts add msa".to_string()),
AuthError::Unsupported(_) => { AuthError::Unsupported(_) => {
Some("微软登录需要设置 ULL_MSA_CLIENT_ID(Azure 公共客户端 id)".to_string()) Some("微软登录需要设置 ULL_MSA_CLIENT_ID(Azure 公共客户端 id)".to_string())
} }
AuthError::Conflict { .. } => Some("用 `ull accounts list` 查看已有账户".to_string()), AuthError::Conflict { .. } => Some("用 `%prog% accounts list` 查看已有账户".to_string()),
AuthError::NotFound { .. } => Some("用 `ull accounts list` 查看账户名".to_string()), AuthError::NotFound { .. } => Some("用 `%prog% accounts list` 查看账户名".to_string()),
AuthError::Denied { code, .. } if code == "no_profile" || code == "no_license" => { AuthError::Denied { code, .. } if code == "no_profile" || code == "no_license" => {
Some("去 minecraft.net 检查该账号的 Java 版拥有权与档案".to_string()) Some("去 minecraft.net 检查该账号的 Java 版拥有权与档案".to_string())
} }
+1 -1
View File
@@ -57,7 +57,7 @@ pub fn list(ctx: &Ctx, limit: usize) -> Result<Report, CliError> {
index.display() index.display()
)]; )];
if shown.is_empty() { if shown.is_empty() {
human.push(" (空)先用 `ull launch <实例>` 跑一局".to_string()); human.push(" (空)先用 `%prog% launch <实例>` 跑一局".to_string());
} }
for entry in &shown { for entry in &shown {
human.push(format!( human.push(format!(
+12 -4
View File
@@ -56,6 +56,14 @@ pub struct CliError {
hint: Option<String>, hint: Option<String>,
} }
/// 把模板里的程序名占位符渲染掉(M82 r3)。
///
/// 渲染放在**构造时**,而不是打印时:错误有两条出口(人类可读 stderr、`--json` 信封),
/// 只在打印处渲染会漏掉 JSON 那条(r2 就是这么漏的,`%prog%` 直接出现在了 JSON 里)。
fn render(text: impl Into<String>) -> String {
crate::cli::program::fill(&text.into())
}
impl CliError { impl CliError {
/// 用法错误(参数不对)。 /// 用法错误(参数不对)。
#[must_use] #[must_use]
@@ -63,7 +71,7 @@ impl CliError {
Self { Self {
kind: ErrorKind::User, kind: ErrorKind::User,
code: "usage", code: "usage",
message: message.into(), message: render(message),
hint: None, hint: None,
} }
} }
@@ -74,7 +82,7 @@ impl CliError {
Self { Self {
kind: ErrorKind::User, kind: ErrorKind::User,
code, code,
message: message.into(), message: render(message),
hint: None, hint: None,
} }
} }
@@ -85,7 +93,7 @@ impl CliError {
Self { Self {
kind: ErrorKind::Internal, kind: ErrorKind::Internal,
code, code,
message: message.into(), message: render(message),
hint: None, hint: None,
} }
} }
@@ -99,7 +107,7 @@ impl CliError {
/// 补一句提示。 /// 补一句提示。
#[must_use] #[must_use]
pub fn with_hint(mut self, hint: impl Into<String>) -> Self { pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(hint.into()); self.hint = Some(render(hint));
self self
} }
+68 -5
View File
@@ -20,10 +20,13 @@ pub struct Report {
} }
impl Report { impl Report {
/// 构造。 /// 构造(人类可读行在这里就把程序名占位符渲染掉,见 [`crate::cli::program::PLACEHOLDER`])。
#[must_use] #[must_use]
pub fn new(data: Value, human: Vec<String>) -> Self { pub fn new(data: Value, human: Vec<String>) -> Self {
Self { data, human } Self {
data,
human: human.into_iter().map(|line| fill_program(&line)).collect(),
}
} }
} }
@@ -46,10 +49,16 @@ impl Outcome {
} }
} }
/// 带上环境提示。 /// 带上环境提示(同样在构造时渲染占位符,JSON 与人类可读两条出口才不会分叉)。
#[must_use] #[must_use]
pub fn with_warnings(report: Report, warnings: Vec<String>) -> Self { pub fn with_warnings(report: Report, warnings: Vec<String>) -> Self {
Self { report, warnings } Self {
report,
warnings: warnings
.into_iter()
.map(|line| fill_program(&line))
.collect(),
}
} }
} }
@@ -108,6 +117,15 @@ pub fn print_error(
Ok(()) Ok(())
} }
/// 渲染文案里的程序名占位符(M82)。
///
/// **只替换占位符**,不对整条文本做词替换:消息里插值进来的账户名 / 路径 / 回显的子命令
/// 一个字节都不动(r1 曾把账户名 `ull` 改成程序名、把 `D:/tmp/ull/.ULL` 改成
/// `D:/tmp/unlucky-launcher/.ULL`)。
fn fill_program(text: &str) -> String {
crate::cli::program::fill(text)
}
/// 长任务的实时进度行(**只进 stderr**,不参与 [`Report`])。 /// 长任务的实时进度行(**只进 stderr**,不参与 [`Report`])。
/// ///
/// 命令的正经输出仍然全部走 [`Report`](stdout、可断言的形状);下载/安装这类分钟级任务 /// 命令的正经输出仍然全部走 [`Report`](stdout、可断言的形状);下载/安装这类分钟级任务
@@ -125,7 +143,10 @@ pub fn progress_line(text: &str) {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::progress_line; use super::{print_report, progress_line, Outcome, Report};
use crate::cli::program::{self, PLACEHOLDER};
use crate::error::CliError;
use serde_json::json;
#[test] #[test]
fn progress_line_never_panics() { fn progress_line_never_panics() {
@@ -133,4 +154,46 @@ mod tests {
progress_line("测试进度 1/2"); progress_line("测试进度 1/2");
progress_line(""); progress_line("");
} }
#[test]
fn json_error_envelope_never_leaks_the_placeholder() {
// r2 的回归:JSON 分支直接序列化未渲染的 message/hint,`%prog%` 原样漏给脚本。
let error = CliError::usage(format!("{PLACEHOLDER} 需要一个值"))
.with_hint(format!("{PLACEHOLDER} --help 查看命令列表"));
let envelope = serde_json::to_string(&error.to_json()).expect("序列化");
assert!(!envelope.contains(PLACEHOLDER), "{envelope}");
assert!(envelope.contains(&program::name()), "{envelope}");
}
#[test]
fn json_warnings_never_leak_the_placeholder() {
// warnings 是第二条 JSON 出口(以前连人类可读那条都没走到)。
let report = Report::new(
json!({ "ok": true }),
vec![format!("{PLACEHOLDER} instances list 看看现有实例")],
);
let outcome =
Outcome::with_warnings(report, vec![format!("用 {PLACEHOLDER} install 装版本")]);
let mut out: Vec<u8> = Vec::new();
let mut err: Vec<u8> = Vec::new();
print_report(&outcome, true, false, &mut out, &mut err).expect("写 JSON");
let text = String::from_utf8_lossy(&out).into_owned();
assert!(!text.contains(PLACEHOLDER), "{text}");
assert!(text.contains(&program::name()), "{text}");
}
#[test]
fn human_lines_render_the_placeholder_too() {
let report = Report::new(
json!({}),
vec![format!("用 `{PLACEHOLDER} install` 登记实例")],
);
assert_eq!(
report.human[0],
format!("用 `{} install` 登记实例", program::name())
);
// 用户数据不受影响:账户名恰好叫 ull 的那种
let report = Report::new(json!({}), vec!["找不到账户 `ull`".to_string()]);
assert_eq!(report.human[0], "找不到账户 `ull`");
}
} }
+10 -2
View File
@@ -11,7 +11,13 @@ fn version_json_has_os_summary() {
assert_eq!(code, 0); assert_eq!(code, 0);
let value = parse_json(&stdout); let value = parse_json(&stdout);
assert_eq!(value["ok"], true); assert_eq!(value["ok"], true);
assert_eq!(value["data"]["name"], "ull"); // M82:自称的名字跟着被调用的二进制走(test harness 里是测试二进制名,`ull` 里是 `ull`);
// argv[0] → 名字的映射由 cli::program 的单测钉死。
assert_eq!(
value["data"]["name"],
unlucky_cli::cli::program::name(),
"{stdout}"
);
assert_eq!(value["data"]["version"], env!("CARGO_PKG_VERSION")); assert_eq!(value["data"]["version"], env!("CARGO_PKG_VERSION"));
// D19/D21:os 对象来自 core::platform::os_info(family + 两档 support)。 // D19/D21:os 对象来自 core::platform::os_info(family + 两档 support)。
assert!(value["data"]["os"]["family"].is_string()); assert!(value["data"]["os"]["family"].is_string());
@@ -23,7 +29,9 @@ fn version_json_has_os_summary() {
fn welcome_without_subcommand_prints_version_and_help() { fn welcome_without_subcommand_prints_version_and_help() {
let (code, stdout, _) = run_cli(&[]); let (code, stdout, _) = run_cli(&[]);
assert_eq!(code, 0); assert_eq!(code, 0);
assert!(stdout.contains("ull "), "{stdout}"); // banner 用被调用的二进制名(M82),不写死 `ull`。
let banner = format!("{} ", unlucky_cli::cli::program::name());
assert!(stdout.starts_with(&banner), "{stdout}");
assert!(stdout.contains("用法:"), "{stdout}"); assert!(stdout.contains("用法:"), "{stdout}");
let (code, stdout, _) = run_cli(&["--json"]); let (code, stdout, _) = run_cli(&["--json"]);
+32 -8
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.4)——两者都不是重复品。
- `<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.4)除 `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 槽位真跑)
@@ -254,7 +264,7 @@ reviewer-packaging 在 M46 review 里连续调 `stage_archive()` 两次 → **
`00000000`,归档内单顶层目录且含 `LICENSE`/`NOTICE`/`README.md`。 `00000000`,归档内单顶层目录且含 `LICENSE`/`NOTICE`/`README.md`。
即"同源构建 → 同 sha256"在 tar 路径上成立,不随打包时刻漂移(见 §1 的约定细节)。 即"同源构建 → 同 sha256"在 tar 路径上成立,不随打包时刻漂移(见 §1 的约定细节)。
### 7.4 运行时依赖证据(PE 导入表) ### 7.4 运行时依赖证据(PE 导入表 / ELF `DT_NEEDED`)
`objdump -p target/release/unlucky-launcher.exe` 共 42 条 DLL 导入记录、33 个不同名字(大小写各记一次),全部是 Windows 自带: `objdump -p target/release/unlucky-launcher.exe` 共 42 条 DLL 导入记录、33 个不同名字(大小写各记一次),全部是 Windows 自带:
`KERNEL32/USER32/GDI32/ADVAPI32/SHELL32/SHLWAPI/ole32/oleaut32/combase/comctl32/dwmapi/dwrite/imm32/ `KERNEL32/USER32/GDI32/ADVAPI32/SHELL32/SHLWAPI/ole32/oleaut32/combase/comctl32/dwmapi/dwrite/imm32/
@@ -268,6 +278,18 @@ 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 那一组。
**补一次真跑(M82 复审在 `debian:bookworm-slim` 上做的)**:镜像里 X11 / wayland / GL / EGL / xkbcommon /
libxcb 条目数 = 0,只把 fontconfig 那 7 个 `.so` 放进 `LD_LIBRARY_PATH`,跑**改动后**的二进制 → `--version`
与 `--help` exit 0、`instances list --json` 合法 JSON exit 0、`lst` exit 1 且带提示;`readelf -d` 复核仍是
恰好 5 条 `DT_NEEDED`(无 `libstdc++`、无任何图形库),`ull` 4 条 —— 即「能跑完子命令」是实测,不是推论。
### 7.5 arm64 槽位的本机验证(脚本层取证,不假装跑得动 arm64 二进制) ### 7.5 arm64 槽位的本机验证(脚本层取证,不假装跑得动 arm64 二进制)
本机没有 ARM 机器,也不具备 MSVC ARM64 链接库组合,所以只做**能真实证明的东西**(`python` 直接加载 本机没有 ARM 机器,也不具备 MSVC ARM64 链接库组合,所以只做**能真实证明的东西**(`python` 直接加载
@@ -322,16 +344,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 槽位不会出现这条。
@@ -341,6 +362,9 @@ experimental / not part of this release matrix)——改一边必须同步另
本机能做且已做的是脚本层取证(见 §7.5):宿主判定注入(`win32/ARM64`、`linux/aarch64` 等全槽位)、 本机能做且已做的是脚本层取证(见 §7.5):宿主判定注入(`win32/ARM64`、`linux/aarch64` 等全槽位)、
可执行文件头架构识别(真 x64 exe + 合成 PE/ELF/Mach-O 头)、以及把真 exe 的 PE 机器码改成 ARM64 可执行文件头架构识别(真 x64 exe + 合成 PE/ELF/Mach-O 头)、以及把真 exe 的 PE 机器码改成 ARM64
当 stub 走完命名/校验/归档全流程 —— 不假装能在 x64 上执行 arm64 二进制。 当 stub 走完命名/校验/归档全流程 —— 不假装能在 x64 上执行 arm64 二进制。
8. **M82 的体积/首帧没重量**:改的时候本机三个 worker 同时在编(全量 release profile 要 400+ crate),
这两个数留给空闲窗口补。结构性判断(**不是实测**):分发发生在 `MainWindow::new()` 与后端探测之前,
GUI 路径只多一次 argv 解析,所以首帧成本没有增加的理由;CLI 路径的耗时则天然与首帧无关。
## 11. CI 运行时冒烟(Linux 真渲染一帧) ## 11. CI 运行时冒烟(Linux 真渲染一帧)
+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
+14 -1
View File
@@ -19,6 +19,19 @@ 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
chmod +x ull-{{VERSION}}-linux-x64 # Release 资产不带可执行位,先补上
./ull-{{VERSION}}-linux-x64 instances list # servers / CI / Termux: no graphics stack needed
unlucky-launcher instances list # desktops: the GUI binary answers the same commands
```
`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 +44,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