dsh-plugin-maoxuan-pet 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.en.md +20 -1
- package/README.md +14 -2
- package/desktop/Info.plist +20 -0
- package/desktop/README.md +74 -0
- package/desktop/build.sh +17 -0
- package/desktop/maoxuan-pet.swift +418 -0
- package/desktop/pet.html +415 -0
- package/docs/RELEASE.md +3 -3
- package/package.json +9 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
## 0.3.0 — 2026-11-01
|
|
4
|
+
|
|
5
|
+
新能力:独立 macOS 桌宠壳——小毛从 DSH 界面里走出来,站到真正的桌面上;DSH 最小化甚至关掉界面,她都站在原地。
|
|
6
|
+
|
|
7
|
+
### 桌宠壳(`desktop/`)
|
|
8
|
+
- **为什么需要它**:Web 宠物是 `position:fixed` 的 DOM,永远画不出浏览器视口之外——DSH 最小化她就没。DSH 自带宠物能「跑出去」,是因为壳层进程建了第二个原生窗口(透明、置顶、跨所有 Space)。本壳用纯 Swift + 系统 WebKit(单文件约 450 行、零第三方依赖)给小毛配了同款独立窗口,进程独立于 DSH。
|
|
9
|
+
- **窗口参数对照**(从 DSH Tauri 壳层二进制核实的参数 → 本壳实现):`transparent`/`alwaysOnTop`/`visibleOnAllWorkspaces`/`skipTaskbar` → 透明无边框 `NSWindow` + `.floating` + `canJoinAllSpaces` + accessory 策略(不占 Dock、不进 Cmd-Tab);`set_pet_input_region`/`set_pet_ignore_cursor_events` → 容器 `hitTest` 白名单矩形,矩形外鼠标事件全部穿透回桌面。
|
|
10
|
+
- **数据通道**:`URLSession`(ephemeral)原生轮询既有同源路由——state 平时 20s、生成中 3s、离线 8s;`再督促`/`收起` POST 同源接口;宿主 `index.js` 零改动,Web 宠物照常并存。
|
|
11
|
+
- **素材也走原生层**:WebKit 的 ATS 不认 loopback 豁免,`file://` 页面直挂 `http://127.0.0.1` 的图会被拦(只剩占位鲸),所以精灵图由 URLSession 拉取后转 data URL 注入(`window.__petSprites`);WebKit 零网络请求,壳零磁盘写入(`nonPersistent` 数据存储 + ephemeral 会话)。
|
|
12
|
+
- **交互语义与 Web 宠物一致**:红皮书卡片、新气泡自动弹出、打开 600ms 后标已读、翻书中状态、倒计时标签、`‹ ›` 回翻、Esc 收起;右键小毛 = 复位位置 / 退出;拖小毛或卡片标题栏 = 整窗移动(`screenX/screenY` 差值,无反馈回环),位置存 `UserDefaults`(suite `dsh-maoxuan-pet.window`)跨启动记忆。
|
|
13
|
+
- **构建与运行**:`npm run pet:build`(swiftc 约几秒,模块缓存收进项目内)/ `npm run pet`;参数 `--origin=` / `--port=` / `--reset` / `--verbose`;详见 `desktop/README.md`。
|
|
14
|
+
|
|
15
|
+
### 打包
|
|
16
|
+
- `package.json` `files` 收录 `desktop/` 五个源码文件(Info.plist/README/build.sh/maoxuan-pet.swift/pet.html),源码随 npm 包分发;`desktop/build/` 构建产物(二进制 + Swift 模块缓存,约 70MB)不进包体——`.gitignore` 只拦 git 不拦 `npm pack`,files 白名单目录内的产物必须逐文件点名(0.3.0 发布前实测修正)。
|
|
17
|
+
|
|
3
18
|
## 0.2.3 — 2026-11-01
|
|
4
19
|
|
|
5
20
|
首次安装修复:模型配置自动从宿主 profile 桥接进 headless 子进程,新装机基本零配置。
|
package/README.en.md
CHANGED
|
@@ -14,6 +14,22 @@ dsh plugin --profile web add dsh-plugin-maoxuan-pet
|
|
|
14
14
|
Requirements: DSH `>=0.2.1-rc.2 <0.3.0`, Node `^22.19.0 || >=24.0.0`. Web shell only.
|
|
15
15
|
Source install / uninstall / troubleshooting: [docs/INSTALL.md](docs/INSTALL.md).
|
|
16
16
|
|
|
17
|
+
## Stand on the real desktop (macOS, optional)
|
|
18
|
+
|
|
19
|
+
The web pet lives inside the DSH window and disappears when it is minimized — a hard browser
|
|
20
|
+
sandbox boundary. `desktop/` ships a **standalone-process pet shell** (pure Swift + system
|
|
21
|
+
WebKit, ~450 lines, zero dependencies): transparent, always-on-top, visible on all Spaces.
|
|
22
|
+
Minimize DSH or close the window entirely — she stays. The plugin still owns the brain (the
|
|
23
|
+
hourly essay reading); the shell only relocates the display.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm run pet:build # swiftc build, a few seconds
|
|
27
|
+
npm run pet # she stands at the bottom-right of your screen
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Click to open the red-book card, drag to move (position is remembered), right-click for
|
|
31
|
+
reset/quit. See [desktop/README.md](desktop/README.md) for details.
|
|
32
|
+
|
|
17
33
|
## Screenshot
|
|
18
34
|
|
|
19
35
|

|
|
@@ -70,7 +86,10 @@ fan-made character; not affiliated with DeepSeek.
|
|
|
70
86
|
|
|
71
87
|
## Known limitations
|
|
72
88
|
|
|
73
|
-
- web
|
|
89
|
+
- web pet only (`dsh.client.platform: web`) — no pet in the terminal / TUI; on macOS use the
|
|
90
|
+
desktop shell above to leave the window;
|
|
91
|
+
- the desktop shell is macOS-only (system WebKit); Windows / Linux need a Tauri 2 / Electron
|
|
92
|
+
shell — `pet.html` can be reused as-is;
|
|
74
93
|
- the timer only runs while this `dsh web` process is alive; a stopped process does not catch up,
|
|
75
94
|
and on restart `nextRunAt` decides whether to run once more;
|
|
76
95
|
- each headless generation is a fresh session (no shared context): stable, but she "doesn't
|
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# 鲸鱼娘 · 小毛 ——
|
|
1
|
+
# 鲸鱼娘 · 小毛 —— 每小时陪你读一篇毛选的桌宠,macOS 上还能站上真正的桌面
|
|
2
2
|
|
|
3
3
|
红皮书气泡 + 四帧立绘;每条内容 = 原文引文(带来源日期)+ 一句说给你听的督促 + 一条能当天验证的启示,整条 ≤ 200 字。
|
|
4
4
|
|
|
@@ -13,6 +13,17 @@ dsh plugin --profile web add dsh-plugin-maoxuan-pet
|
|
|
13
13
|
- 要求:DSH `>=0.2.1-rc.2 <0.3.0`、Node `^22.19.0 || >=24.0.0`,仅 Web 界面;
|
|
14
14
|
- 从源码安装、卸载、排错:[docs/INSTALL.md](docs/INSTALL.md)。
|
|
15
15
|
|
|
16
|
+
## 让她站上真正的桌面(macOS,可选)
|
|
17
|
+
|
|
18
|
+
Web 宠物住在 DSH 界面里,界面最小化她就跟着没了——这是浏览器沙箱的硬边界。`desktop/` 目录自带一个**独立进程的桌宠壳**(纯 Swift + 系统 WebKit,约 450 行、零依赖):透明、置顶、跨所有桌面空间,DSH 最小化甚至关掉界面她都站着;毛选的灵魂(每小时读一篇)仍由插件负责,壳只把「显示器」搬了出来。
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm run pet:build # swiftc 编译,几秒
|
|
22
|
+
npm run pet # 她就站在屏幕右下角了
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
点她开合红皮书卡片、拖她换位置(下次还在那儿)、右键她 = 复位位置 / 退出。详见 [desktop/README.md](desktop/README.md)。
|
|
26
|
+
|
|
16
27
|
## 界面
|
|
17
28
|
|
|
18
29
|

