跳转至

crates\nfarms-gui\src\plugins\mod.rs

源码路径:crates\nfarms-gui\src\plugins\mod.rs · 760 行

//! 页面插件:把「侧栏里的一项」做成可插拔的卡片。
//!
//! 这是 cordis 那套东西在界面上的用法:
//!
//! * 内核启动时**先**把空的 [`PageRegistry`] 以服务名 `pages` 提供出来;
//! * 每个页面插件 `inject = ["pages"]`,在 `apply` 里把自己那张卡片
//!   ([`PageSpec`]:元信息 + 一个绘制函数指针)登记进去;
//! * 侧栏和内容区都只认注册表 —— **装了什么就显示什么**。
//!
//! 于是「给安卓版加一个功能」= 往 [`mobile`] 的列表里加一个 [`Page`],
//! 而不是去 `match` 里加分支、再去手改侧栏常量。
//!
//! 为什么 `draw` 是**函数指针**而不是 `Box<dyn Fn>`:页面都是无状态的
//! (状态全在 [`AppState`] 里),函数指针可 `Copy`、能跨锁传递、
//! 也不需要 `Arc<dyn Fn>` 那一层间接。

mod core_pages;
mod stubs;
pub(crate) mod ui;

use std::sync::{Arc, RwLock};

use eframe::egui;
use nfarms_plugin::{Context, Kernel, Plugin, Result};

use crate::AppState;
use crate::pages::{Group, Page, PageInfo, Sub, PAGES};

pub use ui::card;
/// 去掉 Markdown 粗体标记。弹出窗口的标题栏也要用,所以在模块外再导出一份。
pub use ui::plain as plain_text;

/// 页面注册表在服务容器里的名字。
pub const SERVICE_PAGES: &str = "pages";

/// 一张页面卡片:元信息 + 怎么画。
#[derive(Clone, Copy)]
pub struct PageSpec {
    pub info: &'static PageInfo,
    pub draw: fn(&mut PageCtx<'_>),
}

/// 侧栏上的一行(一级目录的直接内容)。
///
/// 三级结构落在侧栏里的形状:一级目录下面要么是**直接挂着的页**,要么是一个
/// **二级子分组**(它自己再挂几页)。两种可以混在同一个一级目录里
/// (Tools 下面既有 FFmpeg 这样的单页,也有 DS-Harness 这个子分组)。
#[derive(Clone)]
pub enum NavItem {
    /// 直接挂在一级目录下的一页
    Leaf(PageSpec),
    /// 二级子分组及其页面
    Subgroup(Sub, Vec<PageSpec>),
}

/// 一棵一级目录:分区本身 + 它下面的行(按侧栏顺序)。
pub struct NavTree {
    pub group: Group,
    pub items: Vec<NavItem>,
}

impl NavTree {
    /// 这一组里一共多少页(**含子分组里的**)—— 侧栏标题右边那个数字。
    pub fn page_count(&self) -> usize {
        self.items
            .iter()
            .map(|it| match it {
                NavItem::Leaf(_) => 1,
                NavItem::Subgroup(_, v) => v.len(),
            })
            .sum()
    }

    /// 当前页在不在这棵树里 —— 侧栏用它把「当前所在」标出来。
    pub fn holds(&self, page: Page) -> bool {
        self.items.iter().any(|it| match it {
            NavItem::Leaf(s) => s.info.page == page,
            NavItem::Subgroup(_, v) => v.iter().any(|s| s.info.page == page),
        })
    }
}

/// 注册表。
///
/// `Clone` 是**共享**的(内部 `Arc`),不是拷贝 —— 插件拿到手就能往里登记。
#[derive(Clone, Default)]
pub struct PageRegistry {
    slots: Arc<RwLock<Vec<PageSpec>>>,
}

impl std::fmt::Debug for PageRegistry {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let n = self.slots.read().map(|s| s.len()).unwrap_or(0);
        write!(f, "PageRegistry({n} 页)")
    }
}

impl PageRegistry {
    pub fn new() -> Self {
        Self::default()
    }

