crates\nfarms-gui\src\plugins\core_pages.rs¶
源码路径:
crates\nfarms-gui\src\plugins\core_pages.rs· 6631 行
//! 真正画出来的那几页。
//!
//! 其余的页面在 `stubs.rs` 里共用一个占位绘制函数 —— 这里只放
//! **已经能用**的功能:天气、背景图片设置、本地大模型三页、以及插件装载表。
//!
//! 原来这些函数都挂在 `App` 上(`self.weather_page(...)` 之类),
//! 拆成插件后统一收成 `fn(&mut PageCtx)`,于是「加一页」不再需要碰
//! `App` 的实现。
use eframe::egui::{self, Align2, Color32, FontId, RichText};
use nfarms_core::envprobe::PkgMgr;
use nfarms_core::observability::{self, Service};
use nfarms_core::{libs, weather};
use super::{PageCtx, SERVICE_PAGES, ui};
use crate::pages::{Page, WeatherState};
use crate::theme;
use crate::{AppState, Job, background, icons};
use std::cell::RefCell;
use std::path::{Path, PathBuf};
// ===========================================================================
// 天气
// ===========================================================================
pub fn weather(cx: &mut PageCtx<'_>) {
// ---- 地点 + 刷新 ----
let mut refresh = false;
cx.ui.horizontal(|ui| {
ui.label(RichText::new("地点").size(13.0).color(theme::TEXT_SUB));
let resp = ui.add_sized(
[200.0, 30.0],
egui::TextEdit::singleline(&mut cx.state.weather_location).hint_text("shanghai"),
);
let submitted = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
let clicked = ui
.add_sized([96.0, 30.0], egui::Button::new("刷新天气"))
.clicked();
if (submitted || clicked) && cx.state.weather_rx.is_none() {
refresh = true;
}
if cx.state.weather_rx.is_some() {
ui.spinner();
ui.label(RichText::new("获取中…").size(12.0).color(theme::TEXT_SUB));
}
});
if refresh {
cx.state.settings.weather_location = cx.state.weather_location.clone();
cx.state.save_settings();
cx.state.start_weather_fetch(cx.egui);
}
cx.ui.add_space(12.0);
match &cx.state.weather {
WeatherState::Loading => {
ui::card(cx.ui, |ui| {
ui.add_space(24.0);
ui.vertical_centered(|ui| {
ui.spinner();
ui.add_space(6.0);
ui.label(
RichText::new("正在获取天气…")
.size(14.0)
.color(theme::TEXT_SUB),
);
});
ui.add_space(24.0);
});
}
WeatherState::Failed(err) => {
let err = err.clone();
ui::card(cx.ui, |ui| {
ui::err_hint(ui, "获取失败");
ui.add_space(4.0);
ui.label(RichText::new(&err).size(12.0).color(theme::TEXT_SUB));
ui.add_space(4.0);
ui.label(
RichText::new("离线时不影响其它页面。检查地点拼写或网络后点「刷新天气」重试。")
.size(12.0)
.color(theme::TEXT_SUB),
);
});
}
WeatherState::Ready(report) => {
weather_body(cx.ui, report, &cx.state.background);
}
}
}
fn weather_body(ui: &mut egui::Ui, report: &weather::WeatherReport, bg: &background::Background) {
let now = &report.now;
// ---- 渐变主卡片 ----
let hero_h = 156.0;
let (hero, _) = ui.allocate_exact_size(
egui::vec2(ui.available_width(), hero_h),
egui::Sense::hover(),
);
{
let p = ui.painter();
// 圆角靠「用背景色把角补回来」实现。背景是纯色就用纯色;
// 背景是照片就按同一个 cover 映射从照片上取那一小块的颜色,
// 否则四个角会露出色块(见 background::Background::sample)。
let screen = ui.ctx().screen_rect();
theme::gradient_card(
p,
hero,
theme::RADIUS_CARD,
theme::HERO_TOP,
theme::HERO_BOTTOM,
|pos| bg.sample(pos, screen).unwrap_or(theme::APP_BG),
);
let x = hero.left() + 28.0;
p.text(
egui::pos2(x, hero.top() + 22.0),
Align2::LEFT_TOP,
format!("{}°C", now.temp),
FontId::proportional(50.0),
Color32::WHITE,
);
p.text(
egui::pos2(x, hero.top() + 86.0),
Align2::LEFT_TOP,
format!("{} {}", weather::icon_for(&now.desc), now.desc),
FontId::proportional(17.0),
Color32::from_rgba_unmultiplied(255, 255, 255, 225),
);
p.text(
egui::pos2(x, hero.top() + 112.0),
Align2::LEFT_TOP,
format!("体感 {}°C", now.feels),
FontId::proportional(13.0),
Color32::from_rgba_unmultiplied(255, 255, 255, 170),
);
let rx = hero.right() - 28.0;
let mut city = now.city.clone();
if !now.region.is_empty() {
city.push_str(" / ");
city.push_str(&now.region);
}
p.text(
egui::pos2(rx, hero.top() + 24.0),
Align2::RIGHT_TOP,
city,
FontId::proportional(16.0),
Color32::WHITE,
);
p.text(
egui::pos2(rx, hero.top() + 48.0),
Align2::RIGHT_TOP,
format!("观测 {}", now.obs_time),
FontId::proportional(12.0),
Color32::from_rgba_unmultiplied(255, 255, 255, 165),
);
let metrics = [
("湿度", format!("{}%", now.humidity)),
("风速", now.wind.clone()),
("紫外线", now.uv.clone()),
("能见度", format!("{} km", now.visibility)),
];
for (i, (label, value)) in metrics.iter().enumerate() {
let bx = rx - 300.0 + (i as f32) * 78.0;
p.text(
egui::pos2(bx, hero.top() + 92.0),
Align2::LEFT_TOP,
*label,
FontId::proportional(11.0),
Color32::from_rgba_unmultiplied(255, 255, 255, 150),
);
p.text(
egui::pos2(bx, hero.top() + 108.0),
Align2::LEFT_TOP,
value.clone(),
FontId::proportional(14.0),
Color32::WHITE,
);
}
}
ui.add_space(14.0);
ui.label(RichText::new("未来 3 天").size(14.0).color(theme::TEXT_MAIN));
ui.add_space(8.0);
if report.days.is_empty() {
ui::card(ui, |ui| {
ui.label(
RichText::new("这份响应里没有预报数据")
.size(13.0)
.color(theme::TEXT_SUB),
);
});
return;
}
let n = report.days.len().min(3);
ui.columns(n, |cols| {
for (i, day) in report.days.iter().take(n).enumerate() {
ui::card(&mut cols[i], |ui| {
ui.label(
RichText::new(format!("{} {}", day.date, day.week))
.size(12.0)
.color(theme::TEXT_SUB),
);
ui.add_space(6.0);
ui.label(
RichText::new(format!("{} {}", day.icon, day.desc))
.size(15.0)
.color(theme::TEXT_MAIN),
);
ui.add_space(6.0);
ui.label(
RichText::new(format!("{}°C ~ {}°C", day.min, day.max))
.size(18.0)
.color(theme::TEXT_MAIN),
);
});
}
});
}
// ===========================================================================
// Setting → 背景图片 / 启动动画
// ===========================================================================
pub fn settings(cx: &mut PageCtx<'_>) {
// ---- 背景图片 ----
ui::card(cx.ui, |ui| {
ui::section(
ui,
"背景图片",
"留空 = 使用默认纯色底。也可以直接把图片拖进窗口。",
);
ui.add_space(8.0);
ui.horizontal(|ui| {
let (rect, _) = ui.allocate_exact_size(egui::vec2(160.0, 90.0), egui::Sense::hover());
let p = ui.painter().clone();
if cx.state.background.is_set() {
cx.state.background.paint(&p, rect);
} else {
p.rect_filled(rect, 6.0, theme::APP_BG);
p.rect_stroke(
rect,
6.0,
egui::Stroke::new(1.0_f32, theme::BORDER),
egui::StrokeKind::Inside,
);
p.text(
rect.center(),
Align2::CENTER_CENTER,
"默认纯色",
FontId::proportional(12.0),
theme::TEXT_SUB,
);
}
ui.vertical(|ui| {
ui.label(
RichText::new(match &cx.state.background.path {
Some(p) => p.display().to_string(),
None => "(未设置)".to_string(),
})
.size(12.0)
.color(theme::TEXT_MAIN),
);
if let Some((w, h)) = cx.state.background.source_size {
ui.label(
RichText::new(format!("原始尺寸 {w} × {h}"))
.size(11.0)
.color(theme::TEXT_SUB),
);
}
if let Some(err) = cx.state.background.error.clone() {
ui::err_hint(ui, &err);
}
ui.add_space(6.0);
let mut pick = false;
let mut clear = false;
ui.horizontal(|ui| {
let pending = cx.state.picker.is_pending();
if background::PICKER_SUPPORTED {
// 按钮上不摆 📂 / ↺ 这种符号:egui 自带字体只覆盖 emoji,
// 覆盖不了几何符号和箭头,真机上会有一半变成豆腐块。
// 要么用 icons::draw 自己画,要么老老实实写字 —— 这里写字。
if ui
.add_enabled(
!pending,
egui::Button::new(if pending {
"选择中…"
} else {
"选择图片…"
})
.min_size(egui::vec2(120.0, 28.0)),
)
.clicked()
{
pick = true;
}
}
if ui
.add_enabled(
cx.state.background.is_set(),
egui::Button::new("恢复默认").min_size(egui::vec2(110.0, 28.0)),
)
.clicked()
{
clear = true;
}
});
if pick {
cx.state.picker.pick_image(cx.egui);
}
if clear {
cx.state.background.clear();
cx.state.settings.background_image.clear();
cx.state.save_settings();
cx.state.log.write("gui", "背景图已恢复默认");
}
if !background::PICKER_SUPPORTED {
ui.label(
RichText::new("本平台没有系统文件对话框 —— 把图片直接拖进窗口即可")
.size(11.0)
.color(theme::TEXT_SUB),
);
}
});
});
});
cx.ui.add_space(12.0);
// ---- 启动动画 ----
//
// 原来这是**独立的一页**(Setting › 启动动图),现在并进本页:两件事都是
// 「开机第一眼看到什么」,分两页要来回点两次才能配完。
// (路由那一半 —— `Page` 变体 / `PAGES` 表 / `mod.rs` 的映射 —— 由另一处
// 改动同步收尾;本文件只负责把卡片并过来并删掉原来的绘制函数。)
boot_card(cx);
cx.ui.add_space(12.0);
// ---- 天气地点 ----
ui::card(cx.ui, |ui| {
ui::section(
ui,
"天气地点",
"wttr.in 支持城市名、拼音、区县名;天气页也能改。",
);
ui.add_space(6.0);
ui.add_sized(
[260.0, 30.0],
egui::TextEdit::singleline(&mut cx.state.draft_location),
);
});
cx.ui.add_space(12.0);
// 端口编辑已移到 Setting › Proxy and Port。这里原来那张「DSH-Starter
cx.ui.add_space(14.0);
let mut save = false;
let mut revert = false;
cx.ui.horizontal(|ui| {
if ui::primary_button(ui, "保存", [110.0, 32.0]) {
save = true;
}
if ui
.add_sized([110.0, 32.0], egui::Button::new("还原"))
.clicked()
{
revert = true;
}
if cx.state.last_saved.is_some() {
ui::ok_hint(ui, "已保存到 settings.json");
}
});
if save {
cx.state.settings.weather_location = cx.state.draft_location.trim().to_string();
cx.state.weather_location = cx.state.settings.weather_location.clone();
cx.state.save_settings();
cx.state.last_saved = Some(crate::now_stamp());
cx.state.start_weather_fetch(cx.egui);
}
if revert {
cx.state.draft_location = cx.state.settings.weather_location.clone();
}
cx.ui.add_space(10.0);
ui::kv(
cx.ui,
"设置文件",
&cx.state.settings_path.display().to_string(),
);
}
// ---------------------------------------------------------------------------
// 启动动画(原 Setting › 启动动图 那一页,已并进上面的背景图片页)
// ---------------------------------------------------------------------------
/// 内置动画的总时长(毫秒)。
///
/// 和 `lib.rs` 里的 `BUILTIN_BOOT_TOTAL_MS` 是**同一个值**(那边那份是私有的,
/// 页面拿不到,只能在 `boot_store_keys_match_lib_reads` 里核对):页面拿它算进度
/// 文案,那边拿它决定什么时候收遮罩。两处一旦不一致,症状是进度条停在
/// 87% 就没了,或者遮罩早退之后再黑一下 —— 都不像代码问题。
const BOOT_BUILTIN_MS: i64 = 1400;
/// 「启动时播放」这个开关在 store.json 里的键名。
///
/// 写成常量而不是在调用点散字面量:它是**跨文件**的(本页写、`lib.rs` 启动时读),
/// 拼错一个字母的症状是「勾选框点得动、重启就回到默认」—— 编译器一声不吭,
/// 日志和别的测试也全绿。真正拦得住它的是下面的 `boot_store_keys_match_lib_reads`。
///
/// **另外两个键(`boot_video` / `boot_sound`)这里不抄**:它们归 `lib.rs` 的
/// `BOOT_VIDEO_KEY` / `BOOT_SOUND_KEY` 管,本页只调 `AppState::boot_video_set` /
/// `boot_sound_set`,不自己碰 store —— 同一个键有两份字面量就是两个会各自漂移的来源。
const KEY_BOOT_ANIM: &str = "boot_anim";
/// 本卡片写的提示统一带这个前缀(渲染时据此过滤,见 [`boot_message`])。
///
/// `lib.rs` 里有一份同值的私有常量,它写的提示也带这个前缀 ——
/// `boot_store_keys_match_lib_reads` 会核对两边还是同一个串。
const BOOT_MSG_PREFIX: &str = "启动动画:";
/// 「选择 MP4…」这个按钮能不能用。
///
/// **现在是 `true`,用途和路由都接上了**:`background.rs` 的 `PickPurpose` 里有
/// `BootVideo`,选中文件后由 `lib.rs::poll_background_drop` 的对应分支交给
/// `AppState::boot_video_set(Some(path), ctx)`,而那边把「记住路径 + 落盘 +
/// 后台抽帧」当成一个动作做完。
///
/// **为什么不把这个常量删掉**:它是"这条路通不通"的**唯一开关**,而且只有两处
/// 用法 —— 按钮的 `add_enabled` 和下面那句解释文案。路由要是被回退(比如
/// `background.rs` 删了 `BootVideo` 变体、或 `lib.rs` 少了那条分支),编译器只会在
/// `lib.rs` 报"match 不穷尽",本页**一声不吭**;那时把这里改回 `false`,界面就退成
/// "灰按钮 + 说明为什么点不动",而不是让用户点一个没反应的按钮(那种"点了没事发生"
/// 是这次整条通路一开始的样子)。删掉常量的话,回退时连这个开关都没有了。
///
/// 借别的用途(比如 `Video`)顶上是不行的:那会把选出来的 MP4 甩给视频播放器,
/// 而 `background.rs` 的注释里记着的那次事故(用途和路由对不上 → 整窗背景图被换掉)
/// 就是这么发生的。
const BOOT_PICK_READY: bool = true;
/// 开关存进 store 的字符串形式。
///
/// 沿用 `boot_anim` 原来的 `"1"` / `"0"`:同一个 store.json 里同一个语义不该有
/// 两种写法 —— `lib.rs` 是把 `boot_anim` 当 `== Some("1")` 读的,这里要是写成
/// `"true"`,那个开关就"存进去了但读不回来"。
fn flag_str(on: bool) -> &'static str {
if on {
"1"
} else {
"0"
}
}
/// 启动这一帧该拿哪一路画面。
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum BootVisual {
/// 内置的 1.4 秒遮罩(logo + 扫描线 + 进度条)
Builtin,
/// 用户选的自定义 MP4,按抽好的帧播
Video,
}
/// 「当前该用内置动画还是 MP4」。
///
/// 判据是**路径和帧两个都在**,不是只看路径:只有路径、一帧都没有
/// (ffmpeg 没装好 / 文件损坏 / 抽帧还没跑完)时若照路径去播,用户看到的
/// 是一片黑,只会以为程序坏了;退一档播内置动画,至少还能看出「它在启动」。
fn boot_visual(video: Option<&Path>, frames: usize) -> BootVisual {
match video {
Some(_) if frames > 0 => BootVisual::Video,
_ => BootVisual::Builtin,
}
}
/// 这一路画面一共放多久(毫秒)—— 进度文案的分母。
///
/// MP4 那一路**直接问 `lib.rs` 的 `boot_video_total_ms`**,不在这里重算:
/// 那个函数算的是「最后一帧要显示到什么时候」(最后一个偏移 + 一帧的间隔),
/// 播放那边也用同一个值决定何时收遮罩 —— 这里少加那一格的话,文案上写着演完了,
/// 画面其实还差一帧。
fn boot_total_ms(visual: BootVisual, frame_at: &[u64]) -> i64 {
match visual {
BootVisual::Builtin => BOOT_BUILTIN_MS,
BootVisual::Video => {
let t = crate::boot_video_total_ms(frame_at);
// 帧表为空时那边返回 0(`Video` 正常不会走到这里,但不留一个
// `0 毫秒 / 0 毫秒` 的分母)。
if t > 0 { t } else { BOOT_BUILTIN_MS }
}
}
}
/// 卡片上显示的文件名:只取末段。
///
/// 整条路径直接往卡片上摆会把卡片顶破(egui 不会自动省略,长到一定程度还会
/// 画到卡片外面去);完整路径另起一行用 [`ui::kv_one_line`] 显示,那里会中间省略。
fn boot_file_label(p: &Path) -> String {
match p.file_name() {
Some(n) => n.to_string_lossy().to_string(),
// 拿不到末段(路径以 `..` 收尾之类)就退回整条 —— 这里返回空串的话,
// 用户看到的是「选了一个文件,但名字是空的」。
None => p.display().to_string(),
}
}
/// 只认本卡片自己写的提示。
///
/// `misc.message` 是**全 App 共用一个槽**(视频页、ffmpeg 页、拉流页都在写)。
/// 「启动动图」还是独立一页时显示它,顶多显示一条陈旧的话;并进背景图页之后,
/// 用户从视频页回来会看到「载入成功,时长 …」挂在启动动画卡片上,像是本页干的。
/// 约定:本卡片(以及 `lib.rs` 里给启动动画写的那条路由)的消息都以
/// [`BOOT_MSG_PREFIX`] 开头。
fn boot_message(msg: &str) -> Option<&str> {
msg.strip_prefix(BOOT_MSG_PREFIX)
}
/// 预览时到底有没有声音。三件事都容易说漏:内置动画没有音轨、选了 MP4 但没勾
/// 开关同样是静音、勾了才出声 —— 所以分开写死,而不是拼一句含糊的「按设置播放」。
fn boot_preview_audio_note(visual: BootVisual, sound: bool) -> &'static str {
match (visual, sound) {
(BootVisual::Builtin, _) => {
"预览只有画面 —— 内置动画没有音轨。选一个 MP4 才会连声音一起放。"
}
(BootVisual::Video, true) => {
"预览会把 MP4 的声音一起放出来(音量跟系统当前音量一致,不用另找播放器)。"
}
(BootVisual::Video, false) => {
"预览只放画面:上面的「播放声音」没勾。要连声音一起听,勾上它再点预览。"
}
}
}
/// 「启动动画」卡片。
///
/// 两个开关(`boot_anim` / `boot_sound`)和自定义 MP4 都存在 store.json 里 ——
/// **不写 settings.json**:那个文件有 C# 逐字节还原的护栏测试,多一个键就红。
fn boot_card(cx: &mut PageCtx<'_>) {
// 三个动作先记成标志、出了 `ui::card` 的闭包再动状态:闭包里 `cx` 的字段
// 正被借着画画,在里面改状态容易和借用检查打结。
let mut play = false;
let mut reset = false;
let mut clear = false;
// `cx.egui` 先拷出来:下面要拿它调 `AppState::boot_video_set(None, egui)`,
// 而那一句同时要可变借 `cx.state` —— 拆成两个字段的借用就不会打结。
let egui = cx.egui;
// 这几个值闭包里要反复用,而且必须**同一帧内一致** ——
// 在闭包里现取的话,用户一点「清除」,同一帧后面的文案就跟着变了。
let video = cx.state.misc.boot_video.clone();
let has_mp4 = video.is_some();
let visual = boot_visual(video.as_deref(), cx.state.misc.boot_frames.len());
let frames = cx.state.misc.boot_frames.len();
let total = boot_total_ms(visual, &cx.state.misc.boot_frame_at);
ui::card(cx.ui, |ui| {
ui::section(
ui,
"启动动画",
"不选 MP4 = 内置动画(约 1.4 秒的全屏遮罩);选了 MP4 = 逐帧播那个文件",
);
ui.add_space(6.0);
ui.label(
RichText::new(ui::md(
"内置动画是**在主窗口上叠一层全屏遮罩**:logo 淡入 + 纵向扫描线 + 进度条,\
约 1.4 秒后自动淡出、开始正常绘制。不另开窗口 —— 另开窗口会有\
「两个窗口先后出现」的抖动,比不做还难看。",
))
.size(11.5)
.color(theme::TEXT_SUB),
);
ui.add_space(4.0);
ui.label(
RichText::new(ui::md(
"自定义 MP4 是**启动时用 ffmpeg 逐帧抽出来**、再按每帧的时间戳播的\
(和视频播放器同一套地基)。所以文件别太大:抽帧就发生在启动那一下,\
几百 MB 的文件会让首屏干等十几秒,看着像卡死。几秒到十几秒、\
1080p 以内最稳。",
))
.size(11.5)
.color(theme::TEXT_SUB),
);
ui.add_space(10.0);
ui.horizontal(|ui| {
if ui
.checkbox(
&mut cx.state.misc.boot_enabled,
RichText::new("启动时播放").size(12.5),
)
.changed()
{
cx.state
.store
.set_str(KEY_BOOT_ANIM, flag_str(cx.state.misc.boot_enabled));
let _ = cx.state.store.save();
}
// 声音只对自定义 MP4 有意义(内置动画根本没有音轨)。没有 MP4 时
// **禁掉而不是藏起来**:藏起来用户会以为"这软件不能带声音",
// 灰着加一句悬停说明才知道是"还没选文件"。
//
// 值先落到局部变量再交给 `boot_sound_set`:它负责把开关写进 store
// 并立刻落盘,本页不自己碰 store(理由见 [`KEY_BOOT_ANIM`] 那段)。
let mut sound = cx.state.misc.boot_sound;
if ui
.add_enabled(
has_mp4,
egui::Checkbox::new(&mut sound, RichText::new("播放声音").size(12.5)),
)
.on_disabled_hover_text("内置动画没有声音 —— 选一个自定义 MP4 才能开这一项")
.changed()
{
cx.state.boot_sound_set(sound);
}
});
ui.add_space(8.0);
ui.horizontal(|ui| {
// 用途 `BootVideo` 已经在 `lib.rs::poll_background_drop` 里接上路由:
// 选出来的文件交给 `AppState::boot_video_set(Some(path), ctx)`
// (记住 + 落盘 + 起抽帧是一个动作)。本页**不自己改 `misc.boot_video`、
// 也不碰 store** —— 抄一半的症状是:只记路径不抽帧 → 点完没画面,
// 只记路径不落盘 → 重启回到内置动画,两种都"看着能用、其实没接完"。
// 只加用途不加路由比不加更危险(文件会落进默认分支去换整窗背景图)。
//
// `egui` 用的是上面拷出来的那个局部量:`pick_file` 要 `&Context`,
// 而这一句同时要可变借 `cx.state.picker`。
if ui
.add_enabled(
BOOT_PICK_READY,
egui::Button::new("使用自定义 MP4…").min_size(egui::vec2(150.0, 28.0)),
)
.on_disabled_hover_text(
"「启动视频」这条路由在当前构建里没接上(见 BOOT_PICK_READY)",
)
.clicked()
{
cx.state.picker.pick_file(
egui,
crate::background::PickPurpose::BootVideo,
"选择启动视频",
background::FilePicker::VIDEO_FILTER,
);
}
if ui
.add_enabled(
has_mp4,
egui::Button::new("清除(用内置动画)").min_size(egui::vec2(150.0, 28.0)),
)
.clicked()
{
clear = true;
}
match &video {
Some(p) => {
ui::one_line(ui, &boot_file_label(p), 12.0)
.on_hover_text(p.display().to_string());
}
None => {
ui.label(
RichText::new("(未选 MP4 —— 用内置动画)")
.size(12.0)
.color(theme::TEXT_SUB),
);
}
}
});
// 「选了 MP4,但启动时不会播」—— 按钮行下面这句醒目的警告。
//
// **这个状态现在几乎走不到**:`AppState::boot_video_set` 在选中文件时会把
// 「启动时播放」顶成开(见 `lib.rs::boot_enabled_after_pick`)。但**不能因此
// 把它删掉** —— 还有两条路能到这儿:用户手改 store.json(把 `boot_anim`
// 改回 `"0"` 而 `boot_video` 留着),或者先勾了「启动时播放」又取消勾。
// 那时若无这句话,用户看到的只有"动画不播"这一个现象,界面上没有任何解释,
// 他反推不出原因(这正是这次要修的那个缺陷的形状)。
//
// 判据里的 `boot_enabled` **现取现用**,不跟上面那几个"同一帧内一致"的量走:
// 用户在同一帧里刚取消勾,这句警告就该立刻出现,而不是等下一帧才冒出来。
//
// 文案走 `ui::md`:`RichText` 不解析 markdown,星号会原样画在卡片上。
// 强调靠 `theme::DANGER` 这个颜色(egui 没有粗体字重,见 `ui::md` 的说明)。
if has_mp4 && !cx.state.misc.boot_enabled {
ui.add_space(6.0);
ui.label(
RichText::new(ui::md(
"**注意:启动时不会播。**上面「启动时播放」没勾上 —— 启动那边先看\
这个开关,没勾就不起这段动画,所以重启后这个 MP4 不会有任何动静\
(抽好的帧也用不上)。要它播:勾上上面的「启动时播放」。",
))
.size(12.0)
.color(theme::DANGER),
);
}
if let Some(p) = &video {
ui::kv_one_line(ui, "文件", &p.display().to_string());
}
// 这一段只在 `BOOT_PICK_READY` 被回退成 `false` 时才会画出来 ——
// 它是那个开关的"解释那一半"(配对关系见常量上的说明)。现在这个值是
// `true`,所以用户看不到它;留着是为了路由万一被回退时不只剩下一个
// 灰按钮:"点了没反应"和"告诉你为什么点不动"是两种体验,前者会让人
// 以为程序坏了。
if !BOOT_PICK_READY {
ui.label(
RichText::new(
"「使用自定义 MP4…」暂时点不动:「启动视频」这条用途的路由在当前构建里\
没接上,选中了也没法告诉程序它该给谁用(接了错的路由会把整窗背景图换掉)。\
所以现在只能用内置动画。",
)
.size(11.0)
.color(theme::TEXT_SUB),
);
}
if !background::PICKER_SUPPORTED {
ui.label(
RichText::new("本平台没有系统文件对话框 —— 桌面版才有「选择文件」这条路。")
.size(11.0)
.color(theme::TEXT_SUB),
);
}
ui.add_space(8.0);
match visual {
BootVisual::Video => {
ui::kv(
ui,
"抽到的帧",
&format!("{frames} 帧,共 {total} 毫秒"),
);
}
// 选了文件却一帧都没有:这一版会**退回内置动画**,得说出来,
// 否则用户会以为"选了 MP4 没生效"而反复点那个按钮。
BootVisual::Builtin if has_mp4 => {
ui.label(
RichText::new(
"已经选了 MP4,但现在一帧都没抽到 —— 这一版仍按内置动画走。\
可能是抽帧还在跑,或者 ffmpeg 没装好(Tools › ffmpeg 那一页看得到)。",
)
.size(11.5)
.color(theme::TEXT_SUB),
);
}
BootVisual::Builtin => {
ui.label(
RichText::new(format!("当前画面:内置动画({BOOT_BUILTIN_MS} 毫秒)"))
.size(12.0)
.color(theme::TEXT_MAIN),
);
}
}
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.button("立即预览").clicked() {
play = true;
}
if ui.button("重置").clicked() {
reset = true;
}
});
ui.add_space(4.0);
ui.label(
RichText::new(boot_preview_audio_note(visual, cx.state.misc.boot_sound))
.size(11.5)
.color(theme::TEXT_SUB),
);
if let Some(t0) = cx.state.misc.boot_started_ms {
let ms = nfarms_core::netclock::now_ms() - t0;
if ms < total {
ui.add_space(6.0);
ui.label(
RichText::new(format!("正在播放…({ms} 毫秒 / {total} 毫秒)"))
.size(11.5)
.color(theme::ACCENT_DARK),
);
}
}
// 只显示本卡片自己写的提示(见 `boot_message`),别把视频页那句话
// 挂到这一页上。
if let Some(m) = cx.state.misc.message.as_deref().and_then(boot_message) {
ui.add_space(4.0);
ui::ok_hint(ui, m);
}
});
if play {
// 预览就是把「启动那一刻」挪到现在:`lib.rs::render_boot_anim` 只看这一个
// 时间戳。页面上**不自己再画一遍** —— 画两份的话,预览和真启动迟早不是一个东西。
cx.state.misc.boot_started_ms = Some(nfarms_core::netclock::now_ms());
}
if reset {
cx.state.misc.boot_started_ms = None;
cx.state.misc.message = None;
}
if clear {
// 清除走 `AppState::boot_video_set(None, …)`,不自己改字段 + 写 store:
// 「置空路径 / 放掉帧 / 删 store 键 / 写一句提示」在那边是一个动作,
// 这里再抄一遍迟早和它走散(症状:选了又清,重启后视频又回来了)。
cx.state.boot_video_set(None, egui);
}
}
// ===========================================================================
// Environment → DSH-Starter
// ===========================================================================
/// 老的 DSH-Starter 占位(当时只是把本地大模型指向 Model 组)。
///
/// 现在 DSH-Starter 有自己的真页面([`dsh_page`]),这个函数不再挂给任何页面。
/// 留着是因为它那段「本来该做什么」的说明还有参考价值。
#[allow(dead_code)]
pub fn harness(cx: &mut PageCtx<'_>) {
ui::placeholder_body(cx.ui, cx.page.info());
cx.ui.add_space(12.0);
let mut go = false;
ui::card(cx.ui, |ui| {
ui::section(
ui,
"本地大模型界面已收进 Environment › Plugins › Model",
"llama.cpp / stable-diffusion.cpp / Qwen 系列权重现在都由那一页统一管。",
);
ui.add_space(6.0);
go = ui
.add_sized([160.0, 28.0], egui::Button::new("去 Model 页"))
.clicked();
});
if go {
cx.goto(Page::Model);
}
}
// ===========================================================================
// Model
// ===========================================================================
// 统一模型管理页当前显示哪一块。
//
// 为什么用 `thread_local` 而不是往 `AppState` 上再加一个字段:egui 的绘制固定在
// 主线程上,所以两者等价,而这一处只服务本页 —— 加进 `AppState` 会让那个结构体
// 再多一个「只有这一页读」的字段。同一文件里 `ensure_engines` 的槽位就是这个先例。
thread_local! {
static MODEL_TAB: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Environment › Plugins › Model —— **统一模型管理**。
///
/// 本地模型这一块原来横向铺成四页(llama.cpp / stable-diffusion.cpp / qwen-3 /
/// qwen-image-2.1)。新菜单把它们收成一页,所以这里做一层选择:
/// 四个标签各自是**原来那一页的实现**,一行没改 —— 这样「统一入口」不是重写,
/// 而是把已经验过的四块重新组织起来。
pub fn model_page(cx: &mut PageCtx<'_>) {
/// 四个标签:(名字, 一句话说明)
const TABS: [(&str, &str); 4] = [
("llama.cpp", "文本推理后端(llama-server)"),
("stable-diffusion.cpp", "图像生成后端(sd-cli / sd-server)"),
("qwen-3", "编程(Qwen3-Coder-30B-A3B)+ 对话(Qwen3-VL-8B)"),
("qwen-image-2.1", "图像生成 / 图像编辑的四个权重"),
];
let mut cur = MODEL_TAB.with(|t| t.get()).min(TABS.len() - 1);
ui::card(cx.ui, |ui| {
ui.horizontal_wrapped(|ui| {
for (i, (name, _)) in TABS.iter().enumerate() {
if ui.selectable_label(cur == i, *name).clicked() {
cur = i;
}
}
});
ui.add_space(4.0);
ui.label(
RichText::new(TABS[cur].1)
.size(11.5)
.color(theme::TEXT_SUB),
);
});
MODEL_TAB.with(|t| t.set(cur));
cx.ui.add_space(12.0);
match cur {
0 => model_runtime(cx),
1 => model_sd_cpp(cx),
2 => model_qwen3(cx),
_ => model_qwen_image(cx),
}
}
/// Model › llama.cpp:运行时、组件状态与权重的总入口。
fn model_runtime(cx: &mut PageCtx<'_>) {
#[cfg(windows)]
{
// 大模型这一块是新的(C# 版没有)。它要靠子进程拉 llama-server / sd-cli,
// 安卓上没有这些可执行文件,所以整块只在 Windows 下编。
//
// 注意这里三个字段是**分开借**的(local_model 可变,paths/log 只读)——
// 同一结构体的不同字段可以同时借,前提是别写成 self 的整体方法。
cx.state
.local_model
.ui(cx.ui, &cx.state.paths, &cx.state.log);
}
#[cfg(not(windows))]
{
ui::card(cx.ui, |ui| {
ui::section(
ui,
"本地大模型在移动端不可用",
"llama.cpp / stable-diffusion.cpp 是桌面可执行文件,安卓版没有子进程可以拉它们。",
);
});
}
cx.ui.add_space(14.0);
ui::card(cx.ui, |ui| {
ui::section(ui, "组件状态", &libs::model_hint(&cx.state.paths));
ui.add_space(6.0);
for comp in libs::COMPONENTS {
let st = libs::component_status(&cx.state.paths, comp);
let (mark, color) = if st.installed() {
("已就绪", theme::ACCENT_DARK)
} else {
("缺失", theme::DANGER)
};
ui.horizontal(|ui| {
ui.add_sized(
[150.0, 18.0],
egui::Label::new(RichText::new(comp.name).size(12.5).color(theme::TEXT_MAIN)),
);
ui.label(RichText::new(mark).size(12.0).color(color));
ui.label(
RichText::new(if st.total_bytes > 0 {
ui::human_bytes(st.total_bytes)
} else {
String::new()
})
.size(12.0)
.color(theme::TEXT_SUB),
);
if !st.missing.is_empty() {
ui.label(
RichText::new(format!("缺 {}", st.missing.join(", ")))
.size(11.0)
.color(theme::TEXT_SUB),
);
}
});
}
});
}
/// Model › stable-diffusion.cpp:图像生成后端的组件与权重。
///
/// **为什么和 llama.cpp 分成两块**:它们是两个独立后端 —— 一个管文本、一个管图像,
/// 各有自己的二进制、构建标记和显存占用。合在一起时「哪个装了、哪个没装」
/// 要在一大段里翻找;分开之后各自的下载入口就在自己那一块上。
///
/// **刻意不引 `local_model` 里的常量**:那一块的 `SD` 是私有 const,
/// 为了复用去把它改成 pub,会让两处从此耦合在一起。这里直接扫目录 ——
/// 就几行,而且这一页要展示的本来就是「磁盘上有什么」。
fn model_sd_cpp(cx: &mut PageCtx<'_>) {
let dir = cx.state.paths.libs().join("stable-diffusion.cpp");
// (文件名, 字节数)
let mut files: Vec<(String, u64)> = Vec::new();
if let Ok(rd) = std::fs::read_dir(&dir) {
for e in rd.flatten() {
if let Ok(md) = e.metadata()
&& md.is_file()
{
files.push((e.file_name().to_string_lossy().to_string(), md.len()));
}
}
}
// 同上:降序用 `Reverse`,别写成 `b.1.cmp(&a.1)` —— 那个容易被读成升序
files.sort_by_key(|f| std::cmp::Reverse(f.1));
let total: u64 = files.iter().map(|f| f.1).sum();
let has_cli = dir.join("sd-cli.exe").is_file();
// 构建标记:fetch-libs 下载后会写这个文件,用来知道装的是哪一版
let build = std::fs::read_to_string(dir.join(".nfarms-version"))
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
ui::card(cx.ui, |ui| {
ui::section(
ui,
"stable-diffusion.cpp(图像生成后端)",
"sd-cli 的下载、版本与依赖 —— 权重本身在 qwen-image-2.1 页",
);
ui.add_space(6.0);
let (mark, color) = if has_cli {
("已就位", theme::ACCENT_DARK)
} else {
("未安装", theme::DANGER)
};
ui.horizontal(|ui| {
ui.add_sized(
[110.0, 17.0],
egui::Label::new(RichText::new("sd-cli.exe").size(12.0).color(theme::TEXT_MAIN)),
);
ui.label(RichText::new(mark).size(11.5).color(color));
if let Some(b) = &build {
ui.label(RichText::new(format!("构建 {b}")).size(11.0).color(theme::TEXT_SUB));
}
});
ui.horizontal(|ui| {
ui.add_sized(
[110.0, 17.0],
egui::Label::new(RichText::new("目录").size(12.0).color(theme::TEXT_MAIN)),
);
ui.label(
RichText::new(format!(
"libs\\stable-diffusion.cpp · {} 个文件 / {:.1} MB",
files.len(),
total as f64 / 1_048_576.0
))
.size(11.5)
.color(theme::TEXT_SUB),
);
});
if files.is_empty() {
ui.add_space(6.0);
ui::err_hint(
ui,
"目录是空的 —— 还没下过后端。跑 .\\libs\\scripts\\fetch-libs.ps1 -BinariesOnly,\
或在 llama.cpp 页点「下载缺失组件」。",
);
} else {
ui.add_space(6.0);
for (name, size) in files.iter().take(20) {
ui.horizontal(|ui| {
ui.add_space(122.0);
ui.add_sized(
[280.0, 17.0],
egui::Label::new(RichText::new(name).size(11.0).color(theme::TEXT_SUB)),
);
ui.label(
RichText::new(format!("{:.1} MB", *size as f64 / 1_048_576.0))
.size(11.0)
.color(theme::TEXT_SUB),
);
});
}
}
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui
.add_sized([150.0, 26.0], egui::Button::new("打开目录"))
.clicked()
{
nfarms_core::ffmpeg::open_dir(&dir);
}
});
ui.add_space(4.0);
ui.label(
RichText::new(
"GPU 后端:Vulkan 走 vulkan-1.dll、SYCL 走 oneAPI 运行时 —— \
缺了会退回 CPU(能跑但很慢),在 Environment 页的「模型运行环境」里能看到。",
)
.size(10.5)
.color(theme::TEXT_SUB),
);
});
}
/// Model › qwen-3:编程(Qwen3-Coder-30B-A3B)与对话(Qwen3-VL-8B)。
///
/// 出图那一段(权重清单、提示词、出图、显存提示)原样调 `local_model` 里拆出来的
/// `ui_image` —— 不重写,免得把已经调好的东西再抄一遍。
fn model_qwen3(cx: &mut PageCtx<'_>) {
#[cfg(windows)]
{
cx.state
.local_model
.ui_image(cx.ui, &cx.state.paths, &cx.state.log);
}
#[cfg(not(windows))]
{
ui::card(cx.ui, |ui| {
ui::section(ui, "本地大模型在移动端不可用", "安卓版没有子进程可以拉 llama-server。");
});
}
}
/// Model › qwen-image-2.1:sd.cpp 用的四个权重。
fn model_qwen_image(cx: &mut PageCtx<'_>) {
let status = libs::model_status(&cx.state.paths);
let ready = status.ready();
let can_edit = status.can_edit();
ui::card(cx.ui, |ui| {
ui::section(
ui,
"Qwen-Image 2.1",
"由 stable-diffusion.cpp(sd-cli / sd-server)运行,不是 llama.cpp。",
);
ui.add_space(6.0);
ui::kv(ui, "权重目录", &cx.state.paths.show(&status.dir));
ui::kv(ui, "已占空间", &ui::human_bytes(status.total_bytes()));
ui.add_space(6.0);
ui::status_pill(
ui,
ready,
if ready {
"三件必需权重齐了,可以出图"
} else {
"必需权重还没下齐"
},
);
if !can_edit {
ui.label(
RichText::new("mmproj 没下载 —— 只能文生图,图片编辑(图生图)要它。")
.size(11.5)
.color(theme::TEXT_SUB),
);
}
});
cx.ui.add_space(12.0);
for f in libs::QWEN_IMAGE_2_1.iter() {
let present = status
.present
.iter()
.find(|(p, _)| std::ptr::eq(*p, f))
.map(|(_, b)| *b);
ui::card(cx.ui, |ui| {
ui.horizontal(|ui| {
let (rect, _) =
ui.allocate_exact_size(egui::vec2(22.0, 22.0), egui::Sense::hover());
icons::draw(
ui.painter(),
rect,
if present.is_some() { "disk" } else { "database" },
if present.is_some() {
theme::ACCENT
} else {
theme::TEXT_SUB
},
);
ui.label(
RichText::new(f.name)
.size(13.0)
.color(theme::TEXT_MAIN),
);
if !f.required {
ui.label(RichText::new("可选").size(11.0).color(theme::TEXT_SUB));
}
});
ui.add_space(4.0);
ui::kv(ui, "用途", f.role);
ui::kv(
ui,
"状态",
&match present {
Some(b) => ui::human_bytes(b),
None => format!("未下载(约 {} MB)", f.approx_mb),
},
);
});
cx.ui.add_space(10.0);
}
let mut go = false;
ui::card(cx.ui, |ui| {
ui::section(ui, "权重不进安装包", "libs\\model 这一层现在有 28 GB,装不进去也不该装。");
ui.add_space(8.0);
go = ui
.add_sized([180.0, 28.0], egui::Button::new("去运行时页下载 / 启动"))
.clicked();
});
if go {
cx.goto(Page::Model);
}
}
// ===========================================================================
// Environment
// ===========================================================================
// 包管理器探测(winget / choco / scoop)的跨帧槽位。
//
// 为什么不是 `AppState` 上再加一个字段:这一批修复只允许动本文件,而
// `environment()` 每帧都会被调用 —— 「在跑」和「结果」必须跨帧活着。
// egui 的绘制固定在主线程上,所以 thread_local 在这里与结构体字段等价,
// 只是生命周期跟着进程、不跟着 `App`。
//
// 写成 `//` 而不是 `///`:rustdoc 不为宏调用生成文档,`///` 只会换来一条
// `unused_doc_comments` 警告。
thread_local! {
static MGR_PROBE: RefCell<Job<Vec<PkgMgr>>> = RefCell::new(Job::default());
}
/// 包管理器探测的一步:该发起就发起,结果到了就搬进界面状态。
///
/// **界面线程只做这两件事,绝不等 `where`。** 一次探测是三个 `where`,
/// 每个 15 秒超时(`envprobe.rs:878` 的 `capture(..., 15_000)`)——
/// 以前这里是一句同步的 `ep::package_managers()`,`env_mgrs_probed` 只挡住了
/// 「每帧跑一次」,挡不住「第一次进来卡最多 45 秒」,那 45 秒里整页不重绘。
///
/// `work` 做成参数是为了测试能塞替身:真跑 `package_managers()` 要起三个
/// `cmd.exe`,测试里既慢、又取决于跑测试的机器上装了什么。
///
/// 返回 `true` 表示**这一帧刚拿到结果**。
fn pump_mgr_probe(
job: &mut Job<Vec<PkgMgr>>,
ctx: &egui::Context,
done: &mut bool,
out: &mut Vec<PkgMgr>,
work: fn() -> Vec<PkgMgr>,
) -> bool {
if !*done && !job.is_running() {
job.start(ctx, work);
}
if *done || !job.poll() {
return false;
}
// 拿到结果、或者后台线程没了(panic),都算探测结束。
//
// `result()` 为空**不能**把 `done` 留在 false:那样下一帧 `is_running()`
// 又是 false,会每帧重开一个线程 —— 从「卡 45 秒」变成「疯狂起线程」。
if let Some(m) = job.result() {
out.clone_from(m);
}
*done = true;
true
}
pub fn environment(cx: &mut PageCtx<'_>) {
use nfarms_core::envprobe as ep;
let mut start = false;
if !cx.state.env_probe_auto {
cx.state.env_probe_auto = true;
start = true;
}
// 包管理器探测走 `Job`,和上面 `env_probe` / 下面 `env_action` 一样:
// 页面每帧 poll 一次,拿不到就继续画「探测中…」,界面线程一秒都不停。
{
let ctx = cx.egui;
let got = MGR_PROBE.with(|job| {
pump_mgr_probe(
&mut job.borrow_mut(),
ctx,
&mut cx.state.env_mgrs_probed,
&mut cx.state.env_mgrs,
ep::package_managers,
)
});
if got {
let names: Vec<&str> = cx
.state
.env_mgrs
.iter()
.filter(|m| m.available())
.map(|m| m.key)
.collect();
let msg = if names.is_empty() {
"一个都没有(winget / choco / scoop)".to_string()
} else {
names.join(" / ")
};
cx.state
.log
.write("env", &format!("包管理器探测完成:{msg}"));
}
}
let running = cx.state.env_probe.is_running();
let acting = cx.state.env_action.is_running();
let mut rescan = false;
let mut run_action: Option<String> = None;
// 折叠切换:Some(None) = 全收起;Some(Some(k)) = 展开/收起 k
let mut toggle: Option<Option<&'static str>> = None;
let report = cx.state.env_probe.result().cloned();
let mgrs = cx.state.env_mgrs.clone();
let cur_mgr = mgrs.get(cx.state.env_mgr).map(|m| m.key).unwrap_or("none");
let opened = cx.state.env_open;
// ================= 顶部:控制条 =================
//
// 这里**原来是一张「运行时环境」卡片**,把九类汇总在一起。按你的要求去掉了 ——
// 九类现在各自是一个可折叠分组,再套一层汇总就成了「分组里还有分组」。
//
// 但「检测全部」和「包管理器」是**操作**不是分组,所以留下 —— 只是从卡片
// 变成页面上一条细控制条。
cx.ui.horizontal(|ui| {
if ui
.add_enabled(!running, egui::Button::new("检测全部").min_size(egui::vec2(88.0, 28.0)))
.clicked()
{
rescan = true;
}
if running {
ui.spinner();
ui.label(RichText::new("检测中…").size(12.0).color(theme::TEXT_SUB));
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
for (i, m) in mgrs.iter().enumerate().rev() {
let label = if m.available() {
RichText::new(m.key).size(12.0).color(theme::TEXT_MAIN)
} else {
RichText::new(format!("{} (没装)", m.key))
.size(12.0)
.color(theme::TEXT_SUB)
};
if m.available() {
if ui
.selectable_label(cx.state.env_mgr == i, label)
.on_hover_text(
m.path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_default(),
)
.clicked()
{
cx.state.env_mgr = i;
}
} else {
ui.add_enabled(false, egui::Label::new(label));
}
}
if cx.state.env_mgrs_probed {
ui.label(RichText::new("包管理器").size(12.0).color(theme::TEXT_SUB));
} else {
// 探测结果没回来之前列表必然是空的 —— 不写这一句的话,
// 用户看到的是「包管理器」后面什么都没有,还会以为本机一个都没装。
ui.spinner();
ui.label(RichText::new("包管理器探测中…").size(12.0).color(theme::TEXT_SUB));
}
});
});
if let Some(m) = &cx.state.env_msg {
cx.ui.add_space(4.0);
ui::ok_hint(cx.ui, m);
}
// `all()` 对**空表**恒为 true。探测还没回来时 `mgrs` 正是空表,不加
// `env_mgrs_probed` 这个守卫的话,进页面的头几百毫秒会先闪一条
// 「本机没有任何包管理器」的红色错误,然后再自己消失。
if cx.state.env_mgrs_probed && mgrs.iter().all(|m| !m.available()) {
cx.ui.add_space(4.0);
ui::err_hint(
cx.ui,
"本机没有任何包管理器(winget / choco / scoop)。**安装 / 更新按钮不会出现** —— \
与其给一个点不动的按钮,不如直说。",
);
}
if start || rescan {
let paths = cx.state.paths.clone();
cx.state.env_probe.start(cx.egui, move || ep::collect(&paths));
cx.state.env_msg = None;
cx.state.env_started = Some(std::time::Instant::now());
}
// **必须每帧 poll** —— `Job::poll()` 是把后台线程的结果从通道搬进
// `done` 的那一步;不调它 `result()` 永远是 `None`,页面就**永远停在
// 「正在检测…」**。
//
// 我重写这一页时把这段漏掉了,表现是「检测明明跑完了,界面一直转圈」——
// 而且因为完成日志也在这段里,连日志都查不到,只能靠盯着界面猜。
if cx.state.env_probe.poll()
&& let Some(r) = cx.state.env_probe.result()
{
let (set, tot) = r.vars_set();
let msg = format!(
"环境检测完成:{}/{} 类可用,环境变量 {set}/{tot} 已设,镜像源 {} 条",
r.usable_groups(),
r.groups.len(),
r.mirrors.len()
);
cx.state.log.write("env", &msg);
cx.state.env_msg = Some(msg);
cx.state.env_started = None;
}
if cx.state.env_action.poll()
&& let Some(out) = cx.state.env_action.result().cloned()
{
cx.state.log.write("env", &format!("安装/更新动作结束:{}", first_line(&out)));
cx.state.env_action_log = out;
}
let Some(report) = &report else {
cx.ui.add_space(12.0);
// **显示真实已用时长,不写死秒数**。
//
// 原来这里写「约 15 秒」,实测要 45 秒 —— 一个错误的预期比没有预期更糟:
// 用户等到 20 秒会以为卡死了,然后去点「检测全部」,又开一次。
// 现在把已用时间如实报出来,慢就是慢,但至少不像坏了。
let elapsed = cx
.state
.env_started
.map(|t| t.elapsed().as_secs())
.unwrap_or(0);
ui::card(cx.ui, |ui| {
ui.horizontal(|ui| {
ui.spinner();
ui.label(
RichText::new(format!("正在检测…已用 {elapsed} 秒"))
.size(12.5)
.color(theme::TEXT_MAIN),
);
});
ui.label(
RichText::new(
"六十来次子进程探测(java/gradle/sdkmanager 各自要启 JVM),点一次跑一遍,\
期间可以先看别的页。",
)
.size(11.0)
.color(theme::TEXT_SUB),
);
});
cx.egui
.request_repaint_after(std::time::Duration::from_millis(500));
return;
};
// ================= 九类:各自一个可折叠分组 =================
for g in &report.groups {
cx.ui.add_space(10.0);
let key = g.key;
let is_open = opened == Some(key);
let (ok, total) = g.found_count();
let state = g.state();
let state_color = match state {
"可用" => theme::ACCENT_DARK,
"部分可用" => theme::TEXT_MAIN,
_ => theme::DANGER,
};
let name = g.name;
let purpose = g.purpose;
let items: Vec<(String, String, bool)> = g
.items
.iter()
.map(|i| (i.label.clone(), i.summary(), i.found()))
.collect();
let acts = ep::actions_for(&cx.state.paths, key, cur_mgr);
ui::card(cx.ui, |ui| {
// ---- 组头:整行可点 ----
let head = ui
.horizontal(|ui| {
let mark = if is_open { "▼" } else { "▶" };
let resp = ui
.add(
egui::Button::new(
RichText::new(format!("{mark} {name}")).size(14.5).color(theme::TEXT_MAIN),
)
.frame(false),
)
.on_hover_text("点一下展开 / 收起");
ui.label(
RichText::new(format!("{ok}/{total}"))
.size(11.5)
.color(theme::TEXT_SUB),
);
ui.label(RichText::new(state).size(11.5).color(state_color));
resp
})
.inner;
if head.clicked() {
toggle = Some(if is_open { None } else { Some(key) });
}
if !is_open {
// 收起时给一行「有什么」,不然九行全是一样的摘要看不出区别
ui.label(RichText::new(purpose).size(11.0).color(theme::TEXT_SUB));
return;
}
ui.label(RichText::new(purpose).size(11.0).color(theme::TEXT_SUB));
ui.add_space(8.0);
for (label, summary, found) in &items {
ui.horizontal(|ui| {
ui.add_sized(
[190.0, 17.0],
egui::Label::new(RichText::new(label).size(12.0).color(if *found {
theme::TEXT_MAIN
} else {
theme::TEXT_SUB
})),
);
let color = if *found { theme::TEXT_SUB } else { theme::DANGER };
ui.add_sized(
[420.0, 17.0],
egui::Label::new(RichText::new(summary).size(11.5).color(color)),
);
});
}
if !acts.is_empty() {
ui.add_space(8.0);
}
for a in &acts {
ui.horizontal(|ui| match a.kind {
"安装" | "更新" => {
let hit = ui
.add_enabled_ui(!acting, |ui| {
ui.add_sized(
[168.0, 26.0],
egui::Button::new(format!("{}{}", a.kind, a.what)),
)
})
.inner
.on_hover_text(&a.cmd);
if hit.clicked() {
run_action = Some(a.cmd.clone());
}
ui.label(
RichText::new(&a.cmd)
.size(10.5)
.monospace()
.color(theme::TEXT_SUB),
);
}
_ => {
ui.label(
RichText::new(format!("【手动】{}", a.what))
.size(11.5)
.color(theme::TEXT_SUB),
);
}
});
if !a.note.is_empty() {
ui.horizontal(|ui| {
ui.add_space(176.0);
ui.label(RichText::new(a.note).size(10.5).color(theme::TEXT_SUB));
});
}
}
});
}
// ================= 三个横向维度:同样是可折叠分组 =================
let (set, tot) = report.vars_set();
let vars: Vec<(String, Option<String>, String, String)> = report
.vars
.iter()
.map(|v| {
(
v.name.to_string(),
v.value.clone(),
v.note.to_string(),
v.hint.to_string(),
)
})
.collect();
let mirrors: Vec<(String, String, bool)> = report
.mirrors
.iter()
.map(|m| (m.name.to_string(), m.current.clone(), m.official))
.collect();
let sys: Vec<(String, String, bool, String)> = report
.system
.iter()
.map(|i| (i.label.clone(), i.summary(), i.found(), i.note.clone()))
.collect();
let mut fold = |cx: &mut PageCtx<'_>,
key: &'static str,
title: String,
hint: &str,
body: &mut dyn FnMut(&mut egui::Ui)| {
cx.ui.add_space(10.0);
let is_open = opened == Some(key);
let mut local_toggle = false;
ui::card(cx.ui, |ui| {
let mark = if is_open { "▼" } else { "▶" };
let head = ui
.horizontal(|ui| {
let r = ui
.add(
egui::Button::new(
RichText::new(format!("{mark} {title}")).size(14.0).color(theme::TEXT_MAIN),
)
.frame(false),
)
.on_hover_text("点一下展开 / 收起");
ui.label(RichText::new(hint).size(11.0).color(theme::TEXT_SUB));
r
})
.inner;
if head.clicked() {
local_toggle = true;
return;
}
if is_open {
ui.add_space(6.0);
body(ui);
}
});
if local_toggle {
toggle = Some(if is_open { None } else { Some(key) });
}
};
fold(
cx,
"vars",
format!("环境变量({set}/{tot} 已设)"),
"工具链的隐形依赖 —— JAVA_HOME 指错比不装还难查",
&mut |ui| {
for (name, value, note, hint) in &vars {
ui.horizontal(|ui| {
ui.add_sized(
[190.0, 17.0],
egui::Label::new(
RichText::new(name).size(11.5).monospace().color(if value.is_some() {
theme::TEXT_MAIN
} else {
theme::TEXT_SUB
}),
),
);
if let Some(v) = value {
ui.add_sized(
[330.0, 17.0],
egui::Label::new(
RichText::new(ui::ellipsize(v, 60))
.size(11.0)
.color(theme::ACCENT_DARK),
),
);
} else {
ui.add_sized(
[330.0, 17.0],
egui::Label::new(
RichText::new(format!("未设 → 建议 {hint}"))
.size(11.0)
.color(theme::TEXT_SUB),
),
);
}
});
ui.horizontal(|ui| {
ui.add_space(192.0);
ui.label(RichText::new(note).size(10.5).color(theme::TEXT_SUB));
});
}
},
);
fold(
cx,
"mirrors",
format!("镜像源({} 条)", mirrors.len()),
"国内网络下「装不上」的头号原因",
&mut |ui| {
for (name, current, official) in &mirrors {
ui.horizontal(|ui| {
ui.add_sized(
[80.0, 17.0],
egui::Label::new(RichText::new(name).size(12.0).color(theme::TEXT_MAIN)),
);
if *official {
ui.label(RichText::new("官方源").size(11.0).color(theme::DANGER));
} else {
ui.label(RichText::new("已换源").size(11.0).color(theme::ACCENT_DARK));
}
ui.label(
RichText::new(ui::ellipsize(current, 70))
.size(10.5)
.monospace()
.color(theme::TEXT_SUB),
);
});
}
},
);
fold(
cx,
"system",
format!("系统开关与运行库({} 项)", sys.len()),
"缺了只会在运行时报错(找不到 xxx.dll、路径太长建不了文件)",
&mut |ui| {
for (label, summary, found, note) in &sys {
ui.horizontal(|ui| {
ui.add_sized(
[240.0, 17.0],
egui::Label::new(RichText::new(label).size(12.0).color(theme::TEXT_MAIN)),
);
let (mark, color) = if *found {
("有", theme::ACCENT_DARK)
} else {
("缺", theme::DANGER)
};
ui.label(RichText::new(mark).size(11.0).color(color));
ui.label(
RichText::new(ui::ellipsize(summary, 60))
.size(11.0)
.color(theme::TEXT_SUB),
);
});
if !note.is_empty() {
ui.horizontal(|ui| {
ui.add_space(242.0);
ui.label(RichText::new(note).size(10.5).color(theme::TEXT_SUB));
});
}
}
},
);
// ================= 动作输出 =================
if !cx.state.env_action_log.is_empty() || acting {
cx.ui.add_space(10.0);
let log = cx.state.env_action_log.clone();
let max_h = (cx.egui.screen_rect().height() - 420.0).max(160.0);
ui::card(cx.ui, |ui| {
ui::section(ui, "安装 / 更新输出", "命令的原始输出,失败原因都在这里");
ui.add_space(6.0);
if acting {
ui.spinner();
}
egui::ScrollArea::vertical().max_height(max_h).show(ui, |ui| {
for line in log.lines().rev().take(200).collect::<Vec<_>>().into_iter().rev() {
ui.label(
RichText::new(line)
.size(11.0)
.monospace()
.color(log_line_color(line)),
);
}
});
});
}
// ================= 收尾:提交折叠 / 执行动作 =================
if let Some(t) = toggle {
cx.state.env_open = t;
}
if let Some(cmd) = run_action {
cx.state.log.write("env", &format!("执行: {cmd}"));
cx.state.env_action_log.clear();
cx.state.env_msg = Some(format!("已开始执行:{cmd}"));
cx.state.env_action.start(cx.egui, move || ep::run_action(&cmd));
}
}
fn first_line(s: &str) -> String {
s.lines()
.map(|l| l.trim())
.find(|l| !l.is_empty())
.unwrap_or("(没有输出)")
.to_string()
}
// ===========================================================================
// Zero Trust VPN → Channel
// ===========================================================================
pub fn channel(cx: &mut PageCtx<'_>) {
use nfarms_core::ztna;
let running = cx.state.channel.is_running();
let mut start = false;
// 客户端路径是纯本地判断,不需要后台线程
let client = ztna::find_client_path(&cx.state.paths, &cx.state.settings);
ui::card(cx.ui, |ui| {
ui::section(
ui,
"Channel",
"管理现有 nfztna.exe 客户端:登录 / 连接 / 断开 / 状态 / 停止",
);
ui.add_space(6.0);
ui::kv(
ui,
"客户端",
if client.is_empty() {
"(没找到 nfztna.exe —— 在设置里指定路径,或把它放到程序根)"
} else {
&client
},
);
if let Some(s) = cx.state.channel.result() {
ui::kv(ui, "进程", if s.running { "运行中" } else { "未运行" });
if let Some(t) = &cx.state.last_saved {
let _ = t;
}
}
ui.add_space(10.0);
ui.horizontal(|ui| {
if ui
.add_enabled(
!running && !client.is_empty(),
egui::Button::new("刷新状态").min_size(egui::vec2(100.0, 30.0)),
)
.clicked()
{
start = true;
}
if ui
.add_enabled(
!running,
egui::Button::new("停止客户端").min_size(egui::vec2(110.0, 30.0)),
)
.clicked()
{
let ok = ztna::stop();
cx.state.log.write(
"ztna",
if ok { "已发送停止命令" } else { "停止失败(进程可能本来就没跑)" },
);
}
if running {
ui.spinner();
}
});
});
if start {
let exe = client.clone();
cx.state.channel.start(cx.egui, move || {
let running = ztna::is_running();
let status = if exe.is_empty() {
String::new()
} else {
ztna::run_status(&exe)
};
crate::ChannelSnapshot {
client_path: exe,
running,
status,
}
});
}
if let Some(s) = cx.state.channel.result()
&& !s.status.is_empty()
{
cx.ui.add_space(12.0);
ui::card(cx.ui, |ui| {
ui::section(ui, "status 输出", "原版直接打印客户端 status 子命令的原文。");
ui.add_space(6.0);
ui.label(
RichText::new(&s.status)
.size(11.5)
.monospace()
.color(theme::TEXT_MAIN),
);
});
}
cx.ui.add_space(12.0);
// 登录 / 连接:开一个可见控制台窗口 —— 客户端要读标准输入
ui::card(cx.ui, |ui| {
ui::section(
ui,
"登录 / 连接",
&ui::plain(
"会在**单独的可见控制台窗口**里执行(cmd /k)—— 客户端要读标准输入,而且失败时得让你看见原因。",
),
);
ui.add_space(8.0);
ui::kv(ui, "目标", &cx.state.settings.ztna_target);
ui::kv(ui, "用户名", &cx.state.settings.ztna_username);
ui::kv(ui, "身份", &cx.state.settings.ztna_identity);
ui.add_space(8.0);
ui.horizontal(|ui| {
for cmd in ["login", "connect", "disconnect"] {
let label = match cmd {
"login" => "登录",
"connect" => "连接",
_ => "断开",
};
if ui
.add_enabled(
!client.is_empty(),
egui::Button::new(label).min_size(egui::vec2(90.0, 30.0)),
)
.clicked()
{
match ztna::run_interactive(
&client,
cmd,
&cx.state.settings.ztna_target,
&cx.state.settings.ztna_username,
&cx.state.settings.ztna_identity,
) {
Ok(()) => {
cx.state
.log
.write("ztna", &format!("已在新控制台窗口执行 {cmd}"));
if cmd != "disconnect" {
// 登录/连接会创建会话,顺手记一下时间
cx.state.last_saved = Some(crate::now_stamp());
}
}
Err(e) => {
cx.state.log.write("ztna", &format!("{cmd} 启动失败: {e}"));
}
}
}
}
});
ui.add_space(6.0);
ui.label(
RichText::new(
"目标 / 用户名 / 身份在 Setting › 背景图片 那一页改(它们和其它设置存在同一个 settings.json 里)。",
)
.size(11.0)
.color(theme::TEXT_SUB),
);
});
}
// ===========================================================================
// Player → 图片阅读器
// ===========================================================================
pub fn image_viewer(cx: &mut PageCtx<'_>) {
let mut open = false;
ui::card(cx.ui, |ui| {
ui.horizontal(|ui| {
if ui
.add_enabled(
!cx.state.viewer.is_loading(),
egui::Button::new("选择图片…").min_size(egui::vec2(110.0, 30.0)),
)
.clicked()
{
open = true;
}
ui.label(
RichText::new(match cx.state.viewer.path() {
Some(p) => p.display().to_string(),
None => "(还没有打开图片)".to_string(),
})
.size(12.0)
.color(theme::TEXT_MAIN),
);
if let Some((w, h)) = cx.state.viewer.source_size {
ui.label(
RichText::new(format!("原图 {w} × {h}"))
.size(11.0)
.color(theme::TEXT_SUB),
);
}
if let Some(d) = cx.state.viewer.dir() {
let (i, n) = cx.state.viewer.position();
ui.label(
RichText::new(format!("{} · 该目录 {n} 张", d.display()))
.size(11.0)
.color(theme::TEXT_SUB),
)
.on_hover_text(format!("第 {i} 张"));
}
if cx.state.viewer.is_loading() {
ui.spinner();
}
});
ui.add_space(4.0);
ui.label(
RichText::new(
"同目录的图片可以直接翻上/下一张(按文件名排序,和资源管理器一致)。\
也可以把图片直接拖进窗口 —— 在这一页是「打开」,在别的页面是「设为窗口背景」。",
)
.size(11.5)
.color(theme::TEXT_SUB),
);
});
if open {
// **不能**用 `pick_image` —— 那个用途是「设为背景图」,
// 在这里选一张图会把整窗背景换掉(用户报的就是这个)。
cx.state.picker.pick_file(
cx.egui,
crate::background::PickPurpose::Viewer,
"打开图片",
crate::background::FilePicker::IMAGE_FILTER,
);
}
cx.ui.add_space(10.0);
cx.state.viewer.ui(cx.ui, true);
// 「设为背景」在 `viewer.ui()` 里只是置一个 action —— 真正执行放在
// `poll_jobs`(下一帧开头)。阅读器不直接碰 AppState 的解码/设置,
// 免得两个模块互相依赖;代价是晚一帧,肉眼看不出来。
}
// ===========================================================================
// Tools → ffmpeg
// ===========================================================================
pub fn ffmpeg_page(cx: &mut PageCtx<'_>) {
use nfarms_core::ffmpeg;
let mut detect = false;
let mut info = false;
let mut transcode = false;
let mut snapshot = false;
let mut clean = false;
let mut open_dir = false;
// 首次进页面自动探测一次(ffmpeg 探测要扫 PATH,放后台)
if cx.state.ffmpeg.detected.is_none() && !cx.state.ffmpeg.detect_job.is_running() {
cx.state.ffmpeg.detect_job.start(cx.egui, || {
ffmpeg::Ffmpeg::detect(&nfarms_core::Paths::detect())
});
}
let detected = cx.state.ffmpeg.detected.clone();
let dir = cx
.state
.paths
.lib("ffmpeg");
ui::card(cx.ui, |ui| {
ui::section(
ui,
"ffmpeg",
"转码 / 抽帧 / 合流 · 界面不再依赖系统 codec",
);
ui.add_space(6.0);
ui.label(
RichText::new(
"原版视频播放依赖 Windows Media Player ActiveX,解码能力受系统限制。\
Rust 这版统一走 ffmpeg:播放器抽帧上纹理,工具页则直接暴露转码 / 抽帧 / 媒体信息。",
)
.size(11.5)
.color(theme::TEXT_SUB),
);
ui.add_space(10.0);
match &detected {
None => {
ui.horizontal(|ui| {
ui.spinner();
ui.label(RichText::new("正在查找 ffmpeg…").size(12.0).color(theme::TEXT_SUB));
});
}
Some(ff) => {
ui::kv(
ui,
"状态",
if ff.available() { "可用" } else { "未安装" },
);
if let Some(e) = &ff.exe {
ui::kv(ui, "ffmpeg", &e.display().to_string());
}
ui::kv(
ui,
"ffprobe",
&ff.probe
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "(没有 —— 媒体信息改用 ffmpeg -i)".to_string()),
);
ui::kv(ui, "目录", &cx.state.paths.show(&dir));
ui.add_space(8.0);
ui.label(RichText::new(ff.status_text()).size(11.5).color(theme::TEXT_SUB));
}
}
ui.add_space(10.0);
ui.horizontal(|ui| {
if ui.button("重新检测").clicked() {
detect = true;
}
if ui.button("打开目录").clicked() {
open_dir = true;
}
let src_pkg = detected
.as_ref()
.map(|f| f.looks_like_source_package())
.unwrap_or(false);
if ui
.add_enabled(src_pkg, egui::Button::new("清理源码包"))
.on_hover_text("把 libs\\ffmpeg 里的非二进制内容移到 bak\\,不直接删")
.clicked()
{
clean = true;
}
});
});
if detect {
cx.state.ffmpeg.detected = None;
cx.state.ffmpeg.detect_job.start(cx.egui, || {
ffmpeg::Ffmpeg::detect(&nfarms_core::Paths::detect())
});
}
if open_dir {
ffmpeg::open_dir(&dir);
}
if clean {
match cx.state.ffmpeg.detected.as_ref() {
Some(ff) => match ff.clean_non_binary(&cx.state.paths, Some(&cx.state.log)) {
Ok(n) => cx.state.ffmpeg.message = Some(format!("已移走 {n} 项到 bak\\")),
Err(e) => cx.state.ffmpeg.message = Some(e),
},
None => cx.state.ffmpeg.message = Some("还没探测到 ffmpeg".to_string()),
}
}
cx.ui.add_space(12.0);
// ---- 媒体信息 / 转码 / 截图 ----
ui::card(cx.ui, |ui| {
ui::section(ui, "媒体工具", "源文件可以是本地文件,也可以是 http/m3u8/rtmp 地址。");
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label(RichText::new("源").size(12.0).color(theme::TEXT_SUB));
ui.add_sized(
[520.0, 26.0],
egui::TextEdit::singleline(&mut cx.state.ffmpeg.src)
.hint_text(r"C:\video\input.mkv 或 https://.../live.m3u8"),
);
if ui.button("浏览…").clicked() {
// 同上:这里要的是「填进源路径」,不是「换背景图」
cx.state.picker.pick_file(
cx.egui,
crate::background::PickPurpose::FfmpegSrc,
"选择媒体文件",
crate::background::FilePicker::VIDEO_FILTER,
);
}
});
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label(RichText::new("输出").size(12.0).color(theme::TEXT_SUB));
ui.add_sized(
[520.0, 26.0],
egui::TextEdit::singleline(&mut cx.state.ffmpeg.out)
.hint_text(r"留空 = 与源同目录(转码 .mp4 / 截图 .png)"),
);
});
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.button("读媒体信息").clicked() {
info = true;
}
if ui.button("转码为 mp4").clicked() {
transcode = true;
}
ui.label(RichText::new("截图于").size(12.0).color(theme::TEXT_SUB));
ui.add_sized(
[70.0, 26.0],
egui::TextEdit::singleline(&mut cx.state.ffmpeg.at_seconds),
);
ui.label(RichText::new("秒").size(12.0).color(theme::TEXT_SUB));
if ui.button("抽帧截图").clicked() {
snapshot = true;
}
if cx.state.ffmpeg.work.is_running() {
ui.spinner();
ui.label(RichText::new("执行中…").size(12.0).color(theme::TEXT_SUB));
}
});
if let Some(m) = &cx.state.ffmpeg.message {
ui.add_space(6.0);
ui::ok_hint(ui, m);
}
});
if info {
match cx.state.ffmpeg.detected.clone() {
Some(ff) => {
let p = std::path::PathBuf::from(cx.state.ffmpeg.src.trim());
let text = ff.media_info(&p);
cx.state.ffmpeg.info = Some(text);
}
None => cx.state.ffmpeg.message = Some("ffmpeg 还没探测到".to_string()),
}
}
if transcode || snapshot {
let src = std::path::PathBuf::from(cx.state.ffmpeg.src.trim());
let out_field = cx.state.ffmpeg.out.trim().to_string();
let at: f64 = cx.state.ffmpeg.at_seconds.trim().parse().unwrap_or(1.0);
let which = if transcode { "transcode" } else { "snapshot" };
// ffmpeg 的探测结果要搬进闭包(它是 Send 的)
if let Some(ff) = cx.state.ffmpeg.detected.clone() {
let default_ext = if transcode { "mp4" } else { "png" };
let out = if out_field.is_empty() {
let mut o = src.clone();
o.set_extension(default_ext);
o
} else {
std::path::PathBuf::from(&out_field)
};
cx.state.ffmpeg.message = None;
cx.state.ffmpeg.work.start(cx.egui, move || match which {
"transcode" => match ff.transcode_to_mp4(&src, &out, None) {
Ok(()) => format!("转码完成: {}", out.display()),
Err(e) => format!("转码失败: {e}"),
},
_ => match ff.snapshot(&src, &out, at) {
Ok(()) => format!("截图完成: {}", out.display()),
Err(e) => format!("截图失败: {e}"),
},
});
}
}
if let Some(text) = &cx.state.ffmpeg.info {
cx.ui.add_space(12.0);
ui::card(cx.ui, |ui| {
ui::section(ui, "媒体信息", "ffprobe 原文(没有 ffprobe 就用 ffmpeg -i)。");
ui.add_space(6.0);
ui.label(
RichText::new(text)
.size(11.0)
.monospace()
.color(theme::TEXT_MAIN),
);
});
}
}
// ===========================================================================
// Player → 音乐播放器
// ===========================================================================
pub fn music(cx: &mut PageCtx<'_>) {
use crate::background::FilePicker as FP;
use nfarms_core::music::PlayState;
let mut pick = false;
let mut scan = false;
let mut play_idx: Option<usize> = None;
let mut cmd: Option<&str> = None;
let mut seek_to: Option<i64> = None;
let mut volume: Option<u32> = None;
let opened = cx.state.media.music.is_opened();
let state = cx.state.media.music.state;
let len = cx.state.media.music.length_ms();
let pos = if opened && state == PlayState::Playing {
// 播放中才每帧读位置;停着的时候读会白起一次 MCI 调用
cx.ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
cx.state.media.music.position_ms()
} else {
cx.state.media.music.position_ms()
};
ui::card(cx.ui, |ui| {
ui::section(
ui,
"音乐播放器",
"走 Windows 自带 MCI(winmm)—— 零第三方依赖,和 C# 原版同一个后端",
);
ui.add_space(6.0);
ui.label(
RichText::new(
"原版用 mciSendString,这里照抄:直接链 winmm,不引 rodio / cpal 那一大串。\
支持 mp3 / wav / wma(装了相应解码器还能更多)。",
)
.size(11.5)
.color(theme::TEXT_SUB),
);
ui.add_space(10.0);
ui.horizontal(|ui| {
if ui.button("选择音频…").clicked() {
pick = true;
}
if ui.button("扫描目录").clicked() {
scan = true;
}
ui.add_sized(
[380.0, 26.0],
egui::TextEdit::singleline(&mut cx.state.media.playlist_dir)
.hint_text(r"目录,例如 C:\Music"),
);
let (n, i) = (
cx.state.media.playlist.len(),
cx.state.media.track + 1,
);
if n > 0 {
ui.label(
RichText::new(format!("{i} / {n}"))
.size(12.0)
.color(theme::TEXT_SUB),
);
}
});
});
if pick {
cx.state
.picker
.pick_file(cx.egui, crate::background::PickPurpose::Music, "选择音频", FP::AUDIO_FILTER);
}
if scan {
let d = std::path::PathBuf::from(cx.state.media.playlist_dir.trim());
if d.is_dir() {
cx.state.media.scan_playlist(&d);
let n = cx.state.media.playlist.len();
cx.state.media.message = Some(format!("扫到 {n} 首"));
} else {
cx.state.media.message = Some(format!("不是目录: {}", d.display()));
}
}
cx.ui.add_space(12.0);
// ---- 播放控制 ----
ui::card(cx.ui, |ui| {
ui.horizontal(|ui| {
let (w, h) = (76.0, 30.0);
if ui
.add_enabled(opened, egui::Button::new("播放").min_size(egui::vec2(w, h)))
.clicked()
{
cmd = Some("play");
}
if ui
.add_enabled(opened, egui::Button::new("暂停").min_size(egui::vec2(w, h)))
.clicked()
{
cmd = Some("pause");
}
if ui
.add_enabled(opened, egui::Button::new("停止").min_size(egui::vec2(w, h)))
.clicked()
{
cmd = Some("stop");
}
ui.separator();
if ui
.add_enabled(
!cx.state.media.playlist.is_empty(),
egui::Button::new("上一首").min_size(egui::vec2(72.0, h)),
)
.clicked()
{
cmd = Some("prev");
}
if ui
.add_enabled(
!cx.state.media.playlist.is_empty(),
egui::Button::new("下一首").min_size(egui::vec2(72.0, h)),
)
.clicked()
{
cmd = Some("next");
}
ui.separator();
ui.label(RichText::new("音量").size(12.0).color(theme::TEXT_SUB));
let mut v = cx.state.media.music.volume;
if ui
.add(egui::Slider::new(&mut v, 0..=1000).show_value(false))
.changed()
{
volume = Some(v);
}
ui.label(
RichText::new(format!("{}", v / 10))
.size(11.0)
.color(theme::TEXT_SUB),
);
});
ui.add_space(8.0);
// 进度条:拖动就 seek
let mut p = pos as f32;
let maxv = if len > 0 { len as f32 } else { 1.0 };
if ui
.add(
egui::Slider::new(&mut p, 0.0..=maxv)
.show_value(false)
.text(format!(
"{} / {}",
nfarms_core::music::Music::fmt_ms(pos),
nfarms_core::music::Music::fmt_ms(len)
)),
)
.drag_stopped()
{
seek_to = Some(p as i64);
}
ui.add_space(6.0);
match &cx.state.media.music.path {
// 长路径会把卡片撑破 —— 走会自动省略的那条通道
Some(p) => {
ui::kv_one_line(ui, "当前", p);
}
None => {
ui.label(
RichText::new("还没有打开音频 —— 用「选择音频…」,或点下面的播放列表。")
.size(12.0)
.color(theme::TEXT_SUB),
);
}
}
ui::kv(
ui,
"状态",
match state {
PlayState::Playing => "播放中",
PlayState::Paused => "已暂停",
PlayState::Stopped => "已停止",
},
);
if let Some(m) = &cx.state.media.message {
ui.add_space(4.0);
ui::ok_hint(ui, m);
}
});
cx.ui.add_space(12.0);
// ---- 播放列表 ----
ui::card(cx.ui, |ui| {
ui::section(
ui,
&format!("播放列表({} 首)", cx.state.media.playlist.len()),
"扫描一个目录,或者用「选择音频…」自动带上它所在目录。",
);
ui.add_space(6.0);
if cx.state.media.playlist.is_empty() {
ui.label(
RichText::new("(空)—— 填一个目录再点「扫描目录」")
.size(12.0)
.color(theme::TEXT_SUB),
);
}
for (i, p) in cx.state.media.playlist.iter().enumerate() {
let name = p
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let cur = i == cx.state.media.track;
// 文件名可能很长(音乐站下载的文件名动辄上百字),不省略会把卡片撑破
let max_chars = ((ui.available_width() / 8.0).floor() as usize).max(16);
let text = RichText::new(ui::ellipsize(&name, max_chars))
.size(12.0)
.color(if cur { theme::ACCENT } else { theme::TEXT_MAIN });
if ui
.selectable_label(cur, text)
.on_hover_text(&name)
.clicked()
{
play_idx = Some(i);
}
}
});
// ---- 执行动作(UI 借用结束后再做,免得改 self 时还借着)----
if let Some(v) = volume {
cx.state.media.music.set_volume(v);
}
if let Some(ms) = seek_to {
if let Err(e) = cx.state.media.music.seek_ms(ms) {
cx.state.media.message = Some(e);
}
}
if let Some(i) = play_idx {
cx.state.media.track = i;
let p = cx.state.media.playlist[i].clone();
match cx.state.media.music.open(&p) {
Ok(()) => {
let _ = cx.state.media.music.play();
cx.state.media.message = Some(format!("正在播放 {}", ui::ellipsize(&p.display().to_string(), 72)));
}
Err(e) => cx.state.media.message = Some(e),
}
}
if let Some(c) = cmd {
let pl = cx.state.media.playlist.clone();
let n = pl.len();
let mut goto: Option<usize> = None;
let r = match c {
"play" => cx.state.media.music.play(),
"pause" => cx.state.media.music.pause(),
"stop" => cx.state.media.music.stop(),
"next" if n > 0 => {
goto = Some((cx.state.media.track + 1) % n);
Ok(())
}
"prev" if n > 0 => {
goto = Some((cx.state.media.track + n - 1) % n);
Ok(())
}
_ => Ok(()),
};
if let Err(e) = r {
cx.state.media.message = Some(e);
}
if let Some(i) = goto {
cx.state.media.track = i;
let p = pl[i].clone();
if let Err(e) = cx.state.media.music.open(&p) {
cx.state.media.message = Some(e);
} else {
let _ = cx.state.media.music.play();
}
}
}
}
// ===========================================================================
// Player → 文本阅读器
// ===========================================================================
/// 轻量 Markdown 渲染。
///
/// 只处理真正影响阅读的四种:`#` 标题、``` 代码块、`- ` 列表、`**粗体**`。
/// 表格、脚注、HTML 内嵌这些一律按原文显示 —— 半吊子的富文本比老老实实显示原文更难用。
///
/// **现在没有调用点**:唯一的用户是「文本阅读器」那一页,而新菜单里没有它了。
/// 留着不删的理由:这段逻辑本身是对的、也不依赖任何已删的东西,
/// 下一个要显示 Markdown 的页面(DS-Harness 的控制台、文档阅读)直接用。
#[allow(dead_code)]
fn render_markdown(ui: &mut egui::Ui, text: &str, mono: bool) {
let mut in_code = false;
for line in text.lines() {
let trimmed = line.trim_end();
if trimmed.starts_with("```") {
in_code = !in_code;
ui.add_space(2.0);
continue;
}
if in_code {
ui.label(
RichText::new(trimmed)
.size(11.5)
.monospace()
.color(theme::TEXT_MAIN),
);
continue;
}
if trimmed.is_empty() {
ui.add_space(6.0);
continue;
}
if let Some(rest) = trimmed.strip_prefix("### ") {
ui.add_space(4.0);
ui.label(RichText::new(rest).size(14.0).color(theme::TEXT_MAIN));
continue;
}
if let Some(rest) = trimmed.strip_prefix("## ") {
ui.add_space(6.0);
ui.label(RichText::new(rest).size(16.0).color(theme::TEXT_MAIN));
continue;
}
if let Some(rest) = trimmed.strip_prefix("# ") {
ui.add_space(8.0);
ui.label(RichText::new(rest).size(19.0).color(theme::ACCENT_DARK));
continue;
}
if let Some(rest) = trimmed
.strip_prefix("- ")
.or_else(|| trimmed.strip_prefix("* "))
{
ui.horizontal(|ui| {
ui.add_space(12.0);
ui.label(RichText::new("•").size(12.0).color(theme::TEXT_SUB));
ui.label(RichText::new(bold_stripped(rest)).size(12.5).color(theme::TEXT_MAIN));
});
continue;
}
let size = if mono { 11.5 } else { 12.5 };
let r = RichText::new(bold_stripped(trimmed)).size(size).color(theme::TEXT_MAIN);
if mono {
ui.label(r.monospace());
} else {
ui.label(r);
}
}
}
/// 去掉 `**` 标记(egui 的 RichText 不认 Markdown,留着就是两个星号)。
/// 同 [`render_markdown`]:暂时没有调用点。
#[allow(dead_code)]
fn bold_stripped(s: &str) -> String {
s.replace("**", "")
}
// ===========================================================================
// Browser → 书签
// ===========================================================================
pub fn bookmarks(cx: &mut PageCtx<'_>) {
let mut add = false;
let mut remove: Option<String> = None;
let mut open_url: Option<String> = None;
let list = cx.state.store.bookmarks();
ui::card(cx.ui, |ui| {
ui::section(
ui,
"书签",
"存成 src\\store.json —— 人能直接打开看、能手工修,不引 SQLite",
);
ui.add_space(6.0);
ui.label(
RichText::new(ui::plain(
"C# 版的 `Store.cs` 是一套「优先 SQLite、找不到就回退 JSON」的**反射加载**方案\
(`Assembly.LoadFrom` 找 `System.Data.SQLite.dll`),那是 .NET 特有的绕路。\
Rust 这边真要 SQLite 得引 `rusqlite`(带 C 源码编译,构建代价几十 MB),\
而书签这点数据用 JSON 完全够,而且能手工修。",
))
.size(11.5)
.color(theme::TEXT_SUB),
);
ui.add_space(10.0);
ui.horizontal(|ui| {
ui.label(RichText::new("地址").size(12.0).color(theme::TEXT_SUB));
ui.add_sized(
[420.0, 26.0],
egui::TextEdit::singleline(&mut cx.state.bookmark_url)
.hint_text("https://nfarms.cn/"),
);
});
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label(RichText::new("标题").size(12.0).color(theme::TEXT_SUB));
ui.add_sized(
[200.0, 26.0],
egui::TextEdit::singleline(&mut cx.state.bookmark_title).hint_text("留空就用地址"),
);
if ui
.add_enabled(
!cx.state.bookmark_url.trim().is_empty(),
egui::Button::new("加入书签").min_size(egui::vec2(100.0, 28.0)),
)
.clicked()
{
add = true;
}
});
});
if add {
let url = cx.state.bookmark_url.trim().to_string();
let title = cx.state.bookmark_title.trim().to_string();
let now = nfarms_core::time::local_now();
if cx.state.store.add_bookmark(&title, &url, &now) {
match cx.state.store.save() {
Ok(()) => {
cx.state.bookmark_url.clear();
cx.state.bookmark_title.clear();
cx.state
.log
.write("bookmarks", &format!("已加入 {url}"));
}
Err(e) => cx.state.media.message = Some(e),
}
} else {
cx.state.media.message = Some("这个地址已经在书签里了".to_string());
}
}
cx.ui.add_space(12.0);
ui::card(cx.ui, |ui| {
ui::section(ui, &format!("已存书签({} 条)", list.len()), "");
ui.add_space(6.0);
if list.is_empty() {
ui.label(
RichText::new("(空)—— 上面填个地址加一条")
.size(12.0)
.color(theme::TEXT_SUB),
);
}
for b in &list {
ui.horizontal(|ui| {
if ui.button("打开").clicked() {
open_url = Some(b.url.clone());
}
if ui.button("删除").clicked() {
remove = Some(b.url.clone());
}
ui.add_sized(
[260.0, 18.0],
egui::Label::new(RichText::new(&b.title).size(12.5).color(theme::TEXT_MAIN)),
);
ui.label(RichText::new(&b.url).size(11.0).color(theme::TEXT_SUB));
if !b.added.is_empty() {
ui.label(RichText::new(&b.added).size(10.5).color(theme::TEXT_SUB));
}
});
}
});
if let Some(url) = remove {
cx.state.store.remove_bookmark(&url);
if let Err(e) = cx.state.store.save() {
cx.state.media.message = Some(e);
}
}
if let Some(url) = open_url {
// 用系统默认浏览器打开 —— 内嵌浏览器是 Browser 组那几页的事
#[cfg(windows)]
{
let _ = std::process::Command::new("cmd")
.args(["/c", "start", "", &url])
.spawn();
}
cx.state.log.write("bookmarks", &format!("打开 {url}"));
}
if let Some(m) = &cx.state.media.message {
cx.ui.add_space(10.0);
ui::ok_hint(cx.ui, m);
}
}
// ===========================================================================
// Browser → 引擎 / 内核 / grafana
// ===========================================================================
/// 首次进入这两页时自动探测一次引擎。
fn ensure_engines(cx: &mut PageCtx<'_>) {
if cx.state.probe.engines.result().is_none() && !cx.state.probe.engines.is_running() {
let paths = cx.state.paths.clone();
cx.state
.probe
.engines
.start(cx.egui, move || nfarms_core::browser::detect_engines(&paths));
}
}
pub fn browser_engine(cx: &mut PageCtx<'_>) {
ensure_engines(cx);
let mut rescan = false;
let mut open_home = false;
let home = cx.state.settings.browser_home.clone();
ui::card(cx.ui, |ui| {
ui::section(
ui,
"浏览器 · 引擎",
"Chromium(WebView2)/ 兼容(IE)/ Servo(实验)",
);
ui.add_space(6.0);
ui.label(
RichText::new(ui::plain(
"这一页**只探测、不内嵌**。内嵌 WebView2 要引 `webview2-com` + `windows` crate\
(目前唯一需要重依赖的地方)。但在那之前,「这台机器上有哪些引擎、各是什么版本」\
本身就是有用信息 —— WebView2 运行时没装的话,内嵌方案根本跑不起来,先看见比事后调试强。",
))
.size(11.5)
.color(theme::TEXT_SUB),
);
ui.add_space(10.0);
ui.horizontal(|ui| {
if ui.button("重新探测").clicked() {
rescan = true;
}
if ui.button("用系统浏览器打开首页").clicked() {
open_home = true;
}
ui.add_sized(
[360.0, 26.0],
egui::TextEdit::singleline(&mut cx.state.settings.browser_home)
.hint_text("https://nfarms.cn/"),
);
if cx.state.probe.engines.is_running() {
ui.spinner();
}
});
});
if rescan {
let paths = cx.state.paths.clone();
cx.state
.probe
.engines
.start(cx.egui, move || nfarms_core::browser::detect_engines(&paths));
}
if open_home {
let url = if home.trim().is_empty() {
"https://nfarms.cn/".to_string()
} else {
home.clone()
};
match nfarms_core::browser::open_external(&url) {
Ok(()) => cx.state.probe.message = Some(format!("已交给系统浏览器: {url}")),
Err(e) => cx.state.probe.message = Some(e),
}
}
cx.ui.add_space(12.0);
engines_card(cx, false);
cx.ui.add_space(12.0);
ui::card(cx.ui, |ui| {
ui::section(ui, "三内核切换的口径", "原版给的三档,现在的落地情况:");
ui.add_space(6.0);
for (name, state, note) in [
("Chromium(WebView2)", "可落地", "下一步引 webview2-com 就能内嵌;运行时版本见上面"),
("兼容(IE)", "待定", "只在极老的政务站点上还有意义;Win11 上通常已经没有 IE"),
("Servo(实验)", "未做", "原版留的实验项。把 servo.exe 放到 libs\\servo\\ 才会被探测到"),
] {
ui.horizontal(|ui| {
ui.add_sized(
[160.0, 18.0],
egui::Label::new(RichText::new(name).size(12.0).color(theme::TEXT_MAIN)),
);
ui.add_sized(
[60.0, 18.0],
egui::Label::new(RichText::new(state).size(11.5).color(theme::TEXT_SUB)),
);
ui.label(RichText::new(note).size(11.0).color(theme::TEXT_SUB));
});
}
});
}
/// 引擎清单卡片。`kernel_view` 为 true 时多显示可执行文件路径(内核页关心这个)。
fn engines_card(cx: &mut PageCtx<'_>, kernel_view: bool) {
ui::card(cx.ui, |ui| {
match cx.state.probe.engines.result() {
None => {
ui.label(RichText::new("探测中…").size(12.0).color(theme::TEXT_SUB));
}
Some(list) => {
let ok = list.iter().filter(|e| e.available()).count();
ui::section(ui, &format!("引擎({ok} / {} 个可用)", list.len()), "");
ui.add_space(8.0);
for e in list {
ui.horizontal(|ui| {
let (mark, color) = if e.available() {
("可用", theme::ACCENT_DARK)
} else {
("缺失", theme::DANGER)
};
ui.add_sized(
[210.0, 18.0],
egui::Label::new(
RichText::new(&e.name).size(12.5).color(theme::TEXT_MAIN),
),
);
ui.add_sized(
[58.0, 18.0],
egui::Label::new(RichText::new(mark).size(11.5).color(color)),
);
ui.label(
RichText::new(e.summary())
.size(11.0)
.monospace()
.color(theme::TEXT_SUB),
);
});
if kernel_view && let Some(p) = &e.exe {
ui.horizontal(|ui| {
ui.add_space(210.0);
ui.label(
RichText::new(p.display().to_string())
.size(10.5)
.color(theme::TEXT_SUB),
);
});
}
ui.horizontal(|ui| {
ui.add_space(210.0);
ui.label(RichText::new(&e.note).size(11.0).color(theme::TEXT_SUB));
});
ui.add_space(4.0);
}
}
}
});
}
// ---------------------------------------------------------------------------
// 可观测性三件套(VictoriaMetrics / Loki / Grafana)
// ---------------------------------------------------------------------------
/// 三件套在 `obs_proc` / `obs_state` / `obs_ports` 里的下标。
///
/// 这三个数组是**按位置配对**的,所以下标只此一份常量,调用点一律用名字:
/// 写死字面量时,一旦有人插了一行,症状是「点了 Loki 的启动,VM 的灯绿了」——
/// 那看起来不像代码问题,像服务自己乱了。
const OBS_METRICS: usize = 0;
const OBS_LOGS: usize = 1;
const OBS_DASHBOARD: usize = 2;
/// 界面顺序 = 数据流向:指标 → 日志 → 看板。
const OBS_ORDER: [usize; 3] = [OBS_METRICS, OBS_LOGS, OBS_DASHBOARD];
/// 「启动中」的黄。
///
/// theme 里只有绿(`ACCENT_DARK`)/灰(`TEXT_SUB`)/红(`DANGER`)三档,没有
/// 「进行中」这一档,而这一档**必须能区分**:Loki 实测要 17 秒,「在起」和
/// 「没起」都画灰的话,用户会以为点了没反应、于是再点一次(那时按钮已经因为
/// 启动中而禁用,更懵)。所以这一色在本文件里定,不去动 `theme.rs`。
const OBS_BUSY: Color32 = Color32::from_rgb(198, 142, 42);
/// 一个服务的状态在界面上长什么样:文案 + 颜色 + 要不要转圈。
struct ObsBadge {
text: &'static str,
color: Color32,
/// 还在起。要转圈,而且启动按钮得禁用 —— 同一件服务点两次会起两个进程
/// 抢同一个端口,后起的那个 bind 失败退出,而界面上写的是「已就绪」。
busy: bool,
}
/// 状态码(0 未启动 / 1 启动中 / 2 已就绪 / 3 失败)→ 灯的样子。
///
/// # 为什么抽成纯函数
///
/// 这一页要真的起进程才画得出来,而「失败是红的」这种断言靠点界面验不出来:
/// 得先备齐三件套 exe,再制造一次启动失败。所以把映射单独拿出来,
/// 四条分支在测试里直接钉住(见 `obs_state_maps_to_four_distinct_badges`)。
fn obs_badge(state: u8) -> ObsBadge {
match state {
1 => ObsBadge {
text: "启动中…",
color: OBS_BUSY,
busy: true,
},
2 => ObsBadge {
text: "已就绪",
color: theme::ACCENT_DARK,
busy: false,
},
3 => ObsBadge {
text: "失败",
color: theme::DANGER,
busy: false,
},
// 0 以及**将来任何新加的状态值**都落这里:漏一档的后果是这行没有灯
// (或者 panic),而「未启动」是唯一安全的兜底解释。
_ => ObsBadge {
text: "未启动",
color: theme::TEXT_SUB,
busy: false,
},
}
}
/// 下标 → 服务。和 [`OBS_METRICS`] 那一组常量配对。
fn obs_service(idx: usize) -> Service {
match idx {
OBS_LOGS => Service::Logs,
OBS_DASHBOARD => Service::Dashboard,
_ => Service::Metrics,
}
}
/// 服务名。和 `observability::missing()` 返回的是**同一批字符串**
/// (VictoriaMetrics / Loki / Grafana)—— 不一致的话,「未安装:Loki」
/// 和下面那一行的名字会对不上,读起来像两样东西。
fn obs_name(svc: Service) -> &'static str {
match svc {
Service::Metrics => "VictoriaMetrics",
Service::Logs => "Loki",
Service::Dashboard => "Grafana",
}
}
/// 端口输入框里的一段文字 → 端口号。`None` = 不是合法端口。
///
/// **0 不算端口**:Windows 上 `:0` 是「让系统随便挑一个」,于是配置文件里
/// 写着 0、服务实际听着另一个端口,而界面上的地址永远连不上 ——
/// 那比一个明确的「不合法」难查得多。上界交给 `u16` 自己判。
fn parse_port(text: &str) -> Option<u16> {
match text.trim().parse::<u16>() {
Ok(p) if p > 0 => Some(p),
_ => None,
}
}
/// 界面上那三个端口和 `store.json` 对齐 —— store 是唯一真值。
///
/// `AppState` 构造时已经按 store 填过一次(lib.rs 的 `obs_ports_from_store`),
/// 这里再对齐一次是因为**端口能在本程序里改**(Proxy and Port 页):改完立刻存回
/// store,而这一页显示的是 `obs_ports` 那个缓冲 —— 只靠构造时那一次,改完端口
/// 界面还会显示旧值,直到下次开程序(症状是"我改过了但它还写着老的")。
///
/// 复用 lib.rs 那个函数而不是在这里再拼一遍:**默认端口(9091/9092/9093)
/// 只允许有一份定义**,两份的话改一处忘一处就变成「界面显示一个端口、
/// 服务听另一个」。
///
/// 代价是输入框里留不住非法或半截的内容(清空 → 解析不了 → 下一帧退回上个值)。
/// 这是**有意**的:端口是数字,框里留着 `909x` 只会让人以为改过了,而服务听的
/// 是 store 里那个 —— 与「改了没生效」是同一种难查。
fn sync_obs_ports(cx: &mut PageCtx<'_>) {
let want = crate::obs_ports_from_store(&cx.state.store);
for idx in OBS_ORDER {
if cx.state.misc.obs_ports[idx] != want[idx] {
cx.state.misc.obs_ports[idx] = want[idx].clone();
}
}
}
/// 三个端口**一起**写回 store.json,返回一句话给界面。
///
/// 一起存是因为它们是配对项:Grafana 的 provision 文件里,VM/Loki 的地址
/// 就是这两个端口。只存被改的那一个,下次启动时另外两个还是旧值,症状是
/// 数据源指向没人监听的端口 —— 从界面上看不出这跟「没存」有关。
fn save_obs_ports(state: &mut AppState) -> Result<String, String> {
let mut vals = [
state.store.vm_port(),
state.store.loki_port(),
state.store.grafana_port(),
];
let mut bad: Vec<&'static str> = Vec::new();
for idx in OBS_ORDER {
match parse_port(&state.misc.obs_ports[idx]) {
Some(p) => vals[idx] = p,
// 非法的那个**不落盘**,退回上一次的真值(下一帧的 sync 会把框里
// 那串退回去)。留着它不动的话,框里看着像改了、store 里却是旧值,
// 下次开程序用户会发现「改的东西没了」。
None => bad.push(obs_name(obs_service(idx))),
}
}
state.store.set_vm_port(vals[OBS_METRICS]);
state.store.set_loki_port(vals[OBS_LOGS]);
state.store.set_grafana_port(vals[OBS_DASHBOARD]);
state.store.save()?;
if bad.is_empty() {
Ok(format!(
"端口已记忆:VictoriaMetrics={} Loki={} Grafana={}。三个是配对项 —— \
VM/Loki 的地址会写进 Grafana 的 provision 文件,所以改完要三件一起重启。",
vals[OBS_METRICS], vals[OBS_LOGS], vals[OBS_DASHBOARD]
))
} else {
Ok(format!(
"{} 的端口不是合法端口号(1~65535 的数字),那几个已经存了、这个退回上一次的值",
bad.join("、")
))
}
}
pub fn grafana(cx: &mut PageCtx<'_>) {
// 端口先和 store.json 对齐(理由见 `sync_obs_ports`)。
sync_obs_ports(cx);
let mut start_idx: Option<usize> = None;
let mut stop_idx: Option<usize> = None;
let mut stop_all = false;
let mut open = false;
// 这里**不碰** `obs_job` / `obs_proc`:`AppState::poll_observability`(lib.rs)
// 每帧都在跑,那三个灯的唯一来源就是它 —— 收启动结果、看进程还在不在、
// 置 2/3、写日志都是它的活。页面再轮询一次 `obs_job` 的话,
// 同一个结果会被两处抢着消费(`Job::poll` 取走接收端,第二个 poll 拿到的是
// 通道断开),表现成「灯随机不变」,而两边单看都对。
ui::card(cx.ui, |ui| {
ui::section(
ui,
"可观测性三件套",
"指标 VictoriaMetrics + 日志 Loki + 看板 Grafana;起停和端口都在这一页",
);
ui.add_space(6.0);
ui.label(
RichText::new(ui::md(
"三件套是**一套东西**,不是一个面板:VictoriaMetrics 存指标、Loki 存日志、\
Grafana 把这两样画出来。所以这里管的是**起停** —— 和那个独立进程的\
sd/llama 一样,不归 local_model 管。点「启动」之后程序在后台做四步\
(补配置 → 写 Grafana 数据源 → 起进程 → 等就绪),**等就绪最长几十秒**\
(Loki 实测 17 秒),所以灯会先黄一会儿,那不是卡住。\
内嵌面板那一步和「引擎」页一样要等 webview2-com,现在用系统浏览器打开。",
))
.size(11.5)
.color(theme::TEXT_SUB),
);
ui.add_space(8.0);
// ---- 缺件检测 ----
//
// 缺 exe 时**把启动按钮禁掉**:按下去只会 spawn 失败,而那句
// 「系统找不到指定的文件」指不到「该去放哪个文件」。
let missing = observability::missing(&cx.state.paths.libs());
ui.horizontal(|ui| {
if missing.is_empty() {
ui::status_pill(ui, true, "三件套齐全");
} else {
ui::status_pill(ui, false, &format!("未安装:{}", missing.join("、")));
}
});
if !missing.is_empty() {
ui.label(
RichText::new(ui::md(&format!(
"把 {} 的可执行文件放进 {} 之后再回这一页(取件脚本:\
libs\\scripts\\fetch-libs.ps1)。装齐之前启动按钮是禁用的。",
missing.join("、"),
cx.state.paths.libs().display()
)))
.size(11.0)
.color(theme::DANGER),
);
}
// ---- 三行:名字 + 状态灯 + 端口 + 启动/停止 ----
ui.add_space(8.0);
let job_running = cx.state.misc.obs_job.is_running();
// 端口用 lib.rs 那个换算函数(解析不了就退回该服务的默认端口),
// 而不是在这里再判一次:页面上写的数字必须**就是服务实际听的那个**,
// 两套回退规则迟早会分叉。
let ports = crate::obs_ports_u16(&cx.state.misc.obs_ports);
for idx in OBS_ORDER {
let svc = obs_service(idx);
let code = cx.state.misc.obs_state[idx];
let badge = obs_badge(code);
let running = cx.state.misc.obs_proc[idx].is_some();
ui.horizontal(|ui| {
ui.add_sized(
[116.0, 20.0],
egui::Label::new(
RichText::new(obs_name(svc)).size(12.5).color(theme::TEXT_MAIN),
),
);
// 状态灯自己画点:`ui::status_pill` 只有「就绪/未就绪」两色
// (bool 参数),表达不了这里的四种状态,而四色是这一页的要点 ——
// 要它支持四色得改 plugins/ui.rs(不在本次改动范围)。
ui.label(RichText::new("●").size(11.0).color(badge.color));
ui.label(RichText::new(badge.text).size(12.0).color(badge.color));
if badge.busy {
ui.spinner();
}
ui.label(
RichText::new(format!("端口 {}", ports[idx]))
.size(11.0)
.monospace()
.color(theme::TEXT_SUB),
);
// 能不能启动:**句柄不在**才算没在跑(`obs_state` 是说给人听的,
// `obs_proc` 才是事实)。用 `obs_proc` 判还有个好处 ——
// 「失败」那件能从这一行重试,而"已经在跑"的不会被再起一个
// (再起一个会因为端口被占而 bind 失败,界面上却是"已就绪")。
let can_start = missing.is_empty() && !job_running && !running;
if ui
.add_enabled(
can_start,
egui::Button::new("启动").min_size(egui::vec2(58.0, 24.0)),
)
.clicked()
{
start_idx = Some(idx);
}
// 「停止」按单个服务给:三件套常常只需要停一件(比如 Loki 吃满磁盘)。
// **启动中不给停**:那时 Job 还在等就绪,停了之后它的结果回来
// 会把这一件置成「已就绪」,而进程已经没了 —— 灯是绿的、点开却连不上。
let can_stop = running && !badge.busy;
if ui
.add_enabled(
can_stop,
egui::Button::new("停止").min_size(egui::vec2(58.0, 24.0)),
)
.clicked()
{
stop_idx = Some(idx);
}
if !can_start && !running && !badge.busy {
let why = if !missing.is_empty() {
"缺可执行文件"
} else {
// 一次只等一件:等就绪最长 36 秒(Loki),而 Job 只有一个 ——
// 让第二下点进去的话,`Job::start` 会静默丢掉,看起来就是
// 「点了没反应」。
"等上一件就绪中"
};
ui.label(RichText::new(why).size(10.5).color(theme::TEXT_SUB));
} else if running && badge.busy {
ui.label(
RichText::new("启动中:等它就绪再动(最长几十秒)")
.size(10.5)
.color(theme::TEXT_SUB),
);
}
});
}
// ---- 停止全部 + 打开 ----
ui.add_space(8.0);
ui.horizontal(|ui| {
// kill 这一步交给 `AppState::obs_stop`(lib.rs):那是全项目**唯一**
// 一处 kill + wait。页面自己也来一遍的话,同一件事有两份实现,
// 而漏掉 wait 的那份会留下占着端口的孤儿进程(Windows 上还漏句柄)。
// 注意这三件的子进程**不归 local_model 管**(那是 llama/sd 的),
// 所以也不能借它的 stop_all。
//
// 等就绪期间不给点「停止全部」:见上面单件停止的理由 ——
// 停了之后 Job 的结果回来会把灯重新点绿。
let any_proc = OBS_ORDER
.into_iter()
.any(|i| cx.state.misc.obs_proc[i].is_some());
if ui
.add_enabled(
any_proc && !job_running,
egui::Button::new("停止全部").min_size(egui::vec2(84.0, 26.0)),
)
.clicked()
{
stop_all = true;
}
// 「已就绪」以灯为准,但**还要句柄在**:灯和事实差一帧的时候宁可不给点。
let dash_ready = cx.state.misc.obs_state[OBS_DASHBOARD] == 2
&& cx.state.misc.obs_proc[OBS_DASHBOARD].is_some();
if ui
.add_enabled(
dash_ready,
egui::Button::new("打开 Grafana").min_size(egui::vec2(110.0, 26.0)),
)
.clicked()
{
open = true;
}
if dash_ready {
let url = format!("http://127.0.0.1:{}", ports[OBS_DASHBOARD]);
ui.label(RichText::new(url).size(10.5).color(theme::TEXT_SUB));
} else {
ui.label(
RichText::new("Grafana 还没就绪,现在打开只会是「无法连接」")
.size(10.5)
.color(theme::TEXT_SUB),
);
}
});
// ---- 端口说明 ----
ui.add_space(8.0);
ui.label(
RichText::new(ui::md(
"三个端口是**配对项**:Grafana 的 provision 文件里,VictoriaMetrics 和 Loki \
的地址写的就是这两个端口(每次点启动,程序都按当前端口把它重写一遍)。\
所以**改完端口要三件一起重启** —— 已经在跑的进程还听着旧端口,\
只重启 Grafana 的话数据源会指着没人监听的地方,症状是面板上「No data」,\
从那句话里看不出是端口问题。端口在 Setting › Proxy and Port 页改。",
))
.size(11.0)
.color(theme::TEXT_SUB),
);
ui.add_space(4.0);
ui::kv_one_line(
ui,
"数据源文件",
&cx.state
.paths
.root()
.join("data")
.join("grafana")
.join("provisioning")
.join("datasources")
.join("nfarms.yml")
.display()
.to_string(),
);
if let Some(m) = &cx.state.misc.obs_msg {
ui.add_space(6.0);
ui.label(RichText::new(m.as_str()).size(11.5).color(theme::TEXT_MAIN));
}
});
// ---- 启动一件 ----
//
// 页面只做「点一下 → 交给 `AppState::obs_start`」:补配置、写 Grafana 数据源、
// 起进程、等就绪那四步全在它那个后台 Job 里(lib.rs),等就绪是阻塞的、
// Loki 最长 36 秒 —— 在这儿自己起进程就会把窗口冻住几十秒,
// 而且那四步的顺序(先配置、后进程、同一组端口)是本页最不该重新实现的东西。
if let Some(idx) = start_idx {
cx.state.obs_start(idx, cx.egui);
}
// ---- 停止 ----
//
// 同样只转一手:`obs_stop` 是唯一一处 kill + wait(见上面那段说明)。
if let Some(idx) = stop_idx {
cx.state.obs_stop(idx);
}
if stop_all {
// 停之前先数一下有几件在跑 —— `obs_stop` 对没在跑的那件会写一句
// 「本来就没在运行」,三件连起来最后那句会盖掉真正停掉的那几件,
// 所以这里用一句话覆盖掉它。
let n = OBS_ORDER
.into_iter()
.filter(|i| cx.state.misc.obs_proc[*i].is_some())
.count();
for idx in OBS_ORDER {
cx.state.obs_stop(idx);
}
cx.state.misc.obs_msg = Some(format!("已停止 {n} 个服务(没在跑的不计)"));
}
// ---- 打开 Grafana ----
if open {
let ports = crate::obs_ports_u16(&cx.state.misc.obs_ports);
let url = format!("http://127.0.0.1:{}", ports[OBS_DASHBOARD]);
// 打开之前把数据源文件按**当前**端口重写一次:这个按钮只有 Grafana
// 就绪时才点得到,而 Grafana 的 provisioning 是热加载的 —— 刚改过端口、
// 只重启了 Grafana 的情况下,这一步能让它立刻指向现在的 VM/Loki,
// 而不是等下一个人去启动任何一件时才被顺手重写。
// **失败只记日志**:盘满/只读时服务本身是好的,不该因为一个配置文件
// 把「打开看板」也一起挡掉(那看起来像 Grafana 坏了,方向就查偏了)。
match observability::provision_datasources(
&cx.state.paths.root().join("data"),
ports[OBS_METRICS],
ports[OBS_LOGS],
) {
Ok(p) => cx
.state
.log
.write("grafana", &format!("打开前核对数据源:{}", p.display())),
Err(e) => cx
.state
.log
.write("grafana", &format!("核对数据源失败(不影响打开):{e}")),
}
// 和书签页那一处一样走 `cmd /c start`:用系统默认浏览器,
// 不在 GUI 里自己实现一个 —— 内嵌是 Browser 组那几页的事。
#[cfg(windows)]
{
let _ = std::process::Command::new("cmd")
.args(["/c", "start", "", &url])
.spawn();
}
#[cfg(not(windows))]
{
let _ = nfarms_core::browser::open_external(&url);
}
cx.state.misc.obs_msg = Some(format!("已交给系统浏览器:{url}"));
cx.state.log.write("grafana", &format!("打开 {url}"));
}
}
// ===========================================================================
// Slack off → 数字雨
// ===========================================================================
// ===========================================================================
// Zero Trust VPN → Packet capture(Wireshark)
// ===========================================================================
pub fn packet_capture(cx: &mut PageCtx<'_>) {
use nfarms_core::wireshark;
// 首次进页面:探测 Wireshark 并枚举网卡(枚举要起 dumpcap,放后台)
// 探测一次(用 probed 标志,不用 is_none —— 理由见 DshState::probed)
if !cx.state.capture.probed {
cx.state.capture.probed = true;
let ws = wireshark::Wireshark::detect();
if ws.available() {
let ws2 = ws.clone();
cx.state
.capture
.iface_job
.start(cx.egui, move || ws2.interfaces());
}
cx.state.capture.ws = Some(ws);
}
let mut rescan = false;
let mut start = false;
let mut stop = false;
let mut open_ws = false;
let capturing = cx.state.capture.capturing();
let ws_available = cx
.state
.capture
.ws
.as_ref()
.map(|w| w.available())
.unwrap_or(false);
ui::card(cx.ui, |ui| {
ui::section(
ui,
"Packet capture",
"集成 Wireshark / dumpcap:列网卡、抓包落盘、交给 Wireshark 打开",
);
ui.add_space(6.0);
ui.label(
RichText::new(ui::plain(
"**不自己实现抓包** —— dumpcap 已经把权限(Npcap 驱动)、环形缓冲、\
多接口这些脏活做完了,重写一遍没有意义。这里只负责:找它、列网卡、\
把选中的网卡落到 pcapng、交给 Wireshark 打开。",
))
.size(11.5)
.color(theme::TEXT_SUB),
);
ui.add_space(8.0);
if let Some(ws) = &cx.state.capture.ws {
ui.label(
RichText::new(ws.status_text())
.size(11.5)
.monospace()
.color(theme::TEXT_MAIN),
);
}
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui
.add_enabled(ws_available && !capturing, egui::Button::new("重新枚举网卡"))
.clicked()
{
rescan = true;
}
if ui
.add_enabled(capturing, egui::Button::new("停止抓包"))
.clicked()
{
stop = true;
}
if cx.state.capture.iface_job.is_running() {
ui.spinner();
}
});
});
if rescan
&& let Some(ws) = cx.state.capture.ws.clone()
{
cx.state
.capture
.iface_job
.start(cx.egui, move || ws.interfaces());
}
if stop {
let ok = cx.state.capture.stop();
cx.state.capture.message = Some(if ok {
"已停止抓包(dumpcap 会把已抓的包 flush 到文件)".to_string()
} else {
"当前没有在抓包".to_string()
});
}
cx.ui.add_space(12.0);
// ---- 网卡列表 ----
ui::card(cx.ui, |ui| {
ui::section(ui, "网卡", "dumpcap -D 的原样列表。");
ui.add_space(6.0);
match cx.state.capture.iface_job.result() {
None => {
ui.label(
RichText::new(if ws_available { "正在枚举…" } else { "先装好 Wireshark 才能枚举网卡。" })
.size(12.0)
.color(theme::TEXT_SUB),
);
}
Some(Err(e)) => {
ui::err_hint(ui, "没读到网卡");
ui.add_space(4.0);
ui.label(RichText::new(e).size(11.5).color(theme::TEXT_SUB));
}
Some(Ok(list)) => {
for (i, f) in list.iter().enumerate() {
let selected = cx.state.capture.iface_index == f.index;
if ui
.selectable_label(selected, RichText::new(f.label()).size(12.5))
.on_hover_text(&f.device)
.clicked()
{
cx.state.capture.iface_index = f.index;
}
let _ = i;
}
}
}
});
cx.ui.add_space(12.0);
// ---- 抓包参数 ----
ui::card(cx.ui, |ui| {
ui::section(ui, "抓包", "过滤表达式是 BPF 语法(和 Wireshark 输入框里的一样)。");
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label(RichText::new("过滤").size(12.0).color(theme::TEXT_SUB));
ui.add_sized(
[420.0, 26.0],
egui::TextEdit::singleline(&mut cx.state.capture.filter)
.hint_text("留空 = 全抓;例:tcp port 443 or host 10.0.0.1"),
);
});
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label(RichText::new("输出").size(12.0).color(theme::TEXT_SUB));
ui.add_sized(
[420.0, 26.0],
egui::TextEdit::singleline(&mut cx.state.capture.out)
.hint_text("留空 = shots\\capture-<时间戳>.pcapng"),
);
});
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui
.add_enabled(
ws_available && !capturing,
egui::Button::new("开始抓包").min_size(egui::vec2(100.0, 30.0)),
)
.clicked()
{
start = true;
}
if ui
.add_enabled(
cx.state.capture.last_pcap.is_some(),
egui::Button::new("用 Wireshark 打开").min_size(egui::vec2(150.0, 30.0)),
)
.clicked()
{
open_ws = true;
}
if capturing {
ui.label(
RichText::new("抓包中…")
.size(12.0)
.color(theme::ACCENT_DARK),
);
}
});
if let Some(m) = &cx.state.capture.message {
ui.add_space(6.0);
ui::ok_hint(ui, m);
}
if let Some(p) = &cx.state.capture.last_pcap {
ui.add_space(4.0);
ui::kv(ui, "最近文件", &p.display().to_string());
}
});
if start {
let out = if cx.state.capture.out.trim().is_empty() {
cx
.state
.paths
.shots()
.join(format!("capture-{}.pcapng", crate::now_stamp()))
} else {
std::path::PathBuf::from(cx.state.capture.out.trim())
};
if let Some(parent) = out.parent() {
let _ = std::fs::create_dir_all(parent);
}
let idx = cx.state.capture.iface_index;
let filter = cx.state.capture.filter.clone();
match cx.state.capture.ws.as_ref() {
Some(ws) => match ws.start_capture(idx, &out, &filter) {
Ok(child) => {
cx.state.capture.child = Some(child);
cx.state.capture.last_pcap = Some(out.clone());
cx.state.capture.message = Some(format!("已开始抓包 → {}", out.display()));
cx.state
.log
.write("capture", &format!("开始抓包 index={idx} -> {}", out.display()));
}
Err(e) => cx.state.capture.message = Some(e),
},
None => cx.state.capture.message = Some("Wireshark 还没探测到".to_string()),
}
}
if open_ws
&& let (Some(ws), Some(p)) = (
cx.state.capture.ws.as_ref(),
cx.state.capture.last_pcap.clone(),
)
{
match ws.open_in_wireshark(&p) {
Ok(()) => {
cx.state.capture.message = Some("已交给 Wireshark 打开".to_string());
cx.state
.log
.write("capture", &format!("用 Wireshark 打开 {}", p.display()));
}
Err(e) => cx.state.capture.message = Some(e),
}
}
}
// ===========================================================================
// Zero Trust VPN → SecureCRT / WinSCP(深度集成)
// ===========================================================================
pub fn secure_crt(cx: &mut PageCtx<'_>) {
use nfarms_core::remote;
if !cx.state.remote.crt_probed {
cx.state.remote.crt_probed = true;
cx.state.remote.crt_exe = remote::find_securecrt();
}
let exe = cx.state.remote.crt_exe.clone();
let dir = remote::securecrt_session_dir();
let mut reload = false;
let mut launch: Option<remote::Session> = None;
ui::card(cx.ui, |ui| {
ui::section(
ui,
"SecureCRT",
"深度集成:读它的会话目录,按会话名直接拉起",
);
ui.add_space(6.0);
ui.label(
RichText::new(ui::plain(
"深度集成 = 真去读 `%APPDATA%\\VanDyke\\Config\\Sessions\\*.ini`,列出全部会话,\
点一下用**它自己的会话名**启动(`/S`)。这样隧道、跳板、端口转发、字符编码\
这些会话设置全都保留 —— 自己拼 `host:port` 会把它们全丢掉。\
**只读不写、不碰密码。**",
))
.size(11.5)
.color(theme::TEXT_SUB),
);
ui.add_space(8.0);
ui::kv(
ui,
"程序",
&exe
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "(没找到 SecureCRT.exe)".to_string()),
);
ui::kv(
ui,
"会话目录",
&dir.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "(没有 %APPDATA%\\VanDyke\\Config\\Sessions)".to_string()),
);
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui
.add_enabled(exe.is_some(), egui::Button::new("重新读会话"))
.clicked()
{
reload = true;
}
if cx.state.remote.crt_sessions.is_running() {
ui.spinner();
}
});
});
if cx.state.remote.crt_sessions.result().is_none() && !cx.state.remote.crt_sessions.is_running() {
reload = true;
}
if reload {
cx.state.remote.crt_sessions.start(cx.egui, || {
nfarms_core::remote::securecrt_sessions()
});
}
cx.ui.add_space(12.0);
ui::card(cx.ui, |ui| {
let n = cx
.state
.remote
.crt_sessions
.result()
.map(|v| v.len())
.unwrap_or(0);
ui::section(ui, &format!("会话({n} 个)"), "点会话名直接打开。");
ui.add_space(6.0);
match cx.state.remote.crt_sessions.result() {
None => {
ui.label(RichText::new("读取中…").size(12.0).color(theme::TEXT_SUB));
}
Some(list) if list.is_empty() => {
ui.label(
RichText::new(
"没有读到会话。要么 SecureCRT 还没建过会话,要么它把配置放在别处\
(便携模式会放在程序目录下的 Config\\)。",
)
.size(11.5)
.color(theme::TEXT_SUB),
);
}
Some(list) => {
for s in list {
ui.horizontal(|ui| {
if ui
.add_enabled(exe.is_some(), egui::Button::new("打开"))
.clicked()
{
launch = Some(s.clone());
}
ui.add_sized(
[280.0, 18.0],
egui::Label::new(
RichText::new(&s.name).size(12.5).color(theme::TEXT_MAIN),
),
);
ui.label(
RichText::new(s.summary())
.size(11.5)
.color(theme::TEXT_SUB),
);
});
}
}
}
});
if let (Some(s), Some(e)) = (launch, cx.state.remote.crt_exe.clone()) {
match remote::launch_securecrt(&e, &s) {
Ok(()) => {
cx.state.remote.message = Some(format!("已打开会话 {}", s.name));
cx.state
.log
.write("securecrt", &format!("打开会话 {}", s.name));
}
Err(err) => cx.state.remote.message = Some(err),
}
}
if let Some(m) = &cx.state.remote.message {
cx.ui.add_space(10.0);
ui::ok_hint(cx.ui, m);
}
}
// ===========================================================================
// Environment → DSH-Starter
// ===========================================================================
/// `dsh_page`「控制台输出」卡片的跨帧缓存。
///
/// `dsh::tail()` 现在的实现是**先把整个日志读进内存**再取尾部 40 行
/// (`log.rs` 那句 `std::fs::read_to_string`),而这页开着的时候卡片**每帧**
/// 都会调一次;`dsh-web.log` 又没有任何轮转,跑上一天之后每帧一次全量读
/// 就会直接吃掉帧率。
///
/// 缓存键是(路径、长度、修改时间、行数)四元组:四个都没变就复用上一帧的
/// 字符串,一个变了就重读。**拿不到修改时间时不用缓存** —— 宁可每帧重读,
/// 也不能在日志变了之后还显示上一次的内容。
struct DshTailCache {
path: PathBuf,
len: u64,
mtime: Option<std::time::SystemTime>,
lines: usize,
text: String,
}
thread_local! {
static DSH_TAIL: RefCell<Option<DshTailCache>> = const { RefCell::new(None) };
}
// 给测试用的「真的读了几次盘」计数器。界面看不出命中与否(结果一样),
// 所以只能在这里看;生产构建里这个 static 根本不存在。
// (`//` 而非 `///`:理由同上面 `MGR_PROBE`。)
#[cfg(test)]
thread_local! {
static TAIL_READS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// 带跨帧缓存的 `dsh::tail()`(理由见 [`DshTailCache`])。
fn tail_cached(log: &Path, lines: usize) -> String {
let meta = std::fs::metadata(log).ok();
let len = meta.as_ref().map(|m| m.len()).unwrap_or(0);
let mtime = meta.as_ref().and_then(|m| m.modified().ok());
DSH_TAIL.with(|cache| {
let mut cache = cache.borrow_mut();
if let Some(c) = cache.as_ref()
&& mtime.is_some()
&& c.path == log
&& c.len == len
&& c.mtime == mtime
&& c.lines == lines
{
return c.text.clone();
}
let text = nfarms_core::dsh::tail(log, lines);
#[cfg(test)]
TAIL_READS.with(|n| n.set(n.get() + 1));
*cache = Some(DshTailCache {
path: log.to_path_buf(),
len,
mtime,
lines,
text: text.clone(),
});
text
})
}
pub fn dsh_page(cx: &mut PageCtx<'_>) {
use nfarms_core::dsh;
// **只探测一次**,而且要用 `probed` 而不是 `exe.is_none()` 当标志 ——
// 「没装 dsh」是个合法结果,用 is_none() 当守卫会变成每帧跑一次
// `where dsh`(每次起一个 cmd.exe),屏幕上就是疯狂闪控制台。
if !cx.state.dsh.probed {
cx.state.dsh.exe = dsh::find();
cx.state.dsh.probed = true;
// 写一行日志:一是诊断用,二是**这条只该出现一次** ——
// 它要是刷屏,就说明 `probed` 守卫坏了(以前正是每帧跑一次,
// 屏幕上疯狂闪控制台)。
let msg = cx
.state
.dsh
.exe
.clone()
.unwrap_or_else(|| "(PATH 上没有)".to_string());
cx.state.log.write("dsh", &format!("探测 dsh: {msg}"));
}
let mut start = false;
let mut stop = false;
let mut open_url = false;
let running = cx.state.dsh.child.is_some();
ui::card(cx.ui, |ui| {
ui::section(
ui,
"DSH-Starter",
"一键 dsh web --port <端口>,控制台输出集成在界面里",
);
ui.add_space(6.0);
ui.label(
RichText::new(ui::plain(
"原版踩过的坑:**`dsh web` 打印的根地址带一次性 token**,\
不带 token 打开只会 401。所以地址要从它自己的 stdout 里**抓**,\
不能自己拼 `http://127.0.0.1:<端口>/`。",
))
.size(11.5)
.color(theme::TEXT_SUB),
);
ui.add_space(8.0);
ui::kv(
ui,
"dsh",
&cx.state
.dsh
.exe
.clone()
.unwrap_or_else(|| "(PATH 上没有 dsh —— 它通常由 npm 全局装)".to_string()),
);
ui::kv(
ui,
"日志",
&cx.state.paths.show(&dsh::log_path(&cx.state.paths)),
);
ui.add_space(8.0);
ui.horizontal(|ui| {
// 端口已集中到 Setting › Proxy and Port —— 那里是唯一改端口的地方。
// 端口只在 Setting › Proxy and Port 改。这里显示当前值 ——
// 一眼能看到"它跑在哪个端口上",但改不了,避免两处都能改。
ui.label(
RichText::new(format!("端口 {}", cx.state.dsh.port))
.size(11.5)
.color(theme::TEXT_SUB),
)
.on_hover_text("要改端口请到 Setting › Proxy and Port");
if ui
.add_enabled(
!running && cx.state.dsh.exe.is_some(),
egui::Button::new("启动").min_size(egui::vec2(80.0, 28.0)),
)
.clicked()
{
start = true;
}
if ui
.add_enabled(running, egui::Button::new("停止").min_size(egui::vec2(80.0, 28.0)))
.clicked()
{
stop = true;
}
if ui
.add_enabled(
cx.state.dsh.url.is_some(),
egui::Button::new("用浏览器打开(带 token)").min_size(egui::vec2(180.0, 28.0)),
)
.clicked()
{
open_url = true;
}
if running {
ui.spinner();
}
});
if let Some(u) = &cx.state.dsh.url {
ui.add_space(4.0);
ui::kv(ui, "根地址", u);
} else if running {
ui.add_space(4.0);
ui.label(
RichText::new("还在等 dsh 打印带 token 的地址…(它启动要几百毫秒)")
.size(11.5)
.color(theme::TEXT_SUB),
);
}
if let Some(m) = &cx.state.dsh.message {
ui.add_space(4.0);
ui::ok_hint(ui, m);
}
});
if start {
let log = dsh::log_path(&cx.state.paths);
match dsh::start(cx.state.dsh.port, &log) {
Ok(child) => {
cx.state.dsh.child = Some(child);
cx.state.dsh.url = None;
cx.state.dsh.message = Some(format!("已启动,监听 :{}", cx.state.dsh.port));
cx.state
.log
.write("dsh", &format!("启动 dsh web --port {}", cx.state.dsh.port));
}
Err(e) => cx.state.dsh.message = Some(e),
}
}
if stop {
if let Some(mut c) = cx.state.dsh.child.take() {
let _ = c.kill();
let _ = c.wait();
}
cx.state.dsh.url = None;
cx.state.dsh.message = Some("已停止".to_string());
cx.state.log.write("dsh", "停止 dsh");
}
if open_url
&& let Some(u) = cx.state.dsh.url.clone()
{
match nfarms_core::browser::open_external(&u) {
Ok(()) => cx.state.dsh.message = Some("已交给系统浏览器".to_string()),
Err(e) => cx.state.dsh.message = Some(e),
}
}
cx.ui.add_space(12.0);
let log = dsh::log_path(&cx.state.paths);
ui::card(cx.ui, |ui| {
ui::section(ui, "控制台输出(尾部 40 行)", "读的是日志文件,不接管道。");
ui.add_space(6.0);
// 走带缓存的版本:这一页每帧都画,文件没变就不该每帧重读一遍。
let text = tail_cached(&log, 40);
if text.trim().is_empty() {
ui.label(RichText::new("(还没有输出)").size(12.0).color(theme::TEXT_SUB));
} else {
ui.label(
RichText::new(text)
.size(11.0)
.monospace()
.color(theme::TEXT_MAIN),
);
}
});
}
// ===========================================================================
// Tools → bilibili-go(直播录制)
// ===========================================================================
/// bilibili-go 与「拉流」两页共用的一块:bililive-go 的运行控制。
///
/// 抽出来是因为原版这两处本来就是同一个东西(`Live.cs`):Tools 里的
/// bilibili-go 是「录制管理」,Player 里的拉流是「解析 + 录制」。
pub fn live_go_card(cx: &mut PageCtx<'_>, show_rooms: bool) {
use nfarms_core::live;
if !cx.state.live_go.probed {
cx.state.live_go.probed = true;
let l = live::Live::detect(&cx.state.paths);
cx.state.live_go.rooms = l.rooms();
cx.state.live_go.live = Some(l);
}
let mut start = false;
let mut stop = false;
let mut ping = false;
let mut add = false;
let mut remove: Option<String> = None;
let running = cx.state.live_go.child.is_some();
let available = cx
.state
.live_go
.live
.as_ref()
.map(|l| l.available())
.unwrap_or(false);
ui::card(cx.ui, |ui| {
ui::section(
ui,
"bililive-go(直播录制)",
"外部程序:本程序不重写它,只做「找到它、起停它、读写它的 config.yml」",
);
ui.add_space(6.0);
if let Some(l) = &cx.state.live_go.live {
ui.label(
RichText::new(l.status_text())
.size(11.5)
.color(theme::TEXT_MAIN),
);
ui::kv(ui, "配置", &cx.state.paths.show(&l.config_path()));
ui::kv(ui, "日志", &cx.state.paths.show(&l.log_path()));
}
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui
.add_enabled(
!running && available,
egui::Button::new("启动服务").min_size(egui::vec2(100.0, 28.0)),
)
.clicked()
{
start = true;
}
if ui
.add_enabled(running, egui::Button::new("停止").min_size(egui::vec2(80.0, 28.0)))
.clicked()
{
stop = true;
}
if ui.button("查状态").clicked() {
ping = true;
}
if ui
.add_enabled(
available || running,
egui::Button::new("打开下载页").min_size(egui::vec2(110.0, 28.0)),
)
.clicked()
{
let _ = nfarms_core::browser::open_external(live::RELEASES_PAGE);
}
if running {
ui.spinner();
}
});
if let Some(s) = &cx.state.live_go.status {
ui.add_space(4.0);
ui::kv(ui, "服务", s);
}
if let Some(m) = &cx.state.live_go.message {
ui.add_space(4.0);
ui::ok_hint(ui, m);
}
});
if start {
match cx.state.live_go.live.as_ref().map(|l| l.start()) {
Some(Ok(child)) => {
cx.state.live_go.child = Some(child);
cx.state.live_go.message = Some("已启动(输出在日志文件里)".to_string());
cx.state.log.write("live", "启动 bililive-go");
}
Some(Err(e)) => cx.state.live_go.message = Some(e),
None => cx.state.live_go.message = Some("还没探测到".to_string()),
}
}
if stop {
if let Some(mut c) = cx.state.live_go.child.take() {
let _ = c.kill();
let _ = c.wait();
}
cx.state.live_go.status = None;
cx.state.live_go.message = Some("已停止".to_string());
}
if ping
&& let Some(l) = &cx.state.live_go.live
{
match l.ping() {
Ok(s) => {
cx.state.live_go.status = Some(s);
cx.state.live_go.message = Some("服务在跑".to_string());
}
Err(e) => {
cx.state.live_go.status = None;
cx.state.live_go.message = Some(e);
}
}
}
if !show_rooms {
return;
}
cx.ui.add_space(12.0);
ui::card(cx.ui, |ui| {
ui::section(
ui,
&format!("直播间({} 个)", cx.state.live_go.rooms.len()),
"读写的是 libs\\bililive-go\\config.yml —— 只动 live_rooms 那一段,其它设置原样保留",
);
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.add_sized(
[420.0, 26.0],
egui::TextEdit::singleline(&mut cx.state.live_go.room_input)
.hint_text("https://live.bilibili.com/123"),
);
if ui
.add_enabled(
!cx.state.live_go.room_input.trim().is_empty(),
egui::Button::new("添加"),
)
.clicked()
{
add = true;
}
});
ui.add_space(6.0);
if cx.state.live_go.rooms.is_empty() {
ui.label(
RichText::new("(还没有直播间)—— 加一个地址,或者直接用 bililive-go 自己的界面加")
.size(12.0)
.color(theme::TEXT_SUB),
);
}
for (i, r) in cx.state.live_go.rooms.iter().enumerate() {
ui.horizontal(|ui| {
if ui.button("删除").clicked() {
remove = Some(r.clone());
}
ui.label(
RichText::new(format!("{i}. {r}"))
.size(12.0)
.color(theme::TEXT_MAIN),
);
});
}
});
if add {
let url = cx.state.live_go.room_input.trim().to_string();
match cx.state.live_go.live.as_ref().map(|l| l.add_room(&url)) {
Some(Ok(true)) => {
cx.state.live_go.rooms = cx
.state
.live_go
.live
.as_ref()
.map(|l| l.rooms())
.unwrap_or_default();
cx.state.live_go.room_input.clear();
cx.state.live_go.message = Some(format!("已加入 {url}"));
cx.state.log.write("live", &format!("加入直播间 {url}"));
}
Some(Ok(false)) => cx.state.live_go.message = Some("这个地址已经有了".to_string()),
Some(Err(e)) => cx.state.live_go.message = Some(e),
None => {}
}
}
if let Some(r) = remove {
match cx.state.live_go.live.as_ref().map(|l| l.remove_room(&r)) {
Some(Ok(true)) => {
cx.state.live_go.rooms = cx
.state
.live_go
.live
.as_ref()
.map(|l| l.rooms())
.unwrap_or_default();
cx.state.live_go.message = Some(format!("已删除 {r}"));
}
Some(Ok(false)) => cx.state.live_go.message = Some("配置里没找到这条".to_string()),
Some(Err(e)) => cx.state.live_go.message = Some(e),
None => {}
}
}
}
pub fn bilibili_go(cx: &mut PageCtx<'_>) {
live_go_card(cx, true);
}
// ===========================================================================
// Player → 视频拉流与解析
// ===========================================================================
// ===========================================================================
// Tools → Script(脚本下发)
// ===========================================================================
// ===========================================================================
// Player → 视频播放器
// ===========================================================================
pub fn video(cx: &mut PageCtx<'_>) {
use crate::background::FilePicker as FP;
let ff = nfarms_core::ffmpeg::Ffmpeg::detect(&cx.state.paths);
let mut pick = false;
let mut load = false;
let mut toggle = false;
let mut stop = false;
let mut seek_to: Option<i64> = None;
let dur = cx.state.misc.video_duration_ms;
let has = dur > 0;
ui::card(cx.ui, |ui| {
ui::section(
ui,
"视频播放器",
"原版嵌 Windows Media Player ActiveX;Rust 这版统一走 ffmpeg 抽帧上纹理",
);
ui.add_space(6.0);
ui.label(
RichText::new(ui::plain(
"原版用 ActiveX 嵌 WMP,解码能力受系统限制(装了什么解码器就只能播什么)。\
这里改走 ffmpeg:**按时间点抽帧 → 解码 → 上纹理**。\
代价是约 2 帧/秒(每次抽帧都要起一次 ffmpeg),好处是格式支持等于 ffmpeg,\
而且和「拉流」那页共用同一套地基。要做 30 帧的顺滑播放,得改成\
`-f rawvideo` 管道喂帧,那是下一步。",
))
.size(11.5)
.color(theme::TEXT_SUB),
);
ui.add_space(8.0);
if !ff.available() {
ui.label(
RichText::new("ffmpeg 没探测到 —— 先去 Tools › ffmpeg 那一页装好。")
.size(12.0)
.color(theme::DANGER),
);
ui.add_space(6.0);
}
ui.horizontal(|ui| {
if ui.button("打开视频…").clicked() {
pick = true;
}
ui.add_sized(
[380.0, 26.0],
egui::TextEdit::singleline(&mut cx.state.misc.video_src)
.hint_text(r"C:\video\input.mkv 或 http://…/live.m3u8"),
);
if ui
.add_enabled(ff.available(), egui::Button::new("载入"))
.clicked()
{
load = true;
}
});
ui.add_space(8.0);
ui.horizontal(|ui| {
let label = if cx.state.misc.video_playing {
"暂停"
} else {
"播放"
};
if ui
.add_enabled(has, egui::Button::new(label).min_size(egui::vec2(80.0, 30.0)))
.clicked()
{
toggle = true;
}
if ui
.add_enabled(has, egui::Button::new("停止").min_size(egui::vec2(80.0, 30.0)))
.clicked()
{
stop = true;
}
ui.label(
RichText::new(format!(
"{} / {}",
nfarms_core::music::Music::fmt_ms(cx.state.misc.video_pos_ms),
nfarms_core::music::Music::fmt_ms(dur)
))
.size(12.0)
.color(theme::TEXT_SUB),
);
if cx.state.misc.video_frame.is_running() {
ui.spinner();
}
});
let mut p = cx.state.misc.video_pos_ms as f32;
if ui
.add(
egui::Slider::new(&mut p, 0.0..=(if has { dur as f32 } else { 1.0 }))
.show_value(false),
)
.drag_stopped()
{
seek_to = Some(p as i64);
}
if !cx.state.misc.video_info.is_empty() {
ui.add_space(6.0);
ui.label(
RichText::new(&cx.state.misc.video_info)
.size(11.0)
.monospace()
.color(theme::TEXT_SUB),
);
}
if let Some(m) = &cx.state.misc.message {
ui.add_space(4.0);
ui::ok_hint(ui, m);
}
});
if pick {
// 异步:结果从 `picker.poll()` 回来,由 `poll_background_drop`
// 写进 `video_src` 并置 `video_want_load`。
cx.state.picker.pick_file(
cx.egui,
crate::background::PickPurpose::Video,
"打开视频",
FP::VIDEO_FILTER,
);
}
// 上一帧(或别的页面)请求载入 —— 走下面同一条载入路径,不重写一份
if std::mem::take(&mut cx.state.misc.video_want_load) {
load = true;
}
if load {
let src = cx.state.misc.video_src.trim().to_string();
if src.is_empty() {
cx.state.misc.message = Some("源是空的".to_string());
} else if ff.available() && let Some(ff2) = Some(ff.clone()) {
let p = std::path::PathBuf::from(&src);
let info = ff2.media_info(&p);
let d = ff2.duration_ms(&p);
cx.state.misc.video_info = info;
cx.state.misc.video_duration_ms = d.max(0);
cx.state.misc.video_pos_ms = 0;
cx.state.misc.video_last_shot = -1;
cx.state.misc.video_tex = None;
cx.state.misc.message = Some(if d > 0 {
format!("载入成功,时长 {}", nfarms_core::music::Music::fmt_ms(d))
} else {
"读到文件但拿不到时长(远程流常常如此);播放仍可尝试".to_string()
});
// **载入完直接播。** 选了文件就是要看,不该再点一次「播放」。
cx.state.misc.video_playing = true;
cx.state.misc.video_last_shot = -1;
cx.state.log.write("video", &format!("载入 {src}({d} ms),开始播放"));
}
}
if stop {
cx.state.misc.video_playing = false;
cx.state.misc.video_pos_ms = 0;
}
if toggle {
cx.state.misc.video_playing = !cx.state.misc.video_playing;
cx.state.misc.video_last_shot = -1;
}
if let Some(ms) = seek_to {
cx.state.misc.video_pos_ms = ms;
cx.state.misc.video_last_shot = -1;
}
// ---- 抽帧驱动 ----
// 完成上一帧 → 上纹理 → 位置前进 500 ms(约 2 帧/秒)
if cx.state.misc.video_frame.poll()
&& let Some(r) = cx.state.misc.video_frame.result().cloned()
{
match r {
Ok((img, (w, h))) => {
cx.state.misc.video_tex = Some(cx.egui.load_texture(
"nfarms-video-frame",
img,
egui::TextureOptions::LINEAR,
));
let _ = (w, h);
if cx.state.misc.video_playing {
cx.state.misc.video_pos_ms += 500;
let dur = cx.state.misc.video_duration_ms;
if dur > 0 && cx.state.misc.video_pos_ms >= dur {
cx.state.misc.video_pos_ms = dur;
cx.state.misc.video_playing = false;
cx.state.misc.message = Some("播放到结尾".to_string());
}
}
}
Err(e) => {
cx.state.misc.video_playing = false;
cx.state.misc.message = Some(format!("抽帧失败: {e}"));
}
}
}
// 需要新帧就起一个任务
let need_frame = ff.available()
&& !cx.state.misc.video_src.trim().is_empty()
&& !cx.state.misc.video_frame.is_running()
&& (cx.state.misc.video_playing || cx.state.misc.video_last_shot != cx.state.misc.video_pos_ms);
if need_frame {
let at = cx.state.misc.video_pos_ms;
cx.state.misc.video_last_shot = at;
let src = cx.state.misc.video_src.trim().to_string();
let ff2 = ff.clone();
let tmp = std::env::temp_dir().join(format!("nfarms-vframe-{}.png", at));
let tmp2 = tmp.clone();
cx.state.misc.video_frame.start(cx.egui, move || {
ff2.snapshot(std::path::Path::new(&src), &tmp2, at as f64 / 1000.0)?;
let r = crate::background::decode_for_viewer(&tmp2);
let _ = std::fs::remove_file(&tmp2);
r
});
cx.egui
.request_repaint_after(std::time::Duration::from_millis(120));
}
cx.ui.add_space(12.0);
let max_h = (cx.egui.screen_rect().height() - 460.0).max(200.0);
// 纹理的 id / 尺寸先拷出来:`ui::card` 的闭包已经把 `cx` 借走了,
// 闭包里再碰 `cx.state` 就是二次借用。
let tex_info = cx
.state
.misc
.video_tex
.as_ref()
.map(|t| (t.id(), t.size_vec2()));
ui::card(cx.ui, |ui| match tex_info {
Some((id, sz)) => {
let (w, h) = (sz.x.max(1.0), sz.y.max(1.0));
let scale = (ui.available_width() / w).min(max_h / h).min(2.0);
ui.add(egui::Image::new((id, egui::vec2(w * scale, h * scale))));
}
None => {
ui.label(
RichText::new("(还没有画面)—— 选一个视频再点「载入」")
.size(12.5)
.color(theme::TEXT_SUB),
);
}
});
}
// ===========================================================================
// Setting → 监控并同步 / 打印代理
// ===========================================================================
//
// 「启动动图」原来就在这一节里(一个独立的 `boot_anim` 绘制函数)。它的卡片已经
// 并进 Setting › 背景图片 那一页(见 `boot_card`),原来的 `Page::SettingsBootAnim`
// 变体、`PAGES` 表里的条目和 `plugins/mod.rs` 的映射由另一处改动同步删掉 ——
// 这里只保证绘制函数不再存在。
pub fn sync(cx: &mut PageCtx<'_>) {
let mut report = false;
// 报文在闭包外算好:`ui::card` 的闭包已经把 `cx` 借走了
let payload = sync_payload(cx);
ui::card(cx.ui, |ui| {
ui::section(ui, "监控并同步", "把本机状态上报到平台(需要先选择开启)");
ui.add_space(6.0);
ui.label(
RichText::new(ui::plain(
"**默认关闭,而且绝不偷偷打开**。上报内容只有下面这几项,不含任何路径之外的\
个人信息;上报前会把报文原样列出来给你看。\n\
原版这里是「实时监控 + 定时同步」,但没说明白了发什么 —— 这一版把它改清楚了。",
))
.size(11.5)
.color(theme::TEXT_SUB),
);
ui.add_space(10.0);
ui.horizontal(|ui| {
ui.checkbox(&mut cx.state.misc.sync_enabled, RichText::new("开启上报").size(12.5));
ui.label(RichText::new("目标").size(12.0).color(theme::TEXT_SUB));
ui.add_sized(
[260.0, 26.0],
egui::TextEdit::singleline(&mut cx.state.misc.sync_target),
);
if ui
.add_enabled(
cx.state.misc.sync_enabled,
egui::Button::new("立即上报一次").min_size(egui::vec2(120.0, 28.0)),
)
.clicked()
{
report = true;
}
});
ui.add_space(8.0);
ui::section(ui, "将要发送的内容", "就是这些,没有别的。");
ui.add_space(4.0);
ui.label(
RichText::new(payload)
.size(11.0)
.monospace()
.color(theme::TEXT_MAIN),
);
if let Some(m) = &cx.state.misc.message {
ui.add_space(6.0);
ui::ok_hint(ui, m);
}
});
if report {
cx.state
.log
.write("sync", &format!("上报一次(目标 {})", cx.state.misc.sync_target));
cx.state.misc.message = Some(
"已记录本次上报。真正的上传端点还没定(平台侧接口未提供),所以现在只写日志、不发网络请求 —— \
宁可如实说没发,也不要假装发成功了。"
.to_string(),
);
}
}
/// 上报报文(给用户看的,和真正会发的一致)。
fn sync_payload(cx: &PageCtx<'_>) -> String {
format!(
"{{\n \"host\": \"{}\",\n \"version\": \"{}\",\n \"pages\": {},\n \"log_dir\": \"{}\"\n}}",
std::env::var("COMPUTERNAME").unwrap_or_else(|_| "(未知)".to_string()),
env!("CARGO_PKG_VERSION"),
crate::pages::PAGES.len(),
cx.state.paths.logs().display()
)
}
pub fn printer(cx: &mut PageCtx<'_>) {
// 这一页还兼着「端口总表」的职责,其中包括可观测性三件套 ——
// 先把那三个框和 store.json 对齐,免得显示的是上一次的值。
sync_obs_ports(cx);
let mut detect = false;
let mut start = false;
let mut stop = false;
let mut open = false;
if !cx.state.misc.printer_probed {
cx.state.misc.printer_probed = true;
// 只探测一次
let p = cx
.state
.paths
.lib("nfos-printer")
.join("nfos-printer.exe");
cx.state.misc.printer_exe = p.is_file().then_some(p);
}
let running = cx.state.misc.printer_child.is_some();
let url = format!("http://127.0.0.1:{}/", cx.state.misc.printer_port);
ui::card(cx.ui, |ui| {
ui::section(ui, "打印代理", "本地 HTTP 网关 + 打印:把小票/标签打印对接到平台");
ui.add_space(6.0);
ui.label(
RichText::new(ui::plain(
"**不重写网关**。原版 `PrintServer.cs` 是个本地 HTTP 服务\
(收 POST → 拼指令 → 送打印机),这套东西的复杂度在**打印机指令集**\
(ESC/POS、TSPL、ZPL)上,不在 HTTP 上。所以这一页做的是:\
探测并起停 `libs\\nfos-printer\\nfos-printer.exe`,把端口和地址管好。",
))
.size(11.5)
.color(theme::TEXT_SUB),
);
ui.add_space(8.0);
ui::kv(
ui,
"网关程序",
&cx.state
.misc
.printer_exe
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| {
format!(
"(没找到)放到 {} 即启用",
cx.state.paths.lib("nfos-printer").display()
)
}),
);
ui::kv(ui, "监听地址", &url);
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label(RichText::new("端口").size(12.0).color(theme::TEXT_SUB));
ui.add_sized(
[90.0, 26.0],
egui::DragValue::new(&mut cx.state.misc.printer_port).range(1024..=65535),
);
if ui.button("重新探测").clicked() {
detect = true;
}
let has = cx.state.misc.printer_exe.is_some();
if ui
.add_enabled(!running && has, egui::Button::new("启动网关").min_size(egui::vec2(100.0, 28.0)))
.clicked()
{
start = true;
}
if ui
.add_enabled(running, egui::Button::new("停止").min_size(egui::vec2(80.0, 28.0)))
.clicked()
{
stop = true;
}
if ui
.add_enabled(running, egui::Button::new("打开接口页"))
.clicked()
{
open = true;
}
if running {
ui.spinner();
}
});
// ===================== 端口总表(唯一改端口的地方)=====================
// 原来散在三页:DSH 端口在 DSH-Starter 页、llama.cpp 端口在 llama.cpp 页、
// sd.cpp 端口在 DSH 页。接入 DSH 时这几个**必须配对**(provider 的 baseURL
// 指向它们),分散开就会出现「改了一个忘了另一个」,而症状是
// connection refused —— 从这句话里看不出是端口问题。
//
// 所以:其他地方一律改成**只读显示**,改端口只在这里。
// ---- 端口 + 上下文:每个都是**唯一真值** ----
//
// 这几项必须**配对**(DSH 侧的 baseURL / contextWindow 指向它们),
// 分散开就会出现「改了一个忘了另一个」,而症状是 connection
// refused 或 400 —— 从报错里看不出是端口/上下文问题。
// 所以:其他地方一律**只读显示**,能改的只有这里,而且只有一处。
let mut port_msg: Option<String> = None;
ui.label(RichText::new("llama.cpp 端口").size(12.0).color(theme::TEXT_SUB));
let llama_resp = ui
.add_sized(
[90.0, 26.0],
egui::TextEdit::singleline(&mut cx.state.local_model.llama_port),
)
.on_hover_text("llama-server 端口;DSH 里 llama-cpp-local 的 baseURL 用它");
ui.label(RichText::new("sd.cpp 端口").size(12.0).color(theme::TEXT_SUB));
let sd_resp = ui
.add_sized(
[90.0, 26.0],
egui::TextEdit::singleline(&mut cx.state.local_model.sd_port),
)
.on_hover_text("sd-server 端口;DSH 里 sd-cpp-local 的 baseURL 用它");
ui.label(RichText::new("DSH 端口").size(12.0).color(theme::TEXT_SUB));
let dsh_resp = ui
.add_sized(
[90.0, 26.0],
egui::DragValue::new(&mut cx.state.dsh.port).range(1..=65535),
)
.on_hover_text("dsh web 监听端口;改了要重启 DSH");
ui.label(RichText::new("上下文").size(12.0).color(theme::TEXT_SUB));
let ctx_resp = ui
.add_sized(
[90.0, 26.0],
egui::TextEdit::singleline(&mut cx.state.local_model.llama_ctx),
)
.on_hover_text(
"llama-server 的 -c/--ctx-size;DSH 里 llama-cpp-local 的 \
contextWindow 必须是同一个数。改完要重启 llama-server 才生效。",
);
// ---- 可观测性三件套的端口(Grafana 页那三个服务)----
//
// 和上面几行同样是**真值**不是草稿,而且比它们更要紧:这三个端口
// 同时出现在三个地方 —— Grafana 的 provision 文件(VM/Loki 的地址)、
// Loki 自己的 loki-config.yaml、各自的启动参数。所以它们是
// **配对项**:三个要一起改、一起重启(理由写在 Grafana 页那行说明里)。
ui.label(
RichText::new("VictoriaMetrics 端口")
.size(12.0)
.color(theme::TEXT_SUB),
);
let obs_vm_resp = ui
.add_sized(
[90.0, 26.0],
egui::TextEdit::singleline(&mut cx.state.misc.obs_ports[OBS_METRICS]),
)
.on_hover_text("时序库的监听端口;Grafana 里那两条数据源之一指向它");
ui.label(
RichText::new("Loki 端口")
.size(12.0)
.color(theme::TEXT_SUB),
);
let obs_loki_resp = ui
.add_sized(
[90.0, 26.0],
egui::TextEdit::singleline(&mut cx.state.misc.obs_ports[OBS_LOGS]),
)
.on_hover_text(
"日志库的 HTTP 端口;它的 gRPC 端口是 +3(写进 loki-config.yaml)",
);
ui.label(
RichText::new("Grafana 端口")
.size(12.0)
.color(theme::TEXT_SUB),
);
let obs_graf_resp = ui
.add_sized(
[90.0, 26.0],
egui::TextEdit::singleline(&mut cx.state.misc.obs_ports[OBS_DASHBOARD]),
)
.on_hover_text("看板端口;改了要重启 Grafana 才生效");
// 改了**立刻存回**。端口和上下文是**真值**不是草稿:
// 不存的话下次启动回到旧值,而症状是「我明明改过了」——
// 与「保存了却从没生效」一样难查,只是方向相反。
if llama_resp.changed() || sd_resp.changed() {
match cx.state.local_model.save_ports(&mut cx.state.store) {
Ok(()) => {
port_msg = Some(format!(
"端口已记忆: llama.cpp={} sd.cpp={}",
cx.state.local_model.llama_port.trim(),
cx.state.local_model.sd_port.trim()
));
}
Err(e) => port_msg = Some(format!("端口保存失败: {e}")),
}
}
if ctx_resp.changed() {
match cx.state.local_model.save_ctx(&mut cx.state.store) {
Ok(()) => {
port_msg = Some(format!(
"上下文长度已记忆:-c {}。记得把 DSH 的 contextWindow 也改成 {},\
否则超限时只会得到一个 400",
cx.state.local_model.llama_ctx.trim(),
cx.state.local_model.llama_ctx.trim()
));
}
Err(e) => port_msg = Some(format!("上下文长度保存失败: {e}")),
}
}
if dsh_resp.changed() {
cx.state.settings.dsh_port = cx.state.dsh.port;
cx.state.save_settings();
}
// 三件套的端口**三个一起存**:它们是配对项(见上面那段说明),
// 只存被改的那一个,下次启动时另外两个还是旧值,症状是数据源
// 指向没人监听的端口 —— 从界面上看不出这跟"没存"有关。
if obs_vm_resp.changed() || obs_loki_resp.changed() || obs_graf_resp.changed() {
match save_obs_ports(cx.state) {
Ok(m) => port_msg = Some(m),
Err(e) => port_msg = Some(format!("可观测性端口保存失败: {e}")),
}
}
// ---- 服务控制:**启动和停止挨着** ----
//
// 原来「启动服务」散在 llama.cpp 页、「停止」在这个页 ——
// 同一个服务的两个动作分在两个地方,这是「不唯一」的直接来源。
// 而且同一时刻只允许一个本地服务在跑(`running_name`),
// 所以合成**一组**才是它的真实形态。
ui.add_space(10.0);
ui.horizontal(|ui| {
ui.label(RichText::new("服务").size(12.0).color(theme::TEXT_SUB));
let running = cx.state.local_model.running_name();
match running {
Some(n) => {
ui::status_pill(ui, true, &format!("{n} 运行中"));
}
None => {
ui::status_pill(ui, false, "没有本地服务在跑");
}
}
});
ui.horizontal(|ui| {
// 同一时刻只跑一个:已经有别的在跑时,启动按钮直接禁用,
// 而不是让它点了之后报一个 bind 失败 —— 那看不出"前面那个还活着"。
let busy = cx.state.local_model.running_name().is_some();
if ui
.add_enabled(!busy, egui::Button::new("启动 llama.cpp"))
.clicked()
{
cx.state.local_model.start_llama(&cx.state.paths, &cx.state.log);
}
if ui
.add_enabled(!busy, egui::Button::new("启动 sd.cpp"))
.clicked()
{
cx.state.local_model.start_sd(&cx.state.paths, &cx.state.log);
}
if ui
.add_enabled(busy, egui::Button::new("停止全部"))
.clicked()
{
cx.state.local_model.stop_all();
cx.state.log.write("gui", "本地服务已全部停止");
}
});
// ---- 按端口强制终止 ----
//
// **和上面那个「停止」不是一回事,所以两个都留:**
// 本程序的停止只能停它**自己拉起的子进程**;如果 llama-server 是
// 别处启动的(手动跑、上次没退干净、另一个实例),程序显示「未运行」
// 但端口实际被占。这里**按端口**找进程并强杀,不关心是谁拉起的。
//
// 两个按钮组看起来像重复,其实处理的是**两种不同情况** ——
// 上面那个是"我起的,我停",这个是"不是我起的,也得能停"。
ui.add_space(10.0);
ui.label(
RichText::new("按端口强制终止(停掉不是本程序拉起的那个)")
.size(12.0)
.color(theme::TEXT_SUB),
);
ui.horizontal(|ui| {
// netstat + taskkill 约 1~2 秒同步等待。这是**手动动作**,
// 短暂卡顿可以接受;放后台反而要处理「点两次」的并发。
// 三件套的端口用 lib.rs 那个换算函数:框里万一留着半截数字,
// 这里拿去 kill 的必须是**服务实际在听的那个端口**,不是那串文字。
let obs_ports = crate::obs_ports_u16(&cx.state.misc.obs_ports);
let ports = [
("llama.cpp", cx.state.local_model.llama_port.clone()),
("sd.cpp", cx.state.local_model.sd_port.clone()),
("DSH", cx.state.dsh.port.to_string()),
// 三件套也列在这里:本程序自己的「停止」在 Grafana 页,
// 而这一行管的是**不是本程序拉起的**那些(手动跑的、
// 上次没退干净的)—— 两种情况的处理方式不同,所以两组都在。
("VictoriaMetrics", obs_ports[OBS_METRICS].to_string()),
("Loki", obs_ports[OBS_LOGS].to_string()),
("Grafana", obs_ports[OBS_DASHBOARD].to_string()),
];
for (label, port_s) in ports {
if !ui.button(format!("■ {label}")).clicked() {
continue;
}
let msg = match port_s.trim().parse::<u16>() {
Ok(p) => nfarms_core::tools::kill_by_port(p).unwrap_or_else(|e| e),
Err(_) => format!("{label} 的端口「{port_s}」不是合法端口号,没执行"),
};
cx.state.log.write("gui", &msg);
cx.state.misc.port_message = Some(msg);
}
});
if let Some(m) = port_msg {
cx.state.log.write("gui", &m);
cx.state.misc.port_message = Some(m);
}
if let Some(m) = &cx.state.misc.port_message {
ui.label(RichText::new(m).size(11.0).color(theme::TEXT_MAIN));
}
ui.add_space(6.0);
ui.label(
RichText::new(
"网关协议(原版定下来的):`POST /print` 带 JSON,\
`GET /health` 返回存活。想知道接到哪台打印机,看网关自己的日志。",
)
.size(11.0)
.color(theme::TEXT_SUB),
);
if let Some(m) = &cx.state.misc.message {
ui.add_space(6.0);
ui::ok_hint(ui, m);
}
});
if detect {
let p = cx
.state
.paths
.lib("nfos-printer")
.join("nfos-printer.exe");
cx.state.misc.printer_exe = p.is_file().then_some(p);
cx.state.misc.message = Some("已重新探测".to_string());
}
if start
&& let Some(exe) = cx.state.misc.printer_exe.clone()
{
let port = cx.state.misc.printer_port;
let mut cmd = std::process::Command::new(&exe);
cmd.arg("--port")
.arg(port.to_string())
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000);
}
match cmd.spawn() {
Ok(c) => {
cx.state.misc.printer_child = Some(c);
cx.state.misc.message = Some(format!("已启动,监听 :{port}"));
cx.state.log.write("printer", &format!("启动网关 :{port}"));
}
Err(e) => cx.state.misc.message = Some(format!("启动失败: {e}")),
}
}
if stop {
if let Some(mut c) = cx.state.misc.printer_child.take() {
let _ = c.kill();
let _ = c.wait();
}
cx.state.misc.message = Some("已停止".to_string());
}
if open {
let _ = nfarms_core::browser::open_external(&url);
}
}
// ===========================================================================
// Slack off → Pet(桌宠)
// ===========================================================================
/// Model › Tokens:本地模型的 token 用量。
///
/// # 两个视角,不是一个
///
/// * **桌宠对话的累计** —— 这是本进程自己记的,所以**分得清是谁用的**;
/// 代价是只覆盖桌宠那一处,DSH 那边的消耗看不到。
/// * **llama-server 的 /metrics** —— 服务端进程的累计,覆盖**所有**调用方
/// (包括 DSH),但分不清是谁用的。
///
/// 两个都留着,因为它们回答的是不同问题。想凑成一个"总数"反而两头不讨好。
///
/// # 指标名不做解析
///
/// 我没核实过这个版本 llama.cpp 的指标命名。猜一个 `llamacpp:tokens_*` 出来,
/// 在改名后会显示成 0 —— 而那看起来像「没用过 token」,是个会骗人的错。
/// 所以把**含 token 的原始行**照显,名字对不对一眼就能看见。
pub fn tokens(cx: &mut PageCtx<'_>) {
use nfarms_core::chat;
if cx.state.misc.tok_metrics_job.poll()
&& let Some(r) = cx.state.misc.tok_metrics_job.result().cloned()
{
match r {
Ok(v) => {
cx.state.misc.tok_metrics = v;
cx.state.misc.tok_metrics_err = None;
}
Err(e) => cx.state.misc.tok_metrics_err = Some(e),
}
}
// 进页面自动拉一次。**失败后不再自动重试** —— 服务没起时每帧重试
// 会把界面拖住,而用户手动点「重试」就行。
if cx.state.misc.tok_metrics.is_empty()
&& cx.state.misc.tok_metrics_err.is_none()
&& !cx.state.misc.tok_metrics_job.is_running()
{
#[cfg(windows)]
let port = cx.state.local_model.llama_port.trim().to_string();
#[cfg(not(windows))]
let port = "9094".to_string();
cx.state.misc.tok_metrics_job.start(cx.egui, move || {
chat::fetch_metrics(&format!("http://127.0.0.1:{port}/v1"))
});
}
let m = &cx.state.misc;
let total = m.tok_prompt + m.tok_completion;
// ---- 桌宠对话的累计 ----
ui::card(cx.ui, |ui| {
ui::section(ui, "桌宠对话的累计", "本进程记的,分得清是谁用的");
ui.add_space(6.0);
ui::kv(ui, "请求次数", &m.tok_calls.to_string());
ui::kv(ui, "输入 token(累计)", &m.tok_prompt.to_string());
ui::kv(ui, "输出 token(累计)", &m.tok_completion.to_string());
ui::kv(ui, "合计", &total.to_string());
// `checked_div` 而不是 `if calls > 0 { total / calls }`:
// 除零的判断和除法离得越近越好,分开写很容易在改动时漏掉一半。
ui::kv(
ui,
"平均每次",
&total
.checked_div(m.tok_calls)
.map_or_else(|| "—".to_string(), |v| v.to_string()),
);
ui.add_space(4.0);
ui.label(
RichText::new(
ui::md(
"**只统计桌宠对话框**。DSH 用的是同一个 llama-server,\
但它是另一个进程,这里看不到它的消耗 —— 下面那张卡片覆盖它。",
),
)
.size(11.5)
.color(theme::TEXT_SUB),
);
});
cx.ui.add_space(12.0);
// ---- llama-server 的原始指标 ----
// 「停掉端口上那个并重启」标记。**不能在卡片闭包里直接起进程** ——
// 那里正借着 `cx.state`,而启动要 `&mut cx.state.local_model`。
let mut restart_srv = false;
ui::card(cx.ui, |ui| {
ui::section(
ui,
"llama-server 指标",
"覆盖所有调用方(含 DSH),但分不清是谁",
);
ui.add_space(6.0);
// **把实际查的端口写出来。** 这一页用的是 Setting › Proxy and Port
// 里那个端口(和对话、和启动服务用的是**同一个真值**)。写出来是为了
// 让"端口同步"这件事可验证 —— 看到 501 就知道端口对、是接口没开;
// 看到"连不上"才知道端口不对。两种情况长得像,处理方式完全不同。
#[cfg(windows)]
let shown_port = cx.state.local_model.llama_port.trim().to_string();
#[cfg(not(windows))]
let shown_port = "9094".to_string();
ui::kv(ui, "查询端口", &shown_port);
ui.add_space(4.0);
let running = cx.state.misc.tok_metrics_job.is_running();
ui.horizontal(|ui| {
if ui
.add_enabled(!running, egui::Button::new("重新读取").min_size(egui::vec2(84.0, 26.0)))
.clicked()
{
cx.state.misc.tok_metrics.clear();
cx.state.misc.tok_metrics_err = None;
}
if running {
ui.spinner();
}
});
ui.add_space(6.0);
if let Some(e) = &cx.state.misc.tok_metrics_err {
ui::err_hint(ui, e);
// 501 = 服务在跑,但没开指标接口。**这是唯一一种一键能修好的情况**:
// 停掉端口上那个,再用本程序启动(本程序的参数带 --metrics)。
//
// 其他错误不给这个按钮 —— 「连不上」该去启动服务,
// 而那个按钮在第一张卡片外面已经有了。
if e.contains("501") {
ui.add_space(4.0);
if ui
.button("停掉占用端口的进程并用本程序重启")
.clicked()
{
restart_srv = true;
}
ui.label(
RichText::new(ui::md(
"会先终止占用该端口的进程(**不管是不是本程序拉起的**),\
再用本程序启动 —— 本程序的启动参数里带 --metrics。",
))
.size(11.0)
.color(theme::TEXT_SUB),
);
}
}
else if cx.state.misc.tok_metrics.is_empty() {
ui.label(
RichText::new("还没有数据。服务没在跑时读不到指标 —— 去 Model › llama.cpp 启动服务。")
.size(12.0)
.color(theme::TEXT_SUB),
);
}
else {
// 原样照显:名字对不对,用户一眼就能看见
for l in &cx.state.misc.tok_metrics {
ui.label(RichText::new(l.as_str()).size(11.5).color(theme::TEXT_MAIN));
}
}
});
if restart_srv {
#[cfg(windows)]
let port = cx.state.local_model.llama_port.trim().to_string();
#[cfg(not(windows))]
let port = "9094".to_string();
let msg = match port.parse::<u16>() {
Ok(p) => {
// 阻塞 1~2 秒(netstat + taskkill)。这是**手动动作**,
// 短暂卡顿可以接受;放后台反而要处理点两次的并发。
let r = nfarms_core::tools::kill_by_port(p);
std::thread::sleep(std::time::Duration::from_millis(900));
cx.state.local_model.start_llama(&cx.state.paths, &cx.state.log);
match r {
Ok(m) => format!("{m};已用本程序重启服务(带 --metrics)"),
Err(e) => format!("{e};仍然尝试用本程序启动"),
}
}
Err(_) => format!("端口「{port}」不是合法数字,没执行"),
};
cx.state.log.write("today", &msg);
// 重新读一次指标
cx.state.misc.tok_metrics.clear();
cx.state.misc.tok_metrics_err = None;
}
}
/// Viewer › Today's Info:今天有什么。
///
/// # 自动刷新
///
/// 进页面拉一次,之后**每 10 分钟**自动再拉。计时器记在 `today_at` 上,
/// 而它在**成功和失败时都会更新** —— 只在成功时更新的写法会让失败变成
/// 「每帧重试」,把网络和界面一起拖死(这类 bug 很难看出来,因为界面只是在转圈)。
///
/// # 天气为什么直接画在这一页上
///
/// 早先天气有自己的一页,这里只放一张通往那一页的卡片(「两处解析就会有两处
/// 不一致」)。菜单改版把那两页合成了一页(见 `pages.rs`),于是这里直接调
/// [`weather`] —— 解析仍然只有 `nfarms-core::weather` 一份,界面也只有一份。
pub fn today_info(cx: &mut PageCtx<'_>) {
use nfarms_core::today;
use std::time::Duration;
/// 自动刷新的间隔。资讯这东西十分钟足够新了 —— 再密只是白耗流量。
const REFRESH: Duration = Duration::from_secs(600);
// ---- 抓取与刷新 ----
if cx.state.misc.today_job.poll()
&& let Some(r) = cx.state.misc.today_job.result().cloned()
{
// **无论成败都记时间**:失败后也要退避,否则会每帧重试
cx.state.misc.today_at = Some(std::time::Instant::now());
match r {
Ok(v) => {
cx.state.log.write("today", &format!("拉到 {} 条资讯", v.len()));
cx.state.misc.today_items = v;
cx.state.misc.today_err = None;
}
Err(e) => {
cx.state.log.write("today", &format!("抓取失败: {e}"));
cx.state.misc.today_err = Some(e);
}
}
}
let due = cx
.state
.misc
.today_at
.is_none_or(|t| t.elapsed() >= REFRESH);
if due && !cx.state.misc.today_job.is_running() {
cx.state.misc.today_job.start(cx.egui, today::fetch_all);
}
// 让上面那个 10 分钟的判断真的会被执行到 —— 不请求重绘的话界面闲着不动,
// 计时器永远不会到点。
cx.egui.request_repaint_after(Duration::from_secs(30));
let running = cx.state.misc.today_job.is_running();
// ---- 页头 ----
cx.ui.horizontal(|ui| {
ui.label(
RichText::new(match &cx.state.misc.today_at {
Some(t) => format!(
"更新于 {} 秒前",
t.elapsed().as_secs()
),
None => "正在取第一次…".to_string(),
})
.size(12.0)
.color(theme::TEXT_SUB),
);
if ui
.add_enabled(!running, egui::Button::new("刷新").min_size(egui::vec2(64.0, 26.0)))
.clicked()
{
cx.state.misc.today_at = None;
}
if running {
ui.spinner();
}
});
if let Some(e) = &cx.state.misc.today_err {
cx.ui.add_space(6.0);
ui::err_hint(cx.ui, &format!("抓取失败:{e}"));
}
cx.ui.add_space(10.0);
// ---- 资讯 ----
// 点链接**不能当场起进程**:那要 `&mut cx`,而循环里正借着 `cx.state`。
// 记下来,画完再开(和书签页一个套路)。
let mut open: Option<String> = None;
ui::card(cx.ui, |ui| {
ui::section(
ui,
&format!("资讯({} 条)", cx.state.misc.today_items.len()),
"少数派 RSS · 百度热搜",
);
ui.add_space(6.0);
if cx.state.misc.today_items.is_empty() && !running {
ui.label(
RichText::new("还没有内容。点上面的「刷新」再试一次。")
.size(12.0)
.color(theme::TEXT_SUB),
);
return;
}
let mut last_src = "";
for it in &cx.state.misc.today_items {
// 来源变了就插一个小标题 —— 不然十几条混在一起看不出哪条来自哪
if it.source != last_src {
last_src = it.source;
ui.add_space(4.0);
ui.label(RichText::new(it.source).size(11.5).color(theme::ACCENT));
}
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
ui.label(RichText::new("·").size(12.0).color(theme::TEXT_SUB));
if it.link.is_empty() {
ui.label(RichText::new(it.title.as_str()).size(12.5).color(theme::TEXT_MAIN));
}
else if ui
.link(RichText::new(it.title.as_str()).size(12.5))
.on_hover_text(it.link.as_str())
.clicked()
{
open = Some(it.link.clone());
}
if !it.time.is_empty() {
ui.label(
RichText::new(short_time(&it.time))
.size(10.5)
.color(theme::TEXT_SUB),
);
}
});
}
});
if let Some(u) = open {
// `start` 后面那个空 `""` 是**标题占位** —— 少了它,带空格的 URL
// 会被当成标题,浏览器根本不会打开。书签页那边也是这个写法。
#[cfg(windows)]
{
let _ = std::process::Command::new("cmd")
.args(["/c", "start", "", &u])
.spawn();
}
cx.state.log.write("today", &format!("打开 {u}"));
}
cx.ui.add_space(12.0);
// ---- 油价:如实说明没接入 ----
ui::card(cx.ui, |ui| {
ui::section(ui, "油价", "未接入");
ui.add_space(6.0);
ui.label(
RichText::new(ui::md(nfarms_core::today::FUEL_NOTE))
.size(12.0)
.color(theme::TEXT_MAIN),
);
});
cx.ui.add_space(12.0);
// ---- 天气:直接画在首页上 ----
//
// 这里原来是一张「去天气页」的跳转卡片,理由是「两处解析会有两处不一致」。
// 菜单改版把天气页并进了首页(见 pages.rs 里那条说明),那个理由就反过来了:
// **只有这一处**了,所以直接把 [`weather`] 那一段调进来 —— 解析仍然只有
// 一份(nfarms-core::weather),界面也只有一份。
weather(cx);
}
/// 把 RSS 的 RFC822 时间压短一点:`Tue, 13 Oct 2026 17:00:00 +0800` → `13 Oct 2026 17:00`。
///
/// 不引时间库:这里**只是显示**,不需要真的解析成时间戳,
/// 而为了显示一个字串引一个 chrono 不划算。
fn short_time(s: &str) -> String {
let p: Vec<&str> = s.split_whitespace().collect();
if p.len() >= 5 {
let hhmm = &p[4][..p[4].len().min(5)];
return format!("{} {} {} {}", p[1], p[2], p[3], hhmm);
}
s.to_string()
}
pub fn pet(cx: &mut PageCtx<'_>) {
let mut toggle = false;
let mut reset = false;
ui::card(cx.ui, |ui| {
ui::section(ui, "Pet 桌面宠物", "一只浮在桌面上的小生物(无边框置顶小窗)");
ui.add_space(6.0);
ui.label(
RichText::new(ui::plain(
"实现走 **egui 多视口**:一个 `decorations = false` + \
`window_level = AlwaysOnTop` + 不上任务栏的小窗,在它身上按住左键 = \
让系统开始拖窗口。\n\
**不做真透明**,而且这是试过之后的结论不是偷懒:`eframe::App::clear_color` \
是**整帧一次**的、不按视口分 —— 让它清零,桌宠那个窗口透明了,\
**主窗口也一起透明,整窗白屏**。改用 `set_visuals` 标记视口也不生效。\
要真透明得让那个窗口有自己的 GL 上下文和 clear color,立即视口做不到。\n\
**不做全局键鼠钩子**:那要 `SetWindowsHookEx` 注入到所有进程,\
杀毒软件一定会报,而收益只是「它会跟着你的鼠标看」—— 不值得。",
))
.size(11.5)
.color(theme::TEXT_SUB),
);
ui.add_space(10.0);
ui.horizontal(|ui| {
if ui
.checkbox(
&mut cx.state.misc.pet_visible,
RichText::new("显示桌宠").size(12.5),
)
.changed()
{
toggle = true;
}
if ui.button("重置状态").clicked() {
reset = true;
}
});
ui.add_space(8.0);
if cx.state.misc.pet_visible {
ui.label(
RichText::new("它已经在桌面上了 —— 在那个小窗口上按住左键可以拖着走。")
.size(12.0)
.color(theme::ACCENT_DARK),
);
} else {
ui.label(
RichText::new("现在没有显示。勾上「显示桌宠」它就会出现。")
.size(12.0)
.color(theme::TEXT_SUB),
);
}
if let Some(m) = &cx.state.misc.message {
ui.add_space(4.0);
ui::ok_hint(ui, m);
}
});
if toggle {
let shown = cx.state.misc.pet_visible;
// 持久化:关掉程序再开,桌宠该还在(或者该还是不在)
cx.state
.store
.set_str("pet_visible", if shown { "1" } else { "0" });
let _ = cx.state.store.save();
cx.state
.log
.write("pet", if shown { "桌宠出现" } else { "桌宠收起" });
}
if reset {
cx.state.misc.pet_phase = 0.0;
cx.state.misc.message = Some("状态已重置".to_string());
}
}
// ===========================================================================
// Setting → 日志路径 / 缓存路径 / 存储路径
// ===========================================================================
/// 三个「路径」页共用的一块:显示路径 + 是否可写 + 占了多少 + 打开按钮。
fn path_card(cx: &mut PageCtx<'_>, title: &str, subtitle: &str, dir: &std::path::Path, note: &str) {
let exists = dir.is_dir();
let writable = exists && probe_writable(dir);
let size = if exists { dir_size(dir) } else { 0 };
ui::card(cx.ui, |ui| {
ui::section(ui, title, subtitle);
ui.add_space(8.0);
ui::kv(ui, "路径", &cx.state.paths.show(dir));
ui::kv(
ui,
"状态",
match (exists, writable) {
(false, _) => "目录不存在(首次写入时自动创建)",
(true, true) => "存在且可写",
(true, false) => "存在但**不可写** —— 程序会自动降级",
},
);
ui::kv(ui, "占用", &ui::human_bytes(size));
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui
.add_enabled(exists, egui::Button::new("打开文件夹"))
.clicked()
{
// 用系统资源管理器打开 —— 不自己实现文件管理器
#[cfg(windows)]
{
let _ = std::process::Command::new("explorer.exe")
.arg(dir)
.spawn();
}
}
if ui
.add_enabled(!exists, egui::Button::new("创建目录"))
.clicked()
&& let Err(e) = std::fs::create_dir_all(dir)
{
cx.state
.log
.write("paths", &format!("创建 {} 失败: {e}", dir.display()));
}
});
ui.add_space(6.0);
ui.label(RichText::new(ui::plain(note)).size(11.5).color(theme::TEXT_SUB));
});
}
/// 真的写一个探针文件再删掉 —— 只看 ACL 位是不够的(这台机器上就遇到过
/// 「权限位说可写、实际被拒」的情况)。
fn probe_writable(dir: &std::path::Path) -> bool {
let probe = dir.join(format!(".write-probe-{:06x}", rand_hex()));
match std::fs::write(&probe, b"probe") {
Ok(()) => {
let _ = std::fs::remove_file(&probe);
true
}
Err(_) => false,
}
}
fn rand_hex() -> u32 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0)
}
/// 递归算目录大小(深度上限 6,免得误指到 C:\ 时把界面卡死)。
fn dir_size(dir: &std::path::Path) -> u64 {
fn walk(dir: &std::path::Path, depth: usize) -> u64 {
if depth == 0 {
return 0;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return 0;
};
let mut total = 0;
for e in entries.flatten() {
let p = e.path();
match e.metadata() {
Ok(m) if m.is_file() => total += m.len(),
Ok(m) if m.is_dir() => total += walk(&p, depth - 1),
_ => {}
}
}
total
}
walk(dir, 6)
}
pub fn log_path(cx: &mut PageCtx<'_>) {
let dir = cx.state.log.dir().to_path_buf();
path_card(
cx,
"日志路径",
"程序根写不进去时自动落到 %TEMP%",
&dir,
"落盘格式 `[时间] [来源] 消息\\r\\n` 与 C# 版逐字节一致。\
日志目录不可写必须**自动降级**到临时目录,而不是启动就崩 —— \
这一点在 Paths/Log 两个模块里都是显式处理的。",
);
cx.ui.add_space(12.0);
ui::kv(cx.ui, "主日志", &cx.state.paths.show(&cx.state.log.app_path()));
ui::kv(
cx.ui,
"代理日志",
&cx.state.paths.show(&cx.state.log.printer_path()),
);
if cx.state.log.using_temp_dir() {
cx.ui.add_space(8.0);
ui::card(cx.ui, |ui| {
ui::err_hint(
ui,
"当前用的是临时目录 —— 说明程序根不可写。装到 Program Files 下会出现这种情况。",
);
});
}
cx.ui.add_space(16.0);
log_viewer_card(cx);
}
/// 日志查看器:分页看日志文件。
///
/// 为什么必须分页:程序日志跑一天就几万行,一次性塞进 `ScrollArea` 会让
/// egui 每帧都去布局几万个 galley —— 又卡又费内存。分页之后每帧只画一页。
fn log_viewer_card(cx: &mut PageCtx<'_>) {
let dir = cx.state.log.dir().to_path_buf();
cx.state.log_view.ensure_file(&dir);
// 变了才重读(按 长度 + 修改时间 判断)
let reloaded = cx.state.log_view.refresh();
if reloaded && cx.state.log_view.follow {
cx.egui
.request_repaint_after(std::time::Duration::from_millis(500));
}
let mut pick_file: Option<std::path::PathBuf> = None;
let mut want_refresh = false;
let mut goto: Option<usize> = None;
let mut set_per: Option<usize> = None;
ui::card(cx.ui, |ui| {
ui::section(ui, "日志查看", "分页翻,不一次性画几万行");
ui.add_space(6.0);
let files = list_logs(&dir);
let cur = cx.state.log_view.file.clone();
ui.horizontal(|ui| {
ui.label(RichText::new("文件").size(12.0).color(theme::TEXT_SUB));
egui::ComboBox::from_id_salt("log-file")
.width(340.0)
.selected_text(
cur.as_ref()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "(没有日志文件)".to_string()),
)
.show_ui(ui, |ui| {
for f in &files {
let name = f
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let sel = cur.as_ref() == Some(f);
if ui.selectable_label(sel, name).clicked() {
pick_file = Some(f.clone());
}
}
});
if ui.button("刷新").clicked() {
want_refresh = true;
}
if ui
.checkbox(
&mut cx.state.log_view.follow,
RichText::new("跟随最新").size(12.0),
)
.on_hover_text("有新内容时自动跳到最后一页")
.changed()
&& cx.state.log_view.follow
{
goto = Some(cx.state.log_view.page_count().saturating_sub(1));
}
if ui.button("打开目录").clicked() {
nfarms_core::ffmpeg::open_dir(&dir);
}
});
ui.add_space(6.0);
let total_lines = cx.state.log_view.line_count();
let pages = cx.state.log_view.page_count();
let page = cx.state.log_view.page;
ui.horizontal(|ui| {
ui.label(RichText::new("每页").size(12.0).color(theme::TEXT_SUB));
for n in crate::PER_PAGE_CHOICES {
if ui
.selectable_label(cx.state.log_view.per_page == n, format!("{n}"))
.clicked()
{
set_per = Some(n);
}
}
ui.separator();
if ui
.add_enabled(page > 0, egui::Button::new("首页"))
.clicked()
{
goto = Some(0);
}
if ui
.add_enabled(page > 0, egui::Button::new("上一页"))
.clicked()
{
goto = Some(page - 1);
}
ui.label(
RichText::new(format!("第 {} / {} 页", page + 1, pages))
.size(12.0)
.color(theme::ACCENT_DARK),
);
if ui
.add_enabled(page + 1 < pages, egui::Button::new("下一页"))
.clicked()
{
goto = Some(page + 1);
}
if ui
.add_enabled(page + 1 < pages, egui::Button::new("末页"))
.clicked()
{
goto = Some(pages.saturating_sub(1));
}
ui.separator();
ui.label(RichText::new("跳到").size(12.0).color(theme::TEXT_SUB));
ui.add_sized(
[52.0, 24.0],
egui::TextEdit::singleline(&mut cx.state.log_view.goto).hint_text("页"),
);
if ui.button("Go").clicked()
&& let Ok(p) = cx.state.log_view.goto.trim().parse::<usize>()
{
// 用户按 1 起数,内部从 0 起
goto = Some(p.saturating_sub(1));
}
ui.separator();
ui.label(
RichText::new(format!("共 {total_lines} 行"))
.size(11.5)
.color(theme::TEXT_SUB),
);
});
if let Some(e) = &cx.state.log_view.error {
ui.add_space(6.0);
ui::err_hint(ui, e);
} else if total_lines == 0 {
ui.add_space(6.0);
ui.label(
RichText::new("(空文件,或者还没写过日志)")
.size(12.0)
.color(theme::TEXT_SUB),
);
}
});
if let Some(f) = pick_file {
cx.state.log_view.file = Some(f);
cx.state.log_view.stamp = None; // 强制重读
cx.state.log_view.page = 0;
cx.state.log_view.goto.clear();
}
if want_refresh {
cx.state.log_view.stamp = None;
}
if let Some(n) = set_per {
cx.state.log_view.per_page = n;
cx.state.log_view.page = 0;
}
if let Some(p) = goto {
cx.state.log_view.goto_page(p);
}
// ---- 正文 ----
let (start, end) = cx.state.log_view.page_range();
// 先拷贝出要显示的那几行:下面 `ui::card` 会把 cx 借走
let rows: Vec<(usize, String)> = cx
.state
.log_view
.lines()
.into_iter()
.enumerate()
.skip(start)
.take(end.saturating_sub(start))
.map(|(i, l)| (i + 1, l.to_string()))
.collect();
cx.ui.add_space(12.0);
// 和阅读器同一个套路:夹到可视高度,不然 ScrollArea 会以为自己有几千像素高
let max_h = (cx.egui.screen_rect().height() - 430.0).max(220.0);
ui::card(cx.ui, |ui| {
egui::ScrollArea::both()
.auto_shrink([false, false])
.max_height(max_h)
.show(ui, |ui| {
for (no, line) in &rows {
ui.horizontal(|ui| {
// 行号右对齐,方便和日志里的报错行号对
ui.add_sized(
[52.0, 16.0],
egui::Label::new(
RichText::new(format!("{no}"))
.size(10.5)
.monospace()
.color(theme::TEXT_SUB),
),
);
ui.label(
RichText::new(line)
.size(11.0)
.monospace()
.color(log_line_color(line)),
);
});
}
});
});
}
/// 日志行按级别上色:出问题的那几行要能一眼挑出来。
fn log_line_color(line: &str) -> egui::Color32 {
let l = line.to_ascii_lowercase();
if l.contains("失败") || l.contains("error") || l.contains("错误") || l.contains("fail") {
theme::DANGER
} else if l.contains("warn") || l.contains("警告") {
theme::TEXT_MAIN
} else {
theme::TEXT_SUB
}
}
/// 日志目录里的 `.log` 文件。程序日志永远排第一(最常看的那个)。
fn list_logs(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
let mut v: Vec<std::path::PathBuf> = std::fs::read_dir(dir)
.map(|it| {
it.flatten()
.map(|e| e.path())
.filter(|p| {
p.extension()
.and_then(|x| x.to_str())
.map(|x| x.eq_ignore_ascii_case("log"))
.unwrap_or(false)
})
.collect()
})
.unwrap_or_default();
v.sort_by(|a, b| {
let an = a
.file_name()
.map(|x| x.to_string_lossy().to_string())
.unwrap_or_default();
let bn = b
.file_name()
.map(|x| x.to_string_lossy().to_string())
.unwrap_or_default();
let a_first = an == nfarms_core::log::APP_LOG;
let b_first = bn == nfarms_core::log::APP_LOG;
b_first.cmp(&a_first).then_with(|| an.cmp(&bn))
});
v
}
pub fn cache_path(cx: &mut PageCtx<'_>) {
let dir = cx.state.paths.root().join("cache");
path_card(
cx,
"缓存路径",
"解析结果 / 缩略图 / 下载分片的落脚点",
&dir,
"缓存要有上限和清理入口(不然用户磁盘会被悄悄吃掉)。\
默认放程序根下的 cache\\,可以整个指到别的盘 —— 但**改路径要等缓存实现出来**,\
现在这一页只做现状展示。",
);
cx.ui.add_space(12.0);
ui::card(cx.ui, |ui| {
ui::section(ui, "当前谁在往这儿写", "还没实现 —— 这一页先如实说明。");
ui.add_space(6.0);
ui.label(
RichText::new(
"现在真正会用磁盘的是三处:libs\\model(模型权重,最大)、\
libs\\llama.cpp 与 libs\\stable-diffusion.cpp(运行库)、\
logs\\(日志)。它们的位置是跟程序根走的,不受这一页控制。",
)
.size(11.5)
.color(theme::TEXT_SUB),
);
});
}
pub fn storage_path(cx: &mut PageCtx<'_>) {
let root = cx.state.paths.root().to_path_buf();
path_card(
cx,
"存储路径",
"录制 / 转码产物 / 模型权重的存放位置",
&root,
"模型权重默认在 libs\\model,允许改到别的盘 —— 28 GB 放在系统盘上很快就满了。\
**改盘这件事还没做**:现在模型目录是写死在程序根下的。",
);
cx.ui.add_space(12.0);
// 把最大的几个目录列出来,让「哪儿吃了磁盘」一眼可见
ui::card(cx.ui, |ui| {
ui::section(ui, "磁盘占用", "程序根下各子目录的大小(深度 6 层)。");
ui.add_space(8.0);
let mut rows: Vec<(String, u64)> = std::fs::read_dir(&root)
.map(|entries| {
entries
.flatten()
.filter(|e| e.path().is_dir())
.map(|e| {
let name = e.file_name().to_string_lossy().to_string();
(name, dir_size(&e.path()))
})
.collect()
})
.unwrap_or_default();
// 按大小**降序**:`Reverse` 比手写 `b.1.cmp(&a.1)` 更直白,
// 也不会有人把它误读成升序
rows.sort_by_key(|r| std::cmp::Reverse(r.1));
for (name, size) in rows.iter().take(12) {
ui.horizontal(|ui| {
ui.add_sized(
[120.0, 18.0],
egui::Label::new(RichText::new(name).size(12.0).color(theme::TEXT_MAIN)),
);
ui.label(
RichText::new(ui::human_bytes(*size))
.size(12.0)
.color(theme::TEXT_SUB),
);
});
}
});
}
// ===========================================================================
// Zero Trust VPN → TOPO 2FA(拓扑验证器)
// ===========================================================================
/// 三态徽标的颜色。warn 的橙是照 C# `TopoValidator.StateColor` 抄的
/// (Color.FromArgb(198,138,22))。
fn topo_state_color(state: &str) -> Color32 {
match state {
"pass" => theme::ACCENT,
"warn" => Color32::from_rgb(198, 138, 22),
"fail" => theme::DANGER,
_ => theme::TEXT_SUB,
}
}
pub fn topo(cx: &mut PageCtx<'_>) {
use nfarms_core::topo;
let server = topo::server_edges();
let summary = topo::summary_line(&cx.state.topo.edges, server.len());
let running = cx.state.topo.running;
// ---- 汇总 + 操作条 ----
let mut start = false;
// 首次进页面自动跑一轮(原版 C# 的 Topo 页在构造时就把表填好了)
if !cx.state.topo_auto {
cx.state.topo_auto = true;
start = true;
}
let mut copy_result = false;
let mut copy_cmds = false;
ui::card(cx.ui, |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new(&summary).size(14.0).color(theme::TEXT_MAIN));
if running {
ui.spinner();
ui.label(RichText::new("探测中…").size(12.0).color(theme::TEXT_SUB));
}
});
ui.add_space(4.0);
ui.label(
RichText::new(
"26 条边 = 14 条本机实测 + 12 条需服务端探测。口径与网页版 topo.html 完全一致;\
内网端口从外网不可达是设计如此,一律标注「需服务器本机验证」,绝不判成故障。",
)
.size(11.5)
.color(theme::TEXT_SUB),
);
ui.add_space(10.0);
ui.horizontal(|ui| {
if ui
.add_enabled(
!running,
egui::Button::new(format!(
"重新验证({} 条边)",
cx.state.topo.edges.len()
))
.min_size(egui::vec2(160.0, 30.0)),
)
.clicked()
{
start = true;
}
if ui
.add_enabled(!running, egui::Button::new("复制结果").min_size(egui::vec2(90.0, 30.0)))
.clicked()
{
copy_result = true;
}
if ui
.add_enabled(
!running,
egui::Button::new("复制服务端命令").min_size(egui::vec2(130.0, 30.0)),
)
.clicked()
{
copy_cmds = true;
}
ui.label(RichText::new("单边超时").size(12.0).color(theme::TEXT_SUB));
ui.add_sized(
[70.0, 28.0],
egui::DragValue::new(&mut cx.state.topo.timeout_ms)
.range(500..=30000)
.suffix(" ms"),
);
if let Some(t) = &cx.state.topo.last_run {
ui::ok_hint(ui, &format!("上次 {t}"));
}
});
});
if start {
cx.state.topo.start(cx.egui);
}
if copy_result {
let text = topo_report(&cx.state.topo.edges, &server);
cx.egui.copy_text(text);
cx.state.log.write("topo", "已复制验证结果到剪贴板");
}
if copy_cmds {
let mut text = String::new();
for (title, cmd) in topo::SERVER_CMDS {
text.push_str(&format!("# {title}\n{cmd}\n\n"));
}
cx.egui.copy_text(text);
cx.state.log.write("topo", "已复制服务端命令到剪贴板");
}
cx.ui.add_space(12.0);
// ---- 14 条本机实测边 ----
ui::card(cx.ui, |ui| {
ui::section(ui, "本机实测边", "每条的「期望 / 依据 / 口径」都按网页版口径给出。");
ui.add_space(8.0);
for e in &cx.state.topo.edges {
let color = topo_state_color(e.state);
ui.horizontal(|ui| {
ui.add_sized(
[34.0, 18.0],
egui::Label::new(
RichText::new(&e.id).size(11.5).monospace().color(theme::TEXT_SUB),
),
);
ui.add_sized(
[86.0, 18.0],
egui::Label::new(
RichText::new(topo::state_badge(e.state)).size(12.0).color(color),
),
);
ui.label(RichText::new(&e.label).size(12.5).color(theme::TEXT_MAIN))
.on_hover_text(format!(
"期望: {}\n地址: {}\n口径: {}",
topo::want_text(e),
e.url,
if e.why.is_empty() { e.note.as_str() } else { e.why.as_str() }
));
});
if e.judged {
ui.horizontal(|ui| {
ui.add_space(120.0);
ui.label(
RichText::new(format!("依据: {}", e.evidence))
.size(11.0)
.color(theme::TEXT_SUB),
);
});
}
ui.add_space(4.0);
}
});
cx.ui.add_space(12.0);
// ---- 需服务端探测的 12 条边 ----
ui::card(cx.ui, |ui| {
egui::CollapsingHeader::new(format!(
"需服务端探测的边({} 条,不计入通过数)",
server.len()
))
.default_open(false)
.show(ui, |ui| {
for e in &server {
ui.label(
RichText::new(format!("{} {} → {}", e.id, e.label, e.how))
.size(11.5)
.color(theme::TEXT_MAIN),
);
ui.add_space(4.0);
}
});
});
cx.ui.add_space(12.0);
// ---- 内网端口清单 ----
ui::card(cx.ui, |ui| {
egui::CollapsingHeader::new(format!(
"内网端口清单({} 个,公网不可达是设计如此)",
topo::PORTS.len()
))
.default_open(false)
.show(ui, |ui| {
for p in topo::PORTS {
ui.label(RichText::new(p).size(11.0).color(theme::TEXT_SUB));
ui.add_space(2.0);
}
});
});
cx.ui.add_space(12.0);
// ---- 服务端深度探测命令 ----
ui::card(cx.ui, |ui| {
egui::CollapsingHeader::new(format!(
"服务端深度探测命令({} 组)",
topo::SERVER_CMDS.len()
))
.default_open(false)
.show(ui, |ui| {
for (title, cmd) in topo::SERVER_CMDS {
ui.label(RichText::new(title).size(12.0).color(theme::TEXT_MAIN));
ui.label(
RichText::new(cmd)
.size(11.0)
.monospace()
.color(theme::TEXT_SUB),
);
ui.add_space(6.0);
}
});
});
cx.ui.add_space(12.0);
ui::card(cx.ui, |ui| {
ui.label(
RichText::new(
"Rust 版比 C# 原版少一项:TLS 叶子证书的签发者与到期日暂未提取(需要 X.509 解析)。\
其余(状态码、响应体业务字段、TCP 连接被拒 vs 超时)口径完全一致 —— \
--topo-test --offline 的输出已与 C# 原版逐字节相同。",
)
.size(11.5)
.color(theme::TEXT_SUB),
);
});
}
/// 「复制结果」用的纯文本报告。
fn topo_report(edges: &[nfarms_core::topo::TopoEdge], server: &[nfarms_core::topo::TopoEdge]) -> String {
use nfarms_core::topo;
let mut s = String::new();
s.push_str(&topo::summary_line(edges, server.len()));
s.push_str("\r\n\r\n");
for e in edges {
s.push_str(&format!(
"{} [{}] {}\r\n 期望: {}\r\n 依据: {}\r\n 口径: {}\r\n",
e.id,
topo::state_badge(e.state),
e.label,
topo::want_text(e),
e.evidence,
e.why
));
}
s
}
// ===========================================================================
// Setting → 插件(骨架的可视化)
// ===========================================================================
/// Libs 页顶部的「libs\ 磁盘清单」。
///
/// 放在装载表**前面**:先看见磁盘上有什么(28 GB 权重、444 MB 后端),
/// 再看插件注册表 —— 前者是「东西在不在」,后者是「页面挂没挂」,
/// 出问题时先查前者。
fn libs_panel(cx: &mut PageCtx<'_>) {
let dirs = nfarms_core::libs::scan_dirs(&cx.state.paths);
let total: u64 = dirs.iter().map(|d| d.bytes).sum();
ui::card(cx.ui, |ui| {
ui::section(
ui,
&format!(
"libs\\ 目录({} 项 / {:.2} GB)",
dirs.len(),
total as f64 / 1_073_741_824.0
),
"这些不进安装包,由 .\\libs\\scripts\\fetch-libs.ps1 按需下载",
);
ui.add_space(6.0);
if dirs.is_empty() {
ui.label(
RichText::new("libs\\ 下没有子目录 —— 还没下过任何包")
.size(12.0)
.color(theme::TEXT_SUB),
);
return;
}
for d in &dirs {
ui.horizontal(|ui| {
ui.add_sized(
[200.0, 17.0],
egui::Label::new(RichText::new(&d.name).size(12.0).color(theme::TEXT_MAIN)),
);
// 空目录要标出来 —— 「建了目录但没下内容」和「没建」是两回事
let empty = d.files == 0;
ui.add_sized(
[90.0, 17.0],
egui::Label::new(
RichText::new(if empty { "空".to_string() } else { d.human() })
.size(11.5)
.color(if empty { theme::TEXT_SUB } else { theme::ACCENT_DARK }),
),
);
ui.label(
RichText::new(format!("{} · {} 个文件", d.note, d.files))
.size(11.0)
.color(theme::TEXT_SUB),
);
});
}
ui.add_space(6.0);
ui.label(
RichText::new("「空」= 只有目录没内容;「(未登记)」= 你手动放进来的。")
.size(10.5)
.color(theme::TEXT_SUB),
);
});
}
pub fn manager(cx: &mut PageCtx<'_>) {
libs_panel(cx);
cx.ui.add_space(12.0);
let records = cx.kernel.records();
let services = cx.kernel.context().visible_service_names();
// 注册表取一次,后面「几张卡片」和装载表都用它 —— 两个数字必须来自同一张表,
// 否则会出现「标题说 52 张、底下列了 40 行」这种自相矛盾。
let registry = cx
.kernel
.context()
.get::<super::PageRegistry>(SERVICE_PAGES);
let card_count = registry.as_ref().map(|r| r.len()).unwrap_or(0);
let platform = if cfg!(target_os = "android") {
"安卓(骨架:只装了一小撮插件)"
} else {
"Windows 桌面(整棵功能树)"
};
ui::card(cx.ui, |ui| {
ui::section(ui, "插件内核", "cordis 风格:服务容器 + 事件总线 + 生命周期。");
ui.add_space(6.0);
ui::kv(ui, "平台", platform);
ui::kv(ui, "内核版本", env!("CARGO_PKG_VERSION"));
ui::kv(
ui,
"插件",
&format!("{} 个,其中 {} 个已装载", records.len(), cx.kernel.active_count()),
);
ui::kv(ui, "页面", &format!("{card_count} 张卡片"));
ui::kv(ui, "服务", &services.join(", "));
});
cx.ui.add_space(12.0);
// ---- 装载表 ----
//
// 按**三级树**画(和侧栏同一棵树):一级目录 → 二级目录 → 页面。
// 早先这里是「按注册顺序平铺、遇到换组就插一行组名」,三级化之后那样画
// 就分不清「这一页挂在哪一级目录下」了 —— 而这一页正是用来回答那个问题的。
let tree = registry
.as_ref()
.map(|r| r.grouped_tree())
.unwrap_or_default();
ui::card(cx.ui, |ui| {
ui::section(ui, "装载表", "按功能树三级列出;侧栏就是照着这张注册表画的。");
ui.add_space(8.0);
for node in &tree {
ui.add_space(6.0);
ui.label(
RichText::new(node.group.nav())
.size(12.5)
.color(theme::ACCENT_DARK),
);
for item in &node.items {
let kids: &[super::PageSpec] = match item {
super::NavItem::Leaf(s) => std::slice::from_ref(s),
super::NavItem::Subgroup(sub, kids) => {
ui.label(
RichText::new(format!(" {}", sub.nav()))
.size(11.5)
.color(theme::TEXT_SUB),
);
kids
}
};
for spec in kids {
let rec = records.iter().find(|r| r.name == spec.info.id);
let (state, color) = match rec.map(|r| &r.state) {
Some(nfarms_plugin::PluginState::Active) => ("已装载", theme::TEXT_MAIN),
Some(nfarms_plugin::PluginState::Pending) => ("等依赖", theme::TEXT_SUB),
Some(nfarms_plugin::PluginState::Failed(e)) => (e.as_str(), theme::DANGER),
_ => ("没记录", theme::TEXT_SUB),
};
ui.horizontal(|ui| {
let (rect, _) =
ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::hover());
icons::draw(ui.painter(), rect, spec.info.icon, theme::ACCENT);
ui.add_sized(
[130.0, 18.0],
egui::Label::new(
RichText::new(spec.info.nav).size(12.5).color(theme::TEXT_MAIN),
),
);
ui.add_sized(
[70.0, 18.0],
egui::Label::new(RichText::new(state).size(11.5).color(color)),
);
ui.label(
RichText::new(spec.info.id)
.size(11.0)
.monospace()
.color(theme::TEXT_SUB),
);
});
}
}
}
});
cx.ui.add_space(12.0);
// ---- 事件 ----
let events: Vec<String> = cx
.state
.event_log
.lock()
.map(|v| v.clone())
.unwrap_or_default();
ui::card(cx.ui, |ui| {
ui::section(ui, "内核事件", "插件装载 / 服务提供 / 切页都会在这里留一条。");
ui.add_space(6.0);
if events.is_empty() {
ui.label(
RichText::new("(还没有事件)")
.size(12.0)
.color(theme::TEXT_SUB),
);
} else {
for line in events.iter().rev().take(12) {
ui.label(
RichText::new(line)
.size(11.5)
.monospace()
.color(theme::TEXT_SUB),
);
}
}
});
cx.ui.add_space(12.0);
// ---- 怎么加一个功能 ----
ui::card(cx.ui, |ui| {
ui::section(ui, "怎么加一个新功能", "三步,不用改别人的代码。");
ui.add_space(6.0);
ui.label(
RichText::new(
"1. 在 pages.rs 的 PAGES 里加一条(分组 / id / 图标 / 标题 / 要点)\n\
2. 在 plugins/mod.rs 的 renderer_for() 里把 Page → 绘制函数接上\n\
3. 安卓版想让它在 APK 里出现,就往 plugins::mobile() 的列表里加一个 Page",
)
.size(12.0)
.monospace()
.color(theme::TEXT_MAIN),
);
ui.add_space(8.0);
ui.label(
RichText::new(
"功能比「一页」更重的时候,再写一个真正的 Plugin:\n\
ctx.provide(\"我的服务\", …) 把自己的能力登记出去,\n\
inject(&[\"别人的服务\"]) 声明依赖 —— 依赖没到就自动挂起,到了自动装上。",
)
.size(12.0)
.color(theme::TEXT_SUB),
);
});
cx.ui.add_space(12.0);
ui::kv(cx.ui, "程序根", &cx.state.paths.root().display().to_string());
ui::kv(cx.ui, "日志", &cx.state.log.dir().display().to_string());
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
/// 一条独立的临时目录(按测试名分开,免得同进程里几条测试互相踩)。
fn temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("nfarms-core-pages-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("建临时目录");
dir
}
/// 界面线程只负责「发起」和「收结果」,`where` 必须在后台跑。
///
/// 这条测试守的就是回归成同步调用:替身故意睡 600 ms,而 `pump_mgr_probe`
/// 必须在 300 ms 内返回 —— 一旦有人把 `ep::package_managers()` 直接写回
/// `environment()`,这里就再也守不住了(那种改动只能靠 code review 拦)。
/// 留 300 ms 余量是因为跑测试的机器上可能同时有十几份 cargo 在编译。
#[test]
fn package_manager_probe_does_not_block_the_ui_thread() {
static CALLS: AtomicUsize = AtomicUsize::new(0);
let fake: fn() -> Vec<PkgMgr> = || {
CALLS.fetch_add(1, Ordering::SeqCst);
std::thread::sleep(Duration::from_millis(600));
vec![PkgMgr {
key: "winget",
exe: "winget",
path: Some(PathBuf::from("C:\\fake\\winget.exe")),
}]
};
let ctx = egui::Context::default();
let mut job: Job<Vec<PkgMgr>> = Job::default();
let mut done = false;
let mut out: Vec<PkgMgr> = Vec::new();
let t0 = Instant::now();
let got = pump_mgr_probe(&mut job, &ctx, &mut done, &mut out, fake);
let elapsed = t0.elapsed();
assert!(!got, "刚发起不可能已经有结果");
assert!(!done, "结果没回来就不该把探测标成完成");
assert!(out.is_empty());
assert!(
elapsed < Duration::from_millis(300),
"发起这一步在界面线程上等了 {elapsed:?}"
);
// 界面每帧做的就是这件事:抽一次水,拿不到就继续画。
let deadline = Instant::now() + Duration::from_secs(10);
let mut finished = false;
while Instant::now() < deadline {
if pump_mgr_probe(&mut job, &ctx, &mut done, &mut out, fake) {
finished = true;
break;
}
std::thread::sleep(Duration::from_millis(10));
}
assert!(finished, "后台线程没在 10 秒内回结果");
assert!(done);
assert_eq!(out, vec![PkgMgr {
key: "winget",
exe: "winget",
path: Some(PathBuf::from("C:\\fake\\winget.exe")),
}]);
// 探测完成之后**不能再发起**:每帧重开一个线程比原来那次同步卡顿更糟。
out.clear();
assert!(!pump_mgr_probe(&mut job, &ctx, &mut done, &mut out, fake));
assert!(out.is_empty(), "探测完了还在重新发起");
assert_eq!(CALLS.load(Ordering::SeqCst), 1, "替身被跑了不止一次");
}
/// dsh 控制台卡片:文件没变时不许每帧重读,变了就必须立刻反映出来。
#[test]
fn dsh_tail_is_cached_across_frames() {
use std::io::Write;
let dir = temp_dir("dsh-tail");
let log = dir.join("dsh-web.log");
std::fs::write(&log, "第一行\n第二行\n").expect("写日志");
let base = TAIL_READS.with(|n| n.get());
let reads = || TAIL_READS.with(|n| n.get()) - base;
let first = tail_cached(&log, 40);
assert!(first.contains("第二行"), "尾部内容读错了: {first:?}");
assert_eq!(reads(), 1, "第一次必然要读盘");
// 同一帧序列里的第二次:文件没动,必须命中缓存。
let second = tail_cached(&log, 40);
assert_eq!(second, first);
assert_eq!(reads(), 1, "文件没变,每帧还在把整个日志读一遍");
// 追加 → 长度/修改时间都变了 → 必须重读(否则界面永远停在旧输出)。
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&log)
.expect("追加打开");
f.write_all("第三行\n".as_bytes()).expect("追加一行");
f.flush().expect("刷盘");
drop(f);
let grown = tail_cached(&log, 40);
assert!(grown.contains("第三行"), "日志长了却还在用旧内容: {grown:?}");
assert_eq!(reads(), 2);
// 行数变了也是换了一个请求 → 同一个文件必须重算,并且缓存要跟着换。
let last = tail_cached(&log, 1);
assert_eq!(last.trim(), "第三行");
assert_eq!(reads(), 3);
let last_again = tail_cached(&log, 1);
assert_eq!(last_again, last);
assert_eq!(reads(), 3, "行数没变也要命中");
let _ = std::fs::remove_dir_all(&dir);
}
// ---------------------------------------------------------------- 三件套
/// 状态码 → 灯。四种状态必须**四种不同的样子**。
///
/// 这条测试是这三个数组(obs_state/obs_proc/obs_ports)和界面之间唯一
/// 不花钱的验证方式:真要验「失败是红的」得先把三件套 exe 备齐、再制造
/// 一次启动失败 —— 那种测试在别的机器上根本跑不起来。
#[test]
fn obs_state_maps_to_four_distinct_badges() {
let idle = obs_badge(0);
let busy = obs_badge(1);
let ready = obs_badge(2);
let fail = obs_badge(3);
assert_eq!(idle.text, "未启动");
assert_eq!(busy.text, "启动中…");
assert_eq!(ready.text, "已就绪");
assert_eq!(fail.text, "失败");
// 颜色混掉两个,用户就分不出「在起」和「起不来」——
// 而这两种情况该做的事完全相反(等 vs 查)。
let colors = [idle.color, busy.color, ready.color, fail.color];
for i in 0..colors.len() {
for j in (i + 1)..colors.len() {
assert_ne!(colors[i], colors[j], "第 {i} 种和第 {j} 种状态同色");
}
}
// 只有「启动中」转圈、也只有它把启动按钮禁掉:不禁的话点第二下会起
// 第二个进程抢同一个端口,而界面上写的是「已就绪」。
assert!(busy.busy);
assert!(!idle.busy && !ready.busy && !fail.busy);
assert_eq!(ready.color, theme::ACCENT_DARK, "已就绪必须是绿的");
assert_eq!(fail.color, theme::DANGER, "失败必须是红的");
// 未知值(以后加了第五种状态而这里没跟上)按「未启动」兜底,不 panic。
assert_eq!(obs_badge(9).text, "未启动");
assert_eq!(obs_badge(255).color, idle.color);
}
/// 下标必须**每个各一次**地覆盖 0..3。
///
/// 漏一个 → 有件服务在界面上根本没有行(用户以为"没这个服务");
/// 重一个 → 那一行被画两遍,点两边的「启动」会起两个进程。
#[test]
fn obs_order_covers_every_frozen_index_once() {
let mut seen = [false; 3];
for idx in OBS_ORDER {
assert!(!seen[idx], "下标 {idx} 在 OBS_ORDER 里出现了两次");
seen[idx] = true;
}
assert!(seen.iter().all(|s| *s), "0/1/2 必须都出现且只出现一次");
assert_eq!(obs_name(obs_service(OBS_METRICS)), "VictoriaMetrics");
assert_eq!(obs_name(obs_service(OBS_LOGS)), "Loki");
assert_eq!(obs_name(obs_service(OBS_DASHBOARD)), "Grafana");
}
/// 端口输入:只认 1~65535 的纯数字。
#[test]
fn port_input_accepts_only_real_ports() {
assert_eq!(parse_port("9091"), Some(9091));
assert_eq!(parse_port(" 9092 "), Some(9092), "带空格也得认(输入框常见)");
assert_eq!(parse_port("65535"), Some(65535));
assert_eq!(parse_port(""), None, "空串不是端口");
assert_eq!(parse_port("909x"), None, "带后缀不是端口");
assert_eq!(parse_port("70000"), None, "超出 u16");
assert_eq!(parse_port("-1"), None, "负数不是端口");
// 0 在 Windows 上是"让系统随便挑一个":存下去的话配置文件里写着 0、
// 服务听着别的端口,而界面上的地址永远连不上。
assert_eq!(parse_port("0"), None, "0 不算端口");
}
/// 页面的 0/1/2 必须和 `MiscState` 里 `obs_proc` / `obs_state` / `obs_ports`
/// 的下标是同一套。
///
/// 这一条是**跨文件**的约定(lib.rs 定义、这一页读),而且错位的症状特别不像
/// 代码问题:点 Loki 的启动,亮的是 VictoriaMetrics 的灯;端口框里改的是
/// Grafana,Loki 却换了端口。查起来会先怀疑服务。
#[test]
fn page_indices_agree_with_lib_state_order() {
let m = crate::MiscState::default();
assert_eq!(m.obs_ports[OBS_METRICS], "9091");
assert_eq!(m.obs_ports[OBS_LOGS], "9092");
assert_eq!(m.obs_ports[OBS_DASHBOARD], "9093");
// 名字也要和 `observability::missing()` 返回的那组对得上(它按同一顺序)。
assert_eq!(
obs_name(obs_service(OBS_METRICS)),
observability::missing(Path::new("不存在的目录"))[0],
"下标 0 是 VictoriaMetrics,不是别的"
);
}
// ------------------------------------------------------------ 启动动画
/// 只有「选了 MP4 **并且**抽到了帧」才播 MP4,其余一律回内置动画。
///
/// 复发场景:把判据写成只看 `boot_video.is_some()`。ffmpeg 没装好 / 文件损坏 /
/// 抽帧还没跑完时,用户看到的是**一整块黑**,只会以为程序坏了 ——
/// 这时候退一档播内置动画,至少还能看出"它在启动"。
#[test]
fn boot_visual_falls_back_to_builtin() {
let p = PathBuf::from(r"C:\video\boot.mp4");
assert_eq!(boot_visual(None, 0), BootVisual::Builtin, "什么也没选");
assert_eq!(
boot_visual(None, 30),
BootVisual::Builtin,
"有帧表但没路径(清除时漏清了帧)也得回内置"
);
assert_eq!(
boot_visual(Some(&p), 0),
BootVisual::Builtin,
"路径在、一帧都没抽到 —— 这时播 MP4 就是黑屏"
);
assert_eq!(boot_visual(Some(&p), 1), BootVisual::Video, "抽到一帧就得算数");
assert_eq!(boot_visual(Some(&p), 240), BootVisual::Video);
}
/// 进度文案的分母:内置固定 1400;MP4 用 `lib.rs::boot_video_total_ms` 那一个。
///
/// 复发场景:在本页自己写「最后一帧的时间戳」。少的就是最后一帧那一格
/// (10 fps → 100 毫秒):文案上写着已经演完,画面其实还差一帧没放出来 ——
/// 播放那一边正是照「最后一帧要显示到什么时候」判结束的。
#[test]
fn boot_total_ms_follows_lib_for_video() {
assert_eq!(boot_total_ms(BootVisual::Builtin, &[]), BOOT_BUILTIN_MS);
assert_eq!(
boot_total_ms(BootVisual::Builtin, &[0, 800]),
BOOT_BUILTIN_MS,
"内置动画不看帧表"
);
let at = [0_u64, 500, 2600];
assert_eq!(
boot_total_ms(BootVisual::Video, &at),
crate::boot_video_total_ms(&at),
"本页给 MP4 算的时长必须和播放那一边(lib.rs)是同一个值"
);
assert!(
boot_total_ms(BootVisual::Video, &at) > *at.last().unwrap() as i64,
"只算到最后一个偏移:最后一帧永远来不及显示"
);
// 空帧表:那边返回 0,这里不能把 0 当分母摆到进度文案上
assert_eq!(boot_total_ms(BootVisual::Video, &[]), BOOT_BUILTIN_MS);
}
/// 本页的「用不用 MP4」和 `lib.rs::boot_use_video` 不能给出不同答案。
///
/// 两边各判一次不是冗余:这边要再分出「没选文件」和「选了但一帧都没有」
/// 两种文案,那边只管到底播哪一路。但答案必须一致 —— 页面写着"有 30 帧、
/// 在用 MP4",播放那一边却回退成内置遮罩,用户会觉得这段话在骗人。
#[test]
fn boot_visual_agrees_with_lib_use_video() {
let p = PathBuf::from(r"C:\video\boot.mp4");
for (video, frames) in [
(None, 0_usize),
(None, 3),
(Some(p.as_path()), 0),
(Some(p.as_path()), 1),
(Some(p.as_path()), 60),
] {
let at: Vec<u64> = (0..frames as u64).map(|i| i * 100).collect();
assert_eq!(
boot_visual(video, frames) == BootVisual::Video,
crate::boot_use_video(video, &at),
"{video:?} / {frames} 帧:本页和 lib.rs 的判断不一致"
);
}
}
/// 预览那句提示必须把三种情况说全,不能只说一句含糊的"按设置播放"。
///
/// 复发场景:三句话里少一句 —— 选了 MP4 但没勾声音的用户会一直等声音,
/// 以为音频坏了;而内置动画的用户会去翻设置找那个根本没有的音轨开关。
#[test]
fn boot_preview_audio_note_covers_all_three_cases() {
let builtin = boot_preview_audio_note(BootVisual::Builtin, false);
let video_off = boot_preview_audio_note(BootVisual::Video, false);
let video_on = boot_preview_audio_note(BootVisual::Video, true);
assert_ne!(builtin, video_off);
assert_ne!(video_off, video_on);
assert_ne!(builtin, video_on, "内置那两句必须分开说");
assert!(builtin.contains("没有音轨"), "内置动画要说明它没声音:{builtin}");
assert!(
video_on.contains("声音一起"),
"勾了开关要说清预览会出声:{video_on}"
);
assert!(
video_off.contains("只放画面"),
"没勾开关要说明是静音,别让用户以为音频坏了:{video_off}"
);
}
/// 只显示本卡片自己写的提示。
///
/// 复发场景:像原来那页一样无条件显示 `misc.message`。那个槽是视频页 /
/// ffmpeg 页 / 拉流页**共用**的,而本卡片现在长在背景图片页上 ——
/// 用户从视频页回来会看到「载入成功,时长 …」挂在启动动画卡片下面。
#[test]
fn boot_message_only_shows_its_own_hints() {
assert_eq!(
boot_message("启动动画: 已清除自定义 MP4"),
Some(" 已清除自定义 MP4"),
"自己写的那条要能显示(前缀要剥掉,不然界面上会重复一遍「启动动画:」)"
);
assert_eq!(boot_message("载入成功,时长 00:12"), None, "视频页的话不该出现");
assert_eq!(boot_message(""), None);
}
/// 卡片上只摆文件名,别把整条路径塞进去。
///
/// 复发场景:直接摆 `p.display()`。egui 不会自动省略长文本,超过卡片宽度
/// 之后会**画到卡片外面**(音乐页的「当前」就这么炸过),而且这一行的
/// 关键信息只是"选的是哪个文件"。
#[test]
fn boot_file_label_keeps_the_file_name() {
assert_eq!(boot_file_label(Path::new(r"C:\video\boot.mp4")), "boot.mp4");
assert_eq!(boot_file_label(Path::new("boot.mp4")), "boot.mp4");
// 拿不到末段的路径(以 `..` 收尾)退回整条,而不是显示成空串
assert_eq!(boot_file_label(Path::new(r"C:\video\..")), r"C:\video\..");
}
/// `lib.rs` 里那几个字段和本页读的是同一套,默认值也必须是说好的那套。
///
/// 这一条是**跨文件**的约定(lib.rs 定义、这一页读):把 `boot_sound` 的默认值
/// 改成 `false`,症状是"选了 MP4 却没声音",而用户第一反应会去查音频设备。
#[test]
fn boot_state_defaults_match_the_documented_ones() {
let m = crate::MiscState::default();
assert!(m.boot_video.is_none(), "默认没有自定义 MP4 → 用内置动画");
assert!(m.boot_sound, "声音默认开:选了 MP4 的人多半就是要连声音一起看");
assert!(m.boot_frames.is_empty(), "默认一帧都没抽");
assert!(m.boot_frame_at.is_empty(), "帧表和时间戳表要一起空");
}
/// 本页**自己写**的那个 store 键,必须和 `lib.rs` 启动时读的是同一个字符串。
///
/// 「启动时播放」是唯一一个本页直接写 store 的开关(声音和 MP4 都走
/// `AppState::boot_sound_set` / `boot_video_set`,键名归那边管)。
/// 字符串契约编译器管不着:键名错一个字母,勾选框照样点得动、`save()` 也不报错,
/// 只是**重启就回到默认** —— 日志、界面、别的测试全绿。(同类跨文件约定见
/// `page_indices_agree_with_lib_state_order`。)
#[test]
fn boot_store_keys_match_lib_reads() {
assert_eq!(KEY_BOOT_ANIM, "boot_anim");
// `boot_anim` 在 `lib.rs` 里是按 `== Some("1")` 读的:存 `"true"` 等于没存。
assert_eq!(flag_str(true), "1");
assert_eq!(flag_str(false), "0");
let lib = include_str!("../lib.rs");
assert!(
lib.contains(&format!("\"{KEY_BOOT_ANIM}\"")),
"lib.rs 里没有出现 store 键 {KEY_BOOT_ANIM} —— 本页存下去的值没人读,\
这个开关重启就会回到默认(而界面上看不出任何异常)"
);
// 另外两个键:本页不写,但要知道它们确实还是这两个名字
// (`boot_state_defaults_…` 之外的第二道:改了名 = 用户选过的视频失联)。
assert_eq!(crate::BOOT_VIDEO_KEY, "boot_video");
assert_eq!(crate::BOOT_SOUND_KEY, "boot_sound");
// 提示前缀两边各一份(`lib.rs` 那份是私有的,拿不到),必须同值:
// 不一样的话,`lib.rs` 在抽帧失败 / 抽完帧之后说的那句提示会被
// `boot_message` 全部过滤掉 —— 用户点了预览却什么反馈都没有。
assert!(
lib.contains(BOOT_MSG_PREFIX),
"lib.rs 里给启动动画写的提示不再用前缀 {BOOT_MSG_PREFIX:?} —— 那些话会被本页过滤掉"
);
// 内置动画的时长也是两边各一份,值必须一致:不一致的症状是进度文案的
// 分母和真正收遮罩的时刻对不上。
assert!(
lib.contains(&format!("BUILTIN_BOOT_TOTAL_MS: i64 = {BOOT_BUILTIN_MS}")),
"lib.rs 里 BUILTIN_BOOT_TOTAL_MS 不再是 {BOOT_BUILTIN_MS} 毫秒 —— \
本页的 BOOT_BUILTIN_MS 要跟着改"
);
}
}