用户报「CI 构建版启动时底下闪一个控制台」:M213 统一了 crates/core/src 的 20 处 Command::new,但守卫射程只有 core/src,app-iced 的 reg.exe(每次 GUI 启动都跑)漏网。 - crates/core/tests/child_quiet_guard.rs:扫描根 crates/core/src → crates/(1219 个 .rs), 角色清单改成相对 crates/ 的路径(文件角色钉精确处数,目录角色至少一处); 「扫不到文件」兜底断言保留,另加角色文件锚点兜底;顺带修掉全树扫描会撞上的 「纯 //! 结论页没有代码行」误判 - app-iced:platform/locale.rs 的 reg.exe、app/update/update_action.rs、 app/boot/fallback.rs、app/boot/termux_window.rs 的 xdpyinfo、 renderer/acceptance/locale.rs 的 defaults 全部改走 quiet(后三处是同一 exe / POSIX 上的 pass-through,为的是规则整体可被守卫钉住) - app/dialog/export.rs 的 explorer / open / xdg-open 保持裸构造并写清「窗口就是产品」, presenter/src/host/opener.rs 同理登记成角色(不在本 mission 改动范围) 门禁:fmt/check --workspace --all-targets/test --workspace -j 4/clippy --workspace --all-targets -D warnings/全仓 400 行 + <2 字节 .rs 扫描,六步退出码全 0,CHAIN_EXIT=0。
235 lines
9.3 KiB
Rust
235 lines
9.3 KiB
Rust
//! 自更新动作的落点(M159):`pages::Message::Update(UpdateAction)` → presenter 指令 / 重启。
|
||
//!
|
||
//! 三条纪律:
|
||
//!
|
||
//! 1. **不做乐观回显**:点了「下载并安装」不改本地状态,等 `CoreEvent::Update` 回来才变
|
||
//! (与启动/安装同一条:本地状态与事件流打架时,界面会自说一套);
|
||
//! 2. 更新的**判断**(要不要提示、失败算什么、版本比较)全在 core/presenter,这里只转发;
|
||
//! 3. 「立即重启」是本文件唯一碰进程的地方:先按同样的命令行拉起新进程,再退出自己——
|
||
//! 正因为替换已经完成(新 exe 就在原路径上),拉起来的就是新版本。
|
||
|
||
use crate::app::diag::diag;
|
||
use iced::Task;
|
||
use unlucky_presenter::dto::AppCommand;
|
||
|
||
use crate::pages::UpdateAction;
|
||
|
||
use super::super::message::Message;
|
||
use super::super::state::App;
|
||
|
||
impl App {
|
||
/// 一条更新动作。
|
||
pub(super) fn on_update_action(&mut self, action: UpdateAction) -> Task<Message> {
|
||
match action {
|
||
UpdateAction::CheckNow => {
|
||
if self.update_busy() {
|
||
return self.refuse_busy("立即检查");
|
||
}
|
||
self.dispatch(AppCommand::CheckUpdate)
|
||
}
|
||
UpdateAction::Apply => {
|
||
if self.update_busy() {
|
||
return self.refuse_busy("下载并安装");
|
||
}
|
||
self.dispatch(AppCommand::ApplyUpdate)
|
||
}
|
||
UpdateAction::Dismiss => self.dispatch(AppCommand::DismissUpdate),
|
||
UpdateAction::SetAutoCheck(on) => self.dispatch(AppCommand::SetUpdateAutoCheck(on)),
|
||
UpdateAction::RestartNow => return self.restart_now(),
|
||
}
|
||
Task::none()
|
||
}
|
||
|
||
/// 有没有一条检查/下载正在跑。
|
||
///
|
||
/// 与 `pages::Update::busy()` **同一个判据**(`applying` 或 `status == Checking`):
|
||
/// presenter 的 `begin_check` / `begin_apply` 在 inflight 时静默 `return None`,
|
||
/// 所以「忙碌」这件事必须在按下之前就挡住——否则用户点了没反应,还以为按钮坏了(M161)。
|
||
///
|
||
/// 这一层是**最后一道**:按钮已经画成禁用(`pages/settings/update.rs` 的 `can_check` /
|
||
/// `can_apply`),但快捷键/未来新增的入口仍会走到这里。
|
||
fn update_busy(&self) -> bool {
|
||
let view = &self.state.update;
|
||
view.applying || view.outcome.is_checking()
|
||
}
|
||
|
||
/// 忙碌时把这条动作丢掉(不派发、不乐观改状态),并在日志里留一行取证。
|
||
fn refuse_busy(&self, what: &str) -> Task<Message> {
|
||
diag!("[update] 已有一条检查/下载在跑,忽略重复的「{what}」");
|
||
Task::none()
|
||
}
|
||
|
||
/// 立即重启:按同样的命令行拉起自己,然后退出。
|
||
///
|
||
/// 截图通道(`--screenshot`)**不重启**:那条路每跑一次就该落一张图然后退出,重启会覆盖同一
|
||
/// 文件、并把取证进程拖成死循环。
|
||
fn restart_now(&mut self) -> Task<Message> {
|
||
if self.options.screenshot.is_some() {
|
||
diag!("[update] 截图通道不重启(忽略「立即重启」)");
|
||
return Task::none();
|
||
}
|
||
match std::env::current_exe() {
|
||
Ok(exe) => {
|
||
let args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
|
||
// 子进程是本 GUI exe 自己(Subsystem=2,不会分到控制台);走 `quiet` 是为了让
|
||
// 「app-iced 里除 opener 外不留裸构造」这条规则整体可被守卫钉住(M217)。
|
||
let spawned = unlucky_core::platform::child::quiet(&exe)
|
||
.args(&args)
|
||
.spawn();
|
||
match spawned {
|
||
Ok(child) => diag!(
|
||
"[update] 已拉起新进程 pid={}:{}",
|
||
child.id(),
|
||
exe.display()
|
||
),
|
||
Err(error) => {
|
||
diag!("[update] 拉起新进程失败:{error}(请手动重启启动器)");
|
||
return Task::none();
|
||
}
|
||
}
|
||
}
|
||
Err(error) => {
|
||
diag!("[update] 取 current_exe 失败:{error}");
|
||
return Task::none();
|
||
}
|
||
}
|
||
iced::exit()
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use std::sync::{Arc, Mutex};
|
||
|
||
use iced::Task;
|
||
use unlucky_design::color::SchemeFlavor;
|
||
use unlucky_presenter::dto::{AppCommand, AppEvent, Backend};
|
||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||
|
||
use crate::app::state::App;
|
||
use crate::cli::Options;
|
||
use crate::pages::UpdateAction;
|
||
use crate::theme::Families;
|
||
|
||
const FAMILIES: Families = Families {
|
||
text: "Segoe UI",
|
||
display: "Segoe UI",
|
||
mono: "Consolas",
|
||
};
|
||
|
||
/// 记录命令流的假后端:把 `dispatch` 收到的每一条抄进共享表(动作接线断没断靠它判)。
|
||
struct Recorder {
|
||
seen: Arc<Mutex<Vec<AppCommand>>>,
|
||
}
|
||
|
||
impl Backend for Recorder {
|
||
fn drain_events(&mut self) -> Vec<AppEvent> {
|
||
Vec::new()
|
||
}
|
||
|
||
fn dispatch(&mut self, command: AppCommand) {
|
||
self.seen
|
||
.lock()
|
||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||
.push(command);
|
||
}
|
||
|
||
fn tick(&mut self, _elapsed: std::time::Duration) {}
|
||
}
|
||
|
||
fn app() -> (App, Arc<Mutex<Vec<AppCommand>>>) {
|
||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||
let app = App::with_presenter(
|
||
Options::default(),
|
||
FAMILIES,
|
||
SchemeFlavor::Light,
|
||
Box::new(Recorder {
|
||
seen: Arc::clone(&seen),
|
||
}),
|
||
SettingsBridge::memory_only(),
|
||
);
|
||
(app, seen)
|
||
}
|
||
|
||
fn last(seen: &Arc<Mutex<Vec<AppCommand>>>) -> AppCommand {
|
||
seen.lock()
|
||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||
.last()
|
||
.cloned()
|
||
.expect("应当派发过一条命令")
|
||
}
|
||
|
||
/// 四个转发动作各自交给后端**对应的**指令(串线会红)。
|
||
#[test]
|
||
fn every_update_action_reaches_the_backend() {
|
||
let (mut app, seen) = app();
|
||
|
||
let _: Task<_> = app.on_update_action(UpdateAction::CheckNow);
|
||
assert_eq!(last(&seen), AppCommand::CheckUpdate);
|
||
|
||
let _ = app.on_update_action(UpdateAction::Apply);
|
||
assert_eq!(last(&seen), AppCommand::ApplyUpdate);
|
||
|
||
let _ = app.on_update_action(UpdateAction::Dismiss);
|
||
assert_eq!(last(&seen), AppCommand::DismissUpdate);
|
||
|
||
let _ = app.on_update_action(UpdateAction::SetAutoCheck(false));
|
||
assert_eq!(last(&seen), AppCommand::SetUpdateAutoCheck(false));
|
||
}
|
||
|
||
/// **忙碌时按下去不发命令**(M161:此前点了没反应,因为 presenter 在 inflight 时静默返回)。
|
||
///
|
||
/// 三条都要钉住:①`Checking` 时「立即检查」不派发;②`applying` 时「下载并安装」不派发;
|
||
/// ③忙碌结束后**恢回可点**(失败/超时不该把按钮永久钉死)。
|
||
#[test]
|
||
fn pressing_while_busy_dispatches_nothing() {
|
||
let (mut app, seen) = app();
|
||
|
||
app.state.update.outcome = unlucky_presenter::dto::UpdateOutcome::Checking;
|
||
let _ = app.on_update_action(UpdateAction::CheckNow);
|
||
assert!(
|
||
seen.lock()
|
||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||
.is_empty(),
|
||
"正在检查时再按「立即检查」不许派发第二条命令"
|
||
);
|
||
|
||
// 检查回来(失败也算回来):不忙了,能再点。
|
||
app.state.update.outcome = unlucky_presenter::dto::UpdateOutcome::Failed(
|
||
unlucky_presenter::dto::UpdateFailureView::new("network", "连不上", true),
|
||
);
|
||
let _ = app.on_update_action(UpdateAction::CheckNow);
|
||
assert_eq!(last(&seen), AppCommand::CheckUpdate, "失败之后要能重试");
|
||
|
||
// 下载/校验/替换期间:`Apply` 与 `CheckNow` 都不许派发。
|
||
let before = seen.lock().unwrap_or_else(|p| p.into_inner()).len();
|
||
app.state.update.applying = true;
|
||
let _ = app.on_update_action(UpdateAction::Apply);
|
||
let _ = app.on_update_action(UpdateAction::CheckNow);
|
||
assert_eq!(
|
||
seen.lock().unwrap_or_else(|p| p.into_inner()).len(),
|
||
before,
|
||
"applying 期间不许派发任何新的检查/安装"
|
||
);
|
||
|
||
// 应用结束:恢复可点。
|
||
app.state.update.applying = false;
|
||
let _ = app.on_update_action(UpdateAction::Apply);
|
||
assert_eq!(last(&seen), AppCommand::ApplyUpdate, "失败/完成后恢回可点");
|
||
}
|
||
|
||
/// 截图通道不重启:取证进程不许把自己重启成死循环。
|
||
#[test]
|
||
fn the_screenshot_channel_never_restarts() {
|
||
let (mut app, seen) = app();
|
||
app.options.screenshot = Some(std::path::PathBuf::from("C:/Temp/shot.png"));
|
||
let _ = app.on_update_action(UpdateAction::RestartNow);
|
||
assert!(
|
||
seen.lock()
|
||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||
.is_empty(),
|
||
"重启不是一条后端指令(不该派发任何命令)"
|
||
);
|
||
}
|
||
}
|