    /// 取读锁。**所有**访问器都从这里进 —— 锁中毒的策略只有这一份。
    ///
    /// 中毒时**直接崩,不降级成空表**,这是有意的取舍:写只发生在启动装载那几次
    /// ([`install`](Self::install)),中毒意味着某个线程把这张表改到一半就崩了,
    /// 表里剩下的内容已经不可信。降级成空表的话,界面上的表现是「侧栏一页都没有」,
    /// 和「插件根本没装」长得一模一样 —— 排查会一路去怀疑页面清单和装载流程;
    /// 直接崩则把现场指向真正出问题的那次 panic。
    /// (`Debug` 实现是唯一例外:它可能在 panic 展开期间被格式化,再崩一次会把
    ///  原始的 panic 盖掉,所以那里用 `unwrap_or(0)`。)
    fn read_slots(&self) -> std::sync::RwLockReadGuard<'_, Vec<PageSpec>> {
        self.slots.read().expect("页面表被毒化")
    }

    /// 取写锁,中毒策略同 [`read_slots`](Self::read_slots)。
    fn write_slots(&self) -> std::sync::RwLockWriteGuard<'_, Vec<PageSpec>> {
        self.slots.write().expect("页面表被毒化")
    }

    /// 登记一张卡片。同一个页面登记两次只留第一次 —— 重复装载插件时
    /// 不该在侧栏里出现两行一样的。
    pub fn install(&self, spec: PageSpec) {
        let mut slots = self.write_slots();
        if slots.iter().any(|s| s.info.page == spec.info.page) {
            return;
        }
        slots.push(spec);
    }

    pub fn len(&self) -> usize {
        self.read_slots().len()
    }

    /// 注册表里一页都没装上。
    ///
    /// 现在两条装载路径都不会走到这里:桌面版装整张 [`PAGES`],安卓版装 [`mobile()`]
    /// 里写死的 8 项(见测试 `mobile_only_registers_the_skeleton`)。
    /// (旧注释举的例子是「安卓骨架只剩天气」—— 那个形态早就不存在了。)
    ///
    /// TODO:还没有调用者,`#[allow(dead_code)]` 是因为本模块在 crate 私有
    /// (`lib.rs` 的 `mod plugins;`),这个 `pub fn` 在 crate 外不可达,没调用者就算死代码。
    /// 打算做的消费方在 `lib.rs` 的侧栏 / 内容区:「注册表为空」时给一句
    /// 「页面插件一页都没装上」,而不是画一片空白 —— 挂载点就是那里。
    /// 不顺手删掉的理由:它和 [`len`](Self::len) 是成对的查询,有 `len()` 没有
    /// `is_empty()` 的小 API 是残的(clippy 的 `len_without_is_empty` 盯的就是这种配对)。
    #[allow(dead_code)]
    pub fn is_empty(&self) -> bool {
        self.len() == 0
    }

    /// 拷一份出来。侧栏和内容区都在**没有锁**的情况下遍历它 ——
    /// 免得绘制过程中某个插件又登记一页,把正在遍历的迭代器搞坏。
    ///
    /// 注意调用频率:这是**每次重绘**都会走一次的路径
    /// (`lib.rs` 的 `sidebar` → [`grouped`](Self::grouped) → 这里),一次读锁 +
    /// 整份 `Vec` 拷贝。现在三十几项,这点拷贝无所谓;真涨到几百项就得换成
    /// 「在 `App` 里缓存一份快照、只在注册表变化时重建」,而不是每帧克隆一遍。
    pub fn specs(&self) -> Vec<PageSpec> {
        self.read_slots().clone()
    }

    pub fn get(&self, page: Page) -> Option<PageSpec> {
        self.read_slots()
            .iter()
            .find(|s| s.info.page == page)
            .copied()
    }

    pub fn contains(&self, page: Page) -> bool {
        self.get(page).is_some()
    }

    /// 装载之后的第一页(启动时的兜底)。
    pub fn first(&self) -> Option<Page> {
        self.read_slots().first().map(|s| s.info.page)
    }

    /// 按功能树分组,并且**只返回有内容的组**。
    ///
    /// 这是 [`grouped_tree`](Self::grouped_tree) 的**扁平前身**。三级菜单的侧栏
    /// 已经改成画那棵树了,所以它现在**只有测试在用**(本模块与 `lib.rs` 的侧栏
    /// 测试要的是「按组算页数」这个口径,不需要树)。
    ///
    /// 为什么不干脆删掉:那几条测试里的期望值是按组写的(`["today", "weather"]`),
    /// 换成树的形状等于把它们重写一遍,而它们守的东西没变。留着这个「数据库里的
    /// 一个视图」比让每条断言都多写一层 `match NavItem::…` 划算。
    /// `#[allow(dead_code)]` 只对**非测试**构建生效,所以它盖不住「没人用」以外的问题。
    #[allow(dead_code)]
    pub fn grouped(&self) -> Vec<(Group, Vec<PageSpec>)> {
        // 每帧一次读锁 + 整份拷贝 —— 缘由和扩容条件写在 `specs()` 的文档里
        let specs = self.specs();
        Group::ALL
            .iter()
            .filter_map(|g| {
                let children: Vec<PageSpec> =
                    specs.iter().filter(|s| s.info.group == *g).copied().collect();
                if children.is_empty() {
                    None
                } else {
                    Some((*g, children))
                }
            })
            .collect()
    }

    /// 三级结构下的侧栏树:一级目录 → (直接挂着的页 | 二级子分组 → 页)。
    ///
    /// 两条顺序性质都在这儿落地:
    ///
    /// * 一级目录顺序 = [`Group::ALL`];
    /// * 每个容器里的顺序 = **本注册表里的登记顺序**(桌面版就是 `pages.rs`
    ///   那张表的顺序,安卓版就是 [`mobile()`] 的顺序)。
    ///
    /// 用户拖出来的 `nav_order` **不在这里**应用 —— 那是绘制层的事
    /// (`lib.rs` 的 `sidebar` 按 `rank` 现排),因为这里每帧都会跑一遍。
    ///
    /// 「直接挂着的页」排在子分组**前面**:一级目录自己那一项(如 Tools ›
    /// DS-Harness、Player › Music)在功能树里就是排在二级目录之前的。
    /// 这条性质由 `grouped_tree_puts_leaves_before_subgroups` 钉住。
    pub fn grouped_tree(&self) -> Vec<NavTree> {
        let specs = self.specs();
        Group::ALL
            .iter()
            .filter_map(|g| {
                let mine: Vec<PageSpec> =
                    specs.iter().filter(|s| s.info.group == *g).copied().collect();
                if mine.is_empty() {
                    return None;
                }
                let mut items: Vec<NavItem> = Vec::new();
                // 先直接挂着的页
                for s in mine.iter().filter(|s| s.info.sub.is_none()) {
                    items.push(NavItem::Leaf(*s));
                }
                // 再二级子分组(顺序 = `Sub::of`,也就是各子分组首页在表里的先后)
                for sub in Sub::of(*g) {
                    let kids: Vec<PageSpec> = mine
                        .iter()
                        .filter(|s| s.info.sub == Some(sub))
                        .copied()
                        .collect();
                    if !kids.is_empty() {
                        items.push(NavItem::Subgroup(sub, kids));
                    }
                }
                Some(NavTree { group: *g, items })
            })
            .collect()
    }
}

