fix(core,app-iced): M308 Linux/macOS 的 .tar.gz 真的能自替换 + 重试按 retryable 分流 + 三处文档纠偏

M307 盘查抓到的一条真缺陷 + 两处交互/文档不一致,本条只修它们,不重做通道:

① Linux/macOS 自更新的真缺陷(选 A:真做)
   `job::self_replaceable` 只认裸 exe,而发布矩阵里那两平台的启动器是 `.tar.gz`(清单记作
   `portable-zip`)⇒ 查得到更新、按钮画得出来、点下去必失败。新增 `core::update::unpack`:
   **归档字节过完长度/摘要双检之后**,从归档里取出启动器本体(tar 头 mode 有可执行位 /
   `.exe` 名字的唯一普通文件),再交给原有的 `install_staged`(原子替换 + 回滚一字未改)。
   零新依赖(flate2/tar 早就是 core 的依赖,`core::game::java` 解 Temurin 用同一套)。
   三条具名错误:`LauncherMissing` / `LauncherNotExecutable`(魔数与 target 不符)/
   `LauncherSizeMismatch`(0 字节 / 超上限 / 与头声明不符);zip 容器**明确拒绝**(没有可靠的
   「哪个条目可执行」判据,不猜)。`target.rs` 的 `PackageFormat` 文档改成与判据逐字一致。

② 重试按钮按 `retryable` 分流
   验签类(`is_retryable == false`)不画「重试」,正文换成新 key `UpdateBannerFailedFinal`
   (「这次更新已被拒绝(重试没用):{reason}」)。文案抽到 `pages/update/failure.rs`
   (`pages/update.rs` 的红线),判据落在**真 Widget 树**上(探针 tag + 递归计数)。

③ 三处文档纠偏 + 同一条谎话的其它落点(都在 scope 内)
   `trust.rs`(正式表已有 `ull-release`,commit 68362e5)、`check_signing_ready.py` 自述、
   `target.rs`;顺带 `packaging/README.md`、`docs/{signing,signing-runbook}.md`、
   `check_test_key_off.py`;新文档 `docs/update-trust-anchor.md` 记「M254 审计的『正式表为空』
   已作废」。

判据:core 新增 `unpack_cases`(Linux tar.gz / macOS tar.gz 走完整条链 + 五种拒绝)、
`job_cases` 的形态表改成两种;app-iced 两条真 Widget 树用例(按钮在 / 不在)+ 一条取词入口用例
+ 一条源码守卫。i18n 加一条 key 并**重出金表**(`Key::ALL` 853 行,新行落在 index 834)。
This commit is contained in:
AskaEth committed 2026-11-13 23:38:17 +08:00
1 parent db24379e35
commit af82658d08
24 files changed
+1201 -58

No files matched your search

