跳转至

crates\nfarms-gui\src\lib.rs

源码路径:crates\nfarms-gui\src\lib.rs · 5289 行

//! NFarms HUB 图形界面(egui / eframe)。
//!
//! 对应 C# 版的 `MainForm.cs`(5,798 行)那一层。原则和阶段 1 一样:
//! **数据与逻辑在 `nfarms-core`,这里只负责画和交互。**
//!
//! 视觉上复刻原版的三件事:无边框窗口、侧栏雾玻璃、天气页的紫→洋红渐变卡片。
//! UI 线程绝不做等待 —— 联网在后台线程跑,结果用 channel 回传
//! (原版 README 反复强调「UI 线程不做等待与 Sleep」)。
//!
//! # 结构
//!
//! ```text
//! App         壳:窗口、侧栏、内容区、切页 —— 唯一持有 PageRegistry 的地方
//!  ├─ AppState   共享状态:设置 / 日志 / 天气 / 背景图 / 本地模型
//!  ├─ Kernel     插件内核(nfarms-plugin):服务容器 + 事件总线 + 生命周期
//!  └─ PageRegistry  页面插件登记表:侧栏与内容区都只认它
//! ```
//!
//! **侧栏不是常量数组画的**,而是照插件注册表来 —— 装了什么就显示什么。
//! 分组顺序与内容见 `pages::Group`,也就是那张功能树。

mod background;
mod icons;
/// 本地大模型那一块要拉 llama-server / sd-cli 这些**桌面可执行文件**,
/// 安卓上没有,整个模块只在 Windows 下编。
#[cfg(windows)]
mod local_model;
#[cfg(windows)]
mod pet_window;
mod logo;
mod pages;
mod plugins;
mod theme;
mod viewer;

// 页面清单对外公开:`--list-pages`(冒烟测试要问程序「一共有哪些页」)
// 和 README 的自动生成都靠它。`second_level_count` 是三级结构的**现算**统计,
// 命令行的汇总行用它 —— 免得那边自己数一遍、和界面上的口径漂开。
pub use pages::{Group, NavScope, Page, PageInfo, PAGES, Sub, second_level_count};
/// 三级菜单的树形访问器 —— 命令行 `--list-pages` 要按它打,免得自己拼一份。
pub use plugins::{NavItem, PageRegistry, PageSpec, desktop_registry};

// Grafana 真白标(换素材文件)。给 `nfarms-app` 的 `--obs-install` 用 ——
// `[branding]` 这个配置在 Grafana **OSS** 里是付费功能、没人读(理由见 logo.rs 开头),
// 所以只能把 Grafana 自己那几个写死路径的素材换掉。放在 gui 是因为抽 PNG 的
// `extract_largest_png` 在这儿,而 `nfarms-core` 不能反过来依赖 gui。
pub use logo::install_grafana_branding;

use std::path::{Path, PathBuf};
use std::sync::mpsc::{Receiver, TryRecvError};
use std::sync::{Arc, Mutex};
use std::time::Duration;

use eframe::egui;
use egui::{Align2, Color32, FontId, RichText};
use nfarms_plugin::{Event, Kernel};

use nfarms_core::{cyinfo::MachineInfo, observability, weather, Log, Paths, Settings};

/// 侧栏宽度。原版最小窗口 1060×680,侧栏行距 50px —— 这里按同样的手感来。
const NAV_WIDTH: f32 = 236.0;
/// 分组标题行高
const GROUP_ROW_H: f32 = 38.0;
/// 二级子分组标题行高(比一级目录矮 —— 三级菜单里它是中间那一层)
const SUB_ROW_H: f32 = 28.0;
/// 分组里每一项的行高(比分组标题矮,一眼能看出层级)
const CHILD_ROW_H: f32 = 32.0;

/// 起界面(桌面)。返回 `Err` 表示窗口压根没建起来(没有桌面会话、显卡驱动异常等),
/// 调用方据此给出非 0 退出码 —— 别把失败吞成「成功退出」。
///
/// `initial_page` 让命令行能指定开在哪一页(`--page topo`),
/// 这样每一页都能做无人值守冒烟测试,而不是只能看首页。
#[cfg(not(target_os = "android"))]
pub fn run(
    paths: &Paths,
    log: &Log,
    settings_path: &Path,
    initial_page: Option<Page>,
    open_image: Option<&str>,
    popout: bool,
) -> Result<(), String> {
    run_inner(paths, log, settings_path, initial_page, open_image, popout)
}

/// 安卓版的 `run`。窗口句柄由 `run_android` 那条路给,这里只是让签名对齐。
#[cfg(target_os = "android")]
pub fn run(
    paths: &Paths,
    log: &Log,
    settings_path: &Path,
    initial_page: Option<Page>,
    _open_image: Option<&str>,
    _popout: bool,
) -> Result<(), String> {
    // 安卓这条路上没有 AndroidApp(这个入口不是从 NativeActivity 进来的),
    // 最后一个参数传 None。**这个签名一改两边都要跟着改** ——
    // 只编 Windows 是发现不了的,所以每次都要跑一遍交叉编译。
    run_inner(paths, log, settings_path, initial_page, None, false, None)
}

/// 安卓入口的真正实现。由 `nfarms-mobile` 的 `android_main` 调用。
///
/// eframe 在 Android 上**必须**拿到 `AndroidApp`(NativeActivity 的句柄),
/// 否则 `run_native` 会直接报 "`NativeOptions` is missing required `android_app`"。
#[cfg(target_os = "android")]
pub fn run_android(
    android_app: winit::platform::android::activity::AndroidApp,
    paths: &Paths,
    log: &Log,
    settings_path: &Path,
) -> Result<(), String> {
    run_inner(
        paths,
        log,
        settings_path,
        None,
        None,
        false,
        Some(android_app),
    )
}

fn run_inner(
    paths: &Paths,
    log: &Log,
    settings_path: &Path,
    initial_page: Option<Page>,
    open_image: Option<&str>,
    popout: bool,
    #[cfg(target_os = "android")] android_app: Option<winit::platform::android::activity::AndroidApp>,
) -> Result<(), String> {
    // 启动时在**后台**采一份本机信息 → `reports\<计算机名>-<yyyyMMdd>.cy`。
    //
    // 必须放后台:8 个工具探测串起来要 1~2 秒,搁在启动路径上窗口就晚出来这么久。
    // 失败也不拦启动 —— 报告是附带产物,不是必需品。
    {
        let p = paths.clone();
        let l = log.clone();
        std::thread::spawn(move || {
            if let Err(e) = nfarms_core::cyinfo::write_report(&p, &l) {
                l.write("report", &format!("生成本机信息报告失败: {e}"));
            }
        });
    }

    // logo:优先程序根下的 nfarms.ico,读不到/坏掉就退回编译进 exe 的那一份。
    // 和 C# 版 build-nfarms.ps1 的策略一致(exe 里嵌一份给窗口/托盘图标兜底)。
    let (logo_image, logo_notes) = logo::load(paths.root());
    for note in &logo_notes {
        log.write("gui", &format!("logo: {note}"));
    }
    let icon = egui::IconData {
        // egui 的 Color32 是预乘的,图标要非预乘 RGBA,所以显式转一次
        rgba: logo_image
            .pixels
            .iter()
            .flat_map(|c| c.to_srgba_unmultiplied())
            .collect(),
        width: logo_image.size[0] as u32,
        height: logo_image.size[1] as u32,
    };

    let options = eframe::NativeOptions {
        viewport: egui::ViewportBuilder::default()
            .with_title("NFarms HUB")
            .with_icon(icon)
            .with_inner_size([1180.0, 780.0])
            .with_min_inner_size([1060.0, 680.0])
            // 原版是无边框窗口(名称和 logo 已经在侧栏里,标题栏是重复的)
            .with_decorations(false)
            // ---- 真透明:**试过了,不行**(留个记录,免得以后重试) ----
            //
            // 按 egui 文档配齐了两件事:`with_transparent(true)`
            // **加上**面板 frame 也用半透明填充(文档明说 CentralPanel
            // 会把底填掉,必须配合)。
            //
            // **实测结果:背景像素恒定。** 把窗口放在主窗口背景图上,
            // 和放在桌面壁纸上,右边缘同一相对位置都读出
            // `R199 G204 B201` —— 一模一样。背景不随背后内容变化,
            // 就不是真透明。
            //
            // 另外反推出来 `clear_color` 变成了**黑色**:alpha 218 的填充
            // 混出 `(199,204,201)`,比 `APP_BG(233,239,235)` 更深。
            // 也就是说 egui **认了**透明意图,但窗口最终仍按不透明合成。
            //
            // 和早先桌宠窗口的实测一致(那次是像素亮度 69.7 vs 192.8)。
            // **结论:egui 的视口在这个平台上做不了真透明。**
            // 真要做得走 Win32 分层窗口(桌宠那条路),而那意味着
            // 对话界面不能在 egui 里画 —— 是重写,不是改配置。
            .with_resizable(true),
        // 安卓上必须把 NativeActivity 的句柄交给 winit
        #[cfg(target_os = "android")]
        android_app,
        ..Default::default()
    };

    let paths = paths.clone();
    let log = log.clone();
    let settings_path = settings_path.to_path_buf();
    let open_image = open_image.map(|s| s.to_string());
    // 闭包要 `move` 拿走一份,错误分支还要留一份写日志
    let log_for_app = log.clone();

    let result = eframe::run_native(
        "NFarms HUB",
        options,
        Box::new(move |cc| {
            Ok(Box::new(App::new(
                cc,
                paths,
                log_for_app,
                settings_path,
                initial_page,
                open_image,
                popout,
                logo_image,
            )))
        }),
    );

    result.map_err(|e| {
        // 双击运行时 stderr 是没人看的,所以同时落盘。
        let msg = format!("无法创建窗口: {e}");
        log.write("gui", &msg);
        msg
    })
}

// ===========================================================================
// 共享状态
// ===========================================================================

/// 一个后台任务的结果槽。
///
/// 界面上凡是「跑起来要几秒」的活(检测工具版本、问客户端状态、以后解码视频……)
/// 都走这个:页面点一下 → 线程干活 → UI 每帧轮询,拿不到就继续等。
/// **UI 线程永远不等待** —— 这是原版 README 反复强调的一条,也是这个结构存在的理由。
pub(crate) struct Job<T> {
    rx: Option<Receiver<T>>,
    done: Option<T>,
    running: bool,
}

impl<T: Send + 'static> Default for Job<T> {
    fn default() -> Self {
        Self {
            rx: None,
            done: None,
            running: false,
        }
    }
}

impl<T: Send + 'static> Job<T> {
    pub(crate) fn start<F>(&mut self, ctx: &egui::Context, work: F)
    where
        F: FnOnce() -> T + Send + 'static,
    {
        if self.running {
            return;
        }
        self.running = true;
        let (tx, rx) = std::sync::mpsc::channel();
        self.rx = Some(rx);
        let ctx = ctx.clone();
        std::thread::spawn(move || {
            let out = work();
            let _ = tx.send(out);
            // 算完叫一声 —— 不叫的话界面要等下一次输入才刷新
            ctx.request_repaint();
        });
    }

    pub(crate) fn is_running(&self) -> bool {
        self.running
    }

    /// 轮询一次。返回 `true` 表示**这一帧刚拿到结果**。
    pub(crate) fn poll(&mut self) -> bool {
        let Some(rx) = self.rx.take() else {
            return false;
        };
        match rx.try_recv() {
            Ok(v) => {
                self.done = Some(v);
                self.running = false;
                true
            }
            Err(TryRecvError::Empty) => {
                self.rx = Some(rx);
                false
            }
            Err(TryRecvError::Disconnected) => {
                self.running = false;
                true
            }
        }
    }

    pub(crate) fn result(&self) -> Option<&T> {
        self.done.as_ref()
    }

    /// 把结果**取走**(而不是借一份)。给"结果很大"的任务用 ——
    /// 启动动图的一次抽帧是几十张解码后的图(几十 MB),`result().cloned()`
    /// 会让内存在那一瞬间翻倍,而 `.take()` 之后旧的那份立刻可以释放。
    /// 便宜的结果(一行字符串、一个下标)照旧用 `result()`,不必多一次 move。
    pub(crate) fn take_result(&mut self) -> Option<T> {
        self.done.take()
    }
}

/// 客户端(nfztna)的一次状态快照。
pub(crate) struct ChannelSnapshot {
    pub client_path: String,
    pub running: bool,
    pub status: String,
}

/// Tools › ffmpeg 页的状态。
///
/// 探测与转码/拉流都在后台:`ffmpeg` 探测要扫 PATH(可能是网络盘,一次几秒),
/// 转码更可能是几分钟 —— 都不能放在 UI 线程上。
pub(crate) struct FfmpegState {
    /// 已探测到的 ffmpeg(None = 还没探测过)
    pub(crate) detected: Option<nfarms_core::ffmpeg::Ffmpeg>,
    pub(crate) detect_job: Job<nfarms_core::ffmpeg::Ffmpeg>,
    /// 转码 / 截图 / 拉流这类长任务
    pub(crate) work: Job<String>,
    /// 表单:源文件 / 输出文件 / 截图时间点
    pub(crate) src: String,
    pub(crate) out: String,
    pub(crate) at_seconds: String,
    /// 媒体信息(检测过一次就留着)
    pub(crate) info: Option<String>,
    pub(crate) message: Option<String>,
}

impl Default for FfmpegState {
    fn default() -> Self {
        Self {
            detected: None,
            detect_job: Job::default(),
            work: Job::default(),
            src: String::new(),
            out: String::new(),
            at_seconds: "1".to_string(),
            info: None,
            message: None,
        }
    }
}

/// Zero Trust VPN › Packet capture 的状态。
///
/// `child` 是**长期运行**的 dumpcap 进程 —— 它不属于任何 `Job`
/// (`Job` 是「跑完给一个结果」,抓包是「一直跑到你喊停」),
/// 所以单独放在这里,由页面负责 kill。
#[derive(Default)]
pub(crate) struct CaptureState {
    pub(crate) ws: Option<nfarms_core::wireshark::Wireshark>,
    pub(crate) iface_job: Job<Result<Vec<nfarms_core::wireshark::Interface>, String>>,
    pub(crate) child: Option<std::process::Child>,
    /// 输出文件(留空则自动放到 shots\ 下)
    pub(crate) out: String,
    /// BPF 过滤表达式
    pub(crate) filter: String,
    pub(crate) iface_index: u32,
    pub(crate) message: Option<String>,
    /// 探测过没有(理由同 `DshState::probed`)
    pub(crate) probed: bool,
    /// 上次抓到的文件,用来「用 Wireshark 打开」
    pub(crate) last_pcap: Option<PathBuf>,
}


impl CaptureState {
    pub(crate) fn capturing(&self) -> bool {
        self.child.is_some()
    }

    /// 停抓包。dumpcap 收到 kill 会把已抓的包 flush 到文件,所以直接 kill 是安全的。
    pub(crate) fn stop(&mut self) -> bool {
        let Some(mut c) = self.child.take() else {
            return false;
        };
        let _ = c.kill();
        let _ = c.wait();
        true
    }
}

/// SecureCRT 这个「深度集成」页的状态。
///
/// **WinSCP 那一份已经删掉**:新菜单里没有它的落点(SecureCRT 那一页管安全终端)。
/// `nfarms_core::remote` 里的 WinSCP 解析与启动函数还在 —— 那是有测试的纯逻辑,
/// 要加回来时直接用。
#[derive(Default)]
pub(crate) struct RemoteState {
    pub(crate) crt_exe: Option<PathBuf>,
    /// **探测过没有** —— 理由同 `DshState::probed`:没装 SecureCRT 时
    /// `find_*` 每次都要 `reg query`,用 `is_none()` 当守卫就会每帧起一个
    /// cmd.exe 去读注册表。
    pub(crate) crt_probed: bool,
    /// 会话列表要跑子进程(扫 ini),放后台
    pub(crate) crt_sessions: Job<Vec<nfarms_core::remote::Session>>,
    pub(crate) message: Option<String>,
}


/// DSH-Starter 页的状态。
///
/// `child` 是常驻的 dsh 进程 —— 和抓包那页一样,它不属于 `Job`
/// (`Job` 是「跑完给一个结果」,这个是「一直跑到你喊停」)。
pub(crate) struct DshState {
    pub(crate) child: Option<std::process::Child>,
    pub(crate) port: i32,
    /// 从 dsh 输出里抓到的**带 token** 的根地址
    pub(crate) url: Option<String>,
    /// `where dsh` 的结果
    pub(crate) exe: Option<String>,
    /// **探测过没有** —— 不能用 `exe.is_none()` 当标志!
    ///
    /// 「没装 dsh」是个合法结果,而 `is_none()` 分不清「没探测过」和
    /// 「探测过但没有」。用它当守卫的话,没装 dsh 的机器上会**每帧**跑一次
    /// `where dsh`(每次起一个 cmd.exe)—— 就是用户看到的「一直弹控制台闪屏」。
    pub(crate) probed: bool,
    pub(crate) message: Option<String>,
}

impl Default for DshState {
    fn default() -> Self {
        Self {
            child: None,
            port: nfarms_core::dsh::DEFAULT_PORT,
            url: None,
            exe: None,
            probed: false,
            message: None,
        }
    }
}

/// bililive-go(直播录制)页的状态。
#[derive(Default)]
pub(crate) struct LiveState {
    pub(crate) live: Option<nfarms_core::live::Live>,
    pub(crate) child: Option<std::process::Child>,
    pub(crate) rooms: Vec<String>,
    pub(crate) room_input: String,
    /// ping 的结果
    pub(crate) status: Option<String>,
    /// 探测过没有(理由同 `DshState::probed`)
    pub(crate) probed: bool,
    pub(crate) message: Option<String>,
}


/// 剩下几页的状态,合成一个结构体。
///
/// 它们都是「点一下 → 拿个结果」或几个开关,单独给每页开一个 struct 只会
/// 让 `AppState` 变成一张长清单。真正长期的进程句柄(抓包、dsh、相册)
/// 才单独放。
pub(crate) struct MiscState {
    // ---- Setting › Proxy and Port ----
    // ---- Slack off › 桌宠对话框 ----
    // ---- Today's Info ----
    /// 抓取任务。**单独一个 Job**:它和别的页面的任务互不影响。
    pub(crate) today_job: Job<Result<Vec<nfarms_core::today::Item>, String>>,
    pub(crate) today_items: Vec<nfarms_core::today::Item>,
    pub(crate) today_err: Option<String>,
    /// 上次成功抓取的时刻。用它做**自动刷新**(见 today_info 里的间隔说明)。
    pub(crate) today_at: Option<std::time::Instant>,

    /// 对话框开着没有。点桌宠切换。
    pub(crate) chat_open: bool,
    /// 对话历史。`true` = 用户说的,`false` = 小鲸说的。
    ///
    /// 和 DSH 的会话历史**不是一回事**:这里是一次性的「问一句答一句」,
    /// 关掉窗口就从零开始 —— 桌宠不该变成一个要管理的会话。
    pub(crate) chat_msgs: Vec<(bool, String)>,
    pub(crate) chat_input: String,
    pub(crate) chat_job: Job<Result<nfarms_core::chat::Reply, String>>,
    /// 勾上「画图」时,这一条消息走 stable-diffusion.cpp 而不是对话模型。
    pub(crate) chat_draw: bool,
    /// 出图分辨率。sd-cli 默认 512×512 —— 画大图必须显式给。
    pub(crate) chat_draw_w: u32,
    pub(crate) chat_draw_h: u32,
    /// 上一帧对话框是不是开着。用它检测**关的那一刻** ——
    /// 关窗必须发一次 `ViewportCommand::Close`,不能只是"不再画它"。
    pub(crate) chat_was_open: bool,
    /// 对话框的 Win32 半透明**只设一次**。函数本身幂等,但每帧
    /// `FindWindowW` 是白费的;而且失败时要重试(视口第一帧还没建出来)。
    pub(crate) chat_translucent: bool,
    /// 知识库文档。**读一次留着** —— `wiki\` 有好几 MB,
    /// 每条消息重读一遍是几百毫秒的无谓 I/O,而它几乎不变。
    pub(crate) kb_docs: Option<Vec<nfarms_core::kb::Doc>>,
    /// 出图任务。**和对话分开两个 Job** —— 出图要几分钟,复用一个 Job
    /// 会让"正在出图"把对话也一起锁住。
    pub(crate) chat_draw_job: Job<Result<std::path::PathBuf, String>>,
    /// 最近画出来的图。`None` = 还没画过。
    pub(crate) chat_img: Option<egui::TextureHandle>,
    /// 最近那张图的**磁盘路径** —— 「打开所在文件夹」要用它。
    /// 只留纹理是不够的:纹理在内存里,用户要的是文件。
    pub(crate) chat_img_path: Option<std::path::PathBuf>,
    /// 本地模型累计用量。Tokens 页显示它。
    ///
    /// 用 `u64` 而不是 `u32`:单次请求的 token 数不会溢出,但**累计**会。
    pub(crate) tok_prompt: u64,
    pub(crate) tok_completion: u64,
    pub(crate) tok_calls: u64,
    /// 从 llama-server 拉回来的原始指标行(只留含 token 的)。
    pub(crate) tok_metrics: Vec<String>,
    pub(crate) tok_metrics_err: Option<String>,
    pub(crate) tok_metrics_job: Job<Result<Vec<String>, String>>,
    /// 上一次请求的错误。显示在输入框上方,不塞进对话历史 ——
    /// 塞进去的话下一轮会被当成「小鲸说过的话」再发回模型。
    pub(crate) chat_err: Option<String>,

    /// 「终止服务」的结果。**单独一个字段**而不是复用 `message`:
    /// 那个是 Script 页的,写进去会显示在**另一页**上。
    pub(crate) port_message: Option<String>,

    // ---- Slack off › 桌宠 ----


    // ---- Player › 视频播放器 ----
    pub(crate) video_src: String,
    /// 「打开视频…」选完之后要**自动载入并播放**。
    ///
    /// 用一个标志跨页传递,而不是在 `poll_background_drop` 里直接载入:
    /// 载入要用 ffmpeg 句柄和 `video_*` 那一堆状态,那些都在视频页里。
    /// 在这里重写一遍 = 两处逻辑,迟早不一致。
    pub(crate) video_want_load: bool,
    pub(crate) video_duration_ms: i64,
    pub(crate) video_pos_ms: i64,
    pub(crate) video_playing: bool,
    pub(crate) video_tex: Option<egui::TextureHandle>,
    /// 抽帧任务(ffmpeg → png → 解码 → 纹理)
    pub(crate) video_frame: Job<FrameJob>,
    pub(crate) video_info: String,
    /// 上一次抽帧对应的时刻(毫秒),用来在播完时停下来
    pub(crate) video_last_shot: i64,

    // ---- Setting › 启动动图 ----
    pub(crate) boot_enabled: bool,
    pub(crate) boot_started_ms: Option<i64>,
    /// 自定义的启动动画 MP4。`None` = 用内置那层遮罩动画。
    ///
    /// 存**路径本体**而不是 store 里的字符串:读取时已经解析过一次
    /// (见 [`boot_video_from_store`]),播放那一帧里不必再判一次空串。
    pub(crate) boot_video: Option<std::path::PathBuf>,
    /// 启动动画播不播声音,**默认 true**(选了视频的人多半就是要连声音一起放)。
    pub(crate) boot_sound: bool,
    /// 抽好的帧(UI 线程上传的纹理),与 `boot_frame_at` **同长同序**。
    pub(crate) boot_frames: Vec<egui::TextureHandle>,
    /// 每帧对应的毫秒偏移(= 从这一毫秒起显示这一帧)。
    pub(crate) boot_frame_at: Vec<u64>,
    /// 抽帧任务 —— **必须后台**:一次要起 ffmpeg 解一段视频。
    pub(crate) boot_video_job: Job<Result<BootFrames, String>>,
    /// 已经为哪个路径抽过一次帧(成功失败都算)。
    ///
    /// 只给 `render_boot_anim` 里那次"自动补抽"当闸门:没有它的话,
    /// 用户每点一次「立即预览」就会再起一个 ffmpeg,而失败(文件坏了 /
    /// ffmpeg 没装)的那次会永远失败 —— 白白刷日志、白白占 CPU。
    pub(crate) boot_video_tried: Option<std::path::PathBuf>,
    /// 正在放声音的那段动画的起点(即 `boot_started_ms` 的值);`None` = 没在放。
    ///
    /// 用它而不是 `boot_started_ms.is_some()` 当判据:播放起点一变
    /// (启动自动播 → 用户点「立即预览」)就该重起一次声音,而
    /// "有没有在播"这个问法分不出这两种情况。
    pub(crate) boot_audio_for: Option<i64>,

    // ---- Setting › 监控并同步 ----
    pub(crate) sync_enabled: bool,
    pub(crate) sync_target: String,

    // ---- Setting › 打印代理 ----
    pub(crate) printer_exe: Option<std::path::PathBuf>,
    pub(crate) printer_port: u16,
    pub(crate) printer_child: Option<std::process::Child>,
    /// 探测过没有(理由同 `DshState::probed`)
    pub(crate) printer_probed: bool,

    // ---- Slack off › Pet ----
    pub(crate) pet_visible: bool,
    pub(crate) pet_phase: f32,

    // ---- Setting › Proxy and Port › 观测服务 ----
    //
    // 三件(VictoriaMetrics / Loki / Grafana)都是**外部 exe**:起进程、等就绪、
    // 喂数据各自要几秒到几十秒,所以状态机放在这里,页面只负责画灯和按按钮。
    // 下面这五个 `obs_` 字段是**页面要读的契约**(plugins/core_pages.rs),
    // 下标 0/1/2 = 指标 / 日志 / 看板,别改顺序、别改名字。
    /// 三件服务的子进程句柄。
    ///
    /// 单独放而不是塞进 `Job`:它们是"一直跑到你喊停"的常驻进程,而 `Job` 是
    /// "跑完给一个结果"(`CaptureState::child` 是同一个理由)。
    pub(crate) obs_proc: [Option<std::process::Child>; 3],
    /// 三件服务的状态:**0=未启动 1=启动中 2=已就绪 3=失败**。
    ///
    /// 用 `u8` 而不是枚举:页面直接拿它对着数字取色画灯,不必依赖本模块的类型;
    /// 做成枚举的话每加一个态都得同时改两个文件。
    pub(crate) obs_state: [u8; 3],
    /// 最近一条给用户看的话(启动中 / 已就绪 / 失败原因)。`None` = 还没动过。
    pub(crate) obs_msg: Option<String>,
    /// 启动任务的返回槽:`Ok(下标)` = 那一件已经就绪。
    ///
    /// 补配置、生成数据源、起进程、**等就绪**全在这个 Job 里跑 —— 等就绪是阻塞的
    /// (Loki 实测 17 秒),搁在 UI 线程上就是整个窗口冻住几十秒,用户只会以为程序死了。
    pub(crate) obs_job: Job<Result<usize, String>>,
    /// 三件服务的端口。真值存在 store.json,这里存的是**界面上的文本**。
    ///
    /// 存字符串不存 u16:它背后是输入框,用户敲到一半的 `"90"` 也得留住 ——
    /// 每帧解析失败就把框清成默认值的话,那个框根本没法用。
    pub(crate) obs_ports: [String; 3],
    /// 起进程那一步在后台 Job 里,拿到 `Child` 之后从这条通道交回 UI 线程
    /// (`Job` 的结果类型 `Result<usize, String>` 塞不下一个进程句柄)。
    pub(crate) obs_child_rx: Option<Receiver<(usize, std::process::Child)>>,
    /// 上次把 token 用量推给 VM 的**时刻**(u64 毫秒,Unix 纪元);0 = 还没推过。
    ///
    /// 要的就是"上一次推送的时刻"这个值本身,所以用墙上时间而不是 `Instant`:
    /// 后者是单调时钟,存下来既不表示时刻,也没法跟一个"现在"相减。
    pub(crate) obs_pushed_ms: u64,

    pub(crate) message: Option<String>,
}

impl Default for MiscState {
    fn default() -> Self {
        Self {
            port_message: None,
            today_job: Job::default(),
            today_items: Vec::new(),
            today_err: None,
            today_at: None,
            chat_open: false,
            chat_msgs: Vec::new(),
            chat_input: String::new(),
            chat_job: Job::default(),
            chat_draw: false,
            chat_draw_w: 768,
            chat_draw_h: 768,
            chat_was_open: false,
            chat_translucent: false,
            kb_docs: None,
            chat_draw_job: Job::default(),
            chat_img: None,
            chat_img_path: None,
            tok_prompt: 0,
            tok_completion: 0,
            tok_calls: 0,
            tok_metrics: Vec::new(),
            tok_metrics_err: None,
            tok_metrics_job: Job::default(),
            chat_err: None,

            video_src: String::new(),
            video_want_load: false,
            video_duration_ms: 0,
            video_pos_ms: 0,
            video_playing: false,
            video_tex: None,
            video_frame: Job::default(),
            video_info: String::new(),
            video_last_shot: -1,
            boot_enabled: false,
            boot_started_ms: None,
            boot_video: None,
            boot_sound: true,
            boot_frames: Vec::new(),
            boot_frame_at: Vec::new(),
            boot_video_job: Job::default(),
            boot_video_tried: None,
            boot_audio_for: None,
            sync_enabled: false,
            sync_target: "report.nfarms.cn".to_string(),
            printer_exe: None,
            printer_port: 8642,
            printer_child: None,
            printer_probed: false,
            pet_visible: false,
            pet_phase: 0.0,
            // 观测服务:端口初值取自 core 的缺省(9091 / 9092 / 9093),
            // `AppState::new` 里再用 store.json 里存过的值覆盖
            obs_proc: [None, None, None],
            obs_state: [0, 0, 0],
            obs_msg: None,
            obs_job: Job::default(),
            obs_ports: std::array::from_fn(|i| {
                observability::default_port(OBS_SERVICES[i]).to_string()
            }),
            obs_child_rx: None,
            obs_pushed_ms: 0,
            message: None,
        }
    }
}

