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 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: 18, bottom: 18, zIndex: 2147483000,
535
- border: '1px solid #ffd9a855', borderRadius: 999, cursor: 'pointer',
536
- padding: '6px 14px', font: '12px/1.6 -apple-system, sans-serif',
537
- color: '#ffe9c8', background: 'linear-gradient(180deg,#8f1d22,#6d1418)',
538
- boxShadow: '0 4px 14px rgba(0,0,0,.28)',
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
- }, shellBusy ? '收回中…' : '小毛在桌面上 · 点此收回');
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
- return kill(pid, 0) == 0 ? pid : nil
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
- let onScreen = NSScreen.screens.contains { $0.visibleFrame.intersects(frame) }
240
- guard onScreen, frame.width > 100, frame.height > 100 else { return nil }
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 && 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 && 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 && 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
- Shell.log("state HTTP \(http.statusCode)")
341
- self.evaluate("window.__petOffline && window.__petOffline();")
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 && 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
- self.evaluate("window.__petState && window.__petState(\(self.sanitized(text)));")
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.__petSprites && window.__petSprites(\(Shell.jsonString(self.spriteDataURLs)));")
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.log("input rects: \(converted.count)")
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:132px;width:100px;filter:drop-shadow(0 10px 16px #050b14aa)}
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: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}
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 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>`;
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' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-maoxuan-pet",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "DSH 悬浮宠物「鲸鱼娘·小毛」:每小时读一篇毛选,给创业中的你说一句能动手的督促;内置毛选公开语料与 maoxuan-pet preset。",
5
5
  "keywords": [
6
6
  "dsh",