/// 页面绘制时能拿到的东西。
///
/// 插件**不直接摸 `App`**,只摸这个 —— 以后加一页不需要动 `App` 的可见性,
/// 也不需要把 `App` 整块借出去。
pub struct PageCtx<'a> {
    pub page: Page,
    pub ui: &'a mut egui::Ui,
    /// 窗口上下文(要开后台线程、请求重绘、发视口命令时用)
    pub egui: &'a egui::Context,
    /// 界面共享状态(设置、日志、天气、背景图、本地模型……)
    pub state: &'a mut AppState,
    /// 插件内核 —— 插件页要列装载表就得看它
    pub kernel: &'a Kernel,
    /// 页面内部点了「跳到某一页」时往这里塞,由外层统一处理
    pub nav: &'a mut Option<Page>,
}

impl PageCtx<'_> {
    /// 页面里发起一次跳转。
    pub fn goto(&mut self, page: Page) {
        *self.nav = Some(page);
    }
}

/// 一个页面 = 一个插件。
pub struct PagePlugin {
    pub spec: PageSpec,
}

impl Plugin for PagePlugin {
    fn name(&self) -> &str {
        self.spec.info.id
    }

    /// 插件版本 = GUI crate 版本,**有意统一**(不是忘了写页面级版本)。
    ///
    /// 页面插件跟 App 在同一个二进制里、同一个发布节奏,单独编版本号没有消费方:
    /// 装载表(`core_pages::manager`)每行只画 `id` 和装载状态,不显示页面级版本。
    /// 真要页面级版本,得先给 `PageInfo` 加字段**并**让装载表读它 ——
    /// 只加字段没人读就是半截契约,不如不加。
    fn version(&self) -> &str {
        env!("CARGO_PKG_VERSION")
    }