/// 日志查看器的状态(带分页)。
///
/// 为什么要缓存:日志文件可能有几十 MB,**每帧重读一次**会直接把界面拖死。
/// 所以按 `(长度, 修改时间)` 判断有没有变,变了才重读。
pub(crate) struct LogViewer {
    /// 当前看的文件
    pub(crate) file: Option<PathBuf>,
    /// 文件内容(只保留尾部,见 `MAX_BYTES`)
    pub(crate) text: String,
    /// 上次读时的 `(字节数, 修改时间秒)` —— 用来判断要不要重读
    pub(crate) stamp: Option<(u64, u64)>,
    /// 当前页(从 0 开始)
    pub(crate) page: usize,
    /// 每页行数
    pub(crate) per_page: usize,
    /// 自动跟随(有新内容就跳到最后一页)
    pub(crate) follow: bool,
    /// 「跳到第几页」输入框
    pub(crate) goto: String,
    pub(crate) error: Option<String>,
}

/// 单个日志文件最多读这么多字节。超了只留**尾部** ——
/// 排查问题看的是最近发生了什么,不是三个月前。
const LOG_MAX_BYTES: u64 = 8 * 1024 * 1024;

/// 每页行数的候选。
pub(crate) const PER_PAGE_CHOICES: [usize; 5] = [50, 100, 200, 500, 1000];

impl Default for LogViewer {
    fn default() -> Self {
        Self {
            file: None,
            text: String::new(),
            stamp: None,
            page: 0,
            per_page: 200,
            follow: true,
            goto: String::new(),
            error: None,
        }
    }
}

impl LogViewer {
    /// 选一个默认文件(没有选过时用程序日志)。
    pub(crate) fn ensure_file(&mut self, dir: &std::path::Path) {
        if self.file.as_ref().map(|p| p.is_file()).unwrap_or(false) {
            return;
        }
        // 默认看程序日志;它不在就退而求其次,挑目录里最新改动的那个 .log
        let preferred = dir.join(nfarms_core::log::APP_LOG);
        self.file = Some(if preferred.is_file() {
            preferred
        } else {
            newest_log(dir).unwrap_or(preferred)
        });
    }

    /// 需要的话重读文件。返回是否真的重读了。
    pub(crate) fn refresh(&mut self) -> bool {
        let Some(path) = self.file.clone() else {
            return false;
        };
        let meta = match std::fs::metadata(&path) {
            Ok(m) => m,
            Err(e) => {
                self.error = Some(format!("读不到 {}: {e}", path.display()));
                return false;
            }
        };
        let mtime = meta
            .modified()
            .ok()
            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
            .map(|d| d.as_secs())
            .unwrap_or(0);
        let stamp = (meta.len(), mtime);
        if self.stamp == Some(stamp) {
            return false;
        }

        // 超过上限只读尾部:先 seek 到 len - MAX,再丢掉第一个不完整的行
        let mut text = match std::fs::read(&path) {
            Ok(bytes) => {
                let bytes = if bytes.len() as u64 > LOG_MAX_BYTES {
                    let skip = bytes.len() - LOG_MAX_BYTES as usize;
                    // 从 skip 往后找第一个换行,避免把半行读进来
                    let start = bytes[skip..]
                        .iter()
                        .position(|&b| b == b'\n')
                        .map(|p| skip + p + 1)
                        .unwrap_or(skip);
                    bytes[start..].to_vec()
                } else {
                    bytes
                };
                nfarms_core::tools::decode_smart(&bytes)
            }
            Err(e) => {
                self.error = Some(format!("读日志失败: {e}"));
                String::new()
            }
        };
        // 日志文件是 UTF-8,但可能有坏字节 —— decode_smart 已经兜过了
        let was_following = self.follow;
        self.error = None;
        self.text = std::mem::take(&mut text);
        self.stamp = Some(stamp);
        let pages = self.page_count();
        if was_following {
            self.page = pages.saturating_sub(1);
        } else {
            self.page = self.page.min(pages.saturating_sub(1));
        }
        true
    }

    /// 全部分行(不复制)。
    pub(crate) fn lines(&self) -> Vec<&str> {
        self.text.lines().collect()
    }

    pub(crate) fn line_count(&self) -> usize {
        self.text.lines().count()
    }

    pub(crate) fn page_count(&self) -> usize {
        let n = self.line_count();
        if n == 0 {
            return 1;
        }
        n.div_ceil(self.per_page.max(1))
    }

    /// 当前页的行区间 `[start, end)`。
    pub(crate) fn page_range(&self) -> (usize, usize) {
        let per = self.per_page.max(1);
        let start = self.page.saturating_mul(per);
        let total = self.line_count();
        (start.min(total), (start + per).min(total))
    }

    pub(crate) fn goto_page(&mut self, p: usize) {
        self.page = p.min(self.page_count().saturating_sub(1));
        self.follow = false;
    }
}

/// 目录里最近改动的 `.log`。
fn newest_log(dir: &std::path::Path) -> Option<PathBuf> {
    let mut best: Option<(std::time::SystemTime, PathBuf)> = None;
    for e in std::fs::read_dir(dir).ok()?.flatten() {
        let p = e.path();
        let is_log = p
            .extension()
            .and_then(|x| x.to_str())
            .map(|x| x.eq_ignore_ascii_case("log"))
            .unwrap_or(false);
        if !is_log {
            continue;
        }
        if let Ok(m) = e.metadata()
            && let Ok(t) = m.modified()
            && best.as_ref().map(|(bt, _)| t > *bt).unwrap_or(true)
        {
            best = Some((t, p));
        }
    }
    best.map(|(_, p)| p)
}

/// 浏览器引擎探测这一块的一次性后台任务。
pub(crate) struct ProbeState {
    /// 浏览器引擎探测
    pub(crate) engines: Job<Vec<nfarms_core::browser::Engine>>,
    pub(crate) message: Option<String>,
}

impl Default for ProbeState {
    fn default() -> Self {
        Self {
            engines: Job::default(),
            message: None,
        }
    }
}

/// 音乐 / 阅读器两页的共享状态。
///
/// 放一起是因为它们的「打开文件」走同一个对话框(`FilePicker`),靠
/// `PickPurpose` 路由回来 —— 分成两个结构体反而要多写一遍路由。
#[derive(Default)]
pub(crate) struct MediaState {
    /// MCI 播放器(winmm)
    pub(crate) music: nfarms_core::music::Music,
    /// 当前播放列表(某个目录下的音频文件)
    pub(crate) playlist: Vec<std::path::PathBuf>,
    pub(crate) playlist_dir: String,
    /// 播放列表里正在放第几首
    pub(crate) track: usize,

    pub(crate) message: Option<String>,
}


impl MediaState {
    /// 扫一个目录,建播放列表(按文件名排序,和资源管理器一致)。
    pub(crate) fn scan_playlist(&mut self, dir: &std::path::Path) {
        const EXT: [&str; 7] = ["mp3", "wav", "wma", "m4a", "aac", "flac", "ogg"];
        let mut list: Vec<std::path::PathBuf> = std::fs::read_dir(dir)
            .map(|it| {
                it.flatten()
                    .map(|e| e.path())
                    .filter(|p| p.is_file())
                    .filter(|p| {
                        p.extension()
                            .and_then(|e| e.to_str())
                            .map(|e| EXT.contains(&e.to_ascii_lowercase().as_str()))
                            .unwrap_or(false)
                    })
                    .collect()
            })
            .unwrap_or_default();
        list.sort();
        self.playlist = list;
        self.playlist_dir = dir.display().to_string();
        self.track = 0;
    }
}

/// 拓扑验证器的界面状态。
/// 探测跑在后台线程(并发 4、单边超时 4~5 秒),结果经 channel 回传 ——
/// UI 线程照样不做任何等待,和天气页是同一套做法。
pub(crate) struct TopoState {
    pub(crate) edges: Vec<nfarms_core::topo::TopoEdge>,
    pub(crate) running: bool,
    pub(crate) rx: Option<Receiver<Vec<nfarms_core::topo::TopoEdge>>>,
    /// 最近一次跑完的时间戳(`20260930-223907`)
    pub(crate) last_run: Option<String>,
    /// 单边超时,界面上可以调
    pub(crate) timeout_ms: i32,
}

impl TopoState {
    fn new() -> Self {
        Self {
            // 初值就是「未验证」的 14 条边 —— 打开页面先看到清单,
            // 而不是一片空白等着你点按钮。
            edges: nfarms_core::topo::live_edges(),
            running: false,
            rx: None,
            last_run: None,
            timeout_ms: nfarms_core::topo::TIMEOUT_MS,
        }
    }

    pub(crate) fn start(&mut self, ctx: &egui::Context) {
        if self.running {
            return;
        }
        self.running = true;
        // 先全部置成 busy,界面上立刻能看到「进行中」
        for e in self.edges.iter_mut() {
            e.state = "busy";
            e.judged = false;
        }
        let (tx, rx) = std::sync::mpsc::channel();
        self.rx = Some(rx);
        let timeout = self.timeout_ms;
        let ctx = ctx.clone();
        std::thread::spawn(move || {
            let mut edges = nfarms_core::topo::live_edges();
            nfarms_core::topo::run_all_blocking(&mut edges, timeout, None::<fn(&nfarms_core::topo::TopoEdge)>);
            let _ = tx.send(edges);
            ctx.request_repaint();
        });
    }

    /// 轮询后台结果。返回 Some(汇总行) 表示这一轮跑完了。
    pub(crate) fn poll(&mut self) -> Option<String> {
        let rx = self.rx.take()?;
        match rx.try_recv() {
            Ok(edges) => {
                self.edges = edges;
                self.running = false;
                let line = nfarms_core::topo::summary_line(
                    &self.edges,
                    nfarms_core::topo::server_edges().len(),
                );
                self.last_run = Some(now_stamp());
                Some(line)
            }
            Err(TryRecvError::Empty) => {
                self.rx = Some(rx);
                None
            }
            Err(TryRecvError::Disconnected) => {
                self.running = false;
                Some("后台探测线程异常退出".to_string())
            }
        }
    }
}

/// 各页面共用的状态。
///
/// 从原来那个大 `App` 里切出来的:**它只管数据,不管窗口**。
/// 页面插件拿到的 `PageCtx` 里就是 `&mut AppState`,因此加一页
/// 不需要动 `App` 的任何实现。
/// 抽帧任务的结果:成功给出「解码后的图 + 原始尺寸」,失败给出原因。
///
/// 起个别名:`Job<Result<(ColorImage, (u32, u32)), String>>` 写两遍就没人看得懂了。
pub(crate) type FrameJob = Result<(egui::ColorImage, (u32, u32)), String>;

// ===========================================================================
// 启动动图(Setting › 启动动图)
// ===========================================================================
//
// 两种来源:用户选的 MP4(**逐帧抽出来当纹理放**),或者内置那层全屏遮罩动画。
// 这里放纯函数与常量,状态在 `MiscState` 的 `boot_*` 字段上,
// 抽帧在 `AppState::boot_video_load` / `AppState::poll_boot_video`,
// 播放驱动在 `App::render_boot_anim`。

/// store.json 里「自定义启动动画」的键(值是 MP4 路径;空 / 缺失 = 用内置动画)。
///
/// 抽成常量给读写两侧共用 —— 键名打错一个字母的症状是「用户选了视频,重启后
/// 悄悄回到内置动画」,而盘上那份路径还好端端躺在一个没人读的键下面。
/// **不进 settings.json**:那个文件有跟 C# 原版逐字节还原的护栏测试,多一个键就红。
pub(crate) const BOOT_VIDEO_KEY: &str = "boot_video";
/// store.json 里「启动动画播不播声音」的键(`"1"` / `"0"`,`"0"` 之外都算开)。
pub(crate) const BOOT_SOUND_KEY: &str = "boot_sound";
/// store.json 里「启动时播不播启动动画」的键(`"1"` = 开,**默认关**)。
///
/// 这个名字原先是背景图页(`core_pages.rs::KEY_BOOT_ANIM`)一个人在用 ——
/// 现在 [`AppState::save_boot_switches`] 也要写它(用户选 MP4 时会自动把它顶成开,
/// 见 [`boot_enabled_after_pick`]),所以键名挪到这里两边共用:同一个键有两份
/// 字面量,就是两个会各自漂移的来源,而漂移的症状是「勾了、存了、重启还是关」。
pub(crate) const BOOT_ANIM_KEY: &str = "boot_anim";
/// 给启动动画写的提示统一带这个前缀。
///
/// `misc.message` 是**全 App 共用一个槽**(视频页 / ffmpeg 页 / 拉流页都在写),
/// 而启动动画卡片长在背景图片页上:不带前缀的话,用户从视频页回来会看到
/// 「载入成功,时长 …」挂在启动动画卡片下面,像是这一页干的。
/// 卡片那边(`core_pages.rs::boot_message`)据这个前缀过滤。
const BOOT_MSG_PREFIX: &str = "启动动画:";

/// 内置遮罩动画的总时长(毫秒)。
const BUILTIN_BOOT_TOTAL_MS: i64 = 1400;
/// 结尾淡出的时长(毫秒)。两种来源共用 —— MP4 直接切掉会在最后一帧硬闪一下。
const BOOT_FADE_MS: i64 = 400;

/// 抽帧采样率(帧/秒)。
///
/// 10 而不是源视频的 25/30:启动动画只放几秒、当"动起来的画面"看,少一半帧
/// 几乎看不出来;而内存是按「帧数 × 宽 × 高 × 4」算的,30 fps 抽 6 秒就是 180 张。
const BOOT_VIDEO_FPS: u32 = 10;
/// 最多抽多少帧(= 最多放 6 秒)。
///
/// 这不是"性能优化"而是**内存护栏**:960×540 的一帧是 2.0 MB,60 帧约 124 MB;
/// 不封顶的话一个 4K 的 10 秒片会一口气吃掉几个 GB,而启动动画只是层遮罩。
const BOOT_VIDEO_MAX_FRAMES: usize = 60;
/// 抽出来的帧的宽度上限(等比缩放,**只缩不放**)——
/// 见上一条的内存账;同时也保证不超过 `max_texture_side`。
const BOOT_VIDEO_MAX_WIDTH: u32 = 960;
/// 抽帧那次 ffmpeg 的超时(毫秒)。
///
/// `-frames:v` 已经把工作量封在"解前 6 秒"上,2 分钟是给网络盘 / 冷启动留的余量;
/// 真超时会 kill 进程并把 stderr 带回来,不会让界面一直转圈。
const BOOT_EXTRACT_TIMEOUT_MS: u64 = 120_000;

/// 一段 MP4 抽出来的帧,外加一句给用户看的说明。
pub(crate) struct BootFrames {
    /// `(毫秒偏移, 解码后的图)`,按时间升序。
    pub(crate) frames: Vec<(u64, egui::ColorImage)>,
    /// 成功也要说一句(抽了几帧 / 视频太长只取了前几秒)。
    /// 页面把 `misc.message` 画出来,用户才知道这次到底用了什么。
    pub(crate) note: String,
}

/// 按时间找「现在该显示第几帧」。
///
/// 只有一种情况返回 `None`:**一帧都没有**(刚启动还没抽完,或抽帧失败了)。
/// 早于第一帧 / 晚于最后一帧都夹到端点,而不是返回 `None` —— 返回 `None`
/// 会让调用方在这两段里退回内置遮罩,表现成动画开头或结尾突然闪一下内置的 logo,
/// 比停在某一帧难看得多。
///
/// `frame_at` 必须是升序表(抽帧那一步按帧序生成的偏移天然升序)。
pub(crate) fn boot_frame_index_at(at_ms: u64, frame_at: &[u64]) -> Option<usize> {
    if frame_at.is_empty() {
        return None;
    }
    // 返回值是「<= at_ms 的个数」:0 = 还没到第一帧(夹到 0),
    // len = 已经过了最后一帧(夹到 len-1)。两头都由 saturating_sub 自动夹住。
    let n = frame_at.partition_point(|&t| t <= at_ms);
    Some(n.saturating_sub(1))
}

/// 视频帧序列的总时长(毫秒)= 最后一帧的偏移 + 一帧的间隔。
///
/// 少加那一帧的间隔,最后一帧就永远来不及显示(走到它的偏移就已经判成"演完了")。
/// (页面上那个进度文案的分母用的是**最后一个偏移本身**,差的就是这一帧 ——
/// 只差 100 ms,不值得为它把两边都改成"要显示到什么时候"。
/// 真要让两边完全一致,得改的是 `core_pages.rs::boot_total_ms`。)
pub(crate) fn boot_video_total_ms(frame_at: &[u64]) -> i64 {
    let Some(last) = frame_at.last() else {
        return 0;
    };
    (last + (1000 / BOOT_VIDEO_FPS.max(1)) as u64) as i64
}

/// 该用 MP4 还是内置遮罩。
///
/// 两个条件缺一不可:选了文件、帧已经抽好。**「选了但还没抽完」必须回退内置** ——
/// 那时照路径去播就是一片黑,用户只会以为程序坏了。
pub(crate) fn boot_use_video(video: Option<&Path>, frame_at: &[u64]) -> bool {
    video.is_some() && !frame_at.is_empty()
}

/// 结尾淡出的不透明度(0..=1)。
fn boot_fade_alpha(elapsed_ms: i64, total_ms: i64) -> f32 {
    if elapsed_ms > total_ms - BOOT_FADE_MS {
        ((total_ms - elapsed_ms) as f32 / BOOT_FADE_MS as f32).clamp(0.0, 1.0)
    } else {
        1.0
    }
}

/// store 里的字符串 → 自定义启动动画的路径。缺失 / 不是字符串 / 全是空白 → `None`。
///
/// **不检查文件在不在**:那件事留到真去抽帧时报(见 [`extract_boot_frames`])——
/// 在这里检查的话,用户把视频放在迟到挂载的移动硬盘上,设置会被静默丢掉,
/// 而界面上看不出"为什么我的视频不见了"。
pub(crate) fn boot_video_from_store(raw: Option<&str>) -> Option<PathBuf> {
    let s = raw?.trim();
    if s.is_empty() { None } else { Some(PathBuf::from(s)) }
}

/// 用户交来一个启动视频(或取消)之后,「启动时播放」该是什么。
///
/// **选一个启动视频这个动作本身就表达了「启动时要播」。** 两个开关各自为政的
/// 结果是:store.json 里 `boot_video` 存住了、`boot_anim` 却没开 —— 动画不播,
/// 用户以为"设置没保存",而且**从这个现象反推不出原因**(他能看到的只有一个
/// 没勾的 checkbox,和每次启动悄悄跑掉的那一次 ffmpeg 抽帧)。
///
/// 传 `false`(清掉视频)**不反过来关掉**这个开关:那是用户对「启动时播放」的
/// 独立选择,清视频只表示"改回内置动画"。所以这里不是 `has_video` 直接赋值。
pub(crate) fn boot_enabled_after_pick(has_video: bool, current: bool) -> bool {
    has_video || current
}

/// store 里的字符串 → 「播不播声音」。**只有 `"0"` 是关**,其余(含缺失、写坏)
/// 都算开。
///
/// 默认开是有意的(选了 MP4 就是要连声音);写坏的值回落到默认而不是 `unwrap`
/// 炸掉 —— 这个函数跑在**启动路径**上,一次 panic 就是窗口起不来。
pub(crate) fn boot_sound_from_store(raw: Option<&str>) -> bool {
    !matches!(raw.map(str::trim), Some("0"))
}

/// store 里的字符串 → 「启动时播不播启动动画」。**只有 `"1"` 是开**,其余
/// (缺失 / `"0"` / 写坏的值)都算**关** —— 默认关是有意的:一个程序不该一启动
/// 就挡你 1.4 秒,这条语义在 `App::new` 里写着。
///
/// 写坏的值回落到默认而不是 `unwrap` 炸掉:这个函数跑在**启动路径**上。
/// 两边带空白照样认(手改 store.json 的常见写法),而落盘判据
/// ([`boot_anim_unchanged`])用的是同一个口径 —— 两边口径不一致就会造出
/// 「盘上是 `" 1 "`、于是跳过写盘,而重启读回来还是关」这种自相矛盾的状态。
pub(crate) fn boot_anim_from_store(raw: Option<&str>) -> bool {
    matches!(raw.map(str::trim), Some("1"))
}

/// 「想写的这两个值,盘上已经就是它们了吗」—— 决定要不要真的写盘。
///
/// 抽出来是因为这条判断错了**不会报错**:要么白写一次(每次启动把用户手工编辑
/// 过的 store.json 重排一遍,他写的注释和顺序全没了),要么该写的时候不写
/// (用户改完设置、重启后回到默认,而界面上一切正常)。
///
/// 两处"相等"的口径要写死,它们都是会静默出错的地方:
///   * 盘上是**空串** = 没选,与"没有这个键"同义(清除时留下的就是它);
///   * 声音的键**不在**时等于默认的"开",不必特意补一个 `"1"` 上去。
pub(crate) fn boot_switches_unchanged(
    want_video: Option<&str>,
    want_sound: &str,
    stored_video: Option<&str>,
    stored_sound: Option<&str>,
) -> bool {
    let video_same = match (want_video, stored_video) {
        (Some(w), Some(s)) => w == s,
        (None, None) => true,
        (None, Some(s)) => s.trim().is_empty(),
        (Some(_), None) => false,
    };
    let stored_sound = stored_sound.map(str::trim).unwrap_or("1");
    video_same && stored_sound == want_sound
}

/// 「`boot_anim` 这一格,盘上已经就是想要的值了吗」—— 决定要不要写盘。
///
/// 和 [`boot_switches_unchanged`] 同样的口径问题,所以**读和写共用
/// [`boot_anim_from_store`]**:缺失 = 默认关,手改的 `" 1 "` = 开。
/// 口径写成"字符串逐个相等"的话,`" 1 "` 会被判成"已经一样"而跳过写盘,
/// 可启动那边读出来是关 —— 用户手改过的文件就这么把他自己坑了。
pub(crate) fn boot_anim_unchanged(want: bool, stored: Option<&str>) -> bool {
    boot_anim_from_store(stored) == want
}

/// 用 ffmpeg 把一个 MP4 抽成启动动画要用的帧(**在后台 Job 里跑**)。
///
/// 做法:一条 ffmpeg 命令把整段视频按固定帧率、限宽缩放到临时目录里的一串 PNG,
/// 再逐张解码。**不用「每个时间点各起一次 `-ss` 抽帧」**—— 那要起 N 个进程、
/// N 次解文件头,60 帧就是 60 次进程创建,启动那几秒里白白多花十几秒。
///
/// 失败一律**如实报到界面上**(文件不存在 / ffmpeg 没装 / ffmpeg 报了错):
/// 静默回退成内置动画的话,用户只会以为"我选的视频没生效",而真正的原因
/// (缺 ffmpeg、文件损坏)一个字都不会出现。
fn extract_boot_frames(paths: &Paths, src: &Path, log: &Log) -> Result<BootFrames, String> {
    if !src.is_file() {
        return Err(format!("文件不存在: {}", src.display()));
    }
    let ff = nfarms_core::ffmpeg::Ffmpeg::detect(paths);
    let Some(exe) = ff.exe.clone() else {
        // 如实说是 ffmpeg 的事,不假装"视频坏了" —— 这两句话指向的下一步完全不同。
        return Err(format!(
            "没找到 ffmpeg,抽不了帧(应放到 {})。请到 Tools › ffmpeg 那一页看状态",
            ff.dir.display()
        ));
    };

    // 每一轮都从**空目录**开始:上一次抽了一半留下的 PNG 会被这一轮的枚举读到,
    // 于是帧数比实际多、偏移和画面错位。目录名里带 pid,两个实例不会互相踩。
    let dir = std::env::temp_dir().join(format!("nfarms-boot-frames-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&dir).map_err(|e| format!("建临时目录失败: {e}"))?;

    // `min(960,iw)`:只缩不放(小片子放大白费内存);`h=-2` 让高度按原比例取偶数。
    // 逗号在 ffmpeg 的滤镜语法里是分隔符,必须转义成 `\,`。
    let vf = format!(
        "fps={},scale=w=min({}\\,iw):h=-2",
        BOOT_VIDEO_FPS, BOOT_VIDEO_MAX_WIDTH
    );
    let pattern = dir.join("f-%04d.png");
    let r = nfarms_core::ffmpeg::run(
        &exe,
        &[
            "-y".into(),
            "-hide_banner".into(),
            "-loglevel".into(),
            "warning".into(),
            "-i".into(),
            src.display().to_string(),
            "-vf".into(),
            vf,
            // 封顶:解到第 60 帧 ffmpeg 就停,长视频不会把整个文件读完
            "-frames:v".into(),
            BOOT_VIDEO_MAX_FRAMES.to_string(),
            pattern.display().to_string(),
        ],
        BOOT_EXTRACT_TIMEOUT_MS,
    );

    // 按文件名排序收齐(ffmpeg 是 `%04d` 顺序写出来的,字典序 = 时间序)
    let mut files: Vec<PathBuf> = std::fs::read_dir(&dir)
        .map(|it| {
            it.flatten()
                .map(|e| e.path())
                .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("png"))
                .collect()
        })
        .unwrap_or_default();
    files.sort();

    if files.is_empty() {
        let _ = std::fs::remove_dir_all(&dir);
        // 一条画面都没出来:ffmpeg 的 stderr 才是"为什么",必须带上
        let tail: String = r.err.trim().chars().take(300).collect();
        return Err(if tail.is_empty() {
            format!("ffmpeg 没能从这段视频里抽出一帧(退出码 {})", r.code)
        } else {
            format!("ffmpeg 抽帧失败(退出码 {}):{tail}", r.code)
        });
    }

    // 偏移按"第几帧 × 一帧的间隔"算:ffmpeg 的 `fps` 滤镜保证输出帧是等间隔的。
    let step = (1000 / BOOT_VIDEO_FPS.max(1)) as u64;
    let mut frames = Vec::with_capacity(files.len());
    for (i, p) in files.iter().enumerate() {
        match background::decode_for_viewer(p) {
            Ok((img, _)) => frames.push((i as u64 * step, img)),
            Err(e) => {
                // 中间坏一张就整段失败:留着带洞的帧表,播到那一处时间会突然往前跳。
                let _ = std::fs::remove_dir_all(&dir);
                return Err(format!("第 {} 帧读不回来: {e}", i + 1));
            }
        }
    }
    let _ = std::fs::remove_dir_all(&dir);

    // 视频比上限长就说清楚"只取了前几秒" —— 用户看着自己 30 秒的片子被截断,
    // 不写这一句他会以为是程序坏了。
    //
    // 判据要留**一帧的余量**:容器的时长比"帧数 × 帧率"多出小半帧是常态
    // (1.5 秒 / 25 fps 的片子,`duration` 常是 1.52 秒),按 `dur > played_ms`
    // 判会对着一个 1.5 秒的片子说"只放了前 1.5 秒,原片 1.5 秒"—— 用户只会更糊涂。
    let step_ms = step as i64;
    let played_ms = frames.len() as i64 * step_ms;
    let dur = ff.duration_ms(src);
    let mut note = format!(
        "{BOOT_MSG_PREFIX} 从 {} 抽出 {} 帧",
        src.display(),
        frames.len()
    );
    if dur > played_ms + step_ms {
        note.push_str(&format!(
            ",只放了前 {:.1} 秒(原片 {:.1} 秒;启动动画最长 {} 秒)",
            played_ms as f64 / 1000.0,
            dur as f64 / 1000.0,
            (BOOT_VIDEO_MAX_FRAMES as i64 * step_ms) / 1000
        ));
    }
    log.write("boot", &note);
    Ok(BootFrames { frames, note })
}

// ===========================================================================
// 观测服务(VictoriaMetrics / Loki / Grafana)
// ===========================================================================
//
// 这一节是纯函数和常量,状态在 `MiscState` 的 `obs_` 那几个字段上,
// 状态机在 `AppState::poll_observability` / `AppState::obs_start`。

/// token 用量推给 VictoriaMetrics 的最小间隔(毫秒)。
///
/// 30 秒一档:比 Grafana 默认的最小抓取间隔(15 秒)还粗,一屏曲线的点数够用 ——
/// 再密只是把同一份累计值反复写进 TSDB,磁盘涨得比信息快。
const OBS_PUSH_INTERVAL_MS: u64 = 30_000;

/// 三件服务的下标顺序。三个状态灯、`obs_proc` / `obs_state` / `obs_ports`
/// 都按它排 —— 换顺序等于把页面上那三个灯的含义换掉。
const OBS_SERVICES: [observability::Service; 3] = [
    observability::Service::Metrics,
    observability::Service::Logs,
    observability::Service::Dashboard,
];

/// 一件服务的名字。
///
/// 和 `observability::spec().name` / `missing()` 返回的是**同一组字符串**
/// (那边用的是组件自己的拼法:界面上的字要和它们的日志、文档对得上)。
/// 这里再写一遍是因为日志里必须写名字,而 `obs_job` 的结果里只有一个下标。
fn obs_name(service: observability::Service) -> &'static str {
    match service {
        observability::Service::Metrics => "VictoriaMetrics",
        observability::Service::Logs => "Loki",
        observability::Service::Dashboard => "Grafana",
    }
}

