dsh-plugin-maoxuan-pet 0.2.3 → 0.3.1

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.
@@ -0,0 +1,552 @@
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] [--token=… | --public] [--reset] [--verbose]
12
+ // --token= 宿主 launch token(dsh web 启动日志 URL 里的 ?token=…);开了鉴权的宿主(0.2.1-rc.2+)
13
+ // 必须带,否则 API 全 401。先用它 GET /?token= 换签名 cookie,之后所有请求自动携带。
14
+ // --public 走插件自己的 loopback 公开路由(/dsh-maoxuan-pet/…):不需要 token,
15
+ // 桌面 App(不打印 token)旁边也能满血——这是日常推荐用法。
16
+ // 退出:右键小毛 → 退出小毛。
17
+
18
+ import AppKit
19
+ import WebKit
20
+
21
+ // MARK: - 配置
22
+
23
+ enum Shell {
24
+ static var origin = "http://127.0.0.1:3081"
25
+ static var verbose = false
26
+ /// 宿主 launch token(`dsh web` 启动时打印在 URL 的 ?token=…)。给了 token 就先走一次
27
+ /// `GET /?token=` 换回签名 cookie(dsh-auth-*,绑定 127.0.0.1:端口,30 天),之后
28
+ /// state/sprite/POST 全部自带 cookie——0.2.1-rc.2 起宿主 API 有鉴权,无 cookie 一律 401。
29
+ static var token: String?
30
+ /// 公开路由模式(`--public`):走插件注册的 loopback 公开路由(/dsh-maoxuan-pet/…),
31
+ /// 不要 token、不要 cookie。宿主的 /api 有鉴权且拿不到 launch token 时(典型:桌面 App)
32
+ /// 用这个。POST 仍带 `x-dsh-maoxuan-pet: 1` 头,宿主据此拒绝跨站请求。
33
+ static var publicRoutes = false
34
+ static var statePath = "/api/dsh-maoxuan-pet.state"
35
+ static var generatePath = "/api/dsh-maoxuan-pet.generate"
36
+ static var dismissPath = "/api/dsh-maoxuan-pet.dismiss"
37
+ static var assetPath = "/api/dsh-maoxuan-pet.asset"
38
+ static let windowWidth: CGFloat = 640
39
+ static let windowHeight: CGFloat = 720
40
+ static let defaults = UserDefaults(suiteName: "dsh-maoxuan-pet.window")!
41
+
42
+ static func log(_ message: String) {
43
+ guard verbose else { return }
44
+ FileHandle.standardError.write(Data("[maoxuan-pet] \(message)\n".utf8))
45
+ }
46
+
47
+ static func jsonString(_ value: Any) -> String {
48
+ guard JSONSerialization.isValidJSONObject([value]),
49
+ let data = try? JSONSerialization.data(withJSONObject: [value]),
50
+ let array = String(data: data, encoding: .utf8),
51
+ array.count > 1 else { return "{}" }
52
+ return String(array.dropFirst().dropLast())
53
+ }
54
+ }
55
+
56
+ // MARK: - 无边框窗口要显式声明可成为 key(Escape 关卡片需要键盘事件)
57
+
58
+ final class PetWindow: NSWindow {
59
+ override var canBecomeKey: Bool { true }
60
+ override var canBecomeMain: Bool { true }
61
+ }
62
+
63
+ // MARK: - 点击穿透容器:白名单矩形之外,鼠标事件全部放行给桌面
64
+
65
+ final class ClickThroughView: NSView {
66
+ weak var webView: WKWebView?
67
+ /// 容器本地坐标(左下原点);矩形之外 hitTest 返回 nil → 点击落到下层窗口。
68
+ var passRects: [CGRect] = []
69
+
70
+ override func hitTest(_ aPoint: NSPoint) -> NSView? {
71
+ let local: NSPoint
72
+ if let superview {
73
+ local = convert(aPoint, from: superview)
74
+ } else {
75
+ local = aPoint
76
+ }
77
+ for rect in passRects where rect.contains(local) {
78
+ return webView
79
+ }
80
+ return nil
81
+ }
82
+ }
83
+
84
+ // MARK: - 主控制器
85
+
86
+ final class ShellController: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKScriptMessageHandler {
87
+ private var window: PetWindow!
88
+ private var webView: WKWebView!
89
+ private var clickView: ClickThroughView!
90
+ private var pollItem: DispatchWorkItem?
91
+ /// ephemeral:无磁盘缓存,壳不写任何本地数据(也躲开 ~/Library/Caches 权限噪音)。
92
+ /// cookie 用内存 jar:--token 换回的 dsh-auth cookie 存在这里,每个请求自动携带,
93
+ /// 进程退出即消失,不落盘。
94
+ private lazy var httpConfig: URLSessionConfiguration = {
95
+ let configuration = URLSessionConfiguration.ephemeral
96
+ configuration.httpCookieAcceptPolicy = .always
97
+ configuration.httpShouldSetCookies = true
98
+ return configuration
99
+ }()
100
+ private lazy var http: URLSession = URLSession(configuration: httpConfig)
101
+ private var dragBaseOrigin: NSPoint?
102
+ private var pageReady = false
103
+ private var lastInterval: TimeInterval = 20
104
+ /// 精灵图 name → data URL:WebKit 的 ATS 会拦 file:// 页面直挂的 http 图,
105
+ /// 所以素材也由原生层拉取后转 base64 注入,WebKit 全程零网络请求。
106
+ private var spriteDataURLs: [String: String] = [:]
107
+ private var spritePending = Set<String>()
108
+ private var authed = false
109
+ private var authInFlight = false
110
+
111
+ // MARK 壳的单实例与 pidfile
112
+
113
+ /// pidfile:让宿主插件(界面里的开关)认得出「壳是否活着」,包括手动起的。
114
+ /// 崩溃留下的陈旧 pid 由读取方用 kill(pid,0) 探活过滤,不依赖正常退出清理。
115
+ private func pidFilePath() -> String {
116
+ let home = ProcessInfo.processInfo.environment["DSH_HOME"] ?? NSHomeDirectory() + "/.dsh"
117
+ return home + "/storages/dsh-maoxuan-pet.shell.pid"
118
+ }
119
+
120
+ private func readLivePid() -> Int32? {
121
+ guard let raw = try? String(contentsOfFile: pidFilePath(), encoding: .utf8),
122
+ let pid = Int32(raw.trimmingCharacters(in: .whitespacesAndNewlines)), pid > 1 else { return nil }
123
+ return kill(pid, 0) == 0 ? pid : nil
124
+ }
125
+
126
+ private func writePid() {
127
+ let path = pidFilePath()
128
+ let dir = (path as NSString).deletingLastPathComponent
129
+ try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
130
+ try? String(ProcessInfo.processInfo.processIdentifier.description)
131
+ .write(toFile: path, atomically: true, encoding: .utf8)
132
+ }
133
+
134
+ private func clearPid() {
135
+ try? FileManager.default.removeItem(atPath: pidFilePath())
136
+ }
137
+
138
+ // MARK 生命周期
139
+
140
+ func applicationDidFinishLaunching(_ notification: Notification) {
141
+ // 单实例:屏幕上已经有一只就不重复出来(宿主开关和手动双击都可能触发)
142
+ if let existing = readLivePid(), existing != ProcessInfo.processInfo.processIdentifier {
143
+ FileHandle.standardError.write(Data("[maoxuan-pet] 已有一只小毛在跑(pid \(existing)),这次就不重复出现了\n".utf8))
144
+ exit(0)
145
+ }
146
+ writePid()
147
+ let content = NSRect(x: 0, y: 0, width: Shell.windowWidth, height: Shell.windowHeight)
148
+ let window = PetWindow(
149
+ contentRect: restoredFrame() ?? defaultFrame(),
150
+ styleMask: [.borderless],
151
+ backing: .buffered,
152
+ defer: false
153
+ )
154
+ window.isOpaque = false
155
+ window.backgroundColor = .clear
156
+ window.hasShadow = false
157
+ window.level = .floating
158
+ window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle]
159
+ window.isMovableByWindowBackground = false
160
+ window.acceptsMouseMovedEvents = true
161
+ window.isReleasedWhenClosed = false
162
+ window.ignoresMouseEvents = false
163
+
164
+ let container = ClickThroughView(frame: content)
165
+ container.wantsLayer = true
166
+
167
+ let configuration = WKWebViewConfiguration()
168
+ // 壳不需要任何网站数据(位置持久化走原生 UserDefaults),用一次性存储免得碰 ~/Library/WebKit
169
+ configuration.websiteDataStore = .nonPersistent()
170
+ configuration.userContentController.add(self, name: "pet")
171
+ // __petEnv 必须在页面脚本运行前就位(didFinish 时内联脚本早已执行完)
172
+ let envScript = WKUserScript(
173
+ source: "window.__petEnv = \(Shell.jsonString(["origin": Shell.origin]));",
174
+ injectionTime: .atDocumentStart,
175
+ forMainFrameOnly: true
176
+ )
177
+ configuration.userContentController.addUserScript(envScript)
178
+
179
+ let webView = WKWebView(frame: container.bounds, configuration: configuration)
180
+ webView.autoresizingMask = [.width, .height]
181
+ webView.navigationDelegate = self
182
+ // WKWebView 画透明底的两件套
183
+ webView.setValue(false, forKey: "drawsBackground")
184
+ webView.underPageBackgroundColor = .clear
185
+
186
+ container.webView = webView
187
+ container.addSubview(webView)
188
+ window.contentView = container
189
+
190
+ self.window = window
191
+ self.webView = webView
192
+ self.clickView = container
193
+
194
+ if let html = locateHTML() {
195
+ webView.loadFileURL(html, allowingReadAccessTo: html.deletingLastPathComponent())
196
+ } else {
197
+ FileHandle.standardError.write(Data("[maoxuan-pet] 找不到 pet.html(应与可执行文件同目录)\n".utf8))
198
+ NSApp.terminate(nil)
199
+ return
200
+ }
201
+
202
+ window.orderFrontRegardless()
203
+ let authNote: String
204
+ if Shell.publicRoutes { authNote = ",公开路由模式(不需要 token)" }
205
+ else if Shell.token != nil { authNote = ",带 launch token(自动换 dsh-auth cookie)" }
206
+ else { authNote = ",无 token(宿主开鉴权时会是离线模式)" }
207
+ FileHandle.standardError.write(Data("[maoxuan-pet] 小毛已站上桌面 — \(Shell.origin)\(authNote)(独立进程,DSH 最小化不影响她)\n".utf8))
208
+ }
209
+
210
+ func applicationWillTerminate(_ notification: Notification) {
211
+ clearPid()
212
+ persistFrame()
213
+ }
214
+
215
+ /// pet.html 与可执行文件同目录(build.sh 负责拷贝)。
216
+ private func locateHTML() -> URL? {
217
+ let argv0 = CommandLine.arguments.first ?? "maoxuan-pet"
218
+ let executable = URL(fileURLWithPath: argv0).resolvingSymlinksInPath()
219
+ let candidate = executable.deletingLastPathComponent().appendingPathComponent("pet.html")
220
+ return FileManager.default.fileExists(atPath: candidate.path) ? candidate : nil
221
+ }
222
+
223
+ // MARK 窗口位置
224
+
225
+ private func defaultFrame() -> NSRect {
226
+ let visible = NSScreen.main?.visibleFrame
227
+ ?? NSRect(x: 0, y: 0, width: 1440, height: 900)
228
+ let origin = NSPoint(
229
+ x: visible.maxX - Shell.windowWidth - 28,
230
+ y: visible.minY + 96
231
+ )
232
+ return NSRect(origin: origin, size: NSSize(width: Shell.windowWidth, height: Shell.windowHeight))
233
+ }
234
+
235
+ private func restoredFrame() -> NSRect? {
236
+ guard ProcessInfo.processInfo.arguments.contains("--reset") == false,
237
+ let raw = Shell.defaults.string(forKey: "frame") else { return nil }
238
+ 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 }
241
+ return frame
242
+ }
243
+
244
+ private func persistFrame() {
245
+ guard let window else { return }
246
+ Shell.defaults.set(NSStringFromRect(window.frame), forKey: "frame")
247
+ Shell.log("frame saved: \(NSStringFromRect(window.frame))")
248
+ }
249
+
250
+ private func resetFrame() {
251
+ Shell.defaults.removeObject(forKey: "frame")
252
+ let frame = defaultFrame()
253
+ window.setFrame(frame, display: true)
254
+ persistFrame()
255
+ }
256
+
257
+ /// 拖拽落点收敛:至少留 140pt 在可见区域内,防止拖丢。
258
+ private func clampedOrigin(_ origin: NSPoint) -> NSPoint {
259
+ let size = window.frame.size
260
+ let center = NSPoint(x: origin.x + size.width / 2, y: origin.y + size.height / 2)
261
+ let screen = NSScreen.screens.first { $0.frame.contains(center) } ?? NSScreen.main
262
+ let visible = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900)
263
+ let clampedX = min(max(origin.x, visible.minX - size.width + 140), visible.maxX - 140)
264
+ let clampedY = min(max(origin.y, visible.minY - size.height + 140), visible.maxY - 40)
265
+ return NSPoint(x: clampedX, y: clampedY)
266
+ }
267
+
268
+ // MARK 轮询(原生层负责全部 HTTP,页面不跨域)
269
+
270
+ /// 用 launch token 换签名 cookie:`GET /?token=…` → 303 + Set-Cookie(dsh-auth-*,
271
+ /// Max-Age 30 天,绑定 127.0.0.1:端口)。与浏览器打开 GUI 走的是同一条路;cookie 落在
272
+ /// URLSession 内存 jar 里,之后的 state / sprite / POST 每个请求自动携带。
273
+ /// token 来源:`dsh web`(CLI 宿主)启动日志第一行;桌面 App 不打印,拿不到就只能离线模式。
274
+ private func authenticate(_ token: String) {
275
+ if authInFlight { return }
276
+ authInFlight = true
277
+ guard var components = URLComponents(string: Shell.origin + "/") else {
278
+ authInFlight = false
279
+ return
280
+ }
281
+ components.queryItems = [URLQueryItem(name: "token", value: token)]
282
+ guard let url = components.url else {
283
+ authInFlight = false
284
+ return
285
+ }
286
+ var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 8)
287
+ request.httpMethod = "GET"
288
+ Shell.log("auth: 用 launch token 换 cookie…")
289
+ http.dataTask(with: request) { [weak self] _, response, error in
290
+ DispatchQueue.main.async {
291
+ guard let self else { return }
292
+ self.authInFlight = false
293
+ if let error {
294
+ Shell.log("auth failed: \(error.localizedDescription)")
295
+ self.evaluate("window.__petOffline && window.__petOffline();")
296
+ self.schedulePoll(after: 30)
297
+ return
298
+ }
299
+ let status = (response as? HTTPURLResponse)?.statusCode ?? 0
300
+ let jar = self.httpConfig.httpCookieStorage
301
+ let hasAuthCookie = jar?.cookies?.contains { $0.name.hasPrefix("dsh-auth-") } ?? false
302
+ guard (200..<400).contains(status), hasAuthCookie else {
303
+ Shell.log("auth HTTP \(status),没换到 dsh-auth cookie——token 要来自同一个宿主进程(dsh web 启动日志里的 ?token=)")
304
+ self.evaluate("window.__petOffline && window.__petOffline();")
305
+ self.schedulePoll(after: 30)
306
+ return
307
+ }
308
+ self.authed = true
309
+ Shell.log("auth ok:dsh-auth cookie 已入 jar,开始拉取 state")
310
+ self.pollNow()
311
+ }
312
+ }.resume()
313
+ }
314
+
315
+ private func schedulePoll(after interval: TimeInterval) {
316
+ pollItem?.cancel()
317
+ let item = DispatchWorkItem { [weak self] in self?.pollNow() }
318
+ pollItem = item
319
+ DispatchQueue.main.asyncAfter(deadline: .now() + interval, execute: item)
320
+ }
321
+
322
+ private func pollNow() {
323
+ if let token = Shell.token, !authed {
324
+ authenticate(token)
325
+ return
326
+ }
327
+ guard let url = URL(string: Shell.origin + Shell.statePath) else { return }
328
+ var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 8)
329
+ request.httpMethod = "GET"
330
+ http.dataTask(with: request) { [weak self] data, response, error in
331
+ DispatchQueue.main.async {
332
+ guard let self else { return }
333
+ if let error {
334
+ Shell.log("state failed: \(error.localizedDescription)")
335
+ self.evaluate("window.__petOffline && window.__petOffline();")
336
+ self.schedulePoll(after: 8)
337
+ return
338
+ }
339
+ if let http = response as? HTTPURLResponse, http.statusCode != 200 {
340
+ Shell.log("state HTTP \(http.statusCode)")
341
+ self.evaluate("window.__petOffline && window.__petOffline();")
342
+ // 401 = jar 里的 cookie 失效了(宿主重启换了 launch token、或 30 天过期):
343
+ // 置回未鉴权,下一轮先用 --token 重新换一次 cookie 再拉。
344
+ if http.statusCode == 401, Shell.token != nil { self.authed = false }
345
+ self.schedulePoll(after: 8)
346
+ return
347
+ }
348
+ guard let data, let text = String(data: data, encoding: .utf8) else {
349
+ self.evaluate("window.__petOffline && window.__petOffline();")
350
+ self.schedulePoll(after: 8)
351
+ return
352
+ }
353
+ // 生成中的话 3s 后紧跟下一条,让气泡尽快落地
354
+ let running = (text.contains("\"running\":true") || text.contains("\"running\" : true"))
355
+ self.lastInterval = running ? 3 : 20
356
+ self.ensureSprites(from: data)
357
+ self.evaluate("window.__petState && window.__petState(\(self.sanitized(text)));")
358
+ self.schedulePoll(after: self.lastInterval)
359
+ }
360
+ }.resume()
361
+ }
362
+
363
+ /// 从 state 里拿 pet.sprites,逐张原生拉取并转 data URL 注入页面(一次性,之后命中缓存)。
364
+ private func ensureSprites(from stateData: Data) {
365
+ guard let obj = try? JSONSerialization.jsonObject(with: stateData),
366
+ let dict = obj as? [String: Any],
367
+ let pet = dict["pet"] as? [String: Any],
368
+ let sprites = pet["sprites"] as? [String: String] else { return }
369
+ var newlyNeeded: [(name: String, path: String)] = []
370
+ for (name, path) in sprites {
371
+ guard spriteDataURLs[name] == nil, !spritePending.contains(name) else { continue }
372
+ spritePending.insert(name)
373
+ newlyNeeded.append((name, path))
374
+ }
375
+ guard !newlyNeeded.isEmpty else { return }
376
+ for entry in newlyNeeded {
377
+ // 公开路由模式下 state 里的 sprite 路径仍是 /api/…,换到公开素材路由
378
+ let path = Shell.publicRoutes
379
+ ? entry.path.replacingOccurrences(of: "/api/dsh-maoxuan-pet.asset", with: "/dsh-maoxuan-pet/asset.png")
380
+ : entry.path
381
+ guard let url = URL(string: Shell.origin + path) else {
382
+ spritePending.remove(entry.name)
383
+ continue
384
+ }
385
+ http.dataTask(with: URLRequest(url: url, timeoutInterval: 8)) { [weak self] data, response, error in
386
+ DispatchQueue.main.async {
387
+ guard let self else { return }
388
+ self.spritePending.remove(entry.name)
389
+ if let error {
390
+ Shell.log("sprite \(entry.name) failed: \(error.localizedDescription)")
391
+ } else if let http = response as? HTTPURLResponse, http.statusCode == 200, let data {
392
+ let mime = http.mimeType ?? "image/png"
393
+ self.spriteDataURLs[entry.name] = "data:\(mime);base64,\(data.base64EncodedString())"
394
+ Shell.log("sprite \(entry.name) ready (\(data.count) bytes)")
395
+ }
396
+ self.evaluate("window.__petSprites && window.__petSprites(\(Shell.jsonString(self.spriteDataURLs)));")
397
+ }
398
+ }.resume()
399
+ }
400
+ }
401
+
402
+ /// JSON 文本直接内联为 JS 字面量;U+2028/2029 在旧解析器里会断行,换义掉。
403
+ private func sanitized(_ text: String) -> String {
404
+ text
405
+ .replacingOccurrences(of: "\u{2028}", with: "\\u2028")
406
+ .replacingOccurrences(of: "\u{2029}", with: "\\u2029")
407
+ }
408
+
409
+ private func evaluate(_ source: String) {
410
+ guard pageReady else { return }
411
+ webView.evaluateJavaScript(source, completionHandler: { result, error in
412
+ if let error { Shell.log("evaluate failed: \(error.localizedDescription)") }
413
+ })
414
+ }
415
+
416
+ // MARK POST 动作(再督促 / 收起)
417
+
418
+ private func postAction(path: String, body: String) {
419
+ guard let url = URL(string: Shell.origin + path) else { return }
420
+ var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 200)
421
+ request.httpMethod = "POST"
422
+ request.setValue("application/json", forHTTPHeaderField: "content-type")
423
+ // 公开路由的 POST 要求这个头(跨站页面发不出来,宿主据此挡 CSRF);/api 模式忽略它
424
+ request.setValue("1", forHTTPHeaderField: "x-dsh-maoxuan-pet")
425
+ request.httpBody = body.data(using: .utf8)
426
+ Shell.log("POST \(path) \(body)")
427
+ http.dataTask(with: request) { [weak self] data, response, error in
428
+ DispatchQueue.main.async {
429
+ guard let self else { return }
430
+ if let error {
431
+ Shell.log("POST failed: \(error.localizedDescription)")
432
+ } else if let http = (response as? HTTPURLResponse) {
433
+ let bodyText = data.flatMap { String(data: $0, encoding: .utf8) } ?? ""
434
+ Shell.log("POST \(path) → \(http.statusCode) \(String(bodyText.prefix(160)))")
435
+ }
436
+ self.evaluate("window.__petActionDone && window.__petActionDone();")
437
+ self.pollNow()
438
+ }
439
+ }.resume()
440
+ }
441
+
442
+ // MARK WKNavigationDelegate
443
+
444
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
445
+ pageReady = true
446
+ pollNow()
447
+ }
448
+
449
+ func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
450
+ Shell.log("page load failed: \(error.localizedDescription)")
451
+ }
452
+
453
+ // MARK WKScriptMessageHandler(页面 → 原生)
454
+
455
+ func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
456
+ guard message.name == "pet",
457
+ let body = message.body as? [String: Any],
458
+ let type = body["type"] as? String else { return }
459
+ switch type {
460
+ case "input":
461
+ // 页面上报的可交互矩形(CSS px,左上原点)→ 容器本地坐标(左下原点)
462
+ guard let rects = body["rects"] as? [[String: Any]] else { return }
463
+ let contentHeight = webView.bounds.height
464
+ let converted: [CGRect] = rects.compactMap { dict in
465
+ guard let x = (dict["x"] as? NSNumber)?.doubleValue,
466
+ let y = (dict["y"] as? NSNumber)?.doubleValue,
467
+ let w = (dict["w"] as? NSNumber)?.doubleValue,
468
+ let h = (dict["h"] as? NSNumber)?.doubleValue,
469
+ w > 0, h > 0 else { return nil }
470
+ return CGRect(x: x, y: contentHeight - (y + h), width: w, height: h)
471
+ }
472
+ clickView.passRects = converted
473
+ Shell.log("input rects: \(converted.count)")
474
+
475
+ case "dragstart":
476
+ dragBaseOrigin = window.frame.origin
477
+
478
+ case "dragto":
479
+ guard let base = dragBaseOrigin else { return }
480
+ let dx = (body["dx"] as? NSNumber)?.doubleValue ?? 0
481
+ let dy = (body["dy"] as? NSNumber)?.doubleValue ?? 0
482
+ let target = clampedOrigin(NSPoint(x: base.x + dx, y: base.y - dy))
483
+ window.setFrameOrigin(target)
484
+
485
+ case "dragend":
486
+ dragBaseOrigin = nil
487
+ persistFrame()
488
+
489
+ case "action":
490
+ let action = body["action"] as? String
491
+ if action == "generate" {
492
+ postAction(path: Shell.generatePath, body: "{}")
493
+ } else if action == "dismiss" {
494
+ let raw = body["id"] as? String ?? ""
495
+ let id = String(raw.filter { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" })
496
+ let payload = id.isEmpty ? "{}" : "{\"id\":\"\(id)\"}"
497
+ postAction(path: Shell.dismissPath, body: payload)
498
+ }
499
+
500
+ case "config":
501
+ let action = body["action"] as? String
502
+ if action == "quit" {
503
+ NSApp.terminate(nil)
504
+ } else if action == "reset" {
505
+ resetFrame()
506
+ }
507
+
508
+ default:
509
+ break
510
+ }
511
+ }
512
+ }
513
+
514
+ // MARK: - 入口
515
+
516
+ let arguments = ProcessInfo.processInfo.arguments
517
+ var index = 1
518
+ while index < arguments.count {
519
+ let argument = arguments[index]
520
+ if argument.hasPrefix("--origin=") {
521
+ var value = String(argument.dropFirst("--origin=".count))
522
+ while value.hasSuffix("/") { value.removeLast() }
523
+ Shell.origin = value
524
+ } else if argument.hasPrefix("--port=") {
525
+ let value = String(argument.dropFirst("--port=".count))
526
+ Shell.origin = "http://127.0.0.1:\(value)"
527
+ } else if argument.hasPrefix("--token=") {
528
+ // 容错:允许把「?token=xxx」整段或「token=xxx」一起粘进来
529
+ var value = String(argument.dropFirst("--token=".count)).trimmingCharacters(in: .whitespaces)
530
+ if value.hasPrefix("token=") { value = String(value.dropFirst("token=".count)) }
531
+ while value.hasSuffix("/") { value.removeLast() }
532
+ Shell.token = value.isEmpty ? nil : value
533
+ } else if argument == "--public" {
534
+ Shell.publicRoutes = true
535
+ } else if argument == "--verbose" {
536
+ Shell.verbose = true
537
+ }
538
+ index += 1
539
+ }
540
+
541
+ if Shell.publicRoutes {
542
+ Shell.statePath = "/dsh-maoxuan-pet/pet.json"
543
+ Shell.generatePath = "/dsh-maoxuan-pet/generate"
544
+ Shell.dismissPath = "/dsh-maoxuan-pet/dismiss"
545
+ Shell.assetPath = "/dsh-maoxuan-pet/asset.png"
546
+ }
547
+
548
+ let app = NSApplication.shared
549
+ app.setActivationPolicy(.accessory) // 不占 Dock、不进 Cmd-Tab
550
+ let controller = ShellController()
551
+ app.delegate = controller
552
+ app.run()