    fn inject(&self) -> &[&'static str] {
        &[SERVICE_PAGES]
    }

    fn apply(&self, ctx: &Context) -> Result<()> {
        let registry = ctx.require::<PageRegistry>(SERVICE_PAGES)?;
        registry.install(self.spec);
        Ok(())
    }
}

/// 页面 → 绘制函数。插件注册表、命令行、侧栏全靠这一处映射,
/// 加一页只要在这儿加一行 + 在 [`PAGES`] 里加一条。
///
/// 这一版把菜单改成三级之后,新登记进来的页大多**只有骨架**:它们的
/// [`PageInfo::note`] 里会写清「要实现什么」,绘制走 [`stubs::placeholder`] ——
/// 见下面那段说明。已经实现的页照旧一行一个真渲染函数。
fn renderer_for(page: Page) -> fn(&mut PageCtx<'_>) {
    match page {
        // ---- Today's Info ----
        // 首页**只有一页**:资讯与天气合成它了(见 pages.rs 里那条说明),
        // 所以没有独立的 `weather` 页 —— `--page weather` 现在落到这一页。
        Page::TodayInfo => core_pages::today_info,
        // ---- Tools ----
        Page::Ffmpeg => core_pages::ffmpeg_page,
        Page::DsHarness => core_pages::dsh_page,
        Page::Tokens => core_pages::tokens,
        Page::RecordingBiliGo => core_pages::bilibili_go,
        // ---- Player ----
        // Music 那一页**已经能用**(曲库 / 播放列表 / 音量),新菜单把它挂在
        // Player › Music 下面,于是它从「一级页」变成「二级目录的落点页」,
        // 实现一行没动。它下面那五项(收藏 / 列表 / 平台 / 有声书 / 配置)是骨架。
        Page::Music => core_pages::music,
        Page::ImageViewer => core_pages::image_viewer,
        Page::Video => core_pages::video,
        // ---- Browser ----
        // Tauri:内置浏览器换内核这件事还没做,先把原来的引擎/内核两页归到它名下
        Page::Tauri => core_pages::browser_engine,
        Page::Grafana => core_pages::grafana,
        Page::DshStarter => core_pages::dsh_page,
        Page::BiliGo => core_pages::bilibili_go,
        Page::BrowserBookmarks => core_pages::bookmarks,
        // ---- Zero Trust ----
        Page::Topo => core_pages::topo,
        Page::Channel => core_pages::channel,
        Page::SecureCrt => core_pages::secure_crt,
        Page::PacketCapture => core_pages::packet_capture,
        // ---- Environment ----
        // Environment 与 Language Framework 都是「二级条目里属于页的那种」:
        // 前者是这一组的落点,后者是原「运行时环境」的落点(探测结果就是它)。
        Page::Environment => core_pages::manager,
        Page::Plugins => core_pages::manager,
        Page::LanguageFramework => core_pages::environment,
        Page::LangRuntime => core_pages::environment,
        // 本地模型这一块整体收进了 Plugins › Model 这一页:
        // 它内部用四个标签切换原来那四块(见 core_pages::model_page)
        Page::Model => core_pages::model_page,
        // ---- Setting ----
        Page::SettingsBackground => core_pages::settings,
        Page::Pet => core_pages::pet,
        Page::SettingsPrinter => core_pages::printer,
        Page::SettingsCloud => core_pages::cache_path,
        Page::SettingsStoragePath => core_pages::storage_path,
        Page::SettingsLogPath => core_pages::log_path,
        Page::SettingsSync => core_pages::sync,

        // ---- 以下这些页**还没实现**:菜单结构先落地,功能按 note 里的口径逐个补 ----
        //
        // 兜底分支 `_ => stubs::placeholder` 重新挂上是有意的:上一版 34 页全部
        // 有真实现,那个分支成了 unreachable pattern,编译器替我们证明「一页都没漏」。
        // 这一版一口气加了 **33 页骨架**(52 页里 19 页有真实现),要是逐行写
        // `stubs::placeholder` 就得写 33 行;留着兜底则「新加一页 = 只改 pages.rs」
        // 依然成立,而且**上面那 19 行才是显式的**:一眼看得出哪些页是真做的。
        //
        // 想给某一页换成真实现:在上面加一行,它就不再走这里。
        // (这一层查不了的:「复制一行忘了改右半边」照样编译过 —— 只能靠人眼 review。)
        Page::DshConfig
        | Page::IpAddress
        | Page::LabelPrinting
        | Page::MusicCollect
        | Page::MusicListing
        | Page::MusicPlatform
        | Page::MusicAudiobooks
        | Page::MusicConfig
        | Page::VideoXiami
        | Page::VideoHellotik
        | Page::VideoListing
        | Page::VideoCollect
        | Page::IlSpy
        | Page::Fernflower
        | Page::Jadx
        | Page::Infrastructure
        | Page::CordisConfig
        | Page::SystemVariable
        | Page::LangQt
        | Page::LangJdk
        | Page::LangDotnet
        | Page::LangRust
        | Page::LangCmark
        | Page::LangPython
        | Page::LangNodejs
        | Page::LangAndroid
        | Page::BgUi
        | Page::BgStartM
        | Page::BgWallpaper
        | Page::SettingsWeather
        | Page::SettingsProxy
        | Page::SettingsPort
        | Page::SettingsLogging => stubs::placeholder,
    }
}