/// 到没到该把 token 用量推给 VictoriaMetrics 的时刻。
///
/// 抽成纯函数:节流是这条链路上唯一「不报错但会做错」的地方 ——
/// 把 `>=` 写成 `>` 会漏掉正好 30 秒那一帧(表现成"有时 30 秒、有时 60 秒推一次"),
/// 把毫秒当秒写就是每 30 毫秒推一次(磁盘很快写满)。两种写法都不会有任何报错。
/// 传两个 u64 毫秒进来,测试不必真等 30 秒,也不必去动系统时间。
///
/// `last_ms == 0`(还没推过)必然为真:程序一起来先送一份基线 ——
/// 否则 VM 里开头那 30 秒的曲线是断的。
pub(crate) fn obs_push_due(now_ms: u64, last_ms: u64) -> bool {
    // `saturating_sub` 兜住时钟回拨:回拨后 `now < last`,差值按 0 算 = 不推,
    // 而不是让 u64 回绕出一个天文数字、把节流直接判成到期。
    now_ms.saturating_sub(last_ms) >= OBS_PUSH_INTERVAL_MS
}

/// 拼一行 token 累计用量,形状是 Prometheus 的文本格式:
/// `nfarms_tokens_total{kind="prompt"} 1234`。
///
/// 标签值**必须带双引号**:`kind=prompt` 这种裸写法 VM 的 import 接口直接 400,
/// 而它只回一句"格式不对" —— 从这句话里看不出是少了引号。
pub(crate) fn token_metric_line(kind: &str, value: u64) -> String {
    format!("nfarms_tokens_total{{kind=\"{kind}\"}} {value}")
}

// ===========================================================================
// 系统指标(CPU / 内存 / 磁盘)
//
// **为什么由应用自己推,而不是方案文档里的 HardwareExporterWindows + Alloy**:
// 这套东西本来就跑在**同一台机器**上,而 `nfarms_core::cyinfo` 已经在采这些值了。
// 再引一个采集器,等于为了同一份数据多一个进程、多一份配置、多一个会挂的地方 ——
// 而且两份数据迟早会不一致(各有各的采集口径、各有各的失败方式,对不上时
// 还说不清该信哪一份)。所以:同一个进程里,把 `cyinfo` 已经采到的结构化字段
// 直接推上去。
//
// **只认结构化字段,绝不解析 `cyinfo::render` 出来的报告文本** ——
// 那是把结构化数据又拼成字符串、再拆回来,凭空多一层会漂的解析。
// ===========================================================================

/// 系统读数**重新采一次**的间隔(毫秒),比推送间隔(30 秒)长得多。
///
/// 为什么不跟着推送每 30 秒采:**`cyinfo::collect` 是重活**。它要跑 8 个工具的
/// 版本探测(11~12 个子进程;本机实测一次约 1.4 秒)。为了读 4 个数每 30 秒
/// 起一遍,一小时就是一千多个进程 —— 这笔账不值。
///
/// 而且这几个读数里真正会变的只有**磁盘可用空间**(内存总量、核心数都是常量),
/// 磁盘被填满是个以小时计的过程:5 分钟一次照样画得出趋势 ——
/// 图上一个 5 分钟宽的台阶,换掉的是一小时一千多个短命进程。
const OBS_SYS_REFRESH_MS: u64 = 5 * 60 * 1000;

/// 到没到该**重新采**系统读数的时刻。
///
/// 和 [`obs_push_due`] 同一套口径(`>=`、`saturating_sub` 兜住时钟回拨):
/// 写成 `>` 会漏掉正好到点那一帧,表现成"有时 5 分钟、有时 10 分钟";
/// 时钟回拨若让 u64 回绕,就会变成每帧重采一遍(一小时上万个子进程)。
pub(crate) fn sys_readings_due(now_ms: u64, last_ms: u64) -> bool {
    now_ms.saturating_sub(last_ms) >= OBS_SYS_REFRESH_MS
}

/// Prometheus 标签值转义:`\` → `\\`、`"` → `\"`、换行 → `\n`。
///
/// 计算机名是**用户能改的**:里面出现一个双引号,`host="…"` 就会提前收尾,
/// 整行变成语法错 —— VM 的 import 接口会把**这一行样本丢掉**只回一句 400
/// "格式不对"。从这句话里看不出是主机名带了引号,所以必须在拼行时就转义。
pub(crate) fn escape_label_value(v: &str) -> String {
    let mut out = String::with_capacity(v.len());
    for c in v.chars() {
        match c {
            '\\' => out.push_str(r"\\"),
            '"' => out.push_str("\\\""),
            '\n' => out.push_str("\\n"),
            other => out.push(other),
        }
    }
    out
}

/// 一条 gauge 行:`nfarms_x{host="PC"} 123`。
///
/// 数值**一律把单位写进名字**(`_bytes` / `_percent` / `_cores`):这是 Prometheus
/// 的惯例,Grafana 也据此自动选对单位、自动缩放。推一个光秃秃的数字出去,
/// 看板上就得靠人记住"这个 34359738368 是字节",记错一次就得出一个荒谬的结论。
pub(crate) fn gauge_line(name: &str, value: u64, host: &str) -> String {
    format!("{name}{{host=\"{}\"}} {value}", escape_label_value(host))
}

/// 把一份机器画像里的系统读数拼成 Prometheus 行(**纯函数**:不采集、不联网)。
///
/// **读不到就不推,而不是推 0**:`cyinfo` 里 0 的含义就是"没读到"
/// (采集不出来的平台上 `ram_bytes` / `disk_total` 就是 0,它自己的报告注释也这么写)。
/// 推个 0 出去,图上画出来是"内存 0 字节"这种一眼假还真能被当真的曲线 ——
/// 缺一条曲线比一条错曲线好。
///
/// 带 `host=` 标签:`cyinfo` 报告本身就是**按机器归档**的(文件名带计算机名),
/// 多个人的 HUB 往同一个 VM 推时,没有这个标签各机器的曲线会混成一条。
/// (token 那条链路保持原样不加标签,免得动到已经在用的图表。)
pub(crate) fn system_metric_lines(info: &MachineInfo) -> Vec<String> {
    let host = info.hostname.as_str();
    let mut out = Vec::new();
    if info.cores > 0 {
        // 唯一能从 `cyinfo` 拿到的 CPU 侧数据:它**没有** CPU 使用率字段
        // (见 `cyinfo::memory_status` 里那句注释 —— Win32 的 `memory_load`
        // 采了但没往外传)。CPU 利用率要两个采样点相减,是另一件事,不在这里编。
        out.push(gauge_line("nfarms_cpu_cores", info.cores as u64, host));
    }
    if info.ram_bytes > 0 {
        out.push(gauge_line("nfarms_mem_total_bytes", info.ram_bytes, host));
    }
    if info.disk_total > 0 {
        out.push(gauge_line("nfarms_disk_total_bytes", info.disk_total, host));
        // 已用 = 总量 − 可用。两个字段来自**同一次** `GetDiskFreeSpaceExW`,
        // 理论上 free ≤ total;真碰上反过来的脏数据只能用 `saturating_sub` 收成 0 ——
        // 直接相减会让 u64 回绕成一个天文数字,图上顶到天花板,比 0 更难查。
        let used = info.disk_total.saturating_sub(info.disk_free);
        out.push(gauge_line("nfarms_disk_used_bytes", used, host));
    }
    out
}

/// 最近一次采到的系统读数:`(采的时刻, 已经拼好的行)`。
///
/// 和 [`OBS_PUSH_ERR`] 一样放模块级 static:推送跑在**后台线程**里,
/// 而这一份必须跨帧、跨线程活着。
///
/// 存**拼好的行**而不是 `MachineInfo`:`MachineInfo` 里还挂着显卡/模型/工具
/// 那些采一次就过期的字段,存下来只会让后来的人以为它们也是"当前值"。
static OBS_SYS_READINGS: Mutex<Option<(u64, Vec<String>)>> = Mutex::new(None);

/// 取这一轮要推的系统指标行,到点就(**在本线程**)重新采一次。
///
/// **必须在后台线程调用**:`cyinfo::collect` 会起 11~12 个子进程、本机实测约 1.4 秒。
/// 搁在 UI 线程上就是窗口卡一秒多(本文件头一条原则:UI 线程不做等待)。
///
/// 锁中毒(有线程拿着它 panic 了)时退回"现采一次、不写缓存":宁可多花 1.4 秒,
/// 也不能因为一个锁就把系统指标整条链路停掉。
fn system_metric_lines_now(paths: &Paths, now_ms: u64) -> Vec<String> {
    if let Ok(slot) = OBS_SYS_READINGS.lock()
        && let Some((at, lines)) = slot.as_ref()
        && !sys_readings_due(now_ms, *at)
    {
        return lines.clone();
    }
    let lines = system_metric_lines(&nfarms_core::cyinfo::collect(paths));
    if let Ok(mut slot) = OBS_SYS_READINGS.lock() {
        *slot = Some((now_ms, lines.clone()));
    }
    lines
}

/// 三件观测服务的端口初值:从 store.json 读(缺省 9091 / 9092 / 9093)。
///
/// 走 store 存取器而不是写死字面量:端口用户能改,而 Grafana 的数据源文件
/// (`provision_datasources`)由 core 按**同一组端口**生成 —— 两处各写一份默认值,
/// 改一处忘一处就会变成"健康检查打在 A、数据源指着 B",症状是 Grafana 起来了
/// 但图表全空。
pub(crate) fn obs_ports_from_store(store: &nfarms_core::store::Store) -> [String; 3] {
    [
        store.vm_port().to_string(),
        store.loki_port().to_string(),
        store.grafana_port().to_string(),
    ]
}

/// 界面上的端口文本 → 数字;解析不了就退回该服务的默认端口。
///
/// 端口在界面上是**文本框**,清空一次就得到 `""`。这时若退回 0,服务会去监听
/// 一个随机端口,而健康检查还打在 `127.0.0.1:0` 上 —— 表现成"每次都超时",
/// 从这句话里看不出是输入框空了。
pub(crate) fn obs_ports_u16(raw: &[String; 3]) -> [u16; 3] {
    std::array::from_fn(|i| {
        raw[i]
            .trim()
            .parse::<u16>()
            .unwrap_or_else(|_| observability::default_port(OBS_SERVICES[i]))
    })
}

/// 某个端口键在盘上**存过、而且能解析成 u16**吗。
///
/// 判据和 `LocalModel::load_ports` 同源(键在 + 值合法)。只看键在的话,
/// 盘上写了个 `"abc"` 也会被当成"读回来了"。
///
/// `cfg(windows)`:这三个函数只服务于 `LocalModel`(它本身只在 Windows 下编),
/// 安卓上留着就是三个没人调的函数 = 三条 dead_code 警告。
#[cfg(windows)]
fn stored_port(store: &nfarms_core::store::Store, key: &str) -> bool {
    store
        .get_str(key)
        .is_some_and(|v| v.trim().parse::<u16>().is_ok())
}

/// 本地模型(llama.cpp / sd.cpp)的端口初值:**以 store 存取器为准**
/// (缺省 9094 / 9095,旧的 8080 / 1234 已经迁移掉)。
///
/// 为什么还要这一步:`LocalModel::load_ports` 只在盘上**存过**时才覆盖,
/// 没存过时留下的是构造时那份初值 —— 于是"缺省端口"有两份定义,而 DSH 侧
/// 配 provider baseURL 跟着走的是 store 那份。两边不一致的症状是一句
/// connection refused,从这句话里看不出是端口默认值在打架。
///
/// 入参是 `load_ports` 之后的那两个值,出参是同类型的两个字符串
/// (那边是文本框,不能顺手换成 u16 再换回来)。盘上存过就**原样用** ——
/// 迁移不替用户改端口。
#[cfg(windows)]
pub(crate) fn model_ports_from_store(
    store: &nfarms_core::store::Store,
    llama: &str,
    sd: &str,
) -> (String, String) {
    let pick = |key: &str, loaded: &str, default: u16| {
        if stored_port(store, key) {
            loaded.to_string()
        } else {
            default.to_string()
        }
    };
    (
        pick(
            nfarms_core::store::KEY_LLAMA_PORT,
            llama,
            store.llama_port(),
        ),
        pick(nfarms_core::store::KEY_SD_PORT, sd, store.sd_port()),
    )
}

/// 盘上**真的**给过本地模型的端口吗 —— 只用来决定要不要记"端口已从
/// store.json 读回"那一行日志。
///
/// 不能拿"读回来的值和 store 存取器的当前值不同"当判据:盘上有值时,
/// 存取器返回的就是同一个数,拿它去比**永远相等**,那行日志会一次都不打 ——
/// 而它正是"端口记忆到底生效没有"唯一可查的凭据(端口是纯内存状态,
/// 不看日志没法确认读回来的是哪个值)。判据和 [`model_ports_from_store`]
/// 共用 `stored_port`,两处不会漂。
#[cfg(windows)]
pub(crate) fn model_ports_stored(store: &nfarms_core::store::Store) -> bool {
    [nfarms_core::store::KEY_LLAMA_PORT, nfarms_core::store::KEY_SD_PORT]
        .iter()
        .any(|k| stored_port(store, k))
}

/// 上一次已经写进日志的推送错误。
///
/// 放在模块级 static 而不是 `MiscState` 字段上:推送跑在**后台线程**里
/// (理由见调用点),而这条状态只服务于"同一个错误别反复写日志"这一件事 ——
/// 不值得为它往那个给页面看的结构体上加一个字段。
static OBS_PUSH_ERR: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);

/// 记下这次的推送错误;返回 `true` 表示**这个错误还没记过**,调用方该写日志。
///
/// "失败记一条、不刷屏"不是洁癖:VM 不可用时每 30 秒失败一次,每次都写就把日志里
/// 真正有用的那几行顶掉了,而它们说的其实是同一件事。错误内容变了才再记一条。
fn remember_push_error(err: Option<&str>) -> bool {
    let Ok(mut slot) = OBS_PUSH_ERR.lock() else {
        // 锁中毒(有线程拿着它 panic 了)。这时宁可多记一条,也不能把错误吞掉:
        // 推送失败本来就难查,连日志都没有就彻底没线索了。
        return err.is_some();
    };
    let now = err.map(str::to_string);
    if *slot == now {
        return false;
    }
    *slot = now;
    true
}

/// 推**一行**指标;失败时按"同一条错误只记一条日志"记一笔,返回 `false` 让调用方收手。
///
/// 抽出来是为了让"失败只记一条"这件事只有**一个**出口:两个 token 值、四条系统
/// 指标全走这里,于是 30 秒的推送周期里最多只可能写一条失败日志。
fn push_line(port: u16, what: &str, line: &str, log: &Log) -> bool {
    match observability::push_metric(port, line) {
        Ok(()) => true,
        Err(e) => {
            if remember_push_error(Some(&e)) {
                log.write("observe", &format!("{what}推送失败:{e}"));
            }
            false
        }
    }
}

/// 把 token 用量 + 系统指标推给 VictoriaMetrics(**在后台线程上跑**,见调用点)。
///
/// 一行一次请求:core 的 `push_metric` 就是"一行一个样本",多行要多次调用
/// (它自己的文档注释里写明了)。
///
/// 失败处理沿用原来那套,**两个性质都保留**:
/// * **同一条错误只记一条日志** —— VM 不可用时每 30 秒失败一次,每次都写就把日志里
///   真正有用的那几行顶掉了,而它们说的其实是同一件事;
/// * **第一次失败就收手** —— 后面的请求打的是同一个端口,第一次连不上,
///   后面的必然一样,再打只是让"失败"这件事慢一倍。
fn push_metrics(port: u16, paths: &Paths, prompt: u64, completion: u64, now_ms: u64, log: &Log) {
    // token 排在最前面:这两条已经在用了,顺序与原来完全一致。
    // 它们推不上去时**直接收手**,连系统读数那一遍采集都不做 —— 反正也推不出去。
    for (kind, value) in [("prompt", prompt), ("completion", completion)] {
        if !push_line(port, "token 用量", &token_metric_line(kind, value), log) {
            return;
        }
    }
    // token 推上去了,再取系统指标(到点才会真采一次,理由见 `system_metric_lines_now`)
    for line in system_metric_lines_now(paths, now_ms) {
        if !push_line(port, "系统指标", &line, log) {
            return;
        }
    }
    // 一路都成功就把上次的错误清掉,下次真坏了才会重新记一条
    remember_push_error(None);
}

pub(crate) struct AppState {
    pub(crate) paths: Paths,
    pub(crate) log: Log,
    pub(crate) settings: Settings,
    pub(crate) settings_path: PathBuf,

    pub(crate) weather_location: String,
    pub(crate) weather: pages::WeatherState,
    pub(crate) weather_rx: Option<Receiver<Result<weather::WeatherReport, String>>>,
    /// Harness 页里的本地大模型(llama.cpp / stable-diffusion.cpp / Qwen-Image)。
    /// 只存在于 Windows —— 它要靠子进程拉桌面版的可执行文件。
    #[cfg(windows)]
    pub(crate) local_model: local_model::LocalModel,

    /// 自定义背景图(对应 C# 设置里的 `BackgroundImage`)
    pub(crate) background: background::Background,
    /// 侧栏 logo(nfarms.ico)
    pub(crate) logo: logo::Logo,
    /// 「选择图片」对话框(后台线程 + channel,UI 不卡)
    pub(crate) picker: background::FilePicker,
    /// 设置页的草稿:改完点「保存」才写盘,和原版行为一致
    pub(crate) draft_location: String,
    /// 最近一次保存的时间戳(界面上给个「已保存」的反馈)
    pub(crate) last_saved: Option<String>,
    /// 内核事件列表 —— 插件页把它画出来。监听器会一直活着,
    /// 所以用 `Arc<Mutex>` 而不是直接塞在结构体里。
    pub(crate) event_log: Arc<Mutex<Vec<String>>>,
    /// 拓扑验证器(Topo 2FA 页)
    pub(crate) topo: TopoState,
    /// 环境工具检测(Environment 页):后台跑 8 种工具的版本检测
    pub(crate) tools: Job<Vec<nfarms_core::tools::ToolInfo>>,
    /// Environment 页:九大类全景探测(Qt/.NET/C/Rust/Python/JDK/Node/Git/Android)。
    ///
    /// 和上面 `tools` 分开是**刻意的**:`tools` 喂的是 `--versions` / `--npm-apps`
    /// 两条 oracle 验收项,往里加组件会直接打破验收;这一份是纯增量的。
    pub(crate) env_probe: Job<nfarms_core::envprobe::Report>,
    pub(crate) env_probe_auto: bool,
    /// 包管理器探测结果(winget/choco/scoop)—— 只探一次,不能每帧跑 `where`
    pub(crate) env_mgrs: Vec<nfarms_core::envprobe::PkgMgr>,
    pub(crate) env_mgrs_probed: bool,
    /// 选中的包管理器下标
    pub(crate) env_mgr: usize,
    /// 安装 / 更新动作的输出(后台跑,可能要几分钟)
    pub(crate) env_action: Job<String>,
    pub(crate) env_action_log: String,
    /// 环境变量区是否展开(43 条,默认收起)
    /// Environment 页:当前展开的可折叠分组。None = 全部收起。
    /// 九类各自成为一个分组后全展开会很长,默认全收起才扫得动。
    pub(crate) env_open: Option<&'static str>,
    /// 本次探测的起始时刻 —— 用来显示**真实已用时长**。
    ///
    /// 原来界面写死「约 15 秒」,实测要 45 秒。**一个错误的预期比没有预期更糟**:
    /// 用户会以为卡死了,然后去点「检测全部」,又开一次。
    pub(crate) env_started: Option<std::time::Instant>,
    pub(crate) env_msg: Option<String>,
    /// 「首次打开自动跑一遍」的标记。
    ///
    /// 原版 C# 的 Environment / Topo 页在**构造时**就做检测,所以用户点进去
    /// 已经有结果。这里保持同样的手感:第一次进页面自动发起,之后由用户
    /// 点按钮重跑 —— 不做成每帧都发起,那会把机器打满。
    pub(crate) topo_auto: bool,
    /// 零信任客户端状态(Channel 页)
    pub(crate) channel: Job<ChannelSnapshot>,
    /// 图片阅读器(Player 组)
    pub(crate) viewer: viewer::ImageViewer,
    /// ffmpeg(Tools 组;同时是视频播放器与拉流页的地基)
    pub(crate) ffmpeg: FfmpegState,
    /// Packet capture(VPN 组)
    pub(crate) capture: CaptureState,
    /// SecureCRT(VPN 组)
    pub(crate) remote: RemoteState,
    /// 音乐播放器 / 文本阅读器(Player 组)
    pub(crate) media: MediaState,
    /// 小 JSON 键值存储(书签等)
    pub(crate) store: nfarms_core::store::Store,
    /// Browser › 书签页的输入框
    pub(crate) bookmark_url: String,
    pub(crate) bookmark_title: String,
    /// 对时 / 引擎探测 / 脚本清单(Tools 与 Browser 组)
    pub(crate) probe: ProbeState,
    // 这里原来有个 `grafana_url: String`(Browser › grafana 的手填面板地址)。
    // Grafana 页重写后**地址一律由端口推导**(http://127.0.0.1:<grafana_port>/),
    // 手填地址和端口两个真值会打架 —— 所以字段删掉,不是留着不用。
    /// Environment › DSH-Starter
    pub(crate) dsh: DshState,
    /// 桌宠:**独立的 Win32 分层窗口**(见 `pet_window.rs`)。
    /// 不再是 egui 视口 —— 立即视口没有自己的 GL 表面,做不了真透明。
    #[cfg(windows)]
    pub(crate) pet: Option<crate::pet_window::PetWindow>,
    /// 上一次同步给桌宠的可见状态。只在**变化时**发指令,不每帧都发。
    #[cfg(windows)]
    pub(crate) pet_shown: bool,
    /// Tools › Recording-BiliGO(原来和 Player › 拉流共用同一个 bililive-go 实例;
    /// 拉流那一页在新菜单里删掉了)
    pub(crate) live_go: LiveState,
    /// Tools › Script / Player › 视频 / Setting 几页 / Slack off › Pet
    pub(crate) misc: MiscState,
    /// Setting › 日志路径 里的日志查看器(带分页)
    pub(crate) log_view: LogViewer,
}

impl AppState {
    // 参数多是有意的:这是**一次性构造**,把 9 个字段拆成 builder 或子结构
    // 只会让调用点更长、更难看出到底传了什么。真正该警惕的是"每次调用都在变"的
    // 长参数列表,这里不是。
    #[allow(clippy::too_many_arguments)]
    fn new(
        paths: Paths,
        log: Log,
        settings_path: PathBuf,
        settings: Settings,
        background: background::Background,
        logo: logo::Logo,
        event_log: Arc<Mutex<Vec<String>>>,
        store: nfarms_core::store::Store,
        #[cfg(windows)] local_model: local_model::LocalModel,
    ) -> Self {
        // **端口真值只有一个:`dsh.port`。** 初始值取自持久化的 `settings.dsh_port` ——
        // 原来写成 `DshState::default()`,于是 dsh 永远起在常量 7909 上,而
        // settings.json 里那个 `dsh_port` 只有「保存」会写、**没有任何代码读它去
        // 启动 dsh** —— 也就是说那个设置项从来没生效过。
        // 桌宠:**独立的 Win32 分层窗口**。真透明只有这条路 ——
        // 立即视口是渲染进父窗口那一帧的,没有自己的 GL 表面,
        // `with_transparent` 会被忽略(实测:只得到不透明黑块)。
        // 图缺失不会 panic,只是没有桌宠。
        #[cfg(windows)]
        let pet = Some(crate::pet_window::PetWindow::spawn(
            paths.lib("pet").join("whale-girl.png"),
        ));

        let saved_dsh_port = settings.dsh_port;
        // 端口记忆:把上次用的端口从 store.json 读回来(见 LocalModel::load_ports)
        #[cfg(windows)]
        let local_model = {
            let mut m = local_model;
            m.load_ports(&store);
            // 上下文长度与端口是同一类东西:不读回来的话服务端回到默认值,
            // 而 DSH 的 contextWindow 还留着上次的数 —— 两边错位。
            m.load_ctx(&store);
            // ---- 端口缺省值以 store 为准(9094 / 9095),不再写死 8080 / 1234 ----
            //
            // `load_ports` 只认"盘上有值",没值时留下的是 `LocalModel::new` 里
            // 那份构造初值(8080 / 1234)。于是"缺省端口"有两份定义,而 DSH 侧
            // 配 baseURL 走的是 store 那份 —— 两边不一致的症状是一句
            // connection refused,看不出是端口默认值在打架(详见 model_ports_from_store)。
            let (llama_port, sd_port) = model_ports_from_store(&store, &m.llama_port, &m.sd_port);
            // 只在**真的用上了记忆**时记一行 —— 判据是"盘上给过端口"
            // (见 model_ports_stored:拿值和存取器比是永远相等的,那样这行日志
            // 一次都不会打)。这行既是给用户的提示,也是端口记忆唯一可查的凭据。
            if model_ports_stored(&store) {
                log.write(
                    "gui",
                    &format!(
                        "端口已从 store.json 读回: llama.cpp={llama_port} sd.cpp={sd_port}"
                    ),
                );
            }
            m.llama_port = llama_port;
            m.sd_port = sd_port;
            if m.llama_ctx != nfarms_core::libs::DEFAULT_LLAMA_CTX.to_string() {
                log.write(
                    "gui",
                    &format!(
                        "llama.cpp 上下文长度已从 store.json 读回:-c {} —— \
                         DSH 侧 llama-cpp-local 的 contextWindow 必须是同一个数",
                        m.llama_ctx
                    ),
                );
            }
            m
        };

        // 观测服务的三件端口初值:从 store.json 读(缺省 9091 / 9092 / 9093)。
        // 必须在结构体字面量**之前**算好 —— `store` 会在字面量里被 move 进
        // `AppState`(同 `saved_dsh_port` 的理由)。
        let obs_ports = obs_ports_from_store(&store);

        Self {
            draft_location: settings.weather_location.clone(),
            weather_location: settings.weather_location.clone(),
            paths,
            log,
            settings,
            settings_path,
            weather: pages::WeatherState::Loading,
            weather_rx: None,
            #[cfg(windows)]
            local_model,
            background,
            logo,
            picker: background::FilePicker::default(),
            last_saved: None,
            event_log,
            topo: TopoState::new(),
            tools: Job::default(),
            env_probe: Job::default(),
            env_probe_auto: false,
            env_mgrs: Vec::new(),
            env_mgrs_probed: false,
            env_mgr: 0,
            env_action: Job::default(),
            env_action_log: String::new(),
            env_open: None,
            env_started: None,
            env_msg: None,
            topo_auto: false,
            channel: Job::default(),
            viewer: viewer::ImageViewer::default(),
            ffmpeg: FfmpegState::default(),
            capture: CaptureState::default(),
            remote: RemoteState::default(),
            media: MediaState::default(),
            store,
            bookmark_url: String::new(),
            bookmark_title: String::new(),
            probe: ProbeState::default(),
            // (grafana_url 已删 —— 地址由端口推导,见上面那段说明)
            // 端口真值取自持久化设置(见本函数开头 saved_dsh_port 的说明)
            #[cfg(windows)]
            pet,
            #[cfg(windows)]
            pet_shown: false,
            dsh: DshState {
                port: saved_dsh_port,
                ..Default::default()
            },
            live_go: LiveState::default(),
            // 观测服务的端口初值取自 store(见上面 `obs_ports` 那段说明);
            // 其余字段是页面自己的开关,和端口无关,用默认值即可
            misc: MiscState {
                obs_ports,
                ..MiscState::default()
            },
            log_view: LogViewer::default(),
        }
    }

