crates\nfarms-gui\src\lib.rs¶
源码路径:
crates\nfarms-gui\src\lib.rs· 5289 行
//! NFarms HUB 图形界面(egui / eframe)。
//!
//! 对应 C# 版的 `MainForm.cs`(5,798 行)那一层。原则和阶段 1 一样:
//! **数据与逻辑在 `nfarms-core`,这里只负责画和交互。**
//!
//! 视觉上复刻原版的三件事:无边框窗口、侧栏雾玻璃、天气页的紫→洋红渐变卡片。
//! UI 线程绝不做等待 —— 联网在后台线程跑,结果用 channel 回传
//! (原版 README 反复强调「UI 线程不做等待与 Sleep」)。
//!
//! # 结构
//!
//! ```text
//! App 壳:窗口、侧栏、内容区、切页 —— 唯一持有 PageRegistry 的地方
//! ├─ AppState 共享状态:设置 / 日志 / 天气 / 背景图 / 本地模型
//! ├─ Kernel 插件内核(nfarms-plugin):服务容器 + 事件总线 + 生命周期
//! └─ PageRegistry 页面插件登记表:侧栏与内容区都只认它
//! ```
//!
//! **侧栏不是常量数组画的**,而是照插件注册表来 —— 装了什么就显示什么。
//! 分组顺序与内容见 `pages::Group`,也就是那张功能树。
mod background;
mod icons;
/// 本地大模型那一块要拉 llama-server / sd-cli 这些**桌面可执行文件**,
/// 安卓上没有,整个模块只在 Windows 下编。
#[cfg(windows)]
mod local_model;
#[cfg(windows)]
mod pet_window;
mod logo;
mod pages;
mod plugins;
mod theme;
mod viewer;
// 页面清单对外公开:`--list-pages`(冒烟测试要问程序「一共有哪些页」)
// 和 README 的自动生成都靠它。`second_level_count` 是三级结构的**现算**统计,
// 命令行的汇总行用它 —— 免得那边自己数一遍、和界面上的口径漂开。
pub use pages::{Group, NavScope, Page, PageInfo, PAGES, Sub, second_level_count};
/// 三级菜单的树形访问器 —— 命令行 `--list-pages` 要按它打,免得自己拼一份。
pub use plugins::{NavItem, PageRegistry, PageSpec, desktop_registry};
// Grafana 真白标(换素材文件)。给 `nfarms-app` 的 `--obs-install` 用 ——
// `[branding]` 这个配置在 Grafana **OSS** 里是付费功能、没人读(理由见 logo.rs 开头),
// 所以只能把 Grafana 自己那几个写死路径的素材换掉。放在 gui 是因为抽 PNG 的
// `extract_largest_png` 在这儿,而 `nfarms-core` 不能反过来依赖 gui。
pub use logo::install_grafana_branding;
use std::path::{Path, PathBuf};
use std::sync::mpsc::{Receiver, TryRecvError};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use eframe::egui;
use egui::{Align2, Color32, FontId, RichText};
use nfarms_plugin::{Event, Kernel};
use nfarms_core::{cyinfo::MachineInfo, observability, weather, Log, Paths, Settings};
/// 侧栏宽度。原版最小窗口 1060×680,侧栏行距 50px —— 这里按同样的手感来。
const NAV_WIDTH: f32 = 236.0;
/// 分组标题行高
const GROUP_ROW_H: f32 = 38.0;
/// 二级子分组标题行高(比一级目录矮 —— 三级菜单里它是中间那一层)
const SUB_ROW_H: f32 = 28.0;
/// 分组里每一项的行高(比分组标题矮,一眼能看出层级)
const CHILD_ROW_H: f32 = 32.0;
/// 起界面(桌面)。返回 `Err` 表示窗口压根没建起来(没有桌面会话、显卡驱动异常等),
/// 调用方据此给出非 0 退出码 —— 别把失败吞成「成功退出」。
///
/// `initial_page` 让命令行能指定开在哪一页(`--page topo`),
/// 这样每一页都能做无人值守冒烟测试,而不是只能看首页。
#[cfg(not(target_os = "android"))]
pub fn run(
paths: &Paths,
log: &Log,
settings_path: &Path,
initial_page: Option<Page>,
open_image: Option<&str>,
popout: bool,
) -> Result<(), String> {
run_inner(paths, log, settings_path, initial_page, open_image, popout)
}
/// 安卓版的 `run`。窗口句柄由 `run_android` 那条路给,这里只是让签名对齐。
#[cfg(target_os = "android")]
pub fn run(
paths: &Paths,
log: &Log,
settings_path: &Path,
initial_page: Option<Page>,
_open_image: Option<&str>,
_popout: bool,
) -> Result<(), String> {
// 安卓这条路上没有 AndroidApp(这个入口不是从 NativeActivity 进来的),
// 最后一个参数传 None。**这个签名一改两边都要跟着改** ——
// 只编 Windows 是发现不了的,所以每次都要跑一遍交叉编译。
run_inner(paths, log, settings_path, initial_page, None, false, None)
}
/// 安卓入口的真正实现。由 `nfarms-mobile` 的 `android_main` 调用。
///
/// eframe 在 Android 上**必须**拿到 `AndroidApp`(NativeActivity 的句柄),
/// 否则 `run_native` 会直接报 "`NativeOptions` is missing required `android_app`"。
#[cfg(target_os = "android")]
pub fn run_android(
android_app: winit::platform::android::activity::AndroidApp,
paths: &Paths,
log: &Log,
settings_path: &Path,
) -> Result<(), String> {
run_inner(
paths,
log,
settings_path,
None,
None,
false,
Some(android_app),
)
}
fn run_inner(
paths: &Paths,
log: &Log,
settings_path: &Path,
initial_page: Option<Page>,
open_image: Option<&str>,
popout: bool,
#[cfg(target_os = "android")] android_app: Option<winit::platform::android::activity::AndroidApp>,
) -> Result<(), String> {
// 启动时在**后台**采一份本机信息 → `reports\<计算机名>-<yyyyMMdd>.cy`。
//
// 必须放后台:8 个工具探测串起来要 1~2 秒,搁在启动路径上窗口就晚出来这么久。
// 失败也不拦启动 —— 报告是附带产物,不是必需品。
{
let p = paths.clone();
let l = log.clone();
std::thread::spawn(move || {
if let Err(e) = nfarms_core::cyinfo::write_report(&p, &l) {
l.write("report", &format!("生成本机信息报告失败: {e}"));
}
});
}
// logo:优先程序根下的 nfarms.ico,读不到/坏掉就退回编译进 exe 的那一份。
// 和 C# 版 build-nfarms.ps1 的策略一致(exe 里嵌一份给窗口/托盘图标兜底)。
let (logo_image, logo_notes) = logo::load(paths.root());
for note in &logo_notes {
log.write("gui", &format!("logo: {note}"));
}
let icon = egui::IconData {
// egui 的 Color32 是预乘的,图标要非预乘 RGBA,所以显式转一次
rgba: logo_image
.pixels
.iter()
.flat_map(|c| c.to_srgba_unmultiplied())
.collect(),
width: logo_image.size[0] as u32,
height: logo_image.size[1] as u32,
};
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("NFarms HUB")
.with_icon(icon)
.with_inner_size([1180.0, 780.0])
.with_min_inner_size([1060.0, 680.0])
// 原版是无边框窗口(名称和 logo 已经在侧栏里,标题栏是重复的)
.with_decorations(false)
// ---- 真透明:**试过了,不行**(留个记录,免得以后重试) ----
//
// 按 egui 文档配齐了两件事:`with_transparent(true)`
// **加上**面板 frame 也用半透明填充(文档明说 CentralPanel
// 会把底填掉,必须配合)。
//
// **实测结果:背景像素恒定。** 把窗口放在主窗口背景图上,
// 和放在桌面壁纸上,右边缘同一相对位置都读出
// `R199 G204 B201` —— 一模一样。背景不随背后内容变化,
// 就不是真透明。
//
// 另外反推出来 `clear_color` 变成了**黑色**:alpha 218 的填充
// 混出 `(199,204,201)`,比 `APP_BG(233,239,235)` 更深。
// 也就是说 egui **认了**透明意图,但窗口最终仍按不透明合成。
//
// 和早先桌宠窗口的实测一致(那次是像素亮度 69.7 vs 192.8)。
// **结论:egui 的视口在这个平台上做不了真透明。**
// 真要做得走 Win32 分层窗口(桌宠那条路),而那意味着
// 对话界面不能在 egui 里画 —— 是重写,不是改配置。
.with_resizable(true),
// 安卓上必须把 NativeActivity 的句柄交给 winit
#[cfg(target_os = "android")]
android_app,
..Default::default()
};
let paths = paths.clone();
let log = log.clone();
let settings_path = settings_path.to_path_buf();
let open_image = open_image.map(|s| s.to_string());
// 闭包要 `move` 拿走一份,错误分支还要留一份写日志
let log_for_app = log.clone();
let result = eframe::run_native(
"NFarms HUB",
options,
Box::new(move |cc| {
Ok(Box::new(App::new(
cc,
paths,
log_for_app,
settings_path,
initial_page,
open_image,
popout,
logo_image,
)))
}),
);
result.map_err(|e| {
// 双击运行时 stderr 是没人看的,所以同时落盘。
let msg = format!("无法创建窗口: {e}");
log.write("gui", &msg);
msg
})
}
// ===========================================================================
// 共享状态
// ===========================================================================
/// 一个后台任务的结果槽。
///
/// 界面上凡是「跑起来要几秒」的活(检测工具版本、问客户端状态、以后解码视频……)
/// 都走这个:页面点一下 → 线程干活 → UI 每帧轮询,拿不到就继续等。
/// **UI 线程永远不等待** —— 这是原版 README 反复强调的一条,也是这个结构存在的理由。
pub(crate) struct Job<T> {
rx: Option<Receiver<T>>,
done: Option<T>,
running: bool,
}
impl<T: Send + 'static> Default for Job<T> {
fn default() -> Self {
Self {
rx: None,
done: None,
running: false,
}
}
}
impl<T: Send + 'static> Job<T> {
pub(crate) fn start<F>(&mut self, ctx: &egui::Context, work: F)
where
F: FnOnce() -> T + Send + 'static,
{
if self.running {
return;
}
self.running = true;
let (tx, rx) = std::sync::mpsc::channel();
self.rx = Some(rx);
let ctx = ctx.clone();
std::thread::spawn(move || {
let out = work();
let _ = tx.send(out);
// 算完叫一声 —— 不叫的话界面要等下一次输入才刷新
ctx.request_repaint();
});
}
pub(crate) fn is_running(&self) -> bool {
self.running
}
/// 轮询一次。返回 `true` 表示**这一帧刚拿到结果**。
pub(crate) fn poll(&mut self) -> bool {
let Some(rx) = self.rx.take() else {
return false;
};
match rx.try_recv() {
Ok(v) => {
self.done = Some(v);
self.running = false;
true
}
Err(TryRecvError::Empty) => {
self.rx = Some(rx);
false
}
Err(TryRecvError::Disconnected) => {
self.running = false;
true
}
}
}
pub(crate) fn result(&self) -> Option<&T> {
self.done.as_ref()
}
/// 把结果**取走**(而不是借一份)。给"结果很大"的任务用 ——
/// 启动动图的一次抽帧是几十张解码后的图(几十 MB),`result().cloned()`
/// 会让内存在那一瞬间翻倍,而 `.take()` 之后旧的那份立刻可以释放。
/// 便宜的结果(一行字符串、一个下标)照旧用 `result()`,不必多一次 move。
pub(crate) fn take_result(&mut self) -> Option<T> {
self.done.take()
}
}
/// 客户端(nfztna)的一次状态快照。
pub(crate) struct ChannelSnapshot {
pub client_path: String,
pub running: bool,
pub status: String,
}
/// Tools › ffmpeg 页的状态。
///
/// 探测与转码/拉流都在后台:`ffmpeg` 探测要扫 PATH(可能是网络盘,一次几秒),
/// 转码更可能是几分钟 —— 都不能放在 UI 线程上。
pub(crate) struct FfmpegState {
/// 已探测到的 ffmpeg(None = 还没探测过)
pub(crate) detected: Option<nfarms_core::ffmpeg::Ffmpeg>,
pub(crate) detect_job: Job<nfarms_core::ffmpeg::Ffmpeg>,
/// 转码 / 截图 / 拉流这类长任务
pub(crate) work: Job<String>,
/// 表单:源文件 / 输出文件 / 截图时间点
pub(crate) src: String,
pub(crate) out: String,
pub(crate) at_seconds: String,
/// 媒体信息(检测过一次就留着)
pub(crate) info: Option<String>,
pub(crate) message: Option<String>,
}
impl Default for FfmpegState {
fn default() -> Self {
Self {
detected: None,
detect_job: Job::default(),
work: Job::default(),
src: String::new(),
out: String::new(),
at_seconds: "1".to_string(),
info: None,
message: None,
}
}
}
/// Zero Trust VPN › Packet capture 的状态。
///
/// `child` 是**长期运行**的 dumpcap 进程 —— 它不属于任何 `Job`
/// (`Job` 是「跑完给一个结果」,抓包是「一直跑到你喊停」),
/// 所以单独放在这里,由页面负责 kill。
#[derive(Default)]
pub(crate) struct CaptureState {
pub(crate) ws: Option<nfarms_core::wireshark::Wireshark>,
pub(crate) iface_job: Job<Result<Vec<nfarms_core::wireshark::Interface>, String>>,
pub(crate) child: Option<std::process::Child>,
/// 输出文件(留空则自动放到 shots\ 下)
pub(crate) out: String,
/// BPF 过滤表达式
pub(crate) filter: String,
pub(crate) iface_index: u32,
pub(crate) message: Option<String>,
/// 探测过没有(理由同 `DshState::probed`)
pub(crate) probed: bool,
/// 上次抓到的文件,用来「用 Wireshark 打开」
pub(crate) last_pcap: Option<PathBuf>,
}
impl CaptureState {
pub(crate) fn capturing(&self) -> bool {
self.child.is_some()
}
/// 停抓包。dumpcap 收到 kill 会把已抓的包 flush 到文件,所以直接 kill 是安全的。
pub(crate) fn stop(&mut self) -> bool {
let Some(mut c) = self.child.take() else {
return false;
};
let _ = c.kill();
let _ = c.wait();
true
}
}
/// SecureCRT 这个「深度集成」页的状态。
///
/// **WinSCP 那一份已经删掉**:新菜单里没有它的落点(SecureCRT 那一页管安全终端)。
/// `nfarms_core::remote` 里的 WinSCP 解析与启动函数还在 —— 那是有测试的纯逻辑,
/// 要加回来时直接用。
#[derive(Default)]
pub(crate) struct RemoteState {
pub(crate) crt_exe: Option<PathBuf>,
/// **探测过没有** —— 理由同 `DshState::probed`:没装 SecureCRT 时
/// `find_*` 每次都要 `reg query`,用 `is_none()` 当守卫就会每帧起一个
/// cmd.exe 去读注册表。
pub(crate) crt_probed: bool,
/// 会话列表要跑子进程(扫 ini),放后台
pub(crate) crt_sessions: Job<Vec<nfarms_core::remote::Session>>,
pub(crate) message: Option<String>,
}
/// DSH-Starter 页的状态。
///
/// `child` 是常驻的 dsh 进程 —— 和抓包那页一样,它不属于 `Job`
/// (`Job` 是「跑完给一个结果」,这个是「一直跑到你喊停」)。
pub(crate) struct DshState {
pub(crate) child: Option<std::process::Child>,
pub(crate) port: i32,
/// 从 dsh 输出里抓到的**带 token** 的根地址
pub(crate) url: Option<String>,
/// `where dsh` 的结果
pub(crate) exe: Option<String>,
/// **探测过没有** —— 不能用 `exe.is_none()` 当标志!
///
/// 「没装 dsh」是个合法结果,而 `is_none()` 分不清「没探测过」和
/// 「探测过但没有」。用它当守卫的话,没装 dsh 的机器上会**每帧**跑一次
/// `where dsh`(每次起一个 cmd.exe)—— 就是用户看到的「一直弹控制台闪屏」。
pub(crate) probed: bool,
pub(crate) message: Option<String>,
}
impl Default for DshState {
fn default() -> Self {
Self {
child: None,
port: nfarms_core::dsh::DEFAULT_PORT,
url: None,
exe: None,
probed: false,
message: None,
}
}
}
/// bililive-go(直播录制)页的状态。
#[derive(Default)]
pub(crate) struct LiveState {
pub(crate) live: Option<nfarms_core::live::Live>,
pub(crate) child: Option<std::process::Child>,
pub(crate) rooms: Vec<String>,
pub(crate) room_input: String,
/// ping 的结果
pub(crate) status: Option<String>,
/// 探测过没有(理由同 `DshState::probed`)
pub(crate) probed: bool,
pub(crate) message: Option<String>,
}
/// 剩下几页的状态,合成一个结构体。
///
/// 它们都是「点一下 → 拿个结果」或几个开关,单独给每页开一个 struct 只会
/// 让 `AppState` 变成一张长清单。真正长期的进程句柄(抓包、dsh、相册)
/// 才单独放。
pub(crate) struct MiscState {
// ---- Setting › Proxy and Port ----
// ---- Slack off › 桌宠对话框 ----
// ---- Today's Info ----
/// 抓取任务。**单独一个 Job**:它和别的页面的任务互不影响。
pub(crate) today_job: Job<Result<Vec<nfarms_core::today::Item>, String>>,
pub(crate) today_items: Vec<nfarms_core::today::Item>,
pub(crate) today_err: Option<String>,
/// 上次成功抓取的时刻。用它做**自动刷新**(见 today_info 里的间隔说明)。
pub(crate) today_at: Option<std::time::Instant>,
/// 对话框开着没有。点桌宠切换。
pub(crate) chat_open: bool,
/// 对话历史。`true` = 用户说的,`false` = 小鲸说的。
///
/// 和 DSH 的会话历史**不是一回事**:这里是一次性的「问一句答一句」,
/// 关掉窗口就从零开始 —— 桌宠不该变成一个要管理的会话。
pub(crate) chat_msgs: Vec<(bool, String)>,
pub(crate) chat_input: String,
pub(crate) chat_job: Job<Result<nfarms_core::chat::Reply, String>>,
/// 勾上「画图」时,这一条消息走 stable-diffusion.cpp 而不是对话模型。
pub(crate) chat_draw: bool,
/// 出图分辨率。sd-cli 默认 512×512 —— 画大图必须显式给。
pub(crate) chat_draw_w: u32,
pub(crate) chat_draw_h: u32,
/// 上一帧对话框是不是开着。用它检测**关的那一刻** ——
/// 关窗必须发一次 `ViewportCommand::Close`,不能只是"不再画它"。
pub(crate) chat_was_open: bool,
/// 对话框的 Win32 半透明**只设一次**。函数本身幂等,但每帧
/// `FindWindowW` 是白费的;而且失败时要重试(视口第一帧还没建出来)。
pub(crate) chat_translucent: bool,
/// 知识库文档。**读一次留着** —— `wiki\` 有好几 MB,
/// 每条消息重读一遍是几百毫秒的无谓 I/O,而它几乎不变。
pub(crate) kb_docs: Option<Vec<nfarms_core::kb::Doc>>,
/// 出图任务。**和对话分开两个 Job** —— 出图要几分钟,复用一个 Job
/// 会让"正在出图"把对话也一起锁住。
pub(crate) chat_draw_job: Job<Result<std::path::PathBuf, String>>,
/// 最近画出来的图。`None` = 还没画过。
pub(crate) chat_img: Option<egui::TextureHandle>,
/// 最近那张图的**磁盘路径** —— 「打开所在文件夹」要用它。
/// 只留纹理是不够的:纹理在内存里,用户要的是文件。
pub(crate) chat_img_path: Option<std::path::PathBuf>,
/// 本地模型累计用量。Tokens 页显示它。
///
/// 用 `u64` 而不是 `u32`:单次请求的 token 数不会溢出,但**累计**会。
pub(crate) tok_prompt: u64,
pub(crate) tok_completion: u64,
pub(crate) tok_calls: u64,
/// 从 llama-server 拉回来的原始指标行(只留含 token 的)。
pub(crate) tok_metrics: Vec<String>,
pub(crate) tok_metrics_err: Option<String>,
pub(crate) tok_metrics_job: Job<Result<Vec<String>, String>>,
/// 上一次请求的错误。显示在输入框上方,不塞进对话历史 ——
/// 塞进去的话下一轮会被当成「小鲸说过的话」再发回模型。
pub(crate) chat_err: Option<String>,
/// 「终止服务」的结果。**单独一个字段**而不是复用 `message`:
/// 那个是 Script 页的,写进去会显示在**另一页**上。
pub(crate) port_message: Option<String>,
// ---- Slack off › 桌宠 ----
// ---- Player › 视频播放器 ----
pub(crate) video_src: String,
/// 「打开视频…」选完之后要**自动载入并播放**。
///
/// 用一个标志跨页传递,而不是在 `poll_background_drop` 里直接载入:
/// 载入要用 ffmpeg 句柄和 `video_*` 那一堆状态,那些都在视频页里。
/// 在这里重写一遍 = 两处逻辑,迟早不一致。
pub(crate) video_want_load: bool,
pub(crate) video_duration_ms: i64,
pub(crate) video_pos_ms: i64,
pub(crate) video_playing: bool,
pub(crate) video_tex: Option<egui::TextureHandle>,
/// 抽帧任务(ffmpeg → png → 解码 → 纹理)
pub(crate) video_frame: Job<FrameJob>,
pub(crate) video_info: String,
/// 上一次抽帧对应的时刻(毫秒),用来在播完时停下来
pub(crate) video_last_shot: i64,
// ---- Setting › 启动动图 ----
pub(crate) boot_enabled: bool,
pub(crate) boot_started_ms: Option<i64>,
/// 自定义的启动动画 MP4。`None` = 用内置那层遮罩动画。
///
/// 存**路径本体**而不是 store 里的字符串:读取时已经解析过一次
/// (见 [`boot_video_from_store`]),播放那一帧里不必再判一次空串。
pub(crate) boot_video: Option<std::path::PathBuf>,
/// 启动动画播不播声音,**默认 true**(选了视频的人多半就是要连声音一起放)。
pub(crate) boot_sound: bool,
/// 抽好的帧(UI 线程上传的纹理),与 `boot_frame_at` **同长同序**。
pub(crate) boot_frames: Vec<egui::TextureHandle>,
/// 每帧对应的毫秒偏移(= 从这一毫秒起显示这一帧)。
pub(crate) boot_frame_at: Vec<u64>,
/// 抽帧任务 —— **必须后台**:一次要起 ffmpeg 解一段视频。
pub(crate) boot_video_job: Job<Result<BootFrames, String>>,
/// 已经为哪个路径抽过一次帧(成功失败都算)。
///
/// 只给 `render_boot_anim` 里那次"自动补抽"当闸门:没有它的话,
/// 用户每点一次「立即预览」就会再起一个 ffmpeg,而失败(文件坏了 /
/// ffmpeg 没装)的那次会永远失败 —— 白白刷日志、白白占 CPU。
pub(crate) boot_video_tried: Option<std::path::PathBuf>,
/// 正在放声音的那段动画的起点(即 `boot_started_ms` 的值);`None` = 没在放。
///
/// 用它而不是 `boot_started_ms.is_some()` 当判据:播放起点一变
/// (启动自动播 → 用户点「立即预览」)就该重起一次声音,而
/// "有没有在播"这个问法分不出这两种情况。
pub(crate) boot_audio_for: Option<i64>,
// ---- Setting › 监控并同步 ----
pub(crate) sync_enabled: bool,
pub(crate) sync_target: String,
// ---- Setting › 打印代理 ----
pub(crate) printer_exe: Option<std::path::PathBuf>,
pub(crate) printer_port: u16,
pub(crate) printer_child: Option<std::process::Child>,
/// 探测过没有(理由同 `DshState::probed`)
pub(crate) printer_probed: bool,
// ---- Slack off › Pet ----
pub(crate) pet_visible: bool,
pub(crate) pet_phase: f32,
// ---- Setting › Proxy and Port › 观测服务 ----
//
// 三件(VictoriaMetrics / Loki / Grafana)都是**外部 exe**:起进程、等就绪、
// 喂数据各自要几秒到几十秒,所以状态机放在这里,页面只负责画灯和按按钮。
// 下面这五个 `obs_` 字段是**页面要读的契约**(plugins/core_pages.rs),
// 下标 0/1/2 = 指标 / 日志 / 看板,别改顺序、别改名字。
/// 三件服务的子进程句柄。
///
/// 单独放而不是塞进 `Job`:它们是"一直跑到你喊停"的常驻进程,而 `Job` 是
/// "跑完给一个结果"(`CaptureState::child` 是同一个理由)。
pub(crate) obs_proc: [Option<std::process::Child>; 3],
/// 三件服务的状态:**0=未启动 1=启动中 2=已就绪 3=失败**。
///
/// 用 `u8` 而不是枚举:页面直接拿它对着数字取色画灯,不必依赖本模块的类型;
/// 做成枚举的话每加一个态都得同时改两个文件。
pub(crate) obs_state: [u8; 3],
/// 最近一条给用户看的话(启动中 / 已就绪 / 失败原因)。`None` = 还没动过。
pub(crate) obs_msg: Option<String>,
/// 启动任务的返回槽:`Ok(下标)` = 那一件已经就绪。
///
/// 补配置、生成数据源、起进程、**等就绪**全在这个 Job 里跑 —— 等就绪是阻塞的
/// (Loki 实测 17 秒),搁在 UI 线程上就是整个窗口冻住几十秒,用户只会以为程序死了。
pub(crate) obs_job: Job<Result<usize, String>>,
/// 三件服务的端口。真值存在 store.json,这里存的是**界面上的文本**。
///
/// 存字符串不存 u16:它背后是输入框,用户敲到一半的 `"90"` 也得留住 ——
/// 每帧解析失败就把框清成默认值的话,那个框根本没法用。
pub(crate) obs_ports: [String; 3],
/// 起进程那一步在后台 Job 里,拿到 `Child` 之后从这条通道交回 UI 线程
/// (`Job` 的结果类型 `Result<usize, String>` 塞不下一个进程句柄)。
pub(crate) obs_child_rx: Option<Receiver<(usize, std::process::Child)>>,
/// 上次把 token 用量推给 VM 的**时刻**(u64 毫秒,Unix 纪元);0 = 还没推过。
///
/// 要的就是"上一次推送的时刻"这个值本身,所以用墙上时间而不是 `Instant`:
/// 后者是单调时钟,存下来既不表示时刻,也没法跟一个"现在"相减。
pub(crate) obs_pushed_ms: u64,
pub(crate) message: Option<String>,
}
impl Default for MiscState {
fn default() -> Self {
Self {
port_message: None,
today_job: Job::default(),
today_items: Vec::new(),
today_err: None,
today_at: None,
chat_open: false,
chat_msgs: Vec::new(),
chat_input: String::new(),
chat_job: Job::default(),
chat_draw: false,
chat_draw_w: 768,
chat_draw_h: 768,
chat_was_open: false,
chat_translucent: false,
kb_docs: None,
chat_draw_job: Job::default(),
chat_img: None,
chat_img_path: None,
tok_prompt: 0,
tok_completion: 0,
tok_calls: 0,
tok_metrics: Vec::new(),
tok_metrics_err: None,
tok_metrics_job: Job::default(),
chat_err: None,
video_src: String::new(),
video_want_load: false,
video_duration_ms: 0,
video_pos_ms: 0,
video_playing: false,
video_tex: None,
video_frame: Job::default(),
video_info: String::new(),
video_last_shot: -1,
boot_enabled: false,
boot_started_ms: None,
boot_video: None,
boot_sound: true,
boot_frames: Vec::new(),
boot_frame_at: Vec::new(),
boot_video_job: Job::default(),
boot_video_tried: None,
boot_audio_for: None,
sync_enabled: false,
sync_target: "report.nfarms.cn".to_string(),
printer_exe: None,
printer_port: 8642,
printer_child: None,
printer_probed: false,
pet_visible: false,
pet_phase: 0.0,
// 观测服务:端口初值取自 core 的缺省(9091 / 9092 / 9093),
// `AppState::new` 里再用 store.json 里存过的值覆盖
obs_proc: [None, None, None],
obs_state: [0, 0, 0],
obs_msg: None,
obs_job: Job::default(),
obs_ports: std::array::from_fn(|i| {
observability::default_port(OBS_SERVICES[i]).to_string()
}),
obs_child_rx: None,
obs_pushed_ms: 0,
message: None,
}
}
}
/// 日志查看器的状态(带分页)。
///
/// 为什么要缓存:日志文件可能有几十 MB,**每帧重读一次**会直接把界面拖死。
/// 所以按 `(长度, 修改时间)` 判断有没有变,变了才重读。
pub(crate) struct LogViewer {
/// 当前看的文件
pub(crate) file: Option<PathBuf>,
/// 文件内容(只保留尾部,见 `MAX_BYTES`)
pub(crate) text: String,
/// 上次读时的 `(字节数, 修改时间秒)` —— 用来判断要不要重读
pub(crate) stamp: Option<(u64, u64)>,
/// 当前页(从 0 开始)
pub(crate) page: usize,
/// 每页行数
pub(crate) per_page: usize,
/// 自动跟随(有新内容就跳到最后一页)
pub(crate) follow: bool,
/// 「跳到第几页」输入框
pub(crate) goto: String,
pub(crate) error: Option<String>,
}
/// 单个日志文件最多读这么多字节。超了只留**尾部** ——
/// 排查问题看的是最近发生了什么,不是三个月前。
const LOG_MAX_BYTES: u64 = 8 * 1024 * 1024;
/// 每页行数的候选。
pub(crate) const PER_PAGE_CHOICES: [usize; 5] = [50, 100, 200, 500, 1000];
impl Default for LogViewer {
fn default() -> Self {
Self {
file: None,
text: String::new(),
stamp: None,
page: 0,
per_page: 200,
follow: true,
goto: String::new(),
error: None,
}
}
}
impl LogViewer {
/// 选一个默认文件(没有选过时用程序日志)。
pub(crate) fn ensure_file(&mut self, dir: &std::path::Path) {
if self.file.as_ref().map(|p| p.is_file()).unwrap_or(false) {
return;
}
// 默认看程序日志;它不在就退而求其次,挑目录里最新改动的那个 .log
let preferred = dir.join(nfarms_core::log::APP_LOG);
self.file = Some(if preferred.is_file() {
preferred
} else {
newest_log(dir).unwrap_or(preferred)
});
}
/// 需要的话重读文件。返回是否真的重读了。
pub(crate) fn refresh(&mut self) -> bool {
let Some(path) = self.file.clone() else {
return false;
};
let meta = match std::fs::metadata(&path) {
Ok(m) => m,
Err(e) => {
self.error = Some(format!("读不到 {}: {e}", path.display()));
return false;
}
};
let mtime = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
let stamp = (meta.len(), mtime);
if self.stamp == Some(stamp) {
return false;
}
// 超过上限只读尾部:先 seek 到 len - MAX,再丢掉第一个不完整的行
let mut text = match std::fs::read(&path) {
Ok(bytes) => {
let bytes = if bytes.len() as u64 > LOG_MAX_BYTES {
let skip = bytes.len() - LOG_MAX_BYTES as usize;
// 从 skip 往后找第一个换行,避免把半行读进来
let start = bytes[skip..]
.iter()
.position(|&b| b == b'\n')
.map(|p| skip + p + 1)
.unwrap_or(skip);
bytes[start..].to_vec()
} else {
bytes
};
nfarms_core::tools::decode_smart(&bytes)
}
Err(e) => {
self.error = Some(format!("读日志失败: {e}"));
String::new()
}
};
// 日志文件是 UTF-8,但可能有坏字节 —— decode_smart 已经兜过了
let was_following = self.follow;
self.error = None;
self.text = std::mem::take(&mut text);
self.stamp = Some(stamp);
let pages = self.page_count();
if was_following {
self.page = pages.saturating_sub(1);
} else {
self.page = self.page.min(pages.saturating_sub(1));
}
true
}
/// 全部分行(不复制)。
pub(crate) fn lines(&self) -> Vec<&str> {
self.text.lines().collect()
}
pub(crate) fn line_count(&self) -> usize {
self.text.lines().count()
}
pub(crate) fn page_count(&self) -> usize {
let n = self.line_count();
if n == 0 {
return 1;
}
n.div_ceil(self.per_page.max(1))
}
/// 当前页的行区间 `[start, end)`。
pub(crate) fn page_range(&self) -> (usize, usize) {
let per = self.per_page.max(1);
let start = self.page.saturating_mul(per);
let total = self.line_count();
(start.min(total), (start + per).min(total))
}
pub(crate) fn goto_page(&mut self, p: usize) {
self.page = p.min(self.page_count().saturating_sub(1));
self.follow = false;
}
}
/// 目录里最近改动的 `.log`。
fn newest_log(dir: &std::path::Path) -> Option<PathBuf> {
let mut best: Option<(std::time::SystemTime, PathBuf)> = None;
for e in std::fs::read_dir(dir).ok()?.flatten() {
let p = e.path();
let is_log = p
.extension()
.and_then(|x| x.to_str())
.map(|x| x.eq_ignore_ascii_case("log"))
.unwrap_or(false);
if !is_log {
continue;
}
if let Ok(m) = e.metadata()
&& let Ok(t) = m.modified()
&& best.as_ref().map(|(bt, _)| t > *bt).unwrap_or(true)
{
best = Some((t, p));
}
}
best.map(|(_, p)| p)
}
/// 浏览器引擎探测这一块的一次性后台任务。
pub(crate) struct ProbeState {
/// 浏览器引擎探测
pub(crate) engines: Job<Vec<nfarms_core::browser::Engine>>,
pub(crate) message: Option<String>,
}
impl Default for ProbeState {
fn default() -> Self {
Self {
engines: Job::default(),
message: None,
}
}
}
/// 音乐 / 阅读器两页的共享状态。
///
/// 放一起是因为它们的「打开文件」走同一个对话框(`FilePicker`),靠
/// `PickPurpose` 路由回来 —— 分成两个结构体反而要多写一遍路由。
#[derive(Default)]
pub(crate) struct MediaState {
/// MCI 播放器(winmm)
pub(crate) music: nfarms_core::music::Music,
/// 当前播放列表(某个目录下的音频文件)
pub(crate) playlist: Vec<std::path::PathBuf>,
pub(crate) playlist_dir: String,
/// 播放列表里正在放第几首
pub(crate) track: usize,
pub(crate) message: Option<String>,
}
impl MediaState {
/// 扫一个目录,建播放列表(按文件名排序,和资源管理器一致)。
pub(crate) fn scan_playlist(&mut self, dir: &std::path::Path) {
const EXT: [&str; 7] = ["mp3", "wav", "wma", "m4a", "aac", "flac", "ogg"];
let mut list: Vec<std::path::PathBuf> = std::fs::read_dir(dir)
.map(|it| {
it.flatten()
.map(|e| e.path())
.filter(|p| p.is_file())
.filter(|p| {
p.extension()
.and_then(|e| e.to_str())
.map(|e| EXT.contains(&e.to_ascii_lowercase().as_str()))
.unwrap_or(false)
})
.collect()
})
.unwrap_or_default();
list.sort();
self.playlist = list;
self.playlist_dir = dir.display().to_string();
self.track = 0;
}
}
/// 拓扑验证器的界面状态。
/// 探测跑在后台线程(并发 4、单边超时 4~5 秒),结果经 channel 回传 ——
/// UI 线程照样不做任何等待,和天气页是同一套做法。
pub(crate) struct TopoState {
pub(crate) edges: Vec<nfarms_core::topo::TopoEdge>,
pub(crate) running: bool,
pub(crate) rx: Option<Receiver<Vec<nfarms_core::topo::TopoEdge>>>,
/// 最近一次跑完的时间戳(`20260930-223907`)
pub(crate) last_run: Option<String>,
/// 单边超时,界面上可以调
pub(crate) timeout_ms: i32,
}
impl TopoState {
fn new() -> Self {
Self {
// 初值就是「未验证」的 14 条边 —— 打开页面先看到清单,
// 而不是一片空白等着你点按钮。
edges: nfarms_core::topo::live_edges(),
running: false,
rx: None,
last_run: None,
timeout_ms: nfarms_core::topo::TIMEOUT_MS,
}
}
pub(crate) fn start(&mut self, ctx: &egui::Context) {
if self.running {
return;
}
self.running = true;
// 先全部置成 busy,界面上立刻能看到「进行中」
for e in self.edges.iter_mut() {
e.state = "busy";
e.judged = false;
}
let (tx, rx) = std::sync::mpsc::channel();
self.rx = Some(rx);
let timeout = self.timeout_ms;
let ctx = ctx.clone();
std::thread::spawn(move || {
let mut edges = nfarms_core::topo::live_edges();
nfarms_core::topo::run_all_blocking(&mut edges, timeout, None::<fn(&nfarms_core::topo::TopoEdge)>);
let _ = tx.send(edges);
ctx.request_repaint();
});
}
/// 轮询后台结果。返回 Some(汇总行) 表示这一轮跑完了。
pub(crate) fn poll(&mut self) -> Option<String> {
let rx = self.rx.take()?;
match rx.try_recv() {
Ok(edges) => {
self.edges = edges;
self.running = false;
let line = nfarms_core::topo::summary_line(
&self.edges,
nfarms_core::topo::server_edges().len(),
);
self.last_run = Some(now_stamp());
Some(line)
}
Err(TryRecvError::Empty) => {
self.rx = Some(rx);
None
}
Err(TryRecvError::Disconnected) => {
self.running = false;
Some("后台探测线程异常退出".to_string())
}
}
}
}
/// 各页面共用的状态。
///
/// 从原来那个大 `App` 里切出来的:**它只管数据,不管窗口**。
/// 页面插件拿到的 `PageCtx` 里就是 `&mut AppState`,因此加一页
/// 不需要动 `App` 的任何实现。
/// 抽帧任务的结果:成功给出「解码后的图 + 原始尺寸」,失败给出原因。
///
/// 起个别名:`Job<Result<(ColorImage, (u32, u32)), String>>` 写两遍就没人看得懂了。
pub(crate) type FrameJob = Result<(egui::ColorImage, (u32, u32)), String>;
// ===========================================================================
// 启动动图(Setting › 启动动图)
// ===========================================================================
//
// 两种来源:用户选的 MP4(**逐帧抽出来当纹理放**),或者内置那层全屏遮罩动画。
// 这里放纯函数与常量,状态在 `MiscState` 的 `boot_*` 字段上,
// 抽帧在 `AppState::boot_video_load` / `AppState::poll_boot_video`,
// 播放驱动在 `App::render_boot_anim`。
/// store.json 里「自定义启动动画」的键(值是 MP4 路径;空 / 缺失 = 用内置动画)。
///
/// 抽成常量给读写两侧共用 —— 键名打错一个字母的症状是「用户选了视频,重启后
/// 悄悄回到内置动画」,而盘上那份路径还好端端躺在一个没人读的键下面。
/// **不进 settings.json**:那个文件有跟 C# 原版逐字节还原的护栏测试,多一个键就红。
pub(crate) const BOOT_VIDEO_KEY: &str = "boot_video";
/// store.json 里「启动动画播不播声音」的键(`"1"` / `"0"`,`"0"` 之外都算开)。
pub(crate) const BOOT_SOUND_KEY: &str = "boot_sound";
/// store.json 里「启动时播不播启动动画」的键(`"1"` = 开,**默认关**)。
///
/// 这个名字原先是背景图页(`core_pages.rs::KEY_BOOT_ANIM`)一个人在用 ——
/// 现在 [`AppState::save_boot_switches`] 也要写它(用户选 MP4 时会自动把它顶成开,
/// 见 [`boot_enabled_after_pick`]),所以键名挪到这里两边共用:同一个键有两份
/// 字面量,就是两个会各自漂移的来源,而漂移的症状是「勾了、存了、重启还是关」。
pub(crate) const BOOT_ANIM_KEY: &str = "boot_anim";
/// 给启动动画写的提示统一带这个前缀。
///
/// `misc.message` 是**全 App 共用一个槽**(视频页 / ffmpeg 页 / 拉流页都在写),
/// 而启动动画卡片长在背景图片页上:不带前缀的话,用户从视频页回来会看到
/// 「载入成功,时长 …」挂在启动动画卡片下面,像是这一页干的。
/// 卡片那边(`core_pages.rs::boot_message`)据这个前缀过滤。
const BOOT_MSG_PREFIX: &str = "启动动画:";
/// 内置遮罩动画的总时长(毫秒)。
const BUILTIN_BOOT_TOTAL_MS: i64 = 1400;
/// 结尾淡出的时长(毫秒)。两种来源共用 —— MP4 直接切掉会在最后一帧硬闪一下。
const BOOT_FADE_MS: i64 = 400;
/// 抽帧采样率(帧/秒)。
///
/// 10 而不是源视频的 25/30:启动动画只放几秒、当"动起来的画面"看,少一半帧
/// 几乎看不出来;而内存是按「帧数 × 宽 × 高 × 4」算的,30 fps 抽 6 秒就是 180 张。
const BOOT_VIDEO_FPS: u32 = 10;
/// 最多抽多少帧(= 最多放 6 秒)。
///
/// 这不是"性能优化"而是**内存护栏**:960×540 的一帧是 2.0 MB,60 帧约 124 MB;
/// 不封顶的话一个 4K 的 10 秒片会一口气吃掉几个 GB,而启动动画只是层遮罩。
const BOOT_VIDEO_MAX_FRAMES: usize = 60;
/// 抽出来的帧的宽度上限(等比缩放,**只缩不放**)——
/// 见上一条的内存账;同时也保证不超过 `max_texture_side`。
const BOOT_VIDEO_MAX_WIDTH: u32 = 960;
/// 抽帧那次 ffmpeg 的超时(毫秒)。
///
/// `-frames:v` 已经把工作量封在"解前 6 秒"上,2 分钟是给网络盘 / 冷启动留的余量;
/// 真超时会 kill 进程并把 stderr 带回来,不会让界面一直转圈。
const BOOT_EXTRACT_TIMEOUT_MS: u64 = 120_000;
/// 一段 MP4 抽出来的帧,外加一句给用户看的说明。
pub(crate) struct BootFrames {
/// `(毫秒偏移, 解码后的图)`,按时间升序。
pub(crate) frames: Vec<(u64, egui::ColorImage)>,
/// 成功也要说一句(抽了几帧 / 视频太长只取了前几秒)。
/// 页面把 `misc.message` 画出来,用户才知道这次到底用了什么。
pub(crate) note: String,
}
/// 按时间找「现在该显示第几帧」。
///
/// 只有一种情况返回 `None`:**一帧都没有**(刚启动还没抽完,或抽帧失败了)。
/// 早于第一帧 / 晚于最后一帧都夹到端点,而不是返回 `None` —— 返回 `None`
/// 会让调用方在这两段里退回内置遮罩,表现成动画开头或结尾突然闪一下内置的 logo,
/// 比停在某一帧难看得多。
///
/// `frame_at` 必须是升序表(抽帧那一步按帧序生成的偏移天然升序)。
pub(crate) fn boot_frame_index_at(at_ms: u64, frame_at: &[u64]) -> Option<usize> {
if frame_at.is_empty() {
return None;
}
// 返回值是「<= at_ms 的个数」:0 = 还没到第一帧(夹到 0),
// len = 已经过了最后一帧(夹到 len-1)。两头都由 saturating_sub 自动夹住。
let n = frame_at.partition_point(|&t| t <= at_ms);
Some(n.saturating_sub(1))
}
/// 视频帧序列的总时长(毫秒)= 最后一帧的偏移 + 一帧的间隔。
///
/// 少加那一帧的间隔,最后一帧就永远来不及显示(走到它的偏移就已经判成"演完了")。
/// (页面上那个进度文案的分母用的是**最后一个偏移本身**,差的就是这一帧 ——
/// 只差 100 ms,不值得为它把两边都改成"要显示到什么时候"。
/// 真要让两边完全一致,得改的是 `core_pages.rs::boot_total_ms`。)
pub(crate) fn boot_video_total_ms(frame_at: &[u64]) -> i64 {
let Some(last) = frame_at.last() else {
return 0;
};
(last + (1000 / BOOT_VIDEO_FPS.max(1)) as u64) as i64
}
/// 该用 MP4 还是内置遮罩。
///
/// 两个条件缺一不可:选了文件、帧已经抽好。**「选了但还没抽完」必须回退内置** ——
/// 那时照路径去播就是一片黑,用户只会以为程序坏了。
pub(crate) fn boot_use_video(video: Option<&Path>, frame_at: &[u64]) -> bool {
video.is_some() && !frame_at.is_empty()
}
/// 结尾淡出的不透明度(0..=1)。
fn boot_fade_alpha(elapsed_ms: i64, total_ms: i64) -> f32 {
if elapsed_ms > total_ms - BOOT_FADE_MS {
((total_ms - elapsed_ms) as f32 / BOOT_FADE_MS as f32).clamp(0.0, 1.0)
} else {
1.0
}
}
/// store 里的字符串 → 自定义启动动画的路径。缺失 / 不是字符串 / 全是空白 → `None`。
///
/// **不检查文件在不在**:那件事留到真去抽帧时报(见 [`extract_boot_frames`])——
/// 在这里检查的话,用户把视频放在迟到挂载的移动硬盘上,设置会被静默丢掉,
/// 而界面上看不出"为什么我的视频不见了"。
pub(crate) fn boot_video_from_store(raw: Option<&str>) -> Option<PathBuf> {
let s = raw?.trim();
if s.is_empty() { None } else { Some(PathBuf::from(s)) }
}
/// 用户交来一个启动视频(或取消)之后,「启动时播放」该是什么。
///
/// **选一个启动视频这个动作本身就表达了「启动时要播」。** 两个开关各自为政的
/// 结果是:store.json 里 `boot_video` 存住了、`boot_anim` 却没开 —— 动画不播,
/// 用户以为"设置没保存",而且**从这个现象反推不出原因**(他能看到的只有一个
/// 没勾的 checkbox,和每次启动悄悄跑掉的那一次 ffmpeg 抽帧)。
///
/// 传 `false`(清掉视频)**不反过来关掉**这个开关:那是用户对「启动时播放」的
/// 独立选择,清视频只表示"改回内置动画"。所以这里不是 `has_video` 直接赋值。
pub(crate) fn boot_enabled_after_pick(has_video: bool, current: bool) -> bool {
has_video || current
}
/// store 里的字符串 → 「播不播声音」。**只有 `"0"` 是关**,其余(含缺失、写坏)
/// 都算开。
///
/// 默认开是有意的(选了 MP4 就是要连声音);写坏的值回落到默认而不是 `unwrap`
/// 炸掉 —— 这个函数跑在**启动路径**上,一次 panic 就是窗口起不来。
pub(crate) fn boot_sound_from_store(raw: Option<&str>) -> bool {
!matches!(raw.map(str::trim), Some("0"))
}
/// store 里的字符串 → 「启动时播不播启动动画」。**只有 `"1"` 是开**,其余
/// (缺失 / `"0"` / 写坏的值)都算**关** —— 默认关是有意的:一个程序不该一启动
/// 就挡你 1.4 秒,这条语义在 `App::new` 里写着。
///
/// 写坏的值回落到默认而不是 `unwrap` 炸掉:这个函数跑在**启动路径**上。
/// 两边带空白照样认(手改 store.json 的常见写法),而落盘判据
/// ([`boot_anim_unchanged`])用的是同一个口径 —— 两边口径不一致就会造出
/// 「盘上是 `" 1 "`、于是跳过写盘,而重启读回来还是关」这种自相矛盾的状态。
pub(crate) fn boot_anim_from_store(raw: Option<&str>) -> bool {
matches!(raw.map(str::trim), Some("1"))
}
/// 「想写的这两个值,盘上已经就是它们了吗」—— 决定要不要真的写盘。
///
/// 抽出来是因为这条判断错了**不会报错**:要么白写一次(每次启动把用户手工编辑
/// 过的 store.json 重排一遍,他写的注释和顺序全没了),要么该写的时候不写
/// (用户改完设置、重启后回到默认,而界面上一切正常)。
///
/// 两处"相等"的口径要写死,它们都是会静默出错的地方:
/// * 盘上是**空串** = 没选,与"没有这个键"同义(清除时留下的就是它);
/// * 声音的键**不在**时等于默认的"开",不必特意补一个 `"1"` 上去。
pub(crate) fn boot_switches_unchanged(
want_video: Option<&str>,
want_sound: &str,
stored_video: Option<&str>,
stored_sound: Option<&str>,
) -> bool {
let video_same = match (want_video, stored_video) {
(Some(w), Some(s)) => w == s,
(None, None) => true,
(None, Some(s)) => s.trim().is_empty(),
(Some(_), None) => false,
};
let stored_sound = stored_sound.map(str::trim).unwrap_or("1");
video_same && stored_sound == want_sound
}
/// 「`boot_anim` 这一格,盘上已经就是想要的值了吗」—— 决定要不要写盘。
///
/// 和 [`boot_switches_unchanged`] 同样的口径问题,所以**读和写共用
/// [`boot_anim_from_store`]**:缺失 = 默认关,手改的 `" 1 "` = 开。
/// 口径写成"字符串逐个相等"的话,`" 1 "` 会被判成"已经一样"而跳过写盘,
/// 可启动那边读出来是关 —— 用户手改过的文件就这么把他自己坑了。
pub(crate) fn boot_anim_unchanged(want: bool, stored: Option<&str>) -> bool {
boot_anim_from_store(stored) == want
}
/// 用 ffmpeg 把一个 MP4 抽成启动动画要用的帧(**在后台 Job 里跑**)。
///
/// 做法:一条 ffmpeg 命令把整段视频按固定帧率、限宽缩放到临时目录里的一串 PNG,
/// 再逐张解码。**不用「每个时间点各起一次 `-ss` 抽帧」**—— 那要起 N 个进程、
/// N 次解文件头,60 帧就是 60 次进程创建,启动那几秒里白白多花十几秒。
///
/// 失败一律**如实报到界面上**(文件不存在 / ffmpeg 没装 / ffmpeg 报了错):
/// 静默回退成内置动画的话,用户只会以为"我选的视频没生效",而真正的原因
/// (缺 ffmpeg、文件损坏)一个字都不会出现。
fn extract_boot_frames(paths: &Paths, src: &Path, log: &Log) -> Result<BootFrames, String> {
if !src.is_file() {
return Err(format!("文件不存在: {}", src.display()));
}
let ff = nfarms_core::ffmpeg::Ffmpeg::detect(paths);
let Some(exe) = ff.exe.clone() else {
// 如实说是 ffmpeg 的事,不假装"视频坏了" —— 这两句话指向的下一步完全不同。
return Err(format!(
"没找到 ffmpeg,抽不了帧(应放到 {})。请到 Tools › ffmpeg 那一页看状态",
ff.dir.display()
));
};
// 每一轮都从**空目录**开始:上一次抽了一半留下的 PNG 会被这一轮的枚举读到,
// 于是帧数比实际多、偏移和画面错位。目录名里带 pid,两个实例不会互相踩。
let dir = std::env::temp_dir().join(format!("nfarms-boot-frames-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).map_err(|e| format!("建临时目录失败: {e}"))?;
// `min(960,iw)`:只缩不放(小片子放大白费内存);`h=-2` 让高度按原比例取偶数。
// 逗号在 ffmpeg 的滤镜语法里是分隔符,必须转义成 `\,`。
let vf = format!(
"fps={},scale=w=min({}\\,iw):h=-2",
BOOT_VIDEO_FPS, BOOT_VIDEO_MAX_WIDTH
);
let pattern = dir.join("f-%04d.png");
let r = nfarms_core::ffmpeg::run(
&exe,
&[
"-y".into(),
"-hide_banner".into(),
"-loglevel".into(),
"warning".into(),
"-i".into(),
src.display().to_string(),
"-vf".into(),
vf,
// 封顶:解到第 60 帧 ffmpeg 就停,长视频不会把整个文件读完
"-frames:v".into(),
BOOT_VIDEO_MAX_FRAMES.to_string(),
pattern.display().to_string(),
],
BOOT_EXTRACT_TIMEOUT_MS,
);
// 按文件名排序收齐(ffmpeg 是 `%04d` 顺序写出来的,字典序 = 时间序)
let mut files: Vec<PathBuf> = std::fs::read_dir(&dir)
.map(|it| {
it.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("png"))
.collect()
})
.unwrap_or_default();
files.sort();
if files.is_empty() {
let _ = std::fs::remove_dir_all(&dir);
// 一条画面都没出来:ffmpeg 的 stderr 才是"为什么",必须带上
let tail: String = r.err.trim().chars().take(300).collect();
return Err(if tail.is_empty() {
format!("ffmpeg 没能从这段视频里抽出一帧(退出码 {})", r.code)
} else {
format!("ffmpeg 抽帧失败(退出码 {}):{tail}", r.code)
});
}
// 偏移按"第几帧 × 一帧的间隔"算:ffmpeg 的 `fps` 滤镜保证输出帧是等间隔的。
let step = (1000 / BOOT_VIDEO_FPS.max(1)) as u64;
let mut frames = Vec::with_capacity(files.len());
for (i, p) in files.iter().enumerate() {
match background::decode_for_viewer(p) {
Ok((img, _)) => frames.push((i as u64 * step, img)),
Err(e) => {
// 中间坏一张就整段失败:留着带洞的帧表,播到那一处时间会突然往前跳。
let _ = std::fs::remove_dir_all(&dir);
return Err(format!("第 {} 帧读不回来: {e}", i + 1));
}
}
}
let _ = std::fs::remove_dir_all(&dir);
// 视频比上限长就说清楚"只取了前几秒" —— 用户看着自己 30 秒的片子被截断,
// 不写这一句他会以为是程序坏了。
//
// 判据要留**一帧的余量**:容器的时长比"帧数 × 帧率"多出小半帧是常态
// (1.5 秒 / 25 fps 的片子,`duration` 常是 1.52 秒),按 `dur > played_ms`
// 判会对着一个 1.5 秒的片子说"只放了前 1.5 秒,原片 1.5 秒"—— 用户只会更糊涂。
let step_ms = step as i64;
let played_ms = frames.len() as i64 * step_ms;
let dur = ff.duration_ms(src);
let mut note = format!(
"{BOOT_MSG_PREFIX} 从 {} 抽出 {} 帧",
src.display(),
frames.len()
);
if dur > played_ms + step_ms {
note.push_str(&format!(
",只放了前 {:.1} 秒(原片 {:.1} 秒;启动动画最长 {} 秒)",
played_ms as f64 / 1000.0,
dur as f64 / 1000.0,
(BOOT_VIDEO_MAX_FRAMES as i64 * step_ms) / 1000
));
}
log.write("boot", ¬e);
Ok(BootFrames { frames, note })
}
// ===========================================================================
// 观测服务(VictoriaMetrics / Loki / Grafana)
// ===========================================================================
//
// 这一节是纯函数和常量,状态在 `MiscState` 的 `obs_` 那几个字段上,
// 状态机在 `AppState::poll_observability` / `AppState::obs_start`。
/// token 用量推给 VictoriaMetrics 的最小间隔(毫秒)。
///
/// 30 秒一档:比 Grafana 默认的最小抓取间隔(15 秒)还粗,一屏曲线的点数够用 ——
/// 再密只是把同一份累计值反复写进 TSDB,磁盘涨得比信息快。
const OBS_PUSH_INTERVAL_MS: u64 = 30_000;
/// 三件服务的下标顺序。三个状态灯、`obs_proc` / `obs_state` / `obs_ports`
/// 都按它排 —— 换顺序等于把页面上那三个灯的含义换掉。
const OBS_SERVICES: [observability::Service; 3] = [
observability::Service::Metrics,
observability::Service::Logs,
observability::Service::Dashboard,
];
/// 一件服务的名字。
///
/// 和 `observability::spec().name` / `missing()` 返回的是**同一组字符串**
/// (那边用的是组件自己的拼法:界面上的字要和它们的日志、文档对得上)。
/// 这里再写一遍是因为日志里必须写名字,而 `obs_job` 的结果里只有一个下标。
fn obs_name(service: observability::Service) -> &'static str {
match service {
observability::Service::Metrics => "VictoriaMetrics",
observability::Service::Logs => "Loki",
observability::Service::Dashboard => "Grafana",
}
}
/// 到没到该把 token 用量推给 VictoriaMetrics 的时刻。
///
/// 抽成纯函数:节流是这条链路上唯一「不报错但会做错」的地方 ——
/// 把 `>=` 写成 `>` 会漏掉正好 30 秒那一帧(表现成"有时 30 秒、有时 60 秒推一次"),
/// 把毫秒当秒写就是每 30 毫秒推一次(磁盘很快写满)。两种写法都不会有任何报错。
/// 传两个 u64 毫秒进来,测试不必真等 30 秒,也不必去动系统时间。
///
/// `last_ms == 0`(还没推过)必然为真:程序一起来先送一份基线 ——
/// 否则 VM 里开头那 30 秒的曲线是断的。
pub(crate) fn obs_push_due(now_ms: u64, last_ms: u64) -> bool {
// `saturating_sub` 兜住时钟回拨:回拨后 `now < last`,差值按 0 算 = 不推,
// 而不是让 u64 回绕出一个天文数字、把节流直接判成到期。
now_ms.saturating_sub(last_ms) >= OBS_PUSH_INTERVAL_MS
}
/// 拼一行 token 累计用量,形状是 Prometheus 的文本格式:
/// `nfarms_tokens_total{kind="prompt"} 1234`。
///
/// 标签值**必须带双引号**:`kind=prompt` 这种裸写法 VM 的 import 接口直接 400,
/// 而它只回一句"格式不对" —— 从这句话里看不出是少了引号。
pub(crate) fn token_metric_line(kind: &str, value: u64) -> String {
format!("nfarms_tokens_total{{kind=\"{kind}\"}} {value}")
}
// ===========================================================================
// 系统指标(CPU / 内存 / 磁盘)
//
// **为什么由应用自己推,而不是方案文档里的 HardwareExporterWindows + Alloy**:
// 这套东西本来就跑在**同一台机器**上,而 `nfarms_core::cyinfo` 已经在采这些值了。
// 再引一个采集器,等于为了同一份数据多一个进程、多一份配置、多一个会挂的地方 ——
// 而且两份数据迟早会不一致(各有各的采集口径、各有各的失败方式,对不上时
// 还说不清该信哪一份)。所以:同一个进程里,把 `cyinfo` 已经采到的结构化字段
// 直接推上去。
//
// **只认结构化字段,绝不解析 `cyinfo::render` 出来的报告文本** ——
// 那是把结构化数据又拼成字符串、再拆回来,凭空多一层会漂的解析。
// ===========================================================================
/// 系统读数**重新采一次**的间隔(毫秒),比推送间隔(30 秒)长得多。
///
/// 为什么不跟着推送每 30 秒采:**`cyinfo::collect` 是重活**。它要跑 8 个工具的
/// 版本探测(11~12 个子进程;本机实测一次约 1.4 秒)。为了读 4 个数每 30 秒
/// 起一遍,一小时就是一千多个进程 —— 这笔账不值。
///
/// 而且这几个读数里真正会变的只有**磁盘可用空间**(内存总量、核心数都是常量),
/// 磁盘被填满是个以小时计的过程:5 分钟一次照样画得出趋势 ——
/// 图上一个 5 分钟宽的台阶,换掉的是一小时一千多个短命进程。
const OBS_SYS_REFRESH_MS: u64 = 5 * 60 * 1000;
/// 到没到该**重新采**系统读数的时刻。
///
/// 和 [`obs_push_due`] 同一套口径(`>=`、`saturating_sub` 兜住时钟回拨):
/// 写成 `>` 会漏掉正好到点那一帧,表现成"有时 5 分钟、有时 10 分钟";
/// 时钟回拨若让 u64 回绕,就会变成每帧重采一遍(一小时上万个子进程)。
pub(crate) fn sys_readings_due(now_ms: u64, last_ms: u64) -> bool {
now_ms.saturating_sub(last_ms) >= OBS_SYS_REFRESH_MS
}
/// Prometheus 标签值转义:`\` → `\\`、`"` → `\"`、换行 → `\n`。
///
/// 计算机名是**用户能改的**:里面出现一个双引号,`host="…"` 就会提前收尾,
/// 整行变成语法错 —— VM 的 import 接口会把**这一行样本丢掉**只回一句 400
/// "格式不对"。从这句话里看不出是主机名带了引号,所以必须在拼行时就转义。
pub(crate) fn escape_label_value(v: &str) -> String {
let mut out = String::with_capacity(v.len());
for c in v.chars() {
match c {
'\\' => out.push_str(r"\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
other => out.push(other),
}
}
out
}
/// 一条 gauge 行:`nfarms_x{host="PC"} 123`。
///
/// 数值**一律把单位写进名字**(`_bytes` / `_percent` / `_cores`):这是 Prometheus
/// 的惯例,Grafana 也据此自动选对单位、自动缩放。推一个光秃秃的数字出去,
/// 看板上就得靠人记住"这个 34359738368 是字节",记错一次就得出一个荒谬的结论。
pub(crate) fn gauge_line(name: &str, value: u64, host: &str) -> String {
format!("{name}{{host=\"{}\"}} {value}", escape_label_value(host))
}
/// 把一份机器画像里的系统读数拼成 Prometheus 行(**纯函数**:不采集、不联网)。
///
/// **读不到就不推,而不是推 0**:`cyinfo` 里 0 的含义就是"没读到"
/// (采集不出来的平台上 `ram_bytes` / `disk_total` 就是 0,它自己的报告注释也这么写)。
/// 推个 0 出去,图上画出来是"内存 0 字节"这种一眼假还真能被当真的曲线 ——
/// 缺一条曲线比一条错曲线好。
///
/// 带 `host=` 标签:`cyinfo` 报告本身就是**按机器归档**的(文件名带计算机名),
/// 多个人的 HUB 往同一个 VM 推时,没有这个标签各机器的曲线会混成一条。
/// (token 那条链路保持原样不加标签,免得动到已经在用的图表。)
pub(crate) fn system_metric_lines(info: &MachineInfo) -> Vec<String> {
let host = info.hostname.as_str();
let mut out = Vec::new();
if info.cores > 0 {
// 唯一能从 `cyinfo` 拿到的 CPU 侧数据:它**没有** CPU 使用率字段
// (见 `cyinfo::memory_status` 里那句注释 —— Win32 的 `memory_load`
// 采了但没往外传)。CPU 利用率要两个采样点相减,是另一件事,不在这里编。
out.push(gauge_line("nfarms_cpu_cores", info.cores as u64, host));
}
if info.ram_bytes > 0 {
out.push(gauge_line("nfarms_mem_total_bytes", info.ram_bytes, host));
}
if info.disk_total > 0 {
out.push(gauge_line("nfarms_disk_total_bytes", info.disk_total, host));
// 已用 = 总量 − 可用。两个字段来自**同一次** `GetDiskFreeSpaceExW`,
// 理论上 free ≤ total;真碰上反过来的脏数据只能用 `saturating_sub` 收成 0 ——
// 直接相减会让 u64 回绕成一个天文数字,图上顶到天花板,比 0 更难查。
let used = info.disk_total.saturating_sub(info.disk_free);
out.push(gauge_line("nfarms_disk_used_bytes", used, host));
}
out
}
/// 最近一次采到的系统读数:`(采的时刻, 已经拼好的行)`。
///
/// 和 [`OBS_PUSH_ERR`] 一样放模块级 static:推送跑在**后台线程**里,
/// 而这一份必须跨帧、跨线程活着。
///
/// 存**拼好的行**而不是 `MachineInfo`:`MachineInfo` 里还挂着显卡/模型/工具
/// 那些采一次就过期的字段,存下来只会让后来的人以为它们也是"当前值"。
static OBS_SYS_READINGS: Mutex<Option<(u64, Vec<String>)>> = Mutex::new(None);
/// 取这一轮要推的系统指标行,到点就(**在本线程**)重新采一次。
///
/// **必须在后台线程调用**:`cyinfo::collect` 会起 11~12 个子进程、本机实测约 1.4 秒。
/// 搁在 UI 线程上就是窗口卡一秒多(本文件头一条原则:UI 线程不做等待)。
///
/// 锁中毒(有线程拿着它 panic 了)时退回"现采一次、不写缓存":宁可多花 1.4 秒,
/// 也不能因为一个锁就把系统指标整条链路停掉。
fn system_metric_lines_now(paths: &Paths, now_ms: u64) -> Vec<String> {
if let Ok(slot) = OBS_SYS_READINGS.lock()
&& let Some((at, lines)) = slot.as_ref()
&& !sys_readings_due(now_ms, *at)
{
return lines.clone();
}
let lines = system_metric_lines(&nfarms_core::cyinfo::collect(paths));
if let Ok(mut slot) = OBS_SYS_READINGS.lock() {
*slot = Some((now_ms, lines.clone()));
}
lines
}
/// 三件观测服务的端口初值:从 store.json 读(缺省 9091 / 9092 / 9093)。
///
/// 走 store 存取器而不是写死字面量:端口用户能改,而 Grafana 的数据源文件
/// (`provision_datasources`)由 core 按**同一组端口**生成 —— 两处各写一份默认值,
/// 改一处忘一处就会变成"健康检查打在 A、数据源指着 B",症状是 Grafana 起来了
/// 但图表全空。
pub(crate) fn obs_ports_from_store(store: &nfarms_core::store::Store) -> [String; 3] {
[
store.vm_port().to_string(),
store.loki_port().to_string(),
store.grafana_port().to_string(),
]
}
/// 界面上的端口文本 → 数字;解析不了就退回该服务的默认端口。
///
/// 端口在界面上是**文本框**,清空一次就得到 `""`。这时若退回 0,服务会去监听
/// 一个随机端口,而健康检查还打在 `127.0.0.1:0` 上 —— 表现成"每次都超时",
/// 从这句话里看不出是输入框空了。
pub(crate) fn obs_ports_u16(raw: &[String; 3]) -> [u16; 3] {
std::array::from_fn(|i| {
raw[i]
.trim()
.parse::<u16>()
.unwrap_or_else(|_| observability::default_port(OBS_SERVICES[i]))
})
}
/// 某个端口键在盘上**存过、而且能解析成 u16**吗。
///
/// 判据和 `LocalModel::load_ports` 同源(键在 + 值合法)。只看键在的话,
/// 盘上写了个 `"abc"` 也会被当成"读回来了"。
///
/// `cfg(windows)`:这三个函数只服务于 `LocalModel`(它本身只在 Windows 下编),
/// 安卓上留着就是三个没人调的函数 = 三条 dead_code 警告。
#[cfg(windows)]
fn stored_port(store: &nfarms_core::store::Store, key: &str) -> bool {
store
.get_str(key)
.is_some_and(|v| v.trim().parse::<u16>().is_ok())
}
/// 本地模型(llama.cpp / sd.cpp)的端口初值:**以 store 存取器为准**
/// (缺省 9094 / 9095,旧的 8080 / 1234 已经迁移掉)。
///
/// 为什么还要这一步:`LocalModel::load_ports` 只在盘上**存过**时才覆盖,
/// 没存过时留下的是构造时那份初值 —— 于是"缺省端口"有两份定义,而 DSH 侧
/// 配 provider baseURL 跟着走的是 store 那份。两边不一致的症状是一句
/// connection refused,从这句话里看不出是端口默认值在打架。
///
/// 入参是 `load_ports` 之后的那两个值,出参是同类型的两个字符串
/// (那边是文本框,不能顺手换成 u16 再换回来)。盘上存过就**原样用** ——
/// 迁移不替用户改端口。
#[cfg(windows)]
pub(crate) fn model_ports_from_store(
store: &nfarms_core::store::Store,
llama: &str,
sd: &str,
) -> (String, String) {
let pick = |key: &str, loaded: &str, default: u16| {
if stored_port(store, key) {
loaded.to_string()
} else {
default.to_string()
}
};
(
pick(
nfarms_core::store::KEY_LLAMA_PORT,
llama,
store.llama_port(),
),
pick(nfarms_core::store::KEY_SD_PORT, sd, store.sd_port()),
)
}
/// 盘上**真的**给过本地模型的端口吗 —— 只用来决定要不要记"端口已从
/// store.json 读回"那一行日志。
///
/// 不能拿"读回来的值和 store 存取器的当前值不同"当判据:盘上有值时,
/// 存取器返回的就是同一个数,拿它去比**永远相等**,那行日志会一次都不打 ——
/// 而它正是"端口记忆到底生效没有"唯一可查的凭据(端口是纯内存状态,
/// 不看日志没法确认读回来的是哪个值)。判据和 [`model_ports_from_store`]
/// 共用 `stored_port`,两处不会漂。
#[cfg(windows)]
pub(crate) fn model_ports_stored(store: &nfarms_core::store::Store) -> bool {
[nfarms_core::store::KEY_LLAMA_PORT, nfarms_core::store::KEY_SD_PORT]
.iter()
.any(|k| stored_port(store, k))
}
/// 上一次已经写进日志的推送错误。
///
/// 放在模块级 static 而不是 `MiscState` 字段上:推送跑在**后台线程**里
/// (理由见调用点),而这条状态只服务于"同一个错误别反复写日志"这一件事 ——
/// 不值得为它往那个给页面看的结构体上加一个字段。
static OBS_PUSH_ERR: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
/// 记下这次的推送错误;返回 `true` 表示**这个错误还没记过**,调用方该写日志。
///
/// "失败记一条、不刷屏"不是洁癖:VM 不可用时每 30 秒失败一次,每次都写就把日志里
/// 真正有用的那几行顶掉了,而它们说的其实是同一件事。错误内容变了才再记一条。
fn remember_push_error(err: Option<&str>) -> bool {
let Ok(mut slot) = OBS_PUSH_ERR.lock() else {
// 锁中毒(有线程拿着它 panic 了)。这时宁可多记一条,也不能把错误吞掉:
// 推送失败本来就难查,连日志都没有就彻底没线索了。
return err.is_some();
};
let now = err.map(str::to_string);
if *slot == now {
return false;
}
*slot = now;
true
}
/// 推**一行**指标;失败时按"同一条错误只记一条日志"记一笔,返回 `false` 让调用方收手。
///
/// 抽出来是为了让"失败只记一条"这件事只有**一个**出口:两个 token 值、四条系统
/// 指标全走这里,于是 30 秒的推送周期里最多只可能写一条失败日志。
fn push_line(port: u16, what: &str, line: &str, log: &Log) -> bool {
match observability::push_metric(port, line) {
Ok(()) => true,
Err(e) => {
if remember_push_error(Some(&e)) {
log.write("observe", &format!("{what}推送失败:{e}"));
}
false
}
}
}
/// 把 token 用量 + 系统指标推给 VictoriaMetrics(**在后台线程上跑**,见调用点)。
///
/// 一行一次请求:core 的 `push_metric` 就是"一行一个样本",多行要多次调用
/// (它自己的文档注释里写明了)。
///
/// 失败处理沿用原来那套,**两个性质都保留**:
/// * **同一条错误只记一条日志** —— VM 不可用时每 30 秒失败一次,每次都写就把日志里
/// 真正有用的那几行顶掉了,而它们说的其实是同一件事;
/// * **第一次失败就收手** —— 后面的请求打的是同一个端口,第一次连不上,
/// 后面的必然一样,再打只是让"失败"这件事慢一倍。
fn push_metrics(port: u16, paths: &Paths, prompt: u64, completion: u64, now_ms: u64, log: &Log) {
// token 排在最前面:这两条已经在用了,顺序与原来完全一致。
// 它们推不上去时**直接收手**,连系统读数那一遍采集都不做 —— 反正也推不出去。
for (kind, value) in [("prompt", prompt), ("completion", completion)] {
if !push_line(port, "token 用量", &token_metric_line(kind, value), log) {
return;
}
}
// token 推上去了,再取系统指标(到点才会真采一次,理由见 `system_metric_lines_now`)
for line in system_metric_lines_now(paths, now_ms) {
if !push_line(port, "系统指标", &line, log) {
return;
}
}
// 一路都成功就把上次的错误清掉,下次真坏了才会重新记一条
remember_push_error(None);
}
pub(crate) struct AppState {
pub(crate) paths: Paths,
pub(crate) log: Log,
pub(crate) settings: Settings,
pub(crate) settings_path: PathBuf,
pub(crate) weather_location: String,
pub(crate) weather: pages::WeatherState,
pub(crate) weather_rx: Option<Receiver<Result<weather::WeatherReport, String>>>,
/// Harness 页里的本地大模型(llama.cpp / stable-diffusion.cpp / Qwen-Image)。
/// 只存在于 Windows —— 它要靠子进程拉桌面版的可执行文件。
#[cfg(windows)]
pub(crate) local_model: local_model::LocalModel,
/// 自定义背景图(对应 C# 设置里的 `BackgroundImage`)
pub(crate) background: background::Background,
/// 侧栏 logo(nfarms.ico)
pub(crate) logo: logo::Logo,
/// 「选择图片」对话框(后台线程 + channel,UI 不卡)
pub(crate) picker: background::FilePicker,
/// 设置页的草稿:改完点「保存」才写盘,和原版行为一致
pub(crate) draft_location: String,
/// 最近一次保存的时间戳(界面上给个「已保存」的反馈)
pub(crate) last_saved: Option<String>,
/// 内核事件列表 —— 插件页把它画出来。监听器会一直活着,
/// 所以用 `Arc<Mutex>` 而不是直接塞在结构体里。
pub(crate) event_log: Arc<Mutex<Vec<String>>>,
/// 拓扑验证器(Topo 2FA 页)
pub(crate) topo: TopoState,
/// 环境工具检测(Environment 页):后台跑 8 种工具的版本检测
pub(crate) tools: Job<Vec<nfarms_core::tools::ToolInfo>>,
/// Environment 页:九大类全景探测(Qt/.NET/C/Rust/Python/JDK/Node/Git/Android)。
///
/// 和上面 `tools` 分开是**刻意的**:`tools` 喂的是 `--versions` / `--npm-apps`
/// 两条 oracle 验收项,往里加组件会直接打破验收;这一份是纯增量的。
pub(crate) env_probe: Job<nfarms_core::envprobe::Report>,
pub(crate) env_probe_auto: bool,
/// 包管理器探测结果(winget/choco/scoop)—— 只探一次,不能每帧跑 `where`
pub(crate) env_mgrs: Vec<nfarms_core::envprobe::PkgMgr>,
pub(crate) env_mgrs_probed: bool,
/// 选中的包管理器下标
pub(crate) env_mgr: usize,
/// 安装 / 更新动作的输出(后台跑,可能要几分钟)
pub(crate) env_action: Job<String>,
pub(crate) env_action_log: String,
/// 环境变量区是否展开(43 条,默认收起)
/// Environment 页:当前展开的可折叠分组。None = 全部收起。
/// 九类各自成为一个分组后全展开会很长,默认全收起才扫得动。
pub(crate) env_open: Option<&'static str>,
/// 本次探测的起始时刻 —— 用来显示**真实已用时长**。
///
/// 原来界面写死「约 15 秒」,实测要 45 秒。**一个错误的预期比没有预期更糟**:
/// 用户会以为卡死了,然后去点「检测全部」,又开一次。
pub(crate) env_started: Option<std::time::Instant>,
pub(crate) env_msg: Option<String>,
/// 「首次打开自动跑一遍」的标记。
///
/// 原版 C# 的 Environment / Topo 页在**构造时**就做检测,所以用户点进去
/// 已经有结果。这里保持同样的手感:第一次进页面自动发起,之后由用户
/// 点按钮重跑 —— 不做成每帧都发起,那会把机器打满。
pub(crate) topo_auto: bool,
/// 零信任客户端状态(Channel 页)
pub(crate) channel: Job<ChannelSnapshot>,
/// 图片阅读器(Player 组)
pub(crate) viewer: viewer::ImageViewer,
/// ffmpeg(Tools 组;同时是视频播放器与拉流页的地基)
pub(crate) ffmpeg: FfmpegState,
/// Packet capture(VPN 组)
pub(crate) capture: CaptureState,
/// SecureCRT(VPN 组)
pub(crate) remote: RemoteState,
/// 音乐播放器 / 文本阅读器(Player 组)
pub(crate) media: MediaState,
/// 小 JSON 键值存储(书签等)
pub(crate) store: nfarms_core::store::Store,
/// Browser › 书签页的输入框
pub(crate) bookmark_url: String,
pub(crate) bookmark_title: String,
/// 对时 / 引擎探测 / 脚本清单(Tools 与 Browser 组)
pub(crate) probe: ProbeState,
// 这里原来有个 `grafana_url: String`(Browser › grafana 的手填面板地址)。
// Grafana 页重写后**地址一律由端口推导**(http://127.0.0.1:<grafana_port>/),
// 手填地址和端口两个真值会打架 —— 所以字段删掉,不是留着不用。
/// Environment › DSH-Starter
pub(crate) dsh: DshState,
/// 桌宠:**独立的 Win32 分层窗口**(见 `pet_window.rs`)。
/// 不再是 egui 视口 —— 立即视口没有自己的 GL 表面,做不了真透明。
#[cfg(windows)]
pub(crate) pet: Option<crate::pet_window::PetWindow>,
/// 上一次同步给桌宠的可见状态。只在**变化时**发指令,不每帧都发。
#[cfg(windows)]
pub(crate) pet_shown: bool,
/// Tools › Recording-BiliGO(原来和 Player › 拉流共用同一个 bililive-go 实例;
/// 拉流那一页在新菜单里删掉了)
pub(crate) live_go: LiveState,
/// Tools › Script / Player › 视频 / Setting 几页 / Slack off › Pet
pub(crate) misc: MiscState,
/// Setting › 日志路径 里的日志查看器(带分页)
pub(crate) log_view: LogViewer,
}
impl AppState {
// 参数多是有意的:这是**一次性构造**,把 9 个字段拆成 builder 或子结构
// 只会让调用点更长、更难看出到底传了什么。真正该警惕的是"每次调用都在变"的
// 长参数列表,这里不是。
#[allow(clippy::too_many_arguments)]
fn new(
paths: Paths,
log: Log,
settings_path: PathBuf,
settings: Settings,
background: background::Background,
logo: logo::Logo,
event_log: Arc<Mutex<Vec<String>>>,
store: nfarms_core::store::Store,
#[cfg(windows)] local_model: local_model::LocalModel,
) -> Self {
// **端口真值只有一个:`dsh.port`。** 初始值取自持久化的 `settings.dsh_port` ——
// 原来写成 `DshState::default()`,于是 dsh 永远起在常量 7909 上,而
// settings.json 里那个 `dsh_port` 只有「保存」会写、**没有任何代码读它去
// 启动 dsh** —— 也就是说那个设置项从来没生效过。
// 桌宠:**独立的 Win32 分层窗口**。真透明只有这条路 ——
// 立即视口是渲染进父窗口那一帧的,没有自己的 GL 表面,
// `with_transparent` 会被忽略(实测:只得到不透明黑块)。
// 图缺失不会 panic,只是没有桌宠。
#[cfg(windows)]
let pet = Some(crate::pet_window::PetWindow::spawn(
paths.lib("pet").join("whale-girl.png"),
));
let saved_dsh_port = settings.dsh_port;
// 端口记忆:把上次用的端口从 store.json 读回来(见 LocalModel::load_ports)
#[cfg(windows)]
let local_model = {
let mut m = local_model;
m.load_ports(&store);
// 上下文长度与端口是同一类东西:不读回来的话服务端回到默认值,
// 而 DSH 的 contextWindow 还留着上次的数 —— 两边错位。
m.load_ctx(&store);
// ---- 端口缺省值以 store 为准(9094 / 9095),不再写死 8080 / 1234 ----
//
// `load_ports` 只认"盘上有值",没值时留下的是 `LocalModel::new` 里
// 那份构造初值(8080 / 1234)。于是"缺省端口"有两份定义,而 DSH 侧
// 配 baseURL 走的是 store 那份 —— 两边不一致的症状是一句
// connection refused,看不出是端口默认值在打架(详见 model_ports_from_store)。
let (llama_port, sd_port) = model_ports_from_store(&store, &m.llama_port, &m.sd_port);
// 只在**真的用上了记忆**时记一行 —— 判据是"盘上给过端口"
// (见 model_ports_stored:拿值和存取器比是永远相等的,那样这行日志
// 一次都不会打)。这行既是给用户的提示,也是端口记忆唯一可查的凭据。
if model_ports_stored(&store) {
log.write(
"gui",
&format!(
"端口已从 store.json 读回: llama.cpp={llama_port} sd.cpp={sd_port}"
),
);
}
m.llama_port = llama_port;
m.sd_port = sd_port;
if m.llama_ctx != nfarms_core::libs::DEFAULT_LLAMA_CTX.to_string() {
log.write(
"gui",
&format!(
"llama.cpp 上下文长度已从 store.json 读回:-c {} —— \
DSH 侧 llama-cpp-local 的 contextWindow 必须是同一个数",
m.llama_ctx
),
);
}
m
};
// 观测服务的三件端口初值:从 store.json 读(缺省 9091 / 9092 / 9093)。
// 必须在结构体字面量**之前**算好 —— `store` 会在字面量里被 move 进
// `AppState`(同 `saved_dsh_port` 的理由)。
let obs_ports = obs_ports_from_store(&store);
Self {
draft_location: settings.weather_location.clone(),
weather_location: settings.weather_location.clone(),
paths,
log,
settings,
settings_path,
weather: pages::WeatherState::Loading,
weather_rx: None,
#[cfg(windows)]
local_model,
background,
logo,
picker: background::FilePicker::default(),
last_saved: None,
event_log,
topo: TopoState::new(),
tools: Job::default(),
env_probe: Job::default(),
env_probe_auto: false,
env_mgrs: Vec::new(),
env_mgrs_probed: false,
env_mgr: 0,
env_action: Job::default(),
env_action_log: String::new(),
env_open: None,
env_started: None,
env_msg: None,
topo_auto: false,
channel: Job::default(),
viewer: viewer::ImageViewer::default(),
ffmpeg: FfmpegState::default(),
capture: CaptureState::default(),
remote: RemoteState::default(),
media: MediaState::default(),
store,
bookmark_url: String::new(),
bookmark_title: String::new(),
probe: ProbeState::default(),
// (grafana_url 已删 —— 地址由端口推导,见上面那段说明)
// 端口真值取自持久化设置(见本函数开头 saved_dsh_port 的说明)
#[cfg(windows)]
pet,
#[cfg(windows)]
pet_shown: false,
dsh: DshState {
port: saved_dsh_port,
..Default::default()
},
live_go: LiveState::default(),
// 观测服务的端口初值取自 store(见上面 `obs_ports` 那段说明);
// 其余字段是页面自己的开关,和端口无关,用默认值即可
misc: MiscState {
obs_ports,
..MiscState::default()
},
log_view: LogViewer::default(),
}
}
/// 轮询各页的后台任务,完成时写一条日志。
pub(crate) fn poll_jobs(&mut self, ctx: &egui::Context) {
if let Some(line) = self.topo.poll() {
self.log.write("topo", &line);
}
if let Some(line) = self.viewer.poll(ctx) {
self.log.write("viewer", &line);
}
// 阅读器里点「设为背景」:走和拖放同一条路径(异步解码 + 成功后写回设置)
if let Some(viewer::ViewerAction::SetAsBackground(p)) = self.viewer.take_action() {
self.apply_background(ctx, &p);
}
// ffmpeg 探测
if self.ffmpeg.detect_job.poll() {
let found = self.ffmpeg.detect_job.result().cloned();
if let Some(ff) = found {
self.log.write("ffmpeg", &ff.status_text());
self.ffmpeg.detected = Some(ff);
}
}
// 转码 / 截图 / 拉流
if self.ffmpeg.work.poll()
&& let Some(msg) = self.ffmpeg.work.result().cloned()
{
self.log.write("ffmpeg", &msg);
self.ffmpeg.message = Some(msg);
}
// 抓包网卡枚举
if self.capture.iface_job.poll()
&& let Some(Ok(list)) = self.capture.iface_job.result()
{
self.log
.write("capture", &format!("找到 {} 块网卡", list.len()));
}
// 两个远程工具的会话列表
if self.remote.crt_sessions.poll()
&& let Some(list) = self.remote.crt_sessions.result()
{
self.log
.write("securecrt", &format!("读到 {} 个会话", list.len()));
}
// 引擎探测
if self.probe.engines.poll()
&& let Some(list) = self.probe.engines.result()
{
let ok = list.iter().filter(|e| e.available()).count();
self.log
.write("browser", &format!("探测到 {ok}/{} 个可用引擎", list.len()));
}
// dsh:进程没了就把状态清掉;顺手从日志里抓一次带 token 的根地址
if let Some(c) = self.dsh.child.as_mut() {
match c.try_wait() {
Ok(Some(code)) => {
self.log.write("dsh", &format!("dsh 退出(代码 {code})"));
self.dsh.child = None;
}
_ => {
if self.dsh.url.is_none() {
let log = nfarms_core::dsh::log_path(&self.paths);
let text = nfarms_core::dsh::tail(&log, 80);
if let Some(u) = nfarms_core::dsh::parse_root_url(&text) {
self.log.write("dsh", &format!("根地址(带 token): {u}"));
self.dsh.url = Some(u);
}
}
}
}
}
// bililive-go:同上
if let Some(c) = self.live_go.child.as_mut() {
if let Ok(Some(code)) = c.try_wait() {
self.log
.write("live", &format!("bililive-go 退出(代码 {code})"));
self.live_go.child = None;
self.live_go.status = None;
}
}
if self.tools.poll() {
let n = self
.tools
.result()
.map(|v| v.iter().filter(|t| !t.installed.is_empty()).count())
.unwrap_or(0);
let total = self.tools.result().map(|v| v.len()).unwrap_or(0);
self.log
.write("tools", &format!("环境检测完成:{n}/{total} 个工具可用"));
}
if self.channel.poll() {
let msg = match self.channel.result() {
Some(s) => format!(
"客户端状态:路径={} 运行中={}",
if s.client_path.is_empty() { "(未找到)" } else { &s.client_path },
nfarms_core::csharp_bool(s.running)
),
None => "客户端状态查询失败".to_string(),
};
self.log.write("ztna", &msg);
}
// 启动动图的抽帧(Setting › 启动动图)
self.poll_boot_video(ctx);
}
// ------------------------------------------------------------ 启动动图
/// 抽启动动画的视频帧(**后台 Job**),顺手把这次选择落盘。
///
/// 用户选了 MP4 之后调它;`App::new` 里读回盘上存过的路径时也调一次。
/// 抽帧是重活(起 ffmpeg 解一段视频),搁在界面线程上就是几秒的冻屏。
///
/// **失败不在这里报** —— 结果由 [`Self::poll_boot_video`] 收,那时才拿得到
/// ffmpeg 的 stderr,界面和日志写的也才是同一句话。
///
/// 「选了 MP4」这件事**在这里顺手落盘**(见 [`Self::save_boot_switches`]):
/// 抽帧这个动作本身就意味着这个路径被选中了,调用方(文件选择器那条路由 /
/// 启动时从 store 读回)只要设好 `misc.boot_video` 再调本函数,就不必自己
/// 再碰一次 store —— 少了"忘了存"这个失败模式,它的症状是
/// 「选了视频、重启后回到内置动画」,而界面上看不出任何异常。
pub(crate) fn boot_video_load(&mut self, ctx: &egui::Context) {
let Some(src) = self.misc.boot_video.clone() else {
self.misc.message = Some(format!("{BOOT_MSG_PREFIX} 没选视频,启动时用内置动画"));
return;
};
self.save_boot_switches();
if self.misc.boot_video_job.is_running() {
// `Job::start` 在跑着的时候会直接返回。不说一句的话,用户会以为
// 自己点的那次没反应(上一次的结果其实还在路上)。
self.misc.message = Some(format!("{BOOT_MSG_PREFIX} 上一段视频还在抽帧,请稍等"));
return;
}
// **旧帧先清掉**:留着旧的会让「选了但还没抽完」的判定失效 ——
// 新视频的帧到位之前,界面放的是**上一个视频**的画面。
self.misc.boot_frames.clear();
self.misc.boot_frame_at.clear();
// 记下"这个路径试过了":`render_boot_anim` 里那次自动补抽据此只做一次,
// 否则文件坏了 / ffmpeg 没装的机器上每点一次预览就再起一个 ffmpeg。
self.misc.boot_video_tried = Some(src.clone());
self.misc.message = Some(format!("{BOOT_MSG_PREFIX} 正在抽取画面:{}", src.display()));
let paths = self.paths.clone();
let log = self.log.clone();
self.misc.boot_video_job.start(ctx, move || extract_boot_frames(&paths, &src, &log));
}
/// 「用户选了一个 MP4(或取消了)」的入口:**记住 + 打开总开关 + 立刻落盘 + 后台抽帧**。
///
/// 页面只要把路径交给它,不必自己碰 store —— 抽帧与持久化是一个动作的两半,
/// 分开写迟早一边改了另一边没跟上,症状是「选了视频,重启后没记住」
/// (而三个键的名字归本文件管,见 [`BOOT_VIDEO_KEY`])。
///
/// **选了一个 MP4 就顺手把「启动时播放」顶成开**(判据见
/// [`boot_enabled_after_pick`]):这个动作本身就表达了"启动时要播"。
/// 原先两个开关各自为政,于是有「盘上存住了 `boot_video`、`boot_anim` 却没开」
/// 这个状态:动画不播,用户以为设置没保存,而且**他无法从这个现象反推出原因**
/// —— 界面上只是一个没勾的 checkbox,和每次启动白跑一遍的 ffmpeg。
/// 顶成开后,`boot_video_load` 里的 [`Self::save_boot_switches`] 会把它一起写进盘。
pub(crate) fn boot_video_set(&mut self, path: Option<PathBuf>, ctx: &egui::Context) {
// 「这一个路径算不算选中」在 `path` 被 move 进字段之前先记下来。
let picked = path.is_some();
self.misc.boot_video = path;
// 换了一个文件,"试过没有"要重新算:旧的那次抽帧和这次没关系。
self.misc.boot_video_tried = None;
// 选文件 → 开;清文件 → 保持用户原来的选择(不是关,理由见函数文档)。
self.misc.boot_enabled = boot_enabled_after_pick(picked, self.misc.boot_enabled);
if picked {
// 落盘与抽帧都在 `boot_video_load` 里,这里不再写一遍 store。
self.boot_video_load(ctx);
} else {
// 取消选择:清帧表 + 把键从 store 里删掉。**帧必须一起清** ——
// 留着会造出「有画面、没路径」这种自相矛盾的状态,而播放判据
// (路径在不在)与帧表一旦不同步,排查时看到的现象会互相打架。
self.misc.boot_frames.clear();
self.misc.boot_frame_at.clear();
self.save_boot_switches();
self.misc.message = Some(format!("{BOOT_MSG_PREFIX} 已改回内置动画"));
}
}
/// 「启动动画播不播声音」的开关:**记住 + 立刻落盘**。
///
/// 改了立刻存是有意的:这个开关只在"下次启动"那一刻起作用,用户改完顺手
/// 关掉窗口是很自然的动作,不落盘就等于白改。
pub(crate) fn boot_sound_set(&mut self, on: bool) {
self.misc.boot_sound = on;
self.save_boot_switches();
}
/// 把三个开关(`boot_video` / `boot_sound` / `boot_anim`)写回 store.json 并
/// **立刻落盘**。
///
/// 只改内存不写盘的话,用户改完设置直接关窗口就白改了 —— 而这几个开关
/// 恰恰是"下次启动"才生效的东西,不落盘等于没做。
/// 声音那一个**界面上勾一下也会立刻存**(`core_pages.rs` 的启动动画卡片),
/// 这里再写一遍是为了让"路径一确定,三个键就都在盘上"这件事不依赖调用方。
/// `boot_anim` 也写:选 MP4 时它会被 [`boot_enabled_after_pick`] 顶成开,
/// 只改内存不落盘的话,"选了视频、重启又不播"这个原缺陷原样还在。
///
/// **盘上已经是这三个值就一个字节都不写**:store 的约定是「读设置的动作
/// 不该有副作用」(见 `store.rs` 里 `get_port` 那段),而这个函数在启动时
/// 也会被调一次(盘上存过 MP4 且总开关开着就抽帧)—— 无条件 `save` 等于每次
/// 启动都把用户手工编辑过的 store.json 重排一遍,他改过的注释/顺序全没了。
fn save_boot_switches(&mut self) {
let want_video = self
.misc
.boot_video
.as_ref()
.map(|p| p.to_string_lossy().into_owned());
let want_sound = if self.misc.boot_sound { "1" } else { "0" };
let stored_video = self.store.get_str(BOOT_VIDEO_KEY);
let stored_sound = self.store.get_str(BOOT_SOUND_KEY);
let stored_anim = self.store.get_str(BOOT_ANIM_KEY);
// 两半都要看:视频/声音一样**不代表**总开关也一样(选 MP4 时变的正是它),
// 和 `boot_switches_unchanged` 里 `&&` 的写法是一个道理。
if boot_switches_unchanged(
want_video.as_deref(),
want_sound,
stored_video.as_deref(),
stored_sound.as_deref(),
) && boot_anim_unchanged(self.misc.boot_enabled, stored_anim.as_deref())
{
return;
}
match &want_video {
Some(p) => self.store.set_str(BOOT_VIDEO_KEY, p),
// 取消选择就**把键删掉**,而不是留一个空串:空串和"没设过"是同一个
// 语义,盘上却多一条看着像有值的记录,用户手改时会被误导。
None => self.store.remove(BOOT_VIDEO_KEY),
}
self.store.set_str(BOOT_SOUND_KEY, want_sound);
self.store
.set_str(BOOT_ANIM_KEY, if self.misc.boot_enabled { "1" } else { "0" });
if let Err(e) = self.store.save() {
// 存不上要说出来(盘满 / 只读),但不能拦着用户继续用 ——
// 这次会话里的选择仍然是生效的。
self.log
.write("boot", &format!("启动动图设置没存上:{e}"));
self.misc.message = Some(format!("{BOOT_MSG_PREFIX} 设置没存上:{e}"));
}
}
/// 收抽帧任务的结果,**每帧调**(在 `poll_jobs` 里)。
///
/// 纹理**必须在 UI 线程上传**:`Context::load_texture` 在 debug 构建里会拿
/// 当前帧的 `max_texture_side` 做断言(背景图那条路踩过,见 `background.rs`
/// 里「上限必须在 UI 线程上取好再进线程」那段),在后台线程上传就是拿别的帧
/// 去比。所以 Job 只回解码后的图,这里才 `load_texture`。
fn poll_boot_video(&mut self, ctx: &egui::Context) {
if !self.misc.boot_video_job.poll() {
return;
}
// `take_result`:一次抽帧是几十张解码后的图(几十 MB),
// `result().cloned()` 会让内存在那一瞬间翻倍。
let Some(r) = self.misc.boot_video_job.take_result() else {
return;
};
match r {
Ok(bf) => {
// 成对写:帧表和偏移表**必须同长** —— 一边有一边空,
// 播放时按偏移取帧就会越界(靠 `get` 兜住,表现为画面不换)。
self.misc.boot_frames.clear();
self.misc.boot_frame_at.clear();
for (at, img) in bf.frames {
self.misc
.boot_frames
.push(ctx.load_texture(
format!("nfarms-boot-frame-{at}"),
img,
egui::TextureOptions::LINEAR,
));
self.misc.boot_frame_at.push(at);
}
if self.misc.boot_frames.is_empty() {
let msg = format!("{BOOT_MSG_PREFIX} 没抽到任何画面,仍用内置动画");
self.log.write("boot", &msg);
self.misc.message = Some(msg);
} else {
self.misc.message = Some(bf.note);
}
}
Err(e) => {
// **不回退成一句静默的内置动画**:抽帧失败要说清原因 ——
// 文件损坏 / ffmpeg 缺失 / 路径没了,三种情况的下一步完全不同。
self.misc.boot_frames.clear();
self.misc.boot_frame_at.clear();
let msg = format!("{BOOT_MSG_PREFIX} 抽帧失败,本次仍用内置动画:{e}");
self.log.write("boot", &msg);
self.misc.message = Some(msg);
}
}
}
/// 起启动动画的声音。
///
/// 只在 `boot_sound` 为真**且选了 MP4** 时才起(内置动画没有音轨)。
/// 走 `self.media.music`(MCI):设备别名固定是 `nfmusic`,所以播放这段声音
/// 用的就是音乐页那台设备 —— 用户能在音乐页看到"正在播"、也能在那里停掉。
///
/// **失败只记日志,绝不拦启动**(需求明写)。所以这里既不上抛也不弹提示:
/// 用户要的是"启动给我看一段动画",没声音不该变成一个错误弹窗。
/// 同步调用 MCI 的理由:它只读文件头,不是解码整段视频,而音乐页 / 拖放
/// 播歌那条路本来就是同步调的(`poll_background_drop` 里那两处)。
fn start_boot_audio(&mut self) {
if !self.misc.boot_sound {
return;
}
let Some(path) = self.misc.boot_video.clone() else {
return;
};
// 音量口径与音乐页一致(`settings.music_volume * 10`,MCI 是 0..=1000);
// 必须在 `open` **之前**设 —— 设备打开时会把当前音量下发下去。
self.media.music.volume = (self.settings.music_volume.max(0) as u32 * 10).min(1000);
// 两次调用分开写,不串成 `and_then(|()| …)`:`self.media.music` 的
// 可变借用会跨过闭包,借用检查器不会放行。
let mut played = self.media.music.open(&path);
if played.is_ok() {
played = self.media.music.play();
}
match played {
Ok(()) => self
.log
.write("boot", &format!("启动动画声音已开始:{}", path.display())),
Err(e) => self.log.write(
"boot",
&format!("启动动画声音播放失败({}):{e}", path.display()),
),
}
}
/// 停掉启动动画的声音。没起过就什么都不做。
///
/// 用 `close` 而不是 `stop`:启动动画的声音是一次性的,留着设备只会让音乐页
/// 那边看起来"已经在播某一首"。**边界**:启动动画与音乐页共用同一个 MCI
/// 别名,所以这 1~6 秒里用户要是恰好手动开了一首歌,那首歌会被这里一起关掉
/// (MCI 只认别名,分不出是谁开的)。
fn stop_boot_audio(&mut self) {
if self.misc.boot_audio_for.take().is_none() {
return;
}
self.media.music.close();
self.log.write("boot", "启动动画声音已停");
}
// ---------------------------------------------------------- 观测服务
/// 收后台交回来的子进程句柄(见 `MiscState::obs_child_rx`)。
///
/// 单独一个函数是因为**两个地方都要收**:每帧的状态轮询,以及点击「启动」时
/// (上一轮任务可能刚好在这一帧的轮询之后才结束)。漏收一次就是那个句柄被丢掉,
/// 而 `Child` 被 drop **不会**杀进程 —— 留下一个谁也停不掉的孤儿进程,
/// 端口还被它占着。
fn drain_obs_child(&mut self) {
let Some(rx) = self.misc.obs_child_rx.take() else {
return;
};
loop {
match rx.try_recv() {
Ok((idx, child)) => {
if idx < OBS_SERVICES.len() {
self.misc.obs_proc[idx] = Some(child);
}
}
Err(TryRecvError::Empty) => {
// 还没送来:接收端放回去,下一帧再看
self.misc.obs_child_rx = Some(rx);
return;
}
// 发送端没了(任务已结束)而句柄没送出来 = 起进程那一步失败了。
// 那件事由 `obs_job` 的结果负责报,这里把通道收掉就行。
Err(TryRecvError::Disconnected) => return,
}
}
}
/// 观测服务的状态机,**每帧调**。
///
/// 四件事:收后台交回来的句柄、收启动任务的结果、看已经起来的进程还在不在、
/// 每 30 秒把 token 用量推给 VictoriaMetrics。
///
/// **末尾一定要 `request_repaint_after`**:eframe 空闲时根本不调 `update()`,
/// 不要下一帧的话,后台任务回来了界面也不会重画 —— 三个灯会一直停在"启动中",
/// 直到用户碰一下鼠标(这个坑本项目踩过,同 `pet_click` 那段说明)。
pub(crate) fn poll_observability(&mut self, ctx: &egui::Context) {
self.drain_obs_child();
// ---- 启动任务回来了 ----
if self.misc.obs_job.poll() {
match self.misc.obs_job.result().cloned() {
Some(Ok(idx)) => {
if idx < OBS_SERVICES.len() {
self.misc.obs_state[idx] = 2;
self.misc.obs_msg = Some(format!("{} 已就绪", obs_name(OBS_SERVICES[idx])));
}
}
Some(Err(e)) => {
// 结果里只有错误文本、没有下标。**同一时刻只有一个启动任务**
// (`Job::start` 在 running 时直接返回),所以"状态还是 1 的那个"
// 就是它。已经被进程退出那条路置成 3 的话这里就不再动灯 ——
// 灯的含义以"进程没了"为准,那句话更接近根因。
if let Some(i) = self.misc.obs_state.iter().position(|s| *s == 1) {
self.misc.obs_state[i] = 3;
}
self.misc.obs_msg = Some(e);
}
None => {}
}
// 界面和日志写**同一句话**:用户报问题时对着屏幕念的那一句,
// 在日志里能原样搜到
if let Some(msg) = self.misc.obs_msg.clone() {
self.log.write("observe", &msg);
}
}
// ---- 已经起来的进程还在不在 ----
//
// 三件都是常驻进程:它一退,健康检查必然不通,而灯还亮着"已就绪"就是骗人 ——
// 用户会对着一个已经死掉的服务查为什么没有数据。
for (i, svc) in OBS_SERVICES.iter().enumerate() {
// 先在**不碰 self 的地方**把话算好再落状态:一边借着 `obs_proc[i]`
// 一边改 `obs_state[i]`,借用检查器不会放行
let mut gone: Option<String> = None;
if let Some(child) = self.misc.obs_proc[i].as_mut() {
match child.try_wait() {
// 还在跑,正常
Ok(None) => {}
Ok(Some(code)) => {
gone = Some(format!(
"{} 退出了(退出码 {code})",
obs_name(*svc)
));
}
Err(e) => {
gone = Some(format!(
"{} 的进程状态查不出来:{e}",
obs_name(*svc)
));
}
}
}
if let Some(msg) = gone {
self.misc.obs_proc[i] = None;
// **根进程自己没了,Job 可能还兜着插件后端。**
// 关掉这一件才真的收干净 —— 实测:只杀 grafana.exe 而不关 Job,
// gpx 10 秒内一直是 13;补上这行 2 秒内归零。
observability::close_job(*svc);
self.misc.obs_state[i] = 3;
self.log.write("observe", &msg);
self.misc.obs_msg = Some(msg);
}
}
// ---- token 用量 + 系统指标:每 30 秒一次,只在指标库就绪时 ----
//
// 判间隔用的是"上次推送的时刻"这个 u64 毫秒值(存在 `obs_pushed_ms` 里),
// 不是"距上次过了多久":后者要先取一个瞬时时刻再相减,跨帧存不下来。
//
// 系统指标走同一条推送、同一个节流:它由**应用自己**推的理由见
// `system_metric_lines` 上面那段注释。
if self.misc.obs_state[0] == 2 {
let now = nfarms_core::netclock::now_ms().max(0) as u64;
if obs_push_due(now, self.misc.obs_pushed_ms) {
// **先记时刻、再推**:推送在后台线程上,等下一帧进到这里时
// 早就过了 30 秒,不会因为线程还没跑完而重复起线程
self.misc.obs_pushed_ms = now;
let port = obs_ports_u16(&self.misc.obs_ports)[0];
let (prompt, completion) = (self.misc.tok_prompt, self.misc.tok_completion);
let log = self.log.clone();
// `Paths` 只是几个路径,克隆一份带走;采集**必须在后台线程里做**
// (`cyinfo::collect` 要 1.4 秒),所以传进去而不是在这里先采好
let paths = self.paths.clone();
// 后台线程:`push_metric` 是一次真的 HTTP POST(core 里给了 10 秒
// 超时),而且**一行一次请求**(两条 token + 最多四条系统指标)——
// 搁在 UI 线程上窗口最多卡 60 秒,本文件头一条原则就是 UI 线程不做等待。
std::thread::spawn(move || {
push_metrics(port, &paths, prompt, completion, now, &log);
});
}
}
// ---- 该醒着的时候别睡 ----
//
// 有进程在跑、或还有句柄/任务在路上,就每 200 毫秒回来一次。
// eframe 空闲不重绘,不请求下一帧的话状态灯永远不变。
let busy = self.misc.obs_job.is_running()
|| self.misc.obs_child_rx.is_some()
|| self.misc.obs_proc.iter().any(|p| p.is_some());
if busy {
ctx.request_repaint_after(Duration::from_millis(200));
}
}
/// 起第 `idx` 件观测服务(0=VictoriaMetrics / 1=Loki / 2=Grafana)。
///
/// 四步(**补配置 → 生成 Grafana 数据源 → 起进程 → 等就绪**)全在 `obs_job`
/// 里跑。等就绪是阻塞的,而且 Loki 实测 17 秒(预算 36 秒):搁在 UI 线程上
/// 就是整个窗口冻住几十秒,用户只会以为程序死了。
///
/// 状态灯由 [`Self::poll_observability`] 维护 —— 这里只把这一件置成
/// 1(启动中),任务回来之后那边再置 2 或 3,页面不用自己判任何东西。
pub(crate) fn obs_start(&mut self, idx: usize, ctx: &egui::Context) {
if idx >= OBS_SERVICES.len() {
// 页面只会传 0..3。传别的说明是接线错了:不 panic,但要留下痕迹
self.misc.obs_msg = Some(format!("观测服务的下标 {idx} 不合法(只有 0/1/2)"));
return;
}
// 上一轮的句柄可能还压在路上,先收掉(理由见 `drain_obs_child`)
self.drain_obs_child();
if self.misc.obs_proc[idx].is_some() {
self.misc.obs_msg = Some(format!("{} 已经在运行", obs_name(OBS_SERVICES[idx])));
return;
}
if self.misc.obs_job.is_running() {
// **必须拦**:`Job::start` 在 running 时是直接返回的,不拦的话
// 用户点了「启动」却什么都不发生,连个说法都没有
self.misc.obs_msg = Some("另一件服务还在启动中,等它出结果再起这一件".to_string());
return;
}
let libs = self.paths.libs();
let data = self.paths.root().join("data");
let ports = obs_ports_u16(&self.misc.obs_ports);
let Some(sp) = observability::spec(OBS_SERVICES[idx], &libs, &data, ports[idx]) else {
// exe 不在是**常态**(没装三件套的机器),不是异常:这一页照样打开,
// 只是把灯置成失败,并给一句能照着做的话
let absent = observability::missing(&libs).join("、");
let msg = format!("还没装:{absent}(跑一下 libs\\scripts\\fetch-libs.ps1)");
self.misc.obs_state[idx] = 3;
self.log.write("observe", &msg);
self.misc.obs_msg = Some(msg);
return;
};
let (name, port) = (sp.name, sp.port);
self.misc.obs_state[idx] = 1;
self.misc.obs_msg = Some(format!("{name} 启动中…(端口 {port})"));
self.log.write(
"observe",
&format!("启动 {name}:端口 {port},先补配置再等就绪"),
);
// 句柄要从后台交回 UI 线程存进 `obs_proc`(页面上要能停它),
// 所以这里开一条只送一件的通道
let (tx, rx) = std::sync::mpsc::channel();
self.misc.obs_child_rx = Some(rx);
let (vm_port, loki_port, grafana_port) = (ports[0], ports[1], ports[2]);
// 闭包是 `move`,后台线程里拿不到 `self.log` —— 先拷一份。
// 下面两件**失败要记日志**,不能静默(那正是这个项目反复修的那类问题)。
let log_job = self.log.clone();
self.misc.obs_job.start(ctx, move || {
// 缺配置才补(已存在的一律不覆盖:用户手改过的路径、密码要留住)
observability::ensure_configs(&data, loki_port, grafana_port)?;
// Grafana 的数据源文件里写着 VM / Loki 的端口 —— 必须和上面
// `ensure_configs` 用的是同一组端口,否则就是"健康检查打在 A、
// 数据源指着 B",症状是 Grafana 起来了但图表全空
observability::provision_datasources(&data, vm_port, loki_port)?;
// ---- 仪表盘 + 品牌 logo ----
//
// **必须在 `ensure_configs` 之后**:`install_branding` 在 `custom.ini`
// 不存在时**报错而不是自己造一份** —— 造的那份缺 `[server] http_port`,
// Grafana 会退回默认 3000,而健康检查还打在 9093,
// 症状是「永远起不来」,而且看不出是配置缺了一段。
//
// **失败不挡启动**(所以是 `if let Err` 而不是 `?`):
// 服务本身起得来,比"面板装上了"重要 —— 面板是附属品。
// 对比 `provision_datasources` 用的是 `?`:数据源没了,
// Grafana 起来也是个空壳,那才叫"起了等于没起"。
if let Err(e) = observability::provision_dashboards(&data) {
log_job.write("observe", &format!("仪表盘安装失败(不影响服务启动): {e}"));
}
if let Err(e) = observability::install_branding(&libs, &data) {
log_job.write("observe", &format!("品牌 logo 安装失败(不影响服务启动): {e}"));
}
let child = observability::start(&sp)?;
// 挂 Job 失败(本进程已在禁止嵌套的 Job 里,CI 很常见)**不挡启动**,
// 但要把话说出来 —— 静默降级正是那 91 个孤儿当初没人发现的原因。
if let Some(w) = observability::take_job_warning() {
log_job.write("observe", &format!("{w}(不影响启动)"));
}
// 句柄**立刻**交回去:后面等就绪要几十秒,这段时间里用户也该能停掉它
let _ = tx.send((idx, child));
observability::wait_ready(&sp)?;
Ok(idx)
});
}
/// 停掉第 `idx` 件观测服务。
///
/// **不用开 Job**:kill + wait 打的是本机子进程,几毫秒就回来 ——
/// 要等几十秒的是 `wait_ready`,不是这个。状态回到 0(未启动)而不是 3(失败):
/// 是用户自己停的,不是它坏了,页面据此把灯熄掉而不是变红。
#[allow(dead_code)] // 页面(plugins/core_pages.rs)的「停止」按钮调它
pub(crate) fn obs_stop(&mut self, idx: usize) {
if idx >= OBS_SERVICES.len() {
return;
}
let name = obs_name(OBS_SERVICES[idx]);
let Some(mut child) = self.misc.obs_proc[idx].take() else {
self.misc.obs_msg = Some(format!("{name} 本来就没在运行"));
return;
};
// 停之前先关这一件的 Job:内核连带结束它派生的**所有**子孙进程。
// `child.kill()` 只打直接子进程 —— Grafana 那 13 个 gpx_* 插件后端
// 原来就是这么变成孤儿、还占着内存的(实测累计查到 91 个)。
observability::close_job(OBS_SERVICES[idx]);
let _ = child.kill();
// **必须 wait**:不等的话进程表里会留一条记录(Windows 上是句柄泄漏),
// 端口也可能还占着 —— 下一次启动同一件就会报"地址已被占用"
let code = match child.wait() {
Ok(st) => st.to_string(),
Err(e) => format!("查不到退出状态: {e}"),
};
self.misc.obs_state[idx] = 0;
self.misc.obs_msg = Some(format!("{name} 已停止({code})"));
self.log.write("observe", &format!("{name} 已停止({code})"));
}
/// 起一个后台线程去联网,结果用 channel 回传。
pub(crate) fn start_weather_fetch(&mut self, ctx: &egui::Context) {
let loc = self.weather_location.trim().to_string();
let loc = if loc.is_empty() {
weather::DEFAULT_LOCATION.to_string()
} else {
loc
};
let (tx, rx) = std::sync::mpsc::channel();
self.weather_rx = Some(rx);
self.weather = pages::WeatherState::Loading;
self.log.write("weather", &format!("开始获取天气: {loc}"));
let ctx = ctx.clone();
std::thread::spawn(move || {
let r = weather::fetch(&loc);
let _ = tx.send(r);
// 唤醒 UI —— 后台线程算完不叫一声,界面要等下一次输入才刷新
ctx.request_repaint();
});
}
fn poll_weather(&mut self) {
let Some(rx) = self.weather_rx.take() else {
return;
};
match rx.try_recv() {
Ok(Ok(report)) => {
self.log.write(
"weather",
&format!(
"获取成功: {} {}C {}",
report.now.city, report.now.temp, report.now.desc
),
);
self.weather = pages::WeatherState::Ready(Box::new(report));
}
Ok(Err(err)) => {
self.log.write("weather", &format!("获取失败: {err}"));
self.weather = pages::WeatherState::Failed(err);
}
// 还没算完,把接收端放回去继续等
Err(TryRecvError::Empty) => self.weather_rx = Some(rx),
Err(TryRecvError::Disconnected) => {
self.weather = pages::WeatherState::Failed("后台线程异常退出".to_string());
}
}
}
pub(crate) fn save_settings(&self) {
if let Err(e) = self.settings.save(&self.settings_path) {
self.log.write("settings", &format!("保存失败: {e}"));
}
}
/// 换背景图:后台载入,载完自动存盘(原版也是选完即生效)。
fn apply_background(&mut self, ctx: &egui::Context, path: &Path) {
self.background.load_async(ctx, path);
self.log
.write("gui", &format!("背景图载入中: {}", path.display()));
}
/// 轮询后台解码,完成了就记日志 —— **正面证据**:
/// 只写「没失败」的话,日志里看不出到底加载没加载。
fn poll_background(&mut self, ctx: &egui::Context) {
match self.background.poll(ctx) {
Some(background::LoadOutcome::Ok { width, height }) => {
let path = self
.background
.path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_default();
self.log
.write("gui", &format!("背景图已载入: {path} ({width}×{height})"));
// 从这里进来的(启动时按设置载入 / 拖放)都顺手写回设置。
// 注意只在成功时写 —— 载不进来的路径存下去,下次启动还是白等一场。
if self.settings.background_image != path {
self.settings.background_image = path;
self.save_settings();
}
}
Some(background::LoadOutcome::Err(e)) => {
self.log.write("gui", &format!("背景图载入失败: {e}"));
}
None => {}
}
if self.background.is_loading() {
ctx.request_repaint_after(Duration::from_millis(120));
}
}
/// 轮询「选择图片」对话框的结果。
///
/// **拖放进来的文件不在这里处理** —— 拖进来的图片在图片阅读器页应该是
/// 「打开这张」,在别的页面才是「当窗口背景」。那个判断要看当前页,
/// 所以放在 `App::update` 里(见 `handle_dropped_files`)。
fn poll_background_drop(&mut self, ctx: &egui::Context) {
if let Some((purpose, path)) = self.picker.poll() {
// 工具栏上只有一个文件对话框 —— 选出来的东西给谁用,靠 purpose 路由。
// 不路由的话,选一首音乐会被当成新的窗口背景图。
match purpose {
background::PickPurpose::Background => self.apply_background(ctx, &path),
background::PickPurpose::Music => {
if let Some(dir) = path.parent() {
self.media.scan_playlist(dir);
}
match self.media.music.open(&path) {
Ok(()) => {
let _ = self.media.music.play();
self.media.message = Some(format!("正在播放 {}", path.display()));
self.log.write("music", &format!("播放 {}", path.display()));
}
Err(e) => self.media.message = Some(e),
}
}
background::PickPurpose::Video => {
// **原来这里只弹了一句提示,路径被直接丢掉了** ——
// 提示还写着"源已记录",而实际什么都没记;它说的那个
// 「打开」按钮也不存在(视频页上只有「载入」)。
//
// 症状:选了文件之后毫无反应,**连日志都没有** ——
// 因为压根没走到任何会写日志的地方。
self.misc.video_src = path.display().to_string();
// 交给视频页去载入(那边才有 ffmpeg 句柄和状态),
// 并且**载入完直接播**(选了文件就是要看)。
self.misc.video_want_load = true;
self.log.write("video", &format!("选择 {}", path.display()));
}
// 图片阅读器:在阅读器里打开,**不要**动背景图
background::PickPurpose::Viewer => {
self.viewer.load_async(ctx, &path);
}
// ffmpeg 页的「浏览…」:填进源路径输入框
background::PickPurpose::FfmpegSrc => {
self.ffmpeg.src = path.display().to_string();
}
// 启动动画的「使用自定义 MP4…」:交给 `boot_video_set` ——
// 「记住路径 + 落盘 + 后台抽帧」在那边是一个动作,这里拆开写就会
// 缺一半:只记路径不抽帧的话,用户点完没画面(得等下次启动);
// 只抽帧不落盘的话,重启就回到内置动画。两种拆法都是
// "看着能用、其实一半没接"。
// **别借 `Video` 那个用途顶上**:它会把选出来的 MP4 甩给视频播放器;
// 而 `background.rs` 的注释里记着的那次事故(用途和路由对不上 →
// 整窗背景图被换掉)就是这么发生的。
background::PickPurpose::BootVideo => {
self.boot_video_set(Some(path), ctx);
}
}
}
if self.picker.is_pending() {
ctx.request_repaint_after(Duration::from_millis(200));
}
// 音乐:轮询播放状态,放完自动下一首
if self.media.music.is_opened() {
let before = self.media.music.state;
let now = self.media.music.poll_state();
if before == nfarms_core::music::PlayState::Playing
&& now == nfarms_core::music::PlayState::Stopped
{
ctx.request_repaint();
}
}
}
}
// ===========================================================================
// 壳
// ===========================================================================
struct App {
state: AppState,
/// 插件内核
kernel: Kernel,
/// 页面插件登记表 —— 侧栏与内容区都只认它
pages: plugins::PageRegistry,
page: Page,
/// 侧栏手风琴:当前展开的**容器**(一级目录或二级子分组)。
///
/// 三级结构下不能再只记一个 `Option<Group>`:一级目录里嵌着子分组,
/// 展开「Player」和展开「Player › Music」是两件独立的事,而且要能同时开着。
/// 用 `HashSet` 而不是一个 `Option`:默认会把当前页所在的那两级一起展开,
/// 用户手动折起来的下次也不该自己弹开(`goto` 只在**切页**时改它)。
open_containers: std::collections::HashSet<NavKey>,
/// 侧栏当前顺序(用户拖过就用拖的,否则是功能树默认序)。
///
/// 存 `Vec<Page>` 而不是每次去查 store:侧栏每帧都要按它排,查一次
/// 反序列化一次太亏。真值在 `store.json` 的 `nav_order`(存的是 id)。
nav_ordered: Vec<Page>,
/// [`nav_ordered`](Self::nav_ordered) 的倒排:页面 → 它在其中的下标。
///
/// 侧栏每帧、每个容器都要按它排序,现算一遍是 O(n) 一轮 **每容器一次**;
/// 顺序只在切页/拖拽提交时变,所以在这里缓存一份,跟着一起更新。
/// 它和 `nav_ordered` 必须同时更新 —— 只有 [`App::reindex_nav`] 一处改两者。
rank: std::collections::HashMap<Page, usize>,
/// 拖拽进行态。`Some` 表示正在拖某一页。
nav_drag: Option<NavDrag>,
/// 已经弹出成独立窗口的页面。
///
/// 用 `Vec` 不用 `HashSet`:顺序稳定(弹出的顺序),而且数量个位数,
/// 线性查找比哈希快也更好调试。
#[allow(dead_code)] // 恒为空 —— 弹窗功能已移除
popped: Vec<Page>,
}
impl App {
// 同上:一次性构造,拆开只会更难读
#[allow(clippy::too_many_arguments)]
fn new(
cc: &eframe::CreationContext<'_>,
paths: Paths,
log: Log,
settings_path: PathBuf,
initial_page: Option<Page>,
open_image: Option<String>,
_popout: bool,
logo_image: egui::ColorImage,
) -> Self {
// 中文字体:egui 自带字体**没有 CJK**,不装就是一片方块。
install_cjk_font(&cc.egui_ctx);
install_style(&cc.egui_ctx);
let settings = nfarms_core::settings::load(&paths, &settings_path);
#[cfg(windows)]
let local_model = local_model::LocalModel::new(&paths);
let background =
background::Background::from_setting(&cc.egui_ctx, &settings.background_image);
// ---- 插件内核 ----
let kernel = Kernel::new();
let event_log: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
{
// 内核事件同时落到日志文件和内存列表里:
// 文件给事后排查,列表给插件页当场看。
let sink = event_log.clone();
let file_log = log.clone();
kernel.context().on(move |ev| {
let line = format_event(ev);
file_log.write("plugin", &line);
if let Ok(mut v) = sink.lock() {
v.push(line);
// 只留最近 200 条,别让这个列表无限长
if v.len() > 200 {
v.remove(0);
}
}
});
}
// 桌面装整棵树;安卓只装骨架(天气 / 背景图片 / 插件)
let page_plugins = if cfg!(target_os = "android") {
plugins::mobile()
} else {
plugins::desktop()
};
let pages = plugins::install(&kernel, page_plugins);
if let Err(e) = kernel.ready() {
log.write("plugin", &format!("插件装载未完成: {e}"));
}
log.write(
"plugin",
&format!(
"插件内核就绪:{} 个插件,{} 张页面卡片,{} 个服务",
kernel.len(),
pages.len(),
kernel.context().visible_service_names().len()
),
);
// `--page X` 指定的页面在**当前平台**没装载时退回第一页 ——
// 比如安卓骨架里没有 topo,硬开就会是一片空白,不如明确退回去。
// `--open-image` 优先于 `--page`:双击图片打开时,用户要的是看图。
let requested = if open_image.is_some() {
Page::ImageViewer
} else {
initial_page.unwrap_or(Page::TodayInfo)
};
let page = if pages.contains(requested) {
requested
} else {
let fallback = pages.first().unwrap_or(Page::TodayInfo);
if initial_page.is_some() {
log.write(
"gui",
&format!(
"本平台没有装载页面 {} —— 退回 {}",
requested.id(),
fallback.id()
),
);
}
fallback
};
// store.json 的位置要从 `paths` 算,而 `paths` 马上就要被 move 进 AppState ——
// 所以先算好再传进去。
let store = nfarms_core::store::Store::load(&nfarms_core::store::Store::default_path(&paths));
// 几个开关是持久的:启动动图、桌宠、上报。**默认全关** ——
// 一个程序不该一启动就挡你 1.4 秒,也不该往桌面上扔东西。
// store 马上要被 move 进 AppState,所以先读出来。
let boot_enabled = boot_anim_from_store(store.get_str(BOOT_ANIM_KEY).as_deref());
// 自定义启动动画的两个开关也在 store.json 里(键 `boot_video` / `boot_sound`),
// **不进 settings.json** —— 那个文件有跟 C# 原版逐字节还原的护栏测试。
// 读坏的值(不是字符串 / 空串 / 说不清的开关)一律回落默认,不许 panic:
// 这段跑在启动路径上,一次 panic 就是窗口起不来。
let boot_video = boot_video_from_store(store.get_str(BOOT_VIDEO_KEY).as_deref());
let boot_sound = boot_sound_from_store(store.get_str(BOOT_SOUND_KEY).as_deref());
let pet_visible = store.get_str("pet_visible").as_deref() == Some("1");
let sync_enabled = store.get_str("sync_enabled").as_deref() == Some("1");
// 侧栏顺序也得在这里读 —— `store` 马上就要被 move 进 AppState,
// 而 `pages` 会在结构体字面量里被 move,两边都赶不上趟。
let saved_nav_order = store.nav_order();
let mut state = AppState::new(
paths,
log,
settings_path,
settings,
background,
logo::Logo::new(&cc.egui_ctx, logo_image),
event_log,
store,
#[cfg(windows)]
local_model,
);
state.misc.boot_enabled = boot_enabled;
state.misc.boot_video = boot_video;
state.misc.boot_sound = boot_sound;
state.misc.pet_visible = pet_visible;
state.misc.sync_enabled = sync_enabled;
if boot_enabled {
// 启动动图一开场就播(而不是等用户切到 Setting 那一页)
state.misc.boot_started_ms = Some(nfarms_core::netclock::now_ms());
}
// 盘上存过 MP4 **且总开关开着**就**现在**把抽帧发到后台:抽帧要几秒,而这次
// 启动动画只有 1~6 秒 —— 不先起跑的话,存过视频的机器每次启动都只会看到
// 内置动画,用户会以为"我的设置没生效"。(抽得快时本次动画的后半段就能切到
// MP4;抽不完也没关系,`render_boot_anim` 会照旧放内置动画。)
// 代价是一次受限的 ffmpeg:`-frames:v` 封顶,只解前 6 秒。
//
// **判据为什么多了前半截 `boot_enabled`**:总开关关着的时候,
// `render_boot_anim` 根本不会去放这段动画(它连遮罩都不起),抽出来的帧
// 一次也用不上 —— 那就是为一段永远不会播的动画白起一次 ffmpeg,几秒钟的
// CPU 和磁盘读,而用户看到的只是"启动卡了一下"。这个状态是真实存在的:
// 手改 store.json(`boot_video` 留着、`boot_anim` 是 `"0"`),或者先勾了
// 又取消勾(`core_pages.rs` 的卡片就是允许这么点的)。那种情况下卡片上
// 有一条醒目警告说明,而不是靠这里偷偷抽帧。
if state.misc.boot_enabled && state.misc.boot_video.is_some() {
state.boot_video_load(&cc.egui_ctx);
}
// 把「开在哪一页」写进日志 —— 否则 `--page X` 的冒烟测试拿不到任何凭据
// (进程活着只能说明窗口起来了,不能说明开对了页)
state
.log
.write("gui", &format!("界面启动,首页={}", page.id()));
if state.settings.background_image.trim().is_empty() {
state.log.write("gui", "背景图: 未设置,使用默认纯色底");
}
// `--popout`:启动就把首页弹成独立窗口。
// 想一开机就全屏数字雨,建个快捷方式写 `--page matrix-rain --popout` 即可。
//
// 必须在结构体字面量**之前**算出来:`pages` 在字面量里会被 move 进去,
// 之后再 `pages.contains(...)` 就是「借用已移动的值」。
// 侧栏顺序:存过就套用,没存过 = 功能树默认序。
// `apply_order` 两条容错缺一不可(失效 id 忽略、新页追加到末尾),见 pages.rs。
let nav_ordered: Vec<Page> = {
let all: Vec<Page> = pages::Page::all().collect();
pages::apply_order(&all, &saved_nav_order)
};
let mut app = Self {
open_containers: NavKey::of_page(page).into_iter().collect(),
state,
kernel,
pages,
rank: rank_of(&nav_ordered),
nav_ordered,
nav_drag: None,
page,
// 「弹出独立窗口」功能已按用户要求**移除**:这里恒为空,
// 于是 `render_popouts` 每帧一进来就 early-return,弹窗再也不会出现。
//
// 之所以留着字段和方法而不是连根删掉:`render_pet_window`(桌宠)
// 用的是**同一套**立即视口机制,先把弹窗这条路封死、确认桌宠不受影响,
// 再清死代码比一次性动两处稳。
popped: Vec::new(),
};
// 首屏就把天气取上(后台线程,UI 不等待)
app.state.start_weather_fetch(&cc.egui_ctx);
// `--open-image`:直接开在图片阅读器上
if let Some(p) = open_image {
let p = PathBuf::from(p);
app.state.viewer.load_async(&cc.egui_ctx, &p);
app.state
.log
.write("viewer", &format!("命令行打开图片: {}", p.display()));
}
app
}
/// 切页:把当前页所在的两级容器都展开、记日志、发事件。
fn goto(&mut self, page: Page) {
if self.page == page {
return;
}
self.page = page;
self.open_containers.extend(NavKey::of_page(page));
self.state
.log
.write("gui", &format!("切到页面: {}", page.id()));
self.kernel.context().emit(&Event::PageOpened {
id: page.id().to_string(),
});
}
}
/// 内核事件 → 一行给人看的文字。
fn format_event(ev: &Event) -> String {
match ev {
Event::PluginLoaded { name } => format!("插件已装载 {name}"),
Event::PluginFailed { name, error } => format!("插件出错 {name}: {error}"),
Event::PluginDisposed { name } => format!("插件已卸载 {name}"),
Event::ServiceProvided { name } => format!("提供服务 {name}"),
Event::PageOpened { id } => format!("切到页面 {id}"),
Event::SettingsSaved { path } => format!("设置已保存 {path}"),
Event::Log { source, message } => format!("[{source}] {message}"),
}
}
impl eframe::App for App {
fn clear_color(&self, visuals: &egui::Visuals) -> [f32; 4] {
// 这里踩过两次坑,记清楚:
//
// **第一次**:无条件返回不透明的 APP_BG —— 结果桌宠那个视口也被涂成
// 不透明的,一只本该透明的宠物变成桌面上一个黑方块。
//
// **第二次**:于是改成无条件清零 —— 结果**主窗口也变透明了**,
// 整窗内容被合成到白底上,用户看到的是「白屏」。
//
// 正确的结论在第 2 条:`clear_color` 是**整帧一次**的,不按视口分。
// 试过用 `visuals.panel_fill` 当标记(桌宠视口 `set_visuals` 成全透明)、
// **不生效**。所以桌宠不做真透明,而是无边框 + 置顶 + 不上任务栏的小窗,
// 底色与主窗口一致。要真透明得让它有自己的 GL 上下文与 clear color,
// 那不是立即视口能做到的 —— 页面上如实写明这一点。
// `visuals` 在本版本里**区分不出视口**(传进来的永远是主窗口那份),
// 所以这里不按视口分支 —— 见上面那段说明。
let _ = visuals;
// **清零行不通 —— 已实测。**
//
// 前两次失败的原因是:**主窗口的根面板没有填充**,它的底色一直是靠
// 这个 clear_color 提供的。所以一清零,主窗口就"白屏"了。
//
// 这次的组合是三条一起改:
// 1. 这里清零;
// 2. 桌宠视口加 `with_transparent(true)` —— 它的帧缓冲才有 alpha 通道;
// 3. **主窗口根面板给显式不透明填充**(见下面 CentralPanel 那处)。
// 主窗口没有 with_transparent,alpha 会被忽略,但它自己的面板盖满了整窗,
// 所以看上去和以前一样。
// 立即视口**没有自己的 GL 表面**,`with_transparent(true)` 会被忽略,
// 清零只会让桌宠窗口变成**不透明黑块**(实测:主窗口里桌宠那块平均亮度
// 69.7,周边 192.8)。要真透明得换掉 `show_viewport_immediate`。
let c = theme::APP_BG;
[
c.r() as f32 / 255.0,
c.g() as f32 / 255.0,
c.b() as f32 / 255.0,
1.0,
] }
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.state.poll_weather();
if self.state.weather_rx.is_some() {
// 等待期间保持刷新(不阻塞,只是让界面别僵住)
ctx.request_repaint_after(Duration::from_millis(150));
}
self.state.poll_background(ctx);
self.state.poll_background_drop(ctx);
self.handle_dropped_files(ctx);
self.state.poll_jobs(ctx);
// 观测服务(VictoriaMetrics / Loki / Grafana)的状态机:每帧都要跑。
// 它自己会在有进程/有任务时 request_repaint_after —— 不请求的话
// eframe 空闲不重绘,那三个状态灯只在用户碰鼠标时才变。
self.state.poll_observability(ctx);
if self.state.topo.running
|| self.state.tools.is_running()
|| self.state.channel.is_running()
|| self.state.viewer.is_loading()
|| self.state.ffmpeg.detect_job.is_running()
|| self.state.ffmpeg.work.is_running()
{
ctx.request_repaint_after(Duration::from_millis(200));
}
handle_window_resize(ctx);
// 整窗底色:有自定义背景图就贴图(cover),没有就纯色。
// 面板用半透明白盖上去 = 雾玻璃。
let screen = ctx.screen_rect();
let painter = ctx.layer_painter(egui::LayerId::background());
if self.state.background.is_set() {
self.state.background.paint(&painter, screen);
} else {
painter.rect_filled(screen, 0.0, theme::APP_BG);
}
self.sidebar(ctx);
self.content(ctx);
// **桌宠可见时要定时醒一下。**
//
// eframe 只在需要时重绘 —— 程序空闲时 `update()` 根本不会被调用。
// 而桌宠跑在**独立线程**上,它点一下不会唤醒主窗口的重绘,
// 于是 `take_click()` 一次都轮询不到(实测:点击被正确识别、
// 标志也置位了,但界面永远看不到)。
//
// 100ms 一次足够跟手,代价可以忽略(只是跑一遍 update,不重绘画面)。
#[cfg(windows)]
if self.state.misc.pet_visible {
ctx.request_repaint_after(Duration::from_millis(100));
}
// **桌宠被点了一下** → 开关对话框。
//
// 点击的识别在 `pet_window.rs` 里(那边分得清"点一下"和"拖一下"),
// 这里只负责取走这个标志。
#[cfg(windows)]
if pet_window::take_click() {
self.state.misc.chat_open = !self.state.misc.chat_open;
self.state.log.write(
"pet",
if self.state.misc.chat_open {
"桌宠被点击:打开对话框"
} else {
"桌宠被点击:关闭对话框"
},
);
}
// **桌宠被右键收起了** → 把「显示桌宠」的勾一起去掉。
//
// 右键的动作发生在桌宠那个线程的 wndproc 里,它够不着这里的
// `misc.pet_visible`。不对齐的话界面会显示"开着"而桌面上什么都没有,
// 而且更坏:用户取消勾选时 `want=false == pet_shown=false`,那句
// `pet.hide()` 因为 `want != pet_shown` 不成立而**根本不会发**,
// 再勾回来时才补发一次 `show()` —— 桌宠要"关一下再开一下"才回得来。
//
// 这里只改内存里的值,不写 store:右键收起是"这次先收起来",不是
// "以后都不要了";用户真去点那个勾时(core_pages 里)才会落盘。
#[cfg(windows)]
if pet_window::dismiss() && self.state.misc.pet_visible {
self.state.misc.pet_visible = false;
self.state.log.write("pet", "桌宠:被右键收起");
}
#[cfg(windows)]
self.render_chat_window(ctx);
// 桌宠:**原生分层窗口**,不是 egui 视口。这里只负责在可见性
// 变化时同步一次 —— 每帧都发指令会让 channel 白白堆积。
#[cfg(windows)]
{
let want = self.state.misc.pet_visible;
if want != self.state.pet_shown {
self.state.pet_shown = want;
if let Some(pet) = &self.state.pet {
if want {
pet.show();
} else {
pet.hide();
}
}
self.state.log.write(
"pet",
if want {
"桌宠:显示(原生分层窗口)"
} else {
"桌宠:隐藏"
},
);
}
}
self.render_boot_anim(ctx);
}
}
impl App {
/// 启动动图:在主窗口上叠一层全屏遮罩,放完自动淡出。
///
/// **不另开窗口** —— 另开窗口会出现「两个窗口先后冒出来」的抖动,
/// 比不做还难看。
///
/// 两个来源:用户选的 **MP4**(按帧的偏移逐张放)与内置遮罩
/// (logo 淡入 + 扫描线 + 进度条)。判据见 [`boot_use_video`] ——
/// 选了但还没抽完 → **回退内置**,而不是"坚持放视频然后黑屏"。
fn render_boot_anim(&mut self, ctx: &egui::Context) {
let Some(t0) = self.state.misc.boot_started_ms else {
// 没有动画在播。顺手把声音收掉:「重置」按钮就是把 `boot_started_ms`
// 清成 None,不在这里收尾的话那段 MP4 的声音会一直放下去
// (MCI 的设备不跟着纹理走)。
self.state.stop_boot_audio();
return;
};
let use_video = boot_use_video(
self.state.misc.boot_video.as_deref(),
&self.state.misc.boot_frame_at,
);
// ---- 选了视频但一帧都没有:补抽一次 ----
//
// 启动那一刻抽帧通常还没跑完,于是这一轮放的是内置动画(需求要的就是
// 这个回退)。这里再补一次是为了"点「立即预览」时能真的看到 MP4":
// 页面上的预览按钮只挪 `boot_started_ms`,它不知道帧抽好没有。
// `boot_video_tried` 保证同一个路径只补一次 —— 失败(文件坏了、
// ffmpeg 没装)的那次不该每点一次预览就再起一个 ffmpeg。
if !use_video
&& let Some(p) = self.state.misc.boot_video.clone()
&& self.state.misc.boot_video_tried.as_ref() != Some(&p)
&& !self.state.misc.boot_video_job.is_running()
{
self.state.boot_video_load(ctx);
}
let total = if use_video {
boot_video_total_ms(&self.state.misc.boot_frame_at)
} else {
BUILTIN_BOOT_TOTAL_MS
};
// 时钟被往回调时 `now - t0` 是负数,后面 `as u64` 会绕成一个天文数字 →
// 动画在一瞬间被判成"演完了"。夹到 0 起步。
let elapsed = (nfarms_core::netclock::now_ms() - t0).max(0);
if elapsed >= total {
self.state.misc.boot_started_ms = None;
self.state.stop_boot_audio();
return;
}
// ---- 声音:这一段动画还没起过就起一次 ----
//
// 判据是「这一段动画的起点」而不是"设备开着没有":启动自动播和
// 「立即预览」都会把 `boot_started_ms` 换成一个新的时刻,
// 换一次就该重起一次声音。
if self.state.misc.boot_audio_for != Some(t0) {
self.state.misc.boot_audio_for = Some(t0);
self.state.start_boot_audio();
}
let screen = ctx.screen_rect();
let painter = ctx.layer_painter(egui::LayerId::new(
egui::Order::Foreground,
egui::Id::new("nfarms-boot"),
));
let alpha = boot_fade_alpha(elapsed, total);
// ---- 来源一:MP4 的帧 ----
if use_video {
let idx = boot_frame_index_at(elapsed as u64, &self.state.misc.boot_frame_at);
// `get` 的兜底不能省:帧表和偏移表理论上同长(`poll_boot_video` 是
// 成对填的),真错位时 `[]` 会直接 panic 掉整个界面;这里返回 None
// 就落到下面的内置遮罩,最坏也只是难看一帧。
if let Some(tex) = idx.and_then(|i| self.state.misc.boot_frames.get(i)) {
// 等比放大到**刚好盖住整窗**(= CSS 的 `background-size: cover`),
// 而不是把屏幕矩形当目标硬拉:视频的比例和窗口的比例几乎不会相同,
// 硬拉会把人像 / logo 拉扁,而启动动画恰恰是"第一眼"。
// (公式与 `background.rs::cover_rect` 一致 —— 那个函数是私有的,
// 跨模块调不到,只能照抄这一行;它自己有单元测试钉着。)
let ts = tex.size_vec2();
let scale =
(screen.width() / ts.x.max(1.0)).max(screen.height() / ts.y.max(1.0));
painter.image(
tex.id(),
egui::Rect::from_center_size(screen.center(), ts * scale),
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
// 只调透明度,不改颜色 —— 和内置动画共用同一个淡出曲线
egui::Color32::from_white_alpha((255.0 * alpha) as u8),
);
ctx.request_repaint();
return;
}
}
// ---- 来源二:内置遮罩 ----
// (`total` 在 MP4 那一支里是视频时长;走到这里说明没有可用帧,
// 此时 `use_video` 为假、`total` 就是 1400,和原来一模一样。)
let progress = (elapsed as f32 / (total - BOOT_FADE_MS).max(1) as f32).clamp(0.0, 1.0);
painter.rect_filled(
screen,
0.0,
egui::Color32::from_rgba_unmultiplied(6, 10, 9, (245.0 * alpha) as u8),
);
// 扫描线:每 14 px 一条,整体随进度下移 —— 廉价但很像「正在启动」
let offset = progress * 28.0;
let mut y = screen.top() - 28.0 + offset;
while y < screen.bottom() {
painter.rect_filled(
egui::Rect::from_min_size(
egui::pos2(screen.left(), y),
egui::vec2(screen.width(), 2.0),
),
0.0,
egui::Color32::from_rgba_unmultiplied(90, 255, 160, (18.0 * alpha) as u8),
);
y += 14.0;
}
let cx = screen.center().x;
let cy = screen.center().y;
// logo 字:淡入 + 轻微放大
let scale = 0.92 + 0.08 * progress;
painter.text(
egui::pos2(cx, cy - 18.0),
egui::Align2::CENTER_CENTER,
"NFarms HUB",
egui::FontId::proportional(42.0 * scale),
egui::Color32::from_rgba_unmultiplied(235, 255, 245, (255.0 * alpha) as u8),
);
painter.text(
egui::pos2(cx, cy + 22.0),
egui::Align2::CENTER_CENTER,
"正在启动…",
egui::FontId::proportional(13.0),
egui::Color32::from_rgba_unmultiplied(150, 200, 175, (220.0 * alpha) as u8),
);
// 进度条
let w = (screen.width() * 0.22).clamp(160.0, 380.0);
let bar = egui::Rect::from_center_size(
egui::pos2(cx, cy + 60.0),
egui::vec2(w, 3.0),
);
painter.rect_filled(
bar,
2.0,
egui::Color32::from_rgba_unmultiplied(255, 255, 255, (40.0 * alpha) as u8),
);
painter.rect_filled(
egui::Rect::from_min_size(bar.min, egui::vec2(w * progress, 3.0)),
2.0,
egui::Color32::from_rgba_unmultiplied(60, 200, 130, (255.0 * alpha) as u8),
);
ctx.request_repaint();
}
}
/// 侧栏里一个**可折叠容器**的键。
///
/// 三级结构下侧栏有两层能折叠的东西:一级目录(Player)与二级子分组(Player › Music)。
/// 「展开谁」这件事原来只用一个 `Option<Group>` 记,现在要同时记得住两层,
/// 所以换成集合 + 这个键。
///
/// 为什么不直接把 [`pages::NavScope`] 拿来用:那是**拖拽容器**的口径,
/// 它的 `Group(g)` 排除了子分组里的页;而折叠口径里一级目录是**自己一个键**,
/// 和它下面的子分组并列。两者只差一点,但混用会让「展开 Player 却没展开 Music」
/// 这种状态无法表达。
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
enum NavKey {
Group(Group),
Sub(pages::Sub),
}
impl NavKey {
/// 当前页所在的那些容器(一级 + 二级),由外到内。
///
/// 切页时把它们全部展开 —— 否则「跳过去的那一页」在侧栏里看不见,
/// 用户只会觉得程序跳空了。
fn of_page(page: Page) -> Vec<NavKey> {
let mut v = vec![NavKey::Group(page.group())];
if let Some(s) = page.sub() {
v.push(NavKey::Sub(s));
}
v
}
}
/// 侧栏拖拽的进行态。
///
/// 只记「谁、在哪个容器里、插到第几个」—— 目标位置每帧按指针 y 现算,
/// 不缓存行矩形(窗口大小/滚动位置都会变)。
struct NavDrag {
page: Page,
scope: pages::NavScope,
/// 容器内插入下标(0..=容器内页数)
to: usize,
}
/// 画主窗口的底色。
///
/// 现在主窗口靠 `clear_color` 拿不透明底色,这个函数**已经不需要了** ——
/// 留着是因为万一以后又要在主窗口叠自己的底(比如动态渐变背景),
/// 记住「`update` 只对主视口跑」这件事很关键。
#[allow(dead_code)]
fn paint_app_background(ctx: &egui::Context) {
let screen = ctx.screen_rect();
ctx.layer_painter(egui::LayerId::new(
egui::Order::Background,
egui::Id::new("nfarms-app-bg"),
))
.rect_filled(screen, 0.0, theme::APP_BG);
}
impl App {
/// 桌宠的**对话框**。点桌宠开关。
///
/// 用 `show_viewport_immediate` 而不是 deferred:前者**同步**执行闭包,
/// 可以直接借用 `self` 上的状态(和 `render_popouts` 同一个理由)。
/// 这里的窗口**不需要真透明**,所以立即视口够用 —— 只有桌宠本体才必须
/// 走 Win32 分层窗口。
#[cfg(windows)]
fn render_chat_window(&mut self, ctx: &egui::Context) {
use nfarms_core::chat::{self, Msg};
let vid = egui::ViewportId::from_hash_of(("nfarms-pet-chat", 0u8));
if !self.state.misc.chat_open {
// **必须显式关窗。**
//
// 只是"这一帧不再画它"是不够的:egui 不会因为你不渲染就销毁视口,
// 窗口会**带着最后一帧的内容留在屏幕上**。表现出来就是
// 「再点一次桌宠,对话框关不掉」—— 而代码看上去完全正确。
if self.state.misc.chat_was_open {
self.state.misc.chat_was_open = false;
ctx.send_viewport_cmd_to(vid, egui::ViewportCommand::Close);
self.state.log.write("pet", "对话框已关闭");
}
return;
}
self.state.misc.chat_was_open = true;
// ---- 让这个视口窗口半透明(桌面能透出来)----
//
// **第一帧必然失败**:视口刚提交,窗口还没建出来,`FindWindowW`
// 找不到 —— 所以这里每帧重试,成功一次就置位不再试。
//
// 细节和取舍见 `pet_window::make_translucent` 的文档注释
// (为什么是"整窗半透明"而不是"背景透明")。
#[cfg(windows)]
if !self.state.misc.chat_translucent
&& pet_window::make_translucent("小鲸", pet_window::CHAT_ALPHA)
{
self.state.misc.chat_translucent = true;
self.state.log.write("pet", "对话框已设为半透明");
}
// 先收上一轮的结果。`Job::poll` 必须**每帧**调,否则结果永远停在通道里。
if self.state.misc.chat_job.poll()
&& let Some(r) = self.state.misc.chat_job.result().cloned()
{
match r {
Ok(rep) => {
// **用量累计起来** —— Tokens 页显示的就是它
self.state.misc.tok_prompt += rep.prompt_tokens as u64;
self.state.misc.tok_completion += rep.completion_tokens as u64;
self.state.misc.tok_calls += 1;
self.state.misc.chat_msgs.push((false, rep.text));
self.state.misc.chat_err = None;
}
Err(e) => {
self.state.misc.chat_err = Some(e);
self.state.log.write("pet", "对话失败");
}
}
}
// 出图完成 → 读回 PNG 上纹理
if self.state.misc.chat_draw_job.poll()
&& let Some(r) = self.state.misc.chat_draw_job.result().cloned()
{
match r {
Ok(p) => {
self.state.log.write("pet", &format!("出图完成: {}", p.display()));
match image::open(&p) {
Ok(img) => {
let rgba = img.to_rgba8();
let size = [rgba.width() as usize, rgba.height() as usize];
let ci = egui::ColorImage::from_rgba_unmultiplied(size, rgba.as_raw());
self.state.misc.chat_img =
Some(ctx.load_texture("nfarms-chat-draw", ci, egui::TextureOptions::LINEAR));
self.state.misc.chat_img_path = Some(p.clone());
self.state.misc.chat_msgs.push((false, "画好了。".to_string()));
self.state.misc.chat_err = None;
}
Err(e) => {
self.state.misc.chat_err = Some(format!("图读不回来: {e}"));
}
}
}
Err(e) => {
self.state.misc.chat_err = Some(e);
self.state.log.write("pet", "出图失败");
}
}
}
let App { state, .. } = self;
// 和上面那个 `vid` **必须是同一个 hash** —— 不一致的话关闭命令
// 会发给一个不存在的视口,而 `send_viewport_cmd_to` 不会报错。
let id = vid;
let builder = egui::ViewportBuilder::default()
.with_title("小鲸")
.with_inner_size([380.0, 470.0])
.with_min_inner_size([280.0, 300.0])
// **去掉系统标题栏。** 它由 DWM 画成白色,和窗口的 `theme::APP_BG`
// 对不上 —— 高度、字体、配色都不受程序控制,而一条突兀的白条
// 比"没有标题栏"难看。
//
// 代价是拖动和关闭得自己实现(见下面自绘的那一条)——
// **不做的话窗口既挪不动也关不掉**。
.with_decorations(false)
// ---- 真透明:**试过了,不行**(留个记录,免得以后重试) ----
//
// 按 egui 文档配齐了两件事:`with_transparent(true)`
// **加上**面板 frame 也用半透明填充(文档明说 CentralPanel
// 会把底填掉,必须配合)。
//
// **实测结果:背景像素恒定。** 把窗口放在主窗口背景图上,
// 和放在桌面壁纸上,右边缘同一相对位置都读出
// `R199 G204 B201` —— 一模一样。背景不随背后内容变化,
// 就不是真透明。
//
// 另外反推出来 `clear_color` 变成了**黑色**:alpha 218 的填充
// 混出 `(199,204,201)`,比 `APP_BG(233,239,235)` 更深。
// 也就是说 egui **认了**透明意图,但窗口最终仍按不透明合成。
//
// 和早先桌宠窗口的实测一致(那次是像素亮度 69.7 vs 192.8)。
// **结论:egui 的视口在这个平台上做不了真透明。**
// 真要做得走 Win32 分层窗口(桌宠那条路),而那意味着
// 对话界面不能在 egui 里画 —— 是重写,不是改配置。
// 和桌宠一样置顶:对话时视线不该被别的窗口挡
.with_window_level(egui::WindowLevel::AlwaysOnTop);
let mut close_requested = false;
let mut send = false;
ctx.show_viewport_immediate(id, builder, |vctx, _class| {
if vctx.input(|i| i.viewport().close_requested()) {
close_requested = true;
}
egui::CentralPanel::default()
// **必须有不透明填充。** 立即视口自己没有背景,`Frame::NONE`
// 会让下半截露出 GL 清屏色(实测:窗口下面一大片黑,
// 只有画了内容的上半截是正常的)。
// **必须是不透明填充。** 试过半透明(alpha 218)配合
// `with_transparent`,结果透明没生效、底色反而和黑混合
// 变深了(199,204,201 vs APP_BG 的 233,239,235)—— 是退步。
// 见上面那条实验记录。
.frame(
egui::Frame::NONE
.fill(theme::APP_BG)
.inner_margin(egui::Margin::same(12)),
)
.show(vctx, |ui| {
// ---- 自绘标题条 ----
//
// 拖动手柄**只覆盖标题文字那一块**,不覆盖整行 ——
// 覆盖整行的话 `ui.interact` 会把右上角那个关闭按钮的
// 点击也一起吃掉(两个可交互区域重叠时谁赢不好保证)。
// ---- 拖动条:**不画任何东西,只留一块能抓的区域** ----
//
// 原来这里有个「小鲸」加一个 ✕ 按钮。✕(U+2715)**不在
// egui 自带字体里**,渲染出来是个方框 —— 一个看起来像
// 最大化按钮的东西,点下去却是关闭,那比没有按钮更糟。
//
// 标题文字也去掉:窗口自己就叫「小鲸」,标题栏里再写一遍
// 是重复。剩下的这块**空区域仍然可以拖动** ——
// 去掉系统装饰之后,不自己留一块的话窗口就挪不动了。
//
// 关闭走「再点一次桌宠」(那条路径已经修好了)。
let full = ui.available_rect_before_wrap();
let grip = egui::Rect::from_min_size(
full.min,
egui::vec2(full.width(), 20.0),
);
let dragged = ui.interact(
grip,
egui::Id::new("chat-title-grip"),
egui::Sense::drag(),
);
if dragged.drag_started() {
vctx.send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
if dragged.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
}
ui.advance_cursor_after_rect(grip);
let busy = state.misc.chat_job.is_running();
egui::ScrollArea::vertical()
// `max(60)` 是保险:窗口被拖得很矮时
// `available_height() - 52` 会变成负数,
// 而 ScrollArea 拿到负高度会算出一堆 NaN 尺寸。
.max_height((ui.available_height() - 52.0).max(60.0))
.stick_to_bottom(true)
.show(ui, |ui| {
if state.misc.chat_msgs.is_empty() {
ui.label(
RichText::new("Tips:不联网,隐私都留在本地。想聊什么放心聊!")
.size(12.0)
.color(theme::TEXT_SUB),
);
}
for (is_user, text) in &state.misc.chat_msgs {
let (who, col) = if *is_user {
("我", theme::ACCENT)
} else {
("小鲸", theme::TEXT_SUB)
};
ui.label(RichText::new(who).size(10.5).color(col));
ui.label(RichText::new(text.as_str()).size(12.5).color(theme::TEXT_MAIN));
ui.add_space(6.0);
}
if busy {
ui.label(
RichText::new("小鲸正在想…").size(11.5).color(theme::TEXT_SUB),
);
}
});
// 错误显示在**输入框上方**,不进对话历史 ——
// 进了历史下一轮会被当成"小鲸说过的话"再发回模型
if let Some(e) = &state.misc.chat_err {
ui.label(
RichText::new(e.as_str())
.size(11.0)
.color(egui::Color32::from_rgb(214, 92, 92)),
);
}
ui.horizontal(|ui| {
ui.checkbox(
&mut state.misc.chat_draw,
RichText::new("画图").size(12.0),
)
.on_hover_text(
"勾上就把这句话交给 stable-diffusion.cpp 出图,而不是交给对话模型。\
两者是不同的后端,所以**不能同时做**。出图很慢(实测一次约 2.5 分钟)。",
);
if state.misc.chat_draw {
// 分辨率**必须显式给**:sd-cli 的 -W/-H 默认 512。
// 画完再放大只是插值,细节不会多出来。
const PRESETS: [(u32, u32); 5] = [
(512, 512),
(768, 768),
(1024, 1024),
(768, 1024),
(1024, 768),
];
egui::ComboBox::from_id_salt("chat-draw-res")
.selected_text(format!(
"{}×{}",
state.misc.chat_draw_w, state.misc.chat_draw_h
))
.width(96.0)
.show_ui(ui, |ui| {
for (w, h) in PRESETS {
let sel = state.misc.chat_draw_w == w
&& state.misc.chat_draw_h == h;
if ui.selectable_label(sel, format!("{w}×{h}")).clicked() {
state.misc.chat_draw_w = w;
state.misc.chat_draw_h = h;
}
}
});
}
if state.misc.chat_draw_job.is_running() {
ui.spinner();
ui.label(
RichText::new("出图中,约 2~3 分钟…")
.size(11.0)
.color(theme::TEXT_SUB),
);
}
});
ui.horizontal(|ui| {
let w = (ui.available_width() - 68.0).max(80.0);
let r = ui.add_sized(
[w, 28.0],
egui::TextEdit::singleline(&mut state.misc.chat_input)
.hint_text("说点什么…"),
);
// 回车也能发 —— 但只在输入框有焦点时,
// 不然在别的控件上按回车也会发出去
let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
let can = !state.misc.chat_input.trim().is_empty() && !busy;
if ui.add_enabled(can, egui::Button::new("发送")).clicked() || (enter && can)
{
send = true;
}
});
// 画出来的图贴在下面,按宽度缩到不超框
if let Some(t) = &state.misc.chat_img {
let ts = t.size_vec2();
let k = (ui.available_width() / ts.x).min(1.0);
ui.add(egui::Image::new(egui::load::SizedTexture::new(
t.id(),
ts * k,
)));
if let Some(p) = &state.misc.chat_img_path {
ui.horizontal(|ui| {
if ui.button("打开所在文件夹").clicked() {
if let Some(d) = p.parent() {
nfarms_core::ffmpeg::open_dir(d);
}
}
ui.label(
RichText::new(
p.file_name()
.map(|x| x.to_string_lossy().to_string())
.unwrap_or_default(),
)
.size(10.5)
.color(theme::TEXT_SUB),
);
});
}
}
});
});
if send {
let text = state.misc.chat_input.trim().to_string();
state.misc.chat_input.clear();
state.misc.chat_msgs.push((true, text));
state.misc.chat_err = None;
// 刚推进去的那条就是这次的提示词(上面已经把 `text` move 掉了)
let prompt = state
.misc
.chat_msgs
.last()
.map(|(_, t)| t.clone())
.unwrap_or_default();
// 带上全部历史:桌宠的对话短,不需要裁剪
let mut msgs = vec![Msg::system(chat::PERSONA)];
// ---- 本地知识库 ----
//
// 拿 `wiki\` 当资料:按**这次的提问**检索,命中就附上片段。
//
// 检索不到时**什么都不加**,而不是报错 —— 没有 wiki 目录的机器上
// 对话照常能用。每段都带文件名,模型可以说清"按 wiki 里的记录",
// 而不是把内容当成自己的记忆讲出来(出处对排查很重要)。
let docs = state
.misc
.kb_docs
.get_or_insert_with(|| nfarms_core::kb::load(&state.paths.wiki()));
let kb = nfarms_core::kb::context_from(docs, &prompt);
if !kb.is_empty() {
state
.log
.write("pet", &format!("已附上 wiki 知识库({} 字符)", kb.chars().count()));
msgs.push(Msg::system(kb));
}
for (is_user, t) in &state.misc.chat_msgs {
msgs.push(if *is_user {
Msg::user(t.clone())
} else {
Msg::assistant(t.clone())
});
}
if state.misc.chat_draw {
// **画图走 sd-cli,不走对话模型。** 两者根本不是一个后端:
// 对话是 llama-server 的 HTTP;画图是一次性的 sd-cli 进程
// (4 GB 扩散 + 4.8 GB text encoder,实测一次 **148 秒**)。
let dir = state.paths.bak();
let _ = std::fs::create_dir_all(&dir);
let out = dir.join(format!("draw-{}.png", crate::now_stamp()));
let model_dir = state.paths.lib("model");
let sd = state
.paths
.libs()
.join("stable-diffusion.cpp")
.join("sd-cli.exe");
let (dw, dh) = (state.misc.chat_draw_w, state.misc.chat_draw_h);
state.misc.chat_draw_job.start(ctx, move || {
chat::draw(&prompt, &out, &model_dir, &sd, dw, dh).map(|()| out)
});
}
else {
// 端口用**唯一真值**:Setting › Proxy and Port 里的那个
let port = state.local_model.llama_port.trim().to_string();
state.misc.chat_job.start(ctx, move || {
let base = format!("http://127.0.0.1:{port}/v1");
chat::ask(
&base,
nfarms_core::libs::LLAMA_API_ALIAS,
&msgs,
chat::DEFAULT_MAX_TOKENS,
)
});
}
}
if close_requested {
state.misc.chat_open = false;
}
}
/// 把已弹出的页面画到各自的独立窗口里。
///
/// 用的是 `show_viewport_immediate` 而不是 `_deferred`:前者**同步**执行闭包,
/// 于是可以直接借用 `self` 上的状态;后者要求闭包 `'static + Send + Sync`,
/// 那样就得把 `AppState` 整个搬进 `Arc<Mutex<…>>` —— 为了一个「弹出窗口」
/// 把整个应用状态改成加锁的,不划算。
///
/// 代价:立即视口必须在**同一帧内**画完,所以页面里的 `Job` 轮询仍然由
/// 主窗口那套驱动(弹出窗口只是多一个渲染出口,不另起一套状态机)。
#[allow(dead_code)] // 弹窗功能已移除,方法留着给桌宠复用视口机制做参考
fn render_popouts(&mut self, ctx: &egui::Context) {
if self.popped.is_empty() {
return;
}
// 拆开借用:闭包里要 `&mut state`、`&kernel`、`&pages`,
// 循环之外还要改 `popped` —— 不拆的话借用检查过不去。
let App {
state,
kernel,
pages,
popped,
..
} = self;
let mut to_close: Vec<Page> = Vec::new();
for page in popped.iter().copied() {
let info = page.info();
let id = egui::ViewportId::from_hash_of(("nfarms-popout", page.id()));
let builder = egui::ViewportBuilder::default()
.with_title(format!("NFarms HUB — {}", info.title))
.with_inner_size([980.0, 720.0])
.with_min_inner_size([420.0, 320.0]);
let mut close_requested = false;
ctx.show_viewport_immediate(id, builder, |vctx, _class| {
if vctx.input(|i| i.viewport().close_requested()) {
close_requested = true;
}
egui::CentralPanel::default()
.frame(egui::Frame::NONE.inner_margin(egui::Margin::same(16)))
.show(vctx, |ui| {
// 弹出窗口没有侧栏,所以它得自己把「我是哪一页」写清楚
ui.horizontal(|ui| {
let (rect, _) = ui
.allocate_exact_size(egui::vec2(22.0, 22.0), egui::Sense::hover());
icons::draw(ui.painter(), rect, info.icon, theme::ACCENT);
ui.label(
RichText::new(info.title)
.size(19.0)
.color(theme::TEXT_MAIN),
);
ui.label(
RichText::new(info.group.nav())
.size(11.5)
.color(theme::TEXT_SUB),
);
});
ui.add_space(2.0);
ui.label(
RichText::new(plugins::plain_text(info.subtitle))
.size(12.0)
.color(theme::TEXT_SUB),
);
ui.add_space(12.0);
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
if let Some(spec) = pages.get(page) {
let mut nav = None;
let mut pcx = plugins::PageCtx {
page,
ui,
egui: vctx,
state,
kernel,
nav: &mut nav,
};
(spec.draw)(&mut pcx);
}
});
});
});
if close_requested {
to_close.push(page);
}
}
for p in to_close {
popped.retain(|x| *x != p);
}
}
}
impl App {
/// 处理拖进窗口的文件。
///
/// egui 原生支持文件拖放。**同一个动作在不同页面上含义不同**:
/// 在图片阅读器页是「打开这张」,在别处是「当窗口背景」——
/// 所以在壳这一层判断,而不是塞进 `AppState`。
fn handle_dropped_files(&mut self, ctx: &egui::Context) {
let dropped: Option<PathBuf> = ctx.input(|i| {
i.raw
.dropped_files
.iter()
.find_map(|f| f.path.clone())
});
let Some(path) = dropped else {
return;
};
if self.page == Page::ImageViewer {
self.state.viewer.load_async(ctx, &path);
self.state
.log
.write("viewer", &format!("拖入打开: {}", path.display()));
} else {
self.state.apply_background(ctx, &path);
}
}
}
impl App {
/// 侧栏 = 7 个功能分组(手风琴)+ 展开那一组的页面。
///
/// 一次只展开一组是有意的:功能树一共 30 页,全铺开光侧栏就要滚半天,
/// 而分成 7 组之后每一组都能一眼看全,切组的成本只是点一下标题。
/// 拖拽时跟着指针的「幽灵」。
fn draw_nav_ghost(&self, ctx: &egui::Context) {
let Some(d) = &self.nav_drag else { return };
let Some(pos) = ctx.pointer_interact_pos() else { return };
let info = d.page.info();
let p = ctx.layer_painter(egui::LayerId::new(
egui::Order::Tooltip,
egui::Id::new("nav-ghost"),
));
let r = egui::Rect::from_min_size(
pos + egui::vec2(16.0, 8.0),
egui::vec2((NAV_WIDTH - 44.0).max(80.0), CHILD_ROW_H),
);
// 「描边」用先画大一圈的强调色实现 —— `rect_stroke` 的签名在 egui 版本间
// 变过,不想为一条边押上 API 风险。
p.rect_filled(r.expand(1.5), 9.0, theme::ACCENT);
p.rect_filled(r, 8.0, theme::GLASS_NAV_ACTIVE);
let icon_rect = egui::Rect::from_center_size(
egui::pos2(r.left() + 18.0, r.center().y),
egui::vec2(16.0, 16.0),
);
icons::draw(&p, icon_rect, info.icon, theme::ACCENT);
p.text(
egui::pos2(r.left() + 36.0, r.center().y),
Align2::LEFT_CENTER,
info.nav,
FontId::proportional(13.5),
theme::TEXT_MAIN,
);
}
/// 画一个容器里的(一)页,并处理「点开 / 拖拽换位」。
///
/// `depth` 是缩进层级:1 = 直接挂在一级目录下的页,2 = 二级子分组里的页。
///
/// 返回 `Some(page)` 表示这一帧用户点开了某一页。
///
/// 拖拽重排**只在容器内生效**(`NavDrag::scope`):三级结构下跨容器拖拽改不了
/// `PageInfo.group` / `PageInfo.sub`(它们是编译期常量),与其做出「拖过去了但归属
/// 没变」的假象,不如明确禁止 —— 和 `pages::move_within_group` 的说明是同一件事。
fn nav_row(
&mut self,
ui: &mut egui::Ui,
ctx: &egui::Context,
specs: &[plugins::PageSpec],
depth: usize,
) -> Option<Page> {
if specs.is_empty() {
return None;
}
// 按用户顺序重排本容器。**只影响显示顺序** —— 注册表和 PageInfo 都不动。
let rank = &self.rank;
let mut ordered: Vec<&plugins::PageSpec> = specs.iter().collect();
ordered.sort_by_key(|s| rank.get(&s.info.page).copied().unwrap_or(usize::MAX));
let scope = pages::scope_of(specs[0].info.page);
let n = ordered.len();
let mut rects: Vec<egui::Rect> = Vec::with_capacity(n);
let mut started: Option<Page> = None;
let mut clicked: Option<Page> = None;
for spec in &ordered {
let active = self.page == spec.info.page;
let (rect, resp) = child_row(ui, spec.info, active, depth);
rects.push(rect);
if resp.drag_started() {
started = Some(spec.info.page);
}
// 正在拖时不认点击,否则松手会顺带跳页
if resp.clicked() && self.nav_drag.is_none() {
clicked = Some(spec.info.page);
}
}
if let Some(p) = started {
self.nav_drag = Some(NavDrag { page: p, scope, to: 0 });
}
// 拖动中:按指针 y 算插入位,并画插入线
if let Some(d) = &mut self.nav_drag
&& d.scope == scope
&& let Some(pos) = ctx.pointer_interact_pos()
{
let mut idx = 0usize;
for (i, r) in rects.iter().enumerate() {
if pos.y > r.center().y {
idx = i + 1;
}
}
d.to = idx;
if n > 0 {
let y = if idx < n { rects[idx].top() } else { rects[n - 1].bottom() };
let x0 = rects[0].left() + 16.0;
let x1 = rects[0].right() - 4.0;
ui.painter().rect_filled(
egui::Rect::from_min_max(egui::pos2(x0, y - 1.0), egui::pos2(x1, y + 1.0)),
1.0,
theme::ACCENT,
);
}
}
clicked
}
/// 松手时把新顺序落盘。
///
/// **顺序没变就不写盘**:用户可能只是按住又放开,没必要制造一次写操作
/// (也就没必要让 store.json 的修改时间无意义地跳)。
fn commit_nav_drag(&mut self, ctx: &egui::Context) {
if !ctx.input(|i| i.pointer.any_released()) {
return;
}
let Some(d) = self.nav_drag.take() else { return };
let before = self.nav_ordered.clone();
let after = pages::move_within_group(&before, d.page, d.to);
if after == before {
return;
}
self.nav_ordered = after;
self.rank = rank_of(&self.nav_ordered);
// 存 **id** 不存下标:页面增删后下标会错位,id 不会
let ids: Vec<String> = self.nav_ordered.iter().map(|p| p.id().to_string()).collect();
self.state.store.set_nav_order(&ids);
match self.state.store.save() {
Ok(()) => self
.state
.log
.write("gui", &format!("侧栏顺序已保存({} 项)", ids.len())),
// 存不下去要**说出来** —— 静默失败会让用户以为排好了,重启却回到原样
Err(e) => self
.state
.log
.write("gui", &format!("侧栏顺序保存失败: {e}")),
}
}
fn sidebar(&mut self, ctx: &egui::Context) {
let mut nav: Option<Page> = None;
egui::SidePanel::left("nav")
.exact_width(NAV_WIDTH)
.resizable(false)
.frame(egui::Frame::NONE.fill(theme::GLASS_SIDEBAR).inner_margin(12))
.show(ctx, |ui| {
// 「点任意空白处都能拖窗口」:原版用 IMessageFilter 全局接管左键按下。
// egui 这边在最底层放一个拖拽层 —— 后添加的控件优先级更高,
// 所以按钮/输入框仍然照常工作,只有空地方会拖动窗口。
let drag = ui.interact(
ui.max_rect(),
egui::Id::new("sidebar-drag"),
egui::Sense::click_and_drag(),
);
if drag.drag_started() {
ctx.send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
if drag.double_clicked() {
toggle_maximize(ctx);
}
// ---- 品牌区 ----
ui.add_space(4.0);
ui.horizontal(|ui| {
let (rect, _) =
ui.allocate_exact_size(egui::vec2(34.0, 34.0), egui::Sense::hover());
// 原版侧栏就是 nfarms.ico(整张图,笔刷 N + 红叶 + 印章)。
// 只有连内嵌那份都解不出来时才退化成色块 —— 理论上不会发生。
if self.state.logo.is_loaded() {
self.state.logo.draw(ui.painter(), rect);
} else {
ui.painter().rect_filled(rect, 10.0, theme::ACCENT);
ui.painter().text(
rect.center(),
Align2::CENTER_CENTER,
"N",
FontId::proportional(20.0),
Color32::WHITE,
);
}
ui.vertical(|ui| {
ui.add_space(1.0);
ui.label(
RichText::new("NFarms HUB")
.size(16.0)
.color(theme::TEXT_MAIN),
)
.on_hover_text(format!(
"程序根: {}\n日志: {}\n设置: {}\n插件: {} 个 / 页面: {} 张",
self.state.paths.root().display(),
self.state.log.dir().display(),
self.state.settings_path.display(),
self.kernel.len(),
self.pages.len()
));
ui.label(
RichText::new(format!("v{}", env!("CARGO_PKG_VERSION")))
.size(11.0)
.color(theme::TEXT_SUB),
);
});
});
ui.add_space(10.0);
// ---- 功能分组(三级:一级目录 → 二级子分组 → 页面)----
let groups = self.pages.grouped_tree();
// 给底部的 Minimize / Close 留位置,导航区自己滚
let nav_h = (ui.available_height() - 52.0).max(160.0);
egui::ScrollArea::vertical()
.max_height(nav_h)
.auto_shrink([false, false])
// 拖拽用于移动窗口,不能被滚动区抢走
.scroll_source(
egui::scroll_area::ScrollSource::SCROLL_BAR
| egui::scroll_area::ScrollSource::MOUSE_WHEEL,
)
.show(ui, |ui| {
for tree in &groups {
let key = NavKey::Group(tree.group);
let open = self.open_containers.contains(&key);
if group_row(ui, tree, open, tree.holds(self.page)).clicked() {
toggle_key(&mut self.open_containers, key);
}
if !open {
continue;
}
for item in &tree.items {
match item {
plugins::NavItem::Leaf(spec) => {
if let Some(p) =
self.nav_row(ui, ctx, std::slice::from_ref(spec), 1)
{
nav = Some(p);
}
}
plugins::NavItem::Subgroup(sub, children) => {
let sub_key = NavKey::Sub(*sub);
let sub_open = self.open_containers.contains(&sub_key);
let holds = children
.iter()
.any(|s| s.info.page == self.page);
if sub_row(ui, *sub, children.len(), sub_open, holds)
.clicked()
{
toggle_key(&mut self.open_containers, sub_key);
}
if !sub_open {
continue;
}
if let Some(p) =
self.nav_row(ui, ctx, children, 2)
{
nav = Some(p);
}
}
}
}
}
});
// ---- 底部:Minimize / Close(与原版位置一致)----
ui.with_layout(egui::Layout::bottom_up(egui::Align::LEFT), |ui| {
ui.add_space(2.0);
ui.horizontal(|ui| {
if ui
.add_sized([92.0, 32.0], egui::Button::new("Close"))
.clicked()
{
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
if ui
.add_sized([92.0, 32.0], egui::Button::new("Minimize"))
.clicked()
{
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true));
}
});
ui.add_space(6.0);
});
});
// 拖拽幽灵画在 Tooltip 层(浮在一切之上);松手时提交新顺序。
// **放在 SidePanel 之外**:侧栏有滚动区会裁剪,拖到边缘时幽灵会被切掉。
self.draw_nav_ghost(ctx);
self.commit_nav_drag(ctx);
if let Some(page) = nav {
self.goto(page);
}
}
fn content(&mut self, ctx: &egui::Context) {
let info = self.page.info();
let mut nav: Option<Page> = None;
egui::CentralPanel::default()
.frame(egui::Frame::NONE.inner_margin(egui::Margin {
left: 24,
right: 24,
top: 18,
bottom: 18,
}))
.show(ctx, |ui| {
let drag = ui.interact(
ui.max_rect(),
egui::Id::new("content-drag"),
egui::Sense::click_and_drag(),
);
if drag.drag_started() {
ctx.send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
if drag.double_clicked() {
toggle_maximize(ctx);
}
// 面包屑:让「这一页在功能树的哪个位置」一直是可见的
//
// 这里原来右上角有个「弹出独立窗口」按钮,已按用户要求**移除** ——
// 功能没了,按钮就不该留着占位置(留着只会让人以为能点)。
ui.horizontal(|ui| {
ui.label(
RichText::new(info.group.nav())
.size(12.0)
.color(theme::TEXT_SUB),
);
ui.label(RichText::new("›").size(12.0).color(theme::TEXT_SUB));
ui.label(
RichText::new(info.nav)
.size(12.0)
.color(theme::ACCENT_DARK),
);
});
ui.add_space(2.0);
ui.label(RichText::new(info.title).size(24.0).color(theme::TEXT_MAIN));
ui.add_space(2.0);
ui.label(
RichText::new(info.subtitle)
.size(13.0)
.color(theme::TEXT_SUB),
);
ui.add_space(14.0);
egui::ScrollArea::vertical()
.auto_shrink([false, false])
// 拖拽用于移动窗口,不能被滚动区抢走
.scroll_source(
egui::scroll_area::ScrollSource::SCROLL_BAR
| egui::scroll_area::ScrollSource::MOUSE_WHEEL,
)
.show(ui, |ui| match self.pages.get(self.page) {
Some(spec) => {
// PageSpec 是 Copy,拷出来之后对 pages 的借用就结束了,
// 接下来才能放心把 state 可变借出去 —— 两个字段互不干扰。
let mut pcx = plugins::PageCtx {
page: self.page,
ui,
egui: ctx,
state: &mut self.state,
kernel: &self.kernel,
nav: &mut nav,
};
(spec.draw)(&mut pcx);
}
None => not_installed(ui, info),
});
});
if let Some(page) = nav {
self.goto(page);
}
}
}
/// 当前平台没有装载这一页时的说明。
///
/// 正常点不到这里(侧栏只列装载了的页),但 `--page X` 有可能指到没装的那一页 ——
/// 与其给一片空白,不如说清楚为什么。
fn not_installed(ui: &mut egui::Ui, info: &pages::PageInfo) {
plugins::card(ui, |ui| {
ui.label(
RichText::new("当前平台没有装载这一页")
.size(16.0)
.color(theme::TEXT_MAIN),
);
ui.add_space(8.0);
ui.label(
RichText::new(format!(
"页面 id `{}` 属于 {} 组。安卓骨架版只装天气 / 背景图片 / 插件三页,其余功能按插件逐步加入。",
info.id,
info.group.nav()
))
.size(12.5)
.color(theme::TEXT_SUB),
);
});
}
// ===========================================================================
// 侧栏行
// ===========================================================================
/// 页面 → 它在用户顺序里的下标。侧栏每个容器排序都按它。
///
/// 做成 map 而不是在循环里线性找:一个容器几十页、每帧每个容器各排一次,
/// 线性找就是 O(n²)。
fn rank_of(ordered: &[Page]) -> std::collections::HashMap<Page, usize> {
ordered.iter().enumerate().map(|(i, p)| (*p, i)).collect()
}
/// 手风琴开合:开着就关,关着就开。
fn toggle_key(set: &mut std::collections::HashSet<NavKey>, key: NavKey) {
if !set.remove(&key) {
set.insert(key);
}
}
/// 一级目录标题行:图标 + 名字 + **页数(含子分组的)** + 展开箭头。
///
/// 页数按整棵树算 —— 三级结构下如果只数直接挂着的那几页,Tools 会显示成 5,
/// 而它下面实际有 15 页(DS-Harness 里还挂着三个)。
fn group_row(
ui: &mut egui::Ui,
tree: &plugins::NavTree,
open: bool,
holds_current: bool,
) -> egui::Response {
let group = tree.group;
let count = tree.page_count();
let (rect, resp) = ui.allocate_exact_size(
egui::vec2(ui.available_width(), GROUP_ROW_H),
egui::Sense::click(),
);
let hovered = resp.hovered();
let p = ui.painter().clone();
let row = rect.shrink2(egui::vec2(2.0, 3.0));
if open || hovered {
p.rect_filled(
row,
10.0,
if open {
theme::GLASS_NAV_ACTIVE
} else {
theme::GLASS_NAV_HOVER
},
);
}
// 展开的组带一根品牌绿竖条 —— 和原版导航激活项同一个记号
if open {
let bar = egui::Rect::from_min_size(
egui::pos2(row.left() + 1.0, row.center().y - 11.0),
egui::vec2(4.0, 22.0),
);
theme::accent_bar(&p, bar, theme::ACCENT);
}
let ico_color = if open {
theme::ACCENT
} else if hovered {
theme::NAV_TEXT_HOVER
} else {
theme::NAV_TEXT_IDLE
};
let icon_rect = egui::Rect::from_center_size(
egui::pos2(row.left() + 28.0, row.center().y),
egui::vec2(19.0, 19.0),
);
icons::draw(&p, icon_rect, group.icon(), ico_color);
p.text(
egui::pos2(row.left() + 50.0, row.center().y),
Align2::LEFT_CENTER,
group.nav(),
FontId::proportional(14.5),
ico_color,
);
// 右边:页数(当前页所在组前面点一个点)+ 展开箭头
p.text(
egui::pos2(row.right() - 22.0, row.center().y),
Align2::RIGHT_CENTER,
if holds_current {
format!("•{count}")
} else {
count.to_string()
},
FontId::proportional(11.0),
if holds_current {
theme::ACCENT
} else {
theme::TEXT_SUB
},
);
// 箭头**画出来**,不用 `▸ / ▾`:那两个字符既不在 egui 自带字体里、
// 也不在中文字体里,真机上直接渲染成豆腐块(截图里就是 "6 □")。
// 原版 README 记的「Unicode 符号被字体链接换掉」是同一类问题。
chevron(&p, egui::pos2(row.right() - 12.0, row.center().y), 4.5, open, theme::TEXT_SUB);
resp.on_hover_text(group.note())
}
/// 展开 / 收起的小三角(`open` 朝下)。
fn chevron(painter: &egui::Painter, center: egui::Pos2, r: f32, open: bool, color: Color32) {
let pts = if open {
vec![
egui::pos2(center.x - r, center.y - r * 0.55),
egui::pos2(center.x + r, center.y - r * 0.55),
egui::pos2(center.x, center.y + r * 0.75),
]
} else {
vec![
egui::pos2(center.x - r * 0.55, center.y - r),
egui::pos2(center.x - r * 0.55, center.y + r),
egui::pos2(center.x + r * 0.75, center.y),
]
};
painter.add(egui::Shape::convex_polygon(pts, color, egui::Stroke::NONE));
}
/// 二级子分组标题行(三级菜单的中间那一层)。
///
/// 和一级目录行**刻意画得不一样**,否则用户分不清「Player」和「Player 底下的 Music」
/// 哪一层是哪一层:这一行更矮、缩进、不带图标块、字号更小、箭头更小、底色更淡。
/// 计数是它底下真正挂着的页数。
fn sub_row(
ui: &mut egui::Ui,
sub: pages::Sub,
count: usize,
open: bool,
holds_current: bool,
) -> egui::Response {
let (rect, resp) = ui.allocate_exact_size(
egui::vec2(ui.available_width(), SUB_ROW_H),
egui::Sense::click(),
);
let hovered = resp.hovered();
let p = ui.painter().clone();
// 比一级目录再向右缩 16px —— 层级靠缩进一眼看出来
let row = egui::Rect::from_min_max(
egui::pos2(rect.left() + 16.0, rect.top() + 1.0),
egui::pos2(rect.right() - 2.0, rect.bottom() - 1.0),
);
if open || hovered {
p.rect_filled(
row,
8.0,
if open {
theme::GLASS_NAV_ACTIVE
} else {
theme::GLASS_NAV_HOVER
},
);
}
if open {
let bar = egui::Rect::from_min_size(
egui::pos2(row.left() + 1.0, row.center().y - 8.0),
egui::vec2(3.0, 16.0),
);
theme::accent_bar(&p, bar, theme::ACCENT);
}
let color = if open {
theme::ACCENT
} else if hovered {
theme::NAV_TEXT_HOVER
} else {
theme::NAV_TEXT_IDLE
};
// 小圆点当图标:子分组是「一层目录」,不是功能,给它画一个真图标反而更容易和页面混
p.circle_filled(
egui::pos2(row.left() + 14.0, row.center().y),
2.5,
color,
);
p.text(
egui::pos2(row.left() + 26.0, row.center().y),
Align2::LEFT_CENTER,
sub.nav(),
FontId::proportional(13.0),
color,
);
p.text(
egui::pos2(row.right() - 22.0, row.center().y),
Align2::RIGHT_CENTER,
if holds_current {
format!("•{count}")
} else {
count.to_string()
},
FontId::proportional(10.5),
if holds_current { theme::ACCENT } else { theme::TEXT_SUB },
);
chevron(
&p,
egui::pos2(row.right() - 12.0, row.center().y),
4.0,
open,
theme::TEXT_SUB,
);
resp.on_hover_text(sub.note())
}
/// 容器里的一项。
/// 画一行子页。返回 `(行矩形, 响应)`。
///
/// 返回 `rect` 是为了拖拽排序 —— 要拿每行的位置来算「插到第几个」。
/// `Sense::click_and_drag()`(原来是 `click`)让这一行能拖;
/// **点击与拖拽的区分交给 egui 的拖拽阈值**:原地松开算 `clicked()`,
/// 移动超过阈值才算 `drag_started()`。这样不用自己写延迟计时器,
/// 手感也和系统一致。
///
/// `depth` 决定缩进:1 = 直接挂在一级目录下的页,2 = 二级子分组里的页。
fn child_row(
ui: &mut egui::Ui,
info: &pages::PageInfo,
active: bool,
depth: usize,
) -> (egui::Rect, egui::Response) {
let (rect, resp) = ui.allocate_exact_size(
egui::vec2(ui.available_width(), CHILD_ROW_H),
egui::Sense::click_and_drag(),
);
let hovered = resp.hovered();
let p = ui.painter().clone();
// 每深一层缩进 16px
let indent = 14.0 + (depth.saturating_sub(1) as f32) * 16.0;
let row = egui::Rect::from_min_max(
egui::pos2(rect.left() + indent, rect.top() + 1.0),
egui::pos2(rect.right() - 2.0, rect.bottom() - 1.0),
);
if active {
p.rect_filled(row, 8.0, theme::GLASS_NAV_ACTIVE);
} else if hovered {
p.rect_filled(row, 8.0, theme::GLASS_NAV_HOVER);
}
let color = if active {
theme::ACCENT
} else if hovered {
theme::NAV_TEXT_HOVER
} else {
theme::NAV_TEXT_IDLE
};
let icon_rect = egui::Rect::from_center_size(
egui::pos2(row.left() + 18.0, row.center().y),
egui::vec2(16.0, 16.0),
);
icons::draw(&p, icon_rect, info.icon, color);
p.text(
egui::pos2(row.left() + 36.0, row.center().y),
Align2::LEFT_CENTER,
info.nav,
FontId::proportional(13.5),
color,
);
(rect, resp.on_hover_text(format!("{}\n{}", info.subtitle, info.origin())))
}
// ===========================================================================
// 小工具
// ===========================================================================
fn toggle_maximize(ctx: &egui::Context) {
let maximized = ctx.input(|i| i.viewport().maximized.unwrap_or(false));
ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(!maximized));
}
/// 时间戳,只留数字,如 `20261002224213`(yyyyMMddHHmmss)。
///
/// 注意它**不是** `20261002-224213` 那种带横线的形式 —— `local_now()` 里的
/// 分隔符会被下面的 filter 一并吃掉,界面上显示的就是这一串 14 位数字。
pub(crate) fn now_stamp() -> String {
nfarms_core::time::local_now()
.chars()
.filter(|c| c.is_ascii_digit())
.collect()
}
/// 无边框窗口的边缘拉伸 —— 原版靠 `WM_NCHITTEST` 转发,egui 这边显式判断指针位置。
fn handle_window_resize(ctx: &egui::Context) {
use egui::ResizeDirection as Dir;
const EDGE: f32 = 6.0;
let Some(pos) = ctx.input(|i| i.pointer.hover_pos()) else {
return;
};
let r = ctx.screen_rect();
let (l, rt, t, b) = (
pos.x - r.left() < EDGE,
r.right() - pos.x < EDGE,
pos.y - r.top() < EDGE,
r.bottom() - pos.y < EDGE,
);
let (dir, cursor) = match (l, rt, t, b) {
(true, _, true, _) => (Dir::NorthWest, egui::CursorIcon::ResizeNwSe),
(_, true, true, _) => (Dir::NorthEast, egui::CursorIcon::ResizeNeSw),
(true, _, _, true) => (Dir::SouthWest, egui::CursorIcon::ResizeNeSw),
(_, true, _, true) => (Dir::SouthEast, egui::CursorIcon::ResizeNwSe),
(true, _, _, _) => (Dir::West, egui::CursorIcon::ResizeHorizontal),
(_, true, _, _) => (Dir::East, egui::CursorIcon::ResizeHorizontal),
(_, _, true, _) => (Dir::North, egui::CursorIcon::ResizeVertical),
(_, _, _, true) => (Dir::South, egui::CursorIcon::ResizeVertical),
_ => return,
};
ctx.set_cursor_icon(cursor);
if ctx.input(|i| i.pointer.primary_pressed()) {
ctx.send_viewport_cmd(egui::ViewportCommand::BeginResize(dir));
}
}
/// 系统中文字体候选。**安卓和 Windows 完全不是一套路径** ——
/// 安卓上没有 `C:\Windows\Fonts`,找不到就是满屏方块。
#[cfg(target_os = "android")]
const CJK_CANDIDATES: &[&str] = &[
"/system/fonts/NotoSansCJK-Regular.ttc",
"/system/fonts/NotoSansSC-Regular.otf",
"/system/fonts/NotoSansHans-Regular.otf",
"/system/fonts/DroidSansFallback.ttf",
"/system/fonts/DroidSansChinese.ttf",
];
#[cfg(not(target_os = "android"))]
const CJK_CANDIDATES: &[&str] = &[
// 优先微软雅黑;`.ttc` 是字体集合,取第 0 个 face。
r"C:\Windows\Fonts\msyh.ttc",
r"C:\Windows\Fonts\msyh.ttf",
r"C:\Windows\Fonts\Deng.ttf",
r"C:\Windows\Fonts\simhei.ttf",
r"C:\Windows\Fonts\simsun.ttc",
r"C:\Windows\Fonts\msjh.ttc",
// 顺手给 Linux 也留几条 —— 以后要出 Linux 版不用再改
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
];
/// 装一个系统中文字体。
///
/// egui 自带的字体(epaint_default_fonts)**不含 CJK** —— 不装的话整个界面
/// 的中文都是方块。这里直接从系统字体目录读,不往仓库里塞字体文件。
fn install_cjk_font(ctx: &egui::Context) {
for path in CJK_CANDIDATES {
let Ok(bytes) = std::fs::read(path) else {
continue;
};
let mut fonts = egui::FontDefinitions::default();
fonts.font_data.insert(
"cjk".to_owned(),
std::sync::Arc::new(egui::FontData::from_owned(bytes)),
);
// 插到最前面:中文优先用这个;它没有的字符(如 ☀ 这类符号)再回退到 egui 自带字体
fonts
.families
.entry(egui::FontFamily::Proportional)
.or_default()
.insert(0, "cjk".to_owned());
fonts
.families
.entry(egui::FontFamily::Monospace)
.or_default()
.push("cjk".to_owned());
ctx.set_fonts(fonts);
return;
}
// 找不到中文字体就算了 —— 英文界面仍然可用,不乱码崩溃
}
fn install_style(ctx: &egui::Context) {
let mut style = (*ctx.style()).clone();
style.visuals = egui::Visuals::light();
style.visuals.override_text_color = Some(theme::TEXT_MAIN);
style.visuals.panel_fill = Color32::TRANSPARENT;
style.visuals.window_fill = Color32::TRANSPARENT;
let w = &mut style.visuals.widgets;
w.noninteractive.bg_stroke = egui::Stroke::new(1.0_f32, theme::BORDER);
w.inactive.corner_radius = theme::RADIUS_CTRL.into();
w.inactive.bg_fill = theme::GLASS_INPUT;
w.inactive.weak_bg_fill = theme::GLASS_INPUT;
w.hovered.corner_radius = theme::RADIUS_CTRL.into();
w.hovered.bg_fill = theme::glass(250);
w.hovered.weak_bg_fill = theme::glass(250);
w.active.corner_radius = theme::RADIUS_CTRL.into();
w.active.bg_fill = theme::ACCENT;
w.active.weak_bg_fill = theme::ACCENT_DARK;
style.visuals.selection.bg_fill = theme::ACCENT;
style.visuals.selection.stroke = egui::Stroke::new(1.0_f32, theme::ACCENT_DARK);
style.spacing.item_spacing = egui::vec2(8.0, 8.0);
ctx.set_style(style);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn event_lines_are_human_readable() {
assert_eq!(
format_event(&Event::PluginLoaded {
name: "weather".into()
}),
"插件已装载 weather"
);
assert!(
format_event(&Event::PluginFailed {
name: "x".into(),
error: "坏了".into()
})
.contains("坏了")
);
}
/// 侧栏是照注册表画的:页数必须和 PAGES 对得上(一页都不能漏)。
#[test]
fn desktop_registry_covers_every_page() {
let kernel = Kernel::new();
let registry = plugins::install(&kernel, plugins::desktop());
let groups = registry.grouped();
let total: usize = groups.iter().map(|(_, v)| v.len()).sum();
assert_eq!(total, pages::PAGES.len());
assert_eq!(registry.len(), pages::PAGES.len());
}
/// 安卓骨架:七页,分属五个一级目录。
///
/// **没有 Tools**:那一组底下全是外部可执行文件(ffmpeg / bililive-go / 打印网关),
/// 安卓上装着只会显示「未安装」—— 那是看起来能用其实在骗人。
#[test]
fn mobile_sidebar_is_a_skeleton() {
let kernel = Kernel::new();
let registry = plugins::install(&kernel, plugins::mobile());
let groups = registry.grouped();
// 顺序跟着 `Group::ALL` 走,而 **Today's Info 置顶**(见 pages.rs 里那段说明)
assert_eq!(groups.len(), 5);
assert_eq!(groups[0].0, Group::TodayInfo);
assert_eq!(groups[1].0, Group::Player);
assert_eq!(groups[2].0, Group::Vpn);
assert_eq!(groups[3].0, Group::Environment);
assert_eq!(groups[4].0, Group::Setting);
}
/// 桌面版:七个一级目录一个都不能少(需求里的「一级目录:7 个」)。
#[test]
fn desktop_sidebar_has_all_seven_groups() {
let kernel = Kernel::new();
let registry = plugins::install(&kernel, plugins::desktop());
let groups = registry.grouped();
assert_eq!(groups.len(), 7);
let names: Vec<&str> = groups.iter().map(|(g, _)| g.nav()).collect();
assert_eq!(
names,
[
// Today's Info 置顶
"Today's Info",
"Tools",
"Player",
"Browser",
"Zero Trust",
"Environment",
"Setting"
]
);
}
/// 侧栏真的画成了三级:一级目录下面既有直接挂着的页,也有二级子分组。
///
/// 这条守的是「三级结构在**绘制层**也没被打平」—— `grouped()` 那条老路
/// 仍然把子分组里的页混在一起返回,侧栏要是还照它画,二级目录就没了。
#[test]
fn sidebar_tree_has_three_levels() {
let kernel = Kernel::new();
let registry = plugins::install(&kernel, plugins::desktop());
let tree = registry.grouped_tree();
assert_eq!(tree.len(), 7, "一级目录数不对");
// Tools:先是直接挂着的页(FFmpeg…),再是 DS-Harness 子分组
let tools = tree.iter().find(|t| t.group == Group::Tools).unwrap();
assert!(
matches!(tools.items[0], plugins::NavItem::Leaf(_)),
"一级目录自己那些页必须排在子分组前面"
);
let has_sub = tools
.items
.iter()
.any(|it| matches!(it, plugins::NavItem::Subgroup(Sub::DsHarness, _)));
assert!(has_sub, "Tools 下面少了 DS-Harness 这个二级目录");
// 每个一级目录的页数 = 它下面所有叶子(含子分组里的)之和
let total: usize = tree.iter().map(|t| t.page_count()).sum();
assert_eq!(total, pages::PAGES.len(), "侧栏里的页数 != 页面总数");
}
// ------------------------------------------------------------ 观测服务
/// 一个**空** store,相当于"这台机器第一次跑这个程序"。
///
/// `Store` 没有 `Default`,而 `load` 一个不存在的路径给的就是空存储
/// (文件不存在不算错误,见 `store.rs`)—— 所以这里故意**不创建**那个文件。
fn empty_store(tag: &str) -> nfarms_core::store::Store {
let p = std::env::temp_dir().join(format!(
"nfarms-gui-store-{}-{tag}.json",
std::process::id()
));
let _ = std::fs::remove_file(&p);
nfarms_core::store::Store::load(&p)
}
fn ports3(a: &str, b: &str, c: &str) -> [String; 3] {
[a.to_string(), b.to_string(), c.to_string()]
}
/// 端口初值:三件观测服务 9091 / 9092 / 9093。
///
/// 这几条是**实测值**(和 core 的 `default_port` 一致)。谁把它们写死成别的数,
/// 这条测试就红 —— 而端口错一位的症状只是一句 connection refused,
/// 肉眼根本看不出来。
#[test]
fn observability_port_defaults_are_the_measured_ones() {
let store = empty_store("ports-default");
assert_eq!(
obs_ports_from_store(&store),
ports3("9091", "9092", "9093")
);
// 页面上那份状态(来自 MiscState::default)也必须是同一组
assert_eq!(MiscState::default().obs_ports, ports3("9091", "9092", "9093"));
// 盘上存过就原样用 —— 迁移不替用户改端口
let mut saved = empty_store("ports-saved");
saved.set_vm_port(19091);
saved.set_loki_port(19092);
saved.set_grafana_port(19093);
assert_eq!(
obs_ports_from_store(&saved),
ports3("19091", "19092", "19093")
);
}
/// 本地模型的端口初值:llama.cpp 9094 / sd.cpp 9095(旧的 8080 / 1234 已迁移)。
///
/// 只判注释里那句"缺省值以 store 为准"够不够:这里连**没存过时那份构造初值**
/// 一起钉住 —— `load_ports` 不管默认值,拉不回来就会留下 LocalModel 自己那份,
/// 而 DSH 侧 baseURL 跟着 store 走,两边对不上时只报一句 connection refused。
#[cfg(windows)]
#[test]
fn model_port_defaults_come_from_the_store() {
let store = empty_store("model-ports-default");
// store 那一层的两个缺省值(AppState::new 从它读)
assert_eq!(store.llama_port(), 9094, "llama.cpp 的旧默认 8080 已迁移到 9094");
assert_eq!(store.sd_port(), 9095, "sd.cpp 的旧默认 1234 已迁移到 9095");
// 盘上没存过 → 拿 store 的缺省(旧构造初值 8080 / 1234 必须被丢掉)
assert_eq!(
model_ports_from_store(&store, "8080", "1234"),
("9094".to_string(), "9095".to_string())
);
// 空盘上不该打"端口已从 store.json 读回" —— 那行日志是"端口记忆生效"
// 唯一可查的凭据,没读回来说成读回来了就是假证据
assert!(!model_ports_stored(&store), "空盘上没存过端口");
// 盘上存过就原样用(迁移不替用户改端口),并且那行日志该出现
let mut saved = empty_store("model-ports-saved");
saved.set_llama_port(18080);
saved.set_sd_port(11234);
assert_eq!(
model_ports_from_store(&saved, "18080", "11234"),
("18080".to_string(), "11234".to_string())
);
assert!(model_ports_stored(&saved), "盘上存过端口,那行日志才该出现");
// 盘上写坏了:退回缺省,而不是把一个解析不出来的值带到启动参数里;
// 也不该据此说"读回来了"
let mut broken = empty_store("model-ports-broken");
broken.set_str(nfarms_core::store::KEY_LLAMA_PORT, "abc");
assert_eq!(model_ports_from_store(&broken, "8080", "1234").0, "9094");
assert!(!model_ports_stored(&broken), "写了垃圾不算读回来");
}
/// 30 秒节流:边界必须是**到了 30 秒就推**(`>=`,不是 `>`)。
///
/// 抽成纯函数就是为了能在这里把边界钉死 —— 真去等 30 秒的测试没人会跑,
/// 而"跑不动所以不测"正是节流写错了也发现不了的原因。
#[test]
fn token_push_is_throttled_to_every_thirty_seconds() {
let t0 = 1_700_000_000_000u64; // 一个随便的毫秒时刻
assert!(obs_push_due(t0, 0), "还没推过(last=0)时要先送一份基线");
assert!(!obs_push_due(t0 + 29_999, t0), "差 1 毫秒就推 = 节流没生效");
assert!(
obs_push_due(t0 + 30_000, t0),
"正好 30 秒就该推(写成 > 会漏掉这一帧,表现成有时 30 秒有时 60 秒)"
);
assert!(obs_push_due(t0 + 600_000, t0), "隔了很久(机器睡了一觉)当然要推");
// 系统时间被往回调:不能算成"已经过了很久",否则会连着推
assert!(!obs_push_due(t0 - 1, t0), "时钟回拨不能把节流判成到期");
}
/// 指标行的形状:`nfarms_tokens_total{kind="prompt"} 数字`。
///
/// 标签值掉了引号的话 VM 的 import 接口只回一句 400"格式不对",
/// 从这句话里看不出是少了引号 —— 所以形状要在这里钉住。
#[test]
fn token_metric_line_is_prometheus_text_with_a_quoted_kind() {
assert_eq!(
token_metric_line("prompt", 1234),
"nfarms_tokens_total{kind=\"prompt\"} 1234"
);
assert_eq!(
token_metric_line("completion", 0),
"nfarms_tokens_total{kind=\"completion\"} 0"
);
}
// ---- 系统指标:名字 / 单位 / 形状 / 读不到时的处理 ----
/// 一份"什么都读到了"的画像,给下面几条测试共用。
fn machine_with_readings() -> MachineInfo {
MachineInfo {
hostname: "MY-PC".to_string(),
cores: 16,
ram_bytes: 32 * 1024 * 1024 * 1024,
disk_total: 1000,
disk_free: 250,
..Default::default()
}
}
/// 系统指标要**名字带 `nfarms_` 前缀、单位写进名字、形状是 `名字{标签} 数字`**。
///
/// 单位这件事不能靠"约定":推一个光秃秃的 `8589934592` 出去,看板上就得靠人
/// 记住它是字节,记错一次就得出一个荒谬的结论。所以这里连后缀一起钉住。
#[test]
fn system_metrics_carry_units_inside_the_name() {
let info = machine_with_readings();
assert_eq!(
system_metric_lines(&info),
vec![
"nfarms_cpu_cores{host=\"MY-PC\"} 16",
"nfarms_mem_total_bytes{host=\"MY-PC\"} 34359738368",
"nfarms_disk_total_bytes{host=\"MY-PC\"} 1000",
"nfarms_disk_used_bytes{host=\"MY-PC\"} 750",
]
);
// 通用形状:每行都得是 `nfarms_<带单位的名字>{标签} <数字>`
for line in system_metric_lines(&info) {
let (head, value) = line.split_once(' ').expect("行里要有空格分数值");
let name = head.split('{').next().unwrap();
assert!(name.starts_with("nfarms_"), "少了 nfarms_ 前缀: {line}");
assert!(
["_bytes", "_percent", "_cores"].iter().any(|u| name.ends_with(u)),
"名字里没写单位(必须是字节 / 百分比 / 个数): {line}"
);
assert!(value.parse::<u64>().is_ok(), "数值不是数字: {line}");
assert!(head.contains("host=\"MY-PC\""), "少了 host 标签: {line}");
}
}
/// 读不到就**不推**,而不是推 0。
///
/// `cyinfo` 里 0 的含义就是"没读到"(采不出来的平台上 `ram_bytes` /
/// `disk_total` 就是 0)。推 0 出去,图上会画出一条"内存 0 字节"的曲线 ——
/// 一条错曲线比缺一条曲线糟得多。
#[test]
fn unreadable_system_values_are_omitted_not_pushed_as_zero() {
let blank = MachineInfo {
hostname: "PC".to_string(),
..Default::default()
};
assert!(
system_metric_lines(&blank).is_empty(),
"0 等于「没读到」,不许当成读数推上去"
);
// 磁盘总量读不到时,**已用**也不许推:它是从总量减出来的
let no_disk = MachineInfo {
hostname: "PC".to_string(),
disk_free: 5,
..Default::default()
};
assert!(system_metric_lines(&no_disk).is_empty());
// 内存总量读不到 → 只少这一条,别的照推
let mem_missing = MachineInfo {
hostname: "PC".to_string(),
cores: 8,
disk_total: 100,
disk_free: 40,
..Default::default()
};
let lines = system_metric_lines(&mem_missing);
assert!(!lines.iter().any(|l| l.starts_with("nfarms_mem_")), "{lines:?}");
assert!(lines.iter().any(|l| l.starts_with("nfarms_cpu_cores")));
assert!(lines.iter().any(|l| l.starts_with("nfarms_disk_used_bytes")));
}
/// 可用空间比总量还大(脏数据)时,`已用` 收成 0,**不能回绕**。
///
/// `u64` 直接相减会得到一个天文数字,图上直接顶到天花板 ——
/// 那种曲线没人能解释,比 0 更难查。
#[test]
fn disk_used_saturates_instead_of_wrapping() {
let weird = MachineInfo {
hostname: "PC".to_string(),
disk_total: 100,
disk_free: 999,
..Default::default()
};
let lines = system_metric_lines(&weird);
assert!(
lines.contains(&"nfarms_disk_used_bytes{host=\"PC\"} 0".to_string()),
"{lines:?}"
);
}
/// 标签值必须**转义**:计算机名是用户能改的,一个双引号就能让整行变成语法错。
///
/// 那一行会被 VM 的 import 接口整个丢掉(只回一句"格式不对"),
/// 而丢的正好是这台机器上**全部**系统指标的其中一行 —— 从服务端那句话里
/// 完全看不出是主机名带了引号。
#[test]
fn label_values_are_escaped_for_prometheus() {
assert_eq!(escape_label_value(r#"A"B"#), r#"A\"B"#);
assert_eq!(escape_label_value(r"C:\PC"), r"C:\\PC");
assert_eq!(escape_label_value("a\nb"), r"a\nb");
// 换行必须变成**转义序列**,不能真换行:真换行会把一行样本劈成两行,
// 第二行是一段谁也解析不了的垃圾
assert!(!escape_label_value("a\nb").contains('\n'));
let nasty = MachineInfo {
hostname: "MY\"PC\\x".to_string(),
cores: 4,
..Default::default()
};
assert_eq!(
system_metric_lines(&nasty),
vec![r#"nfarms_cpu_cores{host="MY\"PC\\x"} 4"#]
);
// 计算机名取不到时是空串 —— `host=""` 在 Prometheus 里是合法的,
// 不许因此把整行丢掉(丢了就一条指标都没有了)
assert_eq!(gauge_line("nfarms_cpu_cores", 4, ""), "nfarms_cpu_cores{host=\"\"} 4");
}
/// 系统读数的重采周期:比推送稀疏,但边界同样是"到点就采"。
///
/// 重采一次是 11~12 个子进程(本机实测约 1.4 秒),所以它**不能**跟着
/// 30 秒的推送走 —— 这条断言把"稀疏"这件事钉住,免得以后有人顺手把它
/// 改成和推送同频(那会变成一小时一千多个短命进程,而且没有任何报错)。
#[test]
fn system_readings_refresh_on_a_longer_cycle_than_the_push() {
let t0 = 1_700_000_000_000u64;
assert!(sys_readings_due(t0, 0), "还没有过读数时要先采一次");
assert!(!sys_readings_due(t0 + 29_999, t0), "30 秒远不到重采的时候");
assert!(!sys_readings_due(t0 + OBS_SYS_REFRESH_MS - 1, t0));
assert!(sys_readings_due(t0 + OBS_SYS_REFRESH_MS, t0), "正好到点就该采");
assert!(sys_readings_due(t0 + 10 * OBS_SYS_REFRESH_MS, t0));
// 时钟被往回调:不能判成到期,否则会每帧重采一遍
assert!(!sys_readings_due(t0 - 1, t0));
// **编译期断言(const block),不是运行期。**
// 两个常量的大小关系在编译时就定死了:放运行期等于每次测试都验一遍
// 同样的常量;放编译期则**改错的那一刻就编不过**。
// (先例:`weather.rs` 的 TIMEOUT_SECS、`browser.rs` 的两个超时。)
const {
assert!(
OBS_SYS_REFRESH_MS > OBS_PUSH_INTERVAL_MS,
"重采必须比推送稀疏,否则\"少起进程\"这件事就白做了"
)
};
}
/// 端口文本框里可以是垃圾:解析不了要退回默认端口,而不是 0。
///
/// 退回 0 的话服务会去监听一个随机端口,而健康检查还打在 `127.0.0.1:0` 上,
/// 表现成"每次都超时" —— 用户不会想到是输入框被清空了。
#[test]
fn unusable_port_text_falls_back_instead_of_zero() {
let got = obs_ports_u16(&["".to_string(), " 9092 ".to_string(), "不是数字".to_string()]);
assert_eq!(got, [9091, 9092, 9093]);
}
// ------------------------------------------------------------ 启动动图(MP4)
/// 按时间找当前帧:三种边界都要钉死。
///
/// 这段逻辑错了不会报错,只会"看起来闪了一下":早于第一帧返回 `None`,
/// 动画开头会闪一下内置遮罩;晚于最后一帧返回 `None`,结尾会闪。
/// 所以两头都是**夹到端点**,只有"一帧都没有"才返回 `None`。
#[test]
fn boot_frame_lookup_sticks_to_both_ends() {
let at: Vec<u64> = vec![0, 100, 200, 300];
assert_eq!(boot_frame_index_at(0, &at), Some(0), "正好第一帧");
assert_eq!(boot_frame_index_at(50, &at), Some(0), "早于第一帧 → 停在第一帧");
assert_eq!(boot_frame_index_at(100, &at), Some(1), "正好在某一帧上");
assert_eq!(boot_frame_index_at(250, &at), Some(2), "两帧中间 → 用前那一帧");
assert_eq!(boot_frame_index_at(300, &at), Some(3), "正好最后一帧");
assert_eq!(
boot_frame_index_at(99_999, &at),
Some(3),
"晚于最后一帧要停在最后一帧,而不是 None(那样会闪回内置动画)"
);
assert_eq!(
boot_frame_index_at(0, &[]),
None,
"一帧都没有 = 没有可显示的,调用方据此回退内置动画"
);
// 第一帧不从 0 开始(比如源视频开头没有可解的画面):早于它仍要停在第一帧
let late: Vec<u64> = vec![100, 200];
assert_eq!(boot_frame_index_at(0, &late), Some(0));
}
/// 视频那一支的总时长要把**最后一帧的间隔**算进去。
///
/// 只取最后一个偏移的话,最后一帧永远来不及显示(走到它的偏移就已经
/// 判成"演完了")。
#[test]
fn boot_video_total_covers_the_last_frame() {
assert_eq!(boot_video_total_ms(&[]), 0, "空表不该 panic");
assert_eq!(
boot_video_total_ms(&[0, 100, 200]),
300,
"3 帧 × 100 ms:最后一帧也要各占一帧的时间"
);
}
/// 「该用 MP4 还是内置」:没选 / 选了但没抽完 / 选好了。
///
/// 复发场景:判据写成只看 `boot_video.is_some()`。ffmpeg 没装好、文件损坏、
/// 抽帧还没跑完时,用户看到的是一整块**黑**,只会以为程序坏了 ——
/// 那时退一档放内置动画,至少还能看出"它在启动"。
#[test]
fn boot_uses_mp4_only_when_frames_are_ready() {
let p = Path::new(r"C:\video\boot.mp4");
assert!(!boot_use_video(None, &[]), "什么也没选 → 内置");
assert!(
!boot_use_video(None, &[0, 100]),
"路径被清掉但帧表还在(清除时漏清了帧)→ 内置,别放上一个视频的残留"
);
assert!(!boot_use_video(Some(p), &[]), "选了但一帧都没抽到 → 内置(不是黑屏)");
assert!(boot_use_video(Some(p), &[0]), "抽到一帧就得算数");
assert!(boot_use_video(Some(p), &[0, 100, 200]), "选好了 → MP4");
}
/// 抽帧失败 / 文件不在时,那一句提示必须**是给用户看的**、带卡片前缀。
///
/// 前缀不对的话卡片会把它过滤掉(`core_pages.rs::boot_message`),用户
/// 就永远看不到"为什么我选的视频没生效"。
#[test]
fn boot_messages_carry_the_card_prefix() {
// 卡片只认这个前缀,写成别的等于把话吞掉
assert_eq!(BOOT_MSG_PREFIX, "启动动画:");
// store 的键名是跨文件的字符串契约(本文件读、core_pages 写)
assert_eq!(BOOT_VIDEO_KEY, "boot_video");
assert_eq!(BOOT_SOUND_KEY, "boot_sound");
}
/// 「盘上已经就是这样」的判据:一样就不写盘(别动用户手工编辑过的文件),
/// 不一样就必须写(否则改完重启回到默认,而界面上看不出异常)。
#[test]
fn boot_switch_write_is_skipped_only_when_nothing_changed() {
// 全新盘:没选视频、声音缺省开 —— 一个键都不该被写上去
assert!(boot_switches_unchanged(None, "1", None, None));
assert!(
boot_switches_unchanged(None, "1", Some(""), None),
"盘上留着空串 = 没选,和「没有这个键」同义"
);
assert!(
boot_switches_unchanged(None, "1", None, Some(" 1 ")),
"值两边带空白不影响判断(手改过的文件常见)"
);
// 已经就是目标值:不写
assert!(boot_switches_unchanged(
Some(r"C:\anim\boot.mp4"),
"0",
Some(r"C:\anim\boot.mp4"),
Some("0")
));
// 真变了:必须写
assert!(!boot_switches_unchanged(Some(r"C:\anim\boot.mp4"), "1", None, None));
assert!(
!boot_switches_unchanged(None, "0", None, None),
"关掉声音也要落盘,否则重启又出声"
);
assert!(
!boot_switches_unchanged(None, "1", Some(r"C:\anim\boot.mp4"), None),
"清掉视频要落盘(键得删掉)"
);
assert!(
!boot_switches_unchanged(
Some(r"C:\anim\boot.mp4"),
"1",
Some(r"C:\anim\boot.mp4"),
Some("0")
),
"只变了声音那一个也要写"
);
}
/// 两个开关的往返:写 → save → 重新 load 还在;写坏的值回落默认而不是 panic。
#[test]
fn boot_switches_roundtrip_through_the_store() {
let p = std::env::temp_dir().join(format!(
"nfarms-gui-boot-store-{}.json",
std::process::id()
));
let _ = std::fs::remove_file(&p);
// 全新安装:没有视频、声音默认开
let fresh = nfarms_core::store::Store::load(&p);
assert_eq!(
boot_video_from_store(fresh.get_str(BOOT_VIDEO_KEY).as_deref()),
None
);
assert!(
boot_sound_from_store(fresh.get_str(BOOT_SOUND_KEY).as_deref()),
"默认该出声:选了 MP4 的人多半就是要连声音一起放"
);
// 用户选了视频、把声音关掉
let mut saved = nfarms_core::store::Store::load(&p);
saved.set_str(BOOT_VIDEO_KEY, r"C:\anim\boot.mp4");
saved.set_str(BOOT_SOUND_KEY, "0");
saved.save().unwrap();
// 换一个 Store 实例重新读 —— 只有真落盘了才读得回来
let back = nfarms_core::store::Store::load(&p);
assert_eq!(
boot_video_from_store(back.get_str(BOOT_VIDEO_KEY).as_deref()),
Some(PathBuf::from(r"C:\anim\boot.mp4"))
);
assert!(!boot_sound_from_store(back.get_str(BOOT_SOUND_KEY).as_deref()));
// 写坏的值:路径不是字符串 → 没选(用内置);开关说不清 → 回落默认(开)。
// 两处都**不许 panic** —— 这段读的就是启动路径。
std::fs::write(&p, r#"{"boot_video":42,"boot_sound":"也许"}"#).unwrap();
let bad = nfarms_core::store::Store::load(&p);
assert_eq!(
boot_video_from_store(bad.get_str(BOOT_VIDEO_KEY).as_deref()),
None,
"不是字符串 = 读不出来,回默认而不是炸"
);
assert!(
boot_sound_from_store(bad.get_str(BOOT_SOUND_KEY).as_deref()),
"说不清的值回落到默认(开)"
);
// 用户把路径清空会留下空串,那也算"没选"
assert_eq!(boot_video_from_store(Some(" ")), None);
let _ = std::fs::remove_file(&p);
}
/// **原缺陷的正面那条**:选了 MP4 之后「启动时播放」必须是开。
///
/// 判据在 `boot_video_set` 里走的就是这个函数 —— 不全的话,store.json 里
/// `boot_video` 存住了而 `boot_anim` 没开:动画不播,用户以为"设置没保存",
/// 而他从这个现象反推不出原因。
#[test]
fn picking_an_mp4_turns_the_boot_switch_on() {
// 默认关(见 `App::new`)的时候选一个 MP4 → 必须被顶成开。
assert!(
boot_enabled_after_pick(true, false),
"选了 MP4 却不开「启动时播放」= 存了但看不见效果,正是这次要修的缺陷"
);
// 已经开着:再选一个文件还是开(不能把它顶回去)。
assert!(boot_enabled_after_pick(true, true), "选第二个文件不该把开关关掉");
// 清掉视频**不反过来关**开关:那是用户对"启动时播放"的独立选择。
assert!(
boot_enabled_after_pick(false, true),
"清视频只表示改回内置动画,不代表用户想关掉「启动时播放」"
);
}
/// **反面那条**:没选视频时,不许自动打开「启动时播放」。
///
/// 默认关是有意的(一个程序不该一启动就挡你 1.4 秒)。这条要是倒了,
/// 「默认关」这个决定就被一次空手而归的调用悄悄作废了 —— 而且是每次都作废。
#[test]
fn not_picking_a_video_leaves_the_boot_switch_off() {
assert!(
!boot_enabled_after_pick(false, false),
"没选 MP4 时不该自动打开「启动时播放」"
);
// 对照:`boot_video_set(None, …)` 走的就是 `false` 这一支 —— 它必须保持
// 「没选过视频的全新安装」里那个默认值(`MiscState::default()` 是 false)。
assert!(!MiscState::default().boot_enabled, "「启动时播放」默认必须是关");
}
/// 「`boot_anim` 要不要落盘」:一样就不写,不一样就必须写。
///
/// 这条错了不会报错,只会**静默地**:要么白写(把用户手改过的 store.json
/// 重排一遍),要么该写不写(选了 MP4 之后开关只留在内存里,重启回到关 ——
/// 也就是原缺陷换个位置复发)。
#[test]
fn boot_anim_is_written_only_when_it_really_changed() {
// 全新盘:没有这个键 = 默认关。想要关 → 一个字节都不该写。
assert!(
boot_anim_unchanged(false, None),
"默认关的新安装不该被补写一个 boot_anim"
);
assert!(boot_anim_unchanged(false, Some("0")), "已经是关 → 不写");
assert!(boot_anim_unchanged(true, Some("1")), "已经是开 → 不写");
assert!(
boot_anim_unchanged(true, Some(" 1 ")),
"手改过、两边带空白的 1 照样算开(口径和启动那边读的一样)"
);
// **核心那一条**:选了 MP4 把开关从关顶成开 —— 盘上没有键 / 是 "0",
// 都必须真的写下去,否则重启读回来还是关。
assert!(!boot_anim_unchanged(true, None), "从默认关变成开必须落盘");
assert!(!boot_anim_unchanged(true, Some("0")), "0 → 1 必须落盘");
assert!(!boot_anim_unchanged(false, Some("1")), "用户取消了勾也要落盘");
// 写坏的/说不清的值读出来是关(`boot_anim_from_store` 只认 "1"):
// 想要开的时候必须覆盖掉它,而不是当成"已经一样"跳过。
assert!(
!boot_anim_unchanged(true, Some("也许")),
"读不出来的值 = 关,想开就得写"
);
assert!(boot_anim_unchanged(false, Some("也许")), "读出来是关,想要关 → 不写");
}
/// 选 MP4 这一路要落到盘上的三个值:写 → save → 重新 load,`boot_anim` 也在。
///
/// 上面两条测的是判据,这一条测**盘上真的读得回来**:`boot_video_set` 里
/// 「顶成开 + 立刻落盘」是一次动作,少了落盘那半,现象还是"选了视频、重启不播"。
#[test]
fn picking_an_mp4_persists_the_boot_switch_along_with_the_path() {
let p = std::env::temp_dir().join(format!(
"nfarms-gui-boot-pick-{}.json",
std::process::id()
));
let _ = std::fs::remove_file(&p);
// 全新盘:没选视频、开关默认关。这时 `save_boot_switches` 该一个字节都不写。
let fresh = nfarms_core::store::Store::load(&p);
assert!(boot_anim_unchanged(false, fresh.get_str(BOOT_ANIM_KEY).as_deref()));
assert!(!p.exists(), "读设置不该有副作用:没变化就不许建文件/重排盘上那份");
// 用户选了 MP4:`boot_video_set` 里那两步 —— 开关被顶成开,然后落盘。
let mut s = nfarms_core::store::Store::load(&p);
let enabled = boot_enabled_after_pick(true, false);
assert!(enabled, "选了 MP4 → 开关变开");
assert!(
!boot_anim_unchanged(enabled, s.get_str(BOOT_ANIM_KEY).as_deref()),
"开关从关变开必须落盘,否则重启读回来还是关"
);
s.set_str(BOOT_VIDEO_KEY, r"C:\anim\boot.mp4");
s.set_str(BOOT_ANIM_KEY, if enabled { "1" } else { "0" });
s.save().unwrap();
// 换一个实例重新读:只有真落盘了才读得回来(启动时读的就是这两个键)。
let back = nfarms_core::store::Store::load(&p);
assert!(
boot_anim_from_store(back.get_str(BOOT_ANIM_KEY).as_deref()),
"重启后「启动时播放」必须还是开 —— 这就是用户看到的那个勾"
);
assert_eq!(
boot_video_from_store(back.get_str(BOOT_VIDEO_KEY).as_deref()),
Some(PathBuf::from(r"C:\anim\boot.mp4"))
);
let _ = std::fs::remove_file(&p);
}
}