66 Commits
Author SHA1 Message Date
AskaEth 352dd7937e Merge branch 'feat/p4-core-platform-wallpaper-source-detect' 2026-11-01 21:18:02 +08:00
AskaEth 69d0025d09 fix(core): M73 r1 —— Windows 壁纸来源改为「当前主题优先」,通配主题包降为末档
复审 p2-1(medium):Win10+ 上 `%APPDATA%\Microsoft\Windows\Themes\` 通常只有
`TranscodedWallpaper`,一个 `.theme` 都没有;当前生效的主题记在
`HKCU\Software\Microsoft\Windows\CurrentVersion\Themes` 的 `CurrentTheme` 值里,
指向 `%LOCALAPPDATA%\Microsoft\Windows\Themes\Custom.theme`。

- 新增第二步 `windows-current-theme`:`reg.exe query ... /v CurrentTheme` →
  归一化(含 `%LOCALAPPDATA%` 展开)→ 读该 `.theme` 的 `Wallpaper=`;
- `%APPDATA%\...\Themes\*.theme` 通配扫描从「第二步」降为第四步兜底,
  evidence 如实标注「用户另存的主题包,可能未启用」;
- 新顺序:TranscodedWallpaper → CurrentTheme → 注册表 WallPaper → 另存主题包;
- `HostRoots` 增加 `local_app_data`(`%LOCALAPPDATA%`,退 `~/AppData/Local`)。

复审 p2-2(low):`transcoded_wallpaper_hit_comes_first` 补一条字面量路径断言,
防止夹具与生产代码用同一构造函数一起拼错。

复审 p2-3(low):`HostIo::run` 现在检查退出码(`!output.status.success()` 也返回
`None`),与四处「非零退出 → 失败」的文案对齐;`io.rs` 补一条夹具用例
(有 stdout 但退出码非零)证明旧行为会被挡掉。

行长:`windows.rs` 371 → 拆出纯解析到 `wallpaper/windows/parse.rs`(100 行),
全部 7 个文件 ≤400 行。真机复跑 `detect()`:第 1 条 transcoded、第 2 条
`windows-current-theme` → `C:\Windows\web\wallpaper\Windows\img0.jpg`(与注册表同源,
去重后只剩一条),`%APPDATA%` 下无主题包的 note 照旧。
2026-11-01 21:17:55 +08:00
AskaEth d3e9a62b21 feat(core): M73 桌面壁纸路径探测(D38 第②级输入)
新增 platform::wallpaper:只做「桌面壁纸可能是哪个文件」的路径探测,
不解码图片、不取色(莫奈取色与三级降级留给 crates/design 的后续 mission)。

- 唯一入口 detect() / 注入式 detect_with(io, env, platform):返回有序候选列表
  + 各步失败原因,全部找不到时是空列表而不是错误;
- Windows 三步:TranscodedWallpaper(无扩展名,靠魔数认)→ *.theme 的
  Wallpaper= → reg.exe query HKCU\Control Panel\Desktop /v WallPaper;
- Linux 四步:gsettings picture-uri(-dark) → KDE Image= → hyprpaper/sway
  配置 → XFCE xfce4-desktop.xml,每步失败不影响下一步;
- macOS 两步:com.apple.wallpaper/Store/Index.plist 字符串扫描(不引 plist
  依赖,兼容 bplist00)→ defaults read com.apple.desktop Background;
  代码与文档明确标注「未在真机验证」;
- 零 unsafe / 零 FFI(core 是 #![forbid(unsafe_code)]),文件读取与子进程
  全部经 ProbeIo 注入,环境变量表经 EnvSource 注入;三平台采集逻辑不带 cfg,
  CI 的 Linux 上也能跑 Windows / macOS 夹具用例;
- 不新增第三方依赖;6 个文件均 ≤400 行,32 条单测覆盖三平台的
  正常命中 / 多候选 / 全部失败。
2026-11-01 21:17:52 +08:00
AskaEth 614bd253c8 Merge branch 'feat/docs-remove-affiliation-disclaimer-foote' 2026-11-01 20:26:14 +08:00
AskaEth 9e30d2cf15 docs(readme): 删掉文末「与 Mojang / Microsoft 无关联」免责脚注
中英两份都删,不补替代句;文件以感谢段落 + 单个换行结束。
2026-11-01 20:24:14 +08:00
AskaEth 6cfac9c823 Merge branch 'feat/docs-tone-pass-remove-defensive-wording' 2026-11-01 20:02:10 +08:00
AskaEth 4069204379 docs(readme): 复审收尾两处——ull 行补回分发状态,平台独立性改正面陈述
- bin 表 `ull` 行:State 列补回「未随 v0.1.0 资产分发」/ "not in the v0.1.0 assets",
  只看表格的人也读得到分发状态
- 平台基线末条:去掉「而非独立产物链」这半句对没人提的质疑的反驳,
  改为「每个平台跑各自的测试矩阵」/ "Each platform runs its own test matrix"
2026-11-01 20:00:41 +08:00
AskaEth 0cb1454067 docs(readme): 致谢改为平实感谢,去掉「欠人情」的债务叙事
原段落把作者写成亏欠方(「这个项目欠它们一份很大的人情」/ "exists very much in
their debt"),并把 HMCL / PCL2 称作 "old-timers"。两者都是 GPL-3.0 开源项目,
阅读与借鉴本就在许可之内,不需要愧疚来偿付。

- 债务词全部移除:欠 / 人情 / debt / owe / old-timers / enormous amount
- 保留感谢本身,改成「它们做得久 + 本项目从中受益」两句话
- 中文补一次断行以保持 ≤100 显示列
2026-11-01 19:55:55 +08:00
AskaEth aca280fd30 docs(readme): 删掉「合规声明」整节,账户信息归位到特性表
这是项目的 README,不是审核材料:把单独开列的合规节去掉,信息放回它该在的位置。

- 删除整节 `## Compliance statement` / `## 合规声明`(含 5 条 bullet)
- 「Microsoft 账户登录 + OAuth device code → XBL → XSTS → Minecraft Services + 令牌只存本机」
  并入特性表新增的「账户 / Accounts」一条(功能说明,不是专项保证)
- 「不分发游戏文件」「联机需账户与权益」两条删除(常识范围 / 与前一句重复)
- 「与 Mojang、Microsoft 无关联」降为文末一行普通脚注
- 平台基线:标准工具链那条去掉三重「无」,改为平铺陈述
- 平台基线:删掉与「构建 → 渲染开关」重复的渲染兜底条目(第三处),
  文件内不再出现「完整界面」/ "the full UI"
- bin 表:`ull` 状态列去掉与 Track A bullet 重复的「未随资产分发」,保留「同源同版本构建」
- UI 一条:「驱动真实引擎」→「由 core 引擎驱动」/ "drive the real engine" → "run on the core engine"
2026-11-01 19:54:50 +08:00
AskaEth 5a44870de5 docs(readme): 语气体检——去掉「正版登录」及同类防御腔与自我声明
- zh 模块表 auth 行:「Microsoft 正版登录」→「Microsoft 账户登录」,与英文 "Microsoft sign-in" 对齐
- zh/en:CLI 行去掉「完整可用」/ "is fully usable"(要读者相信的形容词)
- zh/en:CPU 软渲染兜底不再承诺「完整界面」/ "the full UI",改为陈述渲染结果
- zh/en:轨道 B 首句去掉「没有任何…任何下载都拿不到它」/ "Nothing…no download contains it" 的双重否认,
  并与紧邻的「只能自己从源码编」合并为一句
- zh:轨道 A 小标题去掉「真能」的强调语气
2026-11-01 19:51:46 +08:00
AskaEth a426cb5dcb Merge branch 'feat/docs-refresh-readme-for-iced-rewrite-and' 2026-11-01 19:49:27 +08:00
AskaEth 9d9ab3e7e0 docs(readme): 合规段第一条去掉防御腔 —— 平静陈述「用 Microsoft 账户登录」
- 英文:Microsoft sign-in only → Sign-in with your Microsoft account;删掉 There is no other
  account provider(过度否认本身是信号)
- 中文:只走 Microsoft 正版登录 → 用 Microsoft 账户登录;删掉「没有别家账户通道」,
  并避开「正版」这个词(中文圈里该词天然与「破解」对照,带防御姿态)
- 事实不变:device code → XBL → XSTS → api.minecraftservices.com,用玩家自己的账户与权益
- 第三条「联机游玩需要 Microsoft 账户与有效权益」保留独立一条(事实陈述,非否认)
2026-11-01 19:49:08 +08:00
AskaEth d0d24d42e7 docs(readme): 按用户令删除全部离线档案描述;门禁口径改为 main 推送 + PR
- 合规条里「Offline profiles cannot play online / 本地离线档案仅限本地与隔离网络测试」整条删除,
  只保留正面陈述「联机游玩需要 Microsoft 账户与有效的游戏权益」
- 模块地图 auth 行去掉「local offline profiles / 本地离线档案」,只留 Microsoft 正版登录全链路
- 全文 grep offline|离线|隔离网络 零命中;不做「不支持离线」的反向承诺,也不暗示别的账户通道
- 顺手修 nit:CI 段「Every push」→「Every push to main and every pull request」
  (ci.yml on.push 限 main 分支,非 main 且无 PR 时门禁不跑)
2026-11-01 19:47:40 +08:00
AskaEth a4bd4d2b38 docs(readme): r1 复审修复 —— 去掉 ull 随包分发的假话、macOS 探针降级为 advisory、离线档案正面陈述、合规段提到第一屏
- P1: ull 与 v0.1.0 同源同版本但发布资产只有 GUI 单文件(make_artifacts.py 只搬 launcher、
  release.yml assets 只 glob unlucky-launcher-*)→ 4 处改为「自行 cargo build --release --bin ull」
- P2-1: macOS 运行期探针 if workflow_dispatch + continue-on-error,属 advisory → 与 Linux 冒烟拆句
- P2-2: 离线档案去掉「不能连校验账户的服务器」这种可反读的限定,标题改「联机游玩必须使用 Microsoft 账户」
- P2-3: 合规声明挪到引言之后、特性 bullet 之前(第一屏可见),文字不变
- nit: 三份合规文本每个 Release 都挂(Windows 因 exe 装不下才并列);Slint 版无显式探测,改为两套栈
  各自回退到 CPU 的准确表述
2026-11-01 19:44:07 +08:00
AskaEth 2a0493c3eb docs(readme): 两份 README 改写为 iced/M3 Expressive 现状(双 bin 并存 + 两轨 Status)
- UI 描述:已发布 v0.1.0 = Slint + Metro;下一代 = iced 0.14 + Material 3 Expressive,dev 上施工中
- 双 bin 并存逐条讲清:unlucky-launcher(Slint,v0.1.0 已发布)/ unlucky-launcher-iced(施工中)
  / ull(CLI),并说明翻牌前不改名的原因(同 target 目录不能同名 bin)
- 模块地图补 crates/design(真 HCT 令牌、零 UI 依赖)与 crates/app-iced;crates/app 保留到等价验收
- Status 重写为两轨:Track A 已发布(含 v0.1.0 资产范围与 CLI 命令集,取自 release.yml 与 cli/help.rs)
  / Track B 施工中(已完成=令牌+骨架;未完成=组件库/页面/i18n,页面全是占位)
- 删除全部过期统计数字(983+ tests 等),改为行为性门禁表述;无测试数、无产物体积
- 合规段落只留 Microsoft 正版登录 / 凭据只存本机且 AI 不可访 / 离线不能联机 / 不分发不镜像不代理
  / 与 Mojang-Microsoft 无关联;无第三方账户服务字样
- 许可:Slint RF 2.0 归属保留并补 iced(MIT)、MCU(Apache-2.0)、其余主要依赖
- 致谢 HMCL / PCL2 改为口语化语气,不加粗、不撇清
- 开头保留 GitHub 官方主页行(英文自指、中文醒目指引 + Releases 链接),全文无 gitea 地址
- 平台基线补 macOS 未签名需清 Gatekeeper 隔离标记
2026-11-01 18:53:26 +08:00
AskaEth cf52751124 Merge branch 'feat/p4-design-cool-sky-blue-default-seed-can' 2026-11-01 18:13:28 +08:00
AskaEth 40864d93d0 fix(design): M70 r4 —— 偏离登记补齐「neutral-variant 8→14」+ 文档口径与 pin 可读性
复审 P2-1:neutral-variant 的那一半偏离漏登记,且文档把 nv 说成官方相对式。
- `color/mod.rs`:标题改「两处有意偏离(同一处偏离的两半)」;偏离登记把
  neutral 6→12 与 neutral-variant 8→14 分列,写明官方 `getNeutralVariantPalette`
  是**绝对值 8**(不是 neutral+2)、影响角色含 outline/outline-variant/surface-variant,
  以及「nv 钉回 8 会让表面(12)比中性变体(8)更有色」的倒挂理由
- `palettes.rs`:`NEUTRAL_VARIANT_OFFSET` 文档改述为「官方绝对值 8,本实现相对式=有意偏离」;
  `DEFAULT_NEUTRAL_CHROMA` 的矛盾区间改正为「数值安全区间 6..=13(14 才撞 secondary),产品取 12」;
  `from_seed_with_neutral_chroma` 加调用方约束(越界静默退化:<0/NaN → 彩度 0;≥14 → nv 撞 secondary);
  新增 `product_default_neutral_chroma_is_a_registered_deviation`(钉 neutral 12 / nv 14,官方锚点仍 6/8)
- nit1:`sky_seed_candidates_meet_wcag_aa` 的「三档」→「四档(208 项)」
- nit2:`product_default_is_the_adopted_candidate` 改成比 hex 文案,失败时可直读「期望 #3C8DBC、实测 #87CEEB(③)」
- nit4:`lib.rs` 的 `DesignTokens::baseline` 文档改准(产品默认 ④ 天蓝,官方口径走 `Scheme::official_baseline`)

无物理行为变更:算法、tone 表、gamut、所有色值一个没动。
2026-11-01 18:09:14 +08:00
AskaEth e37b452345 feat(design): M70 r2 —— 出厂默认定为 ④ 天蓝 #3C8DBC(用户 2026-11-01 拍板)
- `palettes::DEFAULT_SEED`:`#87CEEB`(③ 浅冷蓝)→ `#3C8DBC`(④ 天蓝,主色 `#236488`);
  `DEFAULT_NEUTRAL_CHROMA` 保持 12
- `tests/skyseed_candidates.rs`:`product_default_is_the_recommended_candidate` →
  `product_default_is_the_adopted_candidate`,绑定 `CANDIDATES[3]`(④);四档 pin 不变
- 文档:`color/mod.rs` / `palettes.rs` 的「临时默认 ③ / 待用户确认」全部换成
  「出厂默认 ④(用户 2026-11-01 拍板)」,③ 标注为备选(值仍 pin 住,可一行换回)
- 文档新增定位(D38):这两个常量只定义「**莫奈关闭时的出厂预选色卡**」(配色来源第三级),
  不再代表唯一配色来源;背景图/壁纸取色与内置色卡库都不在本 crate、尚未实现
2026-11-01 18:09:14 +08:00
AskaEth 9519a89472 feat(design): M70 r1 —— 补第 4 档「天蓝」候选 #3C8DBC(四档对比)
③ 的 primary `#0C6780` 偏青(teal),不是「天蓝」。新增 ④ 天蓝:种子 `#3C8DBC`
(CAM16 hue 239.5°,夹 ③ 与 ①② 之间)+ 中性彩度 12 → primary `#236488`。

- `tests/skyseed_candidates.rs`:候选表 3 → 4 档,④ 的 9 角色 × light/dark 全部进 pin;
  `the_three_tiers_are_actually_distinguishable` 改为 `the_candidates_are_actually_distinguishable`,
  增加「③ 与 ④ 中性彩度相同、主色/容器必须靠色相区分」的断言
- `tests/contrast_aa.rs`:候选 WCAG 门禁 3 → 4 档(26 × 4 × 2 = 208 项)
- 默认值**不动**:仍为 ③ `#87CEEB`(临时默认,待用户看完四档再拍);
  `product_default_is_the_recommended_candidate` 保持原样,专门在默认与推荐不一致时报警
- 文档:`color/mod.rs` 与 `palettes.rs` 的候选清单补上 ④ 与「改一行即可切 ④」的说明
2026-11-01 18:09:13 +08:00
AskaEth 79404fa9d1 feat(design): M70 默认色候选 —— 浅冷天蓝落成两行常量 + 三档候选色卡
默认色从官方紫 `#6750A4` 换成「浅冷天蓝」,并把「换默认只改哪一行」做成常量:
- `palettes::DEFAULT_SEED` = `#87CEEB`(CSS skyblue)+ `palettes::DEFAULT_NEUTRAL_CHROMA` = 12
- `Palettes::baseline()` / `Scheme::baseline()` = 产品默认(消费方拿到的就是它)
- 官方口径锚点独立保留:`Palettes::official_baseline()` / `Scheme::official_baseline()`
  (种子 `#6750A4` + 中性 6),官方对齐测试改指向它,98 项仍每通道误差 0
- 新增 `Palettes::from_seed_with_neutral_chroma(seed, neutral_chroma)`:中性彩度从写死
  常数变成入参(neutral-variant 按官方 +2 跟随),算法、tone 表、gamut 映射一行未动

有意偏离登记(已写进 color 模块文档):中性彩度 6 → 12 是对官方 Tonal Spot 的偏离,
理由=产品要冷调表面;primary 的 tone 被官方钉在 40,种子再浅也变不成浅蓝,
「浅」只能落在中性色上。

三档候选(同一条 HCT 管道,只差种子与中性彩度)逐个 pin 在 tests/skyseed_candidates.rs:
① 保守蓝 `#2196F3`/6、② 冷调蓝 `#2196F3`/12、③ 浅冷蓝 `#87CEEB`/12(临时默认,待用户确认)。
WCAG AA 扩到候选档:26 组 × 3 档 × 2 模式 = 156 项全绿,最紧 4.99:1。
2026-11-01 18:09:12 +08:00
AskaEth c20c742b60 Merge branch 'feat/p4-iced-app-skeleton-renderer-fonts-shel' 2026-11-01 17:58:46 +08:00
AskaEth a1adf024ba fix(app-iced): M68 r1 —— M69 迁移 + 守门补洞 + 药丸 56×32 + caption 图标 24 + 东侧热区回 1180
代码复审 p2-2items / 视觉复审 p2-2items 的修复轮(配色重拍等 M70 定案)。

M69 迁移(2 行)
- nav 轨宽:nav_rail_width(metrics) 函数体改用 metrics.nav_rail_width
- theme::STROKE_PX 改为 pub use unlucky_design::stroke::HAIRLINE as STROKE_PX

P2-1 零硬编码守门补洞(reviewer 造样 5 个漏网样本全部补上,并进自检正向断言)
- COLOR 调用表补 from_rgb8 / rgb8 / rgba(`from_rgb(` 子串匹配不到 `from_rgb8(` 是根因)
- 新增 `Color { r: .., g: .. }` 结构体字面量检查
- 尺寸规则遇到 Length::Fixed( / Padding::from( / Size::new( 往里剥一层再判数字
- 新增渲染层回归守门 pill_never_overrides_its_size_with_fill(禁 nav.rs 出现 center_*( ):
  视觉复审 P2-1 的病根就是 center_*(Length::Fill) 把容器尺寸覆盖成 Fill

视觉 P2-1 药丸 88×32 → 56×32(实测 x=16..71)
- 去掉 center_x/center_y(Fill);居中改由父 column 的 align_x + 显式 width(轨宽) 完成
  (只去掉 center_* 还不够:column 会 shrink 到 56,药丸贴左缘 x=0..55,是同一个坑的第二层)

视觉 P2-2 / 用户点名 caption 图标 46 → 24(按钮容器保持 46×48)
- button 内包 container(...).width(24).height(24).center(Length::Fill):
  只设 width/height 会让容器缩到 24 并贴 button 内容左上角;center(Fill) 才既撑满又居中

视觉 P3-1 东侧/南侧缩放热区改成叠层
- 原来是 row 的兄弟列,吃掉 4px 布局宽(右留白 28、关闭键离窗角 4px);
  现在用 stack! + 对齐容器叠在工作区上,布局宽回到 1180(卡片右缘 x=1155 = 右留白 24)

P3 清单
1. --data-dir 接受并忽略(翻牌前 CI 兼容;现役 smoke 命令行可原样跑,exit 0)+ 单测
2. --diff 内建 ±N px 平移扫描(--diff-scan),CI 不再依赖 numpy
3. 截图模式等 SystemTheme 到达才出图(否则暗色系统上会拍到浅色)
4. (Auto, 未编入 wgpu) 返回 tiny-skia,不再把 wgpu 写进候选列表
5. --renderer=gpu 也跳过适配器枚举(决策与探测无关,省 ~0.6~1.2s)
6. icons::handle 加 debug_assert_eq!(IconKind::ALL[kind as usize], kind)
7. 轨顶留白改 container padding-top,实测 24(原来 spacer + column spacing = 28)

顺带:shot.rs 拆成 shot/mod.rs + shot/diff.rs(≤400 行门禁)

门禁:fmt OK / clippy -D warnings 干净 / 46+5 测试全过 / cargo check --workspace --all-targets OK
体积:双后端 6,769,664 B(6.46 MiB)、仅 CPU 3,955,712 B(3.77 MiB)
截图:r1 代码验证图(r1-verify / hover / pressed / compare-nav-rail)已出;4 张交付主图待 M70 定案后重拍
2026-11-01 17:46:55 +08:00
AskaEth 14da7b3dec refactor(app-iced): M68 —— 把 nav 轨宽收敛成一个 M69 迁移点(三处调用 → 一行改动)
M69(141e9b1)把 LayoutMetrics::nav_expanded_width 改名 nav_rail_width、删掉
nav_collapsed_width、新增 nav_drawer_width=240。本分支基线 0aac307 还没有 M69 的
crates/design(那个目录也不在本 mission 的改动范围),现在直接改名会编不过。

所以把 shell/nav.rs 的三处 metrics.nav_expanded_width 收敛到 nav_rail_width(metrics),
并在函数文档里写清 M69 前后对照与「合入后只改函数体一行」;theme::STROKE_PX 同样
注明可换成 unlucky_design::stroke::HAIRLINE。行为/视觉零变化(值都是 88 / 1.0)。
2026-11-01 17:11:25 +08:00
AskaEth df4ff373b7 feat(app-iced): M68 —— iced 0.14 骨架(后端探测/字体基座/自绘标题栏/导航轨/深浅主题/截图通道)
新增 crates/app-iced(package `unlucky-app-iced`,bin `unlucky-launcher-iced`),与 Slint 版并存,
为 D26「UI 从头写」打地基。本 mission 只做骨架:能开窗、颜色/字体对、层级在,不做业务页面。

- 后端(D27):启动探测 wgpu 适配器(只看 device_type,避开 WARP 误判)→ 写 ICED_BACKEND;
  auto/gpu/cpu 三条路径都保留 tiny-skia 兜底,绝不硬失败;`--renderer=cpu` 跳过探测(省 ~0.85s)
- 字体(D28 前半):令牌三平台字体栈 → 用 iced 全局 FontSystem 查装机情况 → 族名与 400/500/700
  字重落点(缺档按 Nearest/Synthetic 记录)打一行启动日志
- 令牌适配层 theme::Ui:唯一把 unlucky-design 的值翻成 iced Color/Font/Border/Radius/Theme 的地方;
  49 个 M3 角色留在自己的 Scheme 里,iced 的 6 色 Palette 只给 devtools
- 骨架:1180×760 无边框窗口、48 高自绘标题栏(drag + 东/南 drag_resize + 最小化/最大化/关闭)、
  88 宽导航轨(56×32 SecondaryContainer 药丸,由令牌推导)、页头 56 + 一张 OutlinedCard
- 主题:系统主题探测 + --theme,切主题只重建 Ui(Theme::name 变化触发重算),不动 UI 状态
- 截图通道:`--screenshot <png>` + `--screenshot-theme`(真开窗第 3 帧出图),以及
  `--diff <a> <b>` 自带逐像素对比(CI 不依赖 Python/ImageMagick)
- 图标:令牌 SVG path → iced svg 控件(颜色走 alpha 蒙版,深浅共用一份位图缓存)
- 门禁:tests/no_hardcoded_tokens.rs 扫源码挡色值/尺寸字面量 + 挡 slint 依赖(自检也算过)

实测(Windows Server 2025 / 96 DPI / RX 6600 XT):浅/深 × GPU/CPU 四张真开窗截图;GPU vs CPU
差异 0.72% 像素、全部落在字形/图标抗锯齿与 1px 圆角描边、最佳对齐 (0,0)、平坦区颜色逐字节相同;
release 体积双后端 6.44 MiB / 仅 CPU 3.75 MiB;首帧 GPU 1.18~1.31s、CPU 33~37ms。

未做/未验证:macOS 与 Wayland 全链路、西/北边缩放热区、letter spacing(iced 0.14 无此属性)、
中文 CDN 字体、玻璃;翻牌步骤写在 DevDocs/SubAgentsTalk/app-iced-骨架与翻牌.md(不入库)。
2026-11-01 17:11:24 +08:00
AskaEth 3fcdc3d3e1 Merge branch 'feat/p4-design-token-extensions-component-met' 2026-11-01 17:10:04 +08:00
AskaEth 16bdad1b98 fix(design): M69 r1 —— SEARCH_BAR 内边距语义收口 + ComponentRadius 取值口上闸
P2-1(语义缝隙):`SEARCH_BAR` 的 pad_start / pad_end 原为 16,与
icon_offset = 4 按字面相加得 20,比官方图标可视边 16 多 4px(越过 rubric
±2px 容差);而被加数的 12 不在 CM 里,消费方只能自己写 12.0 —— 正是 G10
要消灭的字面量。改为 12(文本框内容内边距,Space::X3)+ 4,两侧同式:
图标可视边 = pad + icon_offset = 16。字段文档写明求和公式;测试钉死
pad + offset == 16 且 pad != 16(防有人把 16 写回 pad 再叠加)。
顺带给 SEARCH_BAR.gap 补 [估] 出处(官方无图标↔文字 token)。

P2-2(绕过路径):ComponentRadius::dp() 收成 pub(crate)。它不看尺寸、
无条件返回 2.0,公开等于把 D32 的尺寸闸交给自觉。对外唯一取值口是
radius_px(size) -> Option<f32>;ALL 只暴露变体名、不暴露数值。
tokext_pins 原用 dp(),改为紧邻的 radius_px(20.0) == Some(2.0),覆盖不丢。

顺手:修掉本轮新引入的 rustdoc warning(component_map 文档链到私有模块
metrics,改指 ComponentMetrics);Divider 行标注 motion/duration 为占位,
并在 ComponentSpec 的 text / motion 字段文档写明「无文字 / 无动效的组件
这里是占位值,消费方不得据此排版 / 排动效」。

门禁:test 165 全绿、fmt --check、clippy -D warnings、check --workspace
--all-targets、doc(warning 2 → 1,只剩既有的 typography→emphasized 一条)。
2026-11-01 17:05:19 +08:00
AskaEth 141e9b12c7 feat(design): M69 令牌补齐 —— 圆角分层 / 组件微度量 / 骨架值与一像素线
G13(D32 分层):新增 shape::ComponentRadius(白名单只有 Micro=2)、
MICRO_CONTROL_MAX_SIZE=24 与纯函数 radius_layer_for();微半径走
radius_px(size) -> Option<f32>,尺寸 >24dp 一律 None,靠签名而不是自觉拦。
ShapeScale 与 rank() 一个字未动,2 不进刻度。

G11:LayoutMetrics 加 search_bar_height=56 / nav_rail_width=88 /
nav_drawer_width=240 / divider_thickness=1;删除语义相反的
nav_expanded_width=88 / nav_collapsed_width=48(D33:展开 drawer 240 /
折叠 rail 88;值 88 的后继是新名字 nav_rail_width,48 无后继)。

G10:新增 component_map::ComponentMetrics(12 字段 + 4 个命名常量 +
indicator_width),入口 Component::metrics();框内几何与 LayoutMetrics 的
骨架值分工写进模块文档,骨架级尺寸在 CM 里留 0 不许重复。

G9:Component 末尾追加 Checkbox / Radio / Search / NavigationDrawer /
Divider(20 -> 25),同步 table.rs 槽位、ALL 与 name();ComponentSpec 新增
micro_radius(Some 时 shape 必须是占位 None,由构造器 + 测试钉死)。

G12:新增 spacing::stroke::HAIRLINE = 1.0(crate 根再导出为
unlucky_design::stroke),divider_thickness 直接引用它。

其余:lib.rs 的「OKLCh 近似」文案改回真实口径(真 HCT / ColorSpec2021);
component_map 单测拆到 tests.rs 以免 mod.rs 超 400 行;测试 145 -> 165。
2026-11-01 16:53:30 +08:00
AskaEth b89cc91b78 Merge branch 'feat/p4-design-exact-hct-color-scheme-hctcolo' 2026-11-01 16:40:38 +08:00
AskaEth a4d86e9b19 docs(design): M65 r1 收口 —— 修正覆盖数量口径(290 项 / 8 个种子)
复审 P2-1:两处「验证覆盖数量」还停留在扩种子之前的旧值,只改文档文本。

- NOTICE:「242 项」→「合计 290 项」,并写出分项口径
  (baseline 49 角色 × 2 模式 = 98;强调色 8 种子 × 12 角色 × 2 模式 = 192)。
  这是 Apache-2.0 归属声明,覆盖范围写错比普通注释更该修。
- crates/design/src/color/mod.rs:51:「6 个种子」→「8 个种子 × 12 个强调族角色 × 2 模式 = 192 项」。

顺带核对了 242 的来历:242 = 98 + 6×12×2 = 98 + 144,是扩到 8 个种子之前的旧值,过期而非算错。
配色相关的 HCT 彩度措辞(#E8DEF8 是 16.096 而非 11.9)此前只出现在我的完成报告里,代码/文档/测试
均未写入 11.9(已 grep 确认),故无第三处改动。

不动代码、不动测试、不动阈值。
2026-11-01 16:40:36 +08:00
AskaEth 29f65b8e6f feat(design): M65 真 HCT 色彩 —— CAM16 + ColorSpec2021 + 官方逐角色对齐
把 M61 的「OKLCh 近似 CAM16 + gamut 硬裁」换成官方的 HCT 算法,与
material-color-utilities(Apache-2.0)逐角色对上:baseline 49 角色 ×
light/dark、8 个种子 × 12 个强调族角色 × 2 模式,**实测每通道误差 0**
(判据写入测试:≤1)。

实现(逐函数对照官方 Java 重写为 Rust,f64 内部精度):
- color/cam16.rs + viewing_conditions.rs:CAM16 色相/彩度(D65、200 lux、
  背景 L* 50),默认观察条件的中间量与 Java 实测逐位对齐
- color/hct_solver.rs + critical_planes.rs:官方 gamut 映射(Newton 迭代 +
  色调平面上的色相二分,255 项关键平面表原样搬运),只压彩度不硬裁
- color/hct.rs:HCT 坐标(hue/chroma/tone),公开给工具与测试
- color/space.rs:官方 ColorUtils 的 sRGB↔线性↔XYZ↔L* 换算(矩阵、阈值、
  取整口径一致)
- color/palettes.rs:Tonal Spot 官方彩度常数 36/16/24/6/8,error 25°/84
- color/scheme.rs:tone 表换 ColorSpec2021(on-*container 10→30、
  background 99/10→98/6)

两个旧缺陷关门:
- primary-container 与 secondary-container 不再撞色(彩度 36 vs 16,
  tone 90 装得下,无需「共享基准压彩度」补丁);reference_chroma /
  tone_scaled 随之删除
- 强调色色相不再被 gamut 硬裁(只压彩度,实测偏移 <1°)

对齐证据:tests/official_baseline.rs(98 项 + 12 个 HCT 探针)、
tests/official_accent.rs(192 项)。期望值是把官方 Java 实现本机编译运行
的输出抄本,并用 Flutter 的 ColorScheme.fromSeed 用例交叉验证
(#00FF00 → #406836、#000000 → #8C4A60、#FFFFFF → #006874 一致)。

诚实口径:只做 Tonal Spot 变体 + contrastLevel 0;对比度三档、其他变体、
壁纸多色取色、非默认观察条件都在模块文档的 gap 表里登记。
注:token_pins 的 tone 值变化来自官方算法(ColorSpec2021),不是为过测试而改。

NOTICE 增「Google Material Color Utilities」归属(Apache-2.0 要求,
含逐文件映射与对齐方法)。
2026-11-01 16:40:36 +08:00
AskaEth 958ced2d7e Merge branch 'feat/p3-guards-file-size-cover-py-sh-guardsiz' 2026-11-01 16:22:54 +08:00
AskaEth 95e8a5ca42 fix(ci): M67 文件行数守门补上 *.py / *.sh —— 脚本不再漏网
file-size job 原先只扫 *.rs/*.md/*.slint/*.yml/*.yaml/*.toml/*.json,
packaging/ 下的构建辅助脚本(make_artifacts.py、check_screenshot.py、
linux-smoke-docker.sh)完全没有任何行数保护。

- 在 git ls-files 的 pathspec 末尾加 '*.py' '*.sh',语义不变(>400 行即硬失败)
- 保留 LICENSE*/CHANGELOG* 排除;Cargo.lock 等机器生成文件刻意不入列
- 阈值、失败方式(::error + exit 1)均未改动
2026-11-01 16:22:51 +08:00
AskaEth 0aac307d92 Merge branch 'feat/p3-ci-headless-render-smoke-xvfb-xplatci' 2026-11-01 16:15:49 +08:00
AskaEth 7e08e0ccc9 fix(docs): M62 r1 —— 修正「缺字体→空图+exit 0」的错误论据,拆出运行时冒烟细节文档
review r1(p2-1items/fix-then-merge)只要求改文档与注释,门禁逻辑一行未动。

- P2-1(唯一阻塞项):原稿称「字体库缺失 → 合法 PNG + 退出码 0」,这是错的。review 实测两条失效都是
  **硬失败**:完全没有字体 → fontique panic(退出码 101,不产生 PNG);非软渲染(默认 femtovg)→
  `take_snapshot is not supported by this FemtoVG backend`(退出码 1)。改正口径:`--screenshot` 的 PNG
  是进程自己编码写出的,只要走到 `take_snapshot` 就是「合法 PNG + 退出码 0」,像素断言是**纵深防御**,
  守的是「进程活着、PNG 合法、但场景几乎没画」这一档;其余情形本机暂无样本,属防御性设计、不宣称已复现。
  落点:packaging/ci-runtime-smoke.md §1 与 packaging/check_screenshot.py 模块 docstring。
- P3-1:ci.yml 的 runtime-smoke job 头补一行 —— 本 job 绑死 Slint `Window::take_snapshot`,迁 iced 后必须
  提供等价离屏截图通道,否则静默退化成「能起进程」。
- P3-2:阈值余量写明量级(342/64=5.3×、19.45/3.0=6.5×、0.00761/0.001=7.6×,改「5~8 倍」);并写明
  闸门下限 ≈「至少画出一条标题栏」(review 实测切片 174/35.10/0.02623 与右下 400×300 切片仍 pass),
  即只证明「场景被真实渲染过」,不证明元素齐全、更不证明视觉正确。
- P3-3:README 398/400 已贴红线 —— 借 review 给的逃生口**提前拆**:细节搬进 packaging/ci-runtime-smoke.md
  (78 行),README §11 收缩为 12 行索引(374 行),并写明「再增长继续往细节文档写,两文件都 ≤400」。
- P3-4:本地复跑示例改 Windows 安全路径 `D:/tmp/ull-smoke`;脚本对 repo_root/out_dir 统一 `cygpath -m`
  归一并回显真实路径(Git Bash 下 `/tmp` 会静默落进 Docker VM,宿主什么都收不到 —— review 实测踩到)。
- P3-5:两个 `run:` 里的 `set -o pipefail` 保留但加注释说明「当前无管道,为将来加管道先有失败传播」;
  `cargo clean -p unlucky-app || true` 加内联注释(N7 兜底,CI 里没有对应行,别误抄进 ci.yml)。

验证:`--selftest` 三组仍全部 fail(判红能力未被改坏);真实截图仍 pass(1180x760 / 342 / 19.45 / 0.00761);
0 字节与非 PNG 路径 exit 1;`yaml.safe_load` 通过(6 job,Linux 槽位无 continue-on-error,artifact 步
`if: always()`);`cargo fmt --all --check` 退出 0;cygpath 修复后重跑 Docker 冒烟,宿主机 D:/tmp/ull-m62-rev2
拿到 PNG,md5 `156c382a…` 与前两次 run 及 review 的 artifact 完全一致。
2026-11-01 16:15:47 +08:00
AskaEth ea5f66cf92 feat(ci): M62 —— Linux runtime-smoke(xvfb 真渲染一帧)+ 像素非空白门禁
「编得过」不等于「跑得起来」:CI 里 Linux/macOS 此前只 build + test,从未创建过真实图形窗口。
本 commit 补第一份 Linux 真实图形环境运行证据,并把它做成不可伪造的门禁。

- ci.yml 新增 runtime-smoke(ubuntu-latest):装 xvfb + 软渲染/字体依赖,用 xvfb-run 起 GUI 进程,
  走既有 `--software-renderer --screenshot` 通道渲出一帧 PNG,再断言 ①退出码 0 ②PNG 宽高 > 0
  ③非空白(唯一色数 / 亮度σ / 强边缘占比三项过阈值),并把截图作为 artifact `runtime-smoke-screenshot` 上传。
  既有 fmt/clippy/test/build 四个 job 与触发器语义零改动(纯追加)。
- ci.yml 新增 runtime-smoke-macos 探针:只在 workflow_dispatch 触发且 continue-on-error。官方 macOS
  runner 处于 LaunchDaemon 上下文、无 WindowServer 会话,大概率建不了窗口;本机无 macOS 且 worker 禁
  push,这条路径尚无实测证据,故不拿它卡 PR 门禁(首跑结论决定升常驻或按 D26 归入 iced 重写)。
- packaging/check_screenshot.py:零第三方依赖的 PNG 解码(zlib + 反过滤 0~4)与三项指标判定,
  `--selftest` 打印阈值依据(人为纯色帧 1/0.0/0.0 必红,真实帧 342/19.45/0.00761 全过线)。
- packaging/linux-smoke-docker.sh:在 Docker 里复刻同一 job(TUNA apt/rustup 源),便于无 Linux 桌面的本地复跑。
- packaging/README.md §11:覆盖平台、阈值依据数值、本地复跑、已知跳过项、迁 iced 后需重新验证。

本机实测(Debian 12 容器 + xvfb + 软渲染):退出码 0,PNG 1180x760,唯一色 342 / σ 19.45 / 强边缘 0.00761。
CI(Ubuntu 24.04 runner)首跑仍未验证——本 job 证明「能出图」,不证明视觉正确。
2026-11-01 16:15:47 +08:00
AskaEth a681ccb526 Merge branch 'feat/p4-design-system-tokens-typography-icons' 2026-11-01 16:06:40 +08:00
AskaEth 83e19e58f0 fix(design): M61 r1 收口 —— 官方 emphasized/弹簧/高程 + 冻结测试
reviewer r1 判 p2-2items(fix-then-merge),本轮按条目收口:

P2-1 冻结测试补全(变异实测证明原来可静默漂移):
- tests/type_scale_pins.rs:baseline 15 档 (size,line_height,weight,tracking,family)
  与 Expressive 强调 15 档逐值对拍,期望值独立于实现重写
- tests/token_pins.rs:49 条 tone(light+dark 全表)、7 条 easing 曲线参数、
  16 档时长、9 组弹簧、28 个 IconKind 下标、形状阶梯
- 自证:TitleMedium 16→17 与 EmphasizedDecelerate 换曲线两次变异均 FAILED

P2-2 emphasized 口径事实错误(用户点名):
- 取回 A 级官方值(androidx TypeScaleTokens v0_103 的 *Emphasized* 四元组):
  label-*/title-medium/title-small 强调是 700(此前给 600)、body-large 0.5→0.15、
  title-medium 0.2→0.15、body-medium 0.2→0.25、display-large -0.2→0
- TypeRole::emphasized() 改为官方表(typography/emphasized.rs);
  原「只加一档字重」规则改名 emphasized_simplified() 并标注非官方
- 顺带按官方订正 baseline tracking(display-large -0.2、body-medium 0.2、
  title-medium 0.2)与 TitleLarge 的 Brand 族归属
- crate 文档新增「baseline MD3 vs M3 Expressive 差异清单」+ 未支持能力 gap

弹簧改为两套 token 结构(用户追加令):
- motion::spring(scheme × category × speed) 二维表 12 格:
  standard spatial ζ=0.9 (k 1400/700/300)、expressive spatial ζ=0.6~0.8 (k 800/380/200)、
  effects 两套同值 ζ=1.0 (k 3800/1600/800),均取 A 级 MotionTokens 原值
- Spring 改存阻尼比(与 Compose 令牌同口径),9 个具名常量

中文字重覆盖(用户追加令):
- FontFace 带 WeightCoverage(可变区间/静态表),Platform::resolve_weight 给
  Native/Nearest/Synthetic 三档结论;雅黑无 500、苹方无 700 两处 gap 显式登记

component_map 高程按官方回调(P2 并入 finding):
- FilledCard L2→L0、OutlinedCard L1→L0、Menu L4→L2、Dialog L5→L3(Menu 圆角同时
  按 MenuTokens 改 4dp);新增官方值 pin 测试 + R4「全屏都在飘」守门断言

P3 四条:
- 同族容器撞色:彩度按共享基准等比压缩(Palettes.reference_chroma),
  primary/secondary-container 不再同值,且强调色覆盖时表面逐像素不变
- femtovg 引用改函数名(行号已漂);删掉 contrast_aa 的死代码后门
- 骨架数值跟 M63:nav 56→44、titlebar 40→48,control 40/touch 48 换算依据入文档
- 图标折算口径订正为「居中描边,视觉外扩 <1px」
2026-11-01 15:58:19 +08:00
AskaEth 65ca0ed29a feat(design): 加 unlucky-design 令牌 crate(M3 色彩/排版/形状/动效/图标)
D26 把视觉基线锁到 Material 3 Expressive,本 crate 是「UI 整体性」的地基:
令牌唯一真源,后续 iced 页面只消费不新增。零第三方依赖、零 UI 框架依赖
(cargo tree -p unlucky-design --edges normal 只有它自己一行)。

- 色彩:49 个 M3 语义角色 × light/dark,种子色派生 6 条 tone 调色板;
  tone 轴严格用 CIELAB L*(偏差 ≤1),色相/彩度用 OKLCh 近似 CAM16 并在
  模块文档里写明口径与已知偏差(不宣称是标准 HCT 动态取色)
- 对比度:26 组文字对 × 6 组种子 × 2 模式全部 ≥4.5:1;3 条例外显式登记
  (Outline 非文字 3:1、OutlineVariant 装饰、SurfaceVariant 是容器色)
- 排版:M3 type scale 15 档 + 三平台字体族回退栈(只写族名,不内嵌受限字体)
- 形状/高程/动效:Expressive 形状刻度 + shape morph 数据、elevation 级别与
  surface container 映射、时长/缓动/弹簧(纯函数可采样)
- 图标:24 个从 Slint 版迁移的 SVG path + 4 个新增,IconKind 冻结只可末尾追加;
  写明 Slint 的 stroke-width 是绝对像素、SVG 是 viewBox 用户单位的语义差异,
  换算封装成 Icon::stroke_width
- 组件档位表:20 个组件的形状/高程/字体/动效一次取全

TODO(M63):elevation shadow 的 blur/offset/alpha 与四组弹簧参数是占位值,
接口先定、数值待玻璃视觉规格表(tower 已通告边界)。
2026-11-01 15:34:04 +08:00
AskaEth fef0922073 Merge branch 'feat/p3-release-linux-arm64-slot-linuxarm' 2026-11-01 14:21:12 +08:00
AskaEth 4c1a6c54e5 fix(release): M51 r1 —— linux-arm64 改用 ubuntu-22.04-arm(与 x64 同 glibc 2.35 地板)
P2-1:24.04-arm 会给出 2.39 地板,把 Debian 12 / Raspberry Pi OS Bookworm
(2.36)挡在门外,且「arm64 只有 24.04 档」这句前提是错的(同段注释自己就承认
ubuntu-22.04-arm 有效)。改 os 为 ubuntu-22.04-arm,注释改成「与 x64 同地板
(2.35),覆盖 Debian 12 / Ubuntu 22.04 arm64 / RPi OS Bookworm」;
README §3/§4/§7.5/§9 与 release-notes 的 Downloads/Support matrix 全量订正
2.39 → 2.35+,删掉「arm64 地板高于 x64」的对比措辞。槽位名、产物名、六槽位
齐全性检查与打包脚本均未变。

P2-2:rustup target add 步骤改 env TARGET + `rustup target add "$TARGET"`,
并补 shell: bash(此前是全文件唯一没显式 shell 的 run,在 Windows 槽位会落进
pwsh);Linux 图形栈依赖那步也补了 shell: bash,现在所有 run 都显式 bash、
run 块内零 `${{ }}` 直插。

顺带 nit:host_slot() 报错改回原始 arch token(`riscv64` 不再显示成 None),
README §7.5 宣传的那个用例现在诊断可读。

验证:yaml 结构断言(六槽位 / linux-arm64=ubuntu-22.04-arm / 齐全性循环 6 /
run 块 ${{ }} 零泄漏 / run 步骤全带 shell)、全部 run 块 bash -n、grep 2.39
零残留、host_slot 7 组合注入 + freebsd/riscv64 报错含原名、image_arch 真 exe、
windows-arm64 stub 全流程、架构守卫拦截、linux-arm64 归档两次 sha256 一致
(f949d0f5… 与上一轮相同)、windows-x64 原生回归(冒烟 + sha256sum -c)。
2026-11-01 14:16:29 +08:00
AskaEth 15caa1b61f feat(release): M51 —— 发布矩阵补 Linux arm64 与 Windows arm64 两槽位(六槽位)
- release.yml:加 linux-arm64(原生 ubuntu-24.04-arm runner)与 windows-arm64
  (windows-latest 上交叉编译到 aarch64-pc-windows-msvc);产物根改由
  matrix.build_dir 参数化,dumpbin 自检与打包都吃矩阵路径而非硬编码 target/release;
  交叉槽位多两步(rustup target add + ARM64 库体检),release job 的槽位齐全性
  检查从四扩到六。
- make_artifacts.py:--slot / --build-dir 参数化 + 六槽位映射;打包前回读可执行文件头
  (PE 机器码 / ELF e_machine / Mach-O cputype)校验架构,跨槽位构建自动跳过
  ull --version 冒烟并打印原因(不在 x64 上假装跑得动 arm64 二进制)。
- packaging/README.md + release-notes.md:两张支持矩阵同步加 Windows 11 ARM64 与
  Linux arm64 行;arm64 glibc 地板如实写 2.39(高于 x64 的 2.35),并说明与 Termux
  实验级的区别(这两个是正式发布槽位)。

本地验证:宿主判定注入(七个组合 + 非法宿主 exit 1)、文件头架构识别(真 x64 exe +
合成 PE/ELF/Mach-O 头)、windows-arm64 stub 全流程、架构守卫真拦、linux-arm64 归档
两次 sha256 一致、windows-x64 原生回归(冒烟 + sha256sum -c)。arm64 真机构建由 CI 补。
2026-11-01 13:48:37 +08:00
AskaEth 0bfe22aa6f Merge branch 'feat/p3-release-windows-dumpbin-locator-fix-d'
Release / build (macos-x64) (push) Canceled after 0s
Release / build (macos-arm64) (push) Canceled after 0s
Release / build (linux-x64) (push) Canceled after 0s
Release / build (windows-x64) (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
2026-11-01 13:06:12 +08:00
AskaEth a6013c7d22 fix(ci/release): M50 —— Windows 槽位 dumpbin 定位链改三级(vswhere → find → PATH)
v0.1.0 首跑(run 41273841717)windows-x64 在「Windows 单文件自检」挂
`找不到 dumpbin,无法校验导入表(静态 CRT 门禁不降级)`。两个独立根因:

- `command -v dumpbin` 在 windows-latest 上不成立:VS 环境未预置进 PATH。
- 兜底 glob 深度错了一级:写的是 `<VS 根>/<年>/VC/Tools/...`,真实布局是
  `<VS 根>/<年>/<版次>/VC/Tools/...`(2022/Enterprise、2022/BuildTools),
  所以即便 PATH 失守、glob 也不可能命中。
- 且只扫了 64 位 `Program Files` 根,Build Tools 落在 `Program Files (x86)` 根。

改成三级定位链,全不命中才 fail(门禁语义一字未动:工具缺失仍 exit 1、
导入表出现 VCRUNTIME/MSVCP/MSVCR/ucrtbase 仍 exit 1,文案不变):

1. vswhere —— VS 官方定位器,安装器自身路径固定,与 VS 版本/版次/布局无关:
   `.../Installer/vswhere.exe -latest -products '*' -find <pattern>`。
   模式先试反斜杠(官方文档形式)、再试正斜杠,输出统一折成正斜杠后再执行。
2. find 兜底 —— 两个 VS 根(64 位 + x86)按路径模式扫,`-maxdepth 11`
   留一级余量,不再依赖固定层数的 glob。
3. PATH —— 最后才试,PATH 最不可控。

另加一行 `dumpbin: <路径>(定位自 <层级>)`,下次真机挂掉能直接看出命中哪一级。
同文件其余步骤不依赖 VS 环境(已逐条核对),无需收敛。

验证(本机无 VS):
- 抽出 run 块 `bash -n` 通过;13 条 stub 用例全绿:无工具→fail 文案不变且不写
  dependents.txt、vswhere 反斜杠/正斜杠两个模式、两处 VS 根 find 兜底、PATH 兜底、
  动态 CRT 仍 fail-closed、vswhere 优先于 PATH、vswhere 输出的反斜杠路径归一化后可执行。
- 未改写的原始 run 块在本机实跑:干净 exit 1 + 原文案,不误命中、不崩。
- yaml 解析 + 23 条结构断言全过(矩阵四槽位、触发条件、门禁 grep/文案/exit 原样);
  文件 276 行 ≤ 400。
- git-bash 行为实证:`.exe` 的 `-x` 为真、非可执行文件为假、`find` 接受含空格的
  `C:/Program Files` 根。
真机验证由 merge 后 retag 重跑流水线完成。
2026-11-01 12:58:40 +08:00
AskaEth 37ccaaf23f Merge branch 'feat/p3-fix-game-java-discover-cfg-gating-jav'
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Release / build (macos-x64) (push) Canceled after 0s
Release / build (macos-arm64) (push) Canceled after 0s
Release / build (linux-x64) (push) Canceled after 0s
Release / build (windows-x64) (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
2026-11-01 11:00:32 +08:00
AskaEth dc4fac8391 fix(core/game): M49 —— Java 去重键平台无关归一化(Linux CI 三红)
`game::java::discover::path_key` 只在 `cfg!(windows)` 分支折分隔符,POSIX 上
把 `C:\a\b\java.exe` 原样留成带反斜杠的键,与 `C:/a/b/java.exe` 不同键 →
`dedupe` 不去重。三条用例(Windows 语义无 cfg 门)因此在
`test (ubuntu-latest)` 挂:`path_key_normalises_separators`、
`dedupe_keeps_the_higher_priority_source`、
`java_discover::dedupe_collapses_one_install_found_twice`。

- `path_key`:`\?\` 前缀剥离与分隔符折成 `/` 改为**与平台无关**(同一个
  `bin/java[.exe]` 用哪种拼法都是同一个文件,键必须相同);大小写折叠仍只有
  Windows 做——POSIX 上 `Java` 与 `java` 是两个目录,折了会把两份真实安装误并。
  已知取舍写进文档:POSIX 上文件名里的字面反斜杠会一并被当分隔符。
- 三条用例:不靠 `#[cfg(windows)]` 跳过。`path_key` 单测改成平台无关断言 +
  大小写折叠的双侧断言(Windows 折 / POSIX 不折,两侧都钉住);集成用例的
  「同一路径第二种写法」按平台给(Windows 用大小写+分隔符,POSIX 用分隔符)。
- 回归钉子:新增 `one_install_normalises_to_one_key_whatever_the_spelling`
  (普通写法 / 反斜杠写法 / `\?\` 前缀写法 / 混合写法 四种拼法同键)与
  `path_key_folds_case_only_where_the_filesystem_does`。

验证:Linux(rust:1-bookworm 容器,ull-linux-target 热缓存)
`cargo test -p unlucky-core --lib --test game` → 224 + 120 passed / 0 failed
(基线是 lib 220/2 failed、game 119/1 failed);
`cargo test --workspace --all-targets --no-fail-fast` → 34 个测试块
1137 passed / 0 failed(该次跑在 M48 launch 修复叠加的临时副本上,因为 M48
尚未合入 main,见报告)。Windows 本机 `cargo fmt --check`、
`cargo clippy -p unlucky-core --all-targets -D warnings`、
`cargo test -p unlucky-core` 全绿(17 个测试块 0 failed)。
2026-11-01 11:00:25 +08:00
AskaEth 38832df095 Merge branch 'feat/p3-fix-posix-tree-kill-ci-red-posixkill' 2026-11-01 10:48:15 +08:00
AskaEth 6ce825db8d fix(launch): M48 —— POSIX 组杀假成功导致 stop() 空等超时
CI test (ubuntu-latest) 自 M43 起红的根因不是「组杀打到自身进程组」,
而是 procps-ng 的 kill 把裸 `-<pgid>` 当成又一个信号选项吞掉:退出码 0、
什么都没发。旧实现据此提前 return,单 pid 兜底永远不执行,于是
process_smoke::stop_kills_process_tree 与
pipeline_cases::pipeline_stop_records_killed_exit 只能干等
graceful+5s 预算后报「强制结束后进程仍在运行」。

- process.rs:Unix 分支用 CommandExt::process_group(0)(std 1.64+ 安全
  API,无需 pre_exec/unsafe)让子进程自成一个进程组,与 Windows 的
  CREATE_NEW_PROCESS_GROUP 对称;这也让「树」回收真正覆盖后代。
- tree_kill.rs:组杀改用 `kill -TERM -- -<pgid>`(`--` 不可省),并且
  先过 group_kill_target 三重验证——目标必须是自己的进程组组长、必须
  读得到我们自己的组、目标组不能就是我们所在的组;单 pid 兜底改为
  「永远执行」,kill 缺失(NotFound)返回明确错误而不是静默失败。
  pid==0 守卫从 kill_tree 提到 terminate/force_kill 共用入口。
- 测试钉子(Linux 门住):spawn 后子进程 pgid == 自己的 pid 且 !=
  测试进程 pgid;组杀只接受验证过的独立组;组杀覆盖孙进程且不碰我们
  自己的组;同组子进程靠单 pid 兜底仍能被干掉;pid 0 一律拒绝。

验证:rust:1-bookworm 容器(ull-linux-target 热缓存)cargo fmt --check、
cargo clippy --workspace --all-targets -D warnings、cargo test -p
unlucky-core --test launch(82 passed,两条原红灯转绿)、cargo test
--workspace --all-targets --no-fail-fast 不再被 SIGTERM 打断;Windows
本机 fmt/clippy/cargo test -p unlucky-core 全绿。
2026-11-01 10:32:18 +08:00
AskaEth eaa1414847 Merge branch 'feat/p3-packaging-chore-six-p3-findings-packc' 2026-11-01 09:40:59 +08:00
AskaEth 62019a29cd fix(packaging): M47 r1 P2 —— 重跑不再断言 pre-release 标记
reviewer 指出:edit 分支此前每次默认重跑都重传 --prerelease,会把用户按
README 建议手动提升为 stable 的 Release 静默翻回 pre-release,文档承诺"重跑
不翻转标记"不成立。按首选修法让代码匹配文档:

- release.yml:create/edit 两套标记分开算。create 保持老契约(空输入 → 首建
  pre-release);edit 只在 inputs.prerelease 显式给出时断言
  (true → --prerelease,false → --prerelease=false),空值(push 或其 Re-run)
  不传任何标记。非法取值直接 ::error + exit 1。
- README §3:重跑语义与实现逐字一致,并把"改标记"的两条等价路径
  (gh workflow run -f prerelease=false / 手动 gh release edit)与 dispatch
  语义写顺;yml 头注释同步一句。
2026-11-01 09:37:55 +08:00
AskaEth ea6016716e chore(packaging): 收 M46 r1 六条 P3(dumpbin 门禁/ref 注入面/重跑幂等/文档口径)
- O1 release.yml 找不到 dumpbin 从 ::warning+exit 0 改为 ::error+exit 1(门禁不降级),
  README §7.4 同步为"两侧都 fail、没有降级分支"
- O2 打包 step 改用 env REF_TYPE/TAG 传参,bash 里不再出现 ${{ }} 直插;
  release job 的 TAG/PRERELEASE 已是 env 写法,全文件复核无同类风险点
- O3 gh release create 改为 view 命中则 upload --clobber + edit,同 tag 重跑幂等;
  README §3 补重跑语义与 pre-release 标记以首次创建为准
- O4 yml 头注释与 README §3 点明 inputs.prerelease 只在 tag ref 上 dispatch 可达
  (UI 只能选分支,需 gh workflow run --ref v<tag>),不与"dispatch 只构建不发布"冲突
- O5 release-notes 模板补 Termux 试验级行,README §9 同口径,标题改为"逐行一致"
- O6 README §6 补 ci.yml 每次 push/PR 的 release build 吃 fat LTO 链接时间;
  订正"fat 是全局单线程 LTO"为 codegen-units=1 导致单线程(§6/§7.1 两处);
  §7.3 写入 reviewer 补测的 tar 可复现证据 a99d6e9e…92f8
2026-11-01 09:30:27 +08:00
AskaEth 92d8a705e8 Merge branch 'feat/p3-forge-neoforge-gui-install-via-proces' 2026-11-01 09:14:07 +08:00
AskaEth d95c9664c6 fix(app): M45 r1 两条 P2(向导拒装文案 + 处理器段进度满格)
P2-1:wizard.slint 的 Forge/NeoForge 提示还写着「需要 installer processors:本轮请用 ull install」,
改成「需要 installer processors(首装会外调 java,约 1–3 分钟)」;全仓再扫一遍
「ull install / 显式拒装 / 终端跑」等措辞,user-facing 文案已无残留(余下命中全是
OptiFine/LiteLoader 的正确说明与测试注释)。

P2-2:进度条在处理器依赖库下完那一刻就满格(36/36),随后加载器库的 29 个文件才被计入
→ 100% 回落再涨回,且 bytes_total 用 .max() 与文件数对不上。
- Meter 新增 expect(files, bytes):文件数与字节都 saturating_add;
- install_loader 在处理器段之前就把加载器库的文件/字节一次预记账(两段之和),
  加载器库阶段只剩切阶段文案,不再记账;
- Reporter::expect 改为委托 Meter::expect,处理器依赖库沿用同一条曲线;
- 回归测试钉住:预记账后 files_total=95、bytes_total=17MB、ratio 不再满格,
  处理器库下完仍是中间态,加载器库下完才到 100%。

真机重截 + 识图:D:/tmp/ull-m45-shots/forge-mid-fixed-25s.png —— 同一时刻面板
36/65 文件、3.4 MB/15.3 MB、进度条只填了约一半(旧图 36/36 + 满格)。
2026-11-01 09:14:02 +08:00
AskaEth 3d9372029e refactor(app): 处理器测试夹具用 StepView 别名,过 clippy::type_complexity
clippy --all-targets -D warnings 报 very complex type(RefCell<Vec<(usize, String, Vec<String>, usize)>>),
抽 pub(super) type StepView 别名,语义不变。
2026-11-01 09:14:01 +08:00
AskaEth b0f747074f feat(app): GUI 接上 Forge/NeoForge installer processors(M45)
- host/install_processors:GUI 与 CLI 同序跑 processors(计划 → 依赖库 → 选 Java → 串行 java -cp),
  进度经 HostTask::Install 回流(阶段文案 + 每步处理器名 + 文件/字节/速度/ETA),取消在阶段边界生效;
- host/install_journal:安装过程追加到 .ULL/logs/install.log,失败文案带日志路径,不再静默失败;
- host/install.rs:supported() 扩到原版 + Fabric/Quilt/Forge/NeoForge,删除 Forge/NeoForge 拒装路径
  (仅剩 OptiFine/LiteLoader 两家 core 侧无管线者),spawn_install 统一持 Job 上下文;
- 失败诊断:缺 Java / processor 非零退出(保留 processor_* 大类码与脱敏 stderr 尾部)/ 依赖库下漏
  / 元数据缺失,均为结构化文案 + Toast + 装日志;
- dto::InstallStage::Installing 文案改「生成版本」(同时覆盖写版本 JSON 与跑 installer 两段);
- screenshot:新增 --screenshot-loader,Forge/NeoForge 中间态可离屏取图;
- 测试 +14(138 全绿):supported() 语义、处理器阶段注入 fake 后端覆盖跳过/缺 Java/非零退出/
  依赖库失败/命令行构造/进度文案,另修旧用例(Forge 不再被门禁拒装)。
2026-11-01 09:14:01 +08:00
AskaEth 858c227900 Merge branch 'feat/p3-packaging-release-artifacts-matrix-pa'
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
2026-11-01 08:33:18 +08:00
AskaEth 76d83148ff feat(packaging): 单文件产物矩阵 + tag 触发的 Release 流水线(M46)
- packaging/make_artifacts.py:本地与 CI 共用的打包脚本 —— 版本取自 cargo metadata
  (tag 交叉校验,不一致即失败)、按宿主判定槽位、Windows 裸 exe / UNIX tar.gz
  (内含 LICENSE/NOTICE/README.md)、SHA256SUMS.txt;打包前跑 `ull --version` 断言版本;
  tar 内 mtime/uid 归零 + gzip header mtime=0 → 同源构建 sha256 可复现
- packaging/README.md:产物矩阵与命名约定、版本唯一真相源、各平台运行时依赖、
  软渲染兜底、release profile 取舍、**本机实测**体积与冒烟证据、合规与支持矩阵
- packaging/release-notes.md:GitHub Release 说明模板({{VERSION}}/{{SUMS}},
  写清 Win10+ / macOS 12+ / Linux x64 支持矩阵)
- .github/workflows/release.yml:tag v* 触发四槽位矩阵 → 产物 + 重算 SHA256SUMS →
  gh release create(pre-release 默认开,workflow_dispatch 只构建);Windows 用
  +crt-static 并以 dumpbin 导入表把"单文件"做成硬门禁;归档内容自检 LICENSE/NOTICE/README
- Cargo.toml:release profile lto thin → fat(实测 GUI 16,366,080 → 14,595,584 B
  = −10.8%,ull 7,572,992 → 6,157,824 B = −18.7%)

验证:cargo build --release 通过;GUI `--software-renderer --screenshot` 出 1180×760 PNG、
`ull --version` = 0.1.0;PE 导入表 33 个 DLL 全为系统库(无 VCRUNTIME/libwinpthread);
cargo deny licenses/bans/sources ok;workflow 用 python yaml 解析自检。
2026-11-01 08:33:17 +08:00
AskaEth f9079f8ab0 Merge branch 'feat/p3-msa-authorization-code-flow-browser-l' 2026-11-01 08:33:13 +08:00
AskaEth 07fd6113a7 fix(core): 回调先校验 state 再处理 error,堵掉伪造取消失效登录
reviewer P2(crates/core/src/auth/microsoft/callback.rs):handle() 先处理 error 参数、
之后才校验 state,于是任意一次 `GET http://localhost:{port}/auth?error=access_denied`
(state 随便给甚至不给)都会让 wait_for_code 直接返回 Canceled,登录被静默中止、账户不入库
—— 不需要知道随机 state、不需要 code、不需要任何凭据。违反 RFC 6749 §4.1.2.1 / RFC 9207
(错误响应同样要校验 state),也与本模块「与本次登录无关的请求一律继续等待」的声明矛盾。

- state 校验提到 error 之前:state 不匹配的请求(无论带不带 error)一律 400 后继续等;
  只有带**本机 state** 的 access_denied 才判 Canceled。模块文档第 4 条同步改写。
- 顺带采纳 reviewer 的两条非计数观察:
  * 读头预算改为 accept 之后按 deadline 重算(原来复用 accept 前的 remaining,
    总时长可超发 ≤5s);预算归零直接超时。
  * state 由 64 位升到 128 位(两个独立随机种子的 SipHash 输出拼接),对齐
    RFC 6749 §10.10 的熵建议;新增单测断言 32 位 hex 且每次不同。
- 新增回归用例(crates/core/tests/auth/microsoft_authcode.rs):
  `foreign_state_cannot_cancel_the_login` —— 先发 `?error=access_denied`(无 state)
  再发 `?error=access_denied&state=not-our-state`,两次都必须 400 且服务器继续等待,
  随后真回调拿到 code;`access_denied_with_our_state_is_still_canceled` 钉住另一半语义。
  既有的「陈旧 state 回 400 后继续等」用例保持通过。

门禁:cargo fmt --all --check OK;cargo clippy --workspace --all-targets -- -D warnings
exit 0 无告警;cargo test -p unlucky-core -p unlucky-app 18 个 suite 全绿(auth 59 例)。

AskaEth <i@yeij.top>
2026-11-01 08:33:11 +08:00
AskaEth 8194e38bcf feat(core): 加 MSA 授权码流(本地回调服务器)并接到账户页
research/08 方案 A:GUI 的微软登录改走授权码流(HMCL 主流做法),设备码流保留给
CLI/无浏览器场景。两条流只差「怎么拿到 MS access_token」,拿到之后共用
chain::complete_chain 与 MicrosoftSession,落盘/刷新语义没有第二套。

core(crates/core/src/auth/microsoft/)
- endpoints:新增 authorize(…/consumers/oauth2/v2.0/authorize),与 token 同租户。
- authcode:RedirectUri 是 http://localhost:{port}/auth 的唯一来源(授权 URL、
  token 请求体、实际监听三处共用,坑 5);AuthCodePolicy(回调超时 300s / 绑定重试 3 次 /
  固定端口 / 固定 state);authorization_url(response_type=code + prompt=select_account +
  scope=XboxLive.signin%20offline_access);exchange_code 复用 flow 的 oauth_error/tokens_from。
- callback:本地 HTTP/1.1 回调服务器。内核分配端口(占用按次数退避重试);非 /auth 路径
  (favicon、预连)404 后继续等;state 不匹配 400 后继续等;error=access_denied 归 Canceled;
  单条连接读头超时 5s,半开连接拖不垮时间预算。
- MicrosoftAuth::authcode_login(present):绑端口 → present(宿主开浏览器)→ 等回调 →
  换 token → 整链;失败与设备码流一样落 M38 失败日志。

app(crates/app)
- AppCommand::StartBrowserLogin + host::accounts::start_browser_login;设备码/授权码共用
  LoginJob 取消槽位、收尾入库(store_session)与面板 DTO。
- host::opener 用系统默认浏览器打开授权 URL(Windows rundll32 url.dll,FileProtocolHandler)。
- 账户页:主按钮「微软登录」走授权码流,「设备码登录」作备用;DeviceLoginView::browser_waiting
  在面板里给出可复制的授权链接(user_code 留空)。

测试
- crates/core/tests/auth/microsoft_authcode.rs:授权 URL 向量(租户/编码/prompt)、
  URI 一致性(授权 URL 与 token 表单同字面量)、mock 全链、favicon+陈旧 state 后仍拿到 code、
  端口占用重试文案、超时、access_denied→Canceled、login_with_xbox 403 的 M38 诊断
  (detail_code=login_with_xbox_403,真机白名单未批时验收止步于此)。
- 单测:authcode 编码/向量、callback query 解码与错误分流、DTO browser_waiting、conv 面板。

未做/未验证:真机完整登录需 Azure 公共客户端 id(ULL_MSA_CLIENT_ID)与 Minecraft API 许可,
本机只能到「授权页 302 到 login.live.com 且 redirect_uri/scope/prompt 原样保留」这一步。

AskaEth <i@yeij.top>
2026-11-01 08:33:11 +08:00
AskaEth f62bb00494 docs(readme): add official GitHub project home line
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
2026-10-31 22:19:47 +08:00
AskaEth daadc9170f Merge branch 'feat/p3-gui-full-wiring-all-pages-consume-cor'
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
2026-10-31 22:16:35 +08:00
AskaEth 50b321ec64 fix(app): LaunchEnd 也清 pending 并记失败原因,补早失败回归测试(M43 r3 两条 P2)
P2-1(本轮新引入的回归):`pending` 只在 LaunchStarted 清除,但 `launch::start`
的两条早失败出口(build_request 失败 / spawn 失败——没装 Java、版本 JSON 坏就是
这两条)只发 LaunchEnd → pending 永久残留 → `in_flight` 恒真 → 该实例本会话再也
启动不了,磁贴锁在「启动中」。修:LaunchEnd 分支先 `pending.remove`,早失败与
正常收尾都释放。

P2-2(failed 零 insert):上一轮只在 note 里声称「失败原因进 failed 表」,代码没
落地(唯一 insert 缺失,refresh 的 Failed 分支是死代码,test 6 还靠手动塞表绕过)。
修:LaunchEnd 非 Ok 时 `failed.insert(id, summary)`、Ok 时 remove;refresh_instances
的 Failed 分支从此由真实路径喂数据。

测试(123 → 124,全部走真实入口):
- 新增 `launch_end_before_launch_started_still_releases_the_instance`:mark_pending
  → handle(LaunchEnd Crashed) → pending 空、in_flight 假、refresh 后磁贴是
  Failed{reason}(同时钉住 P2-1 与 P2-2)。
- test 6 重写:Busy 断言改走 `mark_pending`(start_launch 用的同一入口),Failed
  断言改走 `handle(LaunchEnd Crashed)`,不再手动塞 failed 表。
- 所有测试的数据根加 `TempRoot` Drop 守卫,跑完不留临时目录。
2026-10-31 22:14:24 +08:00
AskaEth 7b2ff62ddc fix(app): 运行时状态回归真实来源 + 启动状态机补回归测试(M43 r2 两条 P2)
P2-1(修复零测试):新增 host/launch_state_tests.rs,6 条单测钉住启动状态机:
- running_pid 按实例 id 取 pid(并发 A/B 后停 A 不会拿到 B 的 pid,A 收尾不动 B);
- 重复启动守卫:launches 已落盘时 start_launch不发 Busy、只给 warn(不起第二个进程);
- pending 窗口守卫:后台线程还没回报 LaunchStarted 时同样挡住重复启动;
- LaunchStarted 清 pending 并登记 pid;LaunchEnd 清启动表与失败记录;
- refresh 后在跑实例仍是 Running{pid};pending 是 Busy;失败记录是 Failed(保住
  磁贴的停止键/运行条/重试外观)。

P2-2(运行时状态未派生):refresh_instances 重建快照后按 host 的启动表覆写运行时
(Running{pid} / Busy / Failed),不再被 game::instances 的硬编码 Idle 打回原形——
装版本成功、加删账户、别的实例收尾等任意刷新都不再让「在跑的实例」丢掉停止入口。

顺带加固(reviewer 建议的 pending 窗口):单槽守卫改为 pending ∪ launches 并集,
launch::start 到 LaunchStarted 之间的重复 dispatch 不会起第二个进程;失败原因也
记进 failed 表,避免收尾后的刷新把「失败/重试」覆盖成 Idle。
2026-10-31 22:09:47 +08:00
AskaEth ecded9bef0 fix(app): 停止实例按实例 id 找 pid,并发启动不再杀错进程树
reviewer-guiwire r1 的 P2-1:`running_pid` 只记「最近一次启动」的单槽 pid,
并发启动 A、B 后点 A 的停止键会 tree_kill 掉 B;且实时尾随在 B 启动时被抢占,
A 的日志再也没人跟。兜底分支也永远不命中(`game::instances` 每次重建都把
runtime 置 Idle)。

改法:
- 单槽 `launch_pid` → `launches: Vec<LiveLaunch{instance, session, pid}>`,
  停止/收尾/尾随判定都按实例 id 或会话 id 精确匹配;
- 同一实例重复点启动直接拒绝(「已经在启动/运行中」),不再起第二个进程;
- 收尾只拆自己那场的尾随,并发时另一实例的实时日志不被掐断;
- 实时尾随仍是单窗口(一次只跟一场,另一场收尾后可从 JSONL 回放)——这是
  有意的 UI 取舍,不是漏判。
2026-10-31 21:55:59 +08:00
AskaEth 359a5a4325 chore: gitignore 截图验收产物目录(.screens/.tmp-screens) 2026-10-31 21:55:59 +08:00
AskaEth ddc0577f83 feat(app): GUI 全量接线,五页改吃 core 真实数据(M43)
app 从「mock 骨架」切到「真实 core host」:新增 src/host 装配层(env/job/
game/launch/identity/versions/install/meter/java/accounts/logs/settings/engine/
dispatch),是 app 里除 settings_bridge 外唯一 use unlucky_core 的地方,出口只有
crate::dto 的 DTO/事件(三通道纪律,AGENTS §6)。

页面接线:
- 实例页:profiles+版本 JSON 继承链+会话索引 → 磁贴(真实版本/加载器徽标/最后启动/
  隔离档位/载荷缺失);启动按钮走 LaunchRequest 装配 → Launcher::spawn → JSONL
  尾随 → 运行/失败回写;M30 ModeChangeWarning 进横幅通知条。
- 新建实例向导:版本 → 加载器 → 名字 → 确认 → 真实安装进度;Fabric/Quilt 可装,
  Forge/NeoForge 因需要 installer processors(仅 CLI 有)显式拒装并给出 CLI 命令。
- 下载页:远端 manifest(12h 缓存)+ 内嵌 unlisted → 分类(正式/预览/远古四子类);
  安装进度(文件数/字节/速度/ETA,速度由相邻采样差分)+ 阶段边界可取消;
  Java 分组:四家发行版 + 回退链可视化(计划链/真实 ChainLog)+ 本地运行时。
- 账户页:三轨账户列表(只读公开字段)+ 离线新增 + 删除/切换 + 微软设备码登录
  (用户码/链接/轮询状态;取消后丢弃结果)。凭据只在 host/identity 与 host/accounts
  出现,DTO 无 token 字段(单测钉住)。
- 设置页:新增 download/java/data/ai/mcp 五组,行由 core::setting schema 键生成,
  写回走 SettingStore::set(与 ull config set / AI set_config 同源 launcher.json)。
- 日志页:会话索引 → 列表;事件 JSONL → 日志行(等级高亮)+ D8 规则引擎诊断卡片;
  启动中实时尾随(600ms/拍,只读新增字节);崩溃日志导入分析(hs_err/crash/latest)。

架构与守线:
- 新页面状态走 ui/page_state.slint 的 PageState global(main.slint 再导出),
  Rust 侧 page_sync.rs 灌入、callbacks_pages.rs 接线;避免属性逐层转发顶破 400 行。
- DTO 全部 Serialize+Deserialize(IPC 契约),新增 accounts/java/settings/install/
  notices 五个子模块;事件扩展 LogAppended/LaunchFinished/AccountsChanged/
  DeviceLoginChanged/JavaChanged/InstallProgress/SettingsChanged/NoticesChanged。
- 截图通道扩展:--data-dir(钉数据根且 settings 同源)/--mock/--screenshot-page/
  --screenshot-wizard/--screenshot-session/--screenshot-install/--screenshot-launch
  (真实中间态,取图后自动 stop 防孤儿进程)。

已知边界:GUI 账户「当前启动账户」仅内存态(core schema 无 auth.* 键,app 不自建键);
Forge/NeoForge GUI 安装待 processors 执行器上移。
2026-10-31 21:55:58 +08:00
162 changed files with 28687 additions and 669 deletions

No files matched your search

+80
View File
@@ -90,3 +90,83 @@ jobs:
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
- run: cargo build --workspace --release - run: cargo build --workspace --release
# 运行时冒烟(M62):Linux 上**真渲染一帧**,补上「能编译 ≠ 能跑」的空白。
# 上面四个 job 都只证明代码编得过、测得过,从不创建真实图形窗口;本 job 在 Xvfb 虚拟显示里
# 启动 GUI 进程,让 Slint 软件渲染器把当前场景渲进内存像素缓冲并编码成 PNG,再对**像素本身**
# 断言(不是只看退出码:窗口起来了但一帧空白同样是「退出码 0」)。
# 诚实边界:证明「Linux 上真能跑出非空白的一帧」,**不**证明视觉正确——视觉靠人眼看 artifact。
# 本 job 绑死 Slint Window::take_snapshot;迁 iced 后必须提供等价的离屏截图通道,否则本 job 静默退化成「能起进程」。
runtime-smoke:
name: runtime smoke (ubuntu x64, xvfb)
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: 无头 X 与图形栈依赖(xvfb + 软渲染 + 字体)
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
xvfb xauth \
libxkbcommon-dev libxkbcommon-x11-0 libfontconfig1-dev \
libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev \
libx11-6 libxcursor1 libxrandr2 libxi6 \
fonts-dejavu-core fonts-liberation2
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
# 用 debug 而不是 release:本 job 验的是「运行时渲染通路能通」,不是优化后二进制的行为;
# release 的 lto="fat" + codegen-units=1 会把这一步拖到十几分钟却不带来额外证据。
- run: cargo build --bin unlucky-launcher --locked
- name: 在 Xvfb 里跑一帧并截图(退出码非 0 即失败)
run: |
set -o pipefail # 当前没有管道(死行),保留是为将来加管道时先有失败传播
mkdir -p "$RUNNER_TEMP/smoke/data"
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
target/debug/unlucky-launcher --software-renderer \
--screenshot "$RUNNER_TEMP/smoke/runtime-smoke.png" \
--data-dir "$RUNNER_TEMP/smoke/data"
- name: 断言 PNG 存在且非空白(尺寸 / 唯一色数 / 亮度σ / 强边缘占比)
run: python3 packaging/check_screenshot.py "$RUNNER_TEMP/smoke/runtime-smoke.png" --json
- name: 上传截图与数据目录(失败也传,便于复盘)
if: always()
uses: actions/upload-artifact@v4
with:
name: runtime-smoke-screenshot
path: ${{ runner.temp }}/smoke/
if-no-files-found: warn
# macOS 槽位探针(M62):**默认不在 PR 上跑**,只有手动 dispatch 才触发。
# 之所以是探针而不是常驻槽位:官方 macOS runner 处于 LaunchDaemon 上下文,没有登录的
# WindowServer 会话,Slint 的 winit backend 创建窗口大概率直接失败;而 worker 禁 push,
# 本机也没有 macOS,这条路径**尚无任何实测证据**——不拿未验证的 job 卡住主干门禁。
# 首跑结论决定去留:绿 → 去掉 if/continue-on-error 升为常驻槽位;红 → 按 D26 归入 iced
# 重写后再规划,并把错误原文贴进 progress 文档(不静默删 job、不吞失败原因)。
runtime-smoke-macos:
name: runtime smoke (macos arm64, probe)
if: github.event_name == 'workflow_dispatch'
runs-on: macos-latest
timeout-minutes: 30
# 探针阶段保留证据但不算流水线结论:log/artifact 里失败原因照常可见,
# 解除条件 = 该槽位在真实 runner 上首跑通过并把本行与上面那条 if 一起删掉。
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo build --bin unlucky-launcher --locked
- name: 尝试截图(macOS 没有 X 服务器,直接跑;失败即为结论)
run: |
set -o pipefail # 当前没有管道(死行),保留是为将来加管道时先有失败传播
mkdir -p "$RUNNER_TEMP/smoke/data"
target/debug/unlucky-launcher --software-renderer \
--screenshot "$RUNNER_TEMP/smoke/runtime-smoke.png" \
--data-dir "$RUNNER_TEMP/smoke/data"
- name: 断言 PNG 存在且非空白
run: python3 packaging/check_screenshot.py "$RUNNER_TEMP/smoke/runtime-smoke.png" --json
- name: 上传截图与数据目录(失败也传,便于复盘)
if: always()
uses: actions/upload-artifact@v4
with:
name: runtime-smoke-screenshot-macos
path: ${{ runner.temp }}/smoke/
if-no-files-found: warn
+3 -1
View File
@@ -63,7 +63,9 @@ jobs:
echo "::error file=$f::$f 有 $n 行,超过 $LIMIT 行上限,请按模块拆分" echo "::error file=$f::$f 有 $n 行,超过 $LIMIT 行上限,请按模块拆分"
failed=$((failed + 1)) failed=$((failed + 1))
fi fi
done < <(git ls-files '*.rs' '*.md' '*.slint' '*.yml' '*.yaml' '*.toml' '*.json') # 覆盖范围含脚本(*.py/*.sh):packaging/ 下的构建辅助脚本同样受 400 行约束;
# Cargo.lock 等机器生成文件刻意不在列(否则一生成就超限)
done < <(git ls-files '*.rs' '*.md' '*.slint' '*.yml' '*.yaml' '*.toml' '*.json' '*.py' '*.sh')
if [ "$failed" -ne 0 ]; then if [ "$failed" -ne 0 ]; then
echo "共 $failed 个文件超限" echo "共 $failed 个文件超限"
exit 1 exit 1
+355
View File
@@ -0,0 +1,355 @@
name: Release
# 发布流水线:tag `v*` → 四平台(六槽位)矩阵构建 → 单文件产物 + SHA256SUMS
# → 上传 artifact + 创建 GitHub Release(pre-release 默认开)。同一 tag 重跑幂等:
# Release 已存在时覆盖资产与说明,不再 `gh release create`(见 release job 末步);
# 重跑**不碰** pre-release 标记,该标记只在 `inputs.prerelease` 显式给出时才被断言。
#
# 契约唯一真相源:packaging/README.md(命名/目录/运行时依赖)。
# 打包本体在 packaging/make_artifacts.py(本地与 CI 同一份脚本,逐字节同布局)。
# 手动触发(workflow_dispatch)默认只跑构建不建 Release —— 用来验证矩阵仍然编得过。
# 注意 `inputs.prerelease` 只在 **tag ref** 上 dispatch 才可达:Actions 页面的
# "Run workflow" 只能选分支,要跑 tag 得 `gh workflow run release.yml --ref v0.1.0`。
# 分支 ref 的 dispatch 与 push 事件一样被 release job 的 `if: github.ref_type == 'tag'`
# 挡在发布之外,所以「dispatch 只构建不发布」与「dispatch 可关 pre-release」并不矛盾。
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
prerelease:
description: "标记为 pre-release(仅在 tag ref 上 dispatch 时才生效)"
type: boolean
default: true
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
permissions:
contents: read
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
build:
name: build (${{ matrix.slot }})
strategy:
fail-fast: false
matrix:
include:
# Windows x64:MSVC + 静态 CRT(+crt-static)→ exe 不依赖 VC++ 可再发行包,
# 双击即用的单文件形态;UCRT 本身就是 Win10+ 的系统组件。
# 产物根 target/release;自检与打包都从 matrix.build_dir 取路径(不硬编码)。
- slot: windows-x64
os: windows-latest
build_dir: target/release
rustflags: -C target-feature=+crt-static
# Windows arm64:在 windows-latest(x64 宿主)上**交叉编译**到 aarch64-pc-windows-msvc。
# 为什么不用 windows-11-arm 原生 runner(GitHub 文档 2026 已把 `windows-11-arm` 与
# `windows-11-vs2026-arm` 列为公开仓库**免费**的标准 arm64 runner,原生路线可行):
# ① 双 Windows 槽位共用同一套已验证镜像/工具链,tag 重跑行为与既有槽位一致;
# ② M50 的 dumpbin 定位链取的是 `Hostx64/x64/dumpbin.exe`,dumpbin 读 ARM64 PE 的
# 导入表没问题,而原生 ARM 宿主上那条链得再长一条 `Hostarm64/arm64` 分支——为一个
# 槽位放宽刚立的门禁不值得;
# ③ 交叉编译产物路径变成 target/<triple>/release,已用 matrix.build_dir 参数化。
# 若将来改成原生:只需把 os 换成 windows-11-arm、删掉 target/rustflags 依赖并给 dumpbin
# 定位链补 Hostarm64 分支(届时再评估,不在这里预先放宽)。
- slot: windows-arm64
os: windows-latest
target: aarch64-pc-windows-msvc
build_dir: target/aarch64-pc-windows-msvc/release
rustflags: -C target-feature=+crt-static
# Linux:固定 ubuntu-22.04(glibc 2.35 地板)而不是 latest,
# 否则产物的 glibc 底线会跟着 runner 漂移(24.04 的 glibc 明显更高)。
- slot: linux-x64
os: ubuntu-22.04
build_dir: target/release
# Linux arm64:公开仓库免费的原生 ARM64 runner(原生构建,不做交叉)。
# 用 `ubuntu-22.04-arm` 而**不是** 24.04:与 x64 同 glibc 地板(2.35),
# 覆盖 Debian 12 / Ubuntu 22.04 arm64 / Raspberry Pi OS Bookworm(2.36)这类实际在跑的
# ARM 发行版;换 24.04 会把地板抬高、把上面前两个挡在门外。该家族标签有
# `ubuntu-22.04-arm` / `ubuntu-24.04-arm` / `ubuntu-26.04-arm`,均 4C/16GB、
# 公开仓库免费不限量(GitHub 文档 Standard runners for public repositories)。
- slot: linux-arm64
os: ubuntu-22.04-arm
build_dir: target/release
# macOS:latest = arm64;Intel x64 用 macos-15-intel(macos-13 已下线)。
- slot: macos-arm64
os: macos-latest
build_dir: target/release
- slot: macos-x64
os: macos-15-intel
build_dir: target/release
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Linux 图形栈构建依赖(winit / femtovg 编译期需要)
if: runner.os == 'Linux'
shell: bash
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev \
libwayland-dev libxcb1-dev
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
# 只有交叉编译槽位需要额外 target(原生槽位留空 → 本步整体跳过)。
# 不把 `${{ }}` 直接拼进 bash:与后面的打包步骤同一纪律,一律经 env 传入再展开。
- name: 追加交叉编译目标 ${{ matrix.target }}
if: matrix.target != ''
shell: bash
env:
TARGET: ${{ matrix.target }}
run: rustup target add "$TARGET"
# 交叉链接的前提是镜像里真有 ARM64 的 MSVC 库(VC `/lib/arm64/libcmt.lib`,+crt-static
# 就靠它)与 Windows SDK 的 ARM64 导入库(`um/arm64/kernel32.lib`)。缺了照样是失败,
# 但先给一句能照做的话,而不是让链接器吐一屏乱码才发现方向:门禁不降级,只把失败说清楚。
- name: Windows ARM64 交叉工具链体检
if: matrix.target == 'aarch64-pc-windows-msvc'
shell: bash
run: |
set -euo pipefail
roots=("C:/Program Files/Microsoft Visual Studio" "C:/Program Files (x86)/Microsoft Visual Studio")
vc_lib=$(find "${roots[@]}" -maxdepth 11 -type f -path '*/VC/Tools/MSVC/*/lib/arm64/libcmt.lib' 2>/dev/null | head -n1 || true)
sdk_version=$(ls -d "C:/Program Files (x86)/Windows Kits/10/Lib"/* 2>/dev/null | tail -n1 || true)
sdk_lib=""
if [ -n "$sdk_version" ]; then
sdk_lib=$(find "$sdk_version/um/arm64" -maxdepth 1 -name 'kernel32.lib' 2>/dev/null | head -n1 || true)
fi
echo "MSVC ARM64 静态 CRT: ${vc_lib:-<缺失>}"
echo "SDK ARM64 导入库: ${sdk_lib:-<缺失>}"
if [ -z "$vc_lib" ] || [ -z "$sdk_lib" ]; then
echo "::error::交叉链接缺 ARM64 库:给 VS 加上 Microsoft.VisualStudio.Component.VC.Tools.ARM64(含 ARM64 Windows SDK),或把该槽位改用原生 windows-11-arm runner"
exit 1
fi
- name: cargo build --release --locked
shell: bash
env:
RUSTFLAGS: ${{ matrix.rustflags }}
TARGET: ${{ matrix.target }}
run: |
set -euo pipefail
target_args=()
if [ -n "$TARGET" ]; then
target_args+=(--target "$TARGET")
fi
cargo build --release --locked "${target_args[@]}" --bin unlucky-launcher --bin ull
- name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL)
if: runner.os == 'Windows'
shell: bash
env:
# 跨槽位的关键点:产物在 target/<triple>/release 而不是 target/release。
EXE: ${{ matrix.build_dir }}/unlucky-launcher.exe
run: |
set -euo pipefail
# +crt-static 是否真的生效,只看导入表:出现 VCRUNTIME/MSVCP/msvcr/ucrtbase 说明还得装
# 可再发行包,那就不是"单文件"了,直接 fail。
#
# dumpbin 定位链(三级,全不命中才 fail —— 门禁不降级,只是把"猜路径"换成官方定位):
# ① vswhere:VS 官方定位器,安装器自身路径固定,与 VS 版本/版次/安装布局无关;
# ② find 兜底:Program Files 与 Program Files (x86) 两个 VS 根都扫(Build Tools 落在 x86 根);
# ③ PATH:镜像若已把工具链预置进 PATH 则直接可用。
# PATH 排最后是刻意的:PATH 最不可控,官方定位器优先。
# Hostx64 的 dumpbin 读 ARM64 PE 的导入表没问题,所以 arm64 槽位共用同一条链。
VSWHERE="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
DUMPBIN=""
VIA=""
if [ -x "$VSWHERE" ]; then
# -find 的模式优先用反斜杠(vswhere 官方文档即此形式),再试正斜杠以规避分隔符差异。
for pattern in 'VC\Tools\MSVC\*\bin\Hostx64\x64\dumpbin.exe' \
'VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe'; do
hit=$("$VSWHERE" -latest -products '*' -find "$pattern" 2>/dev/null | head -n1 || true)
if [ -n "$hit" ]; then
DUMPBIN=${hit//\\//}
VIA="vswhere"
break
fi
done
fi
if [ -z "$DUMPBIN" ]; then
# 镜像里的 VS 根形如 <年>/<版次>(2022/Enterprise、2022/BuildTools……),
# 固定层数的 glob 会在 <年>/<版次> 这两级踩空,所以按路径模式扫。
for root in "C:/Program Files/Microsoft Visual Studio" \
"C:/Program Files (x86)/Microsoft Visual Studio"; do
[ -d "$root" ] || continue
hit=$(find "$root" -maxdepth 11 -type f \
-path '*/VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe' 2>/dev/null | head -n1 || true)
if [ -n "$hit" ]; then
DUMPBIN=$hit
VIA="find: $root"
break
fi
done
fi
if [ -z "$DUMPBIN" ]; then
hit=$(command -v dumpbin || true)
if [ -n "$hit" ]; then
DUMPBIN=$hit
VIA="PATH"
fi
fi
# 工具缺失同样 fail:windows-latest 自带 VS 工具链,基本找得到;
# "找不到就跳过"等于让这道门禁在最需要它的时候静默失效。
if [ -z "$DUMPBIN" ]; then
echo "::error::找不到 dumpbin,无法校验导入表(静态 CRT 门禁不降级)"
exit 1
fi
echo "dumpbin: $DUMPBIN(定位自 $VIA)"
ls -l "$EXE"
"$DUMPBIN" -nologo -dependents "$EXE" | tee dependents.txt
if grep -Eiq 'VCRUNTIME|MSVCP|MSVCR|ucrtbase\.dll' dependents.txt; then
echo "::error::静态 CRT 未生效:exe 仍动态依赖 VC++ 可再发行包"
exit 1
fi
- name: 打包(产物 + SHA256SUMS)
shell: bash
env:
# 不把 `${{ }}` 直接拼进 bash 脚本:git ref 名允许 `$`/反引号/`"`,一律经 env 传入再展开。
REF_TYPE: ${{ github.ref_type }}
TAG: ${{ github.ref_name }}
SLOT: ${{ matrix.slot }}
BUILD_DIR: ${{ matrix.build_dir }}
run: |
set -euo pipefail
# git-bash 与 Linux/macOS runner 的 python 入口名不一致,先探一遍。
PY=$(command -v python3 || command -v python)
# 槽位与产物根都从矩阵来:交叉槽位的二进制不在 target/release,脚本据此定位
# 并回读可执行文件头校验架构(宿主 x64 ≠ 产物 arm64 时冒烟自动跳过,只等 CI 真机)。
args=(--slot "$SLOT" --build-dir "$BUILD_DIR")
if [ "$REF_TYPE" = "tag" ]; then
args+=(--version "$TAG") # 与 Cargo.toml 版本交叉校验
fi
"$PY" packaging/make_artifacts.py "${args[@]}" --out dist
ls -l dist
- name: 归档内容自检(二进制定位 + 合规文本)
shell: bash
run: |
set -euo pipefail
for archive in dist/*.tar.gz; do
[ -e "$archive" ] || continue
listing=$(tar -tzf "$archive")
for required in unlucky-launcher LICENSE NOTICE README.md; do
if ! grep -q "/$required$" <<<"$listing"; then
echo "::error file=$archive::$archive 缺少 $required"
exit 1
fi
done
echo "OK: $archive 含二进制与 LICENSE/NOTICE/README.md"
done
if ls dist/*.exe >/dev/null 2>&1; then
echo "Windows 单 exe 形态:LICENSE/NOTICE/README.md 作为 Release 资产并列分发"
fi
- uses: actions/upload-artifact@v4
with:
name: dist-${{ matrix.slot }}
path: dist
if-no-files-found: error
retention-days: 14
release:
name: GitHub Release
needs: build
if: github.ref_type == 'tag'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: 矩阵完整性 + 重算全量 SHA256SUMS
shell: bash
run: |
set -euo pipefail
ls -l dist
# 六槽位全齐才发版:缺任意一个就 fail(宁可不出 Release,也不出少一档的 Release)。
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
done
# 各平台任务各写一份 SHA256SUMS.txt,合并后只有一份是真的 —— 在 Release 侧重算。
cd dist
rm -f SHA256SUMS.txt
sha256sum unlucky-launcher-* > SHA256SUMS.txt
sha256sum -c SHA256SUMS.txt
cat SHA256SUMS.txt
- name: 生成 Release 说明(支持矩阵模板)
shell: bash
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
python3 - <<'PY'
import os, pathlib
version = os.environ["TAG"].lstrip("v")
template = pathlib.Path("packaging/release-notes.md").read_text(encoding="utf-8")
sums = pathlib.Path("dist/SHA256SUMS.txt").read_text(encoding="utf-8").strip()
body = template.replace("{{VERSION}}", version).replace("{{SUMS}}", sums)
pathlib.Path("release-body.md").write_text(body, encoding="utf-8", newline="\n")
print(body[:400])
PY
- name: 创建或更新 GitHub Release(同 tag 重跑幂等)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
PRERELEASE: ${{ inputs.prerelease }}
shell: bash
run: |
set -euo pipefail
base_flags=(--title "Unlucky Launcher $TAG" --notes-file release-body.md)
# pre-release 标记只在**显式要求**时才断言:
# PRERELEASE=true / false —— 来自 tag ref 上的 workflow_dispatch 输入;
# PRERELEASE 空 —— push 触发的运行或它的 Re-run,此时**不碰**该标记,
# 否则会把用户手动提升为 stable 的 Release 静默翻回 pre-release。
# 首次创建(create)仍保持「pre-release 默认开」的老契约:空值按 true 处理。
create_mark=()
edit_mark=()
case "${PRERELEASE:-}" in
true)
create_mark=(--prerelease)
edit_mark=(--prerelease)
;;
false)
edit_mark=(--prerelease=false)
;;
"")
create_mark=(--prerelease)
;;
*)
echo "::error::非预期的 inputs.prerelease 取值:$PRERELEASE"
exit 1
;;
esac
# Windows 单 exe 装不下 LICENSE/NOTICE/README.md,这三份与产物并列挂到 Release 上,
# 保证每个下载平台的许可条款都随包分发。
assets=(dist/unlucky-launcher-* dist/SHA256SUMS.txt LICENSE NOTICE README.md)
if gh release view "$TAG" >/dev/null 2>&1; then
# 同 tag 重跑(补丁 / 重编):Release 已存在 → 覆盖资产 + 刷新说明,
# 不再 `create`(那会因已存在而失败,把整条流水线在最后一步染红)。
gh release upload "$TAG" "${assets[@]}" --clobber
gh release edit "$TAG" "${base_flags[@]}" "${edit_mark[@]}"
else
gh release create "$TAG" "${assets[@]}" "${base_flags[@]}" "${create_mark[@]}"
fi
+4
View File
@@ -5,6 +5,10 @@
# Local development docs # Local development docs
/DevDocs/ /DevDocs/
# Local acceptance screenshots (screenshot channel output, never committed)
/.screens/
/.tmp-screens/
# Agent workspace (local-only) # Agent workspace (local-only)
/.tower/ /.tower/
Generated
+1278 -97
View File
File diff suppressed because it is too large. Load diff
+11 -2
View File
@@ -1,6 +1,15 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
members = ["crates/core", "crates/agent", "crates/app", "crates/cli"] members = [
"crates/core",
"crates/agent",
"crates/app",
"crates/cli",
"crates/design",
# P4(D26 iced 重写)第一步:与 Slint 版 `crates/app` 并存的新 UI crate。
# bin 名 `unlucky-launcher-iced`,翻牌时才改名(见 DevDocs/SubAgentsTalk/app-iced-骨架与翻牌.md)。
"crates/app-iced",
]
[workspace.package] [workspace.package]
version = "0.1.0" version = "0.1.0"
@@ -82,7 +91,7 @@ all = { level = "deny", priority = -1 }
[profile.release] [profile.release]
opt-level = "z" opt-level = "z"
lto = "thin" lto = "fat"
codegen-units = 1 codegen-units = 1
panic = "abort" panic = "abort"
strip = "symbols" strip = "symbols"
+27 -2
View File
@@ -18,13 +18,38 @@ Copyright 2026 AskaEth (YeiJ)
Slint 的其余许可分支(GPL-3.0-only / Slint Software License 3.0)本项目不适用。 Slint 的其余许可分支(GPL-3.0-only / Slint Software License 3.0)本项目不适用。
2. Rust crates 生态 2. Google Material Color Utilities(https://github.com/material-foundation/material-color-utilities)
本产品的色彩令牌(crates/design 的 color 模块)是 Material 3 色彩算法(HCT 色彩空间 +
ColorSpec2021 调色板/角色映射)的 **Rust 重写移植**,依据 Apache License 2.0 使用,未把其任何语言的源码作为依赖
引入;除下面注明的那张生成常数表外,也没有复制其代码文件。
实际移植的部分(均为纯算法,逐函数对照后以 Rust 重写):
- `hct/Cam16.java`、`hct/ViewingConditions.java`、`hct/Hct.java` → `crates/design/src/color/cam16.rs`、
`viewing_conditions.rs`、`hct.rs`(CAM16 色相/彩度 + CIELAB `L*` 亮度)
- `hct/HctSolver.java` → `crates/design/src/color/hct_solver.rs` + `critical_planes.rs`
(色域映射求解器;其中 255 项 `CRITICAL_PLANES` 常数表是原样搬运的生成数据)
- `utils/ColorUtils.java`、`utils/MathUtils.java` → `crates/design/src/color/space.rs`
- `palettes/TonalPalette.java`、`dynamiccolor/ColorSpec2021.java`、`dynamiccolor/DynamicScheme.java`
→ `crates/design/src/color/tonal.rs`、`palettes.rs`、`scheme.rs`(Tonal Spot 变体的彩度常数与
49 个角色的 tone 映射)
对齐验证:`crates/design/tests/official_baseline.rs` 与 `tests/official_accent.rs` 的期望色值,
是把上述官方 Java 实现(Apache-2.0)在本机编译运行后输出的结果抄本——合计 290 项逐通道
比对(baseline 49 角色 × 2 模式 = 98 项;强调色 8 个种子 × 12 个强调族角色 × 2 模式 = 192 项),
误差上限 1(实测为 0)。两文件的表头各写着自己的分项口径。
Apache License 2.0 全文见 https://www.apache.org/licenses/LICENSE-2.0
上游版权声明:Copyright Google LLC。
3. Rust crates 生态
cargo-deny 白名单见 deny.toml:Apache-2.0 / MIT / BSD-2-Clause / BSD-3-Clause / Zlib / BSL-1.0 / ISC / cargo-deny 白名单见 deny.toml:Apache-2.0 / MIT / BSD-2-Clause / BSD-3-Clause / Zlib / BSL-1.0 / ISC /
CC0-1.0 / MIT-0 / Unlicense / Unicode-3.0 / CDLA-Permissive-2.0 / Apache-2.0 WITH LLVM-exception。 CC0-1.0 / MIT-0 / Unlicense / Unicode-3.0 / CDLA-Permissive-2.0 / Apache-2.0 WITH LLVM-exception。
GPL 系(含 AGPL、LGPL 静态链接、SSPL)依赖不得进入主干。 GPL 系(含 AGPL、LGPL 静态链接、SSPL)依赖不得进入主干。
3. 参考实现致谢 4. 参考实现致谢
本项目设计参考自 HMCL(https://github.com/HMCL-dev/HMCL)与 PCL2 本项目设计参考自 HMCL(https://github.com/HMCL-dev/HMCL)与 PCL2
(https://github.com/Meloong-Git/PCL),两者均为 GPL-3.0,特此致谢。 (https://github.com/Meloong-Git/PCL),两者均为 GPL-3.0,特此致谢。
+134 -66
View File
@@ -2,55 +2,91 @@
**English** · [简体中文](README.zh-CN.md) **English** · [简体中文](README.zh-CN.md)
> **Official project home**: https://github.com/AskaEth/Unlucky-Launcher | downloads, issues and
> release notes all live there: <https://github.com/AskaEth/Unlucky-Launcher/releases>
Unlucky Launcher (ULL) is a free, open-source Minecraft: Java Edition launcher written in Rust Unlucky Launcher (ULL) is a free, open-source Minecraft: Java Edition launcher written in Rust
(Apache-2.0, non-commercial), with a Slint + Metro UI. It embeds an AI agent client (chat / (Apache-2.0). It ships as one native binary per platform — no installer, no bundled runtime, no
configuration / crash diagnosis) and integrates with the broader agent tooling ecosystem via MCP. WebView. The launcher embeds an AI agent client (chat / configuration / crash diagnosis) and can be
Minimum baseline: Windows 10. attached to other agent tooling as an MCP server.
- Runtime: a single native Rust binary — no JVM, no WebView, no external runtime ## Highlights
- UI: Slint (Royalty-Free 2.0; GL preferred, CPU software-rendering fallback so older machines
without a usable GPU still get the full UI)
- License: Apache-2.0 © AskaEth (YeiJ)
## Compliance statement - **Single-file native binary**: the launcher itself needs no JVM, no WebView and no sidecar
runtime. The JDK a game needs is downloaded and managed per instance, not as a launcher
Authentication uses only official Microsoft/Mojang endpoints — Microsoft OAuth 2.0 device code dependency.
flow, Xbox Live, XSTS, and api.minecraftservices.com — so players sign in with their own Microsoft - **Two UI stacks during the rewrite**: the released UI is Slint (Royalty-Free 2.0); the
account and launch the game they already own. Account tokens are stored only on the user's local next-generation UI is iced 0.14 + Material 3 Expressive and is still being written (see Status).
machine and are never sent to any server operated by the project. ULL does not distribute, mirror, - **CPU software-rendering fallback**: `--software-renderer` keeps the interface rendering
proxy or modify any Minecraft game files. Online play always requires a Microsoft account with normally on machines with no usable GPU or with broken drivers.
valid entitlements. Local offline profiles never contact Microsoft or Mojang services and cannot - **AI agent + MCP**: chat, setting edits and crash diagnosis inside the launcher; `ull agent
join online-mode servers. Not affiliated with Mojang or Microsoft. serve` exposes the same core as an MCP server (stdio or Streamable HTTP) to external agent tools.
- **Mod loaders**: Fabric, Quilt, NeoForge, Forge, with modpack and CurseForge / Modrinth clients in
the core.
- **Java management**: a Temurin → Oracle → Microsoft → Zulu fallback chain, region-aware.
- **Accounts**: sign in with a Microsoft account over OAuth 2.0 device code flow → Xbox Live →
XSTS → `api.minecraftservices.com`; account tokens are kept on this machine only.
- **Engine**: multi-source download (health scoring, mirrors, chunked transfers, hash while
downloading) and a YAML rule-based crash diagnosis engine.
- **License**: Apache-2.0 © AskaEth (YeiJ).
## Status ## Status
The core engine is complete (workspace: 983+ tests passing): Two tracks. They are not the same product yet, so read the headings before downloading anything.
- **Microsoft sign-in**: OAuth 2.0 device code flow → Xbox Live → XSTS → Minecraft Services, with ### Track A — released: v0.1.0, Slint UI (this is what you can download today)
entitlement verification
- **Account credentials are stored only on the local machine** — never uploaded, and inaccessible
to the AI features
- Mod loaders: Forge / NeoForge / Fabric / Quilt, verified on real machines
- Java management: Temurin / Oracle / Microsoft / Zulu with a regional fallback chain
- Multi-source download engine; YAML rule-based crash diagnosis
- Usable as an MCP server for external agent tooling (`ull agent serve`)
- Local offline profiles: for local / isolated-network testing only; they perform no Microsoft
authentication and cannot join servers with online-mode enabled
**CLI (`ull`) is fully usable**: `install / launch / accounts / java / diagnose / agent`. - **GitHub Releases `v0.1.0`** (2026-11-01, flagged pre-release): single-file assets for Windows
x64 (a bare `.exe`), Linux x64 and macOS (arm64 and x64) `.tar.gz`. Every release also carries
`SHA256SUMS.txt` and the `LICENSE` / `NOTICE` / `README.md` texts as separate assets; on Windows
those texts sit next to the `.exe` because a bare exe cannot hold them, while the unix archives
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`.
- 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.
**GUI in development**: `crates/app` is currently a Slint + Metro skeleton; the theme system and ### Track B — in progress on `dev`: next-generation UI (iced + Material 3 Expressive)
the full interface ship with subsequent versions — use the CLI until then.
**This track is not released yet — you can see it only by building from source.**
- **Locked direction**: the look is Material 3 Expressive and the framework is iced 0.14; the UI
layer is written from scratch. Neither the style nor the framework will change again.
- **Done**: the token crate `crates/design` (no UI framework, no third-party crates — real HCT
color, type scale, shape scale, spacing, elevation, motion, icon geometry, per-component
metrics) and the skeleton crate `crates/app-iced` (opens a window, self-drawn title bar and
window buttons, navigation rail, colors and type taken from tokens only, GPU→CPU backend probe
with automatic fallback, and the screenshot/diff channel the visual reviews use).
- **Not done**: the component library (buttons, dialogs, lists, menus, search, and the rest of the
M3 set), page migration, and the i18n pass. Every page in the iced binary is a placeholder.
- **Slint stays until the flip**: `crates/app` remains the reference implementation until the iced
build passes equivalence acceptance; only then is `unlucky-launcher-iced` renamed to
`unlucky-launcher` and the Slint crate retired.
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 |
## Module map ## Module map
``` ```
Unlucky-Launcher (cargo workspace) Unlucky-Launcher (cargo workspace)
├── crates/core # domain library, zero UI dependencies, eight modules ├── crates/core # domain library: zero UI dependencies, eight modules
├── crates/agent # AI agent: ToolSpec registry + built-in client + MCP server (one shared core) ├── crates/agent # AI agent: ToolSpec registry + built-in client + MCP server (one core, two shapes)
├── crates/app # Slint UI: the only crate allowed to depend on a UI framework ├── crates/design # design tokens: real HCT color / type / shape / spacing / elevation / motion / icons
├── crates/cli # CLI (bin `ull`): headless, zero UI, depends on core + agent │ # / component metrics — zero UI dependencies, no third-party crates
└── packaging # single-file artifact matrix and packaging assets (planned) ├── crates/app # Slint UI (bin `unlucky-launcher`): the released GUI, kept until equivalence acceptance
├── crates/app-iced # next-generation iced UI (bin `unlucky-launcher-iced`): under construction
├── crates/cli # CLI (bin `ull`): headless, zero UI
└── packaging # single-file artifact matrix and release scripts
``` ```
The eight modules of `crates/core`: The eight modules of `crates/core`:
@@ -61,7 +97,7 @@ The eight modules of `crates/core`:
| `download` | multi-source health scoring, mirrors, chunked downloads, hash-while-downloading | | `download` | multi-source health scoring, mirrors, chunked downloads, hash-while-downloading |
| `game` | version / instance repository, snapshots and inheritance resolution | | `game` | version / instance repository, snapshots and inheritance resolution |
| `launch` | 10-stage launch pipeline, processes, JSONL event stream | | `launch` | 10-stage launch pipeline, processes, JSONL event stream |
| `auth` | Microsoft sign-in / local offline profiles | | `auth` | Microsoft sign-in (OAuth 2.0 device code → Xbox Live → XSTS → Minecraft Services) |
| `addon` | mod loader installation, modpacks, CurseForge / Modrinth | | `addon` | mod loader installation, modpacks, CurseForge / Modrinth |
| `diagnose` | five signal collectors, YAML rule engine, hs_err parsing | | `diagnose` | five signal collectors, YAML rule engine, hs_err parsing |
| `platform` | OS abstraction: paths, processes, file locks, capability probing, runtime OS detection | | `platform` | OS abstraction: paths, processes, file locks, capability probing, runtime OS detection |
@@ -70,53 +106,85 @@ The eight modules of `crates/core`:
Prerequisites: Prerequisites:
- Rust stable (`rust-toolchain.toml` selects the toolchain automatically; components `rustfmt` / - Rust stable (`rust-toolchain.toml` selects the toolchain automatically; the gates also need the
`clippy`) `rustfmt` and `clippy` components)
- Windows: MSVC toolchain (used by CI) or MinGW-w64 (`x86_64-pc-windows-gnu`) - Windows: MSVC toolchain (what CI uses) or MinGW-w64 (`x86_64-pc-windows-gnu`)
- Linux: `libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev` - Linux: `libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev libwayland-dev
(Slint winit backend + femtovg build dependencies) libxcb1-dev` — needed by both UI stacks (Slint's winit + femtovg, iced's winit + wgpu/tiny-skia)
```bash ```bash
cargo fmt --all --check # formatting cargo fmt --all --check # formatting
cargo clippy --workspace --all-targets -- -D warnings # lints (zero-warning gate) cargo clippy --workspace --all-targets -- -D warnings # lints (zero-warning gate)
cargo test --workspace # tests cargo test --workspace # tests
cargo build --release # artifact: target/release/unlucky-launcher[.exe] cargo build --release # binaries land in target/release/
``` ```
Software-rendering switch (machines without a usable GPU / with broken drivers): What the build produces:
`unlucky-launcher --software-renderer` (equivalent to `SLINT_BACKEND=winit-software`).
- `unlucky-launcher` — the Slint UI (the released one)
- `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
Rendering switches:
- `unlucky-launcher --software-renderer` (equivalent to `SLINT_BACKEND=winit-software`) forces the
CPU path on the Slint build
- `unlucky-launcher-iced --software-renderer` forces tiny-skia (`ICED_BACKEND=tiny-skia`);
`--screenshot <png>` writes one frame and exits, which is the acceptance channel
- Both stacks fall back to the CPU path when no usable adapter is there: the Slint build through its
own renderer fallback chain, the iced build through its explicit probe
Dependency license check (optional locally, required in CI): Dependency license check (optional locally, required in CI):
`cargo deny check licenses bans sources`. `cargo deny check licenses bans sources`.
## CI gates ## CI gates
Every push to `main` and every pull request runs fmt / clippy (warnings are errors) / the full test
suite, and the Linux headless render smoke is a hard gate on the same footing: a red result blocks
the merge. The macOS runtime probe is not there yet — it only runs on a manual `workflow_dispatch`
and its result is recorded, not enforced (`continue-on-error`), until the slot passes a first run on
a real runner.
| Workflow | Content | | Workflow | Content |
|----------|---------| |----------|---------|
| `.github/workflows/ci.yml` | fmt + clippy + test + build, Windows / Linux matrix | | `.github/workflows/ci.yml` | fmt + clippy + test + release build (Windows / Linux matrix); Linux runtime smoke (Xvfb: renders one real frame, then asserts the PNG is not blank); macOS runtime probe (manual dispatch only) |
| `.github/workflows/guards.yml` | zero-UI guard for core, 400-line-per-file guard, cargo-deny license guard | | `.github/workflows/guards.yml` | zero-UI guard for `crates/core` (manifest plus `cargo tree`), 400-lines-per-file guard, cargo-deny license and source guard |
| `.github/workflows/release.yml` | tag `v*` → per-slot builds → single-file artifacts + `SHA256SUMS.txt` → GitHub Release |
## Platform baseline ## Platform baseline
Minimum baseline: **Windows 10**. Windows 8.1 / 8 / 7 are not supported: Minimum baseline: **Windows 10**; Windows 8.1 / 8 / 7 are not supported.
- Windows 10/11 builds use the **standard toolchain** (MSVC / GNU) — no custom target specs, no - Windows 10/11 builds use the **standard toolchain** (MSVC / GNU), built the same way as
`-Zbuild-std`, no import-table allowlists upstream cargo
- Other platforms: macOS 12+ first class; Linux x64 with X11 preferred, Wayland as the long-term - macOS 12+ (Apple Silicon and Intel) and Linux x64 / arm64 are supported build slots; on Linux,
direction; Android Termux experimental, not committed X11 is the preferred path today and Wayland is the long-term direction. macOS builds are unsigned,
- UI fallback: Slint software rendering (fully usable on machines without a GPU or with broken so the first launch needs the Gatekeeper quarantine attribute cleared
drivers) (`xattr -d com.apple.quarantine`)
- Platform independence is expressed through **separate test matrices**, not separate artifact - Android Termux is experimental and not promised: CLI only, GUI unverified
pipelines; capability differences use feature gates with runtime probing - Each platform runs its own test matrix; capability differences use feature gates with runtime
probing.
## License and acknowledgements ## License and acknowledgements
- This project: Apache License 2.0, see [LICENSE](LICENSE); third-party notices in [NOTICE](NOTICE) - **This project**: Apache License 2.0, see [LICENSE](LICENSE); third-party notices in
- The GUI uses [Slint](https://slint.dev) (Royalty-Free 2.0 track). Per the RF 2.0 obligations an [NOTICE](NOTICE).
`AboutSlint` widget will be placed on the about page (P2); until then NOTICE and this section - **Slint** (<https://slint.dev>), Royalty-Free 2.0: obligation for the Slint build that v0.1.0
serve the attribution duty. ships — an `AboutSlint` widget on the about page (still outstanding) and, until it lands, the
NOTICE file, this section and a "Made with Slint" note on the release page. The obligation is
kept for as long as a Slint-linked binary ships.
- **iced** (<https://github.com/iced-rs/iced>), MIT: the framework of the next-generation UI,
together with `winit` and, through it, `wgpu` / `tiny-skia` (Apache-2.0 / MIT).
- **Google Material Color Utilities** (Apache-2.0): the color tokens in `crates/design` are a Rust
port of its HCT / ColorSpec2021 algorithms; details and the exact ported functions are listed in
[NOTICE](NOTICE).
- **Other major dependencies**: `rmcp` and `axum` (Apache-2.0 / MIT) for the MCP server, `tokio`
(MIT), `reqwest` with rustls (MIT / Apache-2.0), `serde` / `serde_json` (MIT / Apache-2.0),
`zip` / `flate2` / `tar` (MIT / Apache-2.0). The allow-list cargo-deny enforces is in `deny.toml`;
GPL-family dependencies may not enter the main line.
Thanks to [HMCL](https://github.com/HMCL-dev/HMCL) and Thanks to [HMCL](https://github.com/HMCL-dev/HMCL) and [PCL2](https://github.com/Meloong-Git/PCL)
[PCL2](https://github.com/Meloong-Git/PCL) (both GPL-3.0) — these two launchers have accompanied (both GPL-3.0). They have been with the vast majority of Minecraft players for years, and this
the vast majority of Minecraft players for many years. ULL learned a great deal about design and project has benefited a great deal from their design choices and implementation details.
implementation from them, and this project is dedicated to those two pioneers.
+113 -44
View File
@@ -2,39 +2,81 @@
[English](README.md) · **简体中文** [English](README.md) · **简体中文**
Rust 原生单文件 Minecraft 启动器:**Slint + Metro 视觉**,内置 **AI Agent 客户端**(对话 / 改配置 / 崩溃诊断), > **官方项目主页**:https://github.com/AskaEth/Unlucky-Launcher | 下载、议题、发布说明都在这里:
融合主流 Agent 工具(**MCP**),**最低基线 Win10+**。 > <https://github.com/AskaEth/Unlucky-Launcher/releases>
- 运行时:纯 Rust 原生二进制——无 JVM、无 WebView、无外部运行时 Unlucky Launcher(ULL)是一个用 Rust 写的免费开源 Minecraft: Java 版启动器(Apache-2.0)。
- UI:Slint(Royalty-Free 2.0;GL 优先,CPU 软渲染兜底,无可用 GPU 的老机也有完整 UI) 每个平台就一个原生二进制——没有安装包、没有捆绑运行时、没有 WebView。启动器内置 AI Agent
- 许可:Apache-2.0 © AskaEth (YeiJ) 客户端(对话 / 改配置 / 崩溃诊断),也能作为 MCP server 接进别的 Agent 工具。
## 特性
- **单文件原生二进制**:启动器本体不需要 JVM、不需要 WebView、不带旁挂运行时。游戏要的 JDK 由
启动器按实例下载管理,不是启动器的依赖。
- **重写期两套 UI 并存**:已发布版本是 Slint(Royalty-Free 2.0);下一代 UI 是 iced 0.14 +
Material 3 Expressive,目前仍在写(见「当前状态」)。
- **CPU 软渲染兜底**:`--software-renderer` 让没有可用 GPU 或驱动有问题的老机也能正常显示界面。
- **AI Agent + MCP**:启动器内可对话、改设置、读崩溃诊断;`ull agent serve` 把同一个内核以 MCP
server 形态(stdio 或 Streamable HTTP)开放给外部 Agent 工具。
- **加载器**:Fabric / Quilt / NeoForge / Forge;整合包与 CurseForge / Modrinth 客户端在 core 里。
- **Java 管理**:Temurin → Oracle → Microsoft → Zulu 回退链,带地区判定。
- **账户**:用 Microsoft 账户登录,认证走 OAuth 2.0 device code flow → Xbox Live → XSTS →
`api.minecraftservices.com`;账户令牌只保存在本机。
- **引擎**:多源下载(健康度评分、镜像、分块传输、边下边算)与 YAML 规则引擎的崩溃诊断。
- **许可**:Apache-2.0 © AskaEth (YeiJ)。
## 当前状态 ## 当前状态
**核心引擎已完成**(workspace 983+ 测试全绿): 两条轨道,还不是同一个东西——看小标题再决定下载什么。
- **正版登录**:Microsoft OAuth 2.0 device code flow → Xbox Live → XSTS → Minecraft Services,校验游戏拥有状态 ### 轨道 A —— 已发布:v0.1.0,Slint 版(现在能下载的就是它)
- **账户凭据仅保存在本机**,不上传任何服务器,AI 功能无法访问
- 加载器:Forge / NeoForge / Fabric / Quilt 四家真机验证
- Java 管理:Temurin / Oracle / Microsoft / Zulu 四发行版回退链
- 多源下载引擎、崩溃诊断规则引擎
- 可作为 MCP server 接入外部 Agent 工具(`ull agent serve`)
- 本地离线档案:仅用于本地/隔离网络环境的测试,不经过 Microsoft 账户认证,无法连接启用正版验证的服务器
**CLI(`ull`)完整可用**:`install / launch / accounts / java / diagnose / agent` 等全量命令。 - **GitHub Releases `v0.1.0`**(2026-11-01,标记为 pre-release):单文件产物覆盖 Windows x64(裸
`.exe`)、Linux x64、macOS(arm64 与 x64)`.tar.gz`。每个 Release 另挂 `SHA256SUMS.txt` 与
`LICENSE` / `NOTICE` / `README.md` 三份文本——Windows 是「一个 exe 装不下文本」所以并列挂出,
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`。
- Windows ARM64 与 Linux ARM64 两个槽位已接进发布流水线,随下一个 tag 发布;`v0.1.0` 的资产早于
这两个槽位。
**图形界面(GUI)开发中**:`crates/app` 当前为 Slint + Metro 风格骨架,主题系统与完整界面 ### 轨道 B —— dev 上的下一代 UI(iced + M3 Expressive,施工中)
随后续版本交付——在此之前请使用 CLI。
**这条轨道还没发布——要看只能自己从源码编。**
- **方向已锁死**:视觉 = Material 3 Expressive,框架 = iced 0.14,UI 层从头写;风格与框架都不会再换。
- **已完成**:令牌 crate `crates/design`(零 UI 框架依赖、零第三方 crate——真 HCT 色彩、排版档位、
形状刻度、间距、高程、动效、图标几何、组件度量)与骨架 crate `crates/app-iced`(能开窗、自绘标题栏
与窗口键、导航轨、颜色与字号只从令牌取、GPU→CPU 后端探测与自动降级、视觉验收用的截图与逐像素
对比通道)。
- **没完成**:组件库(按钮、对话框、列表、菜单、搜索栏,以及 M3 其余组件)、页面搬迁、i18n 文案。
iced 版里现在每一个页面都是占位。
- **Slint 版在翻牌前不退场**:`crates/app` 作为参考实现保留,直到 iced 版通过等价验收;那时才把
`unlucky-launcher-iced` 改名成 `unlucky-launcher`,并让 Slint 版退役。
工作区里的三个 bin,先认清楚:
| 二进制 | 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 (cargo workspace) Unlucky-Launcher (cargo workspace)
├── crates/core # 领域库:零 UI 依赖,八模块 ├── crates/core # 领域库:零 UI 依赖,八模块
├── crates/agent # AI Agent:ToolSpec 注册表 + 内置客户端 + MCP server(双形态共用一个内核) ├── crates/agent # AI Agent:ToolSpec 注册表 + 内置客户端 + MCP server(双形态共用一个内核)
├── crates/app # Slint UI:唯一允许依赖 UI 框架的 crate ├── crates/design # 设计令牌:真 HCT 色彩 / 排版 / 形状 / 间距 / 高程 / 动效 / 图标 / 组件度量
├── crates/cli # CLI(bin `ull`):headless,零 UI,只依赖 core + agent │ # —— 零 UI 依赖、零第三方 crate
└── packaging # 单文件产物矩阵与打包资源(规划中) ├── crates/app # Slint UI(bin `unlucky-launcher`):已发布的图形界面,保留到等价验收通过
├── crates/app-iced # 下一代 iced UI(bin `unlucky-launcher-iced`):施工中
├── crates/cli # CLI(bin `ull`):headless,零 UI
└── packaging # 单文件产物矩阵与发布脚本
``` ```
`crates/core` 的八个模块: `crates/core` 的八个模块:
@@ -45,7 +87,7 @@ Unlucky-Launcher (cargo workspace)
| `download` | 多源健康度、镜像、分块下载、边下边算 | | `download` | 多源健康度、镜像、分块下载、边下边算 |
| `game` | 版本/实例仓库、快照与继承解析 | | `game` | 版本/实例仓库、快照与继承解析 |
| `launch` | 10 阶段启动管线、进程、JSONL 事件流 | | `launch` | 10 阶段启动管线、进程、JSONL 事件流 |
| `auth` | Microsoft 正版登录 / 本地离线档案 | | `auth` | Microsoft 账户登录(OAuth 2.0 device code → Xbox Live → XSTS → Minecraft Services) |
| `addon` | 加载器安装、整合包、CurseForge / Modrinth | | `addon` | 加载器安装、整合包、CurseForge / Modrinth |
| `diagnose` | 五类信号采集、YAML 规则引擎、hs_err 解析 | | `diagnose` | 五类信号采集、YAML 规则引擎、hs_err 解析 |
| `platform` | OS 抽象:路径、进程、文件锁、能力探测、运行期 OS 检测 | | `platform` | OS 抽象:路径、进程、文件锁、能力探测、运行期 OS 检测 |
@@ -54,44 +96,71 @@ Unlucky-Launcher (cargo workspace)
前置: 前置:
- Rust stable(`rust-toolchain.toml` 会自动选择工具链;组件 `rustfmt` / `clippy`) - Rust stable(`rust-toolchain.toml` 会自动选工具链;门禁还需要 `rustfmt` / `clippy` 组件)
- Windows:MSVC 工具链(CI 用)或 MinGW-w64(`x86_64-pc-windows-gnu`) - Windows:MSVC 工具链(CI 用这套)或 MinGW-w64(`x86_64-pc-windows-gnu`)
- Linux:`libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev` - Linux:`libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev libwayland-dev
(Slint winit 后端 + femtovg 编译依赖) libxcb1-dev`——两套 UI 都要用(Slint 的 winit + femtovg,iced 的 winit + wgpu/tiny-skia)
```bash ```bash
cargo fmt --all --check # 格式 cargo fmt --all --check # 格式
cargo clippy --workspace --all-targets -- -D warnings # 静态检查(零 warning 门禁) cargo clippy --workspace --all-targets -- -D warnings # 静态检查(零 warning 门禁)
cargo test --workspace # 测试 cargo test --workspace # 测试
cargo build --release # 产物:target/release/unlucky-launcher[.exe] cargo build --release # 三个 bin 都落在 target/release/
``` ```
软渲染开关(无可用 GPU / 烂驱动的老机):`unlucky-launcher --software-renderer` 编出来的三个 bin:
(等价于 `SLINT_BACKEND=winit-software`)。
- `unlucky-launcher`——Slint 版(已发布的那个)
- `unlucky-launcher-iced`——iced 版骨架。名字是临时的:只有翻牌时才能改成 `unlucky-launcher`,
因为同一个工作区里两个 crate 不能往同一个 target 目录里产出同名 bin。
- `ull`——CLI
渲染开关:
- `unlucky-launcher --software-renderer`(等价于 `SLINT_BACKEND=winit-software`)强制 Slint 版走 CPU
- `unlucky-launcher-iced --software-renderer` 强制 tiny-skia(`ICED_BACKEND=tiny-skia`);
`--screenshot <png>` 出一帧图后退出,这是验收通道
- 两套栈在没有可用适配器时都会退到 CPU:Slint 版走它自己的渲染器回退链,iced 版走显式探测
依赖许可检查(本地可选,CI 必跑):`cargo deny check licenses bans sources`。 依赖许可检查(本地可选,CI 必跑):`cargo deny check licenses bans sources`。
## CI 门禁 ## CI 门禁
每次推送到 `main` 与每个 PR 都跑 fmt / clippy(warnings 视为错误)/ 全量测试,Linux 无头渲染冒烟
与它们同级、是硬门禁:红了就不许合。macOS 运行期探针还不算——它只在手动 `workflow_dispatch` 时跑,
结果留证据但不作为流水线结论(`continue-on-error`),直到该槽位在真实 runner 上首跑通过。
| Workflow | 内容 | | Workflow | 内容 |
|----------|------| |----------|------|
| `.github/workflows/ci.yml` | fmt + clippy + test + build,Windows / Linux 矩阵 | | `.github/workflows/ci.yml` | fmt + clippy + test + release build(Windows / Linux 矩阵);Linux runtime smoke(Xvfb 里真渲染一帧,再断言 PNG 非空白);macOS 运行期探针(仅手动 dispatch) |
| `.github/workflows/guards.yml` | core 零 UI 守门、单文件 ≤400 行守门、cargo-deny 许可守门 | | `.github/workflows/guards.yml` | `crates/core` 零 UI 守门(依赖表 + `cargo tree`)、单文件 ≤400 行守门、cargo-deny 许可与来源守门 |
| `.github/workflows/release.yml` | tag `v*` → 分槽位构建 → 单文件产物 + `SHA256SUMS.txt` → GitHub Release |
## 平台基线 ## 平台基线
最低基线 **Windows 10**。Windows 8.1 / 8 / 7 不在支持范围内: 最低基线 **Windows 10**;Windows 8.1 / 8 / 7 不在支持范围内。
- Windows 10/11 走**标准工具链**产物(MSVC / GNU),无自定义 target spec、无 `-Zbuild-std`、无导入表白名单 - Windows 10/11 的产物由**标准工具链**(MSVC / GNU)构建,构建方式与上游 cargo 一致
- 其他平台:macOS 12+ 一等;Linux x64 X11 优先、Wayland 为**大方向**;Android Termux 试验级不承诺 - macOS 12+(Apple Silicon 与 Intel)与 Linux x64 / arm64 都是支持槽位;Linux 上 X11 是当前首选路径,
- UI 兜底:Slint 软渲染(无 GPU / 烂驱动的老机可完整使用) Wayland 是大方向。macOS 产物未签名,首次启动要自己清掉 Gatekeeper 隔离标记
- 平台独立性体现在**独立测试矩阵**,而非独立产物链;能力差异用 feature gate + 运行期探测表达 (`xattr -d com.apple.quarantine`)
- Android Termux 试验级、不承诺:只保证 CLI,GUI 未验证
- 每个平台跑各自的测试矩阵;能力差异用 feature gate + 运行期探测表达。
## 许可证与致谢 ## 许可证与致谢
- 本项目:Apache License 2.0,见 [LICENSE](LICENSE);第三方声明见 [NOTICE](NOTICE) - **本项目**:Apache License 2.0,见 [LICENSE](LICENSE);第三方声明见 [NOTICE](NOTICE)。
- GUI 使用 [Slint](https://slint.dev)(Royalty-Free 2.0 轨道)。按 RF 2.0 义务,关于页将放置 - **Slint**(<https://slint.dev>),Royalty-Free 2.0:v0.1.0 发布的正是 Slint 版,这项义务继续有效
`AboutSlint` 部件(P2),在此之前由 NOTICE 与本节承担告知义务 ——关于页放 `AboutSlint` 部件(尚未落地),在此之前由 NOTICE、本节与发布页的「Made with Slint」
说明承担告知义务。只要还有链接 Slint 的产物在发,这条义务就保留。
- **iced**(<https://github.com/iced-rs/iced>),MIT:下一代 UI 的框架;与它同栈的 `winit`、以及经它
引入的 `wgpu` / `tiny-skia`(Apache-2.0 / MIT)。
- **Google Material Color Utilities**(Apache-2.0):`crates/design` 的色彩令牌是它 HCT /
ColorSpec2021 算法的 Rust 重写移植,移植清单与逐函数对照写在 [NOTICE](NOTICE)。
- **其余主要依赖**:MCP server 的 `rmcp` 与 `axum`(Apache-2.0 / MIT)、`tokio`(MIT)、`reqwest`
与 rustls(MIT / Apache-2.0)、`serde` / `serde_json`(MIT / Apache-2.0)、`zip` / `flate2` / `tar`
(MIT / Apache-2.0)。cargo-deny 强制执行的白名单在 `deny.toml`;GPL 系依赖不得进主干。
感谢 [HMCL](https://github.com/HMCL-dev/HMCL) 和 [PCL2](https://github.com/Meloong-Git/PCL)(均 GPL-3.0)这两个启动器陪伴了绝大多数 MC 玩家好多年。 感谢 [HMCL](https://github.com/HMCL-dev/HMCL) 和 [PCL2](https://github.com/Meloong-Git/PCL)
ULL 的设计与实现从它们身上学到了太多,谨以此项目向两位前辈致敬。 (均 GPL-3.0)。这两款启动器陪着绝大多数 MC 玩家走了很多年,
本项目在设计取舍与实现细节上都从中受益良多。
+51
View File
@@ -0,0 +1,51 @@
[package]
name = "unlucky-app-iced"
description = "Unlucky-Launcher 桌面壳(iced 0.14 重写版):M3 Expressive 骨架 / 令牌适配 / 截图通道"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
rust-version.workspace = true
publish.workspace = true
# 并存期(D26):Slint 版仍是 `crates/app` 的 `unlucky-launcher`,本 crate 出
# `unlucky-launcher-iced`。两个 package 在同一 target 目录产出同名 bin 会冲突,
# 所以**翻牌前不许改回 `unlucky-launcher`**(步骤见 SubAgentsTalk/app-iced-骨架与翻牌.md)。
[[bin]]
name = "unlucky-launcher-iced"
path = "src/main.rs"
[lib]
name = "unlucky_app_iced"
path = "src/lib.rs"
[dependencies]
# 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。
unlucky-design = { path = "../design", version = "0.1" }
# UI 框架(D26 锁定 iced 0.14)。**不得依赖 slint**。
# 关掉默认 features 后必须显式给执行器(M60 踩坑 Q2:否则 `No futures executor has been enabled`),
# 这里给 `thread-pool`(iced 自己的默认执行器,不额外引 tokio 运行时)。
# `wgpu` 由本 crate 的 `gpu` feature 打开,于是「仅 CPU」体积基线用 `--no-default-features` 就能量到。
iced = { version = "=0.14.0", default-features = false, features = [
"tiny-skia",
"crisp",
"advanced",
"svg",
"thread-pool",
"x11",
"wayland",
"linux-theme-detection",
] }
# 截图落 PNG。只开 png 编码器,不要默认全套 codec(体积)。
image = { version = "0.25", default-features = false, features = ["png"] }
[features]
default = ["gpu"]
# 硬件渲染后端(wgpu→Vulkan/DX12)。关闭后只剩 tiny-skia(CPU),用于体积基线与无 GPU 机器。
gpu = ["iced/wgpu"]
[lints]
workspace = true
+400
View File
@@ -0,0 +1,400 @@
//! Elm 三件套:`State` / `Message` / `update` + `view` 接线。
//!
//! ## 启动序列(顺序有讲究)
//!
//! 1. **探测渲染后端** → 写 `ICED_BACKEND`(必须在 `run()` 之前,M60 Q1);
//! 2. **求值字体栈**(D28 前半)→ 一行启动日志;
//! 3. `iced::application(..).run()`:窗口 1180×760、无边框(自绘标题栏);
//! 4. 首个 `view()` 打「到首帧」耗时;截图模式下第 3 帧 `window::screenshot` 落盘后退出。
//!
//! ## 为什么截图要等 3 帧
//!
//! 无边框窗口启动时会先收到一次「显示器工作区尺寸」的 `Resized`,再回到设定尺寸(M60 坑 #5),
//! 第一帧可能是旧尺寸;再叠上字体系统首次上屏的排版抖动,留 2 帧余量最稳。
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::OnceLock;
use std::time::Instant;
use iced::{window, window::Direction, Element, Subscription, Task};
use unlucky_design::color::SchemeFlavor;
use crate::cli::Options;
use crate::fonts;
use crate::probe;
use crate::shell::{self, NavItem};
use crate::shot;
use crate::theme::{Families, Ui};
/// 验收窗口尺寸(1180×760,100% DPI —— D30 的硬要求)。
pub const WINDOW_SIZE: iced::Size = iced::Size {
width: 1180.0,
height: 760.0,
};
/// 产品名(自绘标题栏的标题;也是 `'static`,好让 view 借用)。
pub const APP_TITLE: &str = "Unlucky Launcher";
/// 窗口最小尺寸(低于这个值骨架会挤坏:88 导航 + 页面留白 + 卡片)。
const MIN_SIZE: iced::Size = iced::Size {
width: 880.0,
height: 560.0,
};
/// 截图落盘的帧号(见模块文档)。
const CAPTURE_AT_FRAME: u32 = 3;
/// 进程内「run() 开始」的时刻(首帧耗时基线)。
static STARTED: OnceLock<Instant> = OnceLock::new();
/// 首帧日志只打一次。
static FIRST_FRAME_LOGGED: AtomicBool = AtomicBool::new(false);
/// 应用状态(**只有 UI 状态**;业务状态等页面 mission 接 `host`/`state` 时再进)。
pub struct App {
options: Options,
families: Families,
ui: Ui,
nav: NavItem,
window: Option<window::Id>,
frames: u32,
/// 截图模式的「主题已定」闸门:`system::theme()` 是异步的,早拍会拍到错的深浅色。
theme_resolved: bool,
}
/// 消息(单一大枚举;页面接进来时按页拆子枚举)。
#[derive(Debug, Clone)]
pub enum Message {
/// 启动即发一次(`iced::application` 的 boot 闭包)。
Boot,
/// `window::latest()` 的结果。
WindowReady(Option<window::Id>),
/// 系统主题探测结果。
SystemTheme(iced::theme::Mode),
/// 一帧画完(只有截图模式才订阅)。
Frame,
/// 导航项切换。
Select(NavItem),
/// 按下标题栏 → 拖窗。
DragWindow,
/// 按下边缘热区 → 缩放窗口。
ResizeStart(Direction),
/// 最小化。
Minimize,
/// 最大化/还原。
ToggleMaximize,
/// 关闭。
Close,
/// 截图回来了。
Captured(window::Screenshot),
}
impl App {
fn new(options: Options, families: Families, flavor: SchemeFlavor) -> Self {
let options_theme_given = options.shot_flavor().is_some();
Self {
ui: Ui::new(flavor, families),
families,
options,
nav: NavItem::Instances,
window: None,
frames: 0,
// 显式给了主题就算已定;否则等 `SystemTheme` 到达
theme_resolved: options_theme_given,
}
}
fn has_screenshot(&self) -> bool {
self.options.screenshot.is_some()
}
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::Boot => {
let mut tasks = vec![window::latest().map(Message::WindowReady)];
// 主题:参数里没指定就探系统(深浅两套都要能出图)
if self.options.shot_flavor().is_none() {
tasks.push(iced::system::theme().map(Message::SystemTheme));
}
Task::batch(tasks)
}
Message::WindowReady(id) => {
self.window = id;
Task::none()
}
Message::SystemTheme(mode) => {
let flavor = match mode {
iced::theme::Mode::Dark => SchemeFlavor::Dark,
iced::theme::Mode::None | iced::theme::Mode::Light => SchemeFlavor::Light,
};
eprintln!("[theme] 系统主题 = {mode:?} -> {}", slug(flavor));
self.set_flavor(flavor);
self.theme_resolved = true;
Task::none()
}
Message::Frame => self.on_frame(),
Message::Select(nav) => {
self.nav = nav;
Task::none()
}
Message::DragWindow => match self.window {
Some(id) => window::drag(id),
None => Task::none(),
},
Message::ResizeStart(direction) => match self.window {
Some(id) => window::drag_resize(id, direction),
None => Task::none(),
},
Message::Minimize => match self.window {
Some(id) => window::minimize(id, true),
None => Task::none(),
},
Message::ToggleMaximize => match self.window {
Some(id) => window::toggle_maximize(id),
None => Task::none(),
},
Message::Close => iced::exit(),
Message::Captured(screenshot) => self.on_captured(&screenshot),
}
}
/// 切主题:只重建 [`Ui`](= 重建 Style),**不动任何 UI 状态**。
fn set_flavor(&mut self, flavor: SchemeFlavor) {
if self.ui.flavor() == flavor {
return;
}
let next = Ui::new(flavor, self.families);
eprintln!(
"[theme] {} -> {} (Theme::name 变化触发缓存重算)",
self.ui.name(),
next.name()
);
self.ui = next;
}
fn on_frame(&mut self) -> Task<Message> {
self.frames += 1;
// 主题没定就不出图:等 `SystemTheme`(否则暗色系统上会拍到浅色图)
if !self.has_screenshot() || !self.theme_resolved || self.frames < CAPTURE_AT_FRAME {
return Task::none();
}
match self.window {
Some(id) => window::screenshot(id).map(Message::Captured),
None => Task::none(),
}
}
fn on_captured(&mut self, screenshot: &window::Screenshot) -> Task<Message> {
let Some(path) = self.options.screenshot.clone() else {
return Task::none();
};
match shot::save(screenshot, &path) {
Ok(summary) => {
eprintln!("{}", summary.describe());
eprintln!(
"[shot] theme={} renderer={}",
shot::flavor_slug(&self.ui),
renderer_note(self.options.backend)
);
}
Err(error) => eprintln!("[shot] 失败:{error}"),
}
iced::exit()
}
fn subscription(&self) -> Subscription<Message> {
// 只有截图模式才订阅帧事件:否则「每帧一条消息 → 每帧重绘」会白烧 CPU。
if self.wants_frames() {
window::frames().map(|_| Message::Frame)
} else {
Subscription::none()
}
}
/// 是否订阅帧事件(= 截图模式)。
fn wants_frames(&self) -> bool {
self.has_screenshot()
}
fn theme(&self) -> iced::Theme {
self.ui.theme()
}
fn title(&self) -> String {
APP_TITLE.to_owned()
}
fn view(&self) -> Element<'_, Message> {
if !FIRST_FRAME_LOGGED.swap(true, Ordering::Relaxed) {
if let Some(started) = STARTED.get() {
eprintln!(
"[boot] first frame in {:.0} ms(run() 开始 → 首次 view,含后端探测与字体扫描)",
started.elapsed().as_secs_f64() * 1000.0
);
}
}
shell::view(
&self.ui,
self.nav,
APP_TITLE,
self.nav.label(),
"Skeleton only: titlebar, navigation rail and one card. \
Business pages, the component library and the glass shader are follow-up missions.",
self.options.force_state,
)
}
}
fn renderer_note(backend: crate::Backend) -> &'static str {
if cfg!(feature = "gpu") {
match backend {
crate::Backend::Cpu => "cpu(forced)",
crate::Backend::Gpu => "gpu(preferred)",
crate::Backend::Auto => "auto",
}
} else {
"cpu(build without wgpu)"
}
}
/// 主题名(日志用,和 `Ui::name()` 同一口径)。
fn slug(flavor: SchemeFlavor) -> &'static str {
if flavor.is_dark() {
"ull-m3-dark"
} else {
"ull-m3-light"
}
}
/// 启动 iced(阻塞到退出)。`--diff` 不走这里,见 `main.rs`。
pub(crate) fn run(options: Options) -> iced::Result {
let _ = STARTED.set(Instant::now());
let probe_started = Instant::now();
// 只有 `auto` 需要探测:`cpu` 与 `gpu` 的决策与「有没有硬件 GPU」无关,
// 而枚举 13 个适配器在本机要 0.65~1.2 s(实测),纯属白花。
let needs_probe = options.backend == crate::Backend::Auto;
let probe = if needs_probe {
probe::Probe::run()
} else {
probe::Probe::default()
};
let probe_ms = probe_started.elapsed().as_secs_f64() * 1000.0;
probe::apply(options.backend, &probe);
if cfg!(feature = "gpu") {
if needs_probe {
eprintln!("[probe] 探测耗时 {probe_ms:.0} ms(wgpu 实例 + 全后端适配器枚举)");
} else {
eprintln!(
"[probe] 探测耗时 {probe_ms:.0} ms(--renderer={} 与硬件无关,跳过枚举)",
options.backend.name()
);
}
}
let font_started = Instant::now();
let resolved = fonts::resolve();
eprintln!(
"[font] 求值耗时 {:.0} ms",
font_started.elapsed().as_secs_f64() * 1000.0
);
eprintln!("{}", resolved.describe());
if resolved.has_synthetic() {
eprintln!(
"[font] 警告:有字重落在「合成加粗」档(观感降级),D28 后半(CDN 字体/按档换族)要处理"
);
}
let families = resolved.families();
// 截图模式:`--screenshot-theme` > `--theme` > 浅色(后续系统探测会覆盖)
let flavor = options.shot_flavor().unwrap_or(SchemeFlavor::Light);
let boot_options = options.clone();
let application = iced::application(
move || {
(
App::new(boot_options.clone(), families, flavor),
Task::done(Message::Boot),
)
},
App::update,
App::view,
)
.title(App::title)
.theme(App::theme)
.subscription(App::subscription)
.default_font(iced::Font {
family: iced::font::Family::Name(families.text),
..iced::Font::DEFAULT
})
.window(window::Settings {
size: WINDOW_SIZE,
min_size: Some(MIN_SIZE),
decorations: false,
resizable: true,
..window::Settings::default()
});
match options.scale_factor {
Some(scale) => application.scale_factor(move |_| scale).run(),
None => application.run(),
}
}
#[cfg(test)]
mod tests {
use super::*;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
#[test]
fn switching_flavor_rebuilds_styles_but_keeps_ui_state() {
let mut app = App::new(Options::default(), FAMILIES, SchemeFlavor::Light);
app.nav = NavItem::Logs;
assert_eq!(app.ui.name(), "ull-m3-light");
app.set_flavor(SchemeFlavor::Dark);
assert_eq!(app.ui.name(), "ull-m3-dark", "切主题要换主题名");
assert_eq!(app.nav, NavItem::Logs, "切主题不许重置 UI 状态");
assert!(app.options.screenshot.is_none(), "切主题不许改启动参数");
}
#[test]
fn window_size_is_the_acceptance_size() {
assert_eq!((WINDOW_SIZE.width, WINDOW_SIZE.height), (1180.0, 760.0));
const {
assert!(MIN_SIZE.width < WINDOW_SIZE.width);
assert!(MIN_SIZE.height < WINDOW_SIZE.height);
}
}
#[test]
fn slug_matches_the_theme_name() {
assert_eq!(slug(SchemeFlavor::Light), "ull-m3-light");
assert_eq!(slug(SchemeFlavor::Dark), "ull-m3-dark");
}
#[test]
fn frames_are_only_requested_in_screenshot_mode() {
let mut app = App::new(Options::default(), FAMILIES, SchemeFlavor::Light);
assert!(!app.wants_frames(), "普通运行不订阅帧,避免每帧重绘");
app.options.screenshot = Some(std::path::PathBuf::from("x.png"));
assert!(
app.wants_frames(),
"截图模式必须订阅帧事件才能在第 3 帧出图"
);
}
}
+298
View File
@@ -0,0 +1,298 @@
//! 命令行参数(只做骨架期需要的那些;业务开关等页面 mission 再加)。
//!
//! 取值语法与现役 Slint 版一致:`--key value` 与 `--key=value` 两种都认
//! (`crates/app/src/screenshot.rs` 的 `value_of` 是同一口径),这样将来 CI 换 bin 不用改脚本。
use std::path::PathBuf;
use unlucky_design::color::SchemeFlavor;
/// 渲染后端请求(D27)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Backend {
/// 探测后决定:有硬件 GPU 就让 iced 自己走 wgpu→tiny-skia 回退;没有就强制 CPU 起步。
#[default]
Auto,
/// 优先 GPU(写 `ICED_BACKEND=wgpu,tiny-skia`:拿不到适配器仍会降级,不硬失败)。
Gpu,
/// 强制 CPU(`--software-renderer` 的等价物,写 `ICED_BACKEND=tiny-skia`)。
Cpu,
}
impl Backend {
/// 稳定名字(日志用)。
pub const fn name(self) -> &'static str {
match self {
Backend::Auto => "auto",
Backend::Gpu => "gpu",
Backend::Cpu => "cpu",
}
}
}
/// 解析后的启动参数。
#[derive(Debug, Clone, PartialEq)]
pub struct Options {
/// 渲染后端请求。
pub backend: Backend,
/// 主题:`None` = 探测系统主题。
pub theme: Option<SchemeFlavor>,
/// 截图通道:给了路径就出图后退出。
pub screenshot: Option<PathBuf>,
/// 截图时用的主题(`--screenshot-theme`,优先级高于 `--theme`)。
pub screenshot_theme: Option<SchemeFlavor>,
/// 强制窗口缩放因子(截图验收要 100% DPI;`None` = 用系统 DPI)。
pub scale_factor: Option<f32>,
/// `--diff <left> <right>`:只做两图逐像素对比,不开窗。
pub diff: Option<(PathBuf, PathBuf)>,
/// `--diff-scan <n>`:对比时额外做 ±n px 的平移扫描(找最佳对齐,默认 0 = 不扫)。
pub diff_scan: u32,
/// `--force-state=hover|pressed`:把按钮强制成某个状态便于截图取证(D30 的状态图)。
pub force_state: Option<ForcedState>,
/// `--data-dir <dir>`:当前**接受并忽略**。
///
/// 现役 Slint 版的 CI 冒烟用 `--software-renderer --screenshot <png> --data-dir <dir>`,
/// 翻牌后同一套脚本要能直接跑 iced 版;真正的数据目录消费由后续页面 mission 接 `host`/`state` 时做。
pub data_dir: Option<PathBuf>,
}
/// 截图取证用的强制交互状态(只影响观感,不改变任何状态机)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ForcedState {
/// 所有可交互控件按 hover 画。
Hover,
/// 所有可交互控件按 pressed 画。
Pressed,
}
impl ForcedState {
/// 稳定名字(日志用)。
pub const fn name(self) -> &'static str {
match self {
ForcedState::Hover => "hover",
ForcedState::Pressed => "pressed",
}
}
}
impl Default for Options {
fn default() -> Self {
Self {
backend: Backend::Auto,
theme: None,
screenshot: None,
screenshot_theme: None,
scale_factor: None,
diff: None,
diff_scan: 0,
force_state: None,
data_dir: None,
}
}
}
impl Options {
/// 从进程参数解析。
///
/// # Errors
/// 参数值不合法(未知的 `--renderer` / `--theme` / 非法 `--scale-factor`)时返回人话。
pub fn from_env() -> Result<Self, String> {
Self::parse(std::env::args().skip(1))
}
/// 从任意参数序列解析(`argv[1..]`,测试直接喂数组)。
///
/// # Errors
/// 同 [`Options::from_env`]。
pub fn parse(args: impl IntoIterator<Item = String>) -> Result<Self, String> {
let mut options = Self::default();
let mut args = args.into_iter();
while let Some(arg) = args.next() {
let (key, inline) = match arg.split_once('=') {
Some((key, value)) => (key.to_owned(), Some(value.to_owned())),
None => (arg.clone(), None),
};
match key.as_str() {
"--software-renderer" => options.backend = Backend::Cpu,
"--renderer" => {
let value = take(&mut args, inline, "--renderer")?;
options.backend = match value.as_str() {
"auto" => Backend::Auto,
"gpu" => Backend::Gpu,
"cpu" => Backend::Cpu,
other => {
return Err(format!("--renderer 只认 auto|gpu|cpu,收到 {other:?}"));
}
};
}
"--theme" => {
options.theme = Some(flavor(&take(&mut args, inline, "--theme")?)?);
}
"--screenshot-theme" => {
options.screenshot_theme =
Some(flavor(&take(&mut args, inline, "--screenshot-theme")?)?);
}
"--screenshot" => {
options.screenshot =
Some(PathBuf::from(take(&mut args, inline, "--screenshot")?));
}
"--scale-factor" => {
let raw = take(&mut args, inline, "--scale-factor")?;
let value: f32 = raw
.parse()
.map_err(|_| format!("--scale-factor 需要数字,收到 {raw:?}"))?;
if !(value.is_finite() && value > 0.0) {
return Err(format!("--scale-factor 必须 > 0,收到 {raw:?}"));
}
options.scale_factor = Some(value);
}
"--diff" => {
let left = take(&mut args, inline, "--diff")?;
let right = args
.next()
.ok_or_else(|| "--diff 需要两张图的路径".to_owned())?;
options.diff = Some((PathBuf::from(left), PathBuf::from(right)));
}
"--diff-scan" => {
let raw = take(&mut args, inline, "--diff-scan")?;
options.diff_scan = raw
.parse()
.map_err(|_| format!("--diff-scan 需要非负整数,收到 {raw:?}"))?;
}
"--force-state" => {
let raw = take(&mut args, inline, "--force-state")?;
options.force_state = Some(match raw.as_str() {
"hover" => ForcedState::Hover,
"pressed" => ForcedState::Pressed,
other => {
return Err(format!(
"--force-state 只认 hover|pressed,收到 {other:?}"
));
}
});
}
// 翻牌前的兼容参数:接受并忽略(见字段文档)
"--data-dir" => {
options.data_dir = Some(PathBuf::from(take(&mut args, inline, "--data-dir")?));
}
other => return Err(format!("未知参数 {other:?}")),
}
}
Ok(options)
}
/// 截图时实际使用的主题(截图专用参数优先)。
pub fn shot_flavor(&self) -> Option<SchemeFlavor> {
self.screenshot_theme.or(self.theme)
}
}
fn take(
args: &mut impl Iterator<Item = String>,
inline: Option<String>,
flag: &str,
) -> Result<String, String> {
inline
.or_else(|| args.next())
.ok_or_else(|| format!("{flag} 需要一个值"))
}
fn flavor(raw: &str) -> Result<SchemeFlavor, String> {
match raw {
"light" => Ok(SchemeFlavor::Light),
"dark" => Ok(SchemeFlavor::Dark),
other => Err(format!("--theme 只认 light|dark,收到 {other:?}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(args: &[&str]) -> Result<Options, String> {
Options::parse(args.iter().map(|arg| (*arg).to_owned()))
}
#[test]
fn defaults_are_auto_and_system_theme() {
let options = parse(&[]).expect("空参数要能解析");
assert_eq!(options.backend, Backend::Auto);
assert_eq!(options.theme, None);
assert_eq!(options.screenshot, None);
}
#[test]
fn software_renderer_is_an_alias_of_cpu() {
assert_eq!(
parse(&["--software-renderer"]).unwrap().backend,
Backend::Cpu
);
assert_eq!(parse(&["--renderer=cpu"]).unwrap().backend, Backend::Cpu);
assert_eq!(parse(&["--renderer", "gpu"]).unwrap().backend, Backend::Gpu);
}
#[test]
fn screenshot_accepts_both_argv_forms() {
let spaced = parse(&["--screenshot", "out.png"]).unwrap();
let inline = parse(&["--screenshot=out.png"]).unwrap();
assert_eq!(spaced.screenshot, Some(PathBuf::from("out.png")));
assert_eq!(spaced.screenshot, inline.screenshot);
}
#[test]
fn screenshot_theme_wins_over_theme() {
let options = parse(&["--theme=dark", "--screenshot-theme=light"]).unwrap();
assert_eq!(options.theme, Some(SchemeFlavor::Dark));
assert_eq!(options.shot_flavor(), Some(SchemeFlavor::Light));
}
#[test]
fn bad_values_are_reported_not_panicked() {
assert!(parse(&["--renderer=metal"]).is_err());
assert!(parse(&["--theme=blue"]).is_err());
assert!(parse(&["--scale-factor=0"]).is_err());
assert!(parse(&["--scale-factor=abc"]).is_err());
assert!(parse(&["--screenshot"]).is_err());
assert!(parse(&["--diff", "only-one.png"]).is_err());
assert!(parse(&["--nope"]).is_err());
}
#[test]
fn data_dir_is_accepted_and_ignored_for_the_flip() {
// 现役 CI 冒烟:--software-renderer --screenshot <png> --data-dir <dir>
let options = parse(&[
"--software-renderer",
"--screenshot",
"shot.png",
"--data-dir",
"/d/tmp/x",
])
.expect("翻牌前必须兼容 --data-dir");
assert_eq!(options.backend, Backend::Cpu);
assert_eq!(options.screenshot, Some(PathBuf::from("shot.png")));
assert_eq!(options.data_dir, Some(PathBuf::from("/d/tmp/x")));
}
#[test]
fn diff_scan_and_force_state_parse() {
assert_eq!(parse(&["--diff-scan=2"]).unwrap().diff_scan, 2);
assert_eq!(
parse(&["--force-state", "hover"]).unwrap().force_state,
Some(ForcedState::Hover)
);
assert!(parse(&["--force-state=focus"]).is_err());
assert!(parse(&["--diff-scan=-1"]).is_err());
}
#[test]
fn diff_takes_two_paths() {
let options = parse(&["--diff", "a.png", "b.png"]).unwrap();
assert_eq!(
options.diff,
Some((PathBuf::from("a.png"), PathBuf::from("b.png")))
);
}
}
+263
View File
@@ -0,0 +1,263 @@
//! 字体基座(D28 前半):把令牌的字体栈求值成「本机真的有的族名」,并打一行启动日志。
//!
//! ## 做什么
//!
//! 1. 取 `Platform::current()` 的三条栈(Display / Text / Mono,见 `unlucky_design::typography`);
//! 2. 用 iced 的**全局字体系统**(`iced_graphics::text::font_system()`,即 cosmic-text 的
//! `FontSystem`,首次调用会扫系统字体)核对每个族名是否装机 → 取栈里第一个装机可用的族;
//! 3. 给出 400/500/700 三档字重的落点(原生 / 最近档 / 合成加粗):
//! **先查系统里这个族真实提供的字面(fontdb 的 `FaceInfo.weight`)**,查不到才回落到令牌的
//! 纯函数 `Platform::resolve_weight`。缺档按降级记录。
//!
//! ## 为什么要真查系统
//!
//! 令牌栈写的是「Win11 有 Segoe UI Variable Text」,但 Win10 / Server 只有 `Segoe UI`(静态
//! 300/400/600/700,**没有 500**),干净安装的 Linux 可能一个都没有。只按栈头写死族名会把
//! 「实际使用哪个族、哪一档字重」变成猜测——本 mission 的开发机就是 Windows Server 2025,
//! 实测就回退到了经典 `Segoe UI`(见交接文档的启动日志)。
//! ## 不做什么
//!
//! **中文字体 CDN 下载不在这里**(后续 mission)。本模块只做「系统字体优先」这一半,
//! 也没有能力判断 CJK 字形覆盖(那是 `ttf-parser` 查 cmap 的活,M60 Q3 的 spike 做过)。
use unlucky_design::typography::{FontFamily, Platform, Weight, WeightMatch};
use crate::theme::Families;
/// 一条栈的求值结果。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FamilyReport {
/// 栈的用途(`text` / `display` / `mono`)。
pub stack: &'static str,
/// 实际选中的族名(栈里第一个装机可用的;都没装就是栈头)。
pub family: &'static str,
/// 选中的族是否真的在系统字体库里。
pub installed: bool,
}
/// 一档字重的求值结果。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WeightReport {
/// 请求的字重(400/500/700)。
pub requested: u16,
/// 实际可用的字重(降级后)。
pub available: u16,
/// 匹配质量(原生 / 最近档 / 合成加粗)。
pub quality: WeightMatch,
}
/// 字体求值的全部结论(启动日志与测试都用它)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Resolved {
/// 平台(决定栈与字重覆盖表)。
pub platform: Platform,
/// 三条栈的落点。
pub families: [FamilyReport; 3],
/// 三档字重的落点(对**正文**栈求值)。
pub weights: [WeightReport; 3],
}
impl Resolved {
/// 把结论塞回主题适配层要的形状。
pub fn families(&self) -> Families {
Families {
text: self.families[0].family,
display: self.families[1].family,
mono: self.families[2].family,
}
}
/// 是否有任何一档走了「合成加粗」(= 观感降级,要记日志)。
pub fn has_synthetic(&self) -> bool {
self.weights
.iter()
.any(|weight| weight.quality == WeightMatch::Synthetic)
}
/// 启动日志(两行:一行族,一行字重)。
pub fn describe(&self) -> String {
let families = self
.families
.iter()
.map(|report| {
format!(
"{}={:?}{}",
report.stack,
report.family,
if report.installed {
""
} else {
"(未装机→回落)"
}
)
})
.collect::<Vec<_>>()
.join(" ");
let weights = self
.weights
.iter()
.map(|report| {
let quality = match report.quality {
WeightMatch::Native => "native",
WeightMatch::Nearest => "nearest",
WeightMatch::Synthetic => "synthetic",
};
format!("w{}={}({quality})", report.requested, report.available)
})
.collect::<Vec<_>>()
.join(" ");
format!(
"[font] platform={} {families} | weights {weights}",
self.platform.name()
)
}
}
/// 求值本机字体(会触发 iced 全局字体系统的首次初始化,约 0.1~0.8s)。
pub fn resolve() -> Resolved {
let platform = Platform::current();
let faces = system_faces();
let is_installed = |family: &str| -> bool {
faces
.as_ref()
.is_none_or(|faces: &Vec<Face>| faces.iter().any(|face| face.family == family))
};
let families = [
FamilyReport {
stack: "text",
family: pick(platform, FontFamily::Text, &is_installed),
installed: is_installed(pick(platform, FontFamily::Text, &is_installed)),
},
FamilyReport {
stack: "display",
family: pick(platform, FontFamily::Display, &is_installed),
installed: is_installed(pick(platform, FontFamily::Display, &is_installed)),
},
FamilyReport {
stack: "mono",
family: pick(platform, FontFamily::Mono, &is_installed),
installed: is_installed(pick(platform, FontFamily::Mono, &is_installed)),
},
];
// 字重:**优先用系统里这个族真实提供的档位**(令牌表的 `WeightCoverage` 是「这个族通常有哪几档」,
// 装机版可能是静态子集:Win Server / 精简版只有经典 Segoe UI,没有 500)。查不到才回落到令牌纯函数。
let weights = [Weight::Regular, Weight::Medium, Weight::Bold]
.map(|weight| weight_report(platform, families[0].family, weight, faces.as_deref()));
Resolved {
platform,
families,
weights,
}
}
/// 一条系统字面(族名 + 字重)。
#[derive(Debug, Clone, PartialEq, Eq)]
struct Face {
family: String,
weight: u16,
}
/// 某档字重的落点:先问系统,再问令牌。
fn weight_report(
platform: Platform,
family: &str,
weight: Weight,
faces: Option<&[Face]>,
) -> WeightReport {
let requested = weight.value();
let available = faces.and_then(|faces| {
let mut weights = faces
.iter()
.filter(|face| face.family == family)
.map(|face| face.weight)
.collect::<Vec<_>>();
weights.sort_unstable();
weights.dedup();
if weights.is_empty() {
return None;
}
Some(
weights
.iter()
.copied()
.min_by_key(|candidate| candidate.abs_diff(requested))
.unwrap_or(requested),
)
});
match available {
Some(available) => WeightReport {
requested,
available,
quality: quality_of(requested, available),
},
None => {
let choice = platform.resolve_weight(FontFamily::Text, weight);
WeightReport {
requested: choice.requested,
available: choice.available,
quality: choice.quality,
}
}
}
}
/// 与令牌层同一判据:原生 / 最近档(差 <200)/ 合成加粗(差 ≥200)。
fn quality_of(requested: u16, available: u16) -> WeightMatch {
match available.abs_diff(requested) {
0 => WeightMatch::Native,
delta if delta >= 200 => WeightMatch::Synthetic,
_ => WeightMatch::Nearest,
}
}
/// 在栈里选第一个**装机可用**的族(跳过 `sans-serif` 这类通用族名)。
///
/// 一个都没装上时返回栈头:让 cosmic-text 自己去回退,比我们猜一个更靠谱。
pub fn pick(
platform: Platform,
stack: FontFamily,
is_installed: &dyn Fn(&str) -> bool,
) -> &'static str {
let faces = platform.font_stack(stack);
faces
.iter()
.find(|face| !is_generic(face.family) && is_installed(face.family))
.map_or(faces[0].family, |face| face.family)
}
/// 通用族名(CSS 关键字,不是真族名)。
fn is_generic(family: &str) -> bool {
matches!(family, "sans-serif" | "serif" | "monospace" | "cursive")
}
/// 系统字体库里的全部字面(`None` = 拿不到,视为「不知道」,全部当装机)。
fn system_faces() -> Option<Vec<Face>> {
let lock = iced::advanced::graphics::text::font_system();
let mut font_system = lock.write().ok()?;
let mut faces = Vec::new();
for face in font_system.raw().db().faces() {
for (family, _) in &face.families {
faces.push(Face {
family: family.clone(),
weight: face.weight.0,
});
}
}
Some(faces)
}
#[cfg(test)]
mod tests;
+163
View File
@@ -0,0 +1,163 @@
//! `fonts` 的单元测试(拆出来只为「src 文件 ≤400 行」的门禁)。
use super::*;
use unlucky_design::typography::WeightCoverage;
fn only(installed: &'static [&'static str]) -> impl Fn(&str) -> bool {
move |family| installed.contains(&family)
}
#[test]
fn windows_stack_falls_back_to_classic_segoe_when_variable_is_absent() {
// Win10 没有 Segoe UI Variable Text
let family = pick(
Platform::Windows,
FontFamily::Text,
&only(&["Segoe UI", "Microsoft YaHei UI"]),
);
assert_eq!(family, "Segoe UI");
}
#[test]
fn windows_stack_prefers_the_variable_face_on_win11() {
let family = pick(
Platform::Windows,
FontFamily::Text,
&only(&["Segoe UI Variable Text", "Segoe UI"]),
);
assert_eq!(family, "Segoe UI Variable Text");
}
#[test]
fn generic_family_names_are_never_reported_as_the_resolved_family() {
// 一个都没装:返回栈头,且栈头不是通用族名
let family = pick(Platform::Linux, FontFamily::Text, &only(&[]));
assert_eq!(family, "Roboto");
assert!(!is_generic(family));
}
#[test]
fn missing_cjk_fonts_do_not_change_the_latin_pick() {
// 装了苹方但没装 SF:macOS 栈应该选 Helvetica Neue 而不是中文族
let family = pick(
Platform::MacOs,
FontFamily::Text,
&only(&["PingFang SC", "Helvetica Neue"]),
);
assert_eq!(family, "Helvetica Neue");
}
#[test]
fn weight_reports_use_the_token_degradation_path() {
// 没有系统字面表时回落到令牌纯函数:Windows 栈里 Segoe UI Variable Text 是可变 300..700
let medium = weight_report(
Platform::Windows,
"Segoe UI Variable Text",
Weight::Medium,
None,
);
assert_eq!(medium.requested, 500);
assert_eq!(medium.quality, WeightMatch::Native);
// 手工构造一个「只有 400/700」的族(= 经典 Segoe UI),500 应该落到最近档
let faces = [
Face {
family: "Segoe UI".to_owned(),
weight: 400,
},
Face {
family: "Segoe UI".to_owned(),
weight: 700,
},
];
let medium = weight_report(Platform::Windows, "Segoe UI", Weight::Medium, Some(&faces));
assert_eq!(medium.available, 400, "400 比 700 更近");
assert_eq!(medium.quality, WeightMatch::Nearest);
// 只有 400 却要 700 → 合成加粗
let only_regular = [Face {
family: "Narrow".to_owned(),
weight: 400,
}];
let bold = weight_report(
Platform::Windows,
"Narrow",
Weight::Bold,
Some(&only_regular),
);
assert_eq!(bold.quality, WeightMatch::Synthetic);
// 原生有 500 就用 500
let has_medium = [Face {
family: "Noto".to_owned(),
weight: 500,
}];
assert_eq!(
weight_report(Platform::Linux, "Noto", Weight::Medium, Some(&has_medium)).quality,
WeightMatch::Native
);
let narrow = WeightCoverage::Static(&[400]);
assert_eq!(narrow.nearest(700), 400);
}
#[test]
fn describe_is_one_line_and_mentions_all_three_stacks() {
let resolved = Resolved {
platform: Platform::Windows,
families: [
FamilyReport {
stack: "text",
family: "Segoe UI",
installed: true,
},
FamilyReport {
stack: "display",
family: "Segoe UI Variable Display",
installed: false,
},
FamilyReport {
stack: "mono",
family: "Cascadia Mono",
installed: true,
},
],
weights: [
WeightReport {
requested: 400,
available: 400,
quality: WeightMatch::Native,
},
WeightReport {
requested: 500,
available: 400,
quality: WeightMatch::Nearest,
},
WeightReport {
requested: 700,
available: 400,
quality: WeightMatch::Synthetic,
},
],
};
let line = resolved.describe();
assert_eq!(line.lines().count(), 1);
assert!(line.contains("text=\"Segoe UI\""), "{line}");
assert!(line.contains("未装机"), "{line}");
assert!(line.contains("w700=400(synthetic)"), "{line}");
assert!(resolved.has_synthetic());
assert_eq!(resolved.families().text, "Segoe UI");
}
#[test]
fn type_roles_pick_the_display_stack_for_big_text() {
use unlucky_design::typography::TypeRole;
assert_eq!(
TypeRole::TitleLarge.style().family,
FontFamily::Display,
"22px 标题走 Display 栈"
);
assert_eq!(TypeRole::BodyMedium.style().family, FontFamily::Text);
}
+126
View File
@@ -0,0 +1,126 @@
//! 令牌图标(`unlucky_design::icons` 的 24 网格 SVG path)→ iced `svg` 控件。
//!
//! ## 为什么走 SVG 而不是自绘 canvas
//!
//! 令牌里存的是 **SVG path 字符串**(含 `A` 弧线命令),自绘要自己写 path parser + 弧线细分,
//! 那是一份将来只有自己在维护的代码;iced 的 `svg` 控件已经带 usvg 解析与按 id 缓存的位图。
//! 代价是二进制多一份 resvg/usvg(体积基线里会单列出来)。
//!
//! ## 描边语义(`design-令牌约定.md` §4)
//!
//! 令牌的 `Icon::stroke_width(render_px)` 已经把「视觉 1.5px」折算成 **viewBox 单位**
//! (`1.5 × 24 / render_px`),本模块**不重算也不手抄常数**。
//! 本骨架所有图标都按 24px 渲染(12b 2.2 标题栏 / 5.5 导航),所以每个图标只有一份位图缓存。
use std::sync::OnceLock;
use iced::widget::svg;
use iced::Color;
use unlucky_design::icons::{IconKind, IconPaint, IconSize};
/// 骨架期图标的统一渲染尺寸(12b:标题栏与导航轨都是 24)。
pub const RENDER_PX: f32 = IconSize::Toolbar.px();
/// 生成一个图标的 SVG 源码(`viewBox` 24×24,颜色用 `currentColor` 交给 iced 的色滤镜)。
///
/// 之所以用 `currentColor` 而不是把令牌色写进 SVG:色滤镜(`svg::Style::color`)会按
/// **alpha 蒙版**重涂 RGB,这样一份 SVG 位图能被浅/深两套主题复用,缓存不会翻倍。
pub fn source(kind: IconKind) -> String {
let icon = kind.icon();
let paint = match icon.paint {
IconPaint::Stroke => format!(
"fill=\"none\" stroke=\"currentColor\" stroke-width=\"{}\" \
stroke-linecap=\"round\" stroke-linejoin=\"round\"",
icon.stroke_width(RENDER_PX)
),
IconPaint::Fill => "fill=\"currentColor\" stroke=\"none\"".to_owned(),
};
format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{0}\" height=\"{0}\" \
viewBox=\"0 0 24 24\"><path d=\"{1}\" {paint}/></svg>",
RENDER_PX, icon.path
)
}
/// 图标的 SVG 句柄(按 `IconKind` 缓存;句柄 id 是内容哈希,所以 iced 侧也能命中缓存)。
fn handle(kind: IconKind) -> svg::Handle {
static HANDLES: OnceLock<Vec<svg::Handle>> = OnceLock::new();
let handles = HANDLES.get_or_init(|| {
IconKind::ALL
.iter()
.map(|kind| svg::Handle::from_memory(source(*kind).into_bytes()))
.collect()
});
// 依赖「枚举下标 == IconKind::ALL 下标」(令牌层用 `GLYPHS[self as usize]` 同一约定)。
// 这条断言把约定钉在测试与 debug 构建里,`IconKind` 一旦乱序插入就会立刻炸。
debug_assert_eq!(
IconKind::ALL[kind as usize],
kind,
"IconKind::ALL 必须与枚举声明顺序一致"
);
handles[kind as usize].clone()
}
/// 一个图标控件(尺寸走令牌,颜色走令牌角色)。
pub fn view<'a, Message: 'a>(kind: IconKind, color: Color) -> iced::Element<'a, Message> {
svg(handle(kind))
.width(RENDER_PX)
.height(RENDER_PX)
.style(move |_theme, _status| svg::Style { color: Some(color) })
.into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_icon_produces_wellformed_svg() {
for kind in IconKind::ALL {
let source = source(kind);
assert!(source.starts_with("<svg"), "{} 的 SVG 头不对", kind.name());
assert!(source.ends_with("</svg>"), "{} 的 SVG 尾不对", kind.name());
assert!(source.contains("viewBox=\"0 0 24 24\""), "{}", kind.name());
assert!(
source.contains(kind.icon().path),
"{} 的 path 没写进去",
kind.name()
);
assert!(!source.contains('#'), "{} 里出现了色值字面量", kind.name());
}
}
#[test]
fn stroke_icons_carry_the_token_stroke_width() {
let stroke = source(IconKind::Settings);
let expected = IconKind::Settings.icon().stroke_width(RENDER_PX);
assert!(
stroke.contains(&format!("stroke-width=\"{expected}\"")),
"{stroke}"
);
assert!(stroke.contains("stroke-linecap=\"round\""));
// 24px 渲染时的视觉线宽必须是 1.5(M54 结论)
assert!((expected - 1.5).abs() < f32::EPSILON);
}
#[test]
fn filled_icons_do_not_get_a_stroke() {
// `more` 是三个填充圆点
let filled = source(IconKind::More);
assert!(filled.contains("fill=\"currentColor\""));
assert!(filled.contains("stroke=\"none\""));
}
#[test]
fn handles_are_cached_per_kind() {
let first = handle(IconKind::Close);
let second = handle(IconKind::Close);
assert_eq!(first.id(), second.id());
assert_ne!(first.id(), handle(IconKind::Minimize).id());
}
}
+55
View File
@@ -0,0 +1,55 @@
//! # unlucky-app-iced · iced 版桌面壳(P4 / D26)
//!
//! 本 crate 是「UI 从头写」的第一步:**只做骨架**(能开窗、颜色/字体对、层级在),
//! 业务页面、组件库、i18n 全文案、玻璃 shader 都是后续 mission。
//!
//! | 模块 | 职责 | 不负责 |
//! |---|---|---|
//! | [`theme`] | 令牌(`unlucky-design`)→ iced 类型:`Color` / `Font` / `Radius` / `Theme` | 发明令牌值 |
//! | `cli` | `--renderer` / `--software-renderer` / `--theme` / `--screenshot*` 参数 | 业务开关 |
//! | `probe` | 渲染后端探测(D27)+ 写 `ICED_BACKEND`,失败自动降级 CPU | 绘制 |
//! | `fonts` | 字体栈求值(D28 前半):平台族名 → 系统字体库校验 → 启动日志 | 中文字体下载 |
//! | `app` / `shell` | Elm 三件套 + 自绘标题栏 / 导航轨 / 内容骨架 | 业务逻辑 |
//! | `shot` | `window::screenshot` → PNG(验收通道) | 图像比对 |
//! | `icons` | 令牌里的 SVG path → iced `svg` 控件 | 画新图标 |
//!
//! ## 消费纪律
//!
//! **组件层零硬编码色值/字号/半径/间距**:一律走 [`theme::Ui`]。守门测试在
//! `tests/no_hardcoded_tokens.rs`(扫 `src/**`,出现 `#RRGGBB` / `Color::from_rgb(` 即红)。
//!
//! ## 与 Slint 版的关系
//!
//! 并存期:`crates/app` 仍出 `unlucky-launcher`,本 crate 出 `unlucky-launcher-iced`。
//! 等价验收后翻牌(bin 改名 + 旧 crate 降级),步骤写在
//! `DevDocs/SubAgentsTalk/app-iced-骨架与翻牌.md`。
pub mod shot;
pub mod theme;
mod app;
mod cli;
mod fonts;
mod icons;
mod probe;
mod shell;
pub use cli::{Backend, Options};
pub use probe::Probe;
/// 启动 UI(阻塞到窗口关闭)。渲染后端由 `probe` 在 `run()` 之前写进 `ICED_BACKEND`。
pub fn run(options: Options) -> iced::Result {
app::run(options)
}
/// 与 [`run`] 同义,但把命令行做成最终结论:`Options::from_env()` + 启动日志。
///
/// 二进制入口用 [`Options::from_env`] + [`run`] 自己拼(要额外处理 `--diff`),这个函数留给
/// 集成测试与将来的 CLI 复用。
///
/// # Errors
/// 参数不合法时返回人话(不 panic、不 `exit`)。
pub fn run_from_env() -> Result<iced::Result, String> {
let options = Options::from_env()?;
Ok(run(options))
}
+52
View File
@@ -0,0 +1,52 @@
//! 入口。所有实际逻辑在 lib(`unlucky_app_iced`)里:
//!
//! - `--diff <a> <b>`:只读两张 PNG 做逐像素对比(D30 的 GPU/CPU 几何一致性检查),不开窗;
//! - 其余参数:开窗跑骨架,`--screenshot` 出图后退出。
//!
//! 退出码:`0` 成功 / `1` 截图有差异(diff 模式)/ `2` 参数或运行错误。
use std::process::ExitCode;
use unlucky_app_iced::shot;
use unlucky_app_iced::Options;
fn main() -> ExitCode {
let options = match Options::from_env() {
Ok(options) => options,
Err(message) => {
eprintln!("[cli] {message}");
eprintln!(
"用法:unlucky-launcher-iced [--renderer=auto|gpu|cpu] [--software-renderer] \
[--theme=light|dark] [--screenshot <png>] [--screenshot-theme=light|dark] \
[--scale-factor <f32>] [--diff <a.png> <b.png>]"
);
return ExitCode::from(2);
}
};
if let Some((left, right)) = options.diff.clone() {
let scan = i32::try_from(options.diff_scan).unwrap_or(i32::MAX);
return match shot::diff_files_with_scan(&left, &right, scan) {
Ok(diff) => {
eprintln!("{}", diff.describe());
if diff.is_identical() {
ExitCode::SUCCESS
} else {
ExitCode::from(1)
}
}
Err(message) => {
eprintln!("[diff] {message}");
ExitCode::from(2)
}
};
}
match unlucky_app_iced::run(options) {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("[run] {error}");
ExitCode::FAILURE
}
}
}
+238
View File
@@ -0,0 +1,238 @@
//! 渲染后端探测 + 自动降级(D27)。
//!
//! ## 为什么需要探测
//!
//! iced 0.14 的 `Renderer` 本身就是「wgpu → tiny-skia」的运行时回退枚举(M60 Q1):
//! **不设** `ICED_BACKEND` 时,拿不到 GPU 适配器会自动降级到 CPU,零代码。
//! 需要自研的只有**判据**:
//!
//! - 本机会枚举出 `Microsoft Basic Render Driver [Cpu]`(= WARP 软光栅),
//! 所以「有没有适配器」不能当「有没有 GPU」(M60 实测),必须看 `device_type`。
//! - 没有硬件 GPU 时要**主动**把 CPU 放到候选首位(`tiny-skia,wgpu`),
//! 否则 wgpu 会先花时间尝试 WARP,观感与行为都难料。
//!
//! ## 语义订正(与 M60 备忘录的差异,以源码 + 实测为准)
//!
//! M60 记「`ICED_BACKEND=wgpu` 会**关闭**回退」。读 `iced_renderer-0.14.0/src/fallback.rs`
//! 得到的是:**候选列表**语义——列表里没有 `tiny-skia` 时才真的关闭回退。
//! 所以本 crate 的「优先 GPU」写 `wgpu,tiny-skia`(首选 GPU,拿不到仍降级,**绝不硬失败**),
//! 而不是 `wgpu`。
use crate::Backend;
/// 单个适配器的摘要(只留日志要用的字段)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdapterSummary {
/// 适配器名(如 `AMD Radeon RX 6600 XT`)。
pub name: String,
/// `device_type` 的字符串形式(`DiscreteGpu` / `IntegratedGpu` / `Cpu` / …)。
pub device_type: &'static str,
}
/// 探测结论。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Probe {
/// 枚举到的全部适配器(含 WARP 这类软光栅)。
pub adapters: Vec<AdapterSummary>,
/// 是否存在**真**硬件适配器(`device_type` 既不是 `Cpu` 也不是 `Other`)。
pub hardware: bool,
}
impl Probe {
/// 探测本机渲染能力(编入 `wgpu` 时真的枚举适配器)。
#[cfg(feature = "gpu")]
pub fn run() -> Self {
run_wgpu()
}
/// 探测本机渲染能力(`--no-default-features` 的纯 CPU 构建:没有 wgpu,直接判定无硬件)。
#[cfg(not(feature = "gpu"))]
pub fn run() -> Self {
Self::default()
}
/// 一行日志:适配器清单 + 判定。
pub fn describe(&self) -> String {
if self.adapters.is_empty() {
return format!("adapters=0 hardware={}", self.hardware);
}
let list = self
.adapters
.iter()
.map(|adapter| format!("{} [{}]", adapter.name, adapter.device_type))
.collect::<Vec<_>>()
.join(" | ");
format!(
"adapters={} [{list}] hardware={}",
self.adapters.len(),
self.hardware
)
}
}
/// 按请求与探测结果决定 `ICED_BACKEND` 的值(`None` = 不干预,交给 iced 自己回退)。
///
/// 纯函数,好让「降级矩阵」被测试钉死。
pub fn backend_env(request: Backend, probe: &Probe, gpu_compiled: bool) -> Option<&'static str> {
match (request, gpu_compiled) {
// 命令行 --software-renderer / --renderer=cpu:只留 CPU 候选。
(Backend::Cpu, _) => Some("tiny-skia"),
// 用户点名要 GPU,但没编进 wgpu:只能 CPU,日志里说明。
(Backend::Gpu, false) => Some("tiny-skia"),
// 优先 GPU,但保留 tiny-skia 兜底(候选列表语义:不会硬失败)。
(Backend::Gpu, true) => Some("wgpu,tiny-skia"),
// 自动:有硬件就让 iced 走默认顺序(wgpu 优先,自动回退)。
(Backend::Auto, true) if probe.hardware => None,
// 自动 + 没编进 wgpu:只有 CPU 候选,别把 wgpu 写进列表(会与「未编入 wgpu」的日志自相矛盾)。
(Backend::Auto, false) => Some("tiny-skia"),
// 自动 + 有 wgpu 但没硬件(含只有 WARP 的机器):CPU 起步,wgpu 留作兜底。
(Backend::Auto, true) => Some("tiny-skia,wgpu"),
}
}
/// 把决策落到进程环境变量,并把探测结果 + 生效值打进日志。
///
/// 返回**实际生效**的后端描述(`"auto (iced: wgpu -> tiny-skia)"` 之类)。
pub fn apply(request: Backend, probe: &Probe) -> String {
let gpu_compiled = cfg!(feature = "gpu");
let env = backend_env(request, probe, gpu_compiled);
// ICED_BACKEND 是进程级环境变量,必须在 `iced::application(..).run()` 之前写。
// 仓库是 edition 2021 → `set_var` 仍是安全 API(升 2024 后要改成子进程 exec 自身)。
if let Some(value) = env {
std::env::set_var("ICED_BACKEND", value);
}
let effective = if gpu_compiled {
match (request, env) {
(Backend::Cpu, _) => "tiny-skia (CPU,强制)".to_owned(),
(_, None) => "auto (wgpu -> tiny-skia)".to_owned(),
(_, Some("wgpu,tiny-skia")) => "wgpu.then(tiny-skia) (优先 GPU)".to_owned(),
(_, Some(value)) => format!("{value} (CPU 起步)"),
}
} else {
"tiny-skia (本构建未编入 wgpu,只有 CPU)".to_owned()
};
let note = if !gpu_compiled && request != Backend::Cpu {
";本构建未编入 wgpu(--no-default-features),只能 CPU"
} else {
""
};
eprintln!(
"[probe] {} :: request={} -> ICED_BACKEND={}{note}",
probe.describe(),
request.name(),
env.unwrap_or("<unset>")
);
eprintln!("[probe] renderer = {effective}");
effective
}
#[cfg(feature = "gpu")]
fn run_wgpu() -> Probe {
// `iced::wgpu` 就是 wgpu crate 本身(`pub use iced_renderer::wgpu::wgpu` 再导出),
// 所以不需要单独依赖 wgpu,版本也不会和 iced 打架。
use iced::wgpu::{Backends, DeviceType, Instance, InstanceDescriptor};
let instance = Instance::new(&InstanceDescriptor {
backends: Backends::all(),
..Default::default()
});
let adapters = instance
.enumerate_adapters(Backends::all())
.into_iter()
.map(|adapter| {
let info = adapter.get_info();
let device_type = match info.device_type {
DeviceType::DiscreteGpu => "DiscreteGpu",
DeviceType::IntegratedGpu => "IntegratedGpu",
DeviceType::VirtualGpu => "VirtualGpu",
DeviceType::Cpu => "Cpu",
DeviceType::Other => "Other",
};
AdapterSummary {
name: info.name,
device_type,
}
})
.collect::<Vec<_>>();
let hardware = adapters
.iter()
.any(|adapter| !matches!(adapter.device_type, "Cpu" | "Other"));
Probe { adapters, hardware }
}
#[cfg(test)]
mod tests {
use super::*;
fn probe(types: &[&'static str]) -> Probe {
let adapters = types
.iter()
.map(|device_type| AdapterSummary {
name: "adapter".to_owned(),
device_type,
})
.collect::<Vec<_>>();
Probe {
adapters,
hardware: types
.iter()
.any(|device_type| !matches!(*device_type, "Cpu" | "Other")),
}
}
#[test]
fn warp_only_machine_counts_as_no_gpu() {
// 本机实测会枚举出 WARP:`Microsoft Basic Render Driver [Cpu]`
let warp = probe(&["Cpu"]);
assert!(!warp.hardware, "只有 WARP 不能当「有 GPU」");
assert_eq!(
backend_env(Backend::Auto, &warp, true),
Some("tiny-skia,wgpu")
);
}
#[test]
fn auto_with_real_gpu_leaves_iced_alone() {
let gpu = probe(&["DiscreteGpu", "Cpu"]);
assert!(gpu.hardware);
assert_eq!(backend_env(Backend::Auto, &gpu, true), None);
}
#[test]
fn explicit_choices_always_keep_a_fallback() {
let gpu = probe(&["DiscreteGpu"]);
let none = probe(&[]);
// 优先 GPU 但候选列表里留 tiny-skia:拿不到适配器时降级而不是报错
assert_eq!(
backend_env(Backend::Gpu, &gpu, true),
Some("wgpu,tiny-skia")
);
assert_eq!(
backend_env(Backend::Gpu, &none, true),
Some("wgpu,tiny-skia")
);
assert_eq!(backend_env(Backend::Cpu, &gpu, true), Some("tiny-skia"));
// 没编进 wgpu 的构建绝不写 wgpu(连候选列表也不许出现,reviewer P3-4)
assert_eq!(backend_env(Backend::Gpu, &gpu, false), Some("tiny-skia"));
assert_eq!(backend_env(Backend::Auto, &gpu, false), Some("tiny-skia"));
assert_eq!(backend_env(Backend::Auto, &none, false), Some("tiny-skia"));
}
#[test]
fn describe_lists_every_adapter() {
let text = probe(&["DiscreteGpu", "Cpu"]).describe();
assert!(text.contains("DiscreteGpu"), "{text}");
assert!(text.contains("Cpu"), "{text}");
assert!(text.contains("hardware=true"), "{text}");
}
}
+349
View File
@@ -0,0 +1,349 @@
//! 首屏骨架(**不做业务**):自绘标题栏 + 左侧导航轨 + 内容区(页头 + 一张卡片)。
//!
//! ## 与参考判据的对应(`research/12b-component-fidelity.md`)
//!
//! | 部件 | 参考 | 令牌落点 |
//! |---|---|---|
//! | 标题栏高度 | 16.2 桌面标题栏 **40**(登记偏离) | `LM.titlebar_height = 48`(M63 §1.4/R13 桌面档,令牌优先) |
//! | 标题栏按钮 | 16.5 图标 **24** | `IconSize::Toolbar` + `LM.titlebar_button_width = 46` |
//! | 页头 | 16.1 官方 AppBar **64**;本项目 **56** | `LM.page_header_height = 56`(已有偏离登记) |
//! | 卡片 | 3.x Outlined:**Medium(12)**、L0、`OutlineVariant` 1px、`SurfaceContainerLow` | `Component::OutlinedCard.spec()` + `STROKE_PX` |
//! | 字级 | 16.4 标题 `TitleLarge`;卡片 `TitleMedium` / `BodySmall` | `Ui::style` / `Ui::emphasized` |
//!
//! 导航轨在 [`nav`] 子模块里(12b §5 的判据表也在那边)。
//!
//! 一屏只出现 4 档字号(22 / 16 / 14 / 12),且**没有实心 accent**(rubric K2:一屏最多一个实心
//! Primary,骨架期一个都不要)。阴影只给浮层,骨架里没有浮层,所以**全屏零阴影**(rubric K3)。
mod nav;
pub use nav::NavItem;
use iced::widget::text::LineHeight;
use iced::widget::{button, column, container, mouse_area, row, space, stack, text};
use iced::{mouse, Alignment, Background, Border, Color, Element, Length, Padding, Pixels, Shadow};
use iced::{window::Direction, Theme as IcedTheme};
use unlucky_design::color::{Role, StateLayer};
use unlucky_design::component_map::Component;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::app::Message;
use crate::cli::ForcedState;
use crate::icons;
use crate::theme::Ui;
/// 整棵树。
pub fn view<'a>(
ui: &'a Ui,
selected: NavItem,
app_title: &'a str,
page_title: &'a str,
body: &'a str,
forced: Option<ForcedState>,
) -> Element<'a, Message> {
let work_area = column![
titlebar(ui, app_title, forced),
row![
nav::rail(ui, selected, forced),
content(ui, page_title, body)
]
.width(Length::Fill)
.height(Length::Fill),
]
.width(Length::Fill)
.height(Length::Fill);
// 无边框窗口没有系统缩放边框 → 自己给两条热区(东/南)。
// **必须叠层而不是兄弟列**:当兄弟列会吃掉 4px 布局宽(右留白变 28、caption 按钮离窗角 4px,
// 视觉复审 P3-1)。叠层容器自己铺满,再用 align 把 4px 热区贴到边上,布局宽保持 1180。
// 东侧热区从标题栏下方开始,避免吃掉三个 caption 按钮最右 4px 的点击区。
let thickness = ui.space(Space::X1);
let east = container(
column![
space::vertical().height(ui.metrics().titlebar_height),
resize_edge(
Direction::East,
(Length::Fixed(thickness), Length::Fill),
mouse::Interaction::ResizingHorizontally,
),
]
.height(Length::Fill),
)
.width(Length::Fill)
.height(Length::Fill)
.align_x(Alignment::End);
let south = container(resize_edge(
Direction::South,
(Length::Fill, Length::Fixed(thickness)),
mouse::Interaction::ResizingVertically,
))
.width(Length::Fill)
.height(Length::Fill)
.align_y(Alignment::End);
let shell = stack![work_area, east, south];
container(shell)
.width(Length::Fill)
.height(Length::Fill)
.style(|_theme| filled(ui.layer(MaterialLayer::Canvas)))
.into()
}
/// 窗口缩放热区(透明;玻璃/底色由根容器提供)。
///
/// 拖动时调 `window::drag_resize(id, direction)`——Windows/X11 走 winit 原生实现,
/// **macOS 返回 `NotSupported`**(M60 Q4),如实登记在交接文档里。
fn resize_edge<'a>(
direction: Direction,
size: (Length, Length),
interaction: mouse::Interaction,
) -> Element<'a, Message> {
let (width, height) = size;
mouse_area(container(space::horizontal()).width(width).height(height))
.interaction(interaction)
.on_press(Message::ResizeStart(direction))
.into()
}
// ---------------------------------------------------------------- 标题栏
fn titlebar<'a>(ui: &'a Ui, title: &'a str, forced: Option<ForcedState>) -> Element<'a, Message> {
let metrics = ui.metrics();
// 拖动热区:从窗口左缘到第一个按钮(按钮自己不能被拖区吞掉,所以只包左侧一段)
let drag_area = mouse_area(
container(label(
ui,
TypeRole::LabelLarge,
true,
title,
ui.color(Role::OnSurface),
))
.width(Length::Fill)
.height(Length::Fill)
.align_y(Alignment::Center)
.padding(Padding::from([0.0, ui.space(Space::X4)])),
)
.on_press(Message::DragWindow);
let bar = row![
drag_area,
caption(ui, IconKind::Minimize, Message::Minimize, forced),
caption(ui, IconKind::Maximize, Message::ToggleMaximize, forced),
caption(ui, IconKind::Close, Message::Close, forced),
]
.width(Length::Fill)
.height(metrics.titlebar_height)
.align_y(Alignment::Center);
container(bar)
.width(Length::Fill)
.height(metrics.titlebar_height)
.style(|_theme| filled(ui.layer(MaterialLayer::Canvas)))
.into()
}
/// 标题栏按钮(自绘最小化/最大化/关闭)。
///
/// 图标必须**再包一层固定 24×24 的容器**:iced 的 `button` 在 layout 里给内容 Fill 约束,
/// 直接把 `svg(...).width(24)` 塞进 button,usvg 会按 Contain 撑满内区(实测渲染盒 46 → 视觉线宽
/// 从 1.5 变 2.9,视觉复审 P2-2)。Container 尊重子元素的 Length,所以这层是「锁尺寸」用的。
fn caption<'a>(
ui: &'a Ui,
kind: IconKind,
message: Message,
forced: Option<ForcedState>,
) -> Element<'a, Message> {
let metrics = ui.metrics();
// 用 `center(Length::Fill)` 而不是只设 `width(24).height(24)`:后者会让容器缩到 24×24 并贴到
// button 内容的左上角(button 不居中「收缩后」的内容,r1 实测图标跑到 cell 左上 (left+11.5, 11.5));
// `center(Fill)` = 撑满按钮内容区 + 内容居中,字形仍是 24 盒、命中区仍是 46×48。
let icon = container(icons::view::<Message>(
kind,
ui.color(Role::OnSurfaceVariant),
))
.width(icons::RENDER_PX)
.height(icons::RENDER_PX)
.center(Length::Fill);
button(icon)
.width(metrics.titlebar_button_width)
.height(metrics.titlebar_height)
.padding(0)
.style(move |_theme: &IcedTheme, status| {
let background = match overridden(forced, status) {
button::Status::Hovered => Some(Background::Color(ui.state_over(
StateLayer::Hover,
Role::OnSurface,
Role::Surface,
))),
button::Status::Pressed => Some(Background::Color(ui.state_over(
StateLayer::Pressed,
Role::OnSurface,
Role::Surface,
))),
button::Status::Active | button::Status::Disabled => None,
};
button::Style {
background,
text_color: ui.color(Role::OnSurfaceVariant),
border: Border::default(),
shadow: Shadow::default(),
snap: true,
}
})
.on_press(message)
.into()
}
// ---------------------------------------------------------------- 内容区
fn content<'a>(ui: &'a Ui, title: &'a str, body: &'a str) -> Element<'a, Message> {
let metrics = ui.metrics();
// 卡片档位由组件表决定(圆角/字级),组件层不自己挑
let card_spec = Component::OutlinedCard.spec();
let body_role = card_spec.support.unwrap_or(card_spec.text);
let header = container(label(
ui,
TypeRole::TitleLarge,
true,
title,
ui.color(Role::OnSurface),
))
.width(Length::Fill)
.height(metrics.page_header_height)
.align_y(Alignment::Center)
.padding(Padding::from([0.0, ui.space(Space::X6)]));
let card = container(
column![
label(
ui,
card_spec.text,
true,
"Shell ready",
ui.color(Role::OnSurface),
),
label(ui, body_role, false, body, ui.color(Role::OnSurfaceVariant)),
]
.spacing(ui.space(Space::X2)),
)
.width(Length::Fill)
.padding(metrics.card_padding)
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Card))),
border: ui.outlined(card_spec.shape),
..container::Style::default()
});
let body_area = container(card)
.width(Length::Fill)
.height(Length::Fill)
.padding(ui.space(Space::X6))
.align_y(Alignment::Start);
column![header, body_area]
.width(Length::Fill)
.height(Length::Fill)
.into()
}
// ---------------------------------------------------------------- 小工具
/// 一个文字元素:字号/行高/字重全部来自令牌排版档。
pub(super) fn label<'a>(
ui: &'a Ui,
role: TypeRole,
emphasized: bool,
content: &'a str,
color: Color,
) -> Element<'a, Message> {
let style = if emphasized {
ui.emphasized(role)
} else {
ui.style(role)
};
text(content)
.size(style.size_px)
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
.font(ui.font_of(style))
.color(color)
.into()
}
/// 取证用的状态覆盖(`--force-state`):只改画出来的状态,不动任何状态机。
pub(super) fn overridden(forced: Option<ForcedState>, status: button::Status) -> button::Status {
match forced {
Some(ForcedState::Hover) => button::Status::Hovered,
Some(ForcedState::Pressed) => button::Status::Pressed,
None => status,
}
}
pub(super) fn filled(color: Color) -> container::Style {
container::Style {
background: Some(Background::Color(color)),
..container::Style::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
use crate::theme::Families;
fn ui(flavor: SchemeFlavor) -> Ui {
Ui::with_platform(
flavor,
Platform::Windows,
Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
},
)
}
#[test]
fn a_screen_only_uses_four_text_sizes_at_most() {
let ui = ui(SchemeFlavor::Dark);
let card = Component::OutlinedCard.spec();
let sizes = [
ui.emphasized(TypeRole::TitleLarge).size_px,
ui.emphasized(TypeRole::LabelLarge).size_px,
ui.emphasized(card.text).size_px,
ui.style(card.support.unwrap_or(card.text)).size_px,
ui.style(TypeRole::LabelMedium).size_px,
];
let mut unique = sizes.to_vec();
unique.sort_by(f32::total_cmp);
unique.dedup();
assert!(
unique.len() <= 4,
"rubric D4:一屏字号档 ≤4(实际 {} 档 {unique:?})",
unique.len()
);
assert!(unique[0] >= 12.0, "最小档不下 12px(caption ≥11)");
assert!(unique[unique.len() - 1] <= 28.0, "列表页标题不得 >28px");
}
#[test]
fn flavor_slug_is_stable_for_filenames() {
assert_eq!(crate::shot::flavor_slug(&ui(SchemeFlavor::Light)), "light");
assert_eq!(crate::shot::flavor_slug(&ui(SchemeFlavor::Dark)), "dark");
}
}
+258
View File
@@ -0,0 +1,258 @@
//! 左侧导航轨(M3 `NavigationRail`)。
//!
//! 判据来源 `research/12b-component-fidelity.md` §5:
//!
//! | 项 | 参考值 | 令牌落点 |
//! |---|---|---|
//! | 轨宽 | 5.1 官方 96(narrow 80);本项目 **88** | [`nav_rail_width`](`LayoutMetrics` 骨架值,详见该函数文档的 M69 迁移说明) |
//! | 轨容器 | 5.3 形状 None / L0 / `Surface` | `MaterialLayer::Canvas` |
//! | 选中指示器 | 5.4 **56×32**、圆角 full、`SecondaryContainer` | [`indicator_size`](令牌推导) |
//! | 图标 | 5.5 24px | `IconSize::Toolbar` |
//! | 标签 | 5.5 `LabelMedium 12/16` | `TypeRole::LabelMedium` |
//! | 颜色 | 5.6 选中图标 `OnSecondaryContainer` / 选中标签 `Secondary` / 未选中 `OnSurfaceVariant` | `Ui::color` |
//! | 项间距 | 5.7 **4** | `LayoutMetrics::nav_item_gap` |
use iced::widget::{button, column, container};
use iced::{Alignment, Background, Border, Element, Length, Padding, Shadow, Theme as IcedTheme};
use unlucky_design::color::{Role, StateLayer};
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::{LayoutMetrics, Space};
use unlucky_design::typography::TypeRole;
use crate::app::Message;
use crate::cli::ForcedState;
use crate::icons;
use crate::shell::{filled, label, overridden};
use crate::theme::Ui;
/// 导航占位项(任务要求 4~6 个;真正的页面由后续 mission 接管)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NavItem {
/// 实例列表。
Instances,
/// 下载/版本。
Download,
/// 日志。
Logs,
/// 设置。
Settings,
/// AI 助手。
Ai,
}
impl NavItem {
/// 全部占位项(顺序 = 导航栏自上而下)。
pub const ALL: [NavItem; 5] = [
NavItem::Instances,
NavItem::Download,
NavItem::Logs,
NavItem::Settings,
NavItem::Ai,
];
/// 显示名(i18n 是后续 mission;本 mission 用英文基线文案,避免干净 CI 上缺中文变豆腐块)。
pub const fn label(self) -> &'static str {
match self {
NavItem::Instances => "Instances",
NavItem::Download => "Download",
NavItem::Logs => "Logs",
NavItem::Settings => "Settings",
NavItem::Ai => "Assistant",
}
}
/// 令牌图标。
pub const fn icon(self) -> IconKind {
match self {
NavItem::Instances => IconKind::Instances,
NavItem::Download => IconKind::Download,
NavItem::Logs => IconKind::Logs,
NavItem::Settings => IconKind::Settings,
NavItem::Ai => IconKind::Ai,
}
}
}
/// 整条导航轨。
pub(super) fn rail<'a>(
ui: &'a Ui,
selected: NavItem,
forced: Option<ForcedState>,
) -> Element<'a, Message> {
let metrics = ui.metrics();
let indicator = indicator_size(metrics);
let items = NavItem::ALL
.iter()
.map(|item| rail_item(ui, *item, *item == selected, indicator, forced))
.collect::<Vec<_>>();
// 轨顶留白走 container padding,而不是「先塞一个 spacer 元素」:后者会被 column 的
// spacing(4)再加一档,实测变 28(视觉复审 P3-5)。
container(column(items).spacing(metrics.nav_item_gap))
.width(nav_rail_width(metrics))
.height(Length::Fill)
.padding(Padding::from([ui.space(Space::X6), 0.0]))
.style(|_theme| filled(ui.layer(MaterialLayer::Canvas)))
.into()
}
fn rail_item<'a>(
ui: &'a Ui,
item: NavItem,
selected: bool,
indicator: (f32, f32),
forced: Option<ForcedState>,
) -> Element<'a, Message> {
let (indicator_width, indicator_height) = indicator;
let icon_color = if selected {
ui.color(Role::OnSecondaryContainer)
} else {
ui.color(Role::OnSurfaceVariant)
};
// 12b 5.6:选中标签用 `Secondary`,未选中 `OnSurfaceVariant`
let label_color = if selected {
ui.color(Role::Secondary)
} else {
ui.color(Role::OnSurfaceVariant)
};
// **不要用 `center_x/center_y(Fill)`**:那会把容器自己的 width/height 改成 Fill,
// 于是 56 被父级(button 宽 88)覆盖成 88、32 靠布局凑巧(视觉复审 P2-1)。
// 尺寸只认令牌推导值,居中交给父 column 的 `align_x`。
let pill = container(icons::view::<Message>(item.icon(), icon_color))
.width(indicator_width)
.height(indicator_height)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.style(move |_theme| container::Style {
background: selected.then(|| Background::Color(ui.color(Role::SecondaryContainer))),
border: Border {
radius: ui.radius(ShapeScale::Full, indicator_height),
..Border::default()
},
..container::Style::default()
});
let item_height = nav_item_height(ui, indicator_height);
// 列必须显式占满轨宽,否则它会 shrink 到 56,`align_x(Center)` 就变成空操作,
// 药丸会贴左缘(x=0..55 而不是 16..71)——r1 实测踩到的第二个坑。
let stack = column![
pill,
label(ui, TypeRole::LabelMedium, false, item.label(), label_color),
]
.spacing(ui.space(Space::X1))
.width(nav_rail_width(ui.metrics()))
.align_x(Alignment::Center)
.height(item_height);
button(stack)
.width(nav_rail_width(ui.metrics()))
.height(item_height)
.padding(0)
.style(move |_theme: &IcedTheme, status| {
let background = match overridden(forced, status) {
button::Status::Hovered if !selected => Some(Background::Color(ui.state_over(
StateLayer::Hover,
Role::OnSurface,
Role::Surface,
))),
button::Status::Pressed if !selected => Some(Background::Color(ui.state_over(
StateLayer::Pressed,
Role::OnSurface,
Role::Surface,
))),
_ => None,
};
button::Style {
background,
text_color: label_color,
border: Border::default(),
shadow: Shadow::default(),
snap: true,
}
})
.on_press(Message::Select(item))
.into()
}
/// 导航轨宽度(M69 起是令牌层的 `nav_rail_width`)。
///
/// M69(`141e9b1`)把 `LayoutMetrics` 的 nav 字段做了语义纠正:
///
/// | 旧 | 新 |
/// |---|---|
/// | `nav_expanded_width = 88` | **`nav_rail_width = 88`**(值不变,名字从「展开」改成「轨」) |
/// | `nav_collapsed_width = 48` | 删除(48 连 56 宽的选中指示器都放不下) |
/// | —(缺) | `nav_drawer_width = 240`(D33 的展开态抽屉,本 crate 未用) |
///
/// 三处调用点都走这里:将来令牌再改名,仍然只改这一行。
fn nav_rail_width(metrics: LayoutMetrics) -> f32 {
metrics.nav_rail_width
}
/// 选中指示器尺寸(12b 5.4 参考 56×32),全部由令牌推导、不写死:
///
/// - 宽 = [`nav_rail_width`] − 2 × `Space::X4` = 88 − 32 = **56**(轨的左右内边距 16)
/// - 高 = `control_height − Space::X2` = 40 − 8 = **32**(可见高度上下各让 4)
pub fn indicator_size(metrics: LayoutMetrics) -> (f32, f32) {
(
nav_rail_width(metrics) - Space::X4.dp() * 2.0,
metrics.control_height - Space::X2.dp(),
)
}
/// 导航项高度 = 指示器 + 4 + 标签行高 = 32 + 4 + 16 = 52(`LM.nav_item_height` 是列表行节奏,
/// 药丸 + 标签的轨项放不进 44,偏差登记在交接文档里)。
pub fn nav_item_height(ui: &Ui, indicator_height: f32) -> f32 {
indicator_height + ui.space(Space::X1) + ui.style(TypeRole::LabelMedium).line_height_px
}
#[cfg(test)]
mod tests {
use super::*;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
use crate::theme::Families;
fn ui() -> Ui {
Ui::with_platform(
SchemeFlavor::Light,
Platform::Windows,
Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
},
)
}
#[test]
fn indicator_matches_the_reference_56x32() {
let (width, height) = indicator_size(LayoutMetrics::DESKTOP);
assert_eq!((width, height), (56.0, 32.0), "12b 5.4 的参考值");
}
#[test]
fn nav_item_height_is_indicator_plus_gap_plus_label() {
let ui = ui();
let (_, indicator_height) = indicator_size(ui.metrics());
// 32 + 4 + 16
assert_eq!(nav_item_height(&ui, indicator_height), 52.0);
}
#[test]
fn every_nav_item_has_a_label_and_an_icon() {
assert!((4..=6).contains(&NavItem::ALL.len()));
for item in NavItem::ALL {
assert!(!item.label().is_empty(), "{item:?} 没有文案");
assert!(!item.icon().icon().path.is_empty(), "{item:?} 没有图标");
}
}
}
+352
View File
@@ -0,0 +1,352 @@
//! 两图逐像素对比(GPU/CPU 几何一致性)与 ±N px 平移扫描。
//!
//! 拆成子模块只为「src 文件 ≤400 行」的门禁;对外通过 `shot::{compare, diff_files, Diff, Image}`
//! 使用,调用方不用感知这层。
use std::path::Path;
/// 一张已解码的 RGBA 图。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Image {
/// RGBA8 字节(逐像素 4 字节)。
pub rgba: Vec<u8>,
/// 宽(px)。
pub width: u32,
/// 高(px)。
pub height: u32,
}
impl Image {
/// 读 PNG(只支持 PNG:本 crate 的 `image` 只开了 png 编码/解码)。
///
/// # Errors
/// 读文件失败或解码失败时返回人话。
pub fn load(path: &Path) -> Result<Self, String> {
let reader = image::ImageReader::open(path)
.map_err(|error| format!("读图 {} 失败:{error}", path.display()))?
.with_guessed_format()
.map_err(|error| format!("判定 {} 的格式失败:{error}", path.display()))?;
let decoded = reader
.decode()
.map_err(|error| format!("解码 {} 失败:{error}", path.display()))?
.to_rgba8();
Ok(Self {
width: decoded.width(),
height: decoded.height(),
rgba: decoded.into_raw(),
})
}
}
/// 两图对比结果(GPU/CPU 一致性靠它给数字,不靠肉眼)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Diff {
/// 宽(px)。
pub width: u32,
/// 高(px)。
pub height: u32,
/// 有差异的像素数。
pub differing_pixels: usize,
/// 单通道最大差(0~255)。
pub max_channel_delta: u8,
/// 单通道平均差(0~255)。
pub mean_channel_delta: f64,
/// 平移扫描的最佳对齐(dx, dy);不扫描时为 (0, 0)。
pub best_shift: (i32, i32),
/// 最佳对齐下的差异像素数(不扫描时等于 `differing_pixels`)。
pub best_differing_pixels: usize,
}
/// 逐像素对比两张尺寸相同的图。
///
/// # Errors
/// 尺寸不同直接报错——**几何不一致不许静默通过**。
pub fn compare(left: &Image, right: &Image) -> Result<Diff, String> {
if (left.width, left.height) != (right.width, right.height) {
return Err(format!(
"尺寸不同:{}x{} vs {}x{}",
left.width, left.height, right.width, right.height
));
}
let pixel_count = left.rgba.len() / 4;
let mut differing = 0_usize;
let mut max_delta = 0_u8;
let mut sum_delta = 0_u64;
for index in 0..pixel_count {
let offset = index * 4;
let mut pixel_differs = false;
for channel in 0..4 {
let delta = left.rgba[offset + channel].abs_diff(right.rgba[offset + channel]);
if delta > 0 {
pixel_differs = true;
max_delta = max_delta.max(delta);
sum_delta += u64::from(delta);
}
}
if pixel_differs {
differing += 1;
}
}
Ok(Diff {
width: left.width,
height: left.height,
differing_pixels: differing,
max_channel_delta: max_delta,
mean_channel_delta: if pixel_count == 0 {
0.0
} else {
sum_delta as f64 / (pixel_count * 4) as f64
},
best_shift: (0, 0),
best_differing_pixels: differing,
})
}
/// ±`max_shift` px 的平移扫描:把 `right` 平移后再比,返回差异像素数最小的那一档。
///
/// 用途:D30 的「几何逐像素一致」除了看差异占比,还要证明**没有整体错位**——
/// 如果 (0,0) 就是最优,说明差异只来自抗锯齿而不是位移/缩放。CI 不必再依赖 numpy。
///
/// 平移后越界的像素按「无法比较」处理,不计入差异(否则边缘会造成假阳性)。
pub fn shift_scan(left: &Image, right: &Image, max_shift: i32) -> Result<Diff, String> {
let mut best = compare(left, right)?;
let max_shift = max_shift.unsigned_abs();
if (left.width, left.height) != (right.width, right.height) {
return Err("尺寸不同,无法做平移扫描".to_owned());
}
for dy in -(max_shift as i32)..=(max_shift as i32) {
for dx in -(max_shift as i32)..=(max_shift as i32) {
if (dx, dy) == (0, 0) {
continue;
}
let count = shifted_differing_pixels(left, right, dx, dy);
if count < best.best_differing_pixels {
best.best_differing_pixels = count;
best.best_shift = (dx, dy);
}
}
}
Ok(best)
}
/// 把 `right` 平移 `(dx, dy)` 后与 `left` 比较,返回差异像素数(越界不算)。
fn shifted_differing_pixels(left: &Image, right: &Image, dx: i32, dy: i32) -> usize {
let width = left.width as i32;
let height = left.height as i32;
let mut differing = 0;
for y in 0..height {
let source_y = y - dy;
if !(0..height).contains(&source_y) {
continue;
}
for x in 0..width {
let source_x = x - dx;
if !(0..width).contains(&source_x) {
continue;
}
let left_offset = ((y * width + x) * 4) as usize;
let right_offset = ((source_y * width + source_x) * 4) as usize;
if left.rgba[left_offset..left_offset + 4] != right.rgba[right_offset..right_offset + 4]
{
differing += 1;
}
}
}
differing
}
impl Diff {
/// 完全一致(几何 + 颜色都一致)。
pub fn is_identical(&self) -> bool {
self.differing_pixels == 0
}
/// 一行日志。
pub fn describe(&self) -> String {
let total = (self.width as usize) * (self.height as usize);
let percent = if total == 0 {
0.0
} else {
self.differing_pixels as f64 * 100.0 / total as f64
};
format!(
"[diff] {}x{} differing={}/{} ({percent:.2}%) max_delta={} mean_delta={:.4} best_shift=({},{}) best_differing={}",
self.width,
self.height,
self.differing_pixels,
total,
self.max_channel_delta,
self.mean_channel_delta,
self.best_shift.0,
self.best_shift.1,
self.best_differing_pixels,
)
}
}
/// `--diff <left> <right>` 的实现:读两张 PNG、对比、打日志。
///
/// # Errors
/// 任一图读不出来,或尺寸不同。
pub fn diff_files(left: &Path, right: &Path) -> Result<Diff, String> {
diff_files_with_scan(left, right, 0)
}
/// 同 [`diff_files`],但额外做 ±`max_shift` px 的平移扫描(`--diff-scan`)。
///
/// # Errors
/// 任一图读不出来,或尺寸不同。
pub fn diff_files_with_scan(left: &Path, right: &Path, max_shift: i32) -> Result<Diff, String> {
let left = Image::load(left)?;
let right = Image::load(right)?;
if max_shift == 0 {
compare(&left, &right)
} else {
shift_scan(&left, &right, max_shift)
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use iced::window;
use super::*;
use crate::shot::{save, write_rgba};
fn temp_dir() -> PathBuf {
let dir = std::env::temp_dir().join("ull-app-iced-shot-tests");
std::fs::create_dir_all(&dir).expect("建临时目录");
dir
}
#[test]
fn identical_images_have_zero_diff() {
let image = Image {
rgba: vec![7_u8; 4 * 4],
width: 2,
height: 2,
};
let diff = compare(&image, &image).unwrap();
assert!(diff.is_identical());
assert_eq!(diff.max_channel_delta, 0);
}
#[test]
fn geometry_mismatch_is_an_error_not_a_silent_pass() {
let square = Image {
rgba: vec![0; 16],
width: 2,
height: 2,
};
let strip = Image {
rgba: vec![0; 16],
width: 4,
height: 1,
};
assert!(compare(&square, &strip).is_err());
}
#[test]
fn color_only_differences_are_counted_per_pixel() {
let left = Image {
rgba: vec![0, 0, 0, 255, 0, 0, 0, 255],
width: 2,
height: 1,
};
let right = Image {
rgba: vec![10, 0, 0, 255, 0, 0, 0, 255],
width: 2,
height: 1,
};
let diff = compare(&left, &right).unwrap();
assert_eq!(diff.differing_pixels, 1);
assert_eq!(diff.max_channel_delta, 10);
assert!(diff.describe().contains("differing=1/2"));
}
#[test]
fn png_roundtrip_through_disk() {
let path = temp_dir().join("roundtrip.png");
let bytes = vec![200_u8; 4 * 3 * 2];
write_rgba(&bytes, 3, 2, &path).unwrap();
let loaded = Image::load(&path).unwrap();
assert_eq!((loaded.width, loaded.height), (3, 2));
assert_eq!(loaded.rgba, bytes);
let diff = diff_files(&path, &path).unwrap();
assert!(diff.is_identical());
}
#[test]
fn shift_scan_finds_the_best_alignment() {
// 5x5 图,R 通道存唯一值;构造 right[y][x] = left[y+1][x+1](即把左图向左上挪 1px),
// 于是「left 与 right 在 dy=1,dx=1 时逐像素相等」——扫描应该把 (1,1) 找出来。
let side = 5_i32;
let mut rgba = Vec::new();
for y in 0..side {
for x in 0..side {
rgba.extend([(y * side + x) as u8, 0, 0, 255]);
}
}
let left = Image {
rgba,
width: side as u32,
height: side as u32,
};
let mut moved = vec![255_u8; (side * side * 4) as usize];
for y in 0..side - 1 {
for x in 0..side - 1 {
let source = ((y + 1) * side + (x + 1)) as usize * 4;
let target = (y * side + x) as usize * 4;
moved[target..target + 4].copy_from_slice(&left.rgba[source..source + 4]);
}
}
let right = Image {
rgba: moved,
width: side as u32,
height: side as u32,
};
let diff = shift_scan(&left, &right, 2).unwrap();
assert_eq!(diff.best_shift, (1, 1), "应扫出 1px 的位移");
assert_eq!(diff.best_differing_pixels, 0, "对齐后应逐像素相等");
assert!(
diff.differing_pixels > 0,
"不对齐时应该有差异(否则这条测试没有信息量)"
);
// 同一张图:最佳对齐就是 (0,0)
let same = shift_scan(&left, &left, 2).unwrap();
assert_eq!(same.best_shift, (0, 0));
assert_eq!(same.best_differing_pixels, 0);
}
#[test]
fn wrong_byte_count_is_reported() {
let screenshot = window::Screenshot::new(vec![0_u8; 3], iced::Size::new(1, 1), 1.0);
let error = save(&screenshot, &temp_dir().join("unused.png")).unwrap_err();
assert!(error.contains("字节数对不上"), "{error}");
}
}
+100
View File
@@ -0,0 +1,100 @@
//! 截图通道(D30 验收用):`iced::window::screenshot` 的真窗口出图 → PNG,以及两图逐像素对比。
//!
//! ## 为什么用真窗口而不是 `iced_test::Emulator`
//!
//! 任务书首选路径①:真开窗截图覆盖了标题栏/DPI/窗口管理的真实行为,离屏通道不覆盖
//! (M60 §Q6 的边界说明)。本机与 Windows CI 都有交互会话,所以走路径①。
//!
//! ## 关键事实(M60 踩坑 Q3)
//!
//! `Screenshot` 的字节字段在 0.14 叫 **`rgba`**(0.13 是 `bytes`),`size` 是**物理像素**,
//! 另带 `scale_factor`(换算逻辑坐标用)。落盘用 `image::save_buffer` + `ExtendedColorType::Rgba8`。
//!
//! ## 为什么自己带一个 diff
//!
//! D30 的硬项是「GPU 档与 CPU 档**几何逐像素一致**,只有颜色/材质不同」。CI 里不该假设有
//! Python/PIL/ImageMagick,所以把「读两张 PNG → 数差异像素 → 给最大/平均通道差」做进本程序
//! (`--diff a.png b.png`),复用同一个二进制。
use std::path::{Path, PathBuf};
use iced::window;
use crate::theme;
/// 把一帧截图写到 `path`(自动建父目录)。
///
/// # Errors
/// 目录建不出来、PNG 编码失败、字节数与尺寸对不上时返回人话(截图通道要能一眼看出哪一步断)。
pub fn save(screenshot: &window::Screenshot, path: &Path) -> Result<Summary, String> {
let width = screenshot.size.width;
let height = screenshot.size.height;
let bytes = screenshot.rgba.as_ref();
let expected = width as usize * height as usize * 4;
if bytes.len() != expected {
return Err(format!(
"截图字节数对不上:{width}x{height} 应该 {expected} B,实际 {} B",
bytes.len()
));
}
write_rgba(bytes, width, height, path)?;
Ok(Summary {
width,
height,
scale_factor: screenshot.scale_factor,
path: path.to_path_buf(),
})
}
fn write_rgba(bytes: &[u8], width: u32, height: u32, path: &Path) -> Result<(), String> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)
.map_err(|error| format!("建目录 {} 失败:{error}", parent.display()))?;
}
}
image::save_buffer(path, bytes, width, height, image::ExtendedColorType::Rgba8)
.map_err(|error| format!("写 PNG {} 失败:{error}", path.display()))
}
/// 一次截图的结果摘要(打日志与报告用)。
#[derive(Debug, Clone, PartialEq)]
pub struct Summary {
/// 物理宽(px)。
pub width: u32,
/// 物理高(px)。
pub height: u32,
/// 窗口缩放因子(100% DPI = 1.0)。
pub scale_factor: f32,
/// 落盘路径。
pub path: PathBuf,
}
impl Summary {
/// 一行日志。
pub fn describe(&self) -> String {
format!(
"[shot] {}x{} scale={} -> {}",
self.width,
self.height,
self.scale_factor,
self.path.display()
)
}
}
mod diff;
pub use diff::{compare, diff_files, diff_files_with_scan, shift_scan, Diff, Image};
/// 主题名 → 归档文件名片段(`visual-acceptance-protocol.md` §7 的命名)。
pub fn flavor_slug(ui: &theme::Ui) -> &'static str {
match ui.flavor() {
unlucky_design::color::SchemeFlavor::Light => "light",
unlucky_design::color::SchemeFlavor::Dark => "dark",
}
}
+334
View File
@@ -0,0 +1,334 @@
//! 令牌 → iced 类型(`Color` / `Font` / `Border` / `Radius` / `Theme`)。
//!
//! ## 边界(M61 `design-令牌约定.md` §0 的落地)
//!
//! 本模块是**唯一**允许把 `unlucky-design` 的值翻成 iced 类型的地方。组件层(`shell`)
//! 只调 [`Ui`] 的方法,绝不出现色值 / 字号 / 半径 / 间距字面量——`tests/no_hardcoded_tokens.rs`
//! 会扫 `src/**` 做守门。
//!
//! ## 为什么不用 `iced::theme::Base` 自定义主题承载 49 个角色
//!
//! `iced` 的 `Palette` 只有 6 个颜色(`background/text/primary/success/warning/danger`),
//! M3 有 49 个角色,塞不进去(M60 Q5 + M61 §9-0 的同一结论)。所以:
//!
//! - **真源**是本模块持有的 [`Scheme`](49 角色);
//! - iced 的 `Theme` 只用来「换名字」——`Theme::name()` 变了 iced 才会重算受主题影响的缓存
//! (M61 §9 的最后一条)。`palette()` 只给 devtools 看,控件外观一律由各自的 `Style` 注入。
//!
//! `success` / `warning` 在 M3 里**没有对应角色**(`design-令牌约定.md` §2 明写),
//! 这里按「语义最近的现成角色」映射:`success → Secondary`(不是「成功」的承诺)、
//! `warning → Tertiary`(M3 把「提醒」放 Tertiary)。任何控件都不要去读这两个槽位。
use iced::border::Radius;
use iced::theme::Palette;
use iced::{Border, Color, Font, Theme};
use unlucky_design::color::{Role, Scheme, SchemeFlavor, StateLayer};
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::{LayoutMetrics, Space};
use unlucky_design::typography::{FontFamily, Platform, TypeRole, TypeStyle, Weight};
/// 全站描边宽度(逻辑 px @100% DPI)。rubric G7 要求全站 1px —— 令牌层的「一像素线单一真源」。
///
/// M69 起令牌层提供 `unlucky_design::stroke::HAIRLINE`(容器描边/分隔线/聚焦环共用),
/// 这里**再导出**而不是自己写 `1.0`:全仓只有令牌层一个定义点。
pub use unlucky_design::stroke::HAIRLINE as STROKE_PX;
/// 已经解析到系统的字体族名(`&'static str` 才能塞进 `iced::Font`)。
///
/// 由 `fonts` 模块按令牌栈 + 系统字体库求值,本模块只消费。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Families {
/// 正文族(`FontFamily::Text` 栈里第一个装机可用的)。
pub text: &'static str,
/// 展示族(`FontFamily::Display`)。
pub display: &'static str,
/// 等宽族(`FontFamily::Mono`,日志/版本号用)。
pub mono: &'static str,
}
/// 令牌求值器:一个 `Ui` 对应一套主题(浅或深)。
///
/// 深浅热切 = 重新 `Ui::new(flavor, families)` 并整树重绘;控件里不要缓存颜色。
#[derive(Debug, Clone)]
pub struct Ui {
scheme: Scheme,
platform: Platform,
metrics: LayoutMetrics,
families: Families,
}
impl Ui {
/// 按平台(由 `cfg!` 决定)与令牌基线方案求值。
pub fn new(flavor: SchemeFlavor, families: Families) -> Self {
Self::with_platform(flavor, Platform::current(), families)
}
/// 显式指定平台(跨平台核对与测试用)。
pub fn with_platform(flavor: SchemeFlavor, platform: Platform, families: Families) -> Self {
Self {
scheme: Scheme::baseline(flavor),
platform,
metrics: LayoutMetrics::DESKTOP,
families,
}
}
/// 当前深浅模式。
pub fn flavor(&self) -> SchemeFlavor {
self.scheme.flavor()
}
/// 当前平台(决定字体族与字重降级)。
pub fn platform(&self) -> Platform {
self.platform
}
/// 骨架尺寸(标题栏/导航/行高…)。
pub fn metrics(&self) -> LayoutMetrics {
self.metrics
}
/// 已解析的字体族名。
pub fn families(&self) -> Families {
self.families
}
/// 间距档位(4px 栅格)。
pub fn space(&self, space: Space) -> f32 {
space.dp()
}
/// M3 角色 → iced 颜色。
pub fn color(&self, role: Role) -> Color {
to_iced(self.scheme.get(role))
}
/// 材质层(Canvas/Card/Overlay)→ 容器色。
pub fn layer(&self, layer: MaterialLayer) -> Color {
self.color(layer.surface_role())
}
/// 状态层叠在容器色上,返回**不透明**结果。
///
/// 为什么不用带 alpha 的叠加:D27 要求 CPU 档不出现半透明,而「不透明结果」让 GPU/CPU
/// 两档的颜色也逐像素一致(只有材质/玻璃才允许两档不同)。
pub fn state_over(&self, state: StateLayer, content: Role, container: Role) -> Color {
to_iced(state.over(self.scheme.get(content), self.scheme.get(container)))
}
/// 圆角(令牌刻度);`side` 是控件在该轴上的边长(`ShapeScale::Full` 要按边长折算)。
pub fn radius(&self, shape: ShapeScale, side: f32) -> Radius {
Radius::new(shape.radius_px(side))
}
/// 固定档圆角(不需要边长)。`ShapeScale::Full` 必须走 [`Ui::radius`](它没有绝对值)。
pub fn radius_dp(&self, shape: ShapeScale) -> Radius {
debug_assert!(
!matches!(shape, ShapeScale::Full),
"Full 需要边长,请用 Ui::radius(shape, side)"
);
Radius::new(if matches!(shape, ShapeScale::Full) {
0.0
} else {
shape.dp()
})
}
/// 描边:`OutlineVariant` + [`STROKE_PX`],圆角走刻度(固定档)。
pub fn outlined(&self, shape: ShapeScale) -> Border {
Border {
color: self.color(Role::OutlineVariant),
width: STROKE_PX,
radius: self.radius_dp(shape),
}
}
/// baseline 排版档。
pub fn style(&self, role: TypeRole) -> TypeStyle {
role.style()
}
/// Expressive 强调档(官方值:字重升到 500/700)。
pub fn emphasized(&self, role: TypeRole) -> TypeStyle {
role.emphasized()
}
/// 某个排版档对应的 iced 字体(族 + 字重)。
pub fn font_of(&self, style: TypeStyle) -> Font {
let family = match style.family {
FontFamily::Display => self.families.display,
FontFamily::Text => self.families.text,
FontFamily::Mono => self.families.mono,
};
Font {
family: iced::font::Family::Name(family),
weight: weight_of(style.weight),
..Font::DEFAULT
}
}
/// baseline 档的字体。
pub fn font(&self, role: TypeRole) -> Font {
self.font_of(self.style(role))
}
/// 强调档的字体。
pub fn emphasized_font(&self, role: TypeRole) -> Font {
self.font_of(self.emphasized(role))
}
/// iced 的 6 色 `Palette`(只给 devtools / 兜底控件用;M3 的 49 角色在 [`Ui::color`])。
pub fn palette(&self) -> Palette {
Palette {
background: self.color(Role::Surface),
text: self.color(Role::OnSurface),
primary: self.color(Role::Primary),
success: self.color(Role::Secondary),
warning: self.color(Role::Tertiary),
danger: self.color(Role::Error),
}
}
/// iced 主题对象:**名字带深浅**,切主题时 `Theme::name()` 变化会触发重算。
pub fn theme(&self) -> Theme {
Theme::custom(self.name(), self.palette())
}
/// `Theme::name()` 的取值(稳定字符串,改名要先在协作文档登记)。
pub fn name(&self) -> &'static str {
match self.flavor() {
SchemeFlavor::Light => "ull-m3-light",
SchemeFlavor::Dark => "ull-m3-dark",
}
}
}
/// 令牌色(8 bit sRGB)→ iced 的 sRGB 分量。
pub fn to_iced(color: unlucky_design::color::Color) -> Color {
Color::from_rgba8(
color.r,
color.g,
color.b,
f32::from(color.a) / f32::from(u8::MAX),
)
}
/// 令牌字重 → iced 字重。
pub fn weight_of(weight: Weight) -> iced::font::Weight {
match weight {
Weight::Regular => iced::font::Weight::Normal,
Weight::Medium => iced::font::Weight::Medium,
Weight::SemiBold => iced::font::Weight::Semibold,
Weight::Bold => iced::font::Weight::Bold,
}
}
#[cfg(test)]
mod tests {
use super::*;
const FAMILIES: Families = Families {
text: "Segoe UI Variable Text",
display: "Segoe UI Variable Display",
mono: "Cascadia Mono",
};
fn ui(flavor: SchemeFlavor) -> Ui {
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
}
#[test]
fn theme_name_tracks_the_flavor() {
assert_eq!(ui(SchemeFlavor::Light).name(), "ull-m3-light");
assert_eq!(ui(SchemeFlavor::Dark).name(), "ull-m3-dark");
assert_ne!(
ui(SchemeFlavor::Light).theme().to_string(),
ui(SchemeFlavor::Dark).theme().to_string()
);
}
#[test]
fn canvas_card_overlay_follow_surface_containers() {
let ui = ui(SchemeFlavor::Light);
assert_eq!(ui.layer(MaterialLayer::Canvas), ui.color(Role::Surface));
assert_eq!(
ui.layer(MaterialLayer::Card),
ui.color(Role::SurfaceContainerLow)
);
assert_eq!(
ui.layer(MaterialLayer::Overlay),
ui.color(Role::SurfaceContainerHigh)
);
}
#[test]
fn radii_only_come_from_the_scale() {
let ui = ui(SchemeFlavor::Light);
// 卡片 Medium(12):边长够大时就是 12
assert_eq!(ui.radius_dp(ShapeScale::Medium).top_left, 12.0);
assert_eq!(ui.radius_dp(ShapeScale::ExtraSmall).top_left, 4.0);
// 药丸 Full:按边长一半折算
assert_eq!(ui.radius(ShapeScale::Full, 32.0).top_left, 16.0);
// 不许超过边长一半
assert_eq!(ui.radius(ShapeScale::ExtraLarge, 20.0).top_left, 10.0);
}
#[test]
fn outlines_are_one_pixel_and_use_outline_variant() {
let ui = ui(SchemeFlavor::Light);
let border = ui.outlined(ShapeScale::Medium);
assert_eq!(border.width, STROKE_PX);
assert_eq!(border.width, 1.0, "rubric G7:全站 1px");
assert_eq!(border.color, ui.color(Role::OutlineVariant));
assert_eq!(border.radius.top_left, 12.0);
}
#[test]
fn emphasized_weights_are_the_official_ones() {
let ui = ui(SchemeFlavor::Light);
// title-small 的强调档是 700(官方 Emphasized)
assert_eq!(
ui.emphasized_font(TypeRole::TitleSmall).weight,
iced::font::Weight::Bold
);
// baseline 正文是 400
assert_eq!(
ui.font(TypeRole::BodyMedium).weight,
iced::font::Weight::Normal
);
}
#[test]
fn body_text_keeps_wcag_contrast_in_both_flavors() {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let scheme = Scheme::baseline(flavor);
assert!(
scheme.contrast(Role::OnSurface, Role::Surface) >= 4.5,
"{flavor:?} 的正文对比度不够"
);
assert!(
scheme.contrast(Role::OnSecondaryContainer, Role::SecondaryContainer) >= 4.5,
"{flavor:?} 的选中态药丸对比度不够"
);
}
}
#[test]
fn every_role_maps_to_an_iced_color() {
for role in Role::ALL {
let color = ui(SchemeFlavor::Dark).color(role);
assert!((0.0..=1.0).contains(&color.a), "{role:?} 的 alpha 越界");
}
}
#[test]
fn state_layers_are_opaque_so_cpu_and_gpu_match() {
let ui = ui(SchemeFlavor::Light);
let hovered = ui.state_over(StateLayer::Hover, Role::OnSurface, Role::Surface);
assert_eq!(hovered.a, 1.0, "状态层必须给出不透明结果");
assert_ne!(hovered, ui.color(Role::Surface));
}
}
@@ -0,0 +1,351 @@
//! 门禁:**组件层零硬编码**(色值 / 尺寸)与「不依赖 slint」。
//!
//! 这三条测试会扫本 crate 的 `src/**` 与 `Cargo.toml`,故意写得「宁可错杀」:
//! 将来 reviewer 复跑 `cargo test -p unlucky-app-iced` 就能挡住「顺手写个 #RRGGBB」的提交。
//!
//! 判定口径(与 `component-fidelity-rubric.md` T1/T2、`visual-acceptance-protocol.md` §1 对齐):
//!
//! - **色值字面量**:`#` + 6 位十六进制(且**至少含一个数字**,这样文档里的 `#RRGGBB` 占位不会被误杀)
//! 或 `Color::from_rgb(0.1, …)` / `color!(0x…)` 这类带数值参数的构造。
//! - **令牌优先**:唯一允许的是 `theme.rs`(适配层),且它也只做 `token → iced` 的搬运。
//! - **尺寸字面量**:`shell.rs` 里 `.width(12.0)` / `.height(34)` / `.size(22)` 一律不许出现
//! (必须走 `LayoutMetrics` / `TypeStyle` / `Space`)。
use std::path::{Path, PathBuf};
/// 本 crate 根目录(编译期注入,换机器也成立)。
fn manifest_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}
/// 递归收集 `dir` 下的 `.rs` 文件。
fn rust_files(dir: &Path, collected: &mut Vec<PathBuf>) {
let entries =
std::fs::read_dir(dir).unwrap_or_else(|error| panic!("读不动 {}:{error}", dir.display()));
for entry in entries {
let path = entry.expect("目录项读失败").path();
if path.is_dir() {
rust_files(&path, collected);
} else if path.extension().is_some_and(|ext| ext == "rs") {
collected.push(path);
}
}
}
fn sources() -> Vec<(PathBuf, String)> {
let mut files = Vec::new();
rust_files(&manifest_dir().join("src"), &mut files);
assert!(
files.len() >= 8,
"只扫到 {} 个源文件:扫描逻辑坏了,这条门禁等于没跑",
files.len()
);
files
.into_iter()
.map(|path| {
let text = std::fs::read_to_string(&path)
.unwrap_or_else(|error| panic!("读不动 {}:{error}", path.display()));
(path, text)
})
.collect()
}
/// `#RRGGBB` / `#AARRGGBB` 形式的色值字面量(至少含一个数字,避开 `#RRGGBB` 这种占位写法)。
fn hex_color_at(bytes: &[u8], start: usize) -> Option<&str> {
let digits = bytes.get(start + 1..start + 7)?;
let all_hex = digits
.iter()
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_whitespace());
let has_number = digits.iter().any(u8::is_ascii_digit);
if all_hex && has_number {
// 后面不能再跟着十六进制字符(否则是更长的 token,不是 6 位色值)
let next = bytes.get(start + 7).copied();
if next.is_none_or(|byte| !byte.is_ascii_hexdigit()) {
return Some("hex 色值字面量(#RRGGBB)");
}
}
None
}
/// 带数值参数的取色构造(`Color::from_rgb(0.1, …)`、`Color::from_rgb8(103, 80, 164)`、`color!(0x12, …)`)。
///
/// **子串匹配要注意前后缀**:`from_rgb(` 匹配不到 `from_rgb8(`,所以 8 bit 那个必须单独列出来
/// (reviewer 造样就是这么绕过去的)。
const NUMERIC_COLOR_CALLS: [&str; 8] = [
"Color::from_rgb(",
"Color::from_rgb8(",
"Color::from_rgba(",
"Color::from_rgba8(",
"Color::from_hex(",
"Color::rgba(",
"Color::rgb8(",
"color!(",
];
/// 取色构造后面是不是跟着数值字面量(允许 `Color::from_rgba8(color.r, …)` 这类搬运)。
fn numeric_color_call(text: &str) -> Option<&'static str> {
for pattern in NUMERIC_COLOR_CALLS {
let mut from = 0;
while let Some(found) = text[from..].find(pattern) {
let after = from + found + pattern.len();
let next = text[after..].trim_start().chars().next();
// 参数以数字或 `0x`/`#` 开头才算字面量;`Color::from_rgba8(color.r, …)` 是搬运,放行。
if next.is_some_and(|ch| ch.is_ascii_digit() || ch == '#') {
return Some(pattern);
}
from = after;
}
}
None
}
/// 结构体字面量取色:`Color { r: 0.4, g: 0.3, b: 0.6, a: 1.0 }`。
///
/// 判据:`Color {` 之后 64 个字符内同时出现 `r:` 与 `g:` 且有数字(`Color { a: 0.0, .. }` 这种
/// 只改 alpha 的写法不会命中,因为它没有 `r:`)。
fn color_struct_literal(text: &str) -> bool {
let mut from = 0;
while let Some(found) = text[from..].find("Color {") {
let after = from + found + "Color {".len();
let window: String = text[after..].chars().take(64).collect();
let has_channels = window.contains("r:") && window.contains("g:");
let has_number = window.chars().any(|ch| ch.is_ascii_digit());
if has_channels && has_number {
return true;
}
from = after;
}
false
}
#[test]
fn no_hardcoded_colors_in_sources() {
let mut offenders = Vec::new();
for (path, text) in sources() {
for (index, line) in text.lines().enumerate() {
let bytes = line.as_bytes();
let mut position = 0;
while let Some(found) = line[position..].find('#') {
let at = position + found;
if let Some(kind) = hex_color_at(bytes, at) {
offenders.push(format!(
"{}:{} {line:?} -> {kind}",
path.display(),
index + 1
));
}
position = at + 1;
}
if let Some(pattern) = numeric_color_call(line) {
offenders.push(format!(
"{}:{} {line:?} -> {pattern}",
path.display(),
index + 1
));
}
if color_struct_literal(line) {
offenders.push(format!(
"{}:{} {line:?} -> Color {{ r: … }} 结构体字面量",
path.display(),
index + 1
));
}
}
}
assert!(
offenders.is_empty(),
"组件层不许出现色值字面量(一律走 unlucky_design 的 Role):\n{}",
offenders.join("\n")
);
}
/// 判断某个布局调用后面是不是直接跟了尺寸字面量;`0`(= 无内边距)放行。
///
/// iced 里尺寸经常被包一层:`.width(Length::Fixed(123.0))`、`.padding(Padding::from([12.0, 8.0]))`、
/// `.size(Size::new(10.0, 10.0))` —— 所以遇到这些包装要**往里剥一层**再判。
fn size_literal(argument: &str) -> Option<String> {
let mut rest = argument.trim_start();
for wrapper in ["Length::Fixed(", "Padding::from(", "Size::new("] {
if let Some(after) = rest.strip_prefix(wrapper) {
rest = after.trim_start().trim_start_matches(['[', '(']);
break;
}
}
let number: String = rest
.trim_start()
.chars()
.take_while(|ch| ch.is_ascii_digit() || *ch == '.')
.collect();
if number.is_empty() || number.trim_end_matches('.').parse::<f64>() == Ok(0.0) {
return None;
}
Some(number)
}
#[test]
fn shell_takes_every_size_from_tokens() {
let mut files = Vec::new();
rust_files(&manifest_dir().join("src").join("shell"), &mut files);
assert!(
files.len() >= 2,
"shell 模块至少有两个文件(mod.rs + nav.rs),实际扫到 {}",
files.len()
);
let mut offenders = Vec::new();
for path in files {
let text = std::fs::read_to_string(&path).expect("读 shell 源文件");
for (index, line) in text.lines().enumerate() {
// 文档注释是散文不是代码:里面写 `.width(24)` 是在解释坑,不该判违规
if line.trim_start().starts_with("//") {
continue;
}
for pattern in [".width(", ".height(", ".size(", ".spacing(", ".padding("] {
let mut from = 0;
while let Some(found) = line[from..].find(pattern) {
let after = from + found + pattern.len();
if let Some(number) = size_literal(&line[after..]) {
offenders.push(format!(
"{}:{} {line:?} -> {pattern} 后面直接写了数字 {number}",
path.display(),
index + 1
));
}
from = after;
}
}
}
}
assert!(
offenders.is_empty(),
"骨架尺寸必须来自 LayoutMetrics / TypeStyle / Space:\n{}",
offenders.join("\n")
);
}
/// 依赖行里出现 `slint` 就算违规(注释行放过:说明并存期要提到 slint)。
fn manifest_has_slint_dependency(manifest: &str) -> Vec<String> {
manifest
.lines()
.enumerate()
.filter(|(_, line)| {
let trimmed = line.trim_start();
!trimmed.starts_with('#') && trimmed.contains("slint")
})
.map(|(index, line)| format!("Cargo.toml:{} {line}", index + 1))
.collect()
}
#[test]
fn this_crate_never_depends_on_slint() {
let manifest =
std::fs::read_to_string(manifest_dir().join("Cargo.toml")).expect("读 Cargo.toml");
let offenders = manifest_has_slint_dependency(&manifest);
assert!(
offenders.is_empty(),
"P4 的 iced 版 crate 不许依赖 slint(D26):\n{}",
offenders.join("\n")
);
for (path, text) in sources() {
for (index, line) in text.lines().enumerate() {
// 只查真代码里的 use/路径,文档里提 slint 是允许的(要解释并存期)
let code = line.trim_start();
let is_import = code.starts_with("use ") || code.starts_with("extern crate");
if is_import && code.contains("slint") {
panic!("{}:{} 引入了 slint:{line}", path.display(), index + 1);
}
}
}
}
#[test]
fn the_gate_itself_catches_a_planted_violation() {
// 自检:把违规样本喂给同一套判定,必须被抓住(否则门禁是空转)。
// **reviewer r1 造样验证过的 5 个漏网样本逐条钉在这里**,退化成「放行」立刻红。
// 1. `Color::from_rgb8(103, 80, 164)` —— 8bit 版曾因 `from_rgb(` 匹配不到而漏
assert!(numeric_color_call("Color::from_rgb8(103, 80, 164)").is_some());
// 2. `iced::Color::from_rgb8(...)` —— 带路径前缀的写法同样要拦
assert!(numeric_color_call("iced::Color::from_rgb8(103, 80, 164)").is_some());
// 3. `Color { r: 0.4, g: 0.3, b: 0.6, a: 1.0 }` —— 结构体字面量
assert!(color_struct_literal(
"Color { r: 0.4, g: 0.3, b: 0.6, a: 1.0 }"
));
// 4. `.width(Length::Fixed(123.0))` —— 尺寸被 iced 包装一层
assert_eq!(
size_literal("Length::Fixed(123.0))").as_deref(),
Some("123.0")
);
// 5. `.padding(Padding::from([12.0, 8.0]))` —— 数组字面量
assert_eq!(
size_literal("Padding::from([12.0, 8.0]))").as_deref(),
Some("12.0")
);
// 原有规则不能退化
assert!(hex_color_at(b" color: #6750A4,", 11).is_some());
assert!(hex_color_at(b"// placeholder #RRGGBB here", 15).is_none());
assert!(numeric_color_call("Color::from_rgb(0.1, 0.2, 0.3)").is_some());
assert!(numeric_color_call("Color::from_rgba8(color.r, color.g, color.b, color.a)").is_none());
assert!(numeric_color_call("color!(0x5865F2)").is_some());
assert!(size_literal("40.0)").is_some());
assert!(size_literal("0)").is_none());
assert!(size_literal("metrics.titlebar_height)").is_none());
assert!(size_literal("Length::Fixed(thickness))").is_none());
assert!(size_literal("Padding::from([0.0, ui.space(Space::X4)]))").is_none());
assert!(!color_struct_literal(
"Color::from_rgba8(color.r, color.g, color.b, 1.0)"
));
assert!(!color_struct_literal("let color = Color { a: 0.5 };"));
assert!(!manifest_has_slint_dependency("slint = \"1.18\"").is_empty());
assert!(manifest_has_slint_dependency("# 不得依赖 slint").is_empty());
}
/// 渲染层回归守门(视觉复审 P2-1 的教训):`indicator_matches_the_reference_56x32` 只测纯函数,
/// 门禁全绿但图上的药丸实际是 88×32 —— 因为 `.center_x(Length::Fill)` 会把容器 width 覆盖成 Fill。
/// 这里直接扫 `nav.rs`:药丸容器**不许**出现 `center_x(` / `center_y(`。
#[test]
fn pill_never_overrides_its_size_with_fill() {
let nav = manifest_dir().join("src").join("shell").join("nav.rs");
let text = std::fs::read_to_string(&nav).expect("读 nav.rs");
for (index, line) in text.lines().enumerate() {
let code = line.trim_start();
if code.starts_with("//") {
continue;
}
for pattern in ["center_x(", "center_y("] {
assert!(
!code.contains(pattern),
"nav.rs:{} 用了 {pattern}——`center_*(Length::Fill)` 会把容器尺寸改成 Fill, 药丸会被父级宽度覆盖(视觉复审 P2-1)。居中请用父级 `align_x`:{line}",
index + 1
);
}
}
}
+7
View File
@@ -15,6 +15,11 @@ path = "src/main.rs"
[dependencies] [dependencies]
slint.workspace = true slint.workspace = true
# 跨界 DTO 必须可序列化(AGENTS §6 / 01c §5):事件与快照是 IPC 契约,
# 序列化能力由 derive 钉住,测试里做 round-trip 断言。
serde.workspace = true
# host 层要读 core 设置/事件里的 JSON 值(serde_json 是 workspace 既有依赖)
serde_json.workspace = true
# 边界(01c §5 / D3):app 只经 DTO 消费 core/agent,**不 import 内部类型**。 # 边界(01c §5 / D3):app 只经 DTO 消费 core/agent,**不 import 内部类型**。
# P0 的 host 是 src/mock.rs 的假后端;这两个依赖在这里登记,是为了后续把真实 host # P0 的 host 是 src/mock.rs 的假后端;这两个依赖在这里登记,是为了后续把真实 host
@@ -25,5 +30,7 @@ unlucky-agent = { path = "../agent", version = "0.1" }
[build-dependencies] [build-dependencies]
slint-build.workspace = true slint-build.workspace = true
[dev-dependencies]
[lints] [lints]
workspace = true workspace = true
+36
View File
@@ -0,0 +1,36 @@
//! 后端选择与启动选项:真实 core host 优先,解析不出数据根时退回 mock。
use std::path::{Path, PathBuf};
use crate::dto::Backend;
use crate::host::CoreHost;
use crate::host::HostEnv;
use crate::mock::MockBackend;
/// 启动选项(截图/演示通道用;正常启动全用默认值)。
#[derive(Debug, Clone, Default)]
pub struct AppOptions {
/// 钉死数据根(`<root>/.ULL` + `<root>/.minecraft`);None = 按便携配置解析
pub data_root: Option<PathBuf>,
/// 强制 mock 后端(UI 回归/无数据根演示)
pub mock: bool,
}
/// `<root>/.ULL/config/launcher.json`(与 `SettingsBridge::open` 同落点)。
pub(crate) fn config_path(root: &Path) -> PathBuf {
root.join(".ULL").join("config").join("launcher.json")
}
/// 选后端:真实 host 优先;数据根解析不出来或显式 `--mock` 时退回假后端。
pub(crate) fn build_backend(options: &AppOptions) -> Box<dyn Backend> {
if options.mock {
return Box::new(MockBackend::new());
}
match &options.data_root {
Some(root) => Box::new(CoreHost::new(HostEnv::at_root(root))),
None => match CoreHost::open() {
Some(host) => Box::new(host),
None => Box::new(MockBackend::new()),
},
}
}
+2 -4
View File
@@ -180,10 +180,8 @@ fn wire_instances(ui: &crate::MainWindow, weak: &Weak<Inner>) {
let weak = weak.clone(); let weak = weak.clone();
ui.on_new_instance(move || { ui.on_new_instance(move || {
let Some(this) = weak.upgrade() else { return }; let Some(this) = weak.upgrade() else { return };
this.state // G5:打开向导(版本 → 加载器 → 名字 → 确认 → 安装进度)
.borrow_mut() this.state.borrow_mut().wizard = crate::wizard::WizardState::open();
.toasts
.push(ToastKind::Info, "新建实例向导(G5)在 P1 落地");
this.sync(); this.sync();
}); });
} }
+264
View File
@@ -0,0 +1,264 @@
//! `PageState` global 的回调接线:账户 / Java / 设置 / 通知 / 向导 / 崩溃导入 / 日志刷新。
//!
//! 与 `callbacks.rs` 同一纪律:每个回调 = 「改状态 + 发 AppCommand + 同步 UI」,一处看全。
//! 用 global 而不是逐层转发属性(见 `ui/page_state.slint` 顶部的取舍说明)。
use std::rc::Weak;
use slint::ComponentHandle;
use crate::conv;
use crate::dto::{AppCommand, CreateRequest, LoaderKind};
use crate::ui_bind::Inner;
/// 接全部页面状态回调。
pub(crate) fn wire_pages(weak: Weak<Inner>) {
let Some(inner) = weak.upgrade() else { return };
let page = inner.ui.global::<crate::PageState<'_>>();
// ── 账户 ──────────────────────────────────────────────────
{
let weak = weak.clone();
page.on_account_add_offline(move |name| {
let name = name.to_string();
if name.trim().is_empty() {
return;
}
let Some(this) = weak.upgrade() else { return };
this.dispatch(AppCommand::AddOfflineAccount(name));
this.ui
.global::<crate::PageState<'_>>()
.set_offline_name("".into());
this.sync();
});
}
{
let weak = weak.clone();
page.on_account_remove(move |id| {
let Some(this) = weak.upgrade() else { return };
this.dispatch(AppCommand::RemoveAccount(id.to_string()));
this.sync();
});
}
{
let weak = weak.clone();
page.on_account_switch(move |id| {
let Some(this) = weak.upgrade() else { return };
this.dispatch(AppCommand::SwitchAccount(id.to_string()));
this.sync();
});
}
{
let weak = weak.clone();
page.on_msa_begin(move || {
let Some(this) = weak.upgrade() else { return };
this.dispatch(AppCommand::StartDeviceLogin);
this.sync();
});
}
{
let weak = weak.clone();
page.on_msa_browser_begin(move || {
let Some(this) = weak.upgrade() else { return };
this.dispatch(AppCommand::StartBrowserLogin);
this.sync();
});
}
{
let weak = weak.clone();
page.on_msa_cancel(move || {
let Some(this) = weak.upgrade() else { return };
this.dispatch(AppCommand::CancelDeviceLogin);
this.sync();
});
}
// ── Java ──────────────────────────────────────────────────
{
let weak = weak.clone();
page.on_java_install(move |vendor| {
let Some(this) = weak.upgrade() else { return };
this.dispatch(AppCommand::InstallJava(vendor.to_string()));
this.sync();
});
}
// ── 设置 ──────────────────────────────────────────────────
{
let weak = weak.clone();
page.on_setting_changed(move |key, value| {
let Some(this) = weak.upgrade() else { return };
this.dispatch(AppCommand::SetSetting {
key: key.to_string(),
value: value.to_string(),
});
this.sync();
});
}
{
let weak = weak.clone();
page.on_setting_action(move |action| {
let Some(this) = weak.upgrade() else { return };
let action = action.to_string();
match action.as_str() {
// 清 24h 地区缓存 → 下次下载重新判定
"detect_region" => this.dispatch(AppCommand::SetSetting {
key: "download.action.detect_region".to_string(),
value: String::new(),
}),
// Java 分组的「安装/管理」→ 按回退链装一个托管 JDK
"install_java" => this.dispatch(AppCommand::InstallJava(String::new())),
_ => {}
}
this.sync();
});
}
{
let weak = weak.clone();
page.on_settings_tab_changed(move |tab| {
let Some(this) = weak.upgrade() else { return };
this.state.borrow_mut().settings_tab = tab.to_string();
this.sync();
});
}
// ── 通知 ──────────────────────────────────────────────────
{
let weak = weak.clone();
page.on_notice_dismiss(move |id| {
let Some(this) = weak.upgrade() else { return };
this.dispatch(AppCommand::DismissNotice(id.to_string()));
this.sync();
});
}
// ── 向导 ──────────────────────────────────────────────────
{
let weak = weak.clone();
page.on_wizard_open(move || {
let Some(this) = weak.upgrade() else { return };
this.state.borrow_mut().wizard = crate::wizard::WizardState::open();
this.sync();
});
}
{
let weak = weak.clone();
page.on_wizard_close(move || {
let Some(this) = weak.upgrade() else { return };
this.state.borrow_mut().wizard.open = false;
this.sync();
});
}
{
let weak = weak.clone();
page.on_wizard_select_version(move |version| {
let Some(this) = weak.upgrade() else { return };
this.state
.borrow_mut()
.wizard
.select_version(version.as_ref());
this.sync();
});
}
{
let weak = weak.clone();
page.on_wizard_select_loader(move |index| {
let Some(this) = weak.upgrade() else { return };
let loader = match index {
1 => LoaderKind::Fabric,
2 => LoaderKind::Quilt,
3 => LoaderKind::Forge,
4 => LoaderKind::NeoForge,
_ => LoaderKind::Vanilla,
};
this.state.borrow_mut().wizard.select_loader(loader);
this.sync();
});
}
{
let weak = weak.clone();
page.on_wizard_name_changed(move |name| {
let Some(this) = weak.upgrade() else { return };
let mut state = this.state.borrow_mut();
state.wizard.name = name.to_string();
state.wizard.error.clear();
drop(state);
this.sync();
});
}
{
let weak = weak.clone();
page.on_wizard_next(move || {
let Some(this) = weak.upgrade() else { return };
this.state.borrow_mut().wizard.advance();
this.sync();
});
}
{
let weak = weak.clone();
page.on_wizard_back(move || {
let Some(this) = weak.upgrade() else { return };
this.state.borrow_mut().wizard.step_back();
this.sync();
});
}
{
let weak = weak.clone();
page.on_wizard_confirm(move || {
let Some(this) = weak.upgrade() else { return };
let request = {
let state = this.state.borrow();
let wizard = &state.wizard;
wizard.ready_to_submit().map(|()| CreateRequest {
version: wizard.version.trim().to_string(),
loader: wizard.loader,
loader_version: wizard.loader_version.trim().to_string(),
name: wizard.name.trim().to_string(),
})
};
match request {
Ok(request) => {
this.dispatch(AppCommand::CreateInstance(request));
this.state.borrow_mut().wizard.submitted();
}
Err(error) => {
this.state.borrow_mut().wizard.error = error;
}
}
this.sync();
});
}
{
let weak = weak.clone();
page.on_install_cancel(move || {
let Some(this) = weak.upgrade() else { return };
this.dispatch(AppCommand::CancelInstall);
this.sync();
});
}
// ── 日志页 ────────────────────────────────────────────────
{
let weak = weak.clone();
page.on_crash_import(move |path| {
let Some(this) = weak.upgrade() else { return };
let path = path.to_string();
if path.trim().is_empty() {
return;
}
this.dispatch(AppCommand::ImportCrashLog(path));
this.sync();
});
}
{
let weak = weak.clone();
page.on_logs_refresh(move || {
let Some(this) = weak.upgrade() else { return };
this.dispatch(AppCommand::RefreshLogSessions);
this.sync();
});
}
// 设置页分组切换同时刷新一遍分组快照(外部改配置后手点分组即最新)
let _ = conv::setting_groups;
}
+1
View File
@@ -33,6 +33,7 @@ pub fn phase(runtime: &InstanceRuntime) -> (RuntimePhase, f32, String) {
dto::LaunchStage::Download => RuntimePhase::Downloading, dto::LaunchStage::Download => RuntimePhase::Downloading,
dto::LaunchStage::Verify => RuntimePhase::Verifying, dto::LaunchStage::Verify => RuntimePhase::Verifying,
dto::LaunchStage::Launch => RuntimePhase::Launching, dto::LaunchStage::Launch => RuntimePhase::Launching,
dto::LaunchStage::Window => RuntimePhase::Running,
}; };
(phase, progress.ratio(), stage.label().to_string()) (phase, progress.ratio(), stage.label().to_string())
} }
+5
View File
@@ -8,12 +8,17 @@
mod agent; mod agent;
mod instances; mod instances;
mod logs; mod logs;
mod pages;
mod ui; mod ui;
mod versions; mod versions;
pub use agent::{chat, dialog, toasts}; pub use agent::{chat, dialog, toasts};
pub use instances::{running_tiles, tiles}; pub use instances::{running_tiles, tiles};
pub use logs::{diagnosis, log_lines, log_sessions}; pub use logs::{diagnosis, log_lines, log_sessions};
pub use pages::{
accounts, active_account_name, device, install, java_chain, java_runtimes, java_vendors,
notices, setting_groups, wizard, wizard_versions,
};
pub use ui::{ pub use ui::{
accent_color, bg_fill, bg_fill_from, category_from, instance_view, instance_view_from, route, accent_color, bg_fill, bg_fill_from, category_from, instance_view, instance_view_from, route,
route_from, theme_mode, theme_mode_from, tile_size, tile_size_from, version_category, route_from, theme_mode, theme_mode_from, tile_size, tile_size_from, version_category,
+300
View File
@@ -0,0 +1,300 @@
//! 状态 → `page_state.slint` 的视图模型(账户 / Java / 安装 / 设置分组 / 通知 / 向导)。
//!
//! 与 `conv/{instances,versions,logs,agent}.rs` 同一套路:只做「领域值 → 展示值」的搬运,
//! 不含业务判断。`ui_bind::sync` 把这些灌进 `PageState` global。
use slint::{ModelRc, SharedString, VecModel};
use crate::dto::{
AccountKind as DtoAccountKind, DeviceLoginPhase, InstallStage as DtoInstallStage,
JavaChainOutcome, NoticeLevel, SettingControl, VersionKind as DtoVersionKind,
};
use crate::state::AppState;
use crate::wizard::WizardStep;
use crate::{
AccountKind, InstallStage, JavaStepOutcome, NoticeKind, SettingControl as UiControl, UiAccount,
UiDeviceCode, UiInstallProgress, UiJavaRuntime, UiJavaStep, UiJavaVendor, UiNotice,
UiSettingGroup, UiSettingRow, UiVersion, UiWizard,
};
fn strings(items: &[String]) -> ModelRc<SharedString> {
ModelRc::new(VecModel::from(
items
.iter()
.map(|item| SharedString::from(item.as_str()))
.collect::<Vec<_>>(),
))
}
/// 账户行。
pub fn accounts(state: &AppState) -> Vec<UiAccount> {
state
.accounts
.iter()
.map(|account| UiAccount {
id: account.id.as_str().into(),
name: account.name.as_str().into(),
kind: match account.kind {
DtoAccountKind::Offline => AccountKind::Offline,
DtoAccountKind::Microsoft => AccountKind::Microsoft,
DtoAccountKind::Authlib => AccountKind::Authlib,
},
kind_label: account.kind.label().into(),
profile_id: account.profile_id.as_str().into(),
login_name: account.login_name.as_str().into(),
created: account.created.as_str().into(),
active: account.active,
})
.collect()
}
/// 当前启动账户的展示名(导航底部头像)。
pub fn active_account_name(state: &AppState) -> String {
state
.accounts
.iter()
.find(|account| account.active)
.map(|account| account.name.clone())
.unwrap_or_else(|| "未登录".to_string())
}
/// 微软登录面板。
pub fn device(state: &AppState) -> UiDeviceCode {
let view = &state.device_login;
UiDeviceCode {
visible: view.phase.visible(),
busy: view.phase.busy(),
user_code: view.user_code.as_str().into(),
verification_uri: view.verification_uri.as_str().into(),
qr_link: view.qr_link.as_str().into(),
expires: i32::try_from(view.expires_in).unwrap_or(i32::MAX),
message: view.message.as_str().into(),
phase_label: match view.phase {
DeviceLoginPhase::Idle => "空闲",
DeviceLoginPhase::Requesting => "申请中",
DeviceLoginPhase::Waiting => "等待确认",
DeviceLoginPhase::Succeeded => "成功",
DeviceLoginPhase::Failed => "失败",
}
.into(),
}
}
/// Java 发行版行。
pub fn java_vendors(state: &AppState) -> Vec<UiJavaVendor> {
state
.java
.vendors
.iter()
.map(|vendor| UiJavaVendor {
id: vendor.id.as_str().into(),
display: vendor.display.as_str().into(),
rank: i32::try_from(vendor.rank).unwrap_or(0),
majors: vendor.majors.as_str().into(),
sources: vendor.sources.join(" · ").into(),
installed: vendor.installed,
})
.collect()
}
/// Java 回退链。
pub fn java_chain(state: &AppState) -> Vec<UiJavaStep> {
state
.java
.chain
.iter()
.map(|step| UiJavaStep {
vendor: step.vendor.as_str().into(),
outcome: match step.outcome {
JavaChainOutcome::Pending => JavaStepOutcome::Pending,
JavaChainOutcome::Skip => JavaStepOutcome::Skip,
JavaChainOutcome::Failed => JavaStepOutcome::Failed,
JavaChainOutcome::Hit => JavaStepOutcome::Hit,
},
outcome_label: step.outcome.label().into(),
detail: step.detail.as_str().into(),
})
.collect()
}
/// Java 本地运行时。
pub fn java_runtimes(state: &AppState) -> Vec<UiJavaRuntime> {
state
.java
.runtimes
.iter()
.map(|runtime| UiJavaRuntime {
binary: runtime.binary.as_str().into(),
version: runtime.version.as_str().into(),
vendor: runtime.vendor.as_str().into(),
origin: runtime.origin.as_str().into(),
})
.collect()
}
/// 安装进度卡片。
pub fn install(state: &AppState) -> UiInstallProgress {
let Some(progress) = &state.install else {
return UiInstallProgress {
visible: false,
stage: InstallStage::Planning,
..UiInstallProgress::default()
};
};
UiInstallProgress {
visible: true,
version: progress.version.as_str().into(),
instance: progress.instance.as_str().into(),
loader: progress.loader.as_str().into(),
stage: match progress.stage {
DtoInstallStage::Planning => InstallStage::Planning,
DtoInstallStage::Downloading => InstallStage::Downloading,
DtoInstallStage::Installing => InstallStage::Installing,
DtoInstallStage::Registering => InstallStage::Registering,
DtoInstallStage::Done => InstallStage::Done,
DtoInstallStage::Failed => InstallStage::Failed,
DtoInstallStage::Cancelled => InstallStage::Cancelled,
},
stage_label: progress.stage.label().into(),
ratio: progress.ratio(),
known: progress.is_known(),
files_label: progress.files_label().into(),
bytes_label: if progress.bytes_total > 0 {
format!(
"{}/{}",
crate::dto::human_bytes(progress.bytes_done),
crate::dto::human_bytes(progress.bytes_total)
)
} else {
String::new()
}
.into(),
speed: progress.speed_label().into(),
eta: progress.eta_label().into(),
message: progress.message.as_str().into(),
terminal: progress.stage.is_terminal(),
failed: progress.stage == DtoInstallStage::Failed,
}
}
/// 设置分组(行 + 控件)。
pub fn setting_groups(state: &AppState) -> Vec<UiSettingGroup> {
state
.settings_groups
.iter()
.map(|group| UiSettingGroup {
id: group.id.as_str().into(),
title: group.title.as_str().into(),
rows: ModelRc::new(VecModel::from(
group
.rows
.iter()
.map(|row| UiSettingRow {
key: row.key.as_str().into(),
label: row.label.as_str().into(),
description: row.description.as_str().into(),
control: match row.control {
SettingControl::Toggle => UiControl::Toggle,
SettingControl::Choice => UiControl::Choice,
SettingControl::Text => UiControl::Text,
SettingControl::ReadOnly => UiControl::Readonly,
SettingControl::Action => UiControl::Action,
},
flag: row.flag,
selected: row.selected,
options: strings(&row.options),
value_text: row.value_text.as_str().into(),
action: row.action.as_str().into(),
})
.collect::<Vec<_>>(),
)),
})
.collect()
}
/// 横幅通知。
pub fn notices(state: &AppState) -> Vec<UiNotice> {
state
.notices
.iter()
.map(|notice| UiNotice {
id: notice.id.as_str().into(),
kind: match notice.level {
NoticeLevel::Info => NoticeKind::Info,
NoticeLevel::Warn => NoticeKind::Warn,
NoticeLevel::Error => NoticeKind::Error,
},
title: notice.title.as_str().into(),
body: notice.body.as_str().into(),
})
.collect()
}
/// 向导状态。
pub fn wizard(state: &AppState) -> UiWizard {
let wizard = &state.wizard;
let step = wizard.step;
let can_next = match step {
WizardStep::Version => !wizard.version.trim().is_empty(),
WizardStep::Name => crate::wizard::validate_name(&wizard.name).is_ok(),
WizardStep::Loader | WizardStep::Confirm => true,
WizardStep::Progress => false,
};
UiWizard {
open: wizard.open,
step: i32::try_from(step.index()).unwrap_or(0),
step_label: step.title().into(),
step_index: i32::try_from(step.index()).unwrap_or(0),
step_total: 5,
version: wizard.version.as_str().into(),
loader: loader_index(wizard.loader),
loader_label: if wizard.loader == crate::dto::LoaderKind::Vanilla {
"原版".to_string()
} else {
format!("{} {}", wizard.loader.label(), wizard.loader_version.trim())
.trim()
.to_string()
}
.into(),
loader_version: wizard.loader_version.as_str().into(),
name: wizard.name.as_str().into(),
error: wizard.error.as_str().into(),
busy: wizard.submitting,
can_next,
}
}
/// 加载器在向导里的单选序号(与 `wizard.slint` 的 LoaderChoice 顺序一致)。
pub fn loader_index(loader: crate::dto::LoaderKind) -> i32 {
match loader {
crate::dto::LoaderKind::Vanilla => 0,
crate::dto::LoaderKind::Fabric => 1,
crate::dto::LoaderKind::Quilt => 2,
crate::dto::LoaderKind::Forge => 3,
crate::dto::LoaderKind::NeoForge => 4,
crate::dto::LoaderKind::OptiFine | crate::dto::LoaderKind::LiteLoader => 0,
}
}
/// 向导里的版本列表(正式版 + 快照;远古版走下载页的显式入口)。
pub fn wizard_versions(state: &AppState) -> Vec<UiVersion> {
state
.versions
.iter()
.filter(|version| !version.kind.is_ancient())
.map(|version| UiVersion {
id: version.id.as_str().into(),
kind: match version.kind {
DtoVersionKind::Release => crate::VersionKind::Release,
_ => crate::VersionKind::Snapshot,
},
released: version.released.as_str().into(),
installed: version.installed,
ancient: false,
})
.collect()
}
#[cfg(test)]
#[path = "pages_tests.rs"]
mod tests;
+172
View File
@@ -0,0 +1,172 @@
//! conv/pages.rs 的单测(拆文件守 400 行上限)。
use super::*;
use crate::dto::{
AccountView, DeviceLoginView, InstallProgressView, JavaPageView, JavaVendorView, NoticeView,
SettingGroupView, SettingRowView, VersionSummary,
};
use crate::wizard::WizardState;
use slint::Model;
#[test]
fn accounts_and_device_login_render() {
let mut state = AppState::new();
state.accounts = vec![AccountView {
id: "account:1".to_string(),
name: "Steve".to_string(),
kind: DtoAccountKind::Offline,
profile_id: "0f8f".to_string(),
login_name: String::new(),
created: "2026-10-31 20:12".to_string(),
active: true,
}];
state.device_login =
DeviceLoginView::waiting("ABCD-EFGH", "https://www.microsoft.com/link", 900, "");
let rows = accounts(&state);
assert_eq!(rows.len(), 1);
assert!(rows[0].active);
assert_eq!(rows[0].kind_label, "离线");
assert_eq!(active_account_name(&state), "Steve");
let device = device(&state);
assert!(device.visible && device.busy);
assert_eq!(device.user_code, "ABCD-EFGH");
assert_eq!(device.phase_label, "等待确认");
}
#[test]
fn browser_login_panel_renders_the_authorization_link() {
let mut state = AppState::new();
state.device_login = DeviceLoginView::browser_waiting(
"https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id=x",
300,
None,
);
let device = device(&state);
assert!(device.visible && device.busy);
assert_eq!(device.user_code, "", "授权码流没有用户码");
assert!(device.verification_uri.contains("/authorize?client_id=x"));
assert_eq!(device.qr_link, device.verification_uri);
assert_eq!(device.expires, 300);
}
#[test]
fn install_progress_renders_labels_and_terminal_flags() {
let mut state = AppState::new();
state.install = Some(InstallProgressView {
version: "1.20.4".to_string(),
instance: "生存".to_string(),
loader: "Fabric 0.16.5".to_string(),
stage: DtoInstallStage::Downloading,
files_done: 3,
files_total: 6,
bytes_done: 1024,
bytes_total: 4096,
speed_bps: 2048,
eta_secs: 2,
message: "下载中".to_string(),
});
let ui = install(&state);
assert!(ui.visible && !ui.terminal && !ui.failed);
assert!((ui.ratio - 0.5).abs() < f32::EPSILON);
assert_eq!(ui.files_label, "3/6 文件");
assert_eq!(ui.speed, "2.0 KB/s");
assert_eq!(ui.eta, "剩 2s");
state.install = None;
assert!(!install(&state).visible);
}
#[test]
fn java_chain_and_vendors_map_outcomes() {
let mut state = AppState::new();
state.java = JavaPageView {
vendors: vec![JavaVendorView {
id: "temurin".to_string(),
display: "Eclipse Temurin".to_string(),
rank: 1,
majors: "17/21".to_string(),
sources: vec!["BMCLAPI".to_string(), "GitHub".to_string()],
installed: true,
}],
chain: vec![crate::dto::JavaChainStep {
vendor: "temurin".to_string(),
outcome: JavaChainOutcome::Hit,
detail: "21".to_string(),
}],
..JavaPageView::new()
};
let vendors = java_vendors(&state);
assert_eq!(vendors.len(), 1);
assert_eq!(vendors[0].sources, "BMCLAPI · GitHub");
let chain = java_chain(&state);
assert!(matches!(chain[0].outcome, JavaStepOutcome::Hit));
assert_eq!(chain[0].outcome_label, "命中");
}
#[test]
fn settings_groups_map_controls() {
let mut state = AppState::new();
state.settings_groups = vec![SettingGroupView::new("download", "下载")
.with(SettingRowView::toggle("agent.mcp.enabled", "MCP", "", true))
.with(SettingRowView::choice(
"download.region",
"地区",
"",
&["auto", "cn"],
1,
))];
let groups = setting_groups(&state);
assert_eq!(groups.len(), 1);
let rows = groups[0].rows.clone();
assert_eq!(rows.row_count(), 2);
let first = rows.row_data(0).expect("第一行");
let second = rows.row_data(1).expect("第二行");
assert!(first.flag);
assert!(matches!(second.control, UiControl::Choice));
assert_eq!(second.selected, 1);
}
#[test]
fn notices_and_wizard_render() {
let mut state = AppState::new();
state.notices = vec![NoticeView::new(
"isolation:x",
NoticeLevel::Warn,
"隔离档位已切换",
"两侧都有内容",
)];
let notices = notices(&state);
assert!(matches!(notices[0].kind, NoticeKind::Warn));
state.wizard = WizardState::open();
state.wizard.select_version("1.20.4");
let wizard = wizard(&state);
assert!(wizard.open);
assert_eq!(wizard.step_label, "选择版本");
assert!(wizard.can_next);
assert_eq!(wizard.loader, 0);
}
#[test]
fn wizard_versions_skip_ancient_ones() {
let mut state = AppState::new();
state.versions = vec![
VersionSummary {
id: "1.20.4".to_string(),
kind: DtoVersionKind::Release,
released: "2023-12-07".to_string(),
installed: false,
},
VersionSummary {
id: "rd-132211".to_string(),
kind: DtoVersionKind::Ancient(crate::dto::AncientKind::Classic),
released: "2009-05-13".to_string(),
installed: false,
},
];
let rows = wizard_versions(&state);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].id, "1.20.4");
}
+30 -8
View File
@@ -1,17 +1,25 @@
//! 版本列表与下载分类映射(下载页)。 //! 版本列表与下载分类映射(下载页)。
use crate::dto::{AncientKind, VersionKind as DtoVersionKind}; use crate::dto::{AncientKind, VersionCategory, VersionKind as DtoVersionKind, VersionSummary};
use crate::state::AppState; use crate::state::AppState;
use crate::{UiVersion, VersionKind}; use crate::{UiVersion, VersionKind};
/// 下载页版本行(已按「仅已装」+ 版本号筛选词过滤)。 /// 分类侧栏 → 该分类下的版本(远古版按子类细分)。
pub fn versions(state: &AppState) -> Vec<UiVersion> { pub fn in_category(category: VersionCategory, kind: DtoVersionKind) -> bool {
let query = state.download_query.trim().to_lowercase(); match (category, kind) {
state (VersionCategory::Release, DtoVersionKind::Release) => true,
.versions (VersionCategory::Snapshot, DtoVersionKind::Snapshot) => true,
(VersionCategory::Ancient(expected), DtoVersionKind::Ancient(actual)) => expected == actual,
// 未接线的分类(整合包 / Mod)不显示任何版本,而不是把全部版本倒进去
(VersionCategory::Modpack | VersionCategory::Mod, _) => false,
_ => false,
}
}
/// 版本行(下载页与新建向导共用)。
pub fn version_rows(versions: &[VersionSummary]) -> Vec<UiVersion> {
versions
.iter() .iter()
.filter(|version| !state.installed_only || version.installed)
.filter(|version| query.is_empty() || version.id.to_lowercase().contains(&query))
.map(|version| UiVersion { .map(|version| UiVersion {
id: version.id.clone().into(), id: version.id.clone().into(),
kind: version_kind(version.kind), kind: version_kind(version.kind),
@@ -22,6 +30,20 @@ pub fn versions(state: &AppState) -> Vec<UiVersion> {
.collect() .collect()
} }
/// 下载页版本行(按当前分类 + 「仅已装」+ 版本号筛选词过滤)。
pub fn versions(state: &AppState) -> Vec<UiVersion> {
let query = state.download_query.trim().to_lowercase();
let filtered: Vec<VersionSummary> = state
.versions
.iter()
.filter(|version| in_category(state.prefs.download_category, version.kind))
.filter(|version| !state.installed_only || version.installed)
.filter(|version| query.is_empty() || version.id.to_lowercase().contains(&query))
.cloned()
.collect();
version_rows(&filtered)
}
fn version_kind(kind: DtoVersionKind) -> VersionKind { fn version_kind(kind: DtoVersionKind) -> VersionKind {
match kind { match kind {
DtoVersionKind::Release => VersionKind::Release, DtoVersionKind::Release => VersionKind::Release,
+5 -3
View File
@@ -4,11 +4,13 @@
//! 当前条被确认/取消后由 [`DialogQueue::advance`] 推进到下一条,UI 只渲染 `current`。 //! 当前条被确认/取消后由 [`DialogQueue::advance`] 推进到下一条,UI 只渲染 `current`。
//! 非阻塞提示走 [`crate::toast`],不占模态队列。 //! 非阻塞提示走 [`crate::toast`],不占模态队列。
use serde::{Deserialize, Serialize};
/// 对话框标识。 /// 对话框标识。
pub type DialogId = u64; pub type DialogId = u64;
/// 对话框来源:决定插队优先级。 /// 对话框来源:决定插队优先级。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum DialogSource { pub enum DialogSource {
/// 提示类(教程、首次使用说明) /// 提示类(教程、首次使用说明)
Hint = 0, Hint = 0,
@@ -26,7 +28,7 @@ impl DialogSource {
} }
/// 对话框类型(09 §3:确认 / 进度 / 输入 / 错误)。 /// 对话框类型(09 §3:确认 / 进度 / 输入 / 错误)。
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum DialogKind { pub enum DialogKind {
/// 确认:主按钮 + 次按钮 /// 确认:主按钮 + 次按钮
Confirm { Confirm {
@@ -87,7 +89,7 @@ impl DialogKind {
} }
/// 一条对话框请求。 /// 一条对话框请求。
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DialogRequest { pub struct DialogRequest {
/// 队列内唯一 id(由 [`DialogQueue::push_request`] 重新编号) /// 队列内唯一 id(由 [`DialogQueue::push_request`] 重新编号)
pub id: DialogId, pub id: DialogId,
+270
View File
@@ -0,0 +1,270 @@
//! 账户页 DTO:三条轨道的统一抽象 + 微软登录(设备码 / 授权码浏览器)的可见状态。
//!
//! **凭据红线**:本模块(以及整个 `crate::dto`)只有 [`AccountView`] 这种公开字段视图。
//! `core::auth` 的 `Credentials` / `MicrosoftSession` / token 永远不进 app 层——
//! host 侧在拿到账户快照的那一刻就丢掉凭据,只留「能显示什么」。
use serde::{Deserialize, Serialize};
/// 账户轨道(三轨统一抽象,`09 §G6` / `D9`)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AccountKind {
/// 离线账户(HMCL 兼容 UUID)
Offline,
/// 微软正版
Microsoft,
/// authlib-injector(第三方皮肤站)
Authlib,
}
impl AccountKind {
/// 徽标文字。
pub fn label(self) -> &'static str {
match self {
Self::Offline => "离线",
Self::Microsoft => "微软",
Self::Authlib => "第三方",
}
}
/// 稳定 id(事件/持久化用;与 core 的 `kind.as_str()` 同词表)。
pub fn as_str(self) -> &'static str {
match self {
Self::Offline => "offline",
Self::Microsoft => "microsoft",
Self::Authlib => "authlib_injector",
}
}
/// 从 core 的稳定 id 解析(未知轨道按离线处理,不让一个坏条目毁掉整个列表)。
pub fn parse(raw: &str) -> Self {
match raw {
"microsoft" => Self::Microsoft,
"authlib_injector" => Self::Authlib,
_ => Self::Offline,
}
}
}
/// 账户列表行(**只含可公开字段**)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccountView {
/// 稳定 id(`account:...`)
pub id: String,
/// 展示名
pub name: String,
/// 轨道
pub kind: AccountKind,
/// 档案 UUID(公开)
pub profile_id: String,
/// 登录名(微软轨才有;可空)
pub login_name: String,
/// 创建时间展示串(`2026-10-31 20:12`)
pub created: String,
/// 是否是当前启动使用的账户
pub active: bool,
}
/// 微软登录阶段(UI 据此切换面板;两条流共用)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DeviceLoginPhase {
/// 没有登录在进行
Idle,
/// 正在向微软申请设备码(短暂)
Requesting,
/// 已拿到用户码,等待用户在浏览器完成
Waiting,
/// 成功(账户已入库)
Succeeded,
/// 失败(原因在 `message`,已脱敏)
Failed,
}
impl DeviceLoginPhase {
/// 面板是否要显示(非 Idle 都显示)。
pub fn visible(self) -> bool {
!matches!(self, Self::Idle)
}
/// 是否还在跑(Requesting / Waiting)。
pub fn busy(self) -> bool {
matches!(self, Self::Requesting | Self::Waiting)
}
}
/// 设备码登录的可见状态。
///
/// `user_code` / `verification_uri` 来自 `core::auth::microsoft::DeviceCodePrompt`——
/// 它们本来就是给用户看的,不是凭据。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeviceLoginView {
/// 阶段
pub phase: DeviceLoginPhase,
/// 用户码(`ABCD-EFGH`)
pub user_code: String,
/// 验证地址(`https://www.microsoft.com/link`)
pub verification_uri: String,
/// 二维码内容(= 带码的链接;GUI 用文本展示,终端可直接打开)
pub qr_link: String,
/// 有效期(秒;0 = 未知)
pub expires_in: u64,
/// 状态/错误文案
pub message: String,
/// 成功后入库的账户展示名
pub account_name: String,
}
impl Default for DeviceLoginView {
fn default() -> Self {
Self::new()
}
}
impl DeviceLoginView {
/// 空闲态。
pub fn new() -> Self {
Self {
phase: DeviceLoginPhase::Idle,
user_code: String::new(),
verification_uri: String::new(),
qr_link: String::new(),
expires_in: 0,
message: String::new(),
account_name: String::new(),
}
}
/// 已拿到用户码(`present` 回调时刻)。
pub fn waiting(
user_code: &str,
verification_uri: &str,
expires_in: u64,
qr_hint: &str,
) -> Self {
let qr_link = if qr_hint.is_empty() {
format!("{verification_uri}?code={user_code}")
} else {
qr_hint.to_string()
};
Self {
phase: DeviceLoginPhase::Waiting,
user_code: user_code.to_string(),
verification_uri: verification_uri.to_string(),
qr_link,
expires_in,
message: format!(
"在浏览器打开 {verification_uri} 并输入用户码 {user_code}({} 秒内有效)",
expires_in
),
account_name: String::new(),
}
}
/// 授权码流:本地回调已就绪、浏览器已拉起(`present` 回调时刻)。
///
/// 面板不展示用户码,改展示**可复制的授权链接**(用户可能需要在另一台设备/
/// 另一个浏览器里手动打开);`warning` 是「浏览器没拉起来」这类非致命提示。
pub fn browser_waiting(
authorization_url: &str,
expires_in: u64,
warning: Option<&str>,
) -> Self {
let mut message = format!(
"已在系统浏览器打开微软登录页,请完成授权({} 秒内有效);没弹出就把下面的链接粘到浏览器里",
expires_in.max(1)
);
if let Some(warning) = warning {
message.push_str(&format!("({warning})"));
}
Self {
phase: DeviceLoginPhase::Waiting,
user_code: String::new(),
verification_uri: authorization_url.to_string(),
qr_link: authorization_url.to_string(),
expires_in,
message,
account_name: String::new(),
}
}
/// 失败态。
pub fn failed(message: impl Into<String>) -> Self {
Self {
phase: DeviceLoginPhase::Failed,
message: message.into(),
..Self::new()
}
}
/// 成功态。
pub fn succeeded(account_name: impl Into<String>) -> Self {
let account_name = account_name.into();
Self {
phase: DeviceLoginPhase::Succeeded,
message: format!("已登录:{account_name}"),
account_name,
..Self::new()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kind_round_trips_through_stable_ids() {
for kind in [
AccountKind::Offline,
AccountKind::Microsoft,
AccountKind::Authlib,
] {
assert_eq!(AccountKind::parse(kind.as_str()), kind);
assert!(!kind.label().is_empty());
}
}
#[test]
fn device_login_view_never_carries_tokens() {
let view = DeviceLoginView::waiting("ABCD-EFGH", "https://www.microsoft.com/link", 900, "");
assert_eq!(view.phase, DeviceLoginPhase::Waiting);
assert!(view.phase.busy());
assert!(view.qr_link.contains("ABCD-EFGH"));
let json = serde_json::to_string(&view).expect("序列化");
for forbidden in ["token", "secret", "password", "refresh"] {
assert!(
!json.to_lowercase().contains(forbidden),
"视图里不该出现 {forbidden}"
);
}
}
#[test]
fn browser_waiting_shows_a_copyable_link_instead_of_a_user_code() {
let view = DeviceLoginView::browser_waiting(
"https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id=x",
300,
Some("拉起浏览器失败"),
);
assert_eq!(view.phase, DeviceLoginPhase::Waiting);
assert!(view.phase.busy());
assert!(view.user_code.is_empty(), "授权码流没有用户码");
assert_eq!(view.qr_link, view.verification_uri);
assert!(view.message.contains("浏览器"));
assert!(view.message.contains("拉起浏览器失败"), "{}", view.message);
let json = serde_json::to_string(&view).expect("序列化");
for forbidden in ["refresh", "mc-access"] {
assert!(!json.contains(forbidden), "视图里不该出现 {forbidden}");
}
}
#[test]
fn succeeded_and_failed_clear_the_code() {
let ok = DeviceLoginView::succeeded("Steve");
assert_eq!(ok.phase, DeviceLoginPhase::Succeeded);
assert!(ok.user_code.is_empty());
let bad = DeviceLoginView::failed("网络超时");
assert_eq!(bad.phase, DeviceLoginPhase::Failed);
assert_eq!(bad.message, "网络超时");
}
}
+6 -4
View File
@@ -1,7 +1,9 @@
//! AI 会话 DTO:消息、工具调用可视化与 agent 事件。 //! AI 会话 DTO:消息、工具调用可视化与 agent 事件。
use serde::{Deserialize, Serialize};
/// 会话消息角色。 /// 会话消息角色。
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ChatRole { pub enum ChatRole {
/// 用户 /// 用户
User, User,
@@ -14,7 +16,7 @@ pub enum ChatRole {
} }
/// 工具调用可视化条目。 /// 工具调用可视化条目。
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolCallView { pub struct ToolCallView {
/// 工具名(与 ToolSpec 注册表同名) /// 工具名(与 ToolSpec 注册表同名)
pub name: String, pub name: String,
@@ -25,7 +27,7 @@ pub struct ToolCallView {
} }
/// 一条会话消息。 /// 一条会话消息。
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChatMessage { pub struct ChatMessage {
/// 角色 /// 角色
pub role: ChatRole, pub role: ChatRole,
@@ -60,7 +62,7 @@ impl ChatMessage {
} }
/// agent 侧事件。 /// agent 侧事件。
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AgentEvent { pub enum AgentEvent {
/// 追加一条完整消息 /// 追加一条完整消息
MessageAppended(ChatMessage), MessageAppended(ChatMessage),
+162 -11
View File
@@ -1,12 +1,46 @@
//! 事件与指令:core/agent → app 的事件队列,app → core/agent 的用户意图。 //! 事件与指令:core/agent → app 的事件队列,app → core/agent 的用户意图。
//!
//! 三条通道(01c §5):①不可变快照发布(`*Changed` 整体替换)②事件流(`LogAppended` /
//! `InstallProgress` / `DeviceLoginChanged`)③注入的显示服务(先落成事件,见 `DialogRequested`)。
//! 全部类型 `Serialize + Deserialize`:这是 IPC 契约(AGENTS §6)。
use serde::{Deserialize, Serialize};
use crate::dialogs::{DialogId, DialogRequest}; use crate::dialogs::{DialogId, DialogRequest};
use crate::dto::{AgentEvent, DiagnosisReport, InstanceId, InstanceRuntime, InstanceSummary}; use crate::dto::CreateRequest;
use crate::dto::{LogLine, LogSession, SessionId, VersionCategory, VersionSummary}; use crate::dto::{
AccountView, AgentEvent, DeviceLoginView, DiagnosisReport, InstallProgressView, InstanceId,
InstanceRuntime, InstanceSummary, JavaPageView, LogLine, LogOutcome, LogSession, NoticeView,
SessionId, SettingGroupView, VersionCategory, VersionSummary,
};
use crate::toast::Toast; use crate::toast::Toast;
/// 启动会话的一段实时日志(host 尾随 JSONL 后按批次发)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LiveLogBatch {
/// 会话 id
pub session: SessionId,
/// 新增行
pub lines: Vec<LogLine>,
/// 会话是否还在跑(false = 收尾,可以触发诊断/刷新列表)
pub running: bool,
}
/// 一次启动收尾(磁贴状态回写 + 日志页会话列表刷新)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LaunchFinished {
/// 实例 id
pub id: InstanceId,
/// 会话 id
pub session: SessionId,
/// 结局
pub outcome: LogOutcome,
/// 一句人读结论(`正常退出` / `崩溃(exit 1)` / `启动失败:…`)
pub summary: String,
}
/// core 侧事件(不可变快照发布 + 事件流,01c §5 的三条通道之二)。 /// core 侧事件(不可变快照发布 + 事件流,01c §5 的三条通道之二)。
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum CoreEvent { pub enum CoreEvent {
/// 实例列表整体刷新 /// 实例列表整体刷新
InstancesChanged(Vec<InstanceSummary>), InstancesChanged(Vec<InstanceSummary>),
@@ -21,15 +55,31 @@ pub enum CoreEvent {
VersionListChanged(Vec<VersionSummary>), VersionListChanged(Vec<VersionSummary>),
/// 日志会话列表刷新 /// 日志会话列表刷新
LogSessionsChanged(Vec<LogSession>), LogSessionsChanged(Vec<LogSession>),
/// 当前会话日志行刷新(P0 整批替换;P1 改增量追加) /// 当前会话日志行刷新(整批替换)
LogLinesChanged(Vec<LogLine>), LogLinesChanged(Vec<LogLine>),
/// 实时日志增量(启动中尾随 JSONL;`running=false` 表示会话收尾)
LogAppended(LiveLogBatch),
/// 启动收尾(磁贴状态回写 + toast)
LaunchFinished(LaunchFinished),
/// 诊断结论产出 /// 诊断结论产出
DiagnosisReady(DiagnosisReport), DiagnosisReady(DiagnosisReport),
/// 账户列表刷新
AccountsChanged(Vec<AccountView>),
/// 微软登录状态刷新
DeviceLoginChanged(DeviceLoginView),
/// Java 页快照(发行版/回退链/运行时)
JavaChanged(JavaPageView),
/// 安装进度(`None` = 清掉进度面板)
InstallProgress(Option<InstallProgressView>),
/// 设置分组刷新(设置页右栏)
SettingsChanged(Vec<SettingGroupView>),
/// 横幅通知刷新(隔离切换提示/启动失败等)
NoticesChanged(Vec<NoticeView>),
} }
/// 统一事件队列的元素:core / agent / UI 自产(对话框、Toast)都走这一条路, /// 统一事件队列的元素:core / agent / UI 自产(对话框、Toast)都走这一条路,
/// 这样 [`crate::state::AppState::apply`] 是唯一的状态变更入口。 /// 这样 [`crate::state::AppState::apply`] 是唯一的状态变更入口。
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum AppEvent { pub enum AppEvent {
/// 来自 core /// 来自 core
Core(CoreEvent), Core(CoreEvent),
@@ -44,22 +94,58 @@ pub enum AppEvent {
} }
/// app → core/agent 的用户意图。 /// app → core/agent 的用户意图。
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AppCommand { pub enum AppCommand {
/// 重新扫描实例目录 /// 重新扫描实例目录 + 全部页面快照(启动时与手改文件后)
RefreshInstances, RefreshAll,
/// 启动实例 /// 启动实例
LaunchInstance(InstanceId), LaunchInstance(InstanceId),
/// 停止实例(含取消启动中的管线) /// 停止实例(含取消启动中的管线)
StopInstance(InstanceId), StopInstance(InstanceId),
/// 拉取某分类的版本列表 /// 拉取某分类的版本列表
ListVersions(VersionCategory), ListVersions(VersionCategory),
/// 安装指定版本 /// 安装指定版本(下载页行内「安装」按钮;原版)
InstallVersion(String), InstallVersion(String),
/// 取消安装(阶段边界生效:当前阶段跑完就停,不再进下一阶段)
CancelInstall,
/// 新建实例(向导提交:版本 + 加载器 + 名字)
CreateInstance(CreateRequest),
/// 修改实例隔离档位(写实例设置文件)
SetIsolation {
/// 实例 id
id: InstanceId,
/// `isolated` / `shared`
mode: String,
},
/// 刷新日志会话列表 /// 刷新日志会话列表
RefreshLogSessions, RefreshLogSessions,
/// 打开某个会话(拉取日志行 + 触发诊断) /// 打开某个会话(拉取日志行 + 触发诊断)
OpenLogSession(SessionId), OpenLogSession(SessionId),
/// 导入崩溃日志文件做规则分析(`hs_err_pid*.log` / `latest.log`)
ImportCrashLog(String),
/// 加一个离线账户
AddOfflineAccount(String),
/// 删除账户(按 id 或名字)
RemoveAccount(String),
/// 切换当前启动账户
SwitchAccount(String),
/// 开始微软设备码登录(CLI/无浏览器场景、备用通道)
StartDeviceLogin,
/// 开始微软授权码登录(拉起系统默认浏览器 + 本地回调,GUI 的默认入口)
StartBrowserLogin,
/// 取消/关掉微软登录面板(已发出的请求会在收尾时丢弃结果)
CancelDeviceLogin,
/// 安装指定发行版的托管 JDK(`temurin` / `oracle` / `microsoft` / `zulu`)
InstallJava(String),
/// 写一条设置(`key` 必须是 core schema 里的键)
SetSetting {
/// schema 键
key: String,
/// 新值(字符串形态;host 按 schema 类型转换)
value: String,
},
/// 关掉一条横幅通知
DismissNotice(String),
/// 向 AI 提问(会话状态在 app 侧,agent 只回事件) /// 向 AI 提问(会话状态在 app 侧,agent 只回事件)
AskAgent(String), AskAgent(String),
/// 中断当前 AI 流 /// 中断当前 AI 流
@@ -68,7 +154,17 @@ pub enum AppCommand {
SetYolo(bool), SetYolo(bool),
} }
/// 后端通道:core/agent host 实现它;P0 用 [`crate::mock::MockBackend`] 验证通道语义。 impl AppCommand {
/// 构造「切换隔离档位」指令(磁贴/详情页共用)。
pub fn set_isolation(id: &str, mode: &str) -> Self {
Self::SetIsolation {
id: id.to_string(),
mode: mode.to_string(),
}
}
}
/// 后端通道:core/agent host 实现它;[`crate::mock::MockBackend`] 验证通道语义。
/// ///
/// 轮询模型(而非回调)是为了让 Slint 的定时器成为唯一调度者, /// 轮询模型(而非回调)是为了让 Slint 的定时器成为唯一调度者,
/// 避免跨线程回调里碰 Slint 模型。 /// 避免跨线程回调里碰 Slint 模型。
@@ -79,8 +175,63 @@ pub trait Backend {
/// 下发一条用户意图。 /// 下发一条用户意图。
fn dispatch(&mut self, command: AppCommand); fn dispatch(&mut self, command: AppCommand);
/// 时间推进(mock 用脚本推进进度;真实后端可以是空实现)。 /// 时间推进(真实后端在这里收后台线程的结果并尾随日志)。
fn tick(&mut self, elapsed: std::time::Duration) { fn tick(&mut self, elapsed: std::time::Duration) {
let _ = elapsed; let _ = elapsed;
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::dto::{AccountKind, DeviceLoginPhase, InstallStage};
#[test]
fn events_are_serializable_ipc_contracts() {
let event = AppEvent::Core(CoreEvent::InstallProgress(Some(InstallProgressView {
stage: InstallStage::Downloading,
files_done: 3,
files_total: 9,
..InstallProgressView::new()
})));
let text = serde_json::to_string(&event).expect("序列化");
let back: AppEvent = serde_json::from_str(&text).expect("反序列化");
assert_eq!(back, event);
let account = AppEvent::Core(CoreEvent::AccountsChanged(vec![AccountView {
id: "account:1".to_string(),
name: "Steve".to_string(),
kind: AccountKind::Offline,
profile_id: "0f8f".to_string(),
login_name: String::new(),
created: "2026-10-31 20:12".to_string(),
active: true,
}]));
let text = serde_json::to_string(&account).expect("序列化");
let back: AppEvent = serde_json::from_str(&text).expect("反序列化");
assert_eq!(back, account);
// 凭据永不跨界:序列化文本里没有 token 类字段
assert!(!text.to_lowercase().contains("token"));
}
#[test]
fn commands_round_trip() {
let command = AppCommand::CreateInstance(CreateRequest {
version: "1.20.4".to_string(),
loader: crate::dto::LoaderKind::Fabric,
loader_version: "0.16.5".to_string(),
name: "生存".to_string(),
});
let text = serde_json::to_string(&command).expect("序列化");
let back: AppCommand = serde_json::from_str(&text).expect("反序列化");
assert_eq!(back, command);
let login = AppCommand::StartDeviceLogin;
assert_eq!(
serde_json::from_str::<AppCommand>(&serde_json::to_string(&login).expect("序列化"))
.expect("反序列化"),
login
);
assert!(DeviceLoginPhase::Waiting.busy());
}
}
+249
View File
@@ -0,0 +1,249 @@
//! 安装进度 DTO:下载页与新建向导共用的「一个安装任务」视图。
//!
//! 只放聚合量(文件计数 / 字节 / 速度 / 剩余时间)+ 一行状态文案:
//! 逐文件的明细留在 core 的 `InstallReport`,UI 不需要也不该持有。
//! 速度与 ETA 由 host 依据相邻两次字节数差分算(core 的事件只给绝对值)。
use serde::{Deserialize, Serialize};
/// 安装阶段。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum InstallStage {
/// 解析版本清单 / 规划文件(联网取 metadata)
Planning,
/// 下载并校验文件
Downloading,
/// 落版本 JSON / 跑安装器
Installing,
/// 登记实例
Registering,
/// 完成
Done,
/// 失败
Failed,
/// 用户取消
Cancelled,
}
impl InstallStage {
/// 阶段文案。
pub fn label(self) -> &'static str {
match self {
Self::Planning => "解析版本",
Self::Downloading => "下载文件",
Self::Installing => "生成版本",
Self::Registering => "登记实例",
Self::Done => "已完成",
Self::Failed => "失败",
Self::Cancelled => "已取消",
}
}
/// 是否已经收尾(Done/Failed/Cancelled)。
pub fn is_terminal(self) -> bool {
matches!(self, Self::Done | Self::Failed | Self::Cancelled)
}
}
/// 一次安装的进度快照。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InstallProgressView {
/// 目标版本 id
pub version: String,
/// 实例名(默认 = 版本 id)
pub instance: String,
/// 加载器徽标文字(`原版` / `Fabric 0.16.5`)
pub loader: String,
/// 阶段
pub stage: InstallStage,
/// 已完成文件数
pub files_done: u64,
/// 文件总数
pub files_total: u64,
/// 已下载字节
pub bytes_done: u64,
/// 总字节(0 = 未知)
pub bytes_total: u64,
/// 当前速度(字节/秒;0 = 未知)
pub speed_bps: u64,
/// 剩余秒数(0 = 未知)
pub eta_secs: u64,
/// 状态/错误文案
pub message: String,
}
impl Default for InstallProgressView {
fn default() -> Self {
Self::new()
}
}
impl InstallProgressView {
/// 规划中的空快照。
pub fn new() -> Self {
Self {
version: String::new(),
instance: String::new(),
loader: String::new(),
stage: InstallStage::Planning,
files_done: 0,
files_total: 0,
bytes_done: 0,
bytes_total: 0,
speed_bps: 0,
eta_secs: 0,
message: String::new(),
}
}
/// 起一个任务的快照。
pub fn start(version: &str, instance: &str, loader: &str) -> Self {
Self {
version: version.to_string(),
instance: instance.to_string(),
loader: loader.to_string(),
stage: InstallStage::Planning,
message: "正在解析版本清单…".to_string(),
..Self::new()
}
}
/// 整体进度 0.0~1.0(优先文件计数,其次字节;都没有时 0)。
pub fn ratio(&self) -> f32 {
if self.files_total > 0 {
(self.files_done as f32 / self.files_total as f32).clamp(0.0, 1.0)
} else if self.bytes_total > 0 {
(self.bytes_done as f32 / self.bytes_total as f32).clamp(0.0, 1.0)
} else {
0.0
}
}
/// 进度是否已知(未知时进度条转不确定态)。
pub fn is_known(&self) -> bool {
self.files_total > 0 || self.bytes_total > 0
}
/// 速度文案(`1.2 MB/s`;未知给空串)。
pub fn speed_label(&self) -> String {
if self.speed_bps == 0 {
return String::new();
}
format!("{}/s", human_bytes(self.speed_bps))
}
/// 剩余时间文案(`剩 12s`;未知给空串)。
pub fn eta_label(&self) -> String {
if self.eta_secs == 0 {
return String::new();
}
if self.eta_secs >= 60 {
format!("剩 {}m{:02}s", self.eta_secs / 60, self.eta_secs % 60)
} else {
format!("剩 {}s", self.eta_secs)
}
}
/// 文件计数文案(`128/512 文件`)。
pub fn files_label(&self) -> String {
if self.files_total == 0 {
return String::new();
}
format!("{}/{} 文件", self.files_done, self.files_total)
}
}
/// 字节数 → 人读(`1.2 MB`)。
pub fn human_bytes(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
let mut value = bytes as f64;
let mut unit = 0;
while value >= 1024.0 && unit + 1 < UNITS.len() {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{bytes} B")
} else {
format!("{value:.1} {}", UNITS[unit])
}
}
/// 用相邻两次采样算速度与 ETA(`None` = 本次无法计算,保持上次值)。
pub fn derive_speed(
previous: (u64, std::time::Instant),
current: (u64, std::time::Instant),
bytes_total: u64,
) -> Option<(u64, u64)> {
let elapsed = current.1.saturating_duration_since(previous.1);
if elapsed.as_millis() < 50 {
return None;
}
let delta = current.0.saturating_sub(previous.0);
if delta == 0 {
return None;
}
let elapsed_ms = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX);
let speed = delta.saturating_mul(1000) / elapsed_ms.max(1);
let eta = if bytes_total > current.0 && speed > 0 {
(bytes_total - current.0) / speed
} else {
0
};
Some((speed, eta))
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{Duration, Instant};
#[test]
fn ratio_prefers_file_counts() {
let mut view = InstallProgressView::start("1.20.4", "1.20.4", "原版");
assert_eq!(view.ratio(), 0.0);
assert!(!view.is_known());
view.files_total = 4;
view.files_done = 1;
view.bytes_total = 1000;
view.bytes_done = 900;
assert!((view.ratio() - 0.25).abs() < f32::EPSILON);
assert!(view.is_known());
assert_eq!(view.files_label(), "1/4 文件");
}
#[test]
fn speed_and_eta_labels_are_human_readable() {
let view = InstallProgressView {
speed_bps: 1_258_291,
eta_secs: 75,
..InstallProgressView::new()
};
assert_eq!(view.speed_label(), "1.2 MB/s");
assert_eq!(view.eta_label(), "剩 1m15s");
assert_eq!(InstallProgressView::new().speed_label(), "");
}
#[test]
fn derive_speed_needs_a_measurable_delta() {
let start = Instant::now();
let previous = (0, start);
// 间隔太短 → 不算
assert!(derive_speed(previous, (500, start + Duration::from_millis(10)), 1000).is_none());
// 正常差分:1s 下 500 字节 → 500 B/s,剩 500 字节 → 1s
let (speed, eta) =
derive_speed(previous, (500, start + Duration::from_secs(1)), 1000).expect("应能算");
assert_eq!(speed, 500);
assert_eq!(eta, 1);
}
#[test]
fn terminal_stages() {
assert!(InstallStage::Done.is_terminal());
assert!(InstallStage::Failed.is_terminal());
assert!(!InstallStage::Downloading.is_terminal());
assert_eq!(human_bytes(512), "512 B");
assert_eq!(human_bytes(2048), "2.0 KB");
}
}
+152 -7
View File
@@ -1,12 +1,14 @@
//! 实例相关 DTO:磁贴与列表视图的唯一数据源。 //! 实例相关 DTO:磁贴与列表视图的唯一数据源。
use serde::{Deserialize, Serialize};
use std::path::PathBuf; use std::path::PathBuf;
/// 实例标识:core::game 生成的稳定 id,app 视为不透明字符串。 /// 实例标识:core::game 生成的稳定 id,app 视为不透明字符串。
pub type InstanceId = String; pub type InstanceId = String;
/// 加载器种类(磁贴徽标 + 详情页版本链)。 /// 加载器种类(磁贴徽标 + 详情页版本链)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LoaderKind { pub enum LoaderKind {
/// 原版(无加载器) /// 原版(无加载器)
Vanilla, Vanilla,
@@ -37,10 +39,48 @@ impl LoaderKind {
Self::LiteLoader => "LiteLoader", Self::LiteLoader => "LiteLoader",
} }
} }
/// 稳定 id(`vanilla` / `fabric` / …;GUI 与 CLI 的 `--loader` 同词表)。
pub fn as_str(self) -> &'static str {
match self {
Self::Vanilla => "vanilla",
Self::Fabric => "fabric",
Self::Quilt => "quilt",
Self::Forge => "forge",
Self::NeoForge => "neoforge",
Self::OptiFine => "optifine",
Self::LiteLoader => "liteloader",
}
}
/// 从稳定 id 解析(未知给 `None`,由调用方决定报错文案)。
pub fn parse(raw: &str) -> Option<Self> {
Some(match raw.trim().to_ascii_lowercase().as_str() {
"vanilla" | "none" | "" => Self::Vanilla,
"fabric" => Self::Fabric,
"quilt" => Self::Quilt,
"forge" => Self::Forge,
"neoforge" => Self::NeoForge,
"optifine" => Self::OptiFine,
"liteloader" => Self::LiteLoader,
_ => return None,
})
}
/// 向导里的可选加载器(顺序即展示顺序)。
pub fn wizard_choices() -> [Self; 5] {
[
Self::Vanilla,
Self::Fabric,
Self::Quilt,
Self::Forge,
Self::NeoForge,
]
}
} }
/// 加载器 + 版本(例如 Fabric 0.16.5)。 /// 加载器 + 版本(例如 Fabric 0.16.5)。
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LoaderInfo { pub struct LoaderInfo {
/// 加载器种类 /// 加载器种类
pub kind: LoaderKind, pub kind: LoaderKind,
@@ -48,6 +88,15 @@ pub struct LoaderInfo {
pub version: String, pub version: String,
} }
impl Default for LoaderInfo {
fn default() -> Self {
Self {
kind: LoaderKind::Vanilla,
version: String::new(),
}
}
}
impl LoaderInfo { impl LoaderInfo {
/// 徽标文字:原版只写「原版」,装加载器时写「Fabric 0.16.5」。 /// 徽标文字:原版只写「原版」,装加载器时写「Fabric 0.16.5」。
pub fn badge(&self) -> String { pub fn badge(&self) -> String {
@@ -60,7 +109,7 @@ impl LoaderInfo {
} }
/// 字节进度;`total == 0` 表示总量未知(UI 转不确定态)。 /// 字节进度;`total == 0` 表示总量未知(UI 转不确定态)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Progress { pub struct Progress {
/// 已完成量 /// 已完成量
pub done: u64, pub done: u64,
@@ -85,7 +134,7 @@ impl Progress {
} }
/// 启动管线阶段(磁贴进度环的文案来源;D8 的 10 阶段在 P1 细化)。 /// 启动管线阶段(磁贴进度环的文案来源;D8 的 10 阶段在 P1 细化)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LaunchStage { pub enum LaunchStage {
/// 下载资源 /// 下载资源
Download, Download,
@@ -93,6 +142,8 @@ pub enum LaunchStage {
Verify, Verify,
/// 组装命令行并拉起进程 /// 组装命令行并拉起进程
Launch, Launch,
/// 进程已起、等待游戏窗口(M28 的窗口检测阶段)
Window,
} }
impl LaunchStage { impl LaunchStage {
@@ -101,13 +152,14 @@ impl LaunchStage {
match self { match self {
Self::Download => "下载资源", Self::Download => "下载资源",
Self::Verify => "校验文件", Self::Verify => "校验文件",
Self::Launch => "启动中", Self::Launch => "组装命令行",
Self::Window => "等待窗口",
} }
} }
} }
/// 实例运行时状态(磁贴的四种外观)。 /// 实例运行时状态(磁贴的四种外观)。
#[derive(Debug, Clone, PartialEq, Eq, Default)] #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum InstanceRuntime { pub enum InstanceRuntime {
/// 空闲 /// 空闲
#[default] #[default]
@@ -132,7 +184,7 @@ pub enum InstanceRuntime {
} }
/// 实例摘要:磁贴/列表视图唯一数据源。 /// 实例摘要:磁贴/列表视图唯一数据源。
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InstanceSummary { pub struct InstanceSummary {
/// 稳定 id /// 稳定 id
pub id: InstanceId, pub id: InstanceId,
@@ -148,4 +200,97 @@ pub struct InstanceSummary {
pub runtime: InstanceRuntime, pub runtime: InstanceRuntime,
/// 实例图标路径(P0 直接给路径;P1 换成 core 提供的图像句柄) /// 实例图标路径(P0 直接给路径;P1 换成 core 提供的图像句柄)
pub icon: Option<PathBuf>, pub icon: Option<PathBuf>,
/// 隔离档位(`isolated` / `shared` / `custom`;未知为空)
pub isolation: String,
/// 该实例绑定的版本 id(详情页/启动用)
pub version_id: String,
/// 版本本体是否在盘上(缺载荷时磁贴亮黄条)
pub version_present: bool,
}
/// 新建实例向导提交给 host 的请求(已通过向导校验)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreateRequest {
/// 目标原版版本 id(`1.20.4`)
pub version: String,
/// 加载器
pub loader: LoaderKind,
/// 加载器版本(空 = 让 core 取最新)
pub loader_version: String,
/// 实例名(空 = 用版本 id / 加载器版本 id)
pub name: String,
}
impl CreateRequest {
/// 实际使用的实例名。
pub fn instance_name(&self) -> String {
if self.name.trim().is_empty() {
self.version.clone()
} else {
self.name.trim().to_string()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn loader_badges_and_ids() {
let vanilla = LoaderInfo::default();
assert_eq!(vanilla.badge(), "原版");
let fabric = LoaderInfo {
kind: LoaderKind::Fabric,
version: "0.16.5".to_string(),
};
assert_eq!(fabric.badge(), "Fabric 0.16.5");
for kind in LoaderKind::wizard_choices() {
assert_eq!(LoaderKind::parse(kind.as_str()), Some(kind));
}
assert_eq!(LoaderKind::parse("fabric"), Some(LoaderKind::Fabric));
assert_eq!(LoaderKind::parse("unknown"), None);
}
#[test]
fn progress_ratio_handles_unknown_total() {
assert_eq!(Progress { done: 1, total: 0 }.ratio(), 0.0);
assert!((Progress { done: 1, total: 4 }.ratio() - 0.25).abs() < f32::EPSILON);
}
#[test]
fn create_request_defaults_name_to_version() {
let request = CreateRequest {
version: "1.20.4".to_string(),
loader: LoaderKind::Vanilla,
loader_version: String::new(),
name: " ".to_string(),
};
assert_eq!(request.instance_name(), "1.20.4");
}
#[test]
fn instance_summary_is_serde_round_trip_safe() {
let instance = InstanceSummary {
id: "inst-1".to_string(),
name: "生存".to_string(),
mc_version: "1.20.4".to_string(),
loader: LoaderInfo {
kind: LoaderKind::Fabric,
version: "0.16.5".to_string(),
},
last_played: Some("2026-10-31T12:00:00.000Z".to_string()),
runtime: InstanceRuntime::Busy {
stage: LaunchStage::Window,
progress: Progress { done: 1, total: 2 },
},
icon: None,
isolation: "isolated".to_string(),
version_id: "1.20.4".to_string(),
version_present: true,
};
let text = serde_json::to_string(&instance).expect("序列化");
let back: InstanceSummary = serde_json::from_str(&text).expect("反序列化");
assert_eq!(back, instance);
}
} }
+234
View File
@@ -0,0 +1,234 @@
//! Java 页 DTO:四家发行版、回退链可视化与本地运行时清单(D23/D13)。
//!
//! 回退链是「可观测性」的载体:安装失败时用户要能看见「Temurin 跳过 → Oracle 失败 →
//! Microsoft 命中」这条链,而不是只拿到一句「下载失败」。链的每一步由 host 从
//! `core::game::java::source::ChainLog` 翻译过来。
use serde::{Deserialize, Serialize};
/// 一家发行版的展示行。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JavaVendorView {
/// 稳定 id(`temurin` / `oracle` / `microsoft` / `zulu`)
pub id: String,
/// 展示名
pub display: String,
/// 优先级序号(1 起)
pub rank: u32,
/// 支持的 Java 大版本摘要(`21/17/11`)
pub majors: String,
/// 可用源(镜像 + 吞吐备注)
pub sources: Vec<String>,
/// 是否已装过这家(托管目录里有现成的)
pub installed: bool,
}
/// 回退链一步的结局。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum JavaChainOutcome {
/// 还没尝试(安装前的计划链)
Pending,
/// 跳过(不支持目标大版本)
Skip,
/// 尝试过但失败
Failed,
/// 命中(下载/安装成功)
Hit,
}
impl JavaChainOutcome {
/// 徽标文字。
pub fn label(self) -> &'static str {
match self {
Self::Pending => "待尝试",
Self::Skip => "跳过",
Self::Failed => "失败",
Self::Hit => "命中",
}
}
/// 从 core 的 `VendorAttempt` 结局字符串解析。
pub fn parse(raw: &str) -> Self {
match raw {
"hit" => Self::Hit,
"failed" => Self::Failed,
"skip" | "skipped" => Self::Skip,
_ => Self::Pending,
}
}
}
/// 回退链一步。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JavaChainStep {
/// 发行版 id
pub vendor: String,
/// 结局
pub outcome: JavaChainOutcome,
/// 细节(跳过原因 / 失败摘要 / 命中的下载源)
pub detail: String,
}
/// 本地已发现的 Java 运行时。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JavaRuntimeView {
/// 可执行文件路径
pub binary: String,
/// 版本串(`17.0.9`)
pub version: String,
/// 大版本
pub major: u32,
/// 发行方
pub vendor: String,
/// 来源分类(system / managed / instance)
pub origin: String,
}
/// Java 页快照(一次事件整体替换)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JavaPageView {
/// 地区判定(`domestic` / `global`)
pub region: String,
/// 优先级的来源(`region_default` / `java.vendorPriority`)
pub priority_source: String,
/// 生效顺序(`temurin → oracle → microsoft → zulu`)
pub order_text: String,
/// 四家展示行(按生效顺序)
pub vendors: Vec<JavaVendorView>,
/// 回退链(计划链或上一次安装的真实链)
pub chain: Vec<JavaChainStep>,
/// 本地运行时
pub runtimes: Vec<JavaRuntimeView>,
/// 托管目录
pub managed_roots: Vec<String>,
/// 是否有 Java 安装在跑
pub busy: bool,
/// 状态/错误文案
pub message: String,
}
impl Default for JavaPageView {
fn default() -> Self {
Self::new()
}
}
impl JavaPageView {
/// 空态(托管目录还没扫到东西)。
pub fn new() -> Self {
Self {
region: "global".to_string(),
priority_source: "region_default".to_string(),
order_text: String::new(),
vendors: Vec::new(),
chain: Vec::new(),
runtimes: Vec::new(),
managed_roots: Vec::new(),
busy: false,
message: String::new(),
}
}
/// 计划链:按生效顺序铺一排「待尝试」(安装前的可视化)。
pub fn planned_chain(vendors: &[JavaVendorView]) -> Vec<JavaChainStep> {
vendors
.iter()
.map(|vendor| JavaChainStep {
vendor: vendor.id.clone(),
outcome: JavaChainOutcome::Pending,
detail: String::new(),
})
.collect()
}
/// 命中的那一家的展示名(`None` = 链上还没命中)。
pub fn hit_vendor(&self) -> Option<&str> {
self.chain
.iter()
.find(|step| step.outcome == JavaChainOutcome::Hit)
.map(|step| step.vendor.as_str())
}
/// 已安装的大版本列表(去重升序)。
pub fn majors(&self) -> Vec<u32> {
let mut majors: Vec<u32> = self.runtimes.iter().map(|runtime| runtime.major).collect();
majors.sort_unstable();
majors.dedup();
majors
}
}
#[cfg(test)]
mod tests {
use super::*;
fn vendor(id: &str, rank: u32) -> JavaVendorView {
JavaVendorView {
id: id.to_string(),
display: id.to_uppercase(),
rank,
majors: "21/17".to_string(),
sources: Vec::new(),
installed: false,
}
}
#[test]
fn planned_chain_is_all_pending_in_rank_order() {
let vendors = vec![vendor("temurin", 1), vendor("oracle", 2)];
let chain = JavaPageView::planned_chain(&vendors);
assert_eq!(chain.len(), 2);
assert!(chain
.iter()
.all(|step| step.outcome == JavaChainOutcome::Pending));
assert_eq!(chain[0].vendor, "temurin");
}
#[test]
fn hit_vendor_finds_the_hit_step() {
let mut page = JavaPageView::new();
page.chain = vec![
JavaChainStep {
vendor: "temurin".to_string(),
outcome: JavaChainOutcome::Skip,
detail: "不支持 8".to_string(),
},
JavaChainStep {
vendor: "oracle".to_string(),
outcome: JavaChainOutcome::Hit,
detail: "BMCLAPI".to_string(),
},
];
assert_eq!(page.hit_vendor(), Some("oracle"));
}
#[test]
fn outcome_parses_core_words() {
assert_eq!(JavaChainOutcome::parse("hit"), JavaChainOutcome::Hit);
assert_eq!(JavaChainOutcome::parse("failed"), JavaChainOutcome::Failed);
assert_eq!(JavaChainOutcome::parse("skip"), JavaChainOutcome::Skip);
assert_eq!(JavaChainOutcome::parse("?"), JavaChainOutcome::Pending);
}
#[test]
fn majors_are_deduped_and_sorted() {
let mut page = JavaPageView::new();
page.runtimes = vec![
JavaRuntimeView {
binary: "a".to_string(),
version: "17".to_string(),
major: 17,
vendor: "Temurin".to_string(),
origin: "managed".to_string(),
},
JavaRuntimeView {
binary: "b".to_string(),
version: "17".to_string(),
major: 17,
vendor: "Oracle".to_string(),
origin: "system".to_string(),
},
];
assert_eq!(page.majors(), vec![17]);
}
}
+8 -6
View File
@@ -1,10 +1,12 @@
//! 日志页 DTO:会话列表、日志行与 D8 诊断结论。 //! 日志页 DTO:会话列表、日志行与 D8 诊断结论。
use serde::{Deserialize, Serialize};
/// 一次启动会话(日志页左栏单位)标识。 /// 一次启动会话(日志页左栏单位)标识。
pub type SessionId = String; pub type SessionId = String;
/// 一次运行的结局(G3 左栏图标:✓ 正常 / ✗ 崩溃 / ⚠ 静默失败)。 /// 一次运行的结局(G3 左栏图标:✓ 正常 / ✗ 崩溃 / ⚠ 静默失败)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LogOutcome { pub enum LogOutcome {
/// 正常退出 /// 正常退出
Ok, Ok,
@@ -15,7 +17,7 @@ pub enum LogOutcome {
} }
/// 会话摘要。 /// 会话摘要。
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LogSession { pub struct LogSession {
/// 会话 id /// 会话 id
pub id: SessionId, pub id: SessionId,
@@ -30,7 +32,7 @@ pub struct LogSession {
} }
/// 日志行等级(错误行高亮)。 /// 日志行等级(错误行高亮)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LogLevel { pub enum LogLevel {
/// debug /// debug
Debug, Debug,
@@ -43,7 +45,7 @@ pub enum LogLevel {
} }
/// 一行日志。 /// 一行日志。
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LogLine { pub struct LogLine {
/// 原始文本(含时间戳前缀) /// 原始文本(含时间戳前缀)
pub text: String, pub text: String,
@@ -52,7 +54,7 @@ pub struct LogLine {
} }
/// D8 规则引擎结论(日志页崩溃诊断卡片)。 /// D8 规则引擎结论(日志页崩溃诊断卡片)。
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Diagnosis { pub struct Diagnosis {
/// 命中规则 id(`gpu.driver_old`) /// 命中规则 id(`gpu.driver_old`)
pub rule: String, pub rule: String,
@@ -67,7 +69,7 @@ pub struct Diagnosis {
} }
/// 诊断结论 + 归属会话(UI 用它决定卡片显隐)。 /// 诊断结论 + 归属会话(UI 用它决定卡片显隐)。
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiagnosisReport { pub struct DiagnosisReport {
/// 归属会话 /// 归属会话
pub session: SessionId, pub session: SessionId,
+21 -5
View File
@@ -2,22 +2,38 @@
//! //!
//! 这里只允许出现「扁平 DTO / 事件 / 指令」:`crates/app` 任何模块都不得 `use unlucky_core::*` //! 这里只允许出现「扁平 DTO / 事件 / 指令」:`crates/app` 任何模块都不得 `use unlucky_core::*`
//! 或 `use unlucky_agent::*` 的内部类型。core/agent 侧的 host 层负责把自身类型翻译成本模块的类型, //! 或 `use unlucky_agent::*` 的内部类型。core/agent 侧的 host 层负责把自身类型翻译成本模块的类型,
//! 反向的 [`AppCommand`] 也一样。P0 阶段由 [`crate::mock`] 的假后端实现 [`Backend`] 驱动骨架。 //! 反向的 [`AppCommand`] 也一样。P0 阶段由 [`crate::mock`] 的假后端实现 [`Backend`] 驱动骨架,
//! P3 起由 [`crate::host`] 的真实后端驱动全部页面。
//! //!
//! 结构:**按领域拆子模块**(instances / versions / logs / agent / events), //! 结构:**按领域拆子模块**(instances / versions / logs / agent / accounts / java /
//! 全量 `pub use` 出去,调用方依旧写 `crate::dto::InstanceSummary` 这类路径。 //! settings / install / notices / events),全量 `pub use` 出去,调用方依旧写
//! `crate::dto::InstanceSummary` 这类路径。
//! 契约说明(字段口径、事件顺序、错误语义)见 `DevDocs/SubAgentsTalk/app-DTO通道约定.md`。 //! 契约说明(字段口径、事件顺序、错误语义)见 `DevDocs/SubAgentsTalk/app-DTO通道约定.md`。
//!
//! 全部跨界类型 `Serialize + Deserialize`(AGENTS §6):事件与快照是 IPC 契约,
//! 序列化能力由 derive 钉住,`dto` 的单测做 round-trip 断言。
mod accounts;
mod agent; mod agent;
mod events; mod events;
mod install;
mod instances; mod instances;
mod java;
mod logs; mod logs;
mod notices;
mod settings;
mod versions; mod versions;
pub use accounts::{AccountKind, AccountView, DeviceLoginPhase, DeviceLoginView};
pub use agent::{AgentEvent, ChatMessage, ChatRole, ToolCallView}; pub use agent::{AgentEvent, ChatMessage, ChatRole, ToolCallView};
pub use events::{AppCommand, AppEvent, Backend, CoreEvent}; pub use events::{AppCommand, AppEvent, Backend, CoreEvent, LaunchFinished, LiveLogBatch};
pub use install::{derive_speed, human_bytes, InstallProgressView, InstallStage};
pub use instances::{ pub use instances::{
InstanceId, InstanceRuntime, InstanceSummary, LaunchStage, LoaderInfo, LoaderKind, Progress, CreateRequest, InstanceId, InstanceRuntime, InstanceSummary, LaunchStage, LoaderInfo,
LoaderKind, Progress,
}; };
pub use java::{JavaChainOutcome, JavaChainStep, JavaPageView, JavaRuntimeView, JavaVendorView};
pub use logs::{Diagnosis, DiagnosisReport, LogLevel, LogLine, LogOutcome, LogSession, SessionId}; pub use logs::{Diagnosis, DiagnosisReport, LogLevel, LogLine, LogOutcome, LogSession, SessionId};
pub use notices::{NoticeLevel, NoticeView};
pub use settings::{SettingControl, SettingGroupView, SettingRowView};
pub use versions::{AncientKind, VersionCategory, VersionKind, VersionSummary}; pub use versions::{AncientKind, VersionCategory, VersionKind, VersionSummary};
+73
View File
@@ -0,0 +1,73 @@
//! 横幅通知 DTO:实例页/下载页顶部的「需要注意」提示条。
//!
//! 与 Toast 的分工:Toast 是「刚才发生了什么」(3 秒消失),通知条是
//! 「现在需要你处理」(用户点了才收)。隔离模式切换提示(M30 `ModeChangeWarning`)
//! 与启动失败都用它。
use serde::{Deserialize, Serialize};
/// 通知级别。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum NoticeLevel {
/// 一般信息
Info,
/// 需要留意(黄条)
Warn,
/// 失败(红条)
Error,
}
impl NoticeLevel {
/// 级别文案(无障碍/测试用)。
pub fn label(self) -> &'static str {
match self {
Self::Info => "信息",
Self::Warn => "注意",
Self::Error => "错误",
}
}
}
/// 一条通知。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NoticeView {
/// 稳定 id(关闭时按它移除)
pub id: String,
/// 级别
pub level: NoticeLevel,
/// 标题
pub title: String,
/// 正文
pub body: String,
}
impl NoticeView {
/// 建一条。
pub fn new(id: &str, level: NoticeLevel, title: &str, body: &str) -> Self {
Self {
id: id.to_string(),
level,
title: title.to_string(),
body: body.to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn notices_are_serde_round_trip_safe() {
let notice = NoticeView::new(
"isolation:demo",
NoticeLevel::Warn,
"隔离模式已切换",
"旧目录里还有 mods/saves",
);
let text = serde_json::to_string(&notice).expect("序列化");
let back: NoticeView = serde_json::from_str(&text).expect("反序列化");
assert_eq!(back, notice);
assert_eq!(back.level.label(), "注意");
}
}
+220
View File
@@ -0,0 +1,220 @@
//! 设置页 DTO:设置分组的通用行模型。
//!
//! 「行」是 UI 与 `core::setting` schema 之间的唯一形状:一行 = 一个键 + 一种控件 +
//! 当前值。写回走 [`crate::dto::AppCommand::SetSetting`],由 host 用 `SettingStore::set`
//! 落盘(不自己拼 JSON、不另立配置文件,`AGENTS §6`)。
use serde::{Deserialize, Serialize};
/// 控件类型(决定设置页画什么)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SettingControl {
/// 开关
Toggle,
/// 分段单选(`options` + `selected`)
Choice,
/// 单行文本(`value_text`)
Text,
/// 只读展示(当前路径/当前版本这类)
ReadOnly,
/// 动作按钮(`action` 给回调 id)
Action,
}
/// 一行设置。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SettingRowView {
/// schema 键(`download.concurrency`)
pub key: String,
/// 行标题
pub label: String,
/// 说明/当前值附注
pub description: String,
/// 控件
pub control: SettingControl,
/// 开关值
pub flag: bool,
/// 单选序号
pub selected: i32,
/// 单选项文案
pub options: Vec<String>,
/// 文本值
pub value_text: String,
/// 动作 id(`detect_region` 这类)
pub action: String,
}
impl SettingRowView {
/// 开关行。
pub fn toggle(key: &str, label: &str, description: &str, value: bool) -> Self {
Self {
key: key.to_string(),
label: label.to_string(),
description: description.to_string(),
control: SettingControl::Toggle,
flag: value,
selected: 0,
options: Vec::new(),
value_text: String::new(),
action: String::new(),
}
}
/// 单选行。
pub fn choice(
key: &str,
label: &str,
description: &str,
options: &[&str],
selected: i32,
) -> Self {
Self {
key: key.to_string(),
label: label.to_string(),
description: description.to_string(),
control: SettingControl::Choice,
flag: false,
selected,
options: options.iter().map(|item| (*item).to_string()).collect(),
value_text: String::new(),
action: String::new(),
}
}
/// 文本行。
pub fn text(key: &str, label: &str, description: &str, value: &str) -> Self {
Self {
key: key.to_string(),
label: label.to_string(),
description: description.to_string(),
control: SettingControl::Text,
flag: false,
selected: 0,
options: Vec::new(),
value_text: value.to_string(),
action: String::new(),
}
}
/// 只读行。
pub fn read_only(label: &str, value: &str) -> Self {
Self {
key: String::new(),
label: label.to_string(),
description: value.to_string(),
control: SettingControl::ReadOnly,
flag: false,
selected: 0,
options: Vec::new(),
value_text: value.to_string(),
action: String::new(),
}
}
/// 动作行。
pub fn action(key: &str, label: &str, description: &str, action: &str) -> Self {
Self {
key: key.to_string(),
label: label.to_string(),
description: description.to_string(),
control: SettingControl::Action,
flag: false,
selected: 0,
options: Vec::new(),
value_text: String::new(),
action: action.to_string(),
}
}
}
/// 一个设置分组(设置页右栏的一段)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SettingGroupView {
/// 分组 id(与设置页子导航的 `SettingSection` 同名:download / java / data / ai / mcp)
pub id: String,
/// 分组标题
pub title: String,
/// 行
pub rows: Vec<SettingRowView>,
}
impl SettingGroupView {
/// 建组。
pub fn new(id: &str, title: &str) -> Self {
Self {
id: id.to_string(),
title: title.to_string(),
rows: Vec::new(),
}
}
/// 追加一行(链式)。
pub fn with(mut self, row: SettingRowView) -> Self {
self.rows.push(row);
self
}
}
/// 在分组快照里找一个键的当前值(设置页回填用)。
pub fn find_row<'a>(groups: &'a [SettingGroupView], key: &str) -> Option<&'a SettingRowView> {
groups
.iter()
.flat_map(|group| group.rows.iter())
.find(|row| row.key == key)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rows_carry_exactly_one_control_kind() {
let toggle = SettingRowView::toggle("agent.mcp.enabled", "MCP", "", true);
assert_eq!(toggle.control, SettingControl::Toggle);
assert!(toggle.flag);
let choice = SettingRowView::choice("download.region", "地区", "", &["auto", "cn"], 1);
assert_eq!(choice.options, vec!["auto", "cn"]);
assert_eq!(choice.selected, 1);
let action = SettingRowView::action("java.action", "检测", "", "detect_region");
assert_eq!(action.action, "detect_region");
}
#[test]
fn find_row_scans_all_groups() {
let groups = vec![
SettingGroupView::new("download", "下载").with(SettingRowView::text(
"download.proxy_url",
"代理",
"",
"",
)),
SettingGroupView::new("mcp", "MCP").with(SettingRowView::toggle(
"agent.mcp.enabled",
"开关",
"",
false,
)),
];
assert_eq!(
find_row(&groups, "agent.mcp.enabled").map(|row| row.label.as_str()),
Some("开关")
);
assert!(find_row(&groups, "nope").is_none());
}
#[test]
fn rows_are_serde_round_trip_safe() {
let group = SettingGroupView::new("download", "下载").with(SettingRowView::choice(
"download.concurrency",
"并发",
"",
&["4", "8", "16"],
1,
));
let text = serde_json::to_string(&group).expect("序列化");
let back: SettingGroupView = serde_json::from_str(&text).expect("反序列化");
assert_eq!(back, group);
}
}
+6 -4
View File
@@ -1,7 +1,9 @@
//! 下载页 DTO:版本列表与分类(含远古版子类,D16/Q24-4)。 //! 下载页 DTO:版本列表与分类(含远古版子类,D16/Q24-4)。
use serde::{Deserialize, Serialize};
/// 版本类型(远古版按 unlisted-versions 的分类拆开)。 /// 版本类型(远古版按 unlisted-versions 的分类拆开)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum VersionKind { pub enum VersionKind {
/// 正式版 /// 正式版
Release, Release,
@@ -12,7 +14,7 @@ pub enum VersionKind {
} }
/// 远古版子类。 /// 远古版子类。
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AncientKind { pub enum AncientKind {
/// classic(rd-*) /// classic(rd-*)
Classic, Classic,
@@ -32,7 +34,7 @@ impl VersionKind {
} }
/// 下载页版本行。 /// 下载页版本行。
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VersionSummary { pub struct VersionSummary {
/// 版本 id(如 `1.20.4` / `rd-132211`) /// 版本 id(如 `1.20.4` / `rd-132211`)
pub id: String, pub id: String,
@@ -45,7 +47,7 @@ pub struct VersionSummary {
} }
/// 下载页左侧分类(页内常驻侧栏的选中项)。 /// 下载页左侧分类(页内常驻侧栏的选中项)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum VersionCategory { pub enum VersionCategory {
/// 正式版 /// 正式版
Release, Release,
+274
View File
@@ -0,0 +1,274 @@
//! 账户:三轨统一列表(只读公开字段)+ 离线新增 + 删除/切换 + 微软登录(两条流)。
//!
//! 凭据红线(D9):`Credentials` / `MicrosoftSession` 只在本模块与 `launch.rs` 里出现,
//! 出口一律是 [`AccountView`] / [`DeviceLoginView`]。
//!
//! 两条登录流:授权码([`start_browser_login`],GUI 默认:拉起系统浏览器 + 本地回调,
//! `research/08` §3 方案 A)与设备码([`start_login`],无浏览器/CLI 场景)。除了「怎么拿到
//! MS access_token」之外完全共用:同一个取消槽位、同一段账户入库、同一个刷新链。
//!
//! 取消语义:轮询/等回调都跑在 `core` 里、没有取消钩子,所以取消只把面板收回;线程收尾时
//! 若发现「已取消」就不入库(避免用户点了取消却凭空多出一个账户)。
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use unlucky_core::auth::account::Credentials;
use unlucky_core::auth::microsoft::{now_ms, MicrosoftAuth, MicrosoftSession};
use unlucky_core::auth::offline::offline_meta;
use unlucky_core::auth::{AccountStore, AuthResult};
use unlucky_core::download::blocking::block_on;
use crate::dto::{AccountKind, AccountView, DeviceLoginView};
use super::env::HostEnv;
use super::job::{spawn, HostTask, JobSender};
use super::opener;
/// 账户列表(公开字段)。
pub fn accounts(env: &HostEnv, active: Option<&str>) -> Vec<AccountView> {
let store = AccountStore::open(env.layout.auth_data());
store
.accounts()
.iter()
.map(|meta| AccountView {
id: meta.id.as_str().to_string(),
name: meta.display_name().to_string(),
kind: AccountKind::parse(meta.kind.as_str()),
profile_id: meta.profile_id.clone(),
login_name: meta.login_name.clone().unwrap_or_default(),
created: display_ms(meta.created_at_ms),
active: active.is_some_and(|id| id == meta.id.as_str()),
})
.collect()
}
/// 毫秒时间戳 → `2026-10-31 20:12`(UTC;账户列表只用来排序/辨认)。
pub fn display_ms(ms: u64) -> String {
if ms == 0 {
return String::new();
}
let total_secs = ms / 1000;
let days = total_secs / 86_400;
let rem = total_secs % 86_400;
let (year, month, day) = civil_from_days(i64::try_from(days).unwrap_or(0));
format!(
"{year:04}-{month:02}-{day:02} {:02}:{:02}",
rem / 3_600,
(rem % 3_600) / 60
)
}
/// Howard Hinnant 的 `civil_from_days`(1970-01-01 为 0)。
fn civil_from_days(days: i64) -> (i64, u32, u32) {
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
(if m <= 2 { y + 1 } else { y }, m as u32, d as u32)
}
/// 加一个离线账户。
///
/// # Errors
///
/// 名字不合法、同名冲突、落盘失败时返回用户可读文案。
pub fn add_offline(env: &HostEnv, name: &str) -> Result<String, String> {
let name = name.trim();
let meta = offline_meta(name, now_ms()).map_err(|error| error.to_string())?;
let display = meta.display_name().to_string();
let mut store = AccountStore::open(env.layout.auth_data());
store
.insert(meta, Credentials::Offline)
.map_err(|error| format!("添加账户失败:{error}"))?;
store
.save()
.map_err(|error| format!("保存账户失败:{error}"))?;
Ok(display)
}
/// 删除账户(按 id 或名字)。
///
/// # Errors
///
/// 找不到、落盘失败时返回用户可读文案。
pub fn remove(env: &HostEnv, key: &str) -> Result<String, String> {
let mut store = AccountStore::open(env.layout.auth_data());
let meta = store
.remove(key)
.map_err(|error| format!("删除账户失败:{error}"))?;
store
.save()
.map_err(|error| format!("保存账户失败:{error}"))?;
Ok(meta.display_name().to_string())
}
/// 微软登录任务句柄(取消标志 + 账户名回执;两条流共用)。
#[derive(Debug, Clone, Default)]
pub struct LoginJob {
/// 用户点了取消(线程收尾时据此丢弃结果)
pub cancelled: Arc<AtomicBool>,
}
impl LoginJob {
/// 新建一个未取消的句柄。
pub fn new() -> Self {
Self {
cancelled: Arc::new(AtomicBool::new(false)),
}
}
/// 取消。
pub fn cancel(&self) {
self.cancelled.store(true, Ordering::SeqCst);
}
/// 是否已取消。
pub fn is_cancelled(&self) -> bool {
self.cancelled.load(Ordering::SeqCst)
}
}
/// 起一次设备码登录(后台线程;`present` 时刻发 Waiting 事件)。
pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob) {
spawn("msa-login", move || {
let auth = match microsoft_auth(&env) {
Ok(auth) => auth,
Err(message) => return fail(&tx, message),
};
let tx_prompt = tx.clone();
let result = block_on(auth.device_code_login(|prompt| {
let _ = tx_prompt.send(HostTask::Login(Box::new(DeviceLoginView::waiting(
&prompt.user_code,
&prompt.verification_uri,
prompt.expires_in.as_secs(),
&format!("{}#{}", prompt.verification_uri, prompt.user_code),
))));
}));
finish(&env, &tx, &job, result);
});
}
/// 起一次授权码登录(后台线程:绑本地端口 → 拉起系统默认浏览器 → 等回调 → 换链)。
///
/// 与设备码流共用同一个 [`LoginJob`] 取消槽位、同一段账户入库逻辑,所以两条流的
/// 凭据/刷新语义完全一致(`core` 侧也共用 [`unlucky_core::auth::MicrosoftSession`])。
pub fn start_browser_login(env: HostEnv, tx: JobSender, job: LoginJob) {
spawn("msa-authcode", move || {
let auth = match microsoft_auth(&env) {
Ok(auth) => auth,
Err(message) => return fail(&tx, message),
};
let tx_prompt = tx.clone();
let result = block_on(auth.authcode_login(|prompt| {
// 浏览器拉不起来不是致命错误:面板里还有可复制的授权链接。
let opened = opener::open_url(&prompt.authorization_url);
let _ = tx_prompt.send(HostTask::Login(Box::new(DeviceLoginView::browser_waiting(
&prompt.authorization_url,
prompt.timeout.as_secs(),
opened.err().as_deref(),
))));
}));
finish(&env, &tx, &job, result);
});
}
/// 生产入口的 `MicrosoftAuth`:client id 取环境/构建期,失败日志落 `<数据根>/logs/`。
fn microsoft_auth(env: &HostEnv) -> Result<MicrosoftAuth, String> {
MicrosoftAuth::from_env()
.map(|auth| {
auth.with_journal(Some(unlucky_core::auth::AuthJournal::in_logs_dir(
env.layout.logs(),
)))
})
.map_err(|error| error.to_string())
}
fn fail(tx: &JobSender, message: String) {
let _ = tx.send(HostTask::Login(Box::new(DeviceLoginView::failed(message))));
}
/// 收尾:取消就吞掉结果;成功入库;失败带 core 的诊断文案(M38 的 403 也走这条)。
fn finish(env: &HostEnv, tx: &JobSender, job: &LoginJob, result: AuthResult<MicrosoftSession>) {
if job.is_cancelled() {
let _ = tx.send(HostTask::Login(Box::default()));
return;
}
match result {
Ok(session) => match store_session(env, session) {
Ok(name) => {
let _ = tx.send(HostTask::Login(Box::new(DeviceLoginView::succeeded(&name))));
let _ = tx.send(HostTask::Accounts(accounts(env, None)));
}
Err(message) => fail(tx, message),
},
Err(error) => fail(tx, error.to_string()),
}
}
/// 微软会话入库(元数据 + 凭据分文件,见 `core::auth::AccountStore`)。
fn store_session(env: &HostEnv, session: MicrosoftSession) -> Result<String, String> {
let meta = unlucky_core::auth::account::AccountMeta::new(
unlucky_core::auth::account::AccountKind::Microsoft,
session.profile_id.clone(),
session.profile_name.clone(),
now_ms(),
);
let name = meta.display_name().to_string();
let mut store = AccountStore::open(env.layout.auth_data());
store
.insert(meta, Credentials::Microsoft(session))
.and_then(|()| store.save())
.map_err(|error| format!("账户入库失败:{error}"))?;
Ok(name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn time_display_matches_known_timestamps() {
assert_eq!(display_ms(0), "");
// 1_769_000_000_000 ms = 2026-01-21T12:53:20Z(取整到分钟)
assert_eq!(display_ms(1_769_000_000_000), "2026-01-21 12:53");
}
#[test]
fn offline_add_list_remove_round_trips() {
let dir = std::env::temp_dir().join(format!("ull-host-accounts-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
std::fs::create_dir_all(env.layout.auth_data()).expect("auth 目录");
let name = add_offline(&env, "Steve").expect("加账户");
assert_eq!(name, "Steve");
let list = accounts(&env, None);
assert_eq!(list.len(), 1);
assert_eq!(list[0].kind, AccountKind::Offline);
assert!(!list[0].active);
let active = accounts(&env, Some(list[0].id.as_str()));
assert!(active[0].active);
let removed = remove(&env, "Steve").expect("删账户");
assert_eq!(removed, "Steve");
assert!(accounts(&env, None).is_empty());
assert!(add_offline(&env, " ").is_err());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn login_job_cancel_flag_is_shared() {
let job = LoginJob::new();
let clone = job.clone();
assert!(!clone.is_cancelled());
job.cancel();
assert!(clone.is_cancelled());
}
}
+162
View File
@@ -0,0 +1,162 @@
//! `CoreHost::dispatch` 的指令实现(从 `host/mod.rs` 拆出,守 400 行上限)。
//!
//! 父模块的私有字段对子模块可见,所以这里直接读写 `self.env` / `self.tx` 等。
use crate::dto::{AppCommand, Backend, CoreEvent, CreateRequest, LoaderKind};
use crate::toast::ToastKind;
use super::accounts::{self, LoginJob};
use super::{game, install, java, launch, logs, settings, versions, CoreHost};
impl CoreHost {
/// 下发一条用户意图(`Backend::dispatch` 的实现体)。
pub(super) fn dispatch_command(&mut self, command: AppCommand) {
self.dispatch_inner(command);
}
}
impl CoreHost {
/// 指令分发(见 `dispatch_command`)。
fn dispatch_inner(&mut self, command: AppCommand) {
match command {
AppCommand::RefreshAll => self.refresh_all(),
AppCommand::LaunchInstance(id) => self.start_launch(&id),
AppCommand::StopInstance(id) => match self.running_pid(&id) {
Some(pid) => match launch::stop(pid) {
Ok(()) => self.toast(ToastKind::Info, "已请求停止游戏进程"),
Err(reason) => self.toast(ToastKind::Error, reason),
},
None => self.toast(ToastKind::Warn, "这个实例当前没有在跑"),
},
AppCommand::ListVersions(_) => versions::spawn_list(self.env.clone(), self.tx.clone()),
AppCommand::InstallVersion(version) => {
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
self.install_cancel = Some(std::sync::Arc::clone(&cancel));
install::spawn_install(
self.env.clone(),
self.tx.clone(),
CreateRequest {
name: version.clone(),
version,
loader: LoaderKind::Vanilla,
loader_version: String::new(),
},
cancel,
);
}
AppCommand::CreateInstance(request) => {
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
self.install_cancel = Some(std::sync::Arc::clone(&cancel));
install::spawn_install(self.env.clone(), self.tx.clone(), request, cancel);
}
AppCommand::CancelInstall => {
// 取消只在阶段边界生效(core 的下载循环没有取消钩子):这里先把面板收回,
// 后台线程会在下一阶段开始前看到标记并收尾。
if let Some(cancel) = &self.install_cancel {
cancel.store(true, std::sync::atomic::Ordering::SeqCst);
}
self.install = None;
self.send(CoreEvent::InstallProgress(None));
}
AppCommand::SetIsolation { id, mode } => {
match game::set_isolation(&self.env, &id, &mode) {
Ok(()) => {
self.toast(ToastKind::Success, "隔离档位已更新");
self.refresh_instances();
}
Err(reason) => self.toast(ToastKind::Error, reason),
}
}
AppCommand::RefreshLogSessions => {
self.send(CoreEvent::LogSessionsChanged(logs::sessions(&self.env)));
}
AppCommand::OpenLogSession(id) => self.open_session(&id),
AppCommand::ImportCrashLog(path) => match logs::analyze_crash_file(&path) {
Ok((label, report)) => {
self.send(CoreEvent::DiagnosisReady(report));
self.toast(ToastKind::Info, label);
}
Err(reason) => self.toast(ToastKind::Warn, reason),
},
AppCommand::AddOfflineAccount(name) => match accounts::add_offline(&self.env, &name) {
Ok(display) => {
self.toast(ToastKind::Success, format!("已添加离线账户 {display}"));
self.refresh_accounts();
}
Err(reason) => self.toast(ToastKind::Error, reason),
},
AppCommand::RemoveAccount(key) => match accounts::remove(&self.env, &key) {
Ok(display) => {
if self.active_account.as_deref() == Some(key.as_str()) {
self.active_account = None;
}
self.toast(ToastKind::Info, format!("已删除账户 {display}"));
self.refresh_accounts();
}
Err(reason) => self.toast(ToastKind::Error, reason),
},
AppCommand::SwitchAccount(id) => {
self.active_account = Some(id);
self.refresh_accounts();
self.toast(ToastKind::Info, "已切换启动账户");
}
AppCommand::StartDeviceLogin => {
self.login = Some(LoginJob::new());
let job = self.login.clone().unwrap_or_default();
self.send(CoreEvent::DeviceLoginChanged(crate::dto::DeviceLoginView {
phase: crate::dto::DeviceLoginPhase::Requesting,
message: "正在向微软申请设备码…".to_string(),
..crate::dto::DeviceLoginView::new()
}));
accounts::start_login(self.env.clone(), self.tx.clone(), job);
}
AppCommand::StartBrowserLogin => {
self.login = Some(LoginJob::new());
let job = self.login.clone().unwrap_or_default();
self.send(CoreEvent::DeviceLoginChanged(crate::dto::DeviceLoginView {
phase: crate::dto::DeviceLoginPhase::Requesting,
message: "正在准备浏览器登录(本地回调端口)…".to_string(),
..crate::dto::DeviceLoginView::new()
}));
accounts::start_browser_login(self.env.clone(), self.tx.clone(), job);
}
AppCommand::CancelDeviceLogin => {
if let Some(job) = &self.login {
job.cancel();
}
self.login = None;
self.send(CoreEvent::DeviceLoginChanged(
crate::dto::DeviceLoginView::new(),
));
}
AppCommand::InstallJava(vendor) => {
self.java_busy = true;
self.java_message = format!("正在安装 Java({vendor})…");
self.refresh_java();
java::spawn_install(
self.env.clone(),
self.tx.clone(),
unlucky_core::game::java::JdkVendor::parse(&vendor),
);
}
AppCommand::SetSetting { key, value } => match settings::set(&self.env, &key, &value) {
Ok(()) => {
self.send(CoreEvent::SettingsChanged(settings::groups(&self.env)));
self.java_chain.clear();
self.refresh_java();
}
Err(reason) => self.toast(ToastKind::Error, reason),
},
AppCommand::DismissNotice(id) => {
self.notices.retain(|notice| notice.id != id);
self.send(CoreEvent::NoticesChanged(self.notices.clone()));
}
// AI 对话:agent 的 GUI host 未落地前用脚本回复(不接真实模型)
other @ (AppCommand::AskAgent(_) | AppCommand::StopAgent | AppCommand::SetYolo(_)) => {
self.chat.dispatch(other);
for event in self.chat.drain_events() {
self.events.push_back(event);
}
}
}
}
}
+126
View File
@@ -0,0 +1,126 @@
//! 下载引擎与 Java 落点的装配(versions / install / java 三个任务共用)。
//!
//! 与 CLI `install::build_engine` 同源:内容寻址缓存落 `.ULL/cache/`,地区判定走
//! 24h 缓存 + 环境信号(**不做网络探测**,与 D7 的「不探测时」约定一致)。
use std::path::PathBuf;
use std::sync::Arc;
use unlucky_core::download::cache::now_ms;
use unlucky_core::download::region::{decide_from, load_cached, store_cached};
use unlucky_core::download::region_system::detect_signals;
use unlucky_core::download::{
ContentCache, DownloadEngine, EngineConfig, HealthRegistry, MirrorTable,
};
use unlucky_core::game::java::vendor::JdkVendor;
use unlucky_core::game::java::{self, JavaInstallRoot};
use unlucky_core::platform::paths::ProcessEnv;
use super::env::HostEnv;
/// 建下载引擎(缓存/健康度/地区判定都在数据根里)。
pub fn engine(env: &HostEnv) -> DownloadEngine {
let cache_root = env.layout.cache();
let now = now_ms();
let region_path = cache_root.join("region.json");
let region = load_cached(&region_path, now).unwrap_or_else(|| {
let profile = decide_from(&detect_signals(), now);
let _ = store_cached(&region_path, &profile);
profile
});
DownloadEngine::new(
EngineConfig::default(),
Arc::new(MirrorTable::builtin()),
Arc::new(region),
Arc::new(HealthRegistry::open_in(&cache_root, now)),
Arc::new(ContentCache::open(&cache_root)),
)
}
/// 是否国内网络(决定默认发行版顺序与镜像优先级)。
pub fn domestic(env: &HostEnv) -> bool {
engine(env).region().is_domestic()
}
/// 设置里的 `java.vendorPriority`(空 = 按地区默认序)。
pub fn java_priority(env: &HostEnv) -> Vec<JdkVendor> {
env.store()
.effective("java.vendorPriority")
.and_then(serde_json::Value::as_array)
.map(|items| {
let texts: Vec<String> = items
.iter()
.filter_map(serde_json::Value::as_str)
.map(str::to_string)
.collect();
unlucky_core::game::java::vendor::parse_priority(&texts)
})
.unwrap_or_default()
}
/// 设置里的 `java.installRoot`(空 = 按数据目录模式派生)。
pub fn java_install_override(env: &HostEnv) -> Option<PathBuf> {
env.store()
.doc()
.effective_str("java.installRoot")
.map(str::trim)
.filter(|text| !text.is_empty())
.map(PathBuf::from)
}
/// 托管 JDK 落点(解析失败 → 用户可读文案)。
pub fn java_install_root(env: &HostEnv) -> Result<JavaInstallRoot, String> {
let override_root = java_install_override(env);
JavaInstallRoot::resolve(
env.mode(),
&env.layout,
override_root.as_deref(),
env.platform,
&ProcessEnv,
)
.map_err(|error| error.to_string())
}
/// 托管 JDK 可能存在的全部安装根(扫描运行时用)。
pub fn java_scan_roots(env: &HostEnv) -> Vec<PathBuf> {
java::location::scan_roots(
env.mode(),
&env.layout,
java_install_override(env).as_deref(),
env.platform,
&ProcessEnv,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn engine_uses_the_data_root_cache() {
let dir = std::env::temp_dir().join(format!("ull-host-engine-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
std::fs::create_dir_all(env.layout.cache()).expect("cache");
let engine = engine(&env);
assert_eq!(engine.config().max_concurrency, 64);
// 地区判定不联网:只读缓存/信号
let _ = domestic(&env);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn java_merge_priority_reads_settings() {
let dir = std::env::temp_dir().join(format!("ull-host-engine-p-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
std::fs::create_dir_all(env.layout.config()).expect("config");
std::fs::write(
env.launcher_config(),
r#"{"java":{"vendorPriority":["zulu"]}}"#,
)
.expect("写配置");
assert_eq!(java_priority(&env), vec![JdkVendor::Zulu]);
std::fs::remove_dir_all(&dir).ok();
}
}
+170
View File
@@ -0,0 +1,170 @@
//! host 的环境快照:一份 `ResolvedData` + 由其派生的布局视图。
//!
//! app 自己不解析三模式数据根(那是 `core::platform::data_dir` 的活);这里只做两件事:
//! ①按「exe 同级便携 `launcher.json` 的 `core.*` 四键」拼请求;②把解析结果与
//! `Platform` / `DataMode` 打成一个可克隆的结构,交给后台线程。
//!
//! 截图/演示通道用 [`HostEnv::at_root`] 直接钉一个根(`<root>/.ULL` + `<root>/.minecraft`)。
use std::path::{Path, PathBuf};
use unlucky_core::game::GameLayout;
use unlucky_core::platform::data_dir::{DataDirRequest, DataMode, ResolvedData, Resolver};
use unlucky_core::platform::layout::LauncherLayout;
use unlucky_core::platform::paths::Platform;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
/// 数据根决定键(与 CLI 的 `DATA_ROOT_KEYS` 同源同义)。
pub(crate) const DATA_ROOT_KEYS: [&str; 4] = [
"core.data_mode",
"core.data_custom_root",
"core.launcher_home",
"core.game_home",
];
/// host 运行期需要的一切路径/平台信息(可克隆进线程)。
#[derive(Debug, Clone)]
pub struct HostEnv {
/// 数据根解析结果
pub data: ResolvedData,
/// `.ULL/` 布局
pub layout: LauncherLayout,
/// `.minecraft/` 布局
pub game: GameLayout,
/// 平台
pub platform: Platform,
}
impl HostEnv {
/// 按便携目录的 `core.*` 解析真实数据根;解析不出来返回 `None`(host 退回 mock)。
pub fn resolve() -> Option<Self> {
let exe_dir = std::env::current_exe()
.ok()
.and_then(|path| path.parent().map(Path::to_path_buf))?;
let request = request_from_portable(&exe_dir);
let resolver = Resolver::new(Platform::current(), Some(exe_dir));
let data = resolver.resolve_default(&request).ok()?.clone();
Some(Self::from_data(data))
}
/// 直接钉一个根:`<root>/.ULL` + `<root>/.minecraft`(截图/演示通道)。
///
/// 根下已有 `config/launcher.json` 时**尊重其中的 `core.game_home` / `core.launcher_home`**:
/// 演示根常用它把游戏目录指到既有真数据上(避免复制几 GB 的 assets/libraries)。
pub fn at_root(root: impl AsRef<Path>) -> Self {
let root = root.as_ref();
let request = request_from_portable(root);
let absolute = |path: Option<PathBuf>| path.filter(|path| path.is_absolute());
Self::from_data(ResolvedData {
mode: DataMode::Portable,
launcher_home: absolute(request.launcher_home).unwrap_or_else(|| root.join(".ULL")),
game_home: absolute(request.game_home).unwrap_or_else(|| root.join(".minecraft")),
redirected_from: None,
})
}
/// 由解析结果建环境。
pub fn from_data(data: ResolvedData) -> Self {
Self {
layout: data.layout(),
game: GameLayout::new(data.game_home.clone()),
platform: Platform::current(),
data,
}
}
/// `config/launcher.json` 路径。
pub fn launcher_config(&self) -> PathBuf {
self.layout.launcher_config()
}
/// 设置存储(每次现读一份;写操作自己 save)。
pub fn store(&self) -> SettingStore {
SettingStore::load(self.launcher_config(), launcher_schema())
}
/// 启动会话根(`.ULL/data/agent/sessions`)。
pub fn sessions_root(&self) -> PathBuf {
self.layout.agent_sessions()
}
/// 数据目录模式。
pub fn mode(&self) -> DataMode {
self.data.mode
}
/// 人读一行(设置页「数据」分组展示)。
pub fn data_line(&self) -> String {
format!(
"{} · 启动器 {} · 游戏 {}",
self.data.mode,
self.data.launcher_home.display(),
self.data.game_home.display()
)
}
}
/// 便携目录那份 `launcher.json` → 数据根请求(只读 `core.*` 四键)。
pub(crate) fn request_from_portable(exe_dir: &Path) -> DataDirRequest {
let mut request = DataDirRequest::default();
let path = portable_config_path(exe_dir);
if !path.is_file() {
return request;
}
let store = SettingStore::load(&path, launcher_schema());
let text = |key: &str| store.doc().effective_str(key).map(str::to_string);
if let Some(mode) = text(DATA_ROOT_KEYS[0]).as_deref().and_then(DataMode::parse) {
request.mode = mode;
}
if let Some(root) = text(DATA_ROOT_KEYS[1]) {
request.custom_root = Some(PathBuf::from(root));
}
if let Some(home) = text(DATA_ROOT_KEYS[2]) {
request.launcher_home = Some(PathBuf::from(home));
}
if let Some(home) = text(DATA_ROOT_KEYS[3]) {
request.game_home = Some(PathBuf::from(home));
}
request
}
/// `<exe 目录>/.ULL/config/launcher.json`。
pub(crate) fn portable_config_path(exe_dir: &Path) -> PathBuf {
LauncherLayout::new(exe_dir.join(".ULL")).launcher_config()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn at_root_splits_launcher_and_game_homes() {
let env = HostEnv::at_root("D:/tmp/ull-demo");
assert_eq!(env.layout.home(), Path::new("D:/tmp/ull-demo/.ULL"));
assert_eq!(env.game.root(), Path::new("D:/tmp/ull-demo/.minecraft"));
assert_eq!(env.mode(), DataMode::Portable);
assert!(env.data_line().contains("portable"));
}
#[test]
fn portable_request_reads_core_keys_only_when_file_exists() {
let dir = std::env::temp_dir().join(format!("ull-host-env-{}", std::process::id()));
std::fs::create_dir_all(dir.join(".ULL/config")).expect("建目录");
let request = request_from_portable(&dir);
assert_eq!(request, DataDirRequest::default());
std::fs::write(
portable_config_path(&dir),
r#"{"core":{"data_mode":"custom","data_custom_root":"D:/data/ull"}}"#,
)
.expect("写配置");
let request = request_from_portable(&dir);
assert_eq!(request.mode, DataMode::Custom);
assert_eq!(
request.custom_root.as_deref(),
Some(Path::new("D:/data/ull"))
);
std::fs::remove_dir_all(&dir).ok();
}
}
+260
View File
@@ -0,0 +1,260 @@
//! 实例快照:`profiles.json` + 版本 JSON + 会话索引 + 实例设置 → [`InstanceSummary`]。
//!
//! 「磁贴上的每个字」都在这里翻译完:版本号取自继承链根、加载器取自版本 id 前缀、
//! 最后游玩时间取自会话索引、隔离档位取自实例设置(缺省跟全局 `instances.mode`)。
use std::collections::BTreeMap;
use std::path::Path;
use unlucky_core::game::install::instance::{
read_instance_config, read_profiles, write_instance_config,
};
use unlucky_core::game::isolation::IsolationMode;
use unlucky_core::game::{ProfileList, VersionJson};
use unlucky_core::launch::session_index;
use crate::dto::{
InstanceRuntime, InstanceSummary, LoaderInfo, LoaderKind, NoticeLevel, NoticeView,
};
use super::env::HostEnv;
/// 全局默认隔离档位(`instances.mode`;缺键/坏值按 D20 默认 `isolated`)。
pub fn global_mode(env: &HostEnv) -> IsolationMode {
env.store()
.doc()
.effective_str("instances.mode")
.and_then(IsolationMode::parse)
.unwrap_or_default()
}
/// 读实例清单(文件缺失 = 空列表,不是错误)。
pub fn profiles(env: &HostEnv) -> ProfileList {
read_profiles(&env.layout).unwrap_or_default()
}
/// 全会话索引里「每个实例最近一次启动」的时间串。
pub fn last_played(env: &HostEnv) -> BTreeMap<String, String> {
let index = session_index::index_path(&env.sessions_root());
let entries = session_index::read_all(&index).unwrap_or_default();
let mut map: BTreeMap<String, String> = BTreeMap::new();
for entry in entries {
if entry.instance_id.is_empty() || entry.started_at.is_empty() {
continue;
}
let current = map.get(&entry.instance_id);
if current.is_none_or(|existing| existing.as_str() < entry.started_at.as_str()) {
map.insert(entry.instance_id.clone(), entry.started_at);
}
}
map
}
/// 版本 JSON 的继承链根 id + 加载器(读不到返回 `(version_id, 原版)`)。
pub fn describe_version(env: &HostEnv, version_id: &str) -> (String, LoaderInfo) {
let loader = loader_from_id(version_id);
let mut current = version_id.to_string();
let mut seen = 0;
while seen < 16 {
seen += 1;
let path = env.game.version_json(&current);
let Ok(text) = std::fs::read_to_string(&path) else {
break;
};
let Ok(json) = VersionJson::from_json_str(&text) else {
break;
};
match json.inherits_from {
Some(parent) if !parent.is_empty() => current = parent,
_ => break,
}
}
(current, loader)
}
/// 版本 id → 加载器(HMCL 同款命名口径:`1.20.1-forge-47.2.0` / `fabric-loader-0.16.5-1.20.1`)。
pub fn loader_from_id(version_id: &str) -> LoaderInfo {
let lower = version_id.to_ascii_lowercase();
let grab = |marker: &str| -> String {
lower
.split_once(marker)
.map(|(_, rest)| rest.trim_start_matches('-').to_string())
.unwrap_or_default()
};
let (kind, version) = if lower.contains("neoforge") {
(LoaderKind::NeoForge, grab("neoforge-"))
} else if lower.contains("fabric-loader") {
(LoaderKind::Fabric, grab("fabric-loader-"))
} else if lower.contains("quilt-loader") {
(LoaderKind::Quilt, grab("quilt-loader-"))
} else if lower.contains("forge") {
(LoaderKind::Forge, grab("forge-"))
} else if lower.contains("optifine") {
(LoaderKind::OptiFine, grab("optifine"))
} else if lower.contains("liteloader") {
(LoaderKind::LiteLoader, grab("liteloader-"))
} else {
(LoaderKind::Vanilla, String::new())
};
LoaderInfo { kind, version }
}
/// 实例的隔离档位(实例文件优先,缺省显示全局档位)。
pub fn isolation_of(env: &HostEnv, id: &str) -> String {
let global = global_mode(env);
match read_instance_config(&env.layout, id) {
Ok(Some(config)) => config.effective_isolation(global).as_str().to_string(),
_ => global.as_str().to_string(),
}
}
/// 实例快照。
pub fn instances(env: &HostEnv) -> Vec<InstanceSummary> {
let profiles = profiles(env);
let played = last_played(env);
profiles
.profiles
.iter()
.map(|profile| {
let version_id = profile.version_id.clone();
let (mc_version, loader) = describe_version(env, &version_id);
InstanceSummary {
id: profile.id.as_str().to_string(),
name: profile.name.clone(),
mc_version,
loader,
last_played: played.get(profile.id.as_str()).cloned(),
runtime: InstanceRuntime::Idle,
icon: None,
isolation: isolation_of(env, profile.id.as_str()),
version_present: env.game.is_version_installed(&version_id),
version_id,
}
})
.collect()
}
/// 改实例隔离档位(写实例设置文件;不动任何数据)。
///
/// # Errors
///
/// 实例不存在、文件读不了/写不了时返回用户可读文案。
pub fn set_isolation(env: &HostEnv, id: &str, mode: &str) -> Result<(), String> {
let parsed = IsolationMode::parse(mode)
.ok_or_else(|| format!("未知隔离档位 `{mode}`(isolated / shared)"))?;
let profiles = profiles(env);
let profile = profiles
.profiles
.iter()
.find(|profile| profile.id.as_str() == id)
.ok_or_else(|| format!("找不到实例 `{id}`"))?;
let mut config = read_instance_config(&env.layout, id)
.map_err(|error| format!("读实例设置失败:{error}"))?
.unwrap_or_else(|| {
unlucky_core::game::install::instance::InstanceConfig::new(
profile.id.as_str(),
&profile.version_id,
)
});
config.isolation = Some(match parsed {
IsolationMode::Isolated => unlucky_core::game::install::instance::Isolation::Isolated,
IsolationMode::Shared => unlucky_core::game::install::instance::Isolation::Shared,
});
write_instance_config(&env.layout, &config)
.map(|_| ())
.map_err(|error| format!("写实例设置失败:{error}"))
}
/// 启动前的切换检测:把 M30 的 `ModeChangeWarning` 翻成横幅通知。
///
/// 只在真启动时调用(会写 `lastModeChange.warned`,保证同一次切换只提示一次)。
pub fn mode_change_notice(env: &HostEnv, id: &str) -> Option<NoticeView> {
let mut config = read_instance_config(&env.layout, id).ok()??;
let warning = config.take_mode_change_warning()?;
let _ = write_instance_config(&env.layout, &config);
Some(NoticeView::new(
&format!("isolation:{id}"),
NoticeLevel::Warn,
"隔离档位已切换",
&warning.message(),
))
}
/// 建目录(数据根首次运行时)。
pub fn ensure_root(env: &HostEnv) {
let _ = env.layout.ensure();
let _ = std::fs::create_dir_all(env.game.root());
}
/// 版本本体目录是否存在(启动前快速判断)。
pub fn version_present(env: &HostEnv, version_id: &str) -> bool {
env.game.is_version_installed(version_id)
}
/// 路径显示成短串(通知/错误文案用)。
pub fn short_path(path: &Path) -> String {
path.display().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn loader_parsing_covers_all_shapes() {
let cases = [
("1.20.4", LoaderKind::Vanilla, ""),
(
"fabric-loader-0.16.5-1.20.1",
LoaderKind::Fabric,
"0.16.5-1.20.1",
),
(
"quilt-loader-0.24.0-1.20.1",
LoaderKind::Quilt,
"0.24.0-1.20.1",
),
("1.20.1-forge-47.2.0", LoaderKind::Forge, "47.2.0"),
("1.21.1-neoforge-21.1.80", LoaderKind::NeoForge, "21.1.80"),
];
for (id, kind, version) in cases {
let info = loader_from_id(id);
assert_eq!(info.kind, kind, "{id}");
assert_eq!(info.version, version, "{id}");
assert!(!info.badge().is_empty());
}
}
#[test]
fn isolation_write_then_read_round_trips() {
let dir = std::env::temp_dir().join(format!("ull-host-game-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
std::fs::create_dir_all(env.layout.config()).expect("config 目录");
// 直接建一份 profiles.json(register 需要版本本体,这里只测隔离写读)
let profiles = ProfileList {
profiles: vec![unlucky_core::game::InstanceProfile {
id: unlucky_core::game::ProfileId::generate(),
name: "demo".to_string(),
version_id: "1.20.1".to_string(),
game_dir: None,
running_dir: None,
java_path: None,
created_at: None,
last_played_at: None,
extra: BTreeMap::new(),
}],
..ProfileList::default()
};
unlucky_core::game::install::instance::write_profiles(&env.layout, &profiles)
.expect("写清单");
let id = profiles.profiles[0].id.as_str().to_string();
assert_eq!(isolation_of(&env, &id), "isolated");
set_isolation(&env, &id, "shared").expect("切档位");
assert_eq!(isolation_of(&env, &id), "shared");
assert!(set_isolation(&env, &id, "nope").is_err());
std::fs::remove_dir_all(&dir).ok();
}
}
+145
View File
@@ -0,0 +1,145 @@
//! `HostTask` → `CoreEvent` 的翻译(从 `host/mod.rs` 拆出,守 400 行硬限)。
//!
//! 父模块的私有字段对子模块可见,所以这里直接读写 `self.tx` / `self.launches` 等。
use crate::dto::{AppEvent, CoreEvent, InstanceRuntime, LogOutcome};
use crate::toast::ToastKind;
use super::{logs, CoreHost, HostTask, LiveLaunch};
impl CoreHost {
/// 后台消息 → 事件。
pub(super) fn handle(&mut self, task: HostTask) {
match task {
HostTask::Versions(Ok(versions)) => self.send(CoreEvent::VersionListChanged(versions)),
HostTask::Versions(Err(reason)) => {
self.toast(ToastKind::Warn, format!("拉取版本清单失败:{reason}"));
}
HostTask::Install(progress) => {
self.install = Some(*progress.clone());
self.send(CoreEvent::InstallProgress(Some(*progress)));
}
HostTask::InstallDone { progress, result } => {
self.install_cancel = None;
self.install = Some(*progress.clone());
self.send(CoreEvent::InstallProgress(Some(*progress)));
match result {
Ok(_) => {
self.refresh_instances();
self.send(CoreEvent::LogSessionsChanged(logs::sessions(&self.env)));
}
Err(reason) => self.toast(ToastKind::Error, reason),
}
}
HostTask::Accounts(accounts) => {
self.accounts = accounts.clone();
self.send(CoreEvent::AccountsChanged(accounts));
self.refresh_instances();
}
HostTask::Login(view) => self.send(CoreEvent::DeviceLoginChanged(*view)),
HostTask::Java(page) => self.send(CoreEvent::JavaChanged(*page)),
HostTask::Logs {
sessions,
lines,
diagnosis,
} => {
self.send(CoreEvent::LogSessionsChanged(sessions));
self.send(CoreEvent::LogLinesChanged(lines));
if let Some(report) = diagnosis {
self.send(CoreEvent::DiagnosisReady(report));
}
}
HostTask::Sessions(sessions) => self.send(CoreEvent::LogSessionsChanged(sessions)),
HostTask::Tail(batch) => {
if batch.running {
self.send(CoreEvent::LogLinesChanged(batch.lines));
} else {
self.send(CoreEvent::LogAppended(*batch));
}
}
HostTask::LaunchStarted {
id,
session_id,
pid,
} => {
self.pending.remove(&id);
self.failed.remove(&id);
self.launches.retain(|live| live.instance != id);
self.launches.push(LiveLaunch {
instance: id.clone(),
session: session_id.clone(),
pid,
});
// 实时尾随一次只跟一场(下载页/日志页共用一个滚动窗口);
// 已有别的实例在尾随时不抢占——它的日志仍在 JSONL 里,收尾后可回放。
if self.tail.session().is_none() {
self.tail.attach(
self.env
.sessions_root()
.join("game")
.join(format!("{session_id}.jsonl")),
&session_id,
);
}
self.send(CoreEvent::InstanceRuntimeChanged {
id,
runtime: InstanceRuntime::Running { pid },
});
}
HostTask::Runtime { id, runtime } => {
self.send(CoreEvent::InstanceRuntimeChanged { id, runtime });
}
HostTask::LaunchEnd(finished) => {
// 收尾可能发生在 LaunchStarted 之前(build_request / spawn 失败只发
// LaunchEnd):pending 必须在这里也清掉,否则 in_flight 恒真、该实例
// 本会话内再也起不来。
self.pending.remove(&finished.id);
self.launches.retain(|live| live.instance != finished.id);
let runtime = if finished.outcome == LogOutcome::Ok {
self.failed.remove(&finished.id);
InstanceRuntime::Idle
} else {
// 记住失败原因:随后的 refresh_instances 会重建快照,
// 不记的话磁贴的「失败/重试」外观会被 Idle 盖掉。
self.failed
.insert(finished.id.clone(), finished.summary.clone());
InstanceRuntime::Failed {
reason: finished.summary.clone(),
}
};
self.send(CoreEvent::InstanceRuntimeChanged {
id: finished.id.clone(),
runtime,
});
self.send(CoreEvent::LaunchFinished((*finished).clone()));
// 只拆自己那场的尾随:并发时另一实例的实时日志不该被这次收尾掐掉。
if self.tail.session() == Some(finished.session.as_str()) {
self.tail.detach();
}
self.send(CoreEvent::LogSessionsChanged(logs::sessions(&self.env)));
self.refresh_instances();
}
HostTask::Crash(report) => {
self.send(CoreEvent::DiagnosisReady(*report));
}
HostTask::Notice(notice) => self.push_notice(*notice),
HostTask::Toast(toast) => self.events.push_back(AppEvent::Toast(*toast)),
}
}
/// 尾随运行中会话(每 `TAIL_POLL_TICKS` 拍一次)。
pub(super) fn poll_tail(&mut self) {
let Some((session, lines)) = self.tail.poll() else {
return;
};
if lines.is_empty() {
return;
}
let running = self.launches.iter().any(|live| live.session == session);
self.send(CoreEvent::LogAppended(crate::dto::LiveLogBatch {
session,
lines,
running,
}));
}
}
+103
View File
@@ -0,0 +1,103 @@
//! 启动身份:选中账户 → `AuthPlaceholders`(**凭据只在这里读,读完即丢**)+ 收尾结局映射。
//!
//! 与 CLI `commands/identity.rs` 同源:微软轨过期就静默续期并回写凭据(refresh_token 轮换,
//! 旧值作废);零账户回落离线占位,保证没有账户时照样能启动。
use unlucky_core::auth::account::{AccountKind, Credentials};
use unlucky_core::auth::microsoft::MicrosoftAuth;
use unlucky_core::auth::offline::offline_login;
use unlucky_core::auth::AccountStore;
use unlucky_core::download::blocking::block_on;
use unlucky_core::game::ProfileId;
use unlucky_core::launch::request::AuthPlaceholders;
use crate::dto::LogOutcome;
use super::env::HostEnv;
/// 把选中账户翻成启动占位符(**凭据只在这里读,读完即丢**)。
///
/// 微软轨:过期就静默续期并回写凭据(refresh_token 轮换,旧值作废)。
///
/// # Errors
///
/// 账户不存在/凭据类型不符/令牌过期且刷新失败时返回用户可读文案。
pub fn resolve(env: &HostEnv, account: Option<&str>) -> Result<(AuthPlaceholders, String), String> {
let store = AccountStore::open(env.layout.auth_data());
let chosen = match account {
Some(key) => store
.find(key)
.cloned()
.ok_or_else(|| format!("找不到账户 `{key}`"))?,
None => match store.accounts() {
[] => {
let uuid = ProfileId::generate().as_str().replace('-', "");
return Ok((
AuthPlaceholders {
player_name: "Player".to_string(),
uuid,
user_type: "legacy".to_string(),
user_properties: "{}".to_string(),
..AuthPlaceholders::default()
},
"未登录账户,按离线占位启动".to_string(),
));
}
[only] => only.clone(),
many => return Err(format!("有 {} 个账户,请在账户页选一个再启动", many.len())),
},
};
let line = format!("账户 {}({})", chosen.display_name(), chosen.kind.label());
match chosen.kind {
AccountKind::Offline => {
let placeholders =
offline_login(&chosen).map_err(|error| format!("离线账户解析失败:{error}"))?;
Ok((placeholders, line))
}
AccountKind::Microsoft => {
let Some(Credentials::Microsoft(session)) = store.credentials(&chosen.id) else {
return Err(format!(
"账户 {} 的微软凭据缺失或类型不符,请重新登录",
chosen.display_name()
));
};
let auth =
MicrosoftAuth::from_env().map_err(|error| format!("微软登录不可用:{error}"))?;
let (fresh, _) = block_on(auth.ensure_fresh(session))
.map_err(|error| format!("微软令牌刷新失败:{error}"))?;
if &fresh != session {
// refresh_token 已轮换:立刻回写,旧值作废。
let mut store = AccountStore::open(env.layout.auth_data());
let _ = store.update_credentials(&chosen.id, Credentials::Microsoft(fresh.clone()));
let _ = store.save();
}
let placeholders = fresh
.to_placeholders()
.map_err(|error| format!("微软身份转换失败:{error}"))?;
Ok((placeholders, line))
}
AccountKind::AuthlibInjector => {
let Some(Credentials::AuthlibInjector(session)) = store.credentials(&chosen.id) else {
return Err(format!(
"账户 {} 的第三方凭据缺失或类型不符",
chosen.display_name()
));
};
let placeholders = session
.to_placeholders()
.map_err(|error| format!("第三方身份转换失败:{error}"))?;
Ok((placeholders, line))
}
}
}
/// `SessionHandle` → 日志页的三种结局。
pub fn outcome_of(handle: &unlucky_core::launch::SessionHandle) -> LogOutcome {
if handle.exit.silent_failure.is_some() {
LogOutcome::SilentFail
} else if handle.succeeded() {
LogOutcome::Ok
} else {
LogOutcome::Crashed
}
}
+393
View File
@@ -0,0 +1,393 @@
//! 安装:新建实例向导 / 下载页「安装」→ core 安装管线 → 登记实例。
//!
//! 与 CLI `ull install` 同序:`RemoteSource::resolve → InstallPlan::build → execute → register`。
//! Forge/NeoForge 多一段 installer processors([`super::install_processors`]):
//! `ProcessorPlan::build → 依赖库下载 → 选 Java → 串行 java -cp`,**跑完才写版本 JSON**。
//! 差异只在线程模型:进度经 [`super::job::HostTask::Install`] 回流(文件计数 + 字节 + 速度/ETA),
//! 且整段跑在后台线程里(`block_on` 不占 UI 线程)。
//!
//! 加载器范围:原版 + Fabric/Quilt/Forge/NeoForge(四家,与 CLI 对齐)。OptiFine/LiteLoader
//! 两家 core 侧都还没有安装管线,仍在 GUI 里显式拒装并说清去哪装。
//! 失败一律落 `.ULL/logs/install.log`([`super::install_journal`]),文案里带日志路径。
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use unlucky_core::addon::loader::{
LoaderInstallPlan, LoaderKind as CoreLoaderKind, LoaderPlanInput, LoaderSource,
};
use unlucky_core::download::blocking::block_on;
use unlucky_core::download::cache::now_ms;
use unlucky_core::download::DownloadEngine;
use unlucky_core::game::arguments::Environment;
use unlucky_core::game::install::{
execute, InstallError, InstallPlan, InstallReport, PlanInput, RemoteSource,
};
use crate::dto::{CreateRequest, InstallStage, LoaderKind};
use crate::toast::{Toast, ToastKind};
use super::env::HostEnv;
use super::install_journal::Journal;
use super::install_processors::{self, StageInput};
use super::job::{spawn, HostTask, JobSender};
use super::meter::{sink_for, Meter};
/// 文件级并发上限(与 CLI 同值:16 路压满带宽又不至于打爆镜像)。
pub const CONCURRENCY: usize = 16;
/// 用户取消的稳定文案(阶段边界生效)。
pub const CANCELLED: &str = "已取消(阶段边界停下;已下载的文件保留,重装会续用)";
/// 一次安装任务的运行上下文(后台线程内一路传下去,免得每个函数排七八个参数)。
struct Job<'a> {
env: &'a HostEnv,
tx: &'a JobSender,
meter: &'a Arc<std::sync::Mutex<Meter>>,
cancel: &'a Arc<AtomicBool>,
journal: &'a Journal,
}
impl Job<'_> {
/// processor 段的进度出口(同一套 channel/meter/journal)。
fn reporter(&self) -> install_processors::Reporter<'_> {
install_processors::Reporter {
tx: self.tx,
meter: self.meter,
journal: self.journal,
}
}
}
/// 加载器徽标(`原版` / `Fabric 0.16.5`)。
pub fn loader_label(kind: LoaderKind, version: &str) -> String {
if kind == LoaderKind::Vanilla || version.trim().is_empty() {
kind.label().to_string()
} else {
format!("{} {}", kind.label(), version)
}
}
/// GUI 侧支持的加载器(五选:原版 + 四家加载器,与 CLI 的 `--loader` 词表一致)。
///
/// 写成穷举 `matches!` 而不是 `true`:将来 core 加了第六种加载器,这里默认拒绝,
/// 由 [`unsupported_reason`] 给出明确说法,而不是让它走进不属于它的安装分支。
pub fn supported(kind: LoaderKind) -> bool {
matches!(
kind,
LoaderKind::Vanilla
| LoaderKind::Fabric
| LoaderKind::Quilt
| LoaderKind::Forge
| LoaderKind::NeoForge
)
}
/// 不支持的加载器 → 给用户的可执行建议。
pub fn unsupported_reason(kind: LoaderKind) -> String {
format!(
"{} 还没有接入安装管线(目前支持原版 + Fabric/Quilt/Forge/NeoForge);请先在实例页导入已有版本,或换成受支持的加载器。",
kind.label()
)
}
/// 后台安装(向导提交 / 下载页行内安装共用)。
pub fn spawn_install(env: HostEnv, tx: JobSender, request: CreateRequest, cancel: Arc<AtomicBool>) {
spawn("install", move || {
let meter = Arc::new(std::sync::Mutex::new(Meter::new(&request)));
let journal = Journal::open(&env);
journal.line(&format!(
"—— 开始安装 {}({} {})——",
request.instance_name(),
loader_label(request.loader, ""),
request.version
));
let job = Job {
env: &env,
tx: &tx,
meter: &meter,
cancel: &cancel,
journal: &journal,
};
let result = run(&job, &request);
let mut progress = meter
.lock()
.map(|guard| guard.progress.clone())
.unwrap_or_else(|_| Meter::new(&request).progress);
match result {
Ok(name) => {
journal.line(&format!("完成:实例 {name} 已登记"));
progress.stage = InstallStage::Done;
progress.files_done = progress.files_total;
progress.message = format!("已安装 {name}({})", progress.loader);
let _ = tx.send(HostTask::InstallDone {
progress: Box::new(progress),
result: Ok(name),
});
let _ = tx.send(HostTask::Toast(Box::new(Toast {
id: 0,
kind: ToastKind::Success,
text: "安装完成,实例已登记".to_string(),
remaining_ms: 0,
})));
}
Err(reason) => {
let cancelled = reason == CANCELLED;
progress.stage = if cancelled {
InstallStage::Cancelled
} else {
InstallStage::Failed
};
// 失败文案带上日志路径:面板只有一行、Toast 4 秒就消失,
// 装日志是用户事后能自己回看的唯一渠道(进度板之外)。
let text = if cancelled {
reason
} else {
journal.line(&format!("安装未完成:{reason}"));
format!("{reason}(日志:{})", journal.path().display())
};
progress.message.clone_from(&text);
let _ = tx.send(HostTask::InstallDone {
progress: Box::new(progress),
result: Err(text),
});
}
}
});
}
/// 真安装(同步跑完,调用方在后台线程里)。
fn run(job: &Job<'_>, request: &CreateRequest) -> Result<String, String> {
if request.version.trim().is_empty() {
return Err("没有选择版本".to_string());
}
if !supported(request.loader) {
return Err(unsupported_reason(request.loader));
}
let name = request.instance_name();
let env = job.env;
let engine = super::engine::engine(env);
{
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
guard.stage(InstallStage::Planning, "正在解析版本清单…");
let _ = job
.tx
.send(HostTask::Install(Box::new(guard.progress.clone())));
}
if job.cancel.load(Ordering::SeqCst) {
return Err(CANCELLED.to_string());
}
let version_id = if request.loader == LoaderKind::Vanilla {
install_vanilla(job, &engine, request)?
} else {
install_loader(job, &engine, request)?
};
{
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
guard.stage(InstallStage::Registering, "正在登记实例…");
let _ = job
.tx
.send(HostTask::Install(Box::new(guard.progress.clone())));
}
let created_at =
unlucky_core::launch::time::format_rfc3339_millis(unlucky_core::launch::time::now());
unlucky_core::game::install::instance::register(
&env.layout,
&name,
&version_id,
Some(&created_at),
)
.map_err(|error| format!("登记实例失败:{error}"))?;
Ok(name)
}
/// 原版安装(`resolve → plan → [prefetch 资源索引] → execute`)。
fn install_vanilla(
job: &Job<'_>,
engine: &DownloadEngine,
request: &CreateRequest,
) -> Result<String, String> {
let env = job.env;
let version = request.version.trim();
let meta_cache = env.layout.meta_cache();
let now = now_ms();
let sink = sink_for(job.tx.clone(), Arc::clone(job.meter));
block_on(async {
let source = RemoteSource::new(engine, &env.game, &meta_cache, now);
let resolved = source.resolve(version).await.map_err(install_error)?;
let fetched = match resolved
.asset_index
.as_ref()
.filter(|index| index.url.is_some())
{
Some(reference) => {
let (index, bytes) = source.asset_index(reference).await.map_err(install_error)?;
Some((index, env.game.asset_index(&reference.id), bytes))
}
None => None,
};
let mut plan = InstallPlan::build(PlanInput {
layout: &env.game,
version_id: version,
resolved: &resolved,
asset_index: fetched.as_ref().map(|(index, _, _)| index),
environment: &Environment::host(),
})
.map_err(|error| error.to_string())?;
if let Some((_, dest, bytes)) = fetched {
plan.prefetch(dest, bytes);
}
if !plan.is_installable() {
return Err(format!(
"版本 {version} 装不全:{}",
plan.warnings.join(";")
));
}
let (bytes_total, _) = plan.total_bytes();
{
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
guard.progress.files_total = u64::try_from(plan.file_count()).unwrap_or(u64::MAX);
guard.progress.bytes_total = bytes_total;
guard.stage(
InstallStage::Downloading,
&format!("开始下载 {} 个文件…", plan.file_count()),
);
}
if job.cancel.load(Ordering::SeqCst) {
return Err(CANCELLED.to_string());
}
let report = execute(engine, &plan, CONCURRENCY, Some(sink))
.await
.map_err(install_error)?;
ensure_complete(&report)?;
Ok::<String, String>(version.to_string())
})
}
/// 加载器安装(原版前置 → installer processors → 加载器库 → 版本 JSON 落盘)。
fn install_loader(
job: &Job<'_>,
engine: &DownloadEngine,
request: &CreateRequest,
) -> Result<String, String> {
let env = job.env;
let version = request.version.trim();
let kind = CoreLoaderKind::parse(request.loader.as_str())
.ok_or_else(|| format!("未知加载器 `{}`", request.loader.as_str()))?;
let meta_cache = env.layout.meta_cache();
let loader_version = request.loader_version.trim().to_string();
let explicit = (!loader_version.is_empty()).then_some(loader_version.as_str());
let release = block_on(async {
LoaderSource::new(engine, &meta_cache)
.resolve(kind, version, explicit)
.await
})
.map_err(|error| error.to_string())?;
job.journal.line(&format!(
"加载器解析:{} {} → {}",
release.kind.label(),
release.loader_version,
release.version_id
));
let plan = LoaderInstallPlan::build(LoaderPlanInput {
layout: &env.game,
release: &release,
environment: &Environment::host(),
vanilla_installed: env.game.is_version_installed(version),
});
if plan.vanilla_install_needed {
let mut vanilla = request.clone();
vanilla.loader = LoaderKind::Vanilla;
install_vanilla(job, engine, &vanilla)?;
}
// 两段(加载器库 + 处理器依赖库)在动手前一次预记账:进度条只走一条单调曲线,
// 处理器依赖库下完不会先满格、再在加载器库开始时回落到一半。
{
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
let (bytes, _) = plan.install.total_bytes();
guard.expect(plan.files().len(), bytes);
}
// Forge/NeoForge:installer processors 必须先跑完,`execute` 才允许写版本 JSON。
if release.requires_installer() {
let input = StageInput {
kind: release.kind,
mc_version: &release.mc_version,
version_id: &release.version_id,
profile: release.install_profile.as_ref(),
installer: release.installer.as_deref(),
installed: env.game.is_version_installed(&release.version_id),
};
// `None` = 这一版不需要跑处理器(版本 JSON 已在盘上 / profile 没有 client 侧步骤),
// 进度与日志都由处理器段自己写全,这里只消费返回值。
let _ = install_processors::stage(
env,
engine,
&input,
&install_processors::CORE,
&job.reporter(),
job.cancel,
)?;
}
{
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
guard.stage(
InstallStage::Downloading,
&format!("开始下载加载器库({} 个文件)…", plan.files().len()),
);
}
if job.cancel.load(Ordering::SeqCst) {
return Err(CANCELLED.to_string());
}
let sink = sink_for(job.tx.clone(), Arc::clone(job.meter));
let report = block_on(async {
execute(engine, &plan.install, CONCURRENCY, Some(sink))
.await
.map_err(install_error)
})?;
ensure_complete(&report)?;
Ok(release.version_id)
}
/// 安装错误 → 用户可读文案。
fn install_error(error: InstallError) -> String {
match error.hint() {
Some(hint) => format!("{}({hint})", error.message()),
None => error.message().to_string(),
}
}
/// 有文件失败就报错(列前三条明细)。
fn ensure_complete(report: &InstallReport) -> Result<(), String> {
if report.is_complete() {
return Ok(());
}
let details: Vec<String> = report
.failures(3)
.iter()
.filter_map(|outcome| {
outcome
.failure
.as_ref()
.map(|failure| format!("{}:{}", outcome.name, failure.message))
})
.collect();
Err(format!(
"{} 个文件没下成(共 {} 个):{}",
report.failed(),
report.files.len(),
details.join(";")
))
}
/// 安装目录的短显示(诊断/Toast 用)。
pub fn game_root(env: &HostEnv) -> PathBuf {
env.game.root().to_path_buf()
}
#[cfg(test)]
#[path = "install_tests.rs"]
mod tests;
+79
View File
@@ -0,0 +1,79 @@
//! 安装过程的可回溯日志(`.ULL/logs/install.log`,一轮一行)。
//!
//! 起因(M45):Forge/NeoForge 装失败时,GUI 只有一行 Toast,processor 的 stderr 尾部
//! 与「哪一步挂了」在面板一关就没了。这里把安装过程按行追加到 `.ULL/logs/`
//! (与 `auth.jsonl` 同一分区),失败文案里带上日志路径,用户能自己回看。
//!
//! 三条纪律(与 auth 日志同源):
//! 1. **只追加**,不轮转、不截断(体积由「一行一条、只记诊断字段」控制);
//! 2. **只记诊断字段**:阶段、processor 坐标、退出码、已脱敏的 stderr 尾部(core 侧脱敏);
//! 3. **写盘尽力而为**:开不了文件就静默跳过,绝不让日志失败连累安装。
use std::io::Write;
use std::path::{Path, PathBuf};
use unlucky_core::launch::time;
use super::env::HostEnv;
/// 日志文件名(在 `.ULL/logs/` 下)。
pub const INSTALL_LOG_FILE: &str = "install.log";
/// 一轮安装的日志句柄(`Clone` 很便宜:只有一个路径)。
#[derive(Debug, Clone)]
pub struct Journal {
path: PathBuf,
}
impl Journal {
/// 按数据根打开(`logs/` 不存在就补建;建不出来只是没有日志)。
pub fn open(env: &HostEnv) -> Self {
let dir = env.layout.logs();
let _ = std::fs::create_dir_all(&dir);
Self {
path: dir.join(INSTALL_LOG_FILE),
}
}
/// 日志路径(失败文案里给用户看)。
pub fn path(&self) -> &Path {
&self.path
}
/// 追加一行(自动补 RFC3339 时间戳;写失败静默)。
pub fn line(&self, text: &str) {
let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
else {
return;
};
let _ = writeln!(
file,
"[{}] {text}",
time::format_rfc3339_millis(time::now())
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lines_are_appended_with_a_timestamp() {
let root = std::env::temp_dir().join(format!("ull-install-journal-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let env = HostEnv::at_root(&root);
let journal = Journal::open(&env);
journal.line("第一行");
journal.line("第二行");
let text = std::fs::read_to_string(journal.path()).expect("日志存在");
assert!(text.contains("第一行") && text.contains("第二行"), "{text}");
assert_eq!(text.lines().count(), 2);
assert!(text.starts_with("[2"), "带时间戳:{text}");
assert_eq!(journal.path(), env.layout.logs().join(INSTALL_LOG_FILE));
std::fs::remove_dir_all(&root).ok();
}
}
+378
View File
@@ -0,0 +1,378 @@
//! Forge / NeoForge 的 installer processors 段(M45:GUI 与 CLI 行为对齐)。
//!
//! 顺序与 CLI `ull install --loader forge` 完全一致(`crates/cli/src/commands/install/
//! loader_processors.rs`),只是把进度接到 GUI 的回流通道上:
//!
//! ```text
//! ProcessorPlan::build(纯计算:变量表 + 每步 java -cp + 依赖库清单)
//! → download_libraries(M27 引擎;进度回流 HostTask::Install)
//! → select_java(M33 解析链:托管 > JAVA_HOME > PATH > 扫盘)
//! → run(串行 java -cp;每步一条面板文案 + 一行装日志)
//! ```
//!
//! 四条硬约束(与 CLI 同源,GUI 侧一条都不放宽):
//!
//! 1. **processors 跑完才允许写版本 JSON**:版本清单存在 = 这版装完了,抢在 processors
//! 前写会留下启动必崩却被报成"已安装"的实例(顺序由 `install_loader` 保证);
//! 2. **幂等**:版本 JSON 已在盘上 = 上一轮跑完了,整段跳过;
//! 3. **取消在阶段边界生效**:处理器阶段是原子段(串行外调 java,进程内没有取消钩子),
//! 进入前与退出后各查一次 `cancel`;
//! 4. **失败不静默**:结构化文案(带 `processor_*` 大类码)+ 装日志一行一条,由调用方
//! 再补 Toast(`HostTask::InstallDone` 的既有通路)。
//!
//! 执行后端走 [`Backend`]:真实实现 [`CoreBackend`] 直接转调 core,单测注入 fake,
//! 因此「计划怎么组、Java 找不到怎么说、依赖库下漏怎么报」都能在没有网络/没有 JDK
//! 的机器上断言。
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use unlucky_core::addon::loader::forge::InstallProfile;
use unlucky_core::addon::loader::processors::{
self, ProcessorError, ProcessorPlan, ProcessorPlanInput, ProcessorRun, StepOutcome, StepSink,
StepStatus,
};
use unlucky_core::addon::loader::LoaderKind as CoreLoaderKind;
use unlucky_core::download::blocking::block_on;
use unlucky_core::download::DownloadEngine;
use unlucky_core::game::arguments::Environment;
use unlucky_core::game::install::{InstallReport, PlanFile, ProgressSink};
use unlucky_core::game::java::{JavaRequirement, JavaRuntime};
use crate::dto::InstallStage;
use super::env::HostEnv;
use super::install::{CANCELLED, CONCURRENCY};
use super::install_journal::Journal;
use super::job::{HostTask, JobSender};
use super::meter::{sink_for, Meter};
/// 真实后端(无状态,可以放静态里复用)。
pub(super) const CORE: CoreBackend = CoreBackend;
/// processors 段需要的输入(从 `LoaderRelease` 摘出来,便于单测构造)。
pub(super) struct StageInput<'a> {
/// 加载器种类(补库时 maven 基址的兜底来源)。
pub kind: CoreLoaderKind,
/// 目标原版版本。
pub mc_version: &'a str,
/// loader 版本 id(依赖库落点 + 临时目录名)。
pub version_id: &'a str,
/// installer 里读出来的 install profile(`requires_installer` 为真时必须有)。
pub profile: Option<&'a InstallProfile>,
/// installer jar 的磁盘路径。
pub installer: Option<&'a Path>,
/// 版本 JSON 已在盘上 = 上一轮跑完了,整段跳过(幂等)。
pub installed: bool,
}
/// processors 段的执行后端(真实实现转调 core;单测注入 fake)。
pub(super) trait Backend {
/// 下载 processor 依赖库(M27 引擎:镜像/健康度/退避/校验)。
fn download_libraries(
&self,
engine: &DownloadEngine,
files: &[PlanFile],
sink: Option<ProgressSink>,
) -> InstallReport;
/// 按 M33 的解析链挑一个能跑 installer 的 Java。
fn select_java(&self, managed: &Path, requirement: &JavaRequirement) -> Option<JavaRuntime>;
/// 串行跑 processors。
fn run(
&self,
plan: &ProcessorPlan,
java: &JavaRuntime,
sink: Option<StepSink>,
) -> Result<ProcessorRun, ProcessorError>;
}
/// 真实后端:三件事全部转调 core 的同一个执行器(CLI 用的就是它)。
pub(super) struct CoreBackend;
impl Backend for CoreBackend {
fn download_libraries(
&self,
engine: &DownloadEngine,
files: &[PlanFile],
sink: Option<ProgressSink>,
) -> InstallReport {
block_on(processors::download_libraries(
engine,
files,
CONCURRENCY,
sink,
))
}
fn select_java(&self, managed: &Path, requirement: &JavaRequirement) -> Option<JavaRuntime> {
processors::select_java(managed, requirement)
}
fn run(
&self,
plan: &ProcessorPlan,
java: &JavaRuntime,
sink: Option<StepSink>,
) -> Result<ProcessorRun, ProcessorError> {
processors::run(plan, java, sink)
}
}
/// 段内的进度出口:进度面板(`HostTask::Install`)+ 装日志。
pub(super) struct Reporter<'a> {
/// 后台线程 → host 的通道。
pub tx: &'a JobSender,
/// 进度记账器。
pub meter: &'a Arc<Mutex<Meter>>,
/// 装日志。
pub journal: &'a Journal,
}
impl Reporter<'_> {
/// 换阶段并推一条进度。
fn stage(&self, stage: InstallStage, message: &str) {
let Ok(mut guard) = self.meter.lock() else {
return;
};
guard.stage(stage, message);
let _ = self
.tx
.send(HostTask::Install(Box::new(guard.progress.clone())));
}
/// 先把待下的库记账(文件数 + 已知字节),下载回调再逐个累加。
fn expect(&self, files: usize, bytes: u64) {
let Ok(mut guard) = self.meter.lock() else {
return;
};
guard.expect(files, bytes);
}
/// 失败留痕(文案由调用方决定怎么呈现;这里只保证日志里有一条)。
fn fail(&self, reason: String) -> String {
self.journal.line(&format!("失败:{reason}"));
reason
}
}
/// 跑 Forge / NeoForge 的 installer processors(`Ok(None)` = 这一轮不需要跑)。
///
/// # Errors
///
/// 元数据缺 profile/installer、依赖库没下全、找不到可用 Java、processor 非零退出、
/// 产物没落盘、用户取消时返回给 UI 的文案(已写进装日志)。
pub(super) fn stage(
env: &HostEnv,
engine: &DownloadEngine,
input: &StageInput<'_>,
backend: &dyn Backend,
reporter: &Reporter<'_>,
cancel: &Arc<AtomicBool>,
) -> Result<Option<ProcessorRun>, String> {
if input.installed {
reporter.journal.line(&format!(
"{} 的版本 JSON 已在盘上,跳过 installer processors",
input.version_id
));
return Ok(None);
}
let (Some(profile), Some(installer)) = (input.profile, input.installer) else {
return Err(reporter.fail(metadata_missing(input)));
};
reporter.stage(InstallStage::Installing, "正在组 installer 处理器计划…");
let scratch = env
.layout
.cache()
.join("processors")
.join(safe_name(input.version_id));
let plan = ProcessorPlan::build(ProcessorPlanInput {
kind: input.kind,
mc_version: input.mc_version,
version_id: input.version_id,
profile,
layout: &env.game,
installer,
scratch: &scratch,
environment: &Environment::host(),
})
.map_err(|error| reporter.fail(diagnostic(&error)))?;
for warning in &plan.warnings {
reporter.journal.line(&format!("规划告警:{warning}"));
}
if plan.is_empty() {
reporter
.journal
.line("install profile 没有 client 侧 processor,跳过这一段");
return Ok(None);
}
let total = plan.step_count();
let bytes = plan.libraries.iter().filter_map(|file| file.size).sum();
reporter.expect(plan.libraries.len(), bytes);
reporter.stage(
InstallStage::Downloading,
&format!("下载 installer 依赖库({} 个文件)…", plan.libraries.len()),
);
if cancel.load(Ordering::SeqCst) {
return Err(CANCELLED.to_string());
}
let sink = sink_for(reporter.tx.clone(), Arc::clone(reporter.meter));
let libraries = backend.download_libraries(engine, &plan.libraries, Some(sink));
if !libraries.is_complete() {
return Err(reporter.fail(libraries_failed(&libraries)));
}
let managed = env.layout.java_managed();
let Some(java) = backend.select_java(&managed, &plan.java_requirement) else {
return Err(reporter.fail(java_missing(&plan.java_requirement, &managed)));
};
reporter.journal.line(&format!(
"用 {}({})跑 {total} 步 processor",
java.binary.display(),
java_label(&java)
));
reporter.stage(
InstallStage::Installing,
&format!("运行 installer 处理器 0/{total}({})…", java_label(&java)),
);
if cancel.load(Ordering::SeqCst) {
return Err(CANCELLED.to_string());
}
let steps = step_sink(
reporter.tx.clone(),
Arc::clone(reporter.meter),
reporter.journal.clone(),
total,
);
let run = backend
.run(&plan, &java, Some(steps))
.map_err(|error| reporter.fail(diagnostic(&error)))?;
for warning in &run.warnings {
reporter.journal.line(&format!("执行告警:{warning}"));
}
reporter.journal.line(&format!(
"processors 完成:执行 {} 步、跳过 {} 步",
run.executed(),
run.skipped()
));
reporter.stage(
InstallStage::Installing,
&format!(
"installer 处理器完成(执行 {} 步、跳过 {} 步),正在写入版本清单…",
run.executed(),
run.skipped()
),
);
if cancel.load(Ordering::SeqCst) {
return Err(CANCELLED.to_string());
}
Ok(Some(run))
}
/// 每步完成的回调:面板一行 + 装日志一行。
///
/// `StepSink` 要求 `'static`,所以这里全持所有权(通道/记账器/日志都是克隆)。
fn step_sink(tx: JobSender, meter: Arc<Mutex<Meter>>, journal: Journal, total: usize) -> StepSink {
Arc::new(move |outcome: &StepOutcome| {
let line = step_line(outcome, total);
journal.line(&line);
let Ok(mut guard) = meter.lock() else {
return;
};
guard.stage(InstallStage::Installing, &line);
let _ = tx.send(HostTask::Install(Box::new(guard.progress.clone())));
})
}
/// 一步的进度/日志文案(纯函数,单测直接断言)。
pub(super) fn step_line(outcome: &StepOutcome, total: usize) -> String {
let suffix = match outcome.status {
StepStatus::Skipped => "(产物已就绪,跳过)".to_string(),
StepStatus::Executed => format!("({} ms)", outcome.elapsed_ms),
};
format!(
"installer 处理器 {}/{}:{}{suffix}",
outcome.index, total, outcome.label
)
}
/// processor 错误 → 用户可读文案(保留 `processor_*` 大类码,便于按码搜日志)。
pub(super) fn diagnostic(error: &ProcessorError) -> String {
let mut text = format!("{}({})", error.message(), error.kind().as_str());
if let Some(hint) = error.hint() {
text.push_str(";建议:");
text.push_str(hint);
}
text
}
/// 缺 Java 运行时的专用文案(比通用错误多给一条「去哪装」)。
pub(super) fn java_missing(requirement: &JavaRequirement, managed: &Path) -> String {
format!(
"跑 installer 需要 {},但一个可用的 Java 运行时都没找到;请到「Java」页安装托管 JDK,或把 JAVA_HOME 指向可用的 JDK(托管目录 {})",
requirement.describe(),
managed.display()
)
}
/// 依赖库没下全 → 列前三条失败明细。
pub(super) fn libraries_failed(report: &InstallReport) -> String {
let details: Vec<String> = report
.failures(3)
.iter()
.filter_map(|outcome| {
outcome
.failure
.as_ref()
.map(|failure| format!("{}:{}", outcome.name, failure.message))
})
.collect();
format!(
"installer 依赖库没下全({} 个失败,共 {} 个):{}",
report.failed(),
report.files.len(),
details.join(";")
)
}
/// 需要 installer 但元数据不全(缓存半截 / 换线路重解析过)。
fn metadata_missing(input: &StageInput<'_>) -> String {
format!(
"{} {} 需要跑 installer processors,但没拿到 install profile / installer 路径;重跑一次即可(元数据缓存可能不完整)",
input.kind.label(),
input.version_id
)
}
/// Java 的一行短标识(`Java 21 · managed`)。
fn java_label(java: &JavaRuntime) -> String {
match java.major {
Some(major) => format!("Java {major} · {}", java.origin.as_str()),
None => format!("Java 主版本未知 · {}", java.origin.as_str()),
}
}
/// 版本 id → 可做目录名的片段(`+` 与路径分隔符都换掉,与 CLI 同规则)。
fn safe_name(raw: &str) -> String {
raw.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') {
ch
} else {
'_'
}
})
.collect::<String>()
.trim_end_matches('.')
.to_string()
}
#[cfg(test)]
#[path = "install_processors_fixture.rs"]
mod fixture;
#[cfg(test)]
#[path = "install_processors_tests.rs"]
mod tests;
@@ -0,0 +1,272 @@
//! installer processors 段的单测夹具(拆文件守 400 行上限)。
//!
//! 不需要网络、不需要 JDK、不真的起 java:`Harness` 给一个临时数据根 + 进度通道,
//! `Fake` 回放预置的下载/Java/执行结果并记录被调顺序与组出来的命令行。
use std::cell::RefCell;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::Receiver;
use std::sync::{Arc, Mutex};
use unlucky_core::addon::loader::forge::{InstallProfile, Processor};
use unlucky_core::addon::loader::processors::{
ProcessorError, ProcessorPlan, ProcessorRun, StepOutcome, StepSink, StepStatus,
};
use unlucky_core::addon::loader::LoaderKind as CoreLoaderKind;
use unlucky_core::download::{DownloadEngine, DownloadErrorKind};
use unlucky_core::game::install::{
FileFailure, FileOutcome, FileStatus, InstallReport, PlanFile, ProgressSink,
};
use unlucky_core::game::java::{JavaOrigin, JavaRequirement, JavaRuntime};
use super::{Backend, Reporter, StageInput};
use crate::dto::{CreateRequest, LoaderKind};
use crate::host::env::HostEnv;
use crate::host::install_journal::Journal;
use crate::host::job::{channel, HostTask, JobSender};
use crate::host::meter::Meter;
static SEQ: AtomicUsize = AtomicUsize::new(0);
/// 一步的计划视图(序号 / 坐标 / 参数 / classpath 条数)。
pub(super) type StepView = (usize, String, Vec<String>, usize);
/// 一段 cases 的公共环境:临时数据根 + 引擎 + 进度通道。
pub(super) struct Harness {
dir: PathBuf,
pub(super) env: HostEnv,
pub(super) engine: DownloadEngine,
tx: JobSender,
rx: Receiver<HostTask>,
pub(super) meter: Arc<Mutex<Meter>>,
pub(super) journal: Journal,
pub(super) cancel: Arc<AtomicBool>,
}
impl Harness {
pub(super) fn new() -> Self {
let seq = SEQ.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("ull-proc-{}-{seq}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
let engine = crate::host::engine::engine(&env);
let request = CreateRequest {
version: "1.20.1".to_string(),
loader: LoaderKind::Forge,
loader_version: "47.2.0".to_string(),
name: "proc".to_string(),
};
let (tx, rx) = channel();
Self {
journal: Journal::open(&env),
meter: Arc::new(Mutex::new(Meter::new(&request))),
cancel: Arc::new(AtomicBool::new(false)),
dir,
env,
engine,
tx,
rx,
}
}
pub(super) fn reporter(&self) -> Reporter<'_> {
Reporter {
tx: &self.tx,
meter: &self.meter,
journal: &self.journal,
}
}
/// 进度面板收到的所有文案。
pub(super) fn messages(&self) -> Vec<String> {
self.rx
.try_iter()
.filter_map(|task| match task {
HostTask::Install(progress) => Some(progress.message.clone()),
_ => None,
})
.collect()
}
/// 装日志全文。
pub(super) fn log(&self) -> String {
std::fs::read_to_string(self.journal.path()).unwrap_or_default()
}
}
impl Drop for Harness {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.dir).ok();
}
}
/// 注入用的假后端:回放预置结果 + 记录调用顺序与被组出来的命令行。
pub(super) struct Fake {
pub(super) libraries_ok: bool,
pub(super) java: Option<JavaRuntime>,
pub(super) run_error: Option<ProcessorError>,
calls: RefCell<Vec<&'static str>>,
steps: RefCell<Vec<StepView>>,
}
impl Fake {
pub(super) fn new() -> Self {
Self {
libraries_ok: true,
java: Some(java()),
run_error: None,
calls: RefCell::new(Vec::new()),
steps: RefCell::new(Vec::new()),
}
}
pub(super) fn calls(&self) -> Vec<&'static str> {
self.calls.borrow().clone()
}
/// 被组出来的每步(序号 / 坐标 / 参数 / classpath 条数)。
pub(super) fn steps(&self) -> Vec<StepView> {
self.steps.borrow().clone()
}
}
impl Backend for Fake {
fn download_libraries(
&self,
_engine: &DownloadEngine,
files: &[PlanFile],
_sink: Option<ProgressSink>,
) -> InstallReport {
self.calls.borrow_mut().push("libraries");
let ok = self.libraries_ok;
let outcomes = files
.iter()
.map(|file| FileOutcome {
role: file.role,
name: file.name.clone(),
dest: file.dest.clone(),
status: if ok {
FileStatus::Skipped
} else {
FileStatus::Failed
},
bytes: 0,
source: None,
failure: (!ok).then(|| FileFailure {
kind: DownloadErrorKind::Network,
message: "连接超时".to_string(),
}),
})
.collect();
InstallReport {
files: outcomes,
version_json: None,
warnings: Vec::new(),
}
}
fn select_java(&self, _managed: &Path, _requirement: &JavaRequirement) -> Option<JavaRuntime> {
self.calls.borrow_mut().push("java");
self.java.clone()
}
fn run(
&self,
plan: &ProcessorPlan,
java: &JavaRuntime,
sink: Option<StepSink>,
) -> Result<ProcessorRun, ProcessorError> {
self.calls.borrow_mut().push("run");
*self.steps.borrow_mut() = plan
.steps
.iter()
.map(|step| {
(
step.index,
step.label.clone(),
step.args.clone(),
step.classpath.len(),
)
})
.collect();
if let Some(error) = &self.run_error {
return Err(error.clone());
}
let outcomes: Vec<StepOutcome> = plan
.steps
.iter()
.map(|step| StepOutcome {
index: step.index,
label: step.label.clone(),
status: StepStatus::Executed,
outputs: step.outputs.len(),
elapsed_ms: 12,
stdout_tail: None,
stderr_tail: None,
})
.collect();
if let Some(sink) = &sink {
for outcome in &outcomes {
sink(outcome);
}
}
Ok(ProcessorRun {
java: java.clone(),
steps: outcomes,
warnings: Vec::new(),
})
}
}
/// 一个主版本已知的假 Java。
pub(super) fn java() -> JavaRuntime {
JavaRuntime {
binary: PathBuf::from("java"),
origin: JavaOrigin::Managed,
version: Some("21.0.4".to_string()),
major: Some(21),
bits: 64,
vendor: Some("temurin".to_string()),
}
}
/// 一个最小可用的 install profile:一个 client 侧 processor、两个字面量参数。
pub(super) fn profile() -> InstallProfile {
let mut profile = InstallProfile::default();
profile.processors.push(Processor {
sides: vec!["client".to_string()],
jar: Some("net.minecraftforge:installertools:1.3.0".to_string()),
classpath: vec!["net.minecraftforge:jarsplitter:1.1.4".to_string()],
args: vec!["--task".to_string(), "'MCP_DATA'".to_string()],
..Processor::default()
});
profile
}
pub(super) fn input<'a>(profile: &'a InstallProfile, installer: &'a Path) -> StageInput<'a> {
StageInput {
kind: CoreLoaderKind::Forge,
mc_version: "1.20.1",
version_id: "1.20.1-forge-47.2.0",
profile: Some(profile),
installer: Some(installer),
installed: false,
}
}
pub(super) fn installer() -> PathBuf {
PathBuf::from("cache/meta/forge-installer.jar")
}
pub(super) fn outcome(status: StepStatus) -> StepOutcome {
StepOutcome {
index: 3,
label: "net.minecraftforge:installertools:1.3.0".to_string(),
status,
outputs: 2,
elapsed_ms: 1234,
stdout_tail: None,
stderr_tail: None,
}
}
@@ -0,0 +1,213 @@
//! host/install_processors.rs 的单测(拆文件守 400 行上限)。
//!
//! 夹具在 [`super::fixture`]:这里只断言「计划怎么组」「找不到 Java 怎么说」
//! 「哪一步挂了怎么报」「进度文案长什么样」。
use std::path::PathBuf;
use unlucky_core::addon::loader::forge::InstallProfile;
use unlucky_core::addon::loader::processors::{ProcessorError, StepStatus};
use unlucky_core::addon::loader::LoaderKind as CoreLoaderKind;
use unlucky_core::download::DownloadErrorKind;
use unlucky_core::game::install::{FileFailure, FileOutcome, FileRole, FileStatus, InstallReport};
use unlucky_core::game::java::JavaRequirement;
use super::fixture::{input, installer, outcome, profile, Fake, Harness};
use super::{diagnostic, java_missing, libraries_failed, stage, step_line, StageInput};
#[test]
fn step_line_names_the_step_and_marks_the_outcome() {
let executed = step_line(&outcome(StepStatus::Executed), 9);
assert_eq!(
executed,
"installer 处理器 3/9:net.minecraftforge:installertools:1.3.0(1234 ms)"
);
let skipped = step_line(&outcome(StepStatus::Skipped), 9);
assert!(skipped.contains("产物已就绪"), "{skipped}");
}
#[test]
fn diagnostic_keeps_the_stable_code_and_the_hint() {
let failed = ProcessorError::failed("net.minecraftforge:installertools:1.3.0", "1", "boom")
.with_hint("确认 Java 版本与磁盘权限");
let text = diagnostic(&failed);
assert!(text.contains("processor_failed"), "{text}");
assert!(text.contains("退出码 1"), "{text}");
assert!(text.contains("boom"), "stderr 尾部要留:{text}");
assert!(text.contains("建议:确认 Java 版本"), "{text}");
let plan = diagnostic(&ProcessorError::plan("install profile 缺 `jar`"));
assert!(plan.contains("processor_plan"), "{plan}");
}
#[test]
fn java_missing_points_at_the_java_page() {
let managed = PathBuf::from("/tmp/.ULL/java");
let text = java_missing(&JavaRequirement::table(21, None), &managed);
assert!(text.contains("Java 21+"), "{text}");
assert!(text.contains("Java」页"), "{text}");
assert!(text.contains("JAVA_HOME"), "{text}");
assert!(text.contains(&managed.display().to_string()), "{text}");
}
#[test]
fn libraries_failed_lists_at_most_three() {
let files: Vec<FileOutcome> = (0..4)
.map(|index| FileOutcome {
role: FileRole::Library,
name: format!("lib{index}.jar"),
dest: PathBuf::from(format!("lib{index}.jar")),
status: FileStatus::Failed,
bytes: 0,
source: None,
failure: Some(FileFailure {
kind: DownloadErrorKind::Network,
message: "连接超时".to_string(),
}),
})
.collect();
let report = InstallReport {
files,
version_json: None,
warnings: Vec::new(),
};
let text = libraries_failed(&report);
assert!(text.contains("4 个失败,共 4 个"), "{text}");
assert!(text.contains("lib0.jar"), "{text}");
assert!(!text.contains("lib3.jar"), "最多列三条:{text}");
}
#[test]
fn stage_skips_when_the_version_json_is_already_there() {
let h = Harness::new();
let installer = installer();
let profile = profile();
let mut input = input(&profile, &installer);
input.installed = true;
let fake = Fake::new();
let result = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel);
assert!(matches!(result, Ok(None)));
assert!(fake.calls().is_empty(), "已装完就不该再动网络/Java");
assert!(h.log().contains("跳过 installer processors"), "{}", h.log());
}
#[test]
fn stage_skips_an_empty_processor_list() {
let h = Harness::new();
let installer = installer();
let profile = InstallProfile::default();
let input = input(&profile, &installer);
let fake = Fake::new();
let result = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel);
assert!(matches!(result, Ok(None)), "没有 processor 就没这段");
assert!(fake.calls().is_empty());
}
#[test]
fn stage_reports_missing_installer_metadata() {
let h = Harness::new();
let input = StageInput {
kind: CoreLoaderKind::Forge,
mc_version: "1.20.1",
version_id: "1.20.1-forge-47.2.0",
profile: None,
installer: None,
installed: false,
};
let fake = Fake::new();
let err = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel).unwrap_err();
assert!(err.contains("install profile"), "{err}");
assert!(h.log().contains("失败:"), "{}", h.log());
}
#[test]
fn stage_builds_the_command_line_from_the_install_profile() {
let h = Harness::new();
let installer = installer();
let profile = profile();
let input = input(&profile, &installer);
let fake = Fake::new();
let run = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel)
.expect("stage 成功")
.expect("应当真的跑了 processors");
assert_eq!(run.executed(), 1);
let steps = fake.steps();
assert_eq!(steps.len(), 1);
let (index, label, args, classpath) = &steps[0];
assert_eq!(*index, 1);
assert_eq!(label, "net.minecraftforge:installertools:1.3.0");
assert_eq!(args, &vec!["--task".to_string(), "MCP_DATA".to_string()]);
assert_eq!(*classpath, 1, "jar 自己不在 -cp 里(拼命令行时才补)");
assert_eq!(fake.calls(), vec!["libraries", "java", "run"]);
let messages = h.messages();
assert!(
messages.iter().any(|m| m.contains("installer 处理器 1/1")),
"面板要看到当前处理器:{messages:?}"
);
assert!(
messages
.last()
.is_some_and(|m| m.contains("正在写入版本清单")),
"{messages:?}"
);
assert!(
h.log().contains("processors 完成:执行 1 步"),
"{}",
h.log()
);
}
#[test]
fn stage_fails_loudly_without_a_usable_java() {
let h = Harness::new();
let installer = installer();
let profile = profile();
let input = input(&profile, &installer);
let mut fake = Fake::new();
fake.java = None;
let err = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel).unwrap_err();
assert!(err.contains("Java"), "{err}");
assert!(err.contains("托管目录"), "{err}");
assert_eq!(
fake.calls(),
vec!["libraries", "java"],
"没 Java 就不该起进程"
);
assert!(h.log().contains("失败:"), "{}", h.log());
}
#[test]
fn stage_fails_loudly_when_a_processor_exits_nonzero() {
let h = Harness::new();
let installer = installer();
let profile = profile();
let input = input(&profile, &installer);
let mut fake = Fake::new();
fake.run_error = Some(ProcessorError::failed(
"net.minecraftforge:installertools:1.3.0",
"1",
"java.lang.NoSuchMethodError",
));
let err = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel).unwrap_err();
assert!(err.contains("processor_failed"), "{err}");
assert!(err.contains("NoSuchMethodError"), "{err}");
let log = h.log();
assert!(log.contains("失败:"), "{log}");
assert!(log.contains("NoSuchMethodError"), "日志要能回溯:{log}");
}
#[test]
fn stage_reports_incomplete_library_downloads() {
let h = Harness::new();
let installer = installer();
let profile = profile();
let input = input(&profile, &installer);
let mut fake = Fake::new();
fake.libraries_ok = false;
let err = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel).unwrap_err();
assert!(err.contains("依赖库没下全"), "{err}");
assert!(err.contains("连接超时"), "{err}");
assert_eq!(fake.calls(), vec!["libraries"], "库没下全就别去选 Java");
let progress = h.meter.lock().expect("进度锁").progress.clone();
assert!(progress.files_total >= 2, "依赖库要计入文件总数");
}
+218
View File
@@ -0,0 +1,218 @@
//! host/install.rs 的单测(拆文件守 400 行上限)。
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use unlucky_core::game::install::{FileOutcome, FileRole, FileStatus};
use super::{loader_label, run, supported, unsupported_reason, Job};
use crate::dto::{CreateRequest, LoaderKind};
use crate::host::env::HostEnv;
use crate::host::install_journal::Journal;
use crate::host::job::{channel, JobSender};
use crate::host::meter::Meter;
static SEQ: AtomicUsize = AtomicUsize::new(0);
/// 一个装在临时根里的任务上下文(只走「联网前就返回」的用例)。
struct Fixture {
dir: PathBuf,
env: HostEnv,
tx: JobSender,
meter: Arc<Mutex<Meter>>,
cancel: Arc<AtomicBool>,
journal: Journal,
}
impl Fixture {
fn new(request: &CreateRequest) -> Self {
let seq = SEQ.fetch_add(1, Ordering::SeqCst);
let dir =
std::env::temp_dir().join(format!("ull-host-install-{}-{seq}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
let journal = Journal::open(&env);
Self {
dir,
env,
tx: channel().0,
meter: Arc::new(Mutex::new(Meter::new(request))),
cancel: Arc::new(AtomicBool::new(false)),
journal,
}
}
fn job(&self) -> Job<'_> {
Job {
env: &self.env,
tx: &self.tx,
meter: &self.meter,
cancel: &self.cancel,
journal: &self.journal,
}
}
}
impl Drop for Fixture {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.dir).ok();
}
}
fn request(loader: LoaderKind, version: &str) -> CreateRequest {
CreateRequest {
version: version.to_string(),
loader,
loader_version: String::new(),
name: String::new(),
}
}
/// 一个「刚下完」的文件结局(记账用)。
fn downloaded(bytes: u64) -> FileOutcome {
FileOutcome {
role: FileRole::Library,
name: "lib.jar".to_string(),
dest: PathBuf::from("lib.jar"),
status: FileStatus::Downloaded,
bytes,
source: None,
failure: None,
}
}
#[test]
fn progress_is_pre_accounted_across_segments_so_the_bar_never_snaps_to_full() {
// M45 r1 P2-2 的回归:处理器依赖库下完那一刻,面板曾显示 36/36 满格 + 3.4MB/10MB,
// 之后加载器库的 29 个文件才被计入 → 进度条从 100% 回落到 55% 再涨回去。
let request = request(LoaderKind::Forge, "1.20.1");
let mut meter = Meter::new(&request);
// 原版段:30 个文件全下完(install_vanilla 对总量是赋值,不是累加)
meter.progress.files_total = 30;
meter.progress.bytes_total = 3_000_000;
for _ in 0..30 {
meter.note(&downloaded(100_000));
}
assert!((meter.progress.ratio() - 1.0).abs() < f32::EPSILON);
// install_loader 的顺序:加载器库与处理器依赖库都在动手前一次预记账
meter.expect(29, 10_000_000);
meter.expect(36, 4_000_000);
assert_eq!(meter.progress.files_total, 95, "两段之和一次到位");
assert_eq!(
meter.progress.bytes_total, 17_000_000,
"字节是相加不是取最大"
);
assert!(
meter.progress.ratio() < 0.7,
"预记账后不能还停在满格:{}",
meter.progress.ratio()
);
// 处理器依赖库 36 个下完 → 仍没满格(加载器库还在后面)
for _ in 0..36 {
meter.note(&downloaded(110_000));
}
let ratio = meter.progress.ratio();
assert!(
(0.6..1.0).contains(&ratio),
"回落到中间态而不是满格:{ratio}"
);
assert!(
meter.progress.bytes_done < meter.progress.bytes_total,
"字节总量要覆盖已下载量:{}/{}",
meter.progress.bytes_done,
meter.progress.bytes_total
);
// 加载器库也下完 → 这时候才该是满格
for _ in 0..29 {
meter.note(&downloaded(340_000));
}
assert!((meter.progress.ratio() - 1.0).abs() < f32::EPSILON);
}
#[test]
fn loader_support_matrix_is_explicit() {
// M45 语义变更:Forge/NeoForge 从「GUI 显式拒装」扩进受支持集合(走 installer processors)。
for kind in [
LoaderKind::Vanilla,
LoaderKind::Fabric,
LoaderKind::Quilt,
LoaderKind::Forge,
LoaderKind::NeoForge,
] {
assert!(supported(kind), "{} 应当受支持", kind.label());
}
// 这两家 core 侧还没有安装管线,仍然显式拒装并说清去哪装。
for kind in [LoaderKind::OptiFine, LoaderKind::LiteLoader] {
assert!(!supported(kind), "{} 不该被当成可装", kind.label());
let reason = unsupported_reason(kind);
assert!(reason.contains(kind.label()), "{reason}");
assert!(reason.contains("还没有接入"), "{reason}");
}
}
#[test]
fn forge_install_is_no_longer_refused_at_the_door() {
// 旧文案是把用户赶去终端跑 `ull install`;M45 之后 GUI 与 CLI 同一条路径。
assert!(supported(LoaderKind::Forge));
assert!(supported(LoaderKind::NeoForge));
assert_eq!(loader_label(LoaderKind::Forge, "47.2.0"), "Forge 47.2.0");
assert_eq!(
loader_label(LoaderKind::NeoForge, "21.1.80"),
"NeoForge 21.1.80"
);
}
#[test]
fn meter_counts_files_and_skips_skipped_bytes() {
let request = CreateRequest {
version: "1.20.4".to_string(),
loader: LoaderKind::Fabric,
loader_version: "0.16.5".to_string(),
name: "生存".to_string(),
};
let mut meter = Meter::new(&request);
assert_eq!(meter.progress.loader, "Fabric 0.16.5");
assert_eq!(meter.progress.instance, "生存");
meter.progress.files_total = 2;
let mut outcome = FileOutcome {
role: FileRole::Library,
name: "a.jar".to_string(),
dest: PathBuf::from("a.jar"),
status: FileStatus::Downloaded,
bytes: 100,
source: Some("bmclapi".to_string()),
failure: None,
};
meter.note(&outcome);
assert_eq!(meter.progress.files_done, 1);
assert_eq!(meter.progress.bytes_done, 100);
outcome.status = FileStatus::Skipped;
outcome.bytes = 500;
meter.note(&outcome);
assert_eq!(meter.progress.files_done, 2);
assert_eq!(meter.progress.bytes_done, 100, "跳过的文件不计入已下载字节");
assert!((meter.progress.ratio() - 1.0).abs() < f32::EPSILON);
}
#[test]
fn empty_version_is_rejected_before_network() {
let request = request(LoaderKind::Vanilla, "");
let fixture = Fixture::new(&request);
let err = run(&fixture.job(), &request).unwrap_err();
assert!(err.contains("没有选择版本"), "{err}");
}
#[test]
fn unsupported_loader_is_rejected_before_network() {
let request = request(LoaderKind::OptiFine, "1.20.1");
let fixture = Fixture::new(&request);
let err = run(&fixture.job(), &request).unwrap_err();
assert!(err.contains("还没有接入"), "{err}");
}
+304
View File
@@ -0,0 +1,304 @@
//! Java 页:四家发行版 + 回退链可视化 + 本地运行时 + 托管 JDK 安装。
//!
//! 回退链的两态:
//! - **计划链**(还没装):按生效顺序铺一排「待尝试」,让用户先看清会按什么顺序找;
//! - **真实链**(装过一次/装失败):`core::game::java::ChainLog` 的每跳结局与原因。
//!
//! 安装走 `java::install`(core 自己跑回退链),host 只负责把 `ChainLog` 翻成 DTO。
use unlucky_core::game::java::probe::JavaOrigin;
use unlucky_core::game::java::source::ChainLog;
use unlucky_core::game::java::{self, JdkVendor};
use crate::dto::{JavaChainOutcome, JavaChainStep, JavaPageView, JavaRuntimeView, JavaVendorView};
use super::env::HostEnv;
use super::job::{spawn, HostTask, JobSender};
/// 托管 JDK 的默认目标大版本(D13 基准 JDK17)。
pub const DEFAULT_JDK_MAJOR: u32 = 17;
/// 该装哪个大版本:取第一个实例版本的要求,没有实例就给 17。
pub fn target_major(env: &HostEnv) -> u32 {
let profiles = super::game::profiles(env);
profiles
.profiles
.first()
.map(|profile| {
java::requirement_for_version(&env.game, &profile.version_id)
.minimum
.max(8)
})
.unwrap_or(DEFAULT_JDK_MAJOR)
}
/// 四家发行版的展示行(镜像与吞吐口径与 CLI `ull java vendors` 一致)。
pub fn vendors(env: &HostEnv, installed_vendors: &[String]) -> Vec<JavaVendorView> {
let domestic = super::engine::domestic(env);
let order = java::vendor::order(&super::engine::java_priority(env), domestic);
let table = unlucky_core::download::MirrorTable::builtin();
order
.iter()
.enumerate()
.map(|(index, vendor)| {
let id = vendor.id();
JavaVendorView {
id: id.to_string(),
display: vendor.display().to_string(),
rank: u32::try_from(index + 1).unwrap_or(u32::MAX),
majors: vendor
.hint_majors()
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join("/"),
sources: sources_of(&table, *vendor),
installed: installed_vendors.iter().any(|known| known == id),
}
})
.collect()
}
/// 一家的源清单(与 CLI 展示同形)。
fn sources_of(table: &unlucky_core::download::MirrorTable, vendor: JdkVendor) -> Vec<String> {
match vendor {
JdkVendor::Temurin => table
.adoptium
.iter()
.filter(|mirror| mirror.enabled)
.map(|mirror| mirror.name.clone())
.chain(std::iter::once(
"GitHub 官方(国内套 gh-proxy)".to_string(),
))
.collect(),
JdkVendor::Oracle => table
.oracle_mirrors()
.map(|mirror| format!("{} {}", mirror.name, mirror.base()))
.collect(),
JdkVendor::Microsoft => vec!["aka.ms → VS CDN(无国内镜像)".to_string()],
JdkVendor::Zulu => table
.zulu()
.map(|zulu| vec![format!("{}({})", zulu.api, zulu.measured_speed)])
.unwrap_or_default(),
}
}
/// 本地运行时(托管 / JAVA_HOME / PATH / 系统安装位置,同一路径只留优先级高者)。
pub fn runtimes(env: &HostEnv) -> Vec<JavaRuntimeView> {
let roots = super::engine::java_scan_roots(env);
let candidates = java::discover::dedupe(java::candidates_in_roots(&roots));
candidates
.iter()
.map(|runtime| JavaRuntimeView {
binary: runtime.binary.display().to_string(),
version: runtime
.version
.clone()
.unwrap_or_else(|| "版本未知".to_string()),
major: runtime.major.unwrap_or(java::DEFAULT_MAJOR),
vendor: runtime.vendor.clone().unwrap_or_else(|| "—".to_string()),
origin: runtime.origin.category().to_string(),
})
.collect()
}
/// 托管目录(展示「装到哪」)。
pub fn managed_roots(env: &HostEnv) -> Vec<String> {
super::engine::java_scan_roots(env)
.iter()
.map(|root| root.display().to_string())
.collect()
}
/// 计划链(按生效顺序铺「待尝试」)。
pub fn planned_chain(env: &HostEnv) -> Vec<JavaChainStep> {
let domestic = super::engine::domestic(env);
let order = java::vendor::order(&super::engine::java_priority(env), domestic);
order
.iter()
.map(|vendor| JavaChainStep {
vendor: vendor.id().to_string(),
outcome: JavaChainOutcome::Pending,
detail: String::new(),
})
.collect()
}
/// 真实链(`ChainLog` → DTO)。
pub fn chain_steps(log: &ChainLog) -> Vec<JavaChainStep> {
log.attempts()
.iter()
.map(|attempt| JavaChainStep {
vendor: attempt.vendor.id().to_string(),
outcome: if attempt.ok {
JavaChainOutcome::Hit
} else if attempt.detail.contains("不支持") {
JavaChainOutcome::Skip
} else {
JavaChainOutcome::Failed
},
detail: attempt.detail.clone(),
})
.collect()
}
/// 整页快照。
pub fn page(env: &HostEnv, chain: Vec<JavaChainStep>, busy: bool, message: &str) -> JavaPageView {
let runtimes = runtimes(env);
let installed_vendors: Vec<String> = runtimes
.iter()
.filter(|runtime| runtime.origin == JavaOrigin::Managed.category())
.map(|runtime| runtime.vendor.clone())
.collect();
let vendor_rows = vendors(env, &installed_vendors);
let domestic = super::engine::domestic(env);
let priority_set = !super::engine::java_priority(env).is_empty();
let chain = if chain.is_empty() {
planned_chain(env)
} else {
chain
};
JavaPageView {
region: if domestic { "domestic" } else { "global" }.to_string(),
priority_source: if priority_set {
"java.vendorPriority"
} else {
"region_default"
}
.to_string(),
order_text: vendor_rows
.iter()
.map(|vendor| vendor.display.clone())
.collect::<Vec<_>>()
.join(" → "),
vendors: vendor_rows,
chain,
runtimes,
managed_roots: managed_roots(env),
busy,
message: message.to_string(),
}
}
/// 后台装一个托管 JDK(`vendor` 为空 = 按回退链自己选)。
pub fn spawn_install(env: HostEnv, tx: JobSender, vendor: Option<JdkVendor>) {
spawn("java-install", move || {
let major = target_major(&env);
let mut busy = page(&env, Vec::new(), true, &format!("正在安装 Java {major}…"));
busy.busy = true;
let _ = tx.send(HostTask::Java(Box::new(busy)));
let engine = super::engine::engine(&env);
let root = match super::engine::java_install_root(&env) {
Ok(root) => root,
Err(reason) => {
let _ = tx.send(HostTask::Java(Box::new(page(
&env,
Vec::new(),
false,
&format!("解析 Java 安装位置失败:{reason}"),
))));
return;
}
};
let request = match vendor {
Some(vendor) => java::InstallRequest::with_vendor(major, vendor)
.with_priority(super::engine::java_priority(&env)),
None => {
java::InstallRequest::new(major).with_priority(super::engine::java_priority(&env))
}
};
let result =
unlucky_core::download::blocking::block_on(java::install(&engine, &root, request));
match result {
Ok(installed) => {
let message = if installed.reused() {
format!(
"Java {}({})已就绪:{}",
installed.major,
installed.vendor.id(),
installed.root.display()
)
} else {
format!(
"已安装 Java {}({})→ {} · {} 个文件",
installed.major,
installed.vendor.display(),
installed.root.display(),
installed.files
)
};
let chain = chain_steps(&installed.chain);
let _ = tx.send(HostTask::Java(Box::new(page(&env, chain, false, &message))));
}
Err(error) => {
let _ = tx.send(HostTask::Java(Box::new(page(
&env,
Vec::new(),
false,
&format!("Java 安装失败:{error}"),
))));
}
}
});
}
/// 「安装一个 Java」的 UI 文案(下载页 Java 分组的按钮说明)。
pub fn install_hint(env: &HostEnv) -> String {
let major = target_major(env);
format!("按回退链取一个 Java {major}(托管目录,装完自动被启动链选中)")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn planned_chain_covers_four_vendors() {
let dir = std::env::temp_dir().join(format!("ull-host-java-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
std::fs::create_dir_all(env.layout.config()).expect("config");
let chain = planned_chain(&env);
assert_eq!(chain.len(), 4);
assert!(chain
.iter()
.all(|step| step.outcome == JavaChainOutcome::Pending));
assert_eq!(chain[0].vendor, "temurin", "国内/国外默认都是 Temurin 第一");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn chain_log_maps_to_steps() {
let mut log = ChainLog::new();
log.skip(
JdkVendor::Microsoft,
"不支持 Java 8(microsoft 只发 11/17/21/25)",
);
log.hit(JdkVendor::Temurin, "命中 Adoptium 17");
let steps = chain_steps(&log);
assert_eq!(steps.len(), 2);
assert_eq!(steps[0].outcome, JavaChainOutcome::Skip);
assert_eq!(steps[1].outcome, JavaChainOutcome::Hit);
}
#[test]
fn page_reports_region_and_priority_source() {
let dir = std::env::temp_dir().join(format!("ull-host-java-page-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
std::fs::create_dir_all(env.layout.config()).expect("config");
std::fs::write(
env.launcher_config(),
r#"{"java":{"vendorPriority":["zulu"]}}"#,
)
.expect("写配置");
let page = page(&env, Vec::new(), false, "");
assert_eq!(page.priority_source, "java.vendorPriority");
assert_eq!(page.vendors[0].id, "zulu");
assert_eq!(page.vendors[0].rank, 1);
assert!(page.order_text.contains("Azul Zulu"));
assert_eq!(page.vendors.len(), 4);
std::fs::remove_dir_all(&dir).ok();
}
}
+136
View File
@@ -0,0 +1,136 @@
//! 后台任务的回流消息:`HostTask` 是唯一「线程 → host」的载荷。
//!
//! 线程只做 core 调用(网络/磁盘),产出的都是 `crate::dto` 的 DTO;
//! host 的 `tick` 把它们翻成 [`crate::dto::CoreEvent`]。
//! 这样「跨线程传的是什么」在一处看全,也不会出现半成品领域类型漏回 UI。
use std::sync::mpsc::{Receiver, Sender};
use crate::dto::{
AccountView, DeviceLoginView, DiagnosisReport, InstallProgressView, InstanceId,
InstanceRuntime, JavaPageView, LaunchFinished, LiveLogBatch, LogLine, LogOutcome, LogSession,
NoticeView, SessionId, VersionSummary,
};
use crate::toast::Toast;
/// 后台任务 → host 的消息。
#[derive(Debug, Clone, PartialEq)]
pub enum HostTask {
/// 版本清单(下载页)
Versions(Result<Vec<VersionSummary>, String>),
/// 安装进度(高频,host 只做聚合与转发)
Install(Box<InstallProgressView>),
/// 安装收尾(成功带实例名,失败带文案)
InstallDone {
/// 最终进度快照
progress: Box<InstallProgressView>,
/// 成功时为 `Ok(实例名)`
result: Result<String, String>,
},
/// 账户列表
Accounts(Vec<AccountView>),
/// 设备码登录状态
Login(Box<DeviceLoginView>),
/// Java 页(发行版/回退链/运行时)
Java(Box<JavaPageView>),
/// 日志页快照(会话列表 + 选中会话的行 + 诊断)
Logs {
/// 会话列表
sessions: Vec<LogSession>,
/// 选中会话的行
lines: Vec<LogLine>,
/// 诊断结论
diagnosis: Option<DiagnosisReport>,
},
/// 实时日志增量
Tail(Box<LiveLogBatch>),
/// 启动开始(pid + 会话 id)
LaunchStarted {
/// 实例 id
id: InstanceId,
/// 会话 id
session_id: SessionId,
/// 进程号
pid: u32,
},
/// 启动阶段推进(磁贴进度环)
Runtime {
/// 实例 id
id: InstanceId,
/// 阶段状态
runtime: InstanceRuntime,
},
/// 启动收尾
LaunchEnd(Box<LaunchFinished>),
/// 崩溃日志导入的分析结论
Crash(Box<DiagnosisReport>),
/// 横幅通知(隔离切换提示等)
Notice(Box<NoticeView>),
/// Toast
Toast(Box<Toast>),
/// 会话列表刷新(只换左栏)
Sessions(Vec<LogSession>),
}
/// 后台线程用的发送端(`Sender` 克隆即可)。
pub type JobSender = Sender<HostTask>;
/// host 侧的接收端。
pub type JobReceiver = Receiver<HostTask>;
/// 建一对通道。
pub fn channel() -> (JobSender, JobReceiver) {
std::sync::mpsc::channel()
}
/// 起一个带名字的后台线程(名字只进 panic 信息,方便定位)。
pub fn spawn(name: &str, job: impl FnOnce() + Send + 'static) -> std::thread::JoinHandle<()> {
std::thread::Builder::new()
.name(format!("ull-host-{name}"))
.spawn(job)
.unwrap_or_else(|error| panic!("起不来后台线程 {name}: {error}"))
}
/// 人读一行「启动结束」。
pub fn launch_summary(outcome: LogOutcome, exit_code: Option<i32>) -> String {
match outcome {
LogOutcome::Ok => "正常退出".to_string(),
LogOutcome::Crashed => match exit_code {
Some(code) => format!("崩溃(exit {code})"),
None => "崩溃".to_string(),
},
LogOutcome::SilentFail => "静默失败(exit 0 但游戏没起来)".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn channel_carries_tasks_across_threads() {
let (tx, rx) = channel();
let handle = spawn("test", move || {
tx.send(HostTask::Toast(Box::new(Toast {
id: 1,
kind: crate::toast::ToastKind::Info,
text: "hi".to_string(),
remaining_ms: 0,
})))
.expect("发送");
});
handle.join().expect("线程结束");
let task = rx.recv().expect("收到");
assert!(matches!(task, HostTask::Toast(toast) if toast.text == "hi"));
}
#[test]
fn launch_summary_names_the_outcome() {
assert_eq!(launch_summary(LogOutcome::Ok, Some(0)), "正常退出");
assert_eq!(
launch_summary(LogOutcome::Crashed, Some(1)),
"崩溃(exit 1)"
);
assert!(launch_summary(LogOutcome::SilentFail, Some(0)).contains("静默"));
}
}
+353
View File
@@ -0,0 +1,353 @@
//! 启动全链:实例 → `LaunchRequest` → 子进程 → JSONL 事件流(GUI 版)。
//!
//! 与 CLI `ull launch` 同源同序(`core` 的 `game::resolve` / `arguments.expand` /
//! `launch::adapter::build_request` / `Launcher::spawn`),差异只有两处:
//! - **不阻塞 UI**:装配与等待都在后台线程里,进度/日志经 [`super::job::HostTask`] 回流;
//! - **身份来自 GUI 选中的账户**(`AccountStore` → `AuthPlaceholders`),凭据只在
//! 本模块内被读一次,绝不进 DTO。
//!
//! 停止:`kill_tree(pid)`(core 的三段式进程树回收),`finish()` 会照常收尾写索引。
use std::path::PathBuf;
use unlucky_core::game::arguments::Environment;
use unlucky_core::game::install::instance::read_instance_config;
use unlucky_core::game::isolation::{derive_game_dir, observe_mode, IsolationMode};
use unlucky_core::game::{self, VersionJson};
use unlucky_core::launch::adapter::{
self, AdapterOptions, AdapterWarning, LibraryInput, SnapshotInput,
};
use unlucky_core::launch::request::{JavaSelection, LaunchRequest, MemoryOptions};
use unlucky_core::launch::time::{format_rfc3339_millis, now};
use unlucky_core::launch::tree_kill;
use unlucky_core::launch::{Launcher, LauncherConfig};
use unlucky_core::platform::paths::ProcessEnv;
use crate::dto::{
InstanceId, InstanceRuntime, LaunchFinished, LaunchStage, LoaderKind, LogOutcome, Progress,
};
use super::env::HostEnv;
use super::job::{launch_summary, HostTask, JobSender};
/// 启动器名(与 CLI `meta::LAUNCHER_NAME` 同值)。
pub const LAUNCHER_NAME: &str = "Unlucky Launcher";
/// 一次启动需要的实例信息。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LaunchTarget {
/// 实例 id
pub id: InstanceId,
/// 实例名
pub name: String,
/// 绑定的版本 id
pub version_id: String,
/// 账户 id(`None` = 离线占位)
pub account: Option<String>,
}
/// 选中并用起来的 Java(`summary` 给 UI 一行提示)。
pub struct JavaPick {
/// 交给 adapter 的选择
pub selection: JavaSelection,
/// 人读一行
pub summary: String,
}
/// 选一个能用的 Java:托管 > JAVA_HOME > PATH(`core::game::java` 的探测与优先级)。
pub fn pick_java(env: &HostEnv, version_id: &str) -> JavaPick {
let store = env.store();
let install_root = store
.doc()
.effective_str("java.installRoot")
.map(str::trim)
.filter(|text| !text.is_empty())
.map(PathBuf::from);
let roots = unlucky_core::game::java::location::scan_roots(
env.mode(),
&env.layout,
install_root.as_deref(),
env.platform,
&ProcessEnv,
);
let candidates = unlucky_core::game::java::candidates_in_roots(&roots);
let required = unlucky_core::game::java::requirement_for_version(&env.game, version_id);
let picked = unlucky_core::game::java::pick(&candidates, &required).cloned();
match picked {
Some(runtime) => {
let major = runtime
.major
.unwrap_or(unlucky_core::game::java::DEFAULT_MAJOR);
let summary = format!(
"Java {}({}{})",
runtime.version.clone().unwrap_or_else(|| major.to_string()),
runtime.origin.category(),
runtime
.vendor
.as_ref()
.map(|vendor| format!(" · {vendor}"))
.unwrap_or_default()
);
JavaPick {
selection: JavaSelection::new(runtime.binary, major, runtime.bits),
summary,
}
}
None => {
let binary = PathBuf::from(unlucky_core::game::java::java_binary_name());
let major = required.minimum;
JavaPick {
selection: JavaSelection::new(binary, major, 64),
summary: format!("PATH 上的 Java(未探测到版本,按 Java {major} 启动)"),
}
}
}
}
/// 装配 [`LaunchRequest`](与 CLI `snapshot::prepare` 同序)。
///
/// # Errors
///
/// 版本 JSON 缺失/非法、继承链断裂、隔离派生失败时返回用户可读文案。
pub fn build_request(
env: &HostEnv,
target: &LaunchTarget,
) -> Result<(LaunchRequest, Vec<String>), String> {
let version_id = target.version_id.clone();
let manifest_path = env.game.version_json(&version_id);
let text = std::fs::read_to_string(&manifest_path)
.map_err(|error| format!("读版本 JSON 失败 {}:{error}", manifest_path.display()))?;
let manifest =
VersionJson::from_json_str(&text).map_err(|error| format!("版本 JSON 非法:{error}"))?;
let source = |id: &str| -> Option<VersionJson> {
std::fs::read_to_string(env.game.version_json(id))
.ok()
.and_then(|text| VersionJson::from_json_str(&text).ok())
};
let resolved =
game::resolve(&source, &manifest).map_err(|error| format!("解析继承链失败:{error}"))?;
let environment = Environment::host();
let expanded = resolved
.arguments
.as_ref()
.map(|arguments| arguments.expand(&environment))
.unwrap_or_default();
let mut libraries: Vec<LibraryInput> = Vec::new();
for library in resolved.libraries.as_deref().unwrap_or_default() {
if !library.applies(&environment) || library.native_classifier(&environment).is_some() {
continue;
}
let Ok(coordinate) = library.coordinate() else {
continue;
};
let path = if library.is_local() {
env.game.version_library(&version_id, &coordinate)
} else {
env.game.library(&coordinate)
};
libraries.push(LibraryInput::new(path, library.name.clone()));
}
let natives = unlucky_core::launch::natives::collect(
&env.game,
&version_id,
resolved.libraries.as_deref().unwrap_or_default(),
&environment,
);
let game_dir = run_dir(env, target)?;
let jar_id = resolved.jar.clone().unwrap_or_else(|| version_id.clone());
let assets_root = env.game.assets_dir();
let assets_index = resolved
.assets
.clone()
.or_else(|| resolved.asset_index.as_ref().map(|index| index.id.clone()));
let snapshot = SnapshotInput {
version_id: &version_id,
version_name: Some(&target.name),
version_type: None,
game_version: None,
main_class: resolved.main_class.as_deref(),
jvm_arguments: &expanded.jvm,
game_arguments: &expanded.game,
legacy_arguments: resolved.minecraft_arguments.as_deref(),
libraries: &libraries,
natives: &natives,
primary_jar: env.game.version_jar(&jar_id),
game_dir,
assets_index: assets_index.as_deref(),
natives_dir: env.game.natives_dir(&version_id),
library_dir: env.game.libraries_dir(),
assets_root: assets_root.clone(),
game_assets: assets_root,
};
let java = pick_java(env, &version_id);
let (auth, identity_line) = super::identity::resolve(env, target.account.as_deref())?;
let memory = env
.store()
.effective("java.default_memory_mb")
.and_then(serde_json::Value::as_u64)
.and_then(|value| u32::try_from(value).ok());
let options = AdapterOptions {
java: java.selection,
memory: MemoryOptions {
min_mb: None,
max_mb: memory,
},
auth,
launcher_name: LAUNCHER_NAME.to_string(),
launcher_version: env!("CARGO_PKG_VERSION").to_string(),
language: "zh-CN".to_string(),
..AdapterOptions::strict()
};
let output = adapter::build_request(&snapshot, options);
let mut warnings: Vec<String> = output.warnings.iter().map(describe_warning).collect();
warnings.push(java.summary);
warnings.push(identity_line);
let mut request = output.request;
request.instance.id = target.id.clone();
request.instance.name = target.name.clone();
Ok((request, warnings))
}
/// 派生运行目录(D20 覆盖链:实例 `isolation` > 全局 `instances.mode`)。
fn run_dir(env: &HostEnv, target: &LaunchTarget) -> Result<PathBuf, String> {
let global = super::game::global_mode(env);
let config = read_instance_config(&env.layout, &target.id)
.map_err(|error| format!("读实例设置失败:{error}"))?;
let derivation = derive_game_dir(&env.game, &target.version_id, global, config.as_ref(), None)
.map_err(|error| format!("隔离目录派生失败:{error}"))?;
if let Some(mut config) = config {
let at = format_rfc3339_millis(now());
if observe_mode(&mut config, &env.game, &at, derivation.isolation).unwrap_or(false) {
let _ =
unlucky_core::game::install::instance::write_instance_config(&env.layout, &config);
}
}
Ok(derivation.game_dir)
}
/// 起一次启动(后台线程;进度/日志/收尾全部走 `HostTask`)。
pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
let id = target.id.clone();
let tx_progress = tx.clone();
super::job::spawn("launch", move || {
let _ = tx_progress.send(HostTask::Runtime {
id: id.clone(),
runtime: InstanceRuntime::Busy {
stage: LaunchStage::Launch,
progress: Progress { done: 0, total: 1 },
},
});
let (request, warnings) = match build_request(&env, &target) {
Ok(ok) => ok,
Err(reason) => {
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
id,
session: String::new(),
outcome: LogOutcome::Crashed,
summary: format!("启动失败:{reason}"),
})));
return;
}
};
for warning in &warnings {
let _ = tx.send(HostTask::Notice(Box::new(crate::dto::NoticeView::new(
&format!("launch-warn:{}", target.id),
crate::dto::NoticeLevel::Info,
"启动提示",
warning,
))));
}
let config = LauncherConfig::new(env.sessions_root())
.with_launcher(LAUNCHER_NAME, env!("CARGO_PKG_VERSION"));
let session = match Launcher::new(config).spawn(&request) {
Ok(session) => session,
Err(error) => {
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
id,
session: String::new(),
outcome: LogOutcome::Crashed,
summary: format!("启动失败:{error}"),
})));
return;
}
};
let session_id = session.session_id().to_string();
let pid = session.pid();
let _ = tx.send(HostTask::LaunchStarted {
id: id.clone(),
session_id: session_id.clone(),
pid,
});
let _ = tx.send(HostTask::Runtime {
id: id.clone(),
runtime: InstanceRuntime::Running { pid },
});
match session.finish() {
Ok(handle) => {
let outcome = super::identity::outcome_of(&handle);
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
id,
session: session_id,
outcome,
summary: format!(
"{}({},{:.1}s)",
launch_summary(outcome, Some(handle.exit.code)),
handle.exit.exit_type.as_str(),
handle.uptime_ms as f64 / 1000.0
),
})));
}
Err(error) => {
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
id,
session: session_id,
outcome: LogOutcome::SilentFail,
summary: format!("启动收尾失败:{error}"),
})));
}
}
});
}
/// 停止一次启动(按 pid 回收进程树;`finish()` 会在后台线程里照常收尾)。
pub fn stop(pid: u32) -> Result<(), String> {
tree_kill::kill_tree(pid, std::time::Duration::from_secs(3))
.map_err(|error| format!("停止进程 {pid} 失败:{error}"))
}
/// 适配层告警 → 人读一行。
fn describe_warning(warning: &AdapterWarning) -> String {
match warning {
AdapterWarning::MissingLibrary { name, path } => {
format!("缺少库文件 {name}({})", path.display())
}
AdapterWarning::MissingGameArguments => "版本 JSON 没有游戏参数".to_string(),
AdapterWarning::MissingAssetsIndex => "版本 JSON 没有资源索引".to_string(),
AdapterWarning::MissingMainClass => "版本 JSON 没有 mainClass".to_string(),
AdapterWarning::MissingNativeJar { name, path } => {
format!("缺少 natives 构件 {name}({})", path.display())
}
}
}
/// 加载器 → 适配层的模组加载器标记(GUI 只用于显示,不改命令行)。
pub fn loader_label(kind: LoaderKind) -> &'static str {
match kind {
LoaderKind::Vanilla => "原版",
LoaderKind::Fabric => "Fabric",
LoaderKind::Quilt => "Quilt",
LoaderKind::Forge => "Forge",
LoaderKind::NeoForge => "NeoForge",
LoaderKind::OptiFine => "OptiFine",
LoaderKind::LiteLoader => "LiteLoader",
}
}
/// 隔离档位的展示串(`isolated` / `shared`)。
pub fn isolation_label(mode: IsolationMode) -> &'static str {
mode.as_str()
}
+267
View File
@@ -0,0 +1,267 @@
//! host 启动状态机单测(P2-1/P2-2 的回归网)。
//!
//! 覆盖三件事:
//! 1. `running_pid` 按实例 id 取 pid(并发启动不会把 A 的停止键指到 B);
//! 2. 重复启动守卫(`launches` 已落盘 + `pending` 窗口两态都要挡);
//! 3. `refresh_instances` 重建快照后仍保留真实运行时(Running / Busy / Failed)。
use unlucky_core::game::install::instance::write_profiles;
use unlucky_core::game::{InstanceProfile, ProfileId, ProfileList};
use super::*;
use crate::dto::{LaunchFinished, LogOutcome};
use crate::toast::ToastKind;
/// 临时数据根守卫:测试结束(含断言 panic)时把目录删掉,不留空壳。
struct TempRoot(std::path::PathBuf);
impl Drop for TempRoot {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// 建一个只有一条实例档案的数据根,返回 `(host, instance_id, 守卫)`。
fn host_with_instance(label: &str) -> (CoreHost, String, TempRoot) {
let dir = std::env::temp_dir().join(format!(
"ull-host-launch-{label}-{}-{}",
std::process::id(),
line!()
));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
std::fs::create_dir_all(env.layout.config()).expect("config 目录");
let profile = InstanceProfile {
id: ProfileId::generate(),
name: "demo".to_string(),
version_id: "1.20.1".to_string(),
game_dir: None,
running_dir: None,
java_path: None,
created_at: None,
last_played_at: None,
extra: std::collections::BTreeMap::new(),
};
let id = profile.id.as_str().to_string();
let profiles = ProfileList {
profiles: vec![profile],
..ProfileList::default()
};
write_profiles(&env.layout, &profiles).expect("写 profiles");
(CoreHost::headless(env), id, TempRoot(dir))
}
fn live(instance: &str, session: &str, pid: u32) -> LiveLaunch {
LiveLaunch {
instance: instance.to_string(),
session: session.to_string(),
pid,
}
}
#[test]
fn running_pid_is_per_instance_not_a_single_slot() {
let (mut host, id, _root) = host_with_instance("pid");
host.launches.push(live("a", "s-a", 111));
host.launches.push(live("b", "s-b", 222));
assert_eq!(host.running_pid("a"), Some(111));
assert_eq!(host.running_pid("b"), Some(222));
assert_eq!(
host.running_pid("c"),
None,
"没启动过的实例不该拿到别人的 pid"
);
// A 收尾后只影响 A:这正是 review r1 P2-1 的场景(旧实现共用一个槽位)。
host.launches.retain(|entry| entry.instance != "a");
assert_eq!(host.running_pid("a"), None);
assert_eq!(host.running_pid("b"), Some(222));
// 落盘实例默认是 Idle,兜底分支不误报。
assert_eq!(host.running_pid(&id), None);
}
#[test]
fn duplicate_launch_is_rejected_before_spawning() {
let (mut host, id, _root) = host_with_instance("dup");
let _ = host.drain_events();
host.launches.push(live(&id, "s", 111));
host.start_launch(&id);
let events = host.drain_events();
assert!(
!events.iter().any(|event| matches!(
event,
AppEvent::Core(CoreEvent::InstanceRuntimeChanged { .. })
)),
"已在运行/启动中的实例不该再发 Busy(那会起第二个进程)"
);
assert!(
events
.iter()
.any(|event| matches!(event, AppEvent::Toast(toast) if toast.kind == ToastKind::Warn)),
"至少要给用户一条「已经在启动/运行中」的提示"
);
assert_eq!(host.launches.len(), 1, "启动表不该被重复启动污染");
}
#[test]
fn pending_window_also_blocks_a_second_launch() {
let (mut host, id, _root) = host_with_instance("pending");
// 走 start_launch 用的同一个入口,模拟「已受理、后台线程还没回报 LaunchStarted」。
host.mark_pending(&id);
assert!(host.in_flight(&id));
let _ = host.drain_events();
host.start_launch(&id);
let events = host.drain_events();
assert!(
!events.iter().any(|event| matches!(
event,
AppEvent::Core(CoreEvent::InstanceRuntimeChanged { .. })
)),
"pending 窗口内重复点启动必须被挡住"
);
assert_eq!(host.pending.len(), 1);
}
#[test]
fn launch_started_registers_and_launch_end_clears() {
let (mut host, id, _root) = host_with_instance("cycle");
host.mark_pending(&id);
host.handle(HostTask::LaunchStarted {
id: id.clone(),
session_id: "20261031T120000-abcd12".to_string(),
pid: 4_242,
});
assert!(!host.pending.contains(&id), "回报后 pending 要清掉");
assert_eq!(host.running_pid(&id), Some(4_242));
host.handle(HostTask::LaunchEnd(Box::new(LaunchFinished {
id: id.clone(),
session: "20261031T120000-abcd12".to_string(),
outcome: LogOutcome::Ok,
summary: "正常退出".to_string(),
})));
assert!(host.launches.is_empty(), "收尾后启动表要清空");
assert!(!host.failed.contains_key(&id));
assert_eq!(host.running_pid(&id), None);
}
#[test]
fn refresh_keeps_running_instances_running() {
let (mut host, id, _root) = host_with_instance("refresh-run");
host.launches.push(live(&id, "s", 4_242));
let _ = host.drain_events();
host.refresh_instances();
let instance = instances_from_events(host.drain_events())
.into_iter()
.find(|instance| instance.id == id)
.expect("刷新后应有该实例");
assert_eq!(
instance.runtime,
InstanceRuntime::Running { pid: 4_242 },
"刷新(装版本/账户变更/别的实例收尾)不能把在跑实例打回 Idle"
);
}
#[test]
fn refresh_keeps_pending_as_busy_and_failed_as_failed() {
let (mut host, id, _root) = host_with_instance("refresh-busy");
// Busy:走 start_launch 用的同一个入口(mark_pending),而不是直接改字段。
host.mark_pending(&id);
let pending = instances_from_events({
host.refresh_instances();
host.drain_events()
})
.into_iter()
.find(|instance| instance.id == id)
.expect("实例存在");
assert!(matches!(
pending.runtime,
InstanceRuntime::Busy {
stage: crate::dto::LaunchStage::Launch,
..
}
));
// Failed:走唯一真实写入路径 LaunchEnd(不是手动塞 failed 表)。
host.handle(HostTask::LaunchEnd(Box::new(LaunchFinished {
id: id.clone(),
session: "20261031T120000-dead01".to_string(),
outcome: LogOutcome::Crashed,
summary: "崩溃(exit 1)".to_string(),
})));
assert!(!host.pending.contains(&id), "LaunchEnd 必须清 pending");
assert!(!host.in_flight(&id), "收尾后该实例要能被重新启动");
let failed = instances_from_events({
host.refresh_instances();
host.drain_events()
})
.into_iter()
.find(|instance| instance.id == id)
.expect("实例存在");
assert_eq!(
failed.runtime,
InstanceRuntime::Failed {
reason: "崩溃(exit 1)".to_string()
},
"失败原因要经得住一次刷新(否则磁贴的「重试」外观会被 Idle 盖掉)"
);
}
#[test]
fn launch_end_before_launch_started_still_releases_the_instance() {
let (mut host, id, _root) = host_with_instance("fail-early");
// build_request / spawn 失败的现场:start_launch 已标 pending、后台线程只发 LaunchEnd。
host.mark_pending(&id);
assert!(host.in_flight(&id));
host.handle(HostTask::LaunchEnd(Box::new(LaunchFinished {
id: id.clone(),
session: String::new(),
outcome: LogOutcome::Crashed,
summary: "启动失败:找不到 Java".to_string(),
})));
assert!(
!host.pending.contains(&id),
"LaunchEnd 早于 LaunchStarted 时也必须清 pending(否则该实例本会话再也起不来)"
);
assert!(!host.in_flight(&id), "失败后要能重试");
let failed = instances_from_events({
host.refresh_instances();
host.drain_events()
})
.into_iter()
.find(|instance| instance.id == id)
.expect("实例存在");
assert_eq!(
failed.runtime,
InstanceRuntime::Failed {
reason: "启动失败:找不到 Java".to_string()
},
"早失败也要在磁贴上留下「失败/重试」,不能被 Idle 盖掉"
);
}
/// 从事件队列里取最后一次 `InstancesChanged`。
fn instances_from_events(events: Vec<AppEvent>) -> Vec<crate::dto::InstanceSummary> {
events
.into_iter()
.rev()
.find_map(|event| match event {
AppEvent::Core(CoreEvent::InstancesChanged(instances)) => Some(instances),
_ => None,
})
.unwrap_or_default()
}
+342
View File
@@ -0,0 +1,342 @@
//! 日志 / 诊断:会话索引 → 会话列表,事件 JSONL → 日志行,规则引擎 → 诊断卡片。
//!
//! 与 CLI `ull logs` / `ull diagnose` 同源(`launch::session_index` + `diagnose` 规则引擎):
//! 「同一份 jsonl 的两种读法」只有这一处实现(M39 ports/events.rs 的设计)。
//! 实时滚动 = host 每拍尾随启动会话的 JSONL(只读新增字节),不额外开第二条事件通道。
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use serde_json::Value;
use unlucky_core::diagnose::collectors::{ExitSignal, SignalOrigin};
use unlucky_core::diagnose::engine::{Diagnoser, DiagnosisInput};
use unlucky_core::diagnose::loader::RuleLoader;
use unlucky_core::launch::session_index::{self, SessionIndexEntry};
use crate::dto::{
Diagnosis, DiagnosisReport, LogLevel, LogLine, LogOutcome, LogSession, SessionId,
};
use super::env::HostEnv;
/// 送进规则引擎的默认尾巴行数(与 CLI 同值,避免两条路看到不同证据)。
pub const DEFAULT_TAIL: usize = 2000;
/// 日志页一次最多渲染的行数(超出的截断,避免大文件把 UI 拖死)。
pub const MAX_RENDERED_LINES: usize = 2_000;
/// 会话索引目录(`.ULL/data/agent/sessions`)。
pub fn root(env: &HostEnv) -> PathBuf {
env.sessions_root()
}
/// 会话列表(新→旧)。
pub fn sessions(env: &HostEnv) -> Vec<LogSession> {
let index = session_index::index_path(&root(env));
let mut entries = session_index::read_all(&index).unwrap_or_default();
entries.sort_by(|a, b| b.started_at.cmp(&a.started_at));
entries.iter().map(session_of).collect()
}
/// 索引条目 → 左栏会话行。
pub fn session_of(entry: &SessionIndexEntry) -> LogSession {
LogSession {
id: entry.session_id.clone(),
instance: if entry.instance_name.is_empty() {
entry.instance_id.clone()
} else {
entry.instance_name.clone()
},
when: display_time(&entry.started_at),
outcome: outcome_of(entry),
duration_secs: entry.duration_ms / 1000,
}
}
/// 结局:静默失败 > 崩溃(非 0 / crash_found) > 正常。
pub fn outcome_of(entry: &SessionIndexEntry) -> LogOutcome {
if entry.silent_failure {
LogOutcome::SilentFail
} else if entry.crash_found
|| entry.exit_code.is_some_and(|code| code != 0)
|| (entry.exit_code.is_none() && !entry.ended_at.is_empty())
{
LogOutcome::Crashed
} else {
LogOutcome::Ok
}
}
/// `2026-10-29T19:22:45.123Z` → `10-29 19:22`。
pub fn display_time(started_at: &str) -> String {
let body = started_at.trim_end_matches('Z');
let Some((date, time)) = body.split_once('T') else {
return started_at.to_string();
};
let date = date.get(5..).unwrap_or(date);
let time = time.get(..5).unwrap_or(time);
format!("{date} {time}")
}
/// 事件流文件路径(`<sessions>/game/<log_path>`)。
pub fn event_path(env: &HostEnv, entry: &SessionIndexEntry) -> PathBuf {
root(env).join("game").join(&entry.log_path)
}
/// 会话 id 或唯一前缀 → 索引条目。
fn find(env: &HostEnv, key: &str) -> Option<SessionIndexEntry> {
let index = session_index::index_path(&root(env));
let entries = session_index::read_all(&index).ok()?;
if let Some(exact) = entries.iter().find(|entry| entry.session_id == key) {
return Some(exact.clone());
}
entries
.iter()
.find(|entry| entry.session_id.starts_with(key))
.cloned()
}
/// 事件 JSON → 一行日志(`stdout`/`stderr` 的 `payload.line`)。
pub fn line_of(event: &Value) -> Option<LogLine> {
let text = event.pointer("/payload/line").and_then(Value::as_str)?;
let kind = event
.get("kind")
.and_then(Value::as_str)
.unwrap_or_default();
let level = match event
.pointer("/payload/level")
.and_then(Value::as_str)
.unwrap_or_default()
{
"error" => LogLevel::Error,
"warn" | "warning" => LogLevel::Warn,
"debug" | "trace" => LogLevel::Debug,
_ => {
if kind == "stderr" {
LogLevel::Warn
} else {
LogLevel::Info
}
}
};
Some(LogLine {
text: text.to_string(),
level,
})
}
/// 整个事件流 → 日志行(解析失败的行跳过,容忍被 kill 时截断的尾行)。
pub fn read_lines(path: &Path) -> Vec<LogLine> {
let Ok(text) = std::fs::read_to_string(path) else {
return Vec::new();
};
text.lines()
.filter(|line| !line.trim().is_empty())
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
.filter_map(|event| line_of(&event))
.collect()
}
/// 打开一个会话:日志行 + 规则引擎结论。
pub fn open(env: &HostEnv, key: &str) -> (Vec<LogLine>, Option<DiagnosisReport>) {
let Some(entry) = find(env, key) else {
return (Vec::new(), None);
};
let path = event_path(env, &entry);
let lines = read_lines(&path);
let diagnosis = diagnose_lines(&entry, &lines);
(lines, diagnosis)
}
/// 规则引擎跑一遍(**规则先跑,模型补位**:这里只出规则结论)。
pub fn diagnose_lines(entry: &SessionIndexEntry, lines: &[LogLine]) -> Option<DiagnosisReport> {
let stderr_text: String = lines
.iter()
.filter(|line| line.level == LogLevel::Error || line.level == LogLevel::Warn)
.map(|line| line.text.as_str())
.collect::<Vec<_>>()
.join("\n");
let body: String = lines
.iter()
.rev()
.take(DEFAULT_TAIL)
.map(|line| line.text.as_str())
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<Vec<_>>()
.join("\n");
if body.is_empty() {
return None;
}
let error_lines = u32::try_from(
lines
.iter()
.filter(|line| line.level == LogLevel::Error)
.count(),
)
.unwrap_or(u32::MAX);
let exit = ExitSignal::classify(
entry.exit_code,
Some(entry.duration_ms),
error_lines,
&stderr_text,
);
let mut input = DiagnosisInput::new().with_exit(exit);
input.push_text(SignalOrigin::Stderr, body);
let loader = RuleLoader::embedded();
let report = Diagnoser::new(loader.rule_set().rules().to_vec()).analyze(&input);
let finding = report
.findings
.first()
.or_else(|| report.secondary.first())?;
Some(DiagnosisReport {
session: entry.session_id.clone(),
diagnosis: Diagnosis {
rule: finding.rule_id.clone(),
title: finding.message.clone(),
confidence: confidence_percent(finding.confidence),
hint: finding.detail.clone().unwrap_or_else(|| {
report
.exit
.as_ref()
.map(|exit| exit.exit_type.as_str().to_string())
.unwrap_or_else(|| "未知退出".to_string())
}),
fix: finding
.materialized_actions()
.first()
.map(|action| action.tool.to_string()),
},
})
}
/// 0.0~1.0 → 0~100(四舍五入)。
pub fn confidence_percent(confidence: f32) -> u8 {
let percent = (confidence.clamp(0.0, 1.0) * 100.0).round();
u8::try_from(percent as u32).unwrap_or(100)
}
/// 导入崩溃日志(`hs_err_pid*.log` / `crash-*.txt` / `latest.log`)做规则分析。
///
/// 文件名含 `hs_err` 时先走 `hs_err::parse`(结构化字段进规则引擎),否则整份文本当信号喂进去。
pub fn analyze_crash_file(path: &str) -> Result<(String, DiagnosisReport), String> {
let path = Path::new(path);
if !path.is_file() {
return Err(format!("找不到崩溃日志:{}", path.display()));
}
let text = std::fs::read_to_string(path).map_err(|error| format!("读崩溃日志失败:{error}"))?;
let name = path
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| "crash.log".to_string());
let is_hs_err = name.contains("hs_err");
let mut input = DiagnosisInput::new();
if is_hs_err {
input.push_hs_err(unlucky_core::diagnose::hs_err::parse(&text));
}
input.push_text(
if is_hs_err {
SignalOrigin::HsErr
} else if name.contains("crash") {
SignalOrigin::CrashReport
} else {
SignalOrigin::LatestLog
},
text,
);
let loader = RuleLoader::embedded();
let report = Diagnoser::new(loader.rule_set().rules().to_vec()).analyze(&input);
let finding = report
.findings
.first()
.or_else(|| report.secondary.first())
.ok_or_else(|| "规则引擎没有从这份日志里认出已知问题".to_string())?;
Ok((
format!("导入的日志:{name}"),
DiagnosisReport {
session: format!("file:{name}"),
diagnosis: Diagnosis {
rule: finding.rule_id.clone(),
title: finding.message.clone(),
confidence: confidence_percent(finding.confidence),
hint: finding
.detail
.clone()
.unwrap_or_else(|| "规则引擎命中(详见证据行)".to_string()),
fix: finding
.materialized_actions()
.first()
.map(|action| action.tool.to_string()),
},
},
))
}
/// 实时尾随器:只读文件新增的字节,解析出完整的 JSON 行。
#[derive(Debug, Default)]
pub struct Tail {
path: Option<PathBuf>,
session: Option<SessionId>,
offset: u64,
}
impl Tail {
/// 开始尾随一个会话。
pub fn attach(&mut self, path: PathBuf, session: &str) {
self.path = Some(path);
self.session = Some(session.to_string());
self.offset = 0;
}
/// 停止尾随。
pub fn detach(&mut self) {
self.path = None;
self.session = None;
self.offset = 0;
}
/// 当前尾随的会话。
pub fn session(&self) -> Option<&str> {
self.session.as_deref()
}
/// 读新行(返回 `(会话 id, 新行)`;没有新内容返回 `None`)。
pub fn poll(&mut self) -> Option<(String, Vec<LogLine>)> {
let path = self.path.clone()?;
let session = self.session.clone()?;
let file = std::fs::File::open(&path).ok()?;
let len = file.metadata().ok()?.len();
if len <= self.offset {
return None;
}
let mut file = file;
file.seek(SeekFrom::Start(self.offset)).ok()?;
let mut buffer = String::new();
file.read_to_string(&mut buffer).ok()?;
// 只吃到最后一个换行:半行留到下一拍(避免把 JSON 截断解析成脏行)
let last_newline = buffer.rfind('\n')?;
let complete = &buffer[..=last_newline];
self.offset += u64::try_from(complete.len()).unwrap_or(u64::MAX);
let lines: Vec<LogLine> = complete
.lines()
.filter(|line| !line.trim().is_empty())
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
.filter_map(|event| line_of(&event))
.collect();
Some((session, lines))
}
}
/// 会话是否已收尾(索引里出现 `ended_at` 或文件停止增长超过一拍)。
pub fn session_finished(env: &HostEnv, session: &str) -> bool {
let index = session_index::index_path(&root(env));
session_index::read_all(&index)
.unwrap_or_default()
.iter()
.any(|entry| entry.session_id == session && !entry.ended_at.is_empty())
}
#[cfg(test)]
#[path = "logs_tests.rs"]
mod tests;
+86
View File
@@ -0,0 +1,86 @@
//! host/logs.rs 的单测(拆文件守 400 行上限)。
use super::*;
#[test]
fn time_display_and_outcomes() {
assert_eq!(display_time("2026-10-29T19:22:45.123Z"), "10-29 19:22");
assert_eq!(display_time("nope"), "nope");
let mut entry = SessionIndexEntry {
v: 1,
session_id: "s".to_string(),
instance_id: String::new(),
instance_name: String::new(),
game_version: String::new(),
started_at: String::new(),
ended_at: "2026-10-29T19:22:45Z".to_string(),
duration_ms: 1000,
exit_code: Some(0),
exit_type: Some("normal".to_string()),
silent_failure: false,
window_detected: true,
event_count: 1,
log_path: "s.jsonl".to_string(),
crash_found: false,
diagnosis_top: None,
};
assert_eq!(outcome_of(&entry), LogOutcome::Ok);
entry.exit_code = Some(1);
assert_eq!(outcome_of(&entry), LogOutcome::Crashed);
entry.silent_failure = true;
assert_eq!(outcome_of(&entry), LogOutcome::SilentFail);
}
#[test]
fn lines_map_levels_and_skip_non_line_events() {
let stdout = serde_json::json!({"kind":"stdout","payload":{"line":"hi","level":"info"}});
assert_eq!(line_of(&stdout).unwrap().level, LogLevel::Info);
let stderr = serde_json::json!({"kind":"stderr","payload":{"line":"boom","level":"error"}});
assert_eq!(line_of(&stderr).unwrap().level, LogLevel::Error);
let other = serde_json::json!({"kind":"cmd_built","payload":{"argv":[]}});
assert!(line_of(&other).is_none());
}
#[test]
fn tail_only_consumes_complete_lines() {
let dir = std::env::temp_dir().join(format!("ull-host-tail-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("目录");
let path = dir.join("s.jsonl");
std::fs::write(
&path,
"{\"kind\":\"stdout\",\"payload\":{\"line\":\"one\"}}\n",
)
.expect("写");
let mut tail = Tail::default();
tail.attach(path.clone(), "s");
let (session, lines) = tail.poll().expect("首拍有新行");
assert_eq!(session, "s");
assert_eq!(lines.len(), 1);
assert!(tail.poll().is_none(), "没有新增就不该再产出");
// 半行不消费
let mut text = std::fs::read_to_string(&path).expect("读");
text.push_str("{\"kind\":\"stdout\",\"payload\":{\"line\":\"tw");
std::fs::write(&path, text).expect("写");
assert!(tail.poll().is_none(), "半个 JSON 行不能解析");
let mut text = std::fs::read_to_string(&path).expect("读");
text.push_str("o\"}}\n");
std::fs::write(&path, text).expect("写");
let (_, lines) = tail.poll().expect("补全后应产出");
assert_eq!(lines[0].text, "two");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn crash_import_reports_missing_file() {
let err = analyze_crash_file("D:/definitely/missing/hs_err_pid1.log").unwrap_err();
assert!(err.contains("找不到崩溃日志"), "{err}");
}
#[test]
fn confidence_percent_is_rounded_and_clamped() {
assert_eq!(confidence_percent(0.923), 92);
assert_eq!(confidence_percent(-1.0), 0);
assert_eq!(confidence_percent(2.0), 100);
}
+80
View File
@@ -0,0 +1,80 @@
//! 安装进度的记账器(文件计数 / 字节 / 速度 / ETA)。
//!
//! `execute` 的文件级回调已经把每个文件的结局给了我们,这里只做聚合;
//! 速度用相邻两次采样的字节差算(core 的事件只给绝对值)。
use std::sync::Arc;
use std::time::Instant;
use unlucky_core::game::install::{FileOutcome, FileStatus, ProgressSink};
use crate::dto::{derive_speed, CreateRequest, InstallProgressView, InstallStage};
use super::install::loader_label;
use super::job::{HostTask, JobSender};
/// 一次安装的上下文(进度回调共享)。
pub(super) struct Meter {
pub(super) progress: InstallProgressView,
previous: Option<(u64, Instant)>,
}
impl Meter {
pub(super) fn new(request: &CreateRequest) -> Self {
Self {
progress: InstallProgressView::start(
&request.version,
&request.instance_name(),
&loader_label(request.loader, &request.loader_version),
),
previous: None,
}
}
/// 换阶段(重置速度采样)。
pub(super) fn stage(&mut self, stage: InstallStage, message: &str) {
self.progress.stage = stage;
self.progress.message = message.to_string();
self.previous = None;
}
/// 预记账一段待下载的文件(文件数 + 字节)。
///
/// 一次安装可能分几段(原版 / 处理器依赖库 / 加载器库):每段开工前都先记账,
/// 整条进度曲线才是单调的——某一段下完不会先到 100%,再因为下一段的文件被计入而回落。
pub(super) fn expect(&mut self, files: usize, bytes: u64) {
let files = u64::try_from(files).unwrap_or(u64::MAX);
self.progress.files_total = self.progress.files_total.saturating_add(files);
self.progress.bytes_total = self.progress.bytes_total.saturating_add(bytes);
}
/// 记账一个文件的结局。
pub(super) fn note(&mut self, outcome: &FileOutcome) {
self.progress.files_done += 1;
if outcome.status != FileStatus::Skipped {
self.progress.bytes_done = self.progress.bytes_done.saturating_add(outcome.bytes);
}
let now = Instant::now();
if let Some(previous) = self.previous {
if let Some((speed, eta)) = derive_speed(
previous,
(self.progress.bytes_done, now),
self.progress.bytes_total,
) {
self.progress.speed_bps = speed;
self.progress.eta_secs = eta;
}
}
self.previous = Some((self.progress.bytes_done, now));
}
}
/// 进度 sink(`execute` 的文件级回调)。
pub(super) fn sink_for(tx: JobSender, meter: Arc<std::sync::Mutex<Meter>>) -> ProgressSink {
Arc::new(move |outcome: &FileOutcome| {
let Ok(mut meter) = meter.lock() else {
return;
};
meter.note(outcome);
let _ = tx.send(HostTask::Install(Box::new(meter.progress.clone())));
})
}
+344
View File
@@ -0,0 +1,344 @@
//! 真实后端:把 core(game/launch/install/auth/java/diagnose/setting)装配成 GUI 的 host。
//!
//! 边界:本模块是整个 app 里**唯一**允许 `use unlucky_core::…` 的地方(连同 `settings_bridge`);
//! 出口只有 [`crate::dto`] 的快照/事件。线程模型:
//!
//! ```text
//! dispatch(AppCommand) ──► 立即动作(读盘/写设置)或 spawn 后台线程
//! 后台线程 ──HostTask──► rx ──tick()──► CoreEvent ──► state.apply()
//! tick() 另外每 ~600ms 尾随一次运行中会话的 JSONL(实时日志滚动)
//! ```
//!
//! AI 对话暂时仍由 [`MockBackend`] 提供脚本回复(agent 的 GUI host 是后续 mission);
//! 其余页面全部走真实 core。
mod accounts;
mod dispatch;
mod engine;
pub mod env;
mod game;
mod handle;
mod identity;
mod install;
mod install_journal;
mod install_processors;
mod java;
mod job;
mod launch;
mod logs;
mod meter;
mod opener;
mod settings;
mod versions;
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::time::Duration;
use crate::dto::{
AccountView, AppCommand, AppEvent, Backend, CoreEvent, DiagnosisReport, InstallProgressView,
InstanceRuntime, JavaChainStep, NoticeView,
};
use crate::mock::MockBackend;
use crate::toast::{Toast, ToastKind};
use accounts::LoginJob;
pub use env::HostEnv;
use job::{HostTask, JobReceiver, JobSender};
/// 每拍最多处理的后台消息数(防止一次 tick 吃掉整帧预算)。
const MAX_TASKS_PER_TICK: usize = 64;
/// 实时日志尾随间隔(拍数,33ms/拍 → 约 600ms 一次)。
const TAIL_POLL_TICKS: u64 = 18;
/// 一次在跑的启动(并发启动时按实例 id 区分,停止/尾随都不会串到别的实例)。
#[derive(Debug, Clone, PartialEq, Eq)]
struct LiveLaunch {
instance: String,
session: String,
pid: u32,
}
/// 真实 host。
pub struct CoreHost {
env: HostEnv,
events: VecDeque<AppEvent>,
tx: JobSender,
rx: JobReceiver,
ticks: u64,
/// 当前启动账户 id(内存态;跨会话持久化要 core schema 增键,见 handoff)
active_account: Option<String>,
accounts: Vec<AccountView>,
login: Option<LoginJob>,
java_chain: Vec<JavaChainStep>,
java_busy: bool,
java_message: String,
install: Option<InstallProgressView>,
notices: Vec<NoticeView>,
/// 在跑的启动(实例 → 会话 → pid;支持多个实例同时启动,停止按 id 找正确的 pid)
launches: Vec<LiveLaunch>,
/// 已受理但后台线程还没回报 `LaunchStarted` 的实例(防「启动中重复点启动」起第二个进程)
pending: BTreeSet<String>,
/// 上次启动失败的实例与原因(刷新快照时保住磁贴的「失败/重试」外观)
failed: BTreeMap<String, String>,
/// 实时日志尾随器
tail: logs::Tail,
/// AI 对话的临时后端(agent GUI host 落地前的占位)
chat: MockBackend,
/// 当前安装任务的取消标记(阶段边界检查)
install_cancel: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
}
impl CoreHost {
/// 按便携数据根装配;解析不出数据根返回 `None`(调用方退回 mock)。
pub fn open() -> Option<Self> {
HostEnv::resolve().map(Self::new)
}
/// 用给定环境装配(截图/单测通道)。
pub fn new(env: HostEnv) -> Self {
Self::build(env, true)
}
/// 单测用:不跑 `refresh_all`(不起版本清单网络线程),其余装配一致。
#[cfg(test)]
pub(crate) fn headless(env: HostEnv) -> Self {
Self::build(env, false)
}
fn build(env: HostEnv, refresh: bool) -> Self {
let (tx, rx) = job::channel();
game::ensure_root(&env);
let mut host = Self {
env,
events: VecDeque::new(),
tx,
rx,
ticks: 0,
active_account: None,
accounts: Vec::new(),
login: None,
java_chain: Vec::new(),
java_busy: false,
java_message: String::new(),
install: None,
notices: Vec::new(),
launches: Vec::new(),
pending: BTreeSet::new(),
failed: BTreeMap::new(),
tail: logs::Tail::default(),
chat: MockBackend::default(),
install_cancel: None,
};
if refresh {
host.refresh_all();
}
host
}
/// 数据根(截图/诊断展示用)。
pub fn env(&self) -> &HostEnv {
&self.env
}
fn send(&mut self, event: CoreEvent) {
self.events.push_back(AppEvent::Core(event));
}
fn toast(&mut self, kind: ToastKind, text: impl Into<String>) {
self.events.push_back(AppEvent::Toast(Toast {
id: 0,
kind,
text: text.into(),
remaining_ms: 0,
}));
}
/// 全量刷新:实例 / 会话 / 账户 / 设置 / Java,并触发一次版本清单拉取。
pub fn refresh_all(&mut self) {
self.refresh_instances();
self.send(CoreEvent::LogSessionsChanged(logs::sessions(&self.env)));
self.refresh_accounts();
self.send(CoreEvent::SettingsChanged(settings::groups(&self.env)));
self.refresh_java();
versions::spawn_list(self.env.clone(), self.tx.clone());
}
/// 重建实例快照,并把**真实运行时**盖回去。
///
/// `game::instances` 只读盘(每条都是 `Idle`),所以在跑的实例必须在重建后按
/// host 自己的启动表覆写 `Running{pid}`/`Busy`,否则任意一次刷新(装版本成功、
/// 加删账户、另一实例收尾)都会把磁贴打回 Idle:停止键与「运行中」条消失,
/// 进程还在跑却再也停不下来。
fn refresh_instances(&mut self) {
let mut instances = game::instances(&self.env);
for instance in &mut instances {
if let Some(live) = self
.launches
.iter()
.find(|live| live.instance == instance.id)
{
instance.runtime = InstanceRuntime::Running { pid: live.pid };
} else if self.pending.contains(&instance.id) {
instance.runtime = InstanceRuntime::Busy {
stage: crate::dto::LaunchStage::Launch,
progress: crate::dto::Progress { done: 0, total: 1 },
};
} else if let Some(reason) = self.failed.get(&instance.id) {
instance.runtime = InstanceRuntime::Failed {
reason: reason.clone(),
};
}
}
self.send(CoreEvent::InstancesChanged(instances));
}
fn refresh_accounts(&mut self) {
self.accounts = accounts::accounts(&self.env, self.active_account.as_deref());
if self.active_account.is_none() {
// 默认选中第一个账户(没有账户时保持 None = 离线占位)
self.active_account = self.accounts.first().map(|account| account.id.clone());
self.accounts = accounts::accounts(&self.env, self.active_account.as_deref());
}
self.send(CoreEvent::AccountsChanged(self.accounts.clone()));
}
fn refresh_java(&mut self) {
let page = java::page(
&self.env,
self.java_chain.clone(),
self.java_busy,
&self.java_message,
);
self.send(CoreEvent::JavaChanged(page));
}
fn push_notice(&mut self, notice: NoticeView) {
self.notices.retain(|existing| existing.id != notice.id);
self.notices.push(notice);
self.send(CoreEvent::NoticesChanged(self.notices.clone()));
}
/// 打开一个会话(日志行 + 诊断)。
fn open_session(&mut self, id: &str) {
let (lines, diagnosis) = logs::open(&self.env, id);
self.send(CoreEvent::LogLinesChanged(lines));
self.send(CoreEvent::DiagnosisReady(diagnosis.unwrap_or(
DiagnosisReport {
session: id.to_string(),
diagnosis: crate::dto::Diagnosis {
rule: String::new(),
title: "该会话没有命中已知规则".to_string(),
confidence: 0,
hint: "可点「让 AI 详解」把日志交给模型进一步分析".to_string(),
fix: None,
},
},
)));
}
/// 由实例名/版本 id 找启动目标。
fn launch_target(&self, id: &str) -> Option<launch::LaunchTarget> {
let instance = game::instances(&self.env)
.into_iter()
.find(|instance| instance.id == id || instance.name == id)?;
Some(launch::LaunchTarget {
id: instance.id,
name: instance.name,
version_id: instance.version_id,
account: self.active_account.clone(),
})
}
/// 某实例是否已在启动/运行(`pending` 与 `launches` 的并集)。
///
/// 只查 `launches` 不够:`launch::start` 到后台线程回报 `LaunchStarted` 之间有窗口,
/// 这期间重复点启动会起第二个进程、并把旧 pid 在启动表里顶掉(旧进程变孤儿)。
fn in_flight(&self, key: &str) -> bool {
self.pending.contains(key) || self.launches.iter().any(|live| live.instance == key)
}
/// 标记「已受理、等后台线程回报」(`launch::start` 之后的窗口)。
///
/// 单测也走这个入口而不是直接改 `pending`,保证测的是真实状态流转。
fn mark_pending(&mut self, id: &str) {
self.pending.insert(id.to_string());
self.failed.remove(id);
}
fn start_launch(&mut self, id: &str) {
if self.in_flight(id) {
self.toast(ToastKind::Warn, "这个实例已经在启动/运行中");
return;
}
let Some(target) = self.launch_target(id) else {
self.toast(ToastKind::Error, format!("找不到实例 {id}"));
return;
};
let instance_id = target.id.clone();
if self.in_flight(&instance_id) {
self.toast(ToastKind::Warn, "这个实例已经在启动/运行中");
return;
}
self.mark_pending(&instance_id);
if let Some(notice) = game::mode_change_notice(&self.env, &instance_id) {
self.push_notice(notice);
}
self.send(CoreEvent::InstanceRuntimeChanged {
id: instance_id.clone(),
runtime: InstanceRuntime::Busy {
stage: crate::dto::LaunchStage::Launch,
progress: crate::dto::Progress { done: 0, total: 1 },
},
});
launch::start(self.env.clone(), self.tx.clone(), target);
}
/// 某实例在跑的 pid(按实例 id 查启动表;不再有「最近一次启动」这种单槽)。
///
/// 兜底分支读实例快照的 `Running { pid }`:host 自己发的运行时事件会写回快照,
/// 但快照是重建的(`game::instances` 每次 Idle),所以这层只是给将来留的口。
fn running_pid(&self, id: &str) -> Option<u32> {
if let Some(live) = self.launches.iter().find(|live| live.instance == id) {
return Some(live.pid);
}
game::instances(&self.env)
.into_iter()
.find(|instance| instance.id == id)
.and_then(|instance| match instance.runtime {
InstanceRuntime::Running { pid } => Some(pid),
_ => None,
})
}
}
impl Backend for CoreHost {
fn drain_events(&mut self) -> Vec<AppEvent> {
self.events.drain(..).collect()
}
fn dispatch(&mut self, command: AppCommand) {
self.dispatch_command(command);
}
fn tick(&mut self, elapsed: Duration) {
self.chat.tick(elapsed);
for event in self.chat.drain_events() {
self.events.push_back(event);
}
for _ in 0..MAX_TASKS_PER_TICK {
let Ok(task) = self.rx.try_recv() else {
break;
};
self.handle(task);
}
self.ticks += 1;
if self.ticks.is_multiple_of(TAIL_POLL_TICKS) {
self.poll_tail();
}
}
}
#[cfg(test)]
#[path = "launch_state_tests.rs"]
mod launch_state_tests;
+72
View File
@@ -0,0 +1,72 @@
//! 用系统默认浏览器打开授权 URL(授权码流唯一需要宿主做的事)。
//!
//! core 只负责拼授权 URL 与等本地回调(`core::auth::microsoft::authcode`);
//! 「谁来打开浏览器」是宿主的决定:GUI 走这里,CLI/无浏览器场景仍用设备码流。
//!
//! 平台差异收在 [`launcher`] 一处,不往业务代码里散写 `cfg` 补丁(`AGENTS.md` §7)。
use std::process::Command;
/// 拉起系统默认浏览器打开 `url`(只接受 http/https)。
///
/// 不等待浏览器退出:用户可能在浏览器里停留几分钟,宿主只关心「有没有拉起来」。
///
/// # Errors
///
/// URL 不是 http/https,或进程拉不起来时返回用户可读文案。
pub fn open_url(url: &str) -> Result<(), String> {
if !url.starts_with("https://") && !url.starts_with("http://") {
return Err(format!("不是可打开的网址:{url}"));
}
let (program, args) = launcher(url);
Command::new(program)
.args(args)
.spawn()
.map(|_child| ())
.map_err(|error| format!("拉起浏览器失败({program}):{error}"))
}
#[cfg(target_os = "windows")]
fn launcher(url: &str) -> (&'static str, Vec<String>) {
// `rundll32` 不用 shell,URL 里的 `&` 不会被当成命令分隔符(`cmd /C start` 会)。
(
"rundll32",
vec!["url.dll,FileProtocolHandler".to_string(), url.to_string()],
)
}
#[cfg(target_os = "macos")]
fn launcher(url: &str) -> (&'static str, Vec<String>) {
("open", vec![url.to_string()])
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
fn launcher(url: &str) -> (&'static str, Vec<String>) {
("xdg-open", vec![url.to_string()])
}
#[cfg(test)]
mod tests {
use super::{launcher, open_url};
#[test]
fn rejects_non_http_urls() {
assert!(open_url("javascript:alert(1)").is_err());
assert!(open_url("").is_err());
// 合法 URL 只验命令形态,不在单测里真去拉浏览器。
let (program, args) = launcher("http://localhost:1234/auth?code=a&state=b");
assert!(!program.is_empty());
assert!(args
.iter()
.any(|arg| arg.contains("http://localhost:1234/auth")));
}
#[cfg(target_os = "windows")]
#[test]
fn windows_uses_the_file_protocol_handler() {
let (program, args) = launcher("https://example.invalid/");
assert_eq!(program, "rundll32");
assert_eq!(args[0], "url.dll,FileProtocolHandler");
assert_eq!(args[1], "https://example.invalid/");
}
}
+398
View File
@@ -0,0 +1,398 @@
//! 设置页分组:按 `core::setting` schema 的键生成行,写回走 `SettingStore::set`。
//!
//! 三件纪律(AGENTS §6):
//! 1. 读写同一份 `<数据根>/config/launcher.json`(与 `ull config set` / AI `set_config` 同源);
//! 2. 不自己拼 JSON,也不绕 schema(非法键/越界值由 `store.set` 拒绝);
//! 3. 这里只认「键名 + 字符串值」,领域语义留在 core。
//!
//! 分组 id 与设置页子导航同名:`download` / `java` / `data` / `ai` / `mcp`。
use serde_json::Value;
use unlucky_core::game::java::JdkVendor;
use crate::dto::{SettingGroupView, SettingRowView};
use super::env::HostEnv;
/// 全部设置分组(设置页右栏一次拿全,切分组不再请求)。
pub fn groups(env: &HostEnv) -> Vec<SettingGroupView> {
vec![
download_group(env),
java_group(env),
data_group(env),
ai_group(env),
mcp_group(env),
]
}
/// 读一个键的字符串值(缺省给空串)。
fn text(env: &HostEnv, key: &str) -> String {
env.store()
.doc()
.effective_str(key)
.unwrap_or_default()
.to_string()
}
/// 读一个键的整数(缺省给 `fallback`)。
fn int(env: &HostEnv, key: &str, fallback: i64) -> i64 {
env.store()
.effective(key)
.and_then(Value::as_i64)
.unwrap_or(fallback)
}
/// 读一个键的布尔(缺省给 `fallback`)。
fn flag(env: &HostEnv, key: &str, fallback: bool) -> bool {
env.store()
.effective(key)
.and_then(Value::as_bool)
.unwrap_or(fallback)
}
/// 枚举值在选项表里的序号(找不到给 0)。
fn selected(options: &[&str], value: &str) -> i32 {
options
.iter()
.position(|option| *option == value)
.map(|index| i32::try_from(index).unwrap_or(0))
.unwrap_or(0)
}
/// 下载(D7):并发/重试/地区/镜像偏好/代理。
fn download_group(env: &HostEnv) -> SettingGroupView {
let concurrency = int(env, "download.concurrency", 8).to_string();
let retry = int(env, "download.retry_limit", 3).to_string();
let concurrency_options = ["4", "8", "16"];
let retry_options = ["0", "1", "3", "5"];
let regions = ["auto", "cn", "global"];
let mirrors = ["auto", "official", "mirror"];
let proxies = ["none", "system", "manual"];
let region_value = text(env, "download.region");
let mirror_value = text(env, "download.mirror_preference");
let proxy_value = text(env, "download.proxy_mode");
SettingGroupView::new("download", "下载与镜像(D7)")
.with(SettingRowView::choice(
"download.concurrency",
"并发数",
"同时下载的文件数(海量小文件,16 路足够压满带宽)",
&concurrency_options,
selected(&concurrency_options, &concurrency),
))
.with(SettingRowView::choice(
"download.retry_limit",
"单文件重试次数",
"失败后换源重试的上限",
&retry_options,
selected(&retry_options, &retry),
))
.with(SettingRowView::choice(
"download.region",
"地区判定",
"auto = 时区+语言+探测;决定是否优先国内镜像",
&regions,
selected(
&regions,
if region_value.is_empty() {
"auto"
} else {
&region_value
},
),
))
.with(SettingRowView::choice(
"download.mirror_preference",
"镜像偏好",
"auto = 按健康度评分;official = 官方源优先",
&mirrors,
selected(
&mirrors,
if mirror_value.is_empty() {
"auto"
} else {
&mirror_value
},
),
))
.with(SettingRowView::choice(
"download.proxy_mode",
"代理模式",
"none / system(跟随系统)/ manual(下面填地址)",
&proxies,
selected(
&proxies,
if proxy_value.is_empty() {
"none"
} else {
&proxy_value
},
),
))
.with(SettingRowView::text(
"download.proxy_url",
"HTTP 代理",
"manual 模式生效;留空则用系统代理",
&text(env, "download.proxy_url"),
))
.with(SettingRowView::action(
"download.action.detect_region",
"重新检测地区",
"清掉 24h 地区缓存,下次下载重新判定",
"detect_region",
))
}
/// Java(D13/D23):自动安装/内存/选路/发行版优先级。
fn java_group(env: &HostEnv) -> SettingGroupView {
let memory = int(env, "java.default_memory_mb", 2048).to_string();
let memory_options = ["1024", "2048", "4096", "8192"];
let priority: Vec<String> = env
.store()
.effective("java.vendorPriority")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default();
let priority_text = if priority.is_empty() {
"地区默认序(Temurin → …)".to_string()
} else {
priority.join(" → ")
};
let roots = super::engine::java_scan_roots(env);
SettingGroupView::new("java", "Java(D13)")
.with(SettingRowView::toggle(
"java.auto_install",
"缺 Java 时自动下载",
"按回退链装一个托管 JDK(基准 JDK17)",
flag(env, "java.auto_install", true),
))
.with(SettingRowView::choice(
"java.default_memory_mb",
"默认最大堆内存",
"实例没单独指定时用这个值(MB)",
&memory_options,
selected(&memory_options, &memory),
))
.with(SettingRowView::text(
"java.selected_path",
"指定 Java 可执行文件",
"留空 = 自动挑(托管 > JAVA_HOME > PATH)",
&text(env, "java.selected_path"),
))
.with(SettingRowView::text(
"java.installRoot",
"托管 JDK 安装根",
"留空 = 按数据目录模式派生(portable 进数据根 / standard 进系统目录)",
&text(env, "java.installRoot"),
))
.with(SettingRowView::text(
"java.extra_args",
"附加 JVM 参数",
"原样拼到命令行尾部",
&text(env, "java.extra_args"),
))
.with(SettingRowView::read_only(
"发行版优先级(java.vendorPriority)",
&priority_text,
))
.with(SettingRowView::read_only(
"托管根",
&roots
.iter()
.map(|root| root.display().to_string())
.collect::<Vec<_>>()
.join(" · "),
))
}
/// 数据目录(D6 三模式)。
fn data_group(env: &HostEnv) -> SettingGroupView {
let modes = ["portable", "standard", "custom"];
let mode = env.mode().as_str().to_string();
SettingGroupView::new("data", "数据目录(D6 三模式)")
.with(SettingRowView::choice(
"core.data_mode",
"存储模式",
"便携(运行目录)/ 标准(系统应用数据目录)/ 自定义",
&modes,
selected(&modes, &mode),
))
.with(SettingRowView::text(
"core.data_custom_root",
"自定义根目录",
"custom 模式生效(绝对路径)",
&text(env, "core.data_custom_root"),
))
.with(SettingRowView::read_only(
"启动器数据目录",
&env.data.launcher_home.display().to_string(),
))
.with(SettingRowView::read_only(
"游戏目录",
&env.data.game_home.display().to_string(),
))
.with(SettingRowView::read_only(
"生效模式",
&format!("{}(改数据根需重启启动器)", env.mode()),
))
}
/// AI(D4):开关/端点/模型/YOLO。
fn ai_group(env: &HostEnv) -> SettingGroupView {
let providers = ["openai_compatible", "anthropic", "ollama"];
let provider = text(env, "ai.provider");
SettingGroupView::new("ai", "AI 助手(D4)")
.with(SettingRowView::toggle(
"ai.enabled",
"启用 AI 助手",
"关闭后规则引擎诊断仍可用",
flag(env, "ai.enabled", true),
))
.with(SettingRowView::choice(
"ai.provider",
"模型端点",
"OpenAI 兼容(含本地 llama.cpp)/ Anthropic / Ollama",
&providers,
selected(
&providers,
if provider.is_empty() {
"openai_compatible"
} else {
&provider
},
),
))
.with(SettingRowView::text(
"ai.base_url",
"端点 base_url",
"如 http://127.0.0.1:8080/v1",
&text(env, "ai.base_url"),
))
.with(SettingRowView::text(
"ai.model",
"模型名",
"如 deepseek-chat",
&text(env, "ai.model"),
))
.with(SettingRowView::toggle(
"ai.yolo",
"YOLO 模式",
"跳过确认弹窗(审计照记;账户/凭据操作永不 YOLO)",
flag(env, "ai.yolo", false),
))
}
/// M39 的对外 MCP server(默认关、默认只读)。
fn mcp_group(env: &HostEnv) -> SettingGroupView {
let port = int(env, "agent.mcp.port", 47_823);
SettingGroupView::new("mcp", "MCP server(M39)")
.with(SettingRowView::toggle(
"agent.mcp.enabled",
"对外提供 MCP server",
"127.0.0.1 上的 Streamable HTTP(stdio 需显式启动)",
flag(env, "agent.mcp.enabled", false),
))
.with(SettingRowView::text(
"agent.mcp.port",
"端口",
"1024~65535,只绑 127.0.0.1",
&port.to_string(),
))
.with(SettingRowView::toggle(
"agent.mcp.allow_write",
"允许外部客户端写配置",
"默认只读;写仍受 schema 白名单与分级确认约束",
flag(env, "agent.mcp.allow_write", false),
))
.with(SettingRowView::read_only(
"客户端地址",
&format!("http://127.0.0.1:{port}/mcp(令牌见 agent.mcp.token,AI 不可写)"),
))
}
/// 写一条设置。`value` 是字符串形态,按 schema 键的类型转换后交 `store.set`。
///
/// # Errors
///
/// 未知键、值类型不符、越界、落盘失败时返回用户可读文案。
pub fn set(env: &HostEnv, key: &str, value: &str) -> Result<(), String> {
if key == "download.action.detect_region" {
return detect_region(env);
}
let parsed = parse_value(key, value)?;
let mut store = env.store();
store
.set(key, parsed)
.map_err(|error| format!("设置 {key} 失败:{error}"))?;
store
.save()
.map_err(|error| format!("保存设置失败:{error}"))?;
Ok(())
}
/// 键 → 正确类型的 JSON 值(schema 会再校验范围/枚举)。
fn parse_value(key: &str, value: &str) -> Result<Value, String> {
if matches!(key, "download.concurrency" | "download.retry_limit") {
let number: u32 = value
.trim()
.parse()
.map_err(|_| format!("{key} 需要整数:{value}"))?;
return Ok(Value::from(number));
}
if matches!(key, "java.default_memory_mb" | "agent.mcp.port") {
let number: u32 = value
.trim()
.parse()
.map_err(|_| format!("{key} 需要整数:{value}"))?;
return Ok(Value::from(number));
}
if matches!(
key,
"java.auto_install"
| "ai.enabled"
| "ai.yolo"
| "agent.mcp.enabled"
| "agent.mcp.allow_write"
) {
let flag = match value.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "on" | "yes" => true,
"0" | "false" | "off" | "no" => false,
_ => return Err(format!("{key} 需要布尔值:{value}")),
};
return Ok(Value::Bool(flag));
}
Ok(Value::String(value.to_string()))
}
/// 清掉 24h 地区缓存(下次建引擎重新判定)。
fn detect_region(env: &HostEnv) -> Result<(), String> {
let path = env.layout.cache().join("region.json");
if path.is_file() {
std::fs::remove_file(&path).map_err(|error| format!("清地区缓存失败:{error}"))?;
}
Ok(())
}
/// 设置页「重置发行版优先级」用(清空 enum_list)。
///
/// # Errors
///
/// 落盘失败时返回用户可读文案。
pub fn clear_vendor_priority(env: &HostEnv) -> Result<(), String> {
let mut store = env.store();
store
.set("java.vendorPriority", Value::Array(Vec::new()))
.and_then(|()| store.save())
.map_err(|error| format!("清空发行版优先级失败:{error}"))?;
let _ = JdkVendor::ALL;
Ok(())
}
#[cfg(test)]
#[path = "settings_tests.rs"]
mod tests;
+77
View File
@@ -0,0 +1,77 @@
//! host/settings.rs 的单测(拆文件守 400 行上限)。
use super::*;
fn env_in(dir: &std::path::Path) -> HostEnv {
std::fs::create_dir_all(dir.join(".ULL/config")).expect("config");
HostEnv::at_root(dir)
}
#[test]
fn groups_cover_the_six_required_areas() {
let dir = std::env::temp_dir().join(format!("ull-host-settings-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = env_in(&dir);
let groups = groups(&env);
let ids: Vec<&str> = groups.iter().map(|group| group.id.as_str()).collect();
assert_eq!(ids, vec!["download", "java", "data", "ai", "mcp"]);
let keys: Vec<&str> = groups
.iter()
.flat_map(|group| group.rows.iter())
.map(|row| row.key.as_str())
.collect();
for key in [
"download.region",
"download.mirror_preference",
"java.default_memory_mb",
"core.data_mode",
"agent.mcp.enabled",
"agent.mcp.port",
"ai.base_url",
] {
assert!(keys.contains(&key), "缺少 {key}");
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn set_parses_types_and_persists() {
let dir = std::env::temp_dir().join(format!("ull-host-settings-w-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = env_in(&dir);
set(&env, "download.concurrency", "16").expect("写并发");
set(&env, "agent.mcp.enabled", "true").expect("写开关");
set(&env, "agent.mcp.port", "48000").expect("写端口");
set(&env, "download.region", "cn").expect("写地区");
let groups = groups(&env);
let row = |key: &str| {
groups
.iter()
.flat_map(|g| g.rows.iter())
.find(|r| r.key == key)
.cloned()
};
assert_eq!(row("download.concurrency").unwrap().selected, 2);
assert!(row("agent.mcp.enabled").unwrap().flag);
assert_eq!(row("agent.mcp.port").unwrap().value_text, "48000");
assert_eq!(row("download.region").unwrap().selected, 1);
assert!(set(&env, "download.concurrency", "abc").is_err());
assert!(set(&env, "agent.mcp.enabled", "maybe").is_err());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn detect_region_action_removes_cache() {
let dir = std::env::temp_dir().join(format!("ull-host-settings-r-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = env_in(&dir);
let path = env.layout.cache().join("region.json");
std::fs::create_dir_all(env.layout.cache()).expect("cache");
std::fs::write(&path, "{}").expect("写缓存");
set(&env, "download.action.detect_region", "").expect("检测");
assert!(!path.exists());
std::fs::remove_dir_all(&dir).ok();
}
+173
View File
@@ -0,0 +1,173 @@
//! 版本清单(下载页):远端 manifest(12h 缓存)+ 内嵌 unlisted 清单 → 分类好的行。
//!
//! 分类口径抄 analyse/03 §2.4:官方 `type` 决定 release/snapshot/远古;远古再按 id
//! 前缀拆 classic(`rd-*`)/ old_alpha(`a*`)/ old_beta(`b*`),其余归 unlisted 汇总。
//! 拉取失败但有旧缓存时用旧缓存(离线可用),全无才报错。
use std::collections::BTreeSet;
use unlucky_core::download::blocking::block_on;
use unlucky_core::download::cache::now_ms;
use unlucky_core::game::install::RemoteSource;
use unlucky_core::game::unlisted;
use unlucky_core::game::version::{ReleaseType, RemoteVersion};
use crate::dto::{AncientKind, VersionKind, VersionSummary};
use super::env::HostEnv;
use super::job::{spawn, HostTask, JobSender};
/// 版本 id / 官方类型 → 下载页的四种归类。
pub fn kind_of(id: &str, release_type: ReleaseType) -> VersionKind {
match release_type {
ReleaseType::Release => VersionKind::Release,
ReleaseType::Snapshot | ReleaseType::Pending | ReleaseType::Unobfuscated => {
VersionKind::Snapshot
}
ReleaseType::OldAlpha | ReleaseType::OldBeta | ReleaseType::Modified => {
VersionKind::Ancient(ancient_kind(id))
}
ReleaseType::Unknown => {
if id.starts_with("rd-") || id.starts_with("a") || id.starts_with("b") {
VersionKind::Ancient(ancient_kind(id))
} else {
VersionKind::Release
}
}
}
}
/// 远古版子类(`rd-*` → classic;`a1.*` → old_alpha;`b1.*` → old_beta;其余 unlisted)。
pub fn ancient_kind(id: &str) -> AncientKind {
if id.starts_with("rd-") || id.starts_with("inf-") {
AncientKind::Classic
} else if id.starts_with("a1.") || id.starts_with("a0.") || id.starts_with('a') {
AncientKind::OldAlpha
} else if id.starts_with("b1.") || id.starts_with('b') {
AncientKind::OldBeta
} else {
AncientKind::Unlisted
}
}
/// 发布日期取 `releaseTime` 的日期部分(缺失时退回 `time`,再退回空串)。
fn released_of(version: &RemoteVersion) -> String {
version
.release_time
.as_deref()
.or(version.time.as_deref())
.map(|text| text.get(..10).unwrap_or(text).to_string())
.unwrap_or_default()
}
/// 拉清单并翻译成 DTO(安装状态按盘上版本判定)。
fn fetch(env: &HostEnv) -> Result<Vec<VersionSummary>, String> {
let engine = super::engine::engine(env);
let meta_cache = env.layout.meta_cache();
let now = now_ms();
let (mut list, _freshness) = block_on(async {
RemoteSource::new(&engine, &env.game, &meta_cache, now)
.manifest(false)
.await
})
.map_err(|error| format!("拉取版本清单失败:{error}"))?;
// 内嵌 unlisted 清单先合(同 id 先插入者胜),保证远古版显式可达(09 §G2)。
unlisted::merge_into(&mut list);
list.sort_newest_first();
let installed: BTreeSet<String> = env
.game
.installed_versions()
.unwrap_or_default()
.into_iter()
.collect();
Ok(list
.versions
.iter()
.map(|version| VersionSummary {
id: version.id.clone(),
kind: kind_of(&version.id, version.version_type),
released: released_of(version),
installed: installed.contains(&version.id),
})
.collect())
}
/// 后台拉清单(网络在后台线程里,UI 不卡)。
pub fn spawn_list(env: HostEnv, tx: JobSender) {
spawn("versions", move || {
let result = fetch(&env);
let _ = tx.send(HostTask::Versions(result));
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kinds_follow_official_types() {
assert_eq!(
kind_of("1.21.9", ReleaseType::Release),
VersionKind::Release
);
assert_eq!(
kind_of("1.21.9-pre3", ReleaseType::Snapshot),
VersionKind::Snapshot
);
assert_eq!(
kind_of("rd-132211", ReleaseType::OldAlpha),
VersionKind::Ancient(AncientKind::Classic)
);
assert_eq!(
kind_of("a1.1.1", ReleaseType::OldAlpha),
VersionKind::Ancient(AncientKind::OldAlpha)
);
assert_eq!(
kind_of("b1.7.3", ReleaseType::OldBeta),
VersionKind::Ancient(AncientKind::OldBeta)
);
assert_eq!(
kind_of("x-modified", ReleaseType::Modified),
VersionKind::Ancient(AncientKind::Unlisted)
);
}
#[test]
fn released_uses_date_part() {
let version = RemoteVersion {
id: "1.20.4".to_string(),
version_type: ReleaseType::Release,
url: String::new(),
time: None,
release_time: Some("2023-12-07T12:00:00+00:00".to_string()),
sha1: None,
size: None,
};
assert_eq!(released_of(&version), "2023-12-07");
let empty = RemoteVersion {
id: "x".to_string(),
version_type: ReleaseType::Unknown,
url: String::new(),
time: None,
release_time: None,
sha1: None,
size: None,
};
assert_eq!(released_of(&empty), "");
}
#[test]
fn unlisted_merge_keeps_ancient_versions_reachable() {
// core 的内嵌清单里一定有远古版(`rd-*` / `a1.*` / `b1.*`)
let mut list = unlucky_core::game::version::VersionList::default();
let added = unlisted::merge_into(&mut list);
assert!(added > 0, "内嵌 unlisted 清单不该为空");
assert!(
list.versions.iter().any(|v| v.id.starts_with("a1.")
|| v.id.starts_with("b1.")
|| v.id.starts_with("rd-")),
"远古版必须显式可达"
);
}
}
+10 -20
View File
@@ -32,36 +32,40 @@
#![allow(missing_docs, dead_code)] #![allow(missing_docs, dead_code)]
mod appearance_bind; mod appearance_bind;
mod backend;
mod bg_image; mod bg_image;
mod callbacks; mod callbacks;
mod callbacks_pages;
mod conv; mod conv;
mod dialogs; mod dialogs;
mod dto; mod dto;
mod host;
mod mock; mod mock;
mod mock_data; mod mock_data;
mod page_sync;
mod png; mod png;
mod prefs; mod prefs;
mod router; mod router;
mod screenshot;
mod settings_bridge; mod settings_bridge;
mod state; mod state;
mod theme_runtime; mod theme_runtime;
mod toast; mod toast;
mod ui_bind; mod ui_bind;
mod wizard;
slint::include_modules!(); slint::include_modules!();
fn main() -> Result<(), slint::PlatformError> { fn main() -> Result<(), slint::PlatformError> {
apply_renderer_flag(); apply_renderer_flag();
let app = ui_bind::AppUi::new(MainWindow::new()?); let app = ui_bind::AppUi::with_options(MainWindow::new()?, screenshot::app_options());
// 截图验收通道(M41 视觉验收;无交互桌面/无 GPU 环境下的唯一可靠取图方式: // 截图验收通道(M41 视觉验收;无交互桌面/无 GPU 环境下的唯一可靠取图方式:
// 不截屏幕,而是让 Slint 把当前场景渲染进内存像素缓冲再自己编码 PNG)。 // 不截屏幕,而是让 Slint 把当前场景渲染进内存像素缓冲再自己编码 PNG)。
if let Some(path) = screenshot_path() { // P3 追加页面选择与中间态(安装进度 / 启动事件流),见 `crate::screenshot`。
if std::env::args().any(|arg| arg == "--screenshot-settings") { if let Some(options) = screenshot::shot_options() {
app.open_settings_appearance(); screenshot::capture(&app, &options)?;
}
app.render_screenshot(&path)?;
return Ok(()); return Ok(());
} }
@@ -78,17 +82,3 @@ fn apply_renderer_flag() {
std::env::set_var("SLINT_BACKEND", "winit-software"); std::env::set_var("SLINT_BACKEND", "winit-software");
} }
} }
/// `--screenshot <path>` / `--screenshot=<path>`:渲染一帧并写 PNG 后退出。
fn screenshot_path() -> Option<String> {
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
if let Some(value) = arg.strip_prefix("--screenshot=") {
return Some(value.to_owned());
}
if arg == "--screenshot" {
return args.next();
}
}
None
}
+3 -3
View File
@@ -260,9 +260,9 @@ impl Backend for MockBackend {
crate::mock_data::mock_diagnosis(id), crate::mock_data::mock_diagnosis(id),
))); )));
} }
AppCommand::RefreshInstances AppCommand::RefreshAll | AppCommand::RefreshLogSessions | AppCommand::StopAgent => {}
| AppCommand::RefreshLogSessions // 其余指令(安装向导/账户/Java/设置/通知)由真实 host 承接:mock 不模拟
| AppCommand::StopAgent => {} _ => {}
} }
} }
+12
View File
@@ -22,6 +22,9 @@ pub fn mock_instances() -> Vec<InstanceSummary> {
last_played: Some("2026-10-29T19:22:00Z".to_string()), last_played: Some("2026-10-29T19:22:00Z".to_string()),
runtime: InstanceRuntime::Idle, runtime: InstanceRuntime::Idle,
icon: None, icon: None,
isolation: "isolated".to_string(),
version_id: "1.20.4".to_string(),
version_present: true,
}, },
InstanceSummary { InstanceSummary {
id: "vanilla-latest".to_string(), id: "vanilla-latest".to_string(),
@@ -34,6 +37,9 @@ pub fn mock_instances() -> Vec<InstanceSummary> {
last_played: Some("2026-10-28T21:03:00Z".to_string()), last_played: Some("2026-10-28T21:03:00Z".to_string()),
runtime: InstanceRuntime::Idle, runtime: InstanceRuntime::Idle,
icon: None, icon: None,
isolation: "isolated".to_string(),
version_id: "1.21.9".to_string(),
version_present: true,
}, },
InstanceSummary { InstanceSummary {
id: "ancient-1.7.10".to_string(), id: "ancient-1.7.10".to_string(),
@@ -46,6 +52,9 @@ pub fn mock_instances() -> Vec<InstanceSummary> {
last_played: None, last_played: None,
runtime: InstanceRuntime::Idle, runtime: InstanceRuntime::Idle,
icon: None, icon: None,
isolation: "shared".to_string(),
version_id: "1.7.10".to_string(),
version_present: false,
}, },
InstanceSummary { InstanceSummary {
id: "modded-dev".to_string(), id: "modded-dev".to_string(),
@@ -60,6 +69,9 @@ pub fn mock_instances() -> Vec<InstanceSummary> {
reason: "exit0 静默失败".to_string(), reason: "exit0 静默失败".to_string(),
}, },
icon: None, icon: None,
isolation: "isolated".to_string(),
version_id: "1.21.1-neoforge-21.1.72".to_string(),
version_present: true,
}, },
] ]
} }
+36
View File
@@ -0,0 +1,36 @@
//! `PageState` global 的状态灌入(从 `ui_bind` 拆出,守 400 行上限)。
//!
//! 一个函数只做「AppState → Slint global 属性」的搬运,同步发生在每一拍末尾
//! (`ui_bind::Inner::sync`)。分文件而不是砍内容:这些字段每一行都对应一个页面元素。
use crate::conv;
use crate::state::AppState;
use crate::ui_bind::model;
use crate::{MainWindow, PageState};
use slint::ComponentHandle;
/// 把页面状态灌进 `PageState`。
pub(crate) fn apply(ui: &MainWindow, state: &AppState) {
// 导航底部账户 + PageState 全量页面状态(账户/Java/安装/设置/通知/向导)
ui.set_account_name(conv::active_account_name(state).into());
let page = ui.global::<PageState<'_>>();
page.set_accounts(model(conv::accounts(state)));
page.set_active_account(conv::active_account_name(state).into());
page.set_device(conv::device(state));
page.set_java_vendors(model(conv::java_vendors(state)));
page.set_java_chain(model(conv::java_chain(state)));
page.set_java_runtimes(model(conv::java_runtimes(state)));
page.set_java_order(state.java.order_text.clone().into());
page.set_java_region(state.java.region.clone().into());
page.set_java_priority_source(state.java.priority_source.clone().into());
page.set_java_message(state.java.message.clone().into());
page.set_java_busy(state.java.busy);
let major = state.java.majors().last().copied().unwrap_or(17);
page.set_java_target_major(i32::try_from(major).unwrap_or(17));
page.set_install(conv::install(state));
page.set_setting_groups(model(conv::setting_groups(state)));
page.set_setting_tab(state.settings_tab.clone().into());
page.set_notices(model(conv::notices(state)));
page.set_wizard(conv::wizard(state));
page.set_wizard_versions(model(conv::wizard_versions(state)));
}
+177
View File
@@ -0,0 +1,177 @@
//! 截图验收通道的参数解析与流程(`--screenshot*` / `--data-dir` / `--mock`)。
//!
//! 为什么用进程内渲染而不是截屏:验收机没有交互桌面(`CopyFromScreen` 直接失败),
//! 而 Slint 的软件渲染器能把场景渲进内存像素缓冲(`Window::take_snapshot`),
//! 结果与屏幕上逐像素一致且不需要 GPU(M41 落地的通道,这里只加页面/中间态参数)。
//!
//! 中间态(安装进度 / 启动事件流)走真实后端:`--screenshot-install <版本>` 会真的发起
//! 一次安装并停若干毫秒后取图,`--screenshot-launch <实例>` 同理。
use std::path::PathBuf;
use crate::backend::AppOptions;
use crate::dto::AppCommand;
use crate::ui_bind::AppUi;
/// 一次截图请求。
#[derive(Debug, Clone, Default)]
pub struct ShotOptions {
/// 输出 PNG 路径
pub path: String,
/// 页面名(instances / download / java / logs / settings / account / appearance / agent)
pub page: Option<String>,
/// 打开新建实例向导
pub wizard: bool,
/// 发起一次真实安装并停 `dwell_ms` 后取图(中间态)
pub install: Option<String>,
/// 安装走哪个加载器(`--screenshot-loader forge`;缺省 = 原版)
pub loader: Option<String>,
/// 发起一次真实启动并停 `dwell_ms` 后取图(中间态)
pub launch: Option<String>,
/// 打开一个日志会话(日志页有数据态:日志行 + 诊断卡片)
pub session: Option<String>,
/// 中间态停留毫秒(默认 2500)
pub dwell_ms: u64,
}
impl ShotOptions {
/// 中间态默认停留时长。
pub const DEFAULT_DWELL_MS: u64 = 2_500;
}
/// 从命令行读启动选项(数据根 / mock)。
pub fn app_options() -> AppOptions {
AppOptions {
data_root: value_of("--data-dir").map(PathBuf::from),
mock: flag("--mock"),
}
}
/// 从命令行读截图请求;没有 `--screenshot` 时返回 `None`。
pub fn shot_options() -> Option<ShotOptions> {
let path = value_of("--screenshot")?;
let page = value_of("--screenshot-page");
let mut options = ShotOptions {
path,
page: if flag("--screenshot-settings") {
Some("appearance".to_string())
} else {
page
},
wizard: flag("--screenshot-wizard"),
install: value_of("--screenshot-install"),
loader: value_of("--screenshot-loader"),
launch: value_of("--screenshot-launch"),
session: value_of("--screenshot-session"),
dwell_ms: value_of("--screenshot-dwell")
.and_then(|value| value.parse().ok())
.unwrap_or(ShotOptions::DEFAULT_DWELL_MS),
};
if options.dwell_ms < 500 {
options.dwell_ms = 500;
}
options.page = options.page.filter(|page| !page.trim().is_empty());
Some(options)
}
/// 执行截图流程:切页 → (可选)中间态 → 渲染 PNG。
///
/// # Errors
/// 窗口显示/快照/写文件失败时返回错误。
pub fn capture(app: &AppUi, options: &ShotOptions) -> Result<(), slint::PlatformError> {
if let Some(page) = &options.page {
app.open_page(page);
// 先给后端一次全量刷新:版本清单要在后台线程里拉(12h 缓存),
// 下载页/Java 页/向导需要它才有「有数据态」。
let warmup = if matches!(page.as_str(), "download" | "java") {
4_000
} else {
1_200
};
app.dispatch_and_run(AppCommand::RefreshAll, warmup);
}
if options.wizard {
app.open_wizard();
}
if let Some(version) = &options.install {
app.dispatch_and_run(
install_command(version, options.loader.as_deref()),
options.dwell_ms,
);
}
if let Some(session) = &options.session {
app.open_session(session);
app.dispatch_and_run(AppCommand::RefreshLogSessions, options.dwell_ms.min(1_000));
}
if let Some(instance) = &options.launch {
app.dispatch_and_run(
AppCommand::LaunchInstance(instance.clone()),
options.dwell_ms,
);
}
app.render_screenshot(&options.path)?;
// 启动中间态截完就把进程收掉:截图进程退出后子进程会变孤儿,
// 所以在取图之后显式 stop(kill_tree),不留给系统。
if let Some(instance) = &options.launch {
app.dispatch_and_run(AppCommand::StopInstance(instance.clone()), 1_500);
}
Ok(())
}
/// 截图通道的安装指令:缺省原版,给了 `--screenshot-loader` 就走向导同一条路
/// (`CreateInstance`),因此 Forge/NeoForge 的中间态也能取图。
fn install_command(version: &str, loader: Option<&str>) -> AppCommand {
match loader.map(crate::dto::LoaderKind::parse) {
Some(Some(kind)) => AppCommand::CreateInstance(crate::dto::CreateRequest {
version: version.to_string(),
loader: kind,
loader_version: String::new(),
name: version.to_string(),
}),
_ => AppCommand::InstallVersion(version.to_string()),
}
}
/// `--flag` 是否存在。
pub fn flag(name: &str) -> bool {
std::env::args().skip(1).any(|arg| arg == name)
}
/// `--key value` / `--key=value` 的取值。
pub fn value_of(name: &str) -> Option<String> {
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
if let Some(value) = arg.strip_prefix(&format!("{name}=")) {
return Some(value.to_string());
}
if arg == name {
return args.next();
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_dwell_is_a_visible_mid_state() {
const DEFAULT: u64 = ShotOptions::DEFAULT_DWELL_MS;
let dwell = DEFAULT;
assert!(dwell >= 1_000, "中间态至少要停 1 秒才看得见进度");
}
#[test]
fn options_without_screenshot_are_none() {
// 测试进程没有 --screenshot 参数
assert!(
shot_options().is_none() || std::env::args().any(|arg| arg.contains("--screenshot"))
);
}
#[test]
fn value_of_ignores_absent_flags() {
assert!(value_of("--definitely-not-a-real-flag").is_none());
}
}
+95 -5
View File
@@ -5,12 +5,14 @@
use crate::dialogs::DialogQueue; use crate::dialogs::DialogQueue;
use crate::dto::{ use crate::dto::{
AgentEvent, AppEvent, ChatMessage, ChatRole, CoreEvent, DiagnosisReport, InstanceRuntime, AccountView, AgentEvent, AppEvent, ChatMessage, ChatRole, CoreEvent, DeviceLoginView,
InstanceSummary, LogLine, LogSession, Progress, ToolCallView, VersionSummary, DiagnosisReport, InstallProgressView, InstanceRuntime, InstanceSummary, JavaPageView, LogLine,
LogSession, NoticeView, Progress, SettingGroupView, ToolCallView, VersionSummary,
}; };
use crate::prefs::UiPrefs; use crate::prefs::UiPrefs;
use crate::router::{AiPanelMode, Route, Router}; use crate::router::{AiPanelMode, Route, Router};
use crate::toast::ToastCenter; use crate::toast::{ToastCenter, ToastKind};
use crate::wizard::WizardState;
/// AI 会话状态(右侧栏与独立页共享同一份,切换不丢)。 /// AI 会话状态(右侧栏与独立页共享同一份,切换不丢)。
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -75,6 +77,9 @@ pub fn grid_positions(count: usize, columns: usize) -> Vec<(usize, usize)> {
.collect() .collect()
} }
/// 实时日志最多保留的行数(滚动窗口;更多行由日志页的筛选/历史会话承担)。
pub const MAX_LIVE_LINES: usize = 2_000;
/// 应用全局状态。 /// 应用全局状态。
#[derive(Debug)] #[derive(Debug)]
pub struct AppState { pub struct AppState {
@@ -106,6 +111,24 @@ pub struct AppState {
pub installed_only: bool, pub installed_only: bool,
/// 下载页版本号筛选词 /// 下载页版本号筛选词
pub download_query: String, pub download_query: String,
/// 账户列表(三轨统一视图;凭据不在其中)
pub accounts: Vec<AccountView>,
/// 微软登录状态
pub device_login: DeviceLoginView,
/// Java 页快照(发行版 / 回退链 / 运行时)
pub java: JavaPageView,
/// 安装进度(None = 没有安装任务)
pub install: Option<InstallProgressView>,
/// 设置分组(设置页右栏)
pub settings_groups: Vec<SettingGroupView>,
/// 当前选中的设置分组(download / java / data / ai / mcp)
pub settings_tab: String,
/// 横幅通知
pub notices: Vec<NoticeView>,
/// 新建实例向导
pub wizard: WizardState,
/// 正在实时尾随的会话
pub live_session: Option<String>,
} }
impl Default for AppState { impl Default for AppState {
@@ -132,6 +155,15 @@ impl AppState {
instance_query: String::new(), instance_query: String::new(),
installed_only: false, installed_only: false,
download_query: String::new(), download_query: String::new(),
accounts: Vec::new(),
device_login: DeviceLoginView::new(),
java: JavaPageView::new(),
install: None,
settings_groups: Vec::new(),
settings_tab: "download".to_string(),
notices: Vec::new(),
wizard: WizardState::new(),
live_session: None,
} }
} }
@@ -186,12 +218,51 @@ impl AppState {
} }
CoreEvent::VersionListChanged(versions) => self.versions = versions, CoreEvent::VersionListChanged(versions) => self.versions = versions,
CoreEvent::LogSessionsChanged(sessions) => self.log_sessions = sessions, CoreEvent::LogSessionsChanged(sessions) => self.log_sessions = sessions,
CoreEvent::LogLinesChanged(lines) => self.log_lines = lines, CoreEvent::LogLinesChanged(lines) => {
self.log_lines = lines;
self.live_session = None;
}
CoreEvent::LogAppended(batch) => self.append_live_lines(&batch),
CoreEvent::LaunchFinished(finished) => {
self.live_session = None;
self.selected_session = Some(finished.session.clone());
let kind = if finished.outcome == crate::dto::LogOutcome::Ok {
ToastKind::Success
} else {
ToastKind::Error
};
self.toasts.push(kind, finished.summary);
}
CoreEvent::DiagnosisReady(report) => self.diagnosis = Some(report), CoreEvent::DiagnosisReady(report) => self.diagnosis = Some(report),
CoreEvent::AccountsChanged(accounts) => self.accounts = accounts,
CoreEvent::DeviceLoginChanged(view) => {
if view.phase == crate::dto::DeviceLoginPhase::Succeeded {
self.toasts
.push(ToastKind::Success, format!("已登录:{}", view.account_name));
}
if view.phase == crate::dto::DeviceLoginPhase::Failed {
self.toasts.push(ToastKind::Error, view.message.clone());
}
self.device_login = view;
}
CoreEvent::JavaChanged(page) => self.java = page,
CoreEvent::InstallProgress(progress) => match progress {
Some(progress) => {
let terminal = progress.stage.is_terminal();
self.install = Some(progress);
if terminal {
self.wizard.open = false;
self.wizard.submitting = false;
}
}
None => self.install = None,
},
CoreEvent::SettingsChanged(groups) => self.settings_groups = groups,
CoreEvent::NoticesChanged(notices) => self.notices = notices,
} }
} }
/// agent 事件。 /// agent 事件(AI 会话的流式消息与工具调用)。
pub fn apply_agent(&mut self, event: AgentEvent) { pub fn apply_agent(&mut self, event: AgentEvent) {
match event { match event {
AgentEvent::MessageAppended(message) => { AgentEvent::MessageAppended(message) => {
@@ -242,6 +313,25 @@ impl AppState {
} }
} }
/// 实时日志增量:只追加,封顶 [`MAX_LIVE_LINES`] 行(滚动窗口)。
fn append_live_lines(&mut self, batch: &crate::dto::LiveLogBatch) {
if self
.live_session
.as_deref()
.is_some_and(|session| session != batch.session)
{
// 换了一个会话在跑:清掉上一场的行,避免两场日志混在一起
self.log_lines.clear();
}
self.live_session = batch.running.then(|| batch.session.clone());
self.selected_session = Some(batch.session.clone());
self.log_lines.extend(batch.lines.iter().cloned());
if self.log_lines.len() > MAX_LIVE_LINES {
let drop = self.log_lines.len() - MAX_LIVE_LINES;
self.log_lines.drain(..drop);
}
}
/// 按搜索词过滤后的磁贴网格(顺序稳定 = 实例列表顺序)。 /// 按搜索词过滤后的磁贴网格(顺序稳定 = 实例列表顺序)。
pub fn tiles(&self) -> Vec<TileCell<'_>> { pub fn tiles(&self) -> Vec<TileCell<'_>> {
let query = self.instance_query.trim().to_lowercase(); let query = self.instance_query.trim().to_lowercase();
+3
View File
@@ -23,6 +23,9 @@ fn instance(id: &str, name: &str) -> InstanceSummary {
last_played: None, last_played: None,
runtime: InstanceRuntime::Idle, runtime: InstanceRuntime::Idle,
icon: None, icon: None,
isolation: "isolated".to_string(),
version_id: "1.20.4".to_string(),
version_present: true,
} }
} }
+4 -2
View File
@@ -3,11 +3,13 @@
//! 与模态对话框队列([`crate::dialogs`])分开:Toast 不阻塞、不排序、可叠加, //! 与模态对话框队列([`crate::dialogs`])分开:Toast 不阻塞、不排序、可叠加,
//! 只做「先进先出 + 上限裁剪 + TTL 到期」。UI 侧由 Rust 定时器每帧调用 [`ToastCenter::expire`]。 //! 只做「先进先出 + 上限裁剪 + TTL 到期」。UI 侧由 Rust 定时器每帧调用 [`ToastCenter::expire`]。
use serde::{Deserialize, Serialize};
/// Toast 标识。 /// Toast 标识。
pub type ToastId = u64; pub type ToastId = u64;
/// Toast 语义色。 /// Toast 语义色。
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ToastKind { pub enum ToastKind {
/// 一般信息 /// 一般信息
Info, Info,
@@ -20,7 +22,7 @@ pub enum ToastKind {
} }
/// 一条 Toast;`remaining_ms` 由 [`ToastCenter::expire`] 递减。 /// 一条 Toast;`remaining_ms` 由 [`ToastCenter::expire`] 递减。
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Toast { pub struct Toast {
/// 队列内唯一 id(由 [`ToastCenter::push_request`] 重新编号) /// 队列内唯一 id(由 [`ToastCenter::push_request`] 重新编号)
pub id: ToastId, pub id: ToastId,
+87 -88
View File
@@ -14,14 +14,14 @@ use std::time::Duration;
use slint::{ComponentHandle, ModelRc, VecModel}; use slint::{ComponentHandle, ModelRc, VecModel};
use crate::backend::{build_backend, config_path, AppOptions};
use crate::bg_image::BackgroundLayers; use crate::bg_image::BackgroundLayers;
use crate::conv; use crate::conv;
use crate::dto::{AppCommand, Backend, InstanceId}; use crate::dto::{AppCommand, Backend};
use crate::mock::MockBackend;
use crate::prefs::BgFill; use crate::prefs::BgFill;
use crate::settings_bridge::SettingsBridge; use crate::settings_bridge::SettingsBridge;
use crate::state::AppState; use crate::state::AppState;
use crate::{MainWindow, SettingSection}; use crate::{MainWindow, PageState, SettingSection};
/// 定时器节拍:33ms ≈ 30fps(磁贴进度环 / 流式文本 / Toast TTL 的刷新下限)。 /// 定时器节拍:33ms ≈ 30fps(磁贴进度环 / 流式文本 / Toast TTL 的刷新下限)。
const TICK_MS: u64 = 33; const TICK_MS: u64 = 33;
@@ -67,19 +67,28 @@ pub(crate) struct BackgroundKey {
} }
impl AppUi { impl AppUi {
/// 建状态 + 假后端、读配置、接全部回调、灌初始事件并做第一次同步。 /// 建状态 + 真实后端、读配置、接全部回调、灌初始事件并做第一次同步。
pub fn new(ui: MainWindow) -> Self { pub fn new(ui: MainWindow) -> Self {
Self::with_options(ui, AppOptions::default())
}
/// 带启动选项装配(数据根覆盖 / 强制 mock)。
pub fn with_options(ui: MainWindow, options: AppOptions) -> Self {
// 先读一次落盘配置:主题三态 + 外观四件必须在首帧前就位(否则会闪一下出厂外观) // 先读一次落盘配置:主题三态 + 外观四件必须在首帧前就位(否则会闪一下出厂外观)
let bridge = SettingsBridge::open(); let bridge = match &options.data_root {
Some(root) => SettingsBridge::with_path(config_path(root)),
None => SettingsBridge::open(),
};
let (appearance, theme) = bridge.load(); let (appearance, theme) = bridge.load();
let mut state = AppState::new(); let mut state = AppState::new();
state.prefs.appearance = appearance; state.prefs.appearance = appearance;
state.prefs.theme = theme; state.prefs.theme = theme;
let backend = build_backend(&options);
let inner = Rc::new(Inner { let inner = Rc::new(Inner {
ui, ui,
state: RefCell::new(state), state: RefCell::new(state),
backend: RefCell::new(Box::new(MockBackend::new())), backend: RefCell::new(backend),
bridge: RefCell::new(bridge), bridge: RefCell::new(bridge),
background: RefCell::new(BackgroundLayers::default()), background: RefCell::new(BackgroundLayers::default()),
background_key: RefCell::new(None), background_key: RefCell::new(None),
@@ -88,6 +97,7 @@ impl AppUi {
}); });
crate::callbacks::wire(Rc::downgrade(&inner)); crate::callbacks::wire(Rc::downgrade(&inner));
crate::callbacks_pages::wire_pages(Rc::downgrade(&inner));
inner.drain_events(); inner.drain_events();
inner.align_system_theme(); inner.align_system_theme();
inner.apply_theme(); inner.apply_theme();
@@ -134,6 +144,71 @@ impl AppUi {
slint::platform::update_timers_and_animations(); slint::platform::update_timers_and_animations();
} }
/// 直接跳到一个页面(截图验收用:每页一张)。
pub fn open_page(&self, page: &str) {
let route = match page {
"download" | "java" => crate::router::Route::Download,
"logs" => crate::router::Route::Logs,
"settings" | "account" | "appearance" => crate::router::Route::Settings,
"agent" => crate::router::Route::Agent,
_ => crate::router::Route::Instances,
};
{
let mut state = self.inner.state.borrow_mut();
state.navigate(route);
match page {
"account" => state.settings_tab = "account".to_string(),
"java" => state.settings_tab = "java".to_string(),
"settings" => state.settings_tab = "download".to_string(),
_ => {}
}
let download_tab = i32::from(page == "java");
self.inner
.ui
.global::<PageState<'_>>()
.set_download_tab(download_tab);
}
match page {
"account" => self.inner.ui.set_settings_section(SettingSection::Account),
"java" => self.inner.ui.set_settings_section(SettingSection::Java),
"appearance" => self
.inner
.ui
.set_settings_section(SettingSection::Appearance),
"settings" => self.inner.ui.set_settings_section(SettingSection::Download),
_ => {}
}
self.inner.sync();
slint::platform::update_timers_and_animations();
}
/// 打开新建实例向导(截图验收用)。
pub fn open_wizard(&self) {
self.inner.state.borrow_mut().wizard = crate::wizard::WizardState::open();
self.inner.sync();
slint::platform::update_timers_and_animations();
}
/// 打开一个日志会话(截图验收:日志行 + 诊断卡片 + 头部标题都要有值)。
pub fn open_session(&self, id: &str) {
self.inner.state.borrow_mut().selected_session = Some(id.to_string());
self.inner
.dispatch(AppCommand::OpenLogSession(id.to_string()));
self.inner.sync();
slint::platform::update_timers_and_animations();
}
/// 派发一条指令并跑若干毫秒(截图中间态:安装进度 / 启动事件流)。
pub fn dispatch_and_run(&self, command: AppCommand, millis: u64) {
self.inner.dispatch(command);
let steps = (millis / TICK_MS).max(1);
for _ in 0..steps {
self.inner.tick();
slint::platform::update_timers_and_animations();
std::thread::sleep(Duration::from_millis(TICK_MS));
}
}
/// 渲染当前场景并存成 PNG(`--screenshot` 通道;见 `crate::png`)。 /// 渲染当前场景并存成 PNG(`--screenshot` 通道;见 `crate::png`)。
/// ///
/// 为什么要这条通道:验收环境可能没有交互桌面(`CopyFromScreen` 直接失败), /// 为什么要这条通道:验收环境可能没有交互桌面(`CopyFromScreen` 直接失败),
@@ -150,8 +225,8 @@ impl AppUi {
.set_size(slint::WindowSize::Logical(slint::LogicalSize::new( .set_size(slint::WindowSize::Logical(slint::LogicalSize::new(
1180.0, 760.0, 1180.0, 760.0,
))); )));
// 先走几拍:把 mock 事件(实例/版本/日志)与 33ms 节拍的效果灌进去 // 先走几拍:把后端事件(实例/版本/日志/账户/设置)与 33ms 节拍的效果灌进去
for _ in 0..4 { for _ in 0..8 {
self.tick_once(); self.tick_once();
std::thread::sleep(Duration::from_millis(TICK_MS)); std::thread::sleep(Duration::from_millis(TICK_MS));
} }
@@ -245,89 +320,13 @@ impl Inner {
ui.set_dialog(dialog); ui.set_dialog(dialog);
ui.set_dialog_open(open); ui.set_dialog_open(open);
ui.set_dialog_queued(queued); ui.set_dialog_queued(queued);
// PageState(账户/Java/安装/设置/通知/向导)在独立模块里灌,守 400 行
crate::page_sync::apply(ui, &state);
} }
} }
/// Slint 列表模型(VecModel → ModelRc)。
pub(crate) fn model<T: Clone + 'static>(items: Vec<T>) -> ModelRc<T> { pub(crate) fn model<T: Clone + 'static>(items: Vec<T>) -> ModelRc<T> {
ModelRc::new(VecModel::from(items)) ModelRc::new(VecModel::from(items))
} }
/// 停止实例的指令构造(阶段 C 的磁贴「停止」键用;P0 只保留 API)。
#[allow(dead_code)]
pub fn stop_instance(id: &InstanceId) -> AppCommand {
AppCommand::StopInstance(id.clone())
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::send_agent_message;
use crate::dto::{Backend, ChatRole};
use crate::mock::MockBackend;
use crate::state::AppState;
/// 集成测试(callbacks→state→mock 全链路):发一次提问后
/// ①用户消息恰出现一次(无本地回显重复)②所有 assistant 气泡收敛(无幽灵气泡)。
///
/// 分层单测漏掉这条正是 review P1-1 的根因,故这里用真实 mock 后端跑完整事件流。
#[test]
fn send_path_has_no_duplicate_user_message_and_no_ghost_stream() {
let mut state = AppState::new();
let mut backend = MockBackend::new();
// 先灌入种子事件(实例/版本/日志快照)
for event in backend.drain_events() {
state.apply(event);
}
send_agent_message(&mut state, &mut backend, "装哪个版本");
fn user_count(state: &AppState) -> usize {
state
.ai
.messages
.iter()
.filter(|message| message.role == ChatRole::User)
.count()
}
assert_eq!(
user_count(&state),
1,
"用户消息必须恰好一条(本地回显会造成两条)"
);
assert!(state.ai.streaming);
// 推进到流式收敛(mock 300ms/片 + 工具调用 + 收尾消息)
for _ in 0..12 {
backend.tick(Duration::from_millis(400));
for event in backend.drain_events() {
state.apply(event);
}
}
assert_eq!(user_count(&state), 1, "事件回流后用户消息仍应只有一条");
assert!(
state.ai.messages.iter().all(|message| !message.streaming),
"所有气泡必须收敛(幽灵气泡会永久 streaming)"
);
assert!(!state.ai.streaming);
assert!(
state
.ai
.messages
.iter()
.any(|message| message.role == ChatRole::Assistant && !message.text.is_empty()),
"增量应累积到 assistant 正文"
);
assert_eq!(
state
.ai
.messages
.iter()
.filter(|message| message.role == ChatRole::Assistant)
.count(),
2,
"一轮对话 = 流式回复 + 收尾回复"
);
}
}
+275
View File
@@ -0,0 +1,275 @@
//! 新建实例向导(09 §G5)的纯状态机:版本 → 加载器 → 实例名 → 确认 → 进度。
//!
//! 放纯 Rust 而不是 Slint 里:步骤推进、名字合法性、加载器约束都能单测,
//! Slint 只负责画。提交时由 host 转成 [`crate::dto::CreateRequest`] 走安装管线。
use crate::dto::LoaderKind;
/// 向导步骤。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WizardStep {
/// 选版本
#[default]
Version,
/// 选加载器
Loader,
/// 起名
Name,
/// 确认(显示将下载的项)
Confirm,
/// 进度(提交后由安装进度接管)
Progress,
}
impl WizardStep {
/// 步骤序号(0 起,用于「第 N 步」文案)。
pub fn index(self) -> usize {
match self {
Self::Version => 0,
Self::Loader => 1,
Self::Name => 2,
Self::Confirm => 3,
Self::Progress => 4,
}
}
/// 步骤标题。
pub fn title(self) -> &'static str {
match self {
Self::Version => "选择版本",
Self::Loader => "选择加载器",
Self::Name => "实例名",
Self::Confirm => "确认",
Self::Progress => "安装中",
}
}
/// 下一步(进度是终点)。
pub fn next(self) -> Self {
match self {
Self::Version | Self::Progress => {
if self == Self::Progress {
Self::Progress
} else {
Self::Loader
}
}
Self::Loader => Self::Name,
Self::Name => Self::Confirm,
Self::Confirm => Self::Progress,
}
}
/// 上一步(版本是起点)。
pub fn back(self) -> Self {
match self {
Self::Version | Self::Progress => Self::Version,
Self::Loader => Self::Version,
Self::Name => Self::Loader,
Self::Confirm => Self::Name,
}
}
}
/// 向导状态。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WizardState {
/// 是否打开(全屏模态)
pub open: bool,
/// 当前步骤
pub step: WizardStep,
/// 选中的版本 id
pub version: String,
/// 选中的加载器
pub loader: LoaderKind,
/// 加载器版本(空 = 最新)
pub loader_version: String,
/// 实例名草稿
pub name: String,
/// 校验错误(显示在向导内,不弹 Toast)
pub error: String,
/// 已提交(防重复点)
pub submitting: bool,
}
impl Default for WizardState {
fn default() -> Self {
Self::new()
}
}
impl WizardState {
/// 关闭态。
pub fn new() -> Self {
Self {
open: false,
step: WizardStep::Version,
version: String::new(),
loader: LoaderKind::Vanilla,
loader_version: String::new(),
name: String::new(),
error: String::new(),
submitting: false,
}
}
/// 打开向导并复位。
pub fn open() -> Self {
Self {
open: true,
..Self::new()
}
}
/// 选了版本:自动把实例名填成版本号(用户不改就用它)。
pub fn select_version(&mut self, version: &str) {
self.version = version.to_string();
if self.name.trim().is_empty() {
self.name = version.to_string();
}
self.error.clear();
}
/// 选加载器(原版会清掉加载器版本)。
pub fn select_loader(&mut self, loader: LoaderKind) {
self.loader = loader;
if loader == LoaderKind::Vanilla {
self.loader_version.clear();
}
self.error.clear();
}
/// 推进到下一步;校验不通过时留在原地并写 `error`。
pub fn advance(&mut self) -> bool {
match self.step {
WizardStep::Version => {
if self.version.trim().is_empty() {
self.error = "请先选择一个版本".to_string();
return false;
}
}
WizardStep::Name => {
if let Err(error) = validate_name(&self.name) {
self.error = error;
return false;
}
}
WizardStep::Loader | WizardStep::Confirm | WizardStep::Progress => {}
}
self.error.clear();
self.step = self.step.next();
true
}
/// 回退一步。
pub fn step_back(&mut self) {
self.error.clear();
self.step = self.step.back();
}
/// 提交前的最后校验(Confirm 步)。
pub fn ready_to_submit(&self) -> Result<(), String> {
if self.version.trim().is_empty() {
return Err("版本为空".to_string());
}
validate_name(&self.name)?;
Ok(())
}
/// 提交成功后进入进度步(关掉可编辑态)。
pub fn submitted(&mut self) {
self.submitting = true;
self.step = WizardStep::Progress;
self.error.clear();
}
}
/// 实例名合法性(不能空、不能含路径分隔符/控制字符、长度上限 64)。
pub fn validate_name(name: &str) -> Result<(), String> {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err("实例名不能为空".to_string());
}
if trimmed.chars().count() > 64 {
return Err("实例名最多 64 个字符".to_string());
}
if trimmed.contains(['/', '\\', ':', '*', '?', '"', '<', '>', '|']) {
return Err("实例名不能包含 / \\ : * ? \" < > |".to_string());
}
if trimmed.chars().any(|ch| ch.is_control()) {
return Err("实例名不能包含控制字符".to_string());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn steps_walk_forward_and_back() {
assert_eq!(WizardStep::Version.next(), WizardStep::Loader);
assert_eq!(WizardStep::Loader.next(), WizardStep::Name);
assert_eq!(WizardStep::Name.next(), WizardStep::Confirm);
assert_eq!(WizardStep::Confirm.next(), WizardStep::Progress);
assert_eq!(WizardStep::Progress.next(), WizardStep::Progress);
assert_eq!(WizardStep::Confirm.back(), WizardStep::Name);
assert_eq!(WizardStep::Version.back(), WizardStep::Version);
assert_eq!(WizardStep::Progress.index(), 4);
}
#[test]
fn version_selection_fills_name_but_does_not_overwrite() {
let mut wizard = WizardState::open();
assert!(wizard.open);
wizard.select_version("1.20.4");
assert_eq!(wizard.name, "1.20.4");
wizard.name = "生存".to_string();
wizard.select_version("1.21");
assert_eq!(wizard.name, "生存", "用户改过的名字不该被覆盖");
}
#[test]
fn advance_requires_a_version_and_a_valid_name() {
let mut wizard = WizardState::open();
assert!(!wizard.advance(), "没有版本不能进下一步");
assert_eq!(wizard.error, "请先选择一个版本");
wizard.select_version("1.20.4");
assert!(wizard.advance());
assert_eq!(wizard.step, WizardStep::Loader);
assert!(wizard.advance());
assert_eq!(wizard.step, WizardStep::Name);
wizard.name = "a/b".to_string();
assert!(!wizard.advance(), "非法名字不能确认");
assert!(wizard.error.contains("不能包含"));
wizard.name = "生存".to_string();
assert!(wizard.advance());
assert_eq!(wizard.step, WizardStep::Confirm);
assert!(wizard.ready_to_submit().is_ok());
wizard.submitted();
assert_eq!(wizard.step, WizardStep::Progress);
assert!(wizard.submitting);
}
#[test]
fn loader_selection_clears_loader_version_for_vanilla() {
let mut wizard = WizardState::open();
wizard.loader_version = "0.16.5".to_string();
wizard.select_loader(LoaderKind::Fabric);
assert_eq!(wizard.loader_version, "0.16.5");
wizard.select_loader(LoaderKind::Vanilla);
assert_eq!(wizard.loader, LoaderKind::Vanilla);
assert!(wizard.loader_version.is_empty());
}
#[test]
fn name_validation_rejects_long_and_control_input() {
assert!(validate_name("生存").is_ok());
assert!(validate_name("").is_err());
assert!(validate_name(&"x".repeat(65)).is_err());
assert!(validate_name("a\u{7}b").is_err());
}
}
@@ -0,0 +1,73 @@
// 安装进度卡片(下载页底部 / 向导进度步共用):阶段 + 进度条 + 文件/字节/速度/剩余 + 取消。
import { Theme } from "../theme/tokens.slint";
import { Metrics } from "../theme/metrics.slint";
import { UiInstallProgress, InstallStage } from "../models.slint";
import { UiText, TextRole } from "../components/label.slint";
import { ProgressBar } from "../components/progress-bar.slint";
import { AppIcon, IconKind } from "../components/icons.slint";
import { MetroButton, ButtonKind } from "../widgets/button.slint";
export component InstallPanel inherits Rectangle {
in property <UiInstallProgress> data;
callback cancel();
height: 96px;
visible: root.data.visible;
background: Theme.bg-surface;
border-width: 1px;
border-color: Theme.stroke;
VerticalLayout {
padding: Metrics.gap-3;
spacing: Metrics.gap-1;
HorizontalLayout {
spacing: Metrics.gap-2;
AppIcon {
icon-size: 16px;
kind: root.data.failed ? IconKind.error
: root.data.terminal ? IconKind.check
: IconKind.download;
tint: root.data.failed ? Theme.state-danger
: root.data.terminal ? Theme.state-success
: Theme.accent;
}
UiText {
role: TextRole.bodyStrong;
text: root.data.instance + " · " + root.data.loader;
horizontal-stretch: 1;
}
UiText {
role: TextRole.caption;
text: root.data.stage_label;
color: root.data.failed ? Theme.state-danger : Theme.fg-secondary;
}
if !root.data.terminal : MetroButton {
kind: ButtonKind.subtle;
text: "取消";
button-height: 24px;
clicked => { root.cancel(); }
}
}
ProgressBar {
ratio: root.data.ratio;
known: root.data.known;
}
HorizontalLayout {
spacing: Metrics.gap-3;
UiText {
role: TextRole.caption;
text: root.data.message;
horizontal-stretch: 1;
overflow: elide;
}
UiText { role: TextRole.caption; text: root.data.files_label; }
UiText { role: TextRole.caption; text: root.data.bytes_label; }
UiText { role: TextRole.caption; text: root.data.speed; color: Theme.accent; }
UiText { role: TextRole.caption; text: root.data.eta; }
}
}
}
+57
View File
@@ -0,0 +1,57 @@
// 横幅通知条:隔离档位切换提示(M30 ModeChangeWarning)/ 启动失败 / 启动告警。
// 与 Toast 的分工:Toast 是「刚发生了什么」,通知条是「现在需要你处理」(用户点了才收)。
import { Theme } from "../theme/tokens.slint";
import { Metrics } from "../theme/metrics.slint";
import { UiNotice, NoticeKind } from "../models.slint";
import { PageState } from "../page_state.slint";
import { UiText, TextRole } from "../components/label.slint";
import { AppIcon, IconKind } from "../components/icons.slint";
import { MetroButton, ButtonKind } from "../widgets/button.slint";
// 横幅通知:隔离档位切换提示(M30 ModeChangeWarning)/ 启动失败 / 启动告警。
// 与 Toast 的分工:Toast 是「刚发生了什么」,通知条是「现在需要你处理」。
export component NoticeBar inherits Rectangle {
in property <[UiNotice]> notices;
visible: root.notices.length > 0;
height: root.notices.length > 0 ? 48px : 0px;
background: Theme.state-warn-soft;
HorizontalLayout {
padding-left: Metrics.gap-4;
padding-right: Metrics.gap-3;
spacing: Metrics.gap-2;
for notice in root.notices : Rectangle {
horizontal-stretch: 1;
background: transparent;
HorizontalLayout {
spacing: Metrics.gap-2;
AppIcon {
icon-size: 16px;
kind: notice.kind == NoticeKind.error ? IconKind.error : IconKind.warn;
tint: notice.kind == NoticeKind.error ? Theme.state-danger : Theme.state-warn;
}
VerticalLayout {
vertical-stretch: 1;
spacing: 0px;
UiText { role: TextRole.caption; text: notice.title; horizontal-stretch: 1; }
UiText {
role: TextRole.caption;
text: notice.body;
horizontal-stretch: 1;
overflow: elide;
color: Theme.fg-secondary;
}
}
MetroButton {
kind: ButtonKind.subtle;
text: "知道了";
button-height: 22px;
clicked => { PageState.notice-dismiss(notice.id); }
}
}
}
}
}
@@ -0,0 +1,34 @@
// 细进度条(0.0~1.0):安装进度 / 下载页顶部 / 向导进度共用。
// 未知总量(known = false)时画一条不确定态的弱色轨道,而不是假装 0%。
import { Theme } from "../theme/tokens.slint";
import { Metrics } from "../theme/metrics.slint";
export component ProgressBar inherits Rectangle {
in property <float> ratio: 0.0;
in property <bool> known: true;
in property <length> bar-height: 6px;
height: root.bar-height;
border-radius: root.bar-height / 2;
background: Theme.bg-hover;
clip: true;
Rectangle {
x: 0;
y: 0;
width: root.known ? max(0px, root.width * clamp(root.ratio, 0.0, 1.0)) : 0px;
height: root.height;
border-radius: root.bar-height / 2;
background: Theme.accent;
animate width { duration: 180ms; easing: ease-out; }
}
if !root.known : Rectangle {
x: 0;
y: 0;
width: root.width / 3;
height: root.height;
border-radius: root.bar-height / 2;
background: Theme.accent-soft;
}
}
+2
View File
@@ -9,6 +9,8 @@
// 亮/暗双套色板(LightPalette/DarkPalette)、主题三态解析、排版 ramp 与字体栈;M11 的占位 // 亮/暗双套色板(LightPalette/DarkPalette)、主题三态解析、排版 ramp 与字体栈;M11 的占位
// NavItem/Tile 组件由 `components/nav.slint` 与 `components/tile.slint` 取代,占位内核不再保留。 // NavItem/Tile 组件由 `components/nav.slint` 与 `components/tile.slint` 取代,占位内核不再保留。
// P3:页面状态 global 从根文件再导出一遍,Rust 侧才能拿到 `PageState` 类型。
export { PageState } from "page_state.slint";
import { AppShell } from "shell.slint"; import { AppShell } from "shell.slint";
import { Theme } from "theme/tokens.slint"; import { Theme } from "theme/tokens.slint";
import { Metrics } from "theme/metrics.slint"; import { Metrics } from "theme/metrics.slint";
+146
View File
@@ -216,3 +216,149 @@ export struct UiToast {
kind: ToastKind, kind: ToastKind,
text: string, text: string,
} }
// ── P3 全量接线的视图模型(账户 / Java / 安装 / 设置分组 / 通知 / 向导)──────────
// 这些类型由 Rust 的 dto 单向转换(`conv/pages.rs`),Slint 只读不写业务真相。
// 全部经 `page_state.slint` 的 `PageState` global 下发(避免 main/shell 的属性转发膨胀)。
export enum AccountKind {
offline,
microsoft,
authlib,
}
export struct UiAccount {
id: string,
name: string,
kind: AccountKind,
kind_label: string,
profile_id: string,
login_name: string,
created: string,
active: bool,
}
/// 微软登录面板(用户码 / 授权链接都不是凭据,可直接展示)。
export struct UiDeviceCode {
visible: bool,
busy: bool,
user_code: string,
verification_uri: string,
qr_link: string,
expires: int,
message: string,
phase_label: string,
}
export enum JavaStepOutcome {
pending,
skip,
failed,
hit,
}
export struct UiJavaStep {
vendor: string,
outcome: JavaStepOutcome,
outcome_label: string,
detail: string,
}
export struct UiJavaVendor {
id: string,
display: string,
rank: int,
majors: string,
sources: string,
installed: bool,
}
export struct UiJavaRuntime {
binary: string,
version: string,
vendor: string,
origin: string,
}
export enum InstallStage {
planning,
downloading,
installing,
registering,
done,
failed,
cancelled,
}
export struct UiInstallProgress {
visible: bool,
version: string,
instance: string,
loader: string,
stage: InstallStage,
stage_label: string,
ratio: float,
known: bool,
files_label: string,
bytes_label: string,
speed: string,
eta: string,
message: string,
terminal: bool,
failed: bool,
}
export enum SettingControl {
toggle,
choice,
text,
readonly,
action,
}
export struct UiSettingRow {
key: string,
label: string,
description: string,
control: SettingControl,
flag: bool,
selected: int,
options: [string],
value_text: string,
action: string,
}
export struct UiSettingGroup {
id: string,
title: string,
rows: [UiSettingRow],
}
export enum NoticeKind {
info,
warn,
error,
}
export struct UiNotice {
id: string,
kind: NoticeKind,
title: string,
body: string,
}
export struct UiWizard {
open: bool,
step: int,
step_label: string,
step_index: int,
step_total: int,
version: string,
loader: int,
loader_label: string,
loader_version: string,
name: string,
error: string,
busy: bool,
can-next: bool,
}
+77
View File
@@ -0,0 +1,77 @@
// P3 全量接线的页面状态:一个导出的 global,Slint 任何页面都能直接读/回推。
//
// 为什么用 global 而不是继续经 main.slint → shell.slint 逐层转发:属性表已经 100+ 条,
// 再加五页的数据会把 main.slint 顶破 400 行上限(AGENTS §3)。global 在同一个 Slint
// 编译单元内是单例,Rust 侧用 `ui.global::<PageState>()` 读写,语义与 property 相同。
//
// 纪律:这里只放「页面要显示什么」与「用户点了什么」,业务真相在 Rust 的 state.rs。
import { UiAccount, UiDeviceCode, UiJavaVendor, UiJavaStep, UiJavaRuntime, UiInstallProgress, UiSettingGroup, UiNotice, UiWizard, UiVersion } from "models.slint";
export global PageState {
// ── 账户页 ────────────────────────────────────────────────
in-out property <[UiAccount]> accounts;
in property <string> active-account: "";
in property <UiDeviceCode> device;
/// 离线添加表单的输入(不落盘,点「添加」才提交)
in-out property <string> offline-name: "";
// ── Java(下载页 Java 分组 / 设置页 Java 分组)──────────────
in property <[UiJavaVendor]> java-vendors;
in property <[UiJavaStep]> java-chain;
in property <[UiJavaRuntime]> java-runtimes;
in property <string> java-order: "";
in property <string> java-region: "";
in property <string> java-priority-source: "";
in property <string> java-message: "";
in property <bool> java-busy: false;
in property <int> java-target-major: 17;
// ── 安装进度(下载页 + 向导共用)────────────────────────────
in property <UiInstallProgress> install;
/// 下载页右侧视图:0 = 版本列表,1 = Java 运行时(截图通道也从这里切)
in-out property <int> download-tab: 0;
// ── 设置分组(settings_bridge 同源 launcher.json)──────────
in property <[UiSettingGroup]> setting-groups;
in-out property <string> setting-tab: "download";
// ── 通知条 / 向导 ─────────────────────────────────────────
in property <[UiNotice]> notices;
in property <UiWizard> wizard;
in property <[UiVersion]> wizard-versions;
// ── 账户 ──────────────────────────────────────────────────
callback account-add-offline(string);
callback account-remove(string);
callback account-switch(string);
callback msa-begin();
callback msa-browser-begin();
callback msa-cancel();
// ── Java ──────────────────────────────────────────────────
callback java-install(string);
// ── 设置 ──────────────────────────────────────────────────
callback setting-changed(string, string);
callback setting-action(string);
callback settings-tab-changed(string);
// ── 通知 ──────────────────────────────────────────────────
callback notice-dismiss(string);
// ── 向导 ──────────────────────────────────────────────────
callback wizard-open();
callback wizard-close();
callback wizard-select-version(string);
callback wizard-select-loader(int);
callback wizard-name-changed(string);
callback wizard-next();
callback wizard-back();
callback wizard-confirm();
callback install-cancel();
// ── 日志页:崩溃日志导入 / 手动刷新 ──────────────────────────
in-out property <string> crash-path: "";
callback crash-import(string);
callback logs-refresh();
}
+204
View File
@@ -0,0 +1,204 @@
// 下载页 · Java 分组(G2 的第二个分类):四家发行版 + 回退链可视化 + 本地运行时。
//
// 回退链是「可观测性」核心:安装失败时用户要能看见 Temurin 跳过 → Oracle 失败 →
// Microsoft 命中的每一步原因(D23),而不是只看到一句「下载失败」。
import { Theme } from "../theme/tokens.slint";
import { Metrics } from "../theme/metrics.slint";
import { UiJavaVendor, UiJavaStep, UiJavaRuntime, JavaStepOutcome } from "../models.slint";
import { PageState } from "../page_state.slint";
import { UiText, TextRole } from "../components/label.slint";
import { Badge } from "../components/badge.slint";
import { AppIcon, IconKind } from "../components/icons.slint";
import { MetroButton, ButtonKind } from "../widgets/button.slint";
import { ScrollView } from "std-widgets.slint";
component ChainStep inherits Rectangle {
in property <UiJavaStep> step;
height: 26px;
background: transparent;
HorizontalLayout {
spacing: Metrics.gap-1;
AppIcon {
icon-size: 12px;
kind: root.step.outcome == JavaStepOutcome.hit ? IconKind.check
: root.step.outcome == JavaStepOutcome.failed ? IconKind.error
: root.step.outcome == JavaStepOutcome.skip ? IconKind.warn
: IconKind.more;
tint: root.step.outcome == JavaStepOutcome.hit ? Theme.state-success
: root.step.outcome == JavaStepOutcome.failed ? Theme.state-danger
: Theme.fg-secondary;
}
UiText {
role: TextRole.caption;
text: root.step.vendor;
width: 72px;
color: root.step.outcome == JavaStepOutcome.hit ? Theme.state-success : Theme.fg-primary;
}
Badge {
text: root.step.outcome_label;
tint: root.step.outcome == JavaStepOutcome.hit ? Theme.state-success
: root.step.outcome == JavaStepOutcome.failed ? Theme.state-danger
: Theme.fg-secondary;
}
UiText {
role: TextRole.caption;
text: root.step.detail;
horizontal-stretch: 1;
overflow: elide;
}
}
}
component VendorRow inherits Rectangle {
in property <UiJavaVendor> data;
callback install();
height: 56px;
background: vendor-touch.has-hover ? Theme.bg-hover : transparent;
HorizontalLayout {
padding-left: Metrics.gap-3;
padding-right: Metrics.gap-3;
spacing: Metrics.gap-2;
UiText {
role: TextRole.bodyStrong;
text: root.data.rank + ". " + root.data.display;
width: 170px;
}
if root.data.installed : Badge { text: "已装"; tint: Theme.state-success; }
UiText {
role: TextRole.caption;
text: "Java " + root.data.majors;
width: 96px;
}
UiText {
role: TextRole.caption;
text: root.data.sources;
horizontal-stretch: 1;
overflow: elide;
}
MetroButton {
kind: ButtonKind.standard;
text: "安装此家";
button-height: 26px;
enabled: !PageState.java-busy;
clicked => { root.install(); }
}
}
vendor-touch := TouchArea {
mouse-cursor: pointer;
clicked => { root.install(); }
}
}
export component JavaPanel inherits Rectangle {
background: Theme.bg-canvas;
VerticalLayout {
// 顶栏:地区 / 优先级来源 / 目标大版本 / 一键按回退链安装
Rectangle {
height: 56px;
background: Theme.bg-canvas;
HorizontalLayout {
padding-left: Metrics.gap-4;
padding-right: Metrics.gap-4;
spacing: Metrics.gap-3;
UiText {
role: TextRole.subtitle;
text: "Java 运行时";
width: 110px;
}
UiText {
role: TextRole.caption;
text: "地区 " + PageState.java-region + " · 优先级 " + PageState.java-priority-source
+ " · 目标 Java " + PageState.java-target-major;
horizontal-stretch: 1;
overflow: elide;
}
MetroButton {
kind: ButtonKind.accent;
text: PageState.java-busy ? "安装中…" : "按回退链安装";
button-height: 28px;
enabled: !PageState.java-busy;
clicked => { PageState.java-install(""); }
}
}
}
UiText {
x: Metrics.gap-4;
role: TextRole.caption;
text: PageState.java-order;
horizontal-stretch: 1;
overflow: elide;
}
if PageState.java-message != "" : Rectangle {
height: 30px;
background: Theme.accent-soft;
UiText {
x: Metrics.gap-4;
role: TextRole.caption;
text: PageState.java-message;
overflow: elide;
}
}
Rectangle { height: 1px; background: Theme.stroke; }
ScrollView {
vertical-stretch: 1;
VerticalLayout {
padding-bottom: Metrics.gap-4;
// 回退链
UiText {
x: Metrics.gap-4;
role: TextRole.bodyStrong;
text: "回退链";
}
for step in PageState.java-chain : ChainStep { step: step; }
UiText {
x: Metrics.gap-4;
role: TextRole.bodyStrong;
text: "四家发行版";
}
for vendor in PageState.java-vendors : VendorRow {
data: vendor;
install => { PageState.java-install(vendor.id); }
}
UiText {
x: Metrics.gap-4;
role: TextRole.bodyStrong;
text: "本地已发现";
}
if PageState.java-runtimes.length == 0 : UiText {
x: Metrics.gap-4;
role: TextRole.caption;
text: "还没有探测到 Java:点上面的「按回退链安装」装一个托管 JDK。";
}
for runtime in PageState.java-runtimes : Rectangle {
height: 24px;
HorizontalLayout {
padding-left: Metrics.gap-4;
spacing: Metrics.gap-2;
UiText { role: TextRole.caption; text: "Java " + runtime.version; width: 120px; }
UiText { role: TextRole.caption; text: runtime.vendor; width: 96px; }
UiText { role: TextRole.caption; text: runtime.origin; width: 80px; }
UiText { role: TextRole.caption; text: runtime.binary; horizontal-stretch: 1; overflow: elide; }
}
}
}
}
}
}
+174 -124
View File
@@ -1,14 +1,19 @@
// G2 下载页(09 §2 P0·G2 / Q24-4 定案):页内独立左侧分类栏(常驻,非 tab)+ 右侧版本列表。 // G2 下载页(09 §2 P0·G2 / Q24-4 定案):页内独立左侧分类栏(常驻,非 tab)+ 右侧版本列表。
// 远古版入口显式可达(ULL 对 HMCL「隐藏」行为的纠偏),下挂 classic/alpha/beta/unlisted 子类树, // 远古版入口显式可达(ULL 对 HMCL「隐藏」行为的纠偏),下挂 classic/alpha/beta/unlisted 子类树,
// 进入远古版分类时顶部提示兼容性 + 每行带「远古版」徽标(防误装)。 // 进入远古版分类时顶部提示兼容性 + 每行带「远古版」徽标(防误装)。
//
// P3 接线:右侧多一个「Java 运行时」视图(四家发行版 + 回退链),页面底部常驻安装进度条。
import { Theme } from "../theme/tokens.slint"; import { Theme } from "../theme/tokens.slint";
import { Metrics } from "../theme/metrics.slint"; import { Metrics } from "../theme/metrics.slint";
import { UiVersion, VersionKind, DownloadCategory } from "../models.slint"; import { UiVersion, VersionKind, DownloadCategory } from "../models.slint";
import { PageState } from "../page_state.slint";
import { UiText, TextRole } from "../components/label.slint"; import { UiText, TextRole } from "../components/label.slint";
import { AppIcon, IconKind } from "../components/icons.slint"; import { AppIcon, IconKind } from "../components/icons.slint";
import { Badge } from "../components/badge.slint"; import { Badge } from "../components/badge.slint";
import { MetroButton, ButtonKind } from "../widgets/button.slint"; import { MetroButton, ButtonKind } from "../widgets/button.slint";
import { SearchField, CheckBox } from "../widgets/inputs.slint"; import { SearchField, CheckBox } from "../widgets/inputs.slint";
import { InstallPanel } from "../components/install-panel.slint";
import { JavaPanel } from "download-java.slint";
import { ListView } from "std-widgets.slint"; import { ListView } from "std-widgets.slint";
component CategoryRow inherits Rectangle { component CategoryRow inherits Rectangle {
@@ -112,6 +117,8 @@ export component DownloadPage inherits Rectangle {
in-out property <string> query: ""; in-out property <string> query: "";
/// 远古版子类树是否展开(默认展开,显式可达) /// 远古版子类树是否展开(默认展开,显式可达)
in-out property <bool> ancient-expanded: true; in-out property <bool> ancient-expanded: true;
/// 右侧视图:0 = 版本列表,1 = Java 运行时
in-out property <int> view-tab <=> PageState.download-tab;
callback category-changed(DownloadCategory); callback category-changed(DownloadCategory);
callback install(string); callback install(string);
@@ -123,148 +130,191 @@ export component DownloadPage inherits Rectangle {
background: Theme.bg-canvas; background: Theme.bg-canvas;
HorizontalLayout { VerticalLayout {
// ── 页内分类侧栏(常驻)─────────────────────────────────── // 视图切换(版本 / Java)
Rectangle { Rectangle {
width: 200px; height: 40px;
background: Theme.bg-surface; background: Theme.bg-surface;
border-width: 1px; HorizontalLayout {
border-color: Theme.stroke; padding-left: Metrics.gap-4;
spacing: Metrics.gap-2;
UiText {
role: TextRole.subtitle;
text: "版本获取";
width: 96px;
}
MetroButton {
kind: root.view-tab == 0 ? ButtonKind.accent : ButtonKind.subtle;
text: "版本列表";
button-height: 26px;
clicked => { root.view-tab = 0; }
}
MetroButton {
kind: root.view-tab == 1 ? ButtonKind.accent : ButtonKind.subtle;
text: "Java 运行时";
button-height: 26px;
clicked => { root.view-tab = 1; }
}
Rectangle { horizontal-stretch: 1; background: transparent; }
if PageState.java-busy : UiText {
role: TextRole.caption;
text: "Java 安装中…";
color: Theme.accent;
}
}
}
Rectangle { height: 1px; background: Theme.stroke; }
if root.view-tab == 0 : HorizontalLayout {
vertical-stretch: 1;
// ── 页内分类侧栏(常驻)───────────────────────────────────
Rectangle {
width: 200px;
background: Theme.bg-surface;
border-width: 1px;
border-color: Theme.stroke;
VerticalLayout {
padding-top: Metrics.gap-2;
CategoryRow {
label: "正式版 release";
active: root.category == DownloadCategory.release;
clicked => { root.category-changed(DownloadCategory.release); }
}
CategoryRow {
label: "预览版 snapshot";
active: root.category == DownloadCategory.snapshot;
clicked => { root.category-changed(DownloadCategory.snapshot); }
}
// 远古版:可展开的父节点 + 子类树
CategoryRow {
label: root.ancient-expanded ? "远古版 ▾" : "远古版 ▸";
active: root.category == DownloadCategory.ancientClassic
|| root.category == DownloadCategory.ancientAlpha
|| root.category == DownloadCategory.ancientBeta
|| root.category == DownloadCategory.ancientUnlisted;
clicked => { root.toggle-ancient(); }
}
if root.ancient-expanded : CategoryRow {
label: "classic(rd-*)";
sub: true;
active: root.category == DownloadCategory.ancientClassic;
clicked => { root.category-changed(DownloadCategory.ancientClassic); }
}
if root.ancient-expanded : CategoryRow {
label: "old_alpha(a1.*)";
sub: true;
active: root.category == DownloadCategory.ancientAlpha;
clicked => { root.category-changed(DownloadCategory.ancientAlpha); }
}
if root.ancient-expanded : CategoryRow {
label: "old_beta(b1.*)";
sub: true;
active: root.category == DownloadCategory.ancientBeta;
clicked => { root.category-changed(DownloadCategory.ancientBeta); }
}
if root.ancient-expanded : CategoryRow {
label: "unlisted 汇总";
sub: true;
active: root.category == DownloadCategory.ancientUnlisted;
clicked => { root.category-changed(DownloadCategory.ancientUnlisted); }
}
CategoryRow {
label: "整合包(P1)";
disabled: true;
}
CategoryRow {
label: "Mod(P1)";
disabled: true;
}
Rectangle { vertical-stretch: 1; background: transparent; }
}
}
// ── 右侧版本列表 ──────────────────────────────────────────
VerticalLayout { VerticalLayout {
padding-top: Metrics.gap-2; vertical-stretch: 1;
CategoryRow { Rectangle {
label: "正式版 release"; height: Metrics.page-header-height;
active: root.category == DownloadCategory.release; background: Theme.bg-canvas;
clicked => { root.category-changed(DownloadCategory.release); }
} HorizontalLayout {
CategoryRow { padding-left: Metrics.gap-4;
label: "预览版 snapshot"; padding-right: Metrics.gap-4;
active: root.category == DownloadCategory.snapshot; spacing: Metrics.gap-3;
clicked => { root.category-changed(DownloadCategory.snapshot); }
SearchField {
width: 220px;
placeholder: "筛选版本号";
text <=> root.query;
edited(t) => { root.query-changed(t); }
}
CheckBox {
text: "仅已装";
checked: root.installed-only;
toggled(v) => { root.installed-only-changed(v); }
}
Rectangle { horizontal-stretch: 1; background: transparent; }
}
} }
// 远古版:可展开的父节点 + 子类树 // 远古版兼容性提示条(首次进入显示,显式可达的代价告知)
CategoryRow { Rectangle {
label: root.ancient-expanded ? "远古版 ▾" : "远古版 ▸"; height: 44px;
active: root.category == DownloadCategory.ancientClassic visible: root.category == DownloadCategory.ancientClassic
|| root.category == DownloadCategory.ancientAlpha || root.category == DownloadCategory.ancientAlpha
|| root.category == DownloadCategory.ancientBeta || root.category == DownloadCategory.ancientBeta
|| root.category == DownloadCategory.ancientUnlisted; || root.category == DownloadCategory.ancientUnlisted;
clicked => { root.toggle-ancient(); } background: Theme.state-warn-soft;
}
if root.ancient-expanded : CategoryRow { HorizontalLayout {
label: "classic(rd-*)"; padding-left: Metrics.gap-4;
sub: true; padding-right: Metrics.gap-4;
active: root.category == DownloadCategory.ancientClassic; spacing: Metrics.gap-2;
clicked => { root.category-changed(DownloadCategory.ancientClassic); }
} AppIcon { kind: IconKind.warn; icon-size: 16px; tint: Theme.state-warn; }
if root.ancient-expanded : CategoryRow { UiText {
label: "old_alpha(a1.*)"; role: TextRole.caption;
sub: true; text: "远古版(classic/alpha/beta)需要旧版 Java 与旧启动参数,装完请先看兼容性说明。";
active: root.category == DownloadCategory.ancientAlpha; wrap: word-wrap;
clicked => { root.category-changed(DownloadCategory.ancientAlpha); } horizontal-stretch: 1;
} }
if root.ancient-expanded : CategoryRow { MetroButton {
label: "old_beta(b1.*)"; kind: ButtonKind.subtle;
sub: true; text: "说明";
active: root.category == DownloadCategory.ancientBeta; button-height: 26px;
clicked => { root.category-changed(DownloadCategory.ancientBeta); } clicked => { root.ancient-help(); }
} }
if root.ancient-expanded : CategoryRow { }
label: "unlisted 汇总";
sub: true;
active: root.category == DownloadCategory.ancientUnlisted;
clicked => { root.category-changed(DownloadCategory.ancientUnlisted); }
} }
CategoryRow { Rectangle { height: 1px; background: Theme.stroke; }
label: "整合包(P1)";
disabled: true;
}
CategoryRow {
label: "Mod(P1)";
disabled: true;
}
Rectangle { vertical-stretch: 1; background: transparent; } ListView {
vertical-stretch: 1;
for v in root.versions : VersionRow {
data: v;
install => { root.install(v.id); }
}
}
} }
} }
// ── 右侧版本列表 ────────────────────────────────────────── if root.view-tab == 1 : JavaPanel {
VerticalLayout {
vertical-stretch: 1; vertical-stretch: 1;
}
Rectangle { // 安装进度(下载页行内安装 / 向导共用同一份进度)
height: Metrics.page-header-height; InstallPanel {
background: Theme.bg-canvas; data: PageState.install;
cancel => { PageState.install-cancel(); }
HorizontalLayout {
padding-left: Metrics.gap-4;
padding-right: Metrics.gap-4;
spacing: Metrics.gap-3;
UiText {
role: TextRole.subtitle;
text: "版本获取";
width: 96px;
}
SearchField {
width: 220px;
placeholder: "筛选版本号";
text <=> root.query;
edited(t) => { root.query-changed(t); }
}
CheckBox {
text: "仅已装";
checked: root.installed-only;
toggled(v) => { root.installed-only-changed(v); }
}
Rectangle { horizontal-stretch: 1; background: transparent; }
}
}
// 远古版兼容性提示条(首次进入显示,显式可达的代价告知)
Rectangle {
height: 44px;
visible: root.category == DownloadCategory.ancientClassic
|| root.category == DownloadCategory.ancientAlpha
|| root.category == DownloadCategory.ancientBeta
|| root.category == DownloadCategory.ancientUnlisted;
background: Theme.state-warn-soft;
HorizontalLayout {
padding-left: Metrics.gap-4;
padding-right: Metrics.gap-4;
spacing: Metrics.gap-2;
AppIcon { kind: IconKind.warn; icon-size: 16px; tint: Theme.state-warn; }
UiText {
role: TextRole.caption;
text: "远古版(classic/alpha/beta)需要旧版 Java 与旧启动参数,装完请先看兼容性说明。";
wrap: word-wrap;
horizontal-stretch: 1;
}
MetroButton {
kind: ButtonKind.subtle;
text: "说明";
button-height: 26px;
clicked => { root.ancient-help(); }
}
}
}
Rectangle { height: 1px; background: Theme.stroke; }
ListView {
vertical-stretch: 1;
for v in root.versions : VersionRow {
data: v;
install => { root.install(v.id); }
}
}
} }
} }
} }
+49 -19
View File
@@ -8,6 +8,7 @@ import { AppIcon, IconKind } from "../components/icons.slint";
import { Badge } from "../components/badge.slint"; import { Badge } from "../components/badge.slint";
import { MetroButton, ButtonKind } from "../widgets/button.slint"; import { MetroButton, ButtonKind } from "../widgets/button.slint";
import { SearchField } from "../widgets/inputs.slint"; import { SearchField } from "../widgets/inputs.slint";
import { PageState } from "../page_state.slint";
import { ListView } from "std-widgets.slint"; import { ListView } from "std-widgets.slint";
component SessionRow inherits Rectangle { component SessionRow inherits Rectangle {
@@ -106,6 +107,8 @@ export component LogsPage inherits Rectangle {
callback ask-ai(string); callback ask-ai(string);
callback apply-fix(); callback apply-fix();
callback query-changed(string); callback query-changed(string);
/// 重新扫会话索引(手动刷新)
callback refresh();
background: Theme.bg-canvas; background: Theme.bg-canvas;
@@ -157,30 +160,57 @@ export component LogsPage inherits Rectangle {
VerticalLayout { VerticalLayout {
vertical-stretch: 1; vertical-stretch: 1;
// 会话头:标题 + 实时标记 + 筛选 + 刷新(第一行),崩溃日志导入(第二行)
Rectangle { Rectangle {
height: 44px; height: 84px;
background: Theme.bg-surface; background: Theme.bg-surface;
HorizontalLayout { VerticalLayout {
padding-left: Metrics.gap-3; HorizontalLayout {
padding-right: Metrics.gap-3; height: 44px;
spacing: Metrics.gap-2; padding-left: Metrics.gap-3;
padding-right: Metrics.gap-3;
spacing: Metrics.gap-2;
UiText { UiText {
role: TextRole.bodyStrong; role: TextRole.bodyStrong;
text: root.selected == "" ? "未选择会话" : root.selected; text: root.selected == "" ? "未选择会话" : root.selected;
horizontal-stretch: 1; horizontal-stretch: 1;
overflow: elide;
}
if root.live : Badge { text: "实时"; tint: Theme.state-success; }
SearchField {
width: 140px;
placeholder: "筛选日志行";
text <=> root.query;
edited(t) => { root.query-changed(t); }
}
MetroButton {
kind: ButtonKind.subtle;
text: "刷新";
button-height: 26px;
clicked => { root.refresh(); }
}
} }
SearchField {
width: 200px; // 崩溃日志导入分析(hs_err_pid*.log / crash-*.txt / latest.log)
placeholder: "筛选日志行"; HorizontalLayout {
text <=> root.query; height: 40px;
edited(t) => { root.query-changed(t); } padding-left: Metrics.gap-3;
} padding-right: Metrics.gap-3;
MetroButton { spacing: Metrics.gap-2;
kind: ButtonKind.subtle; SearchField {
text: "打开目录"; horizontal-stretch: 1;
button-height: 26px; placeholder: "崩溃日志路径(如 .minecraft/crash-reports/crash-*.txt)";
text <=> PageState.crash-path;
}
MetroButton {
kind: ButtonKind.standard;
text: "导入分析";
button-height: 26px;
enabled: PageState.crash-path != "";
clicked => { PageState.crash-import(PageState.crash-path); }
}
} }
} }
} }
+201
View File
@@ -0,0 +1,201 @@
// 设置 → 账户(D9 / G6):三轨账户列表 + 离线添加 + 微软登录(授权码浏览器 / 设备码)+ 删除/切换。
//
// 凭据红线:这里能看到的只有「名字 / 轨道 / 公开 UUID / 登录名 / 创建时间」——
// token 与密码永远不进 UI(Rust 侧的 AccountView 不含这些字段)。
import { Theme } from "../theme/tokens.slint";
import { Metrics } from "../theme/metrics.slint";
import { AccountKind, UiAccount } from "../models.slint";
import { PageState } from "../page_state.slint";
import { UiText, TextRole } from "../components/label.slint";
import { Badge } from "../components/badge.slint";
import { AppIcon, IconKind } from "../components/icons.slint";
import { MetroButton, ButtonKind } from "../widgets/button.slint";
import { SearchField } from "../widgets/inputs.slint";
import { SettingGroup } from "../widgets/setting-item.slint";
component AccountRow inherits Rectangle {
in property <UiAccount> data;
callback activate();
callback remove();
height: 56px;
background: root.data.active ? Theme.accent-soft : transparent;
if root.data.active : Rectangle {
x: 0;
y: 0;
width: 3px;
height: root.height;
background: Theme.accent;
}
HorizontalLayout {
padding-left: Metrics.gap-3;
padding-right: Metrics.gap-3;
spacing: Metrics.gap-2;
AppIcon {
icon-size: 16px;
kind: root.data.kind == AccountKind.offline ? IconKind.instances : IconKind.ai;
tint: root.data.active ? Theme.accent : Theme.fg-secondary;
}
VerticalLayout {
spacing: 0px;
vertical-stretch: 1;
UiText { role: TextRole.bodyStrong; text: root.data.name; horizontal-stretch: 1; }
UiText {
role: TextRole.caption;
text: root.data.profile_id + (root.data.login_name == "" ? "" : " · " + root.data.login_name);
horizontal-stretch: 1;
overflow: elide;
}
}
Badge { text: root.data.kind_label; tint: Theme.fg-secondary; }
UiText { role: TextRole.caption; text: root.data.created; width: 108px; }
if !root.data.active : MetroButton {
kind: ButtonKind.standard;
text: "用这个";
button-height: 24px;
clicked => { root.activate(); }
}
if root.data.active : Badge { text: "启动中"; tint: Theme.accent; }
MetroButton {
kind: ButtonKind.subtle;
text: "删除";
button-height: 24px;
clicked => { root.remove(); }
}
}
}
export component AccountSection inherits Rectangle {
background: transparent;
VerticalLayout {
// 微软登录面板(设备码显示用户码;授权码显示可复制的授权链接;失败显示原因)
if PageState.device.visible : Rectangle {
height: PageState.device.busy ? 96px : 84px;
background: PageState.device.phase_label == "失败" ? Theme.state-danger-soft : Theme.accent-soft;
VerticalLayout {
padding: Metrics.gap-3;
spacing: Metrics.gap-1;
HorizontalLayout {
spacing: Metrics.gap-2;
AppIcon {
icon-size: 16px;
kind: PageState.device.phase_label == "失败" ? IconKind.error : IconKind.ai;
tint: PageState.device.phase_label == "失败" ? Theme.state-danger : Theme.accent;
}
UiText {
role: TextRole.bodyStrong;
text: "微软登录 · " + PageState.device.phase_label;
horizontal-stretch: 1;
}
if PageState.device.busy : MetroButton {
kind: ButtonKind.subtle;
text: "取消";
button-height: 24px;
clicked => { PageState.msa-cancel(); }
}
if !PageState.device.busy : MetroButton {
kind: ButtonKind.subtle;
text: "关闭";
button-height: 24px;
clicked => { PageState.msa-cancel(); }
}
}
if PageState.device.user_code != "" : HorizontalLayout {
spacing: Metrics.gap-3;
UiText {
role: TextRole.title;
text: PageState.device.user_code;
color: Theme.accent;
}
UiText {
role: TextRole.caption;
text: "在浏览器打开 " + PageState.device.verification_uri + " 输入上面的用户码";
horizontal-stretch: 1;
overflow: elide;
}
}
UiText {
role: TextRole.caption;
text: PageState.device.message;
horizontal-stretch: 1;
wrap: word-wrap;
}
if PageState.device.qr_link != "" : UiText {
role: TextRole.monoSmall;
text: PageState.device.qr_link;
horizontal-stretch: 1;
overflow: elide;
}
}
}
// 离线添加表单
SettingGroup { title: "添加离线账户"; }
HorizontalLayout {
padding-left: Metrics.gap-4;
padding-right: Metrics.gap-4;
spacing: Metrics.gap-2;
SearchField {
width: 220px;
text <=> PageState.offline-name;
placeholder: "玩家名(离线 UUID 自动生成)";
}
MetroButton {
kind: ButtonKind.standard;
text: "添加离线";
button-height: 28px;
enabled: PageState.offline-name != "";
clicked => { PageState.account-add-offline(PageState.offline-name); }
}
MetroButton {
kind: ButtonKind.accent;
text: "微软登录";
button-height: 28px;
enabled: !PageState.device.busy;
clicked => { PageState.msa-browser-begin(); }
}
MetroButton {
kind: ButtonKind.subtle;
text: "设备码登录";
button-height: 28px;
enabled: !PageState.device.busy;
clicked => { PageState.msa-begin(); }
}
}
// 账户列表
SettingGroup { title: "账户(" + PageState.accounts.length + ")"; }
if PageState.accounts.length == 0 : Rectangle {
height: 72px;
VerticalLayout {
alignment: center;
spacing: 2px;
UiText {
role: TextRole.caption;
text: "还没有账户:启动时会用「Player」离线占位。";
horizontal-alignment: center;
}
}
}
for account in PageState.accounts : AccountRow {
data: account;
activate => { PageState.account-switch(account.id); }
remove => { PageState.account-remove(account.id); }
}
UiText {
role: TextRole.caption;
x: Metrics.gap-4;
text: "凭据只存在 <数据根>/data/auth/(账号文件与凭据文件分离),界面与 AI 都拿不到 token。";
horizontal-stretch: 1;
wrap: word-wrap;
}
}
}
+117
View File
@@ -0,0 +1,117 @@
// 设置页右栏的「schema 分组」渲染器:下载 / Java / 数据 / AI / MCP 五组。
//
// 行来自 Rust 侧的 `SettingGroupView`(core::setting schema 的键),这里只按控件类型画:
// 开关 → toggle;分段 → segmented;文本 → text;只读 → 纯文案;动作 → 按钮。
// 写回一律 `PageState.setting-changed(key, value)` / `setting-action(action)`,
// 由 host 走 `SettingStore::set`(同源 launcher.json)。
import { Theme } from "../theme/tokens.slint";
import { Metrics } from "../theme/metrics.slint";
import { UiSettingGroup, UiSettingRow, SettingControl } from "../models.slint";
import { PageState } from "../page_state.slint";
import { UiText, TextRole } from "../components/label.slint";
import { SettingItem, SettingGroup, ControlKind } from "../widgets/setting-item.slint";
component RowView inherits Rectangle {
in property <UiSettingRow> item;
/// 文本编辑的本地缓冲(编辑中不回写模型,失焦/回车才提交)
in-out property <string> draft;
height: root.item.control == SettingControl.readonly ? 48px
: root.item.description == "" ? 44px : 58px;
background: transparent;
init => { self.draft = root.item.value_text; }
changed item => { self.draft = root.item.value_text; }
if root.item.control == SettingControl.toggle : SettingItem {
label: root.item.label;
description: root.item.description;
kind: ControlKind.toggle;
checked: root.item.flag;
toggled(v) => { PageState.setting-changed(root.item.key, v ? "true" : "false"); }
}
if root.item.control == SettingControl.choice : SettingItem {
label: root.item.label;
description: root.item.description;
kind: ControlKind.segmented;
options: root.item.options;
selected: root.item.selected;
changed(i) => {
if (i >= 0 && i < root.item.options.length) {
PageState.setting-changed(root.item.key, root.item.options[i]);
}
}
}
if root.item.control == SettingControl.text : SettingItem {
label: root.item.label;
description: root.item.description;
kind: ControlKind.text;
value: root.draft;
edited(t) => {
root.draft = t;
PageState.setting-changed(root.item.key, t);
}
}
if root.item.control == SettingControl.action : SettingItem {
label: root.item.label;
description: root.item.description;
kind: ControlKind.button;
button-text: "执行";
clicked => { PageState.setting-action(root.item.action); }
}
if root.item.control == SettingControl.readonly : HorizontalLayout {
padding-left: Metrics.gap-4;
padding-right: Metrics.gap-4;
spacing: Metrics.gap-3;
UiText {
role: TextRole.body;
text: root.item.label;
width: 200px;
}
UiText {
role: TextRole.caption;
text: root.item.description;
horizontal-stretch: 1;
wrap: word-wrap;
}
}
}
export component SettingsGroups inherits Rectangle {
in property <string> tab: "";
background: transparent;
VerticalLayout {
padding-bottom: Metrics.gap-5;
if PageState.setting-groups.length == 0 : Rectangle {
height: 120px;
VerticalLayout {
alignment: center;
spacing: 2px;
UiText {
role: TextRole.caption;
text: "设置还没加载出来(host 未就绪或数据根不可写)";
horizontal-alignment: center;
}
}
}
for group in PageState.setting-groups : Rectangle {
visible: group.id == root.tab;
height: group.rows.length * 58px + 40px;
background: transparent;
VerticalLayout {
SettingGroup { title: group.title; }
for setting-row in group.rows : RowView { item: setting-row; }
}
}
}
}
+12 -95
View File
@@ -8,6 +8,9 @@ import { SettingSection, TileSize, ThemeMode, BgFillKind } from "../models.slint
import { UiText, TextRole } from "../components/label.slint"; import { UiText, TextRole } from "../components/label.slint";
import { SettingItem, SettingGroup, ControlKind } from "../widgets/setting-item.slint"; import { SettingItem, SettingGroup, ControlKind } from "../widgets/setting-item.slint";
import { AppearanceSection } from "settings-appearance.slint"; import { AppearanceSection } from "settings-appearance.slint";
import { AccountSection } from "settings-account.slint";
import { SettingsGroups } from "settings-groups.slint";
import { PageState } from "../page_state.slint";
import { SearchField } from "../widgets/inputs.slint"; import { SearchField } from "../widgets/inputs.slint";
import { AboutSlint, ScrollView } from "std-widgets.slint"; import { AboutSlint, ScrollView } from "std-widgets.slint";
@@ -235,101 +238,15 @@ export component SettingsPage inherits Rectangle {
toggled(v) => { root.close-to-tray-changed(v); } toggled(v) => { root.close-to-tray-changed(v); }
} }
// ── 数据 ──────────────────────────────────────── // ── 数据 / 下载 / Java / 账户 / AI / MCP ────────────
if root.section == SettingSection.data : SettingGroup { title: "数据目录(D6 三模式)"; } // 行来自 core::setting schema(`PageState.setting-groups`),写回走
if root.section == SettingSection.data : SettingItem { // `SettingStore::set`——与 `ull config set` / AI `set_config` 同一份 launcher.json。
label: "存储模式"; if root.section == SettingSection.data : SettingsGroups { tab: "data"; }
description: "便携(运行目录 .ULL/.minecraft)/ 标准(系统应用数据目录)/ 自定义"; if root.section == SettingSection.download : SettingsGroups { tab: "download"; }
kind: ControlKind.segmented; if root.section == SettingSection.java : SettingsGroups { tab: "java"; }
options: ["便携", "标准", "自定义"]; if root.section == SettingSection.ai : SettingsGroups { tab: "ai"; }
selected: root.data-mode; if root.section == SettingSection.ai : SettingsGroups { tab: "mcp"; }
changed(i) => { if root.section == SettingSection.account : AccountSection { }
root.data-mode = i;
root.data-mode-changed(i);
}
}
if root.section == SettingSection.data : SettingItem {
label: "当前路径";
description: root.data-path;
kind: ControlKind.button;
button-text: "迁移…";
clicked => { root.migrate-data(); }
}
// ── 下载 ────────────────────────────────────────
if root.section == SettingSection.download : SettingGroup { title: "下载(D7)"; }
if root.section == SettingSection.download : SettingItem {
label: "并发数";
kind: ControlKind.segmented;
options: ["4", "8", "16"];
selected: root.download-concurrency;
changed(i) => {
root.download-concurrency = i;
root.download-concurrency-changed(i);
}
}
if root.section == SettingSection.download : SettingItem {
label: "地区判定";
description: "当前:" + root.region-label + "(决定是否走国内镜像)";
kind: ControlKind.button;
button-text: "重新检测";
clicked => { root.detect-region(); }
}
// ── Java ────────────────────────────────────────
if root.section == SettingSection.java : SettingGroup { title: "Java(D13)"; }
if root.section == SettingSection.java : SettingItem {
label: "Java 管理";
description: "基准 JDK17 自动下载 · 多版本可选 · 不匹配时黄条提示";
kind: ControlKind.button;
button-text: "管理多版本";
clicked => { root.manage-java(); }
}
// ── 账户 ────────────────────────────────────────
if root.section == SettingSection.account : SettingGroup { title: "账户(D9)"; }
if root.section == SettingSection.account : SettingItem {
label: "当前账户";
description: root.account-label;
kind: ControlKind.button;
button-text: "登录 / 切换";
clicked => { root.login(); }
}
// ── AI ──────────────────────────────────────────
if root.section == SettingSection.ai : SettingGroup { title: "AI 助手(D4)"; }
if root.section == SettingSection.ai : SettingItem {
label: "模型端点";
description: "OpenAI 兼容 / 本地 llama.cpp / Anthropic / 自定义";
kind: ControlKind.segmented;
options: ["OpenAI", "llama.cpp", "Anthropic", "自定义"];
selected: root.ai-endpoint;
changed(i) => {
root.ai-endpoint = i;
root.ai-endpoint-changed(i);
}
}
if root.section == SettingSection.ai : SettingItem {
label: "YOLO 模式";
description: "跳过全部确认弹窗(审计照记),账户/凭据操作永不 YOLO";
kind: ControlKind.toggle;
checked: root.yolo;
toggled(v) => { root.yolo-changed(v); }
}
if root.section == SettingSection.ai : SettingItem {
label: "MCP server";
description: "允许外部客户端(如 Claude Desktop)调用工具集";
kind: ControlKind.toggle;
checked: root.mcp-enabled;
toggled(v) => { root.mcp-changed(v); }
}
if root.section == SettingSection.ai : SettingItem {
label: "审计日志";
description: "AI 的所有写操作都有记录";
kind: ControlKind.button;
button-text: "查看";
clicked => { root.open-audit-log(); }
}
// ── 代理 ──────────────────────────────────────── // ── 代理 ────────────────────────────────────────
if root.section == SettingSection.proxy : SettingGroup { title: "代理"; } if root.section == SettingSection.proxy : SettingGroup { title: "代理"; }
+14
View File
@@ -7,6 +7,8 @@ import {
RouteKind, TileSize, InstanceView, SettingSection, DownloadCategory, ThemeMode, BgFillKind, RouteKind, TileSize, InstanceView, SettingSection, DownloadCategory, ThemeMode, BgFillKind,
UiTile, UiVersion, UiLogSession, UiLogLine, UiDiagnosis, UiChatMessage, UiDialog, UiToast, UiTile, UiVersion, UiLogSession, UiLogLine, UiDiagnosis, UiChatMessage, UiDialog, UiToast,
} from "models.slint"; } from "models.slint";
import { PageState } from "page_state.slint";
import { NewInstanceWizard } from "widgets/wizard.slint";
import { TitleBar } from "components/titlebar.slint"; import { TitleBar } from "components/titlebar.slint";
import { Nav } from "components/nav.slint"; import { Nav } from "components/nav.slint";
import { AiPanel } from "components/ai-panel.slint"; import { AiPanel } from "components/ai-panel.slint";
@@ -14,11 +16,13 @@ import { UiText, TextRole } from "components/label.slint";
import { MetroButton, ButtonKind } from "widgets/button.slint"; import { MetroButton, ButtonKind } from "widgets/button.slint";
import { DialogHost } from "widgets/dialog.slint"; import { DialogHost } from "widgets/dialog.slint";
import { ToastHost } from "widgets/toast.slint"; import { ToastHost } from "widgets/toast.slint";
import { NoticeBar } from "components/notice-bar.slint";
import { InstancesPage } from "pages/instances.slint"; import { InstancesPage } from "pages/instances.slint";
import { DownloadPage } from "pages/download.slint"; import { DownloadPage } from "pages/download.slint";
import { LogsPage } from "pages/logs.slint"; import { LogsPage } from "pages/logs.slint";
import { SettingsPage } from "pages/settings.slint"; import { SettingsPage } from "pages/settings.slint";
export component AppShell inherits Rectangle { export component AppShell inherits Rectangle {
// ── 窗口 ──────────────────────────────────────────────────── // ── 窗口 ────────────────────────────────────────────────────
in property <string> app-title: "Unlucky Launcher"; in property <string> app-title: "Unlucky Launcher";
@@ -189,6 +193,10 @@ export component AppShell inherits Rectangle {
request-close => { root.request-close(); } request-close => { root.request-close(); }
} }
NoticeBar {
notices: PageState.notices;
}
HorizontalLayout { HorizontalLayout {
// 左侧主导航 // 左侧主导航
Nav { Nav {
@@ -259,6 +267,7 @@ export component AppShell inherits Rectangle {
select(id) => { root.log-select(id); } select(id) => { root.log-select(id); }
ask-ai(id) => { root.log-ask-ai(id); } ask-ai(id) => { root.log-ask-ai(id); }
apply-fix => { root.log-apply-fix(); } apply-fix => { root.log-apply-fix(); }
refresh => { PageState.logs-refresh(); }
} }
if root.route == RouteKind.settings : SettingsPage { if root.route == RouteKind.settings : SettingsPage {
@@ -356,6 +365,11 @@ export component AppShell inherits Rectangle {
} }
} }
// 新建实例向导(全屏模态;数据来自 PageState)
NewInstanceWizard {
versions: root.versions;
}
// 覆盖层:模态队列(一次一条)+ 右下 Toast // 覆盖层:模态队列(一次一条)+ 右下 Toast
DialogHost { DialogHost {
current: root.dialog; current: root.dialog;
+301
View File
@@ -0,0 +1,301 @@
// 新建实例向导(G5,全屏模态):版本 → 加载器 → 名字 → 确认 → 进度。
// 步骤推进/校验在 Rust(`crate::wizard`),这里只画当前步与提交按钮。
import { Theme } from "../theme/tokens.slint";
import { Metrics } from "../theme/metrics.slint";
import { UiVersion, VersionKind } from "../models.slint";
import { PageState } from "../page_state.slint";
import { UiText, TextRole } from "../components/label.slint";
import { Badge } from "../components/badge.slint";
import { AppIcon, IconKind } from "../components/icons.slint";
import { MetroButton, ButtonKind } from "../widgets/button.slint";
import { SearchField } from "../widgets/inputs.slint";
import { InstallPanel } from "../components/install-panel.slint";
import { ScrollView } from "std-widgets.slint";
component StepDot inherits Rectangle {
in property <int> index: 0;
in property <string> label: "";
height: 28px;
background: transparent;
HorizontalLayout {
spacing: Metrics.gap-1;
alignment: center;
Rectangle {
width: 18px;
height: 18px;
border-radius: 9px;
background: PageState.wizard.step >= root.index ? Theme.accent : Theme.bg-hover;
UiText {
role: TextRole.caption;
text: root.index + 1;
horizontal-alignment: center;
color: PageState.wizard.step >= root.index ? Theme.fg-on-accent : Theme.fg-secondary;
}
}
UiText {
role: TextRole.caption;
text: root.label;
color: PageState.wizard.step >= root.index ? Theme.fg-primary : Theme.fg-disabled;
}
}
}
component VersionChoice inherits Rectangle {
in property <UiVersion> data;
callback picked();
height: 36px;
background: PageState.wizard.version == root.data.id ? Theme.accent-soft
: (touch.has-hover ? Theme.bg-hover : transparent);
HorizontalLayout {
padding-left: Metrics.gap-3;
padding-right: Metrics.gap-3;
spacing: Metrics.gap-2;
UiText { role: TextRole.body; text: root.data.id; width: 160px; }
Badge {
text: root.data.kind == VersionKind.release ? "正式版" : "预览版";
tint: Theme.fg-secondary;
}
UiText { role: TextRole.caption; text: root.data.released; horizontal-stretch: 1; }
if PageState.wizard.version == root.data.id : AppIcon {
kind: IconKind.check;
icon-size: 14px;
tint: Theme.accent;
}
}
touch := TouchArea {
mouse-cursor: pointer;
clicked => { root.picked(); }
}
}
component LoaderChoice inherits Rectangle {
in property <string> label: "";
in property <string> hint: "";
in property <int> index: 0;
callback picked();
height: 48px;
background: PageState.wizard.loader == root.index ? Theme.accent-soft
: (touch.has-hover ? Theme.bg-hover : transparent);
HorizontalLayout {
padding-left: Metrics.gap-3;
padding-right: Metrics.gap-3;
spacing: Metrics.gap-2;
VerticalLayout {
vertical-stretch: 1;
spacing: 0px;
UiText { role: TextRole.body; text: root.label; }
UiText { role: TextRole.caption; text: root.hint; horizontal-stretch: 1; }
}
if PageState.wizard.loader == root.index : AppIcon {
kind: IconKind.check;
icon-size: 14px;
tint: Theme.accent;
}
}
touch := TouchArea {
mouse-cursor: pointer;
clicked => { root.picked(); }
}
}
export component NewInstanceWizard inherits Rectangle {
// 版本列表由 shell 传入(与下载页共用一份,避免二次拉取)
in property <[UiVersion]> versions;
in-out property <string> query: "";
visible: PageState.wizard.open;
background: rgba(0, 0, 0, 0.45);
// 吃掉底层点击(模态)
TouchArea { }
Rectangle {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
width: min(parent.width - 80px, 720px);
height: min(parent.height - 60px, 560px);
background: Theme.bg-surface;
border-radius: Metrics.radius-md;
border-width: 1px;
border-color: Theme.stroke;
VerticalLayout {
// 标题栏
Rectangle {
height: 52px;
background: transparent;
HorizontalLayout {
padding-left: Metrics.gap-4;
padding-right: Metrics.gap-3;
spacing: Metrics.gap-2;
UiText {
role: TextRole.subtitle;
text: "新建实例 · " + PageState.wizard.step_label;
horizontal-stretch: 1;
}
MetroButton {
kind: ButtonKind.subtle;
text: "关闭";
button-height: 24px;
clicked => { PageState.wizard-close(); }
}
}
}
// 步骤指示
HorizontalLayout {
padding-left: Metrics.gap-4;
spacing: Metrics.gap-3;
StepDot { index: 0; label: "版本"; }
StepDot { index: 1; label: "加载器"; }
StepDot { index: 2; label: "名字"; }
StepDot { index: 3; label: "确认"; }
StepDot { index: 4; label: "进度"; }
}
Rectangle { height: 1px; background: Theme.stroke; }
// 步骤内容
Rectangle {
vertical-stretch: 1;
background: transparent;
// ① 选版本(只列正式版/快照,远古版走下载页的显式入口)
if PageState.wizard.step == 0 : VerticalLayout {
padding: Metrics.gap-3;
spacing: Metrics.gap-2;
SearchField {
width: 220px;
text <=> root.query;
placeholder: "筛选版本号";
}
ScrollView {
vertical-stretch: 1;
VerticalLayout {
for v in root.versions : VersionChoice {
visible: !v.ancient;
data: v;
picked => { PageState.wizard-select-version(v.id); }
}
}
}
}
// ② 选加载器
if PageState.wizard.step == 1 : VerticalLayout {
padding: Metrics.gap-3;
spacing: 0px;
LoaderChoice { index: 0; label: "原版(Vanilla)"; hint: "不装加载器,最快最稳"; picked => { PageState.wizard-select-loader(0); } }
LoaderChoice { index: 1; label: "Fabric"; hint: "轻量模组加载器(GUI 可直接装)"; picked => { PageState.wizard-select-loader(1); } }
LoaderChoice { index: 2; label: "Quilt"; hint: "Fabric 的分支(GUI 可直接装)"; picked => { PageState.wizard-select-loader(2); } }
LoaderChoice { index: 3; label: "Forge"; hint: "需要 installer processors(首装会外调 java,约 1–3 分钟)"; picked => { PageState.wizard-select-loader(3); } }
LoaderChoice { index: 4; label: "NeoForge"; hint: "需要 installer processors(首装会外调 java,约 1–3 分钟)"; picked => { PageState.wizard-select-loader(4); } }
}
// ③ 名字
if PageState.wizard.step == 2 : VerticalLayout {
padding: Metrics.gap-4;
spacing: Metrics.gap-2;
UiText { role: TextRole.body; text: "实例名(默认用版本号)"; }
SearchField {
width: 320px;
text: PageState.wizard.name;
placeholder: "例如:生存 1.20.4";
edited(t) => { PageState.wizard-name-changed(t); }
}
if PageState.wizard.error != "" : UiText {
role: TextRole.caption;
text: PageState.wizard.error;
color: Theme.state-danger;
}
UiText {
role: TextRole.caption;
text: "不能包含 / \\ : * ? \" < > |,最多 64 个字符。";
}
}
// ④ 确认
if PageState.wizard.step == 3 : VerticalLayout {
padding: Metrics.gap-4;
spacing: Metrics.gap-2;
UiText { role: TextRole.bodyStrong; text: "将创建实例"; }
UiText { role: TextRole.body; text: "版本 " + PageState.wizard.version; }
UiText { role: TextRole.body; text: "加载器 " + PageState.wizard.loader_label; }
UiText { role: TextRole.body; text: "实例名 " + PageState.wizard.name; }
UiText {
role: TextRole.caption;
text: "确认后会按 core 安装管线下载:版本 JSON → 客户端 jar → 库 → 资源索引 → 登记实例。";
wrap: word-wrap;
}
if PageState.wizard.error != "" : UiText {
role: TextRole.caption;
text: PageState.wizard.error;
color: Theme.state-danger;
}
}
// ⑤ 进度
if PageState.wizard.step == 4 : VerticalLayout {
padding: Metrics.gap-3;
spacing: Metrics.gap-2;
InstallPanel {
data: PageState.install;
cancel => { PageState.install-cancel(); }
}
UiText {
role: TextRole.caption;
text: "装完会自动跳到实例页;失败原因会显示在上面。";
}
}
}
Rectangle { height: 1px; background: Theme.stroke; }
// 底部按钮
Rectangle {
height: 56px;
background: transparent;
HorizontalLayout {
padding: Metrics.gap-4;
spacing: Metrics.gap-2;
Rectangle { horizontal-stretch: 1; background: transparent; }
MetroButton {
kind: ButtonKind.subtle;
text: "上一步";
button-height: 30px;
enabled: PageState.wizard.step > 0 && PageState.wizard.step < 4;
clicked => { PageState.wizard-back(); }
}
if PageState.wizard.step < 3 : MetroButton {
kind: ButtonKind.accent;
text: "下一步";
button-height: 30px;
enabled: PageState.wizard.can-next;
clicked => { PageState.wizard-next(); }
}
if PageState.wizard.step == 3 : MetroButton {
kind: ButtonKind.accent;
text: "开始安装";
button-height: 30px;
enabled: PageState.wizard.can-next && !PageState.wizard.busy;
clicked => { PageState.wizard-confirm(); }
}
if PageState.wizard.step == 4 : MetroButton {
kind: ButtonKind.standard;
text: "后台继续";
button-height: 30px;
clicked => { PageState.wizard-close(); }
}
}
}
}
}
}
+1 -1
View File
@@ -60,7 +60,7 @@ impl std::fmt::Display for AccountId {
pub enum AccountKind { pub enum AccountKind {
/// 离线(本地生成的 UUID,不联网)。 /// 离线(本地生成的 UUID,不联网)。
Offline, Offline,
/// 微软(device-code)。 /// 微软(device-code / 授权码浏览器)。
Microsoft, Microsoft,
/// authlib-injector 第三方认证服务器(标准 Yggdrasil 协议)。 /// authlib-injector 第三方认证服务器(标准 Yggdrasil 协议)。
AuthlibInjector, AuthlibInjector,
+284
View File
@@ -0,0 +1,284 @@
//! 授权码流(`research/08` §3 方案 A):授权 URL 构造 + code 换 token + 整链编排。
//!
//! 与设备码流的分工:**只多一跳**——拿 MS access_token 的方式不同,往后的
//! XBL → XSTS → login_with_xbox → entitlements → profile 与刷新链完全复用
//! ([`super::chain::complete_chain`]),所以两条流的凭据存储与刷新语义天然一致。
//!
//! # 三个硬性细节(都是「抄错就失败」的那一类)
//!
//! 1. **租户 `consumers`**:授权页与 token 端点都走 `login.microsoftonline.com/consumers/…`,
//! 换成 `common`/AAD 租户 id 时 `XboxLive.signin` 直接被拒(`research/08` §2 约束 1)。
//! 2. **重定向 URI 严格一致**:`http://localhost:{port}/auth` —— 授权 URL 里的、
//! token 请求体里的、本地服务器实际监听的必须是**同一个字符串**(含端口与路径),
//! 所以本模块只从 [`RedirectUri`] 取这一个来源。
//! 3. **`prompt=select_account`**:不带这个参数,用户会被静默塞进上一次的账号(坑 6)。
//!
//! scope 与设备码流共用 [`super::flow::SCOPE`](`XboxLive.signin offline_access`)。
use std::fmt;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use reqwest::Client;
use crate::auth::error::AuthResult;
use crate::auth::http::post_form;
use crate::auth::secret::Secret;
use super::callback::{CallbackServer, REDIRECT_PATH};
use super::chain::complete_chain;
use super::endpoints::MicrosoftEndpoints;
use super::flow::{oauth_error, tokens_from, OAuthTokens, SCOPE};
use super::now_ms;
use super::session::MicrosoftSession;
/// 授权码 grant 类型。
pub const AUTHORIZATION_CODE_GRANT: &str = "authorization_code";
/// 默认等回调的时间预算(用户要开浏览器、登录、点同意)。
pub const DEFAULT_CALLBACK_TIMEOUT: Duration = Duration::from_secs(300);
/// 默认的端口绑定尝试次数。
pub const DEFAULT_BIND_ATTEMPTS: u8 = 3;
/// 回填到授权 URL 与 token 请求里的重定向 URI(**唯一来源**)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RedirectUri {
port: u16,
url: String,
}
impl RedirectUri {
/// 按实际绑定的端口构造 `http://localhost:{port}/auth`。
///
/// 用 `localhost` 而不是 `127.0.0.1`:微软对两者处理不一致(`research/08` 坑 5)。
#[must_use]
pub fn new(port: u16) -> Self {
Self {
port,
url: format!("http://localhost:{port}{REDIRECT_PATH}"),
}
}
/// 端口。
#[must_use]
pub fn port(&self) -> u16 {
self.port
}
/// URI 字面量(授权 URL 与 token 请求共用,保证逐字节一致)。
#[must_use]
pub fn as_str(&self) -> &str {
&self.url
}
}
impl fmt::Display for RedirectUri {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.url)
}
}
/// 授权码流的运行参数(生产用 [`AuthCodePolicy::default`],测试压短超时/固定端口)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthCodePolicy {
/// 等浏览器回调的上限。
pub callback_timeout: Duration,
/// 端口绑定尝试次数(`>= 1`)。
pub bind_attempts: u8,
/// 固定端口(`None` = 由内核分配,生产路径)。
pub port: Option<u16>,
/// CSRF 用的 `state`(`None` = 每次随机生成)。
pub state: Option<String>,
}
impl Default for AuthCodePolicy {
fn default() -> Self {
Self {
callback_timeout: DEFAULT_CALLBACK_TIMEOUT,
bind_attempts: DEFAULT_BIND_ATTEMPTS,
port: None,
state: None,
}
}
}
/// 要宿主做的事:打开浏览器(core 不碰 UI:谁来开由 CLI/GUI 决定)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthCodePrompt {
/// 授权页 URL(宿主交给系统默认浏览器;也可展示给用户手动打开)。
pub authorization_url: String,
/// 本地回调地址(展示/排障用;与授权 URL 里的 `redirect_uri` 同源)。
pub redirect_uri: String,
/// 最多等多久。
pub timeout: Duration,
}
/// 拼授权 URL(`response_type=code` + `prompt=select_account` + 严格编码的重定向 URI)。
#[must_use]
pub fn authorization_url(
endpoints: &MicrosoftEndpoints,
client_id: &str,
redirect_uri: &RedirectUri,
state: &str,
) -> String {
format!(
"{}?client_id={}&response_type=code&redirect_uri={}&scope={}&prompt=select_account&state={}",
endpoints.authorize,
encode(client_id),
encode(redirect_uri.as_str()),
encode(SCOPE),
encode(state),
)
}
/// 授权码换 token(`grant_type=authorization_code`)。
///
/// # Errors
///
/// OAuth 拒绝([`crate::auth::AuthError::Denied`] /
/// [`crate::auth::AuthError::CredentialExpired`])、网络失败、响应缺 token。
pub(crate) async fn exchange_code(
client: &Client,
endpoints: &MicrosoftEndpoints,
client_id: &str,
code: &Secret,
redirect_uri: &RedirectUri,
) -> AuthResult<OAuthTokens> {
let reply = post_form(
client,
&endpoints.token,
vec![
("client_id", client_id.to_string()),
("grant_type", AUTHORIZATION_CODE_GRANT.to_string()),
("code", code.expose().to_string()),
("redirect_uri", redirect_uri.as_str().to_string()),
("scope", SCOPE.to_string()),
],
)
.await?;
if reply.text("error").is_some() || !reply.is_success() {
return Err(oauth_error(&reply, &endpoints.token));
}
tokens_from(&reply, &endpoints.token)
}
/// 授权码登录主体:绑端口 → 出授权 URL(交给 `present`)→ 等回调 → 换 token → 整链。
///
/// `present` 在**端口绑定之后、等待之前**被调用:宿主此时才知道端口,也只有这时
/// 打开浏览器才不会让用户对着还没起来的服务端登录。
pub(crate) async fn login<F>(
client: &Client,
endpoints: &MicrosoftEndpoints,
client_id: &str,
policy: AuthCodePolicy,
present: F,
) -> AuthResult<MicrosoftSession>
where
F: FnOnce(&AuthCodePrompt),
{
let state = policy.state.clone().unwrap_or_else(nonce);
let server = CallbackServer::bind(policy.port, policy.bind_attempts, state.clone()).await?;
let redirect_uri = RedirectUri::new(server.port());
present(&AuthCodePrompt {
authorization_url: authorization_url(endpoints, client_id, &redirect_uri, &state),
redirect_uri: redirect_uri.as_str().to_string(),
timeout: policy.callback_timeout,
});
let code = server.wait_for_code(policy.callback_timeout).await?;
let tokens = exchange_code(
client,
endpoints,
client_id,
&Secret::new(code),
&redirect_uri,
)
.await?;
complete_chain(client, endpoints, tokens, now_ms()).await
}
/// 每次登录一个 `state`(不用于加密,只用来把迟到的旧回调挡掉)。
///
/// 128 位(两个独立随机种子的 SipHash 输出拼接):RFC 6749 §10.10 建议 `state` 至少
/// 128 位熵,挡住「猜到 state 就能伪造回调」这类攻击面。
fn nonce() -> String {
use std::collections::hash_map::RandomState;
use std::hash::{BuildHasher, Hasher};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|since| since.as_nanos())
.unwrap_or(0);
let mut halves = [String::new(), String::new()];
for half in &mut halves {
// 每次 `RandomState::new()` 都是独立随机种子 → 两段各 64 位、彼此不可推。
let mut hasher = RandomState::new().build_hasher();
hasher.write_u128(nanos);
hasher.write_u32(std::process::id());
*half = format!("{:016x}", hasher.finish());
}
format!("{}{}", halves[0], halves[1])
}
/// 百分号编码(RFC 3986 unreserved 之外一律转义;空格用 `%20` 而不是 `+`)。
fn encode(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
for byte in raw.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(char::from(byte));
}
_ => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
#[cfg(test)]
mod tests {
use super::{authorization_url, encode, RedirectUri, AUTHORIZATION_CODE_GRANT};
use crate::auth::microsoft::MicrosoftEndpoints;
#[test]
fn redirect_uri_is_localhost_with_exact_port_and_path() {
let uri = RedirectUri::new(51_234);
assert_eq!(uri.as_str(), "http://localhost:51234/auth");
assert_eq!(uri.port(), 51_234);
assert_eq!(uri.to_string(), uri.as_str());
}
#[test]
fn authorization_url_matches_research_08_vector() {
let endpoints = MicrosoftEndpoints::consumers();
let uri = RedirectUri::new(8_080);
let url = authorization_url(&endpoints, "0000-client", &uri, "state-123");
assert_eq!(
url,
"https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize\
?client_id=0000-client&response_type=code\
&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fauth\
&scope=XboxLive.signin%20offline_access&prompt=select_account&state=state-123"
);
// 三个硬性细节各断言一遍,改坏了立刻知道是哪一条。
assert!(url.contains("/consumers/oauth2/v2.0/authorize"));
assert!(url.contains("prompt=select_account"));
assert!(url.contains("scope=XboxLive.signin%20offline_access"));
}
#[test]
fn encode_escapes_everything_but_unreserved() {
assert_eq!(encode("abc-._~09"), "abc-._~09");
assert_eq!(encode("a b/c:"), "a%20b%2Fc%3A");
}
#[test]
fn grant_type_is_the_standard_one() {
assert_eq!(AUTHORIZATION_CODE_GRANT, "authorization_code");
}
#[test]
fn generated_state_has_128_bits_of_entropy() {
let first = super::nonce();
let second = super::nonce();
assert_eq!(first.len(), 32, "128 位 = 32 个 hex 字符:{first}");
assert!(first.chars().all(|c| c.is_ascii_hexdigit()), "{first}");
assert_ne!(first, second, "每次登录的 state 必须不同");
}
}
+346
View File
@@ -0,0 +1,346 @@
//! 授权码流的本地回调服务器(`research/08` §3 方案 A:HMCL 主流做法)。
//!
//! 只干一件事:等浏览器把 `?code=` 送到 `http://localhost:{port}/auth`,把 code 交出去。
//! 协议面故意压到最小(只认一个 GET 路径、只回一个静态页),这样它能被单测直接驱动。
//!
//! # 四条容易踩的细节
//!
//! 1. **重定向 URI 用 `localhost`,监听绑 `127.0.0.1`**:微软对 `localhost` 与 `127.0.0.1`
//! 处理不一致(坑 5),URI 只能写前者;浏览器把 `localhost` 解析到 `::1` 时会被拒一次
//! 再回落 IPv4,Chrome/Edge 都走这条回退,所以只绑 v4 就够。
//! 2. **端口由内核分配**(绑定 `:0`),端口被占时按 `attempts` 重试;授权 URL 里的
//! `redirect_uri` 必须用**最终绑到的端口**回填,换 token 时再原样带上(URI 严格一致)。
//! 3. **非 `/auth` 的请求(`/favicon.ico`、浏览器预连)回 404 后继续等**:让一个 favicon
//! 把整次登录判成失败是最容易犯的错。
//! 4. **`state` 不匹配的请求一律回 400 后继续等**——包括带 `error=` 的那些:旧标签页的
//! 迟到回调不该打断新的登录,更要紧的是 RFC 6749 §4.1.2.1 / RFC 9207 要求**错误响应同样
//! 要校验 `state`**;否则任何人 `GET /auth?error=access_denied` 就能静默中止别人的登录。
//! 真正的超时由调用方的时间预算兜底。
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use crate::auth::error::{AuthError, AuthResult};
/// 回调路径(重定向 URI 的路径段)。
pub const REDIRECT_PATH: &str = "/auth";
/// 一次回调请求最多读这么多字节(超了就丢弃连接,防被灌内存)。
pub const MAX_REQUEST_HEAD: usize = 8 * 1024;
/// 端口被占用时的重试间隔。
const RETRY_DELAY: Duration = Duration::from_millis(20);
/// 单条连接读完请求头的上限(浏览器/预连不把请求头发完时不能把整个登录卡死)。
const REQUEST_READ_TIMEOUT: Duration = Duration::from_secs(5);
const SUCCESS_PAGE: &str = "<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">\
<title>登录完成</title></head><body style=\"font-family:system-ui;padding:40px\">\
<h1>登录完成</h1><p>可以关闭此窗口,回到启动器继续。</p></body></html>";
const DENIED_PAGE: &str = "<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">\
<title>登录未完成</title></head><body style=\"font-family:system-ui;padding:40px\">\
<h1>登录未完成</h1><p>授权没有完成,可以回到启动器重试。</p></body></html>";
const BAD_REQUEST_PAGE: &str = "<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">\
<title>回调无效</title></head><body style=\"font-family:system-ui;padding:40px\">\
<h1>回调无效</h1><p>这个链接不属于本次登录。</p></body></html>";
const NOT_FOUND_PAGE: &str = "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\">\
<title>Not Found</title></head><body><h1>Not Found</h1></body></html>";
/// 本地回调服务器(一个实例只服务一次登录)。
#[derive(Debug)]
pub struct CallbackServer {
listener: TcpListener,
port: u16,
state: String,
}
impl CallbackServer {
/// 绑定回调端口。
///
/// `port` 为 `None` 时由内核分配(生产路径);给定端口时按 `attempts` 重试
/// (CLI/测试里固定端口用,端口被占会退避再试)。
///
/// # Errors
///
/// 全部尝试都绑定失败时返回 [`AuthError::Network`](文案带端口与重试次数)。
pub async fn bind(
port: Option<u16>,
attempts: u8,
state: impl Into<String>,
) -> AuthResult<Self> {
let wanted = port.unwrap_or(0);
let attempts = attempts.max(1);
let mut last = String::new();
for attempt in 0..attempts {
match TcpListener::bind(("127.0.0.1", wanted)).await {
Ok(listener) => {
let port = listener
.local_addr()
.map_err(|error| AuthError::network("localhost", error.to_string()))?
.port();
return Ok(Self {
listener,
port,
state: state.into(),
});
}
Err(error) => {
last = error.to_string();
if attempt + 1 < attempts {
tokio::time::sleep(RETRY_DELAY).await;
}
}
}
}
let what = if wanted == 0 {
"本地回调端口分配失败".to_string()
} else {
format!("本地回调端口 {wanted} 绑定失败(重试 {attempts} 次)")
};
Err(AuthError::network("localhost", format!("{what}:{last}")))
}
/// 实际端口(回填 `redirect_uri` 用)。
#[must_use]
pub fn port(&self) -> u16 {
self.port
}
/// 本次登录的 `state`(授权 URL 与回调校验共用)。
#[must_use]
pub fn state(&self) -> &str {
&self.state
}
/// 等浏览器把 code 送到 `/auth`,最多等 `timeout`。
///
/// # Errors
///
/// 超时([`AuthError::Timeout`])、用户在授权页点了取消([`AuthError::Canceled`])、
/// 授权页回了别的 `error`([`AuthError::Denied`])、socket 出错([`AuthError::Network`])。
pub async fn wait_for_code(self, timeout: Duration) -> AuthResult<String> {
let deadline = tokio::time::Instant::now() + timeout;
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
return Err(wait_timeout(timeout));
}
let accepted = tokio::time::timeout(remaining, self.listener.accept()).await;
let (mut stream, _) = match accepted {
Ok(Ok(pair)) => pair,
Ok(Err(error)) => {
return Err(AuthError::network("localhost", error.to_string()));
}
Err(_) => return Err(wait_timeout(timeout)),
};
// 一条连接最多读这么久请求头:慢客户端/半开连接不能把整个登录拖过时间预算。
// 预算按 **accept 之后** 的剩余时间重算(用 accept 前那份会让总时长超发最多 5s)。
let budget = REQUEST_READ_TIMEOUT
.min(deadline.saturating_duration_since(tokio::time::Instant::now()));
if budget.is_zero() {
return Err(wait_timeout(timeout));
}
match tokio::time::timeout(budget, self.handle(&mut stream)).await {
Ok(result) => {
if let Some(code) = result? {
return Ok(code);
}
}
Err(_) => continue,
}
}
}
/// 处理一条连接:`Some(code)` = 登录完成,`None` = 与本次登录无关,继续等。
async fn handle(&self, stream: &mut TcpStream) -> AuthResult<Option<String>> {
let Some(target) = read_request_target(stream).await else {
return Ok(None);
};
let (path, query) = match target.split_once('?') {
Some((path, query)) => (path, query),
None => (target.as_str(), ""),
};
if path != REDIRECT_PATH {
respond(stream, 404, NOT_FOUND_PAGE).await;
return Ok(None);
}
let params = Query::parse(query);
// state 必须先校验,且**先于** error:错误响应同样要认 state(RFC 6749 §4.1.2.1 /
// RFC 9207)。否则任意一次 `GET /auth?error=access_denied`(state 随便给或不给)就能
// 把这台机器上正在进行的登录静默中止 —— 不需要知道随机 state、不需要 code。
if params.get("state") != Some(self.state.as_str()) {
respond(stream, 400, BAD_REQUEST_PAGE).await;
return Ok(None);
}
if let Some(error) = params.get("error") {
respond(stream, 200, DENIED_PAGE).await;
return Err(oauth_error(error, params.get("error_description")));
}
let Some(code) = params.get("code").filter(|code| !code.is_empty()) else {
respond(stream, 400, BAD_REQUEST_PAGE).await;
return Ok(None);
};
respond(stream, 200, SUCCESS_PAGE).await;
Ok(Some(code.to_string()))
}
}
fn wait_timeout(timeout: Duration) -> AuthError {
AuthError::Timeout {
what: format!("等待浏览器回调(上限 {} 秒)", timeout.as_secs()),
}
}
/// 授权页回的 `error` → 错误分流(`access_denied` 归取消,其余归拒绝)。
fn oauth_error(code: &str, description: Option<&str>) -> AuthError {
if matches!(code, "access_denied" | "authorization_declined") {
return AuthError::Canceled;
}
AuthError::Denied {
code: code.to_string(),
description: description.unwrap_or("授权页没有给出原因").to_string(),
}
}
/// 读请求行里的 target(`/auth?code=...`)。非 GET/HEAD、超长头、连接中断都给 `None`。
async fn read_request_target(stream: &mut TcpStream) -> Option<String> {
let mut buffer = Vec::new();
let mut chunk = [0_u8; 1024];
loop {
if let Some(end) = find_headers_end(&buffer) {
let head = String::from_utf8_lossy(&buffer[..end]).into_owned();
let mut parts = head.lines().next()?.split(' ');
let method = parts.next()?;
if !method.eq_ignore_ascii_case("GET") && !method.eq_ignore_ascii_case("HEAD") {
return None;
}
return parts.next().map(str::to_string);
}
if buffer.len() > MAX_REQUEST_HEAD {
return None;
}
match stream.read(&mut chunk).await {
Ok(0) | Err(_) => return None,
Ok(read) => buffer.extend_from_slice(&chunk[..read]),
}
}
}
fn find_headers_end(buffer: &[u8]) -> Option<usize> {
buffer
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|position| position + 4)
}
async fn respond(stream: &mut TcpStream, status: u16, page: &str) {
let response = format!(
"HTTP/1.1 {status} {}\r\nContent-Type: text/html; charset=utf-8\r\n\
Content-Length: {}\r\nConnection: close\r\n\r\n{page}",
reason(status),
page.len()
);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
}
fn reason(status: u16) -> &'static str {
match status {
200 => "OK",
400 => "Bad Request",
404 => "Not Found",
_ => "Status",
}
}
/// 回调 query 的最小解析(`k=v` 对,percent 解码)。
struct Query(Vec<(String, String)>);
impl Query {
fn parse(raw: &str) -> Self {
Self(
raw.split('&')
.filter(|pair| !pair.is_empty())
.filter_map(|pair| {
let (key, value) = pair.split_once('=')?;
Some((decode(key), decode(value)))
})
.collect(),
)
}
fn get(&self, key: &str) -> Option<&str> {
self.0
.iter()
.find(|(name, _)| name == key)
.map(|(_, value)| value.as_str())
}
}
/// `percent` 解码(`+` 视为空格;坏转义原样保留)。
fn decode(raw: &str) -> String {
let bytes = raw.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
match bytes[index] {
b'%' if index + 2 < bytes.len() => {
let parsed = std::str::from_utf8(&bytes[index + 1..index + 3])
.ok()
.and_then(|hex| u8::from_str_radix(hex, 16).ok());
match parsed {
Some(byte) => {
out.push(byte);
index += 3;
}
None => {
out.push(b'%');
index += 1;
}
}
}
b'+' => {
out.push(b' ');
index += 1;
}
byte => {
out.push(byte);
index += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(test)]
mod tests {
use super::{decode, oauth_error, Query};
use crate::auth::error::AuthError;
#[test]
fn query_decodes_percent_and_plus() {
let query = Query::parse("code=ab%2Fcd&state=s+1&empty=");
assert_eq!(query.get("code"), Some("ab/cd"));
assert_eq!(query.get("state"), Some("s 1"));
assert_eq!(query.get("empty"), Some(""));
assert_eq!(query.get("missing"), None);
}
#[test]
fn decode_keeps_broken_escapes() {
assert_eq!(decode("100%"), "100%");
assert_eq!(decode("%zz"), "%zz");
assert_eq!(decode("a%20b"), "a b");
}
#[test]
fn access_denied_maps_to_canceled() {
assert_eq!(oauth_error("access_denied", None), AuthError::Canceled);
match oauth_error("invalid_request", Some("bad scope")) {
AuthError::Denied { code, description } => {
assert_eq!(code, "invalid_request");
assert_eq!(description, "bad scope");
}
other => panic!("应是 Denied,实际 {other:?}"),
}
}
}
+20 -6
View File
@@ -1,8 +1,11 @@
//! 微软端点表:**直连**(不走镜像,承 mission Context 与 `analyse/04` §4 的端点族区分)。 //! 微软端点表:**直连**(不走镜像,承 mission Context 与 `analyse/04` §4 的端点族区分)。
//! //!
//! 两个端点族不能混用(`analyse/04` §4 的「第一个坑」): //! 两条流**共用同一个端点族**(`research/08` §2 硬性约束 1:租户只能用 `consumers`):
//! - 设备码流:`login.microsoftonline.com/consumers/oauth2/v2.0/*`; //! - 设备码流:`devicecode` + `token`;
//! - HMCL 的浏览器授权码流走 `login.live.com`(本期不实现,故不在此表)。 //! - 授权码流:`authorize`(浏览器打开)+ 同一个 `token`(`grant_type=authorization_code`)。
//!
//! 都走 `login.microsoftonline.com/consumers/oauth2/v2.0/*`:用 `common` 或 AAD 租户 id
//! 拿 `XboxLive.signin` 会直接被拒(`analyse/04` §4 的「第一个坑」)。
//! //!
//! 端点集中成一张表是为了**测试可替换**:整表指向一个进程内 mock 服务器即可覆盖全链, //! 端点集中成一张表是为了**测试可替换**:整表指向一个进程内 mock 服务器即可覆盖全链,
//! 生产默认走 [`MicrosoftEndpoints::consumers`]。 //! 生产默认走 [`MicrosoftEndpoints::consumers`]。
@@ -12,7 +15,9 @@
pub struct MicrosoftEndpoints { pub struct MicrosoftEndpoints {
/// 设备码申请(`POST`,表单 `client_id` + `scope`)。 /// 设备码申请(`POST`,表单 `client_id` + `scope`)。
pub device_code: String, pub device_code: String,
/// 换 token / 刷新 token(`POST`,表单)。 /// 授权码流的授权页(`GET`,浏览器打开;`research/08` §3 方案 A)。
pub authorize: String,
/// 换 token / 刷新 token / 授权码换 token(`POST`,表单)。
pub token: String, pub token: String,
/// Xbox Live 用户认证(`POST` JSON)。 /// Xbox Live 用户认证(`POST` JSON)。
pub xbl_authenticate: String, pub xbl_authenticate: String,
@@ -35,6 +40,8 @@ impl MicrosoftEndpoints {
Self { Self {
device_code: "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode" device_code: "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"
.to_string(), .to_string(),
authorize: "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize"
.to_string(),
token: "https://login.microsoftonline.com/consumers/oauth2/v2.0/token".to_string(), token: "https://login.microsoftonline.com/consumers/oauth2/v2.0/token".to_string(),
xbl_authenticate: "https://user.auth.xboxlive.com/user/authenticate".to_string(), xbl_authenticate: "https://user.auth.xboxlive.com/user/authenticate".to_string(),
xsts_authorize: "https://xsts.auth.xboxlive.com/xsts/authorize".to_string(), xsts_authorize: "https://xsts.auth.xboxlive.com/xsts/authorize".to_string(),
@@ -54,6 +61,7 @@ impl MicrosoftEndpoints {
let join = |path: &str| format!("{}/{path}", base.trim_end_matches('/')); let join = |path: &str| format!("{}/{path}", base.trim_end_matches('/'));
Self { Self {
device_code: join("devicecode"), device_code: join("devicecode"),
authorize: join("authorize"),
token: join("token"), token: join("token"),
xbl_authenticate: join("xbl"), xbl_authenticate: join("xbl"),
xsts_authorize: join("xsts"), xsts_authorize: join("xsts"),
@@ -66,9 +74,10 @@ impl MicrosoftEndpoints {
/// 所有端点(调试/诊断用)。 /// 所有端点(调试/诊断用)。
#[must_use] #[must_use]
pub fn all(&self) -> [&str; 8] { pub fn all(&self) -> [&str; 9] {
[ [
&self.device_code, &self.device_code,
&self.authorize,
&self.token, &self.token,
&self.xbl_authenticate, &self.xbl_authenticate,
&self.xsts_authorize, &self.xsts_authorize,
@@ -95,11 +104,15 @@ mod tests {
let endpoints = MicrosoftEndpoints::consumers(); let endpoints = MicrosoftEndpoints::consumers();
assert!(endpoints.device_code.contains("login.microsoftonline.com")); assert!(endpoints.device_code.contains("login.microsoftonline.com"));
assert!(endpoints.token.contains("/consumers/oauth2/v2.0/token")); assert!(endpoints.token.contains("/consumers/oauth2/v2.0/token"));
// 授权码流的授权页与设备码流同族同租户(`research/08` §2:只能用 consumers)。
assert!(endpoints
.authorize
.contains("/consumers/oauth2/v2.0/authorize"));
assert!(endpoints assert!(endpoints
.xbl_authenticate .xbl_authenticate
.contains("user.auth.xboxlive.com")); .contains("user.auth.xboxlive.com"));
assert!(endpoints.xsts_authorize.contains("xsts.auth.xboxlive.com")); assert!(endpoints.xsts_authorize.contains("xsts.auth.xboxlive.com"));
// 授权码流的 login.live.com 不在这张表里(本期不实现)。 // 微软 OAuth 两流都不走 HMCL 的 login.live.com 端点族。
assert!(endpoints assert!(endpoints
.all() .all()
.iter() .iter()
@@ -110,6 +123,7 @@ mod tests {
fn all_on_keeps_paths_distinct() { fn all_on_keeps_paths_distinct() {
let endpoints = MicrosoftEndpoints::all_on("http://127.0.0.1:1234/"); let endpoints = MicrosoftEndpoints::all_on("http://127.0.0.1:1234/");
assert_eq!(endpoints.device_code, "http://127.0.0.1:1234/devicecode"); assert_eq!(endpoints.device_code, "http://127.0.0.1:1234/devicecode");
assert_eq!(endpoints.authorize, "http://127.0.0.1:1234/authorize");
assert_eq!( assert_eq!(
endpoints.entitlements_license, endpoints.entitlements_license,
"http://127.0.0.1:1234/entitlements/license" "http://127.0.0.1:1234/entitlements/license"
+2 -2
View File
@@ -223,7 +223,7 @@ pub(crate) async fn refresh_tokens(
tokens_from(&reply, &endpoints.token) tokens_from(&reply, &endpoints.token)
} }
fn tokens_from(reply: &Reply, url: &str) -> AuthResult<OAuthTokens> { pub(crate) fn tokens_from(reply: &Reply, url: &str) -> AuthResult<OAuthTokens> {
let access_token = reply let access_token = reply
.text("access_token") .text("access_token")
.ok_or_else(|| AuthError::Malformed(format!("{url} 响应缺 access_token")))?; .ok_or_else(|| AuthError::Malformed(format!("{url} 响应缺 access_token")))?;
@@ -237,7 +237,7 @@ fn tokens_from(reply: &Reply, url: &str) -> AuthResult<OAuthTokens> {
} }
/// OAuth 错误归一:**`error` 非空即失败**(HMCL 只在两个字段都非空时处理,我们收紧)。 /// OAuth 错误归一:**`error` 非空即失败**(HMCL 只在两个字段都非空时处理,我们收紧)。
fn oauth_error(reply: &Reply, url: &str) -> AuthError { pub(crate) fn oauth_error(reply: &Reply, url: &str) -> AuthError {
let Some(code) = reply.text("error") else { let Some(code) = reply.text("error") else {
return AuthError::network( return AuthError::network(
url.split('?').next().unwrap_or(url), url.split('?').next().unwrap_or(url),
+50 -3
View File
@@ -1,25 +1,35 @@
//! 微软轨:device-code 全流程 + 会话/刷新语义(`PlanDoc/07-accounts.md` §1、`analyse/04` §4–§5)。 //! 微软轨:device-code 与授权码(浏览器)两条登录流 + 会话/刷新语义
//! (`PlanDoc/07-accounts.md` §1、`analyse/04` §4–§5、`research/08` §3)。
//! //!
//! 分四块,各管一件事: //! 分五块,各管一件事:
//! - [`session`]:`MicrosoftSession`(凭据载荷)与**刷新判定**(纯函数,可脱离网络单测); //! - [`session`]:`MicrosoftSession`(凭据载荷)与**刷新判定**(纯函数,可脱离网络单测);
//! - [`endpoints`]:端点表(生产 = 微软消费版直连;测试 = 整表指向 mock); //! - [`endpoints`]:端点表(生产 = 微软消费版直连;测试 = 整表指向 mock);
//! - [`flow`]:device-code 发起 / 轮询 / refresh(OAuth 层); //! - [`flow`]:device-code 发起 / 轮询 / refresh(OAuth 层);
//! - [`authcode`] + [`callback`]:授权码流(授权 URL + 本地 HTTP 回调服务器 + code 换 token);
//! - [`chain`]:XBL → XSTS → login_with_xbox → entitlements → profile(Xbox/MC 层)。 //! - [`chain`]:XBL → XSTS → login_with_xbox → entitlements → profile(Xbox/MC 层)。
//! //!
//! 两条流只差「怎么拿到 MS access_token」:拿到之后都进同一个 [`chain::complete_chain`],
//! 落盘/刷新也共用 [`MicrosoftSession`],所以 GUI 账户页的两颗按钮不会长出两套凭据语义。
//!
//! # client id 从哪来 //! # client id 从哪来
//! //!
//! device-code 流必须带一个**已在 Azure 注册的公共客户端 id**。HMCL 把它烘进 jar 的 //! 两条流都必须带一个**已在 Azure 注册的公共客户端 id**(且租户是 `consumers`、
//! 重定向 URI 登记为 `http://localhost`)。HMCL 把它烘进 jar 的
//! manifest(`hmcl.microsoft.auth.id`),我们同理:二进制里不硬编码,改由 //! manifest(`hmcl.microsoft.auth.id`),我们同理:二进制里不硬编码,改由
//! `ULL_MSA_CLIENT_ID`(运行期)或构建期 `option_env!` 提供。没有配时 //! `ULL_MSA_CLIENT_ID`(运行期)或构建期 `option_env!` 提供。没有配时
//! [`MicrosoftAuth::from_env`] 返回 [`AuthError::Unsupported`] 并说清要配什么—— //! [`MicrosoftAuth::from_env`] 返回 [`AuthError::Unsupported`] 并说清要配什么——
//! 这比编一个假 id 让用户看到 `invalid_client` 诚实。 //! 这比编一个假 id 让用户看到 `invalid_client` 诚实。
pub mod authcode;
pub mod callback;
pub mod chain; pub mod chain;
pub mod endpoints; pub mod endpoints;
pub mod flow; pub mod flow;
pub mod session; pub mod session;
pub use authcode::{AuthCodePolicy, AuthCodePrompt, RedirectUri};
pub use callback::CallbackServer;
pub use endpoints::MicrosoftEndpoints; pub use endpoints::MicrosoftEndpoints;
pub use flow::{DeviceCodePrompt, PollPolicy}; pub use flow::{DeviceCodePrompt, PollPolicy};
pub use session::{MicrosoftSession, RefreshDecision, RefreshReason, USER_TYPE_MSA}; pub use session::{MicrosoftSession, RefreshDecision, RefreshReason, USER_TYPE_MSA};
@@ -45,6 +55,7 @@ pub struct MicrosoftAuth {
client_id: String, client_id: String,
endpoints: MicrosoftEndpoints, endpoints: MicrosoftEndpoints,
poll: PollPolicy, poll: PollPolicy,
authcode: AuthCodePolicy,
journal: Option<AuthJournal>, journal: Option<AuthJournal>,
} }
@@ -62,6 +73,7 @@ impl MicrosoftAuth {
client_id: client_id.into(), client_id: client_id.into(),
endpoints: MicrosoftEndpoints::consumers(), endpoints: MicrosoftEndpoints::consumers(),
poll: PollPolicy::default(), poll: PollPolicy::default(),
authcode: AuthCodePolicy::default(),
journal: None, journal: None,
}) })
} }
@@ -131,6 +143,13 @@ impl MicrosoftAuth {
self self
} }
/// 换授权码流参数(回调超时 / 固定端口 / 绑定重试 / 固定 state;测试与 CLI 用)。
#[must_use]
pub fn with_authcode_policy(mut self, policy: AuthCodePolicy) -> Self {
self.authcode = policy;
self
}
/// 当前端点表。 /// 当前端点表。
#[must_use] #[must_use]
pub fn endpoints(&self) -> &MicrosoftEndpoints { pub fn endpoints(&self) -> &MicrosoftEndpoints {
@@ -177,6 +196,34 @@ impl MicrosoftAuth {
chain::complete_chain(&self.client, &self.endpoints, tokens, now_ms()).await chain::complete_chain(&self.client, &self.endpoints, tokens, now_ms()).await
} }
/// 授权码流登录(浏览器 + 本地回调,`research/08` §3 方案 A,GUI 的默认入口)。
///
/// `present` 在端口绑定之后、等待回调之前被调用:宿主在这一刻拉起系统默认浏览器
/// (core 不做 UI,也不假设有 GUI——CLI 可以只把 URL 打到 stderr)。
/// 失败时与 [`Self::device_code_login`] 一样往失败日志追加一条结构化记录(M38)。
///
/// # Errors
///
/// 端口绑不上/网络失败、等回调超时、用户在授权页取消、OAuth 拒绝,以及整链的
/// [`AuthError`](含白名单未批时 `login_with_xbox` 的 403 诊断)。
pub async fn authcode_login<F>(&self, present: F) -> AuthResult<MicrosoftSession>
where
F: FnOnce(&AuthCodePrompt),
{
let result = authcode::login(
&self.client,
&self.endpoints,
&self.client_id,
self.authcode.clone(),
present,
)
.await;
if let Err(error) = &result {
self.record_failure("authcode_login", error);
}
result
}
/// 刷新:Live refresh → 整链重跑(**原样覆盖 refresh_token**)。 /// 刷新:Live refresh → 整链重跑(**原样覆盖 refresh_token**)。
/// ///
/// # Errors /// # Errors
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff. Show more