fn plugin_for(page: Page) -> PagePlugin {
    PagePlugin {
        spec: PageSpec {
            info: page.info(),
            draw: renderer_for(page),
        },
    }
}

/// 桌面版:整棵功能树都装上。
pub fn desktop() -> Vec<PagePlugin> {
    PAGES.iter().map(|info| plugin_for(info.page)).collect()
}

/// 桌面版的**注册表本身**(不经过插件内核)。
///
/// 给**命令行**用的:`nfarms --list-pages` 要打三级菜单,
/// 而它没有也不该建一个窗口 —— 建内核 + 装插件的代价在这个场景里没有意义
/// (插件的 `apply` 只是往注册表里登记一张卡片,见 [`PagePlugin::apply`])。
///
/// 为什么不在 `nfarms-app` 里自己拼一个表:那样命令行看到的菜单和界面上
/// 画的菜单就变成两份实现,迟早对不上 —— 而 `--list-pages` 正是冒烟脚本
/// 用来问「程序一共有哪些页」的入口,它必须与侧栏逐条一致。
pub fn desktop_registry() -> PageRegistry {
    let registry = PageRegistry::new();
    for info in PAGES {
        registry.install(PageSpec {
            info,
            draw: renderer_for(info.page),
        });
    }
    registry
}

/// 安卓版:只装**真的能在手机上跑**的那些页。
///
/// 清单是照一个硬标准挑的:**不碰任何桌面才有的东西**(cmd.exe、注册表、
/// dumpcap、MCI、ActiveX……)。按这个标准过一遍,剩下这些:
///
/// | 页 | 为什么能上安卓 |
/// |---|---|
/// | Today's Info | 纯 HTTP(少数派 RSS / 百度热搜) |
/// | 天气 | 纯 HTTP |
/// | 拓扑验证器 | TcpStream + HTTP,`/proc` 都不碰 |
/// | Image | 纯 Rust 解码 |
/// | Background | 纯解码 + 文件(启动动图的开关也在这里,同样不碰桌面 API) |
/// | 日志路径 | **纯文件 I/O**,含分页日志查看器 |
/// | 运行时环境 | `capture` 目前走 `cmd.exe`,这一版**先按骨架装**(见下面的说明) |
/// | libs | 读内核自己的状态 |
///
/// **时间同步(原 TimeSync)已经删掉了**:它在新菜单里没有落点,
/// 而安卓上手机时钟本来就靠系统同步,留着这一页只是多一个入口。
///
/// **Environment 那一页的口径变了**:它原来写成「Runtime」且**不装**,
/// 理由是 `capture` 走 `cmd.exe /d /c`,安卓上根本没有 —— 装进去只会让
/// 8 个工具全显示「未安装」,那是**看起来能用其实在骗人**。这一版先把这一组
/// (Language Framework)收进菜单,安卓侧按同一个理由**仍然不装**,等
/// `capture` 有了 `sh -c` 分支再放进来(`cyinfo` 的安卓取数已经在等这个分支,
/// 见 `cyinfo::fill_android`)。
pub fn mobile() -> Vec<PagePlugin> {
    [
        Page::TodayInfo,
        Page::Topo,
        Page::ImageViewer,
        Page::SettingsBackground,
        Page::SettingsLogPath,
        Page::Plugins,
    ]
    .iter()
    .map(|&p| plugin_for(p))
    .collect()
}

