magic-agent 2.4.3 → 2.6.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.
Files changed (153) hide show
  1. package/CLIENT-CONTRACT.md +284 -30
  2. package/README.md +15 -14
  3. package/client-ui/pc/app.css +32 -0
  4. package/client-ui/pc/js/04-task-list.js +5 -1
  5. package/client-ui/pc/js/07-chat-stream-render.js +3 -3
  6. package/client-ui/pc/js/90-glue.js +37 -2
  7. package/client-ui/pc/js/92-composer.js +19 -4
  8. package/client-ui/pc/js/93-rail.js +16 -5
  9. package/client-ui/shared/conv/02-media-kinds.js +2 -2
  10. package/client-ui/shared/conv/03-chat-message.js +241 -23
  11. package/client-ui/shared/conv/06-ask-answer.js +75 -1
  12. package/client-ui/shared/conv/09-stream-apply.js +63 -7
  13. package/client-ui/shared/conv/19-attachments.js +157 -6
  14. package/client-ui/shared/conv/27-conv-ledger.js +5 -2
  15. package/client-ui/shared/conv/29-msg-variants.js +81 -0
  16. package/client-ui/shared/conv/conv.css +79 -19
  17. package/client-ui/shared/conv/dom-map.md +1 -0
  18. package/client-ui/shared/conversation.css +13 -2
  19. package/client-ui/shared/conversation.js +336 -50
  20. package/client-ui/shared/transport-http.js +14 -0
  21. package/client-ui-mobile/index.html +2932 -333
  22. package/client-ui-mobile/reqs.css +102 -0
  23. package/client-ui-mobile/reqs.js +485 -70
  24. package/client-ui-mobile/reqshot.js +505 -0
  25. package/client-ui-mobile/scripts.css +21 -0
  26. package/client-ui-mobile/scripts.js +539 -0
  27. package/client-ui-mobile/vendor/cose-base.js +3214 -0
  28. package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
  29. package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
  30. package/client-ui-mobile/vendor/layout-base.js +5230 -0
  31. package/cmd/magic-client/browsectl.go +199 -0
  32. package/cmd/magic-client/clientctl.go +33 -9
  33. package/cmd/magic-client/main.go +57 -26
  34. package/internal/agent/ask.go +45 -0
  35. package/internal/agent/ask_test.go +39 -0
  36. package/internal/agent/attachments_test.go +6 -2
  37. package/internal/agent/capability.go +7 -1
  38. package/internal/agent/claude.go +20 -15
  39. package/internal/agent/codebuddy.go +125 -34
  40. package/internal/agent/codebuddy_account_test.go +54 -0
  41. package/internal/agent/codebuddy_stream_test.go +103 -0
  42. package/internal/agent/credits.go +713 -0
  43. package/internal/agent/credits_minimax_test.go +97 -0
  44. package/internal/agent/credits_test.go +211 -0
  45. package/internal/agent/custom_models.go +155 -0
  46. package/internal/agent/custom_models_test.go +125 -0
  47. package/internal/agent/dsh.go +123 -49
  48. package/internal/agent/dsh_resume.go +13 -2
  49. package/internal/agent/dsh_test.go +108 -6
  50. package/internal/agent/engine.go +37 -1
  51. package/internal/agent/engine_base.go +21 -17
  52. package/internal/agent/engine_base_test.go +2 -2
  53. package/internal/agent/engine_test.go +47 -86
  54. package/internal/agent/fallback.go +313 -0
  55. package/internal/agent/fallback_test.go +340 -0
  56. package/internal/agent/launch.go +43 -1
  57. package/internal/agent/llmhttp.go +46 -4
  58. package/internal/agent/llmhttp_test.go +157 -0
  59. package/internal/agent/models.go +53 -1
  60. package/internal/agent/models_test.go +20 -5
  61. package/internal/agent/pidalive_windows_test.go +40 -0
  62. package/internal/agent/runcmd.go +26 -0
  63. package/internal/agent/runcmd_windows.go +36 -5
  64. package/internal/agent/stream.go +136 -6
  65. package/internal/agent/stream_test.go +25 -38
  66. package/internal/agent/stream_timeline_test.go +2 -37
  67. package/internal/agent/streamjson.go +108 -6
  68. package/internal/agent/tools_suffix_test.go +6 -3
  69. package/internal/agent/trae.go +50 -243
  70. package/internal/agent/trae_exec.go +433 -0
  71. package/internal/agent/trae_exec_test.go +181 -0
  72. package/internal/agent/wraplaunch_test.go +113 -0
  73. package/internal/cli/ask.go +160 -37
  74. package/internal/cli/ask_test.go +1 -1
  75. package/internal/cli/checkin.go +77 -0
  76. package/internal/cli/clientcmd.go +3 -2
  77. package/internal/cli/control.go +46 -5
  78. package/internal/cli/control_test.go +65 -0
  79. package/internal/cli/events.go +4 -0
  80. package/internal/cli/quotas.go +125 -0
  81. package/internal/cli/requirements.go +51 -0
  82. package/internal/cli/root.go +122 -5
  83. package/internal/cli/session.go +6 -0
  84. package/internal/cli/suggest.go +7 -5
  85. package/internal/cli/team.go +37 -9
  86. package/internal/cli/team_test.go +39 -0
  87. package/internal/client/ab.go +372 -0
  88. package/internal/client/ab_test.go +287 -0
  89. package/internal/client/browse.go +84 -36
  90. package/internal/client/browse_test.go +107 -9
  91. package/internal/client/browseinput.go +481 -0
  92. package/internal/client/browseinput_test.go +216 -0
  93. package/internal/client/corecmd.go +23 -0
  94. package/internal/client/corecmd_other.go +8 -0
  95. package/internal/client/corecmd_windows.go +24 -0
  96. package/internal/client/daemon.go +90 -17
  97. package/internal/client/daemon_test.go +33 -0
  98. package/internal/client/engines_checkin_test.go +57 -0
  99. package/internal/client/fallback_flags_test.go +64 -0
  100. package/internal/client/jobs.go +294 -21
  101. package/internal/client/jobs_test.go +299 -2
  102. package/internal/client/knowledge.go +854 -0
  103. package/internal/client/knowledge_test.go +510 -0
  104. package/internal/client/lan.go +3 -3
  105. package/internal/client/quotas_merge_test.go +107 -0
  106. package/internal/client/requirements.go +198 -1
  107. package/internal/client/requirements_shots_test.go +200 -0
  108. package/internal/client/restart.go +134 -0
  109. package/internal/client/restart_test.go +151 -0
  110. package/internal/client/scripts.go +1047 -0
  111. package/internal/client/scripts_decode_other.go +27 -0
  112. package/internal/client/scripts_decode_test.go +125 -0
  113. package/internal/client/scripts_decode_windows.go +206 -0
  114. package/internal/client/scripts_test.go +726 -0
  115. package/internal/client/service.go +322 -18
  116. package/internal/client/service_test.go +51 -0
  117. package/internal/client/suggest.go +105 -2
  118. package/internal/client/suggest_test.go +86 -0
  119. package/internal/client/team_flags.go +25 -11
  120. package/internal/client/team_flags_test.go +39 -0
  121. package/internal/requirements/bootstrap.go +16 -0
  122. package/internal/requirements/consolidate.go +100 -9
  123. package/internal/requirements/doc.go +154 -3
  124. package/internal/requirements/graph.go +138 -0
  125. package/internal/requirements/memory.go +7 -0
  126. package/internal/requirements/modules.go +312 -0
  127. package/internal/requirements/modules_test.go +373 -0
  128. package/internal/requirements/relations.go +155 -0
  129. package/internal/requirements/relations_test.go +231 -0
  130. package/internal/requirements/requirements.go +10 -0
  131. package/internal/requirements/shots.go +418 -0
  132. package/internal/requirements/shots_test.go +387 -0
  133. package/internal/session/session.go +92 -0
  134. package/internal/suggest/suggest.go +23 -1
  135. package/internal/team/event.go +3 -1
  136. package/internal/team/execute.go +21 -6
  137. package/internal/team/fallback_route_test.go +80 -0
  138. package/internal/team/planner.go +15 -3
  139. package/internal/team/route.go +89 -17
  140. package/internal/team/team.go +18 -4
  141. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  142. package/npm/apk/version.json +1 -5
  143. package/npm/dist/darwin-arm64/magic-agent +0 -0
  144. package/npm/dist/darwin-arm64/magic-client +0 -0
  145. package/npm/dist/darwin-x64/magic-agent +0 -0
  146. package/npm/dist/darwin-x64/magic-client +0 -0
  147. package/npm/dist/linux-arm64/magic-agent +0 -0
  148. package/npm/dist/linux-arm64/magic-client +0 -0
  149. package/npm/dist/linux-x64/magic-agent +0 -0
  150. package/npm/dist/linux-x64/magic-client +0 -0
  151. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  152. package/npm/dist/win32-x64/magic-client.exe +0 -0
  153. package/package.json +8 -2
