@different-ai/opencode-browser 4.5.1 → 4.6.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.
- package/.opencode/skill/browser-automation/SKILL.md +14 -1
- package/README.md +53 -2
- package/bin/broker.cjs +18 -2
- package/bin/cli.js +395 -52
- package/bin/native-host.cjs +17 -1
- package/dist/plugin.js +374 -103
- package/extension/background.js +627 -20
- package/extension/manifest.json +4 -2
- package/package.json +15 -9
- package/.opencode/skill/github-release/SKILL.md +0 -12
package/extension/background.js
CHANGED
|
@@ -1,19 +1,200 @@
|
|
|
1
1
|
const NATIVE_HOST_NAME = "com.opencode.browser_automation"
|
|
2
2
|
const KEEPALIVE_ALARM = "keepalive"
|
|
3
|
+
const PERMISSION_HINT = "Click the OpenCode Browser extension icon and approve requested permissions."
|
|
4
|
+
const OPTIONAL_RUNTIME_PERMISSIONS = ["nativeMessaging", "downloads", "debugger"]
|
|
5
|
+
const OPTIONAL_RUNTIME_ORIGINS = ["<all_urls>"]
|
|
6
|
+
|
|
7
|
+
const runtimeManifest = chrome.runtime.getManifest()
|
|
8
|
+
const declaredOptionalPermissions = new Set(runtimeManifest.optional_permissions || [])
|
|
9
|
+
const declaredOptionalOrigins = new Set(runtimeManifest.optional_host_permissions || [])
|
|
3
10
|
|
|
4
11
|
let port = null
|
|
5
12
|
let isConnected = false
|
|
6
13
|
let connectionAttempts = 0
|
|
14
|
+
let nativePermissionHintLogged = false
|
|
15
|
+
|
|
16
|
+
// Debugger state management for console/error capture
|
|
17
|
+
const debuggerState = new Map()
|
|
18
|
+
const MAX_LOG_ENTRIES = 1000
|
|
19
|
+
|
|
20
|
+
async function hasPermissions(query) {
|
|
21
|
+
if (!chrome.permissions?.contains) return true
|
|
22
|
+
try {
|
|
23
|
+
return await chrome.permissions.contains(query)
|
|
24
|
+
} catch {
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function hasNativeMessagingPermission() {
|
|
30
|
+
return await hasPermissions({ permissions: ["nativeMessaging"] })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function hasDebuggerPermission() {
|
|
34
|
+
return await hasPermissions({ permissions: ["debugger"] })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function hasDownloadsPermission() {
|
|
38
|
+
return await hasPermissions({ permissions: ["downloads"] })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function hasHostAccessPermission() {
|
|
42
|
+
return await hasPermissions({ origins: ["<all_urls>"] })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function requestOptionalPermissionsFromClick() {
|
|
46
|
+
if (!chrome.permissions?.contains || !chrome.permissions?.request) {
|
|
47
|
+
return { granted: true, requested: false, permissions: [], origins: [] }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const permissions = []
|
|
51
|
+
for (const permission of OPTIONAL_RUNTIME_PERMISSIONS) {
|
|
52
|
+
if (!declaredOptionalPermissions.has(permission)) continue
|
|
53
|
+
const granted = await hasPermissions({ permissions: [permission] })
|
|
54
|
+
if (!granted) permissions.push(permission)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const origins = []
|
|
58
|
+
for (const origin of OPTIONAL_RUNTIME_ORIGINS) {
|
|
59
|
+
if (!declaredOptionalOrigins.has(origin)) continue
|
|
60
|
+
const granted = await hasPermissions({ origins: [origin] })
|
|
61
|
+
if (!granted) origins.push(origin)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!permissions.length && !origins.length) {
|
|
65
|
+
return { granted: true, requested: false, permissions, origins }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const granted = await chrome.permissions.request({ permissions, origins })
|
|
70
|
+
return { granted, requested: true, permissions, origins }
|
|
71
|
+
} catch (error) {
|
|
72
|
+
return {
|
|
73
|
+
granted: false,
|
|
74
|
+
requested: true,
|
|
75
|
+
permissions,
|
|
76
|
+
origins,
|
|
77
|
+
error: error?.message || String(error),
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function ensureDebuggerAvailable() {
|
|
83
|
+
if (!chrome.debugger?.attach) {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
reason: "Debugger API unavailable in this build.",
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const granted = await hasDebuggerPermission()
|
|
91
|
+
if (!granted) {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
reason: `Debugger permission not granted. ${PERMISSION_HINT}`,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { ok: true }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function ensureDownloadsAvailable() {
|
|
102
|
+
if (!chrome.downloads) {
|
|
103
|
+
throw new Error(`Downloads API unavailable in this build. ${PERMISSION_HINT}`)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const granted = await hasDownloadsPermission()
|
|
107
|
+
if (!granted) {
|
|
108
|
+
throw new Error(`Downloads permission not granted. ${PERMISSION_HINT}`)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function ensureDebuggerAttached(tabId) {
|
|
113
|
+
const availability = await ensureDebuggerAvailable()
|
|
114
|
+
if (!availability.ok) {
|
|
115
|
+
return {
|
|
116
|
+
attached: false,
|
|
117
|
+
unavailableReason: availability.reason,
|
|
118
|
+
consoleMessages: [],
|
|
119
|
+
pageErrors: [],
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (debuggerState.has(tabId)) return debuggerState.get(tabId)
|
|
124
|
+
|
|
125
|
+
const state = { attached: false, consoleMessages: [], pageErrors: [] }
|
|
126
|
+
debuggerState.set(tabId, state)
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
await chrome.debugger.attach({ tabId }, "1.3")
|
|
130
|
+
await chrome.debugger.sendCommand({ tabId }, "Runtime.enable")
|
|
131
|
+
state.attached = true
|
|
132
|
+
} catch (e) {
|
|
133
|
+
console.warn("[OpenCode] Failed to attach debugger:", e.message || e)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return state
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (chrome.debugger?.onEvent) {
|
|
140
|
+
chrome.debugger.onEvent.addListener((source, method, params) => {
|
|
141
|
+
const state = debuggerState.get(source.tabId)
|
|
142
|
+
if (!state) return
|
|
143
|
+
|
|
144
|
+
if (method === "Runtime.consoleAPICalled") {
|
|
145
|
+
if (state.consoleMessages.length >= MAX_LOG_ENTRIES) {
|
|
146
|
+
state.consoleMessages.shift()
|
|
147
|
+
}
|
|
148
|
+
state.consoleMessages.push({
|
|
149
|
+
type: params.type,
|
|
150
|
+
text: params.args.map((a) => a.value ?? a.description ?? "").join(" "),
|
|
151
|
+
timestamp: Date.now(),
|
|
152
|
+
source: params.stackTrace?.callFrames?.[0]?.url,
|
|
153
|
+
line: params.stackTrace?.callFrames?.[0]?.lineNumber,
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (method === "Runtime.exceptionThrown") {
|
|
158
|
+
if (state.pageErrors.length >= MAX_LOG_ENTRIES) {
|
|
159
|
+
state.pageErrors.shift()
|
|
160
|
+
}
|
|
161
|
+
state.pageErrors.push({
|
|
162
|
+
message: params.exceptionDetails.text,
|
|
163
|
+
source: params.exceptionDetails.url,
|
|
164
|
+
line: params.exceptionDetails.lineNumber,
|
|
165
|
+
column: params.exceptionDetails.columnNumber,
|
|
166
|
+
stack: params.exceptionDetails.exception?.description,
|
|
167
|
+
timestamp: Date.now(),
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (chrome.debugger?.onDetach) {
|
|
174
|
+
chrome.debugger.onDetach.addListener((source) => {
|
|
175
|
+
if (debuggerState.has(source.tabId)) {
|
|
176
|
+
const state = debuggerState.get(source.tabId)
|
|
177
|
+
state.attached = false
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
chrome.tabs.onRemoved.addListener((tabId) => {
|
|
183
|
+
if (debuggerState.has(tabId)) {
|
|
184
|
+
if (chrome.debugger?.detach) chrome.debugger.detach({ tabId }).catch(() => {})
|
|
185
|
+
debuggerState.delete(tabId)
|
|
186
|
+
}
|
|
187
|
+
})
|
|
7
188
|
|
|
8
189
|
chrome.alarms.create(KEEPALIVE_ALARM, { periodInMinutes: 0.25 })
|
|
9
190
|
|
|
10
191
|
chrome.alarms.onAlarm.addListener((alarm) => {
|
|
11
192
|
if (alarm.name === KEEPALIVE_ALARM) {
|
|
12
|
-
if (!isConnected) connect()
|
|
193
|
+
if (!isConnected) connect().catch(() => {})
|
|
13
194
|
}
|
|
14
195
|
})
|
|
15
196
|
|
|
16
|
-
function connect() {
|
|
197
|
+
async function connect() {
|
|
17
198
|
if (port) {
|
|
18
199
|
try {
|
|
19
200
|
port.disconnect()
|
|
@@ -21,6 +202,19 @@ function connect() {
|
|
|
21
202
|
port = null
|
|
22
203
|
}
|
|
23
204
|
|
|
205
|
+
const nativeMessagingAllowed = await hasNativeMessagingPermission()
|
|
206
|
+
if (!nativeMessagingAllowed) {
|
|
207
|
+
isConnected = false
|
|
208
|
+
updateBadge(false)
|
|
209
|
+
if (!nativePermissionHintLogged) {
|
|
210
|
+
nativePermissionHintLogged = true
|
|
211
|
+
console.log(`[OpenCode] Native messaging permission not granted. ${PERMISSION_HINT}`)
|
|
212
|
+
}
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
nativePermissionHintLogged = false
|
|
217
|
+
|
|
24
218
|
try {
|
|
25
219
|
port = chrome.runtime.connectNative(NATIVE_HOST_NAME)
|
|
26
220
|
|
|
@@ -111,6 +305,12 @@ async function executeTool(toolName, args) {
|
|
|
111
305
|
query: toolQuery,
|
|
112
306
|
scroll: toolScroll,
|
|
113
307
|
wait: toolWait,
|
|
308
|
+
download: toolDownload,
|
|
309
|
+
list_downloads: toolListDownloads,
|
|
310
|
+
set_file_input: toolSetFileInput,
|
|
311
|
+
highlight: toolHighlight,
|
|
312
|
+
console: toolConsole,
|
|
313
|
+
errors: toolErrors,
|
|
114
314
|
}
|
|
115
315
|
|
|
116
316
|
const fn = tools[toolName]
|
|
@@ -129,13 +329,26 @@ async function getTabById(tabId) {
|
|
|
129
329
|
}
|
|
130
330
|
|
|
131
331
|
async function runInPage(tabId, command, args) {
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
332
|
+
const hasHostAccess = await hasHostAccessPermission()
|
|
333
|
+
if (!hasHostAccess) {
|
|
334
|
+
throw new Error(`Site access permission not granted. ${PERMISSION_HINT}`)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
try {
|
|
338
|
+
const result = await chrome.scripting.executeScript({
|
|
339
|
+
target: { tabId },
|
|
340
|
+
func: pageOps,
|
|
341
|
+
args: [command, args || {}],
|
|
342
|
+
world: "ISOLATED",
|
|
343
|
+
})
|
|
344
|
+
return result[0]?.result
|
|
345
|
+
} catch (error) {
|
|
346
|
+
const message = error?.message || String(error)
|
|
347
|
+
if (message.includes("Cannot access contents of the page")) {
|
|
348
|
+
throw new Error(`Site access permission not granted for this page. ${PERMISSION_HINT}`)
|
|
349
|
+
}
|
|
350
|
+
throw error
|
|
351
|
+
}
|
|
139
352
|
}
|
|
140
353
|
|
|
141
354
|
async function pageOps(command, args) {
|
|
@@ -309,7 +522,7 @@ async function pageOps(command, args) {
|
|
|
309
522
|
const results = []
|
|
310
523
|
const seen = new Set()
|
|
311
524
|
const candidates = deepQuerySelectorAll(
|
|
312
|
-
"button, a, label, option, summary, [role='button'], [role='link'], [role='tab'], [role='menuitem']",
|
|
525
|
+
"button, a, label, option, summary, [role='button'], [role='link'], [role='tab'], [role='menuitem'], [role='option'], [role='listitem'], [role='row'], [tabindex]",
|
|
313
526
|
document
|
|
314
527
|
)
|
|
315
528
|
for (const el of candidates) {
|
|
@@ -319,6 +532,23 @@ async function pageOps(command, args) {
|
|
|
319
532
|
results.push(el)
|
|
320
533
|
}
|
|
321
534
|
}
|
|
535
|
+
|
|
536
|
+
const generic = deepQuerySelectorAll("div, span, li, article", document)
|
|
537
|
+
for (const el of generic) {
|
|
538
|
+
if (!matchesText(el.innerText || el.textContent || "", target)) continue
|
|
539
|
+
const style = window.getComputedStyle(el)
|
|
540
|
+
const likelyInteractive =
|
|
541
|
+
!!el.getAttribute("onclick") ||
|
|
542
|
+
!!el.getAttribute("role") ||
|
|
543
|
+
el.tabIndex >= 0 ||
|
|
544
|
+
style.cursor === "pointer"
|
|
545
|
+
if (!likelyInteractive) continue
|
|
546
|
+
if (!seen.has(el)) {
|
|
547
|
+
seen.add(el)
|
|
548
|
+
results.push(el)
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
322
552
|
const inputs = deepQuerySelectorAll("input[type='button'], input[type='submit'], input[type='reset']", document)
|
|
323
553
|
for (const el of inputs) {
|
|
324
554
|
if (!matchesText(el.value || "", target)) continue
|
|
@@ -609,6 +839,67 @@ async function pageOps(command, args) {
|
|
|
609
839
|
}
|
|
610
840
|
}
|
|
611
841
|
|
|
842
|
+
if (command === "set_file_input") {
|
|
843
|
+
const rawFiles = Array.isArray(options.files) ? options.files : options.files ? [options.files] : []
|
|
844
|
+
if (!rawFiles.length) return { ok: false, error: "files is required" }
|
|
845
|
+
|
|
846
|
+
const match = await resolveMatches(selectors, index, timeoutMs, pollMs)
|
|
847
|
+
if (!match.chosen) {
|
|
848
|
+
return { ok: false, error: `Element not found for selectors: ${selectors.join(", ")}` }
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const tag = match.chosen.tagName
|
|
852
|
+
if (tag !== "INPUT" || match.chosen.type !== "file") {
|
|
853
|
+
return { ok: false, error: `Element is not a file input: ${match.selectorUsed} (${tag.toLowerCase()})` }
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function decodeBase64(value) {
|
|
857
|
+
const raw = safeString(value)
|
|
858
|
+
const b64 = raw.includes(",") ? raw.split(",").pop() : raw
|
|
859
|
+
const binary = atob(b64)
|
|
860
|
+
const bytes = new Uint8Array(binary.length)
|
|
861
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
|
|
862
|
+
return bytes
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const dt = new DataTransfer()
|
|
866
|
+
const names = []
|
|
867
|
+
|
|
868
|
+
for (const fileInfo of rawFiles) {
|
|
869
|
+
const name = safeString(fileInfo?.name) || "upload.bin"
|
|
870
|
+
const mimeType = safeString(fileInfo?.mimeType) || "application/octet-stream"
|
|
871
|
+
const base64 = safeString(fileInfo?.base64)
|
|
872
|
+
if (!base64) return { ok: false, error: "file.base64 is required" }
|
|
873
|
+
const bytes = decodeBase64(base64)
|
|
874
|
+
const file = new File([bytes], name, { type: mimeType, lastModified: Date.now() })
|
|
875
|
+
dt.items.add(file)
|
|
876
|
+
names.push(name)
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
try {
|
|
880
|
+
match.chosen.scrollIntoView({ block: "center", inline: "center" })
|
|
881
|
+
} catch {}
|
|
882
|
+
|
|
883
|
+
try {
|
|
884
|
+
match.chosen.focus()
|
|
885
|
+
} catch {}
|
|
886
|
+
|
|
887
|
+
try {
|
|
888
|
+
match.chosen.files = dt.files
|
|
889
|
+
} catch {
|
|
890
|
+
try {
|
|
891
|
+
Object.defineProperty(match.chosen, "files", { value: dt.files, writable: false })
|
|
892
|
+
} catch {
|
|
893
|
+
return { ok: false, error: "Failed to set file input" }
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
match.chosen.dispatchEvent(new Event("input", { bubbles: true }))
|
|
898
|
+
match.chosen.dispatchEvent(new Event("change", { bubbles: true }))
|
|
899
|
+
|
|
900
|
+
return { ok: true, selectorUsed: match.selectorUsed, count: dt.files.length, names }
|
|
901
|
+
}
|
|
902
|
+
|
|
612
903
|
if (command === "scroll") {
|
|
613
904
|
const scrollX = Number.isFinite(options.x) ? options.x : 0
|
|
614
905
|
const scrollY = Number.isFinite(options.y) ? options.y : 0
|
|
@@ -617,6 +908,21 @@ async function pageOps(command, args) {
|
|
|
617
908
|
if (!match.chosen) {
|
|
618
909
|
return { ok: false, error: `Element not found for selectors: ${selectors.join(", ")}` }
|
|
619
910
|
}
|
|
911
|
+
if (scrollX || scrollY) {
|
|
912
|
+
try {
|
|
913
|
+
if (typeof match.chosen.scrollBy === "function") {
|
|
914
|
+
match.chosen.scrollBy({ left: scrollX, top: scrollY, behavior: "smooth" })
|
|
915
|
+
} else {
|
|
916
|
+
match.chosen.scrollLeft = Number(match.chosen.scrollLeft || 0) + scrollX
|
|
917
|
+
match.chosen.scrollTop = Number(match.chosen.scrollTop || 0) + scrollY
|
|
918
|
+
}
|
|
919
|
+
} catch {
|
|
920
|
+
match.chosen.scrollLeft = Number(match.chosen.scrollLeft || 0) + scrollX
|
|
921
|
+
match.chosen.scrollTop = Number(match.chosen.scrollTop || 0) + scrollY
|
|
922
|
+
}
|
|
923
|
+
return { ok: true, selectorUsed: match.selectorUsed, elementScroll: { x: scrollX, y: scrollY } }
|
|
924
|
+
}
|
|
925
|
+
|
|
620
926
|
try {
|
|
621
927
|
match.chosen.scrollIntoView({ behavior: "smooth", block: "center" })
|
|
622
928
|
} catch {}
|
|
@@ -626,6 +932,73 @@ async function pageOps(command, args) {
|
|
|
626
932
|
return { ok: true }
|
|
627
933
|
}
|
|
628
934
|
|
|
935
|
+
if (command === "highlight") {
|
|
936
|
+
const duration = Number.isFinite(options.duration) ? options.duration : 3000
|
|
937
|
+
const color = typeof options.color === "string" ? options.color : "#ff0000"
|
|
938
|
+
const showInfo = !!options.showInfo
|
|
939
|
+
|
|
940
|
+
const match = await resolveMatches(selectors, index, timeoutMs, pollMs)
|
|
941
|
+
if (!match.chosen) {
|
|
942
|
+
return { ok: false, error: `Element not found for selectors: ${selectors.join(", ")}` }
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const el = match.chosen
|
|
946
|
+
const rect = el.getBoundingClientRect()
|
|
947
|
+
|
|
948
|
+
// Remove any existing highlight overlay
|
|
949
|
+
const existing = document.getElementById("__opc_highlight_overlay")
|
|
950
|
+
if (existing) existing.remove()
|
|
951
|
+
|
|
952
|
+
// Create overlay
|
|
953
|
+
const overlay = document.createElement("div")
|
|
954
|
+
overlay.id = "__opc_highlight_overlay"
|
|
955
|
+
overlay.style.cssText = `
|
|
956
|
+
position: fixed;
|
|
957
|
+
top: ${rect.top}px;
|
|
958
|
+
left: ${rect.left}px;
|
|
959
|
+
width: ${rect.width}px;
|
|
960
|
+
height: ${rect.height}px;
|
|
961
|
+
border: 3px solid ${color};
|
|
962
|
+
box-shadow: 0 0 10px ${color};
|
|
963
|
+
pointer-events: none;
|
|
964
|
+
z-index: 2147483647;
|
|
965
|
+
transition: opacity 0.3s;
|
|
966
|
+
`
|
|
967
|
+
|
|
968
|
+
if (showInfo) {
|
|
969
|
+
const info = document.createElement("div")
|
|
970
|
+
info.style.cssText = `
|
|
971
|
+
position: absolute;
|
|
972
|
+
top: -25px;
|
|
973
|
+
left: 0;
|
|
974
|
+
background: ${color};
|
|
975
|
+
color: white;
|
|
976
|
+
padding: 2px 8px;
|
|
977
|
+
font-size: 12px;
|
|
978
|
+
font-family: monospace;
|
|
979
|
+
border-radius: 3px;
|
|
980
|
+
white-space: nowrap;
|
|
981
|
+
`
|
|
982
|
+
info.textContent = `${el.tagName.toLowerCase()}${el.id ? "#" + el.id : ""}`
|
|
983
|
+
overlay.appendChild(info)
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
document.body.appendChild(overlay)
|
|
987
|
+
|
|
988
|
+
setTimeout(() => {
|
|
989
|
+
overlay.style.opacity = "0"
|
|
990
|
+
setTimeout(() => overlay.remove(), 300)
|
|
991
|
+
}, duration)
|
|
992
|
+
|
|
993
|
+
return {
|
|
994
|
+
ok: true,
|
|
995
|
+
selectorUsed: match.selectorUsed,
|
|
996
|
+
highlighted: true,
|
|
997
|
+
tag: el.tagName,
|
|
998
|
+
id: el.id || null,
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
629
1002
|
if (command === "query") {
|
|
630
1003
|
if (mode === "page_text") {
|
|
631
1004
|
if (selectors.length && timeoutMs > 0) {
|
|
@@ -986,16 +1359,250 @@ async function toolWait({ ms = 1000, tabId }) {
|
|
|
986
1359
|
return { tabId, content: `Waited ${ms}ms` }
|
|
987
1360
|
}
|
|
988
1361
|
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
1362
|
+
function clampNumber(value, min, max, fallback) {
|
|
1363
|
+
const n = Number(value)
|
|
1364
|
+
if (!Number.isFinite(n)) return fallback
|
|
1365
|
+
return Math.min(Math.max(n, min), max)
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
function normalizeDownloadTimeoutMs(value) {
|
|
1369
|
+
return clampNumber(value, 0, 60000, 60000)
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
function waitForNextDownloadCreated(timeoutMs) {
|
|
1373
|
+
const timeout = normalizeDownloadTimeoutMs(timeoutMs)
|
|
1374
|
+
return new Promise((resolve, reject) => {
|
|
1375
|
+
const listener = (item) => {
|
|
1376
|
+
cleanup()
|
|
1377
|
+
resolve(item)
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
const timer = timeout
|
|
1381
|
+
? setTimeout(() => {
|
|
1382
|
+
cleanup()
|
|
1383
|
+
reject(new Error("Timed out waiting for download to start"))
|
|
1384
|
+
}, timeout)
|
|
1385
|
+
: null
|
|
1386
|
+
|
|
1387
|
+
function cleanup() {
|
|
1388
|
+
chrome.downloads.onCreated.removeListener(listener)
|
|
1389
|
+
if (timer) clearTimeout(timer)
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
chrome.downloads.onCreated.addListener(listener)
|
|
1393
|
+
})
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
async function getDownloadById(downloadId) {
|
|
1397
|
+
const items = await chrome.downloads.search({ id: downloadId })
|
|
1398
|
+
return items && items.length ? items[0] : null
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
async function waitForDownloadCompletion(downloadId, timeoutMs) {
|
|
1402
|
+
const timeout = normalizeDownloadTimeoutMs(timeoutMs)
|
|
1403
|
+
const pollMs = 200
|
|
1404
|
+
const endAt = Date.now() + timeout
|
|
1405
|
+
|
|
1406
|
+
while (true) {
|
|
1407
|
+
const item = await getDownloadById(downloadId)
|
|
1408
|
+
if (item && (item.state === "complete" || item.state === "interrupted")) return item
|
|
1409
|
+
if (!timeout || Date.now() >= endAt) return item
|
|
1410
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs))
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
async function toolDownload({
|
|
1415
|
+
url,
|
|
1416
|
+
selector,
|
|
1417
|
+
filename,
|
|
1418
|
+
conflictAction,
|
|
1419
|
+
saveAs = false,
|
|
1420
|
+
wait = false,
|
|
1421
|
+
downloadTimeoutMs,
|
|
1422
|
+
tabId,
|
|
1423
|
+
index = 0,
|
|
1424
|
+
timeoutMs,
|
|
1425
|
+
pollMs,
|
|
1426
|
+
}) {
|
|
1427
|
+
const hasUrl = typeof url === "string" && url.trim()
|
|
1428
|
+
const hasSelector = typeof selector === "string" && selector.trim()
|
|
1429
|
+
|
|
1430
|
+
await ensureDownloadsAvailable()
|
|
1431
|
+
|
|
1432
|
+
if (!hasUrl && !hasSelector) throw new Error("url or selector is required")
|
|
1433
|
+
if (hasUrl && hasSelector) throw new Error("Provide either url or selector, not both")
|
|
1434
|
+
|
|
1435
|
+
let downloadId = null
|
|
1436
|
+
|
|
1437
|
+
if (hasUrl) {
|
|
1438
|
+
const options = { url: url.trim() }
|
|
1439
|
+
if (typeof filename === "string" && filename.trim()) options.filename = filename.trim()
|
|
1440
|
+
if (typeof conflictAction === "string" && conflictAction.trim()) options.conflictAction = conflictAction.trim()
|
|
1441
|
+
if (typeof saveAs === "boolean") options.saveAs = saveAs
|
|
1442
|
+
|
|
1443
|
+
downloadId = await chrome.downloads.download(options)
|
|
1444
|
+
} else {
|
|
1445
|
+
const tab = await getTabById(tabId)
|
|
1446
|
+
const created = waitForNextDownloadCreated(downloadTimeoutMs)
|
|
1447
|
+
const clicked = await runInPage(tab.id, "click", { selector, index, timeoutMs, pollMs })
|
|
1448
|
+
if (!clicked?.ok) throw new Error(clicked?.error || "Click failed")
|
|
1449
|
+
const createdItem = await created
|
|
1450
|
+
downloadId = createdItem?.id
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
if (!Number.isFinite(downloadId)) throw new Error("Download did not start")
|
|
1454
|
+
|
|
1455
|
+
if (!wait) {
|
|
1456
|
+
const item = await getDownloadById(downloadId)
|
|
1457
|
+
return { content: { downloadId, item } }
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
const item = await waitForDownloadCompletion(downloadId, downloadTimeoutMs)
|
|
1461
|
+
return { content: { downloadId, item } }
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
async function toolListDownloads({ limit = 20, state } = {}) {
|
|
1465
|
+
await ensureDownloadsAvailable()
|
|
1466
|
+
|
|
1467
|
+
const limitValue = clampNumber(limit, 1, 200, 20)
|
|
1468
|
+
const query = { orderBy: ["-startTime"], limit: limitValue }
|
|
1469
|
+
if (typeof state === "string" && state.trim()) query.state = state.trim()
|
|
1470
|
+
|
|
1471
|
+
const downloads = await chrome.downloads.search(query)
|
|
1472
|
+
const out = downloads.map((d) => ({
|
|
1473
|
+
id: d.id,
|
|
1474
|
+
url: d.url,
|
|
1475
|
+
filename: d.filename,
|
|
1476
|
+
state: d.state,
|
|
1477
|
+
bytesReceived: d.bytesReceived,
|
|
1478
|
+
totalBytes: d.totalBytes,
|
|
1479
|
+
startTime: d.startTime,
|
|
1480
|
+
endTime: d.endTime,
|
|
1481
|
+
error: d.error,
|
|
1482
|
+
mime: d.mime,
|
|
1483
|
+
}))
|
|
1484
|
+
|
|
1485
|
+
return { content: JSON.stringify({ downloads: out }, null, 2) }
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
async function toolSetFileInput({ selector, tabId, index = 0, timeoutMs, pollMs, files }) {
|
|
1489
|
+
if (!selector) throw new Error("Selector is required")
|
|
1490
|
+
const tab = await getTabById(tabId)
|
|
1491
|
+
|
|
1492
|
+
const result = await runInPage(tab.id, "set_file_input", { selector, index, timeoutMs, pollMs, files })
|
|
1493
|
+
if (!result?.ok) throw new Error(result?.error || "Failed to set file input")
|
|
1494
|
+
const used = result.selectorUsed || selector
|
|
1495
|
+
return { tabId: tab.id, content: JSON.stringify({ selector: used, ...result }, null, 2) }
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
async function toolHighlight({ selector, tabId, index = 0, duration, color, showInfo, timeoutMs, pollMs }) {
|
|
1499
|
+
if (!selector) throw new Error("Selector is required")
|
|
1500
|
+
const tab = await getTabById(tabId)
|
|
1501
|
+
|
|
1502
|
+
const result = await runInPage(tab.id, "highlight", {
|
|
1503
|
+
selector,
|
|
1504
|
+
index,
|
|
1505
|
+
duration,
|
|
1506
|
+
color,
|
|
1507
|
+
showInfo,
|
|
1508
|
+
timeoutMs,
|
|
1509
|
+
pollMs,
|
|
998
1510
|
})
|
|
1511
|
+
if (!result?.ok) throw new Error(result?.error || "Highlight failed")
|
|
1512
|
+
return {
|
|
1513
|
+
tabId: tab.id,
|
|
1514
|
+
content: JSON.stringify({
|
|
1515
|
+
highlighted: true,
|
|
1516
|
+
tag: result.tag,
|
|
1517
|
+
id: result.id,
|
|
1518
|
+
selectorUsed: result.selectorUsed,
|
|
1519
|
+
}),
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
async function toolConsole({ tabId, clear = false, filter } = {}) {
|
|
1524
|
+
const tab = await getTabById(tabId)
|
|
1525
|
+
const state = await ensureDebuggerAttached(tab.id)
|
|
1526
|
+
|
|
1527
|
+
if (!state.attached) {
|
|
1528
|
+
return {
|
|
1529
|
+
tabId: tab.id,
|
|
1530
|
+
content: JSON.stringify({
|
|
1531
|
+
error: state.unavailableReason || "Debugger not attached. DevTools may be open or another debugger is active.",
|
|
1532
|
+
messages: [],
|
|
1533
|
+
}),
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
let messages = [...state.consoleMessages]
|
|
1538
|
+
|
|
1539
|
+
if (filter && typeof filter === "string") {
|
|
1540
|
+
const filterType = filter.toLowerCase()
|
|
1541
|
+
messages = messages.filter((m) => m.type === filterType)
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
if (clear) {
|
|
1545
|
+
state.consoleMessages = []
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
return {
|
|
1549
|
+
tabId: tab.id,
|
|
1550
|
+
content: JSON.stringify(messages, null, 2),
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
async function toolErrors({ tabId, clear = false } = {}) {
|
|
1555
|
+
const tab = await getTabById(tabId)
|
|
1556
|
+
const state = await ensureDebuggerAttached(tab.id)
|
|
1557
|
+
|
|
1558
|
+
if (!state.attached) {
|
|
1559
|
+
return {
|
|
1560
|
+
tabId: tab.id,
|
|
1561
|
+
content: JSON.stringify({
|
|
1562
|
+
error: state.unavailableReason || "Debugger not attached. DevTools may be open or another debugger is active.",
|
|
1563
|
+
errors: [],
|
|
1564
|
+
}),
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
const errors = [...state.pageErrors]
|
|
1569
|
+
|
|
1570
|
+
if (clear) {
|
|
1571
|
+
state.pageErrors = []
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
return {
|
|
1575
|
+
tabId: tab.id,
|
|
1576
|
+
content: JSON.stringify(errors, null, 2),
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
chrome.runtime.onInstalled.addListener(() => connect().catch(() => {}))
|
|
1581
|
+
chrome.runtime.onStartup.addListener(() => connect().catch(() => {}))
|
|
1582
|
+
|
|
1583
|
+
if (chrome.permissions?.onAdded) {
|
|
1584
|
+
chrome.permissions.onAdded.addListener(() => connect().catch(() => {}))
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
chrome.action.onClicked.addListener(async () => {
|
|
1588
|
+
const permissionResult = await requestOptionalPermissionsFromClick()
|
|
1589
|
+
if (!permissionResult.granted) {
|
|
1590
|
+
updateBadge(false)
|
|
1591
|
+
if (permissionResult.error) {
|
|
1592
|
+
console.warn("[OpenCode] Permission request failed:", permissionResult.error)
|
|
1593
|
+
} else {
|
|
1594
|
+
console.warn("[OpenCode] Permission request denied.")
|
|
1595
|
+
}
|
|
1596
|
+
return
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
if (permissionResult.requested) {
|
|
1600
|
+
const requestedPermissions = permissionResult.permissions.join(", ") || "none"
|
|
1601
|
+
const requestedOrigins = permissionResult.origins.join(", ") || "none"
|
|
1602
|
+
console.log(`[OpenCode] Requested permissions -> permissions: ${requestedPermissions}; origins: ${requestedOrigins}`)
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
await connect()
|
|
999
1606
|
})
|
|
1000
1607
|
|
|
1001
|
-
connect()
|
|
1608
|
+
connect().catch(() => {})
|