merge: origin/dev(1623f68)合入 M197 分支——解 golden/tail.rs 冲突
基线:合的是 **origin/dev**(`1623f68`)。`origin/main` 是 D41 的孤儿发布快照 (`d616d65` parents 为空),不参与合并。用 merge 而不是 rebase,保住三轮复审 clean 的 tip。 冲突只有一个:`crates/i18n/src/catalog/golden/tail.rs`(热点文件,多条 mission 都往这里加 key)。 解法(不重排别人的行、不改别人的译文): 1. 以 **dev 侧 398 行**为底稿(含其它 mission 后加的 key); 2. 把 M197 的 68 行(`SettingsJavaTooNew` … `SettingsJavaReasonInstallRootTooLong`) 整块插在 `SettingsJavaReasonWriteFailed` 之后、M174 助手域(`SettingsGroupAssistant`)之前 —— 即 `settings_java_entries` 那段之后,装配链的正确位置; 3. 两侧合起来 463 行会越过 400 行硬线,于是按既有先例把**M174 助手域起的那半截** (dev 的 260 行)整段纯搬移到 `golden/tail_ai.rs`:`tail.rs` 211 行 / `tail_ai.rs` 274 行, 链序 `golden → tail → tail_ai → tail2` 不变,逐字节不删不改; 4. 解冲突时发现 dev 的 golden 行还是 M197 之前的旧文案(`SettingsJavaEmptyBody` / `SettingsJavaAddHint` 的反引号、`SettingsJavaNeverScannedBody` 的旧说法),已把这三条的 金表行同步回域文件里的现行文案(`split_keeps_every_translation` 逐位置比对当场抓出来的)。 顺带(合并导致的越线):`crates/core/src/download/engine.rs` 两侧各 400 行、合并后 401 (我 +1 行 `kind: err.kind`,dev +1 行),把模块文档里那三行散文重排成两行(一字未删)→ 400 行。 同一次还把这文件里对 `MirrorTable::set_gh_proxy` 的旧描述改成 M193 的现实(那两个函数已被删)。 验证:fmt 0 · check(app-iced+core+i18n all-targets)0 · i18n 36 · engine_mock 8 · app-iced java 45 · core lib java 71 · setting 116 · app-iced lib 全量 832;全仓 file-size 守门脚本复制执行 → 超限 0。
This commit is contained in:
commit
6e80fd3563
283 files changed
+22601
-2188
No files matched your search
@@ -95,7 +95,7 @@ impl App {
|
||||
self.dispatch(AppCommand::AddAuthlibAccount(request));
|
||||
}
|
||||
}
|
||||
Panel::Microsoft => return self.on_account(Account::StartBrowser),
|
||||
Panel::Microsoft => return self.on_account(Account::StartDevice),
|
||||
Panel::None => {}
|
||||
},
|
||||
Account::StartDevice => self.dispatch(AppCommand::StartDeviceLogin),
|
||||
@@ -136,7 +136,8 @@ impl App {
|
||||
match account.kind {
|
||||
AccountKind::Microsoft => {
|
||||
self.account_form.panel = Panel::Microsoft;
|
||||
self.dispatch(AppCommand::StartBrowserLogin);
|
||||
// M178:重登也走设备码(不依赖任何应用注册项)。想试浏览器再点面板里的次按钮。
|
||||
self.dispatch(AppCommand::StartDeviceLogin);
|
||||
}
|
||||
AccountKind::Authlib => {
|
||||
self.account_form.open(Track::Authlib);
|
||||
@@ -173,6 +174,23 @@ pub(super) fn force_account_state(app: &mut App) {
|
||||
app.state.device_login_started =
|
||||
Some(std::time::Instant::now() - std::time::Duration::from_secs(3_600));
|
||||
}
|
||||
// M178:浏览器登录没走成、已回退设备码的那一态(设备码 + 一句「为什么 + 怎么修」)。
|
||||
//
|
||||
// 为什么在 app 层造:真走到这一步要一次真网络请求(预检),截图通道要的是**确定性**。
|
||||
// 这里只替换登录视图本身——面板的渲染路径与真 host 完全同一条
|
||||
// (`apply_device_login` → `pages::account::panels::device_panel` → `login::panel`)。
|
||||
"msa-fallback" => {
|
||||
app.account_form.panel = crate::pages::account::Panel::Microsoft;
|
||||
app.state.apply_device_login(
|
||||
unlucky_presenter::dto::DeviceLoginView::waiting(
|
||||
"ABCD-EFGH",
|
||||
"https://www.microsoft.com/link",
|
||||
900,
|
||||
"",
|
||||
)
|
||||
.with_browser_note(unlucky_presenter::dto::BrowserLoginNote::RedirectNotRegistered),
|
||||
);
|
||||
}
|
||||
// 第三方站**失败**态:面板打开 + mock 那边给出的 `AuthlibLoginChanged(Failed)`
|
||||
"authlib-failed" => {
|
||||
app.account_form.panel = crate::pages::account::Panel::Authlib;
|
||||
@@ -198,6 +216,11 @@ pub(super) fn force_account_state(app: &mut App) {
|
||||
"closed" => {
|
||||
let _ = app.on_account(crate::pages::account::Message::Close);
|
||||
}
|
||||
// M178:登录入口面板(两个按钮 + 一行「没有浏览器时用这个」)。复审 r1 P2-5 的文案改动
|
||||
// 要有图可看:`device_login` 是 Idle ⇒ 面板落在空闲态(主按钮=设备码,次按钮=浏览器)。
|
||||
"msa-entry" => {
|
||||
app.account_form.panel = crate::pages::account::Panel::Microsoft;
|
||||
}
|
||||
// 有账号时「Add an account」展开态(P2-4 的收敛后形态)
|
||||
"add" => {
|
||||
app.account_form.section_open = true;
|
||||
|
||||
@@ -125,10 +125,10 @@ fn the_device_code_flow_is_started_visible_and_cancellable() {
|
||||
let _ = app.on_account(Account::StartDevice);
|
||||
match &app.account.device {
|
||||
DevicePanel::Code {
|
||||
code, remaining, ..
|
||||
code, countdown, ..
|
||||
} => {
|
||||
assert_eq!(code, "ABCD-EFGH", "用户码要上屏");
|
||||
assert!(remaining.is_some_and(|left| left > 0), "倒计时在跑");
|
||||
assert!(!countdown.is_empty(), "倒计时在跑(文案由模型拼好)");
|
||||
}
|
||||
other => panic!("进行中状态必须可见,实际 {other:?}"),
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
//! 一次状态,流式标记与未读会打架。所以 [`App::drain_agent`] 抽出来之后立刻
|
||||
//! `on_agent(agent::Message::Events(..))` —— 同一条 `Message::Agent(..)` 通道。
|
||||
|
||||
use crate::app::diag::diag;
|
||||
use iced::keyboard::{key, Key};
|
||||
use iced::Task;
|
||||
|
||||
@@ -141,10 +142,10 @@ impl App {
|
||||
self.drain_agent();
|
||||
}
|
||||
SubmitOutcome::Failed(failure) => {
|
||||
eprintln!("[agent] 提交失败({})", failure.code());
|
||||
diag!("[agent] 提交失败({})", failure.code());
|
||||
}
|
||||
SubmitOutcome::Rejected => {
|
||||
eprintln!("[agent] presenter 拒收:空文本 / 上一轮未完 / 待确认 三闸该先挡住");
|
||||
diag!("[agent] presenter 拒收:空文本 / 上一轮未完 / 待确认 三闸该先挡住");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,7 +205,7 @@ impl App {
|
||||
let on_confirm = matches!(self.agent.focus, Focus::ConfirmAllow | Focus::ConfirmDeny);
|
||||
if pending && !on_confirm {
|
||||
self.agent.focus = Focus::ConfirmAllow;
|
||||
eprintln!("[agent] 写操作待确认 → 焦点落到 Allow(Tab 点亮焦点环后 Enter/Space 裁决)");
|
||||
diag!("[agent] 写操作待确认 → 焦点落到 Allow(Tab 点亮焦点环后 Enter/Space 裁决)");
|
||||
} else if !pending && on_confirm {
|
||||
self.agent.focus = Focus::Composer;
|
||||
self.agent.keyboard_mode = false;
|
||||
@@ -279,7 +280,7 @@ impl App {
|
||||
}
|
||||
|
||||
if self.agent.rail == RailState::Detached && self.state.route() != &Route::Agent {
|
||||
eprintln!("[agent] rail=detached → Route::Agent");
|
||||
diag!("[agent] rail=detached → Route::Agent");
|
||||
self.state.navigate(Route::Agent);
|
||||
// 直达独立页时不要再被「自动选中第一条实例」拉回详情页。
|
||||
self.auto_selected = true;
|
||||
@@ -371,7 +372,7 @@ impl App {
|
||||
let _ = self.on_agent(message);
|
||||
} else {
|
||||
// 「不得静默无反应」:焦点在输入框上按 Enter 由输入框自己处理,这里只留一行日志。
|
||||
eprintln!(
|
||||
diag!(
|
||||
"[agent] Enter on focus={} handled by the composer",
|
||||
self.agent.focus.name()
|
||||
);
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
//! `radius=system` 是刻意的:系统不暴露 DWM 的圆角半径,我们也不自己定一个数字
|
||||
//! (M116 取消了 8/12/16 固定档)。
|
||||
|
||||
use crate::app::diag::diag;
|
||||
use crate::platform;
|
||||
use crate::platform::win32::corner::{self, CornerApplied};
|
||||
use crate::platform::win32::transparency::{apply_corner, style_summary, CornerOutcome};
|
||||
@@ -73,7 +74,7 @@ impl App {
|
||||
"frame=? layered=?".to_owned(),
|
||||
),
|
||||
};
|
||||
eprintln!(
|
||||
diag!(
|
||||
"{}",
|
||||
corner::format_line(context, &plan, policy, &outcome, &style, suppressed_by)
|
||||
);
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
//! 启动期的取证环境变量解析(`ULL_START_ROUTE`)。
|
||||
//! 启动期的取证环境变量解析(`ULL_START_ROUTE` / 动效三个开关)。
|
||||
//!
|
||||
//! 从 `app/update.rs` 原样搬出(那个文件在 M105/M106/M109/M113 之后贴着 400 行硬线):
|
||||
//! **纯函数、无状态**,只是取值表长到了七条。
|
||||
//! **纯函数、无状态**,只是取值表长到了十条。M192 在这里加的是动效那一组
|
||||
//! ([`motion_scale`] / [`env_motion_pin`] / [`env_motion_fixture`]):档位策略只有这一处,
|
||||
//! 帧泵自己不读环境变量。
|
||||
|
||||
use unlucky_design::motion::MotionScale;
|
||||
use unlucky_presenter::router::Route;
|
||||
|
||||
/// 取证开关 `ULL_START_ROUTE` 的取值解析(`settings` / `instances` / `download` / `content` / `logs`)。
|
||||
@@ -24,3 +27,147 @@ pub(super) fn env_start_route() -> Option<Route> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 动效档位与相位钉住(M192):帧泵**只在产品的 `on_boot` 里定档**。
|
||||
///
|
||||
/// ## 为什么档位在这里算
|
||||
///
|
||||
/// 三个输入只有启动这一拍齐全:①是不是截图通道(`--screenshot`)②实际渲染后端
|
||||
/// (`backend::effective_kind()`,不是 `--renderer` 的请求值)③`ULL_MOTION` 的强制值。
|
||||
/// 帧泵自己(`components::motion`)不读环境变量,它只接受一个 [`MotionScale`]——这样动效策略
|
||||
/// 只有一处,测试也不必摆环境变量。
|
||||
///
|
||||
/// | 条件 | 档位 | 为什么 |
|
||||
/// |---|---|---|
|
||||
/// | `ULL_MOTION=off` | `NONE` | 一键关(排障 / 想量基线时用) |
|
||||
/// | 截图通道,且没给 `ULL_MOTION_AT` | `NONE` | D30:出图不许有动画(否则第 3 帧抓到中间态,图就不可复现) |
|
||||
/// | CPU 渲染(`tiny-skia`) | `HALF` | 时长减半 = 帧数减半。位移是**布局量**,CPU 档最贵的就是逐帧重排 |
|
||||
/// | 其余(`wgpu`) | `STANDARD` | —— |
|
||||
/// | `ULL_MOTION=half` / `full` | 强制 | 覆盖上表(出图矩阵要在 GPU 上量同一段动效) |
|
||||
///
|
||||
/// 「减少动效」的用户开关**还不存在**(`core/src/setting` 里没有 animation key,见报告 follow-up),
|
||||
/// 所以本函数只认上面这些来源。
|
||||
pub(super) fn motion_scale(screenshot: bool, gpu: bool, pinned: bool) -> MotionScale {
|
||||
match std::env::var("ULL_MOTION").ok().as_deref().map(str::trim) {
|
||||
Some("off") | Some("none") => return MotionScale::NONE,
|
||||
Some("half") => return MotionScale::HALF,
|
||||
Some("full") => return MotionScale::STANDARD,
|
||||
_ => {}
|
||||
}
|
||||
if pinned {
|
||||
// 取证相位优先于「截图必须静」,否则多帧序列图一张都出不来。
|
||||
return MotionScale::STANDARD;
|
||||
}
|
||||
if screenshot {
|
||||
return MotionScale::NONE;
|
||||
}
|
||||
if gpu {
|
||||
MotionScale::STANDARD
|
||||
} else {
|
||||
MotionScale::HALF
|
||||
}
|
||||
}
|
||||
|
||||
/// `ULL_MOTION_AT=<ms>`:把动画时钟钉在某一毫秒(多帧序列图取证)。
|
||||
///
|
||||
/// 语义与 `ULL_APPEARANCE_*` / `ULL_DIALOG_FIXTURE` 一致:只影响这一次进程。没有它,第 3 帧落在
|
||||
/// 动画的哪个相位取决于这台机器的渲染速度 —— 那样「0% / 50% / 100% 三张图」是抓阄抓出来的。
|
||||
pub(super) fn env_motion_pin() -> Option<f64> {
|
||||
std::env::var("ULL_MOTION_AT")
|
||||
.ok()?
|
||||
.trim()
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.filter(|ms| ms.is_finite() && *ms >= 0.0)
|
||||
}
|
||||
|
||||
/// `ULL_MOTION_FIXTURE`:第 1 帧之后**真的**做一次状态变化(多帧序列图靠它)。
|
||||
///
|
||||
/// 为什么要制造变化:页面主体的入场只在「内容标识变了」时起跑(`motion::observe`),而截图通道
|
||||
/// 从第一帧起画的就是同一页——不制造一次真实的路由变化,就没有可拍的动效。
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) enum MotionFixture {
|
||||
/// 跳到一个一级页(走「换页」那一档)。
|
||||
Route(Route),
|
||||
/// 「列表 → 详情」:先回列表(第 1 帧),再点进第一条实例(第 2 帧)。
|
||||
Detail,
|
||||
}
|
||||
|
||||
/// `ULL_MOTION_FIXTURE=<instances|download|logs|settings|content|detail>`。
|
||||
pub(super) fn env_motion_fixture() -> Option<MotionFixture> {
|
||||
Some(match std::env::var("ULL_MOTION_FIXTURE").ok()?.trim() {
|
||||
"instances" => MotionFixture::Route(Route::Instances),
|
||||
"download" => MotionFixture::Route(Route::Download),
|
||||
"logs" => MotionFixture::Route(Route::Logs),
|
||||
"settings" => MotionFixture::Route(Route::Settings),
|
||||
"content" => MotionFixture::Route(Route::Content),
|
||||
"detail" => MotionFixture::Detail,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `ULL_MOTION_FIXTURE_AT=<秒>`:启动后第 N 次心跳(≈第 N 秒)**真的**导航一次。
|
||||
///
|
||||
/// ## 为什么需要它(真机空闲取证)
|
||||
///
|
||||
/// 复审 r1 要求「空闲 ≥10 s 后点一次导航轨,看 `[tick] motion=` 涨多少」。本机工作区所在会话
|
||||
/// 没有前台窗口(`GetForegroundWindow()==0`),合成鼠标事件(`mouse_event` 与
|
||||
/// `PostMessage(WM_LBUTTONDOWN)` 都试过)递不进 iced 的输入层,所以这里换一条**同构**的路:
|
||||
/// 由 1 Hz 心跳(空闲态本来就有的消息)在第 N 秒做一次真实的路由跳转。
|
||||
///
|
||||
/// 两条路在 `update` 之后**完全同一段代码**:消息 → `sync_motion`(起跑)→ `subscription()`
|
||||
/// (同一拍看见轨道)→ `frames()`。差别只在「消息是谁产生的」,而这正是 P1 要验的那一段。
|
||||
pub(super) fn env_motion_fixture_at() -> Option<u32> {
|
||||
std::env::var("ULL_MOTION_FIXTURE_AT")
|
||||
.ok()?
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.ok()
|
||||
.filter(|beats| *beats > 0)
|
||||
}
|
||||
|
||||
/// 把启动那一拍的**事实**写进启动器自己的日志(M187 / r1 复审 P2-4)。
|
||||
///
|
||||
/// ## 为什么这一拍只能「先记、后落」
|
||||
///
|
||||
/// `.ULL/logs` 的落点由 `platform::data_dir` 的三模式决定(要读配置、follow `redirect.json`),
|
||||
/// 那件事只有 presenter 的 `HostEnv` 做过。app 层拿不到它,于是这里只 `record`
|
||||
/// (core 侧缓冲,最多 200 行);presenter **构造 `HostEnv` 的那一刻**就调
|
||||
/// `launcher_log::set_dir`(M188 补上的那一半),缓冲一次刷进 `<.ULL>/logs/launcher.log`。
|
||||
///
|
||||
/// 效果正是用户投诉要的那份东西:他「双击 → 启动 → 崩 → 找日志」时,`launcher.log` 里从
|
||||
/// **启动这一拍**(主题 / 语言 / 路由 / 窗口 / 后端)一直写到退出,而不是只有 launch 那一段。
|
||||
///
|
||||
/// M188 之后边界也变了:`boot.rs` / `boot/fallback.rs` / `state/*` 那些 `eprintln!` 改走
|
||||
/// [`crate::app::diag::emit`](stderr + 文件双写),同样靠这条缓冲接住——所以「一局都没开就崩」
|
||||
/// 的进程也会留下 `launcher.log`。
|
||||
pub(crate) fn record_boot_log(app: &super::super::state::App) {
|
||||
use unlucky_core::launch::launcher_log;
|
||||
|
||||
launcher_log::record("boot", "app boot");
|
||||
launcher_log::record(
|
||||
"boot",
|
||||
&format!(
|
||||
"theme={} lang={:?} route={:?} window={:.0}x{:.0} screenshot={} instances={}",
|
||||
app.ui().name(),
|
||||
app.lang(),
|
||||
app.state.route(),
|
||||
app.width,
|
||||
app.height,
|
||||
app.has_screenshot(),
|
||||
app.state.instances.len(),
|
||||
),
|
||||
);
|
||||
launcher_log::record("boot", &unlucky_cli::commands::msa_client_id_log_line());
|
||||
// 后端这一拍也要在日志里:`[probe]` / `[backend]` 那几行由 `renderer::acceptance` 打到
|
||||
// stderr(那个模块不在 M188 的 scope),事故之后翻日志的人至少要看得出「这次用的是哪个后端」。
|
||||
launcher_log::record(
|
||||
"boot",
|
||||
&format!(
|
||||
"backend request={} effective={} renderer_fallback={}",
|
||||
app.options.backend.name(),
|
||||
super::super::backend::effective_backend(app.options.backend).name(),
|
||||
std::env::var(crate::renderer::FALLBACK_ENV).unwrap_or_else(|_| "-".to_string()),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
//! [`App::pump`] 里多了一行 [`App::drain_agent`]:AI 助手的事件队列和 core 的事件队列同拍抽干,
|
||||
//! 于是「右栗的未读/流式状态」和页面快照共享同一个刷新节奏(不会一个 30 Hz、一个 1 Hz)。
|
||||
|
||||
use crate::app::diag::diag;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use unlucky_presenter::dto::AppCommand;
|
||||
@@ -74,7 +75,7 @@ impl App {
|
||||
let reloaded =
|
||||
!self.has_screenshot() && poll_due && self.bridge.reload_if_changed().is_some();
|
||||
if reloaded {
|
||||
eprintln!("[settings] 配置文件变了,重读主题 + 外观");
|
||||
diag!("[settings] 配置文件变了,重读主题 + 外观");
|
||||
self.apply_config_theme();
|
||||
// M93:外观也热切(面板 α / 背景图 / 配色来源 / 整窗 α 都在里面)。
|
||||
self.reload_appearance();
|
||||
@@ -92,8 +93,8 @@ impl App {
|
||||
// 忙碌态由帧节拍跨桶(≈1 行/秒),空闲态由心跳一次 +30 跨桶(**也是 1 行/秒**)——
|
||||
// 两种时钟都用同一把尺子,所以 trace 的密度本身就是「时钟在跑」的证据。
|
||||
if bucket_after != bucket_before && std::env::var_os("ULL_TICK_TRACE").is_some() {
|
||||
eprintln!(
|
||||
"[tick] n={} frames={} refreshes={} clock={}",
|
||||
diag!(
|
||||
"[tick] n={} frames={} refreshes={} clock={} motion={}",
|
||||
self.ticks,
|
||||
self.frames,
|
||||
self.refreshes,
|
||||
@@ -101,7 +102,10 @@ impl App {
|
||||
"frames"
|
||||
} else {
|
||||
"idle-1hz"
|
||||
}
|
||||
},
|
||||
// M192:动效时间轴已结算的帧数(每秒一行 ⇒ 这一列的增长速度就是动画的实际帧率,
|
||||
// 也是「动画期间重绘成本」的记账口径,见报告)
|
||||
crate::app::components::motion::samples()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
//! `AppState::content.query` 是「当前结果对应哪份条件」的唯一真相:结果回来时
|
||||
//! `apply_content` 用它判「迟到的事件要不要丢」。页面自己存一份就会有两份真相。
|
||||
|
||||
use crate::app::diag::diag;
|
||||
use unlucky_presenter::dto::{
|
||||
AppCommand, ContentDetailRequest, ContentInstallRequest, ContentQuery, ContentSource,
|
||||
ExportRequestView,
|
||||
@@ -103,6 +104,15 @@ impl App {
|
||||
self.dispatch(AppCommand::InstallContent(Box::new(request)));
|
||||
}
|
||||
}
|
||||
// M191:「只重试上次失败的那几项」。请求构造在 `pages/content/model.rs`
|
||||
// (`retry_install_request`:载荷带 `retry: true`),这里只做转发。
|
||||
pages::content::Message::RetryFailed => {
|
||||
let catalog = unlucky_i18n::Lang::En.catalog();
|
||||
let model = Content::from_snapshot(&self.state, &self.content, catalog);
|
||||
if let Some(request) = model.retry_install_request() {
|
||||
self.dispatch(AppCommand::InstallContent(Box::new(request)));
|
||||
}
|
||||
}
|
||||
// M113: import/export —— 导入/导出区(细节在 `on_pack`)
|
||||
pages::content::Message::Pack(message) => self.on_pack(message),
|
||||
}
|
||||
@@ -199,10 +209,10 @@ impl App {
|
||||
if !model.can_install() {
|
||||
// 有原因就打印出来(取证用);没有选中项时什么都不做
|
||||
for reason in model.blocker_reasons(catalog) {
|
||||
eprintln!("[content] 装入被挡下:{reason}");
|
||||
diag!("[content] 装入被挡下:{reason}");
|
||||
}
|
||||
if model.blockers.contains(&Blocker::NeedsPipeline) {
|
||||
eprintln!("[content] 整合包装入未实现(M111)");
|
||||
diag!("[content] 整合包装入未实现(M111)");
|
||||
}
|
||||
return None;
|
||||
}
|
||||
@@ -221,6 +231,8 @@ impl App {
|
||||
instance: target.id.clone(),
|
||||
game_version: target.mc_version.clone(),
|
||||
loader: target.loader,
|
||||
// 普通装入:从根项目解起(「只重试这些」是另一条消息,`retry: true`)
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! 弹窗消息的落点:关闭 / 展开 / 导出 / 打开文件夹 / 复制路径。
|
||||
//!
|
||||
//! 只有两类副作用:**剪贴板**(返回 `Task`,必须交回 runtime)与**弹 Toast**(就地推)。
|
||||
//! 导出与打开文件夹是同步的本地动作(写一个 zip / 拉起一个 `explorer`),耗时都在毫秒级,
|
||||
//! 不为它们单开线程——真要慢(网络盘)也是用户能感知的一次点击。
|
||||
|
||||
use iced::Task;
|
||||
use unlucky_presenter::toast::ToastKind;
|
||||
|
||||
use crate::app::dialog::{export, Message as DialogMessage};
|
||||
use crate::app::state::App;
|
||||
|
||||
pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::app::Message> {
|
||||
match message {
|
||||
DialogMessage::Primary => {
|
||||
// 崩溃卡片的主按钮是「导出诊断包」;普通确认框的主按钮就是「关掉」。
|
||||
let Some(card) = app.dialog_card.clone() else {
|
||||
app.close_dialog();
|
||||
return Task::none();
|
||||
};
|
||||
let done = export::export(&card.report, app.lang());
|
||||
// 结论**画在卡片里**:导出这种「必须看见落到哪」的结果不能只靠 Toast
|
||||
// (app-iced 的 Toast 至今没有渲染点)。成败按 `write_bundle` 的真实结果判,
|
||||
// 不看「launcher_logs 有没有值」——那只是入参齐全度(r1 复审 P3-2)。
|
||||
app.state.toasts.push(
|
||||
if done.ok {
|
||||
ToastKind::Success
|
||||
} else {
|
||||
ToastKind::Error
|
||||
},
|
||||
done.text.clone(),
|
||||
);
|
||||
if let Some(state) = &mut app.dialog_card {
|
||||
state.outcome = Some(done.text);
|
||||
}
|
||||
app.view_dirty = true;
|
||||
app.refresh_view();
|
||||
Task::none()
|
||||
}
|
||||
DialogMessage::Secondary | DialogMessage::Dismiss => {
|
||||
app.close_dialog();
|
||||
// 立即重画:空闲态只有 1 Hz 心跳,只置 `view_dirty` 会让弹窗再留最多 1 秒
|
||||
// (r1 复审 P3-1)。
|
||||
app.refresh_view();
|
||||
Task::none()
|
||||
}
|
||||
DialogMessage::Toggle => {
|
||||
app.toggle_dialog_detail();
|
||||
Task::none()
|
||||
}
|
||||
DialogMessage::OpenFolder => {
|
||||
let Some(card) = app.dialog_card.clone() else {
|
||||
return Task::none();
|
||||
};
|
||||
let problem = export::open_folder(&card.report, app.lang());
|
||||
if !problem.is_empty() {
|
||||
if let Some(state) = &mut app.dialog_card {
|
||||
state.outcome = Some(problem.clone());
|
||||
}
|
||||
app.state.toasts.push(ToastKind::Error, problem);
|
||||
app.view_dirty = true;
|
||||
app.refresh_view();
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
DialogMessage::CopyPaths => {
|
||||
let Some(card) = app.dialog_card.clone() else {
|
||||
return Task::none();
|
||||
};
|
||||
app.state.toasts.push(
|
||||
ToastKind::Info,
|
||||
app.lang()
|
||||
.catalog()
|
||||
.text(unlucky_i18n::catalog::Key::CrashDialogCopied),
|
||||
);
|
||||
iced::clipboard::write(export::paths_text(&card.report))
|
||||
}
|
||||
DialogMessage::CopyPath(index) => {
|
||||
let Some(card) = app.dialog_card.clone() else {
|
||||
return Task::none();
|
||||
};
|
||||
let Some((_, path)) = card.report.path_lines().into_iter().nth(index) else {
|
||||
return Task::none();
|
||||
};
|
||||
app.state.toasts.push(
|
||||
ToastKind::Info,
|
||||
app.lang()
|
||||
.catalog()
|
||||
.text(unlucky_i18n::catalog::Key::CrashDialogCopied),
|
||||
);
|
||||
iced::clipboard::write(path.display().to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Esc` 关弹窗(有弹窗时**先吃这一条**,不让它落到 AI 栏的 Esc 收起上)。
|
||||
pub(super) fn escape(app: &mut App) -> bool {
|
||||
if app.state.dialogs.current().is_none() {
|
||||
return false;
|
||||
}
|
||||
app.close_dialog();
|
||||
app.refresh_view();
|
||||
true
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
//! 启动失败弹窗的**取证夹具**(`ULL_DIALOG_FIXTURE`,只在 `--screenshot` 下生效)。
|
||||
//!
|
||||
//! ## 为什么要有它
|
||||
//!
|
||||
//! 弹窗要出两张图(「有 crash-report」/「只有捕获输出」),而真机上要复现一次崩溃得先让一个
|
||||
//! 真实的 MC 进程死掉——截图通道要的是**确定性**。做法与 `ULL_ACCOUNT_STATE` 同一档:
|
||||
//! 在临时目录里造一份**真的文件树**(launcher.log / 会话 JSONL / crash-reports / latest.log),
|
||||
//! 于是弹窗上的路径是真路径、点「导出诊断包」真的会打出一个装得下这些文件的 zip。
|
||||
//!
|
||||
//! 取值:
|
||||
//!
|
||||
//! | 值 | 现场 |
|
||||
//! |---|---|
|
||||
//! | `crash-report` | 游戏写了 `crash-reports/crash-*.txt`(证据一) |
|
||||
//! | `no-logs` | 没有 crash-report、没有 latest.log,只有会话 JSONL 里的 stderr(证据二) |
|
||||
//! | `export` | 同 `no-logs`,并在出图前**真的跑一次导出**(第三张图:路径 + 字节数) |
|
||||
//!
|
||||
//! 夹具只影响这一次进程;产品路径(`host/launch.rs` 收尾 → `crash_report::publish` →
|
||||
//! `refresh_view` → 弹窗)与它完全同一条,这里只是**代填事实**。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use unlucky_core::launch::crash_report::{self, ArtifactFact, CrashReport};
|
||||
use unlucky_core::launch::crash_scan::{self, CrashSource};
|
||||
use unlucky_presenter::dto::{LaunchFinished, LogOutcome};
|
||||
|
||||
use crate::app::state::App;
|
||||
|
||||
/// 现场根(`%TEMP%/ull-m187-fixture-<pid>`)。
|
||||
fn root() -> PathBuf {
|
||||
std::env::temp_dir().join(format!("ull-m187-fixture-{}", std::process::id()))
|
||||
}
|
||||
|
||||
fn write(path: &Path, text: &str) {
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let _ = std::fs::write(path, text);
|
||||
}
|
||||
|
||||
/// 出图用的夹具(`None` = 没有开关,什么都不做)。
|
||||
pub(super) fn apply(app: &mut App) -> Option<String> {
|
||||
let mode = std::env::var("ULL_DIALOG_FIXTURE").ok()?;
|
||||
let mode = mode.trim().to_lowercase();
|
||||
if !app.has_screenshot() {
|
||||
return None;
|
||||
}
|
||||
let root = root();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let logs = root.join("logs");
|
||||
write(
|
||||
&logs.join("launcher.log"),
|
||||
"2026-11-08T15:30:11.002Z [boot] renderer=wgpu\n\
|
||||
2026-11-08T15:30:12.441Z [launch] instance=survival spawn game_dir=… java=javaw (21)\n\
|
||||
2026-11-08T15:30:19.870Z [launch] instance=survival session=9f2c exit=1\n",
|
||||
);
|
||||
write(&logs.join("install.log"), "installed 1.20.4-fabric\n");
|
||||
|
||||
let session = "9f2c4a1e-77bd-4f27-9d21-5b0a1f2c3d4e";
|
||||
let session_log = root
|
||||
.join("sessions")
|
||||
.join("game")
|
||||
.join(format!("{session}.jsonl"));
|
||||
write(
|
||||
&session_log,
|
||||
"{\"kind\":\"stdout\",\"payload\":{\"line\":\"[main/INFO]: Loading Minecraft 1.20.4\"}}\n\
|
||||
{\"kind\":\"stderr\",\"payload\":{\"line\":\"java.lang.RuntimeException: mixin injection failed\"}}\n\
|
||||
{\"kind\":\"stderr\",\"payload\":{\"line\":\"Caused by: java.lang.NoClassDefFoundError: org/lwjgl/glfw/GLFW\"}}\n\
|
||||
{\"kind\":\"exit\",\"payload\":{\"code\":1}}\n",
|
||||
);
|
||||
let tail: Vec<String> = vec![
|
||||
"[main/INFO]: Loading Minecraft 1.20.4".to_string(),
|
||||
"java.lang.RuntimeException: mixin injection failed".to_string(),
|
||||
"Caused by: java.lang.NoClassDefFoundError: org/lwjgl/glfw/GLFW".to_string(),
|
||||
];
|
||||
|
||||
let run_dir = root.join("instances").join("survival");
|
||||
let latest = run_dir.join("logs").join("latest.log");
|
||||
let version_json = root
|
||||
.join("game")
|
||||
.join("versions")
|
||||
.join("1.20.4-fabric")
|
||||
.join("1.20.4-fabric.json");
|
||||
write(&version_json, "{\"id\":\"1.20.4-fabric\"}\n");
|
||||
|
||||
let mut artifacts: Vec<ArtifactFact> = Vec::new();
|
||||
if mode != "no-logs" {
|
||||
write(
|
||||
&run_dir
|
||||
.join("crash-reports")
|
||||
.join("crash-2026-11-08_15.30.19-client.txt"),
|
||||
"---- Minecraft Crash Report ----\n\
|
||||
Time: 2026-11-08 15:30:19\n\
|
||||
Description: Initializing game\n\n\
|
||||
java.lang.RuntimeException: mixin injection failed\n\
|
||||
\tat net.fabricmc.loader.impl.launch.knot.Knot.init(Knot.java:142)\n\
|
||||
\tat net.minecraft.client.main.Main.main(Main.java:51)\n",
|
||||
);
|
||||
write(&latest, "[15:30:12] [main/INFO]: Loading Minecraft 1.20.4\n[15:30:19] [Render thread/ERROR]: GLFW init failed\n");
|
||||
let since = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
|
||||
artifacts = crash_scan::scan(&run_dir, since, 8)
|
||||
.into_iter()
|
||||
.map(|artifact| ArtifactFact {
|
||||
source: artifact.source.as_str().to_string(),
|
||||
path: artifact.path,
|
||||
excerpt: artifact.excerpt,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
let report = CrashReport {
|
||||
instance: "survival".to_string(),
|
||||
name: "生存服的 1.20.4".to_string(),
|
||||
version: "1.20.4-fabric".to_string(),
|
||||
session: session.to_string(),
|
||||
session_log: Some(session_log),
|
||||
version_json: Some(version_json),
|
||||
launcher_logs: Some(logs),
|
||||
run_dir: Some(run_dir),
|
||||
artifacts,
|
||||
exit_code: Some(1),
|
||||
exit_type: "application_error".to_string(),
|
||||
uptime_ms: 7_400,
|
||||
command: "javaw -Xmx4096m -Djava.library.path=<home>… ".to_string(),
|
||||
java: "C:/Program Files/Java/jdk-21/bin/javaw.exe (major 21)".to_string(),
|
||||
system: "os=windows version=10.0.26100 arch=x86_64".to_string(),
|
||||
stage: None,
|
||||
stage_detail: None,
|
||||
stderr_tail: tail,
|
||||
};
|
||||
crash_report::publish(report);
|
||||
let finished = LaunchFinished {
|
||||
id: "survival".to_string(),
|
||||
session: session.to_string(),
|
||||
outcome: LogOutcome::Crashed,
|
||||
exit_code: Some(1),
|
||||
uptime_ms: 7_400,
|
||||
failure: None,
|
||||
};
|
||||
app.open_failure_dialog(&finished);
|
||||
if mode == "export" {
|
||||
// 走**真的**那一条:与用户点「导出诊断包」同一个处理函数。
|
||||
let _ = super::dialog::on_dialog(app, crate::app::dialog::Message::Primary);
|
||||
}
|
||||
Some(mode)
|
||||
}
|
||||
|
||||
/// 夹具里崩溃报告的来源名(与 `CrashSource::CrashReport` 同源,测试用)。
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn crash_report_source() -> &'static str {
|
||||
CrashSource::CrashReport.as_str()
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! 动效的**起跑点**(M192 r1 复审 P1-1 修的正是这里)。
|
||||
//!
|
||||
//! ## 为什么这条不能在 `view` 里
|
||||
//!
|
||||
//! iced 0.14 的处理顺序是:
|
||||
//!
|
||||
//! ```text
|
||||
//! 消息 ──► update() ← program.subscription() 在这里求值(iced_winit src/lib.rs:1338)
|
||||
//! ──► build_user_interfaces() ← 这时才调 program.view(src/lib.rs:1233)
|
||||
//! ```
|
||||
//!
|
||||
//! 也就是说**订阅表的求值早于视图**。第一版把「内容标识变了 → 起跑」写在 `pages::render`
|
||||
//! (= `view`)里,于是同一拍订阅表看不见轨道,`frames()` 要等下一条消息(最快鼠标抬起、
|
||||
//! 最慢 1 Hz 心跳)才打开;那一帧的 dt 又是整段空闲时长 ⇒ 轨道被当成「早已到期」清掉,
|
||||
//! 用户看到的是一次 12/8px 的偏移闪跳,比硬切更难看。截图通道因为 `has_screenshot()` 恒真,
|
||||
//! **结构上**不可能暴露这条。
|
||||
//!
|
||||
//! 所以起跑搬到这里:`App::update` 处理完每一条消息后调 [`App::sync_motion`],
|
||||
//! 于是「轨道存在」对**同一拍**的 `subscription()` 可见,帧订阅当拍打开。
|
||||
//!
|
||||
//! ## 标识从哪来
|
||||
//!
|
||||
//! 从**路由**取(`AppState::route()` 是唯一真源),不从视图模型取:账户页 / AI 独立页不走
|
||||
//! `pages::render`,用路由才能表达「这一屏不归 pages 画」(`None`)——它们之间的切换不起跑,
|
||||
//! 而「从它们回到某一屏」照旧跑换页那一档(复审 r1 结论 4 的硬切顺手收口)。
|
||||
|
||||
use unlucky_presenter::router::Route;
|
||||
|
||||
use crate::app::components::motion;
|
||||
use crate::pages;
|
||||
|
||||
use super::boot_env::MotionFixture;
|
||||
|
||||
use super::*;
|
||||
|
||||
impl App {
|
||||
/// 每条消息处理完之后同步一次内容标识(**必须在 `update` 里**,理由见模块文档)。
|
||||
pub(in crate::app) fn sync_motion(&mut self) {
|
||||
// **还没有窗口就什么都不做**(也不记标识):动效是画出来的东西,没有窗口时
|
||||
// `window::frames()` 一条消息也不会有,起跑只会白占一档。更要紧的是「首屏入场」的语义——
|
||||
// 等窗口到了再算「第一眼看到的内容」,否则首屏那一下会被启动期的空树吃掉。
|
||||
// 截图通道例外:它的窗口句柄也是异步到的,而序列图必须从第 1 帧就按钉住的相位画。
|
||||
if self.window.is_none() && !self.has_screenshot() {
|
||||
return;
|
||||
}
|
||||
let route = self.state.route();
|
||||
let screen = screen_key(route);
|
||||
let detail = match route {
|
||||
Route::Detail(id) => Some(id.as_str()),
|
||||
_ => None,
|
||||
};
|
||||
motion::observe(screen, detail);
|
||||
}
|
||||
|
||||
/// 出图取证:让这一次进程真的发生一次「换页」或「列表 → 详情」(见 [`super::boot_env::MotionFixture`])。
|
||||
pub(in crate::app) fn motion_fixture(&mut self) {
|
||||
let Some(fixture) = super::boot_env::env_motion_fixture() else {
|
||||
return;
|
||||
};
|
||||
let route = match (fixture, self.frames) {
|
||||
// 列表 → 详情:第 1 帧先回列表(详情收起来),第 2 帧再点进第一条实例。
|
||||
(MotionFixture::Detail, 1) => Route::Instances,
|
||||
(MotionFixture::Detail, 2) => {
|
||||
let Some(first) = self
|
||||
.state
|
||||
.tiles()
|
||||
.first()
|
||||
.map(|cell| cell.instance.id.clone())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
Route::Detail(first)
|
||||
}
|
||||
(MotionFixture::Route(route), 1) => route,
|
||||
_ => return,
|
||||
};
|
||||
diag!("[motion] ULL_MOTION_FIXTURE → {route:?}");
|
||||
self.state.navigate(route);
|
||||
self.view_dirty = true;
|
||||
self.refresh_view();
|
||||
}
|
||||
|
||||
/// 真机空闲取证:`ULL_MOTION_FIXTURE_AT=<秒>` 到点就**真的**导航一次(`on_heartbeat` 调)。
|
||||
///
|
||||
/// 为什么不用点击:[`super::boot_env::env_motion_fixture_at`] 的文档写了原因——本机会话没有
|
||||
/// 前台窗口,合成鼠标事件递不进 iced 的输入层。心跳这条路与点击在 `update` 之后是同一段代码。
|
||||
/// 返回 `true` = 这一拍真的跳了页。
|
||||
pub(in crate::app) fn motion_fixture_after_idle(&mut self) -> bool {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
/// 已经过去的心跳数(进程内一次;只有取证开关打开时才会用到)。
|
||||
static FIXTURE_BEATS: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
let Some(target) = boot_env::env_motion_fixture_at() else {
|
||||
return false;
|
||||
};
|
||||
let beat = FIXTURE_BEATS.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
if beat != target {
|
||||
return false;
|
||||
}
|
||||
let route = match boot_env::env_motion_fixture() {
|
||||
Some(MotionFixture::Route(route)) => route,
|
||||
Some(MotionFixture::Detail) => {
|
||||
let Some(id) = self
|
||||
.state
|
||||
.tiles()
|
||||
.first()
|
||||
.map(|cell| cell.instance.id.clone())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
Route::Detail(id)
|
||||
}
|
||||
None => Route::Download,
|
||||
};
|
||||
diag!("[motion] ULL_MOTION_FIXTURE_AT 到点(第 {beat} 次心跳)→ {route:?}");
|
||||
self.state.navigate(route);
|
||||
self.view_dirty = true;
|
||||
self.refresh_view();
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// 路由 → 屏的稳定键(`pages::Screen::ALL` 的下标 + 1;`None` = 不归 pages 画)。
|
||||
///
|
||||
/// 与 `pages::Screen::of` 同一判据(`Screen::Instances` ↔ `Route::Instances | Route::Detail(_)`),
|
||||
/// 只是把「有没有这一屏」也表达出来——`pages::Screen::of` 给的是 `Option<Screen>`,这里
|
||||
/// 再折成 `Option<u64>` 给帧泵用。
|
||||
fn screen_key(route: &Route) -> Option<u64> {
|
||||
let screen = pages::Screen::of(route)?;
|
||||
pages::Screen::ALL
|
||||
.iter()
|
||||
.position(|candidate| *candidate == screen)
|
||||
.map(|index| index as u64 + 1)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 路由 → 屏键:五个一级页各自唯一,`Detail` 归「实例」,账户/AI 不归 pages。
|
||||
#[test]
|
||||
fn only_the_five_screens_get_a_key() {
|
||||
let keys: Vec<Option<u64>> = [
|
||||
Route::Instances,
|
||||
Route::Download,
|
||||
Route::Content,
|
||||
Route::Settings,
|
||||
Route::Logs,
|
||||
Route::Detail("survival".to_string()),
|
||||
Route::Accounts,
|
||||
Route::Agent,
|
||||
]
|
||||
.iter()
|
||||
.map(screen_key)
|
||||
.collect();
|
||||
|
||||
assert_eq!(&keys[..5], &[Some(1), Some(2), Some(3), Some(4), Some(5)]);
|
||||
assert_eq!(
|
||||
keys[5],
|
||||
Some(1),
|
||||
"详情页归「实例」那一屏(与 Screen::of 同源)"
|
||||
);
|
||||
assert_eq!(keys[6], None, "账户页不归 pages 画");
|
||||
assert_eq!(keys[7], None, "AI 独立页不归 pages 画");
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
use unlucky_i18n::{Lang, LangChoice};
|
||||
|
||||
use crate::app::diag::diag;
|
||||
use crate::pages;
|
||||
use crate::ui::lang::LangState;
|
||||
|
||||
@@ -94,9 +95,23 @@ impl App {
|
||||
pages::Message::Instances(message) => return self.on_instance_message(message),
|
||||
// M114:账户页(三轨登录 / 列表动作 / 表单输入)
|
||||
pages::Message::Account(message) => return self.on_account(message),
|
||||
// M188:日志页与设置页的「打开日志文件夹」——同一个落点,路径从设置桥反推
|
||||
// (三模式都对),失败文本带完整路径,并**送进剪贴板**(打不开时用户至少能粘出去)。
|
||||
pages::Message::Action(pages::Action::OpenLogs) => {
|
||||
return match crate::app::logs_folder::open() {
|
||||
Ok(dir) => {
|
||||
diag!("[logs] 已打开日志文件夹 {}", dir.display());
|
||||
Task::none()
|
||||
}
|
||||
Err(error) => {
|
||||
diag!("[logs] 打开日志文件夹失败:{error}");
|
||||
iced::clipboard::write(error)
|
||||
}
|
||||
};
|
||||
}
|
||||
// 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记)
|
||||
pages::Message::Action(action) => {
|
||||
eprintln!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)");
|
||||
diag!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,9 +142,7 @@ impl App {
|
||||
self.lang = LangState::of(choice);
|
||||
self.lang.log();
|
||||
if !self.bridge.persist_ui_language(choice.setting_value()) {
|
||||
eprintln!(
|
||||
"[i18n] 语言偏好没落盘(配置只读或数据根解析不出来):本次生效,重启会回到旧值"
|
||||
);
|
||||
diag!("[i18n] 语言偏好没落盘(配置只读或数据根解析不出来):本次生效,重启会回到旧值");
|
||||
}
|
||||
self.lang.log_cjk();
|
||||
self.view_dirty = true;
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
//! 页面自己不看盘:本文件在每次 hydrate 时算好 [`Context`] 塞进状态,渲染器只负责画。
|
||||
|
||||
use super::super::*;
|
||||
use crate::app::diag::diag;
|
||||
|
||||
use crate::pages::settings::java::{Context, Layer, Notice, Requirement};
|
||||
|
||||
@@ -310,7 +311,7 @@ fn write_notice(error: pages::settings::java::store::WriteError) -> Notice {
|
||||
WriteError::Refused(refusal) => Notice::Refused(refusal),
|
||||
WriteError::Unwritable(reason) => Notice::Unwritable(reason),
|
||||
WriteError::Schema(detail) => {
|
||||
eprintln!("[java] 设置写盘失败:{detail}");
|
||||
diag!("[java] 设置写盘失败:{detail}");
|
||||
Notice::WriteFailed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
//! 3. 「立即重启」是本文件唯一碰进程的地方:先按同样的命令行拉起新进程,再退出自己——
|
||||
//! 正因为替换已经完成(新 exe 就在原路径上),拉起来的就是新版本。
|
||||
|
||||
use crate::app::diag::diag;
|
||||
use iced::Task;
|
||||
use unlucky_presenter::dto::AppCommand;
|
||||
|
||||
@@ -54,7 +55,7 @@ impl App {
|
||||
|
||||
/// 忙碌时把这条动作丢掉(不派发、不乐观改状态),并在日志里留一行取证。
|
||||
fn refuse_busy(&self, what: &str) -> Task<Message> {
|
||||
eprintln!("[update] 已有一条检查/下载在跑,忽略重复的「{what}」");
|
||||
diag!("[update] 已有一条检查/下载在跑,忽略重复的「{what}」");
|
||||
Task::none()
|
||||
}
|
||||
|
||||
@@ -64,26 +65,26 @@ impl App {
|
||||
/// 文件、并把取证进程拖成死循环。
|
||||
fn restart_now(&mut self) -> Task<Message> {
|
||||
if self.options.screenshot.is_some() {
|
||||
eprintln!("[update] 截图通道不重启(忽略「立即重启」)");
|
||||
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();
|
||||
match std::process::Command::new(&exe).args(&args).spawn() {
|
||||
Ok(child) => eprintln!(
|
||||
Ok(child) => diag!(
|
||||
"[update] 已拉起新进程 pid={}:{}",
|
||||
child.id(),
|
||||
exe.display()
|
||||
),
|
||||
Err(error) => {
|
||||
eprintln!("[update] 拉起新进程失败:{error}(请手动重启启动器)");
|
||||
diag!("[update] 拉起新进程失败:{error}(请手动重启启动器)");
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("[update] 取 current_exe 失败:{error}");
|
||||
diag!("[update] 取 current_exe 失败:{error}");
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user