magic-agent 2.4.3 → 2.6.3
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/CLIENT-CONTRACT.md +467 -41
- package/README.md +15 -14
- package/client-ui/pc/app.css +74 -0
- package/client-ui/pc/js/04-task-list.js +5 -1
- package/client-ui/pc/js/07-chat-stream-render.js +3 -3
- package/client-ui/pc/js/90-glue.js +40 -2
- package/client-ui/pc/js/92-composer.js +19 -4
- package/client-ui/pc/js/93-rail.js +16 -5
- package/client-ui/shared/conv/02-media-kinds.js +2 -2
- package/client-ui/shared/conv/03-chat-message.js +247 -23
- package/client-ui/shared/conv/06-ask-answer.js +75 -1
- package/client-ui/shared/conv/09-stream-apply.js +72 -7
- package/client-ui/shared/conv/19-attachments.js +157 -6
- package/client-ui/shared/conv/27-conv-ledger.js +5 -2
- package/client-ui/shared/conv/29-msg-variants.js +217 -0
- package/client-ui/shared/conv/conv.css +329 -174
- package/client-ui/shared/conv/dom-map.md +1 -0
- package/client-ui/shared/conversation.css +13 -2
- package/client-ui/shared/conversation.js +373 -50
- package/client-ui/shared/transport-http.js +14 -0
- package/client-ui-mobile/apps.css +16 -0
- package/client-ui-mobile/apps.js +406 -0
- package/client-ui-mobile/index.html +5554 -639
- package/client-ui-mobile/reqs.css +110 -1
- package/client-ui-mobile/reqs.js +533 -74
- package/client-ui-mobile/reqshot.js +505 -0
- package/client-ui-mobile/scripts.css +67 -0
- package/client-ui-mobile/scripts.js +599 -0
- package/client-ui-mobile/vendor/cose-base.js +3214 -0
- package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
- package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
- package/client-ui-mobile/vendor/layout-base.js +5230 -0
- package/cmd/magic-client/browsectl.go +199 -0
- package/cmd/magic-client/clientctl.go +69 -10
- package/cmd/magic-client/main.go +210 -32
- package/internal/agent/ask.go +45 -0
- package/internal/agent/ask_test.go +39 -0
- package/internal/agent/attachments_test.go +6 -2
- package/internal/agent/capability.go +7 -1
- package/internal/agent/claude.go +20 -15
- package/internal/agent/codebuddy.go +125 -34
- package/internal/agent/codebuddy_account_test.go +54 -0
- package/internal/agent/codebuddy_stream_test.go +103 -0
- package/internal/agent/credits.go +713 -0
- package/internal/agent/credits_minimax_test.go +97 -0
- package/internal/agent/credits_test.go +211 -0
- package/internal/agent/custom_models.go +155 -0
- package/internal/agent/custom_models_test.go +125 -0
- package/internal/agent/dsh.go +123 -49
- package/internal/agent/dsh_caps_cache_test.go +184 -0
- package/internal/agent/dsh_resume.go +157 -7
- package/internal/agent/dsh_test.go +108 -6
- package/internal/agent/engine.go +37 -1
- package/internal/agent/engine_base.go +21 -17
- package/internal/agent/engine_base_test.go +2 -2
- package/internal/agent/engine_test.go +47 -86
- package/internal/agent/env.go +173 -1
- package/internal/agent/env_test.go +114 -0
- package/internal/agent/fallback.go +313 -0
- package/internal/agent/fallback_test.go +340 -0
- package/internal/agent/launch.go +43 -1
- package/internal/agent/limitnotice.go +207 -1
- package/internal/agent/limitnotice_test.go +140 -0
- package/internal/agent/llmhttp.go +46 -4
- package/internal/agent/llmhttp_test.go +157 -0
- package/internal/agent/models.go +53 -1
- package/internal/agent/models_test.go +20 -5
- package/internal/agent/pidalive_windows_test.go +40 -0
- package/internal/agent/runcmd.go +26 -0
- package/internal/agent/runcmd_windows.go +36 -5
- package/internal/agent/stream.go +166 -6
- package/internal/agent/stream_test.go +25 -38
- package/internal/agent/stream_timeline_test.go +2 -37
- package/internal/agent/streamjson.go +119 -7
- package/internal/agent/tools_suffix_test.go +6 -3
- package/internal/agent/trae.go +50 -243
- package/internal/agent/trae_exec.go +433 -0
- package/internal/agent/trae_exec_test.go +181 -0
- package/internal/agent/wraplaunch_test.go +113 -0
- package/internal/cli/ask.go +168 -37
- package/internal/cli/ask_test.go +1 -1
- package/internal/cli/checkin.go +77 -0
- package/internal/cli/clientcmd.go +3 -2
- package/internal/cli/control.go +46 -5
- package/internal/cli/control_test.go +65 -0
- package/internal/cli/engines_test.go +103 -0
- package/internal/cli/events.go +15 -0
- package/internal/cli/events_test.go +50 -0
- package/internal/cli/judge.go +104 -0
- package/internal/cli/quotas.go +125 -0
- package/internal/cli/requirements.go +51 -0
- package/internal/cli/root.go +157 -11
- package/internal/cli/session.go +75 -3
- package/internal/cli/session_test.go +82 -3
- package/internal/cli/sessionlog.go +6 -0
- package/internal/cli/sessionlog_test.go +22 -2
- package/internal/cli/suggest.go +7 -5
- package/internal/cli/team.go +37 -9
- package/internal/cli/team_test.go +39 -0
- package/internal/client/ab.go +372 -0
- package/internal/client/ab_test.go +287 -0
- package/internal/client/apk.go +8 -0
- package/internal/client/apk_test.go +59 -0
- package/internal/client/apps.go +1078 -0
- package/internal/client/apps_test.go +516 -0
- package/internal/client/browse.go +84 -36
- package/internal/client/browse_test.go +107 -9
- package/internal/client/browseinput.go +481 -0
- package/internal/client/browseinput_test.go +216 -0
- package/internal/client/corecmd.go +23 -0
- package/internal/client/corecmd_other.go +8 -0
- package/internal/client/corecmd_windows.go +24 -0
- package/internal/client/coreexe.go +196 -0
- package/internal/client/coreexe_test.go +161 -0
- package/internal/client/coreupgrade.go +866 -0
- package/internal/client/coreupgrade_test.go +561 -0
- package/internal/client/daemon.go +297 -33
- package/internal/client/daemon_test.go +33 -0
- package/internal/client/engines_checkin_test.go +57 -0
- package/internal/client/fallback_flags_test.go +64 -0
- package/internal/client/feishu.go +1259 -0
- package/internal/client/feishu_test.go +649 -0
- package/internal/client/health_identity_test.go +84 -0
- package/internal/client/hostenv.go +68 -0
- package/internal/client/hostenv_test.go +37 -0
- package/internal/client/jobs.go +518 -24
- package/internal/client/jobs_auto.go +571 -0
- package/internal/client/jobs_auto_test.go +431 -0
- package/internal/client/jobs_test.go +299 -2
- package/internal/client/knowledge.go +854 -0
- package/internal/client/knowledge_test.go +510 -0
- package/internal/client/lan.go +3 -3
- package/internal/client/plugins.go +5 -1
- package/internal/client/prefs.go +67 -0
- package/internal/client/procstart_linux.go +59 -0
- package/internal/client/publicurl.go +82 -0
- package/internal/client/quotas_merge_test.go +107 -0
- package/internal/client/requirements.go +207 -2
- package/internal/client/requirements_shots_test.go +200 -0
- package/internal/client/requirements_test.go +103 -0
- package/internal/client/restart.go +288 -0
- package/internal/client/restart_handoff_test.go +230 -0
- package/internal/client/restart_test.go +290 -0
- package/internal/client/scripts.go +1047 -0
- package/internal/client/scripts_decode_implausible.go +38 -0
- package/internal/client/scripts_decode_other.go +31 -0
- package/internal/client/scripts_decode_test.go +130 -0
- package/internal/client/scripts_decode_windows.go +182 -0
- package/internal/client/scripts_test.go +739 -0
- package/internal/client/service.go +717 -97
- package/internal/client/service_test.go +217 -1
- package/internal/client/sessions_cache.go +148 -0
- package/internal/client/sessions_cache_test.go +163 -0
- package/internal/client/sessions_fold.go +165 -0
- package/internal/client/sessions_fold_test.go +204 -0
- package/internal/client/singleinstance.go +470 -0
- package/internal/client/singleinstance_test.go +607 -0
- package/internal/client/suggest.go +105 -2
- package/internal/client/suggest_test.go +86 -0
- package/internal/client/team_flags.go +25 -11
- package/internal/client/team_flags_test.go +39 -0
- package/internal/client/testmain_test.go +88 -0
- package/internal/client/workspace_default_test.go +158 -0
- package/internal/client/workspacelockenv_test.go +54 -0
- package/internal/config/config.go +48 -0
- package/internal/judge/judge.go +315 -0
- package/internal/requirements/bootstrap.go +16 -0
- package/internal/requirements/consolidate.go +100 -9
- package/internal/requirements/doc.go +154 -3
- package/internal/requirements/graph.go +138 -0
- package/internal/requirements/memory.go +7 -0
- package/internal/requirements/modules.go +312 -0
- package/internal/requirements/modules_test.go +373 -0
- package/internal/requirements/relations.go +155 -0
- package/internal/requirements/relations_test.go +231 -0
- package/internal/requirements/requirements.go +10 -0
- package/internal/requirements/shots.go +418 -0
- package/internal/requirements/shots_test.go +387 -0
- package/internal/session/multiclient_test.go +368 -0
- package/internal/session/session.go +175 -11
- package/internal/session/session_test.go +7 -0
- package/internal/session/status.go +21 -0
- package/internal/session/status_test.go +28 -0
- package/internal/session/title.go +16 -0
- package/internal/session/title_test.go +26 -0
- package/internal/session/workspace_lock.go +265 -28
- package/internal/suggest/suggest.go +23 -1
- package/internal/team/event.go +3 -1
- package/internal/team/execute.go +21 -6
- package/internal/team/fallback_route_test.go +80 -0
- package/internal/team/planner.go +15 -3
- package/internal/team/route.go +89 -17
- package/internal/team/team.go +18 -4
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +6 -3
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/npm/install.js +11 -0
- package/package.json +14 -3
|
@@ -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
|
+
})();`
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
package client
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"io"
|
|
6
|
+
"log"
|
|
7
|
+
"net/http"
|
|
8
|
+
"net/http/httptest"
|
|
9
|
+
"net/url"
|
|
10
|
+
"strings"
|
|
11
|
+
"testing"
|
|
12
|
+
"time"
|
|
13
|
+
|
|
14
|
+
"github.com/gorilla/websocket"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
// TestRelayJSServed /b/relay.js 是我们自己的资源,必须原样可取(不能当主机名去抓)。
|
|
18
|
+
func TestRelayJSServed(t *testing.T) {
|
|
19
|
+
s := &Service{}
|
|
20
|
+
proxy := httptest.NewServer(http.HandlerFunc(s.handleBrowse))
|
|
21
|
+
defer proxy.Close()
|
|
22
|
+
|
|
23
|
+
resp, err := http.Get(proxy.URL + "/b/relay.js")
|
|
24
|
+
if err != nil {
|
|
25
|
+
t.Fatal(err)
|
|
26
|
+
}
|
|
27
|
+
defer resp.Body.Close()
|
|
28
|
+
if resp.StatusCode != http.StatusOK {
|
|
29
|
+
t.Fatalf("relay.js 应 200,实际 %d", resp.StatusCode)
|
|
30
|
+
}
|
|
31
|
+
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/javascript") {
|
|
32
|
+
t.Errorf("relay.js Content-Type 应为 text/javascript,实际 %q", ct)
|
|
33
|
+
}
|
|
34
|
+
body, _ := io.ReadAll(resp.Body)
|
|
35
|
+
if !strings.Contains(string(body), "browse-input") {
|
|
36
|
+
t.Error("relay.js 应包含 /browse-input 连接逻辑")
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// TestRelayInjected 改写后的 HTML 应注入 relay 脚本(与 <base> 同位置)。
|
|
41
|
+
func TestRelayInjected(t *testing.T) {
|
|
42
|
+
page := mustURL(t, "https://example.com/a")
|
|
43
|
+
out := string(rewriteBrowseHTML([]byte(`<html><head><title>x</title></head><body></body></html>`),
|
|
44
|
+
page, testProxyBase, ""))
|
|
45
|
+
if !strings.Contains(out, `<script src="http://p/b/relay.js" defer></script>`) {
|
|
46
|
+
t.Errorf("应注入 relay 脚本,实际:%s", out)
|
|
47
|
+
}
|
|
48
|
+
if !strings.Contains(out, `<base href="http://p/b/https/example.com/a">`) {
|
|
49
|
+
t.Errorf("<base> 注入不应被破坏,实际:%s", out)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
func dialWS(t *testing.T, urlStr string) *websocket.Conn {
|
|
54
|
+
t.Helper()
|
|
55
|
+
u, err := url.Parse(urlStr)
|
|
56
|
+
if err != nil {
|
|
57
|
+
t.Fatal(err)
|
|
58
|
+
}
|
|
59
|
+
u.Scheme = "ws"
|
|
60
|
+
c, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
|
|
61
|
+
if err != nil {
|
|
62
|
+
t.Fatalf("连接 %s 失败:%v", u, err)
|
|
63
|
+
}
|
|
64
|
+
t.Cleanup(func() { _ = c.Close() })
|
|
65
|
+
return c
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
func readMsg(t *testing.T, c *websocket.Conn) browseMsg {
|
|
69
|
+
t.Helper()
|
|
70
|
+
_ = c.SetReadDeadline(time.Now().Add(5 * time.Second))
|
|
71
|
+
var m browseMsg
|
|
72
|
+
if err := c.ReadJSON(&m); err != nil {
|
|
73
|
+
t.Fatalf("读帧失败:%v", err)
|
|
74
|
+
}
|
|
75
|
+
return m
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// TestBrowseInputAuth 有令牌的服务:握手不带令牌必须被拒。
|
|
79
|
+
func TestBrowseInputAuth(t *testing.T) {
|
|
80
|
+
s := &Service{opts: Options{Token: "sekret"}}
|
|
81
|
+
proxy := httptest.NewServer(http.HandlerFunc(s.handleBrowseInput))
|
|
82
|
+
defer proxy.Close()
|
|
83
|
+
|
|
84
|
+
u, _ := url.Parse(proxy.URL)
|
|
85
|
+
u.Scheme = "ws"
|
|
86
|
+
_, resp, err := websocket.DefaultDialer.Dial(u.String(), nil)
|
|
87
|
+
if err == nil {
|
|
88
|
+
t.Fatal("缺令牌的握手应失败")
|
|
89
|
+
}
|
|
90
|
+
if resp == nil || resp.StatusCode != http.StatusUnauthorized {
|
|
91
|
+
t.Errorf("缺令牌应 401,实际:%v", err)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// TestBrowseInputDispatch 端到端:driver 发 input → viewer 收到(seq 由 server 重编),
|
|
96
|
+
// viewer 回 ack → driver 收到。也验证无 viewer 时 ack 失败、未知动作被拦。
|
|
97
|
+
func TestBrowseInputDispatch(t *testing.T) {
|
|
98
|
+
// log 必须给:viewer 循环会打连接日志,nil logger 会 panic(被 net/http recover
|
|
99
|
+
// 吃掉后 defer 又把 viewer 清掉,表象是「viewer 永远离线」)。
|
|
100
|
+
s := &Service{log: log.New(io.Discard, "", 0)}
|
|
101
|
+
proxy := httptest.NewServer(http.HandlerFunc(s.handleBrowseInput))
|
|
102
|
+
defer proxy.Close()
|
|
103
|
+
|
|
104
|
+
viewer := dialWS(t, proxy.URL+"/browse-input?role=viewer")
|
|
105
|
+
time.Sleep(100 * time.Millisecond) // 等服务端把 viewer 登记进 hub(dial 返回与 setViewer 有竞态)
|
|
106
|
+
driver := dialWS(t, proxy.URL+"/browse-input?role=driver")
|
|
107
|
+
|
|
108
|
+
// 新 driver 上线会先收到 viewer 状态帧(此时 viewer 已连 → online)。
|
|
109
|
+
if m := readMsg(t, driver); m.Type != "viewer" || !m.Online {
|
|
110
|
+
t.Fatalf("driver 首帧应是 viewer 上线通知,实际:%+v", m)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 未知动作:直接 ack 失败,不往页面派。
|
|
114
|
+
_ = driver.WriteJSON(browseMsg{Type: "input", Action: "hack"})
|
|
115
|
+
if m := readMsg(t, driver); m.Type != "ack" || m.OK == nil || *m.OK {
|
|
116
|
+
t.Fatalf("未知动作应 ack 失败,实际:%+v", m)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 正常 click:viewer 收到重编过 seq 的 input,回 ack,driver 收到。
|
|
120
|
+
_ = driver.WriteJSON(browseMsg{Type: "input", Action: "click", X: 10, Y: 20})
|
|
121
|
+
in := readMsg(t, viewer)
|
|
122
|
+
if in.Type != "input" || in.Action != "click" || in.X != 10 || in.Y != 20 || in.Seq == 0 {
|
|
123
|
+
t.Fatalf("viewer 应收到 input 帧,实际:%+v", in)
|
|
124
|
+
}
|
|
125
|
+
_ = viewer.WriteJSON(browseMsg{Type: "ack", Seq: in.Seq, OK: boolPtr(true)})
|
|
126
|
+
if m := readMsg(t, driver); m.Type != "ack" || m.OK == nil || !*m.OK || m.Seq != in.Seq {
|
|
127
|
+
t.Fatalf("driver 应收到 ack,实际:%+v", m)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// viewer 上报 state → driver 收到。
|
|
131
|
+
_ = viewer.WriteJSON(browseMsg{Type: "state", Title: "T", W: 390, H: 844})
|
|
132
|
+
if m := readMsg(t, driver); m.Type != "state" || m.Title != "T" || m.W != 390 {
|
|
133
|
+
t.Fatalf("driver 应收到 state,实际:%+v", m)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// viewer 掉线后再派发:ack 失败。
|
|
137
|
+
_ = viewer.Close()
|
|
138
|
+
time.Sleep(100 * time.Millisecond) // 给服务端读循环时间清理 viewer
|
|
139
|
+
// driver 会先收到 viewer 下线通知帧。
|
|
140
|
+
if m := readMsg(t, driver); m.Type != "viewer" || m.Online {
|
|
141
|
+
t.Fatalf("应先收到 viewer 下线通知,实际:%+v", m)
|
|
142
|
+
}
|
|
143
|
+
_ = driver.WriteJSON(browseMsg{Type: "input", Action: "click"})
|
|
144
|
+
if m := readMsg(t, driver); m.Type != "ack" || m.OK == nil || *m.OK {
|
|
145
|
+
t.Fatalf("viewer 离线后派发应 ack 失败,实际:%+v", m)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// TestBrowseRelayJSON 形状自检:ack/OK 等字段按 omitempty 序列化,不能互相污染。
|
|
150
|
+
func TestBrowseRelayJSON(t *testing.T) {
|
|
151
|
+
b, err := json.Marshal(browseMsg{Type: "input", Seq: 3, Action: "click", X: 1.5, Y: 2})
|
|
152
|
+
if err != nil {
|
|
153
|
+
t.Fatal(err)
|
|
154
|
+
}
|
|
155
|
+
var m map[string]any
|
|
156
|
+
if err := json.Unmarshal(b, &m); err != nil {
|
|
157
|
+
t.Fatal(err)
|
|
158
|
+
}
|
|
159
|
+
if m["type"] != "input" || m["action"] != "click" {
|
|
160
|
+
t.Errorf("序列化形状不对:%s", b)
|
|
161
|
+
}
|
|
162
|
+
if _, ok := m["ok"]; ok {
|
|
163
|
+
t.Errorf("ack 专用的 ok 字段不应出现在 input 帧里:%s", b)
|
|
164
|
+
}
|
|
165
|
+
if b2, _ := json.Marshal(browseMsg{Type: "ack", OK: boolPtr(false), Reason: "x"}); !strings.Contains(string(b2), `"ok":false`) {
|
|
166
|
+
t.Errorf("ack 的 ok=false 不能被 omitempty 吞掉:%s", b2)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// TestRelayJSHasNoShotBranch relay 不再承担截图(2026-10-25 改基 agent-browser):
|
|
171
|
+
// 页面内栅格化的代码必须删干净,不然页面里还会去加载已经不存在的 /b/shotlib.js。
|
|
172
|
+
func TestRelayJSHasNoShotBranch(t *testing.T) {
|
|
173
|
+
for _, banned := range []string{
|
|
174
|
+
"doShot", "/b/shotlib.js", "/desk/upload", "html2canvas",
|
|
175
|
+
} {
|
|
176
|
+
if strings.Contains(browseRelayJS, banned) {
|
|
177
|
+
t.Errorf("relay 脚本不应再含 %q —— 页面内截图已退役", banned)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// TestShotLibGone /b/shotlib.js 已随页面内截图退役,必须 404(不能吐空文件假装成功)。
|
|
183
|
+
func TestShotLibGone(t *testing.T) {
|
|
184
|
+
s := &Service{}
|
|
185
|
+
proxy := httptest.NewServer(http.HandlerFunc(s.handleBrowse))
|
|
186
|
+
defer proxy.Close()
|
|
187
|
+
|
|
188
|
+
resp, err := http.Get(proxy.URL + "/b/shotlib.js")
|
|
189
|
+
if err != nil {
|
|
190
|
+
t.Fatal(err)
|
|
191
|
+
}
|
|
192
|
+
defer resp.Body.Close()
|
|
193
|
+
// 不走中转特判后,"shotlib.js" 会被当主机名去抓 —— 无论哪种结果都不该 200。
|
|
194
|
+
if resp.StatusCode == http.StatusOK {
|
|
195
|
+
body, _ := io.ReadAll(resp.Body)
|
|
196
|
+
t.Errorf("shotlib.js 应已下线,实际 200(内容前 80 字节: %.80q)", body)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// TestShotActionRejected shot 动作已从白名单移除(截图走 agent-browser):
|
|
201
|
+
// driver 发 shot 必须立刻 ack 失败「未知动作」,而不是悄悄派给页面。
|
|
202
|
+
func TestShotActionRejected(t *testing.T) {
|
|
203
|
+
s := &Service{log: log.New(io.Discard, "", 0)}
|
|
204
|
+
proxy := httptest.NewServer(http.HandlerFunc(s.handleBrowseInput))
|
|
205
|
+
defer proxy.Close()
|
|
206
|
+
|
|
207
|
+
driver := dialWS(t, proxy.URL+"/browse-input?role=driver")
|
|
208
|
+
if m := readMsg(t, driver); m.Type != "viewer" || m.Online {
|
|
209
|
+
t.Fatalf("首帧应是 viewer 状态(离线),实际:%+v", m)
|
|
210
|
+
}
|
|
211
|
+
_ = driver.WriteJSON(browseMsg{Type: "input", Action: "shot"})
|
|
212
|
+
m := readMsg(t, driver)
|
|
213
|
+
if m.Type != "ack" || m.OK == nil || *m.OK {
|
|
214
|
+
t.Fatalf("shot 应 ack 失败(动作已退役),实际:%+v", m)
|
|
215
|
+
}
|
|
216
|
+
}
|