Compare commits

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) Blocked by required conditions
Release / build (macos-arm64) (push) Blocked by required conditions
Release / build (linux-x64) (push) Blocked by required conditions
Release / build (windows-x64) (push) Blocked by required conditions
Release / GitHub Release (push) Blocked by required conditions
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'
Guards / core 零 UI 守门 (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release / build (macos-x64) (push) Waiting to run
Release / build (macos-arm64) (push) Waiting to run
Release / build (linux-x64) (push) Waiting to run
Release / build (windows-x64) (push) Waiting to run
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
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'
Guards / core 零 UI 守门 (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
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
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'
Guards / core 零 UI 守门 (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
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
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: Swatinem/rust-cache@v2
- 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 行上限,请按模块拆分"
failed=$((failed + 1))
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
echo "共 $failed 个文件超限"
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
/DevDocs/
# Local acceptance screenshots (screenshot channel output, never committed)
/.screens/
/.tmp-screens/
# Agent workspace (local-only)
/.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]
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]
version = "0.1.0"
@@ -82,7 +91,7 @@ all = { level = "deny", priority = -1 }
[profile.release]
opt-level = "z"
lto = "thin"
lto = "fat"
codegen-units = 1
panic = "abort"
strip = "symbols"
+27 -2
View File
@@ -18,13 +18,38 @@ Copyright 2026 AskaEth (YeiJ)
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 /
CC0-1.0 / MIT-0 / Unlicense / Unicode-3.0 / CDLA-Permissive-2.0 / Apache-2.0 WITH LLVM-exception。
GPL 系(含 AGPL、LGPL 静态链接、SSPL)依赖不得进入主干。
3. 参考实现致谢
4. 参考实现致谢
本项目设计参考自 HMCL(https://github.com/HMCL-dev/HMCL)与 PCL2
(https://github.com/Meloong-Git/PCL),两者均为 GPL-3.0,特此致谢。
+134 -66
View File
@@ -2,55 +2,91 @@
**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
(Apache-2.0, non-commercial), with a Slint + Metro UI. It embeds an AI agent client (chat /
configuration / crash diagnosis) and integrates with the broader agent tooling ecosystem via MCP.
Minimum baseline: Windows 10.
(Apache-2.0). It ships as one native binary per platform — no installer, no bundled runtime, no
WebView. The launcher embeds an AI agent client (chat / configuration / crash diagnosis) and can be
attached to other agent tooling as an MCP server.
- Runtime: a single native Rust binary — no JVM, no WebView, no external runtime
- 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)
## Highlights
## Compliance statement
Authentication uses only official Microsoft/Mojang endpoints — Microsoft OAuth 2.0 device code
flow, Xbox Live, XSTS, and api.minecraftservices.com — so players sign in with their own Microsoft
account and launch the game they already own. Account tokens are stored only on the user's local
machine and are never sent to any server operated by the project. ULL does not distribute, mirror,
proxy or modify any Minecraft game files. Online play always requires a Microsoft account with
valid entitlements. Local offline profiles never contact Microsoft or Mojang services and cannot
join online-mode servers. Not affiliated with Mojang or Microsoft.
- **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
dependency.
- **Two UI stacks during the rewrite**: the released UI is Slint (Royalty-Free 2.0); the
next-generation UI is iced 0.14 + Material 3 Expressive and is still being written (see Status).
- **CPU software-rendering fallback**: `--software-renderer` keeps the interface rendering
normally on machines with no usable GPU or with broken drivers.
- **AI agent + MCP**: chat, setting edits and crash diagnosis inside the launcher; `ull agent
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
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
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
### Track A — released: v0.1.0, Slint UI (this is what you can download today)
**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
the full interface ship with subsequent versions — use the CLI until then.
### Track B — in progress on `dev`: next-generation UI (iced + Material 3 Expressive)
**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
```
Unlucky-Launcher (cargo workspace)
├── crates/core # domain library, zero UI dependencies, eight modules
├── crates/agent # AI agent: ToolSpec registry + built-in client + MCP server (one shared core)
├── crates/app # Slint UI: the only crate allowed to depend on a UI framework
├── crates/cli # CLI (bin `ull`): headless, zero UI, depends on core + agent
└── packaging # single-file artifact matrix and packaging assets (planned)
├── crates/core # domain library: zero UI dependencies, eight modules
├── crates/agent # AI agent: ToolSpec registry + built-in client + MCP server (one core, two shapes)
├── crates/design # design tokens: real HCT color / type / shape / spacing / elevation / motion / icons
│ # / component metrics — zero UI dependencies, no third-party crates
├── 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`:
@@ -61,7 +97,7 @@ The eight modules of `crates/core`:
| `download` | multi-source health scoring, mirrors, chunked downloads, hash-while-downloading |
| `game` | version / instance repository, snapshots and inheritance resolution |
| `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 |
| `diagnose` | five signal collectors, YAML rule engine, hs_err parsing |
| `platform` | OS abstraction: paths, processes, file locks, capability probing, runtime OS detection |
@@ -70,53 +106,85 @@ The eight modules of `crates/core`:
Prerequisites:
- Rust stable (`rust-toolchain.toml` selects the toolchain automatically; components `rustfmt` /
`clippy`)
- Windows: MSVC toolchain (used by CI) or MinGW-w64 (`x86_64-pc-windows-gnu`)
- Linux: `libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev`
(Slint winit backend + femtovg build dependencies)
- Rust stable (`rust-toolchain.toml` selects the toolchain automatically; the gates also need the
`rustfmt` and `clippy` components)
- 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` — needed by both UI stacks (Slint's winit + femtovg, iced's winit + wgpu/tiny-skia)
```bash
cargo fmt --all --check # formatting
cargo clippy --workspace --all-targets -- -D warnings # lints (zero-warning gate)
cargo test --workspace # tests
cargo build --release # artifact: target/release/unlucky-launcher[.exe]
cargo fmt --all --check # formatting
cargo clippy --workspace --all-targets -- -D warnings # lints (zero-warning gate)
cargo test --workspace # tests
cargo build --release # binaries land in target/release/
```
Software-rendering switch (machines without a usable GPU / with broken drivers):
`unlucky-launcher --software-renderer` (equivalent to `SLINT_BACKEND=winit-software`).
What the build produces:
- `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):
`cargo deny check licenses bans sources`.
## 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 |
|----------|---------|
| `.github/workflows/ci.yml` | fmt + clippy + test + build, Windows / Linux matrix |
| `.github/workflows/guards.yml` | zero-UI guard for core, 400-line-per-file guard, cargo-deny license guard |
| `.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 `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
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
`-Zbuild-std`, no import-table allowlists
- Other platforms: macOS 12+ first class; Linux x64 with X11 preferred, Wayland as the long-term
direction; Android Termux experimental, not committed
- UI fallback: Slint software rendering (fully usable on machines without a GPU or with broken
drivers)
- Platform independence is expressed through **separate test matrices**, not separate artifact
pipelines; capability differences use feature gates with runtime probing
- Windows 10/11 builds use the **standard toolchain** (MSVC / GNU), built the same way as
upstream cargo
- macOS 12+ (Apple Silicon and Intel) and Linux x64 / arm64 are supported build slots; on Linux,
X11 is the preferred path today and Wayland is the long-term direction. macOS builds are unsigned,
so the first launch needs the Gatekeeper quarantine attribute cleared
(`xattr -d com.apple.quarantine`)
- Android Termux is experimental and not promised: CLI only, GUI unverified
- Each platform runs its own test matrix; capability differences use feature gates with runtime
probing.
## License and acknowledgements
- This project: Apache License 2.0, see [LICENSE](LICENSE); third-party notices in [NOTICE](NOTICE)
- The GUI uses [Slint](https://slint.dev) (Royalty-Free 2.0 track). Per the RF 2.0 obligations an
`AboutSlint` widget will be placed on the about page (P2); until then NOTICE and this section
serve the attribution duty.
- **This project**: Apache License 2.0, see [LICENSE](LICENSE); third-party notices in
[NOTICE](NOTICE).
- **Slint** (<https://slint.dev>), Royalty-Free 2.0: obligation for the Slint build that v0.1.0
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
[PCL2](https://github.com/Meloong-Git/PCL) (both GPL-3.0) — these two launchers have accompanied
the vast majority of Minecraft players for many years. ULL learned a great deal about design and
implementation from them, and this project is dedicated to those two pioneers.
Thanks to [HMCL](https://github.com/HMCL-dev/HMCL) and [PCL2](https://github.com/Meloong-Git/PCL)
(both GPL-3.0). They have been with the vast majority of Minecraft players for years, and this
project has benefited a great deal from their design choices and implementation details.
+113 -44
View File
@@ -2,39 +2,81 @@
[English](README.md) · **简体中文**
Rust 原生单文件 Minecraft 启动器:**Slint + Metro 视觉**,内置 **AI Agent 客户端**(对话 / 改配置 / 崩溃诊断),
融合主流 Agent 工具(**MCP**),**最低基线 Win10+**。
> **官方项目主页**:https://github.com/AskaEth/Unlucky-Launcher | 下载、议题、发布说明都在这里:
> <https://github.com/AskaEth/Unlucky-Launcher/releases>
- 运行时:纯 Rust 原生二进制——无 JVM、无 WebView、无外部运行时
- UI:Slint(Royalty-Free 2.0;GL 优先,CPU 软渲染兜底,无可用 GPU 的老机也有完整 UI)
- 许可:Apache-2.0 © AskaEth (YeiJ)
Unlucky Launcher(ULL)是一个用 Rust 写的免费开源 Minecraft: Java 版启动器(Apache-2.0)。
每个平台就一个原生二进制——没有安装包、没有捆绑运行时、没有 WebView。启动器内置 AI Agent
客户端(对话 / 改配置 / 崩溃诊断),也能作为 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,校验游戏拥有状态
- **账户凭据仅保存在本机**,不上传任何服务器,AI 功能无法访问
- 加载器:Forge / NeoForge / Fabric / Quilt 四家真机验证
- Java 管理:Temurin / Oracle / Microsoft / Zulu 四发行版回退链
- 多源下载引擎、崩溃诊断规则引擎
- 可作为 MCP server 接入外部 Agent 工具(`ull agent serve`)
- 本地离线档案:仅用于本地/隔离网络环境的测试,不经过 Microsoft 账户认证,无法连接启用正版验证的服务器
### 轨道 A —— 已发布:v0.1.0,Slint 版(现在能下载的就是它)
**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 风格骨架,主题系统与完整界面
随后续版本交付——在此之前请使用 CLI。
### 轨道 B —— dev 上的下一代 UI(iced + M3 Expressive,施工中)
**这条轨道还没发布——要看只能自己从源码编。**
- **方向已锁死**:视觉 = 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)
├── crates/core # 领域库:零 UI 依赖,八模块
├── crates/agent # AI Agent:ToolSpec 注册表 + 内置客户端 + MCP server(双形态共用一个内核)
├── crates/app # Slint UI:唯一允许依赖 UI 框架的 crate
├── crates/cli # CLI(bin `ull`):headless,零 UI,只依赖 core + agent
└── packaging # 单文件产物矩阵与打包资源(规划中)
├── crates/core # 领域库:零 UI 依赖,八模块
├── crates/agent # AI Agent:ToolSpec 注册表 + 内置客户端 + MCP server(双形态共用一个内核)
├── crates/design # 设计令牌:真 HCT 色彩 / 排版 / 形状 / 间距 / 高程 / 动效 / 图标 / 组件度量
│ # —— 零 UI 依赖、零第三方 crate
├── 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` 的八个模块:
@@ -45,7 +87,7 @@ Unlucky-Launcher (cargo workspace)
| `download` | 多源健康度、镜像、分块下载、边下边算 |
| `game` | 版本/实例仓库、快照与继承解析 |
| `launch` | 10 阶段启动管线、进程、JSONL 事件流 |
| `auth` | Microsoft 正版登录 / 本地离线档案 |
| `auth` | Microsoft 账户登录(OAuth 2.0 device code → Xbox Live → XSTS → Minecraft Services) |
| `addon` | 加载器安装、整合包、CurseForge / Modrinth |
| `diagnose` | 五类信号采集、YAML 规则引擎、hs_err 解析 |
| `platform` | OS 抽象:路径、进程、文件锁、能力探测、运行期 OS 检测 |
@@ -54,44 +96,71 @@ Unlucky-Launcher (cargo workspace)
前置:
- Rust stable(`rust-toolchain.toml` 会自动选择工具链;组件 `rustfmt` / `clippy`)
- 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`
(Slint winit 后端 + femtovg 编译依赖)
- Rust stable(`rust-toolchain.toml` 会自动选工具链;门禁还需要 `rustfmt` / `clippy` 组件)
- 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`——两套 UI 都要用(Slint 的 winit + femtovg,iced 的 winit + wgpu/tiny-skia)
```bash
cargo fmt --all --check # 格式
cargo clippy --workspace --all-targets -- -D warnings # 静态检查(零 warning 门禁)
cargo test --workspace # 测试
cargo build --release # 产物:target/release/unlucky-launcher[.exe]
cargo fmt --all --check # 格式
cargo clippy --workspace --all-targets -- -D warnings # 静态检查(零 warning 门禁)
cargo test --workspace # 测试
cargo build --release # 三个 bin 都落在 target/release/
```
软渲染开关(无可用 GPU / 烂驱动的老机):`unlucky-launcher --software-renderer`
(等价于 `SLINT_BACKEND=winit-software`)。
编出来的三个 bin:
- `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 门禁
每次推送到 `main` 与每个 PR 都跑 fmt / clippy(warnings 视为错误)/ 全量测试,Linux 无头渲染冒烟
与它们同级、是硬门禁:红了就不许合。macOS 运行期探针还不算——它只在手动 `workflow_dispatch` 时跑,
结果留证据但不作为流水线结论(`continue-on-error`),直到该槽位在真实 runner 上首跑通过。
| Workflow | 内容 |
|----------|------|
| `.github/workflows/ci.yml` | fmt + clippy + test + build,Windows / Linux 矩阵 |
| `.github/workflows/guards.yml` | core 零 UI 守门、单文件 ≤400 行守门、cargo-deny 许可守门 |
| `.github/workflows/ci.yml` | fmt + clippy + test + release build(Windows / Linux 矩阵);Linux runtime smoke(Xvfb 里真渲染一帧,再断言 PNG 非空白);macOS 运行期探针(仅手动 dispatch) |
| `.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`、无导入表白名单
- 其他平台:macOS 12+ 一等;Linux x64 X11 优先、Wayland 为**大方向**;Android Termux 试验级不承诺
- UI 兜底:Slint 软渲染(无 GPU / 烂驱动的老机可完整使用)
- 平台独立性体现在**独立测试矩阵**,而非独立产物链;能力差异用 feature gate + 运行期探测表达
- Windows 10/11 的产物由**标准工具链**(MSVC / GNU)构建,构建方式与上游 cargo 一致
- macOS 12+(Apple Silicon 与 Intel)与 Linux x64 / arm64 都是支持槽位;Linux 上 X11 是当前首选路径,
Wayland 是大方向。macOS 产物未签名,首次启动要自己清掉 Gatekeeper 隔离标记
(`xattr -d com.apple.quarantine`)
- Android Termux 试验级、不承诺:只保证 CLI,GUI 未验证
- 每个平台跑各自的测试矩阵;能力差异用 feature gate + 运行期探测表达。
## 许可证与致谢
- 本项目:Apache License 2.0,见 [LICENSE](LICENSE);第三方声明见 [NOTICE](NOTICE)
- GUI 使用 [Slint](https://slint.dev)(Royalty-Free 2.0 轨道)。按 RF 2.0 义务,关于页将放置
`AboutSlint` 部件(P2),在此之前由 NOTICE 与本节承担告知义务
- **本项目**:Apache License 2.0,见 [LICENSE](LICENSE);第三方声明见 [NOTICE](NOTICE)。
- **Slint**(<https://slint.dev>),Royalty-Free 2.0:v0.1.0 发布的正是 Slint 版,这项义务继续有效
——关于页放 `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 玩家好多年。
ULL 的设计与实现从它们身上学到了太多,谨以此项目向两位前辈致敬。
感谢 [HMCL](https://github.com/HMCL-dev/HMCL) 和 [PCL2](https://github.com/Meloong-Git/PCL)
(均 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]
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 内部类型**。
# P0 的 host 是 src/mock.rs 的假后端;这两个依赖在这里登记,是为了后续把真实 host
@@ -25,5 +30,7 @@ unlucky-agent = { path = "../agent", version = "0.1" }
[build-dependencies]
slint-build.workspace = true
[dev-dependencies]
[lints]
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();
ui.on_new_instance(move || {
let Some(this) = weak.upgrade() else { return };
this.state
.borrow_mut()
.toasts
.push(ToastKind::Info, "新建实例向导(G5)在 P1 落地");
// G5:打开向导(版本 → 加载器 → 名字 → 确认 → 安装进度)
this.state.borrow_mut().wizard = crate::wizard::WizardState::open();
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::Verify => RuntimePhase::Verifying,
dto::LaunchStage::Launch => RuntimePhase::Launching,
dto::LaunchStage::Window => RuntimePhase::Running,
};
(phase, progress.ratio(), stage.label().to_string())
}
+5
View File
@@ -8,12 +8,17 @@
mod agent;
mod instances;
mod logs;
mod pages;
mod ui;
mod versions;
pub use agent::{chat, dialog, toasts};
pub use instances::{running_tiles, tiles};
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::{
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,
+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::{UiVersion, VersionKind};
/// 下载页版本行(已按「仅已装」+ 版本号筛选词过滤)。
pub fn versions(state: &AppState) -> Vec<UiVersion> {
let query = state.download_query.trim().to_lowercase();
state
.versions
/// 分类侧栏 → 该分类下的版本(远古版按子类细分)。
pub fn in_category(category: VersionCategory, kind: DtoVersionKind) -> bool {
match (category, kind) {
(VersionCategory::Release, DtoVersionKind::Release) => true,
(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()
.filter(|version| !state.installed_only || version.installed)
.filter(|version| query.is_empty() || version.id.to_lowercase().contains(&query))
.map(|version| UiVersion {
id: version.id.clone().into(),
kind: version_kind(version.kind),
@@ -22,6 +30,20 @@ pub fn versions(state: &AppState) -> Vec<UiVersion> {
.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 {
match kind {
DtoVersionKind::Release => VersionKind::Release,
+5 -3
View File
@@ -4,11 +4,13 @@
//! 当前条被确认/取消后由 [`DialogQueue::advance`] 推进到下一条,UI 只渲染 `current`。
//! 非阻塞提示走 [`crate::toast`],不占模态队列。
use serde::{Deserialize, Serialize};
/// 对话框标识。
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 {
/// 提示类(教程、首次使用说明)
Hint = 0,
@@ -26,7 +28,7 @@ impl DialogSource {
}
/// 对话框类型(09 §3:确认 / 进度 / 输入 / 错误)。
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum DialogKind {
/// 确认:主按钮 + 次按钮
Confirm {
@@ -87,7 +89,7 @@ impl DialogKind {
}
/// 一条对话框请求。
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DialogRequest {
/// 队列内唯一 id(由 [`DialogQueue::push_request`] 重新编号)
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 事件。
use serde::{Deserialize, Serialize};
/// 会话消息角色。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ChatRole {
/// 用户
User,
@@ -14,7 +16,7 @@ pub enum ChatRole {
}
/// 工具调用可视化条目。
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolCallView {
/// 工具名(与 ToolSpec 注册表同名)
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 role: ChatRole,
@@ -60,7 +62,7 @@ impl ChatMessage {
}
/// agent 侧事件。
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AgentEvent {
/// 追加一条完整消息
MessageAppended(ChatMessage),
+162 -11
View File
@@ -1,12 +1,46 @@
//! 事件与指令: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::dto::{AgentEvent, DiagnosisReport, InstanceId, InstanceRuntime, InstanceSummary};
use crate::dto::{LogLine, LogSession, SessionId, VersionCategory, VersionSummary};
use crate::dto::CreateRequest;
use crate::dto::{
AccountView, AgentEvent, DeviceLoginView, DiagnosisReport, InstallProgressView, InstanceId,
InstanceRuntime, InstanceSummary, JavaPageView, LogLine, LogOutcome, LogSession, NoticeView,
SessionId, SettingGroupView, VersionCategory, VersionSummary,
};
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 的三条通道之二)。
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum CoreEvent {
/// 实例列表整体刷新
InstancesChanged(Vec<InstanceSummary>),
@@ -21,15 +55,31 @@ pub enum CoreEvent {
VersionListChanged(Vec<VersionSummary>),
/// 日志会话列表刷新
LogSessionsChanged(Vec<LogSession>),
/// 当前会话日志行刷新(P0 整批替换;P1 改增量追加)
/// 当前会话日志行刷新(整批替换)
LogLinesChanged(Vec<LogLine>),
/// 实时日志增量(启动中尾随 JSONL;`running=false` 表示会话收尾)
LogAppended(LiveLogBatch),
/// 启动收尾(磁贴状态回写 + toast)
LaunchFinished(LaunchFinished),
/// 诊断结论产出
DiagnosisReady(DiagnosisReport),
/// 账户列表刷新
AccountsChanged(Vec<AccountView>),
/// 微软登录状态刷新
DeviceLoginChanged(DeviceLoginView),
/// Java 页快照(发行版/回退链/运行时)
JavaChanged(JavaPageView),
/// 安装进度(`None` = 清掉进度面板)
InstallProgress(Option<InstallProgressView>),
/// 设置分组刷新(设置页右栏)
SettingsChanged(Vec<SettingGroupView>),
/// 横幅通知刷新(隔离切换提示/启动失败等)
NoticesChanged(Vec<NoticeView>),
}
/// 统一事件队列的元素:core / agent / UI 自产(对话框、Toast)都走这一条路,
/// 这样 [`crate::state::AppState::apply`] 是唯一的状态变更入口。
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum AppEvent {
/// 来自 core
Core(CoreEvent),
@@ -44,22 +94,58 @@ pub enum AppEvent {
}
/// app → core/agent 的用户意图。
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AppCommand {
/// 重新扫描实例目录
RefreshInstances,
/// 重新扫描实例目录 + 全部页面快照(启动时与手改文件后)
RefreshAll,
/// 启动实例
LaunchInstance(InstanceId),
/// 停止实例(含取消启动中的管线)
StopInstance(InstanceId),
/// 拉取某分类的版本列表
ListVersions(VersionCategory),
/// 安装指定版本
/// 安装指定版本(下载页行内「安装」按钮;原版)
InstallVersion(String),
/// 取消安装(阶段边界生效:当前阶段跑完就停,不再进下一阶段)
CancelInstall,
/// 新建实例(向导提交:版本 + 加载器 + 名字)
CreateInstance(CreateRequest),
/// 修改实例隔离档位(写实例设置文件)
SetIsolation {
/// 实例 id
id: InstanceId,
/// `isolated` / `shared`
mode: String,
},
/// 刷新日志会话列表
RefreshLogSessions,
/// 打开某个会话(拉取日志行 + 触发诊断)
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 只回事件)
AskAgent(String),
/// 中断当前 AI 流
@@ -68,7 +154,17 @@ pub enum AppCommand {
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 模型。
@@ -79,8 +175,63 @@ pub trait Backend {
/// 下发一条用户意图。
fn dispatch(&mut self, command: AppCommand);
/// 时间推进(mock 用脚本推进进度;真实后端可以是空实现)。
/// 时间推进(真实后端在这里收后台线程的结果并尾随日志)。
fn tick(&mut self, elapsed: std::time::Duration) {
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:磁贴与列表视图的唯一数据源。
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// 实例标识:core::game 生成的稳定 id,app 视为不透明字符串。
pub type InstanceId = String;
/// 加载器种类(磁贴徽标 + 详情页版本链)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LoaderKind {
/// 原版(无加载器)
Vanilla,
@@ -37,10 +39,48 @@ impl LoaderKind {
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)。
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LoaderInfo {
/// 加载器种类
pub kind: LoaderKind,
@@ -48,6 +88,15 @@ pub struct LoaderInfo {
pub version: String,
}
impl Default for LoaderInfo {
fn default() -> Self {
Self {
kind: LoaderKind::Vanilla,
version: String::new(),
}
}
}
impl LoaderInfo {
/// 徽标文字:原版只写「原版」,装加载器时写「Fabric 0.16.5」。
pub fn badge(&self) -> String {
@@ -60,7 +109,7 @@ impl LoaderInfo {
}
/// 字节进度;`total == 0` 表示总量未知(UI 转不确定态)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Progress {
/// 已完成量
pub done: u64,
@@ -85,7 +134,7 @@ impl Progress {
}
/// 启动管线阶段(磁贴进度环的文案来源;D8 的 10 阶段在 P1 细化)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LaunchStage {
/// 下载资源
Download,
@@ -93,6 +142,8 @@ pub enum LaunchStage {
Verify,
/// 组装命令行并拉起进程
Launch,
/// 进程已起、等待游戏窗口(M28 的窗口检测阶段)
Window,
}
impl LaunchStage {
@@ -101,13 +152,14 @@ impl LaunchStage {
match self {
Self::Download => "下载资源",
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 {
/// 空闲
#[default]
@@ -132,7 +184,7 @@ pub enum InstanceRuntime {
}
/// 实例摘要:磁贴/列表视图唯一数据源。
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InstanceSummary {
/// 稳定 id
pub id: InstanceId,
@@ -148,4 +200,97 @@ pub struct InstanceSummary {
pub runtime: InstanceRuntime,
/// 实例图标路径(P0 直接给路径;P1 换成 core 提供的图像句柄)
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 诊断结论。
use serde::{Deserialize, Serialize};
/// 一次启动会话(日志页左栏单位)标识。
pub type SessionId = String;
/// 一次运行的结局(G3 左栏图标:✓ 正常 / ✗ 崩溃 / ⚠ 静默失败)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LogOutcome {
/// 正常退出
Ok,
@@ -15,7 +17,7 @@ pub enum LogOutcome {
}
/// 会话摘要。
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LogSession {
/// 会话 id
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 {
/// 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 text: String,
@@ -52,7 +54,7 @@ pub struct LogLine {
}
/// D8 规则引擎结论(日志页崩溃诊断卡片)。
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Diagnosis {
/// 命中规则 id(`gpu.driver_old`)
pub rule: String,
@@ -67,7 +69,7 @@ pub struct Diagnosis {
}
/// 诊断结论 + 归属会话(UI 用它决定卡片显隐)。
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiagnosisReport {
/// 归属会话
pub session: SessionId,
+21 -5
View File
@@ -2,22 +2,38 @@
//!
//! 这里只允许出现「扁平 DTO / 事件 / 指令」:`crates/app` 任何模块都不得 `use unlucky_core::*`
//! 或 `use unlucky_agent::*` 的内部类型。core/agent 侧的 host 层负责把自身类型翻译成本模块的类型,
//! 反向的 [`AppCommand`] 也一样。P0 阶段由 [`crate::mock`] 的假后端实现 [`Backend`] 驱动骨架。
//! 反向的 [`AppCommand`] 也一样。P0 阶段由 [`crate::mock`] 的假后端实现 [`Backend`] 驱动骨架,
//! P3 起由 [`crate::host`] 的真实后端驱动全部页面。
//!
//! 结构:**按领域拆子模块**(instances / versions / logs / agent / events),
//! 全量 `pub use` 出去,调用方依旧写 `crate::dto::InstanceSummary` 这类路径。
//! 结构:**按领域拆子模块**(instances / versions / logs / agent / accounts / java /
//! settings / install / notices / events),全量 `pub use` 出去,调用方依旧写
//! `crate::dto::InstanceSummary` 这类路径。
//! 契约说明(字段口径、事件顺序、错误语义)见 `DevDocs/SubAgentsTalk/app-DTO通道约定.md`。
//!
//! 全部跨界类型 `Serialize + Deserialize`(AGENTS §6):事件与快照是 IPC 契约,
//! 序列化能力由 derive 钉住,`dto` 的单测做 round-trip 断言。
mod accounts;
mod agent;
mod events;
mod install;
mod instances;
mod java;
mod logs;
mod notices;
mod settings;
mod versions;
pub use accounts::{AccountKind, AccountView, DeviceLoginPhase, DeviceLoginView};
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::{
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 notices::{NoticeLevel, NoticeView};
pub use settings::{SettingControl, SettingGroupView, SettingRowView};
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)。
use serde::{Deserialize, Serialize};
/// 版本类型(远古版按 unlisted-versions 的分类拆开)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum VersionKind {
/// 正式版
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 {
/// classic(rd-*)
Classic,
@@ -32,7 +34,7 @@ impl VersionKind {
}
/// 下载页版本行。
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VersionSummary {
/// 版本 id(如 `1.20.4` / `rd-132211`)
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 {
/// 正式版
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)]
mod appearance_bind;
mod backend;
mod bg_image;
mod callbacks;
mod callbacks_pages;
mod conv;
mod dialogs;
mod dto;
mod host;
mod mock;
mod mock_data;
mod page_sync;
mod png;
mod prefs;
mod router;
mod screenshot;
mod settings_bridge;
mod state;
mod theme_runtime;
mod toast;
mod ui_bind;
mod wizard;
slint::include_modules!();
fn main() -> Result<(), slint::PlatformError> {
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 环境下的唯一可靠取图方式:
// 不截屏幕,而是让 Slint 把当前场景渲染进内存像素缓冲再自己编码 PNG)。
if let Some(path) = screenshot_path() {
if std::env::args().any(|arg| arg == "--screenshot-settings") {
app.open_settings_appearance();
}
app.render_screenshot(&path)?;
// P3 追加页面选择与中间态(安装进度 / 启动事件流),见 `crate::screenshot`。
if let Some(options) = screenshot::shot_options() {
screenshot::capture(&app, &options)?;
return Ok(());
}
@@ -78,17 +82,3 @@ fn apply_renderer_flag() {
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),
)));
}
AppCommand::RefreshInstances
| AppCommand::RefreshLogSessions
| AppCommand::StopAgent => {}
AppCommand::RefreshAll | AppCommand::RefreshLogSessions | AppCommand::StopAgent => {}
// 其余指令(安装向导/账户/Java/设置/通知)由真实 host 承接:mock 不模拟
_ => {}
}
}
+12
View File
@@ -22,6 +22,9 @@ pub fn mock_instances() -> Vec<InstanceSummary> {
last_played: Some("2026-10-29T19:22:00Z".to_string()),
runtime: InstanceRuntime::Idle,
icon: None,
isolation: "isolated".to_string(),
version_id: "1.20.4".to_string(),
version_present: true,
},
InstanceSummary {
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()),
runtime: InstanceRuntime::Idle,
icon: None,
isolation: "isolated".to_string(),
version_id: "1.21.9".to_string(),
version_present: true,
},
InstanceSummary {
id: "ancient-1.7.10".to_string(),
@@ -46,6 +52,9 @@ pub fn mock_instances() -> Vec<InstanceSummary> {
last_played: None,
runtime: InstanceRuntime::Idle,
icon: None,
isolation: "shared".to_string(),
version_id: "1.7.10".to_string(),
version_present: false,
},
InstanceSummary {
id: "modded-dev".to_string(),
@@ -60,6 +69,9 @@ pub fn mock_instances() -> Vec<InstanceSummary> {
reason: "exit0 静默失败".to_string(),
},
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::dto::{
AgentEvent, AppEvent, ChatMessage, ChatRole, CoreEvent, DiagnosisReport, InstanceRuntime,
InstanceSummary, LogLine, LogSession, Progress, ToolCallView, VersionSummary,
AccountView, AgentEvent, AppEvent, ChatMessage, ChatRole, CoreEvent, DeviceLoginView,
DiagnosisReport, InstallProgressView, InstanceRuntime, InstanceSummary, JavaPageView, LogLine,
LogSession, NoticeView, Progress, SettingGroupView, ToolCallView, VersionSummary,
};
use crate::prefs::UiPrefs;
use crate::router::{AiPanelMode, Route, Router};
use crate::toast::ToastCenter;
use crate::toast::{ToastCenter, ToastKind};
use crate::wizard::WizardState;
/// AI 会话状态(右侧栏与独立页共享同一份,切换不丢)。
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -75,6 +77,9 @@ pub fn grid_positions(count: usize, columns: usize) -> Vec<(usize, usize)> {
.collect()
}
/// 实时日志最多保留的行数(滚动窗口;更多行由日志页的筛选/历史会话承担)。
pub const MAX_LIVE_LINES: usize = 2_000;
/// 应用全局状态。
#[derive(Debug)]
pub struct AppState {
@@ -106,6 +111,24 @@ pub struct AppState {
pub installed_only: bool,
/// 下载页版本号筛选词
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 {
@@ -132,6 +155,15 @@ impl AppState {
instance_query: String::new(),
installed_only: false,
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::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::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) {
match event {
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<'_>> {
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,
runtime: InstanceRuntime::Idle,
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 不阻塞、不排序、可叠加,
//! 只做「先进先出 + 上限裁剪 + TTL 到期」。UI 侧由 Rust 定时器每帧调用 [`ToastCenter::expire`]。
use serde::{Deserialize, Serialize};
/// Toast 标识。
pub type ToastId = u64;
/// Toast 语义色。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ToastKind {
/// 一般信息
Info,
@@ -20,7 +22,7 @@ pub enum ToastKind {
}
/// 一条 Toast;`remaining_ms` 由 [`ToastCenter::expire`] 递减。
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Toast {
/// 队列内唯一 id(由 [`ToastCenter::push_request`] 重新编号)
pub id: ToastId,
+87 -88
View File
@@ -14,14 +14,14 @@ use std::time::Duration;
use slint::{ComponentHandle, ModelRc, VecModel};
use crate::backend::{build_backend, config_path, AppOptions};
use crate::bg_image::BackgroundLayers;
use crate::conv;
use crate::dto::{AppCommand, Backend, InstanceId};
use crate::mock::MockBackend;
use crate::dto::{AppCommand, Backend};
use crate::prefs::BgFill;
use crate::settings_bridge::SettingsBridge;
use crate::state::AppState;
use crate::{MainWindow, SettingSection};
use crate::{MainWindow, PageState, SettingSection};
/// 定时器节拍:33ms ≈ 30fps(磁贴进度环 / 流式文本 / Toast TTL 的刷新下限)。
const TICK_MS: u64 = 33;
@@ -67,19 +67,28 @@ pub(crate) struct BackgroundKey {
}
impl AppUi {
/// 建状态 + 假后端、读配置、接全部回调、灌初始事件并做第一次同步。
/// 建状态 + 真实后端、读配置、接全部回调、灌初始事件并做第一次同步。
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 mut state = AppState::new();
state.prefs.appearance = appearance;
state.prefs.theme = theme;
let backend = build_backend(&options);
let inner = Rc::new(Inner {
ui,
state: RefCell::new(state),
backend: RefCell::new(Box::new(MockBackend::new())),
backend: RefCell::new(backend),
bridge: RefCell::new(bridge),
background: RefCell::new(BackgroundLayers::default()),
background_key: RefCell::new(None),
@@ -88,6 +97,7 @@ impl AppUi {
});
crate::callbacks::wire(Rc::downgrade(&inner));
crate::callbacks_pages::wire_pages(Rc::downgrade(&inner));
inner.drain_events();
inner.align_system_theme();
inner.apply_theme();
@@ -134,6 +144,71 @@ impl AppUi {
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`)。
///
/// 为什么要这条通道:验收环境可能没有交互桌面(`CopyFromScreen` 直接失败),
@@ -150,8 +225,8 @@ impl AppUi {
.set_size(slint::WindowSize::Logical(slint::LogicalSize::new(
1180.0, 760.0,
)));
// 先走几拍:把 mock 事件(实例/版本/日志)与 33ms 节拍的效果灌进去
for _ in 0..4 {
// 先走几拍:把后端事件(实例/版本/日志/账户/设置)与 33ms 节拍的效果灌进去
for _ in 0..8 {
self.tick_once();
std::thread::sleep(Duration::from_millis(TICK_MS));
}
@@ -245,89 +320,13 @@ impl Inner {
ui.set_dialog(dialog);
ui.set_dialog_open(open);
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> {
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 的占位
// NavItem/Tile 组件由 `components/nav.slint` 与 `components/tile.slint` 取代,占位内核不再保留。
// P3:页面状态 global 从根文件再导出一遍,Rust 侧才能拿到 `PageState` 类型。
export { PageState } from "page_state.slint";
import { AppShell } from "shell.slint";
import { Theme } from "theme/tokens.slint";
import { Metrics } from "theme/metrics.slint";
+146
View File
@@ -216,3 +216,149 @@ export struct UiToast {
kind: ToastKind,
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)+ 右侧版本列表。
// 远古版入口显式可达(ULL 对 HMCL「隐藏」行为的纠偏),下挂 classic/alpha/beta/unlisted 子类树,
// 进入远古版分类时顶部提示兼容性 + 每行带「远古版」徽标(防误装)。
//
// P3 接线:右侧多一个「Java 运行时」视图(四家发行版 + 回退链),页面底部常驻安装进度条。
import { Theme } from "../theme/tokens.slint";
import { Metrics } from "../theme/metrics.slint";
import { UiVersion, VersionKind, DownloadCategory } from "../models.slint";
import { PageState } from "../page_state.slint";
import { UiText, TextRole } from "../components/label.slint";
import { AppIcon, IconKind } from "../components/icons.slint";
import { Badge } from "../components/badge.slint";
import { MetroButton, ButtonKind } from "../widgets/button.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";
component CategoryRow inherits Rectangle {
@@ -112,6 +117,8 @@ export component DownloadPage inherits Rectangle {
in-out property <string> query: "";
/// 远古版子类树是否展开(默认展开,显式可达)
in-out property <bool> ancient-expanded: true;
/// 右侧视图:0 = 版本列表,1 = Java 运行时
in-out property <int> view-tab <=> PageState.download-tab;
callback category-changed(DownloadCategory);
callback install(string);
@@ -123,148 +130,191 @@ export component DownloadPage inherits Rectangle {
background: Theme.bg-canvas;
HorizontalLayout {
// ── 页内分类侧栏(常驻)───────────────────────────────────
VerticalLayout {
// 视图切换(版本 / Java)
Rectangle {
width: 200px;
height: 40px;
background: Theme.bg-surface;
border-width: 1px;
border-color: Theme.stroke;
HorizontalLayout {
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 {
padding-top: Metrics.gap-2;
vertical-stretch: 1;
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); }
Rectangle {
height: Metrics.page-header-height;
background: Theme.bg-canvas;
HorizontalLayout {
padding-left: Metrics.gap-4;
padding-right: Metrics.gap-4;
spacing: Metrics.gap-3;
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 {
label: root.ancient-expanded ? "远古版 ▾" : "远古版 ▸";
active: root.category == DownloadCategory.ancientClassic
// 远古版兼容性提示条(首次进入显示,显式可达的代价告知)
Rectangle {
height: 44px;
visible: 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); }
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(); }
}
}
}
CategoryRow {
label: "整合包(P1)";
disabled: true;
}
CategoryRow {
label: "Mod(P1)";
disabled: true;
}
Rectangle { height: 1px; background: Theme.stroke; }
Rectangle { vertical-stretch: 1; background: transparent; }
ListView {
vertical-stretch: 1;
for v in root.versions : VersionRow {
data: v;
install => { root.install(v.id); }
}
}
}
}
// ── 右侧版本列表 ──────────────────────────────────────────
VerticalLayout {
if root.view-tab == 1 : JavaPanel {
vertical-stretch: 1;
}
Rectangle {
height: Metrics.page-header-height;
background: Theme.bg-canvas;
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); }
}
}
// 安装进度(下载页行内安装 / 向导共用同一份进度)
InstallPanel {
data: PageState.install;
cancel => { PageState.install-cancel(); }
}
}
}
+49 -19
View File
@@ -8,6 +8,7 @@ import { AppIcon, IconKind } from "../components/icons.slint";
import { Badge } from "../components/badge.slint";
import { MetroButton, ButtonKind } from "../widgets/button.slint";
import { SearchField } from "../widgets/inputs.slint";
import { PageState } from "../page_state.slint";
import { ListView } from "std-widgets.slint";
component SessionRow inherits Rectangle {
@@ -106,6 +107,8 @@ export component LogsPage inherits Rectangle {
callback ask-ai(string);
callback apply-fix();
callback query-changed(string);
/// 重新扫会话索引(手动刷新)
callback refresh();
background: Theme.bg-canvas;
@@ -157,30 +160,57 @@ export component LogsPage inherits Rectangle {
VerticalLayout {
vertical-stretch: 1;
// 会话头:标题 + 实时标记 + 筛选 + 刷新(第一行),崩溃日志导入(第二行)
Rectangle {
height: 44px;
height: 84px;
background: Theme.bg-surface;
HorizontalLayout {
padding-left: Metrics.gap-3;
padding-right: Metrics.gap-3;
spacing: Metrics.gap-2;
VerticalLayout {
HorizontalLayout {
height: 44px;
padding-left: Metrics.gap-3;
padding-right: Metrics.gap-3;
spacing: Metrics.gap-2;
UiText {
role: TextRole.bodyStrong;
text: root.selected == "" ? "未选择会话" : root.selected;
horizontal-stretch: 1;
UiText {
role: TextRole.bodyStrong;
text: root.selected == "" ? "未选择会话" : root.selected;
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;
placeholder: "筛选日志行";
text <=> root.query;
edited(t) => { root.query-changed(t); }
}
MetroButton {
kind: ButtonKind.subtle;
text: "打开目录";
button-height: 26px;
// 崩溃日志导入分析(hs_err_pid*.log / crash-*.txt / latest.log)
HorizontalLayout {
height: 40px;
padding-left: Metrics.gap-3;
padding-right: Metrics.gap-3;
spacing: Metrics.gap-2;
SearchField {
horizontal-stretch: 1;
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 { SettingItem, SettingGroup, ControlKind } from "../widgets/setting-item.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 { AboutSlint, ScrollView } from "std-widgets.slint";
@@ -235,101 +238,15 @@ export component SettingsPage inherits Rectangle {
toggled(v) => { root.close-to-tray-changed(v); }
}
// ── 数据 ────────────────────────────────────────
if root.section == SettingSection.data : SettingGroup { title: "数据目录(D6 三模式)"; }
if root.section == SettingSection.data : SettingItem {
label: "存储模式";
description: "便携(运行目录 .ULL/.minecraft)/ 标准(系统应用数据目录)/ 自定义";
kind: ControlKind.segmented;
options: ["便携", "标准", "自定义"];
selected: root.data-mode;
changed(i) => {
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(); }
}
// ── 数据 / 下载 / Java / 账户 / AI / MCP ────────────
// 行来自 core::setting schema(`PageState.setting-groups`),写回走
// `SettingStore::set`——与 `ull config set` / AI `set_config` 同一份 launcher.json。
if root.section == SettingSection.data : SettingsGroups { tab: "data"; }
if root.section == SettingSection.download : SettingsGroups { tab: "download"; }
if root.section == SettingSection.java : SettingsGroups { tab: "java"; }
if root.section == SettingSection.ai : SettingsGroups { tab: "ai"; }
if root.section == SettingSection.ai : SettingsGroups { tab: "mcp"; }
if root.section == SettingSection.account : AccountSection { }
// ── 代理 ────────────────────────────────────────
if root.section == SettingSection.proxy : SettingGroup { title: "代理"; }
+14
View File
@@ -7,6 +7,8 @@ import {
RouteKind, TileSize, InstanceView, SettingSection, DownloadCategory, ThemeMode, BgFillKind,
UiTile, UiVersion, UiLogSession, UiLogLine, UiDiagnosis, UiChatMessage, UiDialog, UiToast,
} from "models.slint";
import { PageState } from "page_state.slint";
import { NewInstanceWizard } from "widgets/wizard.slint";
import { TitleBar } from "components/titlebar.slint";
import { Nav } from "components/nav.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 { DialogHost } from "widgets/dialog.slint";
import { ToastHost } from "widgets/toast.slint";
import { NoticeBar } from "components/notice-bar.slint";
import { InstancesPage } from "pages/instances.slint";
import { DownloadPage } from "pages/download.slint";
import { LogsPage } from "pages/logs.slint";
import { SettingsPage } from "pages/settings.slint";
export component AppShell inherits Rectangle {
// ── 窗口 ────────────────────────────────────────────────────
in property <string> app-title: "Unlucky Launcher";
@@ -189,6 +193,10 @@ export component AppShell inherits Rectangle {
request-close => { root.request-close(); }
}
NoticeBar {
notices: PageState.notices;
}
HorizontalLayout {
// 左侧主导航
Nav {
@@ -259,6 +267,7 @@ export component AppShell inherits Rectangle {
select(id) => { root.log-select(id); }
ask-ai(id) => { root.log-ask-ai(id); }
apply-fix => { root.log-apply-fix(); }
refresh => { PageState.logs-refresh(); }
}
if root.route == RouteKind.settings : SettingsPage {
@@ -356,6 +365,11 @@ export component AppShell inherits Rectangle {
}
}
// 新建实例向导(全屏模态;数据来自 PageState)
NewInstanceWizard {
versions: root.versions;
}
// 覆盖层:模态队列(一次一条)+ 右下 Toast
DialogHost {
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 {
/// 离线(本地生成的 UUID,不联网)。
Offline,
/// 微软(device-code)。
/// 微软(device-code / 授权码浏览器)。
Microsoft,
/// authlib-injector 第三方认证服务器(标准 Yggdrasil 协议)。
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 的端点族区分)。
//!
//! 两个端点族不能混用(`analyse/04` §4 的「第一个坑」):
//! - 设备码流:`login.microsoftonline.com/consumers/oauth2/v2.0/*`;
//! - HMCL 的浏览器授权码流走 `login.live.com`(本期不实现,故不在此表)。
//! 两条流**共用同一个端点族**(`research/08` §2 硬性约束 1:租户只能用 `consumers`):
//! - 设备码流:`devicecode` + `token`;
//! - 授权码流:`authorize`(浏览器打开)+ 同一个 `token`(`grant_type=authorization_code`)。
//!
//! 都走 `login.microsoftonline.com/consumers/oauth2/v2.0/*`:用 `common` 或 AAD 租户 id
//! 拿 `XboxLive.signin` 会直接被拒(`analyse/04` §4 的「第一个坑」)。
//!
//! 端点集中成一张表是为了**测试可替换**:整表指向一个进程内 mock 服务器即可覆盖全链,
//! 生产默认走 [`MicrosoftEndpoints::consumers`]。
@@ -12,7 +15,9 @@
pub struct MicrosoftEndpoints {
/// 设备码申请(`POST`,表单 `client_id` + `scope`)。
pub device_code: String,
/// 换 token / 刷新 token(`POST`,表单)。
/// 授权码流的授权页(`GET`,浏览器打开;`research/08` §3 方案 A)。
pub authorize: String,
/// 换 token / 刷新 token / 授权码换 token(`POST`,表单)。
pub token: String,
/// Xbox Live 用户认证(`POST` JSON)。
pub xbl_authenticate: String,
@@ -35,6 +40,8 @@ impl MicrosoftEndpoints {
Self {
device_code: "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"
.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(),
xbl_authenticate: "https://user.auth.xboxlive.com/user/authenticate".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('/'));
Self {
device_code: join("devicecode"),
authorize: join("authorize"),
token: join("token"),
xbl_authenticate: join("xbl"),
xsts_authorize: join("xsts"),
@@ -66,9 +74,10 @@ impl MicrosoftEndpoints {
/// 所有端点(调试/诊断用)。
#[must_use]
pub fn all(&self) -> [&str; 8] {
pub fn all(&self) -> [&str; 9] {
[
&self.device_code,
&self.authorize,
&self.token,
&self.xbl_authenticate,
&self.xsts_authorize,
@@ -95,11 +104,15 @@ mod tests {
let endpoints = MicrosoftEndpoints::consumers();
assert!(endpoints.device_code.contains("login.microsoftonline.com"));
assert!(endpoints.token.contains("/consumers/oauth2/v2.0/token"));
// 授权码流的授权页与设备码流同族同租户(`research/08` §2:只能用 consumers)。
assert!(endpoints
.authorize
.contains("/consumers/oauth2/v2.0/authorize"));
assert!(endpoints
.xbl_authenticate
.contains("user.auth.xboxlive.com"));
assert!(endpoints.xsts_authorize.contains("xsts.auth.xboxlive.com"));
// 授权码流的 login.live.com 不在这张表里(本期不实现)。
// 微软 OAuth 两流都不走 HMCL 的 login.live.com 端点族。
assert!(endpoints
.all()
.iter()
@@ -110,6 +123,7 @@ mod tests {
fn all_on_keeps_paths_distinct() {
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.authorize, "http://127.0.0.1:1234/authorize");
assert_eq!(
endpoints.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)
}
fn tokens_from(reply: &Reply, url: &str) -> AuthResult<OAuthTokens> {
pub(crate) fn tokens_from(reply: &Reply, url: &str) -> AuthResult<OAuthTokens> {
let access_token = reply
.text("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 只在两个字段都非空时处理,我们收紧)。
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 {
return AuthError::network(
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`(凭据载荷)与**刷新判定**(纯函数,可脱离网络单测);
//! - [`endpoints`]:端点表(生产 = 微软消费版直连;测试 = 整表指向 mock);
//! - [`flow`]:device-code 发起 / 轮询 / refresh(OAuth 层);
//! - [`authcode`] + [`callback`]:授权码流(授权 URL + 本地 HTTP 回调服务器 + code 换 token);
//! - [`chain`]:XBL → XSTS → login_with_xbox → entitlements → profile(Xbox/MC 层)。
//!
//! 两条流只差「怎么拿到 MS access_token」:拿到之后都进同一个 [`chain::complete_chain`],
//! 落盘/刷新也共用 [`MicrosoftSession`],所以 GUI 账户页的两颗按钮不会长出两套凭据语义。
//!
//! # client id 从哪来
//!
//! device-code 流必须带一个**已在 Azure 注册的公共客户端 id**。HMCL 把它烘进 jar 的
//! 两条流都必须带一个**已在 Azure 注册的公共客户端 id**(且租户是 `consumers`、
//! 重定向 URI 登记为 `http://localhost`)。HMCL 把它烘进 jar 的
//! manifest(`hmcl.microsoft.auth.id`),我们同理:二进制里不硬编码,改由
//! `ULL_MSA_CLIENT_ID`(运行期)或构建期 `option_env!` 提供。没有配时
//! [`MicrosoftAuth::from_env`] 返回 [`AuthError::Unsupported`] 并说清要配什么——
//! 这比编一个假 id 让用户看到 `invalid_client` 诚实。
pub mod authcode;
pub mod callback;
pub mod chain;
pub mod endpoints;
pub mod flow;
pub mod session;
pub use authcode::{AuthCodePolicy, AuthCodePrompt, RedirectUri};
pub use callback::CallbackServer;
pub use endpoints::MicrosoftEndpoints;
pub use flow::{DeviceCodePrompt, PollPolicy};
pub use session::{MicrosoftSession, RefreshDecision, RefreshReason, USER_TYPE_MSA};
@@ -45,6 +55,7 @@ pub struct MicrosoftAuth {
client_id: String,
endpoints: MicrosoftEndpoints,
poll: PollPolicy,
authcode: AuthCodePolicy,
journal: Option<AuthJournal>,
}
@@ -62,6 +73,7 @@ impl MicrosoftAuth {
client_id: client_id.into(),
endpoints: MicrosoftEndpoints::consumers(),
poll: PollPolicy::default(),
authcode: AuthCodePolicy::default(),
journal: None,
})
}
@@ -131,6 +143,13 @@ impl MicrosoftAuth {
self
}
/// 换授权码流参数(回调超时 / 固定端口 / 绑定重试 / 固定 state;测试与 CLI 用)。
#[must_use]
pub fn with_authcode_policy(mut self, policy: AuthCodePolicy) -> Self {
self.authcode = policy;
self
}
/// 当前端点表。
#[must_use]
pub fn endpoints(&self) -> &MicrosoftEndpoints {
@@ -177,6 +196,34 @@ impl MicrosoftAuth {
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**)。
///
/// # Errors
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff. Show more