|
|
@@ -67,7 +78,8 @@ dsh plugin --profile web add dsh-plugin-maoxuan-pet
|
|
|
67
78
|
|
|
68
79
|
## 已知限制
|
|
69
80
|
|
|
70
|
-
-
|
|
81
|
+
- Web 宠物只支持 Web 界面(`dsh.client.platform: web`),终端 / TUI 里没有宠物;macOS 想让她离开界面,用上面的桌宠壳;
|
|
82
|
+
- 桌宠壳仅 macOS(系统 WebKit);Windows / Linux 需要 Tauri 2 / Electron 起同款壳,`pet.html` 可直接复用;
|
|
71
83
|
- 定时器只在这个 `dsh web` 进程活着时走;进程停了不会补跑,重启后按 `nextRunAt` 决定是否补一次;
|
|
72
84
|
- headless 生成每次是独立会话(不复用上下文):稳定,但「不记得昨天说过什么」——所以 prompt 里带最近 6 条督促来避免重复。
|
|
73
85
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>CFBundleIdentifier</key>
|
|
6
|
+
<string>dev.yuna.dsh-maoxuan-pet.shell</string>
|
|
7
|
+
<key>CFBundleName</key>
|
|
8
|
+
<string>maoxuan-pet</string>
|
|
9
|
+
<key>CFBundlePackageType</key>
|
|
10
|
+
<string>APPL</string>
|
|
11
|
+
<!-- 透明窗要画的宿主数据走 http://127.0.0.1,必须放开 ATS -->
|
|
12
|
+
<key>NSAppTransportSecurity</key>
|
|
13
|
+
<dict>
|
|
14
|
+
<key>NSAllowsArbitraryLoads</key>
|
|
15
|
+
<true/>
|
|
16
|
+
</dict>
|
|
17
|
+
<key>NSHighResolutionCapable</key>
|
|
18
|
+
<true/>
|
|
19
|
+
</dict>
|
|
20
|
+
</plist>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# 桌面壳:让小毛跑出 DSH 窗口(macOS)
|
|
2
|
+
|
|
3
|
+
DSH 自带宠物之所以能「跑出去」,是因为它**根本不在网页里**——壳层 Rust 进程建了第二个
|
|
4
|
+
原生窗口(透明、置顶、跨所有 Space)。网页(无论浏览器还是 iframe)永远画不出视口之外,
|
|
5
|
+
这是浏览器沙箱的硬边界。
|
|
6
|
+
|
|
7
|
+
本目录给「鲸鱼娘·小毛」配了一个**独立的原生壳**:进程独立于 DSH,DSH 主窗口最小化、
|
|
8
|
+
甚至 Web 界面整个关掉,她都站在桌面上。毛选的灵魂(每小时一篇的 headless 生成)
|
|
9
|
+
仍由 `index.js` 宿主插件负责,壳只是把「显示器」搬了出来。
|
|
10
|
+
|
|
11
|
+
## 架构
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
┌─ DSH 宿主(照旧,什么都不用改) ─────────────┐
|
|
15
|
+
│ index.js 插件:每小时 headless 读毛选 → │
|
|
16
|
+
│ GET /api/dsh-maoxuan-pet.state │
|
|
17
|
+
│ POST /api/dsh-maoxuan-pet.generate|dismiss │
|
|
18
|
+
│ GET /api/dsh-maoxuan-pet.asset?name=… │
|
|
19
|
+
└──────────────┬──────────────────────────────┘
|
|
20
|
+
│ URLSession 原生轮询(绕开 WKWebView 跨域)
|
|
21
|
+
┌──────────────┴──────────────────────────────┐
|
|
22
|
+
│ maoxuan-pet(独立 Swift 进程) │
|
|
23
|
+
│ · 透明无边框 NSWindow,.floating 置顶, │
|
|
24
|
+
│ canJoinAllSpaces(所有桌面/全屏旁可见) │
|
|
25
|
+
│ · WKWebView 渲染 pet.html(宠物+红皮书卡片) │
|
|
26
|
+
│ · hitTest 白名单矩形 = 点击穿透 │
|
|
27
|
+
│ (等价 dsh-tauri 的 set_pet_input_region) │
|
|
28
|
+
│ · 拖拽/位置持久化/右键菜单/退出 │
|
|
29
|
+
└─────────────────────────────────────────────┘
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
对照 DSH 自带宠物窗口(从壳层二进制里核实到的参数):
|
|
33
|
+
`transparent` / `alwaysOnTop` / `visibleOnAllWorkspaces` / `skipTaskbar` /
|
|
34
|
+
`set_pet_input_region` / `set_pet_ignore_cursor_events` / 位置持久化到 store——
|
|
35
|
+
本壳一一对应,只是把宿主从 Tauri 换成了一个几十 KB 的独立进程。
|
|
36
|
+
|
|
37
|
+
## 构建与运行
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
npm run pet:build # swiftc 编译(约几秒,产物 desktop/build/maoxuan-pet)
|
|
41
|
+
npm run pet # 编译并前台运行(Ctrl-C 或右键小毛→退出)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
参数:
|
|
45
|
+
|
|
46
|
+
| 参数 | 说明 |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| `--origin=URL` | 宿主地址,默认 `http://127.0.0.1:3081` |
|
|
49
|
+
| `--port=N` | 等价 `--origin=http://127.0.0.1:N` |
|
|
50
|
+
| `--reset` | 忽略保存的位置,回到屏幕右下 |
|
|
51
|
+
| `--verbose` | 向 stderr 打轮询/POST/输入区日志 |
|
|
52
|
+
|
|
53
|
+
无 Dock 图标、不进 Cmd-Tab(`NSActivationPolicy.accessory`)。窗口位置存在
|
|
54
|
+
`UserDefaults`(suite `dsh-maoxuan-pet.window`),拖到哪儿下次就在哪儿。
|
|
55
|
+
|
|
56
|
+
## 交互
|
|
57
|
+
|
|
58
|
+
- **点小毛**:开合红皮书卡片;新气泡照样自动弹出、600ms 后自动标记已读——与 web 版同语义。
|
|
59
|
+
- **拖小毛或卡片标题栏**:整窗移动;原地松手才算点击(>4px 判定为拖拽)。
|
|
60
|
+
- **右键小毛**:复位位置 / 退出小毛。
|
|
61
|
+
- **Esc**:收起卡片。
|
|
62
|
+
- 宿主不在时:圆点变红、卡片内提示「连接中断,重试中…」,人继续站着。
|
|
63
|
+
|
|
64
|
+
## 已知边界
|
|
65
|
+
|
|
66
|
+
- 仅 macOS(用系统 WebKit,零第三方依赖)。Windows/Linux 想要同款,
|
|
67
|
+
照 `docs/` 里 dsh-tauri 的窗口参数用 Tauri 2 或 Electron 起一个同样的壳即可,
|
|
68
|
+
页面(pet.html)不用改——它只依赖 `__petState/__petSprites/__petOffline/__petActionDone`
|
|
69
|
+
几个注入点和 `webkit.messageHandlers.pet` 一个消息通道。
|
|
70
|
+
- **精灵图走原生层转 data URL**:WebKit 的 ATS 不认 loopback 豁免,
|
|
71
|
+
`file://` 页面直挂 `http://127.0.0.1` 的图会被拦(表现为只剩占位鲸),
|
|
72
|
+
所以素材由 URLSession 拉取后 base64 注入,WebKit 全程零网络请求。
|
|
73
|
+
- 宿主关掉后她只是「待机」并不会死——离线轮询 8s 一次,开销可忽略。
|
|
74
|
+
- 本壳不上传任何东西;全部数据都来自本机 harness 端口。
|
package/desktop/build.sh
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# 构建独立桌宠壳:纯 Swift + 系统 WebKit,无第三方依赖。
|
|
3
|
+
# 产物:desktop/build/maoxuan-pet(单文件可执行,pet.html 同目录随行)。
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
cd "$(dirname "$0")/.."
|
|
6
|
+
|
|
7
|
+
mkdir -p desktop/build
|
|
8
|
+
# 模块缓存放进项目内:系统默认缓存目录(/var/folders/.../C/clang)可能不可写
|
|
9
|
+
swiftc -O \
|
|
10
|
+
-module-cache-path desktop/build/.module-cache \
|
|
11
|
+
-o desktop/build/maoxuan-pet \
|
|
12
|
+
desktop/maoxuan-pet.swift \
|
|
13
|
+
-Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist -Xlinker desktop/Info.plist
|
|
14
|
+
cp desktop/pet.html desktop/build/pet.html
|
|
15
|
+
|
|
16
|
+
echo "built: desktop/build/maoxuan-pet"
|
|
17
|
+
echo "run: ./desktop/build/maoxuan-pet [--origin=http://127.0.0.1:3081] [--reset] [--verbose]"
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
// desktop/maoxuan-pet.swift — 「鲸鱼娘·小毛」独立桌宠壳(macOS)
|
|
2
|
+
//
|
|
3
|
+
// 学 dsh-tauri-pet 的原生窗口思路,但**进程独立于 DSH**:
|
|
4
|
+
// - 透明、无边框、置顶、跨所有 Space 的 NSWindow + WKWebView
|
|
5
|
+
// (对应 Tauri 的 transparent / alwaysOnTop / visibleOnAllWorkspaces / skipTaskbar);
|
|
6
|
+
// - 点击穿透用 hitTest 白名单矩形实现(等价 dsh-tauri 的 set_pet_input_region);
|
|
7
|
+
// - 宿主数据由本进程 URLSession 轮询(等价宠物窗口的 session stream),
|
|
8
|
+
// 绕开 WKWebView 的跨域限制,页面只管渲染;
|
|
9
|
+
// - DSH 主窗口最小化甚至退出都不影响她站着——她是自己的进程。
|
|
10
|
+
//
|
|
11
|
+
// 用法:maoxuan-pet [--origin=http://127.0.0.1:3081 | --port=3081] [--reset] [--verbose]
|
|
12
|
+
// 退出:右键小毛 → 退出小毛。
|
|
13
|
+
|
|
14
|
+
import AppKit
|
|
15
|
+
import WebKit
|
|
16
|
+
|
|
17
|
+
// MARK: - 配置
|
|
18
|
+
|
|
19
|
+
enum Shell {
|
|
20
|
+
static var origin = "http://127.0.0.1:3081"
|
|
21
|
+
static var verbose = false
|
|
22
|
+
static let statePath = "/api/dsh-maoxuan-pet.state"
|
|
23
|
+
static let generatePath = "/api/dsh-maoxuan-pet.generate"
|
|
24
|
+
static let dismissPath = "/api/dsh-maoxuan-pet.dismiss"
|
|
25
|
+
static let windowWidth: CGFloat = 640
|
|
26
|
+
static let windowHeight: CGFloat = 720
|
|
27
|
+
static let defaults = UserDefaults(suiteName: "dsh-maoxuan-pet.window")!
|
|
28
|
+
|
|
29
|
+
static func log(_ message: String) {
|
|
30
|
+
guard verbose else { return }
|
|
31
|
+
FileHandle.standardError.write(Data("[maoxuan-pet] \(message)\n".utf8))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
static func jsonString(_ value: Any) -> String {
|
|
35
|
+
guard JSONSerialization.isValidJSONObject([value]),
|
|
36
|
+
let data = try? JSONSerialization.data(withJSONObject: [value]),
|
|
37
|
+
let array = String(data: data, encoding: .utf8),
|
|
38
|
+
array.count > 1 else { return "{}" }
|
|
39
|
+
return String(array.dropFirst().dropLast())
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// MARK: - 无边框窗口要显式声明可成为 key(Escape 关卡片需要键盘事件)
|
|
44
|
+
|
|
45
|
+
final class PetWindow: NSWindow {
|
|
46
|
+
override var canBecomeKey: Bool { true }
|
|
47
|
+
override var canBecomeMain: Bool { true }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// MARK: - 点击穿透容器:白名单矩形之外,鼠标事件全部放行给桌面
|
|
51
|
+
|
|
52
|
+
final class ClickThroughView: NSView {
|
|
53
|
+
weak var webView: WKWebView?
|
|
54
|
+
/// 容器本地坐标(左下原点);矩形之外 hitTest 返回 nil → 点击落到下层窗口。
|
|
55
|
+
var passRects: [CGRect] = []
|
|
56
|
+
|
|
57
|
+
override func hitTest(_ aPoint: NSPoint) -> NSView? {
|
|
58
|
+
let local: NSPoint
|
|
59
|
+
if let superview {
|
|
60
|
+
local = convert(aPoint, from: superview)
|
|
61
|
+
} else {
|
|
62
|
+
local = aPoint
|
|
63
|
+
}
|
|
64
|
+
for rect in passRects where rect.contains(local) {
|
|
65
|
+
return webView
|
|
66
|
+
}
|
|
67
|
+
return nil
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// MARK: - 主控制器
|
|
72
|
+
|
|
73
|
+
final class ShellController: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKScriptMessageHandler {
|
|
74
|
+
private var window: PetWindow!
|
|
75
|
+
private var webView: WKWebView!
|
|
76
|
+
private var clickView: ClickThroughView!
|
|
77
|
+
private var pollItem: DispatchWorkItem?
|
|
78
|
+
/// ephemeral:无磁盘缓存/cookie,壳不写任何本地数据(也躲开 ~/Library/Caches 权限噪音)
|
|
79
|
+
private lazy var http: URLSession = URLSession(configuration: .ephemeral)
|
|
80
|
+
private var dragBaseOrigin: NSPoint?
|
|
81
|
+
private var pageReady = false
|
|
82
|
+
private var lastInterval: TimeInterval = 20
|
|
83
|
+
/// 精灵图 name → data URL:WebKit 的 ATS 会拦 file:// 页面直挂的 http 图,
|
|
84
|
+
/// 所以素材也由原生层拉取后转 base64 注入,WebKit 全程零网络请求。
|
|
85
|
+
private var spriteDataURLs: [String: String] = [:]
|
|
86
|
+
private var spritePending = Set<String>()
|
|
87
|
+
|
|
88
|
+
// MARK 生命周期
|
|
89
|
+
|
|
90
|
+
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
91
|
+
let content = NSRect(x: 0, y: 0, width: Shell.windowWidth, height: Shell.windowHeight)
|
|
92
|
+
let window = PetWindow(
|
|
93
|
+
contentRect: restoredFrame() ?? defaultFrame(),
|
|
94
|
+
styleMask: [.borderless],
|
|
95
|
+
backing: .buffered,
|
|
96
|
+
defer: false
|
|
97
|
+
)
|
|
98
|
+
window.isOpaque = false
|
|
99
|
+
window.backgroundColor = .clear
|
|
100
|
+
window.hasShadow = false
|
|
101
|
+
window.level = .floating
|
|
102
|
+
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle]
|
|
103
|
+
window.isMovableByWindowBackground = false
|
|
104
|
+
window.acceptsMouseMovedEvents = true
|
|
105
|
+
window.isReleasedWhenClosed = false
|
|
106
|
+
window.ignoresMouseEvents = false
|
|
107
|
+
|
|
108
|
+
let container = ClickThroughView(frame: content)
|
|
109
|
+
container.wantsLayer = true
|
|
110
|
+
|
|
111
|
+
let configuration = WKWebViewConfiguration()
|
|
112
|
+
// 壳不需要任何网站数据(位置持久化走原生 UserDefaults),用一次性存储免得碰 ~/Library/WebKit
|
|
113
|
+
configuration.websiteDataStore = .nonPersistent()
|
|
114
|
+
configuration.userContentController.add(self, name: "pet")
|
|
115
|
+
// __petEnv 必须在页面脚本运行前就位(didFinish 时内联脚本早已执行完)
|
|
116
|
+
let envScript = WKUserScript(
|
|
117
|
+
source: "window.__petEnv = \(Shell.jsonString(["origin": Shell.origin]));",
|
|
118
|
+
injectionTime: .atDocumentStart,
|
|
119
|
+
forMainFrameOnly: true
|
|
120
|
+
)
|
|
121
|
+
configuration.userContentController.addUserScript(envScript)
|
|
122
|
+
|
|
123
|
+
let webView = WKWebView(frame: container.bounds, configuration: configuration)
|
|
124
|
+
webView.autoresizingMask = [.width, .height]
|
|
125
|
+
webView.navigationDelegate = self
|
|
126
|
+
// WKWebView 画透明底的两件套
|
|
127
|
+
webView.setValue(false, forKey: "drawsBackground")
|
|
128
|
+
webView.underPageBackgroundColor = .clear
|
|
129
|
+
|
|
130
|
+
container.webView = webView
|
|
131
|
+
container.addSubview(webView)
|
|
132
|
+
window.contentView = container
|
|
133
|
+
|
|
134
|
+
self.window = window
|
|
135
|
+
self.webView = webView
|
|
136
|
+
self.clickView = container
|
|
137
|
+
|
|
138
|
+
if let html = locateHTML() {
|
|
139
|
+
webView.loadFileURL(html, allowingReadAccessTo: html.deletingLastPathComponent())
|
|
140
|
+
} else {
|
|
141
|
+
FileHandle.standardError.write(Data("[maoxuan-pet] 找不到 pet.html(应与可执行文件同目录)\n".utf8))
|
|
142
|
+
NSApp.terminate(nil)
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
window.orderFrontRegardless()
|
|
147
|
+
FileHandle.standardError.write(Data("[maoxuan-pet] 小毛已站上桌面 — \(Shell.origin)(独立进程,DSH 最小化不影响她)\n".utf8))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
func applicationWillTerminate(_ notification: Notification) {
|
|
151
|
+
persistFrame()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/// pet.html 与可执行文件同目录(build.sh 负责拷贝)。
|
|
155
|
+
private func locateHTML() -> URL? {
|
|
156
|
+
let argv0 = CommandLine.arguments.first ?? "maoxuan-pet"
|
|
157
|
+
let executable = URL(fileURLWithPath: argv0).resolvingSymlinksInPath()
|
|
158
|
+
let candidate = executable.deletingLastPathComponent().appendingPathComponent("pet.html")
|
|
159
|
+
return FileManager.default.fileExists(atPath: candidate.path) ? candidate : nil
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// MARK 窗口位置
|
|
163
|
+
|
|
164
|
+
private func defaultFrame() -> NSRect {
|
|
165
|
+
let visible = NSScreen.main?.visibleFrame
|
|
166
|
+
?? NSRect(x: 0, y: 0, width: 1440, height: 900)
|
|
167
|
+
let origin = NSPoint(
|
|
168
|
+
x: visible.maxX - Shell.windowWidth - 28,
|
|
169
|
+
y: visible.minY + 96
|
|
170
|
+
)
|
|
171
|
+
return NSRect(origin: origin, size: NSSize(width: Shell.windowWidth, height: Shell.windowHeight))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private func restoredFrame() -> NSRect? {
|
|
175
|
+
guard ProcessInfo.processInfo.arguments.contains("--reset") == false,
|
|
176
|
+
let raw = Shell.defaults.string(forKey: "frame") else { return nil }
|
|
177
|
+
let frame = NSRectFromString(raw)
|
|
178
|
+
let onScreen = NSScreen.screens.contains { $0.visibleFrame.intersects(frame) }
|
|
179
|
+
guard onScreen, frame.width > 100, frame.height > 100 else { return nil }
|
|
180
|
+
return frame
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private func persistFrame() {
|
|
184
|
+
guard let window else { return }
|
|
185
|
+
Shell.defaults.set(NSStringFromRect(window.frame), forKey: "frame")
|
|
186
|
+
Shell.log("frame saved: \(NSStringFromRect(window.frame))")
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private func resetFrame() {
|
|
190
|
+
Shell.defaults.removeObject(forKey: "frame")
|
|
191
|
+
let frame = defaultFrame()
|
|
192
|
+
window.setFrame(frame, display: true)
|
|
193
|
+
persistFrame()
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/// 拖拽落点收敛:至少留 140pt 在可见区域内,防止拖丢。
|
|
197
|
+
private func clampedOrigin(_ origin: NSPoint) -> NSPoint {
|
|
198
|
+
let size = window.frame.size
|
|
199
|
+
let center = NSPoint(x: origin.x + size.width / 2, y: origin.y + size.height / 2)
|
|
200
|
+
let screen = NSScreen.screens.first { $0.frame.contains(center) } ?? NSScreen.main
|
|
201
|
+
let visible = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900)
|
|
202
|
+
let clampedX = min(max(origin.x, visible.minX - size.width + 140), visible.maxX - 140)
|
|
203
|
+
let clampedY = min(max(origin.y, visible.minY - size.height + 140), visible.maxY - 40)
|
|
204
|
+
return NSPoint(x: clampedX, y: clampedY)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// MARK 轮询(原生层负责全部 HTTP,页面不跨域)
|
|
208
|
+
|
|
209
|
+
private func schedulePoll(after interval: TimeInterval) {
|
|
210
|
+
pollItem?.cancel()
|
|
211
|
+
let item = DispatchWorkItem { [weak self] in self?.pollNow() }
|
|
212
|
+
pollItem = item
|
|
213
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + interval, execute: item)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private func pollNow() {
|
|
217
|
+
guard let url = URL(string: Shell.origin + Shell.statePath) else { return }
|
|
218
|
+
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 8)
|
|
219
|
+
request.httpMethod = "GET"
|
|
220
|
+
http.dataTask(with: request) { [weak self] data, response, error in
|
|
221
|
+
DispatchQueue.main.async {
|
|
222
|
+
guard let self else { return }
|
|
223
|
+
if let error {
|
|
224
|
+
Shell.log("state failed: \(error.localizedDescription)")
|
|
225
|
+
self.evaluate("window.__petOffline && window.__petOffline();")
|
|
226
|
+
self.schedulePoll(after: 8)
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
if let http = response as? HTTPURLResponse, http.statusCode != 200 {
|
|
230
|
+
Shell.log("state HTTP \(http.statusCode)")
|
|
231
|
+
self.evaluate("window.__petOffline && window.__petOffline();")
|
|
232
|
+
self.schedulePoll(after: 8)
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
guard let data, let text = String(data: data, encoding: .utf8) else {
|
|
236
|
+
self.evaluate("window.__petOffline && window.__petOffline();")
|
|
237
|
+
self.schedulePoll(after: 8)
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
// 生成中的话 3s 后紧跟下一条,让气泡尽快落地
|
|
241
|
+
let running = (text.contains("\"running\":true") || text.contains("\"running\" : true"))
|
|
242
|
+
self.lastInterval = running ? 3 : 20
|
|
243
|
+
self.ensureSprites(from: data)
|
|
244
|
+
self.evaluate("window.__petState && window.__petState(\(self.sanitized(text)));")
|
|
245
|
+
self.schedulePoll(after: self.lastInterval)
|
|
246
|
+
}
|
|
247
|
+
}.resume()
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/// 从 state 里拿 pet.sprites,逐张原生拉取并转 data URL 注入页面(一次性,之后命中缓存)。
|
|
251
|
+
private func ensureSprites(from stateData: Data) {
|
|
252
|
+
guard let obj = try? JSONSerialization.jsonObject(with: stateData),
|
|
253
|
+
let dict = obj as? [String: Any],
|
|
254
|
+
let pet = dict["pet"] as? [String: Any],
|
|
255
|
+
let sprites = pet["sprites"] as? [String: String] else { return }
|
|
256
|
+
var newlyNeeded: [(name: String, path: String)] = []
|
|
257
|
+
for (name, path) in sprites {
|
|
258
|
+
guard spriteDataURLs[name] == nil, !spritePending.contains(name) else { continue }
|
|
259
|
+
spritePending.insert(name)
|
|
260
|
+
newlyNeeded.append((name, path))
|
|
261
|
+
}
|
|
262
|
+
guard !newlyNeeded.isEmpty else { return }
|
|
263
|
+
for entry in newlyNeeded {
|
|
264
|
+
guard let url = URL(string: Shell.origin + entry.path) else {
|
|
265
|
+
spritePending.remove(entry.name)
|
|
266
|
+
continue
|
|
267
|
+
}
|
|
268
|
+
http.dataTask(with: URLRequest(url: url, timeoutInterval: 8)) { [weak self] data, response, error in
|
|
269
|
+
DispatchQueue.main.async {
|
|
270
|
+
guard let self else { return }
|
|
271
|
+
self.spritePending.remove(entry.name)
|
|
272
|
+
if let error {
|
|
273
|
+
Shell.log("sprite \(entry.name) failed: \(error.localizedDescription)")
|
|
274
|
+
} else if let http = response as? HTTPURLResponse, http.statusCode == 200, let data {
|
|
275
|
+
let mime = http.mimeType ?? "image/png"
|
|
276
|
+
self.spriteDataURLs[entry.name] = "data:\(mime);base64,\(data.base64EncodedString())"
|
|
277
|
+
Shell.log("sprite \(entry.name) ready (\(data.count) bytes)")
|
|
278
|
+
}
|
|
279
|
+
self.evaluate("window.__petSprites && window.__petSprites(\(Shell.jsonString(self.spriteDataURLs)));")
|
|
280
|
+
}
|
|
281
|
+
}.resume()
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/// JSON 文本直接内联为 JS 字面量;U+2028/2029 在旧解析器里会断行,换义掉。
|
|
286
|
+
private func sanitized(_ text: String) -> String {
|
|
287
|
+
text
|
|
288
|
+
.replacingOccurrences(of: "\u{2028}", with: "\\u2028")
|
|
289
|
+
.replacingOccurrences(of: "\u{2029}", with: "\\u2029")
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private func evaluate(_ source: String) {
|
|
293
|
+
guard pageReady else { return }
|
|
294
|
+
webView.evaluateJavaScript(source, completionHandler: { result, error in
|
|
295
|
+
if let error { Shell.log("evaluate failed: \(error.localizedDescription)") }
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// MARK POST 动作(再督促 / 收起)
|
|
300
|
+
|
|
301
|
+
private func postAction(path: String, body: String) {
|
|
302
|
+
guard let url = URL(string: Shell.origin + path) else { return }
|
|
303
|
+
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 200)
|
|
304
|
+
request.httpMethod = "POST"
|
|
305
|
+
request.setValue("application/json", forHTTPHeaderField: "content-type")
|
|
306
|
+
request.httpBody = body.data(using: .utf8)
|
|
307
|
+
Shell.log("POST \(path) \(body)")
|
|
308
|
+
http.dataTask(with: request) { [weak self] data, response, error in
|
|
309
|
+
DispatchQueue.main.async {
|
|
310
|
+
guard let self else { return }
|
|
311
|
+
if let error {
|
|
312
|
+
Shell.log("POST failed: \(error.localizedDescription)")
|
|
313
|
+
} else if let http = (response as? HTTPURLResponse) {
|
|
314
|
+
let bodyText = data.flatMap { String(data: $0, encoding: .utf8) } ?? ""
|
|
315
|
+
Shell.log("POST \(path) → \(http.statusCode) \(String(bodyText.prefix(160)))")
|
|
316
|
+
}
|
|
317
|
+
self.evaluate("window.__petActionDone && window.__petActionDone();")
|
|
318
|
+
self.pollNow()
|
|
319
|
+
}
|
|
320
|
+
}.resume()
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// MARK WKNavigationDelegate
|
|
324
|
+
|
|
325
|
+
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
|
326
|
+
pageReady = true
|
|
327
|
+
pollNow()
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
|
331
|
+
Shell.log("page load failed: \(error.localizedDescription)")
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// MARK WKScriptMessageHandler(页面 → 原生)
|
|
335
|
+
|
|
336
|
+
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
|
337
|
+
guard message.name == "pet",
|
|
338
|
+
let body = message.body as? [String: Any],
|
|
339
|
+
let type = body["type"] as? String else { return }
|
|
340
|
+
switch type {
|
|
341
|
+
case "input":
|
|
342
|
+
// 页面上报的可交互矩形(CSS px,左上原点)→ 容器本地坐标(左下原点)
|
|
343
|
+
guard let rects = body["rects"] as? [[String: Any]] else { return }
|
|
344
|
+
let contentHeight = webView.bounds.height
|
|
345
|
+
let converted: [CGRect] = rects.compactMap { dict in
|
|
346
|
+
guard let x = (dict["x"] as? NSNumber)?.doubleValue,
|
|
347
|
+
let y = (dict["y"] as? NSNumber)?.doubleValue,
|
|
348
|
+
let w = (dict["w"] as? NSNumber)?.doubleValue,
|
|
349
|
+
let h = (dict["h"] as? NSNumber)?.doubleValue,
|
|
350
|
+
w > 0, h > 0 else { return nil }
|
|
351
|
+
return CGRect(x: x, y: contentHeight - (y + h), width: w, height: h)
|
|
352
|
+
}
|
|
353
|
+
clickView.passRects = converted
|
|
354
|
+
Shell.log("input rects: \(converted.count)")
|
|
355
|
+
|
|
356
|
+
case "dragstart":
|
|
357
|
+
dragBaseOrigin = window.frame.origin
|
|
358
|
+
|
|
359
|
+
case "dragto":
|
|
360
|
+
guard let base = dragBaseOrigin else { return }
|
|
361
|
+
let dx = (body["dx"] as? NSNumber)?.doubleValue ?? 0
|
|
362
|
+
let dy = (body["dy"] as? NSNumber)?.doubleValue ?? 0
|
|
363
|
+
let target = clampedOrigin(NSPoint(x: base.x + dx, y: base.y - dy))
|
|
364
|
+
window.setFrameOrigin(target)
|
|
365
|
+
|
|
366
|
+
case "dragend":
|
|
367
|
+
dragBaseOrigin = nil
|
|
368
|
+
persistFrame()
|
|
369
|
+
|
|
370
|
+
case "action":
|
|
371
|
+
let action = body["action"] as? String
|
|
372
|
+
if action == "generate" {
|
|
373
|
+
postAction(path: Shell.generatePath, body: "{}")
|
|
374
|
+
} else if action == "dismiss" {
|
|
375
|
+
let raw = body["id"] as? String ?? ""
|
|
376
|
+
let id = String(raw.filter { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" })
|
|
377
|
+
let payload = id.isEmpty ? "{}" : "{\"id\":\"\(id)\"}"
|
|
378
|
+
postAction(path: Shell.dismissPath, body: payload)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
case "config":
|
|
382
|
+
let action = body["action"] as? String
|
|
383
|
+
if action == "quit" {
|
|
384
|
+
NSApp.terminate(nil)
|
|
385
|
+
} else if action == "reset" {
|
|
386
|
+
resetFrame()
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
default:
|
|
390
|
+
break
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// MARK: - 入口
|
|
396
|
+
|
|
397
|
+
let arguments = ProcessInfo.processInfo.arguments
|
|
398
|
+
var index = 1
|
|
399
|
+
while index < arguments.count {
|
|
400
|
+
let argument = arguments[index]
|
|
401
|
+
if argument.hasPrefix("--origin=") {
|
|
402
|
+
var value = String(argument.dropFirst("--origin=".count))
|
|
403
|
+
while value.hasSuffix("/") { value.removeLast() }
|
|
404
|
+
Shell.origin = value
|
|
405
|
+
} else if argument.hasPrefix("--port=") {
|
|
406
|
+
let value = String(argument.dropFirst("--port=".count))
|
|
407
|
+
Shell.origin = "http://127.0.0.1:\(value)"
|
|
408
|
+
} else if argument == "--verbose" {
|
|
409
|
+
Shell.verbose = true
|
|
410
|
+
}
|
|
411
|
+
index += 1
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
let app = NSApplication.shared
|
|
415
|
+
app.setActivationPolicy(.accessory) // 不占 Dock、不进 Cmd-Tab
|
|
416
|
+
let controller = ShellController()
|
|
417
|
+
app.delegate = controller
|
|
418
|
+
app.run()
|
package/desktop/pet.html
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<!--
|
|
3
|
+
dsh-plugin-maoxuan-pet · 桌面壳渲染页
|
|
4
|
+
|
|
5
|
+
学 dsh-tauri-pet 的窗口思路,但进程独立于 DSH:
|
|
6
|
+
宿主数据由 Swift 原生层轮询后经 window.__petState() 注入(绕开 WKWebView 跨域),
|
|
7
|
+
页面只负责画「鲸鱼娘·小毛」+ 红皮书卡片,并把可交互矩形上报给原生层做点击穿透。
|
|
8
|
+
视觉与交互语义与 client.js(web 宠物)保持一致。
|
|
9
|
+
-->
|
|
10
|
+
<html lang="zh-CN">
|
|
11
|
+
<head>
|
|
12
|
+
<meta charset="utf-8">
|
|
13
|
+
<style>
|
|
14
|
+
html,body{margin:0;padding:0;width:100%;height:100%;background:transparent;overflow:hidden}
|
|
15
|
+
body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;color:#ffe9d6;font-size:13px;line-height:1.6}
|
|
16
|
+
*{box-sizing:border-box}
|
|
17
|
+
#stage{position:absolute;right:44px;bottom:56px}
|
|
18
|
+
.mp-anchor{position:relative;display:flex;flex-direction:column;align-items:center}
|
|
19
|
+
.mp-pet{position:relative;z-index:1;flex:0 0 auto;display:flex;flex-direction:column;align-items:center;cursor:grab;user-select:none;transition:transform .25s ease}
|
|
20
|
+
.mp-pet:active{cursor:grabbing}
|
|
21
|
+
.mp-pet:hover{transform:translateY(-3px)}
|
|
22
|
+
.mp-img{display:block;height:132px;width:auto;max-width:140px;object-fit:contain;filter:drop-shadow(0 10px 16px #050b14aa)}
|
|
23
|
+
.mp-img[data-talking="on"]{animation:mp-bob 1.6s ease-in-out infinite}
|
|
24
|
+
.mp-fallback{height:132px;width:100px;filter:drop-shadow(0 10px 16px #050b14aa)}
|
|
25
|
+
.mp-tag{margin-top:2px;font-size:10px;font-weight:700;letter-spacing:.06em;color:#ffe2bd;background:#5c0f0fd9;border:1px solid #ffd9a833;border-radius:999px;padding:2px 8px;white-space:nowrap;backdrop-filter:blur(8px)}
|
|
26
|
+
.mp-dot{display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:5px;background:#f6cf9a;box-shadow:0 0 6px #f6cf9aaa;vertical-align:1px}
|
|
27
|
+
.mp-dot[data-off="off"]{background:#fca5a5;box-shadow:0 0 6px #fca5a5aa}
|
|
28
|
+
.mp-bubble{position:absolute;z-index:2;right:calc(100% + 12px);bottom:30px;width:340px;animation:mp-in .16s ease-out}
|
|
29
|
+
.mp-card{position:relative;max-height:min(58vh,480px);overflow-y:auto;overflow-x:hidden;overscroll-behavior:contain;scrollbar-width:thin;scrollbar-color:#f6cf9a #5f1010dd;color:#fff3e4;border:1px solid #8f1616;border-radius:12px;background:linear-gradient(155deg,#b32323 0%,#941a1a 46%,#771111 100%);box-shadow:0 22px 54px #35040466,0 0 0 1px #ffd9a814 inset}
|
|
30
|
+
.mp-card::-webkit-scrollbar{width:8px;height:8px}
|
|
31
|
+
.mp-card::-webkit-scrollbar-track{background:#5f1010cc;border-radius:8px}
|
|
32
|
+
.mp-card::-webkit-scrollbar-thumb{background:linear-gradient(180deg,#f7d9a4,#d09a3f);border-radius:8px;border:1px solid #5f1010}
|
|
33
|
+
.mp-card::-webkit-scrollbar-thumb:hover{background:linear-gradient(180deg,#ffe9c4,#e0aa4d)}
|
|
34
|
+
.mp-card::before{content:"";position:absolute;left:0;top:0;bottom:0;width:5px;border-radius:11px 0 0 11px;background:linear-gradient(180deg,#f7d9a4,#d09a3f 45%,#b8792a);opacity:.92}
|
|
35
|
+
.mp-tail{position:absolute;right:-5px;bottom:38px;width:10px;height:10px;background:#8f1616;border-right:1px solid #8f1616;border-top:1px solid #8f1616;transform:rotate(45deg)}
|
|
36
|
+
.mp-head{display:flex;align-items:center;gap:6px;padding:9px 8px 8px 14px;border-bottom:1px solid #ffd9a82e;cursor:grab}
|
|
37
|
+
.mp-head:active{cursor:grabbing}
|
|
38
|
+
.mp-state{flex:1;min-width:0;font-size:10.5px;font-weight:750;letter-spacing:.06em;color:#ffe0b6;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
39
|
+
.mp-nav{border:0;background:none;color:#ffd9a8;font-size:15px;line-height:1;padding:1px 4px;cursor:pointer;border-radius:4px;opacity:.8}
|
|
40
|
+
.mp-nav:hover:not(:disabled){color:#fff8ee;opacity:1}
|
|
41
|
+
.mp-nav:disabled{opacity:.3;cursor:default}
|
|
42
|
+
.mp-close{border:0;background:none;color:#ffd9a8;font-size:16px;line-height:1;padding:2px 4px;cursor:pointer;border-radius:4px;opacity:.85}
|
|
43
|
+
.mp-close:hover{color:#fff8ee;opacity:1}
|
|
44
|
+
.mp-body{padding:10px 14px 11px 16px}
|
|
45
|
+
.mp-cite{display:flex;gap:9px;margin:0 0 8px}
|
|
46
|
+
.mp-rule{flex:0 0 auto;width:2px;border-radius:2px;background:#f6cf9a}
|
|
47
|
+
.mp-cite-main{min-width:0}
|
|
48
|
+
.mp-cite p{margin:0;font-size:13px;color:#fff8ee;white-space:pre-wrap;word-break:break-word}
|
|
49
|
+
.mp-cite[data-kind="summary"] .mp-rule{background:#f0b57a}
|
|
50
|
+
.mp-meta{display:flex;align-items:center;gap:6px;margin:0 0 8px;font-size:10px;color:#f3cfa8}
|
|
51
|
+
.mp-meta b{font-weight:750;color:#ffe0b6}
|
|
52
|
+
.mp-pua{display:flex;gap:6px;margin:0 0 8px;font-size:13.5px;font-weight:750;color:#ffe7c2;letter-spacing:.01em}
|
|
53
|
+
.mp-pua span:first-child{color:#ffcf8a;font-size:15px;line-height:1.35;font-weight:800}
|
|
54
|
+
.mp-lesson{margin:0;font-size:12.5px;color:#ffeede}
|
|
55
|
+
.mp-lesson b{color:#ffd08a;font-weight:750}
|
|
56
|
+
.mp-axis{display:inline-block;font-size:9.5px;font-weight:800;letter-spacing:.08em;padding:1px 6px;border-radius:4px;margin-right:6px;color:#5a1208;vertical-align:1px}
|
|
57
|
+
.mp-foot{display:flex;align-items:center;gap:8px;margin-top:10px;padding-top:8px;border-top:1px solid #ffd9a82a}
|
|
58
|
+
.mp-src{flex:1;min-width:0;font-size:10px;color:#f0c9a2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
59
|
+
.mp-src a{color:#f0c9a2;text-decoration:none}
|
|
60
|
+
.mp-src a:hover{color:#fff8ee;text-decoration:underline}
|
|
61
|
+
.mp-offline{color:#f3cfa8;opacity:.9}
|
|
62
|
+
.mp-btn{border:0;background:none;padding:2px 3px;color:#ffe4c7;font:inherit;font-size:11.5px;font-weight:650;cursor:pointer;white-space:nowrap;text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:3px;text-decoration-color:#ffd9a84d;opacity:.92}
|
|
63
|
+
.mp-btn:hover:not(:disabled){color:#fff8ee;text-decoration-color:#ffd9a8;opacity:1}
|
|
64
|
+
.mp-btn:disabled{opacity:.5;cursor:default}
|
|
65
|
+
.mp-loading{display:flex;align-items:center;gap:7px;color:#ffe7c2;font-size:12px;padding:6px 0}
|
|
66
|
+
.mp-spin{width:12px;height:12px;border-radius:50%;border:2px solid #ffd9a83d;border-top-color:#f6cf9a;animation:mp-rot .8s linear infinite}
|
|
67
|
+
.mp-empty{margin:0 0 8px;color:#ffe7c2;font-size:12.5px}
|
|
68
|
+
.mp-warn{margin:6px 0 0;font-size:10px;color:#ffd0d0}
|
|
69
|
+
.mp-badge{position:absolute;z-index:3;top:-7px;right:-7px;min-width:17px;height:17px;border-radius:9px;background:#f6cf9a;color:#6b1206;font-size:10px;font-weight:800;line-height:17px;text-align:center;padding:0 4px;box-shadow:0 4px 10px #2b040466}
|
|
70
|
+
.mp-menu{position:absolute;z-index:9;left:0;top:100%;min-width:96px;border:1px solid #8f1616;border-radius:8px;background:#5c0f0f;box-shadow:0 10px 24px #00000055;padding:4px;animation:mp-in .12s ease-out}
|
|
71
|
+
.mp-menu button{display:block;width:100%;border:0;background:none;color:#ffe4c7;font:inherit;font-size:11.5px;text-align:left;padding:5px 8px;border-radius:5px;cursor:pointer;white-space:nowrap}
|
|
72
|
+
.mp-menu button:hover{background:#7a1616;color:#fff8ee}
|
|
73
|
+
@keyframes mp-in{from{opacity:0;transform:translateY(4px) scale(.985)}to{opacity:1;transform:none}}
|
|
74
|
+
@keyframes mp-rot{to{transform:rotate(360deg)}}
|
|
75
|
+
@keyframes mp-bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-3px)}}
|
|
76
|
+
@media (prefers-reduced-motion: reduce){
|
|
77
|
+
body *{animation:none!important;transition:none!important}
|
|
78
|
+
.mp-pet:hover{transform:none}
|
|
79
|
+
}
|
|
80
|
+
</style>
|
|
81
|
+
</head>
|
|
82
|
+
<body>
|
|
83
|
+
<div id="stage">
|
|
84
|
+
<div class="mp-anchor" id="anchor">
|
|
85
|
+
<div class="mp-pet" id="pet" role="button" tabindex="0" aria-label="鲸鱼娘小毛"></div>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
<script>
|
|
89
|
+
'use strict';
|
|
90
|
+
|
|
91
|
+
// --- 原生桥 ----------------------------------------------------------------
|
|
92
|
+
const native = window.webkit?.messageHandlers?.pet;
|
|
93
|
+
const send = (msg) => { try { native?.postMessage(msg); } catch { /* 桥断了就静默 */ } };
|
|
94
|
+
|
|
95
|
+
// --- 状态 ------------------------------------------------------------------
|
|
96
|
+
let env = Object.assign({ origin: 'http://127.0.0.1:3081' }, window.__petEnv || null);
|
|
97
|
+
let state = null; // 最近一次宿主快照
|
|
98
|
+
let offline = false; // 原生层最近一次轮询失败
|
|
99
|
+
let busy = false; // 本端触发的「再督促」进行中
|
|
100
|
+
let open = false, pinned = null;
|
|
101
|
+
let seen = null, autoFloor = null;
|
|
102
|
+
const closedIds = new Set();
|
|
103
|
+
const marked = new Set();
|
|
104
|
+
let spriteKey = '';
|
|
105
|
+
let cardKey = '';
|
|
106
|
+
let ctxOpen = false;
|
|
107
|
+
|
|
108
|
+
const AXIS_COLOR = { 客户:'#f7d9a4', 项目:'#f0b478', 组织:'#e9c887', 现金流:'#e8ac52', 决策:'#efd9b6', 自律:'#d7a76b' };
|
|
109
|
+
const PET_TAG = document.getElementById('pet');
|
|
110
|
+
|
|
111
|
+
/** v1 气泡(quote/thought/insight/nag/action)兼容成形。 */
|
|
112
|
+
function shape(bubble) {
|
|
113
|
+
if (!bubble) return null;
|
|
114
|
+
if (bubble.cite && typeof bubble.cite.text === 'string') return bubble;
|
|
115
|
+
return {
|
|
116
|
+
...bubble,
|
|
117
|
+
template: bubble.quote ? 'quote' : 'summary',
|
|
118
|
+
cite: { kind: bubble.quote ? 'quote' : 'summary', label: bubble.quote ? '原文' : '小毛概括', text: bubble.quote || bubble.thought || '' },
|
|
119
|
+
axis: bubble.axis || '项目',
|
|
120
|
+
pua: bubble.pua || bubble.nag || '',
|
|
121
|
+
lesson: bubble.lesson || bubble.insight || bubble.action || '',
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const mmss = (delta) => {
|
|
126
|
+
if (!Number.isFinite(delta)) return '';
|
|
127
|
+
const total = Math.max(0, Math.round(delta / 1000));
|
|
128
|
+
const h = Math.floor(total / 3600), m = Math.floor((total % 3600) / 60);
|
|
129
|
+
return h > 0 ? `${h}h${String(m).padStart(2,'0')}m` : `${m}m`;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
function currentSprite() {
|
|
133
|
+
const map = state?.pet?.sprites || {};
|
|
134
|
+
if (state?.running || busy) return map.read ? 'read' : 'idle';
|
|
135
|
+
if (open && current() && current().mode !== 'offline') return map.talk ? 'talk' : 'idle';
|
|
136
|
+
return 'idle';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 精灵图 data URL 表(原生层拉好注入;WebKit 的 ATS 会拦 file:// 页面直挂的 http 图)。 */
|
|
140
|
+
let petSprites = null;
|
|
141
|
+
|
|
142
|
+
const latest = () => shape(state?.current);
|
|
143
|
+
const history = () => (state?.bubbles || []).map(shape).filter(Boolean);
|
|
144
|
+
function current() {
|
|
145
|
+
const pin = pinned && history().find((b) => b.id === pinned);
|
|
146
|
+
return pin || latest() || history()[0] || null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// --- 渲染:宠物本体(一次性搭好,之后只改动态位) ----------------------------
|
|
150
|
+
const FALLBACK_SVG = `<svg class="mp-fallback" viewBox="0 0 104 112" role="img" aria-label="鲸鱼娘占位图">
|
|
151
|
+
<ellipse cx="52" cy="62" rx="34" ry="30" fill="#2f6fd0"/><ellipse cx="43" cy="82" rx="22" ry="14" fill="#dbeafe"/>
|
|
152
|
+
<circle cx="41" cy="55" r="5" fill="#0b1220"/><circle cx="63" cy="55" r="5" fill="#0b1220"/>
|
|
153
|
+
<circle cx="43" cy="53" r="1.6" fill="#fff"/><circle cx="65" cy="53" r="1.6" fill="#fff"/>
|
|
154
|
+
<path d="M46 68 q6 5 12 0" stroke="#0b1220" stroke-width="2" fill="none" stroke-linecap="round"/>
|
|
155
|
+
<path d="M52 24 C 74 14, 92 26, 90 44 C 82 34, 68 34, 52 40 Z" fill="#2f6fd0"/>
|
|
156
|
+
<path d="M78 6 l4 10 10 4 -10 4 -4 10 -4 -10 -10 -4 10 -4 z" fill="#ef4444"/></svg>`;
|
|
157
|
+
|
|
158
|
+
PET_TAG.innerHTML = `
|
|
159
|
+
<span id="pet-visual"></span>
|
|
160
|
+
<span class="mp-badge" id="badge" aria-hidden="true" hidden></span>
|
|
161
|
+
<span class="mp-tag"><i class="mp-dot" id="dot"></i><span id="tag-label">待命中</span></span>`;
|
|
162
|
+
const petVisual = document.getElementById('pet-visual');
|
|
163
|
+
const badge = document.getElementById('badge');
|
|
164
|
+
const dot = document.getElementById('dot');
|
|
165
|
+
const tagLabel = document.getElementById('tag-label');
|
|
166
|
+
|
|
167
|
+
function renderPetVisual() {
|
|
168
|
+
const name = currentSprite();
|
|
169
|
+
const src = petSprites?.[name] || null;
|
|
170
|
+
const key = src ? name : 'fallback';
|
|
171
|
+
if (key === spriteKey && petVisual.firstChild) {
|
|
172
|
+
// 只同步说话动画开关
|
|
173
|
+
const img = petVisual.querySelector('.mp-img');
|
|
174
|
+
const cur = current();
|
|
175
|
+
if (img) img.dataset.talking = open && cur && cur.mode !== 'offline' ? 'on' : 'off';
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
spriteKey = key;
|
|
179
|
+
if (!src) { petVisual.innerHTML = FALLBACK_SVG; reportRects(); return; }
|
|
180
|
+
petVisual.innerHTML = `<img class="mp-img" alt="鲸鱼娘·小毛" draggable="false">`;
|
|
181
|
+
const img = petVisual.querySelector('.mp-img');
|
|
182
|
+
img.addEventListener('load', reportRects);
|
|
183
|
+
img.src = src;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// --- 渲染:红皮书卡片(key 变了才重建) --------------------------------------
|
|
187
|
+
const anchor = document.getElementById('anchor');
|
|
188
|
+
let cardEl = null, menuEl = null;
|
|
189
|
+
|
|
190
|
+
function esc(text) {
|
|
191
|
+
return String(text ?? '').replace(/[&<>"']/g, (c) => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function renderCard() {
|
|
195
|
+
const cur = current();
|
|
196
|
+
const hist = history();
|
|
197
|
+
const idx = cur ? hist.findIndex((b) => b.id === cur.id) : -1;
|
|
198
|
+
const older = idx >= 0 && idx < hist.length - 1;
|
|
199
|
+
const newer = idx > 0;
|
|
200
|
+
const running = !!(state?.running || busy);
|
|
201
|
+
const key = `${open}|${cur?.id}|${running}|${offline}|${state?.lastError?.message || ''}`;
|
|
202
|
+
if (key === cardKey && cardEl) return;
|
|
203
|
+
cardKey = key;
|
|
204
|
+
if (cardEl) { cardEl.remove(); cardEl = null; }
|
|
205
|
+
if (!open) { reportRects(); return; }
|
|
206
|
+
|
|
207
|
+
const bodyInner = running
|
|
208
|
+
? `<div class="mp-loading"><i class="mp-spin"></i>小毛正在翻毛选…</div>`
|
|
209
|
+
: cur ? `
|
|
210
|
+
<blockquote class="mp-cite" data-kind="${esc(cur.cite?.kind || 'quote')}"><i class="mp-rule"></i>
|
|
211
|
+
<div class="mp-cite-main"><p>${cur.cite?.kind === 'summary' ? esc(cur.cite.text) : `「${esc(cur.cite.text)}」`}</p></div>
|
|
212
|
+
</blockquote>
|
|
213
|
+
<p class="mp-meta"><b>${esc(cur.cite?.label || '原文')}</b>${cur.year ? ` · ${esc(cur.year)}` : ''}</p>
|
|
214
|
+
${cur.pua ? `<div class="mp-pua"><span aria-hidden="true">“</span><span>${esc(cur.pua)}</span></div>` : ''}
|
|
215
|
+
<p class="mp-lesson">${cur.axis ? `<span class="mp-axis" style="background:${AXIS_COLOR[cur.axis] || '#64748b'}" title="启示落点:${esc(cur.axis)}">${esc(cur.axis)}</span>` : ''}<b>启示 </b>${esc(cur.lesson)}</p>
|
|
216
|
+
<div class="mp-foot">
|
|
217
|
+
<span class="mp-src">${cur.sourceUrl ? `<a href="${esc(cur.sourceUrl)}" target="_blank" rel="noreferrer noopener">中文马克思主义文库</a>` : '内置语料'}${cur.mode === 'offline' ? ' <span class="mp-offline">· 离线摘录</span>' : ''}</span>
|
|
218
|
+
<button class="mp-btn" data-act="dismiss" aria-label="收起这条督促">收起</button>
|
|
219
|
+
<button class="mp-btn" data-act="generate" data-primary="on" aria-label="再督促我一次">再督促</button>
|
|
220
|
+
</div>`
|
|
221
|
+
: `<p class="mp-empty">${running ? '小毛正在翻第一篇毛选…' : '小毛还没读过毛选。'}</p>
|
|
222
|
+
<button class="mp-btn" data-act="generate" data-primary="on" aria-label="让小毛现在读一篇">现在读一篇</button>`;
|
|
223
|
+
|
|
224
|
+
const warn = state?.lastError
|
|
225
|
+
? `<p class="mp-warn">上次生成没成功:${esc(String(state.lastError.message || '')).slice(0, 90)}</p>` : '';
|
|
226
|
+
const offlineWarn = offline ? `<p class="mp-warn">连接中断,重试中…</p>` : '';
|
|
227
|
+
|
|
228
|
+
const bubble = document.createElement('div');
|
|
229
|
+
bubble.className = 'mp-bubble';
|
|
230
|
+
bubble.innerHTML = `
|
|
231
|
+
<i class="mp-tail" aria-hidden="true"></i>
|
|
232
|
+
<section class="mp-card" aria-label="小毛的毛选督促">
|
|
233
|
+
<div class="mp-head" id="card-head">
|
|
234
|
+
<span class="mp-state">${cur ? `《${esc(cur.title)}》` : '鲸鱼娘·小毛'}</span>
|
|
235
|
+
${hist.length > 1 ? `<button class="mp-nav" data-act="nav-newer" ${newer ? '' : 'disabled'} title="更新的一条" aria-label="更新的一条">‹</button>` : ''}
|
|
236
|
+
${hist.length > 1 ? `<button class="mp-nav" data-act="nav-older" ${older ? '' : 'disabled'} title="更早的一条" aria-label="更早的一条">›</button>` : ''}
|
|
237
|
+
<button class="mp-close" data-act="close" aria-label="收起气泡">×</button>
|
|
238
|
+
</div>
|
|
239
|
+
<div class="mp-body">${bodyInner}${warn}${offlineWarn}</div>
|
|
240
|
+
</section>`;
|
|
241
|
+
anchor.appendChild(bubble);
|
|
242
|
+
cardEl = bubble;
|
|
243
|
+
|
|
244
|
+
bubble.addEventListener('click', (event) => {
|
|
245
|
+
const btn = event.target.closest('[data-act]');
|
|
246
|
+
if (!btn) return;
|
|
247
|
+
const act = btn.dataset.act;
|
|
248
|
+
if (act === 'close') { open = false; pinned = null; renderCard(); }
|
|
249
|
+
else if (act === 'nav-newer' && newer) { pinned = hist[idx - 1].id; cardKey = ''; renderCard(); }
|
|
250
|
+
else if (act === 'nav-older' && older) { pinned = hist[idx + 1].id; cardKey = ''; renderCard(); }
|
|
251
|
+
else if (act === 'dismiss') { if (cur) closedIds.add(cur.id); open = false; pinned = null; send({ type:'action', action:'dismiss', id: cur?.id || '' }); renderCard(); }
|
|
252
|
+
else if (act === 'generate' && !running) { busy = true; send({ type:'action', action:'generate' }); cardKey = ''; renderCard(); }
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
reportRects();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// --- 渲染:徽标 / 标签 / 右键菜单 -------------------------------------------
|
|
259
|
+
function renderMeta() {
|
|
260
|
+
const unread = Number(state?.unread || 0);
|
|
261
|
+
badge.hidden = !(unread > 0);
|
|
262
|
+
if (unread > 0) badge.textContent = unread > 9 ? '9+' : String(unread);
|
|
263
|
+
dot.dataset.off = offline ? 'off' : 'on';
|
|
264
|
+
const countdown = state?.nextRunAt ? Date.parse(state.nextRunAt) - Date.now() : NaN;
|
|
265
|
+
tagLabel.textContent = (state?.running || busy) ? '翻书中'
|
|
266
|
+
: Number.isFinite(countdown) && countdown > 0 ? `${mmss(countdown)} 后再读` : '待命中';
|
|
267
|
+
const cur = current();
|
|
268
|
+
const visual = petVisual.querySelector('.mp-img');
|
|
269
|
+
if (visual) visual.dataset.talking = open && cur && cur.mode !== 'offline' ? 'on' : 'off';
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function renderMenu() {
|
|
273
|
+
if (menuEl) { menuEl.remove(); menuEl = null; }
|
|
274
|
+
if (!ctxOpen) { reportRects(); return; }
|
|
275
|
+
menuEl = document.createElement('div');
|
|
276
|
+
menuEl.className = 'mp-menu';
|
|
277
|
+
menuEl.innerHTML = `<button data-mact="reset">复位位置</button><button data-mact="quit">退出小毛</button>`;
|
|
278
|
+
anchor.appendChild(menuEl);
|
|
279
|
+
menuEl.addEventListener('click', (event) => {
|
|
280
|
+
const btn = event.target.closest('[data-mact]');
|
|
281
|
+
if (!btn) return;
|
|
282
|
+
ctxOpen = false; menuEl.remove(); menuEl = null;
|
|
283
|
+
send({ type:'config', action: btn.dataset.mact });
|
|
284
|
+
reportRects();
|
|
285
|
+
});
|
|
286
|
+
reportRects();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function renderAll() { renderPetVisual(); renderCard(); renderMeta(); if (!open) reportRects(); }
|
|
290
|
+
|
|
291
|
+
// --- 输入区上报(原生层据此做点击穿透,等价 dsh-tauri 的 input region) -------
|
|
292
|
+
function reportRects() {
|
|
293
|
+
const rects = [];
|
|
294
|
+
const push = (el) => {
|
|
295
|
+
if (!el) return;
|
|
296
|
+
const r = el.getBoundingClientRect();
|
|
297
|
+
if (r.width <= 0 || r.height <= 0) return;
|
|
298
|
+
rects.push({ x: r.left - 6, y: r.top - 6, w: r.width + 12, h: r.height + 12 });
|
|
299
|
+
};
|
|
300
|
+
push(anchor);
|
|
301
|
+
push(cardEl);
|
|
302
|
+
push(menuEl);
|
|
303
|
+
send({ type:'input', rects });
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
new ResizeObserver(reportRects).observe(anchor);
|
|
307
|
+
|
|
308
|
+
// --- 打开卡片 = 这条已读(与 web 宠物同语义) --------------------------------
|
|
309
|
+
function markRead(cur) {
|
|
310
|
+
if (!open || !cur || cur.read) return;
|
|
311
|
+
if (marked.has(cur.id)) return;
|
|
312
|
+
marked.add(cur.id);
|
|
313
|
+
window.setTimeout(() => {
|
|
314
|
+
send({ type:'action', action:'dismiss', id: cur.id });
|
|
315
|
+
if (state) {
|
|
316
|
+
state.unread = Math.max(0, (state.unread || 0) - (state.current?.id === cur.id ? 1 : 0));
|
|
317
|
+
state.bubbles = (state.bubbles || []).map((b) => (b.id === cur.id ? { ...b, read: true } : b));
|
|
318
|
+
renderMeta();
|
|
319
|
+
}
|
|
320
|
+
}, 600);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// --- 交互:点击开合 / 拖动 / 键盘 --------------------------------------------
|
|
324
|
+
let drag = null;
|
|
325
|
+
|
|
326
|
+
anchor.addEventListener('pointerdown', (event) => {
|
|
327
|
+
if (event.button !== 0) return;
|
|
328
|
+
if (event.target.closest('.mp-menu') || event.target.closest('.mp-btn') || event.target.closest('.mp-nav') || event.target.closest('.mp-close')) return;
|
|
329
|
+
drag = { sx: event.screenX, sy: event.screenY, moved: false };
|
|
330
|
+
event.preventDefault();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
window.addEventListener('pointermove', (event) => {
|
|
334
|
+
if (!drag) return;
|
|
335
|
+
const dx = event.screenX - drag.sx, dy = event.screenY - drag.sy;
|
|
336
|
+
if (!drag.moved && Math.abs(dx) + Math.abs(dy) > 4) {
|
|
337
|
+
drag.moved = true;
|
|
338
|
+
send({ type:'dragstart' }); // 原生层记住当前窗口位置,之后按差值绝对定位
|
|
339
|
+
}
|
|
340
|
+
if (drag.moved) send({ type:'dragto', dx, dy });
|
|
341
|
+
}, { passive: true });
|
|
342
|
+
|
|
343
|
+
window.addEventListener('pointerup', (event) => {
|
|
344
|
+
if (!drag) return;
|
|
345
|
+
const moved = drag.moved;
|
|
346
|
+
drag = null;
|
|
347
|
+
if (moved) { send({ type:'dragend' }); return; }
|
|
348
|
+
// 原地松手 = 点击:点宠物开合,点卡片头部不动
|
|
349
|
+
const onPet = !event.target.closest('.mp-card');
|
|
350
|
+
if (onPet) {
|
|
351
|
+
if (open) { open = false; pinned = null; }
|
|
352
|
+
else { pinned = latest()?.id || null; open = true; markRead(current()); }
|
|
353
|
+
cardKey = '';
|
|
354
|
+
renderCard(); renderMeta();
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
window.addEventListener('keydown', (event) => {
|
|
359
|
+
if (event.key === 'Escape' && open) { open = false; pinned = null; ctxOpen = false; cardKey = ''; renderCard(); renderMenu(); }
|
|
360
|
+
if ((event.key === 'Enter' || event.key === ' ') && event.target === PET_TAG) {
|
|
361
|
+
event.preventDefault();
|
|
362
|
+
open = !open; if (open) markRead(current()); cardKey = ''; renderCard();
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
anchor.addEventListener('contextmenu', (event) => {
|
|
367
|
+
event.preventDefault();
|
|
368
|
+
ctxOpen = !ctxOpen;
|
|
369
|
+
renderMenu();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
document.addEventListener('click', (event) => {
|
|
373
|
+
if (ctxOpen && !event.target.closest('.mp-menu')) { ctxOpen = false; renderMenu(); }
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// --- 原生层入口 --------------------------------------------------------------
|
|
377
|
+
window.__petReady = () => { reportRects(); };
|
|
378
|
+
window.__petSprites = (map) => {
|
|
379
|
+
petSprites = map || {};
|
|
380
|
+
spriteKey = '';
|
|
381
|
+
renderPetVisual();
|
|
382
|
+
reportRects();
|
|
383
|
+
};
|
|
384
|
+
window.__petState = (json) => {
|
|
385
|
+
offline = false;
|
|
386
|
+
state = json;
|
|
387
|
+
busy = false; const fresh = latest();
|
|
388
|
+
if (fresh) {
|
|
389
|
+
const at = Date.parse(fresh.at || '') || Date.now();
|
|
390
|
+
if (autoFloor === null) autoFloor = at;
|
|
391
|
+
if (seen !== fresh.id) {
|
|
392
|
+
seen = fresh.id;
|
|
393
|
+
if (at > autoFloor && !closedIds.has(fresh.id)) {
|
|
394
|
+
autoFloor = at;
|
|
395
|
+
pinned = fresh.id;
|
|
396
|
+
open = true;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
spriteKey = ''; // sprite 选择可能随 running/open 变化
|
|
401
|
+
cardKey = '';
|
|
402
|
+
renderAll();
|
|
403
|
+
};
|
|
404
|
+
window.__petOffline = () => { offline = true; busy = false; renderMeta(); renderCard(); };
|
|
405
|
+
window.__petActionDone = () => { busy = false; cardKey = ''; renderCard(); renderMeta(); };
|
|
406
|
+
|
|
407
|
+
// 时钟:倒计时文案每秒刷新
|
|
408
|
+
window.setInterval(renderMeta, 1000);
|
|
409
|
+
|
|
410
|
+
// 起手画一只(还没有数据时是占位鲸鱼 + 待命中)
|
|
411
|
+
renderAll();
|
|
412
|
+
send({ type:'ready' });
|
|
413
|
+
</script>
|
|
414
|
+
</body>
|
|
415
|
+
</html>
|
package/docs/RELEASE.md
CHANGED
|
@@ -8,11 +8,11 @@
|
|
|
8
8
|
| 项 | 现状(2026-11-01 核对) |
|
|
9
9
|
|---|---|
|
|
10
10
|
| 包名 | `dsh-plugin-maoxuan-pet`(unscoped,npm 上未占用) |
|
|
11
|
-
| 版本 | `0.
|
|
11
|
+
| 版本 | `0.3.0` |
|
|
12
12
|
| 代码许可 | MIT(`LICENSE`);语料与立绘的非 MIT 声明见 `NOTICE` |
|
|
13
|
-
| 打包清单 | `npm pack --dry-run` →
|
|
13
|
+
| 打包清单 | `npm pack --dry-run` → 64 files / 1.3 MB(含 33 篇语料 + desktop/ 5 个壳源码文件;`desktop/build/` 产物靠 files 逐文件点名排除,勿改回整目录收录) |
|
|
14
14
|
| GitHub 账号 | `gh auth status` → `yuna78`(scopes 含 `repo`) |
|
|
15
|
-
| npm 凭据 |
|
|
15
|
+
| npm 凭据 | `npm whoami` → `yuna78`(2026-11-01 复核有效);发布需 2FA 一次性验证码,`npm publish --otp=<6位码>` |
|
|
16
16
|
| 市场收录机制 | awesome-dsh-plugin 索引 **npm 包**(`versions[].source.type = npm`),并用 `package.json` 的 `repository.url` 关联 GitHub 仓;**没有投稿表单**,发布后由爬虫收录 |
|
|
17
17
|
|
|
18
18
|
## 1. 发布前自检(每次都跑)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-maoxuan-pet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "DSH 悬浮宠物「鲸鱼娘·小毛」:每小时读一篇毛选,给创业中的你说一句能动手的督促;内置毛选公开语料与 maoxuan-pet preset。",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -51,6 +51,11 @@
|
|
|
51
51
|
"assets/",
|
|
52
52
|
"corpus/",
|
|
53
53
|
"skill/",
|
|
54
|
+
"desktop/Info.plist",
|
|
55
|
+
"desktop/README.md",
|
|
56
|
+
"desktop/build.sh",
|
|
57
|
+
"desktop/maoxuan-pet.swift",
|
|
58
|
+
"desktop/pet.html",
|
|
54
59
|
"docs/",
|
|
55
60
|
"tools/",
|
|
56
61
|
"README.md",
|
|
@@ -65,7 +70,9 @@
|
|
|
65
70
|
"test": "node verify/smoke.mjs && node verify/contract.mjs && node verify/host.test.mjs",
|
|
66
71
|
"test:contract": "node verify/contract.mjs",
|
|
67
72
|
"audit:corpus": "node tools/audit-corpus-coverage.mjs",
|
|
68
|
-
"prepublishOnly": "node verify/smoke.mjs && node verify/contract.mjs"
|
|
73
|
+
"prepublishOnly": "node verify/smoke.mjs && node verify/contract.mjs",
|
|
74
|
+
"pet:build": "bash desktop/build.sh",
|
|
75
|
+
"pet": "bash desktop/build.sh && ./desktop/build/maoxuan-pet"
|
|
69
76
|
},
|
|
70
77
|
"dsh": {
|
|
71
78
|
"bundle": {
|