@@ -43,18 +43,25 @@ func TestRemapBrowseHost(t *testing.T) {
43
43
 
44
44
  func TestBrowseURL(t *testing.T) {
45
45
  cases := []struct {
46
- in, want string
46
+ in, want, tok string
47
47
  }{
48
- {"https://example.com/", "http://p/b/https/example.com/"},
49
- {"https://example.com/a/b?x=1", "http://p/b/https/example.com/a/b?x=1"},
50
- {"http://192.168.1.5:8080/x", "http://p/b/http/192.168.1.5:8080/x"},
51
- {"mailto:a@b.c", "mailto:a@b.c"},
48
+ {"https://example.com/", "http://p/b/https/example.com/", ""},
49
+ {"https://example.com/a/b?x=1", "http://p/b/https/example.com/a/b?x=1", ""},
50
+ {"http://192.168.1.5:8080/x", "http://p/b/http/192.168.1.5:8080/x", ""},
51
+ {"mailto:a@b.c", "mailto:a@b.c", ""},
52
+ // 令牌拼进查询串:无 query 用 ?,有 query 用 &(APK 跨站 iframe 下
53
+ // SameSite=Lax cookie 不随子资源发送,令牌必须走在 URL 里)。
54
+ {"https://example.com/", "http://p/b/https/example.com/?magicToken=T", "T"},
55
+ {"https://example.com/a/b?x=1", "http://p/b/https/example.com/a/b?x=1&magicToken=T", "T"},
52
56
  }
53
57
  for _, c := range cases {
54
- if got := browseURL(mustURL(t, c.in), testProxyBase); got != c.want {
55
- t.Errorf("browseURL(%q) = %q,期望 %q", c.in, got, c.want)
58
+ if got := browseURL(mustURL(t, c.in), testProxyBase, c.tok); got != c.want {
59
+ t.Errorf("browseURL(%q, tok=%q) = %q,期望 %q", c.in, c.tok, got, c.want)
56
60
  }
57
61
  }
62
+ if got := browseURL(nil, testProxyBase, "T"); got != "" {
63
+ t.Errorf("browseURL(nil) 应为空串,实际 %q", got)
64
+ }
58
65
  }
59
66
 
60
67
  func TestStripMagicToken(t *testing.T) {
@@ -86,7 +93,7 @@ func TestRewriteBrowseHTML(t *testing.T) {
86
93
  `<img srcset="/1.png 1x, /2.png 2x">` +
87
94
  `<script>var s="/y";</script>` +
88
95
  `</body></html>`
89
- out := string(rewriteBrowseHTML([]byte(in), page, testProxyBase))
96
+ out := string(rewriteBrowseHTML([]byte(in), page, testProxyBase, ""))
90
97
 
91
98
  wants := []string{
92
99
  `href="http://p/b/https/example.com/x"`,
@@ -131,7 +138,7 @@ func TestRewriteCSS(t *testing.T) {
131
138
  page := mustURL(t, "https://example.com/css/style.css")
132
139
  in := `.a{background:url('/img/a.png')} .b{background:url(../img/b.png)}` +
133
140
  `@import "sub.css"; @import url('/other.css');`
134
- out := rewriteCSS(in, page, testProxyBase)
141
+ out := rewriteCSS(in, page, testProxyBase, "")
135
142
  wants := []string{
136
143
  `url(http://p/b/https/example.com/img/a.png)`,
137
144
  `url(http://p/b/https/example.com/img/b.png)`,
@@ -145,6 +152,60 @@ func TestRewriteCSS(t *testing.T) {
145
152
  }
146
153
  }
147
154
 
155
+ // TestRewriteBrowseHTMLScriptSrc <script> 的**开标签属性**必须改写(src 等),
156
+ // 内容原样保留。不改的后果://cdn.example.com/a.js 这类协议相对地址绕过中转
157
+ // 直连手机网络 —— 桌面端才能访问的内网站点照样裂(百度首页就挂着两个这种脚本)。
158
+ func TestRewriteBrowseHTMLScriptSrc(t *testing.T) {
159
+ page := mustURL(t, "https://example.com/a/b")
160
+ in := `<script src="//cdn.example.com/a.js"></script>` +
161
+ `<script src="/local.js" defer></script>` +
162
+ `<script>var s="/y"; var u="https://keep.example.com/z";</script>` +
163
+ `<script type="application/json">{"src":"/not-a-url"}</script>`
164
+ out := string(rewriteBrowseHTML([]byte(in), page, testProxyBase, ""))
165
+
166
+ wants := []string{
167
+ `<script src="http://p/b/https/cdn.example.com/a.js"></script>`,
168
+ `<script src="http://p/b/https/example.com/local.js" defer></script>`,
169
+ }
170
+ for _, w := range wants {
171
+ if !strings.Contains(out, w) {
172
+ t.Errorf("script 开标签应被改写,缺少 %q\n实际:%s", w, out)
173
+ }
174
+ }
175
+ notWants := []string{
176
+ `/b/https/example.com/y`, // 脚本内字符串不许动
177
+ `/b/https/keep.example.com/z`, // 脚本内的绝对 URL 字符串也不许动
178
+ `/b/https/example.com/not-a-url`, // JSON 脚本块内容不许动
179
+ }
180
+ for _, nw := range notWants {
181
+ if strings.Contains(out, nw) {
182
+ t.Errorf("脚本内容不应被改写,却包含 %q\n实际:%s", nw, out)
183
+ }
184
+ }
185
+ if !strings.Contains(out, `var s="/y";`) || !strings.Contains(out, `var u="https://keep.example.com/z";`) {
186
+ t.Errorf("脚本内容应原样保留,实际:%s", out)
187
+ }
188
+ }
189
+
190
+ // TestRewriteBrowseHTMLToken 令牌要拼进每条改写出的 URL —— 跨站 iframe(APK 壳)下
191
+ // SameSite=Lax cookie 不随子资源请求发送,URL 里的令牌是唯一凭据。
192
+ func TestRewriteBrowseHTMLToken(t *testing.T) {
193
+ page := mustURL(t, "https://example.com/a/b")
194
+ in := `<img src="pic.png"><a href="/x?q=1">l</a>` +
195
+ `<div style="background:url('/bg.png')"></div>`
196
+ out := string(rewriteBrowseHTML([]byte(in), page, testProxyBase, "T"))
197
+ wants := []string{
198
+ `src="http://p/b/https/example.com/a/pic.png?magicToken=T"`,
199
+ `href="http://p/b/https/example.com/x?q=1&magicToken=T"`,
200
+ `url(http://p/b/https/example.com/bg.png?magicToken=T)`,
201
+ }
202
+ for _, w := range wants {
203
+ if !strings.Contains(out, w) {
204
+ t.Errorf("改写 URL 应带令牌,缺少 %q\n实际:%s", w, out)
205
+ }
206
+ }
207
+ }
208
+
148
209
  // TestHandleBrowseE2E 端到端:起一个「拒绝内嵌」的上游站点,经中转取回,
149
210
  // 断言 (1) X-Frame-Options 被剥掉、(2) 页内链接被改写到本中转。
150
211
  func TestHandleBrowseE2E(t *testing.T) {
@@ -197,3 +258,40 @@ func TestHandleBrowseAuth(t *testing.T) {
197
258
  t.Errorf("缺令牌应 401,实际 %d", resp.StatusCode)
198
259
  }
199
260
  }
261
+
262
+ // TestHandleBrowseTokenInURL 开了令牌时:改写出的 URL 必须带 ?magicToken=,
263
+ // 且这个参数**不准透传给上游**(stripMagicToken 剥掉)。
264
+ func TestHandleBrowseTokenInURL(t *testing.T) {
265
+ var gotQuery string
266
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
267
+ gotQuery = r.URL.RawQuery
268
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
269
+ _, _ = io.WriteString(w, `<html><head></head><body><img src="/pic.png"></body></html>`)
270
+ }))
271
+ defer upstream.Close()
272
+
273
+ up, err := url.Parse(upstream.URL)
274
+ if err != nil {
275
+ t.Fatal(err)
276
+ }
277
+ s := &Service{opts: Options{Token: "sekret"}}
278
+ proxy := httptest.NewServer(http.HandlerFunc(s.handleBrowse))
279
+ defer proxy.Close()
280
+
281
+ resp, err := http.Get(proxy.URL + browsePrefix + "http/" + up.Host + "/?magicToken=sekret")
282
+ if err != nil {
283
+ t.Fatal(err)
284
+ }
285
+ defer resp.Body.Close()
286
+ body, _ := io.ReadAll(resp.Body)
287
+ if resp.StatusCode != http.StatusOK {
288
+ t.Fatalf("带正确令牌应 200,实际 %d", resp.StatusCode)
289
+ }
290
+ want := `src="` + proxy.URL + browsePrefix + "http/" + up.Host + "/pic.png?magicToken=sekret" + `"`
291
+ if !strings.Contains(string(body), want) {
292
+ t.Errorf("改写出的子资源 URL 应带令牌,期望包含 %q\n实际:%s", want, body)
293
+ }
294
+ if strings.Contains(gotQuery, "magicToken") {
295
+ t.Errorf("magicToken 不准透传给上游,实际上游收到 query=%q", gotQuery)
296
+ }
297
+ }
@@ -0,0 +1,481 @@
1
+ package client
2
+
3
+ import (
4
+ "net/http"
5
+ "strings"
6
+ "sync"
7
+ "time"
8
+
9
+ "github.com/gorilla/websocket"
10
+ )
11
+
12
+ /* 内置浏览器的**输入中继**(手机端远程触控)。
13
+ *
14
+ * ── 分工(2026-10-25 内置浏览器 MCP 改基 agent-browser 后)──
15
+ * 驱动/截图面(`magic-client browse …`、需求截图)走 **agent-browser**
16
+ * (真实 Chromium/CDP,见 ab.go)——真实事件、CDP 级截图。
17
+ * 本中继只承担手机端「远程触控」:页面跑在手机的 WebView iframe 里,
18
+ * agent-browser 的浏览器在服务端,两者是两个上下文 —— 手机用户触屏操控
19
+ * iframe 里的页面仍走这里。
20
+ *
21
+ * ── 形状 ──
22
+ * viewer(页面内 relay 脚本,同一时刻至多一个,后连顶掉前连)
23
+ * → server:hello / state / ack
24
+ * ← server:input / ping
25
+ * driver(手机 UI,可多个)
26
+ * → server:input(server 重编 seq 后转发给 viewer)
27
+ * ← server:state / ack / viewer 上下线通知
28
+ *
29
+ * 坐标系 = 被代理页面自身的 CSS 像素;viewer 在 hello/state 里上报页面宽高,
30
+ * driver 需要换算时用它。VPN 开启或未配对时页面不走中转、脚本不注入,
31
+ * 鼠标中继自然不可用 —— 与浏览中转同边界。
32
+ *
33
+ * 已知边界:合成事件 isTrusted=false,个别反自动化站点会忽略 —— 如实记录,不假装是 CDP。
34
+ */
35
+
36
+ const browseWSPath = "/browse-input"
37
+
38
+ // BrowseWSPath 导出给驱动端 CLI(cmd/magic-client)拼连接地址用。
39
+ const BrowseWSPath = browseWSPath
40
+
41
+ // 输入动作白名单:白名单外的 action 直接回 ack 失败,不往页面派。
42
+ //
43
+ // (2026-10-25)shot 已退役:截图改走服务端 agent-browser(internal/client/ab.go,
44
+ // POST /desk/requirements/shot-remote)。relay 只承担「远程鼠标」——
45
+ // 真实事件、CDP 级截图交给 agent-browser,页面内栅格化的路子(html2canvas)
46
+ // 有跨域污染 / iframe 抓不到 / 慢页超时三个硬伤,不再保留。
47
+ var browseInputActions = map[string]bool{
48
+ "move": true, "down": true, "up": true, "click": true, "dblclick": true,
49
+ "scroll": true, "type": true, "key": true,
50
+ }
51
+
52
+ // browseMsg 中继线上的统一信封。字段全部可选,按 type 取用。
53
+ type browseMsg struct {
54
+ Type string `json:"type"`
55
+ // input 帧
56
+ Seq int `json:"seq,omitempty"`
57
+ Action string `json:"action,omitempty"`
58
+ X float64 `json:"x,omitempty"`
59
+ Y float64 `json:"y,omitempty"`
60
+ DX float64 `json:"dx,omitempty"`
61
+ DY float64 `json:"dy,omitempty"`
62
+ Text string `json:"text,omitempty"`
63
+ Key string `json:"key,omitempty"`
64
+ Mods []string `json:"modifiers,omitempty"`
65
+ // state / hello 帧
66
+ Title string `json:"title,omitempty"`
67
+ URL string `json:"url,omitempty"`
68
+ W int `json:"w,omitempty"`
69
+ H int `json:"h,omitempty"`
70
+ // ack 帧(OK 用指针:false 不能被 omitempty 吞掉,又不想让 input 帧带 ok 字段)
71
+ OK *bool `json:"ok,omitempty"`
72
+ Reason string `json:"reason,omitempty"`
73
+ // viewer 帧
74
+ Online bool `json:"online,omitempty"`
75
+ // Data 截图帧带回的数据(当前只有 shot 用:staging 上传后的 URL)。
76
+ Data string `json:"data,omitempty"`
77
+ }
78
+
79
+ // boolPtr ack 帧的小工具。
80
+ func boolPtr(v bool) *bool { return &v }
81
+
82
+ // browsePeer 一条已升级的连接:写互斥(gorilla 的 WriteJSON 并发不安全)。
83
+ type browsePeer struct {
84
+ conn *websocket.Conn
85
+ wmu sync.Mutex
86
+ }
87
+
88
+ func (p *browsePeer) write(msg *browseMsg) error {
89
+ p.wmu.Lock()
90
+ defer p.wmu.Unlock()
91
+ return p.conn.WriteJSON(msg)
92
+ }
93
+
94
+ // browseInputHub 中继枢纽。零值不可用,用 Service.inputHub() 惰性建。
95
+ type browseInputHub struct {
96
+ mu sync.Mutex
97
+ viewer *browsePeer
98
+ drivers map[*browsePeer]bool
99
+ seq int
100
+ }
101
+
102
+ func (s *Service) inputHub() *browseInputHub {
103
+ s.inputHubOnce.Do(func() { s.inputHubRef = &browseInputHub{drivers: map[*browsePeer]bool{}} })
104
+ return s.inputHubRef
105
+ }
106
+
107
+ func (h *browseInputHub) setViewer(p *browsePeer) (old *browsePeer) {
108
+ h.mu.Lock()
109
+ defer h.mu.Unlock()
110
+ old = h.viewer
111
+ h.viewer = p
112
+ return old
113
+ }
114
+
115
+ func (h *browseInputHub) clearViewer(p *browsePeer) {
116
+ h.mu.Lock()
117
+ defer h.mu.Unlock()
118
+ if h.viewer == p {
119
+ h.viewer = nil
120
+ }
121
+ }
122
+
123
+ func (h *browseInputHub) addDriver(p *browsePeer) {
124
+ h.mu.Lock()
125
+ defer h.mu.Unlock()
126
+ h.drivers[p] = true
127
+ }
128
+
129
+ func (h *browseInputHub) removeDriver(p *browsePeer) {
130
+ h.mu.Lock()
131
+ defer h.mu.Unlock()
132
+ delete(h.drivers, p)
133
+ }
134
+
135
+ // toDrivers 把一帧广播给所有 driver(state / ack / viewer 上下线)。
136
+ func (h *browseInputHub) toDrivers(msg *browseMsg) {
137
+ h.mu.Lock()
138
+ defer h.mu.Unlock()
139
+ for d := range h.drivers {
140
+ _ = d.write(msg) // 单条失败留给读循环发现并清理
141
+ }
142
+ }
143
+
144
+ // dispatch driver 的 input 帧:重编 seq(保证全局有序且 ack 能对上),转给 viewer。
145
+ func (h *browseInputHub) dispatch(action string, in *browseMsg) *browseMsg {
146
+ if !browseInputActions[action] {
147
+ return &browseMsg{Type: "ack", OK: boolPtr(false), Reason: "未知动作:" + action}
148
+ }
149
+ h.mu.Lock()
150
+ defer h.mu.Unlock()
151
+ if h.viewer == nil {
152
+ return &browseMsg{Type: "ack", OK: boolPtr(false), Reason: "页面未连接(viewer 离线)"}
153
+ }
154
+ h.seq++
155
+ in.Seq = h.seq
156
+ if err := h.viewer.write(in); err != nil {
157
+ return &browseMsg{Type: "ack", OK: boolPtr(false), Reason: "派发失败:" + err.Error()}
158
+ }
159
+ return nil // ack 由 viewer 回
160
+ }
161
+
162
+ func (s *Service) handleBrowseInput(w http.ResponseWriter, r *http.Request) {
163
+ // 鉴权:与 /b/、/proxy-ws 同口径 —— 查询令牌或 cookie。
164
+ if want := strings.TrimSpace(s.opts.Token); want != "" {
165
+ got := strings.TrimSpace(r.URL.Query().Get("token"))
166
+ if got == "" {
167
+ if c, err := r.Cookie(browseCookie); err == nil {
168
+ got = c.Value
169
+ }
170
+ }
171
+ if got != want {
172
+ http.Error(w, "缺少或错误的访问令牌", http.StatusUnauthorized)
173
+ return
174
+ }
175
+ }
176
+
177
+ role := r.URL.Query().Get("role")
178
+ if role != "viewer" && role != "driver" {
179
+ role = "driver"
180
+ }
181
+
182
+ c, err := wsUpgrader.Upgrade(w, r, nil)
183
+ if err != nil {
184
+ return // Upgrade 已写过响应
185
+ }
186
+ peer := &browsePeer{conn: c}
187
+ hub := s.inputHub()
188
+
189
+ defer func() {
190
+ _ = c.WriteControl(websocket.CloseMessage,
191
+ websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""),
192
+ time.Now().Add(time.Second))
193
+ _ = c.Close()
194
+ }()
195
+
196
+ switch role {
197
+ case "viewer":
198
+ old := hub.setViewer(peer)
199
+ if old != nil {
200
+ _ = old.conn.Close() // 后连顶前连:旧 viewer 的读循环随即退出
201
+ }
202
+ hub.toDrivers(&browseMsg{Type: "viewer", Online: true})
203
+ defer func() {
204
+ hub.clearViewer(peer)
205
+ hub.toDrivers(&browseMsg{Type: "viewer", Online: false})
206
+ }()
207
+ s.browseViewerLoop(hub, peer)
208
+ default: // driver
209
+ hub.addDriver(peer)
210
+ defer hub.removeDriver(peer)
211
+ // 新 driver 上线:把当前 viewer 在线状态与最后一次页面状态同步给它。
212
+ hub.mu.Lock()
213
+ online := hub.viewer != nil
214
+ hub.mu.Unlock()
215
+ _ = peer.write(&browseMsg{Type: "viewer", Online: online})
216
+ s.browseDriverLoop(hub, peer)
217
+ }
218
+ }
219
+
220
+ // browseViewerLoop 读 viewer 的帧:hello/state 转给 drivers,其余忽略。
221
+ func (s *Service) browseViewerLoop(hub *browseInputHub, peer *browsePeer) {
222
+ s.log.Printf("browse-input: viewer 已连接 (%s)", peer.conn.RemoteAddr())
223
+ defer s.log.Printf("browse-input: viewer 已断开")
224
+ for {
225
+ var m browseMsg
226
+ if err := peer.conn.ReadJSON(&m); err != nil {
227
+ return
228
+ }
229
+ switch m.Type {
230
+ case "hello", "state", "ack":
231
+ // hello/state 广播给所有 driver;ack 按 seq 归属,典型只有一个 driver,
232
+ // 这里同样广播,由 driver 自己按 seq 认领。
233
+ hub.toDrivers(&m)
234
+ default:
235
+ // ping 等 server 主动发;viewer 发来的其它类型不认识就不转发。
236
+ }
237
+ }
238
+ }
239
+
240
+ // browseDriverLoop 读 driver 的帧:只认 input,派发给 viewer;其余回 ack 失败。
241
+ func (s *Service) browseDriverLoop(hub *browseInputHub, peer *browsePeer) {
242
+ for {
243
+ var m browseMsg
244
+ if err := peer.conn.ReadJSON(&m); err != nil {
245
+ return
246
+ }
247
+ switch m.Type {
248
+ case "input":
249
+ if ack := hub.dispatch(m.Action, &m); ack != nil {
250
+ _ = peer.write(ack)
251
+ }
252
+ case "ping":
253
+ // 让 viewer 报一次当前状态(CLI 的 `browse state` 靠它)。
254
+ hub.mu.Lock()
255
+ v := hub.viewer
256
+ hub.mu.Unlock()
257
+ if v != nil {
258
+ _ = v.write(&browseMsg{Type: "ping"})
259
+ } else {
260
+ _ = peer.write(&browseMsg{Type: "ack", OK: boolPtr(false), Reason: "页面未连接(viewer 离线)"})
261
+ }
262
+ default:
263
+ _ = peer.write(&browseMsg{Type: "ack", OK: boolPtr(false), Reason: "driver 只能发 input/ping 帧"})
264
+ }
265
+ }
266
+ }
267
+
268
+ /* browseRelayJS 注入到中转页面里的 relay 脚本。
269
+ *
270
+ * 职责:
271
+ * - 连回 /browse-input?role=viewer(cookie 鉴权由浏览器自动带上;断线重连)
272
+ * - 收 input 帧在被代理页面里派发合成事件(页面自身 CSS 像素坐标)
273
+ * - 叠加一只虚拟鼠标:对称导航箭头(绿色),跟随 driver 的移动/点击,点击有双涟漪反馈
274
+ * - 页面加载完成 / 标题变化时上报 state;每条 input 回 ack
275
+ *
276
+ * 刻意不做的:不拦截页面自身事件、不做 DOM 摘要 —— 只当一只「远程鼠标」。
277
+ */
278
+ const browseRelayJS = `(function () {
279
+ 'use strict';
280
+ var SENTINEL = '__magicBrowseRelay';
281
+ if (window[SENTINEL]) return; // 防 SPA 反复执行
282
+ window[SENTINEL] = true;
283
+
284
+ var ws = null, seqSeen = 0, titleTimer = null;
285
+ var curEl = null, curSvg = null, curDot = null, curPressed = false, curX = -100, curY = -100;
286
+
287
+ // ---- 虚拟鼠标:对称导航箭头(绿填充+白描边),热点=顶部尖端;按压点+点击双涟漪。
288
+ // pointer-events:none,不干扰页面事件。 ----
289
+ function ensureCursor() {
290
+ if (curEl) return;
291
+ var doc = document;
292
+ curEl = doc.createElement('div');
293
+ curEl.id = '__magic_vmouse';
294
+ curEl.setAttribute('style',
295
+ 'position:fixed;left:0;top:0;z-index:2147483647;pointer-events:none;will-change:transform;');
296
+ curEl.innerHTML =
297
+ '<svg width="50" height="50" viewBox="0 0 32 32" style="display:block;transition:transform .1s;' +
298
+ 'filter:drop-shadow(0 2px 6px rgba(46,160,100,.5))">' +
299
+ '<path d="M16 1.5 L30 28.5 L16 21.8 L2 28.5 Z" ' +
300
+ 'fill="#2fbf71" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/></svg>';
301
+ curSvg = curEl.firstChild;
302
+ curDot = doc.createElement('div');
303
+ curDot.setAttribute('style',
304
+ 'position:fixed;left:0;top:0;z-index:2147483647;pointer-events:none;width:16px;height:16px;margin:-8px;' +
305
+ 'border-radius:50%;background:rgba(47,191,113,.4);border:3px solid #fff;' +
306
+ 'box-shadow:0 0 10px rgba(47,191,113,.9);transition:transform .1s;');
307
+ doc.body.appendChild(curEl);
308
+ doc.body.appendChild(curDot);
309
+ curEl.style.transform = 'translate(' + (curX - 25) + 'px,' + (curY - 2.3) + 'px)';
310
+ curDot.style.transform = 'translate(' + curX + 'px,' + curY + 'px) scale(0)';
311
+ }
312
+
313
+ function moveCursor(x, y) {
314
+ if (typeof x !== 'number' || typeof y !== 'number') return;
315
+ curX = x; curY = y;
316
+ ensureCursor();
317
+ // viewBox 0 0 32 32 → 50px:尖端在 (25, 2.3),把它对齐到坐标点
318
+ curEl.style.transform = 'translate(' + (x - 25) + 'px,' + (y - 2.3) + 'px)';
319
+ curDot.style.transform = 'translate(' + x + 'px,' + y + 'px) scale(' + (curPressed ? 1 : 0) + ')';
320
+ }
321
+
322
+ function pressCursor(down) {
323
+ curPressed = down;
324
+ if (curSvg) curSvg.style.transform = down ? 'scale(.9)' : 'scale(1)';
325
+ if (curDot) curDot.style.transform =
326
+ 'translate(' + curX + 'px,' + curY + 'px) scale(' + (down ? 1 : 0) + ')';
327
+ }
328
+
329
+ function rippleCursor(x, y) {
330
+ ensureCursor();
331
+ var mk = function (scale, opacity) {
332
+ var d = document.createElement('div');
333
+ var size = 60;
334
+ d.setAttribute('style',
335
+ 'position:fixed;left:' + (x - size / 2) + 'px;top:' + (y - size / 2) + 'px;width:' + size + 'px;height:' + size + 'px;' +
336
+ 'margin:0;border-radius:50%;border:3px solid rgba(47,191,113,.85);pointer-events:none;z-index:2147483646;' +
337
+ 'box-shadow:0 0 14px rgba(47,191,113,.45);transform:scale(' + scale + ');opacity:' + opacity + ';' +
338
+ 'transition:transform .45s ease-out,opacity .45s ease-out;');
339
+ document.body.appendChild(d);
340
+ requestAnimationFrame(function () {
341
+ d.style.transform = 'scale(' + (scale + 0.35) + ')';
342
+ d.style.opacity = '0';
343
+ });
344
+ setTimeout(function () { if (d.parentNode) d.parentNode.removeChild(d); }, 520);
345
+ };
346
+ mk(0.45, 0.95); // 快环
347
+ mk(0.85, 0.5); // 慢环
348
+ }
349
+
350
+ function pageState(type) {
351
+ return { type: type || 'state', title: document.title || '',
352
+ url: location.href, w: window.innerWidth, h: window.innerHeight };
353
+ }
354
+
355
+ function send(m) {
356
+ try { if (ws && ws.readyState === 1) ws.send(JSON.stringify(m)); } catch (e) {}
357
+ }
358
+
359
+ function fireMouse(action, m) {
360
+ var x = m.x || 0, y = m.y || 0;
361
+ var opts = { bubbles: true, cancelable: true, view: window,
362
+ clientX: x, clientY: y, button: 0, buttons: 0 };
363
+ var tgt = document.elementFromPoint(x, y) || document.body;
364
+ if (!tgt) return;
365
+ function ev(Type, extra) {
366
+ var o = {}; for (var k in opts) o[k] = opts[k];
367
+ if (extra) for (var k2 in extra) o[k2] = extra[k2];
368
+ return typeof PointerEvent === 'function' ? new PointerEvent(Type, o) : new MouseEvent(Type, o);
369
+ }
370
+ if (action === 'move') { tgt.dispatchEvent(ev('pointermove', { pointerType: 'mouse' })); tgt.dispatchEvent(new MouseEvent('mousemove', opts)); return; }
371
+ if (action === 'scroll') {
372
+ tgt.dispatchEvent(new WheelEvent('wheel', { bubbles: true, cancelable: true, view: window,
373
+ clientX: x, clientY: y, deltaX: m.dx || 0, deltaY: m.dy || 0 }));
374
+ window.scrollBy(m.dx || 0, m.dy || 0);
375
+ return;
376
+ }
377
+ var down = (action === 'down' || action === 'click' || action === 'dblclick');
378
+ var up = (action === 'up' || action === 'click' || action === 'dblclick');
379
+ if (down) { tgt.dispatchEvent(ev('pointerdown', { pointerType: 'mouse', isPrimary: true, buttons: 1 })); tgt.dispatchEvent(new MouseEvent('mousedown', Object.assign({}, opts, { buttons: 1 }))); }
380
+ if (up) {
381
+ tgt.dispatchEvent(ev('pointerup', { pointerType: 'mouse', isPrimary: true }));
382
+ tgt.dispatchEvent(new MouseEvent('mouseup', opts));
383
+ tgt.dispatchEvent(new MouseEvent('click', opts));
384
+ if (action === 'dblclick') tgt.dispatchEvent(new MouseEvent('dblclick', opts));
385
+ }
386
+ }
387
+
388
+ function fireKey(m) {
389
+ var tgt = document.activeElement || document.body;
390
+ var base = { bubbles: true, cancelable: true, view: window, key: m.key || '', code: m.key || '' };
391
+ tgt.dispatchEvent(new KeyboardEvent('keydown', base));
392
+ tgt.dispatchEvent(new KeyboardEvent('keyup', base));
393
+ }
394
+
395
+ function fireType(m) {
396
+ var x = m.x, y = m.y, tgt = null;
397
+ if (typeof x === 'number' && typeof y === 'number') tgt = document.elementFromPoint(x, y);
398
+ tgt = tgt || document.activeElement || document.body;
399
+ if (tgt.focus) { try { tgt.focus(); } catch (e) {} }
400
+ var text = m.text || '';
401
+ if (tgt && (tgt.tagName === 'INPUT' || tgt.tagName === 'TEXTAREA' || tgt.isContentEditable)) {
402
+ var v = tgt.value !== undefined ? tgt.value : tgt.textContent;
403
+ var merged = (v || '') + text;
404
+ if (tgt.value !== undefined) { tgt.value = merged; } else { tgt.textContent = merged; }
405
+ tgt.dispatchEvent(new Event('input', { bubbles: true }));
406
+ tgt.dispatchEvent(new Event('change', { bubbles: true }));
407
+ return;
408
+ }
409
+ fireKey({ key: text });
410
+ }
411
+
412
+ function handle(m) {
413
+ if (!m || m.type !== 'input' || !m.action) return;
414
+ try {
415
+ switch (m.action) {
416
+ case 'move': case 'down': case 'up': case 'click': case 'dblclick': case 'scroll': case 'type':
417
+ if (typeof m.x === 'number' && typeof m.y === 'number') {
418
+ moveCursor(m.x, m.y);
419
+ if (m.action === 'down') pressCursor(true);
420
+ if (m.action === 'up') pressCursor(false);
421
+ if (m.action === 'click' || m.action === 'dblclick') rippleCursor(m.x, m.y);
422
+ }
423
+ break;
424
+ default: break;
425
+ }
426
+ switch (m.action) {
427
+ case 'move': case 'down': case 'up': case 'click': case 'dblclick': case 'scroll':
428
+ fireMouse(m.action, m); break;
429
+ case 'type': fireType(m); break;
430
+ case 'key': fireKey(m); break;
431
+ default: send({ type: 'ack', seq: m.seq, ok: false, reason: '未知动作' }); return;
432
+ }
433
+ seqSeen = m.seq;
434
+ send({ type: 'ack', seq: m.seq, ok: true });
435
+ } catch (e) {
436
+ send({ type: 'ack', seq: m.seq, ok: false, reason: String(e && e.message || e) });
437
+ }
438
+ }
439
+
440
+ /* 连接用的令牌:中转 URL 上的 magicToken 就是访问令牌(browse.go 的鉴权口径)。
441
+ APK 壳的 iframe 与桌面端**跨站**,SameSite=Lax 的 cookie 不随 WebSocket
442
+ 握手发送 —— 只靠 cookie 会连不上 viewer。 */
443
+ function pageToken(fallback) {
444
+ try {
445
+ var q = new URLSearchParams(location.search);
446
+ var t = q.get('magicToken');
447
+ if (t) return t;
448
+ } catch (e) {}
449
+ return fallback || '';
450
+ }
451
+
452
+ function connect() {
453
+ var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
454
+ /* 令牌:优先取页面地址里的 magicToken(每条改写出的 URL 都带它)。
455
+ 没有就退回 cookie(同源场景能过)—— 但 APK 壳的跨站 iframe 两者都可能没有,
456
+ 那时连不上 viewer:如实表现为「页面未连接」,不假装能驱动。 */
457
+ var wsUrl = proto + '//' + location.host + '/browse-input?role=viewer';
458
+ var tok = pageToken();
459
+ if (tok) wsUrl += '&token=' + encodeURIComponent(tok);
460
+ try { ws = new WebSocket(wsUrl); }
461
+ catch (e) { setTimeout(connect, 4000); return; }
462
+ ws.onopen = function () { send(pageState('hello')); };
463
+ ws.onmessage = function (e) {
464
+ var m; try { m = JSON.parse(e.data); } catch (err) { return; }
465
+ if (m.type === 'ping') { send(pageState('state')); return; }
466
+ handle(m);
467
+ };
468
+ ws.onclose = function () { setTimeout(connect, 2000); };
469
+ ws.onerror = function () { try { ws.close(); } catch (e2) {} };
470
+ }
471
+
472
+ connect();
473
+ // 标题/地址变化探测:轻量轮询足够(SPA 改标题不派事件)。
474
+ var lastTitle = document.title, lastHref = location.href;
475
+ titleTimer = setInterval(function () {
476
+ if (document.title !== lastTitle || location.href !== lastHref) {
477
+ lastTitle = document.title; lastHref = location.href;
478
+ send(pageState('state'));
479
+ }
480
+ }, 1500);
481
+ })();`