# dsh-pet-live2d-desktop
**把 [dsh-live2d-pet](../dsh-live2d-pet/) 这只桌宠从 DSH 的网页搬到你自己的桌面上** ——
一只真的站在桌面上、不挡其它窗口、跟着 DSH 会话状态换动作的 Live2D 宠物。
**发出去的是一个文件:`DSH桌宠.exe`(8.94 MB,不用安装)。** 双击就跑:不需要装
Node、不需要装 DSH、不需要装插件 —— 宠物本体(模型 + 贴图 + 动作)就嵌在 exe 里。
屏幕实拍:只有角色吃鼠标事件,方形画布的透明处直接透到壁纸
## 怎么用
| 想干什么 | 怎么做 |
|---|---|
| 看见她 | 双击 exe(托盘里会出现她的图标,桌宠没有任务栏按钮) |
| 换个动作 / 换装扮 | 在她身上**右键** |
| **改设置** | 右键 →「设置」页签(桌面端专属)**或**托盘 →「设置…」 |
| **换一块屏幕** | 直接**按住她拖过去**(多屏、异形排布都一样;位置会记住,重启还在那儿) |
| 藏起来 / 显示 | 托盘图标**左键单击**=显示;托盘菜单 →「藏起来」 |
| 归位(回到当前屏幕右下角) | 托盘菜单 →「归位」。右键面板底部的「归位」只复位表情/动作,不动位置 |
| 退出 | 托盘菜单 →「退出」 |
**跟着 DSH 走**(可选):本机跑着 `dsh web`(默认 `127.0.0.1:3080`)时她会自动连上,
思考 / 用工具 / 等你批准 / 完成 / 出错都换动作与表情;DSH 没开就自己摸鱼,互不打扰。
想彻底关掉:`DSH桌宠.exe --dsh none`。
**第一次启动**会把随包宠物解包到 exe 旁边的 `DSH桌宠-data\plugin\`,再按内容指纹同步进
`%DSH_HOME%\pets\ds-whale-girl\`(与网页端插件同一份规矩:你改过的那份一个字都不碰)。
## 架构:三层,一个进程
```
DSH桌宠.exe(一个进程)
├── 壳(Tauri) 窗口:透明、置顶、不进任务栏、穿透轮询、托盘
├── host(Rust) 宠物扫描 → catalog、资产路由(引用闭包白名单)、相位流(SSE)、页面分发
└── 页面(WebView) 跑的就是 lib/client.js
```
**页面一行没改**:它认的是 `/api/live2d-pet/*` 与 `/__desktop/*` 两组路径,谁在背后答它
并不关心。这是刻意的 —— 网页端的 19 个 driver 因此仍然是桌面端的回归网。
| 层 | 谁 | 复用程度 |
|---|---|---|
| 浏览器半区 | `dsh-live2d-pet/lib/client.js` | **零改动语义**(只多了一个桌面端守卫,见下) |
| 宿主半区 | `src/host/`(Rust) | **重写**:见"为什么翻 Rust" |
| 随包宠物 | `dsh-live2d-pet/pets/` | 编译期嵌进 exe,运行时解包 |
## 两种运行模式:独立 / 挂载
同一个 exe,一个启动参数决定"宠物数据从哪来":
| 模式 | 怎么起 | 宠物数据 / 相位 | 什么时候用 |
|---|---|---|---|
| **独立**(默认) | `DSH桌宠.exe` | 本机 Rust 宿主扫 `%DSH_HOME%\pets`,自己发资产;相位订阅 DSH | **DSH 没开也要她在** —— 这就是这个模式存在的理由 |
| **挂载** | `DSH桌宠.exe --attach http://127.0.0.1:3080` | 全部转发给 DSH 里的插件;本机**不碰**宠物目录 | 你已经装了网页插件,只想把她挪到桌面上 |
**挂载模式是"改 bug 只改一处"的落点**:这时本机的宠物扫描、catalog、资产路由一次都不
参与,页面拿到的每个字节都来自 DSH 里的 `lib/index.js` —— 那边修好了,桌面这只跟着好。
事件走同一条路(`/api/live2d-pet/events` 直接转发),所以挂载时**不需要**本机那条相位桥。
两条边界:
- **挂载模式下 DSH 必须是活的**。连不上上游时它**故意报 502**,不静默退回本机宿主 ——
静默兜底会把"挂载没成功"伪装成"挂载成功"(页面上照样有宠物,但它其实来自本机扫描)。
真兜底是用户的显式选择:不加 `--attach` 就是独立模式。
- **一个 exe 只能跑一份**(WebView2 的 user-data-dir 是独占的),别指望同机开两个。
## 和 DSH 网页插件的关系
两边跑的是**同一个 `lib/client.js`**(摸头、摸尾巴、槽位装扮、相位动作、右键面板、
设置正文全是它),所以**行为类 bug 只改一处、两边一起好**:
| 改动落在哪 | 要改几处 |
|---|---|
| 客户端(`lib/client.js`):渲染、动作状态机、判定、界面 | **一处**(网页端与桌面端共用) |
| 宿主(catalog / 资产路由 / 宠物扫描):`lib/index.js` 与 `src-tauri/src/host/` | 两份 —— 但**挂载模式下只有一份在干活**,独立模式下才需要两边同步 |
| 壳(窗口行为) | 本来就不同,谈不上改两次 |
改了宿主契约(`pet.json` 字段语义、资产 URL 形状)时,跑 `tools/probe-catalog.mjs`
让两份实现当场对拍。
### 为什么翻 Rust(以及代价)
原来那套是"Tauri 壳 + Node sidecar",用 `deno compile` 把 sidecar 编成独立二进制嵌进
exe。**跑通了,但 exe 有 95MB**:其中 86MB 是 V8 运行时本体 —— `deno compile` 没有
`--strip`,`llvm-strip --strip-all` 实测也压不动(86.3MB 进、86.3MB 出)。
翻成 Rust 之后 **8.94 MB**。代价是**第二份实现**,而两份实现一定会分叉 —— 所以配了
一条**对拍驱动**(见下):把 JS 版宿主半区与 Rust 版放在一起,逐字段比 catalog、逐字节
比资产。翻错了立刻红,而不是等用户发现装扮少了一个槽位。
顺带白拿的三件事:没有子进程要收尸(不再可能留孤儿)、壳的状态不必再绕一个
`shell-state.json`(同进程直接读内存)、启动不需要解包 86MB。
## 构建
```bash
cd dsh-live2d-pet-desktop
npm run build:portable # 一键产出 dist/DSH桌宠.exe
```
顺序很简单(资源在**编译期**由 `src-tauri/build.rs` 嵌进去):
1. `cargo build --release`;
2. 拷到 `dist/DSH桌宠.exe`;
3. 起壳 → 对拍 + 壳全链 → 网页端回归(`--skip-suite` 可跳过)。
开发时不必每次 release:
```bash
npm run dev # 改页面/客户端 JS 后重新构建即可生效
npm run dev:spike # 换成极简气球页:只验"透明 + 穿透 + 性能"
```
前置:Rust stable + MSVC、WebView2 运行时(Win10/11 自带)。
**不需要 Node 运行时、不需要 deno**(构建期只用 node 跑工具脚本)。
React UMD 从 `tools/browser-test/node_modules` 取(与 DSH 客户端同一个版本)。
### 图标
```bash
npm run icon -- --source <你的图.png> # 默认用 src-tauri/icons/icon.png
```
产出多尺寸 `icon.ico`(16/24/32/48/64/128/256)与托盘的 `32x32.png`。
## 桌面端专属:设置菜单
桌面端没有 DSH 的客户端壳,`ctx.slots`(设置页那一节)挂不上 —— 所以设置正文在**右键
面板的第三个页签**里,和 DSH 设置页那一节是**同一个组件、同一份存档**:
- 页签只在桌面端出现(守卫是页面运行时留下的 `window.__petDesktop` 标记);
- 打开设置页签时面板自动加宽一档(270 → 342px),因为设置正文是表格;
- 托盘 →「设置…」等于替用户点开它。
**网页端行为一个字都没变**:那边仍然没有这个页签。
## 验证(都是确定信号,不做截图比对)
```bash
# 起壳(验证驱动都接管壳里的 WebView)
PET_DESKTOP_CDP=8823 ./dist/DSH桌宠.exe # PowerShell: $env:PET_DESKTOP_CDP="8823"
node tools/probe-attach.mjs # 15/15 挂载模式:逐字节同上游 + 相位转发 + 不兜底(要 DSH 在跑)
node tools/probe-catalog.mjs # 7/7 独立模式对拍:JS 参照 vs Rust,逐字段 + 58 个资产逐字节
node tools/desktop-driver.mjs # 16/16 壳:透明 + 穿透(读窗口扩展样式)+ 判定链
node tools/probe-settings.mjs # 9/9 设置菜单:页签 / 正文 / 真的改得动设置 / 托盘事件
node tools/page-driver.mjs # 8/8 页面:插件挂载、canvas、无脚本错误
node tools/probe-phase.mjs # 相位端到端:真实 DSH 事件 → data-phase
node tools/probe-anim.mjs # 待机动画:页内连续采样引擎参数与帧率
node tools/probe-through.ps1 -X 150 -Y 320 # 穿透"铁证":WS_EX_TRANSPARENT 那一位
```
`probe-catalog.mjs` 是这次翻实现的关键:它同时跑两份真实宿主,比 catalog 的每个字段与
闭包里每个资产的每个字节。**浮点给 1e-9 相对容差**(Node 与 Rust 读同一个十进制小数可能
差 1 ULP),其余一律严格相等。
## 目录
```
sidecar/page/ 页面(编译期被 build.rs 嵌进 exe)
index.html 桌宠页面(React UMD + __ModuleLoader__ 垫片 + 插件浏览器半区)
runtime.js 桌面端运行时:判定 + 给插件的 ctx 桩
desktop.js 反向通道:页面来领探针任务
boot.js 等插件注册 → apply → 挂状态读口
spike.html 极简气球页(只验壳能力)
hover.js spike 页的判定实现
src-tauri/
src/lib.rs 装配:运行期目录、启动宿主、建窗、穿透轮询、相位桥
src/host/ **宿主半区(Rust)**
catalog.rs 宠物扫描 / pet.json 归一化 / 引用闭包 / cdi3 部件 / 随包宠物同步
http.rs 进程内 HTTP:资产、运行时、SSE 相位流、页面、桌面端接口
shared.rs 同进程共享状态(穿透判定、壳状态、相位)
embed.rs 编译期嵌入的资源查表与解包
dsh_link.rs 订阅运行中 DSH 的相位流(裸 TcpStream 逐行读 SSE)
src/pet_window.rs 透明置顶窗口
src/tray.rs 托盘图标与菜单
build.rs 把插件/页面/宠物/React/Cubism Core 嵌成 Rust 源码
tools/
build-portable.mjs 一键出单文件 exe
probe-catalog.mjs **对拍驱动**(JS 参照 vs Rust)
desktop-driver.mjs 壳的全链驱动
probe-settings.mjs 设置菜单驱动
probe-phase.mjs 相位端到端
probe-anim.mjs 待机动画与帧率
probe-through.ps1 穿透"铁证"
page-driver.mjs 只验页面
probe-core.mjs 脚本加载诊断
make-icon.mjs 多尺寸 ico + 托盘图
shot.ps1 截屏
paths.mjs 工具脚本的共享路径
```
## 读口(排查用)
| 读口 | 在哪 | 看什么 |
|---|---|---|
| **启动日志** | `%DSH_HOME%\pet-desktop.log` | 起手参数、显示层偏好、**"显示 / 让位"的结论与原因**、起不来时的错误 |
| 运行期目录 | exe 旁边 `DSH桌宠-data/` | 解包出来的随包插件 |
| `GET /__desktop/ping` | 宿主(端口从进程上找) | 活着吗、发现几只宠物、探针问答数、DSH 连上没有 |
| `GET /__desktop/shell` | 宿主 | 壳的状态(窗口几何、DPI、光标、判定、切换次数、探针错误数) |
| `window.__petDesktop.diag()` | 页面(devtools) | 启动成功没、判定理由、命中元素、错误 |
| `window.__dshLive2dPet.settingsOverrides()` | 页面 | 设置存档(开关 / 相位 / 台词覆盖) |
## 双击了,但没看到她?
**先看 `%DSH_HOME%\pet-desktop.log`** —— 每次启动它都记了结论:
| 日志里写的是 | 意思 | 怎么办 |
|---|---|---|
| `显示层决定:她在桌面上` | 她在显示,可能只是被别的窗口盖住 / 在另一块屏上 | 托盘图标 → 「归位」 |
| `显示层决定:按偏好让位(mode=inline)` | 你在 DSH 设置里选了「页面内」,桌面这只让位了 | 设置里选「桌面」。(**手动双击 exe 现在会自动切成「桌面」**,不再静默让位) |
| `已经有一只桌宠在跑(pid …)` | 一只只能跑一份(WebView2 数据目录独占) | 用托盘里那只;想换一只先「退出」再启动 |
| `**起不来**:…` | 启动失败(Windows 上还会弹框,框里写明程序路径) | 框/日志里有完整错误:建窗被拒 / WebView2 忙 / 权限 |
| `托盘图标已就绪` / `托盘图标注册了,但系统没给出它的位置` | 托盘这条路是通的 | Windows 11 默认把新图标收进**溢出区**(点任务栏的 `^`),也可以到「设置 → 个性化 → 任务栏 → 其他系统托盘图标」里把它切成常显 |
| `托盘图标注册失败` + `GetLastError=5`(ACCESS_DENIED) | 系统**拒绝**了这个进程往通知区域注册(不是"没就绪",重试无用) | 见下面「托盘里没有她」一节 |
| 日志一切正常、**但屏幕上没有她**(设置里显示「桌面已接管」) | 窗口在、页面在跑,但页面**把自己藏了**(让位判据读到 `owner: "inline"`) | 这曾经是个 bug(Rust 宿主少了 `GET /api/live2d-pet/layer`,独立模式下页面取不到 owner)—— 已修;升级到 3.1+ 即可 |
## 托盘里没有她?
`TrayIconBuilder` 建图标返回 Ok **不代表图标进了通知区域**:底层 `Shell_NotifyIcon(NIM_ADD)`
失败时是**静默忽略**的。所以启动时会自证一次并写进日志(见上表)。
如果是 `GetLastError=5`:那是**系统拒绝了这个进程**(实测:同一台机器上,一个无关的最小程序
能注册成功,而本程序任何构建 / 任何位置、连手工调用都被拒)。已经排除的变量有:二进制、
构建环境、exe 所在目录、文件完整性标签、库的参数。剩下的是进程级因素(job 对象 / UIPI /
窗口所在桌面),诊断信息会写进同一份日志。
**她照样能用**,只是没有托盘菜单。想退出她(没有托盘图标时)用任务管理器结束
`DSH-Pet.exe` / `dsh-pet-live2d-desktop.exe`;「设置 / 归位 / 藏起来」仍然可以在她自己的
右键面板里用。
三条容易误判的:
- **一只只能跑一份**:插件已经拉起一只时,再双击 exe 那份会撞 `0x800700AA 请求的资源在使用中`
—— 现在它会**自动重试 3 次**,仍不行就明确告诉你"已经有一只了",而不是报"起不来"。
- Windows 11 默认把新托盘图标收进**溢出区**(任务栏那个 `^`),别以为进程没起来。
- **不要在 DSH 会话里启动这个 exe**(比如在某个终端里敲它、或让 agent 帮你跑):那个进程在
DSH 的文件沙箱里,**写不进 `%DSH_HOME%`、也建不了 `%LOCALAPPDATA%` 下的 WebView2 数据
目录**,症状正是 `0x800700AA`(还会连日志都写不出来)。要她出现在桌面上,就在资源管理器里
**双击**,或者用 DSH 设置里的「桌面」让宿主进程去拉起(那条路是沙箱外的)。
### 如果弹框说 `拒绝访问` / `0x800700AA`,而且日志落在 exe 旁边
那多半是这个 exe 被打了 **Low 完整性标签**(Windows 上,低完整性进程写不进任何 Medium
对象 —— `%DSH_HOME%`、WebView2 数据目录都算)。**在 DSH 会话里构建/复制出来的文件会这样**:
```bat
icacls "" :: 看那一行 "Mandatory Label\… Mandatory Level"
icacls "" /setintegritylevel High :: 摆正(一条命令)
```
⚠️ **要摆成 `High`,不要摆成 `Medium`**:完整性标签是**上限** —— Medium 会把进程压在
Medium,在**资源管理器自己跑在 High** 的机器上(UAC 关闭)连**托盘图标都注册不上**
(`GetLastError=5`,日志里能看到)。没标签也行(那就跟着启动者走),但要摘掉标签只能靠
"复制到无标签目录",`icacls /reset` 摘不掉。
`npm run build:portable` 与 `node tools/npm-prepare-subpackage.mjs` 现在都会自动检查并摆正,
摆不正就直接让构建失败 —— 所以发布物不会再有这个问题。
## 已知限制
- **拖动还是"窗口内挪位置"**:桌面版应该是"拖动整只宠物 = 移动窗口"。位置也还是相对
窗口存的,换分辨率会偏。
- **托盘菜单的"显示/隐藏"不会按状态灰掉**(`pet_window::is_visible` 已经写好,还没接)。
- **托盘图标在 Windows 11 默认收进溢出区**(系统行为,不是坏了)。
- **宿主半区现在是两份实现**:Rust 一份、`lib/index.js` 一份(网页端在用)。改宠物契约
(`pet.json` 的字段语义)时要**两边一起改**,然后跑 `probe-catalog.mjs`。
- **只在 Windows 上验过**。这套透明 + 逐像素穿透在 Windows 上的每一环都有驱动盯着;
macOS 那一份**构建**已经通了(见下),但"能不能点、跟不跟手"还没人在真机上验过;
Linux 连构建都还没有。
## 平台支持
| 平台 | 构建 | 验证 | 说明 |
|---|---|---|---|
| Windows x64 | ✅ 本机 / CI | ✅ 驱动齐全(对拍 + 壳全链 + 网页端 suite) | 单文件 `DSH桌宠.exe`,随插件包分发 |
| macOS arm64 | ✅ CI(`.github/workflows/desktop-mac.yml`,`macos-14`) | ❌ **未在真机验证** | 出 `DSH桌宠.app`(ad-hoc 签名)+ 裸二进制(npm 子包用) |
| macOS x64 / Linux | ❌ | ❌ | 平台表与子包清单已按平台写好,加一个矩阵项 / 一次移植即可 |
macOS 那份的三条硬事实(写下来免得下次重新踩):
1. **只能在 macOS 上构建**:依赖里有要编 Objective-C 的 crate,本机(Windows)连
`cargo check --target aarch64-apple-darwin` 都过不去(缺 `cc` 与 macOS SDK);
2. **没签名、没公证**:第一次打开会被 Gatekeeper 拦,要右键→打开,或者
`xattr -dr com.apple.quarantine <路径>`;想双击就开得有 Apple Developer ID;
3. **不进程序坞**靠 `ActivationPolicy::Accessory`(Windows 那个 `skip_taskbar(true)` 在
macOS 上**没有实现**),`.app` 里另外用 `LSUIElement` 兜一层。
## 许可
与主仓库一致:插件与壳的代码 MIT;随包的 DS鲸鱼娘模型 **CC BY-NC-SA 4.0**(署名 ·
非商业 · 相同方式共享),完整说明见 [`../NOTICE.md`](../NOTICE.md)。
**Cubism Core(Live2D 株式会社的专有运行时)**:构建时会把本机缓存过的那份嵌进 exe,
让离线也能用;插件本身仍然走"从官方 CDN 取一次再缓存"那条路,仓库里不分发它。