+8 -9
View File
@@ -103,6 +103,11 @@ pub struct Update {
}
mod busy;
// M308:失败提示条的正文与按钮(按 `retryable` 分流——验签类不画「重试」)。
mod failure;
use failure::failed_actions;
pub use failure::failed_body;
impl Update {
/// 有没有可装的候选(有的话设置页给「下载并安装」)。
@@ -300,16 +305,10 @@ pub fn banner<'a>(
)],
Role::PrimaryContainer,
),
Banner::Failed { reason, .. } => (
Banner::Failed { reason, retryable } => (
catalog.text(Key::UpdateBannerTitleFailed).to_string(),
reason.to_string(),
vec![buttons::text(
ui,
Button::new(
catalog.text(Key::UpdateBannerRetry),
Message::Update(UpdateAction::CheckNow),
),
)],
failed_body(catalog, reason, retryable),
failed_actions(ui, catalog, retryable),
Role::ErrorContainer,
),
};
@@ -0,0 +1,54 @@
//! 失败提示条的**文案与按钮**(M308:按 `retryable` 分流)。
//!
//! 判据来自 core:`UpdateErrorKind::is_retryable` 只有字节层(摘要 / 长度)、网络、IO 算可重试;
//! 签名五兄弟(漏签 / 签名坏 / 表里没这把钥 / 签名对不上)与归档三兄弟(没有唯一本体 / 本体不是
//! 本平台的可执行文件 / 本体大小不合规)都**不可重试**——成因在发布侧,重放一千次还是同一个坏包。
//!
//! | `retryable` | 正文 | 按钮 |
//! |---|---|---|
//! | `true` | 原因本身(「隔一会儿再试可能就过了」) | 一颗「重试」 |
//! | `false` | 「这次更新已被拒绝(重试没用):原因」 | **没有**(空表) |
//!
//! 为什么拆到本文件:`pages/update.rs` 贴着 400 行硬线(AGENTS §3-1),而「失败了该说什么、
//! 还能不能点」是独立的一块问题(与 `busy.rs` 同一取舍)。拆开之后正文那一半还能被单测直接喂——
//! `text` 控件在 iced 0.14 里是 stateless 的(`tag() == Tag::stateless()`),真控件树里读不到字符串,
//! 所以「哪句话」只能在取词入口断、「按钮在不在」才在真控件树上断(`tests.rs`)。
use iced::Element;
use unlucky_i18n::{Catalog, Key};
use crate::app::components::buttons::{self, Button};
use crate::pages::message::UpdateAction;
use crate::pages::Message;
use crate::theme::Ui;
/// 失败提示条的正文。
#[must_use]
pub fn failed_body(catalog: Catalog, reason: &str, retryable: bool) -> String {
if retryable {
return reason.to_string();
}
catalog.render(Key::UpdateBannerFailedFinal, &[("reason", reason)])
}
/// 失败提示条的按钮:**只有可重试才画「重试」**。
///
/// 不可重试时返回空表:那颗键根本不在控件树里(M307 抓到的第二处不一致——此前不论
/// `retryable` 都画一颗「重试」,而 `error.rs` 早就把验签类标成不可重试了)。
pub(super) fn failed_actions<'a>(
ui: &'a Ui,
catalog: Catalog,
retryable: bool,
) -> Vec<Element<'a, Message>> {
if !retryable {
return Vec::new();
}
vec![buttons::text(
ui,
Button::new(
catalog.text(Key::UpdateBannerRetry),
Message::Update(UpdateAction::CheckNow),
),
)]
}
+132
View File
@@ -2,8 +2,15 @@
use super::*;
use iced::advanced::widget::tree::Tag;
use iced::advanced::widget::Tree;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
use unlucky_i18n::Lang;
use crate::theme::Families;
/// 提示条的正文**同时**给体积与说明摘要(mission:提示条含版本号、体积、说明摘要)。
#[test]
fn the_available_banner_carries_both_size_and_notes() {
@@ -54,3 +61,128 @@ fn the_banner_priority_matches_the_presenter() {
update.ready = Some("0.2.3".to_string());
assert!(matches!(update.banner(), Some(Banner::Ready { .. })));
}
// ---------------------------------------------------------------- M308:重试按 retryable 分流
//
// 判据分两条腿(真控件树读不到字符串:iced 0.14 的 `text` 是 stateless,`tag()` 恒为
// `Tag::stateless()`):
//
// | 要断的事 | 用什么 | 抓不住什么 |
// |---|---|---|
// | 不可重试时**没有**「重试」按钮 / 可重试时**有** | 真控件树里按钮节点的条数(探针取 tag,与 `pages/instances/tests/cold_boot.rs` 同一手法) | 是哪一枚(按条数判) |
// | 那句「已拒绝、重试没用」 | `failed_body`(**唯一**取词入口,中英各一条) | 它被 layout 丢掉(那是像素的事) |
// | 提示条真的用了那个入口 | 源码守卫(断言 `banner()` 里那行调用还在) | 参数传对没传对(前一条管文案,这一条只管接线) |
//
// 变异:把 `failed_actions` 改成无条件返回那枚按钮 ⇒ 第一条用例红;
// 把 `banner()` 的正文改回 `reason.to_string()` ⇒ 第三条用例红(前两条抓不住这一种,写在明处)。
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn ui() -> Ui {
Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES)
}
/// 一次**不可重试/可重试**的失败结论(`silent = false`:用户主动查过,提示条要出来)。
fn failed(retryable: bool) -> Update {
Update {
status: UpdateStatus::Failed {
reason: "清单签名对不上清单字节".to_string(),
retryable,
},
..Update::default()
}
}
/// 探针:任意一个 `buttons::*` 元素在树里的标签(`interaction::tracked` 那层包裹是同一个类型,
/// 所以「可选按钮」与「禁用的按钮」都算同一个 tag)。
fn button_tag(ui: &Ui) -> Tag {
let probe = buttons::filled(
ui,
Button::new("probe", Message::Update(UpdateAction::CheckNow)),
);
Tree::new(probe.as_widget()).tag
}
fn count_tag(tree: &Tree, tag: Tag) -> usize {
usize::from(tree.tag == tag)
+ tree
.children
.iter()
.map(|child| count_tag(child, tag))
.sum::<usize>()
}
/// 这条结论画出来的提示条里有几枚按钮(**真控件树**)。
fn button_count(ui: &Ui, update: &Update, catalog: Catalog) -> usize {
let element = banner(ui, update, catalog).expect("这条用例的状态总要画一条提示条");
count_tag(&Tree::new(element.as_widget()), button_tag(ui))
}
/// 验签类失败(`retryable = false`):**那枚「重试」按钮不在树里**,正文换成「已拒绝、重试没用」。
///
/// 这是 M307 抓到的第二处不一致:`error.rs` 早把签名五兄弟标成不可重试,界面照旧画一颗重试。
#[test]
fn a_refused_update_draws_no_retry_button_and_says_why() {
let ui = ui();
let catalog = Catalog::of(Lang::ZhCn);
let update = failed(false);
assert_eq!(
button_count(&ui, &update, catalog),
0,
"不可重试的失败里不许出现「重试」那枚按钮(重放一千次还是同一个坏包)"
);
let zh = failed_body(catalog, "清单签名对不上清单字节", false);
assert!(
zh.contains("这次更新已被拒绝"),
"正文要说清「已被拒绝」:{zh}"
);
assert!(zh.contains("重试没用"), "正文还要说清「重试没用」:{zh}");
assert!(
zh.contains("清单签名对不上清单字节"),
"原因要原样带出来(用户要知道是哪一步拒的):{zh}"
);
let en = failed_body(Lang::En.catalog(), "the signature does not match", false);
assert!(
en.contains("retrying will not help"),
"英文同一条判据:{en}"
);
}
/// 可重试的失败(网络 / 摘要不符 / IO):「重试」按钮**在**树里,且正文就是原因(不换句子)。
#[test]
fn a_retryable_failure_keeps_the_retry_button() {
let ui = ui();
let catalog = Catalog::of(Lang::ZhCn);
let update = failed(true);
assert_eq!(
button_count(&ui, &update, catalog),
1,
"可重试的失败必须留一条重试的路(M161:失败不许把按钮永久钉死)"
);
assert_eq!(
failed_body(catalog, "取更新清单失败:连接被重置", true),
"取更新清单失败:连接被重置",
"可重试的正文就是原因本身,别套「已拒绝」那句"
);
}
/// 提示条的正文**走** `failed_body`(源码守卫)。
///
/// 为什么要这一条:`text` 控件没有状态,真控件树里读不到那句话;`failed_body` 自己的用例只能
/// 证明「函数返回值对」,证明不了「提示条用了它」。变异(把 `banner()` 的正文改回
/// `reason.to_string()`)只会让这一条红——这就是它的全部价值,说在明处。
#[test]
fn the_banner_routes_its_body_through_failed_body() {
let source = include_str!("../update.rs");
assert!(
source.contains("failed_body(catalog, reason, retryable)"),
"提示条的失败正文必须经取词入口 failed_body(...):这句话不见了就等于「重试没用」又被吞掉"
);
}