733 lines
29 KiB
Swift
733 lines
29 KiB
Swift
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)
|
|
return [.text(log.joined(separator: "\n")), .png(png)]
|
|
}
|