文章目录9 节
BiliDesk 是用 Tauri + React + libmpv 写的 Windows B 站桌面客户端。本文讲它怎么在 Rust 侧实现 WBI 签名、怎么用 DPAPI 保护登录态、怎么把弹幕转成 ASS 交给 mpv,以及为什么封面要走自定义协议。
用"网页壳"做 B 站客户端很省事,但一旦要内嵌播放器、要自己管弹幕、要把登录态安全地落在本地,就必须把关键逻辑搬到原生侧。BiliDesk 的选择是 Tauri 2 + React + libmpv:界面用 Web 技术,签名、会话、播放器控制、弹幕转换全部在 Rust。
一、先解决"播放器怎么装"
桌面端打包最容易被忽略的是外部二进制依赖。BiliDesk 用 libmpv,并把获取过程写成脚本 scripts/fetch-mpv.sh / .ps1:
- 固定上游与版本:
zhongfly/mpv-winbuildtag2026-08-21-49418246f3,资产名mpv-dev-lgpl-x86_64-20260821-git-49418246f3.7z; - 校验 SHA-256(
317dfd9e…0510e0,用sha256sum/shasum校验)后才解压; - 想换版本必须同时提供
MPV_RELEASE/MPV_ASSET/MPV_SHA256三个环境变量,避免"只改了 tag 导致校验失效"; - 已经存在时跳过,除非
MPV_FORCE=1。
这条规则的意义在于可复现:CI 和本地拿到的是同一个二进制,libmpv_search_starts_in_vendor 这类测试才有意义。
二、WBI 签名:把"能跑"变成"可验证"
B 站的 Web 接口对部分端点要求 WBI 签名(w_rid)。这类算法的常见问题是:写完只能靠"接口没报错"来验证。BiliDesk 的做法是把官方公布的示例作为单元测试,并且把"去掉非法字符"这一步单独覆盖——签名是纯函数,纯函数就该有断言。
配套的请求侧约束:
- 分区/排行等接口按"官方 PC 端契约"构造 URL,并对响应做校验,把鉴权失败、风控、限流等业务码原样保留(而不是统一吞成"请求失败");
logout会清掉SESSDATA、bili_jct、DedeUserID、DedeUserID__ckMd5、sid这几项,不留下半套凭据。
三、会话保护:魔法头 + DPAPI
session.json 不是明文 cookie 转储:
- 文件以
BILIDESK_SESSION_V1\0作为魔法头; - 内容用 Windows DPAPI(
CryptProtectData,CRYPTPROTECT_UI_FORBIDDEN)加密,绑定当前用户; - 首次读取到旧的明文文件时自动迁移成加密格式——迁移而不是报错,这决定老用户升级后的体验;
PRIVACY.md把边界写清楚了:无遥测、无广告、无云同步;DPAPI 只防"文件被拷走",不防同用户下的恶意程序,也不防运行时内存读取;数据在%APPDATA%\com.bilidesk.desktop,卸载后可能保留。
本地数据用 SQLite(bilidesk.db):history、settings、play_progress、schema_migrations,历史迁移版本 HISTORY_MIGRATION_VERSION = 1。老的 history.json 会被导入并改名为 history.json.migrated——保留原件,这在数据迁移里是低成本高价值的习惯。
四、封面与图片:为什么不用 <img src="https://...">
直接让 WebView 加载 B 站 CDN 图片,等于把用户的 Referer/IP 直接暴露给第三方,也会让 CSP 变得很松。BiliDesk 改走自定义协议:
- Rust 侧注册
biliimg协议作为封面代理,media.rs里维护主机白名单(只允许 B 站系 CDN,测试allows_bili_cdns/rejects_other_hosts),并对响应大小设 8 MiB 上限(超出报"封面过大"); - 前端按平台拼 URL:Windows WebView2 下用
http://biliimg.localhost/img?u=…,其他情况用biliimg://localhost/…; - CSP 因此可以收紧到
img-src只允许该主机、connect-src只留ipc:、object-src 'none'、frame-ancestors 'none'。
"所有远程图片都经过自己的白名单代理"是一条很好复用的模式:可缓存、可限流、可限大小、可审计。
五、弹幕 → ASS → mpv
弹幕不是叠加在 WebView 上,而是转成 ASS 字幕交给 mpv 渲染(bili/danmaku.rs):
- 默认字体 48、行数 12、不透明度 1.0、显示区域 1.0、加粗;行高按
font + 8;显示区高度按play_y * display_area折算; - 宽度估算用
字符数 × font × 0.7,用来决定滚动轨道的碰撞与占用; - 生成
Scroll/Top/Bottom三种 ASS 样式,字体族Microsoft YaHei;不透明度同时映射到 ASS 的 alpha 值和阴影距离((1-opacity)*255、*96),保证半透明时仍然有可读性。
弹幕偏好(字号 28–72、行数 4–20、不透明度 0.1–1.0、显示区域 0.25–1.0)在写入和读取两侧都做钳制(player_set_danmaku_prefs 与加载路径都校验)。只在前端限制等于没限制——配置文件是可以手改的。
六、分区/发现与状态
- 分区 tab 的合并是显式设计过的:
?tab=ranking与?tab=region合并,OGV 分区 id13/167/168回落到"动画",rid=0表示全站,不做分页; - 播放进度、音量、倍速、主题、弹幕开关等落到 settings,默认值在 Rust 侧集中定义(theme=light、danmaku=true、font=42、rows=10、volume=80、speed=1);
- 播放器命令做了作用域取消:切换视频时旧的加载作用域会被取消,避免"上一个视频的响应把新视频的状态覆盖"。
七、验证
- 前端:11 个 vitest 文件(
media、errors、regions、region-feed、hotkeys、discover-tabs、watch、wheel-navigation、version、releaseVersions…),例如media.test.ts断言按 UA 选择 URL 方案、空值直接透传; - Rust:13 个模块内
#[cfg(test)]约 23 个测试,覆盖 WBI 示例与非法字符、弹幕密度/区域/透明度、媒体白名单、分区排行 URL 与响应校验、历史往返与损坏 JSON 再导入、播放器作用域取消、libmpv 定位; - CI:
rust-audit(rustsec audit)之外,qualityjob 在 windows-latest 上跑"下载固定 libmpv →check:release→ build → test →npm audit --audit-level=high→cargo fmt --check→clippy -D warnings --all-targets --all-features→cargo test --locked";tag 触发的安装包 job 支持可选证书签名。
八、限制
不做直播流、不下载缓存、不破解会员、不做区域绕过;上游 Web 接口变化可能影响功能;没有配置证书时安装包未签名;设计文档(docs/superpowers/specs/)明确了"UI 不直接请求 B 站域名"的边界,但也承认区域/发现页的完整契约仍依赖第三方接口的稳定性。
小结
这类"第三方客户端"项目的技术含量不在界面,而在三件事:外部二进制可复现(版本 + 哈希 + 显式覆盖变量)、凭据用系统能力保护并显式写出防护边界、把算法(WBI)与渲染(弹幕 ASS)变成可断言的纯逻辑。