    /// 轮询各页的后台任务,完成时写一条日志。
    pub(crate) fn poll_jobs(&mut self, ctx: &egui::Context) {
        if let Some(line) = self.topo.poll() {
            self.log.write("topo", &line);
        }
        if let Some(line) = self.viewer.poll(ctx) {
            self.log.write("viewer", &line);
        }
        // 阅读器里点「设为背景」:走和拖放同一条路径(异步解码 + 成功后写回设置)
        if let Some(viewer::ViewerAction::SetAsBackground(p)) = self.viewer.take_action() {
            self.apply_background(ctx, &p);
        }
        // ffmpeg 探测
        if self.ffmpeg.detect_job.poll() {
            let found = self.ffmpeg.detect_job.result().cloned();
            if let Some(ff) = found {
                self.log.write("ffmpeg", &ff.status_text());
                self.ffmpeg.detected = Some(ff);
            }
        }
        // 转码 / 截图 / 拉流
        if self.ffmpeg.work.poll()
            && let Some(msg) = self.ffmpeg.work.result().cloned()
        {
            self.log.write("ffmpeg", &msg);
            self.ffmpeg.message = Some(msg);
        }
        // 抓包网卡枚举
        if self.capture.iface_job.poll()
            && let Some(Ok(list)) = self.capture.iface_job.result()
        {
            self.log
                .write("capture", &format!("找到 {} 块网卡", list.len()));
        }
        // 两个远程工具的会话列表
        if self.remote.crt_sessions.poll()
            && let Some(list) = self.remote.crt_sessions.result()
        {
            self.log
                .write("securecrt", &format!("读到 {} 个会话", list.len()));
        }
        // 引擎探测
        if self.probe.engines.poll()
            && let Some(list) = self.probe.engines.result()
        {
            let ok = list.iter().filter(|e| e.available()).count();
            self.log
                .write("browser", &format!("探测到 {ok}/{} 个可用引擎", list.len()));
        }
        // dsh:进程没了就把状态清掉;顺手从日志里抓一次带 token 的根地址
        if let Some(c) = self.dsh.child.as_mut() {
            match c.try_wait() {
                Ok(Some(code)) => {
                    self.log.write("dsh", &format!("dsh 退出(代码 {code})"));
                    self.dsh.child = None;
                }
                _ => {
                    if self.dsh.url.is_none() {
                        let log = nfarms_core::dsh::log_path(&self.paths);
                        let text = nfarms_core::dsh::tail(&log, 80);
                        if let Some(u) = nfarms_core::dsh::parse_root_url(&text) {
                            self.log.write("dsh", &format!("根地址(带 token): {u}"));
                            self.dsh.url = Some(u);
                        }
                    }
                }
            }
        }
        // bililive-go:同上
        if let Some(c) = self.live_go.child.as_mut() {
            if let Ok(Some(code)) = c.try_wait() {
                self.log
                    .write("live", &format!("bililive-go 退出(代码 {code})"));
                self.live_go.child = None;
                self.live_go.status = None;
            }
        }
        if self.tools.poll() {
            let n = self
                .tools
                .result()
                .map(|v| v.iter().filter(|t| !t.installed.is_empty()).count())
                .unwrap_or(0);
            let total = self.tools.result().map(|v| v.len()).unwrap_or(0);
            self.log
                .write("tools", &format!("环境检测完成:{n}/{total} 个工具可用"));
        }
        if self.channel.poll() {
            let msg = match self.channel.result() {
                Some(s) => format!(
                    "客户端状态:路径={} 运行中={}",
                    if s.client_path.is_empty() { "(未找到)" } else { &s.client_path },
                    nfarms_core::csharp_bool(s.running)
                ),
                None => "客户端状态查询失败".to_string(),
            };
            self.log.write("ztna", &msg);
        }
        // 启动动图的抽帧(Setting › 启动动图)
        self.poll_boot_video(ctx);
    }

    // ------------------------------------------------------------ 启动动图

    /// 抽启动动画的视频帧(**后台 Job**),顺手把这次选择落盘。
    ///
    /// 用户选了 MP4 之后调它;`App::new` 里读回盘上存过的路径时也调一次。
    /// 抽帧是重活(起 ffmpeg 解一段视频),搁在界面线程上就是几秒的冻屏。
    ///
    /// **失败不在这里报** —— 结果由 [`Self::poll_boot_video`] 收,那时才拿得到
    /// ffmpeg 的 stderr,界面和日志写的也才是同一句话。
    ///
    /// 「选了 MP4」这件事**在这里顺手落盘**(见 [`Self::save_boot_switches`]):
    /// 抽帧这个动作本身就意味着这个路径被选中了,调用方(文件选择器那条路由 /
    /// 启动时从 store 读回)只要设好 `misc.boot_video` 再调本函数,就不必自己
    /// 再碰一次 store —— 少了"忘了存"这个失败模式,它的症状是
    /// 「选了视频、重启后回到内置动画」,而界面上看不出任何异常。
    pub(crate) fn boot_video_load(&mut self, ctx: &egui::Context) {
        let Some(src) = self.misc.boot_video.clone() else {
            self.misc.message = Some(format!("{BOOT_MSG_PREFIX} 没选视频,启动时用内置动画"));
            return;
        };
        self.save_boot_switches();
        if self.misc.boot_video_job.is_running() {
            // `Job::start` 在跑着的时候会直接返回。不说一句的话,用户会以为
            // 自己点的那次没反应(上一次的结果其实还在路上)。
            self.misc.message = Some(format!("{BOOT_MSG_PREFIX} 上一段视频还在抽帧,请稍等"));
            return;
        }
        // **旧帧先清掉**:留着旧的会让「选了但还没抽完」的判定失效 ——
        // 新视频的帧到位之前,界面放的是**上一个视频**的画面。
        self.misc.boot_frames.clear();
        self.misc.boot_frame_at.clear();
        // 记下"这个路径试过了":`render_boot_anim` 里那次自动补抽据此只做一次,
        // 否则文件坏了 / ffmpeg 没装的机器上每点一次预览就再起一个 ffmpeg。
        self.misc.boot_video_tried = Some(src.clone());
        self.misc.message = Some(format!("{BOOT_MSG_PREFIX} 正在抽取画面:{}", src.display()));
        let paths = self.paths.clone();
        let log = self.log.clone();
        self.misc.boot_video_job.start(ctx, move || extract_boot_frames(&paths, &src, &log));
    }

    /// 「用户选了一个 MP4(或取消了)」的入口:**记住 + 打开总开关 + 立刻落盘 + 后台抽帧**。
    ///
    /// 页面只要把路径交给它,不必自己碰 store —— 抽帧与持久化是一个动作的两半,
    /// 分开写迟早一边改了另一边没跟上,症状是「选了视频,重启后没记住」
    /// (而三个键的名字归本文件管,见 [`BOOT_VIDEO_KEY`])。
    ///
    /// **选了一个 MP4 就顺手把「启动时播放」顶成开**(判据见
    /// [`boot_enabled_after_pick`]):这个动作本身就表达了"启动时要播"。
    /// 原先两个开关各自为政,于是有「盘上存住了 `boot_video`、`boot_anim` 却没开」
    /// 这个状态:动画不播,用户以为设置没保存,而且**他无法从这个现象反推出原因**
    /// —— 界面上只是一个没勾的 checkbox,和每次启动白跑一遍的 ffmpeg。
    /// 顶成开后,`boot_video_load` 里的 [`Self::save_boot_switches`] 会把它一起写进盘。
    pub(crate) fn boot_video_set(&mut self, path: Option<PathBuf>, ctx: &egui::Context) {
        // 「这一个路径算不算选中」在 `path` 被 move 进字段之前先记下来。
        let picked = path.is_some();
        self.misc.boot_video = path;
        // 换了一个文件,"试过没有"要重新算:旧的那次抽帧和这次没关系。
        self.misc.boot_video_tried = None;
        // 选文件 → 开;清文件 → 保持用户原来的选择(不是关,理由见函数文档)。
        self.misc.boot_enabled = boot_enabled_after_pick(picked, self.misc.boot_enabled);
        if picked {
            // 落盘与抽帧都在 `boot_video_load` 里,这里不再写一遍 store。
            self.boot_video_load(ctx);
        } else {
            // 取消选择:清帧表 + 把键从 store 里删掉。**帧必须一起清** ——
            // 留着会造出「有画面、没路径」这种自相矛盾的状态,而播放判据
            // (路径在不在)与帧表一旦不同步,排查时看到的现象会互相打架。
            self.misc.boot_frames.clear();
            self.misc.boot_frame_at.clear();
            self.save_boot_switches();
            self.misc.message = Some(format!("{BOOT_MSG_PREFIX} 已改回内置动画"));
        }
    }

    /// 「启动动画播不播声音」的开关:**记住 + 立刻落盘**。
    ///
    /// 改了立刻存是有意的:这个开关只在"下次启动"那一刻起作用,用户改完顺手
    /// 关掉窗口是很自然的动作,不落盘就等于白改。
    pub(crate) fn boot_sound_set(&mut self, on: bool) {
        self.misc.boot_sound = on;
        self.save_boot_switches();
    }

    /// 把三个开关(`boot_video` / `boot_sound` / `boot_anim`)写回 store.json 并
    /// **立刻落盘**。
    ///
    /// 只改内存不写盘的话,用户改完设置直接关窗口就白改了 —— 而这几个开关
    /// 恰恰是"下次启动"才生效的东西,不落盘等于没做。
    /// 声音那一个**界面上勾一下也会立刻存**(`core_pages.rs` 的启动动画卡片),
    /// 这里再写一遍是为了让"路径一确定,三个键就都在盘上"这件事不依赖调用方。
    /// `boot_anim` 也写:选 MP4 时它会被 [`boot_enabled_after_pick`] 顶成开,
    /// 只改内存不落盘的话,"选了视频、重启又不播"这个原缺陷原样还在。
    ///
    /// **盘上已经是这三个值就一个字节都不写**:store 的约定是「读设置的动作
    /// 不该有副作用」(见 `store.rs` 里 `get_port` 那段),而这个函数在启动时
    /// 也会被调一次(盘上存过 MP4 且总开关开着就抽帧)—— 无条件 `save` 等于每次
    /// 启动都把用户手工编辑过的 store.json 重排一遍,他改过的注释/顺序全没了。
    fn save_boot_switches(&mut self) {
        let want_video = self
            .misc
            .boot_video
            .as_ref()
            .map(|p| p.to_string_lossy().into_owned());
        let want_sound = if self.misc.boot_sound { "1" } else { "0" };
        let stored_video = self.store.get_str(BOOT_VIDEO_KEY);
        let stored_sound = self.store.get_str(BOOT_SOUND_KEY);
        let stored_anim = self.store.get_str(BOOT_ANIM_KEY);
        // 两半都要看:视频/声音一样**不代表**总开关也一样(选 MP4 时变的正是它),
        // 和 `boot_switches_unchanged` 里 `&&` 的写法是一个道理。
        if boot_switches_unchanged(
            want_video.as_deref(),
            want_sound,
            stored_video.as_deref(),
            stored_sound.as_deref(),
        ) && boot_anim_unchanged(self.misc.boot_enabled, stored_anim.as_deref())
        {
            return;
        }
        match &want_video {
            Some(p) => self.store.set_str(BOOT_VIDEO_KEY, p),
            // 取消选择就**把键删掉**,而不是留一个空串:空串和"没设过"是同一个
            // 语义,盘上却多一条看着像有值的记录,用户手改时会被误导。
            None => self.store.remove(BOOT_VIDEO_KEY),
        }
        self.store.set_str(BOOT_SOUND_KEY, want_sound);
        self.store
            .set_str(BOOT_ANIM_KEY, if self.misc.boot_enabled { "1" } else { "0" });
        if let Err(e) = self.store.save() {
            // 存不上要说出来(盘满 / 只读),但不能拦着用户继续用 ——
            // 这次会话里的选择仍然是生效的。
            self.log
                .write("boot", &format!("启动动图设置没存上:{e}"));
            self.misc.message = Some(format!("{BOOT_MSG_PREFIX} 设置没存上:{e}"));
        }
    }

    /// 收抽帧任务的结果,**每帧调**(在 `poll_jobs` 里)。
    ///
    /// 纹理**必须在 UI 线程上传**:`Context::load_texture` 在 debug 构建里会拿
    /// 当前帧的 `max_texture_side` 做断言(背景图那条路踩过,见 `background.rs`
    /// 里「上限必须在 UI 线程上取好再进线程」那段),在后台线程上传就是拿别的帧
    /// 去比。所以 Job 只回解码后的图,这里才 `load_texture`。
    fn poll_boot_video(&mut self, ctx: &egui::Context) {
        if !self.misc.boot_video_job.poll() {
            return;
        }
        // `take_result`:一次抽帧是几十张解码后的图(几十 MB),
        // `result().cloned()` 会让内存在那一瞬间翻倍。
        let Some(r) = self.misc.boot_video_job.take_result() else {
            return;
        };
        match r {
            Ok(bf) => {
                // 成对写:帧表和偏移表**必须同长** —— 一边有一边空,
                // 播放时按偏移取帧就会越界(靠 `get` 兜住,表现为画面不换)。
                self.misc.boot_frames.clear();
                self.misc.boot_frame_at.clear();
                for (at, img) in bf.frames {
                    self.misc
                        .boot_frames
                        .push(ctx.load_texture(
                            format!("nfarms-boot-frame-{at}"),
                            img,
                            egui::TextureOptions::LINEAR,
                        ));
                    self.misc.boot_frame_at.push(at);
                }
                if self.misc.boot_frames.is_empty() {
                    let msg = format!("{BOOT_MSG_PREFIX} 没抽到任何画面,仍用内置动画");
                    self.log.write("boot", &msg);
                    self.misc.message = Some(msg);
                } else {
                    self.misc.message = Some(bf.note);
                }
            }
            Err(e) => {
                // **不回退成一句静默的内置动画**:抽帧失败要说清原因 ——
                // 文件损坏 / ffmpeg 缺失 / 路径没了,三种情况的下一步完全不同。
                self.misc.boot_frames.clear();
                self.misc.boot_frame_at.clear();
                let msg = format!("{BOOT_MSG_PREFIX} 抽帧失败,本次仍用内置动画:{e}");
                self.log.write("boot", &msg);
                self.misc.message = Some(msg);
            }
        }
    }

    /// 起启动动画的声音。
    ///
    /// 只在 `boot_sound` 为真**且选了 MP4** 时才起(内置动画没有音轨)。
    /// 走 `self.media.music`(MCI):设备别名固定是 `nfmusic`,所以播放这段声音
    /// 用的就是音乐页那台设备 —— 用户能在音乐页看到"正在播"、也能在那里停掉。
    ///
    /// **失败只记日志,绝不拦启动**(需求明写)。所以这里既不上抛也不弹提示:
    /// 用户要的是"启动给我看一段动画",没声音不该变成一个错误弹窗。
    /// 同步调用 MCI 的理由:它只读文件头,不是解码整段视频,而音乐页 / 拖放
    /// 播歌那条路本来就是同步调的(`poll_background_drop` 里那两处)。
    fn start_boot_audio(&mut self) {
        if !self.misc.boot_sound {
            return;
        }
        let Some(path) = self.misc.boot_video.clone() else {
            return;
        };
        // 音量口径与音乐页一致(`settings.music_volume * 10`,MCI 是 0..=1000);
        // 必须在 `open` **之前**设 —— 设备打开时会把当前音量下发下去。
        self.media.music.volume = (self.settings.music_volume.max(0) as u32 * 10).min(1000);
        // 两次调用分开写,不串成 `and_then(|()| …)`:`self.media.music` 的
        // 可变借用会跨过闭包,借用检查器不会放行。
        let mut played = self.media.music.open(&path);
        if played.is_ok() {
            played = self.media.music.play();
        }
        match played {
            Ok(()) => self
                .log
                .write("boot", &format!("启动动画声音已开始:{}", path.display())),
            Err(e) => self.log.write(
                "boot",
                &format!("启动动画声音播放失败({}):{e}", path.display()),
            ),
        }
    }

    /// 停掉启动动画的声音。没起过就什么都不做。
    ///
    /// 用 `close` 而不是 `stop`:启动动画的声音是一次性的,留着设备只会让音乐页
    /// 那边看起来"已经在播某一首"。**边界**:启动动画与音乐页共用同一个 MCI
    /// 别名,所以这 1~6 秒里用户要是恰好手动开了一首歌,那首歌会被这里一起关掉
    /// (MCI 只认别名,分不出是谁开的)。
    fn stop_boot_audio(&mut self) {
        if self.misc.boot_audio_for.take().is_none() {
            return;
        }
        self.media.music.close();
        self.log.write("boot", "启动动画声音已停");
    }

    // ---------------------------------------------------------- 观测服务

    /// 收后台交回来的子进程句柄(见 `MiscState::obs_child_rx`)。
    ///
    /// 单独一个函数是因为**两个地方都要收**:每帧的状态轮询,以及点击「启动」时
    /// (上一轮任务可能刚好在这一帧的轮询之后才结束)。漏收一次就是那个句柄被丢掉,
    /// 而 `Child` 被 drop **不会**杀进程 —— 留下一个谁也停不掉的孤儿进程,
    /// 端口还被它占着。
    fn drain_obs_child(&mut self) {
        let Some(rx) = self.misc.obs_child_rx.take() else {
            return;
        };
        loop {
            match rx.try_recv() {
                Ok((idx, child)) => {
                    if idx < OBS_SERVICES.len() {
                        self.misc.obs_proc[idx] = Some(child);
                    }
                }
                Err(TryRecvError::Empty) => {
                    // 还没送来:接收端放回去,下一帧再看
                    self.misc.obs_child_rx = Some(rx);
                    return;
                }
                // 发送端没了(任务已结束)而句柄没送出来 = 起进程那一步失败了。
                // 那件事由 `obs_job` 的结果负责报,这里把通道收掉就行。
                Err(TryRecvError::Disconnected) => return,
            }
        }
    }

    /// 观测服务的状态机,**每帧调**。
    ///
    /// 四件事:收后台交回来的句柄、收启动任务的结果、看已经起来的进程还在不在、
    /// 每 30 秒把 token 用量推给 VictoriaMetrics。
    ///
    /// **末尾一定要 `request_repaint_after`**:eframe 空闲时根本不调 `update()`,
    /// 不要下一帧的话,后台任务回来了界面也不会重画 —— 三个灯会一直停在"启动中",
    /// 直到用户碰一下鼠标(这个坑本项目踩过,同 `pet_click` 那段说明)。
    pub(crate) fn poll_observability(&mut self, ctx: &egui::Context) {
        self.drain_obs_child();

        // ---- 启动任务回来了 ----
        if self.misc.obs_job.poll() {
            match self.misc.obs_job.result().cloned() {
                Some(Ok(idx)) => {
                    if idx < OBS_SERVICES.len() {
                        self.misc.obs_state[idx] = 2;
                        self.misc.obs_msg = Some(format!("{} 已就绪", obs_name(OBS_SERVICES[idx])));
                    }
                }
                Some(Err(e)) => {
                    // 结果里只有错误文本、没有下标。**同一时刻只有一个启动任务**
                    // (`Job::start` 在 running 时直接返回),所以"状态还是 1 的那个"
                    // 就是它。已经被进程退出那条路置成 3 的话这里就不再动灯 ——
                    // 灯的含义以"进程没了"为准,那句话更接近根因。
                    if let Some(i) = self.misc.obs_state.iter().position(|s| *s == 1) {
                        self.misc.obs_state[i] = 3;
                    }
                    self.misc.obs_msg = Some(e);
                }
                None => {}
            }
            // 界面和日志写**同一句话**:用户报问题时对着屏幕念的那一句,
            // 在日志里能原样搜到
            if let Some(msg) = self.misc.obs_msg.clone() {
                self.log.write("observe", &msg);
            }
        }

        // ---- 已经起来的进程还在不在 ----
        //
        // 三件都是常驻进程:它一退,健康检查必然不通,而灯还亮着"已就绪"就是骗人 ——
        // 用户会对着一个已经死掉的服务查为什么没有数据。
        for (i, svc) in OBS_SERVICES.iter().enumerate() {
            // 先在**不碰 self 的地方**把话算好再落状态:一边借着 `obs_proc[i]`
            // 一边改 `obs_state[i]`,借用检查器不会放行
            let mut gone: Option<String> = None;
            if let Some(child) = self.misc.obs_proc[i].as_mut() {
                match child.try_wait() {
                    // 还在跑,正常
                    Ok(None) => {}
                    Ok(Some(code)) => {
                        gone = Some(format!(
                            "{} 退出了(退出码 {code})",
                            obs_name(*svc)
                        ));
                    }
                    Err(e) => {
                        gone = Some(format!(
                            "{} 的进程状态查不出来:{e}",
                            obs_name(*svc)
                        ));
                    }
                }
            }
            if let Some(msg) = gone {
                self.misc.obs_proc[i] = None;
                // **根进程自己没了,Job 可能还兜着插件后端。**
                // 关掉这一件才真的收干净 —— 实测:只杀 grafana.exe 而不关 Job,
                // gpx 10 秒内一直是 13;补上这行 2 秒内归零。
                observability::close_job(*svc);
                self.misc.obs_state[i] = 3;
                self.log.write("observe", &msg);
                self.misc.obs_msg = Some(msg);
            }
        }

        // ---- token 用量 + 系统指标:每 30 秒一次,只在指标库就绪时 ----
        //
        // 判间隔用的是"上次推送的时刻"这个 u64 毫秒值(存在 `obs_pushed_ms` 里),
        // 不是"距上次过了多久":后者要先取一个瞬时时刻再相减,跨帧存不下来。
        //
        // 系统指标走同一条推送、同一个节流:它由**应用自己**推的理由见
        // `system_metric_lines` 上面那段注释。
        if self.misc.obs_state[0] == 2 {
            let now = nfarms_core::netclock::now_ms().max(0) as u64;
            if obs_push_due(now, self.misc.obs_pushed_ms) {
                // **先记时刻、再推**:推送在后台线程上,等下一帧进到这里时
                // 早就过了 30 秒,不会因为线程还没跑完而重复起线程
                self.misc.obs_pushed_ms = now;
                let port = obs_ports_u16(&self.misc.obs_ports)[0];
                let (prompt, completion) = (self.misc.tok_prompt, self.misc.tok_completion);
                let log = self.log.clone();
                // `Paths` 只是几个路径,克隆一份带走;采集**必须在后台线程里做**
                // (`cyinfo::collect` 要 1.4 秒),所以传进去而不是在这里先采好
                let paths = self.paths.clone();
                // 后台线程:`push_metric` 是一次真的 HTTP POST(core 里给了 10 秒
                // 超时),而且**一行一次请求**(两条 token + 最多四条系统指标)——
                // 搁在 UI 线程上窗口最多卡 60 秒,本文件头一条原则就是 UI 线程不做等待。
                std::thread::spawn(move || {
                    push_metrics(port, &paths, prompt, completion, now, &log);
                });
            }
        }

        // ---- 该醒着的时候别睡 ----
        //
        // 有进程在跑、或还有句柄/任务在路上,就每 200 毫秒回来一次。
        // eframe 空闲不重绘,不请求下一帧的话状态灯永远不变。
        let busy = self.misc.obs_job.is_running()
            || self.misc.obs_child_rx.is_some()
            || self.misc.obs_proc.iter().any(|p| p.is_some());
        if busy {
            ctx.request_repaint_after(Duration::from_millis(200));
        }
    }

    /// 起第 `idx` 件观测服务(0=VictoriaMetrics / 1=Loki / 2=Grafana)。
    ///
    /// 四步(**补配置 → 生成 Grafana 数据源 → 起进程 → 等就绪**)全在 `obs_job`
    /// 里跑。等就绪是阻塞的,而且 Loki 实测 17 秒(预算 36 秒):搁在 UI 线程上
    /// 就是整个窗口冻住几十秒,用户只会以为程序死了。
    ///
    /// 状态灯由 [`Self::poll_observability`] 维护 —— 这里只把这一件置成
    /// 1(启动中),任务回来之后那边再置 2 或 3,页面不用自己判任何东西。
    pub(crate) fn obs_start(&mut self, idx: usize, ctx: &egui::Context) {
        if idx >= OBS_SERVICES.len() {
            // 页面只会传 0..3。传别的说明是接线错了:不 panic,但要留下痕迹
            self.misc.obs_msg = Some(format!("观测服务的下标 {idx} 不合法(只有 0/1/2)"));
            return;
        }
        // 上一轮的句柄可能还压在路上,先收掉(理由见 `drain_obs_child`)
        self.drain_obs_child();
        if self.misc.obs_proc[idx].is_some() {
            self.misc.obs_msg = Some(format!("{} 已经在运行", obs_name(OBS_SERVICES[idx])));
            return;
        }
        if self.misc.obs_job.is_running() {
            // **必须拦**:`Job::start` 在 running 时是直接返回的,不拦的话
            // 用户点了「启动」却什么都不发生,连个说法都没有
            self.misc.obs_msg = Some("另一件服务还在启动中,等它出结果再起这一件".to_string());
            return;
        }

        let libs = self.paths.libs();
        let data = self.paths.root().join("data");
        let ports = obs_ports_u16(&self.misc.obs_ports);
        let Some(sp) = observability::spec(OBS_SERVICES[idx], &libs, &data, ports[idx]) else {
            // exe 不在是**常态**(没装三件套的机器),不是异常:这一页照样打开,
            // 只是把灯置成失败,并给一句能照着做的话
            let absent = observability::missing(&libs).join("、");
            let msg = format!("还没装:{absent}(跑一下 libs\\scripts\\fetch-libs.ps1)");
            self.misc.obs_state[idx] = 3;
            self.log.write("observe", &msg);
            self.misc.obs_msg = Some(msg);
            return;
        };
        let (name, port) = (sp.name, sp.port);
        self.misc.obs_state[idx] = 1;
        self.misc.obs_msg = Some(format!("{name} 启动中…(端口 {port})"));
        self.log.write(
            "observe",
            &format!("启动 {name}:端口 {port},先补配置再等就绪"),
        );

        // 句柄要从后台交回 UI 线程存进 `obs_proc`(页面上要能停它),
        // 所以这里开一条只送一件的通道
        let (tx, rx) = std::sync::mpsc::channel();
        self.misc.obs_child_rx = Some(rx);
        let (vm_port, loki_port, grafana_port) = (ports[0], ports[1], ports[2]);
        // 闭包是 `move`,后台线程里拿不到 `self.log` —— 先拷一份。
        // 下面两件**失败要记日志**,不能静默(那正是这个项目反复修的那类问题)。
        let log_job = self.log.clone();

        self.misc.obs_job.start(ctx, move || {
            // 缺配置才补(已存在的一律不覆盖:用户手改过的路径、密码要留住)
            observability::ensure_configs(&data, loki_port, grafana_port)?;
            // Grafana 的数据源文件里写着 VM / Loki 的端口 —— 必须和上面
            // `ensure_configs` 用的是同一组端口,否则就是"健康检查打在 A、
            // 数据源指着 B",症状是 Grafana 起来了但图表全空
            observability::provision_datasources(&data, vm_port, loki_port)?;
            // ---- 仪表盘 + 品牌 logo ----
            //
            // **必须在 `ensure_configs` 之后**:`install_branding` 在 `custom.ini`
            // 不存在时**报错而不是自己造一份** —— 造的那份缺 `[server] http_port`,
            // Grafana 会退回默认 3000,而健康检查还打在 9093,
            // 症状是「永远起不来」,而且看不出是配置缺了一段。
            //
            // **失败不挡启动**(所以是 `if let Err` 而不是 `?`):
            // 服务本身起得来,比"面板装上了"重要 —— 面板是附属品。
            // 对比 `provision_datasources` 用的是 `?`:数据源没了,
            // Grafana 起来也是个空壳,那才叫"起了等于没起"。
            if let Err(e) = observability::provision_dashboards(&data) {
                log_job.write("observe", &format!("仪表盘安装失败(不影响服务启动): {e}"));
            }
            if let Err(e) = observability::install_branding(&libs, &data) {
                log_job.write("observe", &format!("品牌 logo 安装失败(不影响服务启动): {e}"));
            }
            let child = observability::start(&sp)?;
            // 挂 Job 失败(本进程已在禁止嵌套的 Job 里,CI 很常见)**不挡启动**,
            // 但要把话说出来 —— 静默降级正是那 91 个孤儿当初没人发现的原因。
            if let Some(w) = observability::take_job_warning() {
                log_job.write("observe", &format!("{w}(不影响启动)"));
            }
            // 句柄**立刻**交回去:后面等就绪要几十秒,这段时间里用户也该能停掉它
            let _ = tx.send((idx, child));
            observability::wait_ready(&sp)?;
            Ok(idx)
        });
    }

    /// 停掉第 `idx` 件观测服务。
    ///
    /// **不用开 Job**:kill + wait 打的是本机子进程,几毫秒就回来 ——
    /// 要等几十秒的是 `wait_ready`,不是这个。状态回到 0(未启动)而不是 3(失败):
    /// 是用户自己停的,不是它坏了,页面据此把灯熄掉而不是变红。
    #[allow(dead_code)] // 页面(plugins/core_pages.rs)的「停止」按钮调它
    pub(crate) fn obs_stop(&mut self, idx: usize) {
        if idx >= OBS_SERVICES.len() {
            return;
        }
        let name = obs_name(OBS_SERVICES[idx]);
        let Some(mut child) = self.misc.obs_proc[idx].take() else {
            self.misc.obs_msg = Some(format!("{name} 本来就没在运行"));
            return;
        };
        // 停之前先关这一件的 Job:内核连带结束它派生的**所有**子孙进程。
        // `child.kill()` 只打直接子进程 —— Grafana 那 13 个 gpx_* 插件后端
        // 原来就是这么变成孤儿、还占着内存的(实测累计查到 91 个)。
        observability::close_job(OBS_SERVICES[idx]);
        let _ = child.kill();
        // **必须 wait**:不等的话进程表里会留一条记录(Windows 上是句柄泄漏),
        // 端口也可能还占着 —— 下一次启动同一件就会报"地址已被占用"
        let code = match child.wait() {
            Ok(st) => st.to_string(),
            Err(e) => format!("查不到退出状态: {e}"),
        };
        self.misc.obs_state[idx] = 0;
        self.misc.obs_msg = Some(format!("{name} 已停止({code})"));
        self.log.write("observe", &format!("{name} 已停止({code})"));
    }