/// 把页面插件装进内核,并把注册表以 `pages` 服务提供出去。
///
/// 顺序很重要:**先 provide 空注册表,再装页面插件** ——
/// 反过来的话每个页面插件都会因为 `inject` 没满足而挂起。
///
/// 返回值就是 provide 进去的那**同一个**表(`PageRegistry` 的 `Clone` 是共享内部
/// `Arc`,不是拷贝)。所以调用方拿它当 `App.pages` 之后,和以后从内核服务 `pages`
/// 里取的那份(`core_pages::manager` 取 `specs()` 画装载表)是同一张表 ——
/// 侧栏和装载表看到的永远一致,不会有「两处各说各话」。
pub fn install(kernel: &Kernel, plugins: Vec<PagePlugin>) -> PageRegistry {
    let registry = PageRegistry::new();
    kernel.provide(SERVICE_PAGES, registry.clone());
    kernel.load_all(plugins);
    registry
}

#[cfg(test)]
mod tests {
    use super::*;
    use nfarms_plugin::{Plugin, PluginState};

    #[test]
    fn desktop_registers_every_page() {
        let kernel = Kernel::new();
        let registry = install(&kernel, desktop());
        kernel.ready().expect("桌面版所有插件都该装载成功");
        assert_eq!(registry.len(), PAGES.len());
        for info in PAGES {
            assert!(registry.contains(info.page), "{} 没登记上", info.id);
        }
    }

    /// 删掉的页面必须从**运行时那一份清单**里也一起消失。
    ///
    /// 路由那一侧由 `renderer_for` 的 match 穷尽性在**编译期**守着 ——
    /// 枚举里没有的变体根本写不出分支来(这次删 `SettingsBootAnim` 时,
    /// 编译器当场就把漏改的那一行指出来了)。这条测试补的是运行时的另一半:
    /// 桌面注册表是从 `PAGES` 反推的,页面只从表里划掉、却还有第二条清单在喂它,
    /// 侧栏和装载表就会各说各话。
    #[test]
    fn a_removed_page_is_gone_from_the_registry_too() {
        let kernel = Kernel::new();
        let registry = install(&kernel, desktop());
        kernel.ready().unwrap();
        assert_eq!(Page::from_id("boot-anim"), None, "页面清单里还留着启动动图");
        assert_eq!(registry.len(), PAGES.len());
        let setting: Vec<&str> = registry
            .grouped()
            .iter()
            .filter(|(g, _)| *g == Group::Setting)
            .flat_map(|(_, specs)| specs.iter().map(|s| s.info.id))
            .collect();
        assert!(!setting.contains(&"boot-anim"), "Setting 组里还挂着启动动图这一页");
        assert!(
            setting.contains(&"background"),
            "BackGround 页还得留在 Setting 组里 —— 启动动图的开关归它管"
        );
    }

