crates\nfarms-gui\src\logo.rs¶
源码路径:
crates\nfarms-gui\src\logo.rs· 950 行
//! 应用 logo(`nfarms.ico`)。
//!
//! 对应 C# 版的 `Ui.LoadLogo(size)`,以及 build-nfarms.ps1 里的
//! `/win32icon:nfarms.ico`。原版注释写得很清楚:
//!
//! > 图标要放在程序根(exe 里也嵌了一份,这份是给「窗口/托盘图标」兜底用的)
//!
//! 这里照搬这个策略:**优先读程序根下的 `nfarms.ico`**(用户可以自己换),
//! 读不到就用编译进 exe 的那一份,永远不会出现「没 logo」。
//!
//! ## 为什么不直接引 `image` 的 ico 特性
//!
//! `nfarms.ico` 里 9 张图有 8 张是 BMP/DIB 格式、只有 256×256 那张是 PNG。
//! 容器格式极简单(6 字节头 + 每条 16 字节的目录项),自己剥出来比多引一个依赖划算。
//!
//! BMP/DIB 条目**现在也支持**,但只支持 **32 位 BI_RGB** 那一种:用户自己换进来的
//! 传统 ico 常常整份都是 BMP 条目,只认 PNG 的话那种图标会静默不生效(退回内嵌那份,
//! 界面上看不出任何区别)。8/24 位调色板条目仍然不做 —— 那要再写一套调色板展开,
//! 而缩到 34px 用不上,报错信息里会说明是位深不支持。
//!
//! 实测这个 ico 的 256×256 条目**四角与中心 alpha 都是 0**(真透明),
//! 所以直接贴在雾玻璃或照片背景上都干净,不需要白色托板。
use std::path::{Path, PathBuf};
use eframe::egui;
/// 编译进 exe 的那一份,磁盘上找不到时兜底。
const EMBEDDED_ICO: &[u8] = include_bytes!("../../../assets/nfarms.ico");
/// 图标文件名(约定放程序根,和应用目录同级的 `nfarms.ico`)
///
/// `pub(crate)` 而不是 `pub`:`mod logo` 本身是私有的(`lib.rs` 里写的 `mod logo;`),
/// 写成 `pub` 只会让人以为 crate 外也能拿到。
pub(crate) const ICON_FILE: &str = "nfarms.ico";
/// 从 ICO 字节里解出面积最大的那张图并解码。
///
/// **PNG 优先,再退回 32 位 BMP/DIB**:ico 里带 PNG 条目的一定是最大那张
/// (现代工具写 256×256 都走 PNG),混合格式时保持这一优先次序 = 保持既有行为不变;
/// 只有 BMP 条目的传统 ico 以前完全解不开,现在走第二条路。
pub fn decode_ico(ico: &[u8]) -> Result<egui::ColorImage, String> {
match extract_largest_png(ico) {
Ok(png) => decode_png(&png),
// 「一张 PNG 都没有」要退回 BMP;而「头不对 / 目录被截断」这种结构性错误
// 在下面那次扫描里会原样再报一次,所以这里不必去分辨错误的类型。
Err(_) => match extract_largest_dib(ico)? {
Some(dib) => decode_dib32(&dib),
None => Err(
"ICO 里没有可用的图像(需要 PNG 或 32 位 BMP/DIB 条目;8/24 位调色板条目不支持)"
.into(),
),
},
}
}
/// PNG 载荷 → `ColorImage`。
fn decode_png(png: &[u8]) -> Result<egui::ColorImage, String> {
let img = image::load_from_memory(png).map_err(|e| format!("PNG 解码失败:{e}"))?;
let rgba = img.to_rgba8();
Ok(egui::ColorImage::from_rgba_unmultiplied(
[rgba.width() as usize, rgba.height() as usize],
rgba.as_raw(),
))
}
/// 一条**边界已经校验过**的目录项:载荷切片一定落在文件范围内。
struct Entry<'a> {
/// 宽(目录项里 0 表示 256)
w: u32,
/// 高(目录项里 0 表示 256)
h: u32,
data: &'a [u8],
}
impl Entry<'_> {
fn area(&self) -> u32 {
// 目录项的宽高各只有 1 字节,最大 256×256,不会溢出
self.w * self.h
}
fn is_png(&self) -> bool {
// PNG 魔数 89 50 4E 47
self.data.starts_with(&[0x89, b'P', b'N', b'G'])
}
}
/// 取出面积最大的一条满足 `keep` 的条目;面积相同时保留**先遇到的**(目录项顺序)。
fn pick_largest<'a, 'b>(
entries: &'a [Entry<'b>],
keep: fn(&Entry<'b>) -> bool,
) -> Option<&'a Entry<'b>> {
let mut best: Option<&'a Entry<'b>> = None;
for e in entries.iter().filter(|e| keep(e)) {
if best.is_none_or(|b| e.area() > b.area()) {
best = Some(e);
}
}
best
}
/// 扫一遍 `ICONDIR` 和全部 16 字节的目录项,只留下边界合法的那些。
///
/// ICO 结构:`ICONDIR`(6 字节:保留位 / 类型 / 图像数)+ 每条 16 字节的
/// `ICONDIRENTRY`(宽 / 高 / 调色板数 / 保留位 / 位深 / 保留位 / 数据长度 / 数据偏移)。
///
/// 目录项越界以前是 `break`(当文件正常接着往下走)。文件被截断时前面的条目往往还是好的,
/// 于是「ico 的目录坏了」这件事永远没人发现 —— 现在直接报错。
fn scan_entries(ico: &[u8]) -> Result<Vec<Entry<'_>>, String> {
if ico.len() < 6 {
return Err("ICO 文件太小".into());
}
let reserved = u16::from_le_bytes([ico[0], ico[1]]);
let kind = u16::from_le_bytes([ico[2], ico[3]]);
if reserved != 0 || kind != 1 {
return Err("不是 ICO 文件(头两个字段应当是 0 和 1)".into());
}
let count = u16::from_le_bytes([ico[4], ico[5]]) as usize;
if count == 0 {
return Err("ICO 里一张图都没有".into());
}
let mut entries = Vec::new();
for i in 0..count {
let o = 6 + i * 16;
if o + 16 > ico.len() {
return Err(format!(
"ICO 目录被截断:头部声明 {count} 张图,实际只放得下 {i} 条"
));
}
let w = ico[o] as u32;
let h = ico[o + 1] as u32;
let (w, h) = (if w == 0 { 256 } else { w }, if h == 0 { 256 } else { h });
let size = u32::from_le_bytes([ico[o + 8], ico[o + 9], ico[o + 10], ico[o + 11]]) as usize;
let off = u32::from_le_bytes([ico[o + 12], ico[o + 13], ico[o + 14], ico[o + 15]]) as usize;
// 边界必须写成 `off > len || size > len - off`:先挡 off 再减,减法不可能下溢;
// 写成 `off + size > len` 的话,32 位目标上 off=0xFFFF_FFFF、size=1 会回绕成 0,
// 校验被绕过,后面按 off 切片就 panic(窗口还没创建就崩)。
if size == 0 || off > ico.len() || size > ico.len() - off {
continue;
}
entries.push(Entry {
w,
h,
data: &ico[off..off + size],
});
}
Ok(entries)
}
/// 取出 ICO 里面积最大的那张 PNG 条目。
pub fn extract_largest_png(ico: &[u8]) -> Result<Vec<u8>, String> {
let entries = scan_entries(ico)?;
pick_largest(&entries, Entry::is_png)
.map(|e| e.data.to_vec())
.ok_or_else(|| "ICO 里没有 PNG 格式的图像".to_string())
}
/// 取出 ICO 里面积最大的那条 32 位 BMP/DIB 条目;一条都没有时给 `Ok(None)`。
///
/// 返回 `Ok(None)` 而不是 `Err`:调用方要靠它区分「这张 ico 一张能用的图都没有」
/// (要报错)和「只是没有 PNG」(要换条路走)。
fn extract_largest_dib(ico: &[u8]) -> Result<Option<Vec<u8>>, String> {
let entries = scan_entries(ico)?;
Ok(pick_largest(&entries, |e| !e.is_png() && is_dib32(e.data)).map(|e| e.data.to_vec()))
}
/// 这条载荷像不像 32 位 BI_RGB 的 DIB(ICO 的 BMP 条目:没有 `BITMAPFILEHEADER`)。
///
/// 只做「能不能交给 `decode_dib32`」的粗筛,真正的合法性由 `decode_dib32` 逐项查 ——
/// 这里宽高都没读,所以不能拿它当校验用。
fn is_dib32(dib: &[u8]) -> bool {
// 头长 4 字节、位深在 14..16、压缩方式在 16..20 —— 短于 20 就什么都读不出来
if dib.len() < 20 {
return false;
}
let header = u32::from_le_bytes([dib[0], dib[1], dib[2], dib[3]]);
let bpp = u16::from_le_bytes([dib[14], dib[15]]);
let compression = u32::from_le_bytes([dib[16], dib[17], dib[18], dib[19]]);
(40..=124).contains(&header) && header % 4 == 0 && bpp == 32 && compression == 0
}
/// 解一条 32 位 BI_RGB 的 DIB(ICO 里的 BMP 条目)。
///
/// ICO 的 BMP 条目 = 去掉 14 字节 `BITMAPFILEHEADER` 的 DIB:头部 + **倒序**(底行在前)
/// 的 XOR 位图 + 1bpp 的 AND 掩码。32 位条目的 XOR 位图就是 BGRA。
///
/// 透明度取法(顺序不能反):
/// ① XOR 位图里有任何非 0 的 alpha → 用它(现代工具写的 32 位 ico 都带真 alpha);
/// ② alpha 全 0(老工具只写 AND 掩码)→ 看 AND 掩码,掩码位为 1 表示全透明。
/// 少了 ②,这类图标会整张 alpha=0,画出来等于没画。
fn decode_dib32(dib: &[u8]) -> Result<egui::ColorImage, String> {
if dib.len() < 20 {
return Err("BMP 条目太小,放不下 DIB 头".into());
}
let header = u32::from_le_bytes([dib[0], dib[1], dib[2], dib[3]]) as usize;
if !(40..=124).contains(&header) || header % 4 != 0 {
return Err(format!("BMP 条目的 DIB 头长度 {header} 不认识"));
}
let w = i32::from_le_bytes([dib[4], dib[5], dib[6], dib[7]]);
let h2 = i32::from_le_bytes([dib[8], dib[9], dib[10], dib[11]]);
let bpp = u16::from_le_bytes([dib[14], dib[15]]);
let compression = u32::from_le_bytes([dib[16], dib[17], dib[18], dib[19]]);
if bpp != 32 || compression != 0 {
return Err(format!(
"BMP 条目是 {bpp} 位、压缩方式 {compression},只支持 32 位 BI_RGB"
));
}
// ICO 里的高度是 XOR + AND 两张位图叠起来的高度,必然是正的偶数
if w <= 0 || h2 <= 0 || h2 % 2 != 0 {
return Err(format!("BMP 条目的尺寸不合法({w}×{h2})"));
}
let (w, h) = (w as usize, (h2 / 2) as usize);
// 目录项的宽高只有 1 字节,头部却可以写任意值。4096 足够覆盖正常图标,
// 同时让下面几处乘法在 32 位目标上也不可能溢出。
if w > 4096 || h > 4096 {
return Err(format!("BMP 条目太大({w}×{h}),不当图标用"));
}
let stride = w * 4; // 32bpp 每行本来就是 4 字节对齐,没有 padding
let mask_stride = w.div_ceil(32) * 4; // AND 掩码每行按 4 字节对齐
let xor_len = stride * h;
let mask_len = mask_stride * h;
if dib.len() < header + xor_len + mask_len {
return Err("BMP 条目的像素数据被截断".into());
}
let xor = &dib[header..header + xor_len];
let mask = &dib[header + xor_len..header + xor_len + mask_len];
// 只要有一个像素的 alpha 非 0,就认为这张图真的带了 alpha 通道
let has_alpha = xor.iter().skip(3).step_by(4).any(|&a| a != 0);
let mut rgba = vec![0u8; w * h * 4];
for y in 0..h {
// DIB 底行在前,`ColorImage` 顶行在前 —— 取源行时要翻过来
let src = (h - 1 - y) * stride;
let mrow = (h - 1 - y) * mask_stride;
for x in 0..w {
let s = src + x * 4;
let alpha = if has_alpha {
xor[s + 3]
} else if mask[mrow + x / 8] & (0x80 >> (x % 8)) != 0 {
0
} else {
255
};
let d = (y * w + x) * 4;
// 32 位 DIB 的 BGRA 是**非预乘**的,egui 要的 `from_rgba_unmultiplied` 也是,
// 所以这里只换通道顺序,不做预乘
rgba[d] = xor[s + 2];
rgba[d + 1] = xor[s + 1];
rgba[d + 2] = xor[s];
rgba[d + 3] = alpha;
}
}
Ok(egui::ColorImage::from_rgba_unmultiplied([w, h], &rgba))
}
/// 载入 logo:先看磁盘,再退回内嵌的那份。
///
/// `dir` 传程序根(去找 `<dir>\nfarms.ico`)。第二个返回值是「走了兜底」的原因,
/// 只在**磁盘上那份确实有问题**时才非空,调用方(`lib.rs`)逐条写日志。
///
/// 文件不存在**不算**问题:`nfarms.ico` 本来就是可选的自定义图标,程序根下没有它是
/// 常态,回退内嵌那份正是设计意图 —— 所以这里不产生任何提示(否则每次启动都刷一条噪音)。
///
/// 类型仍是 `Vec<String>` 而不是 `Option<String>`:同一次调用里最多只会 push 一条
/// (读失败与解码失败互斥,成功分支直接 return),但调用方是 `for note in &logo_notes`,
/// 换成 `Option` 就得同时把 `lib.rs` 那个循环改成 `if let`(否则触发
/// `for_loops_over_fallibles` 警告),那是另一个文件的改动,不在本次范围内。
pub fn load(dir: &Path) -> (egui::ColorImage, Vec<String>) {
let mut notes = Vec::new();
let on_disk = dir.join(ICON_FILE);
if on_disk.is_file() {
match std::fs::read(&on_disk) {
Ok(bytes) => match decode_ico(&bytes) {
Ok(img) => return (img, notes),
Err(e) => notes.push(format!(
"{} 用不了({e}),已退回内置图标",
on_disk.display()
)),
},
Err(e) => notes.push(format!(
"读不了 {}:{e},已退回内置图标",
on_disk.display()
)),
}
}
let embedded =
decode_ico(EMBEDDED_ICO).expect("内嵌图标损坏 —— 这是编译期就该发现的错误");
(embedded, notes)
}
/// logo 纹理的采样方式。
///
/// 侧栏只画 34×34,源图是 256×256 —— 缩 7.5 倍只做双线性采样会丢细节、边缘发跳,
/// 所以显式开 mipmap。本项目是 glow 后端(`Cargo.toml` 里 eframe 开着 `glow`),
/// 它是 egui 里唯一支持 mipmap 的后端;别的后端会忽略这个字段,退化成普通 LINEAR,
/// 不会报错。`TextureOptions::LINEAR` 的 `mipmap_mode` 是 `None`,直接用它就没有 mipmap。
const LOGO_TEXTURE_OPTIONS: egui::TextureOptions =
egui::TextureOptions::LINEAR.with_mipmap_mode(Some(egui::TextureFilter::Linear));
/// 界面里用的 logo 纹理。
///
/// **不变量:只要有 `Logo`,就一定有纹理。** 它只能由 [`Logo::new`] 造出来
/// (故意不实现 `Default` —— 那会让「空 logo」在模块外也能凭空造出来,把这条不变量
/// 变成靠自觉维持),`texture` 因此永远是 `Some`。
///
/// 所以 `lib.rs` 侧栏里那段「`is_loaded()` 为假就画色块 + 字母 N」的兜底分支今天不可达,
/// 它是给「将来真出现无图状态」留的,而不是 [`Logo::draw`] 的分支 ——
/// `draw` 在没纹理时静默什么都不画(不画色块),画什么由调用方决定。
pub struct Logo {
texture: Option<egui::TextureHandle>,
}
impl Logo {
pub fn new(ctx: &egui::Context, image: egui::ColorImage) -> Self {
Self {
texture: Some(ctx.load_texture("nfarms-logo", image, LOGO_TEXTURE_OPTIONS)),
}
}
pub fn is_loaded(&self) -> bool {
self.texture.is_some()
}
/// 按短边等比画进 `rect`(居中)。
///
/// 没有纹理时不画任何东西 —— **不**在这里退化成色块:调用方(`lib.rs` 侧栏)
/// 才知道该画什么兜底,这里画一份就会和它的兜底重复。
pub fn draw(&self, painter: &egui::Painter, rect: egui::Rect) {
let Some(tex) = &self.texture else {
return;
};
let ts = tex.size_vec2();
if ts.x <= 0.0 || ts.y <= 0.0 {
return;
}
let scale = (rect.width() / ts.x).min(rect.height() / ts.y);
painter.image(
tex.id(),
egui::Rect::from_center_size(rect.center(), ts * scale),
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
egui::Color32::WHITE,
);
}
}
// ===========================================================================
// Grafana 真白标:换素材文件
// ===========================================================================
//
// # 为什么是"换文件"而不是配 `[branding]`
//
// `custom.ini` 里的 `[branding]`(app_title / login_logo / menu_logo / favicon)
// 在这个 Grafana **OSS** 构建里是**付费功能**:`/api/admin/settings` 会把四个键
// 原样收下("配置被接受"),但没有任何代码去读它们 —— 实测登录页 HTML 里
// `nfarms` / `loginLogo` / `appTitle` / `branding` 各出现 0 次,`<title>` 仍是
// `Grafana`。所以路只剩一条:**把 Grafana 自己的那几个素材文件换掉**。
//
// # 为什么两个 `.svg` 要包一层、不能直接塞 PNG 字节
//
// 四个目标文件的名字与扩展名**都是写死的**(登录页 HTML 引用前三个,侧栏菜单的
// logo 由 JS 按第四个路径加载),一个字都不能改。而 Grafana 是**按扩展名**给
// `Content-Type` 的:`.svg` 发 `image/svg+xml`。往一个发 `image/svg+xml` 的位置塞
// PNG 字节,浏览器按 SVG 解 → 失败 → 裂图。所以 `.svg` 那两个写**用 SVG 包住
// base64 PNG** 的文本(内容真的是合法 SVG,用 PNG 显示),`.png` 那两个直接写
// PNG 字节(浏览器看的是内容不是扩展名)。
//
// # 备份
//
// 每个文件在被改**之前**留一份 `<原名>.nfarms-orig`;备份已经存在就不再动它 ——
// 否则第二次调用会把备份覆盖成 nfarms 自己的图,「还原」这条路就断了。
/// Grafana 里那几个「路径写死」的素材,路径相对 `<libs>\grafana\`。
///
/// 逐段 `split('/')` 再 `push`,不直接 `join("public/img/x.svg")`:后者在 Windows
/// 上会拼出 `…\public/img/x.svg` 这种混着两种分隔符的路径(core 里的
/// `branding_logo_path` 是同一个理由)。
const GRAFANA_IMG_TARGETS: [(&str, AssetKind); 4] = [
// 登录页那个大 logo
("public/img/grafana_mask_icon.svg", AssetKind::Svg),
// 侧栏菜单的 logo(JS 加载)
("public/img/grafana_icon.svg", AssetKind::Svg),
// 浏览器标签页图标
("public/build/img/fav32.png", AssetKind::Png),
// iOS 主屏图标
("public/build/img/apple-touch-icon.png", AssetKind::Png),
];
/// 这个位置上该写什么(文件名/扩展名不变,内容类型按 Grafana 给的 `Content-Type` 定)。
#[derive(Clone, Copy, PartialEq, Eq)]
enum AssetKind {
/// 包一层 SVG 的 base64 PNG(Grafana 按 `image/svg+xml` 发它)
Svg,
/// 裸 PNG 字节
Png,
}
/// 原件的备份后缀。
const BACKUP_SUFFIX: &str = ".nfarms-orig";
/// SVG 的边长。那张 256×256 的 PNG 是方形,写死即可(不在运行时解 PNG 读尺寸:
/// 只是为了一个 `viewBox`,不值得把解码器搬进这条路径)。
const BRAND_SVG_SIDE: u32 = 256;
/// 把 Grafana 的 logo 素材换成 nfarms 的;**返回这次真正写过的文件路径**。
///
/// **幂等**:每个文件都是"内容一样就一个字节都不写"(和 core 里
/// [`nfarms_core::observability::provision_dashboards`] 同一个口径),
/// 所以第二次调用返回空 `Vec` 且盘上一个字节都不动 —— 不会给正在跑的 Grafana
/// 反复制造 `rename` 事件(它是**在跑着的时候**从 `public\` 读这些文件的)。
///
/// `libs` 传程序根下的 `libs\`(即 `Paths::libs()`),Grafana 的 homepath 是
/// `<libs>\grafana`。**素材必须落在 Grafana 自己的 `public\` 下**:它只从 homepath
/// 提供 `/public/...` 的静态文件(和 core 里 `install_branding` 的 logo 是同一个
/// 原因),代价同样是"重装 `libs\` 会把这一份冲掉,要再跑一次"。
///
/// 图形取自编译进 exe 的 `assets\nfarms.ico` 里那张最大的 PNG(256×256),
/// 和 core 的 `install_branding` 用同一份素材,不依赖盘上有没有 `nfarms.ico`。
pub fn install_grafana_branding(libs: &Path) -> Result<Vec<PathBuf>, String> {
let grafana = libs.join("grafana");
if !grafana.is_dir() {
return Err(format!(
"{} 不存在 —— 先装 Grafana(libs\\scripts\\fetch-libs.ps1)再换素材",
grafana.display()
));
}
// ICO 里那张 256×256 的 PNG,直接复用现成的解析(见 extract_largest_png)
let png = extract_largest_png(EMBEDDED_ICO)?;
let svg = svg_wrapping_png(&png);
let mut written = Vec::new();
for (rel, kind) in GRAFANA_IMG_TARGETS {
let mut path = grafana.clone();
for seg in rel.split('/') {
path.push(seg);
}
let want: &[u8] = match kind {
AssetKind::Svg => svg.as_bytes(),
AssetKind::Png => &png,
};
if write_if_changed(&path, want)? {
written.push(path);
}
}
Ok(written)
}
/// 把 PNG 字节包成一张 SVG(`<image>` 里放 base64 data URI)。
///
/// 用 SVG2 的 `href`(不是 `xlink:href`):Chrome 50+ / Firefox 51+ / Edge 都认,
/// 而 Grafana 只会被现代浏览器打开。
fn svg_wrapping_png(png: &[u8]) -> String {
format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {side} {side}\">\
<image href=\"data:image/png;base64,{b64}\" width=\"{side}\" height=\"{side}\"/></svg>",
side = BRAND_SVG_SIDE,
b64 = base64(png)
)
}
/// 标准 base64(RFC 4648:字母表 + `=` 补齐)。
///
/// 自己写十几行而不是引一个 crate:这个功能只需要**编码**,而多一个依赖要多改
/// `Cargo.toml`、多一份离线构建的前提;要的是"能被浏览器解回来",不是通用解码。
fn base64(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
// 不足 3 字节的尾部用 0 补齐参与运算,长度由下面那两个 `=` 表达
let n = ((chunk[0] as u32) << 16)
| ((*chunk.get(1).unwrap_or(&0) as u32) << 8)
| (*chunk.get(2).unwrap_or(&0) as u32);
out.push(ALPHABET[(n >> 18) as usize & 0x3F] as char);
out.push(ALPHABET[(n >> 12) as usize & 0x3F] as char);
out.push(if chunk.len() > 1 {
ALPHABET[(n >> 6) as usize & 0x3F] as char
} else {
'='
});
out.push(if chunk.len() > 2 {
ALPHABET[n as usize & 0x3F] as char
} else {
'='
});
}
out
}
/// 备份路径:`<原名>.nfarms-orig`。
///
/// 直接往 `OsString` 后面接后缀(不是 `with_extension`):后者会**把原扩展名换掉**
/// (`fav32.png` → `fav32.nfarms-orig`),和另一个 `logo.png` 撞名就互相覆盖,
/// 而"备份只剩一份"这个症状肉眼看不出来。
fn backup_path(path: &Path) -> PathBuf {
let mut name = path.file_name().map(|s| s.to_os_string()).unwrap_or_default();
name.push(BACKUP_SUFFIX);
match path.parent() {
Some(dir) => dir.join(name),
None => PathBuf::from(name),
}
}
/// 改动前留一份原件,**只留第一次那份**。
///
/// 备份已经存在 → 什么都不做(不然第二次调用就把原件换成 nfarms 的图了,
/// 而"备份"两个字正是用来还原的)。目标文件本身不存在 → 也没什么可备的。
fn backup_once(path: &Path) -> Result<(), String> {
let backup = backup_path(path);
if backup.exists() || !path.is_file() {
return Ok(());
}
std::fs::copy(path, &backup)
.map_err(|e| format!("备份 {} → {} 失败: {e}", path.display(), backup.display()))?;
Ok(())
}
/// 幂等落盘:**内容一样就一个字节都不写**;真不同时才备份 + 原子替换。
///
/// 返回是否真的写了。和 core 的 `write_if_changed` 等价 —— 那个是私有的,
/// 而 crate 依赖方向不允许 gui 去调 core 的内部实现。
fn write_if_changed(path: &Path, bytes: &[u8]) -> Result<bool, String> {
// 按**字节**比而不是按长度/时间戳:换一张图但大小一样是很可能的(同一套设计改个颜色)
if std::fs::read(path).is_ok_and(|old| old == bytes) {
return Ok(false);
}
backup_once(path)?;
write_atomic(path, bytes)?;
Ok(true)
}
/// 写文件:**先写 `.tmp` 再 `rename` 顶替**(和 core 的 `write_atomic` 同一套做法)。
///
/// 直接 `fs::write` 会先截断原文件,写到一半失败(磁盘满 / 进程被杀)就只剩半张图 ——
/// 半张图在页面上就是裂图,而且不报任何错。同目录内的 `rename` 是原子的:
/// 浏览器要么读到旧的完整版,要么读到新的完整版。
fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> {
if let Some(dir) = path.parent() {
if !dir.as_os_str().is_empty() {
std::fs::create_dir_all(dir)
.map_err(|e| format!("建目录 {} 失败: {e}", dir.display()))?;
}
}
let mut name = path.file_name().map(|s| s.to_os_string()).unwrap_or_default();
name.push(".tmp");
let tmp = match path.parent() {
Some(dir) => dir.join(name),
None => PathBuf::from(name),
};
if let Err(e) = std::fs::write(&tmp, bytes) {
// 写坏的临时文件要清掉 —— 没人会来收拾它,而用户看到 `xxx.tmp` 只会更困惑
let _ = std::fs::remove_file(&tmp);
return Err(format!("写临时文件 {} 失败: {e}", tmp.display()));
}
if let Err(e) = std::fs::rename(&tmp, path) {
// 替换失败时原文件**一个字都没动**(rename 要么成功要么什么都没发生)
let _ = std::fs::remove_file(&tmp);
return Err(format!("替换 {} 失败: {e}", path.display()));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// 拼一个只有一条 32 位 BMP 条目的 ICO。
///
/// `top_down_bgra` 是**从上到下**的像素(测试里按人眼顺序写更直观),拼进 DIB 时翻转成
/// 底行在前;`mask` 是 AND 掩码的原始字节(底行在前,每行 4 字节对齐),不关心时传全 0。
fn ico_with_one_bmp(w: usize, h: usize, top_down_bgra: &[[u8; 4]], mask: &[u8]) -> Vec<u8> {
let stride = w * 4;
let mut xor = vec![0u8; stride * h];
for y in 0..h {
for (x, px) in top_down_bgra[y * w..(y + 1) * w].iter().enumerate() {
let d = (h - 1 - y) * stride + x * 4;
xor[d..d + 4].copy_from_slice(px);
}
}
let mut dib = Vec::new();
dib.extend_from_slice(&40u32.to_le_bytes()); // biSize = BITMAPINFOHEADER
dib.extend_from_slice(&(w as i32).to_le_bytes()); // biWidth
dib.extend_from_slice(&((h * 2) as i32).to_le_bytes()); // biHeight = XOR + AND
dib.extend_from_slice(&1u16.to_le_bytes()); // biPlanes
dib.extend_from_slice(&32u16.to_le_bytes()); // biBitCount
dib.extend_from_slice(&0u32.to_le_bytes()); // biCompression = BI_RGB
dib.extend_from_slice(&0u32.to_le_bytes()); // biSizeImage
dib.extend_from_slice(&[0u8; 16]); // 剩下的四个字段用不上
assert_eq!(dib.len(), 40);
dib.extend_from_slice(&xor);
dib.extend_from_slice(mask);
let mut ico = vec![0u8, 0, 1, 0, 1, 0];
let mut entry = vec![w as u8, h as u8, 0, 0, 1, 0, 32, 0];
entry.extend_from_slice(&(dib.len() as u32).to_le_bytes());
entry.extend_from_slice(&22u32.to_le_bytes()); // 6 + 16 之后就是载荷
ico.extend_from_slice(&entry);
ico.extend_from_slice(&dib);
ico
}
#[test]
fn parses_the_real_nfarms_ico() {
// 内嵌的那份一定能解出来 —— 这是「永远不会没 logo」的保证
let img = decode_ico(EMBEDDED_ICO).expect("内嵌 ico 必须能解码");
// ico 里最大的那条是 256×256
assert_eq!(img.size, [256, 256]);
}
#[test]
fn picks_the_largest_png_entry_not_the_first() {
// 真正的检验:ico 里第 0 条是 16×16 的 **BMP**,最大的 PNG 在第 8 条。
// 如果实现写成了「取第一条」或者「不看格式」,这里就会挂。
let png = extract_largest_png(EMBEDDED_ICO).unwrap();
assert!(png.starts_with(&[0x89, b'P', b'N', b'G']));
assert!(png.len() > 50_000, "应当拿到那张 256×256 的(约 56KB)");
}
#[test]
fn decodes_a_legacy_ico_that_only_has_a_bmp_entry() {
// 传统工具写出来的 ico 整份都是 BMP 条目(这个仓库自己的 nfarms.ico 里也有 8 条)。
// 以前只认 PNG,这种图标会静默退回内嵌那份 —— 用户换了图标却看不出任何变化。
let px = [
[0, 0, 255, 255], // 左上:红(BGRA)
[0, 255, 0, 255], // 右上:绿
[255, 0, 0, 255], // 左下:蓝
[255, 255, 255, 128], // 右下:半透明白
];
let ico = ico_with_one_bmp(2, 2, &px, &[0u8; 8]);
let img = decode_ico(&ico).expect("只有 BMP 条目的 ico 也要能解");
assert_eq!(img.size, [2, 2]);
let got: Vec<[u8; 4]> = img.pixels.iter().map(|c| c.to_srgba_unmultiplied()).collect();
assert_eq!(got[0], [255, 0, 0, 255]);
assert_eq!(got[1], [0, 255, 0, 255]);
assert_eq!(got[2], [0, 0, 255, 255]);
assert_eq!(got[3], [255, 255, 255, 128], "alpha 通道要照抄,不能当不透明");
}
#[test]
fn bmp_entry_without_alpha_channel_uses_the_and_mask() {
// 老工具只写 AND 掩码、alpha 通道全 0。只看 alpha 的话整张图会全透明,
// 界面上等于没画 —— 所以 alpha 全 0 时必须去看掩码。
let px = [
[0, 0, 255, 0],
[0, 255, 0, 0],
[255, 0, 0, 0],
[255, 255, 255, 0],
];
// 掩码底行在前,每行 4 字节,最高位对应最左像素:把左下角标成透明
let mask = [0b1000_0000, 0, 0, 0, 0, 0, 0, 0];
let img = decode_ico(&ico_with_one_bmp(2, 2, &px, &mask)).unwrap();
let got: Vec<[u8; 4]> = img.pixels.iter().map(|c| c.to_srgba_unmultiplied()).collect();
assert_eq!(got[0][3], 255, "没被掩码点掉的像素应当不透明");
assert_eq!(got[2][3], 0, "掩码位为 1 的像素应当全透明");
}
#[test]
fn rejects_garbage_without_panicking() {
assert!(decode_ico(&[]).is_err());
assert!(decode_ico(&[0, 0, 1, 0]).is_err());
assert!(decode_ico(&[0, 0, 2, 0, 1, 0]).is_err(), "type 不是 1 就该拒");
// 头合法但没有条目
assert!(decode_ico(&[0, 0, 1, 0, 0, 0]).is_err());
// 条目越界不能 panic
let mut ico = vec![0u8, 0, 1, 0, 1, 0];
ico.extend_from_slice(&[16, 16, 0, 0, 1, 0, 32, 0, 255, 255, 255, 255, 255, 255, 255, 255]);
assert!(decode_ico(&ico).is_err());
}
#[test]
fn truncated_directory_is_reported_instead_of_silently_accepted() {
// 头部声明 2 张图却只放得下 1 条目录项。以前是 `break` —— 前面那条刚好是好的
// PNG 时整个函数照样返回成功,「文件被截断」没人发现。
let mut ico = vec![0u8, 0, 1, 0, 2, 0];
ico.extend_from_slice(&[16, 16, 0, 0, 1, 0, 32, 0, 4, 0, 0, 0, 22, 0, 0, 0]);
ico.extend_from_slice(&[0x89, b'P', b'N', b'G']);
let e = extract_largest_png(&ico).unwrap_err();
assert!(e.contains("截断"), "要说清是目录被截断: {e}");
assert!(decode_ico(&ico).is_err(), "截断的文件不能当成好的用");
}
#[test]
fn entry_offset_past_the_end_of_file_is_rejected() {
// off/size 都是 32 位无符号数,32 位目标上 `off + size` 会回绕,所以校验必须
// 先挡 off 再减。64 位目标上这组字节本来就会被挡住 —— 这条测试是把
// 「不许 panic、必须报错」钉住(32 位目标上它才会真的抓到回绕)。
let mut ico = vec![0u8, 0, 1, 0, 1, 0];
ico.extend_from_slice(&[16, 16, 0, 0, 1, 0, 32, 0]);
ico.extend_from_slice(&1u32.to_le_bytes()); // size = 1
ico.extend_from_slice(&u32::MAX.to_le_bytes()); // off = 4G-1
ico.extend_from_slice(&[0x89, b'P', b'N', b'G']);
assert!(extract_largest_png(&ico).is_err());
assert!(decode_ico(&ico).is_err());
}
#[test]
fn logo_texture_enables_mipmaps() {
// 侧栏 34px 显示 256px 原图,非 mipmap 的 LINEAR 会丢细节、边缘发跳。
// 谁把它改回 `TextureOptions::LINEAR`(mipmap_mode = None),这条就挂。
assert!(
LOGO_TEXTURE_OPTIONS.mipmap_mode.is_some(),
"logo 纹理必须开 mipmap:缩 7.5 倍要抗锯齿"
);
}
#[test]
fn a_constructed_logo_always_has_a_texture() {
// Logo 故意没有 Default:`new` 是唯一构造点,所以「没纹理」这个状态在模块外
// 造不出来,`is_loaded()` 恒真,lib.rs 侧栏那段色块兜底不可达。
let ctx = egui::Context::default();
let rgba: Vec<u8> = [255u8, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 9, 9, 9, 255]
.to_vec();
let logo = Logo::new(
&ctx,
egui::ColorImage::from_rgba_unmultiplied([2, 2], &rgba),
);
assert!(logo.is_loaded());
}
#[test]
fn falls_back_to_embedded_when_the_file_is_missing() {
let dir = std::env::temp_dir().join("nfarms-logo-missing");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let (img, notes) = load(&dir);
assert_eq!(img.size, [256, 256]);
// 程序根下没有 nfarms.ico 是**常态**(它就是可选的自定义图标),
// 所以这里不该有任何提示 —— 否则每次启动都刷一条噪音。
assert!(notes.is_empty(), "没有磁盘文件不该有告警: {notes:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn broken_file_on_disk_warns_but_still_gives_a_logo() {
let dir = std::env::temp_dir().join("nfarms-logo-broken");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(ICON_FILE), b"this is not an ico").unwrap();
let (img, notes) = load(&dir);
assert_eq!(img.size, [256, 256], "坏文件也要退回内嵌那份,不能没 logo");
assert_eq!(notes.len(), 1, "坏文件必须给出一条提示: {notes:?}");
assert!(notes[0].contains("退回内置图标"), "{notes:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn prefers_the_file_on_disk_so_users_can_replace_it() {
let dir = std::env::temp_dir().join("nfarms-logo-ondisk");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
// 放一份 128×128 的(自己造,尺寸和内嵌那份不同)
let mut png = Vec::new();
let img = image::RgbaImage::from_pixel(128, 128, image::Rgba([255, 0, 0, 255]));
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
// 包成 ICO
let mut ico = vec![0u8, 0, 1, 0, 1, 0];
ico.extend_from_slice(&[
128,
128,
0,
0,
1,
0,
32,
0,
png.len() as u8,
(png.len() >> 8) as u8,
(png.len() >> 16) as u8,
(png.len() >> 24) as u8,
22,
0,
0,
0,
]);
ico.extend_from_slice(&png);
std::fs::write(dir.join(ICON_FILE), &ico).unwrap();
let (got, notes) = load(&dir);
assert_eq!(got.size, [128, 128], "磁盘上的要优先于内嵌的");
assert!(notes.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
// ---- Grafana 真白标(换素材文件) ----
/// 搭一个假的 Grafana 目录:四个目标文件都在,内容是「原版」的标记字节。
///
/// 返回 `(libs, 四个目标文件的绝对路径)`。
fn fake_grafana(tag: &str) -> (PathBuf, Vec<PathBuf>) {
let libs = std::env::temp_dir().join(format!("nfarms-grafana-{tag}")).join("libs");
let _ = std::fs::remove_dir_all(libs.parent().unwrap());
let mut paths = Vec::new();
for (rel, _) in GRAFANA_IMG_TARGETS {
let mut p = libs.join("grafana");
for seg in rel.split('/') {
p.push(seg);
}
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
// 「原版 Grafana 的字节」用各自可辨认的内容,备份对不对一眼能看出来
std::fs::write(&p, format!("ORIGINAL-GRAFANA-{rel}")).unwrap();
paths.push(p);
}
(libs, paths)
}
/// 测试用的 base64 解码器。
///
/// **不能拿被测的那个编码器自己解自己** —— 那样编码器整体错位也照样"通过"。
fn decode_b64(s: &str) -> Option<Vec<u8>> {
const ALPHABET: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = Vec::new();
let mut acc: u32 = 0;
let mut bits: u32 = 0;
for c in s.bytes() {
if c == b'=' {
break;
}
let v = ALPHABET.iter().position(|&a| a == c)? as u32;
// 只留最近 24 位就够(每轮最多再进 6 位),免得 u32 上悄悄丢高位
acc = ((acc << 6) | v) & 0x00FF_FFFF;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((acc >> bits) as u8);
}
}
Some(out)
}
#[test]
fn base64_matches_the_rfc_4648_vectors() {
// 覆盖 len % 3 = 0 / 1 / 2 三种补齐,以及空输入
assert_eq!(base64(b""), "");
assert_eq!(base64(b"f"), "Zg==");
assert_eq!(base64(b"fo"), "Zm8=");
assert_eq!(base64(b"foo"), "Zm9v");
assert_eq!(base64(b"foob"), "Zm9vYg==");
assert_eq!(base64(b"fooba"), "Zm9vYmE=");
assert_eq!(base64(b"foobar"), "Zm9vYmFy");
}
#[test]
fn grafana_branding_writes_png_and_svg_wrapping_the_png() {
let (libs, paths) = fake_grafana("write");
let written = install_grafana_branding(&libs).expect("四个文件都在,应当成功");
assert_eq!(written.len(), 4, "第一次调用四个都要写: {written:?}");
assert_eq!(written, paths, "返回的应当是那四个目标文件的路径");
let png = extract_largest_png(EMBEDDED_ICO).unwrap();
assert!(png.starts_with(&[0x89, b'P', b'N', b'G']), "源头就该是 PNG");
// .png 那两个:直接就是 PNG 字节(浏览器看内容是看魔数,不看扩展名)
for p in &paths[2..] {
let got = std::fs::read(p).unwrap();
assert_eq!(&got[..4], &[0x89, b'P', b'N', b'G'], "{} 不是 PNG", p.display());
assert_eq!(got, png, "{} 应当是抽出来那张原样字节", p.display());
}
// .svg 那两个:是真 SVG(不是塞了 PNG 字节),里面是 base64 的 PNG
for p in &paths[..2] {
let text = std::fs::read_to_string(p).unwrap();
assert!(text.starts_with("<svg xmlns=\"http://www.w3.org/2000/svg\""), "{}", p.display());
assert!(text.ends_with("</svg>"), "{}", p.display());
let head = "<image href=\"data:image/png;base64,";
let at = text.find(head).expect("要有一个 base64 的 <image>");
let payload = &text[at + head.len()..];
let payload = &payload[..payload.find('"').unwrap()];
assert_eq!(decode_b64(payload).unwrap(), png, "SVG 里包的必须是那张 PNG");
// base64 的长度规律:4 的倍数,且只含字母表与补齐符
assert_eq!(payload.len() % 4, 0);
assert!(payload.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'+' || c == b'/' || c == b'='));
}
}
#[test]
fn grafana_branding_is_idempotent_and_only_backs_up_once() {
let (libs, paths) = fake_grafana("idempotent");
install_grafana_branding(&libs).unwrap();
// 第一次改完之后:备份是「原版」的字节
let mut after_first = Vec::new();
for p in &paths {
let backup = backup_path(p);
assert_eq!(
std::fs::read_to_string(&backup).unwrap(),
format!("ORIGINAL-GRAFANA-{}", rel_of(p)),
"备份里应当是原版内容: {}",
backup.display()
);
after_first.push(std::fs::read(p).unwrap());
}
// 第二次:一个字节都不写,返回空
let again = install_grafana_branding(&libs).unwrap();
assert!(again.is_empty(), "幂等:第二次不该写任何文件,实际 {again:?}");
for (p, before) in paths.iter().zip(&after_first) {
assert_eq!(&std::fs::read(p).unwrap(), before, "{} 被改动了", p.display());
}
// 备份**只在第一次建**:谁要是把「第一次的备份」当成"该刷新了"再备一次,
// 备份里就会变成 nfarms 自己的图,还原这条路就断了。
for p in &paths {
let backup = backup_path(p);
assert_eq!(
std::fs::read_to_string(&backup).unwrap(),
format!("ORIGINAL-GRAFANA-{}", rel_of(p)),
"第二次调用把备份覆盖掉了: {}",
backup.display()
);
}
// 第三次(内容被外部改回去):要重新写,而备份**仍然**是原版那份
let fav = &paths[2];
std::fs::write(fav, b"someone-else-changed-it").unwrap();
let third = install_grafana_branding(&libs).unwrap();
assert_eq!(third, vec![fav.clone()], "只有被改动的那一个该重写");
assert_eq!(
std::fs::read_to_string(backup_path(fav)).unwrap(),
format!("ORIGINAL-GRAFANA-{}", rel_of(fav))
);
let _ = std::fs::remove_dir_all(libs.parent().unwrap());
}
/// 目标文件的相对路径(测试里用来核对备份内容)。
fn rel_of(path: &Path) -> String {
let s = path.to_string_lossy().replace('\\', "/");
for (rel, _) in GRAFANA_IMG_TARGETS {
if s.ends_with(rel) {
return rel.to_string();
}
}
panic!("{} 不在目标清单里", path.display());
}
#[test]
fn grafana_branding_reports_a_missing_grafana_instead_of_creating_it() {
// Grafana 没装时不能凭空造一棵 `public\` 出来 —— 那只会让用户以为装好了
let libs = std::env::temp_dir().join("nfarms-grafana-missing").join("libs");
let _ = std::fs::remove_dir_all(libs.parent().unwrap());
std::fs::create_dir_all(&libs).unwrap();
let e = install_grafana_branding(&libs).unwrap_err();
assert!(e.contains("grafana"), "错误里要说清缺的是什么: {e}");
assert!(!libs.join("grafana").exists(), "不该造出一个 grafana 目录");
let _ = std::fs::remove_dir_all(libs.parent().unwrap());
}
}