    /// 起一个后台线程去联网,结果用 channel 回传。
    pub(crate) fn start_weather_fetch(&mut self, ctx: &egui::Context) {
        let loc = self.weather_location.trim().to_string();
        let loc = if loc.is_empty() {
            weather::DEFAULT_LOCATION.to_string()
        } else {
            loc
        };

        let (tx, rx) = std::sync::mpsc::channel();
        self.weather_rx = Some(rx);
        self.weather = pages::WeatherState::Loading;
        self.log.write("weather", &format!("开始获取天气: {loc}"));

        let ctx = ctx.clone();
        std::thread::spawn(move || {
            let r = weather::fetch(&loc);
            let _ = tx.send(r);
            // 唤醒 UI —— 后台线程算完不叫一声,界面要等下一次输入才刷新
            ctx.request_repaint();
        });
    }

    fn poll_weather(&mut self) {
        let Some(rx) = self.weather_rx.take() else {
            return;
        };
        match rx.try_recv() {
            Ok(Ok(report)) => {
                self.log.write(
                    "weather",
                    &format!(
                        "获取成功: {} {}C {}",
                        report.now.city, report.now.temp, report.now.desc
                    ),
                );
                self.weather = pages::WeatherState::Ready(Box::new(report));
            }
            Ok(Err(err)) => {
                self.log.write("weather", &format!("获取失败: {err}"));
                self.weather = pages::WeatherState::Failed(err);
            }
            // 还没算完,把接收端放回去继续等
            Err(TryRecvError::Empty) => self.weather_rx = Some(rx),
            Err(TryRecvError::Disconnected) => {
                self.weather = pages::WeatherState::Failed("后台线程异常退出".to_string());
            }
        }
    }

    pub(crate) fn save_settings(&self) {
        if let Err(e) = self.settings.save(&self.settings_path) {
            self.log.write("settings", &format!("保存失败: {e}"));
        }
    }

    /// 换背景图:后台载入,载完自动存盘(原版也是选完即生效)。
    fn apply_background(&mut self, ctx: &egui::Context, path: &Path) {
        self.background.load_async(ctx, path);
        self.log
            .write("gui", &format!("背景图载入中: {}", path.display()));
    }

    /// 轮询后台解码,完成了就记日志 —— **正面证据**:
    /// 只写「没失败」的话,日志里看不出到底加载没加载。
    fn poll_background(&mut self, ctx: &egui::Context) {
        match self.background.poll(ctx) {
            Some(background::LoadOutcome::Ok { width, height }) => {
                let path = self
                    .background
                    .path
                    .as_ref()
                    .map(|p| p.display().to_string())
                    .unwrap_or_default();
                self.log
                    .write("gui", &format!("背景图已载入: {path} ({width}×{height})"));
                // 从这里进来的(启动时按设置载入 / 拖放)都顺手写回设置。
                // 注意只在成功时写 —— 载不进来的路径存下去,下次启动还是白等一场。
                if self.settings.background_image != path {
                    self.settings.background_image = path;
                    self.save_settings();
                }
            }
            Some(background::LoadOutcome::Err(e)) => {
                self.log.write("gui", &format!("背景图载入失败: {e}"));
            }
            None => {}
        }
        if self.background.is_loading() {
            ctx.request_repaint_after(Duration::from_millis(120));
        }
    }

    /// 轮询「选择图片」对话框的结果。
    ///
    /// **拖放进来的文件不在这里处理** —— 拖进来的图片在图片阅读器页应该是
    /// 「打开这张」,在别的页面才是「当窗口背景」。那个判断要看当前页,
    /// 所以放在 `App::update` 里(见 `handle_dropped_files`)。
    fn poll_background_drop(&mut self, ctx: &egui::Context) {
        if let Some((purpose, path)) = self.picker.poll() {
            // 工具栏上只有一个文件对话框 —— 选出来的东西给谁用,靠 purpose 路由。
            // 不路由的话,选一首音乐会被当成新的窗口背景图。
            match purpose {
                background::PickPurpose::Background => self.apply_background(ctx, &path),
                background::PickPurpose::Music => {
                    if let Some(dir) = path.parent() {
                        self.media.scan_playlist(dir);
                    }
                    match self.media.music.open(&path) {
                        Ok(()) => {
                            let _ = self.media.music.play();
                            self.media.message = Some(format!("正在播放 {}", path.display()));
                            self.log.write("music", &format!("播放 {}", path.display()));
                        }
                        Err(e) => self.media.message = Some(e),
                    }
                }
                background::PickPurpose::Video => {
                    // **原来这里只弹了一句提示,路径被直接丢掉了** ——
                    // 提示还写着"源已记录",而实际什么都没记;它说的那个
                    // 「打开」按钮也不存在(视频页上只有「载入」)。
                    //
                    // 症状:选了文件之后毫无反应,**连日志都没有** ——
                    // 因为压根没走到任何会写日志的地方。
                    self.misc.video_src = path.display().to_string();
                    // 交给视频页去载入(那边才有 ffmpeg 句柄和状态),
                    // 并且**载入完直接播**(选了文件就是要看)。
                    self.misc.video_want_load = true;
                    self.log.write("video", &format!("选择 {}", path.display()));
                }
                // 图片阅读器:在阅读器里打开,**不要**动背景图
                background::PickPurpose::Viewer => {
                    self.viewer.load_async(ctx, &path);
                }
                // ffmpeg 页的「浏览…」:填进源路径输入框
                background::PickPurpose::FfmpegSrc => {
                    self.ffmpeg.src = path.display().to_string();
                }
                // 启动动画的「使用自定义 MP4…」:交给 `boot_video_set` ——
                // 「记住路径 + 落盘 + 后台抽帧」在那边是一个动作,这里拆开写就会
                // 缺一半:只记路径不抽帧的话,用户点完没画面(得等下次启动);
                // 只抽帧不落盘的话,重启就回到内置动画。两种拆法都是
                // "看着能用、其实一半没接"。
                // **别借 `Video` 那个用途顶上**:它会把选出来的 MP4 甩给视频播放器;
                // 而 `background.rs` 的注释里记着的那次事故(用途和路由对不上 →
                // 整窗背景图被换掉)就是这么发生的。
                background::PickPurpose::BootVideo => {
                    self.boot_video_set(Some(path), ctx);
                }
            }
        }
        if self.picker.is_pending() {
            ctx.request_repaint_after(Duration::from_millis(200));
        }

        // 音乐:轮询播放状态,放完自动下一首
        if self.media.music.is_opened() {
            let before = self.media.music.state;
            let now = self.media.music.poll_state();
            if before == nfarms_core::music::PlayState::Playing
                && now == nfarms_core::music::PlayState::Stopped
            {
                ctx.request_repaint();
            }
        }
    }
}

// ===========================================================================
// 壳
// ===========================================================================

struct App {
    state: AppState,
    /// 插件内核
    kernel: Kernel,
    /// 页面插件登记表 —— 侧栏与内容区都只认它
    pages: plugins::PageRegistry,
    page: Page,
    /// 侧栏手风琴:当前展开的**容器**(一级目录或二级子分组)。
    ///
    /// 三级结构下不能再只记一个 `Option<Group>`:一级目录里嵌着子分组,
    /// 展开「Player」和展开「Player › Music」是两件独立的事,而且要能同时开着。
    /// 用 `HashSet` 而不是一个 `Option`:默认会把当前页所在的那两级一起展开,
    /// 用户手动折起来的下次也不该自己弹开(`goto` 只在**切页**时改它)。
    open_containers: std::collections::HashSet<NavKey>,
    /// 侧栏当前顺序(用户拖过就用拖的,否则是功能树默认序)。
    ///
    /// 存 `Vec<Page>` 而不是每次去查 store:侧栏每帧都要按它排,查一次
    /// 反序列化一次太亏。真值在 `store.json` 的 `nav_order`(存的是 id)。
    nav_ordered: Vec<Page>,
    /// [`nav_ordered`](Self::nav_ordered) 的倒排:页面 → 它在其中的下标。
    ///
    /// 侧栏每帧、每个容器都要按它排序,现算一遍是 O(n) 一轮 **每容器一次**;
    /// 顺序只在切页/拖拽提交时变,所以在这里缓存一份,跟着一起更新。
    /// 它和 `nav_ordered` 必须同时更新 —— 只有 [`App::reindex_nav`] 一处改两者。
    rank: std::collections::HashMap<Page, usize>,
    /// 拖拽进行态。`Some` 表示正在拖某一页。
    nav_drag: Option<NavDrag>,
    /// 已经弹出成独立窗口的页面。
    ///
    /// 用 `Vec` 不用 `HashSet`:顺序稳定(弹出的顺序),而且数量个位数,
    /// 线性查找比哈希快也更好调试。
    #[allow(dead_code)] // 恒为空 —— 弹窗功能已移除
    popped: Vec<Page>,
}

impl App {
    // 同上:一次性构造,拆开只会更难读
    #[allow(clippy::too_many_arguments)]
    fn new(
        cc: &eframe::CreationContext<'_>,
        paths: Paths,
        log: Log,
        settings_path: PathBuf,
        initial_page: Option<Page>,
        open_image: Option<String>,
        _popout: bool,
        logo_image: egui::ColorImage,
    ) -> Self {
        // 中文字体:egui 自带字体**没有 CJK**,不装就是一片方块。
        install_cjk_font(&cc.egui_ctx);
        install_style(&cc.egui_ctx);

        let settings = nfarms_core::settings::load(&paths, &settings_path);
        #[cfg(windows)]
        let local_model = local_model::LocalModel::new(&paths);
        let background =
            background::Background::from_setting(&cc.egui_ctx, &settings.background_image);

        // ---- 插件内核 ----
        let kernel = Kernel::new();
        let event_log: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
        {
            // 内核事件同时落到日志文件和内存列表里:
            // 文件给事后排查,列表给插件页当场看。
            let sink = event_log.clone();
            let file_log = log.clone();
            kernel.context().on(move |ev| {
                let line = format_event(ev);
                file_log.write("plugin", &line);
                if let Ok(mut v) = sink.lock() {
                    v.push(line);
                    // 只留最近 200 条,别让这个列表无限长
                    if v.len() > 200 {
                        v.remove(0);
                    }
                }
            });
        }

        // 桌面装整棵树;安卓只装骨架(天气 / 背景图片 / 插件)
        let page_plugins = if cfg!(target_os = "android") {
            plugins::mobile()
        } else {
            plugins::desktop()
        };
        let pages = plugins::install(&kernel, page_plugins);
        if let Err(e) = kernel.ready() {
            log.write("plugin", &format!("插件装载未完成: {e}"));
        }
        log.write(
            "plugin",
            &format!(
                "插件内核就绪:{} 个插件,{} 张页面卡片,{} 个服务",
                kernel.len(),
                pages.len(),
                kernel.context().visible_service_names().len()
            ),
        );

        // `--page X` 指定的页面在**当前平台**没装载时退回第一页 ——
        // 比如安卓骨架里没有 topo,硬开就会是一片空白,不如明确退回去。
        // `--open-image` 优先于 `--page`:双击图片打开时,用户要的是看图。
        let requested = if open_image.is_some() {
            Page::ImageViewer
        } else {
            initial_page.unwrap_or(Page::TodayInfo)
        };
        let page = if pages.contains(requested) {
            requested
        } else {
            let fallback = pages.first().unwrap_or(Page::TodayInfo);
            if initial_page.is_some() {
                log.write(
                    "gui",
                    &format!(
                        "本平台没有装载页面 {} —— 退回 {}",
                        requested.id(),
                        fallback.id()
                    ),
                );
            }
            fallback
        };

        // store.json 的位置要从 `paths` 算,而 `paths` 马上就要被 move 进 AppState ——
        // 所以先算好再传进去。
        let store = nfarms_core::store::Store::load(&nfarms_core::store::Store::default_path(&paths));
        // 几个开关是持久的:启动动图、桌宠、上报。**默认全关** ——
        // 一个程序不该一启动就挡你 1.4 秒,也不该往桌面上扔东西。
        // store 马上要被 move 进 AppState,所以先读出来。
        let boot_enabled = boot_anim_from_store(store.get_str(BOOT_ANIM_KEY).as_deref());
        // 自定义启动动画的两个开关也在 store.json 里(键 `boot_video` / `boot_sound`),
        // **不进 settings.json** —— 那个文件有跟 C# 原版逐字节还原的护栏测试。
        // 读坏的值(不是字符串 / 空串 / 说不清的开关)一律回落默认,不许 panic:
        // 这段跑在启动路径上,一次 panic 就是窗口起不来。
        let boot_video = boot_video_from_store(store.get_str(BOOT_VIDEO_KEY).as_deref());
        let boot_sound = boot_sound_from_store(store.get_str(BOOT_SOUND_KEY).as_deref());
        let pet_visible = store.get_str("pet_visible").as_deref() == Some("1");
        let sync_enabled = store.get_str("sync_enabled").as_deref() == Some("1");
        // 侧栏顺序也得在这里读 —— `store` 马上就要被 move 进 AppState,
        // 而 `pages` 会在结构体字面量里被 move,两边都赶不上趟。
        let saved_nav_order = store.nav_order();

        let mut state = AppState::new(
            paths,
            log,
            settings_path,
            settings,
            background,
            logo::Logo::new(&cc.egui_ctx, logo_image),
            event_log,
            store,
            #[cfg(windows)]
            local_model,
        );

        state.misc.boot_enabled = boot_enabled;
        state.misc.boot_video = boot_video;
        state.misc.boot_sound = boot_sound;
        state.misc.pet_visible = pet_visible;
        state.misc.sync_enabled = sync_enabled;
        if boot_enabled {
            // 启动动图一开场就播(而不是等用户切到 Setting 那一页)
            state.misc.boot_started_ms = Some(nfarms_core::netclock::now_ms());
        }
        // 盘上存过 MP4 **且总开关开着**就**现在**把抽帧发到后台:抽帧要几秒,而这次
        // 启动动画只有 1~6 秒 —— 不先起跑的话,存过视频的机器每次启动都只会看到
        // 内置动画,用户会以为"我的设置没生效"。(抽得快时本次动画的后半段就能切到
        // MP4;抽不完也没关系,`render_boot_anim` 会照旧放内置动画。)
        // 代价是一次受限的 ffmpeg:`-frames:v` 封顶,只解前 6 秒。
        //
        // **判据为什么多了前半截 `boot_enabled`**:总开关关着的时候,
        // `render_boot_anim` 根本不会去放这段动画(它连遮罩都不起),抽出来的帧
        // 一次也用不上 —— 那就是为一段永远不会播的动画白起一次 ffmpeg,几秒钟的
        // CPU 和磁盘读,而用户看到的只是"启动卡了一下"。这个状态是真实存在的:
        // 手改 store.json(`boot_video` 留着、`boot_anim` 是 `"0"`),或者先勾了
        // 又取消勾(`core_pages.rs` 的卡片就是允许这么点的)。那种情况下卡片上
        // 有一条醒目警告说明,而不是靠这里偷偷抽帧。
        if state.misc.boot_enabled && state.misc.boot_video.is_some() {
            state.boot_video_load(&cc.egui_ctx);
        }

        // 把「开在哪一页」写进日志 —— 否则 `--page X` 的冒烟测试拿不到任何凭据
        // (进程活着只能说明窗口起来了,不能说明开对了页)
        state
            .log
            .write("gui", &format!("界面启动,首页={}", page.id()));
        if state.settings.background_image.trim().is_empty() {
            state.log.write("gui", "背景图: 未设置,使用默认纯色底");
        }

        // `--popout`:启动就把首页弹成独立窗口。
        // 想一开机就全屏数字雨,建个快捷方式写 `--page matrix-rain --popout` 即可。
        //
        // 必须在结构体字面量**之前**算出来:`pages` 在字面量里会被 move 进去,
        // 之后再 `pages.contains(...)` 就是「借用已移动的值」。
       // 侧栏顺序:存过就套用,没存过 = 功能树默认序。
       // `apply_order` 两条容错缺一不可(失效 id 忽略、新页追加到末尾),见 pages.rs。
       let nav_ordered: Vec<Page> = {
           let all: Vec<Page> = pages::Page::all().collect();
           pages::apply_order(&all, &saved_nav_order)
       };

       let mut app = Self {
            open_containers: NavKey::of_page(page).into_iter().collect(),
            state,
            kernel,
            pages,
            rank: rank_of(&nav_ordered),
            nav_ordered,
            nav_drag: None,

            page,
            // 「弹出独立窗口」功能已按用户要求**移除**:这里恒为空,
            // 于是 `render_popouts` 每帧一进来就 early-return,弹窗再也不会出现。
            //
            // 之所以留着字段和方法而不是连根删掉:`render_pet_window`(桌宠)
            // 用的是**同一套**立即视口机制,先把弹窗这条路封死、确认桌宠不受影响,
            // 再清死代码比一次性动两处稳。
            popped: Vec::new(),
        };
        // 首屏就把天气取上(后台线程,UI 不等待)
        app.state.start_weather_fetch(&cc.egui_ctx);
        // `--open-image`:直接开在图片阅读器上
        if let Some(p) = open_image {
            let p = PathBuf::from(p);
            app.state.viewer.load_async(&cc.egui_ctx, &p);
            app.state
                .log
                .write("viewer", &format!("命令行打开图片: {}", p.display()));
        }
        app
    }

    /// 切页:把当前页所在的两级容器都展开、记日志、发事件。
    fn goto(&mut self, page: Page) {
        if self.page == page {
            return;
        }
        self.page = page;
        self.open_containers.extend(NavKey::of_page(page));
        self.state
            .log
            .write("gui", &format!("切到页面: {}", page.id()));
        self.kernel.context().emit(&Event::PageOpened {
            id: page.id().to_string(),
        });
    }
}

/// 内核事件 → 一行给人看的文字。
fn format_event(ev: &Event) -> String {
    match ev {
        Event::PluginLoaded { name } => format!("插件已装载  {name}"),
        Event::PluginFailed { name, error } => format!("插件出错    {name}: {error}"),
        Event::PluginDisposed { name } => format!("插件已卸载  {name}"),
        Event::ServiceProvided { name } => format!("提供服务    {name}"),
        Event::PageOpened { id } => format!("切到页面    {id}"),
        Event::SettingsSaved { path } => format!("设置已保存  {path}"),
        Event::Log { source, message } => format!("[{source}] {message}"),
    }
}

impl eframe::App for App {
    fn clear_color(&self, visuals: &egui::Visuals) -> [f32; 4] {
        // 这里踩过两次坑,记清楚:
        //
        // **第一次**:无条件返回不透明的 APP_BG —— 结果桌宠那个视口也被涂成
        // 不透明的,一只本该透明的宠物变成桌面上一个黑方块。
        //
        // **第二次**:于是改成无条件清零 —— 结果**主窗口也变透明了**,
        // 整窗内容被合成到白底上,用户看到的是「白屏」。
        //
        // 正确的结论在第 2 条:`clear_color` 是**整帧一次**的,不按视口分。
        // 试过用 `visuals.panel_fill` 当标记(桌宠视口 `set_visuals` 成全透明)、
        // **不生效**。所以桌宠不做真透明,而是无边框 + 置顶 + 不上任务栏的小窗,
        // 底色与主窗口一致。要真透明得让它有自己的 GL 上下文与 clear color,
        // 那不是立即视口能做到的 —— 页面上如实写明这一点。
        // `visuals` 在本版本里**区分不出视口**(传进来的永远是主窗口那份),
        // 所以这里不按视口分支 —— 见上面那段说明。
        let _ = visuals;
        // **清零行不通 —— 已实测。**
        //
        // 前两次失败的原因是:**主窗口的根面板没有填充**,它的底色一直是靠
        // 这个 clear_color 提供的。所以一清零,主窗口就"白屏"了。
        //
        // 这次的组合是三条一起改:
        //   1. 这里清零;
        //   2. 桌宠视口加 `with_transparent(true)` —— 它的帧缓冲才有 alpha 通道;
        //   3. **主窗口根面板给显式不透明填充**(见下面 CentralPanel 那处)。
        // 主窗口没有 with_transparent,alpha 会被忽略,但它自己的面板盖满了整窗,
        // 所以看上去和以前一样。
        // 立即视口**没有自己的 GL 表面**,`with_transparent(true)` 会被忽略,
        // 清零只会让桌宠窗口变成**不透明黑块**(实测:主窗口里桌宠那块平均亮度
        // 69.7,周边 192.8)。要真透明得换掉 `show_viewport_immediate`。
        let c = theme::APP_BG;
        [
            c.r() as f32 / 255.0,
            c.g() as f32 / 255.0,
            c.b() as f32 / 255.0,
            1.0,
        ]    }

    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
        self.state.poll_weather();
        if self.state.weather_rx.is_some() {
            // 等待期间保持刷新(不阻塞,只是让界面别僵住)
            ctx.request_repaint_after(Duration::from_millis(150));
        }
        self.state.poll_background(ctx);
        self.state.poll_background_drop(ctx);
        self.handle_dropped_files(ctx);
        self.state.poll_jobs(ctx);
        // 观测服务(VictoriaMetrics / Loki / Grafana)的状态机:每帧都要跑。
        // 它自己会在有进程/有任务时 request_repaint_after —— 不请求的话
        // eframe 空闲不重绘,那三个状态灯只在用户碰鼠标时才变。
        self.state.poll_observability(ctx);
        if self.state.topo.running
            || self.state.tools.is_running()
            || self.state.channel.is_running()
            || self.state.viewer.is_loading()
            || self.state.ffmpeg.detect_job.is_running()
            || self.state.ffmpeg.work.is_running()
        {
            ctx.request_repaint_after(Duration::from_millis(200));
        }
        handle_window_resize(ctx);

        // 整窗底色:有自定义背景图就贴图(cover),没有就纯色。
        // 面板用半透明白盖上去 = 雾玻璃。
        let screen = ctx.screen_rect();
        let painter = ctx.layer_painter(egui::LayerId::background());
        if self.state.background.is_set() {
            self.state.background.paint(&painter, screen);
        } else {
            painter.rect_filled(screen, 0.0, theme::APP_BG);
        }

        self.sidebar(ctx);
        self.content(ctx);
        // **桌宠可见时要定时醒一下。**
        //
        // eframe 只在需要时重绘 —— 程序空闲时 `update()` 根本不会被调用。
        // 而桌宠跑在**独立线程**上,它点一下不会唤醒主窗口的重绘,
        // 于是 `take_click()` 一次都轮询不到(实测:点击被正确识别、
        // 标志也置位了,但界面永远看不到)。
        //
        // 100ms 一次足够跟手,代价可以忽略(只是跑一遍 update,不重绘画面)。
        #[cfg(windows)]
        if self.state.misc.pet_visible {
            ctx.request_repaint_after(Duration::from_millis(100));
        }

        // **桌宠被点了一下** → 开关对话框。
        //
        // 点击的识别在 `pet_window.rs` 里(那边分得清"点一下"和"拖一下"),
        // 这里只负责取走这个标志。
        #[cfg(windows)]
        if pet_window::take_click() {
            self.state.misc.chat_open = !self.state.misc.chat_open;
            self.state.log.write(
                "pet",
                if self.state.misc.chat_open {
                    "桌宠被点击:打开对话框"
                } else {
                    "桌宠被点击:关闭对话框"
                },
            );
        }
        // **桌宠被右键收起了** → 把「显示桌宠」的勾一起去掉。
        //
        // 右键的动作发生在桌宠那个线程的 wndproc 里,它够不着这里的
        // `misc.pet_visible`。不对齐的话界面会显示"开着"而桌面上什么都没有,
        // 而且更坏:用户取消勾选时 `want=false == pet_shown=false`,那句
        // `pet.hide()` 因为 `want != pet_shown` 不成立而**根本不会发**,
        // 再勾回来时才补发一次 `show()` —— 桌宠要"关一下再开一下"才回得来。
        //
        // 这里只改内存里的值,不写 store:右键收起是"这次先收起来",不是
        // "以后都不要了";用户真去点那个勾时(core_pages 里)才会落盘。
        #[cfg(windows)]
        if pet_window::dismiss() && self.state.misc.pet_visible {
            self.state.misc.pet_visible = false;
            self.state.log.write("pet", "桌宠:被右键收起");
        }
        #[cfg(windows)]
        self.render_chat_window(ctx);

        // 桌宠:**原生分层窗口**,不是 egui 视口。这里只负责在可见性
        // 变化时同步一次 —— 每帧都发指令会让 channel 白白堆积。
        #[cfg(windows)]
        {
            let want = self.state.misc.pet_visible;
            if want != self.state.pet_shown {
                self.state.pet_shown = want;
                if let Some(pet) = &self.state.pet {
                    if want {
                        pet.show();
                    } else {
                        pet.hide();
                    }
                }
                self.state.log.write(
                    "pet",
                    if want {
                        "桌宠:显示(原生分层窗口)"
                    } else {
                        "桌宠:隐藏"
                    },
                );
            }
        }
        self.render_boot_anim(ctx);
    }
}

impl App {

    /// 启动动图:在主窗口上叠一层全屏遮罩,放完自动淡出。
    ///
    /// **不另开窗口** —— 另开窗口会出现「两个窗口先后冒出来」的抖动,
    /// 比不做还难看。
    ///
    /// 两个来源:用户选的 **MP4**(按帧的偏移逐张放)与内置遮罩
    /// (logo 淡入 + 扫描线 + 进度条)。判据见 [`boot_use_video`] ——
    /// 选了但还没抽完 → **回退内置**,而不是"坚持放视频然后黑屏"。
    fn render_boot_anim(&mut self, ctx: &egui::Context) {
        let Some(t0) = self.state.misc.boot_started_ms else {
            // 没有动画在播。顺手把声音收掉:「重置」按钮就是把 `boot_started_ms`
            // 清成 None,不在这里收尾的话那段 MP4 的声音会一直放下去
            // (MCI 的设备不跟着纹理走)。
            self.state.stop_boot_audio();
            return;
        };

        let use_video = boot_use_video(
            self.state.misc.boot_video.as_deref(),
            &self.state.misc.boot_frame_at,
        );
        // ---- 选了视频但一帧都没有:补抽一次 ----
        //
        // 启动那一刻抽帧通常还没跑完,于是这一轮放的是内置动画(需求要的就是
        // 这个回退)。这里再补一次是为了"点「立即预览」时能真的看到 MP4":
        // 页面上的预览按钮只挪 `boot_started_ms`,它不知道帧抽好没有。
        // `boot_video_tried` 保证同一个路径只补一次 —— 失败(文件坏了、
        // ffmpeg 没装)的那次不该每点一次预览就再起一个 ffmpeg。
        if !use_video
            && let Some(p) = self.state.misc.boot_video.clone()
            && self.state.misc.boot_video_tried.as_ref() != Some(&p)
            && !self.state.misc.boot_video_job.is_running()
        {
            self.state.boot_video_load(ctx);
        }

        let total = if use_video {
            boot_video_total_ms(&self.state.misc.boot_frame_at)
        } else {
            BUILTIN_BOOT_TOTAL_MS
        };
        // 时钟被往回调时 `now - t0` 是负数,后面 `as u64` 会绕成一个天文数字 →
        // 动画在一瞬间被判成"演完了"。夹到 0 起步。
        let elapsed = (nfarms_core::netclock::now_ms() - t0).max(0);
        if elapsed >= total {
            self.state.misc.boot_started_ms = None;
            self.state.stop_boot_audio();
            return;
        }
        // ---- 声音:这一段动画还没起过就起一次 ----
        //
        // 判据是「这一段动画的起点」而不是"设备开着没有":启动自动播和
        // 「立即预览」都会把 `boot_started_ms` 换成一个新的时刻,
        // 换一次就该重起一次声音。
        if self.state.misc.boot_audio_for != Some(t0) {
            self.state.misc.boot_audio_for = Some(t0);
            self.state.start_boot_audio();
        }

        let screen = ctx.screen_rect();
        let painter = ctx.layer_painter(egui::LayerId::new(
            egui::Order::Foreground,
            egui::Id::new("nfarms-boot"),
        ));
        let alpha = boot_fade_alpha(elapsed, total);

        // ---- 来源一:MP4 的帧 ----
        if use_video {
            let idx = boot_frame_index_at(elapsed as u64, &self.state.misc.boot_frame_at);
            // `get` 的兜底不能省:帧表和偏移表理论上同长(`poll_boot_video` 是
            // 成对填的),真错位时 `[]` 会直接 panic 掉整个界面;这里返回 None
            // 就落到下面的内置遮罩,最坏也只是难看一帧。
            if let Some(tex) = idx.and_then(|i| self.state.misc.boot_frames.get(i)) {
                // 等比放大到**刚好盖住整窗**(= CSS 的 `background-size: cover`),
                // 而不是把屏幕矩形当目标硬拉:视频的比例和窗口的比例几乎不会相同,
                // 硬拉会把人像 / logo 拉扁,而启动动画恰恰是"第一眼"。
                // (公式与 `background.rs::cover_rect` 一致 —— 那个函数是私有的,
                //  跨模块调不到,只能照抄这一行;它自己有单元测试钉着。)
                let ts = tex.size_vec2();
                let scale =
                    (screen.width() / ts.x.max(1.0)).max(screen.height() / ts.y.max(1.0));
                painter.image(
                    tex.id(),
                    egui::Rect::from_center_size(screen.center(), ts * scale),
                    egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
                    // 只调透明度,不改颜色 —— 和内置动画共用同一个淡出曲线
                    egui::Color32::from_white_alpha((255.0 * alpha) as u8),
                );
                ctx.request_repaint();
                return;
            }
        }

        // ---- 来源二:内置遮罩 ----
        // (`total` 在 MP4 那一支里是视频时长;走到这里说明没有可用帧,
        //  此时 `use_video` 为假、`total` 就是 1400,和原来一模一样。)
        let progress = (elapsed as f32 / (total - BOOT_FADE_MS).max(1) as f32).clamp(0.0, 1.0);
        painter.rect_filled(
            screen,
            0.0,
            egui::Color32::from_rgba_unmultiplied(6, 10, 9, (245.0 * alpha) as u8),
        );

        // 扫描线:每 14 px 一条,整体随进度下移 —— 廉价但很像「正在启动」
        let offset = progress * 28.0;
        let mut y = screen.top() - 28.0 + offset;
        while y < screen.bottom() {
            painter.rect_filled(
                egui::Rect::from_min_size(
                    egui::pos2(screen.left(), y),
                    egui::vec2(screen.width(), 2.0),
                ),
                0.0,
                egui::Color32::from_rgba_unmultiplied(90, 255, 160, (18.0 * alpha) as u8),
            );
            y += 14.0;
        }

        let cx = screen.center().x;
        let cy = screen.center().y;
        // logo 字:淡入 + 轻微放大
        let scale = 0.92 + 0.08 * progress;
        painter.text(
            egui::pos2(cx, cy - 18.0),
            egui::Align2::CENTER_CENTER,
            "NFarms HUB",
            egui::FontId::proportional(42.0 * scale),
            egui::Color32::from_rgba_unmultiplied(235, 255, 245, (255.0 * alpha) as u8),
        );
        painter.text(
            egui::pos2(cx, cy + 22.0),
            egui::Align2::CENTER_CENTER,
            "正在启动…",
            egui::FontId::proportional(13.0),
            egui::Color32::from_rgba_unmultiplied(150, 200, 175, (220.0 * alpha) as u8),
        );

        // 进度条
        let w = (screen.width() * 0.22).clamp(160.0, 380.0);
        let bar = egui::Rect::from_center_size(
            egui::pos2(cx, cy + 60.0),
            egui::vec2(w, 3.0),
        );
        painter.rect_filled(
            bar,
            2.0,
            egui::Color32::from_rgba_unmultiplied(255, 255, 255, (40.0 * alpha) as u8),
        );
        painter.rect_filled(
            egui::Rect::from_min_size(bar.min, egui::vec2(w * progress, 3.0)),
            2.0,
            egui::Color32::from_rgba_unmultiplied(60, 200, 130, (255.0 * alpha) as u8),
        );

        ctx.request_repaint();
    }
}