    #[test]
    fn mobile_only_registers_the_skeleton() {
        let kernel = Kernel::new();
        let registry = install(&kernel, mobile());
        kernel.ready().unwrap();
        assert_eq!(registry.len(), 6);
        assert!(registry.contains(Page::TodayInfo));
        assert!(registry.contains(Page::Topo));
        assert!(registry.contains(Page::ImageViewer));
        assert!(registry.contains(Page::SettingsLogPath), "日志查看器是纯文件 I/O,手机上有用");
        assert!(registry.contains(Page::Plugins));
        assert!(
            !registry.contains(Page::LangRuntime),
            "运行时环境的 capture 走 cmd.exe,安卓上还没适配,不该装进去"
        );
        assert!(
            !registry.contains(Page::PacketCapture),
            "抓包要 dumpcap,安卓上没有"
        );
        // 一级目录:Today's Info(置顶) / Player / Vpn / Environment / Setting
        // **没有 Tools**:那底下全是外部可执行文件(ffmpeg / bililive-go / 打印网关),
        // 安卓上装着只会显示「未安装」,是看起来能用其实在骗人。
        let groups: Vec<Group> = registry.grouped().iter().map(|(g, _)| *g).collect();
        assert_eq!(
            groups,
            vec![
                // Today's Info 置顶 —— 顺序跟着 `Group::ALL` 走
                Group::TodayInfo,
                Group::Player,
                Group::Vpn,
                Group::Environment,
                Group::Setting
            ]
        );
    }

    #[test]
    fn pages_hang_until_the_registry_is_provided() {
        // 先装页面插件、后给注册表 —— cordis 的 inject 语义:
        // 应该先挂起,等服务出现再自动装上。
        let kernel = Kernel::new();
        kernel.load(plugin_for(Page::TodayInfo));
        assert_eq!(kernel.state_of("today"), Some(PluginState::Pending));

        kernel.provide(SERVICE_PAGES, PageRegistry::new());
        assert_eq!(kernel.state_of("today"), Some(PluginState::Active));
    }

    #[test]
    fn installing_the_same_page_twice_keeps_one() {
        let registry = PageRegistry::new();
        let spec = PageSpec {
            info: Page::TodayInfo.info(),
            draw: stubs::placeholder,
        };
        registry.install(spec);
        registry.install(spec);
        assert_eq!(registry.len(), 1);
    }

    #[test]
    fn grouped_follows_the_feature_tree_order() {
        let kernel = Kernel::new();
        let registry = install(&kernel, desktop());
        let groups: Vec<&str> = registry.grouped().iter().map(|(g, _)| g.nav()).collect();
        assert_eq!(
            groups,
            [
                "Today's Info",
                "Tools",
                "Player",
                "Browser",
                "Zero Trust",
                "Environment",
                "Setting"
            ]
        );
    }

    /// 三级树的形状:一级目录下面**先直接挂着的页、再子分组**,
    /// 而且每个容器里的页数加起来正好是全部页面。
    ///
    /// 这条和 `grouped()` 那条并存是有意的:老的扁平访问器还在(安卓装载表、
    /// 各处测试都在用),但**侧栏画的是这一棵**。两条路要是各说各话,
    /// 症状就是「侧栏里少了一页,而页面清单说它在」。
    #[test]
    fn grouped_tree_puts_leaves_before_subgroups() {
        let kernel = Kernel::new();
        let registry = install(&kernel, desktop());
        let tree = registry.grouped_tree();
        assert_eq!(tree.len(), Group::ALL.len(), "一级目录少了");
        let mut total = 0usize;
        for node in &tree {
            total += node.page_count();
            let mut seen_sub = false;
            for it in &node.items {
                match it {
                    NavItem::Leaf(_) => {
                        assert!(
                            !seen_sub,
                            "{}:直接挂着的页排到子分组后面去了",
                            node.group.nav()
                        );
                    }
                    NavItem::Subgroup(_, kids) => {
                        seen_sub = true;
                        assert!(!kids.is_empty(), "空子分组不该出现在侧栏里");
                        for s in kids {
                            assert_eq!(
                                s.info.group, node.group,
                                "{}:页面 {} 挂到了别的组下",
                                node.group.nav(),
                                s.info.id
                            );
                        }
                    }
                }
            }
        }
        assert_eq!(total, PAGES.len(), "三级树里的页数 != 页面总数");
        // 扁平那条访问器必须仍然覆盖全部页面(装载表还在用它)
        let flat: usize = registry.grouped().iter().map(|(_, v)| v.len()).sum();
        assert_eq!(flat, total, "扁平分组与三级树的页数对不上");
    }

