mirror of
https://github.com/github/awesome-copilot.git
synced 2026-06-15 04:15:03 +00:00
Add Chromium Control Canvas extension
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
# Chromium Control Canvas
|
||||
|
||||
A GitHub Copilot canvas that drives a real **headful Chromium** window via Playwright.
|
||||
The host app's built-in `browser` canvas is WebKit (WKWebView); this gives you actual
|
||||
Chromium, controllable both from the panel UI and by the agent.
|
||||
|
||||
The canvas panel is a control strip (URL bar, back/forward/reload, screenshot). A separate
|
||||
Chromium window does the real rendering, because you can't embed Chromium inside a WebKit
|
||||
iframe.
|
||||
|
||||
## Files
|
||||
|
||||
- `extension.mjs` — the extension: canvas declaration, Playwright launch, a loopback HTTP
|
||||
server for the panel, and the agent actions.
|
||||
- `index.html` — the control strip UI the panel renders.
|
||||
- `package.json` — declares the `playwright` dependency and `"type": "module"`.
|
||||
- `copilot-extension.json` — name/version metadata.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js 18 or newer** (Playwright 1.60 requires `node >=18`; older versions hit a
|
||||
cryptic engine error). The extension runs as a Node child process.
|
||||
- The app's **canvas / UI-extensions experiment enabled**. Without it, the extension
|
||||
loads but the canvas never appears in the panel. Enable it in the app's
|
||||
Settings → Experiments. (This may not be available to all accounts.)
|
||||
|
||||
## Install
|
||||
|
||||
Drop this folder at `~/.copilot/extensions/chromium-control-canvas/` (user scope) or in a repo's
|
||||
`.github/extensions/chromium-control-canvas/` (project scope), then install dependencies and the
|
||||
Chromium binary from inside that folder:
|
||||
|
||||
```sh
|
||||
cd ~/.copilot/extensions/chromium-control-canvas
|
||||
npm install # playwright is declared in package.json
|
||||
npx playwright install chromium # downloads the browser, a few hundred MB
|
||||
```
|
||||
|
||||
Reload extensions in the app, then open the `chromium-control-canvas` canvas.
|
||||
|
||||
Note: copying the extension files only places the source. It does **not** run the
|
||||
commands above or enable the experiment, so those steps are still required on first
|
||||
setup.
|
||||
|
||||
## Attach to your own Chrome
|
||||
|
||||
By default the canvas launches the bundled Chromium with a persistent profile. To drive
|
||||
a Chrome you already have running instead, start it with a debug port and pass `cdpUrl`
|
||||
when opening the canvas:
|
||||
|
||||
```sh
|
||||
google-chrome --remote-debugging-port=9222 # then open the canvas with cdpUrl: http://localhost:9222
|
||||
```
|
||||
|
||||
In this mode the extension connects over CDP and never launches or kills your browser;
|
||||
closing the canvas just disconnects.
|
||||
|
||||
## Agent actions
|
||||
|
||||
- `navigate { url }` — go to a URL or search query (blocklist-guarded).
|
||||
- `back` / `forward` / `reload` — history navigation.
|
||||
- `current_url` — current URL and page title.
|
||||
- `snapshot` — structured list of visible interactive elements, each with a stable ref.
|
||||
- `click { ref | selector }` — click an element by snapshot ref or CSS selector.
|
||||
- `type { ref | selector, text, submit? }` — fill an input; optionally press Enter.
|
||||
- `screenshot { fullPage? }` — save a PNG to `artifacts/` and return its path and size.
|
||||
|
||||
## Notes
|
||||
|
||||
- A persistent profile is stored in `profile/` so logins survive restarts. **Do not commit
|
||||
or share `profile/`** — it contains real session cookies.
|
||||
- Raw `evaluate` (arbitrary in-page JS) is intentionally omitted.
|
||||
- `navigate` is checked against a blocklist, and a request interceptor also blocks
|
||||
navigations to blocked hosts that happen via in-page redirects. The shipped
|
||||
`BLOCKLIST` entries are illustrative examples, not real coverage — edit the list in
|
||||
`extension.mjs` to fit your environment.
|
||||
- The loopback control server requires a per-launch token (templated into the panel),
|
||||
so other pages in your browser can't drive it.
|
||||
- Typed text (e.g. passwords) is redacted in `audit.log`, and password field values are
|
||||
excluded from snapshots.
|
||||
- Generated at runtime and not part of the source: `node_modules/`, `profile/`,
|
||||
`artifacts/`, `audit.log`.
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "chromium-control-canvas",
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
// Extension: chromium-control-canvas
|
||||
// Launches a real headful Chromium window via Playwright and uses the canvas
|
||||
// panel as a control strip (URL bar, back, forward, reload, screenshot,
|
||||
// snapshot/click/type).
|
||||
//
|
||||
// Why this shape: the host app renders canvases in a WebKit (WKWebView) webview,
|
||||
// not Chromium. To get a true Chromium engine we run the browser as a separate
|
||||
// headful window owned by this extension process and drive it with Playwright.
|
||||
// The canvas iframe is only the control surface; it POSTs commands to a
|
||||
// per-instance loopback HTTP server, which calls Playwright on the page. The
|
||||
// same handlers are exposed as agent-callable canvas actions.
|
||||
//
|
||||
// Patterns borrowed from AndreaGriffiths11/claw-relay (grep "[claw-relay]" below
|
||||
// for the exact spots):
|
||||
// - persistent profile so logins survive restarts
|
||||
// - optional connect-to-existing-Chrome over CDP instead of relaunching
|
||||
// - a real action set (snapshot/click/type/screenshot), not just navigation
|
||||
// - ref-based element resolution from a snapshot, or raw CSS selector
|
||||
// - a site blocklist guard and a JSONL audit log
|
||||
// Intentionally omitted: raw `evaluate` (arbitrary JS execution).
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { appendFile, mkdir, readFile, readlink, rm, writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
CanvasError,
|
||||
createCanvas,
|
||||
joinSession,
|
||||
} from "@github/copilot-sdk/extension";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const COPILOT_HOME = process.env.COPILOT_HOME || join(homedir(), ".copilot");
|
||||
const EXT_HOME = join(COPILOT_HOME, "extensions", "chromium-control-canvas");
|
||||
// [claw-relay] Persistent profile: cookies/logins survive canvas closes,
|
||||
// reloads, and sessions, so a hand-login in the window sticks.
|
||||
const PROFILE_DIR = join(EXT_HOME, "profile");
|
||||
const ARTIFACTS_DIR = join(EXT_HOME, "artifacts");
|
||||
const AUDIT_LOG = join(EXT_HOME, "audit.log");
|
||||
|
||||
// [claw-relay] Site blocklist guard.
|
||||
// Sites the agent may never drive. Edit this list to taste. Patterns match the
|
||||
// hostname; a leading "*." matches any subdomain. Navigation to a blocked host
|
||||
// is refused before Chromium is told to go there.
|
||||
const BLOCKLIST = [
|
||||
"*.bank.com",
|
||||
"*.chase.com",
|
||||
"*.paypal.com",
|
||||
"accounts.google.com",
|
||||
];
|
||||
|
||||
// One Chromium context + control-strip server per open canvas instance.
|
||||
const instances = new Map(); // instanceId -> { context, browser, page, server, url, mode }
|
||||
|
||||
// Per-launch secret, templated into index.html and required on every state route
|
||||
// so cross-origin pages in the user's normal browser can't POST to this server.
|
||||
const TOKEN = randomUUID();
|
||||
|
||||
let log = (..._args) => {};
|
||||
|
||||
function hostMatches(host, pattern) {
|
||||
if (pattern.startsWith("*.")) {
|
||||
const base = pattern.slice(2);
|
||||
return host === base || host.endsWith(`.${base}`);
|
||||
}
|
||||
return host === pattern;
|
||||
}
|
||||
|
||||
function blockedReason(targetUrl) {
|
||||
let host;
|
||||
try {
|
||||
host = new URL(targetUrl).hostname;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
const hit = BLOCKLIST.find((p) => hostMatches(host, p));
|
||||
return hit ? `${host} is blocked by the Chromium Control Canvas blocklist (${hit})` : null;
|
||||
}
|
||||
|
||||
function normalizeUrl(input) {
|
||||
const raw = String(input ?? "").trim();
|
||||
if (!raw) return "about:blank";
|
||||
if (raw === "about:blank") return raw;
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) return raw;
|
||||
// Local dev servers have no dot; send them to http, not search.
|
||||
if (/^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?([/?#]|$)/i.test(raw)) return `http://${raw}`;
|
||||
if (!/\s/.test(raw) && /\.[a-z]{2,}/i.test(raw)) return `https://${raw}`;
|
||||
return `https://www.google.com/search?q=${encodeURIComponent(raw)}`;
|
||||
}
|
||||
|
||||
// [claw-relay] JSONL audit log: one line per action (panel or agent) for a
|
||||
// reviewable trail of what drove the browser.
|
||||
async function audit(entry) {
|
||||
const line = JSON.stringify({ at: new Date().toISOString(), ...entry });
|
||||
await mkdir(EXT_HOME, { recursive: true }).catch(() => {});
|
||||
await appendFile(AUDIT_LOG, `${line}\n`).catch(() => {});
|
||||
}
|
||||
|
||||
// Keep secrets out of the audit log: redact free-text fields (e.g. a typed
|
||||
// password) while preserving the rest of the entry for the trail.
|
||||
function redactInput(input) {
|
||||
if (input && typeof input === "object" && "text" in input) {
|
||||
return { ...input, text: "[redacted]" };
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
async function pageState(page) {
|
||||
let url = "about:blank";
|
||||
let title = "";
|
||||
try {
|
||||
url = page.url();
|
||||
} catch (_) {}
|
||||
try {
|
||||
title = await page.title();
|
||||
} catch (_) {}
|
||||
return { url, title };
|
||||
}
|
||||
|
||||
function getInstance(instanceId) {
|
||||
const entry = instances.get(instanceId);
|
||||
if (!entry) {
|
||||
throw new CanvasError(
|
||||
"no_instance",
|
||||
"No open Chromium canvas for this instance. Open the canvas first.",
|
||||
);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
// Return a usable page for the instance, recovering if the user closed the tab
|
||||
// or window. Reuses an open tab, opens a new one in the live context, or (for
|
||||
// persistent mode) relaunches the browser in place.
|
||||
async function livePage(entry) {
|
||||
if (entry.page && !entry.page.isClosed()) return entry.page;
|
||||
try {
|
||||
const open = entry.context?.pages().find((p) => !p.isClosed());
|
||||
entry.page = open || (await entry.context.newPage());
|
||||
return entry.page;
|
||||
} catch (_) {
|
||||
// Context/browser is gone below.
|
||||
}
|
||||
if (entry.mode === "persistent") {
|
||||
// Memoize the relaunch so two concurrent panel requests after a crash
|
||||
// don't race launchPersistentContext into the lock error we handle below.
|
||||
if (!entry.relaunching) {
|
||||
entry.relaunching = (async () => {
|
||||
await mkdir(PROFILE_DIR, { recursive: true });
|
||||
await clearStaleLockIfDead();
|
||||
const context = await chromium.launchPersistentContext(PROFILE_DIR, PERSISTENT_OPTS);
|
||||
await installGuards(context);
|
||||
entry.context = context;
|
||||
entry.page = context.pages()[0] || (await context.newPage());
|
||||
return entry.page;
|
||||
})();
|
||||
entry.relaunching.then(
|
||||
() => {
|
||||
entry.relaunching = null;
|
||||
},
|
||||
() => {
|
||||
entry.relaunching = null;
|
||||
},
|
||||
);
|
||||
}
|
||||
return entry.relaunching;
|
||||
}
|
||||
throw new CanvasError(
|
||||
"disconnected",
|
||||
"The Chrome connection was lost. Close this panel and reopen the canvas.",
|
||||
);
|
||||
}
|
||||
|
||||
async function navigate(page, rawUrl) {
|
||||
const url = normalizeUrl(rawUrl);
|
||||
const reason = blockedReason(url);
|
||||
if (reason) throw new CanvasError("site_blocked", reason);
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
|
||||
return pageState(page);
|
||||
}
|
||||
|
||||
// [claw-relay] ref-based element resolution. Resolve an element target to a
|
||||
// Playwright selector. `selector` wins over `ref` when both are given. `ref`
|
||||
// values come from a prior snapshot and are stamped onto the DOM as
|
||||
// data-cc-ref attributes.
|
||||
function resolveTarget(input) {
|
||||
if (input?.selector) return input.selector;
|
||||
if (input?.ref) return `[data-cc-ref="${String(input.ref).replace(/"/g, "")}"]`;
|
||||
throw new CanvasError("bad_target", "Provide a `ref` (from snapshot) or a `selector`.");
|
||||
}
|
||||
|
||||
// [claw-relay] Accessibility-style page snapshot: enumerate visible interactive
|
||||
// elements and stamp each with a stable ref (e1, e2, ...) the agent can target.
|
||||
const SNAPSHOT_FN = () => {
|
||||
const sel = [
|
||||
"a[href]",
|
||||
"button",
|
||||
"input",
|
||||
"textarea",
|
||||
"select",
|
||||
"[role=button]",
|
||||
"[role=link]",
|
||||
"[role=textbox]",
|
||||
"[role=checkbox]",
|
||||
"[onclick]",
|
||||
'[contenteditable=""]',
|
||||
'[contenteditable="true"]',
|
||||
].join(",");
|
||||
const out = [];
|
||||
let i = 0;
|
||||
// Clear refs from a prior snapshot so a renumbered ref can't match a stale
|
||||
// element that scrolled out of view.
|
||||
for (const stamped of document.querySelectorAll("[data-cc-ref]")) {
|
||||
stamped.removeAttribute("data-cc-ref");
|
||||
}
|
||||
for (const el of document.querySelectorAll(sel)) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const style = getComputedStyle(el);
|
||||
const visible =
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
style.visibility !== "hidden" &&
|
||||
style.display !== "none";
|
||||
if (!visible) continue;
|
||||
i += 1;
|
||||
if (i > 200) break;
|
||||
const ref = `e${i}`;
|
||||
el.setAttribute("data-cc-ref", ref);
|
||||
const name = (
|
||||
el.getAttribute("aria-label") ||
|
||||
el.getAttribute("placeholder") ||
|
||||
(el.type === "password" ? "" : el.value) ||
|
||||
el.innerText ||
|
||||
el.getAttribute("title") ||
|
||||
""
|
||||
)
|
||||
.trim()
|
||||
.replace(/\s+/g, " ")
|
||||
.slice(0, 80);
|
||||
out.push({
|
||||
ref,
|
||||
tag: el.tagName.toLowerCase(),
|
||||
type: el.getAttribute("type") || el.getAttribute("role") || "",
|
||||
name,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
async function snapshot(page) {
|
||||
const elements = await page.evaluate(SNAPSHOT_FN);
|
||||
return { ...(await pageState(page)), elements };
|
||||
}
|
||||
|
||||
async function clickTarget(page, input) {
|
||||
const selector = resolveTarget(input);
|
||||
await page.click(selector, { timeout: 15000 });
|
||||
return pageState(page);
|
||||
}
|
||||
|
||||
async function typeTarget(page, input) {
|
||||
const text = String(input?.text ?? "");
|
||||
const selector = resolveTarget(input);
|
||||
const locator = page.locator(selector).first();
|
||||
await locator.fill(text, { timeout: 15000 });
|
||||
if (input?.submit) await locator.press("Enter").catch(() => {});
|
||||
return pageState(page);
|
||||
}
|
||||
|
||||
async function screenshot(page, opts = {}) {
|
||||
await mkdir(ARTIFACTS_DIR, { recursive: true });
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const name = `shot-${stamp}.png`;
|
||||
const buffer = await page.screenshot({ fullPage: !!opts.fullPage });
|
||||
await writeFile(join(ARTIFACTS_DIR, name), buffer);
|
||||
return { name, path: join(ARTIFACTS_DIR, name), size: buffer.length };
|
||||
}
|
||||
|
||||
function sendJson(res, status, body) {
|
||||
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function publicErrorMessage(err, fallback) {
|
||||
return err instanceof CanvasError ? err.message : fallback;
|
||||
}
|
||||
|
||||
async function readBody(req) {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) chunks.push(chunk);
|
||||
return Buffer.concat(chunks).toString("utf-8");
|
||||
}
|
||||
|
||||
function makeHandler(entry) {
|
||||
return async function handleRequest(req, res) {
|
||||
const { pathname } = new URL(req.url, "http://127.0.0.1");
|
||||
|
||||
if (req.method === "GET" && (pathname === "/" || pathname === "/index.html")) {
|
||||
const html = (await readFile(join(__dirname, "index.html"), "utf-8")).replaceAll("__TOKEN__", TOKEN);
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(html);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && pathname.startsWith("/shot/")) {
|
||||
const name = decodeURIComponent(pathname.slice("/shot/".length));
|
||||
if (!/^shot-[\w-]+\.png$/.test(name)) {
|
||||
sendJson(res, 400, { error: "invalid name" });
|
||||
return;
|
||||
}
|
||||
const bytes = await readFile(join(ARTIFACTS_DIR, name));
|
||||
res.writeHead(200, { "Content-Type": "image/png", "Cache-Control": "no-store" });
|
||||
res.end(bytes);
|
||||
return;
|
||||
}
|
||||
|
||||
// Every state route below requires the per-launch token templated into
|
||||
// index.html. A cross-origin page can't read it, and the custom header
|
||||
// also forces a CORS preflight this server never answers with allow.
|
||||
if (req.headers["x-canvas-token"] !== TOKEN) {
|
||||
sendJson(res, 403, { error: "forbidden" });
|
||||
return;
|
||||
}
|
||||
|
||||
const page = await livePage(entry);
|
||||
|
||||
if (req.method === "GET" && pathname === "/state") {
|
||||
sendJson(res, 200, { ...(await pageState(page)), mode: entry.mode });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && pathname === "/navigate") {
|
||||
const body = JSON.parse((await readBody(req)) || "{}");
|
||||
try {
|
||||
const state = await navigate(page, body?.url);
|
||||
await audit({ source: "panel", instanceId: entry.instanceId, action: "navigate", input: body?.url, url: state.url, ok: true });
|
||||
sendJson(res, 200, state);
|
||||
} catch (err) {
|
||||
sendJson(res, 200, { ...(await pageState(page)), error: publicErrorMessage(err, "Navigation failed.") });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const simple = { "/back": "goBack", "/forward": "goForward", "/reload": "reload" };
|
||||
if (req.method === "POST" && simple[pathname]) {
|
||||
await page[simple[pathname]]({ waitUntil: "domcontentloaded" }).catch(() => {});
|
||||
sendJson(res, 200, await pageState(page));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && pathname === "/screenshot") {
|
||||
const shot = await screenshot(page);
|
||||
sendJson(res, 200, shot);
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(res, 404, { error: "not found" });
|
||||
};
|
||||
}
|
||||
|
||||
async function startServer(entry) {
|
||||
const handler = makeHandler(entry);
|
||||
const server = createServer((req, res) => {
|
||||
handler(req, res).catch((err) => {
|
||||
sendJson(res, 500, { error: publicErrorMessage(err, "Request failed.") });
|
||||
});
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
const port = typeof address === "object" && address ? address.port : 0;
|
||||
return { server, url: `http://127.0.0.1:${port}/` };
|
||||
}
|
||||
|
||||
const PERSISTENT_OPTS = { headless: false, viewport: null };
|
||||
|
||||
// [claw-relay] Enforce the blocklist on real navigations, not just explicit
|
||||
// navigate() calls, so in-page redirects are caught too. Every request
|
||||
// round-trips through Node, which is fine at agent/human browsing pace.
|
||||
async function installGuards(context) {
|
||||
await context.route("**/*", (route) => {
|
||||
const req = route.request();
|
||||
if (req.isNavigationRequest() && blockedReason(req.url())) {
|
||||
return route.abort("blockedbyclient");
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
}
|
||||
|
||||
// Chromium writes a SingletonLock symlink (target: "<host>-<pid>") into the
|
||||
// profile while a window owns it. A reload or killed process can leave that
|
||||
// lock behind even though no Chromium is running. If the referenced PID is
|
||||
// dead, the lock is stale and safe to clear; if it's alive, a real window
|
||||
// owns the profile and we must not touch it.
|
||||
async function clearStaleLockIfDead() {
|
||||
const lockPath = join(PROFILE_DIR, "SingletonLock");
|
||||
let target;
|
||||
try {
|
||||
target = await readlink(lockPath);
|
||||
} catch (_) {
|
||||
return false; // no lock present
|
||||
}
|
||||
const pid = Number(target.split("-").pop());
|
||||
if (Number.isFinite(pid) && pid > 0) {
|
||||
try {
|
||||
process.kill(pid, 0); // throws if the process is gone
|
||||
return false; // alive -> a real window owns the profile
|
||||
} catch (err) {
|
||||
if (err?.code === "EPERM") return false; // exists, not ours -> leave it
|
||||
}
|
||||
}
|
||||
for (const f of ["SingletonLock", "SingletonSocket", "SingletonCookie"]) {
|
||||
await rm(join(PROFILE_DIR, f), { force: true }).catch(() => {});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function openInstance(instanceId, input) {
|
||||
const cdpUrl = input?.cdpUrl;
|
||||
let context;
|
||||
let browser = null;
|
||||
let mode;
|
||||
if (cdpUrl) {
|
||||
// [claw-relay] Connect to an already-running Chrome over CDP (started with
|
||||
// --remote-debugging-port) instead of launching our own window.
|
||||
browser = await chromium.connectOverCDP(cdpUrl);
|
||||
context = browser.contexts()[0] || (await browser.newContext());
|
||||
mode = "cdp";
|
||||
} else {
|
||||
// One shared persistent profile can only back one live Chromium window.
|
||||
// Refuse a second persistent instance with a readable message instead of
|
||||
// a cryptic Playwright lock dump.
|
||||
for (const e of instances.values()) {
|
||||
if (e.mode === "persistent") {
|
||||
throw new CanvasError(
|
||||
"profile_busy",
|
||||
"A Chromium canvas is already open. Close it before opening another — they share one logged-in profile.",
|
||||
);
|
||||
}
|
||||
}
|
||||
// Persistent profile: same Chromium user-data-dir every time, so logins
|
||||
// you complete by hand in the window survive across sessions.
|
||||
await mkdir(PROFILE_DIR, { recursive: true });
|
||||
try {
|
||||
context = await chromium.launchPersistentContext(PROFILE_DIR, PERSISTENT_OPTS);
|
||||
} catch (err) {
|
||||
if (!/existing browser session/i.test(String(err?.message))) throw err;
|
||||
const cleared = await clearStaleLockIfDead();
|
||||
if (!cleared) {
|
||||
throw new CanvasError(
|
||||
"profile_busy",
|
||||
"The Chromium profile is in use by another live window. Close that Chromium window first.",
|
||||
);
|
||||
}
|
||||
context = await chromium.launchPersistentContext(PROFILE_DIR, PERSISTENT_OPTS);
|
||||
}
|
||||
mode = "persistent";
|
||||
}
|
||||
await installGuards(context);
|
||||
const page = context.pages()[0] || (await context.newPage());
|
||||
const entry = { instanceId, context, browser, page, mode };
|
||||
await navigate(page, input?.url || "about:blank").catch(() => {});
|
||||
const { server, url } = await startServer(entry);
|
||||
entry.server = server;
|
||||
entry.url = url;
|
||||
instances.set(instanceId, entry);
|
||||
await audit({ instanceId, action: "open", mode, url: input?.url || "about:blank" });
|
||||
return entry;
|
||||
}
|
||||
|
||||
async function closeInstance(instanceId) {
|
||||
const entry = instances.get(instanceId);
|
||||
if (!entry) return;
|
||||
instances.delete(instanceId);
|
||||
await new Promise((resolve) => {
|
||||
entry.server.closeAllConnections?.();
|
||||
entry.server.close(() => resolve());
|
||||
}).catch(() => {});
|
||||
if (entry.mode === "cdp") {
|
||||
// Don't kill the user's own Chrome; just disconnect.
|
||||
await entry.browser?.close().catch(() => {});
|
||||
} else {
|
||||
await entry.context.close().catch(() => {});
|
||||
}
|
||||
await audit({ instanceId, action: "close", mode: entry.mode });
|
||||
}
|
||||
|
||||
// Wrap a canvas action handler so every agent-driven call is audited.
|
||||
function action(name, description, run, inputSchema) {
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
...(inputSchema ? { inputSchema } : {}),
|
||||
handler: async (ctx) => {
|
||||
const entry = getInstance(ctx.instanceId);
|
||||
try {
|
||||
const page = await livePage(entry);
|
||||
const result = await run(page, ctx.input, entry);
|
||||
const state = result?.url ? result : await pageState(page);
|
||||
await audit({ source: "agent", instanceId: ctx.instanceId, action: name, input: redactInput(ctx.input), url: state.url, ok: true });
|
||||
return result;
|
||||
} catch (err) {
|
||||
await audit({ source: "agent", instanceId: ctx.instanceId, action: name, input: redactInput(ctx.input), ok: false, error: publicErrorMessage(err, "Action failed.") });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const session = await joinSession({
|
||||
canvases: [
|
||||
createCanvas({
|
||||
id: "chromium-control-canvas",
|
||||
displayName: "Chromium Control Canvas",
|
||||
description:
|
||||
"Control canvas for a real headful Chromium window driven by Playwright.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
url: { type: "string", description: "Optional URL to open on launch." },
|
||||
cdpUrl: {
|
||||
type: "string",
|
||||
description:
|
||||
"Optional CDP endpoint (e.g. http://localhost:9222) to attach to an existing Chrome instead of launching one.",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: [
|
||||
action(
|
||||
"navigate",
|
||||
"Navigate to a URL or search query (blocklist enforced).",
|
||||
(page, input) => navigate(page, input?.url),
|
||||
{
|
||||
type: "object",
|
||||
properties: { url: { type: "string", description: "URL (https assumed) or search query." } },
|
||||
required: ["url"],
|
||||
},
|
||||
),
|
||||
action("back", "Go back in history.", async (page) => {
|
||||
await page.goBack({ waitUntil: "domcontentloaded" }).catch(() => {});
|
||||
return pageState(page);
|
||||
}),
|
||||
action("forward", "Go forward in history.", async (page) => {
|
||||
await page.goForward({ waitUntil: "domcontentloaded" }).catch(() => {});
|
||||
return pageState(page);
|
||||
}),
|
||||
action("reload", "Reload the current page.", async (page) => {
|
||||
await page.reload({ waitUntil: "domcontentloaded" }).catch(() => {});
|
||||
return pageState(page);
|
||||
}),
|
||||
action("current_url", "Get the current URL and page title.", (page) => pageState(page)),
|
||||
action(
|
||||
"snapshot",
|
||||
"List visible interactive elements with stable refs (e1, e2, ...) for click/type.",
|
||||
(page) => snapshot(page),
|
||||
),
|
||||
action(
|
||||
"click",
|
||||
"Click an element by `ref` (from snapshot) or CSS `selector`.",
|
||||
(page, input) => clickTarget(page, input),
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
ref: { type: "string", description: "Element ref from a snapshot, e.g. e3." },
|
||||
selector: { type: "string", description: "CSS selector (takes priority over ref)." },
|
||||
},
|
||||
},
|
||||
),
|
||||
action(
|
||||
"type",
|
||||
"Fill text into an input by `ref` or `selector`; set submit to press Enter.",
|
||||
(page, input) => typeTarget(page, input),
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
text: { type: "string", description: "Text to enter." },
|
||||
ref: { type: "string", description: "Element ref from a snapshot." },
|
||||
selector: { type: "string", description: "CSS selector (takes priority over ref)." },
|
||||
submit: { type: "boolean", description: "Press Enter after filling." },
|
||||
},
|
||||
required: ["text"],
|
||||
},
|
||||
),
|
||||
action(
|
||||
"screenshot",
|
||||
"Capture a PNG of the page, saved under the extension artifacts dir.",
|
||||
(page, input) => screenshot(page, { fullPage: input?.fullPage }),
|
||||
{
|
||||
type: "object",
|
||||
properties: { fullPage: { type: "boolean", description: "Capture the full scrollable page." } },
|
||||
},
|
||||
),
|
||||
],
|
||||
open: async (ctx) => {
|
||||
let entry = instances.get(ctx.instanceId);
|
||||
if (!entry) {
|
||||
entry = await openInstance(ctx.instanceId, ctx.input || {});
|
||||
log(`Launched Chromium (${entry.mode}) for instance ${ctx.instanceId}`, {
|
||||
level: "info",
|
||||
ephemeral: true,
|
||||
});
|
||||
}
|
||||
return { title: "Chromium Control Canvas", url: entry.url };
|
||||
},
|
||||
onClose: async (ctx) => {
|
||||
await closeInstance(ctx.instanceId);
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
log = (message, opts) => session.log(message, opts);
|
||||
|
||||
// Close Chromium contexts on shutdown so reloading the extension doesn't orphan
|
||||
// the window and leave a stale profile lock behind. The runtime sends SIGTERM
|
||||
// (then SIGKILL after ~5s), so keep teardown fast.
|
||||
let shuttingDown = false;
|
||||
async function shutdown() {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
await Promise.allSettled(
|
||||
[...instances.keys()].map((id) => closeInstance(id)),
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
@@ -0,0 +1,396 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Chromium</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark light;
|
||||
--bg: #0b0f14;
|
||||
--panel: #11161d;
|
||||
--panel-2: #171d25;
|
||||
--panel-3: #1d242e;
|
||||
--border: #2a313c;
|
||||
--border-strong: #3a4350;
|
||||
--text: #e6edf3;
|
||||
--muted: #8b949e;
|
||||
--faint: #6e7681;
|
||||
--accent: #2f81f7;
|
||||
--green: #238636;
|
||||
--green-hi: #2ea043;
|
||||
--danger: #f85149;
|
||||
--radius: 12px;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||
font-size: 13px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Indeterminate progress bar, shown while a request is in flight. */
|
||||
.progress {
|
||||
height: 2px;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
body.busy .progress::after {
|
||||
content: "";
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 40%;
|
||||
background: var(--accent);
|
||||
animation: slide 1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes slide {
|
||||
0% { margin-left: -40%; }
|
||||
100% { margin-left: 100%; }
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
padding: 8px 8px 6px;
|
||||
border-bottom: 1px solid rgba(48, 54, 61, .65);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(47, 129, 247, .12), transparent 38%),
|
||||
var(--bg);
|
||||
}
|
||||
.control-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 13px;
|
||||
background: rgba(17, 22, 29, .92);
|
||||
box-shadow: 0 8px 22px rgba(0, 0, 0, .18);
|
||||
}
|
||||
.chrome-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.nav {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--panel-2);
|
||||
}
|
||||
.icon-btn {
|
||||
width: 26px; height: 26px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font: inherit; font-size: 14px; line-height: 1;
|
||||
cursor: pointer; border-radius: 8px;
|
||||
border: 1px solid transparent; background: transparent; color: var(--muted);
|
||||
transition: background .12s ease, border-color .12s ease;
|
||||
}
|
||||
.icon-btn:hover { background: var(--panel-3); border-color: var(--border); }
|
||||
.icon-btn:active { background: var(--border); }
|
||||
|
||||
.address {
|
||||
flex: 1; min-width: 0;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 0 10px;
|
||||
height: 30px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
transition: border-color .12s ease, box-shadow .12s ease;
|
||||
}
|
||||
.address:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(47, 129, 247, .25);
|
||||
}
|
||||
.scheme { font-size: 12px; flex: 0 0 auto; color: var(--muted); }
|
||||
.scheme.secure { color: var(--green-hi); }
|
||||
input#url {
|
||||
flex: 1; min-width: 0;
|
||||
font: inherit; border: 0; outline: none;
|
||||
background: transparent; color: var(--text);
|
||||
}
|
||||
input#url::placeholder { color: var(--faint); }
|
||||
|
||||
.btn {
|
||||
font: inherit; cursor: pointer; height: 30px; padding: 0 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border); background: var(--panel-2); color: var(--text);
|
||||
transition: background .12s ease, transform .06s ease, border-color .12s ease;
|
||||
}
|
||||
.btn:hover { background: var(--border); }
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn.primary { background: var(--green); border-color: var(--green-hi); font-weight: 600; }
|
||||
.btn.primary:hover { background: var(--green-hi); }
|
||||
.btn.subtle {
|
||||
color: var(--muted);
|
||||
background: var(--panel-2);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.btn.subtle:hover {
|
||||
color: var(--text);
|
||||
background: var(--panel-2);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1; min-height: 0;
|
||||
display: flex; flex-direction: column;
|
||||
gap: 10px; padding: 10px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 0 4px 1px;
|
||||
}
|
||||
.badge {
|
||||
flex: 0 0 auto;
|
||||
font-size: 11px; font-weight: 600; letter-spacing: .02em;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 7px; border-radius: 999px;
|
||||
border: 1px solid var(--border-strong); color: var(--muted);
|
||||
background: var(--panel);
|
||||
}
|
||||
.badge.persistent { color: var(--muted); border-color: var(--border-strong); }
|
||||
.badge.cdp { color: var(--accent); border-color: rgba(47,129,247,.4); }
|
||||
.page-title {
|
||||
flex: 1; min-width: 0;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
color: var(--muted);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 12px; color: var(--muted); min-height: 16px;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.status.error { color: var(--danger); }
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
background: var(--panel); overflow: hidden;
|
||||
display: flex; flex-direction: column;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .03);
|
||||
}
|
||||
.card-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 8px; padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 12px; color: var(--muted);
|
||||
}
|
||||
.card-head a { color: var(--accent); text-decoration: none; }
|
||||
.card-head a:hover { text-decoration: underline; }
|
||||
.card-body img { width: 100%; display: block; }
|
||||
.empty {
|
||||
font-size: 12px; color: var(--faint);
|
||||
padding: 22px 16px; text-align: center;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: auto;
|
||||
font-size: 12px; color: var(--muted); line-height: 1.55;
|
||||
border-top: 1px solid var(--border); padding-top: 10px;
|
||||
}
|
||||
code {
|
||||
background: var(--panel-2); padding: 1px 5px; border-radius: 5px;
|
||||
font-size: 11.5px; color: var(--text);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 12px; height: 12px; flex: 0 0 auto;
|
||||
border: 2px solid var(--border-strong); border-top-color: var(--accent);
|
||||
border-radius: 50%; animation: spin .7s linear infinite;
|
||||
display: none;
|
||||
}
|
||||
body.busy .spinner { display: inline-block; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="progress"></div>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="control-card">
|
||||
<div class="chrome-row">
|
||||
<div class="nav" aria-label="Browser controls">
|
||||
<button class="icon-btn" id="back" aria-label="Back" title="Back">←</button>
|
||||
<button class="icon-btn" id="forward" aria-label="Forward" title="Forward">→</button>
|
||||
<button class="icon-btn" id="reload" aria-label="Reload" title="Reload">⟳</button>
|
||||
</div>
|
||||
<div class="address">
|
||||
<span class="scheme" id="scheme" title="Connection">🌐</span>
|
||||
<input id="url" type="text" placeholder="Search or enter address" autocomplete="off" spellcheck="false" />
|
||||
</div>
|
||||
<button class="btn primary" id="go">Go</button>
|
||||
<button class="btn subtle" id="shot">Capture</button>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span class="badge" id="badge" title="Connection mode">…</span>
|
||||
<span class="page-title" id="title">about:blank</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<div class="status" id="status"><span class="spinner"></span><span id="status-text"></span></div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span id="shot-name">Screenshot</span>
|
||||
<a id="shot-open" href="#" target="_blank" rel="noopener" style="display:none">Open ↗</a>
|
||||
</div>
|
||||
<div class="card-body" id="preview">
|
||||
<div class="empty">
|
||||
This panel drives a separate <strong>Chromium window</strong>. Navigate above and
|
||||
a snapshot of the page shows up here. Log in by hand in that window; the profile
|
||||
persists. The agent can also <code>snapshot</code>, <code>click</code>, and
|
||||
<code>type</code>.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hint">
|
||||
The preview updates after you navigate from this panel. Agent <code>click</code> and
|
||||
<code>type</code> actions go straight to the page and bypass this panel, so the
|
||||
preview can lag during agent work — focus the panel to resync.
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const urlEl = document.getElementById("url");
|
||||
const titleEl = document.getElementById("title");
|
||||
const statusEl = document.getElementById("status");
|
||||
const statusTextEl = document.getElementById("status-text");
|
||||
const schemeEl = document.getElementById("scheme");
|
||||
const badgeEl = document.getElementById("badge");
|
||||
const previewEl = document.getElementById("preview");
|
||||
const shotNameEl = document.getElementById("shot-name");
|
||||
const shotOpenEl = document.getElementById("shot-open");
|
||||
|
||||
const TOKEN = "__TOKEN__";
|
||||
const AUTH = { "x-canvas-token": TOKEN };
|
||||
|
||||
let inFlight = 0;
|
||||
let currentUrl = "about:blank";
|
||||
function busy(on) {
|
||||
inFlight = Math.max(0, inFlight + (on ? 1 : -1));
|
||||
document.body.classList.toggle("busy", inFlight > 0);
|
||||
}
|
||||
|
||||
function setStatus(msg, isError) {
|
||||
statusTextEl.textContent = msg || "";
|
||||
statusEl.classList.toggle("error", !!isError);
|
||||
}
|
||||
|
||||
function setScheme(url) {
|
||||
const secure = /^https:\/\//i.test(url || "");
|
||||
schemeEl.textContent = secure ? "🔒" : "🌐";
|
||||
schemeEl.classList.toggle("secure", secure);
|
||||
}
|
||||
|
||||
function setBadge(mode) {
|
||||
if (!mode) return;
|
||||
badgeEl.textContent = mode === "cdp" ? "attached" : "persistent";
|
||||
badgeEl.classList.remove("persistent", "cdp");
|
||||
badgeEl.classList.add(mode === "cdp" ? "cdp" : "persistent");
|
||||
badgeEl.title = mode === "cdp"
|
||||
? "Attached to an existing Chrome over CDP; closing the panel only disconnects."
|
||||
: "Persistent profile — logins you complete by hand in the window survive restarts.";
|
||||
}
|
||||
|
||||
function render(state) {
|
||||
if (!state) return;
|
||||
if (state.mode) setBadge(state.mode);
|
||||
if (state.url && document.activeElement !== urlEl) {
|
||||
urlEl.value = state.url === "about:blank" ? "" : state.url;
|
||||
}
|
||||
setScheme(state.url);
|
||||
if (state.url) currentUrl = state.url;
|
||||
titleEl.textContent = state.title || state.url || "about:blank";
|
||||
titleEl.title = state.url || "";
|
||||
setStatus(state.error ? "Error: " + state.error : "", !!state.error);
|
||||
}
|
||||
|
||||
async function capturePreview(announce) {
|
||||
try {
|
||||
const res = await fetch("/screenshot", { method: "POST", headers: { ...AUTH } });
|
||||
const data = await res.json();
|
||||
if (data.error) { if (announce) setStatus("Error: " + data.error, true); return; }
|
||||
const src = "/shot/" + encodeURIComponent(data.name) + "?t=" + Date.now();
|
||||
previewEl.innerHTML = "";
|
||||
const img = document.createElement("img");
|
||||
img.src = src;
|
||||
img.alt = data.name;
|
||||
previewEl.appendChild(img);
|
||||
shotNameEl.textContent = data.name;
|
||||
shotOpenEl.href = src;
|
||||
shotOpenEl.style.display = "";
|
||||
if (announce) {
|
||||
const kb = data.size ? " (" + Math.round(data.size / 1024) + " KB)" : "";
|
||||
setStatus("Captured " + data.name + kb);
|
||||
}
|
||||
} catch (err) {
|
||||
if (announce) setStatus(String(err && err.message ? err.message : err), true);
|
||||
}
|
||||
}
|
||||
|
||||
async function call(path, body) {
|
||||
const opts = body
|
||||
? { method: "POST", headers: { "Content-Type": "application/json", ...AUTH }, body: JSON.stringify(body) }
|
||||
: { method: "POST", headers: { ...AUTH } };
|
||||
busy(true);
|
||||
setStatus("Loading…");
|
||||
try {
|
||||
const res = await fetch(path, opts);
|
||||
const state = await res.json();
|
||||
render(state);
|
||||
// Couple capture to navigation so the preview reflects the current page
|
||||
// without a manual screenshot click.
|
||||
if (!state || !state.error) await capturePreview(false);
|
||||
} catch (err) {
|
||||
setStatus(String(err && err.message ? err.message : err), true);
|
||||
} finally {
|
||||
busy(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("go").addEventListener("click", () => call("/navigate", { url: urlEl.value }));
|
||||
urlEl.addEventListener("keydown", (e) => { if (e.key === "Enter") call("/navigate", { url: urlEl.value }); });
|
||||
document.getElementById("back").addEventListener("click", () => call("/back"));
|
||||
document.getElementById("forward").addEventListener("click", () => call("/forward"));
|
||||
document.getElementById("reload").addEventListener("click", () => call("/reload"));
|
||||
|
||||
document.getElementById("shot").addEventListener("click", async () => {
|
||||
busy(true);
|
||||
setStatus("Capturing screenshot…");
|
||||
try {
|
||||
await capturePreview(true);
|
||||
} finally {
|
||||
busy(false);
|
||||
}
|
||||
});
|
||||
|
||||
async function refreshState() {
|
||||
try { render(await (await fetch("/state", { headers: { ...AUTH } })).json()); } catch (_) {}
|
||||
}
|
||||
// The panel can't see the Chromium window, so it goes stale if you browse by
|
||||
// hand there or the agent acts on the page. Resync state and preview whenever
|
||||
// the panel regains focus (skip the blank-page case).
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) return;
|
||||
refreshState();
|
||||
if (currentUrl && currentUrl !== "about:blank") capturePreview(false);
|
||||
});
|
||||
refreshState();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
{
|
||||
"name": "chromium-control-canvas",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "chromium-control-canvas",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@github/copilot-sdk": "latest",
|
||||
"playwright": "^1.60.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot": {
|
||||
"version": "1.0.61",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.61.tgz",
|
||||
"integrity": "sha512-E4f7YXTL2uUZY/ypnfsUruAeSgrHx3AGYEbm5N0DrpzPqoNAZqV6kHEWM4vu+W/nGvydIfPxmOTqaMEhM8r0Uw==",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.1.2"
|
||||
},
|
||||
"bin": {
|
||||
"copilot": "npm-loader.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@github/copilot-darwin-arm64": "1.0.61",
|
||||
"@github/copilot-darwin-x64": "1.0.61",
|
||||
"@github/copilot-linux-arm64": "1.0.61",
|
||||
"@github/copilot-linux-x64": "1.0.61",
|
||||
"@github/copilot-linuxmusl-arm64": "1.0.61",
|
||||
"@github/copilot-linuxmusl-x64": "1.0.61",
|
||||
"@github/copilot-win32-arm64": "1.0.61",
|
||||
"@github/copilot-win32-x64": "1.0.61"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-darwin-arm64": {
|
||||
"version": "1.0.61",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.61.tgz",
|
||||
"integrity": "sha512-10prvjHRXB0SD28NsIpzdNDgLquQYUwaH5Ev9KVdIWdBPAvlQsHmQ4JSCyD/UILc/nrrr02CKUgum+mZRKUKIg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-darwin-arm64": "copilot"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-darwin-x64": {
|
||||
"version": "1.0.61",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.61.tgz",
|
||||
"integrity": "sha512-NXUjageJ3mxDfHtXGYu//XhJ+dhJFYObT4R3jeWgIHhd+4lX7FlC754nwlBP/ZuVhJ3ND22JK9sua9d2F3Cbwg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-darwin-x64": "copilot"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-linux-arm64": {
|
||||
"version": "1.0.61",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.61.tgz",
|
||||
"integrity": "sha512-dwB2+QSMr622JkePeK56M7YWXsTT/DQzKfpDq8Lk2kmGU052RZAarRmt8gcNm4anofN7pMSrqc3YHj1TM84MFw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-linux-arm64": "copilot"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-linux-x64": {
|
||||
"version": "1.0.61",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.61.tgz",
|
||||
"integrity": "sha512-q6n8R8oybvuCmmkP+43w809Wpud/wwRi/fFSZEYJagiNGmYJ00SDkrfJxHbZsAFMpaJC+oTswqzJHjRoZbO74w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-linux-x64": "copilot"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-linuxmusl-arm64": {
|
||||
"version": "1.0.61",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.61.tgz",
|
||||
"integrity": "sha512-yWo7JXnZS11eJpm68E1RWKMR47EwzPKj3V7GX0EMTd8Fw0T2Aurk9wt9p3c9w0v02nTO1DqJhi68KVWJPdVqvA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-linuxmusl-arm64": "copilot"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-linuxmusl-x64": {
|
||||
"version": "1.0.61",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.61.tgz",
|
||||
"integrity": "sha512-nHzx27Ac4B0fpD9CcmvyrGOBEMJ01CPRgVRP0yAl4wpU4cM2I6+9TPyfYThlWDqZqiUKGXC1ZRQ+B8cJREVGmA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-linuxmusl-x64": "copilot"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-sdk": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.1.tgz",
|
||||
"integrity": "sha512-w6AaS0WqqTE/3iyUrZznvgCLQhsUF7ZmEVCneacuHCfOzlH0r6ww9WUmyA0zgqmXO75V0IYrkIcnFke/qJkkDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@github/copilot": "^1.0.61",
|
||||
"vscode-jsonrpc": "^8.2.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-win32-arm64": {
|
||||
"version": "1.0.61",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.61.tgz",
|
||||
"integrity": "sha512-k6knzI+K5HlZeJDS/yeJAfoYD4xcURWfuqunpTCyk1pDbIFxmrLSqR/TDi7KNlpsf883n5WqpnB06K5kysdHHQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-win32-arm64": "copilot.exe"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-win32-x64": {
|
||||
"version": "1.0.61",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.61.tgz",
|
||||
"integrity": "sha512-L6NZ6o73VZFHd7OoRaztV3Prh1PbW9HXqYsAx+XywNALQvE1u489WBUC1ggfYBW5MTBCf8mxSkYQdb3Am2omsw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-win32-x64": "copilot.exe"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-jsonrpc": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz",
|
||||
"integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "chromium-control-canvas",
|
||||
"version": "1.0.0",
|
||||
"description": "GitHub Copilot canvas that drives a real headful Chromium window via Playwright.",
|
||||
"main": "extension.mjs",
|
||||
"keywords": [],
|
||||
"author": "Andrea Griffiths",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@github/copilot-sdk": "latest",
|
||||
"playwright": "^1.60.0"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user