/// 侧栏里一个**可折叠容器**的键。
///
/// 三级结构下侧栏有两层能折叠的东西:一级目录(Player)与二级子分组(Player › Music)。
/// 「展开谁」这件事原来只用一个 `Option<Group>` 记,现在要同时记得住两层,
/// 所以换成集合 + 这个键。
///
/// 为什么不直接把 [`pages::NavScope`] 拿来用:那是**拖拽容器**的口径,
/// 它的 `Group(g)` 排除了子分组里的页;而折叠口径里一级目录是**自己一个键**,
/// 和它下面的子分组并列。两者只差一点,但混用会让「展开 Player 却没展开 Music」
/// 这种状态无法表达。
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
enum NavKey {
    Group(Group),
    Sub(pages::Sub),
}

impl NavKey {
    /// 当前页所在的那些容器(一级 + 二级),由外到内。
    ///
    /// 切页时把它们全部展开 —— 否则「跳过去的那一页」在侧栏里看不见,
    /// 用户只会觉得程序跳空了。
    fn of_page(page: Page) -> Vec<NavKey> {
        let mut v = vec![NavKey::Group(page.group())];
        if let Some(s) = page.sub() {
            v.push(NavKey::Sub(s));
        }
        v
    }
}

/// 侧栏拖拽的进行态。
///
/// 只记「谁、在哪个容器里、插到第几个」—— 目标位置每帧按指针 y 现算,
/// 不缓存行矩形(窗口大小/滚动位置都会变)。
struct NavDrag {
    page: Page,
    scope: pages::NavScope,
    /// 容器内插入下标(0..=容器内页数)
    to: usize,
}

/// 画主窗口的底色。
///
/// 现在主窗口靠 `clear_color` 拿不透明底色,这个函数**已经不需要了** ——
/// 留着是因为万一以后又要在主窗口叠自己的底(比如动态渐变背景),
/// 记住「`update` 只对主视口跑」这件事很关键。
#[allow(dead_code)]
fn paint_app_background(ctx: &egui::Context) {
    let screen = ctx.screen_rect();
    ctx.layer_painter(egui::LayerId::new(
        egui::Order::Background,
        egui::Id::new("nfarms-app-bg"),
    ))
    .rect_filled(screen, 0.0, theme::APP_BG);
}



impl App {
    /// 桌宠的**对话框**。点桌宠开关。
    ///
    /// 用 `show_viewport_immediate` 而不是 deferred:前者**同步**执行闭包,
    /// 可以直接借用 `self` 上的状态(和 `render_popouts` 同一个理由)。
    /// 这里的窗口**不需要真透明**,所以立即视口够用 —— 只有桌宠本体才必须
    /// 走 Win32 分层窗口。
    #[cfg(windows)]
    fn render_chat_window(&mut self, ctx: &egui::Context) {
        use nfarms_core::chat::{self, Msg};

        let vid = egui::ViewportId::from_hash_of(("nfarms-pet-chat", 0u8));
        if !self.state.misc.chat_open {
            // **必须显式关窗。**
            //
            // 只是"这一帧不再画它"是不够的:egui 不会因为你不渲染就销毁视口,
            // 窗口会**带着最后一帧的内容留在屏幕上**。表现出来就是
            // 「再点一次桌宠,对话框关不掉」—— 而代码看上去完全正确。
            if self.state.misc.chat_was_open {
                self.state.misc.chat_was_open = false;
                ctx.send_viewport_cmd_to(vid, egui::ViewportCommand::Close);
                self.state.log.write("pet", "对话框已关闭");
            }
            return;
        }
        self.state.misc.chat_was_open = true;
        // ---- 让这个视口窗口半透明(桌面能透出来)----
        //
        // **第一帧必然失败**:视口刚提交,窗口还没建出来,`FindWindowW`
        // 找不到 —— 所以这里每帧重试,成功一次就置位不再试。
        //
        // 细节和取舍见 `pet_window::make_translucent` 的文档注释
        // (为什么是"整窗半透明"而不是"背景透明")。
        #[cfg(windows)]
        if !self.state.misc.chat_translucent
            && pet_window::make_translucent("小鲸", pet_window::CHAT_ALPHA)
        {
            self.state.misc.chat_translucent = true;
            self.state.log.write("pet", "对话框已设为半透明");
        }
        // 先收上一轮的结果。`Job::poll` 必须**每帧**调,否则结果永远停在通道里。
        if self.state.misc.chat_job.poll()
            && let Some(r) = self.state.misc.chat_job.result().cloned()
        {
            match r {
                Ok(rep) => {
                    // **用量累计起来** —— Tokens 页显示的就是它
                    self.state.misc.tok_prompt += rep.prompt_tokens as u64;
                    self.state.misc.tok_completion += rep.completion_tokens as u64;
                    self.state.misc.tok_calls += 1;
                    self.state.misc.chat_msgs.push((false, rep.text));
                    self.state.misc.chat_err = None;
                }
                Err(e) => {
                    self.state.misc.chat_err = Some(e);
                    self.state.log.write("pet", "对话失败");
                }
            }
        }
        // 出图完成 → 读回 PNG 上纹理
        if self.state.misc.chat_draw_job.poll()
            && let Some(r) = self.state.misc.chat_draw_job.result().cloned()
        {
            match r {
                Ok(p) => {
                    self.state.log.write("pet", &format!("出图完成: {}", p.display()));
                    match image::open(&p) {
                        Ok(img) => {
                            let rgba = img.to_rgba8();
                            let size = [rgba.width() as usize, rgba.height() as usize];
                            let ci = egui::ColorImage::from_rgba_unmultiplied(size, rgba.as_raw());
                            self.state.misc.chat_img =
                                Some(ctx.load_texture("nfarms-chat-draw", ci, egui::TextureOptions::LINEAR));
                            self.state.misc.chat_img_path = Some(p.clone());
                            self.state.misc.chat_msgs.push((false, "画好了。".to_string()));
                            self.state.misc.chat_err = None;
                        }
                        Err(e) => {
                            self.state.misc.chat_err = Some(format!("图读不回来: {e}"));
                        }
                    }
                }
                Err(e) => {
                    self.state.misc.chat_err = Some(e);
                    self.state.log.write("pet", "出图失败");
                }
            }
        }

        let App { state, .. } = self;
        // 和上面那个 `vid` **必须是同一个 hash** —— 不一致的话关闭命令
        // 会发给一个不存在的视口,而 `send_viewport_cmd_to` 不会报错。
        let id = vid;
        let builder = egui::ViewportBuilder::default()
            .with_title("小鲸")
            .with_inner_size([380.0, 470.0])
            .with_min_inner_size([280.0, 300.0])
            // **去掉系统标题栏。** 它由 DWM 画成白色,和窗口的 `theme::APP_BG`
            // 对不上 —— 高度、字体、配色都不受程序控制,而一条突兀的白条
            // 比"没有标题栏"难看。
            //
            // 代价是拖动和关闭得自己实现(见下面自绘的那一条)——
            // **不做的话窗口既挪不动也关不掉**。
            .with_decorations(false)
            // ---- 真透明:**试过了,不行**(留个记录,免得以后重试) ----
            //
            // 按 egui 文档配齐了两件事:`with_transparent(true)`
            // **加上**面板 frame 也用半透明填充(文档明说 CentralPanel
            // 会把底填掉,必须配合)。
            //
            // **实测结果:背景像素恒定。** 把窗口放在主窗口背景图上,
            // 和放在桌面壁纸上,右边缘同一相对位置都读出
            // `R199 G204 B201` —— 一模一样。背景不随背后内容变化,
            // 就不是真透明。
            //
            // 另外反推出来 `clear_color` 变成了**黑色**:alpha 218 的填充
            // 混出 `(199,204,201)`,比 `APP_BG(233,239,235)` 更深。
            // 也就是说 egui **认了**透明意图,但窗口最终仍按不透明合成。
            //
            // 和早先桌宠窗口的实测一致(那次是像素亮度 69.7 vs 192.8)。
            // **结论:egui 的视口在这个平台上做不了真透明。**
            // 真要做得走 Win32 分层窗口(桌宠那条路),而那意味着
            // 对话界面不能在 egui 里画 —— 是重写,不是改配置。
            // 和桌宠一样置顶:对话时视线不该被别的窗口挡
            .with_window_level(egui::WindowLevel::AlwaysOnTop);

        let mut close_requested = false;
        let mut send = false;
        ctx.show_viewport_immediate(id, builder, |vctx, _class| {
            if vctx.input(|i| i.viewport().close_requested()) {
                close_requested = true;
            }
            egui::CentralPanel::default()
                // **必须有不透明填充。** 立即视口自己没有背景,`Frame::NONE`
                // 会让下半截露出 GL 清屏色(实测:窗口下面一大片黑,
                // 只有画了内容的上半截是正常的)。
                // **必须是不透明填充。** 试过半透明(alpha 218)配合
                // `with_transparent`,结果透明没生效、底色反而和黑混合
                // 变深了(199,204,201 vs APP_BG 的 233,239,235)—— 是退步。
                // 见上面那条实验记录。
                .frame(
                    egui::Frame::NONE
                        .fill(theme::APP_BG)
                        .inner_margin(egui::Margin::same(12)),
                )
                .show(vctx, |ui| {
                    // ---- 自绘标题条 ----
                    //
                    // 拖动手柄**只覆盖标题文字那一块**,不覆盖整行 ——
                    // 覆盖整行的话 `ui.interact` 会把右上角那个关闭按钮的
                    // 点击也一起吃掉(两个可交互区域重叠时谁赢不好保证)。
                    // ---- 拖动条:**不画任何东西,只留一块能抓的区域** ----
                    //
                    // 原来这里有个「小鲸」加一个 ✕ 按钮。✕(U+2715)**不在
                    // egui 自带字体里**,渲染出来是个方框 —— 一个看起来像
                    // 最大化按钮的东西,点下去却是关闭,那比没有按钮更糟。
                    //
                    // 标题文字也去掉:窗口自己就叫「小鲸」,标题栏里再写一遍
                    // 是重复。剩下的这块**空区域仍然可以拖动** ——
                    // 去掉系统装饰之后,不自己留一块的话窗口就挪不动了。
                    //
                    // 关闭走「再点一次桌宠」(那条路径已经修好了)。
                    let full = ui.available_rect_before_wrap();
                    let grip = egui::Rect::from_min_size(
                        full.min,
                        egui::vec2(full.width(), 20.0),
                    );
                    let dragged = ui.interact(
                        grip,
                        egui::Id::new("chat-title-grip"),
                        egui::Sense::drag(),
                    );
                    if dragged.drag_started() {
                        vctx.send_viewport_cmd(egui::ViewportCommand::StartDrag);
                    }
                    if dragged.hovered() {
                        ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
                    }
                    ui.advance_cursor_after_rect(grip);
                    let busy = state.misc.chat_job.is_running();
                    egui::ScrollArea::vertical()
                        // `max(60)` 是保险:窗口被拖得很矮时
                        // `available_height() - 52` 会变成负数,
                        // 而 ScrollArea 拿到负高度会算出一堆 NaN 尺寸。
                        .max_height((ui.available_height() - 52.0).max(60.0))
                        .stick_to_bottom(true)
                        .show(ui, |ui| {
                            if state.misc.chat_msgs.is_empty() {
                                ui.label(
                                    RichText::new("Tips:不联网,隐私都留在本地。想聊什么放心聊!")
                                        .size(12.0)
                                        .color(theme::TEXT_SUB),
                                );
                            }
                            for (is_user, text) in &state.misc.chat_msgs {
                                let (who, col) = if *is_user {
                                    ("我", theme::ACCENT)
                                } else {
                                    ("小鲸", theme::TEXT_SUB)
                                };
                                ui.label(RichText::new(who).size(10.5).color(col));
                                ui.label(RichText::new(text.as_str()).size(12.5).color(theme::TEXT_MAIN));
                                ui.add_space(6.0);
                            }
                            if busy {
                                ui.label(
                                    RichText::new("小鲸正在想…").size(11.5).color(theme::TEXT_SUB),
                                );
                            }
                        });
                    // 错误显示在**输入框上方**,不进对话历史 ——
                    // 进了历史下一轮会被当成"小鲸说过的话"再发回模型
                    if let Some(e) = &state.misc.chat_err {
                        ui.label(
                            RichText::new(e.as_str())
                                .size(11.0)
                                .color(egui::Color32::from_rgb(214, 92, 92)),
                        );
                    }
                    ui.horizontal(|ui| {
                        ui.checkbox(
                            &mut state.misc.chat_draw,
                            RichText::new("画图").size(12.0),
                        )
                        .on_hover_text(
                            "勾上就把这句话交给 stable-diffusion.cpp 出图,而不是交给对话模型。\
两者是不同的后端,所以**不能同时做**。出图很慢(实测一次约 2.5 分钟)。",
                        );
                        if state.misc.chat_draw {
                            // 分辨率**必须显式给**:sd-cli 的 -W/-H 默认 512。
                            // 画完再放大只是插值,细节不会多出来。
                            const PRESETS: [(u32, u32); 5] = [
                                (512, 512),
                                (768, 768),
                                (1024, 1024),
                                (768, 1024),
                                (1024, 768),
                            ];
                            egui::ComboBox::from_id_salt("chat-draw-res")
                                .selected_text(format!(
                                    "{}×{}",
                                    state.misc.chat_draw_w, state.misc.chat_draw_h
                                ))
                                .width(96.0)
                                .show_ui(ui, |ui| {
                                    for (w, h) in PRESETS {
                                        let sel = state.misc.chat_draw_w == w
                                            && state.misc.chat_draw_h == h;
                                        if ui.selectable_label(sel, format!("{w}×{h}")).clicked() {
                                            state.misc.chat_draw_w = w;
                                            state.misc.chat_draw_h = h;
                                        }
                                    }
                                });
                        }
                        if state.misc.chat_draw_job.is_running() {
                            ui.spinner();
                            ui.label(
                                RichText::new("出图中,约 2~3 分钟…")
                                    .size(11.0)
                                    .color(theme::TEXT_SUB),
                            );
                        }
                    });
                    ui.horizontal(|ui| {
                        let w = (ui.available_width() - 68.0).max(80.0);
                        let r = ui.add_sized(
                            [w, 28.0],
                            egui::TextEdit::singleline(&mut state.misc.chat_input)
                                .hint_text("说点什么…"),
                        );
                        // 回车也能发 —— 但只在输入框有焦点时,
                        // 不然在别的控件上按回车也会发出去
                        let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
                        let can = !state.misc.chat_input.trim().is_empty() && !busy;
                        if ui.add_enabled(can, egui::Button::new("发送")).clicked() || (enter && can)
                        {
                            send = true;
                        }
                    });
                    // 画出来的图贴在下面,按宽度缩到不超框
                    if let Some(t) = &state.misc.chat_img {
                        let ts = t.size_vec2();
                        let k = (ui.available_width() / ts.x).min(1.0);
                        ui.add(egui::Image::new(egui::load::SizedTexture::new(
                            t.id(),
                            ts * k,
                        )));
                        if let Some(p) = &state.misc.chat_img_path {
                            ui.horizontal(|ui| {
                                if ui.button("打开所在文件夹").clicked() {
                                    if let Some(d) = p.parent() {
                                        nfarms_core::ffmpeg::open_dir(d);
                                    }
                                }
                                ui.label(
                                    RichText::new(
                                        p.file_name()
                                            .map(|x| x.to_string_lossy().to_string())
                                            .unwrap_or_default(),
                                    )
                                    .size(10.5)
                                    .color(theme::TEXT_SUB),
                                );
                            });
                        }
                    }
                });
        });

        if send {
            let text = state.misc.chat_input.trim().to_string();
            state.misc.chat_input.clear();
            state.misc.chat_msgs.push((true, text));
            state.misc.chat_err = None;
            // 刚推进去的那条就是这次的提示词(上面已经把 `text` move 掉了)
            let prompt = state
                .misc
                .chat_msgs
                .last()
                .map(|(_, t)| t.clone())
                .unwrap_or_default();
            // 带上全部历史:桌宠的对话短,不需要裁剪
            let mut msgs = vec![Msg::system(chat::PERSONA)];
            // ---- 本地知识库 ----
            //
            // 拿 `wiki\` 当资料:按**这次的提问**检索,命中就附上片段。
            //
            // 检索不到时**什么都不加**,而不是报错 —— 没有 wiki 目录的机器上
            // 对话照常能用。每段都带文件名,模型可以说清"按 wiki 里的记录",
            // 而不是把内容当成自己的记忆讲出来(出处对排查很重要)。
            let docs = state
                .misc
                .kb_docs
                .get_or_insert_with(|| nfarms_core::kb::load(&state.paths.wiki()));
            let kb = nfarms_core::kb::context_from(docs, &prompt);
            if !kb.is_empty() {
                state
                    .log
                    .write("pet", &format!("已附上 wiki 知识库({} 字符)", kb.chars().count()));
                msgs.push(Msg::system(kb));
            }
            for (is_user, t) in &state.misc.chat_msgs {
                msgs.push(if *is_user {
                    Msg::user(t.clone())
                } else {
                    Msg::assistant(t.clone())
                });
            }
            if state.misc.chat_draw {
                // **画图走 sd-cli,不走对话模型。** 两者根本不是一个后端:
                // 对话是 llama-server 的 HTTP;画图是一次性的 sd-cli 进程
                // (4 GB 扩散 + 4.8 GB text encoder,实测一次 **148 秒**)。
                let dir = state.paths.bak();
                let _ = std::fs::create_dir_all(&dir);
                let out = dir.join(format!("draw-{}.png", crate::now_stamp()));
                let model_dir = state.paths.lib("model");
                let sd = state
                    .paths
                    .libs()
                    .join("stable-diffusion.cpp")
                    .join("sd-cli.exe");
                let (dw, dh) = (state.misc.chat_draw_w, state.misc.chat_draw_h);
                state.misc.chat_draw_job.start(ctx, move || {
                    chat::draw(&prompt, &out, &model_dir, &sd, dw, dh).map(|()| out)
                });
            }
            else {
                // 端口用**唯一真值**:Setting › Proxy and Port 里的那个
                let port = state.local_model.llama_port.trim().to_string();
                state.misc.chat_job.start(ctx, move || {
                    let base = format!("http://127.0.0.1:{port}/v1");
                    chat::ask(
                        &base,
                        nfarms_core::libs::LLAMA_API_ALIAS,
                        &msgs,
                        chat::DEFAULT_MAX_TOKENS,
                    )
                });
            }
        }
        if close_requested {
            state.misc.chat_open = false;
        }
    }

    /// 把已弹出的页面画到各自的独立窗口里。
    ///
    /// 用的是 `show_viewport_immediate` 而不是 `_deferred`:前者**同步**执行闭包,
    /// 于是可以直接借用 `self` 上的状态;后者要求闭包 `'static + Send + Sync`,
    /// 那样就得把 `AppState` 整个搬进 `Arc<Mutex<…>>` —— 为了一个「弹出窗口」
    /// 把整个应用状态改成加锁的,不划算。
    ///
    /// 代价:立即视口必须在**同一帧内**画完,所以页面里的 `Job` 轮询仍然由
    /// 主窗口那套驱动(弹出窗口只是多一个渲染出口,不另起一套状态机)。
    #[allow(dead_code)] // 弹窗功能已移除,方法留着给桌宠复用视口机制做参考
    fn render_popouts(&mut self, ctx: &egui::Context) {
        if self.popped.is_empty() {
            return;
        }
        // 拆开借用:闭包里要 `&mut state`、`&kernel`、`&pages`,
        // 循环之外还要改 `popped` —— 不拆的话借用检查过不去。
        let App {
            state,
            kernel,
            pages,
            popped,
            ..
        } = self;

        let mut to_close: Vec<Page> = Vec::new();
        for page in popped.iter().copied() {
            let info = page.info();
            let id = egui::ViewportId::from_hash_of(("nfarms-popout", page.id()));
            let builder = egui::ViewportBuilder::default()
                .with_title(format!("NFarms HUB — {}", info.title))
                .with_inner_size([980.0, 720.0])
                .with_min_inner_size([420.0, 320.0]);

            let mut close_requested = false;
            ctx.show_viewport_immediate(id, builder, |vctx, _class| {
                if vctx.input(|i| i.viewport().close_requested()) {
                    close_requested = true;
                }
                egui::CentralPanel::default()
                    .frame(egui::Frame::NONE.inner_margin(egui::Margin::same(16)))
                    .show(vctx, |ui| {
                        // 弹出窗口没有侧栏,所以它得自己把「我是哪一页」写清楚
                        ui.horizontal(|ui| {
                            let (rect, _) = ui
                                .allocate_exact_size(egui::vec2(22.0, 22.0), egui::Sense::hover());
                            icons::draw(ui.painter(), rect, info.icon, theme::ACCENT);
                            ui.label(
                                RichText::new(info.title)
                                    .size(19.0)
                                    .color(theme::TEXT_MAIN),
                            );
                            ui.label(
                                RichText::new(info.group.nav())
                                    .size(11.5)
                                    .color(theme::TEXT_SUB),
                            );
                        });
                        ui.add_space(2.0);
                        ui.label(
                            RichText::new(plugins::plain_text(info.subtitle))
                                .size(12.0)
                                .color(theme::TEXT_SUB),
                        );
                        ui.add_space(12.0);
                        egui::ScrollArea::vertical()
                            .auto_shrink([false, false])
                            .show(ui, |ui| {
                                if let Some(spec) = pages.get(page) {
                                    let mut nav = None;
                                    let mut pcx = plugins::PageCtx {
                                        page,
                                        ui,
                                        egui: vctx,
                                        state,
                                        kernel,
                                        nav: &mut nav,
                                    };
                                    (spec.draw)(&mut pcx);
                                }
                            });
                    });
            });

            if close_requested {
                to_close.push(page);
            }
        }
        for p in to_close {
            popped.retain(|x| *x != p);
        }
    }
}

impl App {
    /// 处理拖进窗口的文件。
    ///
    /// egui 原生支持文件拖放。**同一个动作在不同页面上含义不同**:
    /// 在图片阅读器页是「打开这张」,在别处是「当窗口背景」——
    /// 所以在壳这一层判断,而不是塞进 `AppState`。
    fn handle_dropped_files(&mut self, ctx: &egui::Context) {
        let dropped: Option<PathBuf> = ctx.input(|i| {
            i.raw
                .dropped_files
                .iter()
                .find_map(|f| f.path.clone())
        });
        let Some(path) = dropped else {
            return;
        };
        if self.page == Page::ImageViewer {
            self.state.viewer.load_async(ctx, &path);
            self.state
                .log
                .write("viewer", &format!("拖入打开: {}", path.display()));
        } else {
            self.state.apply_background(ctx, &path);
        }
    }
}

impl App {
    /// 侧栏 = 7 个功能分组(手风琴)+ 展开那一组的页面。
    ///
    /// 一次只展开一组是有意的:功能树一共 30 页,全铺开光侧栏就要滚半天,
    /// 而分成 7 组之后每一组都能一眼看全,切组的成本只是点一下标题。
    /// 拖拽时跟着指针的「幽灵」。
    fn draw_nav_ghost(&self, ctx: &egui::Context) {
        let Some(d) = &self.nav_drag else { return };
        let Some(pos) = ctx.pointer_interact_pos() else { return };
        let info = d.page.info();
        let p = ctx.layer_painter(egui::LayerId::new(
            egui::Order::Tooltip,
            egui::Id::new("nav-ghost"),
        ));
        let r = egui::Rect::from_min_size(
            pos + egui::vec2(16.0, 8.0),
            egui::vec2((NAV_WIDTH - 44.0).max(80.0), CHILD_ROW_H),
        );
        // 「描边」用先画大一圈的强调色实现 —— `rect_stroke` 的签名在 egui 版本间
        // 变过,不想为一条边押上 API 风险。
        p.rect_filled(r.expand(1.5), 9.0, theme::ACCENT);
        p.rect_filled(r, 8.0, theme::GLASS_NAV_ACTIVE);
        let icon_rect = egui::Rect::from_center_size(
            egui::pos2(r.left() + 18.0, r.center().y),
            egui::vec2(16.0, 16.0),
        );
        icons::draw(&p, icon_rect, info.icon, theme::ACCENT);
        p.text(
            egui::pos2(r.left() + 36.0, r.center().y),
            Align2::LEFT_CENTER,
            info.nav,
            FontId::proportional(13.5),
            theme::TEXT_MAIN,
        );
    }