    #[test]
    fn empty_registry_is_empty_until_something_is_installed() {
        // `is_empty` 的文档说「两条装载路径都不会产生空表」。前半句在这儿钉住
        // (空 → 装一页 → 非空),后半句钉住真正那两条名单本身非空。
        let registry = PageRegistry::new();
        assert!(registry.is_empty());
        assert_eq!(registry.len(), 0);

        registry.install(PageSpec {
            info: Page::TodayInfo.info(),
            draw: stubs::placeholder,
        });
        assert!(!registry.is_empty());
        assert_eq!(registry.len(), 1);

        assert!(!desktop().is_empty(), "桌面版页面名单不该是空的");
        assert!(!mobile().is_empty(), "安卓版页面名单不该是空的");
    }

    #[test]
    fn specs_returns_a_snapshot_not_a_live_view() {
        // `specs()` 承诺「拷一份出来」:侧栏每帧拿着它遍历,遍历期间插件再登记一页
        // 也不能改到已经取走的那一份(否则就成了边遍历边改)。
        let registry = PageRegistry::new();
        registry.install(PageSpec {
            info: Page::TodayInfo.info(),
            draw: stubs::placeholder,
        });
        let snapshot = registry.specs();

        registry.install(PageSpec {
            info: Page::Topo.info(),
            draw: stubs::placeholder,
        });
        assert_eq!(
            snapshot.len(),
            1,
            "快照被后来的 install 改到了,说明返回的不是拷贝"
        );
        assert_eq!(registry.specs().len(), 2);
    }

    #[test]
    fn a_poisoned_table_panics_instead_of_reporting_an_empty_sidebar() {
        // 中毒策略(见 `read_slots` 的文档):宁可崩也不降级成空表 ——
        // 空表在界面上看起来就是「插件一页都没装」,会把排查引偏。
        // 谁把 `expect` 换成 `unwrap_or_default()`,这条测试就会红。
        let registry = PageRegistry::new();
        let writer = registry.clone();
        let _ = std::thread::spawn(move || {
            let _guard = writer.write_slots();
            panic!("故意在持有写锁时崩,把锁毒掉");
        })
        .join();

        let got = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| registry.len()));
        assert!(got.is_err(), "锁中毒之后 len() 该崩,而不是悄悄报 0");

        // 唯一必须降级的地方:Debug 可能在 panic 展开期间被调用,不能再崩一次
        assert_eq!(format!("{registry:?}"), "PageRegistry(0 页)");
    }

    #[test]
    fn the_returned_registry_is_the_same_table_the_kernel_publishes() {
        // `install()` 的返回值与内核服务 `pages` 必须是同一个 `Arc`:
        // 侧栏画的是 `App.pages`,装载表画的是内核服务里那份 —— 一旦变成两份拷贝
        // 就会各说各话(装上去的页在装载表里查不到)。
        let kernel = Kernel::new();
        let registry = install(&kernel, Vec::new());
        let published = kernel
            .context()
            .get::<PageRegistry>(SERVICE_PAGES)
            .expect("install 之后内核里必须有 pages 服务");
        assert_eq!(published.len(), 0);

        published.install(PageSpec {
            info: Page::TodayInfo.info(),
            draw: stubs::placeholder,
        });
        assert!(registry.contains(Page::TodayInfo), "两条入口不是同一张表");
        assert_eq!(registry.len(), 1);
    }

    #[test]
    fn every_page_plugin_reports_the_same_gui_crate_version() {
        // `version()` 的文档说「有意统一」:不做页面级版本。这条测试把「统一」钉住 ——
        // 哪天有人给某一页单独编了版本,它会红,逼着同时回来改那份文档。
        let plugins = desktop();
        let mut versions: Vec<&str> = plugins.iter().map(|p| p.version()).collect();
        versions.sort_unstable();
        versions.dedup();
        assert_eq!(versions, vec![env!("CARGO_PKG_VERSION")]);
    }
}