dsh-plugin-maoxuan-pet 0.3.1 → 0.3.2
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 +9 -0
- package/client.js +15 -6
- package/desktop/maoxuan-pet.swift +192 -12
- package/desktop/pet.html +22 -9
- package/index.js +8 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
## 0.3.2(2026-11-01)
|
|
4
|
+
|
|
5
|
+
### 桌宠壳
|
|
6
|
+
|
|
7
|
+
- **新增:点击真穿透(窗口随内容收缩)**——原 hitTest 白名单只能在 AppKit 层丢事件,WindowServer 已把点击判给壳窗口(640×720 的透明玻璃),后面 App 点不到。曾试过 CGS 窗口形状(`CGSSetWindowShape`):坐标约定对不上且**连渲染一起裁**(小毛活着但隐形),弃用。最终方案:壳窗口随页面报告的可点区域收缩(右下角锚定,stage 定位固定 ⇒ 小毛纹丝不动),卡片高度与视口解耦(固定 480px)防止窗口伸缩反馈回路;窗口外点击天然直达下层。
|
|
8
|
+
- **修复:恢复的窗口位置会把小毛放回屏幕外**——保存的位置 {{1218,-19}} 挂在屏幕右缘外(屏幕 1512pt 宽,窗口右缘 1858pt),她本人活着但站在不可见区域,看起来就是「放出去没了」。现在恢复前校验小毛站立处(窗口右下 220×300)是否整块在屏内,不在就回退默认位置并留日志。
|
|
9
|
+
- **新增:跟随宿主换端口**——桌面版重启会换 web 端口,壳还指着旧 origin 就是反复出现的「连接中断」。连续 3 次拉取失败后扫 `$DSH_HOME/agent-tree-dock-*.json`(文件名带端口,取最新),用公开路由探活成功即自动跟随;约 30 秒内自愈。`--token` 模式不自动跟随(token 按次发放),日志提示重新放她到桌面。
|
|
10
|
+
- **修复:立绘「下载成功但画面不换装」(蓝色大脑袋)**——立绘/状态注入是单次 `evaluate`,若那一刻 WKWebView 还没加载完 `pet.html`,注入落在即将被丢弃的上下文里,数据永久丢失;而立绘只在下载完成时注入一次,错过就永远停在壳内置占位形象。0.3.1 的 `--public` 模式无鉴权流程、轮询极快,把窗口期放大成必现。现在:注入前先落页面全局 `__petSpriteData` / `__petStateData`(页面脚本就绪时自取),并且壳以页面 rects 上报为「活着」心跳,发现立绘/首份 state 尚未送达就补发——时序无论怎么排都能收敛。新增恒落盘诊断日志 `$DSH_HOME/storages/dsh-maoxuan-pet.shell.log`(256KB 自动截断;宿主按钮拉起时 stdio 被丢弃,这份日志是唯一黑盒透视)。
|
|
11
|
+
|
|
3
12
|
## 0.3.1 — 2026-11-01
|
|
4
13
|
|
|
5
14
|
修复:预设面板上 maoxuan-pet 显示「加载失败 / Failed to load」;桌宠壳补上宿主鉴权支持。
|
package/client.js
CHANGED
|
@@ -530,15 +530,24 @@ window.__ModuleLoader__.load({
|
|
|
530
530
|
if (shell?.running) {
|
|
531
531
|
return h('button', {
|
|
532
532
|
className: 'mp-dock-chip', type: 'button', onClick: toggleShell,
|
|
533
|
+
title: '收回桌面上的小毛',
|
|
533
534
|
style: {
|
|
534
|
-
position: 'fixed', right:
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
535
|
+
position: 'fixed', right: 16, bottom: 16, zIndex: 2147483000,
|
|
536
|
+
display: 'flex', alignItems: 'center', gap: 5,
|
|
537
|
+
border: '1px solid rgba(255,217,168,.35)', borderRadius: 999, cursor: 'pointer',
|
|
538
|
+
padding: '3px 8px 3px 4px', font: '11px/1.4 -apple-system, sans-serif',
|
|
539
|
+
color: '#ffe9c8', background: 'rgba(109,20,24,.92)',
|
|
540
|
+
boxShadow: '0 2px 8px rgba(0,0,0,.24)',
|
|
539
541
|
},
|
|
540
542
|
'aria-label': '收回桌面上的小毛',
|
|
541
|
-
},
|
|
543
|
+
},
|
|
544
|
+
h('svg', { width: 14, height: 14, viewBox: '0 0 512 512', 'aria-hidden': true },
|
|
545
|
+
h('rect', { x: 0, y: 0, width: 512, height: 512, rx: 116, fill: '#FFF6E9' }),
|
|
546
|
+
h('rect', { x: 120, y: 104, width: 232, height: 304, rx: 26, fill: '#B3271E' }),
|
|
547
|
+
h('rect', { x: 120, y: 104, width: 46, height: 304, rx: 22, fill: '#8F1E17' }),
|
|
548
|
+
h('polygon', { fill: '#E8C36A', points: '263,150 273.32,181.76 306.72,181.76 279.7,201.74 289.02,234.5 263,214.25 236.98,234.5 246.3,201.74 219.28,181.76 252.68,181.76' }),
|
|
549
|
+
h('rect', { x: 215, y: 272, width: 96, height: 16, rx: 8, fill: '#E8C36A' })),
|
|
550
|
+
shellBusy ? '收回中…' : '收回');
|
|
542
551
|
}
|
|
543
552
|
|
|
544
553
|
return h('div', { style, 'data-maoxuan-pet': true },
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import AppKit
|
|
19
19
|
import WebKit
|
|
20
20
|
|
|
21
|
+
|
|
21
22
|
// MARK: - 配置
|
|
22
23
|
|
|
23
24
|
enum Shell {
|
|
@@ -40,10 +41,45 @@ enum Shell {
|
|
|
40
41
|
static let defaults = UserDefaults(suiteName: "dsh-maoxuan-pet.window")!
|
|
41
42
|
|
|
42
43
|
static func log(_ message: String) {
|
|
44
|
+
let line = "[maoxuan-pet] \(message)\n"
|
|
45
|
+
if verbose {
|
|
46
|
+
FileHandle.standardError.write(Data(line.utf8))
|
|
47
|
+
}
|
|
48
|
+
// 恒落文件:按钮拉起的壳 stdio 被宿主丢弃(stdio: 'ignore'),
|
|
49
|
+
// 没有这份日志,出问题就是黑盒。256KB 自动截断。
|
|
50
|
+
Self.appendToFile(line)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/// 高频日志(每秒多次的 input rects 等)只进 stderr,不刷文件。
|
|
54
|
+
static func debug(_ message: String) {
|
|
43
55
|
guard verbose else { return }
|
|
44
56
|
FileHandle.standardError.write(Data("[maoxuan-pet] \(message)\n".utf8))
|
|
45
57
|
}
|
|
46
58
|
|
|
59
|
+
/// 关键诊断恒落一份到文件:按钮拉起的壳 stdio 被宿主丢弃(stdio: 'ignore'),
|
|
60
|
+
/// 没有这份日志,出问题就是黑盒。超过 256KB 从头截断,避免无限增长。
|
|
61
|
+
private static func appendToFile(_ line: String) {
|
|
62
|
+
let home = ProcessInfo.processInfo.environment["DSH_HOME"] ?? NSHomeDirectory() + "/.dsh"
|
|
63
|
+
let path = home + "/storages/dsh-maoxuan-pet.shell.log"
|
|
64
|
+
let dir = (path as NSString).deletingLastPathComponent
|
|
65
|
+
try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
|
66
|
+
let formatter = DateFormatter()
|
|
67
|
+
formatter.dateFormat = "HH:mm:ss"
|
|
68
|
+
let stamped = "[" + formatter.string(from: Date()) + "] " + line
|
|
69
|
+
guard let data = stamped.data(using: .utf8) else { return }
|
|
70
|
+
if let handle = FileHandle(forWritingAtPath: path) {
|
|
71
|
+
if let attrs = try? FileManager.default.attributesOfItem(atPath: path),
|
|
72
|
+
let size = attrs[.size] as? NSNumber, size.intValue > 262_144 {
|
|
73
|
+
try? handle.truncate(atOffset: 0)
|
|
74
|
+
}
|
|
75
|
+
try? handle.seekToEndOfFile()
|
|
76
|
+
try? handle.write(contentsOf: data)
|
|
77
|
+
try? handle.close()
|
|
78
|
+
} else {
|
|
79
|
+
FileManager.default.createFile(atPath: path, contents: data)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
47
83
|
static func jsonString(_ value: Any) -> String {
|
|
48
84
|
guard JSONSerialization.isValidJSONObject([value]),
|
|
49
85
|
let data = try? JSONSerialization.data(withJSONObject: [value]),
|
|
@@ -104,6 +140,76 @@ final class ShellController: NSObject, NSApplicationDelegate, WKNavigationDelega
|
|
|
104
140
|
/// 精灵图 name → data URL:WebKit 的 ATS 会拦 file:// 页面直挂的 http 图,
|
|
105
141
|
/// 所以素材也由原生层拉取后转 base64 注入,WebKit 全程零网络请求。
|
|
106
142
|
private var spriteDataURLs: [String: String] = [:]
|
|
143
|
+
/// 页面就绪回放:WKWebView 加载完成前 evaluate 会落在将被丢弃的上下文里
|
|
144
|
+
/// (立绘/状态一次性注入从此丢失,页面永远停在占位形象)。页面第一次上报
|
|
145
|
+
/// rects 即「活着」,此时把手里已有的 state 与立绘重放一次,时序就无关紧要了。
|
|
146
|
+
private var replayedSpriteCount = 0
|
|
147
|
+
private var consecutiveFailures = 0
|
|
148
|
+
private var everSucceeded = false
|
|
149
|
+
private var rediscoveryCooldown = Date.distantPast
|
|
150
|
+
private var rediscoverHintShown = false
|
|
151
|
+
private var lastRegionDesc = ""
|
|
152
|
+
/// 穿透问题的釜底抽薪:窗口收缩到只有小毛(+展开的卡片)那么大,
|
|
153
|
+
/// 挡住后面 App 的区域天然就只有那一块——不用任何私有 API,渲染永远完整。
|
|
154
|
+
/// 右下角锚定缩放:stage 定位 right:44 bottom:56 固定,小毛本人纹丝不动。
|
|
155
|
+
private func syncWindowFrame(to union: CGRect) {
|
|
156
|
+
guard let window, union.width > 0 else { return }
|
|
157
|
+
// 只用 union 的「尺寸」、不用它的「位置」:位置随窗口高度 relayout 漂移,
|
|
158
|
+
// 尺寸稳定——这样窗口缩放没有反馈回路。右下角锚定 + stage right:44
|
|
159
|
+
// bottom:56 固定 ⇒ 小毛的屏幕位置纹丝不动。
|
|
160
|
+
let pad: CGFloat = 14
|
|
161
|
+
let width = max(union.width + 44 + pad, 160)
|
|
162
|
+
let height = max(union.height + 56 + pad, 220)
|
|
163
|
+
let frame = window.frame
|
|
164
|
+
guard abs(frame.width - width) > 1 || abs(frame.height - height) > 1 else { return }
|
|
165
|
+
window.setFrame(NSRect(x: frame.maxX - width, y: frame.minY, width: width, height: height), display: true)
|
|
166
|
+
Shell.log("window → \(Int(width))x\(Int(height)) @右下角锚定")
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
/// 宿主重启会换端口,壳却还指着旧 origin——这正是「连接中断」反复出现的原因。
|
|
171
|
+
/// 连续失败后扫 ~/.dsh/agent-tree-dock-*.json(文件名带端口,取最新修改的那个),
|
|
172
|
+
/// 用公开路由探活成功就跟随过去。--token 模式不自动跟随(token 是按次发放的)。
|
|
173
|
+
private func tryRediscover() {
|
|
174
|
+
guard Shell.token == nil else {
|
|
175
|
+
if !rediscoverHintShown {
|
|
176
|
+
rediscoverHintShown = true
|
|
177
|
+
Shell.log("宿主可能换了端口;token 模式不自动跟随——请点「收回」后重新放她到桌面")
|
|
178
|
+
}
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
let home = ProcessInfo.processInfo.environment["DSH_HOME"] ?? NSHomeDirectory() + "/.dsh"
|
|
182
|
+
guard let names = try? FileManager.default.contentsOfDirectory(atPath: home) else { return }
|
|
183
|
+
let newest = names
|
|
184
|
+
.filter { $0.hasPrefix("agent-tree-dock-") && $0.hasSuffix(".json") }
|
|
185
|
+
.compactMap { name -> (port: String, mtime: Date)? in
|
|
186
|
+
guard let start = name.range(of: "agent-tree-dock-")?.upperBound,
|
|
187
|
+
let end = name.range(of: ".json")?.lowerBound, start < end else { return nil }
|
|
188
|
+
let port = String(name[start..<end])
|
|
189
|
+
let path = home + "/" + name
|
|
190
|
+
guard let attrs = try? FileManager.default.attributesOfItem(atPath: path),
|
|
191
|
+
let mtime = attrs[.modificationDate] as? Date else { return nil }
|
|
192
|
+
return (port, mtime)
|
|
193
|
+
}
|
|
194
|
+
.max { $0.mtime < $1.mtime }
|
|
195
|
+
guard let newest, let port = Int(newest.port), (1024..<65536).contains(port) else { return }
|
|
196
|
+
let candidate = "http://127.0.0.1:\(port)"
|
|
197
|
+
guard candidate != Shell.origin, let url = URL(string: candidate + Shell.statePath) else { return }
|
|
198
|
+
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 4)
|
|
199
|
+
request.httpMethod = "GET"
|
|
200
|
+
http.dataTask(with: request) { [weak self] _, response, _ in
|
|
201
|
+
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return }
|
|
202
|
+
DispatchQueue.main.async {
|
|
203
|
+
guard let self, Shell.origin != candidate else { return }
|
|
204
|
+
Shell.log("DSH 换了端口 → 跟随 \(candidate)(公开路由探活 200)")
|
|
205
|
+
Shell.origin = candidate
|
|
206
|
+
self.consecutiveFailures = 0
|
|
207
|
+
self.pollNow()
|
|
208
|
+
}
|
|
209
|
+
}.resume()
|
|
210
|
+
}
|
|
211
|
+
private var stateReplayed = false
|
|
212
|
+
private var lastStateJSON: String?
|
|
107
213
|
private var spritePending = Set<String>()
|
|
108
214
|
private var authed = false
|
|
109
215
|
private var authInFlight = false
|
|
@@ -120,7 +226,12 @@ final class ShellController: NSObject, NSApplicationDelegate, WKNavigationDelega
|
|
|
120
226
|
private func readLivePid() -> Int32? {
|
|
121
227
|
guard let raw = try? String(contentsOfFile: pidFilePath(), encoding: .utf8),
|
|
122
228
|
let pid = Int32(raw.trimmingCharacters(in: .whitespacesAndNewlines)), pid > 1 else { return nil }
|
|
123
|
-
|
|
229
|
+
guard kill(pid, 0) == 0 else { return nil }
|
|
230
|
+
// pid 会被系统复用:文件里的编号活着 ≠ 还是我们的小毛。校验进程身份。
|
|
231
|
+
var buffer = [CChar](repeating: 0, count: Int(MAXPATHLEN))
|
|
232
|
+
guard proc_pidpath(pid, &buffer, UInt32(MAXPATHLEN)) > 0,
|
|
233
|
+
String(cString: buffer).hasSuffix("maoxuan-pet") else { return nil }
|
|
234
|
+
return pid
|
|
124
235
|
}
|
|
125
236
|
|
|
126
237
|
private func writePid() {
|
|
@@ -191,6 +302,21 @@ final class ShellController: NSObject, NSApplicationDelegate, WKNavigationDelega
|
|
|
191
302
|
self.webView = webView
|
|
192
303
|
self.clickView = container
|
|
193
304
|
|
|
305
|
+
// 被谁杀的要有案底:SIGTERM/SIGINT/SIGHUP 落日志再走(SIGKILL 无法捕获)。
|
|
306
|
+
for name in [SIGTERM, SIGINT, SIGHUP] {
|
|
307
|
+
signal(name) { code in
|
|
308
|
+
Shell.log("收到信号 \(code),退出")
|
|
309
|
+
exit(0)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
DispatchQueue.main.async { [weak self] in
|
|
313
|
+
guard let self else { return }
|
|
314
|
+
let start = Date()
|
|
315
|
+
Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { _ in
|
|
316
|
+
Shell.log("heartbeat up=\(Int(-start.timeIntervalSinceNow))s origin=\(Shell.origin)")
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
194
320
|
if let html = locateHTML() {
|
|
195
321
|
webView.loadFileURL(html, allowingReadAccessTo: html.deletingLastPathComponent())
|
|
196
322
|
} else {
|
|
@@ -205,6 +331,7 @@ final class ShellController: NSObject, NSApplicationDelegate, WKNavigationDelega
|
|
|
205
331
|
else if Shell.token != nil { authNote = ",带 launch token(自动换 dsh-auth cookie)" }
|
|
206
332
|
else { authNote = ",无 token(宿主开鉴权时会是离线模式)" }
|
|
207
333
|
FileHandle.standardError.write(Data("[maoxuan-pet] 小毛已站上桌面 — \(Shell.origin)\(authNote)(独立进程,DSH 最小化不影响她)\n".utf8))
|
|
334
|
+
Shell.log("started pid=\(ProcessInfo.processInfo.processIdentifier) ppid=\(getppid()) origin=\(Shell.origin) auth=\(Shell.token == nil ? "public" : "token")")
|
|
208
335
|
}
|
|
209
336
|
|
|
210
337
|
func applicationWillTerminate(_ notification: Notification) {
|
|
@@ -236,8 +363,14 @@ final class ShellController: NSObject, NSApplicationDelegate, WKNavigationDelega
|
|
|
236
363
|
guard ProcessInfo.processInfo.arguments.contains("--reset") == false,
|
|
237
364
|
let raw = Shell.defaults.string(forKey: "frame") else { return nil }
|
|
238
365
|
let frame = NSRectFromString(raw)
|
|
239
|
-
|
|
240
|
-
|
|
366
|
+
guard frame.width > 100, frame.height > 100 else { return nil }
|
|
367
|
+
// 小毛站在窗口右下角(stage right:44 bottom:56):那一块必须在屏内。
|
|
368
|
+
// 只判「窗口与屏幕相交」会恢复出人在屏外的幽灵窗口——看起来就是丢了。
|
|
369
|
+
let petCorner = NSRect(x: frame.maxX - 220, y: frame.minY, width: 220, height: 300)
|
|
370
|
+
guard NSScreen.screens.contains(where: { $0.visibleFrame.intersection(petCorner) == petCorner }) else {
|
|
371
|
+
Shell.log("保存的位置会让小毛站在屏幕外(\(NSStringFromRect(frame))),改用默认位置")
|
|
372
|
+
return nil
|
|
373
|
+
}
|
|
241
374
|
return frame
|
|
242
375
|
}
|
|
243
376
|
|
|
@@ -292,7 +425,7 @@ final class ShellController: NSObject, NSApplicationDelegate, WKNavigationDelega
|
|
|
292
425
|
self.authInFlight = false
|
|
293
426
|
if let error {
|
|
294
427
|
Shell.log("auth failed: \(error.localizedDescription)")
|
|
295
|
-
self.evaluate("window.__petOffline
|
|
428
|
+
self.evaluate("window.__petOfflineSeen=true;if (window.__petOffline) window.__petOffline();")
|
|
296
429
|
self.schedulePoll(after: 30)
|
|
297
430
|
return
|
|
298
431
|
}
|
|
@@ -301,7 +434,7 @@ final class ShellController: NSObject, NSApplicationDelegate, WKNavigationDelega
|
|
|
301
434
|
let hasAuthCookie = jar?.cookies?.contains { $0.name.hasPrefix("dsh-auth-") } ?? false
|
|
302
435
|
guard (200..<400).contains(status), hasAuthCookie else {
|
|
303
436
|
Shell.log("auth HTTP \(status),没换到 dsh-auth cookie——token 要来自同一个宿主进程(dsh web 启动日志里的 ?token=)")
|
|
304
|
-
self.evaluate("window.__petOffline
|
|
437
|
+
self.evaluate("window.__petOfflineSeen=true;if (window.__petOffline) window.__petOffline();")
|
|
305
438
|
self.schedulePoll(after: 30)
|
|
306
439
|
return
|
|
307
440
|
}
|
|
@@ -332,29 +465,44 @@ final class ShellController: NSObject, NSApplicationDelegate, WKNavigationDelega
|
|
|
332
465
|
guard let self else { return }
|
|
333
466
|
if let error {
|
|
334
467
|
Shell.log("state failed: \(error.localizedDescription)")
|
|
335
|
-
self.evaluate("window.__petOffline
|
|
468
|
+
self.evaluate("window.__petOfflineSeen=true;if (window.__petOffline) window.__petOffline();")
|
|
469
|
+
self.consecutiveFailures += 1
|
|
470
|
+
if self.consecutiveFailures >= 3 || (self.consecutiveFailures >= 1 && !self.everSucceeded), Date() >= self.rediscoveryCooldown {
|
|
471
|
+
self.rediscoveryCooldown = Date().addingTimeInterval(20)
|
|
472
|
+
self.tryRediscover()
|
|
473
|
+
}
|
|
336
474
|
self.schedulePoll(after: 8)
|
|
337
475
|
return
|
|
338
476
|
}
|
|
339
477
|
if let http = response as? HTTPURLResponse, http.statusCode != 200 {
|
|
340
|
-
|
|
341
|
-
|
|
478
|
+
let snippet = String(data: (data ?? Data()).prefix(160), encoding: .utf8) ?? "-"
|
|
479
|
+
Shell.log("state HTTP \(http.statusCode) url=\(url.absoluteString) body=\(snippet)")
|
|
480
|
+
self.evaluate("window.__petOfflineSeen=true;if (window.__petOffline) window.__petOffline();")
|
|
342
481
|
// 401 = jar 里的 cookie 失效了(宿主重启换了 launch token、或 30 天过期):
|
|
343
482
|
// 置回未鉴权,下一轮先用 --token 重新换一次 cookie 再拉。
|
|
344
483
|
if http.statusCode == 401, Shell.token != nil { self.authed = false }
|
|
484
|
+
self.consecutiveFailures += 1
|
|
485
|
+
if self.consecutiveFailures >= 3 || (self.consecutiveFailures >= 1 && !self.everSucceeded), Date() >= self.rediscoveryCooldown {
|
|
486
|
+
self.rediscoveryCooldown = Date().addingTimeInterval(20)
|
|
487
|
+
self.tryRediscover()
|
|
488
|
+
}
|
|
345
489
|
self.schedulePoll(after: 8)
|
|
346
490
|
return
|
|
347
491
|
}
|
|
348
492
|
guard let data, let text = String(data: data, encoding: .utf8) else {
|
|
349
|
-
self.evaluate("window.__petOffline
|
|
493
|
+
self.evaluate("window.__petOfflineSeen=true;if (window.__petOffline) window.__petOffline();")
|
|
350
494
|
self.schedulePoll(after: 8)
|
|
351
495
|
return
|
|
352
496
|
}
|
|
497
|
+
self.consecutiveFailures = 0
|
|
498
|
+
self.everSucceeded = true
|
|
353
499
|
// 生成中的话 3s 后紧跟下一条,让气泡尽快落地
|
|
354
500
|
let running = (text.contains("\"running\":true") || text.contains("\"running\" : true"))
|
|
355
501
|
self.lastInterval = running ? 3 : 20
|
|
356
502
|
self.ensureSprites(from: data)
|
|
357
|
-
|
|
503
|
+
let payload = self.sanitized(text)
|
|
504
|
+
self.lastStateJSON = payload
|
|
505
|
+
self.evaluate("window.__petStateData=\(payload);if (window.__petState) window.__petState(window.__petStateData);")
|
|
358
506
|
self.schedulePoll(after: self.lastInterval)
|
|
359
507
|
}
|
|
360
508
|
}.resume()
|
|
@@ -393,7 +541,7 @@ final class ShellController: NSObject, NSApplicationDelegate, WKNavigationDelega
|
|
|
393
541
|
self.spriteDataURLs[entry.name] = "data:\(mime);base64,\(data.base64EncodedString())"
|
|
394
542
|
Shell.log("sprite \(entry.name) ready (\(data.count) bytes)")
|
|
395
543
|
}
|
|
396
|
-
self.evaluate("window.
|
|
544
|
+
self.evaluate("window.__petSpriteData=\(Shell.jsonString(self.spriteDataURLs));if (window.__petSprites) window.__petSprites(window.__petSpriteData);")
|
|
397
545
|
}
|
|
398
546
|
}.resume()
|
|
399
547
|
}
|
|
@@ -460,6 +608,15 @@ final class ShellController: NSObject, NSApplicationDelegate, WKNavigationDelega
|
|
|
460
608
|
case "input":
|
|
461
609
|
// 页面上报的可交互矩形(CSS px,左上原点)→ 容器本地坐标(左下原点)
|
|
462
610
|
guard let rects = body["rects"] as? [[String: Any]] else { return }
|
|
611
|
+
// 原始页面坐标(top-left)正是 CGS 形状要的坐标系
|
|
612
|
+
let raw: [CGRect] = rects.compactMap { dict in
|
|
613
|
+
guard let x = (dict["x"] as? NSNumber)?.doubleValue,
|
|
614
|
+
let y = (dict["y"] as? NSNumber)?.doubleValue,
|
|
615
|
+
let w = (dict["w"] as? NSNumber)?.doubleValue,
|
|
616
|
+
let h = (dict["h"] as? NSNumber)?.doubleValue,
|
|
617
|
+
w > 0, h > 0 else { return nil }
|
|
618
|
+
return CGRect(x: x, y: y, width: w, height: h)
|
|
619
|
+
}
|
|
463
620
|
let contentHeight = webView.bounds.height
|
|
464
621
|
let converted: [CGRect] = rects.compactMap { dict in
|
|
465
622
|
guard let x = (dict["x"] as? NSNumber)?.doubleValue,
|
|
@@ -470,7 +627,30 @@ final class ShellController: NSObject, NSApplicationDelegate, WKNavigationDelega
|
|
|
470
627
|
return CGRect(x: x, y: contentHeight - (y + h), width: w, height: h)
|
|
471
628
|
}
|
|
472
629
|
clickView.passRects = converted
|
|
473
|
-
Shell.
|
|
630
|
+
Shell.debug("input rects: \(converted.count)")
|
|
631
|
+
// 可点区域的并集写进日志:用户体感「隐形区域大」时,拿这行对账
|
|
632
|
+
let union = converted.reduce(CGRect.null) { $0.union($1) }
|
|
633
|
+
syncWindowFrame(to: union)
|
|
634
|
+
let desc = String(format: "input region: x=%.0f y=%.0f w=%.0f h=%.0f(窗口 %.0fx%.0f)",
|
|
635
|
+
union.minX, union.minY, union.width, union.height,
|
|
636
|
+
webView.bounds.width, webView.bounds.height)
|
|
637
|
+
if desc != lastRegionDesc {
|
|
638
|
+
lastRegionDesc = desc
|
|
639
|
+
Shell.log(desc)
|
|
640
|
+
}
|
|
641
|
+
// 页面就绪补发:立绘/首份 state 可能比页面加载完成更晚/更早到达,
|
|
642
|
+
// 一次性 evaluate 会落在将被丢弃的上下文里。rects 是页面「活着」的
|
|
643
|
+
// 心跳,数据比上次补发多就再补一轮——最终一定补齐。
|
|
644
|
+
if !stateReplayed, let payload = lastStateJSON {
|
|
645
|
+
stateReplayed = true
|
|
646
|
+
self.evaluate("window.__petStateData=\(payload);if (window.__petState) window.__petState(window.__petStateData);")
|
|
647
|
+
Shell.log("page ready → 补发 state")
|
|
648
|
+
}
|
|
649
|
+
if spriteDataURLs.count > replayedSpriteCount {
|
|
650
|
+
replayedSpriteCount = spriteDataURLs.count
|
|
651
|
+
self.evaluate("window.__petSpriteData=\(Shell.jsonString(spriteDataURLs));if (window.__petSprites) window.__petSprites(window.__petSpriteData);")
|
|
652
|
+
Shell.log("page ready → 补发立绘 \(replayedSpriteCount) 张")
|
|
653
|
+
}
|
|
474
654
|
|
|
475
655
|
case "dragstart":
|
|
476
656
|
dragBaseOrigin = window.frame.origin
|
package/desktop/pet.html
CHANGED
|
@@ -21,12 +21,12 @@
|
|
|
21
21
|
.mp-pet:hover{transform:translateY(-3px)}
|
|
22
22
|
.mp-img{display:block;height:132px;width:auto;max-width:140px;object-fit:contain;filter:drop-shadow(0 10px 16px #050b14aa)}
|
|
23
23
|
.mp-img[data-talking="on"]{animation:mp-bob 1.6s ease-in-out infinite}
|
|
24
|
-
.mp-fallback{height:
|
|
24
|
+
.mp-fallback{height:118px;width:118px;filter:drop-shadow(0 10px 16px #050b14aa)}
|
|
25
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
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
27
|
.mp-dot[data-off="off"]{background:#fca5a5;box-shadow:0 0 6px #fca5a5aa}
|
|
28
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:
|
|
29
|
+
.mp-card{position:relative;max-height: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
30
|
.mp-card::-webkit-scrollbar{width:8px;height:8px}
|
|
31
31
|
.mp-card::-webkit-scrollbar-track{background:#5f1010cc;border-radius:8px}
|
|
32
32
|
.mp-card::-webkit-scrollbar-thumb{background:linear-gradient(180deg,#f7d9a4,#d09a3f);border-radius:8px;border:1px solid #5f1010}
|
|
@@ -147,13 +147,21 @@ function current() {
|
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
// --- 渲染:宠物本体(一次性搭好,之后只改动态位) ----------------------------
|
|
150
|
-
const FALLBACK_SVG = `<svg class="mp-fallback" viewBox="0 0
|
|
151
|
-
<
|
|
152
|
-
<
|
|
153
|
-
<
|
|
154
|
-
<
|
|
155
|
-
|
|
156
|
-
|
|
150
|
+
const FALLBACK_SVG = `<svg class="mp-fallback" viewBox="0 0 512 512" role="img" aria-label="鲸鱼娘·小毛 图标:红皮书《毛泽东选集》">
|
|
151
|
+
<rect x="0" y="0" width="512" height="512" rx="116" fill="#FFF6E9"/>
|
|
152
|
+
<rect x="14" y="14" width="484" height="484" rx="104" fill="none" stroke="#C1342B" stroke-width="6" stroke-opacity="0.16"/>
|
|
153
|
+
<g opacity="0.22"><rect x="132" y="112" width="286" height="308" rx="28" fill="#7A1410"/></g>
|
|
154
|
+
<g transform="translate(252,256) scale(1.08) translate(-258,-256)">
|
|
155
|
+
<path d="M330 118 h78 a14 14 0 0 1 14 14 v248 a14 14 0 0 1 -14 14 h-78 z" fill="#EFDFC4"/>
|
|
156
|
+
<path d="M356 138 v236 M382 138 v236" stroke="#D9C4A2" stroke-width="5" stroke-linecap="round" fill="none"/>
|
|
157
|
+
<rect x="120" y="104" width="232" height="304" rx="26" fill="#B3271E"/>
|
|
158
|
+
<rect x="120" y="104" width="46" height="304" rx="22" fill="#8F1E17"/>
|
|
159
|
+
<path d="M143 148 v216" stroke="#E8C36A" stroke-width="7" stroke-linecap="round" opacity="0.9" fill="none"/>
|
|
160
|
+
<rect x="192" y="126" width="142" height="260" rx="14" fill="none" stroke="#E8C36A" stroke-width="5" stroke-opacity="0.55"/>
|
|
161
|
+
<polygon fill="#E8C36A" points="263,150 273.32,181.76 306.72,181.76 279.7,201.74 289.02,234.5 263,214.25 236.98,234.5 246.3,201.74 219.28,181.76 252.68,181.76"/>
|
|
162
|
+
<g fill="#E8C36A"><rect x="215" y="272" width="96" height="16" rx="8"/><rect x="232" y="304" width="62" height="13" rx="6.5" opacity="0.78"/></g>
|
|
163
|
+
</g>
|
|
164
|
+
</svg>`;
|
|
157
165
|
|
|
158
166
|
PET_TAG.innerHTML = `
|
|
159
167
|
<span id="pet-visual"></span>
|
|
@@ -381,6 +389,8 @@ window.__petSprites = (map) => {
|
|
|
381
389
|
renderPetVisual();
|
|
382
390
|
reportRects();
|
|
383
391
|
};
|
|
392
|
+
// 壳可能在当前脚本执行前就注入了立绘(--public 模式轮询极快)——这里回放
|
|
393
|
+
if (window.__petSpriteData) window.__petSprites(window.__petSpriteData);
|
|
384
394
|
window.__petState = (json) => {
|
|
385
395
|
offline = false;
|
|
386
396
|
state = json;
|
|
@@ -402,6 +412,9 @@ window.__petState = (json) => {
|
|
|
402
412
|
renderAll();
|
|
403
413
|
};
|
|
404
414
|
window.__petOffline = () => { offline = true; busy = false; renderMeta(); renderCard(); };
|
|
415
|
+
// 状态/离线标记可能早于本脚本到达(--public 轮询极快)——回放
|
|
416
|
+
if (window.__petStateData) window.__petState(window.__petStateData);
|
|
417
|
+
else if (window.__petOfflineSeen) window.__petOffline();
|
|
405
418
|
window.__petActionDone = () => { busy = false; cardKey = ''; renderCard(); renderMeta(); };
|
|
406
419
|
|
|
407
420
|
// 时钟:倒计时文案每秒刷新
|
package/index.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { readFile, writeFile, rename, mkdir, stat } from 'node:fs/promises';
|
|
19
19
|
import { existsSync, readFileSync } from 'node:fs';
|
|
20
|
-
import { spawn } from 'node:child_process';
|
|
20
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
21
21
|
import { dirname, join } from 'node:path';
|
|
22
22
|
import { fileURLToPath } from 'node:url';
|
|
23
23
|
import { homedir } from 'node:os';
|
|
@@ -1175,6 +1175,11 @@ export function apply(ctx, config) {
|
|
|
1175
1175
|
const pid = Number(readFileSync(shellPidPath, 'utf8').trim());
|
|
1176
1176
|
if (!Number.isInteger(pid) || pid <= 1) return null;
|
|
1177
1177
|
process.kill(pid, 0);
|
|
1178
|
+
// pid 会被系统复用:编号活着 ≠ 还是小毛。校验进程名,防止徽章状态失真。
|
|
1179
|
+
if (shellPlatform) {
|
|
1180
|
+
const comm = execFileSync('ps', ['-p', String(pid), '-o', 'comm='], { encoding: 'utf8' });
|
|
1181
|
+
if (!comm.trim().endsWith('maoxuan-pet')) return null;
|
|
1182
|
+
}
|
|
1178
1183
|
return pid;
|
|
1179
1184
|
} catch { return null; }
|
|
1180
1185
|
};
|
|
@@ -1218,7 +1223,8 @@ export function apply(ctx, config) {
|
|
|
1218
1223
|
}
|
|
1219
1224
|
const args = ['--public'];
|
|
1220
1225
|
// 只接受 loopback origin:这个值来自浏览器 location.origin,别让它带去别处
|
|
1221
|
-
if (typeof origin === 'string' && /^http:\/\/127\.0\.0\.1:\d{2,5}$/.test(origin)) {
|
|
1226
|
+
if (typeof origin === 'string' && /^http:\/\/(127\.0\.0\.1|localhost|\[::1\]):\d{2,5}$/.test(origin)) {
|
|
1227
|
+
origin = origin.replace('localhost', '127.0.0.1').replace('[::1]', '127.0.0.1');
|
|
1222
1228
|
args.push(`--origin=${origin}`);
|
|
1223
1229
|
}
|
|
1224
1230
|
const child = spawn(shellBin, args, { detached: true, stdio: 'ignore' });
|