    /// 画一个容器里的(一)页,并处理「点开 / 拖拽换位」。
    ///
    /// `depth` 是缩进层级:1 = 直接挂在一级目录下的页,2 = 二级子分组里的页。
    ///
    /// 返回 `Some(page)` 表示这一帧用户点开了某一页。
    ///
    /// 拖拽重排**只在容器内生效**(`NavDrag::scope`):三级结构下跨容器拖拽改不了
    /// `PageInfo.group` / `PageInfo.sub`(它们是编译期常量),与其做出「拖过去了但归属
    /// 没变」的假象,不如明确禁止 —— 和 `pages::move_within_group` 的说明是同一件事。
    fn nav_row(
        &mut self,
        ui: &mut egui::Ui,
        ctx: &egui::Context,
        specs: &[plugins::PageSpec],
        depth: usize,
    ) -> Option<Page> {
        if specs.is_empty() {
            return None;
        }
        // 按用户顺序重排本容器。**只影响显示顺序** —— 注册表和 PageInfo 都不动。
        let rank = &self.rank;
        let mut ordered: Vec<&plugins::PageSpec> = specs.iter().collect();
        ordered.sort_by_key(|s| rank.get(&s.info.page).copied().unwrap_or(usize::MAX));

        let scope = pages::scope_of(specs[0].info.page);
        let n = ordered.len();
        let mut rects: Vec<egui::Rect> = Vec::with_capacity(n);
        let mut started: Option<Page> = None;
        let mut clicked: Option<Page> = None;
        for spec in &ordered {
            let active = self.page == spec.info.page;
            let (rect, resp) = child_row(ui, spec.info, active, depth);
            rects.push(rect);
            if resp.drag_started() {
                started = Some(spec.info.page);
            }
            // 正在拖时不认点击,否则松手会顺带跳页
            if resp.clicked() && self.nav_drag.is_none() {
                clicked = Some(spec.info.page);
            }
        }
        if let Some(p) = started {
            self.nav_drag = Some(NavDrag { page: p, scope, to: 0 });
        }

        // 拖动中:按指针 y 算插入位,并画插入线
        if let Some(d) = &mut self.nav_drag
            && d.scope == scope
            && let Some(pos) = ctx.pointer_interact_pos()
        {
            let mut idx = 0usize;
            for (i, r) in rects.iter().enumerate() {
                if pos.y > r.center().y {
                    idx = i + 1;
                }
            }
            d.to = idx;
            if n > 0 {
                let y = if idx < n { rects[idx].top() } else { rects[n - 1].bottom() };
                let x0 = rects[0].left() + 16.0;
                let x1 = rects[0].right() - 4.0;
                ui.painter().rect_filled(
                    egui::Rect::from_min_max(egui::pos2(x0, y - 1.0), egui::pos2(x1, y + 1.0)),
                    1.0,
                    theme::ACCENT,
                );
            }
        }

        clicked
    }

    /// 松手时把新顺序落盘。
    ///
    /// **顺序没变就不写盘**:用户可能只是按住又放开,没必要制造一次写操作
    /// (也就没必要让 store.json 的修改时间无意义地跳)。
    fn commit_nav_drag(&mut self, ctx: &egui::Context) {
        if !ctx.input(|i| i.pointer.any_released()) {
            return;
        }
        let Some(d) = self.nav_drag.take() else { return };
        let before = self.nav_ordered.clone();
        let after = pages::move_within_group(&before, d.page, d.to);
        if after == before {
            return;
        }
        self.nav_ordered = after;
        self.rank = rank_of(&self.nav_ordered);
        // 存 **id** 不存下标:页面增删后下标会错位,id 不会
        let ids: Vec<String> = self.nav_ordered.iter().map(|p| p.id().to_string()).collect();
        self.state.store.set_nav_order(&ids);
        match self.state.store.save() {
            Ok(()) => self
                .state
                .log
                .write("gui", &format!("侧栏顺序已保存({} 项)", ids.len())),
            // 存不下去要**说出来** —— 静默失败会让用户以为排好了,重启却回到原样
            Err(e) => self
                .state
                .log
                .write("gui", &format!("侧栏顺序保存失败: {e}")),
        }
    }
    fn sidebar(&mut self, ctx: &egui::Context) {
        let mut nav: Option<Page> = None;

        egui::SidePanel::left("nav")
            .exact_width(NAV_WIDTH)
            .resizable(false)
            .frame(egui::Frame::NONE.fill(theme::GLASS_SIDEBAR).inner_margin(12))
            .show(ctx, |ui| {
                // 「点任意空白处都能拖窗口」:原版用 IMessageFilter 全局接管左键按下。
                // egui 这边在最底层放一个拖拽层 —— 后添加的控件优先级更高,
                // 所以按钮/输入框仍然照常工作,只有空地方会拖动窗口。
                let drag = ui.interact(
                    ui.max_rect(),
                    egui::Id::new("sidebar-drag"),
                    egui::Sense::click_and_drag(),
                );
                if drag.drag_started() {
                    ctx.send_viewport_cmd(egui::ViewportCommand::StartDrag);
                }
                if drag.double_clicked() {
                    toggle_maximize(ctx);
                }

                // ---- 品牌区 ----
                ui.add_space(4.0);
                ui.horizontal(|ui| {
                    let (rect, _) =
                        ui.allocate_exact_size(egui::vec2(34.0, 34.0), egui::Sense::hover());
                    // 原版侧栏就是 nfarms.ico(整张图,笔刷 N + 红叶 + 印章)。
                    // 只有连内嵌那份都解不出来时才退化成色块 —— 理论上不会发生。
                    if self.state.logo.is_loaded() {
                        self.state.logo.draw(ui.painter(), rect);
                    } else {
                        ui.painter().rect_filled(rect, 10.0, theme::ACCENT);
                        ui.painter().text(
                            rect.center(),
                            Align2::CENTER_CENTER,
                            "N",
                            FontId::proportional(20.0),
                            Color32::WHITE,
                        );
                    }
                    ui.vertical(|ui| {
                        ui.add_space(1.0);
                        ui.label(
                            RichText::new("NFarms HUB")
                                .size(16.0)
                                .color(theme::TEXT_MAIN),
                        )
                        .on_hover_text(format!(
                            "程序根: {}\n日志: {}\n设置: {}\n插件: {} 个 / 页面: {} 张",
                            self.state.paths.root().display(),
                            self.state.log.dir().display(),
                            self.state.settings_path.display(),
                            self.kernel.len(),
                            self.pages.len()
                        ));
                        ui.label(
                            RichText::new(format!("v{}", env!("CARGO_PKG_VERSION")))
                                .size(11.0)
                                .color(theme::TEXT_SUB),
                        );
                    });
                });
                ui.add_space(10.0);

                // ---- 功能分组(三级:一级目录 → 二级子分组 → 页面)----
                let groups = self.pages.grouped_tree();
                // 给底部的 Minimize / Close 留位置,导航区自己滚
                let nav_h = (ui.available_height() - 52.0).max(160.0);
                egui::ScrollArea::vertical()
                    .max_height(nav_h)
                    .auto_shrink([false, false])
                    // 拖拽用于移动窗口,不能被滚动区抢走
                    .scroll_source(
                        egui::scroll_area::ScrollSource::SCROLL_BAR
                            | egui::scroll_area::ScrollSource::MOUSE_WHEEL,
                    )
                    .show(ui, |ui| {
                        for tree in &groups {
                            let key = NavKey::Group(tree.group);
                            let open = self.open_containers.contains(&key);
                            if group_row(ui, tree, open, tree.holds(self.page)).clicked() {
                                toggle_key(&mut self.open_containers, key);
                            }
                            if !open {
                                continue;
                            }

                            for item in &tree.items {
                                match item {
                                    plugins::NavItem::Leaf(spec) => {
                                        if let Some(p) =
                                            self.nav_row(ui, ctx, std::slice::from_ref(spec), 1)
                                        {
                                            nav = Some(p);
                                        }
                                    }
                                    plugins::NavItem::Subgroup(sub, children) => {
                                        let sub_key = NavKey::Sub(*sub);
                                        let sub_open = self.open_containers.contains(&sub_key);
                                        let holds = children
                                            .iter()
                                            .any(|s| s.info.page == self.page);
                                        if sub_row(ui, *sub, children.len(), sub_open, holds)
                                            .clicked()
                                        {
                                            toggle_key(&mut self.open_containers, sub_key);
                                        }
                                        if !sub_open {
                                            continue;
                                        }
                                        if let Some(p) =
                                            self.nav_row(ui, ctx, children, 2)
                                        {
                                            nav = Some(p);
                                        }
                                    }
                                }
                            }
                        }
                    });

                // ---- 底部:Minimize / Close(与原版位置一致)----
                ui.with_layout(egui::Layout::bottom_up(egui::Align::LEFT), |ui| {
                    ui.add_space(2.0);
                    ui.horizontal(|ui| {
                        if ui
                            .add_sized([92.0, 32.0], egui::Button::new("Close"))
                            .clicked()
                        {
                            ctx.send_viewport_cmd(egui::ViewportCommand::Close);
                        }
                        if ui
                            .add_sized([92.0, 32.0], egui::Button::new("Minimize"))
                            .clicked()
                        {
                            ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true));
                        }
                    });
                    ui.add_space(6.0);
                });
            });

        // 拖拽幽灵画在 Tooltip 层(浮在一切之上);松手时提交新顺序。
        // **放在 SidePanel 之外**:侧栏有滚动区会裁剪,拖到边缘时幽灵会被切掉。
        self.draw_nav_ghost(ctx);
        self.commit_nav_drag(ctx);

        if let Some(page) = nav {
            self.goto(page);
        }
    }

    fn content(&mut self, ctx: &egui::Context) {
        let info = self.page.info();
        let mut nav: Option<Page> = None;

        egui::CentralPanel::default()
            .frame(egui::Frame::NONE.inner_margin(egui::Margin {
                left: 24,
                right: 24,
                top: 18,
                bottom: 18,
            }))
            .show(ctx, |ui| {
                let drag = ui.interact(
                    ui.max_rect(),
                    egui::Id::new("content-drag"),
                    egui::Sense::click_and_drag(),
                );
                if drag.drag_started() {
                    ctx.send_viewport_cmd(egui::ViewportCommand::StartDrag);
                }
                if drag.double_clicked() {
                    toggle_maximize(ctx);
                }

                // 面包屑:让「这一页在功能树的哪个位置」一直是可见的
                //
                // 这里原来右上角有个「弹出独立窗口」按钮,已按用户要求**移除** ——
                // 功能没了,按钮就不该留着占位置(留着只会让人以为能点)。
                ui.horizontal(|ui| {
                    ui.label(
                        RichText::new(info.group.nav())
                            .size(12.0)
                            .color(theme::TEXT_SUB),
                    );
                    ui.label(RichText::new("›").size(12.0).color(theme::TEXT_SUB));
                    ui.label(
                        RichText::new(info.nav)
                            .size(12.0)
                            .color(theme::ACCENT_DARK),
                    );
                });
                ui.add_space(2.0);
                ui.label(RichText::new(info.title).size(24.0).color(theme::TEXT_MAIN));
                ui.add_space(2.0);
                ui.label(
                    RichText::new(info.subtitle)
                        .size(13.0)
                        .color(theme::TEXT_SUB),
                );
                ui.add_space(14.0);

                egui::ScrollArea::vertical()
                    .auto_shrink([false, false])
                    // 拖拽用于移动窗口,不能被滚动区抢走
                    .scroll_source(
                        egui::scroll_area::ScrollSource::SCROLL_BAR
                            | egui::scroll_area::ScrollSource::MOUSE_WHEEL,
                    )
                    .show(ui, |ui| match self.pages.get(self.page) {
                        Some(spec) => {
                            // PageSpec 是 Copy,拷出来之后对 pages 的借用就结束了,
                            // 接下来才能放心把 state 可变借出去 —— 两个字段互不干扰。
                            let mut pcx = plugins::PageCtx {
                                page: self.page,
                                ui,
                                egui: ctx,
                                state: &mut self.state,
                                kernel: &self.kernel,
                                nav: &mut nav,
                            };
                            (spec.draw)(&mut pcx);
                        }
                        None => not_installed(ui, info),
                    });
            });

        if let Some(page) = nav {
            self.goto(page);
        }
    }
}

/// 当前平台没有装载这一页时的说明。
///
/// 正常点不到这里(侧栏只列装载了的页),但 `--page X` 有可能指到没装的那一页 ——
/// 与其给一片空白,不如说清楚为什么。
fn not_installed(ui: &mut egui::Ui, info: &pages::PageInfo) {
    plugins::card(ui, |ui| {
        ui.label(
            RichText::new("当前平台没有装载这一页")
                .size(16.0)
                .color(theme::TEXT_MAIN),
        );
        ui.add_space(8.0);
        ui.label(
            RichText::new(format!(
                "页面 id `{}` 属于 {} 组。安卓骨架版只装天气 / 背景图片 / 插件三页,其余功能按插件逐步加入。",
                info.id,
                info.group.nav()
            ))
            .size(12.5)
            .color(theme::TEXT_SUB),
        );
    });
}

// ===========================================================================
// 侧栏行
// ===========================================================================

/// 页面 → 它在用户顺序里的下标。侧栏每个容器排序都按它。
///
/// 做成 map 而不是在循环里线性找:一个容器几十页、每帧每个容器各排一次,
/// 线性找就是 O(n²)。
fn rank_of(ordered: &[Page]) -> std::collections::HashMap<Page, usize> {
    ordered.iter().enumerate().map(|(i, p)| (*p, i)).collect()
}

/// 手风琴开合:开着就关,关着就开。
fn toggle_key(set: &mut std::collections::HashSet<NavKey>, key: NavKey) {
    if !set.remove(&key) {
        set.insert(key);
    }
}

/// 一级目录标题行:图标 + 名字 + **页数(含子分组的)** + 展开箭头。
///
/// 页数按整棵树算 —— 三级结构下如果只数直接挂着的那几页,Tools 会显示成 5,
/// 而它下面实际有 15 页(DS-Harness 里还挂着三个)。
fn group_row(
    ui: &mut egui::Ui,
    tree: &plugins::NavTree,
    open: bool,
    holds_current: bool,
) -> egui::Response {
    let group = tree.group;
    let count = tree.page_count();
    let (rect, resp) = ui.allocate_exact_size(
        egui::vec2(ui.available_width(), GROUP_ROW_H),
        egui::Sense::click(),
    );
    let hovered = resp.hovered();
    let p = ui.painter().clone();
    let row = rect.shrink2(egui::vec2(2.0, 3.0));

    if open || hovered {
        p.rect_filled(
            row,
            10.0,
            if open {
                theme::GLASS_NAV_ACTIVE
            } else {
                theme::GLASS_NAV_HOVER
            },
        );
    }
    // 展开的组带一根品牌绿竖条 —— 和原版导航激活项同一个记号
    if open {
        let bar = egui::Rect::from_min_size(
            egui::pos2(row.left() + 1.0, row.center().y - 11.0),
            egui::vec2(4.0, 22.0),
        );
        theme::accent_bar(&p, bar, theme::ACCENT);
    }

    let ico_color = if open {
        theme::ACCENT
    } else if hovered {
        theme::NAV_TEXT_HOVER
    } else {
        theme::NAV_TEXT_IDLE
    };
    let icon_rect = egui::Rect::from_center_size(
        egui::pos2(row.left() + 28.0, row.center().y),
        egui::vec2(19.0, 19.0),
    );
    icons::draw(&p, icon_rect, group.icon(), ico_color);
    p.text(
        egui::pos2(row.left() + 50.0, row.center().y),
        Align2::LEFT_CENTER,
        group.nav(),
        FontId::proportional(14.5),
        ico_color,
    );
    // 右边:页数(当前页所在组前面点一个点)+ 展开箭头
    p.text(
        egui::pos2(row.right() - 22.0, row.center().y),
        Align2::RIGHT_CENTER,
        if holds_current {
            format!("•{count}")
        } else {
            count.to_string()
        },
        FontId::proportional(11.0),
        if holds_current {
            theme::ACCENT
        } else {
            theme::TEXT_SUB
        },
    );
    // 箭头**画出来**,不用 `▸ / ▾`:那两个字符既不在 egui 自带字体里、
    // 也不在中文字体里,真机上直接渲染成豆腐块(截图里就是 "6 □")。
    // 原版 README 记的「Unicode 符号被字体链接换掉」是同一类问题。
    chevron(&p, egui::pos2(row.right() - 12.0, row.center().y), 4.5, open, theme::TEXT_SUB);

    resp.on_hover_text(group.note())
}

/// 展开 / 收起的小三角(`open` 朝下)。
fn chevron(painter: &egui::Painter, center: egui::Pos2, r: f32, open: bool, color: Color32) {
    let pts = if open {
        vec![
            egui::pos2(center.x - r, center.y - r * 0.55),
            egui::pos2(center.x + r, center.y - r * 0.55),
            egui::pos2(center.x, center.y + r * 0.75),
        ]
    } else {
        vec![
            egui::pos2(center.x - r * 0.55, center.y - r),
            egui::pos2(center.x - r * 0.55, center.y + r),
            egui::pos2(center.x + r * 0.75, center.y),
        ]
    };
    painter.add(egui::Shape::convex_polygon(pts, color, egui::Stroke::NONE));
}

/// 二级子分组标题行(三级菜单的中间那一层)。
///
/// 和一级目录行**刻意画得不一样**,否则用户分不清「Player」和「Player 底下的 Music」
/// 哪一层是哪一层:这一行更矮、缩进、不带图标块、字号更小、箭头更小、底色更淡。
/// 计数是它底下真正挂着的页数。
fn sub_row(
    ui: &mut egui::Ui,
    sub: pages::Sub,
    count: usize,
    open: bool,
    holds_current: bool,
) -> egui::Response {
    let (rect, resp) = ui.allocate_exact_size(
        egui::vec2(ui.available_width(), SUB_ROW_H),
        egui::Sense::click(),
    );
    let hovered = resp.hovered();
    let p = ui.painter().clone();
    // 比一级目录再向右缩 16px —— 层级靠缩进一眼看出来
    let row = egui::Rect::from_min_max(
        egui::pos2(rect.left() + 16.0, rect.top() + 1.0),
        egui::pos2(rect.right() - 2.0, rect.bottom() - 1.0),
    );
    if open || hovered {
        p.rect_filled(
            row,
            8.0,
            if open {
                theme::GLASS_NAV_ACTIVE
            } else {
                theme::GLASS_NAV_HOVER
            },
        );
    }
    if open {
        let bar = egui::Rect::from_min_size(
            egui::pos2(row.left() + 1.0, row.center().y - 8.0),
            egui::vec2(3.0, 16.0),
        );
        theme::accent_bar(&p, bar, theme::ACCENT);
    }
    let color = if open {
        theme::ACCENT
    } else if hovered {
        theme::NAV_TEXT_HOVER
    } else {
        theme::NAV_TEXT_IDLE
    };
    // 小圆点当图标:子分组是「一层目录」,不是功能,给它画一个真图标反而更容易和页面混
    p.circle_filled(
        egui::pos2(row.left() + 14.0, row.center().y),
        2.5,
        color,
    );
    p.text(
        egui::pos2(row.left() + 26.0, row.center().y),
        Align2::LEFT_CENTER,
        sub.nav(),
        FontId::proportional(13.0),
        color,
    );
    p.text(
        egui::pos2(row.right() - 22.0, row.center().y),
        Align2::RIGHT_CENTER,
        if holds_current {
            format!("•{count}")
        } else {
            count.to_string()
        },
        FontId::proportional(10.5),
        if holds_current { theme::ACCENT } else { theme::TEXT_SUB },
    );
    chevron(
        &p,
        egui::pos2(row.right() - 12.0, row.center().y),
        4.0,
        open,
        theme::TEXT_SUB,
    );

    resp.on_hover_text(sub.note())
}

/// 容器里的一项。
/// 画一行子页。返回 `(行矩形, 响应)`。
///
/// 返回 `rect` 是为了拖拽排序 —— 要拿每行的位置来算「插到第几个」。
/// `Sense::click_and_drag()`(原来是 `click`)让这一行能拖;
/// **点击与拖拽的区分交给 egui 的拖拽阈值**:原地松开算 `clicked()`,
/// 移动超过阈值才算 `drag_started()`。这样不用自己写延迟计时器,
/// 手感也和系统一致。
///
/// `depth` 决定缩进:1 = 直接挂在一级目录下的页,2 = 二级子分组里的页。
fn child_row(
    ui: &mut egui::Ui,
    info: &pages::PageInfo,
    active: bool,
    depth: usize,
) -> (egui::Rect, egui::Response) {
    let (rect, resp) = ui.allocate_exact_size(
        egui::vec2(ui.available_width(), CHILD_ROW_H),
        egui::Sense::click_and_drag(),
    );
    let hovered = resp.hovered();
    let p = ui.painter().clone();
    // 每深一层缩进 16px
    let indent = 14.0 + (depth.saturating_sub(1) as f32) * 16.0;
    let row = egui::Rect::from_min_max(
        egui::pos2(rect.left() + indent, rect.top() + 1.0),
        egui::pos2(rect.right() - 2.0, rect.bottom() - 1.0),
    );

    if active {
        p.rect_filled(row, 8.0, theme::GLASS_NAV_ACTIVE);
    } else if hovered {
        p.rect_filled(row, 8.0, theme::GLASS_NAV_HOVER);
    }
    let color = if active {
        theme::ACCENT
    } else if hovered {
        theme::NAV_TEXT_HOVER
    } else {
        theme::NAV_TEXT_IDLE
    };
    let icon_rect = egui::Rect::from_center_size(
        egui::pos2(row.left() + 18.0, row.center().y),
        egui::vec2(16.0, 16.0),
    );
    icons::draw(&p, icon_rect, info.icon, color);
    p.text(
        egui::pos2(row.left() + 36.0, row.center().y),
        Align2::LEFT_CENTER,
        info.nav,
        FontId::proportional(13.5),
        color,
    );

    (rect, resp.on_hover_text(format!("{}\n{}", info.subtitle, info.origin())))
}

// ===========================================================================
// 小工具
// ===========================================================================

fn toggle_maximize(ctx: &egui::Context) {
    let maximized = ctx.input(|i| i.viewport().maximized.unwrap_or(false));
    ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(!maximized));
}

/// 时间戳,只留数字,如 `20261002224213`(yyyyMMddHHmmss)。
///
/// 注意它**不是** `20261002-224213` 那种带横线的形式 —— `local_now()` 里的
/// 分隔符会被下面的 filter 一并吃掉,界面上显示的就是这一串 14 位数字。
pub(crate) fn now_stamp() -> String {
    nfarms_core::time::local_now()
        .chars()
        .filter(|c| c.is_ascii_digit())
        .collect()
}

/// 无边框窗口的边缘拉伸 —— 原版靠 `WM_NCHITTEST` 转发,egui 这边显式判断指针位置。
fn handle_window_resize(ctx: &egui::Context) {
    use egui::ResizeDirection as Dir;
    const EDGE: f32 = 6.0;

    let Some(pos) = ctx.input(|i| i.pointer.hover_pos()) else {
        return;
    };
    let r = ctx.screen_rect();
    let (l, rt, t, b) = (
        pos.x - r.left() < EDGE,
        r.right() - pos.x < EDGE,
        pos.y - r.top() < EDGE,
        r.bottom() - pos.y < EDGE,
    );

    let (dir, cursor) = match (l, rt, t, b) {
        (true, _, true, _) => (Dir::NorthWest, egui::CursorIcon::ResizeNwSe),
        (_, true, true, _) => (Dir::NorthEast, egui::CursorIcon::ResizeNeSw),
        (true, _, _, true) => (Dir::SouthWest, egui::CursorIcon::ResizeNeSw),
        (_, true, _, true) => (Dir::SouthEast, egui::CursorIcon::ResizeNwSe),
        (true, _, _, _) => (Dir::West, egui::CursorIcon::ResizeHorizontal),
        (_, true, _, _) => (Dir::East, egui::CursorIcon::ResizeHorizontal),
        (_, _, true, _) => (Dir::North, egui::CursorIcon::ResizeVertical),
        (_, _, _, true) => (Dir::South, egui::CursorIcon::ResizeVertical),
        _ => return,
    };

    ctx.set_cursor_icon(cursor);
    if ctx.input(|i| i.pointer.primary_pressed()) {
        ctx.send_viewport_cmd(egui::ViewportCommand::BeginResize(dir));
    }
}

/// 系统中文字体候选。**安卓和 Windows 完全不是一套路径** ——
/// 安卓上没有 `C:\Windows\Fonts`,找不到就是满屏方块。
#[cfg(target_os = "android")]
const CJK_CANDIDATES: &[&str] = &[
    "/system/fonts/NotoSansCJK-Regular.ttc",
    "/system/fonts/NotoSansSC-Regular.otf",
    "/system/fonts/NotoSansHans-Regular.otf",
    "/system/fonts/DroidSansFallback.ttf",
    "/system/fonts/DroidSansChinese.ttf",
];

#[cfg(not(target_os = "android"))]
const CJK_CANDIDATES: &[&str] = &[
    // 优先微软雅黑;`.ttc` 是字体集合,取第 0 个 face。
    r"C:\Windows\Fonts\msyh.ttc",
    r"C:\Windows\Fonts\msyh.ttf",
    r"C:\Windows\Fonts\Deng.ttf",
    r"C:\Windows\Fonts\simhei.ttf",
    r"C:\Windows\Fonts\simsun.ttc",
    r"C:\Windows\Fonts\msjh.ttc",
    // 顺手给 Linux 也留几条 —— 以后要出 Linux 版不用再改
    "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
    "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
];

/// 装一个系统中文字体。
///
/// egui 自带的字体(epaint_default_fonts)**不含 CJK** —— 不装的话整个界面
/// 的中文都是方块。这里直接从系统字体目录读,不往仓库里塞字体文件。
fn install_cjk_font(ctx: &egui::Context) {
    for path in CJK_CANDIDATES {
        let Ok(bytes) = std::fs::read(path) else {
            continue;
        };
        let mut fonts = egui::FontDefinitions::default();
        fonts.font_data.insert(
            "cjk".to_owned(),
            std::sync::Arc::new(egui::FontData::from_owned(bytes)),
        );
        // 插到最前面:中文优先用这个;它没有的字符(如 ☀ 这类符号)再回退到 egui 自带字体
        fonts
            .families
            .entry(egui::FontFamily::Proportional)
            .or_default()
            .insert(0, "cjk".to_owned());
        fonts
            .families
            .entry(egui::FontFamily::Monospace)
            .or_default()
            .push("cjk".to_owned());
        ctx.set_fonts(fonts);
        return;
    }
    // 找不到中文字体就算了 —— 英文界面仍然可用,不乱码崩溃
}

