From 8a1a6b8fb4c94d4d32dc9f628eb644723ace69e2 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:58:02 +1000 Subject: [PATCH] feat(desktop): add browser tabs and Chromium diagnostics (#44838) --- bun.lock | 172 ++- packages/app/package.json | 1 + packages/app/src/desktop.ts | 10 + packages/app/src/runtime/i18n/en.ts | 10 +- .../app/src/runtime/platform/browser-pane.ts | 27 + .../app/src/runtime/platform/platform.tsx | 4 + .../app/src/session/browser/attachments.ts | 158 +++ packages/app/src/session/browser/model.ts | 115 ++ packages/app/src/session/browser/pane.tsx | 185 +++ .../src/session/files/session-side-panel.tsx | 246 ++-- packages/app/src/session/files/tab.tsx | 13 +- packages/app/src/session/helpers.test.ts | 47 + packages/app/src/session/helpers.ts | 26 +- packages/app/src/session/model.ts | 7 + packages/app/src/session/review/view.tsx | 8 +- packages/app/src/session/screen.tsx | 4 +- .../settings/experimental/experimental.tsx | 18 + packages/app/src/settings/model.test.ts | 7 + packages/app/src/settings/model.tsx | 9 + packages/app/src/shell/routes/routes.tsx | 5 +- packages/app/src/shell/state/session-tabs.ts | 4 + packages/desktop/package.json | 7 +- packages/desktop/src/main/browser-chromium.ts | 1046 +++++++++++++++++ .../src/main/browser-pane-policy.test.ts | 15 + packages/desktop/src/main/browser-pane.ts | 389 ++++++ packages/desktop/src/main/browser/analysis.ts | 229 ++++ packages/desktop/src/main/browser/cdp.ts | 82 ++ packages/desktop/src/main/browser/corners.ts | 23 + .../desktop/src/main/browser/diagnostics.ts | 415 +++++++ packages/desktop/src/main/browser/errors.ts | 48 + packages/desktop/src/main/browser/files.ts | 119 ++ .../desktop/src/main/browser/lighthouse.ts | 89 ++ packages/desktop/src/main/browser/network.ts | 72 ++ packages/desktop/src/main/browser/policy.ts | 17 + .../desktop/src/main/browser/profiling.ts | 282 +++++ .../desktop/src/main/ipc-handlers/events.ts | 21 + packages/desktop/src/renderer/api-types.ts | 7 + packages/desktop/src/renderer/api.ts | 5 + .../desktop/src/renderer/platform/index.ts | 29 + .../desktop/src/shared/ipc-rpc/browser.ts | 38 + packages/desktop/src/shared/ipc-rpc/events.ts | 9 +- .../desktop/test/browser-analysis.test.ts | 77 ++ packages/desktop/test/browser-errors.test.ts | 81 ++ packages/desktop/test/browser-native.test.ts | 123 ++ packages/desktop/test/browser/contract.ts | 19 + packages/desktop/test/browser/native.ts | 581 +++++++++ packages/desktop/test/browser/plugin/index.ts | 55 + packages/desktop/test/browser/server.ts | 31 + packages/desktop/test/browser/targets.ts | 138 +++ .../session-ui/src/tools/tool-renderer.tsx | 5 + packages/ui/src/i18n/en.ts | 1 + .../ui/src/icons/icon/additional-icons.ts | 2 +- 52 files changed, 5041 insertions(+), 90 deletions(-) create mode 100644 packages/app/src/runtime/platform/browser-pane.ts create mode 100644 packages/app/src/session/browser/attachments.ts create mode 100644 packages/app/src/session/browser/model.ts create mode 100644 packages/app/src/session/browser/pane.tsx create mode 100644 packages/desktop/src/main/browser-chromium.ts create mode 100644 packages/desktop/src/main/browser-pane-policy.test.ts create mode 100644 packages/desktop/src/main/browser-pane.ts create mode 100644 packages/desktop/src/main/browser/analysis.ts create mode 100644 packages/desktop/src/main/browser/cdp.ts create mode 100644 packages/desktop/src/main/browser/corners.ts create mode 100644 packages/desktop/src/main/browser/diagnostics.ts create mode 100644 packages/desktop/src/main/browser/errors.ts create mode 100644 packages/desktop/src/main/browser/files.ts create mode 100644 packages/desktop/src/main/browser/lighthouse.ts create mode 100644 packages/desktop/src/main/browser/network.ts create mode 100644 packages/desktop/src/main/browser/policy.ts create mode 100644 packages/desktop/src/main/browser/profiling.ts create mode 100644 packages/desktop/src/shared/ipc-rpc/browser.ts create mode 100644 packages/desktop/test/browser-analysis.test.ts create mode 100644 packages/desktop/test/browser-errors.test.ts create mode 100644 packages/desktop/test/browser-native.test.ts create mode 100644 packages/desktop/test/browser/contract.ts create mode 100644 packages/desktop/test/browser/native.ts create mode 100644 packages/desktop/test/browser/plugin/index.ts create mode 100644 packages/desktop/test/browser/server.ts create mode 100644 packages/desktop/test/browser/targets.ts diff --git a/bun.lock b/bun.lock index 86f825420b1..a139e4be70c 100644 --- a/bun.lock +++ b/bun.lock @@ -63,6 +63,7 @@ "@ibm/plex": "6.4.1", "@kobalte/core": "catalog:", "@opencode-ai/client": "workspace:*", + "@opencode-ai/plugin-browser": "workspace:*", "@opencode-ai/schema": "workspace:*", "@opencode-ai/session-ui": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -416,6 +417,7 @@ "electron-store": "11.0.2", "electron-updater": "6.8.9", "electron-window-state": "^5.0.3", + "lighthouse": "13.4.1", }, "devDependencies": { "@brendonovich/vite-plugin-opencode": "0.1.3", @@ -423,6 +425,8 @@ "@lydell/node-pty": "catalog:", "@opencode-ai/app": "workspace:*", "@opencode-ai/client": "workspace:*", + "@opencode-ai/plugin-browser": "workspace:*", + "@opencode-ai/schema": "workspace:*", "@opencode-ai/ui": "workspace:*", "@sentry/solid": "catalog:", "@sentry/vite-plugin": "catalog:", @@ -433,12 +437,14 @@ "@types/node": "catalog:", "@typescript/native-preview": "catalog:", "app-builder-lib": "26.15.7", + "devtools-protocol": "0.0.1687809", "drizzle-kit": "catalog:", "drizzle-orm": "catalog:", "effect": "catalog:", "electron": "42.10.1", "electron-builder": "26.15.7", "electron-vite": "6.0.0-beta.1", + "puppeteer-core": "25.9.0", "solid-js": "catalog:", "typescript": "~5.6.2", "vite": "8.2.2", @@ -1840,6 +1846,16 @@ "@fontsource/inter": ["@fontsource/inter@5.3.0", "", {}, "sha512-RofMylZmjlJEfELXeNHFWBRcSs75rGU/6bV2S2jfnvv/3rPXPGe0LgUJTklcHZ9lM4OZmAVFhcJPnACfb91A3g=="], + "@formatjs/ecma402-abstract": ["@formatjs/ecma402-abstract@2.3.6", "", { "dependencies": { "@formatjs/fast-memoize": "2.2.7", "@formatjs/intl-localematcher": "0.6.2", "decimal.js": "^10.4.3", "tslib": "^2.8.0" } }, "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw=="], + + "@formatjs/fast-memoize": ["@formatjs/fast-memoize@2.2.7", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ=="], + + "@formatjs/icu-messageformat-parser": ["@formatjs/icu-messageformat-parser@2.11.4", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/icu-skeleton-parser": "1.8.16", "tslib": "^2.8.0" } }, "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw=="], + + "@formatjs/icu-skeleton-parser": ["@formatjs/icu-skeleton-parser@1.8.16", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "tslib": "^2.8.0" } }, "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ=="], + + "@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.6.2", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA=="], + "@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="], "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], @@ -2226,6 +2242,8 @@ "@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.214.0", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/otlp-exporter-base": "0.214.0", "@opentelemetry/otlp-transformer": "0.214.0", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-kIN8nTBMgV2hXzV/a20BCFilPZdAIMYYJGSgfMMRm/Xa+07y5hRDS2Vm12A/z8Cdu3Sq++ZvJfElokX2rkgGgw=="], + "@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.220.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.220.0", "import-in-the-middle": "^3.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw=="], + "@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.214.0", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/otlp-transformer": "0.214.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-u1Gdv0/E9wP+apqWf7Wv2npXmgJtxsW2XL0TEv9FZloTZRuMBKmu8cYVXwS4Hm3q/f/3FuCnPTgiwYvIqRSpRg=="], "@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-logs": "0.214.0", "@opentelemetry/sdk-metrics": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1", "protobufjs": "^7.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DSaYcuBRh6uozfsWN3R8HsN0yDhCuWP7tOFdkUOVaWD1KVJg8m4qiLUsg/tNhTLS9HUYUcwNpwL2eroLtsZZ/w=="], @@ -2236,6 +2254,8 @@ "@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ=="], + "@opentelemetry/sdk-trace": ["@opentelemetry/sdk-trace@2.11.0", "", { "dependencies": { "@opentelemetry/core": "2.11.0", "@opentelemetry/resources": "2.11.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-fFnTqGm8/G73GQVnxYi7LXa1ZVYEUvgL6XI1LpvV0bPC7WQ/ZGgKxCSl8FnlZBKto9JHHEFTO6s6CUpvvtwFrA=="], + "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw=="], "@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.6.1", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/core": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-Hh2i4FwHWRFhnO2Q/p6svMxy8MPsNCG0uuzUY3glqm0rwM0nQvbTO1dXSp9OqQoTKXcQzaz9q1f65fsurmOhNw=="], @@ -2508,6 +2528,8 @@ "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="], + "@paulirish/trace_engine": ["@paulirish/trace_engine@0.0.65", "", { "dependencies": { "legacy-javascript": "latest", "third-party-web": "latest" } }, "sha512-Qsm6F5C8xf6ZzQXbQc2+wcpe6sggfs/gvc/ytqSurdvYg3kyW0ECHCqE0CWBKZpqgjVfPNX9c7SCS3r2nEIRGg=="], + "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.9.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-AKvPMOM7LfK0uFe1m7o7+veOa8xQGPqsqOrKi3QKgCElzwjGp39mbhr2g7mt/v/mXQHiIvJmDj5cJS173x8Q9Q=="], "@peculiar/json-schema": ["@peculiar/json-schema@1.1.12", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w=="], @@ -2564,6 +2586,8 @@ "@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="], + "@puppeteer/browsers": ["@puppeteer/browsers@3.2.1", "", { "dependencies": { "modern-tar": "^0.8.0", "yargs": "^18.0.0" }, "peerDependencies": { "proxy-agent": ">=8.0.1", "yauzl": "^2.10.0 || ^3.4.0" }, "optionalPeers": ["proxy-agent", "yauzl"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-KDz+3qDRdBAlRlMjmKyj6dEs33YHTk/xRHEENSXq6TNnhgoU15ruSHtEBeVF6OZ9tBDY55Se4P0nFMNsipzU9A=="], + "@radix-ui/colors": ["@radix-ui/colors@1.0.1", "", {}, "sha512-xySw8f0ZVsAEP+e7iLl3EvcBXX7gsIlC1Zso/sPBW9gIWerBTgz6axrjU+MZ39wD+WFi5h5zdWpsg3+hwt2Qsg=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.0.1", "", { "dependencies": { "@babel/runtime": "^7.13.10" } }, "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw=="], @@ -2762,10 +2786,18 @@ "@sentry/feedback": ["@sentry/feedback@10.71.0", "", { "dependencies": { "@sentry/core": "10.71.0" } }, "sha512-4+zMAmn1DcbYBtFE0pWt6zo8vWoBHTuP/UhtCMTCoGB4sVYvmwRxv9Hc9OpRHSzE9kSl8beD0zKFxuoVHizKQQ=="], + "@sentry/node": ["@sentry/node@10.73.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.1", "@opentelemetry/instrumentation": "^0.220.0", "@opentelemetry/sdk-trace-base": "^2.9.0", "@sentry/conventions": "^0.16.0", "@sentry/core": "10.73.0", "@sentry/node-core": "10.73.0", "@sentry/opentelemetry": "10.73.0", "@sentry/server-utils": "10.73.0", "import-in-the-middle": "^3.0.0" } }, "sha512-jiMJ6GgXDw6UMGzJY+o0c8OoeA9OfHqZ/xEpHfDqy75hn+9CEkRkbNGCIdMvsc7wW/Se1Os394hHfTpU+YEskg=="], + + "@sentry/node-core": ["@sentry/node-core@10.73.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0", "@sentry/core": "10.73.0", "@sentry/opentelemetry": "10.73.0", "import-in-the-middle": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/core", "@opentelemetry/exporter-trace-otlp-http", "@opentelemetry/instrumentation", "@opentelemetry/sdk-trace-base"] }, "sha512-GHAGUmZPmm6FKfxfv2maVVJ/A99YAmd7oFOuKMDmITI6O/Msl6JB3vPTEpopVAHLW0LYJQBOjddL9C7o+Jt44g=="], + + "@sentry/opentelemetry": ["@sentry/opentelemetry@10.73.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0", "@sentry/core": "10.73.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" } }, "sha512-fQouPQKsH0CQrw6oAn1k0Z2I+tgyochCovifr5qNS69i0OzjknLa03WJyiZ/IuzXc4AVa5jAKfOeE9slABz8Qw=="], + "@sentry/replay": ["@sentry/replay@10.71.0", "", { "dependencies": { "@sentry/browser-utils": "10.71.0", "@sentry/core": "10.71.0" } }, "sha512-ytEgVg7isvavQL6hgsgYeuSCkcA3EyOKm9lMLQOZTLkiUCtFT/ItWwGNhzS46vQ6wsTv3Uc0Y1JlcJHSFKe/Kg=="], "@sentry/replay-canvas": ["@sentry/replay-canvas@10.71.0", "", { "dependencies": { "@sentry/core": "10.71.0", "@sentry/replay": "10.71.0" } }, "sha512-YTqesxBh9arExI49LrTm4Y2/xPX40q2GWi0gWRw6lNfYtyNz7/ZfHi8/1N6JEc8dldTslqFhII6ZFecUyU9iaA=="], + "@sentry/server-utils": ["@sentry/server-utils@10.73.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0", "@sentry/core": "10.73.0" } }, "sha512-QskripdKFbM/+gipC6mpa2crLwL7+VbkX84IpHg2z9UlYQ1kNKd3aMT+Qk9NLRSw/zu1rSIAvlbWfx4D3rgNAA=="], + "@sentry/solid": ["@sentry/solid@10.71.0", "", { "dependencies": { "@sentry/browser": "10.71.0", "@sentry/conventions": "^0.16.0", "@sentry/core": "10.71.0" }, "peerDependencies": { "@solidjs/router": ">=0.13.4 <2.0.0-0", "@tanstack/solid-router": "^1.132.27", "solid-js": "^1.8.4" }, "optionalPeers": ["@solidjs/router", "@tanstack/solid-router"] }, "sha512-/vVy9GmB/RejmomWEMZ+P3xlwrhfpIYJ3F0+UcTE836uuH92r8CSnmucdGQqke8c/6ullQFjZrLd/dTDPXj1aA=="], "@sentry/vite-plugin": ["@sentry/vite-plugin@5.4.0", "", { "dependencies": { "@sentry/bundler-plugins": "^10.64.0" } }, "sha512-fFJgCxs5hDyAm9BbZJ+LbA+LK2tjX5OoD0v0ARU4StR6KQmGUduoPs69yJ9AfqZ0om3Rlp5JDliiwFcNkasORA=="], @@ -3332,6 +3364,8 @@ "ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="], + "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], + "ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -3560,10 +3594,16 @@ "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + "chrome-launcher": ["chrome-launcher@1.2.1", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^2.0.1" }, "bin": { "print-chrome-path": "bin/print-chrome-path.cjs" } }, "sha512-qmFR5PLMzHyuNJHwOloHPAHhbaNglkfeV/xDtt5b7xiFFyU1I+AZZX0PYseMuhenJSSirgxELYIbswcoc+5H4A=="], + + "chromium-bidi": ["chromium-bidi@17.0.2", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg=="], + "chromium-pickle-js": ["chromium-pickle-js@0.2.0", "", {}, "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw=="], "ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], + "cjs-module-lexer": ["cjs-module-lexer@2.2.1", "", {}, "sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q=="], + "classnames": ["classnames@2.3.2", "", {}, "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw=="], "clean-css": ["clean-css@5.3.3", "", { "dependencies": { "source-map": "~0.6.0" } }, "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg=="], @@ -3620,6 +3660,8 @@ "config-chain": ["config-chain@1.1.13", "", { "dependencies": { "ini": "^1.3.4", "proto-list": "~1.2.1" } }, "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ=="], + "configstore": ["configstore@7.1.0", "", { "dependencies": { "atomically": "^2.0.3", "dot-prop": "^9.0.0", "graceful-fs": "^4.2.11", "xdg-basedir": "^5.1.0" } }, "sha512-N4oog6YJWbR9kGyXvS7jEykLDXIE2C0ILYqNBZBp9iwiJpoCBWYsuAdW6PPFn6w06jjnC+3JstVvWHO4cZqvRg=="], + "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], @@ -3656,6 +3698,8 @@ "crypto-random-string": ["crypto-random-string@2.0.0", "", {}, "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA=="], + "csp_evaluator": ["csp_evaluator@1.1.8", "", {}, "sha512-EwOnfYuNbTytvbMKsLixTrRgnjOa0WZCxGy8A9nnSYAicrdwn+T/epU/yjgymmOxlgKnvH+8wXt+7p/8ak5Feg=="], + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], "css-selector-parser": ["css-selector-parser@3.3.0", "", {}, "sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g=="], @@ -3760,6 +3804,8 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], @@ -3808,6 +3854,8 @@ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + "devtools-protocol": ["devtools-protocol@0.0.1687809", "", {}, "sha512-t7kSb+UKYdyxcmqy98U2P86ne6rCve3DIJ4+lp54i/hhClgFuG35JrLj23rO14Jw3sWDsp7i+22QhNU0+/qk8g=="], + "dfa": ["dfa@1.2.0", "", {}, "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="], "diacritics": ["diacritics@1.3.0", "", {}, "sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA=="], @@ -3912,6 +3960,8 @@ "enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="], + "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], @@ -3956,7 +4006,7 @@ "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], - "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], @@ -4258,6 +4308,8 @@ "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "http-link-header": ["http-link-header@1.1.4", "", {}, "sha512-xT3GPW6/ZbGuw4UvwHqErSCEjNUlwbQJuZn9/q5U4WEKfp2kENVCAlousG1zLxHeaQ/ffOHUNpWamvkbBW0eNw=="], + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="], @@ -4282,8 +4334,12 @@ "ignore-walk": ["ignore-walk@8.0.0", "", { "dependencies": { "minimatch": "^10.0.3" } }, "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A=="], + "image-ssim": ["image-ssim@0.2.0", "", {}, "sha512-W7+sO6/yhxy83L0G7xR8YAc5Z5QFtYEXXRV6EaE8tuYBZJnA3gVgp3q7X7muhLZVodeb9UfvjSbwt9VJwjIYAg=="], + "immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="], + "import-in-the-middle": ["import-in-the-middle@3.4.0", "", { "dependencies": { "cjs-module-lexer": "^2.2.0", "es-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-Xfjwfarhe+LGmoaof+sexeNo3sGRysb5x56WrZLwHtmqT0OilSKtVWkv0lrCcMgSKyKP9oBogBAisHZvtJH0aw=="], + "import-local": ["import-local@3.2.0", "", { "dependencies": { "pkg-dir": "^4.2.0", "resolve-cwd": "^3.0.0" }, "bin": { "import-local-fixture": "fixtures/cli.js" } }, "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA=="], "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], @@ -4302,6 +4358,8 @@ "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + "intl-messageformat": ["intl-messageformat@10.7.18", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/fast-memoize": "2.2.7", "@formatjs/icu-messageformat-parser": "2.11.4", "tslib": "^2.8.0" } }, "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g=="], + "ip-address": ["ip-address@10.5.0", "", {}, "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -4420,12 +4478,16 @@ "jose": ["jose@6.0.11", "", {}, "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg=="], + "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], + "js-base64": ["js-base64@3.7.7", "", {}, "sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw=="], "js-beautify": ["js-beautify@1.15.4", "", { "dependencies": { "config-chain": "^1.1.13", "editorconfig": "^1.0.4", "glob": "^10.4.2", "js-cookie": "^3.0.5", "nopt": "^7.2.1" }, "bin": { "js-beautify": "js/bin/js-beautify.js", "css-beautify": "js/bin/css-beautify.js", "html-beautify": "js/bin/html-beautify.js" } }, "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA=="], "js-cookie": ["js-cookie@3.0.8", "", {}, "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw=="], + "js-library-detector": ["js-library-detector@6.7.0", "", {}, "sha512-c80Qupofp43y4cJ7+8TTDN/AsDwLi5oOm/plBrWI+iQt485vKXCco+yVmOwEgdo9VOdsYTuV0UlTeetVPTriXA=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@3.15.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag=="], @@ -4498,8 +4560,16 @@ "leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="], + "legacy-javascript": ["legacy-javascript@0.0.1", "", {}, "sha512-lPyntS4/aS7jpuvOlitZDFifBCb4W8L/3QU0PLbUTUj+zYah8rfVjYic88yG7ZKTxhS5h9iz7duT8oUXKszLhg=="], + "leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="], + "lighthouse": ["lighthouse@13.4.1", "", { "dependencies": { "@paulirish/trace_engine": "0.0.65", "@sentry/node": "^10.0.0", "axe-core": "^4.12.1", "chrome-launcher": "^1.2.1", "configstore": "^7.0.0", "csp_evaluator": "1.1.8", "devtools-protocol": "0.0.1663043", "enquirer": "^2.3.6", "http-link-header": "^1.1.1", "intl-messageformat": "^10.5.3", "jpeg-js": "^0.4.4", "js-library-detector": "^6.7.0", "lighthouse-logger": "^2.0.2", "lighthouse-stack-packs": "1.12.3", "lodash-es": "^4.17.21", "lookup-closest-locale": "6.2.0", "open": "^8.4.0", "puppeteer-core": "^25.3.0", "robots-parser": "^3.0.1", "speedline-core": "^1.4.3", "third-party-web": "^0.29.2", "tldts-icann": "^7.4.9", "web-features": "^3.34.0", "ws": "^7.0.0", "yargs": "^17.3.1", "yargs-parser": "^21.0.0" }, "bin": { "lighthouse": "cli/index.js", "smokehouse": "cli/test/smokehouse/frontends/smokehouse-bin.js", "chrome-debug": "core/scripts/manual-chrome-launcher.js" } }, "sha512-fDu8lt3QLK/lTqIxtp1HkzQNJ32rsFHhbadYOepcMZFLgA8oINhxutMbMv8XXnpTOvZ0TXCo4JCk1LDTWaRLnA=="], + + "lighthouse-logger": ["lighthouse-logger@2.0.2", "", { "dependencies": { "debug": "^4.4.1", "marky": "^1.2.2" } }, "sha512-vWl2+u5jgOQuZR55Z1WM0XDdrJT6mzMP8zHUct7xTlWhuQs+eV0g+QL0RQdFjT54zVmbhLCP8vIVpy1wGn/gCg=="], + + "lighthouse-stack-packs": ["lighthouse-stack-packs@1.12.3", "", {}, "sha512-d8IsOpE83kbANgnM+Tp8+x6HcMpX9o2ITBiUERssgzAIFdZCQzs/f4k6D0DLQTE59enml9mbAOU52Wu35exWtg=="], + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], @@ -4546,6 +4616,8 @@ "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + "lookup-closest-locale": ["lookup-closest-locale@6.2.0", "", {}, "sha512-/c2kL+Vnp1jnV6K6RpDTHK3dgg0Tu2VVp+elEiJpjfS1UyY7AjOYHohRug6wT0OpoX2qFgNORndE9RqesfVxWQ=="], + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], @@ -4578,6 +4650,8 @@ "marked-shiki": ["marked-shiki@1.2.1", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-yHxYQhPY5oYaIRnROn98foKhuClark7M373/VpLxiy5TrDu9Jd/LsMwo8w+U91Up4oDb9IXFrP0N1MFRz8W/DQ=="], + "marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="], + "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -4738,8 +4812,14 @@ "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], + "mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], + "modern-tar": ["modern-tar@0.8.4", "", {}, "sha512-gN54ddmyzEg10orwZ2u4OOv+bjpMWdIl5jIkodK97bMq8QBSL5c0D7YX0lT1Ooz+99S7+PvFbnxzdjgHo1r41g=="], + + "module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="], + "motion": ["motion@12.34.5", "", { "dependencies": { "framer-motion": "^12.34.5", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ=="], "motion-dom": ["motion-dom@12.43.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag=="], @@ -5050,6 +5130,8 @@ "pupa": ["pupa@3.3.0", "", { "dependencies": { "escape-goat": "^4.0.0" } }, "sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA=="], + "puppeteer-core": ["puppeteer-core@25.9.0", "", { "dependencies": { "@puppeteer/browsers": "3.2.1", "chromium-bidi": "17.0.2", "devtools-protocol": "0.0.1666840", "typed-query-selector": "^2.12.2", "webdriver-bidi-protocol": "0.4.2", "ws": "^8.21.3" } }, "sha512-U61rCwSMha62CA/Opy6tCx2Fx+ck7ouiKnbpEApzSoLYMoEu9F71nuFpHL55vmIt33/GYm6eKZVhH2ev0nAIeg=="], + "pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="], "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], @@ -5176,6 +5258,8 @@ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], + "resedit": ["resedit@1.7.2", "", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="], "reselect": ["reselect@4.1.8", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="], @@ -5210,6 +5294,8 @@ "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], + "robots-parser": ["robots-parser@3.0.1", "", {}, "sha512-s+pyvQeIKIZ0dx5iJiQk1tPLJAWln39+MI5jtM8wnyws+G5azk+dMnMX0qfbqNetKKNgcWWOdi0sfm+FbQbgdQ=="], + "robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="], "rolldown": ["rolldown@1.2.5", "", { "dependencies": { "@oxc-project/types": "=0.146.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.5", "@rolldown/binding-android-arm64": "1.2.5", "@rolldown/binding-darwin-arm64": "1.2.5", "@rolldown/binding-darwin-x64": "1.2.5", "@rolldown/binding-freebsd-x64": "1.2.5", "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", "@rolldown/binding-linux-arm64-gnu": "1.2.5", "@rolldown/binding-linux-arm64-musl": "1.2.5", "@rolldown/binding-linux-ppc64-gnu": "1.2.5", "@rolldown/binding-linux-s390x-gnu": "1.2.5", "@rolldown/binding-linux-x64-gnu": "1.2.5", "@rolldown/binding-linux-x64-musl": "1.2.5", "@rolldown/binding-openharmony-arm64": "1.2.5", "@rolldown/binding-win32-arm64-msvc": "1.2.5", "@rolldown/binding-win32-x64-msvc": "1.2.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA=="], @@ -5368,6 +5454,8 @@ "spdx-license-ids": ["spdx-license-ids@3.0.23", "", {}, "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw=="], + "speedline-core": ["speedline-core@1.4.3", "", { "dependencies": { "@types/node": "*", "image-ssim": "^0.2.0", "jpeg-js": "^0.4.1" } }, "sha512-DI7/OuAUD+GMpR6dmu8lliO2Wg5zfeh+/xsdyJZCzd8o5JgFUjCeLsBDuZjIQJdwXS3J0L/uZYrELKYqx+PXog=="], + "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], "sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="], @@ -5502,6 +5590,8 @@ "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + "third-party-web": ["third-party-web@0.29.2", "", {}, "sha512-fegtha91tq2DHphyoiBXVHjVi2YG9zFaRnboT9C28tO1en9Y3wJsfspuy40F+u5wl3hHVbw7cnd1b67kEGHb8g=="], + "tiny-async-pool": ["tiny-async-pool@1.3.0", "", { "dependencies": { "semver": "^5.5.0" } }, "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA=="], "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], @@ -5524,6 +5614,10 @@ "titleize": ["titleize@4.0.0", "", {}, "sha512-ZgUJ1K83rhdu7uh7EHAC2BgY5DzoX8V5rTvoWI4vFysggi6YjLe5gUXABPWAU7VkvGP7P/0YiWq+dcPeYDsf1g=="], + "tldts-core": ["tldts-core@7.4.11", "", {}, "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg=="], + + "tldts-icann": ["tldts-icann@7.4.11", "", { "dependencies": { "tldts-core": "^7.4.11" } }, "sha512-1p+NDJ7FUYCliESmsQl9EW5Um8JIyFheiy6Y6ZoHho27d+TLAGFa0gMf1VOVy02vNY1aQo6xeKiaGj45+7P+PA=="], + "tmp": ["tmp@0.2.7", "", {}, "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw=="], "tmp-promise": ["tmp-promise@3.0.3", "", { "dependencies": { "tmp": "^0.2.0" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="], @@ -5586,6 +5680,8 @@ "typed-array-length": ["typed-array-length@1.0.8", "", { "dependencies": { "call-bind": "^1.0.9", "for-each": "^0.3.5", "gopd": "^1.2.0", "is-typed-array": "^1.1.15", "possible-typed-array-names": "^1.1.0", "reflect.getprototypeof": "^1.0.10" } }, "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g=="], + "typed-query-selector": ["typed-query-selector@2.12.2", "", {}, "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ=="], + "typesafe-path": ["typesafe-path@0.2.2", "", {}, "sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA=="], "typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="], @@ -5746,6 +5842,8 @@ "walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="], + "web-features": ["web-features@3.37.0", "", {}, "sha512-47x5zchtpsvvH/EEQ0h3d3E9kZyFxFyk6LkmYHXDZjLF0RAa3OV2s8dMRqphp/PTxxrd/iZwz37dte6xWgV7mQ=="], + "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], "web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="], @@ -5754,6 +5852,8 @@ "webcrypto-core": ["webcrypto-core@1.9.2", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q=="], + "webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.2", "", {}, "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA=="], + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], @@ -5830,6 +5930,8 @@ "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], + "xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="], + "xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="], "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], @@ -6302,6 +6404,12 @@ "@opencode-ai/www/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="], + "@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.220.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w=="], + + "@opentelemetry/sdk-trace/@opentelemetry/core": ["@opentelemetry/core@2.11.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA=="], + + "@opentelemetry/sdk-trace/@opentelemetry/resources": ["@opentelemetry/resources@2.11.0", "", { "dependencies": { "@opentelemetry/core": "2.11.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-Ie7+8q8MDF4FAEQCKVMTx3ReUvxiIAgIiiW3c9JdmP8+HMcDy20puT+AHjexnExgnbvBxjQ9fjkFDWrikJ2jQA=="], + "@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], "@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], @@ -6334,6 +6442,8 @@ "@protobuf-ts/plugin/typescript": ["typescript@3.9.10", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q=="], + "@puppeteer/browsers/yargs": ["yargs@18.1.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg=="], + "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "@sentry/bundler-plugins/dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], @@ -6344,6 +6454,18 @@ "@sentry/cli/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "@sentry/node/@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], + + "@sentry/node/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.11.0", "", { "dependencies": { "@opentelemetry/core": "2.11.0", "@opentelemetry/resources": "2.11.0", "@opentelemetry/sdk-trace": "2.11.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-H19x/TX/LZdqiYOjM7fqtSxwlplC5pgelavqbQdHbhdq0q/AI/TGkM2dfGuuynTXmJPeF2HoZVoPDu+TGoW78A=="], + + "@sentry/node/@sentry/core": ["@sentry/core@10.73.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0" } }, "sha512-FLO1UgH19RyasVpofu612WCOgb2nEH0dZy+R72d7p65XU9i0wxlMKm3+sgfwKmiSJp1Qhilaaxs4Jg6BbiM5HA=="], + + "@sentry/node-core/@sentry/core": ["@sentry/core@10.73.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0" } }, "sha512-FLO1UgH19RyasVpofu612WCOgb2nEH0dZy+R72d7p65XU9i0wxlMKm3+sgfwKmiSJp1Qhilaaxs4Jg6BbiM5HA=="], + + "@sentry/opentelemetry/@sentry/core": ["@sentry/core@10.73.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0" } }, "sha512-FLO1UgH19RyasVpofu612WCOgb2nEH0dZy+R72d7p65XU9i0wxlMKm3+sgfwKmiSJp1Qhilaaxs4Jg6BbiM5HA=="], + + "@sentry/server-utils/@sentry/core": ["@sentry/core@10.73.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0" } }, "sha512-FLO1UgH19RyasVpofu612WCOgb2nEH0dZy+R72d7p65XU9i0wxlMKm3+sgfwKmiSJp1Qhilaaxs4Jg6BbiM5HA=="], + "@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], "@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], @@ -6446,6 +6568,10 @@ "builder-util/js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="], + "chrome-launcher/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + + "chromium-bidi/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "clean-css/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -6464,6 +6590,8 @@ "config-chain/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "configstore/dot-prop": ["dot-prop@9.0.0", "", { "dependencies": { "type-fest": "^4.18.2" } }, "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ=="], + "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], @@ -6508,6 +6636,8 @@ "electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], + "enquirer/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "esast-util-from-js/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], "esbuild-plugin-copy/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], @@ -6552,6 +6682,8 @@ "htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "import-in-the-middle/es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], + "js-beautify/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "js-beautify/nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="], @@ -6560,10 +6692,16 @@ "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], - "matcher/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "lighthouse/devtools-protocol": ["devtools-protocol@0.0.1663043", "", {}, "sha512-33aOY3ZnBP1dgZsshgaL+/XlsQleiFZgyUaDtdZkEa1nbZhVY1MoDeWjk+wxg25fU924l1ZJfoGNmjjeA/5s1w=="], + + "lighthouse/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], + + "lighthouse/ws": ["ws@7.5.13", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA=="], "md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="], + "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + "mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], "micromark-extension-mdxjs/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], @@ -6628,6 +6766,10 @@ "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + "puppeteer-core/devtools-protocol": ["devtools-protocol@0.0.1666840", "", {}, "sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg=="], + + "puppeteer-core/ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + "readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -6696,6 +6838,8 @@ "unplugin/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], + "unused-filename/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + "unused-filename/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="], "unzipper/fs-extra": ["fs-extra@11.3.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g=="], @@ -7212,6 +7356,8 @@ "@opencode-ai/www/wrangler/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="], + "@opentelemetry/instrumentation/@opentelemetry/api-logs/@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], + "@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@oxc-resolver/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], @@ -7220,10 +7366,20 @@ "@pierre/trees/react-dom/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "@puppeteer/browsers/yargs/cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], + + "@puppeteer/browsers/yargs/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], + + "@puppeteer/browsers/yargs/yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], + "@sentry/cli/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "@sentry/cli/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "@sentry/node/@opentelemetry/sdk-trace-base/@opentelemetry/core": ["@opentelemetry/core@2.11.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA=="], + + "@sentry/node/@opentelemetry/sdk-trace-base/@opentelemetry/resources": ["@opentelemetry/resources@2.11.0", "", { "dependencies": { "@opentelemetry/core": "2.11.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-Ie7+8q8MDF4FAEQCKVMTx3ReUvxiIAgIiiW3c9JdmP8+HMcDy20puT+AHjexnExgnbvBxjQ9fjkFDWrikJ2jQA=="], + "@solidjs/start/shiki/@shikijs/core": ["@shikijs/core@1.29.2", "", { "dependencies": { "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.4" } }, "sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ=="], "@solidjs/start/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "oniguruma-to-es": "^2.2.0" } }, "sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A=="], @@ -7388,6 +7544,8 @@ "builder-util/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "chrome-launcher/is-wsl/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], + "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -7424,6 +7582,8 @@ "electron/@electron/get/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], + "enquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "esbuild-plugin-copy/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], "filelist/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="], @@ -7446,6 +7606,12 @@ "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "lighthouse/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="], + + "lighthouse/open/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], + + "lighthouse/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + "miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="], "miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="], @@ -8190,6 +8356,8 @@ "@opencode-ai/www/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260708.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bC/aSAwLy16Vjo24i9XU3aWH+eRgz7NeR5xPKavGbembO18ZywYTQbXh14eXtY6fAqN3RzRG8psijTdhX4xydA=="], + "@puppeteer/browsers/yargs/cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="], "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], diff --git a/packages/app/package.json b/packages/app/package.json index 1f729ea50eb..0963ec879aa 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -65,6 +65,7 @@ "@kobalte/core": "catalog:", "@opencode-ai/client": "workspace:*", "@opencode-ai/schema": "workspace:*", + "@opencode-ai/plugin-browser": "workspace:*", "@opencode-ai/session-ui": "workspace:*", "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", diff --git a/packages/app/src/desktop.ts b/packages/app/src/desktop.ts index 67573782a26..1cea7f62691 100644 --- a/packages/app/src/desktop.ts +++ b/packages/app/src/desktop.ts @@ -4,6 +4,16 @@ export { useCommand } from "./shell/commands/command" export { currentRoute, type LayoutRoute, useCurrentRoute } from "./shell/state/layout" export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./runtime/i18n/language" export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./runtime/platform/platform" +export type { + BrowserPaneCommand, + BrowserPaneEndpoint, + BrowserPaneEvent, + BrowserPaneLayout, + BrowserPanePlatform, + BrowserPaneRegistration, + BrowserPaneState, + BrowserPaneTarget, +} from "./runtime/platform/browser-pane" export { ServerConnection, useServers } from "./runtime/server/registry" export { useTabs } from "./shell/tabs/tabs" export { createDraftStore } from "./runtime/persistence/drafts" diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 3fdba18ac35..3a1c58c9c50 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -667,9 +667,11 @@ export const dict = { "session.tab.usage": "Usage", "session.view.select": "Session view", "session.tab.review": "Review", + "session.tab.browser": "Browser", + "session.tab.add": "Add tab", "session.tab.context": "Context", "session.tab.unknown": "Unknown Session", - "session.panel.reviewAndFiles": "Review and files", + "session.panel.reviewAndFiles": "Review, files, and browser", "session.error.notFound": "This session cannot be found", "session.error.notFound.description": "This tab points to a session that no longer exists on this server.", "session.error.notFound.closeTab": "Close Tab", @@ -823,6 +825,10 @@ export const dict = { "PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.", "terminal.connectTicket.statusError": "PTY connect ticket failed with {{status}}", + "session.browser.address": "Browser address", + "session.browser.replaced": "Browser control moved to another desktop window.", + "session.browser.address.placeholder": "Enter a URL", + "titlebar.update": "Update", "titlebar.tabs": "Tabs", "titlebar.channel.local": "Local", @@ -1049,6 +1055,8 @@ export const dict = { "settings.general.row.uiFont.description": "Customise the font used throughout the interface", "settings.general.row.showFileTree.title": "File tree", "settings.general.row.showFileTree.description": "Show the file tree panel in sessions", + "settings.general.row.browserPane.title": "Browser pane", + "settings.general.row.browserPane.description": "Allow agents to open and control an in-app development browser.", "settings.general.row.showNavigation.title": "Navigation controls", "settings.general.row.showNavigation.description": "Show the back and forward buttons in the desktop title bar", "settings.general.row.showSearch.title": "Command palette", diff --git a/packages/app/src/runtime/platform/browser-pane.ts b/packages/app/src/runtime/platform/browser-pane.ts new file mode 100644 index 00000000000..3c037a9be45 --- /dev/null +++ b/packages/app/src/runtime/platform/browser-pane.ts @@ -0,0 +1,27 @@ +import type { Browser } from "@opencode-ai/plugin-browser/rpc" + +export type BrowserPaneEndpoint = Readonly<{ url: string; username?: string; password?: string }> +export type BrowserPaneTarget = Readonly<{ sessionID: string; endpoint: BrowserPaneEndpoint }> +export type BrowserPaneLayout = { + tabID: Browser.TabID + visible: boolean + bounds?: { x: number; y: number; width: number; height: number } + background?: readonly [number, number, number, number] + radius?: number +} + +export type BrowserPaneCommand = Browser.Action +export type BrowserPaneState = Browser.State | null +export type BrowserPaneEvent = + | { type: "focus"; tabID: Browser.TabID } + | { type: "state"; state: BrowserPaneState; error?: string } + +export type BrowserPaneRegistration = { + setLayout(layout?: BrowserPaneLayout): void + command(command: BrowserPaneCommand): Promise + close(): void +} + +export type BrowserPanePlatform = { + register(target: BrowserPaneTarget, listener: (event: BrowserPaneEvent) => void): BrowserPaneRegistration +} diff --git a/packages/app/src/runtime/platform/platform.tsx b/packages/app/src/runtime/platform/platform.tsx index 173cdbe047c..b8592aa2b25 100644 --- a/packages/app/src/runtime/platform/platform.tsx +++ b/packages/app/src/runtime/platform/platform.tsx @@ -6,6 +6,7 @@ import { ServerConnection } from "@/runtime/server/registry" import type { WslServersPlatform } from "@/servers/wsl/types" import type { UpdaterPlatform } from "@/shell/updates/types" import type { DraftStore } from "@/runtime/persistence/drafts" +import type { BrowserPanePlatform } from "./browser-pane" type PickerPaths = string | string[] | null type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean } @@ -118,6 +119,9 @@ type PlatformBase = { /** Record a fatal renderer error in platform logs (desktop only) */ recordFatalRendererError?(error: FatalRendererErrorLog): Promise + + /** Native browser pane hosted by the platform (desktop only). */ + browserPane?: BrowserPanePlatform } export type Platform = PlatformBase & diff --git a/packages/app/src/session/browser/attachments.ts b/packages/app/src/session/browser/attachments.ts new file mode 100644 index 00000000000..d557bc1722e --- /dev/null +++ b/packages/app/src/session/browser/attachments.ts @@ -0,0 +1,158 @@ +import { batch, createEffect, createMemo, getOwner, onCleanup, runWithOwner } from "solid-js" +import { createStore, reconcile } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import type { Browser } from "@opencode-ai/plugin-browser/rpc" +import { useLanguage } from "@/runtime/i18n/language" +import type { BrowserPaneCommand, BrowserPaneRegistration, BrowserPaneState } from "@/runtime/platform/browser-pane" +import { usePlatform } from "@/runtime/platform/platform" +import type { useServer } from "@/runtime/server/current" +import { useSettings } from "@/settings/model" +import { findSessionTab, tabKey, useTabs } from "@/shell/tabs/tabs" + +type Server = ReturnType + +export type BrowserAttachment = { + registration?: BrowserPaneRegistration + browser: BrowserPaneState + error?: string +} + +type Live = { + server: Server + sessionID: string + /** Shell tab that owns this attachment once seen; it may route to a child session later. */ + tab?: string + registration?: BrowserPaneRegistration + retry?: ReturnType + attempts: number + dispose: () => void +} + +// Attachments belong to the shell session tab, not the session route: native pages and the agent's +// browser survive visiting Settings or another tab and close when the session tab or the setting does. +export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider } = createSimpleContext({ + name: "BrowserAttachments", + gate: false, + init: () => { + const platform = usePlatform() + const settings = useSettings() + const language = useLanguage() + const shellTabs = useTabs() + const owner = getOwner() + const [store, setStore] = createStore>({}) + // Servers whose plugin lacks the browser RPC; sessions on them stop retrying. + const [unsupported, setUnsupported] = createStore>({}) + const live = new Map() + const focus = new Map void>>() + const key = (server: Server, sessionID: string) => `${server.key}\n${sessionID}` + const enabled = createMemo( + () => !!platform.browserPane && settings.ready() && settings.general.experimentalBrowser(), + ) + const close = (id: string) => { + live.get(id)?.dispose() + live.delete(id) + setStore(id, undefined) + } + createEffect(() => { + const on = enabled() + const tabs = shellTabs.store + // The store's keys mirror `live`, and reading them keeps this effect subscribed to new attachments. + Object.keys(store).forEach((id) => { + const entry = live.get(id) + if (!entry) return + // Tabs hydrate asynchronously, so the owner is learned when first seen rather than required up + // front. A tab keeps owning the attachment while it exists, even after routing back to its parent. + const current = findSessionTab(tabs, entry.server.key, entry.sessionID) + if (current) entry.tab = tabKey(current) + const owned = entry.tab === undefined || tabs.some((tab) => tabKey(tab) === entry.tab) + if (on && owned && !entry.server.health?.incompatible) return + close(id) + }) + }) + onCleanup(() => Array.from(live.keys()).forEach(close)) + + return { + enabled, + supported: (server: Server) => !unsupported[server.key], + state: (server: Server, sessionID: string) => store[key(server, sessionID)], + attach(server: Server, sessionID: string) { + const id = key(server, sessionID) + if (live.has(id)) return + const pane = platform.browserPane + if (!pane || !enabled() || unsupported[server.key] || server.health?.incompatible) return + const entry: Live = { server, sessionID, attempts: 0, dispose: () => undefined } + live.set(id, entry) + setStore(id, { browser: null }) + const register = () => { + if (entry.registration || live.get(id) !== entry) return + // The server's shared transport follows a restarted sidecar's port whether or not any route + // for this session is mounted; the connection captured at attach time may predate it. + const endpoint = { ...server.conn.http, url: server.ctx.sdk.url } + const registration = pane.register({ sessionID, endpoint }, (event) => { + if (live.get(id) !== entry) return + if (event.type === "focus") return focus.get(id)?.forEach((listener) => listener(event.tabID)) + if (event.error === "browser.pane.unsupported") { + setUnsupported(server.key, true) + return close(id) + } + if (event.error === "browser.pane.replaced") { + registration.close() + entry.registration = undefined + return setStore(id, { + registration: undefined, + browser: null, + error: language.t("session.browser.replaced"), + }) + } + // The desktop dropped the attachment (server restart, attach race). Re-register so the + // agent's browser tool comes back without a reload. + if (event.error === "browser.pane.registration.closed") { + registration.close() + entry.registration = undefined + setStore(id, { registration: undefined, browser: null, error: undefined }) + entry.retry = setTimeout(register, Math.min(30_000, 1_000 * 2 ** entry.attempts++)) + return + } + if (event.state) entry.attempts = 0 + batch(() => { + setStore(id, "browser", reconcile(event.state)) + setStore(id, "error", event.error ? language.t("common.requestFailed") : undefined) + }) + }) + entry.registration = registration + setStore(id, { registration, browser: null, error: undefined }) + } + // A new session appears in the UI before its server-side creation finishes. The listener + // belongs to this provider, not to the route effect that happened to call attach(). + const data = server.ctx.data + const unsubscribe = runWithOwner(owner, () => + data.on("session.created", (event) => { + if (event.data.sessionID === sessionID) register() + }), + ) + if (!data.session.creating(sessionID)) register() + entry.dispose = () => { + unsubscribe?.() + clearTimeout(entry.retry) + entry.registration?.close() + } + }, + /** Desktop focus requests for a mounted session route; nothing is replayed to routes mounted later. */ + onFocus(server: Server, sessionID: string, listener: (tabID: Browser.TabID) => void) { + const id = key(server, sessionID) + const listeners = focus.get(id) ?? new Set() + listeners.add(listener) + focus.set(id, listeners) + return () => { + listeners.delete(listener) + if (!listeners.size) focus.delete(id) + } + }, + command(server: Server, sessionID: string, command: BrowserPaneCommand) { + const registration = live.get(key(server, sessionID))?.registration + if (!registration) return Promise.reject(new Error("browser.pane.unavailable")) + return registration.command(command) + }, + } + }, +}) diff --git a/packages/app/src/session/browser/model.ts b/packages/app/src/session/browser/model.ts new file mode 100644 index 00000000000..2fedd29dc66 --- /dev/null +++ b/packages/app/src/session/browser/model.ts @@ -0,0 +1,115 @@ +import { batch, createEffect, createMemo, on, onCleanup } from "solid-js" +import type { Browser } from "@opencode-ai/plugin-browser/rpc" +import { createStore } from "solid-js/store" +import { useLanguage } from "@/runtime/i18n/language" +import type { BrowserPaneCommand } from "@/runtime/platform/browser-pane" +import { useServer } from "@/runtime/server/current" +import type { SessionModel } from "../model" +import { isSessionBrowserTab, sessionBrowserTab } from "../helpers" +import { useBrowserAttachments } from "./attachments" + +export function createSessionBrowser(session: SessionModel) { + const attachments = useBrowserAttachments() + const language = useLanguage() + const server = useServer() + const [local, setLocal] = createStore({ error: undefined as string | undefined }) + const attachment = () => { + const sessionID = session.identity.sessionID() + return sessionID ? attachments.state(server, sessionID) : undefined + } + const available = createMemo( + () => + attachments.enabled() && + attachments.supported(server) && + !!session.identity.sessionID() && + !server.health?.incompatible && + session.isDesktop(), + ) + const attached = () => attachment()?.registration !== undefined + const browserTabs = createMemo( + () => + attachment()?.browser?.tabs.filter((tab) => session.layout.tabs().all().includes(sessionBrowserTab(tab.id))) ?? + [], + ) + const focus = (tabID: Browser.TabID) => { + session.layout.view().reviewPanel.open() + const tabs = session.layout.tabs() + const key = sessionBrowserTab(tabID) + if (!tabs.all().includes(key)) tabs.setAll([...tabs.all(), key]) + tabs.setActive(key) + } + const command = (command: BrowserPaneCommand) => { + const sessionID = session.identity.sessionID() + if (!sessionID) return + setLocal("error", undefined) + const owner = session.ownership.capture() + void attachments.command(server, sessionID, command).catch(() => { + if (owner.current()) setLocal("error", language.t("common.requestFailed")) + }) + } + createEffect(() => { + const sessionID = session.identity.sessionID() + if (!sessionID) return + if (attachments.enabled()) attachments.attach(server, sessionID) + onCleanup(attachments.onFocus(server, sessionID, focus)) + }) + createEffect( + on( + () => session.layout.tabs().active(), + (active) => { + const tab = attachment()?.browser?.tabs.find((tab) => sessionBrowserTab(tab.id) === active) + if (tab && tab.id !== attachment()?.browser?.focusedTabID) command({ type: "tabs.focus", tabID: tab.id }) + }, + ), + ) + createEffect( + on( + () => session.layout.tabs().all(), + (current, previous) => { + previous + ?.filter((key) => isSessionBrowserTab(key) && !current.includes(key)) + .forEach((key) => { + const tab = attachment()?.browser?.tabs.find((tab) => sessionBrowserTab(tab.id) === key) + if (tab) command({ type: "tabs.close", tabID: tab.id }) + }) + }, + ), + ) + // Mirror the desktop's tab inventory into this session's layout tabs. Only tabs new since the last + // inventory are added, so a layout tab the user just closed is not reopened before the desktop confirms. + createEffect( + on( + () => attachment()?.browser?.tabs.map((tab) => sessionBrowserTab(tab.id)), + (ids, previous) => { + if (!ids) return + const known = new Set(previous ?? []) + batch(() => { + const tabs = session.layout.tabs() + tabs + .all() + .filter((key) => isSessionBrowserTab(key) && !ids.includes(key)) + .forEach(tabs.close) + const current = tabs.all() + const added = ids.filter((key) => !known.has(key) && !current.includes(key)) + if (added.length) tabs.setAll([...current, ...added]) + }) + }, + ), + ) + return { + available, + attached, + opened: () => attached() && browserTabs().length > 0, + state: () => attachment()?.browser ?? null, + tabs: browserTabs, + active: () => + browserTabs().find((tab) => sessionBrowserTab(tab.id) === session.layout.tabs().active()) ?? + browserTabs().find((tab) => tab.id === attachment()?.browser?.focusedTabID) ?? + browserTabs()[0], + error: () => local.error ?? attachment()?.error, + registration: () => attachment()?.registration, + close: (tabID: Browser.TabID) => session.layout.tabs().close(sessionBrowserTab(tabID)), + open: () => command({ type: "tabs.open" }), + command, + } +} diff --git a/packages/app/src/session/browser/pane.tsx b/packages/app/src/session/browser/pane.tsx new file mode 100644 index 00000000000..d854a791de1 --- /dev/null +++ b/packages/app/src/session/browser/pane.tsx @@ -0,0 +1,185 @@ +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Loader } from "@opencode-ai/ui/loader" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { createEventListener } from "@solid-primitives/event-listener" +import { createResizeObserver } from "@solid-primitives/resize-observer" +import { createEffect, For, on, onCleanup, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { useLanguage } from "@/runtime/i18n/language" +import type { BrowserPaneRegistration } from "@/runtime/platform/browser-pane" +import { usePlatform } from "@/runtime/platform/platform" +import type { createSessionBrowser } from "./model" + +export function SessionBrowserPane(props: { + registration: BrowserPaneRegistration + browser: ReturnType + visible: boolean +}) { + const platform = usePlatform() + const language = useLanguage() + const dialog = useDialog() + const state = props.browser.active + const button = { variant: "ghost", size: "large" } as const + const [store, setStore] = createStore({ + address: "", + editing: false, + visible: typeof document === "undefined" || document.visibilityState === "visible", + }) + let surface: HTMLDivElement | undefined + let frame: number | undefined + let layout: string | undefined + let until = 0 + const canvas = document.createElement("canvas") + canvas.width = canvas.height = 1 + const paint = canvas.getContext("2d", { willReadFrequently: true }) + + // The native page always paints above the DOM, so hide it while a floating + // menu, select, or popover overlaps it. Tooltips are excluded. + const covered = (rect: DOMRect) => + Array.from(document.querySelectorAll('[data-popper-positioner]:not(:has([role="tooltip"]))')).some((el) => { + const r = el.getBoundingClientRect() + return r.width > 0 && r.left < rect.right && r.right > rect.left && r.top < rect.bottom && r.bottom > rect.top + }) + const measure = () => { + if (!surface) return + const tab = state() + if (!tab) { + props.registration.setLayout() + return + } + const rect = surface.getBoundingClientRect() + const zoom = platform.webviewZoom?.() ?? 1 + const left = Math.round(rect.left * zoom) + const top = Math.round(rect.top * zoom) + const right = Math.round(rect.right * zoom) + const bottom = Math.round(rect.bottom * zoom) + const visible = props.visible && store.visible && !dialog.active && !covered(rect) + // The cutout exposes the app backdrop outside the rounded Review card, + // not the browser surface inside it. + const color = getComputedStyle( + surface.closest(".bg-v2-background-bg-deep") ?? document.documentElement, + ).backgroundColor + const next = `${tab.id}:${visible}:${left}:${top}:${right}:${bottom}:${color}:${window.devicePixelRatio}` + if (next !== layout) { + layout = next + // Let the browser resolve the semantic backdrop color, including custom + // themes using color formats that Electron's color parser cannot read. + if (paint) { + paint.clearRect(0, 0, 1, 1) + paint.fillStyle = color + paint.fillRect(0, 0, 1, 1) + } + const rgba = paint?.getImageData(0, 0, 1, 1).data + props.registration.setLayout({ + tabID: tab.id, + visible, + bounds: { x: left, y: top, width: Math.max(0, right - left), height: Math.max(0, bottom - top) }, + background: rgba ? [rgba[0], rgba[1], rgba[2], rgba[3]] : undefined, + radius: Math.round(10 * zoom), + }) + } + } + const tick = () => { + frame = undefined + measure() + if (performance.now() < until) frame = requestAnimationFrame(tick) + } + const schedule = (duration = 0) => { + until = Math.max(until, performance.now() + duration) + if (frame === undefined) frame = requestAnimationFrame(tick) + } + + createEffect(() => !store.editing && setStore("address", state()?.url ?? "")) + createEffect( + on( + [ + () => platform.webviewZoom?.(), + () => dialog.active, + () => store.visible, + () => props.visible, + () => state()?.id, + ], + () => schedule(300), + ), + ) + // ResizeObserver runs after layout in the same frame; measuring here instead of on the next + // animation frame keeps the native view in step with a pane drag. + createResizeObserver(() => surface, measure) + createEventListener(window, "resize", () => schedule(300)) + // Floating content portals directly into ; keep measuring briefly so + // the positioner has settled before the overlap check runs. + const portals = new MutationObserver(() => schedule(300)) + portals.observe(document.body, { childList: true }) + onCleanup(() => portals.disconnect()) + const appearance = new MutationObserver(() => schedule(300)) + appearance.observe(document.documentElement, { attributes: true, attributeFilter: ["style", "data-theme"] }) + onCleanup(() => appearance.disconnect()) + createEventListener(window.matchMedia("(prefers-color-scheme: dark)"), "change", () => schedule(300)) + createEventListener(document, "visibilitychange", () => setStore("visible", document.visibilityState === "visible")) + onCleanup(() => { + if (frame !== undefined) cancelAnimationFrame(frame) + props.registration.setLayout() + }) + + return ( + + ) +} diff --git a/packages/app/src/session/files/session-side-panel.tsx b/packages/app/src/session/files/session-side-panel.tsx index 6913690c902..26c22772a2e 100644 --- a/packages/app/src/session/files/session-side-panel.tsx +++ b/packages/app/src/session/files/session-side-panel.tsx @@ -12,6 +12,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Mark } from "@opencode-ai/ui/logo" import { Keybind } from "@opencode-ai/ui/keybind" import { Tooltip } from "@opencode-ai/ui/tooltip" +import { Menu } from "@opencode-ai/ui/menu" import type { FileDiffInfo } from "@opencode-ai/client/promise" import FileTree from "@/session/files/file-tree" @@ -20,6 +21,8 @@ import { SessionContextUsage } from "@/session/timeline/session-context-usage" const reviewTabID = "session-side-panel-review-tab" const reviewTabPanelID = "session-side-panel-review-tabpanel" +const browserTabID = "session-side-panel-browser-tab" +const browserTabPanelID = "session-side-panel-browser-tabpanel" const fileBrowserTabPanelID = "session-side-panel-file-browser-tabpanel" import { SessionContextTab } from "@/session/files/session-context-tab" import { SortableTab } from "@/session/files/tab" @@ -33,6 +36,8 @@ import { useSettings } from "@/settings/model" import { createFileTabListSync } from "@/session/files/file-tab-scroll" import { SESSION_OPEN_FILE_TAB, + isSessionBrowserTab, + sessionBrowserTab, createOpenSessionFileTab, createSessionTabs, shouldShowFileTree, @@ -41,6 +46,8 @@ import { import { setSessionHandoff } from "@/session/handoff" import { useSessionLayout } from "@/session/session-layout" import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/session/files/session-file-browser-tab" +import { SessionBrowserPane } from "@/session/browser/pane" +import type { createSessionBrowser } from "@/session/browser/model" type ReviewDiff = FileDiffInfo type RenderDiff = FileDiffInfo @@ -65,6 +72,7 @@ export function SessionSidePanel(props: { reviewPresent?: boolean size: Sizing stacked?: boolean + browser: ReturnType }) { const layout = useLayout() const settings = useSettings() @@ -165,6 +173,7 @@ export function SessionSidePanel(props: { review: reviewTab, hasReview: () => props.canReview, fileBrowser: () => true, + browser: props.browser.attached, }) const contextOpen = tabState.contextOpen const openFileOpen = tabState.openFileOpen @@ -202,7 +211,7 @@ export function SessionSidePanel(props: { openReviewPanel() tabs().setActive(next) } - const browserTab = createMemo(() => { + const fileTab = createMemo(() => { const active = activeTab() if (active === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB if (active && file.pathFromTab(active)) return active @@ -212,11 +221,11 @@ export function SessionSidePanel(props: { // selects Review while the tab For replaces a preview trigger, which would // otherwise dispose the sidebar and reset scroll. const fileBrowserMounted = createMemo(() => { - return openedTabs().length > 0 || openFileOpen() || !!browserTab() + return openedTabs().length > 0 || openFileOpen() || !!fileTab() }) const fileBrowserVisible = createMemo(() => { const active = activeTab() - return active !== "review" && active !== "context" && active !== "empty" + return active !== "review" && active !== "context" && active !== "empty" && !isSessionBrowserTab(active) }) const openFileKeybind = createMemo(() => command.keybindParts("file.open")) const closeTabKeybind = createMemo(() => command.keybindParts("file.close")) @@ -298,7 +307,7 @@ export function SessionSidePanel(props: { onCleanup(stop) }} > -
+
{props.reviewSidebarToggle(activeTab() === SESSION_OPEN_FILE_TAB)}
@@ -345,8 +354,7 @@ export function SessionSidePanel(props: { {(tab) => ( - } > - tabs().close(SESSION_OPEN_FILE_TAB)} - closeButton={ - - {language.t("common.closeTab")} - 0}> - - - - } - placement="bottom" - gutter={10} - > - { - event.preventDefault() - event.stopPropagation() - }} - onClick={(event) => { - event.preventDefault() - event.stopPropagation() - tabs().close(SESSION_OPEN_FILE_TAB) - }} - icon={} - aria-label={language.t("common.closeTab")} - /> - - } - hideCloseButton - > -
- - {language.t("command.file.open")} -
-
-
+ + sessionBrowserTab(item.id) === tab)}> + {(item) => ( + props.browser.close(item().id)} + id={`${browserTabID}-${item().id}`} + ariaControls={activeTab() === tab ? browserTabPanelID : undefined} + > +
+ + + {item().title || language.t("session.tab.browser")} + +
+
+ )} +
+
+ + tabs().close(SESSION_OPEN_FILE_TAB)} + closeButton={ + + {language.t("common.closeTab")} + 0}> + + + + } + placement="bottom" + gutter={10} + > + { + event.preventDefault() + event.stopPropagation() + }} + onClick={(event) => { + event.preventDefault() + event.stopPropagation() + tabs().close(SESSION_OPEN_FILE_TAB) + }} + icon={} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + > +
+ + {language.t("command.file.open")} +
+
+
+ )}
-
- - {language.t("command.file.open")} - 0}> - - - +
+ {/* With only files to add, the plus stays a one-click "Open file" button. */} + + {language.t("command.file.open")} + 0}> + + + + } + placement="bottom" + class="flex items-center" + > + } + variant="ghost-muted" + size="large" + onClick={() => openFileBrowser()} + aria-label={language.t("command.file.open")} + /> + } - placement="bottom" - class="flex items-center" > - } - variant="ghost-muted" - size="large" - onClick={() => openFileBrowser()} - aria-label={language.t("command.file.open")} - /> - + + + } + variant="ghost-muted" + size="large" + aria-label={language.t("session.tab.add")} + // The tablist redirects focus entering it to the selected + // tab, which counts as focus-outside and closes the menu. + onPointerDown={(event: PointerEvent) => event.preventDefault()} + /> + + + 0}> + + + } + > +
+ + {language.t("command.file.open")} +
+
+ +
+ + {language.t("session.tab.browser")} +
+
+
+
+
+
+
+ +
+ + {(registration) => ( + + )} + +
+
+
previewTab(file.tab(path))} diff --git a/packages/app/src/session/files/tab.tsx b/packages/app/src/session/files/tab.tsx index 2ef16052b89..14f246f944e 100644 --- a/packages/app/src/session/files/tab.tsx +++ b/packages/app/src/session/files/tab.tsx @@ -1,4 +1,4 @@ -import { createMemo, Show } from "solid-js" +import { children, createMemo, Show } from "solid-js" import type { JSX } from "solid-js" import { useSortable } from "@dnd-kit/solid/sortable" import { Keybind } from "@opencode-ai/ui/keybind" @@ -15,6 +15,10 @@ export function SortableTab(props: { temporary?: boolean onTabClose: (tab: string) => void onTabDoubleClick?: (tab: string) => void + /** Replaces the file visual for non-file tabs such as the browser. */ + children?: JSX.Element + id?: string + ariaControls?: string }): JSX.Element { const file = useFile() const language = useLanguage() @@ -29,6 +33,7 @@ export function SortableTab(props: { }, }) const path = createMemo(() => file.pathFromTab(props.tab)) + const custom = children(() => props.children) const content = createMemo(() => { const value = path() if (!value) return @@ -39,6 +44,8 @@ export function SortableTab(props: {
props.onTabClose(props.tab)} onDblClick={() => props.onTabDoubleClick?.(props.tab)} closeButton={ @@ -63,7 +70,9 @@ export function SortableTab(props: { } hideCloseButton > - {(value) => value()} + {(value) => value()}}> + {custom()} +
diff --git a/packages/app/src/session/helpers.test.ts b/packages/app/src/session/helpers.test.ts index 38a34104f33..6ecca230775 100644 --- a/packages/app/src/session/helpers.test.ts +++ b/packages/app/src/session/helpers.test.ts @@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test" import { createMemo, createRoot } from "solid-js" import { createStore } from "solid-js/store" import { + SESSION_BROWSER_TAB, + sessionBrowserTab, SESSION_OPEN_FILE_TAB, createOpenReviewFile, createOpenSessionFileTab, @@ -233,4 +235,49 @@ describe("createSessionTabs", () => { dispose() }) }) + + test("exposes one browser tab without treating it as a file tab", () => { + createRoot((dispose) => { + const tabs = createMemo(() => ({ active: () => SESSION_BROWSER_TAB, all: () => [SESSION_BROWSER_TAB] })) + const result = createSessionTabs({ + tabs, + pathFromTab: () => undefined, + normalizeTab: (tab) => tab, + browser: () => true, + }) + + expect(result.panelTabs()).toEqual([SESSION_BROWSER_TAB]) + expect(result.openedTabs()).toEqual([]) + expect(result.activeTab()).toBe(SESSION_BROWSER_TAB) + expect(result.activeFileTab()).toBeUndefined() + expect(result.closableTab()).toBe(SESSION_BROWSER_TAB) + dispose() + }) + }) + + test("keeps browser tabs in layout order beside file tabs, and drops them when the browser is detached", () => { + const first = sessionBrowserTab("first") + const second = sessionBrowserTab("second") + const input = { + tabs: () => ({ active: () => second, all: () => [first, "file://src/a.ts", second] }), + pathFromTab: (tab: string) => (tab.startsWith("file://") ? tab.slice(7) : undefined), + normalizeTab: (tab: string) => tab, + } + createRoot((dispose) => { + const result = createSessionTabs({ ...input, browser: () => true }) + expect(result.panelTabs()).toEqual([first, "file://src/a.ts", second]) + expect(result.openedTabs()).toEqual(["file://src/a.ts"]) + expect(result.activeTab()).toBe(second) + expect(result.activeFileTab()).toBeUndefined() + expect(result.closableTab()).toBe(second) + dispose() + }) + createRoot((dispose) => { + const result = createSessionTabs({ ...input, browser: () => false }) + expect(result.panelTabs()).toEqual(["file://src/a.ts"]) + expect(result.activeTab()).toBe("file://src/a.ts") + expect(result.closableTab()).toBe("file://src/a.ts") + dispose() + }) + }) }) diff --git a/packages/app/src/session/helpers.ts b/packages/app/src/session/helpers.ts index 0193346b303..f337725200b 100644 --- a/packages/app/src/session/helpers.ts +++ b/packages/app/src/session/helpers.ts @@ -2,9 +2,14 @@ import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js" import { createStore } from "solid-js/store" import { makeEventListener } from "@solid-primitives/event-listener" import { same } from "@/runtime/persistence/equality" -import { SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs" +import { isSessionBrowserTab, SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs" -export { SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs" +export { + SESSION_BROWSER_TAB, + SESSION_OPEN_FILE_TAB, + sessionBrowserTab, + isSessionBrowserTab, +} from "@/shell/state/session-tabs" const emptyTabs: string[] = [] @@ -20,6 +25,7 @@ type TabsInput = { review?: Accessor hasReview?: Accessor fileBrowser?: Accessor + browser?: Accessor } export function shouldShowFileTree(input: { visible: boolean; opened: boolean }) { @@ -30,6 +36,7 @@ export const createSessionTabs = (input: TabsInput) => { const review = input.review ?? (() => false) const hasReview = input.hasReview ?? (() => false) const fileBrowser = input.fileBrowser ?? (() => false) + const browser = input.browser ?? (() => false) const contextOpen = createMemo(() => input.tabs().active() === "context" || input.tabs().all().includes("context")) const openFileOpen = createMemo( () => @@ -44,6 +51,7 @@ export const createSessionTabs = (input: TabsInput) => { .all() .flatMap((tab) => { if (tab === "context" || tab === "review") return [] + if (isSessionBrowserTab(tab)) return browser() ? [tab] : [] if (tab === SESSION_OPEN_FILE_TAB && !fileBrowser()) return [] const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab if (seen.has(value)) return [] @@ -54,13 +62,16 @@ export const createSessionTabs = (input: TabsInput) => { emptyTabs, { equals: same }, ) - const openedTabs = createMemo(() => panelTabs().filter((tab) => tab !== SESSION_OPEN_FILE_TAB), emptyTabs, { - equals: same, - }) + const openedTabs = createMemo( + () => panelTabs().filter((tab) => tab !== SESSION_OPEN_FILE_TAB && !isSessionBrowserTab(tab)), + emptyTabs, + { equals: same }, + ) const activeTab = createMemo(() => { const active = input.tabs().active() if (active === "context") return active if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active + if (active && isSessionBrowserTab(active) && browser()) return active if (active === "review" && review()) return active if (active && input.pathFromTab(active)) return input.normalizeTab(active) @@ -70,15 +81,16 @@ export const createSessionTabs = (input: TabsInput) => { if (review() && hasReview()) return "review" return "empty" }) - const activeFileTab = createMemo(() => { + const activeFileTab = createMemo(() => { const active = activeTab() if (!openedTabs().includes(active)) return return active }) - const closableTab = createMemo(() => { + const closableTab = createMemo(() => { const active = activeTab() if (active === "context") return active if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active + if (active && isSessionBrowserTab(active) && browser()) return active if (!openedTabs().includes(active)) return return active }) diff --git a/packages/app/src/session/model.ts b/packages/app/src/session/model.ts index 4bf7d971350..4711554ce9e 100644 --- a/packages/app/src/session/model.ts +++ b/packages/app/src/session/model.ts @@ -7,6 +7,7 @@ import { useData } from "@/runtime/server/current" import { same } from "@/runtime/persistence/equality" import { containsDirectory, isProjectDirectory, isWorkspaceDirectory } from "@/workspaces/paths" import { projectForSession } from "@/shell/layout/helpers" +import { useBrowserAttachments } from "./browser/attachments" import { createSessionTabs } from "./helpers" import { normalizeSessionTab, @@ -28,6 +29,7 @@ export function useSessionModel() { const data = useData() const server = useServer() const shellTabs = useTabs() + const attachments = useBrowserAttachments() const layout = useSessionLayout() const location = useWorkspaceLocation() const isDesktop = createMediaQuery("(min-width: 768px)") @@ -82,6 +84,11 @@ export function useSessionModel() { review: isDesktop, hasReview: canReview, fileBrowser: () => isDesktop() && !!sessionID(), + // Same flag the side panel uses, so keyboard tab commands see the browser tab the panel shows. + browser: () => { + const id = sessionID() + return !!id && attachments.state(server, id)?.registration !== undefined + }, }) return { diff --git a/packages/app/src/session/review/view.tsx b/packages/app/src/session/review/view.tsx index 70499773bc5..1f3e78cc2b1 100644 --- a/packages/app/src/session/review/view.tsx +++ b/packages/app/src/session/review/view.tsx @@ -13,6 +13,7 @@ import { SessionSidePanel } from "../files/session-side-panel" import { ReviewPanel } from "./panel" import { SessionReviewTab } from "./review-tab" import type { ChangeMode, SessionReviewModel } from "./model" +import type { createSessionBrowser } from "../browser/model" const StatusDrawer = lazy(async () => { const { StatusDrawer } = await import("@/shell/status/status-drawer") @@ -144,7 +145,11 @@ export function SessionMobileReview(props: { review: SessionReviewModel }) { ) } -export function SessionDesktopReview(props: { review: SessionReviewModel; present?: boolean }) { +export function SessionDesktopReview(props: { + review: SessionReviewModel + browser: ReturnType + present?: boolean +}) { return ( ) diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index a68dcae954a..54432cc8274 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -33,6 +33,7 @@ import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./compos import { SessionIdentityHeader } from "./session-identity-header" import { SessionReviewToggle } from "./header/session-header-actions" import { createAnimatedPresence } from "@/runtime/animated-presence" +import { createSessionBrowser } from "./browser/model" const SessionMobileFiles = lazy(async () => { const { SessionMobileFiles } = await import("./files/session-mobile-files") @@ -47,6 +48,7 @@ export function SessionScreen(props: { session: SessionModel }) { return info ? projectForSession(info, server.ctx.sync.data.project) : undefined }) const isDesktop = session.isDesktop + const browser = createSessionBrowser(session) const screen = createSessionScreenLayout(session) const timeline = createSessionTimelineInteraction(session) const timelineSearch = createTimelineSearchController({ @@ -377,7 +379,7 @@ export function SessionScreen(props: { session: SessionModel }) { setStore("sideReviewPresent", false) }} > - +
diff --git a/packages/app/src/settings/experimental/experimental.tsx b/packages/app/src/settings/experimental/experimental.tsx index 8d52fcb64cd..a83e2d8c645 100644 --- a/packages/app/src/settings/experimental/experimental.tsx +++ b/packages/app/src/settings/experimental/experimental.tsx @@ -2,6 +2,7 @@ import { Component, Show } from "solid-js" import { Select } from "@opencode-ai/ui/select" import { Switch } from "@opencode-ai/ui/switch" import { useLanguage } from "@/runtime/i18n/language" +import { usePlatform } from "@/runtime/platform/platform" import { SettingsList } from "@/settings/list" import { useSettings } from "@/settings/model" import { SettingsRow } from "@/settings/row" @@ -12,6 +13,7 @@ const tabLayoutOptions: ("horizontal" | "vertical")[] = ["horizontal", "vertical export const SettingsExperimental: Component = () => { const language = useLanguage() const settings = useSettings() + const platform = usePlatform() return ( <> @@ -29,6 +31,22 @@ export const SettingsExperimental: Component = () => {
+ + +
+ + {language.t("settings.general.row.browserPane.title")} + +
+
+
{ mobileDiffWrap: true, terminalPlacement: "side", followUpBehavior: "steer", + experimentalBrowser: false, }, appearance: { fontSize: 14, @@ -130,6 +131,12 @@ describe("settings schema", () => { expect(decode(encode(settings))).toEqual(settings) }) + test("browser attachment is opt-in and preserves an explicit choice", () => { + expect(decode({}).general.experimentalBrowser).toBe(false) + expect(decode({ general: { experimentalBrowser: true } }).general.experimentalBrowser).toBe(true) + expect(decode({ general: { experimentalBrowser: false } }).general.experimentalBrowser).toBe(false) + }) + test.each([undefined, null, false, 7, "invalid", []].map((invalid) => [invalid]))( "defaults malformed sections without losing other sections: %j", (invalid) => { diff --git a/packages/app/src/settings/model.tsx b/packages/app/src/settings/model.tsx index 00b72b95f36..937a33226f9 100644 --- a/packages/app/src/settings/model.tsx +++ b/packages/app/src/settings/model.tsx @@ -95,6 +95,7 @@ const generalSchema = Persistence.struct({ mobileDiffWrap: Schema.Boolean, terminalPlacement: Schema.Literals(["side", "bottom"]), followUpBehavior: Schema.Literals(["queue", "steer"]), + experimentalBrowser: Schema.Boolean, }) const appearanceSchema = Persistence.struct({ @@ -251,6 +252,7 @@ export const defaultSettings: Settings = { mobileDiffWrap: true, terminalPlacement: "side", followUpBehavior: "steer", + experimentalBrowser: false, }, appearance: { fontSize: 14, mono: "", sans: "", terminal: "", tabLayout: "horizontal", showProjectName: false }, keybinds: {}, @@ -358,6 +360,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setFollowUpBehavior(value: FollowUpBehavior) { setStore("general", "followUpBehavior", value) }, + experimentalBrowser: withFallback( + () => store.general?.experimentalBrowser, + defaultSettings.general.experimentalBrowser, + ), + setExperimentalBrowser(value: boolean) { + setStore("general", "experimentalBrowser", value) + }, }, visibility: { fileTree: showFileTree, diff --git a/packages/app/src/shell/routes/routes.tsx b/packages/app/src/shell/routes/routes.tsx index c708914266d..dacfe431128 100644 --- a/packages/app/src/shell/routes/routes.tsx +++ b/packages/app/src/shell/routes/routes.tsx @@ -4,6 +4,7 @@ import { Home } from "@/home/route" import { ServerProvider } from "@/runtime/server/current" import { useGlobal } from "@/runtime/server/runtime" import { ServerConnection } from "@/runtime/server/registry" +import { BrowserAttachmentsProvider } from "@/session/browser/attachments" import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame" import { LayoutProvider } from "@/shell/state/layout" import { SettingsSurfaceProvider } from "@/settings/surface" @@ -73,7 +74,9 @@ function AppLayout(props: ParentProps) { return ( - {props.children} + + {props.children} + ) diff --git a/packages/app/src/shell/state/session-tabs.ts b/packages/app/src/shell/state/session-tabs.ts index ea4c675ff4e..1f3f0ad5b0f 100644 --- a/packages/app/src/shell/state/session-tabs.ts +++ b/packages/app/src/shell/state/session-tabs.ts @@ -1,4 +1,8 @@ export const SESSION_OPEN_FILE_TAB = "open-file" +export const SESSION_BROWSER_TAB = "browser" +export const sessionBrowserTab = (tabID: string) => `${SESSION_BROWSER_TAB}:${tabID}` +export const isSessionBrowserTab = (tab: string | undefined) => + !!tab && (tab === SESSION_BROWSER_TAB || tab.startsWith(`${SESSION_BROWSER_TAB}:`)) export type SessionTabs = { active?: string diff --git a/packages/desktop/package.json b/packages/desktop/package.json index b858ce83679..099495576de 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -29,7 +29,8 @@ "electron-log": "^5", "electron-store": "11.0.2", "electron-updater": "6.8.9", - "electron-window-state": "^5.0.3" + "electron-window-state": "^5.0.3", + "lighthouse": "13.4.1" }, "devDependencies": { "@brendonovich/vite-plugin-opencode": "0.1.3", @@ -37,6 +38,8 @@ "@lydell/node-pty": "catalog:", "@opencode-ai/app": "workspace:*", "@opencode-ai/client": "workspace:*", + "@opencode-ai/schema": "workspace:*", + "@opencode-ai/plugin-browser": "workspace:*", "@opencode-ai/ui": "workspace:*", "@sentry/solid": "catalog:", "@sentry/vite-plugin": "catalog:", @@ -47,12 +50,14 @@ "@types/node": "catalog:", "@typescript/native-preview": "catalog:", "app-builder-lib": "26.15.7", + "devtools-protocol": "0.0.1687809", "drizzle-kit": "catalog:", "drizzle-orm": "catalog:", "effect": "catalog:", "electron": "42.10.1", "electron-builder": "26.15.7", "electron-vite": "6.0.0-beta.1", + "puppeteer-core": "25.9.0", "solid-js": "catalog:", "typescript": "~5.6.2", "vite": "8.2.2" diff --git a/packages/desktop/src/main/browser-chromium.ts b/packages/desktop/src/main/browser-chromium.ts new file mode 100644 index 00000000000..25b0cbe3249 --- /dev/null +++ b/packages/desktop/src/main/browser-chromium.ts @@ -0,0 +1,1046 @@ +import { Browser } from "@opencode-ai/plugin-browser/rpc" +import electron, { type BrowserWindow, type WebContents } from "electron" +import type { Protocol } from "devtools-protocol" +import { Schema } from "effect" +import { createCdp, abortError, waitFor } from "./browser/cdp" +import { createBrowserFiles } from "./browser/files" +import { createDiagnostics } from "./browser/diagnostics" +import { createProfiling } from "./browser/profiling" +import { createCornerImages } from "./browser/corners" +import type { BrowserNetwork } from "./browser/network" +import { destinationOrigin, normalizeURL } from "./browser/policy" + +type Element = { backendID: number; frameID: string; sessionID?: string } +let nextRef = 0 +// Captures and downloads belong to the tab, not whichever document it now shows; navigate replaces it anyway. +const retainedOperations = new Set([ + "navigate", + "files.list", + "files.get", + "trace.stop", + "trace.analyze", + "cpu.stop", + "cpu.analyze", + "heap.summary", + "heap.query", + "heap.object", + "heap.compare", +]) +export type BrowserPage = ReturnType + +export function createBrowserPage( + win: BrowserWindow, + options: { + id: Browser.TabID + partition: string + network: BrowserNetwork | null + publish: (error?: string) => void + fail: () => void + popup: (options: Electron.BrowserWindowConstructorOptions) => WebContents + initialize?: boolean + popupOptions?: Electron.BrowserWindowConstructorOptions + }, +) { + const view = new electron.WebContentsView({ + ...options.popupOptions, + webPreferences: { + partition: options.partition, + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + webSecurity: true, + webviewTag: false, + devTools: false, + backgroundThrottling: false, + }, + }) + const contents = view.webContents + const detachNetwork = options.network?.attach(contents) + contents.on("before-input-event", (event, input) => { + if (input.type !== "keyDown" || input.alt || !(process.platform === "darwin" ? input.meta : input.control)) return + const step = + input.key === "=" || input.key === "+" || input.code === "NumpadAdd" + ? 0.5 + : input.key === "-" || input.code === "NumpadSubtract" + ? -0.5 + : 0 + if (!step && input.key !== "0") return + event.preventDefault() + contents.setZoomLevel(input.key === "0" ? 0 : contents.getZoomLevel() + step) + }) + const cdp = createCdp(contents) + const documents = new Map() + const sourceURLs = () => [...new Set([contents.getURL(), ...documents.values()])].sort() + const files = createBrowserFiles(sourceURLs) + const diagnostics = createDiagnostics(cdp) + const profiling = createProfiling(contents, cdp, files, sourceURLs) + const refs = new Map() + const sessions = new Map() + const parents = new Map() + const contexts = new Map() + const dialogs = new Set<() => void>() + let dialog: { type: string; message: string; defaultValue: string } | null = null + let dialogURL = "" + let dialogRevision = 0 + let generation = 0 + let revision = 0 + cdp.on("Page.frameNavigated", ({ frame }) => { + documents.set(frame.id, frame.url) + revision++ + }) + cdp.on("Page.frameDetached", ({ frameId }) => { + documents.delete(frameId) + revision++ + }) + let closed = false + const state = (): Browser.Tab => ({ + id: options.id, + url: contents.getURL().slice(0, 16_384), + title: contents.getTitle().slice(0, 2_048), + loading: contents.isLoading(), + canGoBack: contents.navigationHistory.canGoBack(), + canGoForward: contents.navigationHistory.canGoForward(), + generation, + }) + const publish = () => { + if (!closed) options.publish() + } + const reset = (event: Electron.Event<{ isMainFrame: boolean; isSameDocument: boolean }>) => { + if (!event.isMainFrame || event.isSameDocument) return + generation++ + documents.clear() + refs.clear() + diagnostics.clear() + publish() + } + contents.on("did-start-navigation", reset) + contents.on("did-stop-loading", publish) + contents.on("did-navigate-in-page", publish) + contents.on("page-title-updated", publish) + contents.on("render-process-gone", () => { + if (!closed) options.fail() + }) + contents.debugger.on("detach", () => { + if (!closed) options.fail() + }) + contents.session.setPermissionRequestHandler((_contents, _permission, callback) => callback(false)) + contents.session.setPermissionCheckHandler(() => false) + contents.session.setDevicePermissionHandler(() => false) + contents.session.setDisplayMediaRequestHandler((_request, callback) => callback({})) + contents.on("content-bounds-updated", (event) => event.preventDefault()) + // Sub-frames keep Chromium's own rules so blob:/data: viewers and sandboxed previews still load. + const guard = (event: Electron.Event<{ url: string; isMainFrame: boolean }>) => { + if (!event.isMainFrame || event.url === "about:blank" || destinationOrigin(event.url)) return + event.preventDefault() + options.publish("ERR_BLOCKED_BY_CLIENT") + } + contents.on("will-frame-navigate", guard) + contents.on("will-redirect", guard) + contents.setWindowOpenHandler(({ url }) => + url === "about:blank" || destinationOrigin(url) + ? { + action: "allow", + outlivesOpener: true, + overrideBrowserWindowOptions: { + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + webSecurity: true, + webviewTag: false, + devTools: false, + partition: options.partition, + }, + }, + createWindow: (popupOptions) => options.popup(popupOptions), + } + : { action: "deny" }, + ) + const download = (_event: Electron.Event, item: Electron.DownloadItem, source: WebContents) => { + if (source !== contents) return + try { + const file = files.add(item.getFilename(), item.getMimeType() || "application/octet-stream", [ + ...sourceURLs(), + ...item.getURLChain(), + ]) + item.setSavePath(file.path) + item.on("updated", () => { + file.bytes = item.getReceivedBytes() + if (file.bytes > Browser.MAX_FILE_BYTES) item.cancel() + }) + item.once("done", (_event, status) => { + file.bytes = item.getReceivedBytes() + file.state = status === "completed" ? "completed" : "failed" + }) + } catch { + item.cancel() + options.publish("download_failed") + } + } + contents.session.on("will-download", download) + cdp.on("Runtime.executionContextCreated", ({ context }, sessionID) => { + const aux = context.auxData as { frameId?: string; isDefault?: boolean } | undefined + if (aux?.frameId && aux.isDefault) contexts.set(aux.frameId, { id: context.id, sessionID }) + }) + cdp.on("Runtime.executionContextDestroyed", ({ executionContextId }, sessionID) => { + contexts.forEach((context, key) => { + if (context.id === executionContextId && context.sessionID === sessionID) contexts.delete(key) + }) + }) + cdp.on("Target.attachedToTarget", ({ sessionId, targetInfo }, parentSessionID) => { + if (targetInfo.type !== "iframe") return + const parentID = targetInfo.parentFrameId ?? Array.from(sessions).find(([, id]) => id === parentSessionID)?.[0] + if (parentID) parents.set(targetInfo.targetId, parentID) + sessions.set(targetInfo.targetId, sessionId) + void Promise.all([ + diagnostics.enable(sessionId), + cdp.send("Page.enable", {}, sessionId), + cdp.send( + "Target.setAutoAttach", + { + autoAttach: true, + waitForDebuggerOnStart: false, + flatten: true, + filter: [{ type: "iframe", exclude: false }, { exclude: true }], + }, + sessionId, + ), + ]).catch(() => undefined) + }) + cdp.on("Target.detachedFromTarget", ({ sessionId }) => { + sessions.forEach((id, frameID) => { + if (id === sessionId) { + sessions.delete(frameID) + parents.delete(frameID) + } + }) + }) + cdp.on("Page.javascriptDialogOpening", (event) => { + dialogURL = event.url + dialogRevision++ + dialog = { + type: event.type, + message: event.message.slice(0, Browser.MAX_TEXT), + defaultValue: event.defaultPrompt ?? "", + } + dialogs.forEach((reject) => reject()) + publish() + }) + cdp.on("Page.javascriptDialogClosed", () => { + dialogRevision++ + dialog = null + publish() + }) + view.setBounds({ x: 0, y: 0, width: 1000, height: 700 }) + view.setVisible(false) + win.contentView.addChildView(view) + const corners = [new electron.ImageView(), new electron.ImageView()] + let cornerKey = "" + corners.forEach((corner) => { + corner.setVisible(false) + win.contentView.addChildView(corner) + }) + const ready = Promise.all([ + files.ready, + ...(options.initialize === false ? [] : [contents.loadURL("about:blank")]), + diagnostics.enable(), + cdp.send("Page.enable"), + cdp.send("DOM.enable"), + cdp.send("Target.setAutoAttach", { + autoAttach: true, + waitForDebuggerOnStart: false, + flatten: true, + filter: [{ type: "iframe", exclude: false }, { exclude: true }], + }), + ]).then(() => undefined) + + return { + view, + contents, + state, + ready, + layout(bounds: Electron.Rectangle, background?: readonly [number, number, number, number], radius = 10) { + view.setBounds(bounds) + const size = Math.min(radius, Math.floor(bounds.width / 2), Math.floor(bounds.height / 2)) + const scale = electron.screen.getDisplayMatching(win.getBounds()).scaleFactor + const key = background && size > 0 ? `${background}:${size}:${scale}` : "" + if (key && key !== cornerKey && background) { + createCornerImages(background, size, scale).forEach((image, index) => corners[index].setImage(image)) + } + cornerKey = key + corners.forEach((corner, index) => { + // A composited layer is required above WebContentsView. A zero-duration + // bounds update creates that layer without a visible animation. + corner.setBounds( + { + x: bounds.x + (index ? bounds.width - size : 0), + y: bounds.y + bounds.height - size, + width: size, + height: size, + }, + { animate: { duration: 0 } }, + ) + }) + }, + setVisible(visible: boolean) { + view.setVisible(visible) + corners.forEach((corner) => corner.setVisible(visible && !!cornerKey)) + }, + async execute(command: Browser.Command, signal: AbortSignal): Promise { + await ready + abortError(signal) + if (closed) + throw new Error( + "Browser tab was closed. Call browser.tabs.list({}) and choose an existing tabID; do not reuse the closed tab's refs.", + ) + if (dialog && command.action.type !== "dialog") + throw new Error( + 'A JavaScript dialog is open. Inspect it with browser.dialog({tabID,action:"get"}), then explicitly accept or dismiss it before continuing.', + ) + if (command.inspect) return { value: await inspect(command.action), files: [] } + if (command.target && JSON.stringify(await inspect(command.action)) !== JSON.stringify(command.target)) + throw new Error( + "Browser target changed while permission was pending. Take a fresh snapshot or listing and request the action again; it was not executed.", + ) + if ( + command.generation !== undefined && + command.generation !== generation && + !retainedOperations.has(command.action.type) + ) + throw new Error( + "The document changed before this operation ran. Call browser.tabs.list({}) to check its current URL, then browser.snapshot({tabID}) for fresh refs. Reconsider the action before retrying on the new page.", + ) + const modal = Promise.withResolvers() + const cancelled = Promise.withResolvers() + // The action underneath the race keeps running after a dialog wins it; it checks this + // signal before each further step, so a validation alert on one field stops the rest. + // A navigation is left alone: its beforeunload dialog is answered through browser.dialog + // and the pending load then proceeds or not. + const run = new AbortController() + const cancel = () => { + run.abort() + cancelled.reject( + new Error( + "Browser operation was cancelled. Inspect the tab before deciding to repeat an action; cancellation does not undo changes already made.", + ), + ) + } + signal.addEventListener("abort", cancel, { once: true }) + const reject = () => { + if (command.action.type !== "navigate") run.abort() + modal.reject( + new Error( + 'A JavaScript dialog opened while the action was running. Inspect it with browser.dialog({tabID,action:"get"}) and accept or dismiss it. Do not repeat the original action just to close the dialog.', + ), + ) + } + if (command.action.type !== "dialog") dialogs.add(reject) + try { + return await Promise.race([ + execute(command.action, command.files, run.signal, command.target), + modal.promise, + cancelled.promise, + ]) + } finally { + signal.removeEventListener("abort", cancel) + dialogs.delete(reject) + } + }, + async dispose() { + if (closed) return + closed = true + detachNetwork?.() + contents.session.off("will-download", download) + await profiling.dispose() + cdp.dispose() + refs.clear() + if (!win.isDestroyed()) { + corners.forEach((corner) => win.contentView.removeChildView(corner)) + win.contentView.removeChildView(view) + } + if (!contents.isDestroyed()) contents.close({ waitForBeforeUnload: false }) + await files.dispose() + }, + } + + async function execute( + action: Browser.Action, + transfers: readonly Browser.File[], + signal: AbortSignal, + approved?: Browser.Target, + ): Promise { + const captureSources = sourceURLs() + const transfer = (id: Browser.FileID) => files.transfer(id, approved?.resources) + const result = (value: unknown, attached: Browser.File[] = []): Browser.Result => { + const json = Schema.decodeUnknownSync(Schema.Json)(value) + if (JSON.stringify(json).length > 512_000) + throw new Error( + "Browser result exceeds 512000 JSON characters. Request fewer entries, reduce snapshot depth, or return only selected fields from the evaluation script. Repeating the same request will not reduce its output.", + ) + return { value: json, files: attached } + } + switch (action.type) { + case "navigate": { + const url = normalizeURL(action.url) + const cancel = () => contents.stop() + signal.addEventListener("abort", cancel, { once: true }) + try { + await contents.loadURL(url) + } finally { + signal.removeEventListener("abort", cancel) + } + abortError(signal) + return result(state()) + } + case "back": + case "forward": + case "reload": + case "stop": { + if (action.type === "back" && contents.navigationHistory.canGoBack()) contents.navigationHistory.goBack() + if (action.type === "forward" && contents.navigationHistory.canGoForward()) + contents.navigationHistory.goForward() + if (action.type === "reload") contents.reload() + if (action.type === "stop") contents.stop() + if (action.type !== "stop") await waitFor(() => !contents.isLoading(), signal, 30_000) + return result(state()) + } + case "frames": + return result({ tab: state(), frames: await frames() }) + case "snapshot": + case "find": + return result({ tab: state(), ...(await snapshot(action)) }) + case "evaluate": { + const context = action.frameID ? contexts.get(action.frameID) : undefined + if (action.frameID && !context) + throw new Error( + "Frame context is unavailable. Call browser.frames({tabID}) and use a current frameID from this tab, or omit frameID to target the main frame.", + ) + const value = await cdp.send( + "Runtime.evaluate", + { + expression: action.script, + contextId: context?.id, + awaitPromise: true, + returnByValue: true, + userGesture: true, + }, + context?.sessionID, + ) + if (value.exceptionDetails) + throw new Error( + `Page JavaScript threw an exception. Check the script and frameID; inspect the page before repeating code with side effects. Details: ${(value.exceptionDetails.exception?.description ?? value.exceptionDetails.text).slice(0, 800)}`, + ) + abortError(signal) + return result({ tab: state(), value: value.result.value ?? null }) + } + case "click": + await click(target(action.ref), action.button ?? "left", action.count ?? 1, action.modifiers) + break + case "hover": + await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", ...(await point(target(action.ref))) }) + break + case "drag": { + const source = target(action.from) + const destination = target(action.to) + await point(destination) + const from = await point(source) + const box = await rect(destination) + const to = { x: box.x + box.width / 2, y: box.y + box.height / 2 } + const html5 = await call(source, "function() { return this.draggable; }") + let data: Protocol.Input.DragData | undefined + const off = cdp.on("Input.dragIntercepted", (event) => { + data = event.data + }) + await cdp.send("Input.setInterceptDrags", { enabled: true }) + await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", ...from }) + await cdp.send("Input.dispatchMouseEvent", { + type: "mousePressed", + ...from, + button: "left", + buttons: 1, + clickCount: 1, + }) + try { + for (let i = 1; i <= 10; i++) { + abortError(signal) + await cdp.send("Input.dispatchMouseEvent", { + type: "mouseMoved", + x: from.x + ((to.x - from.x) * i) / 10, + y: from.y + ((to.y - from.y) * i) / 10, + button: "left", + buttons: 1, + }) + } + if (html5) await waitFor(() => data !== undefined, signal, 2_000) + if (data) { + for (const type of ["dragEnter", "dragOver", "drop"]) + await cdp.send("Input.dispatchDragEvent", { type, ...to, data }) + } + } finally { + off() + await cdp.send("Input.setInterceptDrags", { enabled: false }) + await cdp.send("Input.dispatchMouseEvent", { type: "mouseReleased", ...to, button: "left", clickCount: 1 }) + } + break + } + case "fill": + await fill(target(action.ref), action.text, signal) + break + case "fill_form": + for (const field of action.fields) { + abortError(signal) + if (field.type === "text") await fill(target(field.ref), field.value, signal) + if (field.type === "select") await select(target(field.ref), field.values) + if (field.type === "check") await check(target(field.ref), field.checked) + } + break + case "select": + await select(target(action.ref), action.values) + break + case "check": + await check(target(action.ref), action.checked) + break + case "press": + await key(action.key) + break + case "scroll": { + const bounds = view.getBounds() + await cdp.send("Input.dispatchMouseEvent", { + type: "mouseWheel", + x: bounds.width / 2, + y: bounds.height / 2, + deltaX: action.deltaX ?? 0, + deltaY: action.deltaY, + }) + break + } + case "wait": { + if (action.condition !== "load" && !action.text) + throw new Error( + 'browser.wait requires non-empty text for condition "text" or "textGone". Use condition "load" without text to wait for loading.', + ) + await waitFor( + async () => { + if (action.condition === "load") return !contents.isLoading() + const context = action.frameID ? contexts.get(action.frameID) : undefined + if (action.frameID && !context) + throw new Error( + "Frame context is unavailable. Call browser.frames({tabID}) and use a frameID from this tab.", + ) + const value = await cdp.send( + "Runtime.evaluate", + { + expression: `document.body?.innerText.includes(${JSON.stringify(action.text)}) ?? false`, + returnByValue: true, + contextId: context?.id, + }, + context?.sessionID, + ) + return Boolean(value.result.value) === (action.condition === "text") + }, + signal, + action.timeoutMs, + ).catch((error) => { + if (signal.aborted) throw error + throw new Error( + `browser.wait failed for condition ${JSON.stringify(action.condition)} (timeoutMs: ${action.timeoutMs ?? 10_000}). Inspect browser.snapshot({tabID}) and check text/frameID before retrying; timeoutMs can be increased up to 30000 for a genuinely slow page. Details: ${error instanceof Error ? error.message : String(error)}`, + ) + }) + break + } + case "screenshot": { + if (action.ref && action.fullPage) + throw new Error( + "Choose either ref for an element screenshot or fullPage:true for the whole page. Remove the other argument before retrying.", + ) + await waitFor(() => view.getVisible() && win.isVisible() && !win.isMinimized(), signal, 3_000).catch( + (error) => { + if (signal.aborted) throw error + throw new Error( + "Screenshot needs a visible tab. Call browser.tabs.focus and keep its desktop window visible.", + ) + }, + ) + const element = action.ref ? await rect(target(action.ref), true) : undefined + const metrics = await cdp.send("Page.getLayoutMetrics") + const bounds = element + ? { + ...element, + x: element.x + metrics.cssVisualViewport.pageX, + y: element.y + metrics.cssVisualViewport.pageY, + } + : action.fullPage + ? metrics.cssContentSize + : { + x: metrics.cssVisualViewport.pageX, + y: metrics.cssVisualViewport.pageY, + width: metrics.cssVisualViewport.clientWidth, + height: metrics.cssVisualViewport.clientHeight, + } + const pixelRatio = contents.getZoomFactor() * electron.screen.getDisplayMatching(win.getBounds()).scaleFactor + const scale = Math.min(1, (action.maxWidth ?? 2000) / (bounds.width * pixelRatio)) + if (bounds.width <= 0 || bounds.height <= 0) + throw new Error( + "Element or page has no visible screenshot area. Take a fresh snapshot and choose a visible element, or omit ref to capture the viewport.", + ) + if (bounds.width * bounds.height * (scale * pixelRatio) ** 2 > 16_000_000) + throw new Error("Screenshot exceeds 16 megapixels; capture an element or use a smaller maxWidth.") + const format = action.format ?? "png" + const capture = await cdp.send("Page.captureScreenshot", { + format, + quality: format === "png" ? undefined : (action.quality ?? 80), + captureBeyondViewport: true, + clip: { ...bounds, scale }, + }) + const id = await files.save(`screenshot.${format}`, `image/${format}`, Buffer.from(capture.data, "base64"), [ + ...captureSources, + ...sourceURLs(), + ]) + return result({ tab: state() }, [await transfer(id)]) + } + case "dialog": { + if (action.action !== "get") { + if (!dialog) + throw new Error( + 'This tab has no JavaScript dialog to handle. browser.dialog({tabID,action:"get"}) returns null when none is open; continue without accepting or dismissing one.', + ) + await cdp.send("Page.handleJavaScriptDialog", { + accept: action.action === "accept", + promptText: action.promptText, + }) + dialog = null + } + return result({ tab: state(), dialog }) + } + case "files.upload": + case "files.drop": { + if (!transfers.length) + throw new Error( + "Upload command has no file bytes. Supply server-local paths to browser.files.upload/drop; do not call the desktop RPC directly with desktop paths. If paths were supplied, report a client/server transfer mismatch.", + ) + const local = await Promise.all( + transfers.map(async (file) => files.get(await files.save(file.name, file.mime, file.data)).path), + ) + const element = target(action.ref) + if (action.type === "files.upload") + await cdp.send("DOM.setFileInputFiles", { files: local, backendNodeId: element.backendID }, element.sessionID) + if (action.type === "files.drop") { + const position = await point(element) + for (const type of ["dragEnter", "dragOver", "drop"]) + await cdp.send("Input.dispatchDragEvent", { + type, + ...position, + data: { items: [], files: local, dragOperationsMask: 1 }, + }) + } + break + } + case "files.list": + return result({ tab: state(), files: files.list() }) + case "files.get": + return result({ tab: state() }, [await transfer(action.fileID)]) + case "console": + return result({ tab: state(), ...diagnostics.console(action) }) + case "network.list": + return result({ tab: state(), ...diagnostics.list(action) }) + case "network.get": + return result({ tab: state(), ...(await diagnostics.get(action)) }) + case "trace.start": + await profiling.startTrace(action.durationMs) + return result({ tab: state(), recording: true }) + case "trace.stop": { + const value = await profiling.stopTrace() + return result({ tab: state(), durationMs: value.durationMs, incomplete: value.incomplete }, [ + await transfer(value.id), + ]) + } + case "cpu.start": + await profiling.startCpu() + return result({ tab: state(), recording: true }) + case "cpu.stop": { + const value = await profiling.stopCpu() + return result({ tab: state(), durationMs: value.durationMs }, [await transfer(value.id)]) + } + case "heap.snapshot": + return result({ tab: state() }, [await transfer(await profiling.heap())]) + case "trace.analyze": + case "cpu.analyze": + case "heap.summary": + case "heap.query": + case "heap.object": + case "heap.compare": + return result({ tab: state(), ...(await profiling.analyze(action)) }) + case "lighthouse": { + const { audit } = await import("./browser/lighthouse") + const report = await audit(contents, files, cdp, captureSources) + return result( + { tab: state(), scores: report.scores, failures: report.failures }, + await Promise.all(report.files.map(transfer)), + ) + } + default: + throw new Error( + "This operation was routed to a page instead of the tab manager. Report a desktop/plugin routing mismatch; changing tab IDs or repeating the operation will not fix it.", + ) + } + abortError(signal) + return result(state()) + } + + async function inspect(action: Browser.Action): Promise { + // Most CDP queries cannot run while a JavaScript dialog blocks the renderer. + if (action.type === "dialog") + return { + resources: [dialog ? dialogURL : contents.getURL()], + key: `${generation}:${dialogRevision}:${Boolean(dialog)}`, + } + const fileIDs = + action.type === "heap.compare" ? [action.before, action.after] : "fileID" in action ? [action.fileID] : [] + if (fileIDs.length) + return { + resources: [...new Set(fileIDs.flatMap((id) => files.get(id).resources))].sort(), + key: JSON.stringify(fileIDs), + } + if (action.type === "network.get") return { resources: [diagnostics.info(action.id).url], key: action.id } + if (action.type === "trace.stop" || action.type === "cpu.stop") + return profiling.target(action.type === "trace.stop" ? "trace" : "cpu") + const tree = await frames() + tree.forEach((frame) => documents.set(frame.id, frame.url)) + const refs = + action.type === "drag" + ? [action.from, action.to] + : action.type === "fill_form" + ? action.fields.map((field) => field.ref) + : "ref" in action && action.ref + ? [action.ref] + : [] + const selected = refs.map(target) + const frameIDs = selected.length + ? selected.map((element) => element.frameID) + : "frameID" in action && action.frameID + ? [action.frameID] + : [] + const urls = frameIDs.map((id) => { + const frame = tree.find((frame) => frame.id === id) + if (!frame) throw new Error("Frame is unavailable. Call browser.frames({tabID}) and use a current frameID.") + return frame.url + }) + const capture = ["screenshot", "lighthouse", "trace.start", "cpu.start", "heap.snapshot"].includes(action.type) + return { + resources: [ + ...new Set( + action.type === "navigate" + ? [new URL(normalizeURL(action.url)).href] + : capture + ? sourceURLs() + : urls.length + ? urls + : [contents.getURL()], + ), + ].sort(), + key: JSON.stringify([generation, revision, selected]), + } + } + + function target(ref: Browser.Ref): Element { + const value = refs.get(ref.replace(/^@/, "")) + if (!value) + throw new Error( + "Element ref is stale or belongs to another tab. Call browser.snapshot({tabID}) and use a ref from that tab's newest snapshot. Do not reuse refs after navigation or a newer snapshot.", + ) + return value + } + + async function frames() { + const root = await cdp.send("Page.getFrameTree") + const result: { id: string; parentID?: string; url: string; name: string }[] = [] + const walk = (tree: Protocol.Page.FrameTree, parentID?: string) => { + if (!result.some((frame) => frame.id === tree.frame.id)) + result.push({ + id: tree.frame.id, + ...(tree.frame.parentId || parentID ? { parentID: tree.frame.parentId ?? parentID } : {}), + url: tree.frame.url, + name: tree.frame.name ?? "", + }) + tree.childFrames?.forEach((child) => walk(child, tree.frame.id)) + } + walk(root.frameTree) + const children = await Promise.all( + Array.from(sessions, async ([id, sessionID]) => ({ + id, + tree: await cdp.send("Page.getFrameTree", {}, sessionID).catch(() => undefined), + })), + ) + children.forEach(({ id, tree }) => { + if (tree) walk(tree.frameTree, parents.get(id) ?? root.frameTree.frame.id) + }) + return result + } + + async function call(element: Element, functionDeclaration: string, args: unknown[] = []) { + const object = await cdp.send("DOM.resolveNode", { backendNodeId: element.backendID }, element.sessionID) + const objectId = object.object.objectId + if (!objectId) + throw new Error( + "Element is no longer available. Call browser.snapshot({tabID}) and use a fresh ref; the page may have replaced the element.", + ) + try { + const result = await cdp.send( + "Runtime.callFunctionOn", + { + objectId, + functionDeclaration, + arguments: args.map((value) => ({ value })), + returnByValue: true, + awaitPromise: true, + userGesture: true, + }, + element.sessionID, + ) + if (result.exceptionDetails) + throw new Error(result.exceptionDetails.exception?.description ?? result.exceptionDetails.text) + return result.result.value as unknown + } finally { + await cdp.send("Runtime.releaseObject", { objectId }, element.sessionID).catch(() => undefined) + } + } + + async function rect(element: Element, scroll = false) { + if (scroll) { + await cdp.send("DOM.scrollIntoViewIfNeeded", { backendNodeId: element.backendID }, element.sessionID) + // Force a compositor update after scrolling. requestAnimationFrame can + // stall in a hidden WebContentsView, even with background throttling off. + await contents.capturePage(undefined, { stayHidden: false, stayAwake: true }) + } + const shape = Schema.Struct({ x: Schema.Finite, y: Schema.Finite, width: Schema.Finite, height: Schema.Finite }) + const value = { + ...Schema.decodeUnknownSync(shape)( + await call( + element, + "function() { const r = this.getBoundingClientRect(); return {x:r.x,y:r.y,width:r.width,height:r.height}; }", + ), + ), + } + const tree = await frames() + let frame = tree.find((frame) => frame.id === element.frameID) + while (frame?.parentID) { + const parent = { frameID: frame.parentID, sessionID: sessionFor(frame.parentID, tree) } + const owner = await cdp.send("DOM.getFrameOwner", { frameId: frame.id }, parent.sessionID) + // A CSS transform on the iframe scales its content box; the child's own coordinates are unscaled. + const box = Schema.decodeUnknownSync( + Schema.Struct({ ...shape.fields, scaleX: Schema.Finite, scaleY: Schema.Finite }), + )( + await call( + { backendID: owner.backendNodeId, ...parent }, + "function() { const r = this.getBoundingClientRect(); const sx = this.offsetWidth ? r.width / this.offsetWidth : 1; const sy = this.offsetHeight ? r.height / this.offsetHeight : 1; return {x:r.x+this.clientLeft*sx,y:r.y+this.clientTop*sy,width:r.width,height:r.height,scaleX:sx,scaleY:sy}; }", + ), + ) + value.x = box.x + value.x * box.scaleX + value.y = box.y + value.y * box.scaleY + value.width *= box.scaleX + value.height *= box.scaleY + frame = tree.find((item) => item.id === frame?.parentID) + } + return value + } + + // Same-process child frames have no CDP target of their own; the nearest ancestor with one owns them. + function sessionFor(frameID: string, tree: { id: string; parentID?: string }[]) { + let id: string | undefined = frameID + while (id && !sessions.has(id)) id = tree.find((frame) => frame.id === id)?.parentID + return id ? sessions.get(id) : undefined + } + + async function point(element: Element) { + const box = await rect(element, true) + return { x: box.x + box.width / 2, y: box.y + box.height / 2 } + } + + async function click(element: Element, button = "left", count = 1, modifiers: readonly string[] = []) { + const position = await point(element) + const flags = modifiers.reduce((mask, key) => mask | ({ Alt: 1, Control: 2, Meta: 4, Shift: 8 }[key] ?? 0), 0) + await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", ...position, modifiers: flags }) + for (let clickCount = 1; clickCount <= count; clickCount++) { + await cdp.send("Input.dispatchMouseEvent", { + type: "mousePressed", + ...position, + button, + clickCount, + modifiers: flags, + }) + await cdp.send("Input.dispatchMouseEvent", { + type: "mouseReleased", + ...position, + button, + clickCount, + modifiers: flags, + }) + } + } + + async function fill(element: Element, value: string, signal: AbortSignal) { + const kind = await call( + element, + "function() { if (this.disabled || this.readOnly) return; if (this instanceof HTMLTextAreaElement || this.isContentEditable) return 'text'; if (!(this instanceof HTMLInputElement)) return; if (['date','time','datetime-local','month','week'].includes(this.type)) return 'structured'; if (!['file','checkbox','radio','button','submit','reset','image','hidden','range','color'].includes(this.type)) return 'text'; }", + ) + if (!kind) + throw new Error( + "Target is not an enabled editable text field. Take a fresh snapshot and choose a textbox; use browser.select for dropdowns, browser.check for checkboxes/radios, or browser.files.upload for file inputs.", + ) + // Keyboard input cannot compose a date or time control's value; Chromium clears a malformed one. + if (kind === "structured") { + await call( + element, + "function(value) { const previous = this.value; this.focus(); this.value = value; if (this.value !== value) { this.value = previous; throw new Error('The ' + this.type + ' input rejected this value and keeps its previous one. Use its required format, for example 2026-09-07 for date, 14:45 for time, 2026-09-07T14:45 for datetime-local, 2026-09 for month, or 2026-W37 for week.'); } this.dispatchEvent(new Event('input',{bubbles:true})); this.dispatchEvent(new Event('change',{bubbles:true})); }", + [value], + ) + return + } + await cdp.send("DOM.focus", { backendNodeId: element.backendID }, element.sessionID) + // Focusing this field blurs the previous one; a validation dialog from that blur must stop here. + abortError(signal) + await key(process.platform === "darwin" ? "Meta+A" : "Control+A") + await key("Backspace") + abortError(signal) + await cdp.send("Input.insertText", { text: value }) + } + + async function select(element: Element, values: readonly string[]) { + await call( + element, + `function(values) { if (!(this instanceof HTMLSelectElement) || this.disabled) throw new Error('Target is not an enabled HTML select. Take a fresh snapshot and choose an enabled dropdown ref.'); if (!this.multiple && values.length !== 1) throw new Error('This dropdown accepts exactly one value; pass a one-item values array.'); for (const value of values) if (!Array.from(this.options).some(option => option.value === value && !option.disabled)) throw new Error('Option value was not found or is disabled. Inspect option values with browser.evaluate before retrying browser.select; values are not visible labels.'); for (const option of this.options) option.selected = values.includes(option.value); this.dispatchEvent(new Event('input',{bubbles:true})); this.dispatchEvent(new Event('change',{bubbles:true})); }`, + [values], + ) + } + + async function check(element: Element, checked: boolean) { + const current = await call( + element, + "function(checked) { if (!(this instanceof HTMLInputElement) || !['checkbox','radio'].includes(this.type) || this.disabled) throw new Error('Target is not an enabled checkbox or radio. Take a fresh snapshot and choose the correct ref.'); if (this.type === 'radio' && this.checked && !checked) throw new Error('A selected radio cannot be cleared by clicking it. Select a different radio in its group instead.'); return this.checked; }", + [checked], + ) + if (current !== checked) await click(element) + if ((await call(element, "function() { return this.checked; }")) !== checked) + throw new Error( + "The page did not keep the requested checked state. Inspect the current snapshot and page validation before retrying; do not blindly toggle the control again.", + ) + } + + async function key(chord: string) { + if (!chord) + throw new Error( + "A key is required. Use a named key such as Enter or ArrowDown, a single character, or a chord such as Control+A.", + ) + const parts = (chord.endsWith("+") ? chord.slice(0, -1) : chord).split("+") + const key = parts.pop() || "+" + const modifiers = parts.reduce((mask, key) => { + const bit = { Alt: 1, Control: 2, Meta: 4, Shift: 8 }[key] + if (!bit) + throw new Error( + `Unknown key modifier ${JSON.stringify(key)}. Supported modifiers are Alt, Control, Meta, and Shift; for example Control+A. Use Meta for macOS command shortcuts.`, + ) + return mask | bit + }, 0) + const codes: Record = { + Enter: 13, + Tab: 9, + Escape: 27, + Backspace: 8, + Delete: 46, + ArrowUp: 38, + ArrowDown: 40, + ArrowLeft: 37, + ArrowRight: 39, + PageUp: 33, + PageDown: 34, + Home: 36, + End: 35, + Space: 32, + } + const code = + codes[key] ?? + (key.length === 1 + ? key.toUpperCase().charCodeAt(0) + : /^F([1-9]|1[0-2])$/.test(key) + ? 111 + Number(key.slice(1)) + : undefined) + if (code === undefined) + throw new Error( + `Unknown key ${JSON.stringify(key)}. Use Enter, Tab, Escape, Backspace, Delete, ArrowUp/Down/Left/Right, PageUp/Down, Home, End, Space, F1–F12, or one character. Use browser.fill for text.`, + ) + // Named keys need their character data too: Enter submits forms and inserts newlines only with "\r". + const text = key === "Enter" ? "\r" : key === "Space" ? " " : key.length === 1 ? key : undefined + const params = { + key: key === "Space" ? " " : key, + windowsVirtualKeyCode: code, + modifiers, + ...(text !== undefined && !(modifiers & 6) ? { text } : {}), + } + await cdp.send("Input.dispatchKeyEvent", { type: "keyDown", ...params }) + await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", ...params }) + } + + async function snapshot(action: Extract) { + const tree = await frames() + const selected = action.type === "snapshot" && action.ref ? target(action.ref) : undefined + const frameID = selected?.frameID ?? action.frameID ?? tree[0]?.id + if (!frameID || !tree.some((frame) => frame.id === frameID)) + throw new Error( + "Frame is unavailable. Call browser.frames({tabID}) and use a current frameID from this tab; omit frameID for the main frame.", + ) + const sessionID = sessionFor(frameID, tree) + const depth = action.type === "snapshot" ? (action.depth ?? 8) : 8 + const ax = await cdp.send("Accessibility.getFullAXTree", { frameId: frameID, depth }, sessionID) + const nodes = new Map(ax.nodes.map((node) => [node.nodeId, node])) + const root = selected ? ax.nodes.find((node) => node.backendDOMNodeId === selected.backendID) : ax.nodes[0] + if (!root) + throw new Error( + "Element is absent from this frame's accessibility snapshot. Retry browser.snapshot with the same tabID and no ref to refresh the frame, then choose a returned ref.", + ) + refs.clear() + const lines: string[] = [] + let truncated = false + const walk = async (node: Protocol.Accessibility.AXNode, level: number): Promise => { + if (level > depth || lines.length >= 500) { + truncated = true + return + } + const role = String(node.role?.value ?? "node") + .replace(/[^a-zA-Z0-9_-]/g, "") + .slice(0, 40) + const properties = new Map(node.properties?.map((property) => [property.name, property.value.value]) ?? []) + if (!node.ignored) { + const actionable = + role !== "RootWebArea" && + (properties.get("focusable") || /^(button|link|textbox|combobox|checkbox|radio|option)$/.test(role)) + const ref = actionable && node.backendDOMNodeId ? `e${++nextRef}` : "" + const element = node.backendDOMNodeId ? { backendID: node.backendDOMNodeId, frameID, sessionID } : undefined + if (ref && element) refs.set(ref, element) + const flags = (["checked", "disabled", "expanded", "selected"] as const).flatMap((name) => + properties.has(name) ? [`${name}=${properties.get(name)}`] : [], + ) + const box = + action.type === "snapshot" && action.boxes && ref && element + ? await rect(element).catch(() => undefined) + : undefined + lines.push( + `${" ".repeat(level)}${ref ? `@${ref} ` : ""}[${role}] ${JSON.stringify( + String(node.name?.value ?? "") + .replace(/\s+/g, " ") + .slice(0, 300), + )} ${flags.join(" ")}${box ? ` box=${JSON.stringify(box)}` : ""}`, + ) + } + if (["textbox", "searchbox"].includes(role) || properties.get("editable")) return + for (const childID of node.childIds ?? []) { + const child = nodes.get(childID) + if (child) await walk(child, level + 1) + } + } + await walk(root, 0) + const content = ( + action.type === "find" ? lines.filter((line) => line.toLowerCase().includes(action.text.toLowerCase())) : lines + ).join("\n") + return { content: content.slice(0, Browser.MAX_TEXT), truncated: truncated || content.length > Browser.MAX_TEXT } + } +} diff --git a/packages/desktop/src/main/browser-pane-policy.test.ts b/packages/desktop/src/main/browser-pane-policy.test.ts new file mode 100644 index 00000000000..ed1f707bc89 --- /dev/null +++ b/packages/desktop/src/main/browser-pane-policy.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from "bun:test" +import { destinationOrigin } from "./browser/policy" + +test("allows cross-origin HTTP navigation but rejects unsafe destinations and embedded credentials", () => { + expect(destinationOrigin("https://other.example/path")).toBe("https://other.example") + expect(destinationOrigin("http://localhost:3000/")).toBe("http://localhost:3000") + for (const url of [ + "file:///etc/passwd", + "javascript:alert(1)", + "data:text/html,test", + "https://user:pass@example.com", + ]) { + expect(destinationOrigin(url)).toBeUndefined() + } +}) diff --git a/packages/desktop/src/main/browser-pane.ts b/packages/desktop/src/main/browser-pane.ts new file mode 100644 index 00000000000..b791e88a622 --- /dev/null +++ b/packages/desktop/src/main/browser-pane.ts @@ -0,0 +1,389 @@ +import type { BrowserPaneCommand, BrowserPaneLayout, BrowserPaneTarget } from "@opencode-ai/app/desktop" +import { NodeHttpClient } from "@effect/platform-node" +import { Browser } from "@opencode-ai/plugin-browser/rpc" +import { OpenCode } from "@opencode-ai/client/effect" +import { SessionID } from "@opencode-ai/schema/session-id" +import type { BrowserWindow } from "electron" +import { Deferred, Effect, ManagedRuntime, Queue, Schedule, Schema, Stream } from "effect" +import { HttpClient, HttpClientRequest } from "effect/unstable/http" +import { BrowserPaneEvent } from "../shared/ipc-rpc/events" +import { createBrowserPage, type BrowserPage } from "./browser-chromium" +import { browserFailure } from "./browser/errors" +import { createBrowserNetwork, type BrowserNetwork } from "./browser/network" +import { destinationOrigin } from "./browser/policy" +import { emitIpcEvent } from "./ipc-events" +import { SidecarCredentials } from "./service/sidecar-credentials" + +type Entry = { + bindingID: string + win: BrowserWindow + abort: AbortController + registered: PromiseWithResolvers + requests: Map + report?: (event: BrowserPaneEvent["event"]) => void + cleanup?: () => void + pages: Map + focusedTabID: Browser.TabID | null + partition: string + lastState?: string + network?: BrowserNetwork +} + +export function createBrowserPane() { + const entries = new Map() + // Keep long-lived RPC requests off Chromium's shared HTTP connection pool. + const runtime = ManagedRuntime.make(NodeHttpClient.layerNodeHttp) + let disposed = false + return { + async register(win: BrowserWindow, bindingID: string, target: BrowserPaneTarget) { + if (disposed || !destinationOrigin(target.endpoint.url)) throw new Error("browser.pane.registration.invalid") + if (target.endpoint.username && !target.endpoint.password) throw new Error("browser.pane.endpoint.invalid") + if (entries.has(bindingID)) throw new Error("browser.pane.owner.invalid") + if (win.isDestroyed() || win.webContents.isDestroyed()) throw new Error("browser.pane.owner.unavailable") + const sessionID = SessionID.make(target.sessionID) + const entry: Entry = { + bindingID, + win, + abort: new AbortController(), + registered: Promise.withResolvers(), + requests: new Map(), + pages: new Map(), + focusedTabID: null, + partition: `opencode-browser-${crypto.randomUUID()}`, + } + // "unsupported" means the server has no browser plugin; the renderer stops retrying. + let reason: "browser.pane.unsupported" | "browser.pane.replaced" | undefined + const stop = () => close(entry, reason) + const navigate = (event: Electron.Event<{ isMainFrame: boolean; isSameDocument: boolean }>) => { + if (event.isMainFrame && !event.isSameDocument) stop() + } + win.webContents.once("destroyed", stop) + win.webContents.on("did-start-navigation", navigate) + entry.cleanup = () => { + if (win.isDestroyed()) return + win.webContents.off("destroyed", stop) + win.webContents.off("did-start-navigation", navigate) + } + entries.set(bindingID, entry) + void runtime + .runPromise( + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + // The renderer never holds the managed sidecar's password; Node requests bypass + // the webRequest header injection, so resolve the credential here in main. + const authorization = target.endpoint.password + ? `Basic ${Buffer.from(`${target.endpoint.username ?? "opencode"}:${target.endpoint.password}`).toString("base64")}` + : SidecarCredentials.authorization(SidecarCredentials.get(), target.endpoint.url) + const client = yield* OpenCode.make({ baseUrl: target.endpoint.url }).pipe( + Effect.provideService( + HttpClient.HttpClient, + authorization + ? HttpClient.mapRequest(http, HttpClientRequest.setHeader("authorization", authorization)) + : http, + ), + ) + const session = yield* client.session.get({ sessionID }) + const options = { + location: { directory: session.location.directory, workspace: session.location.workspaceID }, + } + const attachment = { sessionID, connectionID: crypto.randomUUID() } + const rpc = client.rpc(Browser.Definition) + entry.network = yield* createBrowserNetwork({ + rpc, + attachment, + location: options.location, + partition: entry.partition, + }) + const connected = yield* Deferred.make() + const outbound = yield* Queue.unbounded>() + // A send that fails because the server already replaced or closed this attachment must + // not decide the close reason; only the attach call's outcome does. + const send = (effect: Effect.Effect) => + Queue.offerUnsafe( + outbound, + effect.pipe(Effect.catchCause((cause) => Effect.logWarning("Browser send failed", cause))), + ) + const reply = (requestID: string, outcome: Browser.Outcome) => + send( + rpc.result({ ...attachment, requestID, outcome: Schema.encodeSync(Browser.Outcome)(outcome) }, options), + ) + // Report state before publishing it locally or completing a command. The server's copy of + // the inventory resolves every tab ID, so a state is retried until it arrives or the + // attachment ends; the results queued behind it then never name a tab the server lacks. + // "unavailable" means the server already dropped this attachment, which attach reports. + entry.report = (event) => { + const local = Effect.sync(() => publish(entry, event)) + if (event.type !== "state") return send(local) + send( + rpc.state({ ...attachment, state: event.state ?? { tabs: [], focusedTabID: null } }, options).pipe( + Effect.retry({ + while: (error) => !("type" in error && error.type === "unavailable"), + schedule: Schedule.min([Schedule.exponential("250 millis"), Schedule.spaced("10 seconds")]), + }), + Effect.ensuring(local), + ), + ) + } + const receive = client.event.subscribe().pipe( + Stream.runForEach((event) => + Effect.gen(function* () { + if (event.type === "server.connected") { + yield* Deferred.succeed(connected, undefined) + return + } + if ( + event.type !== "rpc.experimental.browser.control" || + event.data.connectionID !== attachment.connectionID + ) + return + const message = yield* Schema.decodeUnknownEffect(Browser.Control)(event.data).pipe( + Effect.tapError(() => + Effect.sync(() => { + reason = "browser.pane.unsupported" + }), + ), + ) + if (message.type === "attached") return entry.registered.resolve() + if (message.type === "cancel") return entry.requests.get(message.requestID)?.abort.abort() + const abort = new AbortController() + entry.requests.set(message.requestID, { abort }) + yield* rpc.command({ ...attachment, requestID: message.requestID }, options).pipe( + Effect.flatMap((command) => + Effect.promise(async () => { + entry.requests.set(message.requestID, { + abort, + ...("tabID" in command.action ? { tabID: command.action.tabID } : {}), + }) + reply( + message.requestID, + await execute(entry, command, abort.signal).then( + (result) => ({ type: "success" as const, result }), + (error: unknown) => browserFailure(command.action, error), + ), + ) + }), + ), + Effect.ensuring(Effect.sync(() => entry.requests.delete(message.requestID))), + // An operation this desktop cannot decode comes from a newer plugin; answer it so + // the agent does not wait out the server's timeout. + Effect.tapError((error) => + Effect.sync(() => { + if (!Schema.isSchemaError(error)) return + reply(message.requestID, { + type: "failure", + code: "unsupported", + message: + "This desktop app does not support the requested browser operation. Ask the user to update the desktop app, or use another operation.", + }) + }), + ), + // A request that vanished (cancelled before retrieval) fails only that request. + // Transport loss surfaces through the event stream and attach call instead. + Effect.catchCause((cause) => + abort.signal.aborted ? Effect.void : Effect.logWarning("Browser command failed", cause), + ), + Effect.forkScoped, + ) + }), + ), + ) + yield* Effect.raceAllFirst([ + receive, + Stream.fromQueue(outbound).pipe(Stream.runForEach((send) => send)), + Deferred.await(connected).pipe( + Effect.andThen(rpc.attach({ ...attachment, version: 4 }, options)), + Effect.tap((result) => + Effect.sync(() => { + if (result === "replaced") reason = "browser.pane.replaced" + }), + ), + ), + ]) + }).pipe( + Effect.scoped, + Effect.tapError((error) => + Effect.sync(() => { + const type = error instanceof Object && "type" in error ? error.type : undefined + if (type === "rpc.unavailable" || type === "rpc.method_not_found" || type === "rpc.invalid_input") + reason = "browser.pane.unsupported" + }), + ), + Effect.ensuring(Effect.sync(stop)), + ), + { signal: entry.abort.signal }, + ) + .catch(stop) + const timeout = setTimeout(stop, 15_000) + await entry.registered.promise.finally(() => clearTimeout(timeout)) + if (entries.get(bindingID) !== entry) throw new Error("browser.pane.registration.closed") + publishState(entry) + }, + layout(win: BrowserWindow, bindingID: string, value?: BrowserPaneLayout) { + const entry = owned(win, bindingID) + if (!value) return entry.pages.forEach((page) => page.setVisible(false)) + const page = entry.pages.get(value.tabID) + if (!page) return + const bounds = value.bounds + if (!value.visible || !bounds || bounds.width <= 0 || bounds.height <= 0) { + page.setVisible(false) + return + } + entry.pages.forEach((other) => { + if (other !== page) other.setVisible(false) + }) + page.layout(bounds, value.background, value.radius) + page.setVisible(true) + }, + async command(win: BrowserWindow, bindingID: string, command: BrowserPaneCommand) { + const entry = owned(win, bindingID) + await execute(entry, { action: command, files: [] }, new AbortController().signal) + }, + async close(win: BrowserWindow, bindingID: string) { + close(owned(win, bindingID)) + }, + async dispose() { + disposed = true + entries.forEach((entry) => close(entry)) + await runtime.dispose() + }, + } + + function owned(win: BrowserWindow, bindingID: string) { + const entry = entries.get(bindingID) + if (!entry || entry.win !== win) throw new Error("browser.pane.unavailable") + return entry + } + + function publish(entry: Entry, event: BrowserPaneEvent["event"]) { + if (!entries.has(entry.bindingID) || entry.win.isDestroyed() || entry.win.webContents.isDestroyed()) return + emitIpcEvent(entry.win.webContents, new BrowserPaneEvent({ bindingID: entry.bindingID, event })) + } + + function close(entry: Entry, reason = "browser.pane.registration.closed") { + if (entries.get(entry.bindingID) !== entry) return + entry.report = undefined + entry.requests.forEach((request) => request.abort.abort()) + entry.requests.clear() + entry.pages.forEach((page) => { + void page.dispose().catch(() => undefined) + }) + entry.pages.clear() + entry.focusedTabID = null + publishState(entry, reason) + entries.delete(entry.bindingID) + entry.registered.reject(new Error("browser.pane.registration.closed")) + entry.cleanup?.() + entry.abort.abort() + } + + async function closePage(entry: Entry, tabID: Browser.TabID, error?: string) { + const page = entry.pages.get(tabID) + if (!page) + throw new Error( + "This tab is no longer available. Call browser.tabs.list({}) and use an existing tabID from this session.", + ) + const focused = entry.focusedTabID === tabID + entry.requests.forEach((request) => { + if (request.tabID === tabID) request.abort.abort() + }) + entry.pages.delete(tabID) + if (focused) entry.focusedTabID = entry.pages.keys().next().value ?? null + await page.dispose() + publishState(entry, error) + } + + function publishState(entry: Entry, error?: string) { + const event = { + type: "state" as const, + state: { tabs: Array.from(entry.pages.values(), (page) => page.state()), focusedTabID: entry.focusedTabID }, + ...(error === undefined ? {} : { error }), + } + const next = JSON.stringify(event) + if (entry.lastState === next) return + entry.lastState = next + report(entry, event) + } + + function report(entry: Entry, event: BrowserPaneEvent["event"]) { + if (entry.report) return entry.report(event) + publish(entry, event) + } + + function create(entry: Entry, initialize = true, popupOptions?: Electron.BrowserWindowConstructorOptions) { + if (!entry.network) throw new Error("Browser network is not ready; no tab was opened.") + const id = Browser.TabID.make(`tab_${crypto.randomUUID()}`) + const fail = () => { + if (entry.pages.has(id)) void closePage(entry, id, "page_crashed").catch(() => undefined) + } + const page = createBrowserPage(entry.win, { + id, + partition: entry.partition, + network: entry.network, + initialize, + popupOptions, + fail, + publish: (error) => { + if (entry.pages.has(id)) publishState(entry, error) + }, + popup: (popupOptions) => { + const popup = create(entry, false, popupOptions) + focus(entry, popup.state().id) + return popup.contents + }, + }) + entry.pages.set(id, page) + void page.ready + .then(() => { + if (entry.pages.get(id) === page) publishState(entry) + }) + .catch(fail) + return page + } + + async function execute(entry: Entry, command: Browser.Command, signal: AbortSignal) { + const action = command.action + const state = () => ({ + tabs: Array.from(entry.pages.values(), (page) => page.state()), + focusedTabID: entry.focusedTabID, + }) + if (signal.aborted) + throw new Error( + "Browser request was cancelled. Do not repeat a mutating action until you have inspected its outcome.", + ) + if (action.type === "tabs.list") return { value: state(), files: [] } + if (action.type === "tabs.open") { + const page = create(entry) + if (action.focus !== false) focus(entry, page.state().id) + await page.ready + await page.execute( + { action: { type: "navigate", tabID: page.state().id, url: action.url ?? "about:blank" }, files: [] }, + signal, + ) + publishState(entry) + return { value: page.state(), files: [] } + } + const page = entry.pages.get(action.tabID) + if (!page) + throw new Error( + "Browser tab is unavailable. Call browser.tabs.list({}) and use an existing tabID from this session; a closed tab is not replaced automatically.", + ) + if (action.type === "tabs.focus") { + focus(entry, action.tabID) + return { value: page.state(), files: [] } + } + if (action.type === "tabs.close") { + await closePage(entry, action.tabID) + return { value: state(), files: [] } + } + await page.ready + const result = await page.execute(command, signal) + publishState(entry) + return result + } + + function focus(entry: Entry, tabID: Browser.TabID) { + entry.focusedTabID = tabID + publishState(entry) + report(entry, { type: "focus", tabID }) + } +} diff --git a/packages/desktop/src/main/browser/analysis.ts b/packages/desktop/src/main/browser/analysis.ts new file mode 100644 index 00000000000..def9ce4eaf8 --- /dev/null +++ b/packages/desktop/src/main/browser/analysis.ts @@ -0,0 +1,229 @@ +import { Schema } from "effect" + +const number = Schema.Finite +const Trace = Schema.Struct({ + traceEvents: Schema.Array( + Schema.Struct({ + name: Schema.optionalKey(Schema.String), + ph: Schema.optionalKey(Schema.String), + pid: Schema.optionalKey(number), + tid: Schema.optionalKey(number), + ts: Schema.optionalKey(number), + dur: Schema.optionalKey(number), + }), + ), +}) +const Cpu = Schema.Struct({ + startTime: number, + endTime: number, + nodes: Schema.Array( + Schema.Struct({ + id: number, + callFrame: Schema.Struct({ functionName: Schema.String, url: Schema.String, lineNumber: number }), + }), + ), + samples: Schema.optionalKey(Schema.Array(number)), + timeDeltas: Schema.optionalKey(Schema.Array(number)), +}) +const Heap = Schema.Struct({ + snapshot: Schema.Struct({ + meta: Schema.Struct({ + node_fields: Schema.Array(Schema.String), + node_types: Schema.Array(Schema.Union([Schema.String, Schema.Array(Schema.String)])), + edge_fields: Schema.Array(Schema.String), + edge_types: Schema.Array(Schema.Union([Schema.String, Schema.Array(Schema.String)])), + }), + }), + nodes: Schema.Array(number), + edges: Schema.Array(number), + strings: Schema.Array(Schema.String), +}) + +export function analyzeTrace(value: unknown, limit = 100) { + const decoded = Schema.decodeUnknownOption(Trace)(value) + if (decoded._tag === "None") + throw new Error( + "Selected file is not a Chromium performance trace. Use a fileID returned by browser.trace.stop for this tab; CPU profiles and heap snapshots use their own analysis tools.", + ) + const trace = decoded.value + const events = new Map() + const longTasks: number[] = [] + trace.traceEvents.forEach((event) => { + if (event.ph !== "X" || !event.name || event.dur === undefined) return + const duration = event.dur / 1000 + const item = events.get(event.name) ?? { name: event.name.slice(0, 2_048), count: 0, totalMs: 0, maxMs: 0 } + item.count++ + item.totalMs += duration + item.maxMs = Math.max(item.maxMs, duration) + events.set(event.name, item) + if ((event.name === "RunTask" || event.name === "ThreadControllerImpl::RunTask") && duration > 50) + longTasks.push(duration) + }) + return { + metrics: [ + { name: "recordedEvents", value: trace.traceEvents.length, unit: "count" }, + { name: "longTasks", value: longTasks.length, unit: "count" }, + { name: "longTaskBlocking", value: longTasks.reduce((sum, duration) => sum + duration - 50, 0), unit: "ms" }, + ], + events: Array.from(events.values()) + .sort((a, b) => b.totalMs - a.totalMs) + .slice(0, limit), + insights: [ + "Event durations can overlap and must not be summed into total page time.", + longTasks.length + ? `${longTasks.length} recorded tasks exceeded 50 ms; inspect their stacks in the exported trace.` + : "No tasks over 50 ms were observed in the retained trace. This is not proof that the page has no long tasks.", + "This renderer-process trace does not calculate LCP, INP, CLS, or Lighthouse performance scores.", + ], + } +} + +export function analyzeCpu(value: unknown, limit = 100) { + const decoded = Schema.decodeUnknownOption(Cpu)(value) + if (decoded._tag === "None") + throw new Error( + "Selected file is not a CPU profile. Use a fileID returned by browser.cpu.stop for this tab, not a trace or heap snapshot.", + ) + const profile = decoded.value + const times = new Map() + profile.samples?.forEach((id, index) => + times.set(id, (times.get(id) ?? 0) + (profile.timeDeltas?.[index] ?? 0) / 1000), + ) + return { + durationMs: Math.max(0, (profile.endTime - profile.startTime) / 1000), + functions: profile.nodes + .map((node) => ({ + name: node.callFrame.functionName.slice(0, 2_048), + url: node.callFrame.url.slice(0, 100_000), + line: Math.max(0, node.callFrame.lineNumber + 1), + selfMs: times.get(node.id) ?? 0, + })) + .filter((node) => node.selfMs > 0) + .sort((a, b) => b.selfMs - a.selfMs) + .slice(0, limit), + } +} + +const malformedHeap = () => + new Error( + "Heap snapshot layout is unsupported or incomplete. Use a complete capture from browser.heap.snapshot; if this tool produced it, report a parser/Chromium compatibility issue instead of repeatedly capturing the same heap.", + ) + +export function parseHeap(value: unknown) { + const decoded = Schema.decodeUnknownOption(Heap)(value) + if (decoded._tag === "None") + throw new Error( + "Selected file is not a V8 heap snapshot. Use a fileID returned by browser.heap.snapshot for this tab, not a trace or CPU profile.", + ) + const heap = decoded.value + const fields = heap.snapshot.meta.node_fields + const edgeFields = heap.snapshot.meta.edge_fields + const width = fields.length + const edgeWidth = edgeFields.length + const indexes = { + type: fields.indexOf("type"), + name: fields.indexOf("name"), + id: fields.indexOf("id"), + size: fields.indexOf("self_size"), + count: fields.indexOf("edge_count"), + edgeType: edgeFields.indexOf("type"), + edgeName: edgeFields.indexOf("name_or_index"), + to: edgeFields.indexOf("to_node"), + } + if ( + !width || + !edgeWidth || + Object.values(indexes).some((index) => index < 0) || + heap.nodes.length % width || + heap.edges.length % edgeWidth + ) + throw malformedHeap() + const types = heap.snapshot.meta.node_types[indexes.type] + const edgeTypes = heap.snapshot.meta.edge_types[indexes.edgeType] + if (!Array.isArray(types) || !Array.isArray(edgeTypes)) + throw new Error( + "Heap snapshot type tables are unsupported. Use a complete capture from browser.heap.snapshot and report the compatibility issue if it persists.", + ) + const node = (offset: number) => ({ + id: heap.nodes[offset + indexes.id], + name: (heap.strings[heap.nodes[offset + indexes.name]] ?? "").slice(0, 100_000), + type: types[heap.nodes[offset + indexes.type]] ?? "unknown", + selfBytes: heap.nodes[offset + indexes.size], + edgeCount: heap.nodes[offset + indexes.count], + }) + const classes = new Map() + let selfBytes = 0 + let edgeTotal = 0 + for (let offset = 0; offset < heap.nodes.length; offset += width) { + const item = node(offset) + if (!Number.isSafeInteger(item.edgeCount) || item.edgeCount < 0) throw malformedHeap() + edgeTotal += item.edgeCount + const name = item.name.slice(0, 2_048) + const entry = classes.get(name) ?? { name, count: 0, bytes: 0 } + entry.count++ + entry.bytes += item.selfBytes + selfBytes += item.selfBytes + classes.set(name, entry) + } + // Edge counts drive the traversal below; a downloaded file can claim trillions of edges it does not carry. + if (edgeTotal * edgeWidth !== heap.edges.length) throw malformedHeap() + for (let offset = indexes.to; offset < heap.edges.length; offset += edgeWidth) { + const to = heap.edges[offset] + if (!Number.isSafeInteger(to) || to < 0 || to >= heap.nodes.length || to % width) throw malformedHeap() + } + return { + summary(limit = 100) { + return { + nodes: heap.nodes.length / width, + edges: heap.edges.length / edgeWidth, + selfBytes, + classes: Array.from(classes.values()) + .sort((a, b) => b.bytes - a.bytes) + .slice(0, limit), + } + }, + classes, + query(name = "", limit = 100) { + const found: ReturnType[] = [] + for (let offset = 0; offset < heap.nodes.length; offset += width) { + const item = node(offset) + if (item.name.toLowerCase().includes(name.toLowerCase())) found.push(item) + } + return { nodes: found.sort((a, b) => b.selfBytes - a.selfBytes).slice(0, limit), truncated: found.length > limit } + }, + object(id: number, limit = 100) { + const target = heap.nodes.findIndex((value, index) => index % width === indexes.id && value === id) - indexes.id + if (target < 0) + throw new Error( + "Object ID was not found in this heap snapshot. Call browser.heap.query with the same tabID and fileID, then copy an exact returned object id. Object IDs cannot be reused across snapshots.", + ) + const references: { name: string; node: ReturnType }[] = [] + const retainers: { name: string; node: ReturnType }[] = [] + let edgeOffset = 0 + let truncated = false + for (let offset = 0; offset < heap.nodes.length; offset += width) { + const count = heap.nodes[offset + indexes.count] + for (let index = 0; index < count; index++, edgeOffset += edgeWidth) { + const to = heap.edges[edgeOffset + indexes.to] + if (offset !== target && to !== target) continue + const type = edgeTypes[heap.edges[edgeOffset + indexes.edgeType]] + const raw = heap.edges[edgeOffset + indexes.edgeName] + const name = (type === "element" || type === "hidden" ? String(raw) : (heap.strings[raw] ?? "")).slice( + 0, + 100_000, + ) + if (offset === target) { + if (references.length < limit) references.push({ name, node: node(to) }) + else truncated = true + } + // A weak edge (WeakRef, WeakMap key) does not keep the target alive, so it is not a retainer. + if (to === target && type !== "weak") { + if (retainers.length < limit) retainers.push({ name, node: node(offset) }) + else truncated = true + } + } + } + return { node: node(target), references, retainers, truncated } + }, + } +} diff --git a/packages/desktop/src/main/browser/cdp.ts b/packages/desktop/src/main/browser/cdp.ts new file mode 100644 index 00000000000..e7320f1b812 --- /dev/null +++ b/packages/desktop/src/main/browser/cdp.ts @@ -0,0 +1,82 @@ +import type { WebContents } from "electron" +import type { ProtocolMapping } from "devtools-protocol/types/protocol-mapping.js" +import { protocolError } from "./errors" + +export type Cdp = ReturnType + +export function createCdp(contents: WebContents) { + const listeners = new Map void>>() + const sessions = new Set([""]) + const receive = (_event: Electron.Event, name: string, params: unknown, sessionID?: string) => { + if (!sessions.has(sessionID ?? "")) return + if (name === "Target.attachedToTarget") { + const event = params as ProtocolMapping.Events["Target.attachedToTarget"][0] + if (event.targetInfo.type === "iframe") sessions.add(event.sessionId) + } + if (name === "Target.detachedFromTarget") + sessions.delete((params as ProtocolMapping.Events["Target.detachedFromTarget"][0]).sessionId) + listeners.get(name)?.forEach((callback) => callback(params, sessionID || undefined)) + } + contents.debugger.on("message", receive) + return { + async send( + method: Method, + params: object = {}, + sessionID?: string, + ): Promise { + if (contents.isDestroyed()) + throw new Error( + "Browser tab was closed. Call browser.tabs.list({}) and choose an existing tabID, or browser.tabs.open({}) if no tabs remain.", + ) + // attach can throw synchronously; sendCommand can reject asynchronously. + try { + if (!contents.debugger.isAttached()) contents.debugger.attach("1.3") + return await contents.debugger.sendCommand(method, params, sessionID) + } catch (error) { + throw protocolError(method, error) + } + }, + on( + method: Method, + callback: (params: ProtocolMapping.Events[Method][0], sessionID?: string) => void, + ) { + const handler = (params: unknown, sessionID?: string) => + callback(params as ProtocolMapping.Events[Method][0], sessionID) + const handlers = listeners.get(method) ?? new Set() + handlers.add(handler) + listeners.set(method, handlers) + return () => { + handlers.delete(handler) + if (!handlers.size) listeners.delete(method) + } + }, + dispose() { + contents.debugger.off("message", receive) + listeners.clear() + }, + } +} + +export function abortError(signal: AbortSignal) { + if (signal.aborted) + throw new Error( + "Browser operation was cancelled. Inspect the tab before deciding to repeat an action; cancellation does not undo changes already made.", + ) +} + +export async function waitFor(check: () => boolean | Promise, signal: AbortSignal, timeoutMs = 10_000) { + const deadline = Date.now() + timeoutMs + const timeout = () => new Error(`Condition was not met within ${timeoutMs} ms.`) + // A busy renderer can hold one check past the deadline, so each check races the remaining time. + while (true) { + abortError(signal) + const remaining = deadline - Date.now() + if (remaining <= 0) throw timeout() + let timer: ReturnType | undefined + const expired = new Promise((_, reject) => { + timer = setTimeout(() => reject(timeout()), remaining) + }) + if (await Promise.race([check(), expired]).finally(() => clearTimeout(timer))) return + await new Promise((resolve) => setTimeout(resolve, 50)) + } +} diff --git a/packages/desktop/src/main/browser/corners.ts b/packages/desktop/src/main/browser/corners.ts new file mode 100644 index 00000000000..8a6baa18739 --- /dev/null +++ b/packages/desktop/src/main/browser/corners.ts @@ -0,0 +1,23 @@ +import { nativeImage } from "electron" + +// Native browser surfaces ignore a parent View's clip path. Cover only the +// pixels outside the bottom arcs; never resize or style the page itself. +export function createCornerImages(color: readonly [number, number, number, number], radius: number, scale: number) { + const size = Math.max(1, Math.round(radius * scale)) + return [false, true].map((right) => { + const pixels = Buffer.alloc(size * size * 4) + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const distance = Math.hypot(right ? x + 0.5 : size - x - 0.5, y + 0.5) + const alpha = Math.round(color[3] * Math.max(0, Math.min(1, distance - size + 0.5))) + const offset = (y * size + x) * 4 + // NativeImage bitmaps use premultiplied BGRA on supported desktop platforms. + pixels[offset] = Math.round((color[2] * alpha) / 255) + pixels[offset + 1] = Math.round((color[1] * alpha) / 255) + pixels[offset + 2] = Math.round((color[0] * alpha) / 255) + pixels[offset + 3] = alpha + } + } + return nativeImage.createFromBitmap(pixels, { width: size, height: size, scaleFactor: scale }) + }) +} diff --git a/packages/desktop/src/main/browser/diagnostics.ts b/packages/desktop/src/main/browser/diagnostics.ts new file mode 100644 index 00000000000..2bd06efbd81 --- /dev/null +++ b/packages/desktop/src/main/browser/diagnostics.ts @@ -0,0 +1,415 @@ +import { Browser } from "@opencode-ai/plugin-browser/rpc" +import type { Protocol } from "devtools-protocol" +import type { Cdp } from "./cdp" + +type Request = { + info: Browser.NetworkRequest + nativeID: string + sessionID?: string + /** Monotonic start; unknown for a WebSocket until its handshake is sent. */ + started?: number + request: Pick + response?: Pick + headersTruncated: boolean + postDataTruncated: boolean + redirected?: boolean + /** ExtraInfo arrives once per redirect hop, in order; these mark which hops consumed theirs. */ + wire: { request: boolean; response: boolean } +} +type Wire = { headers: Protocol.Network.Headers; statusCode: number } +const levels = ["debug", "info", "warning", "error"] as const +// Values the model must not read; the header name still shows it was sent. +const redacted = new Set(["cookie", "set-cookie", "authorization", "proxy-authorization"]) + +export function createDiagnostics(cdp: Cdp) { + const messages: Browser.ConsoleEntry[] = [] + const requests = new Map() + // Chromium reuses one request ID across a redirect chain; every hop is retained in order. + const hops = new Map() + const latest = (key: string) => hops.get(key)?.at(-1) + // Network-stack headers and wire status arrive as ExtraInfo events, in either order relative to + // the renderer-side events; stash whichever comes first. + const extra = new Map() + const scope = crypto.randomUUID() + let sequence = 0 + let droppedMessages = 0 + let droppedRequests = 0 + const add = ( + level: (typeof levels)[number], + text: string, + timestampMs: number, + source?: Browser.ConsoleEntry["source"], + ) => { + messages.push({ + id: `${scope}:${++sequence}`, + timestampMs, + level, + text: text.slice(0, 2_000), + textTruncated: text.length > 2_000, + ...(source ? { source } : {}), + }) + if (messages.length > 500) { + messages.shift() + droppedMessages++ + } + } + cdp.on("Runtime.consoleAPICalled", (event) => { + const source = event.stackTrace?.callFrames[0] + add( + event.type === "error" || event.type === "assert" + ? "error" + : event.type === "warning" + ? "warning" + : event.type === "debug" + ? "debug" + : "info", + event.args + .map((arg) => + typeof arg.value === "string" + ? arg.value + : arg.value !== undefined + ? JSON.stringify(arg.value) + : (arg.description ?? arg.unserializableValue ?? arg.type), + ) + .join(" "), + event.timestamp, + source + ? { url: source.url.slice(0, Browser.MAX_TEXT), line: source.lineNumber + 1, column: source.columnNumber + 1 } + : undefined, + ) + }) + // Chromium's own messages (CSP refusals, mixed content, failed resource loads, deprecations) + // are Log entries, not Runtime console calls. + cdp.on("Log.entryAdded", (event) => { + const entry = event.entry + add( + entry.level === "verbose" ? "debug" : entry.level, + entry.text, + entry.timestamp, + entry.url + ? { url: entry.url.slice(0, Browser.MAX_TEXT), line: (entry.lineNumber ?? 0) + 1, column: 1 } + : undefined, + ) + }) + cdp.on("Runtime.exceptionThrown", (event) => { + const error = event.exceptionDetails + add( + "error", + error.exception?.description ?? error.text, + event.timestamp, + error.url ? { url: error.url, line: error.lineNumber + 1, column: error.columnNumber + 1 } : undefined, + ) + }) + const begin = ( + key: string, + sessionID: string | undefined, + nativeID: string, + started: number | undefined, + wallTime: number, + info: Pick, + request: Request["request"], + ) => { + const headers = trimHeaders(request.headers) + const entry: Request = { + nativeID, + sessionID, + started, + request: { ...request, headers: headers.headers, postData: request.postData?.slice(0, 20_000) }, + headersTruncated: headers.truncated, + postDataTruncated: (request.postData?.length ?? 0) > 20_000, + wire: { request: false, response: false }, + info: { + id: `${scope}:${++sequence}`, + ...info, + url: info.url.slice(0, 16_384), + timestampMs: wallTime * 1000, + state: "pending", + }, + } + requests.set(entry.info.id, entry) + hops.set(key, [...(hops.get(key) ?? []), entry]) + // A stash can only belong to this hop: earlier hops would have consumed it on arrival. + const stashed = extra.get(key) + if (stashed?.request) requestHeaders(entry, stashed.request) + if (stashed?.response) responseInfo(entry, stashed.response) + extra.delete(key) + if (requests.size > 500) { + const first = requests.values().next().value + if (first) { + requests.delete(first.info.id) + const firstKey = `${first.sessionID ?? ""}:${first.nativeID}` + const rest = hops.get(firstKey)?.filter((hop) => hop !== first) ?? [] + if (rest.length) hops.set(firstKey, rest) + if (!rest.length) hops.delete(firstKey) + } + droppedRequests++ + } + return entry + } + const requestHeaders = (request: Request, headers: Protocol.Network.Headers) => { + const trimmed = trimHeaders({ ...request.request.headers, ...headers }) + request.request = { ...request.request, headers: trimmed.headers } + request.headersTruncated ||= trimmed.truncated + request.wire.request = true + } + const responseInfo = (request: Request, wire: Wire) => { + const trimmed = trimHeaders({ ...request.response?.headers, ...wire.headers }) + request.response = { mimeType: request.response?.mimeType ?? "", headers: trimmed.headers } + request.headersTruncated ||= trimmed.truncated + request.info = { ...request.info, statusCode: wire.statusCode } + request.wire.response = true + } + const finish = (key: string, timestamp: number, failure?: string) => { + const request = latest(key) + if (!request) return + const durationMs = request.started === undefined ? 0 : Math.max(0, (timestamp - request.started) * 1000) + request.info = + failure === undefined + ? { ...request.info, state: "completed", durationMs } + : { ...request.info, state: "failed", failure: failure.slice(0, 2_048), durationMs } + } + cdp.on("Network.requestWillBeSent", (event, sessionID) => { + const key = `${sessionID ?? ""}:${event.requestId}` + const previous = latest(key) + if (previous && event.redirectResponse) { + // Wire headers for this hop may already be merged in; the renderer copy lacks set-cookie. + const response = trimHeaders({ ...event.redirectResponse.headers, ...previous.response?.headers }) + previous.response = { mimeType: event.redirectResponse.mimeType, headers: response.headers } + previous.headersTruncated ||= response.truncated + previous.redirected = true + // A cached redirect never gets ExtraInfo; the next hop's must not be attributed to it. + if (!event.redirectHasExtraInfo) previous.wire = { request: true, response: true } + previous.info = { + ...previous.info, + state: "completed", + statusCode: previous.info.statusCode ?? event.redirectResponse.status, + durationMs: Math.max(0, (event.timestamp - (previous.started ?? event.timestamp)) * 1000), + } + } + begin( + key, + sessionID, + event.requestId, + event.timestamp, + event.wallTime, + { + url: event.request.url, + method: event.request.method, + resourceType: resourceType((event.type ?? "other").toLowerCase()), + }, + { headers: event.request.headers, hasPostData: event.request.hasPostData, postData: event.request.postData }, + ) + }) + // ExtraInfo for a redirect hop can land after the renderer already started the next hop, so it + // goes to the earliest hop that has not consumed its own rather than to the latest. + cdp.on("Network.requestWillBeSentExtraInfo", (event, sessionID) => { + const key = `${sessionID ?? ""}:${event.requestId}` + const request = hops.get(key)?.find((hop) => !hop.wire.request) + if (request) return requestHeaders(request, event.headers) + extra.set(key, { ...extra.get(key), request: event.headers }) + }) + cdp.on("Network.responseReceived", (event, sessionID) => { + const request = latest(`${sessionID ?? ""}:${event.requestId}`) + if (!request) return + const headers = trimHeaders({ ...event.response.headers, ...request.response?.headers }) + request.response = { mimeType: event.response.mimeType, headers: headers.headers } + request.headersTruncated ||= headers.truncated + // ExtraInfo already carries the wire status when it arrived first; the renderer may report 200 for a 304. + request.info = { ...request.info, statusCode: request.info.statusCode ?? event.response.status } + if (!event.hasExtraInfo) request.wire = { request: true, response: true } + }) + cdp.on("Network.responseReceivedExtraInfo", (event, sessionID) => { + const key = `${sessionID ?? ""}:${event.requestId}` + const request = hops.get(key)?.find((hop) => !hop.wire.response) + if (request) return responseInfo(request, event) + extra.set(key, { ...extra.get(key), response: event }) + }) + // WebSockets never emit requestWillBeSent; their handshake is the whole request lifecycle. + cdp.on("Network.webSocketCreated", (event, sessionID) => { + begin( + `${sessionID ?? ""}:${event.requestId}`, + sessionID, + event.requestId, + undefined, + Date.now() / 1000, + { url: event.url, method: "GET", resourceType: "websocket" }, + { headers: {}, hasPostData: false }, + ) + }) + cdp.on("Network.webSocketWillSendHandshakeRequest", (event, sessionID) => { + const request = latest(`${sessionID ?? ""}:${event.requestId}`) + if (!request) return + request.started = event.timestamp + request.info = { ...request.info, timestampMs: event.wallTime * 1000 } + requestHeaders(request, event.request.headers) + }) + cdp.on("Network.webSocketHandshakeResponseReceived", (event, sessionID) => { + const key = `${sessionID ?? ""}:${event.requestId}` + const request = latest(key) + if (!request) return + responseInfo(request, { headers: event.response.headers, statusCode: event.response.status }) + finish(key, event.timestamp) + }) + cdp.on("Network.webSocketFrameError", (event, sessionID) => + finish(`${sessionID ?? ""}:${event.requestId}`, event.timestamp, event.errorMessage), + ) + cdp.on("Network.webSocketClosed", (event, sessionID) => { + const key = `${sessionID ?? ""}:${event.requestId}` + if (latest(key)?.info.state === "pending") finish(key, event.timestamp, "Closed before the handshake completed") + }) + cdp.on("Network.loadingFinished", (event, sessionID) => + finish(`${sessionID ?? ""}:${event.requestId}`, event.timestamp), + ) + cdp.on("Network.loadingFailed", (event, sessionID) => + finish(`${sessionID ?? ""}:${event.requestId}`, event.timestamp, event.errorText), + ) + return { + clear() { + messages.length = 0 + requests.clear() + hops.clear() + extra.clear() + droppedMessages = 0 + droppedRequests = 0 + }, + async enable(sessionID?: string) { + await cdp.send("Runtime.enable", {}, sessionID) + await cdp.send("Log.enable", {}, sessionID) + await cdp.send( + "Network.enable", + { maxTotalBufferSize: 5 * 1024 * 1024, maxResourceBufferSize: 1024 * 1024, maxPostDataSize: 20_000 }, + sessionID, + ) + }, + console(input: Extract) { + const matching = messages.filter((entry) => levels.indexOf(entry.level) >= levels.indexOf(input.level ?? "info")) + const result = bounded(matching, input.limit ?? 100) + return { messages: result, truncated: matching.length > result.length, dropped: droppedMessages } + }, + list(input: Extract) { + const matching = Array.from(requests.values()) + .map((request) => request.info) + .filter( + (request) => + (!input.urlContains || request.url.includes(input.urlContains)) && + (!input.resourceType || input.resourceType === request.resourceType), + ) + const result = bounded(matching, input.limit ?? 100) + return { requests: result, truncated: matching.length > result.length, dropped: droppedRequests } + }, + info(id: string) { + const request = requests.get(id) + if (!request) + throw new Error( + "Request is no longer retained. Call browser.network.list({tabID}) and use a current request id. Do not reload or resend it just to inspect it.", + ) + return request.info + }, + async get(input: Extract) { + const request = requests.get(input.id) + if (!request) + throw new Error( + "Request ID is no longer retained in this tab or belongs to another tab. Call browser.network.list({tabID}) and copy a current id into browser.network.get with the same tabID. Do not reload or resend a request just to inspect it.", + ) + const max = input.maxBodyChars ?? 20_000 + const text = (value: string): Browser.Body => ({ + state: "text", + text: value.slice(0, max), + truncated: value.length > max, + }) + const responseBody = async (): Promise => { + if (!input.includeBody) return { state: "notRequested" } + if (request.info.state === "pending") return { state: "pending" } + if (request.redirected || !request.response || request.info.resourceType === "websocket") + return { state: "unavailable", reason: "notCaptured" } + if ( + !/^(text\/|application\/(json|.*\+json|javascript|xml|.*\+xml|x-www-form-urlencoded))/i.test( + request.response.mimeType, + ) + ) + return { state: "unavailable", reason: "binary" } + const body = await cdp + .send("Network.getResponseBody", { requestId: request.nativeID }, request.sessionID) + .catch(() => undefined) + if (!body) return { state: "unavailable", reason: "backendUnavailable" } + const value = body.base64Encoded ? Buffer.from(body.body, "base64").toString("utf8") : body.body + return value.length ? text(value) : { state: "empty" } + } + const requestBody: Browser.Body = !input.includeBody + ? { state: "notRequested" } + : request.request.postData !== undefined + ? { + state: "text", + text: request.request.postData.slice(0, max), + truncated: request.postDataTruncated || request.request.postData.length > max, + } + : request.request.hasPostData + ? { state: "unavailable", reason: "notCaptured" } + : { state: "empty" } + return { + request: request.info, + requestHeaders: headerEntries(request.request.headers), + responseHeaders: headerEntries(request.response?.headers ?? {}), + headersTruncated: request.headersTruncated, + requestBody, + responseBody: await responseBody(), + } + }, + } +} + +function resourceType(type: string): Browser.ResourceType { + switch (type) { + case "document": + case "stylesheet": + case "image": + case "media": + case "font": + case "script": + case "xhr": + case "fetch": + case "eventsource": + case "websocket": + case "manifest": + return type + default: + return "other" + } +} + +function trimHeaders(headers: Protocol.Network.Headers) { + const entries = Object.entries(headers) + const kept: [string, string][] = [] + let size = 0 + let truncated = entries.length > 100 + for (const [key, value] of entries.slice(0, 100)) { + // Lower-case names so renderer and wire copies of one header merge instead of duplicating. + const name = key.toLowerCase().slice(0, 2_048) + const text = redacted.has(name) ? "" : String(value) + const remaining = Math.max(0, 16_000 - size - name.length) + if (!remaining) { + truncated = true + break + } + const bounded = text.slice(0, Math.min(2_000, remaining)) + truncated ||= bounded.length < text.length || name.length < key.length + kept.push([name, bounded]) + size += name.length + bounded.length + } + return { headers: Object.fromEntries(kept), truncated } +} + +function bounded(items: readonly Item[], limit: number) { + const selected: Item[] = [] + let size = 0 + for (const item of items.slice(-limit).reverse()) { + size += JSON.stringify(item).length + if (size > Browser.MAX_TEXT) break + selected.push(item) + } + return selected.reverse() +} + +function headerEntries(headers: Protocol.Network.Headers) { + return Object.entries(headers).map(([name, value]) => ({ name, value: String(value) })) +} diff --git a/packages/desktop/src/main/browser/errors.ts b/packages/desktop/src/main/browser/errors.ts new file mode 100644 index 00000000000..48fe1108957 --- /dev/null +++ b/packages/desktop/src/main/browser/errors.ts @@ -0,0 +1,48 @@ +import type { Browser } from "@opencode-ai/plugin-browser/rpc" + +export function protocolError(method: string, error: unknown) { + const detail = message(error) + const recovery = + method === "DOM.setFileInputFiles" && /not.*file input/i.test(detail) + ? "Target is not a file input. Call browser.snapshot({tabID}) and choose an input[type=file] ref for browser.files.upload, or use browser.files.drop for a drop area." + : /(?:node|object).*(?:not found|not exist)|(?:find|resolve).*(?:node|object)|detached/i.test(detail) + ? "The element may have detached. Call browser.snapshot({tabID}) and use a fresh ref from that tab." + : /context.*(?:destroyed|not found)|find.*context|session.*not found/i.test(detail) + ? "The document or frame changed. Call browser.frames({tabID}) and browser.snapshot({tabID}); use current frame IDs and refs." + : /wasn't found|method not found|not implemented|not allowed/i.test(detail) + ? "This Chromium target does not support or allow the operation. Check desktop/plugin compatibility and report it; do not retry unchanged or disable browser security." + : "Inspect browser.tabs.list({}) and the target tab before deciding to retry; a partially completed action is not automatically safe to repeat." + return new Error(`${recovery} Chromium command ${method} failed: ${detail}`, { cause: error }) +} + +export function browserFailure(action: Browser.Action, error: unknown): Extract { + const detail = message(error, 1_700) + const navigation = ["tabs.open", "navigate", "back", "forward", "reload"].includes(action.type) + const network = navigation ? detail.match(/\bERR_[A-Z_]+\b/)?.[0] : undefined + const hint = + network === "ERR_CONNECTION_REFUSED" + ? "The browser could not connect to the site. Check its hostname/port on the connected server. localhost means that server, not the desktop." + : network === "ERR_NAME_NOT_RESOLVED" + ? "The connected server could not resolve the hostname. Check the URL spelling and the server's DNS/network connection." + : network?.startsWith("ERR_CERT_") || network?.startsWith("ERR_SSL_") + ? "The desktop rejected the site's TLS connection. Ask the user to fix the certificate or trust configuration; do not bypass certificate checks." + : network === "ERR_ABORTED" + ? "Navigation was interrupted or became a download. Inspect browser.tabs.list({}) and browser.files.list({tabID}) before deciding to navigate again." + : network + ? "The site failed to load through the connected server. Check its URL/network and the OC2 connection before retrying; inspect the current tab first." + : action.type === "screenshot" && /UnknownVizError|capture.*(?:failed|unavailable)/i.test(detail) + ? "Chromium could not capture a rendered frame. Call browser.tabs.focus({tabID}) and keep the desktop window visible. If it is already visible, report the capture failure instead of repeating it unchanged." + : undefined + return { + type: "failure", + code: network ? "navigation_failed" : "operation_failed", + message: `browser.${action.type} failed. ${hint ? `${hint} Details: ${detail.slice(0, 400)}` : detail}`.slice( + 0, + 2_048, + ), + } +} + +function message(error: unknown, limit = 400) { + return (error instanceof Error ? error.message : String(error)).slice(0, limit) +} diff --git a/packages/desktop/src/main/browser/files.ts b/packages/desktop/src/main/browser/files.ts new file mode 100644 index 00000000000..e98e9bc51be --- /dev/null +++ b/packages/desktop/src/main/browser/files.ts @@ -0,0 +1,119 @@ +import { Browser } from "@opencode-ai/plugin-browser/rpc" +import { mkdir, readFile, stat, writeFile, rm } from "node:fs/promises" +import { mkdirSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" + +export type BrowserFiles = ReturnType + +export function createBrowserFiles(source: () => readonly string[]) { + const directory = path.join(tmpdir(), `opencode-browser-client-${crypto.randomUUID()}`) + const files = new Map< + Browser.FileID, + { + id: Browser.FileID + name: string + mime: string + bytes: number + state: "pending" | "completed" | "failed" + path: string + resources: readonly string[] + } + >() + // Captures, uploads, and heap snapshots sit in the shared temp directory; keep them owner-only. + const ready = mkdir(directory, { recursive: true, mode: 0o700 }) + return { + directory, + ready, + list: () => Array.from(files.values()).map(({ path: _path, resources: _resources, ...file }) => file), + add(name: string, mime: string, resources = source()) { + const id = Browser.FileID.make(`file_${crypto.randomUUID()}`) + const target = path.join(directory, id) + // setSavePath must run during Electron's synchronous will-download callback. + mkdirSync(target, { recursive: true, mode: 0o700 }) + const file = { + id, + name: name.slice(0, 2_048), + mime, + bytes: 0, + state: "pending" as "pending" | "completed" | "failed", + // A page receives this basename as the uploaded File.name, so spaces and non-ASCII stay; + // only characters no supported filesystem accepts are replaced. Each file has its own + // directory, so names never collide. + path: path.join( + target, + name + .replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_") + .replace(/[. ]+$/, "") + .slice(-120) || "file", + ), + resources: [...new Set(resources)].sort(), + } + files.set(id, file) + return file + }, + async save(name: string, mime: string, data: Uint8Array, resources = source()) { + if (data.byteLength > Browser.MAX_FILE_BYTES) + throw new Error( + "Capture exceeds the 5 MiB transfer limit. Reduce screenshot maxWidth/quality or trace duration; for a heap snapshot, use a smaller page/test case. Do not retry an identical capture.", + ) + await ready + const file = this.add(name, mime, resources) + await writeFile(file.path, data, { mode: 0o600 }).catch((error: unknown) => { + file.state = "failed" + throw new Error( + "Cannot write the capture on the desktop. Ask the user to check desktop temporary-directory access and free space before retrying.", + { cause: error }, + ) + }) + file.bytes = data.byteLength + file.state = "completed" + return file.id + }, + get(id: Browser.FileID) { + const file = files.get(id) + if (!file) + throw new Error( + "File ID is not retained in this tab. Call browser.files.list({tabID}) and use an exact returned fileID from the same tab, not a server path or request ID.", + ) + if (file.state === "pending") + throw new Error( + "File is still being downloaded or captured. Check browser.files.list({tabID}) again and wait for state completed; do not start a duplicate download.", + ) + if (file.state === "failed") + throw new Error( + "The download or capture failed, so this file cannot be read. Inspect browser.console and browser.network.list for the cause before deciding to start it again.", + ) + return file + }, + async transfer(id: Browser.FileID, authorized?: readonly string[]): Promise { + const file = this.get(id) + if (authorized && file.resources.some((url) => !authorized.includes(url))) + throw new Error( + "Capture source changed before export. Inspect the tab and request the file again to check its source permissions; no bytes were exported.", + ) + if ( + ( + await stat(file.path).catch((error: unknown) => { + throw unavailableFile(error) + }) + ).size > Browser.MAX_FILE_BYTES + ) + throw new Error( + "File exceeds the 5 MiB transfer limit. Choose a smaller completed file; repeating browser.files.get for this file will not help.", + ) + const data = await readFile(file.path).catch((error: unknown) => { + throw unavailableFile(error) + }) + return { id, name: file.name, mime: file.mime, data: new Uint8Array(data) } + }, + dispose: () => ready.then(() => rm(directory, { recursive: true, force: true })), + } +} + +function unavailableFile(error: unknown) { + return new Error( + "The retained file cannot be read on the desktop. Its temporary copy may have been removed. Use an already exported server-local path if available, or deliberately create a new capture.", + { cause: error }, + ) +} diff --git a/packages/desktop/src/main/browser/lighthouse.ts b/packages/desktop/src/main/browser/lighthouse.ts new file mode 100644 index 00000000000..2bd2481ddc5 --- /dev/null +++ b/packages/desktop/src/main/browser/lighthouse.ts @@ -0,0 +1,89 @@ +import type { WebContents } from "electron" +import type { Page } from "puppeteer-core" +import { EventEmitter } from "node:events" +import type { BrowserFiles } from "./files" +import type { Cdp } from "./cdp" + +export async function audit(contents: WebContents, files: BrowserFiles, cdp: Cdp, resources: readonly string[]) { + const { snapshot, generateReport } = await import("lighthouse") + const info = (await contents.debugger.sendCommand("Target.getTargetInfo")) as { targetInfo: { targetId: string } } + const sessions = new Map>() + function session(id: string) { + const emitter = new EventEmitter() + const value = Object.assign(emitter, { + id: () => id, + send: (method: string, params?: object) => cdp.send(method as Parameters[0], params, id), + detach: async () => { + sessions.delete(id) + await contents.debugger.sendCommand("Target.detachFromTarget", { sessionId: id }) + }, + }) + sessions.set(id, value) + return value + } + const event = (_event: Electron.Event, method: string, params: unknown, sessionID?: string) => { + const owner = sessionID ? sessions.get(sessionID) : undefined + if (!owner) return + if (method === "Target.attachedToTarget") { + const child = params as { sessionId: string } + owner.emit("sessionattached", session(child.sessionId)) + } + owner.emit(method, params) + owner.emit("*", method, params) + } + contents.debugger.on("message", event) + try { + const attached = (await contents.debugger.sendCommand("Target.attachToTarget", { + targetId: info.targetInfo.targetId, + flatten: true, + })) as { sessionId: string } + const root = session(attached.sessionId) + // Lighthouse's snapshot driver uses only url() and target().createCDPSession(). + // Adapt that narrow boundary to Electron instead of exposing a browser-wide + // Puppeteer connection or emulating unsupported Browser/Target commands. + const page = { + url: () => contents.getURL(), + target: () => ({ createCDPSession: async () => root }), + } as unknown as Page + // Our screenshot tool handles captures separately. Lighthouse's screenshot + // gatherer resizes the viewport and waits for frames that hidden views may not paint. + const result = await snapshot(page, { + flags: { + onlyCategories: ["accessibility", "seo", "best-practices"], + formFactor: "desktop", + screenEmulation: { disabled: true }, + throttlingMethod: "provided", + disableStorageReset: true, + disableFullPageScreenshot: true, + }, + }) + if (!result) + throw new Error( + "Lighthouse did not produce a report. Check browser.console and browser.network.list for page failures and confirm the tab has loaded. Report an audit failure if the page is healthy; do not repeat the audit unchanged.", + ) + return { + scores: Object.values(result.lhr.categories).map((category) => ({ + id: category.id, + title: category.title, + score: category.score, + })), + failures: Object.values(result.lhr.audits) + .filter((audit) => audit.score !== null && audit.score < 1) + .map((audit) => ({ id: audit.id, title: audit.title, description: audit.description })) + .slice(0, 100), + files: await Promise.all([ + files.save("lighthouse.json", "application/json", Buffer.from(generateReport(result.lhr, "json")), [ + ...resources, + contents.getURL(), + ]), + files.save("lighthouse.html", "text/html", Buffer.from(generateReport(result.lhr, "html")), [ + ...resources, + contents.getURL(), + ]), + ]), + } + } finally { + contents.debugger.off("message", event) + await Promise.all(Array.from(sessions.values(), (session) => session.detach().catch(() => undefined))) + } +} diff --git a/packages/desktop/src/main/browser/network.ts b/packages/desktop/src/main/browser/network.ts new file mode 100644 index 00000000000..d45aac20c68 --- /dev/null +++ b/packages/desktop/src/main/browser/network.ts @@ -0,0 +1,72 @@ +import { session, type WebContents } from "electron" +import type { RpcClient } from "@opencode-ai/client/effect/api" +import type { Session } from "@opencode-ai/schema/session" +import { Browser } from "@opencode-ai/plugin-browser/rpc" +import { BrowserProxy } from "@opencode-ai/plugin-browser/proxy" +import { Effect, Encoding } from "effect" + +export type BrowserNetwork = Effect.Success> + +export const createBrowserNetwork = Effect.fn("BrowserNetwork.create")(function* (input: { + rpc: RpcClient + attachment: { sessionID: Session.ID; connectionID: string } + location: { directory: string; workspace?: string } + partition: string +}) { + const options = { location: input.location } + const proxy = yield* Effect.acquireRelease( + Effect.tryPromise(() => + BrowserProxy.make({ + open: (target, signal) => + Effect.runPromise(input.rpc["tunnel.open"]({ ...input.attachment, target }, options), { signal }), + read: (tunnelID, signal) => + Effect.runPromise(input.rpc["tunnel.read"]({ ...input.attachment, tunnelID }, options), { signal }), + write: (tunnelID, data, end, signal) => + Effect.runPromise( + input.rpc["tunnel.write"]( + { ...input.attachment, tunnelID, data: Encoding.encodeBase64(data), end }, + options, + ), + { signal }, + ), + close: (tunnelID) => + Effect.runPromise( + input.rpc["tunnel.close"]({ ...input.attachment, tunnelID }, options).pipe(Effect.timeout("5 seconds")), + ), + }), + ), + (proxy) => Effect.promise(() => proxy.close()), + ) + const partition = session.fromPartition(input.partition) + yield* Effect.addFinalizer(() => Effect.promise(() => partition.closeAllConnections())) + // This is the browser's private partition, not the app/API connection. Never + // bypass localhost: it must resolve on the machine running the OC2 server. + yield* Effect.tryPromise(() => + partition.setProxy({ mode: "fixed_servers", proxyRules: proxy.url, proxyBypassRules: "<-loopback>" }), + ) + yield* Effect.tryPromise(() => partition.closeAllConnections()) + return { + attach(contents: WebContents) { + const login = ( + event: Electron.Event, + _details: Electron.AuthenticationResponseDetails, + auth: Electron.AuthInfo, + callback: (username?: string, password?: string) => void, + ) => { + if ( + !auth.isProxy || + auth.scheme !== "basic" || + auth.host !== proxy.host || + auth.port !== proxy.port || + auth.realm !== "OpenCode Browser Proxy" + ) + return + event.preventDefault() + callback(proxy.credentials.username, proxy.credentials.password) + } + contents.on("login", login) + contents.setWebRTCIPHandlingPolicy("disable_non_proxied_udp") + return () => contents.off("login", login) + }, + } +}) diff --git a/packages/desktop/src/main/browser/policy.ts b/packages/desktop/src/main/browser/policy.ts new file mode 100644 index 00000000000..ee531d3553b --- /dev/null +++ b/packages/desktop/src/main/browser/policy.ts @@ -0,0 +1,17 @@ +// URL policy shared by the pane and page. Electron-free so it stays unit-testable under Bun. + +export function destinationOrigin(input: string) { + if (!URL.canParse(input)) return + const url = new URL(input) + return /^https?:$/.test(url.protocol) && !url.username && !url.password ? url.origin : undefined +} + +export function normalizeURL(input: string) { + const value = input.trim() || "about:blank" + const local = /^(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::\d+)?(?:[/?#]|$)/i.test(value) + const url = + value === "about:blank" || /^[a-z][a-z\d+.-]*:\/\//i.test(value) ? value : `${local ? "http" : "https"}://${value}` + if (url !== "about:blank" && !destinationOrigin(url)) + throw new Error("Only HTTP, HTTPS, and about:blank URLs are supported.") + return url +} diff --git a/packages/desktop/src/main/browser/profiling.ts b/packages/desktop/src/main/browser/profiling.ts new file mode 100644 index 00000000000..5e14e5ea03b --- /dev/null +++ b/packages/desktop/src/main/browser/profiling.ts @@ -0,0 +1,282 @@ +import type { WebContents } from "electron" +import { Browser } from "@opencode-ai/plugin-browser/rpc" +import { gzipSync, gunzipSync } from "node:zlib" +import { Schema } from "effect" +import type { Cdp } from "./cdp" +import type { BrowserFiles } from "./files" +import { analyzeCpu, analyzeTrace, parseHeap } from "./analysis" + +let recording: + | { + owner: WebContents + pid: number + started: number + timer?: ReturnType + finish: () => Promise<{ id: Browser.FileID; durationMs: number; incomplete: boolean }> + } + | undefined + +export function createProfiling(contents: WebContents, cdp: Cdp, files: BrowserFiles, source: () => readonly string[]) { + let trace: Promise<{ id: Browser.FileID; durationMs: number; incomplete: boolean }> | undefined + let traceResources = new Set() + let traceID = "" + let cpu: + | { + started: number + id: string + resources: Set + timer?: ReturnType + result?: Promise<{ id: Browser.FileID; durationMs: number }> + } + | undefined + let takingHeap = false + cdp.on("Page.frameNavigated", ({ frame }) => { + if (recording?.owner === contents) traceResources.add(frame.url) + if (cpu && !cpu.result) cpu.resources.add(frame.url) + }) + const json = async (id: Browser.FileID) => { + const file = await files.transfer(id) + try { + const data = Buffer.from(file.data) + return Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))( + (file.name.endsWith(".gz") ? gunzipSync(data, { maxOutputLength: 128 * 1024 * 1024 }) : data).toString("utf8"), + ) + } catch (error) { + throw new Error( + "Selected file cannot be decoded as a JSON capture, or expands beyond the 128 MiB analysis limit. Call browser.files.list({tabID}) and choose the fileID from the matching trace, CPU, or heap capture, not a screenshot/download. Do not retry the same invalid file.", + { cause: error }, + ) + } + } + const stopCpu = () => { + if (!cpu) + return Promise.reject( + new Error( + "No CPU profile has been started in this tab. Call browser.cpu.start({tabID}), perform the interaction to inspect, then browser.cpu.stop({tabID}).", + ), + ) + if (cpu.result) return cpu.result + const resources = cpu.resources + clearTimeout(cpu.timer) + cpu.result = cdp.send("Profiler.stop").then(async ({ profile }) => ({ + id: await files.save("profile.cpuprofile", "application/json", Buffer.from(JSON.stringify(profile)), [ + ...resources, + ]), + durationMs: (profile.endTime - profile.startTime) / 1000, + })) + return cpu.result + } + return { + target(type: "trace" | "cpu"): Browser.Target { + return { + resources: (type === "trace" ? [...traceResources] : [...(cpu?.resources ?? [])]).sort(), + key: type === "trace" ? traceID : (cpu?.id ?? ""), + } + }, + async startTrace(durationMs = 10_000) { + if (recording) + throw new Error( + recording.owner === contents + ? "A performance trace is already active in this tab. Use browser.trace.stop({tabID}) to finish it before starting another." + : "Another tab owns the active performance trace. Wait for its owner to finish; do not stop or replace another tab's recording.", + ) + const complete = Promise.withResolvers<{ stream?: string; dataLossOccurred: boolean }>() + const off = cdp.on("Tracing.tracingComplete", (event) => complete.resolve(event)) + const owner = { + owner: contents, + pid: contents.getOSProcessId(), + started: performance.now(), + timer: undefined as ReturnType | undefined, + finish: () => { + if (trace) return trace + trace = (async () => { + clearTimeout(owner.timer) + const durationMs = performance.now() - owner.started + const deadline = Promise.withResolvers() + const timeout = setTimeout( + () => + deadline.reject( + new Error( + "Chromium did not finish flushing the trace within 10 seconds. No complete export is confirmed. Check browser.files.list({tabID}); do not start another recording until the current trace has finished or the user resolves the failure.", + ), + ), + 10_000, + ) + try { + const result = await Promise.race([ + cdp.send("Tracing.end").then(() => complete.promise), + deadline.promise, + ]) + if (!result.stream) + throw new Error( + "Chromium stopped tracing without returning a trace stream. No export is available. Check desktop/plugin compatibility and report the failure; repeating trace.stop cannot recover a missing stream.", + ) + const chunks: Buffer[] = [] + let bytes = 0 + try { + while (true) { + const part = await cdp.send("IO.read", { handle: result.stream, size: 256 * 1024 }) + const buffer = Buffer.from(part.data, part.base64Encoded ? "base64" : "utf8") + bytes += buffer.byteLength + if (bytes > 64 * 1024 * 1024) + throw new Error( + "Trace exceeded its 64 MiB desktop capture limit. Record a shorter interaction with a smaller durationMs in browser.trace.start; do not repeat the same recording unchanged.", + ) + chunks.push(buffer) + if (part.eof) break + } + } finally { + await cdp.send("IO.close", { handle: result.stream }) + } + const raw = Schema.decodeUnknownSync( + Schema.fromJsonString( + Schema.Struct({ traceEvents: Schema.Array(Schema.Record(Schema.String, Schema.Json)) }), + ), + )(Buffer.concat(chunks).toString("utf8")) + // A CDP trace is browser-wide. Export only this renderer process, never + // the application renderer or another session's process metadata. + const traceEvents = raw.traceEvents.filter((event) => event.pid === owner.pid) + const id = await files.save( + "trace.json.gz", + "application/gzip", + gzipSync( + JSON.stringify({ + traceEvents, + metadata: { source: "opencode", scope: "renderer-process", processId: owner.pid }, + }), + ), + [...traceResources], + ) + return { + id, + durationMs, + incomplete: + result.dataLossOccurred || contents.isDestroyed() || contents.getOSProcessId() !== owner.pid, + } + } finally { + clearTimeout(timeout) + off() + if (recording === owner) recording = undefined + } + })() + return trace + }, + } + recording = owner + trace = undefined + traceResources = new Set(source()) + traceID = crypto.randomUUID() + try { + await cdp.send("Tracing.start", { + transferMode: "ReturnAsStream", + traceConfig: { + recordMode: "recordUntilFull", + traceBufferSizeInKb: 8192, + includedCategories: [ + "devtools.timeline", + "disabled-by-default-devtools.timeline", + "disabled-by-default-devtools.timeline.stack", + "v8.execute", + "blink.user_timing", + "disabled-by-default-v8.cpu_profiler", + ], + excludedCategories: ["*"], + }, + }) + owner.timer = setTimeout(() => { + void owner.finish().catch(() => undefined) + }, durationMs) + } catch (error) { + off() + if (recording === owner) recording = undefined + throw error + } + }, + stopTrace() { + if (recording?.owner === contents) return recording.finish() + if (trace) return trace + return Promise.reject( + new Error( + "This tab has no performance trace to stop. Call browser.trace.start({tabID}), perform the interaction to inspect, then browser.trace.stop({tabID}).", + ), + ) + }, + async startCpu() { + if (cpu && !cpu.result) + throw new Error( + "A CPU profile is already active in this tab. Use browser.cpu.stop({tabID}) before starting another profile.", + ) + await cdp.send("Profiler.enable") + await cdp.send("Profiler.start") + cpu = { started: Date.now(), id: crypto.randomUUID(), resources: new Set(source()) } + cpu.timer = setTimeout(() => { + void stopCpu().catch(() => undefined) + }, 30_000) + }, + stopCpu, + async heap() { + if (takingHeap) + throw new Error( + "A heap snapshot is already being captured in this tab. Await that call before taking another; do not capture the same tab's heaps in parallel.", + ) + takingHeap = true + const resources = source() + const chunks: string[] = [] + let size = 0 + let overflow = false + const off = cdp.on("HeapProfiler.addHeapSnapshotChunk", ({ chunk }) => { + size += Buffer.byteLength(chunk) + if (size > 128 * 1024 * 1024) overflow = true + if (!overflow) chunks.push(chunk) + }) + try { + await cdp.send("HeapProfiler.enable") + await cdp.send("HeapProfiler.takeHeapSnapshot", { reportProgress: false }) + } finally { + takingHeap = false + off() + } + if (overflow) + throw new Error( + "Heap snapshot exceeded the 128 MiB desktop capture limit. Use a smaller page/test case or ask the user to inspect the heap with desktop developer tools; this tool has no size override.", + ) + return files.save("heap.heapsnapshot.gz", "application/gzip", gzipSync(chunks.join("")), [ + ...resources, + ...source(), + ]) + }, + async analyze( + action: Extract< + Browser.Action, + { type: "trace.analyze" | "cpu.analyze" | "heap.summary" | "heap.query" | "heap.object" | "heap.compare" } + >, + ) { + if (action.type === "heap.compare") { + const before = parseHeap(await json(action.before)).classes + const after = parseHeap(await json(action.after)).classes + return { + classes: Array.from(new Set([...before.keys(), ...after.keys()])) + .map((name) => ({ + name, + countDelta: (after.get(name)?.count ?? 0) - (before.get(name)?.count ?? 0), + bytesDelta: (after.get(name)?.bytes ?? 0) - (before.get(name)?.bytes ?? 0), + })) + .filter((item) => item.countDelta || item.bytesDelta) + .sort((a, b) => Math.abs(b.bytesDelta) - Math.abs(a.bytesDelta)) + .slice(0, action.limit ?? 100), + } + } + const value = await json(action.fileID) + if (action.type === "trace.analyze") return analyzeTrace(value, action.limit) + if (action.type === "cpu.analyze") return analyzeCpu(value, action.limit) + const heap = parseHeap(value) + if (action.type === "heap.summary") return heap.summary(action.limit) + if (action.type === "heap.query") return heap.query(action.name, action.limit) + return heap.object(action.id, action.limit) + }, + async dispose() { + if (recording?.owner === contents) await recording.finish().catch(() => undefined) + if (cpu && !cpu.result) await stopCpu().catch(() => undefined) + }, + } +} diff --git a/packages/desktop/src/main/ipc-handlers/events.ts b/packages/desktop/src/main/ipc-handlers/events.ts index 018bbb1d8d9..1f9ee2643b3 100644 --- a/packages/desktop/src/main/ipc-handlers/events.ts +++ b/packages/desktop/src/main/ipc-handlers/events.ts @@ -1,14 +1,35 @@ +import { BrowserWindow } from "electron" import { Effect } from "effect" import { EventRpcs } from "../../shared/ipc-rpc" +import { createBrowserPane } from "../browser-pane" import { ipcEventStream } from "../ipc-events" import { IpcPortHandoff } from "../ipc-transport" +import { Shutdown } from "../lifecycle/shutdown" +import { isRendererUrl } from "../windows/protocol" import { sender } from "./context" export const eventHandlers = EventRpcs.toLayer( Effect.gen(function* () { const handoff = yield* IpcPortHandoff + const shutdown = yield* Shutdown.Service + const browser = createBrowserPane() + const stop = Effect.promise(() => browser.dispose()) + const remove = yield* shutdown.add(stop) + yield* Effect.addFinalizer(() => Effect.sync(remove).pipe(Effect.andThen(stop))) return EventRpcs.of({ DesktopEvents: (_request, context) => ipcEventStream(sender(handoff, context).id), + BrowserPane: ({ request }, context) => + Effect.tryPromise(async () => { + const contents = sender(handoff, context) + const win = BrowserWindow.fromWebContents(contents) + if (!win || win.isDestroyed() || win.webContents !== contents || !isRendererUrl(contents.getURL())) { + throw new Error("browser.pane.owner.invalid") + } + if (request.type === "register") return browser.register(win, request.bindingID, request.target) + if (request.type === "layout") return browser.layout(win, request.bindingID, request.layout) + if (request.type === "command") return browser.command(win, request.bindingID, request.command) + return browser.close(win, request.bindingID) + }).pipe(Effect.orDie), }) }), ) diff --git a/packages/desktop/src/renderer/api-types.ts b/packages/desktop/src/renderer/api-types.ts index 1079dde3364..aea01a8f246 100644 --- a/packages/desktop/src/renderer/api-types.ts +++ b/packages/desktop/src/renderer/api-types.ts @@ -1,7 +1,9 @@ +import type { BrowserPaneEvent } from "@opencode-ai/app/desktop" import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu" import type { DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native" import type { UpdaterState } from "@opencode-ai/app/updater" import type { WslServersPlatform } from "@opencode-ai/app/wsl/types" +import type { BrowserPaneRequest } from "../shared/ipc-rpc/browser" import type { ClipboardImage, DirectoryPickerOptions, @@ -23,6 +25,11 @@ export type UpdaterAPI = { export type ElectronAPI = { awaitInitialization(): Promise reconnectService(): Promise + browserPane: { + request(request: BrowserPaneRequest): Promise + send(request: BrowserPaneRequest): void + onEvent(callback: (value: { readonly bindingID: string; readonly event: BrowserPaneEvent }) => void): () => void + } wslServers: WslServersAPI updater: UpdaterAPI consumeInitialDeepLinks(): Promise diff --git a/packages/desktop/src/renderer/api.ts b/packages/desktop/src/renderer/api.ts index 5f1c99ae5e0..71d4ad3a825 100644 --- a/packages/desktop/src/renderer/api.ts +++ b/packages/desktop/src/renderer/api.ts @@ -25,6 +25,11 @@ const updaterHandler = (state: UpdaterState) => { export const api: ElectronAPI = { awaitInitialization: () => invoke("AppAwaitInitialization"), reconnectService: () => invoke("AppReconnectService"), + browserPane: { + request: (request) => invoke("BrowserPane", { request }), + send: (request) => send("BrowserPane", { request }), + onEvent: (callback) => listen("BrowserPaneEvent", (value) => callback(value)), + }, wslServers: { getState: () => invoke("WslGetState").then(mutable), subscribe: (cb) => { diff --git a/packages/desktop/src/renderer/platform/index.ts b/packages/desktop/src/renderer/platform/index.ts index 7432294b1aa..9b01958ec17 100644 --- a/packages/desktop/src/renderer/platform/index.ts +++ b/packages/desktop/src/renderer/platform/index.ts @@ -31,6 +31,35 @@ export function createDesktopPlatform( windowID: windowState.id, ...createDesktopFiles(api, os, ACCEPTED_FILE_EXTENSIONS), ...createDesktopStorage(api), + browserPane: { + register(target, onEvent) { + const bindingID = crypto.randomUUID() + let closed = false + const dispose = api.browserPane.onEvent((value) => { + if (!closed && value.bindingID === bindingID) onEvent(value.event) + }) + const ready = api.browserPane.request({ type: "register", bindingID, target }) + // Failures reach the owner through the closed-state event; keep the bare promise handled. + void ready.catch(() => undefined) + return { + setLayout(layout) { + if (!closed) + void ready + .then(() => + api.browserPane.send({ type: "layout", bindingID, ...(layout === undefined ? {} : { layout }) }), + ) + .catch(() => undefined) + }, + command: (command) => ready.then(() => api.browserPane.request({ type: "command", bindingID, command })), + close() { + if (closed) return + closed = true + dispose() + void ready.then(() => api.browserPane.request({ type: "close", bindingID })).catch(() => undefined) + }, + } + }, + }, updater, exportDebugLogs: () => api.exportDebugLogs(), setForceFocus: (enabled) => api.setForceFocus(enabled), diff --git a/packages/desktop/src/shared/ipc-rpc/browser.ts b/packages/desktop/src/shared/ipc-rpc/browser.ts new file mode 100644 index 00000000000..d9f0257edae --- /dev/null +++ b/packages/desktop/src/shared/ipc-rpc/browser.ts @@ -0,0 +1,38 @@ +import { Browser } from "@opencode-ai/plugin-browser/rpc" +import { Schema } from "effect" +import { Rpc } from "effect/unstable/rpc" + +const text = (maximum: number) => Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(maximum)) +const bindingID = text(128) +const endpoint = Schema.Struct({ + url: text(16_384), + username: Schema.optionalKey(text(1_024)), + password: Schema.optionalKey(text(4_096)), +}) +const target = Schema.Struct({ sessionID: text(256).check(Schema.isStartsWith("ses")), endpoint }) +const bounds = Schema.Struct({ x: Schema.Finite, y: Schema.Finite, width: Schema.Finite, height: Schema.Finite }) +const channel = Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 255 })) +const layout = Schema.Struct({ + tabID: Browser.TabID, + visible: Schema.Boolean, + bounds: Schema.optionalKey(bounds), + background: Schema.optionalKey(Schema.Tuple([channel, channel, channel, channel])), + radius: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 }))), +}) +export const BrowserPaneRequestSchema = Schema.Union([ + Schema.Struct({ type: Schema.Literal("register"), bindingID, target }), + Schema.Struct({ type: Schema.Literal("layout"), bindingID, layout: Schema.optionalKey(layout) }), + Schema.Struct({ type: Schema.Literal("command"), bindingID, command: Browser.Action }), + Schema.Struct({ type: Schema.Literal("close"), bindingID }), +]) +export type BrowserPaneRequest = Schema.Schema.Type + +export const BrowserPaneEventSchema = Schema.Union([ + Schema.Struct({ type: Schema.Literal("focus"), tabID: Browser.TabID }), + Schema.Struct({ + type: Schema.Literal("state"), + state: Schema.NullOr(Browser.State), + error: Schema.optionalKey(Schema.String), + }), +]) +export const BrowserPaneRpc = Rpc.make("BrowserPane", { payload: { request: BrowserPaneRequestSchema } }) diff --git a/packages/desktop/src/shared/ipc-rpc/events.ts b/packages/desktop/src/shared/ipc-rpc/events.ts index ce9dffd0cb9..26c4f6c14e0 100644 --- a/packages/desktop/src/shared/ipc-rpc/events.ts +++ b/packages/desktop/src/shared/ipc-rpc/events.ts @@ -1,8 +1,14 @@ import { Schema } from "effect" import { Rpc, RpcGroup } from "effect/unstable/rpc" +import { BrowserPaneEventSchema, BrowserPaneRpc } from "./browser" import { UpdaterStateSchema } from "./updater" import { WslServersEventSchema } from "./wsl" +export class BrowserPaneEvent extends Schema.TaggedClass()("BrowserPaneEvent", { + bindingID: Schema.String, + event: BrowserPaneEventSchema, +}) {} + export class DeepLinksOpened extends Schema.TaggedClass()("DeepLinksOpened", { urls: Schema.Array(Schema.String), }) {} @@ -40,6 +46,7 @@ export class StorageChanged extends Schema.TaggedClass()("Storag }) {} export const DesktopEvent = Schema.Union([ + BrowserPaneEvent, DeepLinksOpened, MenuCommandTriggered, UpdaterStateChanged, @@ -52,4 +59,4 @@ export const DesktopEvent = Schema.Union([ export type DesktopEvent = Schema.Schema.Type export const DesktopEvents = Rpc.make("DesktopEvents", { success: DesktopEvent, stream: true }) -export const EventRpcs = RpcGroup.make(DesktopEvents) +export const EventRpcs = RpcGroup.make(DesktopEvents, BrowserPaneRpc) diff --git a/packages/desktop/test/browser-analysis.test.ts b/packages/desktop/test/browser-analysis.test.ts new file mode 100644 index 00000000000..957a3849556 --- /dev/null +++ b/packages/desktop/test/browser-analysis.test.ts @@ -0,0 +1,77 @@ +import { expect, test } from "bun:test" +import { analyzeCpu, analyzeTrace, parseHeap } from "../src/main/browser/analysis" + +test("trace analysis reports observed durations without inventing Web Vitals", () => { + const result = analyzeTrace({ + traceEvents: [ + { name: "RunTask", ph: "X", dur: 75_000 }, + { name: "Paint", ph: "X", dur: 2_500 }, + { name: "Paint", ph: "X", dur: 1_000 }, + { name: "marker", ph: "i" }, + ], + }) + expect(result.metrics).toEqual([ + { name: "recordedEvents", value: 4, unit: "count" }, + { name: "longTasks", value: 1, unit: "count" }, + { name: "longTaskBlocking", value: 25, unit: "ms" }, + ]) + expect(result.events).toContainEqual({ name: "Paint", count: 2, totalMs: 3.5, maxMs: 2.5 }) +}) + +test("CPU analysis attributes sample intervals to their functions", () => { + const result = analyzeCpu({ + startTime: 0, + endTime: 6000, + nodes: [ + { id: 1, callFrame: { functionName: "first", url: "a.js", lineNumber: 0 } }, + { id: 2, callFrame: { functionName: "second", url: "b.js", lineNumber: 2 } }, + ], + samples: [1, 2, 1], + timeDeltas: [1000, 2000, 3000], + }) + expect(result.durationMs).toBe(6) + expect(result.functions).toEqual([ + { name: "first", url: "a.js", line: 1, selfMs: 4 }, + { name: "second", url: "b.js", line: 3, selfMs: 2 }, + ]) +}) + +test("heap queries and object links use snapshot IDs and shallow sizes", () => { + const heap = parseHeap({ + snapshot: { + meta: { + node_fields: ["type", "name", "id", "self_size", "edge_count"], + node_types: [["object"], "string", "number", "number", "number"], + edge_fields: ["type", "name_or_index", "to_node"], + edge_types: [["property", "weak"], "string", "node"], + }, + }, + // root -next-> child, plus a WeakRef whose weak "target" edge points at child too. + nodes: [0, 0, 1, 10, 1, 0, 1, 3, 20, 0, 0, 3, 5, 8, 1], + edges: [0, 2, 5, 1, 4, 5], + strings: ["root", "child", "next", "WeakRef", "target"], + }) + expect(heap.summary()).toMatchObject({ nodes: 3, edges: 2, selfBytes: 38 }) + expect(heap.query("", 1)).toMatchObject({ nodes: [{ id: 3, selfBytes: 20 }], truncated: true }) + expect(heap.object(1).references).toMatchObject([{ name: "next", node: { id: 3 } }]) + expect(heap.object(5).references).toMatchObject([{ name: "target", node: { id: 3 } }]) + // The weak edge is visible from its owner but never counts as keeping the child alive. + expect(heap.object(3).retainers).toEqual([{ name: "next", node: expect.objectContaining({ id: 1 }) }]) + expect(() => heap.object(99)).toThrow("Object ID was not found") +}) + +test("heap parsing rejects edge counts and targets the file does not carry", () => { + const meta = { + node_fields: ["type", "name", "id", "self_size", "edge_count"], + node_types: [["object"], "string", "number", "number", "number"], + edge_fields: ["type", "name_or_index", "to_node"], + edge_types: [["property"], "string", "node"], + } + // A trillion claimed edges with an empty edge array must fail at parse time, not traverse. + expect(() => parseHeap({ snapshot: { meta }, nodes: [0, 0, 1, 10, 1e12], edges: [], strings: ["root"] })).toThrow( + "unsupported or incomplete", + ) + expect(() => parseHeap({ snapshot: { meta }, nodes: [0, 0, 1, 10, 1], edges: [0, 0, 7], strings: ["root"] })).toThrow( + "unsupported or incomplete", + ) +}) diff --git a/packages/desktop/test/browser-errors.test.ts b/packages/desktop/test/browser-errors.test.ts new file mode 100644 index 00000000000..fb985224764 --- /dev/null +++ b/packages/desktop/test/browser-errors.test.ts @@ -0,0 +1,81 @@ +import { expect, test } from "bun:test" +import { rm } from "node:fs/promises" +import path from "node:path" +import { Browser } from "@opencode-ai/plugin-browser/rpc" +import { browserFailure, protocolError } from "../src/main/browser/errors" +import { createBrowserFiles } from "../src/main/browser/files" +import { analyzeCpu, analyzeTrace, parseHeap } from "../src/main/browser/analysis" + +const tabID = Browser.TabID.make(`tab_${crypto.randomUUID()}`) + +test("navigation failures explain the server network and never recommend disabling TLS", () => { + const action: Browser.Action = { type: "navigate", tabID, url: "https://example.com" } + const refused = browserFailure(action, new Error("net::ERR_CONNECTION_REFUSED")) + expect(refused.code).toBe("navigation_failed") + expect(refused.message).toContain("localhost means that server") + expect(refused.message).toContain("hostname/port") + const tls = browserFailure(action, new Error("net::ERR_CERT_AUTHORITY_INVALID")) + expect(tls.message).toContain("do not bypass certificate checks") + const aborted = browserFailure(action, new Error("net::ERR_ABORTED")) + expect(aborted.message).toContain("browser.files.list({tabID})") +}) + +test("native protocol errors keep the cause and give a valid recovery operation", () => { + expect(protocolError("DOM.resolveNode", new Error("Could not find node with given id")).message).toContain( + "browser.snapshot({tabID})", + ) + expect(protocolError("Runtime.evaluate", new Error("Cannot find context with specified id")).message).toContain( + "browser.frames({tabID})", + ) + const unsupported = protocolError("Target.getBrowserContexts", new Error("Not allowed")) + expect(unsupported.message).toContain("does not support or allow") + expect(unsupported.message).toContain("do not retry unchanged") + expect(unsupported.cause).toBeInstanceOf(Error) + const failure = browserFailure({ type: "screenshot", tabID }, new Error("UnknownVizError")) + expect(failure.message).toContain("browser.tabs.focus({tabID})") + expect(failure.message).toContain("report the capture failure") +}) + +test("long page errors retain the operation context without classifying script text as a navigation error", () => { + const failure = browserFailure( + { type: "evaluate", tabID, script: "throw Error()" }, + new Error("ERR_CONNECTION_REFUSED " + "x".repeat(10_000)), + ) + expect(failure.code).toBe("operation_failed") + expect(failure.message.startsWith("browser.evaluate failed.")).toBe(true) + expect(failure.message.length).toBeLessThanOrEqual(2_048) +}) + +test("files distinguish pending, failed, unknown and missing desktop copies", async () => { + const files = createBrowserFiles(() => ["https://example.com"]) + await files.ready + try { + const pending = files.add("download.txt", "text/plain") + expect(() => files.get(pending.id)).toThrow("do not start a duplicate download") + pending.state = "failed" + expect(() => files.get(pending.id)).toThrow("Inspect browser.console") + expect(() => files.get(Browser.FileID.make(`file_${crypto.randomUUID()}`))).toThrow( + "not a server path or request ID", + ) + const id = await files.save("capture.json", "application/json", new TextEncoder().encode("{}")) + await rm(files.get(id).path) + await expect(files.transfer(id)).rejects.toThrow("on the desktop") + await expect( + files.save("large.bin", "application/octet-stream", new Uint8Array(Browser.MAX_FILE_BYTES + 1)), + ).rejects.toThrow("Do not retry an identical capture") + // The page sees the temp basename as File.name: spaces and non-ASCII stay, only characters no + // filesystem accepts change, and a bare dot or separator cannot leave the per-file directory. + expect(path.basename(files.add("Quarter 1 日本語.csv", "text/csv").path)).toBe("Quarter 1 日本語.csv") + expect(path.basename(files.add("a/b:c?.txt", "text/plain").path)).toBe("a_b_c_.txt") + expect(path.basename(files.add("..", "text/plain").path)).toBe("file") + expect(path.dirname(files.add(".", "text/plain").path).startsWith(files.directory)).toBe(true) + } finally { + await files.dispose() + } +}) + +test("wrong capture formats identify the matching capture tool", () => { + expect(() => analyzeTrace({ nodes: [] })).toThrow("browser.trace.stop") + expect(() => analyzeCpu({ traceEvents: [] })).toThrow("browser.cpu.stop") + expect(() => parseHeap({ traceEvents: [] })).toThrow("browser.heap.snapshot") +}) diff --git a/packages/desktop/test/browser-native.test.ts b/packages/desktop/test/browser-native.test.ts new file mode 100644 index 00000000000..b4bfcc62b4f --- /dev/null +++ b/packages/desktop/test/browser-native.test.ts @@ -0,0 +1,123 @@ +import { test, expect } from "bun:test" +import { mkdir, mkdtemp, rm } from "node:fs/promises" +import path from "node:path" +import { tmpdir } from "node:os" + +// Spawns a real Electron window; CI runners have no display for it. +test.skipIf(!!process.env.CI)( + "browser suite over authenticated HTTP RPC with separate server and desktop storage", + async () => { + const root = await mkdtemp(path.join(tmpdir(), "opencode-browser-suite-")) + const output = await mkdtemp(path.resolve(import.meta.dir, "../node_modules/.browser-suite-")) + const names = ["home", "config", "data", "cache", "state", "server-files", "client-files", "electron-data"] + await Promise.all(names.map((name) => mkdir(path.join(root, name), { recursive: true }))) + const built = await Bun.build({ + entrypoints: [path.join(import.meta.dir, "browser/native.ts")], + outdir: output, + naming: "native.mjs", + target: "node", + format: "esm", + external: ["electron", "lighthouse", "puppeteer-core"], + }) + if (!built.success) throw new AggregateError(built.logs) + const environment = { + ...process.env, + OPENCODE_TEST_HOME: path.join(root, "home"), + OPENCODE_CONFIG_DIR: path.join(root, "config"), + XDG_CONFIG_HOME: path.join(root, "config"), + XDG_DATA_HOME: path.join(root, "data"), + XDG_CACHE_HOME: path.join(root, "cache"), + XDG_STATE_HOME: path.join(root, "state"), + SMOKE_PASSWORD: crypto.randomUUID(), + SMOKE_SERVER_FILES: path.join(root, "server-files"), + SMOKE_ROOT: root, + ELECTRON_RUN_AS_NODE: undefined, + } + const ready = Promise.withResolvers() + const server = Bun.spawn([process.execPath, path.join(import.meta.dir, "browser/server.ts")], { + cwd: root, + env: { ...environment, TMP: path.join(root, "server-files"), TEMP: path.join(root, "server-files") }, + stdout: "inherit", + stderr: "inherit", + ipc(message: unknown) { + if (typeof message === "string") ready.resolve(message) + }, + }) + void server.exited.then((code) => ready.reject(new Error(`Fixture server exited: ${code}`))) + let native: ReturnType | undefined + let proxy: Bun.Server | undefined + let tunnels = 0 + let rejectedStates = 0 + try { + const url = await Promise.race([ + ready.promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error("Fixture server startup timed out")), 30_000).unref(), + ), + ]) + // Exercise the real HTTP boundary with delayed state acknowledgments, as on + // a remote server. Neither endpoint can rely on synchronous UI/state updates. + // The first inventory that carries a tab is rejected five times, an outage of almost + // eight seconds: the desktop must keep resending until the server has it, or the + // server never learns the tab exists. + proxy = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + idleTimeout: 0, + async fetch(request) { + const incoming = new URL(request.url) + if (incoming.pathname.endsWith("/tunnel.open")) tunnels++ + if (incoming.pathname.endsWith("/experimental.browser/state")) { + if (rejectedStates < 5 && (await request.clone().text()).includes('"tab_')) { + rejectedStates++ + return new Response(null, { status: 503 }) + } + await new Promise((resolve) => setTimeout(resolve, 75)) + } + return fetch(new Request(new URL(incoming.pathname + incoming.search, url), request), { decompress: false }) + }, + }) + const electron: unknown = (await import("electron")).default + if (typeof electron !== "string") throw new Error("Electron binary path is unavailable.") + native = Bun.spawn([electron, path.join(output, "native.mjs")], { + cwd: output, + env: { + ...environment, + SMOKE_URL: proxy.url.href, + TMP: path.join(root, "client-files"), + TEMP: path.join(root, "client-files"), + }, + stdout: "inherit", + stderr: "inherit", + }) + expect( + await Promise.race([ + native.exited, + new Promise((_, reject) => + setTimeout(() => reject(new Error("Native browser suite timed out")), 140_000).unref(), + ), + ]), + ).toBe(0) + expect(tunnels).toBeGreaterThan(0) + expect(rejectedStates).toBe(5) + } finally { + if (native?.exitCode === null) { + native.kill() + await native.exited + } + if (server.exitCode === null) server.send("stop") + proxy?.stop(true) + await Promise.race([ + server.exited, + new Promise((resolve) => + setTimeout(() => { + if (server.exitCode === null) server.kill() + resolve() + }, 10_000).unref(), + ), + ]) + await Promise.all([rm(root, { recursive: true, force: true }), rm(output, { recursive: true, force: true })]) + } + }, + 180_000, +) diff --git a/packages/desktop/test/browser/contract.ts b/packages/desktop/test/browser/contract.ts new file mode 100644 index 00000000000..5bf43bac10d --- /dev/null +++ b/packages/desktop/test/browser/contract.ts @@ -0,0 +1,19 @@ +import { Rpc } from "@opencode-ai/schema/rpc" +import { Session } from "@opencode-ai/schema/session" +import { Schema } from "effect" + +export const Smoke = Rpc.define({ + id: "browser.smoke", + methods: { + execute: { + input: Schema.Struct({ sessionID: Session.ID, code: Schema.String }), + output: Schema.Struct({ output: Schema.String, error: Schema.optionalKey(Schema.Boolean) }), + }, + write: { + input: Schema.Struct({ text: Schema.String, name: Schema.optionalKey(Schema.String) }), + output: Schema.String, + }, + read: { input: Schema.Struct({ path: Schema.String }), output: Schema.String }, + }, + events: {}, +}) diff --git a/packages/desktop/test/browser/native.ts b/packages/desktop/test/browser/native.ts new file mode 100644 index 00000000000..7e752c96310 --- /dev/null +++ b/packages/desktop/test/browser/native.ts @@ -0,0 +1,581 @@ +import assert from "node:assert/strict" +import { createServer } from "node:http" +import { once } from "node:events" +import { createHash } from "node:crypto" +import path from "node:path" +import { app, BrowserWindow, nativeImage } from "electron" +import { Browser } from "@opencode-ai/plugin-browser/rpc" +import { OpenCode } from "@opencode-ai/client" +import { Effect, Fiber, Schema, Stream } from "effect" +import { createBrowserPane } from "../../src/main/browser-pane" +import { bindIpcEvents, ipcEventStream } from "../../src/main/ipc-events" +import { Smoke } from "./contract" +import { verifyTargets } from "./targets" + +type Output = Schema.Schema.Type["output"]> + +// Fail the disposable fixture in stderr instead of opening Electron's error dialog. +process.on("uncaughtException", (error) => { + console.error(error) + app.exit(1) +}) +process.on("unhandledRejection", (error) => { + console.error(error) + app.exit(1) +}) + +async function main() { + const root = process.env.SMOKE_ROOT! + app.setPath("userData", path.join(root, "electron-data")) + app.on("window-all-closed", () => {}) + await app.whenReady() + const web = createServer((request, response) => { + if (request.url === "/cors-allowed" || request.url === "/cors-denied") { + if (request.url === "/cors-allowed") response.setHeader("access-control-allow-origin", "*") + response.setHeader("content-type", "text/plain") + response.end("cors proof") + return + } + if (request.url === "/api/test") { + response.setHeader("content-type", "application/json") + response.end('{"message":"network body"}') + return + } + if (request.url === "/missing") { + response.writeHead(404, { "content-type": "text/plain" }) + response.end("not found") + return + } + if (request.url === "/download") { + response.setHeader("content-disposition", 'attachment; filename="report.txt"') + response.setHeader("content-type", "text/plain") + response.end("desktop download bytes") + return + } + if (request.url === "/frame") { + response.setHeader("content-type", "text/html") + response.end("") + return + } + // A same-origin child inside a cross-origin frame shares its parent's renderer and has no CDP target. + if (request.url === "/nested") { + response.setHeader("content-type", "text/html") + response.end('') + return + } + // One Chromium request ID spans both hops; each hop must keep its own wire headers. + if (request.url === "/redirect") { + response.writeHead(302, { location: "/redirected", "set-cookie": "hop=1; Path=/" }) + response.end() + return + } + if (request.url === "/redirected") { + response.writeHead(200, { "content-type": "text/plain" }) + response.end("landed") + return + } + // Fetched twice: the second 301 comes from Chromium's cache without ExtraInfo events, while + // the uncacheable target still emits them for the same request ID. + if (request.url === "/moved") { + response.writeHead(301, { location: "/moved-target", "cache-control": "max-age=60" }) + response.end() + return + } + if (request.url === "/moved-target") { + response.writeHead(201, { "content-type": "text/plain", "cache-control": "no-store", "x-hop": "target" }) + response.end("moved") + return + } + // Revalidation: the wire answers 304 while the renderer reports the cached 200. + if (request.url === "/etag") { + if (request.headers["if-none-match"] === '"v1"') { + response.writeHead(304) + response.end() + return + } + response.writeHead(200, { etag: '"v1"', "cache-control": "no-cache", "content-type": "text/plain" }) + response.end("etag body") + return + } + const crossOrigin = `http://${request.headers.host?.replace(/^[^:]+/, "localhost")}` + response.setHeader("content-type", "text/html") + response.end( + `Browser suite

Browser suite

Waiting
DownloadPopup
Scroll content
`, + ) + }) + web.on("upgrade", (request, socket) => { + socket.on("error", () => socket.destroy()) + const accept = createHash("sha1") + .update(request.headers["sec-websocket-key"] + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11") + .digest("base64") + socket.write( + `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`, + ) + const message = Buffer.from("rpc websocket proof") + socket.write(Buffer.concat([Buffer.from([0x81, message.length]), message])) + socket.once("data", () => socket.end(Buffer.from([0x88, 0]))) + }) + await once(web.listen(0, "127.0.0.1"), "listening") + const address = web.address() + assert(address && typeof address !== "string") + const fixture = `http://127.0.0.1:${address.port}` + const client = OpenCode.make({ + baseUrl: process.env.SMOKE_URL!, + headers: { authorization: `Basic ${Buffer.from(`opencode:${process.env.SMOKE_PASSWORD}`).toString("base64")}` }, + }) + const location = { directory: process.env.SMOKE_SERVER_FILES! } + const rpc = client.rpc(Smoke) + const session = await client.session.create({ title: "Browser suite", location }) + const pane = createBrowserPane() + const win = new BrowserWindow({ show: false, width: 1100, height: 800, webPreferences: { sandbox: true } }) + const readyToShow = once(win, "ready-to-show") + await win.loadURL("about:blank") + await readyToShow + win.setTitle("Browser suite (isolated test)") + win.showInactive() + await win.webContents.executeJavaScript( + "new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve(null))))", + ) + const ipcErrors: string[] = [] + const replaced: string[] = [] + const inventories = new Map() + const unbind = await Effect.runPromise(bindIpcEvents(win.webContents.id)) + const events = Effect.runFork( + ipcEventStream(win.webContents.id).pipe( + Stream.runForEach((event) => + Effect.sync(() => { + if (event._tag !== "BrowserPaneEvent") return + if (event.event.type === "state") { + if (event.event.error === "browser.pane.replaced") replaced.push(event.bindingID) + inventories.set(event.bindingID, event.event.state) + return + } + if (!inventories.get(event.bindingID)?.tabs.some((tab) => tab.id === event.event.tabID)) + ipcErrors.push("Focus arrived before its tab inventory") + pane.layout(win, event.bindingID, { + tabID: event.event.tabID, + visible: true, + bounds: { x: 0, y: 0, width: 1000, height: 700 }, + }) + }), + ), + ), + ) + const visited = new Set() + async function call( + name: Name, + input: Omit, "type">, + ): Promise> { + console.log(`BROWSER ${name}`) + const result = await rpc.execute( + { sessionID: session.id, code: `return await tools.browser.${name}(${JSON.stringify(input)})` }, + { location }, + ) + assert(!result.error, result.output) + const operation = Browser.Operations.find((operation) => operation.name === name) + assert(operation) + visited.add(name) + return Schema.decodeUnknownSync(operation.output)(JSON.parse(result.output)) as Output + } + async function fails(name: Browser.Method, input: object, expected: RegExp) { + const result = await rpc.execute( + { sessionID: session.id, code: `return await tools.browser.${name}(${JSON.stringify(input)})` }, + { location }, + ) + assert.equal(result.error, true, `Expected browser.${name} to fail`) + assert.match(result.output, expected) + } + try { + await verifyTargets(win, fixture) + await pane.register(win, "suite", { + sessionID: session.id, + endpoint: { url: process.env.SMOKE_URL!, password: process.env.SMOKE_PASSWORD }, + }) + const first = await call("tabs.open", { url: fixture }) + const second = await call("tabs.open", { url: `${fixture}/other`, focus: false }) + assert.equal((await call("tabs.list", {})).tabs.length, 2) + const tabID = first.id + assert.equal(first.url, fixture + "/") + const networkProof = await call("evaluate", { + tabID, + script: `(async () => ({ + origin: location.origin, + denied: await fetch('http://localhost:${address.port}/cors-denied').then(() => false, () => true), + allowed: await fetch('http://localhost:${address.port}/cors-allowed').then(response => response.text()), + websocket: await new Promise((resolve, reject) => { const socket = new WebSocket('ws://localhost:${address.port}/hmr'); socket.onmessage = event => { resolve(event.data); socket.close(); }; socket.onerror = () => reject(new Error('WebSocket failed')); }), + cookie: (document.cookie = 'wire=1', await fetch('/api/test?cookie').then(response => response.ok)), + etag: [await fetch('/etag').then(response => response.status), await fetch('/etag').then(response => response.status)], + redirect: await fetch('/redirect').then(response => response.text()), + moved: [await fetch('/moved').then(response => response.status), await fetch('/moved').then(response => response.status)], + refused: await new Promise((resolve) => { const socket = new WebSocket('ws://127.0.0.1:1/refused'); socket.onerror = () => resolve('refused'); socket.onopen = () => resolve('opened'); }), + }))()`, + }) + assert.deepEqual(networkProof.value, { + origin: fixture, + denied: true, + allowed: "cors proof", + websocket: "rpc websocket proof", + cookie: true, + etag: [200, 200], + redirect: "landed", + moved: [201, 201], + refused: "refused", + }) + const sockets = await call("network.list", { tabID, resourceType: "websocket" }) + const opened = sockets.requests.find((request) => request.url.includes("/hmr")) + const refused = sockets.requests.find((request) => request.url.includes("/refused")) + assert.equal(opened?.statusCode, 101, JSON.stringify(sockets)) + assert.equal(opened?.state, "completed") + assert.equal(refused?.state, "failed", JSON.stringify(refused)) + assert( + refused?.state === "failed" && refused.durationMs >= 0 && refused.durationMs < 60_000, + JSON.stringify(refused), + ) + const chain = await call("network.list", { tabID, urlContains: "/redirect" }) + assert.deepEqual( + chain.requests.map((request) => [request.url.endsWith("/redirect"), request.statusCode]), + [ + [true, 302], + [false, 200], + ], + JSON.stringify(chain), + ) + const hop = await call("network.get", { tabID, id: chain.requests[0].id }) + const landed = await call("network.get", { tabID, id: chain.requests[1].id }) + const names = (headers: { name: string }[]) => headers.map((header) => header.name) + assert(names(hop.responseHeaders).includes("set-cookie"), JSON.stringify(hop.responseHeaders)) + assert(names(hop.responseHeaders).includes("location"), JSON.stringify(hop.responseHeaders)) + assert(!names(landed.responseHeaders).includes("location"), JSON.stringify(landed.responseHeaders)) + assert( + hop.responseHeaders.every((header) => header.name !== "set-cookie" || header.value === ""), + JSON.stringify(hop.responseHeaders), + ) + const moved = await call("network.list", { tabID, urlContains: "/moved" }) + assert.deepEqual( + moved.requests.map((request) => request.statusCode), + [301, 201, 301, 201], + JSON.stringify(moved), + ) + const cachedHop = await call("network.get", { tabID, id: moved.requests[2].id }) + assert(!names(cachedHop.responseHeaders).includes("x-hop"), JSON.stringify(cachedHop.responseHeaders)) + const revalidated = await call("network.list", { tabID, urlContains: "/etag" }) + assert.deepEqual( + revalidated.requests.map((request) => request.statusCode), + [200, 304], + JSON.stringify(revalidated), + ) + const withCookie = await call("network.list", { tabID, urlContains: "/api/test?cookie" }) + const cookieDetail = await call("network.get", { tabID, id: withCookie.requests[0].id }) + // The wire header proves the cookie was sent; its value never reaches the model. + assert( + cookieDetail.requestHeaders.some((header) => header.name === "cookie" && header.value === ""), + JSON.stringify(cookieDetail.requestHeaders), + ) + assert(!cookieDetail.requestHeaders.some((header) => header.name !== header.name.toLowerCase())) + const upload = await rpc.write({ text: "server upload bytes" }, { location }) + await fails("trace.stop", { tabID }, /browser\.trace\.start/) + await fails("cpu.stop", { tabID }, /browser\.cpu\.start/) + await fails("evaluate", { tabID, frameID: "missing-frame", script: "1" }, /browser\.frames/) + await fails("evaluate", { tabID, script: "throw new Error('fixture exception')" }, /Check the script and frameID/) + await fails("press", { tabID, key: "ControlOrMeta+A" }, /Supported modifiers are Alt, Control, Meta, and Shift/) + await fails("wait", { tabID, condition: "text" }, /requires non-empty text/) + await fails("wait", { tabID, condition: "text", text: "not-on-the-page", timeoutMs: 1 }, /check text\/frameID/) + await fails("files.get", { tabID, fileID: `file_${crypto.randomUUID()}` }, /not a server path or request ID/) + await call("evaluate", { + tabID, + script: `(async () => { + const radio=document.createElement('input'); radio.type='radio'; radio.checked=true; radio.setAttribute('aria-label','Selected radio'); document.querySelector('select').after(radio); + const source=document.createElement('div'); source.draggable=true; source.tabIndex=0; source.setAttribute('role','button'); source.textContent='Drag source'; source.ondragstart=e=>e.dataTransfer.setData('text/plain','element dropped'); document.body.prepend(source); + const drop=document.createElement('div'); drop.tabIndex=0; drop.setAttribute('role','button'); drop.textContent='Drop target'; drop.style.cssText='height:40px;width:300px;background:#ddd'; drop.ondragover=e=>e.preventDefault(); drop.ondrop=async e=>{e.preventDefault();document.querySelector('output').textContent=e.dataTransfer.files.length?await e.dataTransfer.files[0].text():e.dataTransfer.getData('text/plain')}; document.body.prepend(drop); + await new Promise(resolve=>{const frame=document.querySelector('iframe'); frame.onload=()=>resolve(null); frame.src=${JSON.stringify(`http://localhost:${address.port}/frame`)};}); + })()`, + }) + pane.layout(win, "suite", { tabID, visible: true, bounds: { x: 0, y: 0, width: 1000, height: 700 } }) + await call("tabs.focus", { tabID: second.id }) + pane.layout(win, "suite", { tabID: second.id, visible: true, bounds: { x: 0, y: 0, width: 1000, height: 700 } }) + const snap = await call("snapshot", { tabID, boxes: true }) + const ref = (text: string) => { + const match = snap.content + .split("\n") + .find((line) => /@e\d+/.test(line) && line.includes(`"${text}"`)) + ?.match(/@e\d+/)?.[0] + assert(match, `Missing ${text}: ${snap.content}`) + return Browser.Ref.make(match) + } + await call("fill", { tabID, ref: ref("Name"), text: "remote browser" }) + await fails("fill", { tabID, ref: ref("Apply"), text: "wrong target" }, /choose a textbox/) + await fails("fill", { tabID, ref: ref("Upload"), text: "wrong target" }, /browser\.files\.upload/) + await fails("check", { tabID, ref: ref("Selected radio"), checked: false }, /Select a different radio/) + await call("check", { tabID, ref: ref("Selected radio"), checked: true }) + await fails("files.upload", { tabID, ref: ref("Apply"), paths: [upload] }, /choose an input\[type=file\] ref/) + await fails("select", { tabID, ref: ref("Color"), values: ["missing-option"] }, /values are not visible labels/) + await fails("click", { tabID, ref: "e999999999" }, /tab's newest snapshot/) + await fails("screenshot", { tabID, ref: ref("Name"), fullPage: true }, /Remove the other argument/) + await call("hover", { tabID, ref: ref("Apply") }) + await call("click", { tabID, ref: ref("Apply") }) + assert.equal( + (await call("evaluate", { tabID, script: "document.querySelector('output').textContent" })).value, + "remote browser", + ) + assert.equal( + (await call("evaluate", { tabID: second.id, script: "document.querySelector('input').value" })).value, + "", + ) + assert.equal((await call("tabs.list", {})).focusedTabID, second.id) + const wrongTab = await rpc.execute( + { + sessionID: session.id, + code: `return await tools.browser.click({tabID:${JSON.stringify(second.id)},ref:${JSON.stringify(ref("Apply"))}})`, + }, + { location }, + ) + assert(wrongTab.error && wrongTab.output.includes("another tab")) + await call("check", { tabID, ref: ref("Remember"), checked: true }) + await call("select", { tabID, ref: ref("Color"), values: ["blue"] }) + await call("fill_form", { + tabID, + fields: [ + { type: "text", ref: ref("Name"), value: "batch" }, + { type: "check", ref: ref("Remember"), checked: false }, + ], + }) + await call("press", { tabID, key: "Tab" }) + await call("fill", { tabID, ref: ref("Query"), text: "typed" }) + await call("press", { tabID, key: "Space" }) + await call("press", { tabID, key: "Enter" }) + assert.equal( + (await call("evaluate", { tabID, script: "document.querySelector('output').textContent" })).value, + "submitted:typed ", + ) + await call("scroll", { tabID, deltaY: 100 }) + await call("wait", { tabID, condition: "text", text: "Scroll content" }) + await call("drag", { tabID, from: ref("Drag source"), to: ref("Drop target") }) + assert.equal( + (await call("evaluate", { tabID, script: "document.querySelector('output').textContent" })).value, + "element dropped", + ) + // The first field's change validation alerts while the second takes focus; once the dialog + // is dismissed the second field must still be untouched. + await fails( + "fill_form", + { + tabID, + fields: [ + { type: "text", ref: ref("Validated"), value: "first" }, + { type: "text", ref: ref("After"), value: "late mutation" }, + ], + }, + /Inspect it with browser\.dialog/, + ) + await call("dialog", { tabID, action: "dismiss" }) + // Date and time controls take their value directly; keyboard input cannot compose it. + await call("fill", { tabID, ref: ref("Date"), text: "2026-09-07" }) + await call("fill", { tabID, ref: ref("Time"), text: "14:45" }) + await fails("fill", { tabID, ref: ref("Date"), text: "next week" }, /Use its required format/) + assert.deepEqual( + ( + await call("evaluate", { + tabID, + script: + "[...document.querySelectorAll('[aria-label=Validated],[aria-label=After],[type=date],[type=time]')].map(input => input.value)", + }) + ).value, + ["first", "", "2026-09-07", "14:45"], + ) + const frames = await call("frames", { tabID }) + const child = frames.frames.find((frame) => frame.parentID && !frame.name) + assert(child) + assert.equal( + (await call("evaluate", { tabID, frameID: child.id, script: "document.querySelector('button').textContent" })) + .value, + "Frame button", + ) + const childSnapshot = await call("snapshot", { tabID, frameID: child.id }) + const childRef = childSnapshot.content + .split("\n") + .find((line) => line.includes('[button] "Frame button"')) + ?.match(/@e\d+/)?.[0] + assert(childRef, childSnapshot.content) + await call("click", { tabID, ref: Browser.Ref.make(childRef) }) + assert.equal( + (await call("evaluate", { tabID, frameID: child.id, script: "document.querySelector('button').textContent" })) + .value, + "Frame clicked", + ) + const frameButton = (content: string) => + Browser.Ref.make( + content + .split("\n") + .find((line) => line.includes('[button] "Frame button"')) + ?.match(/@e\d+/)?.[0] ?? assert.fail(content), + ) + // Input coordinates must follow the iframe's CSS transform, not only its offset. + const scaled = frames.frames.find((frame) => frame.name === "scaled") + assert(scaled) + await call("click", { tabID, ref: frameButton((await call("snapshot", { tabID, frameID: scaled.id })).content) }) + assert.equal( + (await call("evaluate", { tabID, frameID: scaled.id, script: "document.querySelector('button').textContent" })) + .value, + "Frame clicked", + ) + // The inner frame shares its cross-origin parent's renderer, so it has no CDP target of its own. + const inner = frames.frames.find((frame) => frame.name === "inner") + assert(inner?.parentID && frames.frames.find((frame) => frame.id === inner.parentID)?.parentID) + frameButton((await call("snapshot", { tabID, frameID: inner.id })).content) + const found = await call("find", { tabID, text: "Apply" }) + assert(found.content.includes("Apply")) + await fails("screenshot", { tabID }, /Screenshot needs a visible tab/) + await call("tabs.focus", { tabID }) + const screenshot = await call("screenshot", { tabID, fullPage: true, maxWidth: 1000 }) + const screenshotBytes = await rpc.read({ path: screenshot.files[0].path }, { location }) + assert( + Buffer.from(screenshotBytes, "base64") + .subarray(0, 8) + .equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])), + ) + assert(screenshot.files[0].path.startsWith(process.env.SMOKE_SERVER_FILES!)) + const logs = await call("console", { tabID, level: "debug" }) + assert(logs.messages.some((message) => message.text.includes("fixture error"))) + // Chromium's own report of the failed /missing fetch is a Log entry, not a console call. + const errors = await call("console", { tabID, level: "error" }) + assert( + errors.messages.some((message) => message.text.includes("404") && message.source?.url.endsWith("/missing")), + JSON.stringify(errors), + ) + const network = await call("network.list", { tabID, urlContains: "/api/test" }) + assert(network.requests.length) + const detail = await call("network.get", { tabID, id: network.requests[0].id, includeBody: true }) + assert.equal(detail.responseBody.state, "text") + await fails("network.get", { tabID, id: "unknown-request" }, /Do not reload or resend/) + const fileSnap = await call("snapshot", { tabID }) + const input = fileSnap.content + .split("\n") + .find((line) => line.includes('"Upload"')) + ?.match(/@e\d+/)?.[0] + assert(input, fileSnap.content) + await call("scroll", { tabID, deltaY: 500 }) + const elementImage = await call("screenshot", { + tabID, + ref: Browser.Ref.make(input), + format: "jpeg", + quality: 70, + maxWidth: 100, + }) + const imageBytes = Buffer.from(await rpc.read({ path: elementImage.files[0].path }, { location }), "base64") + assert.equal(imageBytes[0], 255) + assert.equal(imageBytes[1], 216) + assert(nativeImage.createFromBuffer(imageBytes).getSize().width <= 100) + await call("files.upload", { tabID, ref: Browser.Ref.make(input), paths: [upload] }) + assert.equal( + (await call("evaluate", { tabID, script: "document.querySelector('input[type=file]').files[0].name" })).value, + "upload.txt", + ) + assert.equal( + (await call("evaluate", { tabID, script: "document.querySelector('input[type=file]').files[0].text()" })).value, + "server upload bytes", + ) + // The page reads the temp file's basename as File.name, so spaces and non-ASCII must survive. + const named = await rpc.write({ text: "q1", name: "Quarter 1 日本語.csv" }, { location }) + await call("files.upload", { tabID, ref: Browser.Ref.make(input), paths: [named] }) + assert.equal( + (await call("evaluate", { tabID, script: "document.querySelector('input[type=file]').files[0].name" })).value, + "Quarter 1 日本語.csv", + ) + const drop = fileSnap.content + .split("\n") + .find((line) => line.includes('[button] "Drop target"')) + ?.match(/@e\d+/)?.[0] + assert(drop, fileSnap.content) + await call("files.drop", { tabID, ref: Browser.Ref.make(drop), paths: [upload] }) + await call("wait", { tabID, condition: "text", text: "server upload bytes" }) + await call("evaluate", { tabID, script: "document.querySelector('a[href=\"/download\"]').click()" }) + const downloads = await until(async () => { + const result = await call("files.list", { tabID }) + return result.files.find((file) => file.name === "report.txt" && file.state === "completed") + }) + const download = await call("files.get", { tabID, fileID: downloads.id }) + assert.equal( + Buffer.from(await rpc.read({ path: download.files[0].path }, { location }), "base64").toString(), + "desktop download bytes", + ) + await call("evaluate", { tabID, script: "setTimeout(()=>alert('hello dialog'),0); null" }) + await until(async () => (await call("dialog", { tabID, action: "get" })).dialog) + await fails("evaluate", { tabID, script: "1" }, /Inspect it with browser\.dialog/) + await call("dialog", { tabID, action: "dismiss" }) + await fails("dialog", { tabID, action: "accept" }, /no JavaScript dialog to handle/) + await call("evaluate", { tabID, script: "window.open('/frame'); null" }) + await until(async () => (await call("tabs.list", {})).tabs.length === 3) + await call("navigate", { tabID, url: `${fixture}/next` }) + await call("back", { tabID }) + await call("forward", { tabID }) + await call("trace.start", { tabID, durationMs: 30_000 }) + await fails("trace.start", { tabID }, /browser\.trace\.stop/) + await fails("trace.start", { tabID: second.id }, /do not stop or replace another tab's recording/) + await call("reload", { tabID }) + await call("evaluate", { + tabID, + script: "performance.mark('browser-suite-marker'); let n=0; for(let i=0;i<100000;i++) n+=i; n", + }) + const trace = await call("trace.stop", { tabID }) + const traceAnalysis = await call("trace.analyze", { tabID, fileID: trace.files[0].id }) + assert(traceAnalysis.metrics[0].value > 0) + await call("cpu.start", { tabID }) + await fails("cpu.start", { tabID }, /browser\.cpu\.stop/) + await call("evaluate", { tabID, script: "Array.from({length:100000},(_,i)=>Math.sqrt(i)).reduce((a,b)=>a+b,0)" }) + const cpu = await call("cpu.stop", { tabID }) + await call("cpu.analyze", { tabID, fileID: cpu.files[0].id }) + const heap = await call("heap.snapshot", { tabID }) + const heapID = heap.files[0].id + assert((await call("heap.summary", { tabID, fileID: heapID })).nodes > 0) + const query = await call("heap.query", { tabID, fileID: heapID, name: "Object", limit: 3 }) + assert(query.nodes.length) + await call("heap.object", { tabID, fileID: heapID, id: query.nodes[0].id }) + await fails( + "heap.object", + { tabID, fileID: heapID, id: Number.MAX_SAFE_INTEGER }, + /Object IDs cannot be reused across snapshots/, + ) + assert.equal((await call("heap.compare", { tabID, before: heapID, after: heapID })).classes.length, 0) + const audit = await call("lighthouse", { tabID }) + assert(audit.scores.some((score) => score.id === "accessibility")) + await call("stop", { tabID }) + await call("tabs.close", { tabID: second.id }) + assert.equal((await call("tabs.list", {})).tabs.length, 2) + assert.deepEqual( + Browser.Operations.map((operation) => operation.name).filter((name) => !visited.has(name)), + [], + ) + assert.deepEqual(ipcErrors, []) + await pane.register(win, "replacement", { + sessionID: session.id, + endpoint: { url: process.env.SMOKE_URL!, password: process.env.SMOKE_PASSWORD }, + }) + await until(async () => replaced.includes("suite")) + console.log( + `PASS ${visited.size} browser operations over physical authenticated HTTP, including file bytes in both directions`, + ) + } finally { + await pane.dispose() + await Effect.runPromise(Fiber.interrupt(events)) + await Effect.runPromise(unbind) + win.destroy() + web.closeAllConnections() + await new Promise((resolve) => web.close(() => resolve())) + app.quit() + } +} + +async function until(read: () => Promise) { + const deadline = Date.now() + 10_000 + while (true) { + const value = await read() + if (value) return value + if (Date.now() > deadline) throw new Error("Condition timed out") + await new Promise((resolve) => setTimeout(resolve, 50)) + } +} + +main().catch((error) => { + console.error(error) + app.exit(1) +}) diff --git a/packages/desktop/test/browser/plugin/index.ts b/packages/desktop/test/browser/plugin/index.ts new file mode 100644 index 00000000000..3ce0cc93fea --- /dev/null +++ b/packages/desktop/test/browser/plugin/index.ts @@ -0,0 +1,55 @@ +import { Plugin } from "@opencode-ai/plugin/effect" +import { Agent } from "@opencode-ai/schema/agent" +import { SessionMessage } from "@opencode-ai/schema/session-message" +import { Tool } from "@opencode-ai/schema/tool" +import { CodeModeTool } from "../../../../core/src/codemode/tool" +import { execute } from "../../../../core/src/tool/runtime" +import { Effect, Schema } from "effect" +import path from "node:path" +import { readFile, writeFile } from "node:fs/promises" +import { Smoke } from "../contract" + +export default Plugin.define({ + id: "browser.smoke", + effect: (ctx) => + Effect.gen(function* () { + const tools = new Map() + yield* ctx.tool + .transform((editor) => { + editor + .list() + .filter((tool) => tool.options?.namespace?.startsWith("browser")) + .forEach((tool) => tools.set(tool.id, tool)) + }) + .pipe(Effect.orDie) + yield* ctx.rpc + .register(Smoke, { + execute: (input) => + Effect.gen(function* () { + const result = yield* CodeModeTool.create({ tools }, (name, tool, input, context) => + execute(tool, input, context), + ).execute( + { code: input.code }, + { + sessionID: input.sessionID, + agent: Agent.ID.make("build"), + messageID: SessionMessage.ID.create(), + id: Tool.CallID.make(crypto.randomUUID()), + progress: () => Effect.void, + }, + ) + return Schema.decodeUnknownSync( + Schema.Struct({ output: Schema.String, error: Schema.optionalKey(Schema.Boolean) }), + )(result.output) + }).pipe(Effect.orDie), + write: ({ text, name }) => + Effect.promise(async () => { + const file = path.join(process.env.SMOKE_SERVER_FILES!, name ?? "upload.txt") + await writeFile(file, text) + return file + }), + read: ({ path }) => Effect.promise(async () => (await readFile(path)).toString("base64")), + }) + .pipe(Effect.orDie) + }), +}) diff --git a/packages/desktop/test/browser/server.ts b/packages/desktop/test/browser/server.ts new file mode 100644 index 00000000000..01cf0ffc077 --- /dev/null +++ b/packages/desktop/test/browser/server.ts @@ -0,0 +1,31 @@ +import path from "node:path" +import { Effect } from "effect" +import { HttpServer } from "effect/unstable/http" +import { ServerProcess } from "../../../server/src/process" + +const stopped = Promise.withResolvers() +process.on("message", (message) => { + if (message === "stop") stopped.resolve() +}) +await Effect.gen(function* () { + const server = yield* ServerProcess.start({ + hostname: "127.0.0.1", + port: 0, + password: process.env.SMOKE_PASSWORD, + app: { name: "browser-suite-test", version: process.env.SMOKE_VERSION ?? "test", channel: "test" }, + database: { path: ":memory:" }, + models: { fetch: false }, + fs: { filewatcher: false, fff: false }, + config: { + directory: process.env.OPENCODE_CONFIG_DIR!, + project: false, + content: JSON.stringify({ + plugins: ["-opencode.provider.*", path.join(import.meta.dir, "plugin")], + permissions: [{ action: "*", resource: "*", effect: "allow" }], + }), + }, + }) + process.send?.(HttpServer.formatAddress(server.address)) + yield* Effect.promise(() => stopped.promise) +}).pipe(Effect.scoped, Effect.runPromise) +process.disconnect?.() diff --git a/packages/desktop/test/browser/targets.ts b/packages/desktop/test/browser/targets.ts new file mode 100644 index 00000000000..b50f9ce3602 --- /dev/null +++ b/packages/desktop/test/browser/targets.ts @@ -0,0 +1,138 @@ +import assert from "node:assert/strict" +import type { BrowserWindow } from "electron" +import { Browser } from "@opencode-ai/plugin-browser/rpc" +import { Schema } from "effect" +import { createBrowserPage } from "../../src/main/browser-chromium" +import { createCornerImages } from "../../src/main/browser/corners" + +export async function verifyTargets(win: BrowserWindow, url: string) { + const children = win.contentView.children.length + const tabID = Browser.TabID.make(`tab_${crypto.randomUUID()}`) + const page = createBrowserPage(win, { + id: tabID, + partition: `target-test-${crypto.randomUUID()}`, + network: null, + publish() {}, + fail() { + throw new Error("Target test page failed") + }, + popup() { + throw new Error("Unexpected popup") + }, + }) + const execute = (action: Browser.Action, target?: Browser.Target) => + page.execute({ action, files: [], target }, new AbortController().signal) + const inspect = async (action: Browser.Action) => { + const result = await page.execute({ action, files: [], inspect: true }, new AbortController().signal) + assert.equal(result.files.length, 0) + return Schema.decodeUnknownSync(Browser.Target)(result.value) + } + try { + await page.ready + for (const color of [ + [255, 255, 255, 255], + [20, 24, 30, 255], + [40, 100, 160, 255], + ] as const) { + const images = createCornerImages(color, 10, 1) + const left = images[0].toBitmap() + const right = images[1].toBitmap() + assert.deepEqual([...left.subarray(9 * 10 * 4, 9 * 10 * 4 + 4)], [color[2], color[1], color[0], 255]) + assert.equal(left[9 * 4 + 3], 0) + assert.equal(right[3], 0) + assert.equal(right[(10 * 10 - 1) * 4 + 3], 255) + } + for (const bounds of [ + { x: 30, y: 40, width: 500, height: 300 }, + { x: 50, y: 60, width: 600, height: 400 }, + ]) { + page.layout(bounds, [255, 255, 255, 255], 10) + page.setVisible(true) + await page.contents.executeJavaScript( + "new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve(null))))", + ) + assert.deepEqual(page.view.getBounds(), bounds) + assert.deepEqual(await page.contents.executeJavaScript("({width:innerWidth,height:innerHeight})"), { + width: bounds.width, + height: bounds.height, + }) + assert.equal(win.contentView.children.length, children + 3) + page.setVisible(false) + assert(win.contentView.children.slice(children).every((view) => !view.getVisible())) + } + await execute({ type: "navigate", tabID, url }) + const frameURL = new URL("/frame", url.replace("127.0.0.1", "localhost")).href + await execute({ + type: "evaluate", + tabID, + script: `new Promise(resolve => { const frame = document.querySelector('iframe'); frame.onload = () => resolve(null); frame.src = ${JSON.stringify(frameURL)}; })`, + }) + const operation = Browser.Operations.find((op) => op.name === "frames") + assert(operation) + const listing = Schema.decodeUnknownSync(operation.output)((await execute({ type: "frames", tabID })).value) + const frame = listing.frames.find((frame) => frame.url === frameURL) + assert(frame) + const frameID = frame.id + const evaluation: Browser.Action = { type: "evaluate", tabID, frameID, script: "document.body.innerText" } + assert.deepEqual((await inspect(evaluation)).resources, [frameURL]) + const snapshot = await execute({ type: "snapshot", tabID, frameID }) + const content = Schema.decodeUnknownSync(Schema.Struct({ content: Schema.String }))(snapshot.value).content + const ref = content.match(/@e\d+/)?.[0] + assert(ref) + const click: Browser.Action = { type: "click", tabID, ref: Browser.Ref.make(ref) } + const selected = await inspect(click) + assert.deepEqual(selected.resources, [frameURL]) + await execute({ + type: "evaluate", + tabID, + script: `new Promise(resolve => { const frame = document.querySelector('iframe'); frame.onload = () => resolve(null); frame.src = ${JSON.stringify(url + "/frame")}; })`, + }) + await assert.rejects(execute(click, selected), /target changed|Frame is unavailable|stale/) + + await execute({ type: "evaluate", tabID, script: "document.querySelector('a[href=\"/download\"]').click()" }) + const fileID = await waitForFile() + const fileAction: Browser.Action = { type: "files.get", tabID, fileID } + const original = await inspect(fileAction) + assert(original.resources.includes(url + "/download")) + await execute({ type: "evaluate", tabID, script: "fetch('/api/test').then(response => response.json())" }) + const requests = Schema.decodeUnknownSync( + Schema.Struct({ requests: Schema.Array(Schema.Struct({ id: Schema.String, url: Schema.String })) }), + )((await execute({ type: "network.list", tabID })).value).requests + const request = requests.find((request) => request.url.includes("/api/test")) + assert(request) + assert.deepEqual(await inspect({ type: "network.get", tabID, id: request.id, includeBody: true }), { + resources: [request.url], + key: request.id, + }) + await execute({ type: "navigate", tabID, url: "about:blank" }) + assert.deepEqual(await inspect(fileAction), original) + assert.equal(Buffer.from((await execute(fileAction, original)).files[0].data).toString(), "desktop download bytes") + + await execute({ type: "navigate", tabID, url }) + await execute({ type: "trace.start", tabID }) + await execute({ type: "navigate", tabID, url: "about:blank" }) + const traceTarget = await inspect({ type: "trace.stop", tabID }) + assert(traceTarget.resources.includes(url + "/")) + assert(traceTarget.resources.includes("about:blank")) + const trace = await execute({ type: "trace.stop", tabID }, traceTarget) + const retained = await inspect({ type: "trace.analyze", tabID, fileID: trace.files[0].id }) + assert(retained.resources.includes(url + "/")) + } finally { + await page.dispose() + assert.equal(win.contentView.children.length, children) + } + + async function waitForFile() { + const deadline = Date.now() + 5_000 + while (Date.now() < deadline) { + const value = (await execute({ type: "files.list", tabID })).value + const files = Schema.decodeUnknownSync( + Schema.Struct({ files: Schema.Array(Schema.Struct({ id: Browser.FileID, state: Schema.String })) }), + )(value).files + const file = files.find((file) => file.state === "completed") + if (file) return file.id + await new Promise((resolve) => setTimeout(resolve, 25)) + } + throw new Error("Download did not complete") + } +} diff --git a/packages/session-ui/src/tools/tool-renderer.tsx b/packages/session-ui/src/tools/tool-renderer.tsx index a672949ec24..ed888e630f7 100644 --- a/packages/session-ui/src/tools/tool-renderer.tsx +++ b/packages/session-ui/src/tools/tool-renderer.tsx @@ -322,6 +322,11 @@ export function getToolInfo( title: i18n.t("ui.tool.grep"), subtitle: typeof input.pattern === "string" ? input.pattern : undefined, } + case "browser": + return { + icon: "window-cursor", + title: i18n.t("ui.tool.browser"), + } case "webfetch": return { icon: "window-cursor", diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index bd9ef77a0e1..7a40e1f1d86 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -176,6 +176,7 @@ const source = { "ui.tool.glob": "Glob", "ui.tool.grep": "Grep", "ui.tool.task": "Task", + "ui.tool.browser": "Browser", "ui.tool.webfetch": "Webfetch", "ui.tool.websearch": "Web Search", "ui.tool.websearch.provider": "{{provider}} Web Search", diff --git a/packages/ui/src/icons/icon/additional-icons.ts b/packages/ui/src/icons/icon/additional-icons.ts index 3e61e193f38..bdeafd22c5f 100644 --- a/packages/ui/src/icons/icon/additional-icons.ts +++ b/packages/ui/src/icons/icon/additional-icons.ts @@ -49,7 +49,7 @@ export const additionalIcons = { mcp: ``, glasses: ``, "magnifying-glass-menu": ``, - "window-cursor": ``, + "window-cursor": ``, task: ``, subagent: ``, stop: ``,