Merge branch 'feat/cli-unify-launcher-binary-subcommands-wi'
This commit is contained in:
commit
00f132d8ce
34 files changed
+677
-121
No files matched your search
@@ -281,16 +281,20 @@ jobs:
|
||||
set -euo pipefail
|
||||
ls -l dist
|
||||
# 六槽位全齐才发版:缺任意一个就 fail(宁可不出 Release,也不出少一档的 Release)。
|
||||
# 每个槽位都要 GUI 与 CLI 两个资产——只按「槽位名出现过」判会被其中一个掩盖。
|
||||
for slot in windows-x64 windows-arm64 linux-x64 linux-arm64 macos-arm64 macos-x64; do
|
||||
if ! compgen -G "dist/*-$slot*" >/dev/null; then
|
||||
echo "::error::矩阵缺产物:$slot"
|
||||
exit 1
|
||||
fi
|
||||
for prefix in unlucky-launcher ull; do
|
||||
if ! compgen -G "dist/$prefix-*-$slot*" >/dev/null; then
|
||||
echo "::error::矩阵缺产物:$prefix / $slot"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
done
|
||||
# 各平台任务各写一份 SHA256SUMS.txt,合并后只有一份是真的 —— 在 Release 侧重算。
|
||||
# GUI 与 CLI 都要进清单:漏了 ull 就是「发了但没有校验和」。
|
||||
cd dist
|
||||
rm -f SHA256SUMS.txt
|
||||
sha256sum unlucky-launcher-* > SHA256SUMS.txt
|
||||
sha256sum unlucky-launcher-* ull-* > SHA256SUMS.txt
|
||||
sha256sum -c SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
|
||||
@@ -344,7 +348,8 @@ jobs:
|
||||
esac
|
||||
# Windows 单 exe 装不下 LICENSE/NOTICE/README.md,这三份与产物并列挂到 Release 上,
|
||||
# 保证每个下载平台的许可条款都随包分发。
|
||||
assets=(dist/unlucky-launcher-* dist/SHA256SUMS.txt LICENSE NOTICE README.md)
|
||||
# `ull` 是独立资产(不在 tar.gz 里):headless 用户下一个小文件直接跑,不用解档。
|
||||
assets=(dist/unlucky-launcher-* dist/ull-* dist/SHA256SUMS.txt LICENSE NOTICE README.md)
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
# 同 tag 重跑(补丁 / 重编):Release 已存在 → 覆盖资产 + 刷新说明,
|
||||
# 不再 `create`(那会因已存在而失败,把整条流水线在最后一步染红)。
|
||||
|
||||
Generated
+2
@@ -6892,6 +6892,7 @@ dependencies = [
|
||||
"slint",
|
||||
"slint-build",
|
||||
"unlucky-agent",
|
||||
"unlucky-cli",
|
||||
"unlucky-core",
|
||||
]
|
||||
|
||||
@@ -6901,6 +6902,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"iced",
|
||||
"image",
|
||||
"unlucky-cli",
|
||||
"unlucky-design",
|
||||
]
|
||||
|
||||
|
||||
@@ -43,10 +43,14 @@ Two tracks. They are not the same product yet, so read the headings before downl
|
||||
already contain them.
|
||||
- **UI**: Slint + Metro. This is the shipped GUI — the instance, download, account, Java, log and
|
||||
settings pages all run on the core engine (the in-app AI chat panel is a scripted placeholder).
|
||||
- **CLI `ull`** is built from the same tree at the same version — but the `v0.1.0` assets ship
|
||||
only the GUI binary. If you want the CLI, build it yourself:
|
||||
`cargo build --release --bin ull`. Commands: `instances`, `install`, `launch`, `accounts`,
|
||||
`sessions`, `logs`, `diag`, `config`, `java`, `agent`.
|
||||
- **CLI in the same binary**: `unlucky-launcher instances list`, `unlucky-launcher launch <instance>`,
|
||||
`unlucky-launcher logs <sid>` and the rest of the command set run before any UI is built — no
|
||||
window, no renderer probe, no font scan. Commands: `instances`, `install`, `launch`, `accounts`,
|
||||
`sessions`, `logs`, `diag`, `config`, `java`, `agent`, plus `--help` / `--version`.
|
||||
- **A second, lighter artifact `ull`** (`cargo build --release --bin ull`, zero UI dependencies): the
|
||||
same command set from a binary that links no graphics stack at all — not even fontconfig. Servers,
|
||||
CI containers and Termux get `ull`; desktops get `unlucky-launcher` and one binary covers both GUI
|
||||
and CLI. Both are release assets from the next tagged release (the `v0.1.0` assets predate this).
|
||||
- Windows ARM64 and Linux ARM64 slots are wired into the release pipeline; the next tagged release
|
||||
builds them. The `v0.1.0` assets predate those two slots.
|
||||
|
||||
@@ -71,9 +75,44 @@ Binaries in the workspace, so you can tell them apart:
|
||||
|
||||
| Binary | Crate | State |
|
||||
|--------|-------|-------|
|
||||
| `unlucky-launcher` | `crates/app` (Slint) | released in v0.1.0; still the usable GUI |
|
||||
| `unlucky-launcher-iced` | `crates/app-iced` (iced 0.14) | under construction; skeleton only, not shipped |
|
||||
| `ull` | `crates/cli` | built from the same tree at the same version; not in the v0.1.0 assets |
|
||||
| `unlucky-launcher` | `crates/app` (Slint) | released in v0.1.0; still the usable GUI; also runs every CLI subcommand |
|
||||
| `unlucky-launcher-iced` | `crates/app-iced` (iced 0.14) | under construction; skeleton only, not shipped; same subcommand dispatch |
|
||||
| `ull` | `crates/cli` | the zero-UI build of the same command set, shipped as its own asset |
|
||||
|
||||
`unlucky-launcher` and `ull` are not duplicates. The GUI binary needs the graphics stack when it
|
||||
opens a window (it `dlopen`s X11 / Wayland / GL, and links `libfontconfig` at load); `ull` needs
|
||||
nothing beyond glibc, so it runs on a server, a CI container or Termux where no window can exist.
|
||||
Pick `ull` when there is no desktop, `unlucky-launcher <command>` when there is. 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
|
||||
|
||||
@@ -121,11 +160,13 @@ cargo build --release # binaries land in target
|
||||
|
||||
What the build produces:
|
||||
|
||||
- `unlucky-launcher` — the Slint UI (the released one)
|
||||
- `unlucky-launcher` — the Slint UI (the released one). It is also the CLI: the same binary answers
|
||||
every subcommand listed above.
|
||||
- `unlucky-launcher-iced` — the iced UI skeleton. The name is temporary: it can only become
|
||||
`unlucky-launcher` at the flip, because two crates in one workspace cannot emit the same binary
|
||||
name into the same target directory.
|
||||
- `ull` — the CLI
|
||||
name into the same target directory. Same subcommand dispatch.
|
||||
- `ull` — the CLI as a standalone, UI-free binary. Same command set, no graphics stack, smaller, and
|
||||
the right artifact for a machine that has no display at all.
|
||||
|
||||
Rendering switches:
|
||||
|
||||
|
||||
+45
-9
@@ -37,9 +37,14 @@ Unlucky Launcher(ULL)是一个用 Rust 写的免费开源 Minecraft: Java
|
||||
unix 归档里本来就装着。
|
||||
- **UI**:Slint + Metro。这是已交付的图形界面——实例、下载、账户、Java、日志、设置
|
||||
各页都由 core 引擎驱动(应用内 AI 对话面板仍是脚本化的占位)。
|
||||
- **CLI `ull`** 与 v0.1.0 同源同版本,但发布资产里只有 GUI 单文件——要用它请自己构建:
|
||||
`cargo build --release --bin ull`。命令:`instances`、`install`、`launch`、`accounts`、`sessions`、
|
||||
`logs`、`diag`、`config`、`java`、`agent`。
|
||||
- **同一个二进制就是 CLI**:`unlucky-launcher instances list`、`unlucky-launcher launch <实例>`、
|
||||
`unlucky-launcher logs <sid>` 等命令在任何 UI 代码之前分流——不开窗、不探渲染后端、不扫字体。
|
||||
命令集:`instances`、`install`、`launch`、`accounts`、`sessions`、`logs`、`diag`、`config`、
|
||||
`java`、`agent`,外加 `--help` / `--version`。
|
||||
- **另有一个更轻的 `ull`**(`cargo build --release --bin ull`,零 UI 依赖):同一套命令,但二进制不
|
||||
链接任何图形栈——连 fontconfig 都不需要。服务器、CI 容器、Termux 拿 `ull`;有桌面的机器拿
|
||||
`unlucky-launcher`,一个文件同时管 GUI 与命令行。两者都从下一个 tag 起进发布资产
|
||||
(`v0.1.0` 的资产早于这个变化)。
|
||||
- Windows ARM64 与 Linux ARM64 两个槽位已接进发布流水线,随下一个 tag 发布;`v0.1.0` 的资产早于
|
||||
这两个槽位。
|
||||
|
||||
@@ -61,9 +66,40 @@ Unlucky Launcher(ULL)是一个用 Rust 写的免费开源 Minecraft: Java
|
||||
|
||||
| 二进制 | crate | 状态 |
|
||||
|--------|-------|------|
|
||||
| `unlucky-launcher` | `crates/app`(Slint) | v0.1.0 已发布,仍是当前能用的图形界面 |
|
||||
| `unlucky-launcher-iced` | `crates/app-iced`(iced 0.14) | 施工中,仅骨架,不随发布包分发 |
|
||||
| `ull` | `crates/cli` | 与发布版同源同版本构建;未随 v0.1.0 资产分发 |
|
||||
| `unlucky-launcher` | `crates/app`(Slint) | v0.1.0 已发布,仍是当前能用的图形界面;同时支持全部 CLI 子命令 |
|
||||
| `unlucky-launcher-iced` | `crates/app-iced`(iced 0.14) | 施工中,仅骨架,不随发布包分发;子命令分发同款 |
|
||||
| `ull` | `crates/cli` | 同一套命令的零 UI 构建,作为独立资产随发布分发 |
|
||||
|
||||
`unlucky-launcher` 与 `ull` 不是重复品:开窗时 GUI 二进制要用图形栈(X11 / Wayland / GL 走
|
||||
`dlopen`,启动时直接链接 `libfontconfig`);`ull` 除了 glibc 什么都不需要,所以在没有窗口的服务器、
|
||||
CI 容器、Termux 上跑得起来。没有桌面就用 `ull`,有桌面就用 `unlucky-launcher <子命令>`。两者打出来的
|
||||
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:
|
||||
|
||||
- `unlucky-launcher`——Slint 版(已发布的那个)
|
||||
- `unlucky-launcher`——Slint 版(已发布的那个)。它同时也是 CLI:同一个二进制认上面列的全部子命令。
|
||||
- `unlucky-launcher-iced`——iced 版骨架。名字是临时的:只有翻牌时才能改成 `unlucky-launcher`,
|
||||
因为同一个工作区里两个 crate 不能往同一个 target 目录里产出同名 bin。
|
||||
- `ull`——CLI
|
||||
因为同一个工作区里两个 crate 不能往同一个 target 目录里产出同名 bin。子命令分发同款。
|
||||
- `ull`——CLI 的独立无 UI 构建:同一套命令、不链接图形栈、体积更小,给完全没有显示设备的机器用。
|
||||
|
||||
渲染开关:
|
||||
|
||||
|
||||
@@ -34,14 +34,31 @@ iced = { version = "=0.14.0", default-features = false, features = [
|
||||
"advanced",
|
||||
"svg",
|
||||
"thread-pool",
|
||||
"x11",
|
||||
"wayland",
|
||||
"linux-theme-detection",
|
||||
] }
|
||||
|
||||
# 截图落 PNG。只开 png 编码器,不要默认全套 codec(体积)。
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
|
||||
# M82:同一个二进制也带 CLI 子命令(`unlucky-launcher-iced instances list` …),
|
||||
# 进 UI 之前分发,命中就把控制权交给 `unlucky_cli::run`。依赖方向 app-iced → cli →
|
||||
# core/agent 不违反 core 零 UI 铁律(core 的依赖树里没有 cli)。零 UI 依赖的 `ull`
|
||||
# 仍在 `crates/cli`,给没有图形库的机器用——两者不是重复品。
|
||||
unlucky-cli = { path = "../cli", version = "0.1" }
|
||||
|
||||
# X11 / Wayland 后端与 Linux 主题检测只在 Linux 上启用:macOS 没有 X11/Wayland(走 Metal +
|
||||
# 系统外观),Windows 走 DX12,这三个 feature 在那边没有意义。同一个包在两处写 features 是
|
||||
# **取并集**(Cargo 的 feature 解析是加法),所以 Linux 上拿到的仍是上面 5 个 + 这 3 个,
|
||||
# Windows / macOS 只拿上面那 5 个(`cargo tree --target` 可复核)。
|
||||
# 诚实边界:这**不**解决「iced 版至今没在 macOS 上编过」这件事——`iced_winit` 已把 `mundy`
|
||||
# 放在 `cfg(target_os = "linux")` 段里,这三个 feature 在非 Linux 上本来就不拉依赖,这里只是
|
||||
# 让本仓库的依赖图按平台自述清楚,不再把 Linux 专属能力写成全平台事实。
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
iced = { version = "=0.14.0", default-features = false, features = [
|
||||
"x11",
|
||||
"wayland",
|
||||
"linux-theme-detection",
|
||||
] }
|
||||
|
||||
[features]
|
||||
default = ["gpu"]
|
||||
# 硬件渲染后端(wgpu→Vulkan/DX12)。关闭后只剩 tiny-skia(CPU),用于体积基线与无 GPU 机器。
|
||||
|
||||
+53
-12
@@ -1,9 +1,20 @@
|
||||
//! 入口。所有实际逻辑在 lib(`unlucky_app_iced`)里:
|
||||
//!
|
||||
//! - `--diff <a> <b>`:只读两张 PNG 做逐像素对比(D30 的 GPU/CPU 几何一致性检查),不开窗;
|
||||
//! - 其余参数:开窗跑骨架,`--screenshot` 出图后退出。
|
||||
//! 一个二进制两种用法(M82),分发在进 UI 之前完成:
|
||||
//!
|
||||
//! 退出码:`0` 成功 / `1` 截图有差异(diff 模式)/ `2` 参数或运行错误。
|
||||
//! - 被子命令命中(`instances` / `install` / `launch` / `accounts` / `sessions` / `logs` /
|
||||
//! `diag` / `config` / `java` / `agent`,或 `--help` / `--version`)→ 直接转给
|
||||
//! `unlucky_cli::run`,**不初始化任何 UI**(不 probe 后端、不扫字体、不碰图形库)。
|
||||
//! 子命令表不在这里抄一份:唯一真相源是 `crates/cli` 的解析器
|
||||
//! (`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;
|
||||
|
||||
@@ -11,8 +22,13 @@ use unlucky_app_iced::shot;
|
||||
use unlucky_app_iced::Options;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let options = match Options::from_env() {
|
||||
Ok(options) => options,
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if let Some(code) = run_cli_if_requested(&args) {
|
||||
return code;
|
||||
}
|
||||
|
||||
match run_gui() {
|
||||
Ok(code) => code,
|
||||
Err(message) => {
|
||||
eprintln!("[cli] {message}");
|
||||
eprintln!(
|
||||
@@ -20,9 +36,34 @@ fn main() -> ExitCode {
|
||||
[--theme=light|dark] [--screenshot <png>] [--screenshot-theme=light|dark] \
|
||||
[--scale-factor <f32>] [--diff <a.png> <b.png>]"
|
||||
);
|
||||
return ExitCode::from(2);
|
||||
ExitCode::from(2)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 子命令命中 CLI 就跑 CLI 并以它的退出码收尾;返回 `None` 表示「这不是 CLI 调用,去开窗」。
|
||||
///
|
||||
/// 这条路径上**不构造窗口、不探后端、不扫字体、不依赖任何图形库**(这些库缺失时同一条命令照样
|
||||
/// 跑完,实测见 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() {
|
||||
let scan = i32::try_from(options.diff_scan).unwrap_or(i32::MAX);
|
||||
@@ -30,23 +71,23 @@ fn main() -> ExitCode {
|
||||
Ok(diff) => {
|
||||
eprintln!("{}", diff.describe());
|
||||
if diff.is_identical() {
|
||||
ExitCode::SUCCESS
|
||||
Ok(ExitCode::SUCCESS)
|
||||
} else {
|
||||
ExitCode::from(1)
|
||||
Ok(ExitCode::from(1))
|
||||
}
|
||||
}
|
||||
Err(message) => {
|
||||
eprintln!("[diff] {message}");
|
||||
ExitCode::from(2)
|
||||
Ok(ExitCode::from(2))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
match unlucky_app_iced::run(options) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Ok(()) => Ok(ExitCode::SUCCESS),
|
||||
Err(error) => {
|
||||
eprintln!("[run] {error}");
|
||||
ExitCode::FAILURE
|
||||
Ok(ExitCode::FAILURE)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,11 @@ serde_json.workspace = true
|
||||
# (core 的快照/事件流翻译成 src/dto 的类型)落位时不必再动 workspace 契约。
|
||||
unlucky-core = { path = "../core", version = "0.1" }
|
||||
unlucky-agent = { path = "../agent", version = "0.1" }
|
||||
# M82:同一个二进制也带 CLI 子命令(`unlucky-launcher instances list` …)。
|
||||
# 依赖方向 app → cli → core/agent 不违反 core 零 UI 铁律(guards.yml 的 core-no-ui 只查
|
||||
# `cargo tree -p unlucky-core`,core 不依赖 cli)。零 UI 依赖的 `ull` 仍在 `crates/cli`,
|
||||
# 给没有图形库的机器用——两者不是重复品。
|
||||
unlucky-cli = { path = "../cli", version = "0.1" }
|
||||
|
||||
[build-dependencies]
|
||||
slint-build.workspace = true
|
||||
|
||||
+46
-2
@@ -13,7 +13,14 @@
|
||||
//! - 全部走 `core::setting` 的 schema(`crate::settings_bridge`),AI 可写并且**热切**
|
||||
//! (tick 里 1s 一次 mtime 比对);系统主题由 `core::platform::theme_probe` 对齐 + 兜底轮询。
|
||||
//!
|
||||
//! 架构分层(本文件只有一个职责:把窗口拉起来):
|
||||
//! 一个二进制两种用法(M82):`unlucky-launcher` 不带子命令时起 GUI;带子命令
|
||||
//! (`instances` / `install` / `launch` / `accounts` / `sessions` / `logs` / `diag` /
|
||||
//! `config` / `java` / `agent`)时直接转给 `unlucky_cli::run`,**不初始化任何 UI**。
|
||||
//! 子命令表不在这里抄一份——唯一真相源是 `crates/cli` 的解析器(见
|
||||
//! `unlucky_cli::cli::looks_like_cli`)。
|
||||
//! 零 UI 依赖的 `ull` 仍然保留:它连 fontconfig 都不需要,给服务器 / CI / Termux 用。
|
||||
//!
|
||||
//! 架构分层(本文件只有一个职责:分发 + 把窗口拉起来):
|
||||
//! - `state.rs` / `prefs.rs` / `router.rs` / `dialogs.rs` / `toast.rs`:纯 Rust 状态(可单测,无 Slint)
|
||||
//! - `dto/`:跨边界 DTO 与事件/指令
|
||||
//! - `conv.rs`:状态 → Slint 视图模型
|
||||
@@ -31,6 +38,8 @@
|
||||
// 会把噪音藏进实现细节,这里统一放开并在 P1 接线时逐个转正(届时删掉本行即可暴露真死码)。
|
||||
#![allow(missing_docs, dead_code)]
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
mod appearance_bind;
|
||||
mod backend;
|
||||
mod bg_image;
|
||||
@@ -56,7 +65,22 @@ mod wizard;
|
||||
|
||||
slint::include_modules!();
|
||||
|
||||
fn main() -> Result<(), slint::PlatformError> {
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if let Some(code) = run_cli_if_requested(&args) {
|
||||
return code;
|
||||
}
|
||||
match run_gui() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
eprintln!("[run] {error}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 把窗口拉起来(分发之后的唯一路径)。
|
||||
fn run_gui() -> Result<(), slint::PlatformError> {
|
||||
apply_renderer_flag();
|
||||
|
||||
let app = ui_bind::AppUi::with_options(MainWindow::new()?, screenshot::app_options());
|
||||
@@ -73,6 +97,26 @@ fn main() -> Result<(), slint::PlatformError> {
|
||||
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 核心卖点)。
|
||||
fn apply_renderer_flag() {
|
||||
let software = std::env::args()
|
||||
|
||||
+19
-19
@@ -7,10 +7,10 @@
|
||||
#[must_use]
|
||||
pub const fn general() -> &'static str {
|
||||
"\
|
||||
ull · Unlucky-Launcher 命令行(D18)
|
||||
%prog% · Unlucky-Launcher 命令行(D18)
|
||||
|
||||
用法:
|
||||
ull [全局选项] <命令> [参数]
|
||||
%prog% [全局选项] <命令> [参数]
|
||||
|
||||
命令:
|
||||
instances list 列出实例与已安装版本
|
||||
@@ -55,14 +55,14 @@ pub fn topic(name: &str) -> Option<&'static str> {
|
||||
match name {
|
||||
"instances" => Some(
|
||||
"\
|
||||
ull instances list
|
||||
%prog% instances list
|
||||
|
||||
列出 profiles.json 里的实例与 versions/ 下已安装的版本。
|
||||
--json 输出 {\"data\":{\"instances\":[…],\"installed_versions\":[…],\"data_root\":…}}",
|
||||
),
|
||||
"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/)→ 登记实例
|
||||
(profiles.json + config/instances/<id>.json)。已存在且校验通过的文件会跳过,
|
||||
@@ -81,7 +81,7 @@ versions/<原版>-<加载器>-<版本>/<…>.json(inheritsFrom 原版 + 主类
|
||||
),
|
||||
"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>]
|
||||
|
||||
<instance> 可以是实例 id、实例名,或直接是版本 id。
|
||||
@@ -92,10 +92,10 @@ accessToken 一律脱敏(输出里出现的是 <redacted:token>)。",
|
||||
),
|
||||
"accounts" => Some(
|
||||
"\
|
||||
ull accounts list
|
||||
ull accounts add offline <name>
|
||||
ull accounts add msa
|
||||
ull accounts remove <name>
|
||||
%prog% accounts list
|
||||
%prog% accounts add offline <name>
|
||||
%prog% accounts add msa
|
||||
%prog% accounts remove <name>
|
||||
|
||||
账户存在 <数据根>/.ULL/data/auth/:accounts.json(可公开元数据)与 credentials.dat(凭据)
|
||||
**分文件**存放,先写凭据后写元数据、原子写、凭据文件 POSIX 侧 0600。
|
||||
@@ -104,25 +104,25 @@ add msa 走微软 device-code(用户码打在 stderr,去 https://www.microso
|
||||
需要先配置 ULL_MSA_CLIENT_ID(Azure 公共客户端 id)。
|
||||
authlib-injector 账户的密码登录留给 GUI(密码进 argv 会泄露,CLI 不开这条通道)。",
|
||||
),
|
||||
"sessions" => Some("ull sessions list [--limit N] 列出会话索引(按开始时间倒序,缺省 20 条)"),
|
||||
"logs" => Some("ull logs <sid> [--tail N] <sid> 允许唯一前缀;输出会话 JSONL 末尾 N 条事件(缺省 200)"),
|
||||
"sessions" => Some("%prog% sessions list [--limit N] 列出会话索引(按开始时间倒序,缺省 20 条)"),
|
||||
"logs" => Some("%prog% logs <sid> [--tail N] <sid> 允许唯一前缀;输出会话 JSONL 末尾 N 条事件(缺省 200)"),
|
||||
"diag" => Some(
|
||||
"ull diag <sid> [--tail N] 把会话的退出信号与日志喂给规则引擎(内嵌规则集),输出结构化诊断",
|
||||
"%prog% diag <sid> [--tail N] 把会话的退出信号与日志喂给规则引擎(内嵌规则集),输出结构化诊断",
|
||||
),
|
||||
"config" => Some(
|
||||
"\
|
||||
ull config get [<key>] [--show-secret]
|
||||
ull config set <key> <value>
|
||||
%prog% config get [<key>] [--show-secret]
|
||||
%prog% config set <key> <value>
|
||||
|
||||
读写 <数据根>/.ULL/config/launcher.json,走 core::setting 的 schema(类型/枚举校验、
|
||||
原子写)。敏感键 get 时脱敏,--show-secret 才给明文;值先按 JSON 解析,失败按字符串。",
|
||||
),
|
||||
"java" => Some(
|
||||
"\
|
||||
ull java list 探测 JAVA_HOME / PATH / 托管目录 / 实例覆盖里的 Java
|
||||
%prog% java list 探测 JAVA_HOME / PATH / 托管目录 / 实例覆盖里的 Java
|
||||
并打印每个 JDK 的实际落点(managed 项带目录)
|
||||
ull java install <major> [--vendor X] 安装托管 JDK(D13/D23):回退链查版本 → 下载 → 解压 → 验证
|
||||
ull java vendors 列出四家发行版的镜像/支持版本/当前优先级顺序
|
||||
%prog% java install <major> [--vendor X] 安装托管 JDK(D13/D23):回退链查版本 → 下载 → 解压 → 验证
|
||||
%prog% java vendors 列出四家发行版的镜像/支持版本/当前优先级顺序
|
||||
|
||||
落点(D23 三模式,随 core.data_mode 与设置键 java.installRoot 走):
|
||||
portable(默认) <数据根>/.ULL/data/java-managed/jdk-<major>-<vendor>/
|
||||
@@ -137,8 +137,8 @@ ull java vendors 列出四家发行版的镜像/支持版
|
||||
),
|
||||
"agent" => Some(
|
||||
"\
|
||||
ull agent serve [--stdio] [--port N] [--host H] [--allow-write|--readonly]
|
||||
ull agent config [--port N]
|
||||
%prog% agent serve [--stdio] [--port N] [--host H] [--allow-write|--readonly]
|
||||
%prog% agent config [--port N]
|
||||
|
||||
把 agent 的工具注册表挂成 **MCP server**(02 文档 §1 的第二形态:同一注册表、同一安全策略、
|
||||
同一审计)。默认走 Streamable HTTP,只绑回环地址(127.0.0.1,默认端口 47823);
|
||||
|
||||
@@ -9,9 +9,101 @@
|
||||
pub mod help;
|
||||
pub mod model;
|
||||
pub mod parse;
|
||||
pub mod program;
|
||||
|
||||
pub use model::{
|
||||
AccountsAddArgs, AccountsAddKind, AgentServeArgs, Command, ConfigGetArgs, ConfigSetArgs,
|
||||
DiagArgs, Globals, InstallArgs, Invocation, LaunchArgs, LogsArgs,
|
||||
};
|
||||
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"]));
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,8 @@ fn parse_command(rest: &[String], globals: &Globals) -> Result<Command, CliError
|
||||
"accounts" => accounts::parse_accounts(tail),
|
||||
"agent" => agent::parse_agent(tail),
|
||||
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> {
|
||||
let Some(sub) = tail.first().map(String::as_str) else {
|
||||
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();
|
||||
match sub {
|
||||
@@ -212,7 +213,7 @@ fn parse_config(tail: &[String]) -> Result<Command, CliError> {
|
||||
let parsed = parse_flags(rest, &[], &[])?;
|
||||
if parsed.positionals.len() != 2 {
|
||||
return Err(CliError::usage(
|
||||
"config set 需要键路径与值:ull config set <key> <value>",
|
||||
"config set 需要键路径与值:%prog% config set <key> <value>",
|
||||
));
|
||||
}
|
||||
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> {
|
||||
match parsed.positionals.as_slice() {
|
||||
[only] => Ok(only.clone()),
|
||||
[] => Err(CliError::usage(format!("缺少参数:ull {usage}"))),
|
||||
_ => Err(CliError::usage(format!("参数过多:ull {usage}"))),
|
||||
[] => Err(CliError::usage(format!("缺少参数:%prog% {usage}"))),
|
||||
_ => Err(CliError::usage(format!("参数过多:%prog% {usage}"))),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ fn parse_add(rest: &[String]) -> Result<Command, CliError> {
|
||||
let Some(kind) = rest.first().map(String::as_str) else {
|
||||
return Err(
|
||||
CliError::usage("accounts add 需要轨道:offline|msa").with_hint(
|
||||
"ull accounts add offline <玩家名> / ull accounts add msa(设备码交互式登录)",
|
||||
"%prog% accounts add offline <玩家名> / %prog% accounts add msa(设备码交互式登录)",
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ use super::{ensure_no_positionals, parse_flags};
|
||||
pub fn parse_agent(tail: &[String]) -> Result<Command, CliError> {
|
||||
let Some(sub) = tail.first().map(String::as_str) else {
|
||||
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();
|
||||
match sub {
|
||||
|
||||
@@ -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} 被改了");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,8 @@ pub fn list(ctx: &Ctx) -> Result<Report, CliError> {
|
||||
let mut human = Vec::new();
|
||||
if store.is_empty() {
|
||||
human.push("还没有账户。".to_string());
|
||||
human.push(" 离线:ull accounts add offline <玩家名>".to_string());
|
||||
human.push(" 微软:ull accounts add msa(设备码交互式登录)".to_string());
|
||||
human.push(" 离线:%prog% accounts add offline <玩家名>".to_string());
|
||||
human.push(" 微软:%prog% accounts add msa(设备码交互式登录)".to_string());
|
||||
} else {
|
||||
human.push(format!("账户({} 个):", store.accounts().len()));
|
||||
for meta in store.accounts() {
|
||||
|
||||
@@ -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.extend(pretty_lines(&samples.cursor));
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ fn get_one(ctx: &Ctx, key: &str, show_secret: bool) -> Result<Report, CliError>
|
||||
let Some(value) = store.effective(key) else {
|
||||
return Err(
|
||||
CliError::user("unknown_key", format!("配置键 `{key}` 不在 schema 里"))
|
||||
.with_hint("`ull config get`(不带键)可以看到全部生效键"),
|
||||
.with_hint("`%prog% config get`(不带键)可以看到全部生效键"),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ pub fn resolve(
|
||||
format!("有 {} 个账户,需要指定用哪个", many.len()),
|
||||
)
|
||||
.with_hint(format!(
|
||||
"ull launch <instance> --account <名字>;候选:{}",
|
||||
"%prog% launch <instance> --account <名字>;候选:{}",
|
||||
names_of(&store)
|
||||
)))
|
||||
}
|
||||
@@ -75,7 +75,7 @@ pub fn resolve(
|
||||
"已选中账户 {},`--username` 只用于没有账户时的离线占位",
|
||||
meta.display_name()
|
||||
))
|
||||
.with_hint("去掉 --username,或用 `ull accounts add offline <名字>` 建账户"));
|
||||
.with_hint("去掉 --username,或用 `%prog% accounts add offline <名字>` 建账户"));
|
||||
}
|
||||
|
||||
let mut notes = Vec::new();
|
||||
@@ -103,7 +103,7 @@ fn offline_fallback(username: Option<&str>) -> Identity {
|
||||
},
|
||||
account: None,
|
||||
notes: vec![
|
||||
"没有账户,按离线占位启动(ull accounts add offline <名字> 可建账户)".to_string(),
|
||||
"没有账户,按离线占位启动(%prog% accounts add offline <名字> 可建账户)".to_string(),
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -120,7 +120,7 @@ fn microsoft_identity(
|
||||
"account_credentials",
|
||||
format!("账户 {} 的微软凭据缺失或类型不符", meta.display_name()),
|
||||
)
|
||||
.with_hint("重新登录:ull accounts add msa"));
|
||||
.with_hint("重新登录:%prog% accounts add msa"));
|
||||
};
|
||||
|
||||
if dry_run {
|
||||
@@ -193,7 +193,7 @@ fn names_of(store: &AccountStore) -> String {
|
||||
|
||||
fn unknown_account(key: &str, store: &AccountStore) -> CliError {
|
||||
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 {
|
||||
format!("可用账户:{}", names_of(store))
|
||||
};
|
||||
|
||||
@@ -44,7 +44,7 @@ pub(super) fn run(ctx: &Ctx, args: &InstallArgs, kind: LoaderKind) -> Result<Rep
|
||||
let version = args.version.trim().to_string();
|
||||
if version.is_empty() {
|
||||
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!(
|
||||
"`ull java install {}` 装一个托管 JDK,或把 JAVA_HOME 指向可用的 JDK(托管目录 {})",
|
||||
"`%prog% java install {}` 装一个托管 JDK,或把 JAVA_HOME 指向可用的 JDK(托管目录 {})",
|
||||
plan.java_requirement.minimum,
|
||||
managed.display()
|
||||
)));
|
||||
|
||||
@@ -113,7 +113,7 @@ pub(super) fn ensure_name_free(ctx: &Ctx, name: &str, version: &str) -> Result<(
|
||||
"instance_exists",
|
||||
format!("同名实例已存在:{name}(绑定版本 {})", profile.version_id),
|
||||
)
|
||||
.with_hint("用 --name 换个名字,或 `ull instances list` 看看现有实例")),
|
||||
.with_hint("用 --name 换个名字,或 `%prog% instances list` 看看现有实例")),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
@@ -145,7 +145,7 @@ pub(super) fn failure_error(report: &InstallReport, ctx: &Ctx) -> CliError {
|
||||
}
|
||||
}
|
||||
CliError::user(code, message).with_hint(format!(
|
||||
"重跑 `ull install` 会续传并跳过已就绪文件;游戏根 {}",
|
||||
"重跑 `%prog% install` 会续传并跳过已就绪文件;游戏根 {}",
|
||||
ctx.game().root().display()
|
||||
))
|
||||
}
|
||||
@@ -37,7 +37,7 @@ pub(super) fn run(ctx: &Ctx, args: &InstallArgs) -> Result<Report, CliError> {
|
||||
let version = args.version.trim().to_string();
|
||||
if version.is_empty() {
|
||||
return Err(CliError::usage(
|
||||
"install 需要版本 id:ull install <version>",
|
||||
"install 需要版本 id:%prog% install <version>",
|
||||
));
|
||||
}
|
||||
let name = args
|
||||
@@ -139,7 +139,7 @@ pub(super) fn plan_and_install(
|
||||
format!("版本 {version} 装不全:{reasons}"),
|
||||
)
|
||||
.with_hint(
|
||||
"旧版(pre-1.6)清单兜底待补;可先用官方启动器装好该版本再用 `ull launch`",
|
||||
"旧版(pre-1.6)清单兜底待补;可先用官方启动器装好该版本再用 `%prog% launch`",
|
||||
));
|
||||
}
|
||||
let sink = report::progress_sink(progress, json_progress, plan.file_count());
|
||||
|
||||
@@ -59,7 +59,7 @@ pub fn list(ctx: &Ctx) -> Result<Report, CliError> {
|
||||
ctx.data.launcher_home.display()
|
||||
)];
|
||||
if body.is_empty() {
|
||||
human.push(" (空)用 `ull install <version>` 登记第一个实例".to_string());
|
||||
human.push(" (空)用 `%prog% install <version>` 登记第一个实例".to_string());
|
||||
}
|
||||
human.extend(body);
|
||||
if installed.is_empty() {
|
||||
|
||||
@@ -111,7 +111,7 @@ pub fn list(ctx: &Ctx) -> Result<Report, CliError> {
|
||||
let mut human = vec![format!("Java {} 个", candidates.len())];
|
||||
if candidates.is_empty() {
|
||||
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(),
|
||||
);
|
||||
}
|
||||
@@ -209,7 +209,7 @@ pub fn install(ctx: &Ctx, major: u32, vendor: Option<JdkVendor>) -> Result<Repor
|
||||
for note in installed.chain.degradations() {
|
||||
human.push(format!("降级:{note}"));
|
||||
}
|
||||
human.push("`ull launch` 会自动优先用它".to_string());
|
||||
human.push("`%prog% launch` 会自动优先用它".to_string());
|
||||
Ok(Report::new(data, human))
|
||||
}
|
||||
|
||||
|
||||
@@ -330,7 +330,7 @@ fn map_launch_error(error: &LaunchError, ctx: &Ctx) -> CliError {
|
||||
|
||||
fn instance_hint(profiles: &ProfileList) -> String {
|
||||
if profiles.profiles.is_empty() {
|
||||
"用 `ull instances list` 查看实例,或 `ull install <version>` 登记一个".to_string()
|
||||
"用 `%prog% instances list` 查看实例,或 `%prog% install <version>` 登记一个".to_string()
|
||||
} else {
|
||||
let names: Vec<&str> = profiles
|
||||
.profiles
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::cli::help;
|
||||
use crate::cli::{help, program};
|
||||
use crate::os;
|
||||
use crate::output::Report;
|
||||
|
||||
@@ -12,18 +12,20 @@ pub const LAUNCHER_NAME: &str = "UnluckyLauncher";
|
||||
/// 版本 + 系统摘要(`ull --version`,D19 §5-2 要求附带 os 摘要)。
|
||||
///
|
||||
/// 探测走 `core::platform::os_info::current()`(进程内缓存),未达基线时附带升级指引。
|
||||
/// 自称的名字取 argv[0](M82):GUI 二进制调进来时是 `unlucky-launcher`,`ull` 自己跑时仍是 `ull`。
|
||||
#[must_use]
|
||||
pub fn version() -> Report {
|
||||
let info = os::detect();
|
||||
let invoked_as = program::name();
|
||||
let data = json!({
|
||||
"name": "ull",
|
||||
"name": invoked_as,
|
||||
"package": env!("CARGO_PKG_NAME"),
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"os": os::to_json(&info),
|
||||
});
|
||||
let mut human = vec![
|
||||
format!(
|
||||
"ull {}({})",
|
||||
"{invoked_as} {}({})",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
env!("CARGO_PKG_NAME")
|
||||
),
|
||||
@@ -38,20 +40,29 @@ pub fn version() -> Report {
|
||||
pub fn welcome() -> Report {
|
||||
let version_report = version();
|
||||
let mut human = version_report.human;
|
||||
let text = general_help();
|
||||
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;
|
||||
data["help"] = json!(help::general());
|
||||
data["help"] = json!(text);
|
||||
Report::new(data, human)
|
||||
}
|
||||
|
||||
/// 帮助(`ull help <命令>` 给该命令,未知主题退回总帮助)。
|
||||
#[must_use]
|
||||
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!({
|
||||
"topic": topic,
|
||||
"text": text,
|
||||
});
|
||||
Report::new(data, text.lines().map(str::to_string).collect())
|
||||
}
|
||||
|
||||
/// 总帮助,按被调用的二进制名自述(`ull` 自己跑时逐字不变)。
|
||||
fn general_help() -> String {
|
||||
program::fill(help::general())
|
||||
}
|
||||
@@ -75,14 +75,14 @@ pub(crate) fn map_auth_error(error: unlucky_core::auth::AuthError) -> CliError {
|
||||
let hint = match &error {
|
||||
AuthError::Canceled => Some("登录被取消,可以重跑一次".to_string()),
|
||||
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(_) => {
|
||||
Some("微软登录需要设置 ULL_MSA_CLIENT_ID(Azure 公共客户端 id)".to_string())
|
||||
}
|
||||
AuthError::Conflict { .. } => Some("用 `ull accounts list` 查看已有账户".to_string()),
|
||||
AuthError::NotFound { .. } => Some("用 `ull accounts list` 查看账户名".to_string()),
|
||||
AuthError::Conflict { .. } => Some("用 `%prog% accounts list` 查看已有账户".to_string()),
|
||||
AuthError::NotFound { .. } => Some("用 `%prog% accounts list` 查看账户名".to_string()),
|
||||
AuthError::Denied { code, .. } if code == "no_profile" || code == "no_license" => {
|
||||
Some("去 minecraft.net 检查该账号的 Java 版拥有权与档案".to_string())
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ pub fn list(ctx: &Ctx, limit: usize) -> Result<Report, CliError> {
|
||||
index.display()
|
||||
)];
|
||||
if shown.is_empty() {
|
||||
human.push(" (空)先用 `ull launch <实例>` 跑一局".to_string());
|
||||
human.push(" (空)先用 `%prog% launch <实例>` 跑一局".to_string());
|
||||
}
|
||||
for entry in &shown {
|
||||
human.push(format!(
|
||||
|
||||
+12
-4
@@ -56,6 +56,14 @@ pub struct CliError {
|
||||
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 {
|
||||
/// 用法错误(参数不对)。
|
||||
#[must_use]
|
||||
@@ -63,7 +71,7 @@ impl CliError {
|
||||
Self {
|
||||
kind: ErrorKind::User,
|
||||
code: "usage",
|
||||
message: message.into(),
|
||||
message: render(message),
|
||||
hint: None,
|
||||
}
|
||||
}
|
||||
@@ -74,7 +82,7 @@ impl CliError {
|
||||
Self {
|
||||
kind: ErrorKind::User,
|
||||
code,
|
||||
message: message.into(),
|
||||
message: render(message),
|
||||
hint: None,
|
||||
}
|
||||
}
|
||||
@@ -85,7 +93,7 @@ impl CliError {
|
||||
Self {
|
||||
kind: ErrorKind::Internal,
|
||||
code,
|
||||
message: message.into(),
|
||||
message: render(message),
|
||||
hint: None,
|
||||
}
|
||||
}
|
||||
@@ -99,7 +107,7 @@ impl CliError {
|
||||
/// 补一句提示。
|
||||
#[must_use]
|
||||
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
|
||||
self.hint = Some(hint.into());
|
||||
self.hint = Some(render(hint));
|
||||
self
|
||||
}
|
||||
|
||||
|
||||
@@ -20,10 +20,13 @@ pub struct Report {
|
||||
}
|
||||
|
||||
impl Report {
|
||||
/// 构造。
|
||||
/// 构造(人类可读行在这里就把程序名占位符渲染掉,见 [`crate::cli::program::PLACEHOLDER`])。
|
||||
#[must_use]
|
||||
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]
|
||||
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(())
|
||||
}
|
||||
|
||||
/// 渲染文案里的程序名占位符(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`])。
|
||||
///
|
||||
/// 命令的正经输出仍然全部走 [`Report`](stdout、可断言的形状);下载/安装这类分钟级任务
|
||||
@@ -125,7 +143,10 @@ pub fn progress_line(text: &str) {
|
||||
|
||||
#[cfg(test)]
|
||||
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]
|
||||
fn progress_line_never_panics() {
|
||||
@@ -133,4 +154,46 @@ mod tests {
|
||||
progress_line("测试进度 1/2");
|
||||
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`");
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,13 @@ fn version_json_has_os_summary() {
|
||||
assert_eq!(code, 0);
|
||||
let value = parse_json(&stdout);
|
||||
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"));
|
||||
// D19/D21:os 对象来自 core::platform::os_info(family + 两档 support)。
|
||||
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() {
|
||||
let (code, stdout, _) = run_cli(&[]);
|
||||
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}");
|
||||
|
||||
let (code, stdout, _) = run_cli(&["--json"]);
|
||||
|
||||
+32
-8
@@ -14,6 +14,7 @@
|
||||
| `linux-arm64` | `unlucky-launcher-<ver>-linux-arm64.tar.gz` | 单二进制归档(原生 ARM64 runner 构建) | 同上 |
|
||||
| `macos-arm64` | `unlucky-launcher-<ver>-macos-arm64.tar.gz` | 单二进制归档 | 同上 |
|
||||
| `macos-x64` | `unlucky-launcher-<ver>-macos-x64.tar.gz` | 单二进制归档 | 同上 |
|
||||
| 每槽位 | `ull-<ver>-<槽位>[.exe]` | **裸 CLI 文件**(独立资产,不进 tar.gz) | 零 UI 依赖的 `ull` 本体,进同一份 `SHA256SUMS.txt` |
|
||||
| 全平台 | `SHA256SUMS.txt` | 校验清单 | 每行 `<sha256>␠␠<文件名>`(`sha256sum -c` 可直吃) |
|
||||
|
||||
约定细节:
|
||||
@@ -22,6 +23,10 @@
|
||||
归档里额外放三份合规文本是许可义务要求,不是运行时依赖;Windows 因为"一个 exe 装不下文本",
|
||||
把 `LICENSE` / `NOTICE` / `README.md` 作为 Release 的并列资产分发,条款同样随下载走。
|
||||
- 归档解压后是**一个顶层目录** `unlucky-launcher-<ver>-<槽位>/`,用户不会解压出一堆散文件。
|
||||
- **`ull` 是并列的裸资产,不塞进 tar.gz**:headless 用户要的就是「下一个小文件直接跑」;归档里放
|
||||
LICENSE/NOTICE/README.md 是许可义务,而这三份本来就作为并列 Release 资产分发,裸 `ull` 不缺条款。
|
||||
- `unlucky-launcher` 本体也认全部 CLI 子命令(M82),`ull` 不是第二个界面,而是**不链接图形栈**的
|
||||
同一套命令(差异见 §4 与 §7.4)——两者都不是重复品。
|
||||
- `<ver>` 不带前导 `v`(tag 是 `v0.1.0`,文件名是 `0.1.0`)——由脚本统一裁剪。
|
||||
- UNIX 归档内 `mtime`/`uid`/`gid` 归零、权限固定(二进制 0755、文本 0644),
|
||||
同源构建的 sha256 不受打包时刻影响。
|
||||
@@ -148,6 +153,11 @@ Windows ARM64 原生库(Mojang/LWJGL 只发 x64/x86),实际游玩仍走系
|
||||
Debian/Ubuntu 一行:`sudo apt install libxkbcommon0 fontconfig libgl1 libegl1 libxcb1 libx11-6 libwayland-client0`。
|
||||
运行期**不需要** `-dev` 包、不需要 Java(Java 是启动游戏时才由启动器自己管理的东西)。
|
||||
|
||||
上表是**开窗**要用的库,不是进程启动时就要的:实测(§7.4)除 `libfontconfig.so.1` 外它们**都不在** ELF
|
||||
的 `DT_NEEDED` 里,由 `dlopen` 在创建窗口那一刻才加载。所以同一台没有 X11 / Wayland / GL 的机器上
|
||||
`unlucky-launcher instances list` 照样跑得起来(GUI 二进制先分发后开窗),而 `ull` 连 fontconfig 都不需要。
|
||||
arm64 侧是同一套(同一份依赖树、同一个 winit/femtovg 组合)。
|
||||
|
||||
### Linux arm64(aarch64)
|
||||
|
||||
依赖与 x64 完全同一套(`libxkbcommon` / `fontconfig` / `libGL` / `libxcb` / `libwayland-client`),
|
||||
@@ -233,7 +243,7 @@ LTO 也就只有一个线程可跑(`fat` 本身只管"跨 crate 全量参与"
|
||||
|------|---------|
|
||||
| `ull.exe --version` | 输出 `ull 0.1.0(unlucky-cli)` + `os: Windows 11 · 10.0.26100(x86_64)· 满足最低要求`,exit 0 —— 版本与 workspace `0.1.0` 一致 |
|
||||
| `unlucky-launcher.exe --software-renderer --screenshot … --data-dir …` | exit 0,产出 1180×760 PNG(3,588,298 B);看图确认 Metro 实例页(侧栏/搜索/空态/AI 助手面板)正常渲染 → **软渲染路径真的能出图**,不依赖 GL |
|
||||
| `unlucky-launcher.exe --version` | **无输出**,8 秒后被 timeout 杀掉(exit 124)——GUI 目前不解析 `--version`,会直接开窗口。已作为 finding 上报,见 §10-1 |
|
||||
| `unlucky-launcher.exe --version` | **无输出**,8 秒后被 timeout 杀掉(exit 124)——GUI 当时不解析 `--version`,会直接开窗口。M82 后已修(走 CLI 分发,见 §10-1)|
|
||||
|
||||
### 7.3 打包验证(本机 Windows 槽位真跑)
|
||||
|
||||
@@ -254,7 +264,7 @@ reviewer-packaging 在 M46 review 里连续调 `stage_archive()` 两次 → **
|
||||
`00000000`,归档内单顶层目录且含 `LICENSE`/`NOTICE`/`README.md`。
|
||||
即"同源构建 → 同 sha256"在 tar 路径上成立,不随打包时刻漂移(见 §1 的约定细节)。
|
||||
|
||||
### 7.4 运行时依赖证据(PE 导入表)
|
||||
### 7.4 运行时依赖证据(PE 导入表 / ELF `DT_NEEDED`)
|
||||
|
||||
`objdump -p target/release/unlucky-launcher.exe` 共 42 条 DLL 导入记录、33 个不同名字(大小写各记一次),全部是 Windows 自带:
|
||||
`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 首跑验证。
|
||||
|
||||
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 二进制)
|
||||
|
||||
本机没有 ARM 机器,也不具备 MSVC ARM64 链接库组合,所以只做**能真实证明的东西**(`python` 直接加载
|
||||
@@ -322,16 +344,15 @@ experimental / not part of this release matrix)——改一边必须同步另
|
||||
|
||||
## 10. 已知边界 / 后续
|
||||
|
||||
1. **GUI 无 `--version`**:`unlucky-launcher.exe` 目前只解析 `--software-renderer` / `--screenshot*` /
|
||||
`--data-dir` / `--mock`,`--version` 会当成普通参数忽略并直接开窗口。版本自证目前靠:
|
||||
①产物名里的 `<ver>` ②`ull --version`(同 workspace 版本)③tag 交叉校验。
|
||||
加一个 `--version` 分支属于 `crates/app` 范围,已作为 finding 上报,不在本 mission 内改。
|
||||
1. **GUI 的 `--version` 已解决**(M82):`unlucky-launcher --version` 现在命中子命令分发,输出与
|
||||
`ull --version` 同一条版本 + 系统摘要后退出,不再开窗。§7.2 里「无输出、8 秒后被 timeout 杀掉」那行
|
||||
是**改之前**的实测记录,留作对照。版本自证的另外两条(产物名里的 `<ver>`、tag 交叉校验)不变。
|
||||
2. **签名/公证**:Windows 无 Authenticode、macOS 无 codesign/notarize(都要证书/开发者账号),
|
||||
当前以 SHA256SUMS + Release 说明作为完整性参考。
|
||||
3. **macOS `.app` bundle / `.dmg`**:与单文件承诺冲突,暂不做;若用户要 Dock 图标再单开 mission。
|
||||
4. **Linux musl 静态版**:可彻底摆脱 glibc 地板,但 Slint 在 musl 上需额外验证,未纳入本矩阵。
|
||||
5. **`ull` CLI 未随包**:本次矩阵只发 GUI 单文件(用户核心卖点是启动器本体);headless 用户可从
|
||||
源码 `cargo build --release --bin ull`,或在后续把 `ull` 加进同一矩阵(脚本已按槽位参数化,改动很小)。
|
||||
5. **`ull` 已随包**(M82 起):每个槽位与 GUI 并列发一份裸 `ull`,进同一份 `SHA256SUMS.txt`,矩阵
|
||||
完整性检查按「GUI 与 CLI 各自都要有」分开查(`release.yml`)。等 CI 首跑确认这条在真流水线上有效。
|
||||
6. **本机 GNU 链接器的已知无害告警**:`cargo build --release`(GNU 工具链)会打一行
|
||||
`warning: linker stderr: corrupt .drectve at end of def file`,来自 MinGW `ld` 对 `.drectve` 段的
|
||||
老式抱怨,不影响产物(体积/导入表/冒烟均已实测正常);CI 的 MSVC 槽位不会出现这条。
|
||||
@@ -341,6 +362,9 @@ experimental / not part of this release matrix)——改一边必须同步另
|
||||
本机能做且已做的是脚本层取证(见 §7.5):宿主判定注入(`win32/ARM64`、`linux/aarch64` 等全槽位)、
|
||||
可执行文件头架构识别(真 x64 exe + 合成 PE/ELF/Mach-O 头)、以及把真 exe 的 PE 机器码改成 ARM64
|
||||
当 stub 走完命名/校验/归档全流程 —— 不假装能在 x64 上执行 arm64 二进制。
|
||||
8. **M82 的体积/首帧没重量**:改的时候本机三个 worker 同时在编(全量 release profile 要 400+ crate),
|
||||
这两个数留给空闲窗口补。结构性判断(**不是实测**):分发发生在 `MainWindow::new()` 与后端探测之前,
|
||||
GUI 路径只多一次 argv 解析,所以首帧成本没有增加的理由;CLI 路径的耗时则天然与首帧无关。
|
||||
|
||||
## 11. CI 运行时冒烟(Linux 真渲染一帧)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ Release matrix (see `packaging/README.md` for the full contract):
|
||||
Linux arm64 unlucky-launcher-<ver>-linux-arm64.tar.gz
|
||||
macOS arm64 unlucky-launcher-<ver>-macos-arm64.tar.gz
|
||||
macOS x64 unlucky-launcher-<ver>-macos-x64.tar.gz
|
||||
every slot ull-<ver>-<slot>[.exe] (bare CLI binary, no archive)
|
||||
all SHA256SUMS.txt
|
||||
|
||||
`--version` is taken from the cargo workspace so a tag can never drift away from
|
||||
@@ -17,6 +18,15 @@ The archives carry the launcher binary plus LICENSE / NOTICE / README.md, which
|
||||
what the license obligations ask for; Windows ships the bare `.exe` (the single-file
|
||||
selling point) and the same three documents are attached to the Release next to it.
|
||||
|
||||
The CLI ships as its **own bare asset** on every platform rather than inside the tar.gz:
|
||||
the point of `ull` is a small single file you download and run on a machine with no
|
||||
display, and an archive it has to be extracted from adds a step for that user. The
|
||||
launcher is the archive because it needs the license texts beside it; the Release
|
||||
carries LICENSE / NOTICE / README.md anyway, so a bare `ull` loses nothing.
|
||||
|
||||
The launcher binary also answers every CLI subcommand, so `ull` is not a second
|
||||
interface — it is the version of it that links no graphics stack at all.
|
||||
|
||||
Slot and build directory are overridable because a cross build's binaries live in
|
||||
`target/<triple>/release` and the build host (x64) is not the artifact (arm64): CI
|
||||
passes both from its matrix, and the executable header is read back so a binary can
|
||||
@@ -189,6 +199,16 @@ def write_sums(out_dir: Path, assets: list[Path]) -> Path:
|
||||
return sums
|
||||
|
||||
|
||||
def stage_cli(out_dir: Path, version: str, slot: str, cli: Path) -> Path:
|
||||
"""Copy the CLI out as its own bare asset (`ull-<ver>-<slot>[.exe]`)."""
|
||||
name = f"{CLI_BIN}-{version}-{slot}" + (".exe" if cli.suffix == ".exe" else "")
|
||||
asset = out_dir / name
|
||||
shutil.copy2(cli, asset)
|
||||
# 裸文件下载即可运行:Windows 宿主机上源文件没有 x 位,这里补上(UNIX 侧才有意义)。
|
||||
asset.chmod(0o755)
|
||||
return asset
|
||||
|
||||
|
||||
def stage_archive(out_dir: Path, tag: str, version: str, launcher: Path) -> Path:
|
||||
"""tar.gz with one top-level dir holding the binary + compliance docs."""
|
||||
stem = f"unlucky-launcher-{version}-{tag}"
|
||||
@@ -259,10 +279,15 @@ def main() -> int:
|
||||
shutil.copy2(launcher, asset)
|
||||
else:
|
||||
asset = stage_archive(out_dir, slot, version, launcher)
|
||||
# CLI 与 GUI 同一矩阵槽位、各自独立资产:架构同样回读文件头校验,不给 arm64 槽位混进 x64 的 ull。
|
||||
cli = binary(CLI_BIN, build_dir, family)
|
||||
verify_arch(cli, slot)
|
||||
cli_asset = stage_cli(out_dir, version, slot, cli)
|
||||
|
||||
sums = write_sums(out_dir, [asset])
|
||||
size_mb = asset.stat().st_size / (1024 * 1024)
|
||||
print(f"artifact: {asset.name} {size_mb:.2f} MiB sha256={sha256(asset)}")
|
||||
sums = write_sums(out_dir, [asset, cli_asset])
|
||||
for item in (asset, cli_asset):
|
||||
size_mb = item.stat().st_size / (1024 * 1024)
|
||||
print(f"artifact: {item.name} {size_mb:.2f} MiB sha256={sha256(item)}")
|
||||
print(f"sums: {sums.name}")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
| 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, Wayland | supported | the above plus `wayland-client` |
|
||||
| Windows 8.1/8/7 | unsupported | baseline was raised to Win10 (decision D21) |
|
||||
| Android — Termux + X11 | experimental, not promised | CLI only (build from source), GUI unverified; not part of this release matrix |
|
||||
| Android — Termux + X11 | experimental, not promised | CLI only (the `ull` asset, or build from source), GUI unverified; not part of this release matrix |
|
||||
|
||||
Both arm64 rows are **formally released slots** (the assets ship in this release and in
|
||||
`SHA256SUMS.txt`), not the experimental Termux path: Linux arm64 is built and smoke-tested on a
|
||||
|
||||
Reference in new issue
Block a user