fn install_style(ctx: &egui::Context) {
    let mut style = (*ctx.style()).clone();
    style.visuals = egui::Visuals::light();
    style.visuals.override_text_color = Some(theme::TEXT_MAIN);
    style.visuals.panel_fill = Color32::TRANSPARENT;
    style.visuals.window_fill = Color32::TRANSPARENT;

    let w = &mut style.visuals.widgets;
    w.noninteractive.bg_stroke = egui::Stroke::new(1.0_f32, theme::BORDER);
    w.inactive.corner_radius = theme::RADIUS_CTRL.into();
    w.inactive.bg_fill = theme::GLASS_INPUT;
    w.inactive.weak_bg_fill = theme::GLASS_INPUT;
    w.hovered.corner_radius = theme::RADIUS_CTRL.into();
    w.hovered.bg_fill = theme::glass(250);
    w.hovered.weak_bg_fill = theme::glass(250);
    w.active.corner_radius = theme::RADIUS_CTRL.into();
    w.active.bg_fill = theme::ACCENT;
    w.active.weak_bg_fill = theme::ACCENT_DARK;
    style.visuals.selection.bg_fill = theme::ACCENT;
    style.visuals.selection.stroke = egui::Stroke::new(1.0_f32, theme::ACCENT_DARK);

    style.spacing.item_spacing = egui::vec2(8.0, 8.0);
    ctx.set_style(style);
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn event_lines_are_human_readable() {
        assert_eq!(
            format_event(&Event::PluginLoaded {
                name: "weather".into()
            }),
            "插件已装载  weather"
        );
        assert!(
            format_event(&Event::PluginFailed {
                name: "x".into(),
                error: "坏了".into()
            })
            .contains("坏了")
        );
    }



    /// 侧栏是照注册表画的:页数必须和 PAGES 对得上(一页都不能漏)。
    #[test]
    fn desktop_registry_covers_every_page() {
        let kernel = Kernel::new();
        let registry = plugins::install(&kernel, plugins::desktop());
        let groups = registry.grouped();
        let total: usize = groups.iter().map(|(_, v)| v.len()).sum();
        assert_eq!(total, pages::PAGES.len());
        assert_eq!(registry.len(), pages::PAGES.len());
    }

    /// 安卓骨架:七页,分属五个一级目录。
    ///
    /// **没有 Tools**:那一组底下全是外部可执行文件(ffmpeg / bililive-go / 打印网关),
    /// 安卓上装着只会显示「未安装」—— 那是看起来能用其实在骗人。
    #[test]
    fn mobile_sidebar_is_a_skeleton() {
        let kernel = Kernel::new();
        let registry = plugins::install(&kernel, plugins::mobile());
        let groups = registry.grouped();
        // 顺序跟着 `Group::ALL` 走,而 **Today's Info 置顶**(见 pages.rs 里那段说明)
        assert_eq!(groups.len(), 5);
        assert_eq!(groups[0].0, Group::TodayInfo);
        assert_eq!(groups[1].0, Group::Player);
        assert_eq!(groups[2].0, Group::Vpn);
        assert_eq!(groups[3].0, Group::Environment);
        assert_eq!(groups[4].0, Group::Setting);
    }

    /// 桌面版:七个一级目录一个都不能少(需求里的「一级目录:7 个」)。
    #[test]
    fn desktop_sidebar_has_all_seven_groups() {
        let kernel = Kernel::new();
        let registry = plugins::install(&kernel, plugins::desktop());
        let groups = registry.grouped();
        assert_eq!(groups.len(), 7);
        let names: Vec<&str> = groups.iter().map(|(g, _)| g.nav()).collect();
        assert_eq!(
            names,
            [
                // Today's Info 置顶
                "Today's Info",
                "Tools",
                "Player",
                "Browser",
                "Zero Trust",
                "Environment",
                "Setting"
            ]
        );
    }

    /// 侧栏真的画成了三级:一级目录下面既有直接挂着的页,也有二级子分组。
    ///
    /// 这条守的是「三级结构在**绘制层**也没被打平」—— `grouped()` 那条老路
    /// 仍然把子分组里的页混在一起返回,侧栏要是还照它画,二级目录就没了。
    #[test]
    fn sidebar_tree_has_three_levels() {
        let kernel = Kernel::new();
        let registry = plugins::install(&kernel, plugins::desktop());
        let tree = registry.grouped_tree();
        assert_eq!(tree.len(), 7, "一级目录数不对");
        // Tools:先是直接挂着的页(FFmpeg…),再是 DS-Harness 子分组
        let tools = tree.iter().find(|t| t.group == Group::Tools).unwrap();
        assert!(
            matches!(tools.items[0], plugins::NavItem::Leaf(_)),
            "一级目录自己那些页必须排在子分组前面"
        );
        let has_sub = tools
            .items
            .iter()
            .any(|it| matches!(it, plugins::NavItem::Subgroup(Sub::DsHarness, _)));
        assert!(has_sub, "Tools 下面少了 DS-Harness 这个二级目录");
        // 每个一级目录的页数 = 它下面所有叶子(含子分组里的)之和
        let total: usize = tree.iter().map(|t| t.page_count()).sum();
        assert_eq!(total, pages::PAGES.len(), "侧栏里的页数 != 页面总数");
    }

    // ------------------------------------------------------------ 观测服务

    /// 一个**空** store,相当于"这台机器第一次跑这个程序"。
    ///
    /// `Store` 没有 `Default`,而 `load` 一个不存在的路径给的就是空存储
    /// (文件不存在不算错误,见 `store.rs`)—— 所以这里故意**不创建**那个文件。
    fn empty_store(tag: &str) -> nfarms_core::store::Store {
        let p = std::env::temp_dir().join(format!(
            "nfarms-gui-store-{}-{tag}.json",
            std::process::id()
        ));
        let _ = std::fs::remove_file(&p);
        nfarms_core::store::Store::load(&p)
    }

    fn ports3(a: &str, b: &str, c: &str) -> [String; 3] {
        [a.to_string(), b.to_string(), c.to_string()]
    }

    /// 端口初值:三件观测服务 9091 / 9092 / 9093。
    ///
    /// 这几条是**实测值**(和 core 的 `default_port` 一致)。谁把它们写死成别的数,
    /// 这条测试就红 —— 而端口错一位的症状只是一句 connection refused,
    /// 肉眼根本看不出来。
    #[test]
    fn observability_port_defaults_are_the_measured_ones() {
        let store = empty_store("ports-default");
        assert_eq!(
            obs_ports_from_store(&store),
            ports3("9091", "9092", "9093")
        );
        // 页面上那份状态(来自 MiscState::default)也必须是同一组
        assert_eq!(MiscState::default().obs_ports, ports3("9091", "9092", "9093"));

        // 盘上存过就原样用 —— 迁移不替用户改端口
        let mut saved = empty_store("ports-saved");
        saved.set_vm_port(19091);
        saved.set_loki_port(19092);
        saved.set_grafana_port(19093);
        assert_eq!(
            obs_ports_from_store(&saved),
            ports3("19091", "19092", "19093")
        );
    }

    /// 本地模型的端口初值:llama.cpp 9094 / sd.cpp 9095(旧的 8080 / 1234 已迁移)。
    ///
    /// 只判注释里那句"缺省值以 store 为准"够不够:这里连**没存过时那份构造初值**
    /// 一起钉住 —— `load_ports` 不管默认值,拉不回来就会留下 LocalModel 自己那份,
    /// 而 DSH 侧 baseURL 跟着 store 走,两边对不上时只报一句 connection refused。
    #[cfg(windows)]
    #[test]
    fn model_port_defaults_come_from_the_store() {
        let store = empty_store("model-ports-default");
        // store 那一层的两个缺省值(AppState::new 从它读)
        assert_eq!(store.llama_port(), 9094, "llama.cpp 的旧默认 8080 已迁移到 9094");
        assert_eq!(store.sd_port(), 9095, "sd.cpp 的旧默认 1234 已迁移到 9095");
        // 盘上没存过 → 拿 store 的缺省(旧构造初值 8080 / 1234 必须被丢掉)
        assert_eq!(
            model_ports_from_store(&store, "8080", "1234"),
            ("9094".to_string(), "9095".to_string())
        );
        // 空盘上不该打"端口已从 store.json 读回" —— 那行日志是"端口记忆生效"
        // 唯一可查的凭据,没读回来说成读回来了就是假证据
        assert!(!model_ports_stored(&store), "空盘上没存过端口");

        // 盘上存过就原样用(迁移不替用户改端口),并且那行日志该出现
        let mut saved = empty_store("model-ports-saved");
        saved.set_llama_port(18080);
        saved.set_sd_port(11234);
        assert_eq!(
            model_ports_from_store(&saved, "18080", "11234"),
            ("18080".to_string(), "11234".to_string())
        );
        assert!(model_ports_stored(&saved), "盘上存过端口,那行日志才该出现");

        // 盘上写坏了:退回缺省,而不是把一个解析不出来的值带到启动参数里;
        // 也不该据此说"读回来了"
        let mut broken = empty_store("model-ports-broken");
        broken.set_str(nfarms_core::store::KEY_LLAMA_PORT, "abc");
        assert_eq!(model_ports_from_store(&broken, "8080", "1234").0, "9094");
        assert!(!model_ports_stored(&broken), "写了垃圾不算读回来");
    }

    /// 30 秒节流:边界必须是**到了 30 秒就推**(`>=`,不是 `>`)。
    ///
    /// 抽成纯函数就是为了能在这里把边界钉死 —— 真去等 30 秒的测试没人会跑,
    /// 而"跑不动所以不测"正是节流写错了也发现不了的原因。
    #[test]
    fn token_push_is_throttled_to_every_thirty_seconds() {
        let t0 = 1_700_000_000_000u64; // 一个随便的毫秒时刻
        assert!(obs_push_due(t0, 0), "还没推过(last=0)时要先送一份基线");
        assert!(!obs_push_due(t0 + 29_999, t0), "差 1 毫秒就推 = 节流没生效");
        assert!(
            obs_push_due(t0 + 30_000, t0),
            "正好 30 秒就该推(写成 > 会漏掉这一帧,表现成有时 30 秒有时 60 秒)"
        );
        assert!(obs_push_due(t0 + 600_000, t0), "隔了很久(机器睡了一觉)当然要推");
        // 系统时间被往回调:不能算成"已经过了很久",否则会连着推
        assert!(!obs_push_due(t0 - 1, t0), "时钟回拨不能把节流判成到期");
    }

    /// 指标行的形状:`nfarms_tokens_total{kind="prompt"} 数字`。
    ///
    /// 标签值掉了引号的话 VM 的 import 接口只回一句 400"格式不对",
    /// 从这句话里看不出是少了引号 —— 所以形状要在这里钉住。
    #[test]
    fn token_metric_line_is_prometheus_text_with_a_quoted_kind() {
        assert_eq!(
            token_metric_line("prompt", 1234),
            "nfarms_tokens_total{kind=\"prompt\"} 1234"
        );
        assert_eq!(
            token_metric_line("completion", 0),
            "nfarms_tokens_total{kind=\"completion\"} 0"
        );
    }

    // ---- 系统指标:名字 / 单位 / 形状 / 读不到时的处理 ----

    /// 一份"什么都读到了"的画像,给下面几条测试共用。
    fn machine_with_readings() -> MachineInfo {
        MachineInfo {
            hostname: "MY-PC".to_string(),
            cores: 16,
            ram_bytes: 32 * 1024 * 1024 * 1024,
            disk_total: 1000,
            disk_free: 250,
            ..Default::default()
        }
    }

    /// 系统指标要**名字带 `nfarms_` 前缀、单位写进名字、形状是 `名字{标签} 数字`**。
    ///
    /// 单位这件事不能靠"约定":推一个光秃秃的 `8589934592` 出去,看板上就得靠人
    /// 记住它是字节,记错一次就得出一个荒谬的结论。所以这里连后缀一起钉住。
    #[test]
    fn system_metrics_carry_units_inside_the_name() {
        let info = machine_with_readings();
        assert_eq!(
            system_metric_lines(&info),
            vec![
                "nfarms_cpu_cores{host=\"MY-PC\"} 16",
                "nfarms_mem_total_bytes{host=\"MY-PC\"} 34359738368",
                "nfarms_disk_total_bytes{host=\"MY-PC\"} 1000",
                "nfarms_disk_used_bytes{host=\"MY-PC\"} 750",
            ]
        );
        // 通用形状:每行都得是 `nfarms_<带单位的名字>{标签} <数字>`
        for line in system_metric_lines(&info) {
            let (head, value) = line.split_once(' ').expect("行里要有空格分数值");
            let name = head.split('{').next().unwrap();
            assert!(name.starts_with("nfarms_"), "少了 nfarms_ 前缀: {line}");
            assert!(
                ["_bytes", "_percent", "_cores"].iter().any(|u| name.ends_with(u)),
                "名字里没写单位(必须是字节 / 百分比 / 个数): {line}"
            );
            assert!(value.parse::<u64>().is_ok(), "数值不是数字: {line}");
            assert!(head.contains("host=\"MY-PC\""), "少了 host 标签: {line}");
        }
    }

    /// 读不到就**不推**,而不是推 0。
    ///
    /// `cyinfo` 里 0 的含义就是"没读到"(采不出来的平台上 `ram_bytes` /
    /// `disk_total` 就是 0)。推 0 出去,图上会画出一条"内存 0 字节"的曲线 ——
    /// 一条错曲线比缺一条曲线糟得多。
    #[test]
    fn unreadable_system_values_are_omitted_not_pushed_as_zero() {
        let blank = MachineInfo {
            hostname: "PC".to_string(),
            ..Default::default()
        };
        assert!(
            system_metric_lines(&blank).is_empty(),
            "0 等于「没读到」,不许当成读数推上去"
        );

        // 磁盘总量读不到时,**已用**也不许推:它是从总量减出来的
        let no_disk = MachineInfo {
            hostname: "PC".to_string(),
            disk_free: 5,
            ..Default::default()
        };
        assert!(system_metric_lines(&no_disk).is_empty());

        // 内存总量读不到 → 只少这一条,别的照推
        let mem_missing = MachineInfo {
            hostname: "PC".to_string(),
            cores: 8,
            disk_total: 100,
            disk_free: 40,
            ..Default::default()
        };
        let lines = system_metric_lines(&mem_missing);
        assert!(!lines.iter().any(|l| l.starts_with("nfarms_mem_")), "{lines:?}");
        assert!(lines.iter().any(|l| l.starts_with("nfarms_cpu_cores")));
        assert!(lines.iter().any(|l| l.starts_with("nfarms_disk_used_bytes")));
    }

    /// 可用空间比总量还大(脏数据)时,`已用` 收成 0,**不能回绕**。
    ///
    /// `u64` 直接相减会得到一个天文数字,图上直接顶到天花板 ——
    /// 那种曲线没人能解释,比 0 更难查。
    #[test]
    fn disk_used_saturates_instead_of_wrapping() {
        let weird = MachineInfo {
            hostname: "PC".to_string(),
            disk_total: 100,
            disk_free: 999,
            ..Default::default()
        };
        let lines = system_metric_lines(&weird);
        assert!(
            lines.contains(&"nfarms_disk_used_bytes{host=\"PC\"} 0".to_string()),
            "{lines:?}"
        );
    }

    /// 标签值必须**转义**:计算机名是用户能改的,一个双引号就能让整行变成语法错。
    ///
    /// 那一行会被 VM 的 import 接口整个丢掉(只回一句"格式不对"),
    /// 而丢的正好是这台机器上**全部**系统指标的其中一行 —— 从服务端那句话里
    /// 完全看不出是主机名带了引号。
    #[test]
    fn label_values_are_escaped_for_prometheus() {
        assert_eq!(escape_label_value(r#"A"B"#), r#"A\"B"#);
        assert_eq!(escape_label_value(r"C:\PC"), r"C:\\PC");
        assert_eq!(escape_label_value("a\nb"), r"a\nb");
        // 换行必须变成**转义序列**,不能真换行:真换行会把一行样本劈成两行,
        // 第二行是一段谁也解析不了的垃圾
        assert!(!escape_label_value("a\nb").contains('\n'));

        let nasty = MachineInfo {
            hostname: "MY\"PC\\x".to_string(),
            cores: 4,
            ..Default::default()
        };
        assert_eq!(
            system_metric_lines(&nasty),
            vec![r#"nfarms_cpu_cores{host="MY\"PC\\x"} 4"#]
        );

        // 计算机名取不到时是空串 —— `host=""` 在 Prometheus 里是合法的,
        // 不许因此把整行丢掉(丢了就一条指标都没有了)
        assert_eq!(gauge_line("nfarms_cpu_cores", 4, ""), "nfarms_cpu_cores{host=\"\"} 4");
    }

    /// 系统读数的重采周期:比推送稀疏,但边界同样是"到点就采"。
    ///
    /// 重采一次是 11~12 个子进程(本机实测约 1.4 秒),所以它**不能**跟着
    /// 30 秒的推送走 —— 这条断言把"稀疏"这件事钉住,免得以后有人顺手把它
    /// 改成和推送同频(那会变成一小时一千多个短命进程,而且没有任何报错)。
    #[test]
    fn system_readings_refresh_on_a_longer_cycle_than_the_push() {
        let t0 = 1_700_000_000_000u64;
        assert!(sys_readings_due(t0, 0), "还没有过读数时要先采一次");
        assert!(!sys_readings_due(t0 + 29_999, t0), "30 秒远不到重采的时候");
        assert!(!sys_readings_due(t0 + OBS_SYS_REFRESH_MS - 1, t0));
        assert!(sys_readings_due(t0 + OBS_SYS_REFRESH_MS, t0), "正好到点就该采");
        assert!(sys_readings_due(t0 + 10 * OBS_SYS_REFRESH_MS, t0));
        // 时钟被往回调:不能判成到期,否则会每帧重采一遍
        assert!(!sys_readings_due(t0 - 1, t0));
        // **编译期断言(const block),不是运行期。**
        // 两个常量的大小关系在编译时就定死了:放运行期等于每次测试都验一遍
        // 同样的常量;放编译期则**改错的那一刻就编不过**。
        // (先例:`weather.rs` 的 TIMEOUT_SECS、`browser.rs` 的两个超时。)
        const {
            assert!(
                OBS_SYS_REFRESH_MS > OBS_PUSH_INTERVAL_MS,
                "重采必须比推送稀疏,否则\"少起进程\"这件事就白做了"
            )
        };
    }

    /// 端口文本框里可以是垃圾:解析不了要退回默认端口,而不是 0。
    ///
    /// 退回 0 的话服务会去监听一个随机端口,而健康检查还打在 `127.0.0.1:0` 上,
    /// 表现成"每次都超时" —— 用户不会想到是输入框被清空了。
    #[test]
    fn unusable_port_text_falls_back_instead_of_zero() {
        let got = obs_ports_u16(&["".to_string(), " 9092 ".to_string(), "不是数字".to_string()]);
        assert_eq!(got, [9091, 9092, 9093]);
    }

    // ------------------------------------------------------------ 启动动图(MP4)

    /// 按时间找当前帧:三种边界都要钉死。
    ///
    /// 这段逻辑错了不会报错,只会"看起来闪了一下":早于第一帧返回 `None`,
    /// 动画开头会闪一下内置遮罩;晚于最后一帧返回 `None`,结尾会闪。
    /// 所以两头都是**夹到端点**,只有"一帧都没有"才返回 `None`。
    #[test]
    fn boot_frame_lookup_sticks_to_both_ends() {
        let at: Vec<u64> = vec![0, 100, 200, 300];
        assert_eq!(boot_frame_index_at(0, &at), Some(0), "正好第一帧");
        assert_eq!(boot_frame_index_at(50, &at), Some(0), "早于第一帧 → 停在第一帧");
        assert_eq!(boot_frame_index_at(100, &at), Some(1), "正好在某一帧上");
        assert_eq!(boot_frame_index_at(250, &at), Some(2), "两帧中间 → 用前那一帧");
        assert_eq!(boot_frame_index_at(300, &at), Some(3), "正好最后一帧");
        assert_eq!(
            boot_frame_index_at(99_999, &at),
            Some(3),
            "晚于最后一帧要停在最后一帧,而不是 None(那样会闪回内置动画)"
        );
        assert_eq!(
            boot_frame_index_at(0, &[]),
            None,
            "一帧都没有 = 没有可显示的,调用方据此回退内置动画"
        );
        // 第一帧不从 0 开始(比如源视频开头没有可解的画面):早于它仍要停在第一帧
        let late: Vec<u64> = vec![100, 200];
        assert_eq!(boot_frame_index_at(0, &late), Some(0));
    }

    /// 视频那一支的总时长要把**最后一帧的间隔**算进去。
    ///
    /// 只取最后一个偏移的话,最后一帧永远来不及显示(走到它的偏移就已经
    /// 判成"演完了")。
    #[test]
    fn boot_video_total_covers_the_last_frame() {
        assert_eq!(boot_video_total_ms(&[]), 0, "空表不该 panic");
        assert_eq!(
            boot_video_total_ms(&[0, 100, 200]),
            300,
            "3 帧 × 100 ms:最后一帧也要各占一帧的时间"
        );
    }

    /// 「该用 MP4 还是内置」:没选 / 选了但没抽完 / 选好了。
    ///
    /// 复发场景:判据写成只看 `boot_video.is_some()`。ffmpeg 没装好、文件损坏、
    /// 抽帧还没跑完时,用户看到的是一整块**黑**,只会以为程序坏了 ——
    /// 那时退一档放内置动画,至少还能看出"它在启动"。
    #[test]
    fn boot_uses_mp4_only_when_frames_are_ready() {
        let p = Path::new(r"C:\video\boot.mp4");
        assert!(!boot_use_video(None, &[]), "什么也没选 → 内置");
        assert!(
            !boot_use_video(None, &[0, 100]),
            "路径被清掉但帧表还在(清除时漏清了帧)→ 内置,别放上一个视频的残留"
        );
        assert!(!boot_use_video(Some(p), &[]), "选了但一帧都没抽到 → 内置(不是黑屏)");
        assert!(boot_use_video(Some(p), &[0]), "抽到一帧就得算数");
        assert!(boot_use_video(Some(p), &[0, 100, 200]), "选好了 → MP4");
    }

    /// 抽帧失败 / 文件不在时,那一句提示必须**是给用户看的**、带卡片前缀。
    ///
    /// 前缀不对的话卡片会把它过滤掉(`core_pages.rs::boot_message`),用户
    /// 就永远看不到"为什么我选的视频没生效"。
    #[test]
    fn boot_messages_carry_the_card_prefix() {
        // 卡片只认这个前缀,写成别的等于把话吞掉
        assert_eq!(BOOT_MSG_PREFIX, "启动动画:");
        // store 的键名是跨文件的字符串契约(本文件读、core_pages 写)
        assert_eq!(BOOT_VIDEO_KEY, "boot_video");
        assert_eq!(BOOT_SOUND_KEY, "boot_sound");
    }

    /// 「盘上已经就是这样」的判据:一样就不写盘(别动用户手工编辑过的文件),
    /// 不一样就必须写(否则改完重启回到默认,而界面上看不出异常)。
    #[test]
    fn boot_switch_write_is_skipped_only_when_nothing_changed() {
        // 全新盘:没选视频、声音缺省开 —— 一个键都不该被写上去
        assert!(boot_switches_unchanged(None, "1", None, None));
        assert!(
            boot_switches_unchanged(None, "1", Some(""), None),
            "盘上留着空串 = 没选,和「没有这个键」同义"
        );
        assert!(
            boot_switches_unchanged(None, "1", None, Some(" 1 ")),
            "值两边带空白不影响判断(手改过的文件常见)"
        );
        // 已经就是目标值:不写
        assert!(boot_switches_unchanged(
            Some(r"C:\anim\boot.mp4"),
            "0",
            Some(r"C:\anim\boot.mp4"),
            Some("0")
        ));
        // 真变了:必须写
        assert!(!boot_switches_unchanged(Some(r"C:\anim\boot.mp4"), "1", None, None));
        assert!(
            !boot_switches_unchanged(None, "0", None, None),
            "关掉声音也要落盘,否则重启又出声"
        );
        assert!(
            !boot_switches_unchanged(None, "1", Some(r"C:\anim\boot.mp4"), None),
            "清掉视频要落盘(键得删掉)"
        );
        assert!(
            !boot_switches_unchanged(
                Some(r"C:\anim\boot.mp4"),
                "1",
                Some(r"C:\anim\boot.mp4"),
                Some("0")
            ),
            "只变了声音那一个也要写"
        );
    }

    /// 两个开关的往返:写 → save → 重新 load 还在;写坏的值回落默认而不是 panic。
    #[test]
    fn boot_switches_roundtrip_through_the_store() {
        let p = std::env::temp_dir().join(format!(
            "nfarms-gui-boot-store-{}.json",
            std::process::id()
        ));
        let _ = std::fs::remove_file(&p);

        // 全新安装:没有视频、声音默认开
        let fresh = nfarms_core::store::Store::load(&p);
        assert_eq!(
            boot_video_from_store(fresh.get_str(BOOT_VIDEO_KEY).as_deref()),
            None
        );
        assert!(
            boot_sound_from_store(fresh.get_str(BOOT_SOUND_KEY).as_deref()),
            "默认该出声:选了 MP4 的人多半就是要连声音一起放"
        );

        // 用户选了视频、把声音关掉
        let mut saved = nfarms_core::store::Store::load(&p);
        saved.set_str(BOOT_VIDEO_KEY, r"C:\anim\boot.mp4");
        saved.set_str(BOOT_SOUND_KEY, "0");
        saved.save().unwrap();

        // 换一个 Store 实例重新读 —— 只有真落盘了才读得回来
        let back = nfarms_core::store::Store::load(&p);
        assert_eq!(
            boot_video_from_store(back.get_str(BOOT_VIDEO_KEY).as_deref()),
            Some(PathBuf::from(r"C:\anim\boot.mp4"))
        );
        assert!(!boot_sound_from_store(back.get_str(BOOT_SOUND_KEY).as_deref()));

        // 写坏的值:路径不是字符串 → 没选(用内置);开关说不清 → 回落默认(开)。
        // 两处都**不许 panic** —— 这段读的就是启动路径。
        std::fs::write(&p, r#"{"boot_video":42,"boot_sound":"也许"}"#).unwrap();
        let bad = nfarms_core::store::Store::load(&p);
        assert_eq!(
            boot_video_from_store(bad.get_str(BOOT_VIDEO_KEY).as_deref()),
            None,
            "不是字符串 = 读不出来,回默认而不是炸"
        );
        assert!(
            boot_sound_from_store(bad.get_str(BOOT_SOUND_KEY).as_deref()),
            "说不清的值回落到默认(开)"
        );
        // 用户把路径清空会留下空串,那也算"没选"
        assert_eq!(boot_video_from_store(Some("   ")), None);

        let _ = std::fs::remove_file(&p);
    }

    /// **原缺陷的正面那条**:选了 MP4 之后「启动时播放」必须是开。
    ///
    /// 判据在 `boot_video_set` 里走的就是这个函数 —— 不全的话,store.json 里
    /// `boot_video` 存住了而 `boot_anim` 没开:动画不播,用户以为"设置没保存",
    /// 而他从这个现象反推不出原因。
    #[test]
    fn picking_an_mp4_turns_the_boot_switch_on() {
        // 默认关(见 `App::new`)的时候选一个 MP4 → 必须被顶成开。
        assert!(
            boot_enabled_after_pick(true, false),
            "选了 MP4 却不开「启动时播放」= 存了但看不见效果,正是这次要修的缺陷"
        );
        // 已经开着:再选一个文件还是开(不能把它顶回去)。
        assert!(boot_enabled_after_pick(true, true), "选第二个文件不该把开关关掉");
        // 清掉视频**不反过来关**开关:那是用户对"启动时播放"的独立选择。
        assert!(
            boot_enabled_after_pick(false, true),
            "清视频只表示改回内置动画,不代表用户想关掉「启动时播放」"
        );
    }

    /// **反面那条**:没选视频时,不许自动打开「启动时播放」。
    ///
    /// 默认关是有意的(一个程序不该一启动就挡你 1.4 秒)。这条要是倒了,
    /// 「默认关」这个决定就被一次空手而归的调用悄悄作废了 —— 而且是每次都作废。
    #[test]
    fn not_picking_a_video_leaves_the_boot_switch_off() {
        assert!(
            !boot_enabled_after_pick(false, false),
            "没选 MP4 时不该自动打开「启动时播放」"
        );
        // 对照:`boot_video_set(None, …)` 走的就是 `false` 这一支 —— 它必须保持
        // 「没选过视频的全新安装」里那个默认值(`MiscState::default()` 是 false)。
        assert!(!MiscState::default().boot_enabled, "「启动时播放」默认必须是关");
    }

    /// 「`boot_anim` 要不要落盘」:一样就不写,不一样就必须写。
    ///
    /// 这条错了不会报错,只会**静默地**:要么白写(把用户手改过的 store.json
    /// 重排一遍),要么该写不写(选了 MP4 之后开关只留在内存里,重启回到关 ——
    /// 也就是原缺陷换个位置复发)。
    #[test]
    fn boot_anim_is_written_only_when_it_really_changed() {
        // 全新盘:没有这个键 = 默认关。想要关 → 一个字节都不该写。
        assert!(
            boot_anim_unchanged(false, None),
            "默认关的新安装不该被补写一个 boot_anim"
        );
        assert!(boot_anim_unchanged(false, Some("0")), "已经是关 → 不写");
        assert!(boot_anim_unchanged(true, Some("1")), "已经是开 → 不写");
        assert!(
            boot_anim_unchanged(true, Some(" 1 ")),
            "手改过、两边带空白的 1 照样算开(口径和启动那边读的一样)"
        );
        // **核心那一条**:选了 MP4 把开关从关顶成开 —— 盘上没有键 / 是 "0",
        // 都必须真的写下去,否则重启读回来还是关。
        assert!(!boot_anim_unchanged(true, None), "从默认关变成开必须落盘");
        assert!(!boot_anim_unchanged(true, Some("0")), "0 → 1 必须落盘");
        assert!(!boot_anim_unchanged(false, Some("1")), "用户取消了勾也要落盘");
        // 写坏的/说不清的值读出来是关(`boot_anim_from_store` 只认 "1"):
        // 想要开的时候必须覆盖掉它,而不是当成"已经一样"跳过。
        assert!(
            !boot_anim_unchanged(true, Some("也许")),
            "读不出来的值 = 关,想开就得写"
        );
        assert!(boot_anim_unchanged(false, Some("也许")), "读出来是关,想要关 → 不写");
    }

    /// 选 MP4 这一路要落到盘上的三个值:写 → save → 重新 load,`boot_anim` 也在。
    ///
    /// 上面两条测的是判据,这一条测**盘上真的读得回来**:`boot_video_set` 里
    /// 「顶成开 + 立刻落盘」是一次动作,少了落盘那半,现象还是"选了视频、重启不播"。
    #[test]
    fn picking_an_mp4_persists_the_boot_switch_along_with_the_path() {
        let p = std::env::temp_dir().join(format!(
            "nfarms-gui-boot-pick-{}.json",
            std::process::id()
        ));
        let _ = std::fs::remove_file(&p);

        // 全新盘:没选视频、开关默认关。这时 `save_boot_switches` 该一个字节都不写。
        let fresh = nfarms_core::store::Store::load(&p);
        assert!(boot_anim_unchanged(false, fresh.get_str(BOOT_ANIM_KEY).as_deref()));
        assert!(!p.exists(), "读设置不该有副作用:没变化就不许建文件/重排盘上那份");

        // 用户选了 MP4:`boot_video_set` 里那两步 —— 开关被顶成开,然后落盘。
        let mut s = nfarms_core::store::Store::load(&p);
        let enabled = boot_enabled_after_pick(true, false);
        assert!(enabled, "选了 MP4 → 开关变开");
        assert!(
            !boot_anim_unchanged(enabled, s.get_str(BOOT_ANIM_KEY).as_deref()),
            "开关从关变开必须落盘,否则重启读回来还是关"
        );
        s.set_str(BOOT_VIDEO_KEY, r"C:\anim\boot.mp4");
        s.set_str(BOOT_ANIM_KEY, if enabled { "1" } else { "0" });
        s.save().unwrap();

        // 换一个实例重新读:只有真落盘了才读得回来(启动时读的就是这两个键)。
        let back = nfarms_core::store::Store::load(&p);
        assert!(
            boot_anim_from_store(back.get_str(BOOT_ANIM_KEY).as_deref()),
            "重启后「启动时播放」必须还是开 —— 这就是用户看到的那个勾"
        );
        assert_eq!(
            boot_video_from_store(back.get_str(BOOT_VIDEO_KEY).as_deref()),
            Some(PathBuf::from(r"C:\anim\boot.mp4"))
        );

        let _ = std::fs::remove_file(&p);
    }
}