Files
byre/hands/Sources/byre-hands/Chrome.swift
T

736 lines
29 KiB
Swift
Raw Normal View History

2026-10-18 07:47:10 -07:00
import AppKit
import ImageIO
import UniformTypeIdentifiers
let chromePort = 9222
let chromeProfile = FileManager.default.homeDirectoryForCurrentUser.appending(path: "Library/Application Support/byre/chrome").path
final class Req {
let seq: Int
let id: String
let url: String
let method: String
let type: String
let start: Double
var end: Double?
var status: Int?
var proto = ""
var remote = ""
var mime = ""
var bytes = 0
var error: String?
var cached = false
var hasPost = false
var redirected = false
var gotRespExtra = false
var reqHeaders: [String: Any] = [:]
var respHeaders: [String: Any] = [:]
init(seq: Int, id: String, url: String, method: String, type: String, start: Double) {
self.seq = seq
self.id = id
self.url = url
self.method = method
self.type = type
self.start = start
}
}
final class Chain {
var hops: [Req] = []
var reqExtra: [[String: Any]?] = []
var respExtra: [[String: Any]?] = []
func sync() {
for (i, hop) in hops.enumerated() {
if i < reqExtra.count, let h = reqExtra[i] {
hop.reqHeaders = h
}
if i < respExtra.count, let h = respExtra[i] {
hop.respHeaders = h
hop.gotRespExtra = true
}
}
}
func noExtra(_ i: Int) {
if reqExtra.count == i {
reqExtra.append(nil)
}
if respExtra.count == i {
respExtra.append(nil)
}
}
}
final class TabState {
let session: String
let target: String
var seq = 0
var reqs: [Req] = []
var chains: [String: Chain] = [:]
var console: [String] = []
var traceStream: String?
init(session: String, target: String) {
self.session = session
self.target = target
}
func clear() {
seq = 0
reqs = []
chains = [:]
console = []
}
func chain(_ rid: String) -> Chain {
if let c = chains[rid] {
return c
}
let c = Chain()
chains[rid] = c
return c
}
func last(_ rid: String) -> Req? {
return chains[rid]?.hops.last
}
}
final class Chrome: @unchecked Sendable {
let ws: URLSessionWebSocketTask
let lock = NSLock()
var next = 0
var pending: [Int: CheckedContinuation<[String: Any], Error>] = [:]
var tabs: [String: Task<TabState, Error>] = [:]
var sessions: [String: TabState] = [:]
var current: String?
var closed = false
init(_ url: URL) {
ws = URLSession.shared.webSocketTask(with: url)
ws.maximumMessageSize = 1 << 28
ws.resume()
receive()
}
func receive() {
ws.receive { [self] result in
switch result {
case .failure(let e):
fail(e)
case .success(let msg):
switch msg {
case .string(let s):
dispatch(Data(s.utf8))
case .data(let d):
dispatch(d)
@unknown default:
break
}
receive()
}
}
}
func fail(_ e: Error) {
let waiting = lock.withLock {
closed = true
let p = pending
pending = [:]
return p
}
for c in waiting.values {
c.resume(throwing: ToolError("lost the connection to Chrome: \(e.localizedDescription)"))
}
}
func resolve(_ id: Int, _ r: Result<[String: Any], Error>) {
guard let c = lock.withLock({ pending.removeValue(forKey: id) }) else {
return
}
c.resume(with: r)
}
func send(_ method: String, _ params: [String: Any] = [:], session: String? = nil, timeout: Int = 30) async throws -> [String: Any] {
let id = lock.withLock {
next += 1
return next
}
var msg: [String: Any] = ["id": id, "method": method, "params": params]
if let session {
msg["sessionId"] = session
}
let data = try JSONSerialization.data(withJSONObject: msg)
return try await withCheckedThrowingContinuation { c in
let dead = lock.withLock {
if closed {
return true
}
pending[id] = c
return false
}
if dead {
c.resume(throwing: ToolError("lost the connection to Chrome"))
return
}
ws.send(.string(String(decoding: data, as: UTF8.self))) { [self] err in
if let err {
resolve(id, .failure(err))
}
}
Task {
await pause(timeout * 1000)
resolve(id, .failure(ToolError("\(method) got no answer from Chrome in \(timeout)s")))
}
}
}
func dispatch(_ data: Data) {
guard let m = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return
}
if let id = m.int("id") {
if let err = m.dict("error") {
resolve(id, .failure(ToolError("Chrome: \(err.string("message") ?? "\(err)")")))
} else {
resolve(id, .success(m.dict("result") ?? [:]))
}
return
}
let method = m.string("method") ?? ""
let p = m.dict("params") ?? [:]
lock.withLock {
if method == "Target.detachedFromTarget", let s = p.string("sessionId"), let t = sessions.removeValue(forKey: s) {
tabs.removeValue(forKey: t.target)
return
}
guard let s = m.string("sessionId"), let t = sessions[s] else {
return
}
event(method, p, t)
}
}
func event(_ method: String, _ p: [String: Any], _ t: TabState) {
let rid = p.string("requestId") ?? ""
switch method {
case "Network.requestWillBeSent":
let r = p.dict("request") ?? [:]
let chain = t.chain(rid)
if let prev = chain.hops.last, let redirect = p.dict("redirectResponse") {
respond(prev, redirect)
prev.redirected = true
prev.end = p.double("timestamp")
if !p.bool("redirectHasExtraInfo") {
chain.noExtra(chain.hops.count - 1)
}
}
t.seq += 1
let req = Req(seq: t.seq, id: rid, url: r.string("url") ?? "", method: r.string("method") ?? "", type: p.string("type") ?? "", start: p.double("timestamp") ?? 0)
req.reqHeaders = r.dict("headers") ?? [:]
req.hasPost = r.bool("hasPostData")
t.reqs.append(req)
chain.hops.append(req)
chain.sync()
if t.reqs.count > 5000 {
for old in t.reqs.prefix(1000) where t.last(old.id) === old {
t.chains.removeValue(forKey: old.id)
}
t.reqs.removeFirst(1000)
}
case "Network.requestWillBeSentExtraInfo":
let chain = t.chain(rid)
chain.reqExtra.append(p.dict("headers") ?? [:])
chain.sync()
case "Network.responseReceived":
guard let chain = t.chains[rid], let req = chain.hops.last else {
return
}
respond(req, p.dict("response") ?? [:])
if !p.bool("hasExtraInfo") {
chain.noExtra(chain.hops.count - 1)
}
case "Network.responseReceivedExtraInfo":
let chain = t.chain(rid)
chain.respExtra.append(p.dict("headers") ?? [:])
chain.sync()
case "Network.loadingFinished":
t.last(rid)?.end = p.double("timestamp")
t.last(rid)?.bytes = p.int("encodedDataLength") ?? 0
case "Network.loadingFailed":
t.last(rid)?.end = p.double("timestamp")
t.last(rid)?.error = p.string("blockedReason").map { "blocked: \($0)" } ?? p.string("errorText")
case "Runtime.consoleAPICalled":
let args = (p["args"] as? [[String: Any]] ?? []).map(remoteText)
let frame = (p.dict("stackTrace")?["callFrames"] as? [[String: Any]])?.first
t.console.append(consoleLine(p.string("type") ?? "log", args.joined(separator: " "), frame?.string("url"), frame?.int("lineNumber")))
case "Runtime.exceptionThrown":
let d = p.dict("exceptionDetails") ?? [:]
t.console.append(consoleLine("exception", d.dict("exception")?.string("description") ?? d.string("text") ?? "", d.string("url"), d.int("lineNumber")))
case "Log.entryAdded":
let e = p.dict("entry") ?? [:]
t.console.append(consoleLine("\(e.string("level") ?? "info") (\(e.string("source") ?? ""))", e.string("text") ?? "", e.string("url"), e.int("lineNumber")))
case "Tracing.tracingComplete":
t.traceStream = p.string("stream") ?? ""
default:
break
}
if t.console.count > 2000 {
t.console.removeFirst(500)
}
}
func respond(_ req: Req, _ r: [String: Any]) {
req.status = r.int("status")
req.proto = r.string("protocol") ?? ""
req.remote = r.string("remoteIPAddress") ?? ""
req.mime = r.string("mimeType") ?? ""
req.cached = r.bool("fromDiskCache") || r.bool("fromServiceWorker") || r.bool("fromPrefetchCache")
if !req.gotRespExtra {
req.respHeaders = r.dict("headers") ?? [:]
}
}
func pages() async throws -> [[String: Any]] {
let r = try await send("Target.getTargets")
return (r["targetInfos"] as? [[String: Any]] ?? []).filter { $0.string("type") == "page" }
}
func tab(_ id: String?) async throws -> (String, TabState) {
var target = id
if target == nil {
let open = try await pages().compactMap { $0.string("targetId") }
target = lock.withLock { current }.flatMap { open.contains($0) ? $0 : nil } ?? open.first
}
guard let target else {
throw ToolError("Chrome has no tabs; navigate with new_tab to open one")
}
let task = lock.withLock {
current = target
if let a = tabs[target] {
return a
}
let a = Task { try await attach(target) }
tabs[target] = a
return a
}
do {
return (target, try await task.value)
} catch {
lock.withLock {
if tabs[target] == task {
tabs.removeValue(forKey: target)
}
}
throw error
}
}
func attach(_ target: String) async throws -> TabState {
let r = try await send("Target.attachToTarget", ["targetId": target, "flatten": true])
guard let s = r.string("sessionId") else {
throw ToolError("Chrome attached to \(target) without a session")
}
let t = TabState(session: s, target: target)
lock.withLock { sessions[s] = t }
for m in ["Page.enable", "Network.enable", "Runtime.enable", "Log.enable"] {
_ = try await send(m, session: s)
}
return t
}
}
func remoteText(_ o: [String: Any]) -> String {
if let v = o["value"] {
if let s = v as? String {
return s
}
if let d = try? JSONSerialization.data(withJSONObject: v, options: [.fragmentsAllowed, .withoutEscapingSlashes]) {
return String(decoding: d, as: UTF8.self)
}
}
if let p = o.dict("preview"), let props = p["properties"] as? [[String: Any]] {
let array = p.string("subtype") == "array"
let items = props.map { array ? ($0.string("value") ?? "") : "\($0.string("name") ?? ""): \($0.string("value") ?? "")" }.joined(separator: ", ")
let more = p.bool("overflow") ? ", …" : ""
return array ? "[\(items)\(more)]" : "{\(items)\(more)}"
}
return o.string("unserializableValue") ?? o.string("description") ?? o.string("type") ?? ""
}
func consoleLine(_ level: String, _ text: String, _ url: String?, _ line: Int?) -> String {
guard let url, !url.isEmpty else {
return "\(level) \(text)"
}
return "\(level) \(text) (\(url):\((line ?? 0) + 1))"
}
func chromeVersion() async -> [String: Any]? {
var req = URLRequest(url: URL(string: "http://127.0.0.1:\(chromePort)/json/version")!)
req.timeoutInterval = 2
guard let (d, _) = try? await URLSession.shared.data(for: req) else {
return nil
}
return try? JSONSerialization.jsonObject(with: d) as? [String: Any]
}
func openChrome() async throws -> (Chrome, String) {
var note = ""
var info = await chromeVersion()
if info == nil {
let lock = try await lockInput()
defer {
close(lock)
}
let p = Process()
p.executableURL = URL(filePath: "/usr/bin/open")
p.arguments = ["-na", "Google Chrome", "--args", "--user-data-dir=\(chromeProfile)", "--remote-debugging-port=\(chromePort)", "--no-first-run", "--no-default-browser-check"]
try p.run()
p.waitUntilExit()
if p.terminationStatus != 0 {
throw ToolError("could not open Google Chrome (open exited \(p.terminationStatus)); is it installed on this Mac?")
}
let start = Date()
while info == nil, Date().timeIntervalSince(start) < 15 {
await pause(200)
info = await chromeVersion()
}
note = "started Chrome with byre's profile\n"
}
guard let ws = info?.string("webSocketDebuggerUrl").flatMap(URL.init(string:)) else {
throw ToolError("Chrome's debugging port \(chromePort) didn't answer within 15s")
}
return (Chrome(ws), note)
}
actor ChromeLink {
var conn: Task<(Chrome, String), Error>?
func get() async throws -> (Chrome, String) {
if let conn, let (c, _) = try? await conn.value, !c.lock.withLock({ c.closed }) {
return (c, "")
}
let t = Task { try await openChrome() }
conn = t
return try await t.value
}
}
let chromeLink = ChromeLink()
func evaluate(_ c: Chrome, _ t: TabState, _ code: String) async throws -> String {
let r = try await c.send("Runtime.evaluate", ["expression": code, "returnByValue": true, "awaitPromise": true, "replMode": true, "userGesture": true], session: t.session)
if let d = r.dict("exceptionDetails") {
throw ToolError(d.dict("exception")?.string("description") ?? d.string("text") ?? "exception")
}
let v = r.dict("result") ?? [:]
if v.string("type") == "undefined" {
return "undefined"
}
return remoteText(v)
}
func size(_ n: Int) -> String {
if n >= 1 << 20 {
return String(format: "%.1fM", Double(n) / Double(1 << 20))
}
if n >= 1 << 10 {
return String(format: "%.1fk", Double(n) / Double(1 << 10))
}
return "\(n)"
}
func networkList(_ t: TabState, _ filter: String?) -> String {
let first = t.reqs.first?.start ?? 0
var out = "# status type at_ms ms bytes method url\n"
for r in t.reqs where filter.map({ r.url.lowercased().contains($0.lowercased()) }) ?? true {
let status = r.error.map { "failed(\($0))" } ?? r.status.map { "\($0)\(r.cached ? "(cache)" : "")" } ?? "pending"
let ms = r.end.map { String(Int((($0 - r.start) * 1000).rounded())) } ?? "-"
out += "\(r.seq) \(status) \(r.type) \(Int(((r.start - first) * 1000).rounded())) \(ms) \(size(r.bytes)) \(r.method) \(r.url)\n"
}
return out
}
func headerLines(_ h: [String: Any]) -> String {
return h.keys.sorted().map { " \($0): \(h[$0] ?? "")" }.joined(separator: "\n")
}
func networkGet(_ c: Chrome, _ t: TabState, _ n: Int) async throws -> String {
let snap: (head: String, resp: String, id: String, hasPost: Bool, done: Bool, mime: String)? = c.lock.withLock {
guard let r = t.reqs.first(where: { $0.seq == n }) else {
return nil
}
var head = "#\(r.seq) \(r.method) \(r.url)\n"
head += "type \(r.type), status \(r.error.map { "failed: \($0)" } ?? r.status.map(String.init) ?? "pending")"
head += r.proto.isEmpty ? "" : " \(r.proto)"
head += r.remote.isEmpty ? "" : ", from \(r.remote)"
head += r.cached ? ", from cache" : ""
head += ", \(size(r.bytes)) transferred"
head += r.end.map { ", took \(Int((($0 - r.start) * 1000).rounded()))ms" } ?? ""
head += "\nrequest headers:\n\(headerLines(r.reqHeaders))\n"
return (head, "response headers:\n\(headerLines(r.respHeaders))\n", r.id, r.hasPost, r.status != nil && r.error == nil && !r.redirected, r.mime)
}
guard let snap else {
throw ToolError("no request \(n); network lists them")
}
var out = snap.head
if snap.hasPost, let post = try? await c.send("Network.getRequestPostData", ["requestId": snap.id], session: t.session) {
out += "request body:\n\(post.string("postData") ?? "")\n"
}
out += snap.resp
guard snap.done else {
return out
}
do {
let b = try await c.send("Network.getResponseBody", ["requestId": snap.id], session: t.session)
let body = b.string("body") ?? ""
let bytes: Data = b.bool("base64Encoded") ? Data(base64Encoded: body) ?? Data() : Data(body.utf8)
guard let text = String(data: bytes, encoding: .utf8) else {
return out + "response body: \(bytes.count) bytes of \(snap.mime), not text\n"
}
let limit = 64 << 10
if bytes.count <= limit {
return out + "response body (\(snap.mime), \(bytes.count) bytes):\n\(text)\n"
}
return out + "response body (\(snap.mime), \(bytes.count) bytes, the first \(limit) shown):\n\(String(decoding: bytes.prefix(limit), as: UTF8.self))\n"
} catch let e as ToolError {
return out + "response body: \(e.message)\n"
}
}
let traceCategories = [
"devtools.timeline", "disabled-by-default-devtools.timeline", "disabled-by-default-devtools.timeline.frame",
"disabled-by-default-devtools.timeline.stack", "toplevel", "v8.execute", "disabled-by-default-v8.cpu_profiler",
"blink.console", "blink.user_timing", "loading", "latencyInfo",
]
func traceStop(_ c: Chrome, _ t: TabState, _ target: String) async throws -> String {
c.lock.withLock { t.traceStream = nil }
_ = try await c.send("Tracing.end", session: t.session)
let start = Date()
var stream: String?
while stream == nil {
if Date().timeIntervalSince(start) > 60 {
throw ToolError("Chrome didn't finish the trace within 60s")
}
await pause(100)
stream = c.lock.withLock { t.traceStream }
}
var data = Data()
while true {
let r = try await c.send("IO.read", ["handle": stream!, "size": 4 << 20], session: t.session)
let chunk = r.string("data") ?? ""
data.append(r.bool("base64Encoded") ? Data(base64Encoded: chunk) ?? Data() : Data(chunk.utf8))
if r.bool("eof") {
break
}
}
_ = try? await c.send("IO.close", ["handle": stream!], session: t.session)
let url = FileManager.default.temporaryDirectory.appending(path: "byre-trace-\(UUID().uuidString.prefix(8).lowercased()).json")
try data.write(to: url)
return "saved the trace to \(url.path) (\(size(data.count)); Chrome DevTools' Performance panel opens it)\n" + traceSummary(data, frame: target)
}
let taskParts: Set<String> = [
"EvaluateScript", "v8.compile", "v8.compileModule", "v8.evaluateModule", "FunctionCall",
"ParseHTML", "ParseAuthorStyleSheet", "UpdateLayoutTree", "Layout",
"PrePaint", "Paint", "Layerize", "MajorGC", "MinorGC", "V8.GC_MARK_COMPACTOR", "V8.GC_SCAVENGER",
]
func traceSummary(_ data: Data, frame: String) -> String {
let root = try? JSONSerialization.jsonObject(with: data)
let events = (root as? [[String: Any]]) ?? ((root as? [String: Any])?["traceEvents"] as? [[String: Any]]) ?? []
guard !events.isEmpty else {
return "the trace has no events"
}
var main: [Int: Int] = [:]
for e in events where e.string("ph") == "M" && e.string("name") == "thread_name" && e.dict("args")?.string("name") == "CrRendererMain" {
if let pid = e.int("pid"), let tid = e.int("tid") {
main[pid] = tid
}
}
let frameOf: ([String: Any]) -> String? = { $0.dict("args")?.string("frame") ?? $0.dict("args")?.dict("data")?.string("frame") }
let nav = events.last { e in
let url = e.dict("args")?.dict("data")?.string("documentLoaderURL") ?? ""
return e.string("name") == "navigationStart" && frameOf(e) == frame && !url.isEmpty && !url.hasPrefix("about:")
}
var out = ""
let base: Double
var pid: Int?
if let nav {
base = nav.double("ts") ?? 0
pid = nav.int("pid")
out += "times are ms from the navigation to \(nav.dict("args")?.dict("data")?.string("documentLoaderURL") ?? "")\n"
} else {
base = events.compactMap { $0.double("ts") }.filter { $0 > 0 }.min() ?? 0
let frames = events.first { $0.string("name") == "TracingStartedInBrowser" }?.dict("args")?.dict("data")?["frames"] as? [[String: Any]]
pid = frames?.first { $0.string("frame") == frame }?.int("processId")
out += "no page navigation in the trace; times are ms from the trace's start\n"
}
guard let pid, let tid = main[pid] else {
return out + "couldn't find the page's main thread in the trace"
}
let ms: (Double) -> Int = { Int((($0 - base) / 1000).rounded()) }
let mine = events.filter { $0.int("pid") == pid && ($0.double("ts") ?? 0) >= base }
var marks: [String] = []
for (label, name, last) in [("FCP", "firstContentfulPaint", false), ("LCP", "largestContentfulPaint::Candidate", true), ("DOMContentLoaded", "MarkDOMContent", false), ("load", "MarkLoad", false)] {
let hits = mine.filter { $0.string("name") == name && frameOf($0) == frame }
if let e = last ? hits.last : hits.first, let ts = e.double("ts") {
var s = "\(label) \(ms(ts))"
if label == "LCP", let d = e.dict("args")?.dict("data"), let type = d.string("type") {
s += " (\(type), \(d.int("size") ?? 0)px²)"
}
marks.append(s)
}
}
out += marks.isEmpty ? "no paint or load marks in the trace\n" : marks.joined(separator: " ") + "\n"
let thread = mine.filter { $0.int("tid") == tid && $0.string("ph") == "X" }
let long = thread.filter { $0.string("name") == "RunTask" && ($0.double("dur") ?? 0) >= 50_000 }.sorted { ($0.double("dur") ?? 0) > ($1.double("dur") ?? 0) }
let busy = thread.filter { $0.string("name") == "RunTask" }.reduce(0.0) { $0 + ($1.double("dur") ?? 0) }
out += "main thread busy \(Int((busy / 1000).rounded()))ms in tasks; \(long.count) long tasks (50ms or more)"
if long.isEmpty {
return out
}
out += ", longest first:\n"
for task in long.prefix(10) {
let ts = task.double("ts") ?? 0
let end = ts + (task.double("dur") ?? 0)
out += " \(Int(((task.double("dur") ?? 0) / 1000).rounded()))ms at \(ms(ts))"
let part = thread.filter { e in
let s = e.double("ts") ?? 0
return taskParts.contains(e.string("name") ?? "") && s >= ts && s < end
}.max { ($0.double("dur") ?? 0) < ($1.double("dur") ?? 0) }
if let part {
let d = part.dict("args")?.dict("data")
let what = [d?.string("url"), d?.string("functionName"), d?.string("type")].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " ")
out += ": mostly \(part.string("name") ?? "") \(Int(((part.double("dur") ?? 0) / 1000).rounded()))ms\(what.isEmpty ? "" : " " + what)"
}
out += "\n"
}
return out
}
func shrink(_ png: Data, _ maxEdge: Double) throws -> (Data, String) {
guard let src = CGImageSourceCreateWithData(png as CFData, nil),
let full = CGImageSourceCreateImageAtIndex(src, 0, nil)
else {
throw ToolError("Chrome's screenshot isn't a PNG")
}
guard Double(max(full.width, full.height)) > maxEdge else {
return (png, "page viewport, image \(full.width)x\(full.height)")
}
let opts: [CFString: Any] = [kCGImageSourceCreateThumbnailFromImageAlways: true, kCGImageSourceThumbnailMaxPixelSize: Int(maxEdge)]
guard let img = CGImageSourceCreateThumbnailAtIndex(src, 0, opts as CFDictionary) else {
throw ToolError("couldn't scale Chrome's screenshot")
}
let data = NSMutableData()
let dest = CGImageDestinationCreateWithData(data, UTType.png.identifier as CFString, 1, nil)!
CGImageDestinationAddImage(dest, img, nil)
CGImageDestinationFinalize(dest)
return (data as Data, "page viewport, image \(img.width)x\(img.height) scaled from \(full.width)x\(full.height)")
}
func chromeTool(_ args: [String: Any]) async throws -> [Content] {
let op = try args.require("op", args.string)
let maxEdge = args.double("max_edge") ?? 1568
if !maxEdge.isFinite || maxEdge < 16 || maxEdge > 8192 {
throw ToolError("max_edge must be between 16 and 8192 pixels")
}
let (c, note) = try await chromeLink.get()
var log: [String] = note.isEmpty ? [] : [note]
if op == "tabs" {
var out = "tab title | url\n"
for p in try await c.pages() {
out += "\(p.string("targetId") ?? "") \(p.string("title") ?? "") | \(p.string("url") ?? "")\n"
}
log.append(out)
if !args.bool("capture") && args["wait_for"] == nil && args["wait_ms"] == nil {
return [.text(log.joined(separator: "\n"))]
}
}
let visible = ["select", "close", "cdp"].contains(op) || (op == "navigate" && args.bool("new_tab")) || args.bool("capture")
let lock = visible ? try await lockInput() : nil
defer {
if let lock {
close(lock)
}
}
var tabId = args.string("tab")
if op == "navigate", args.bool("new_tab") {
let r = try await c.send("Target.createTarget", ["url": "about:blank"])
tabId = r.string("targetId")
}
let (target, t) = try await c.tab(tabId)
switch op {
case "tabs":
break
case "navigate":
let url = try args.require("url", args.string)
c.lock.withLock { t.clear() }
let r = try await c.send("Page.navigate", ["url": url], session: t.session)
if let err = r.string("errorText") {
throw ToolError("navigating to \(url) failed: \(err)")
}
let timeout = Double(try args.ms("timeout_ms") ?? 15000) / 1000
let start = Date()
await pause(300)
var loaded = false
while !loaded, Date().timeIntervalSince(start) < timeout {
loaded = (try? await evaluate(c, t, "document.readyState")) == "complete"
if !loaded {
await pause(200)
}
}
log.append(String(format: loaded ? "loaded after %.1fs" : "still loading after %.1fs", Date().timeIntervalSince(start)))
log.append("tab \(target): " + (try await evaluate(c, t, "document.title + ' | ' + location.href")))
case "js":
log.append(try await evaluate(c, t, try args.require("code", args.string)))
case "select":
_ = try await c.send("Target.activateTarget", ["targetId": target])
log.append("selected tab \(target)")
case "close":
_ = try await c.send("Target.closeTarget", ["targetId": target])
return [.text(log.joined() + "closed tab \(target)")]
case "network":
if let n = args.int("request") {
log.append(try await networkGet(c, t, n))
} else {
log.append(c.lock.withLock { networkList(t, args.string("filter")) })
}
case "console":
let lines = c.lock.withLock { t.console }
log.append(lines.isEmpty ? "no console messages" : lines.joined(separator: "\n"))
case "cdp":
let method = try args.require("method", args.string)
let r = try await c.send(method, args.dict("params") ?? [:], session: t.session)
let d = try JSONSerialization.data(withJSONObject: r, options: [.sortedKeys, .withoutEscapingSlashes])
log.append(String(decoding: d, as: UTF8.self))
case "trace_start":
_ = try await c.send("Tracing.start", ["transferMode": "ReturnAsStream", "traceConfig": ["includedCategories": traceCategories]], session: t.session)
log.append("tracing tab \(target); trace_stop ends it")
case "trace_stop":
log.append(try await traceStop(c, t, target))
default:
throw ToolError("unknown op \(op.debugDescription)")
}
let present: (String) async throws -> Bool = { needle in
(try? await evaluate(c, t, "String((document.title + '\\n' + (document.body ? document.body.innerText : '')).toLowerCase().includes(\(jsString(needle.lowercased()))))")) == "true"
}
if let note = try await waitFor(args, acted: false, present) {
log.append(note)
}
guard args.bool("capture") else {
return [.text(log.joined(separator: "\n"))]
}
_ = try await c.send("Page.bringToFront", session: t.session)
let shot = try await c.send("Page.captureScreenshot", ["format": "png"], session: t.session)
let (png, shotNote) = try shrink(Data(base64Encoded: shot.string("data") ?? "") ?? Data(), maxEdge)
log.append(shotNote)
if args.bool("save") {
log.append("saved to \(try save(png))")
}
2026-10-18 07:47:10 -07:00
return [.text(log.joined(separator: "\n")), .png(png)]
}