| ... | @@ -66,6 +66,7 @@ import type { | ... | @@ -66,6 +66,7 @@ import type { |
| 66 | Dialog, | 66 | Dialog, |
| 67 | Download, | 67 | Download, |
| 68 | FileChooser, | 68 | FileChooser, |
| | 69 | Frame, |
| 69 | Page, | 70 | Page, |
| 70 | } from "playwright-core"; | 71 | } from "playwright-core"; |
| 71 | | 72 | |
| ... | @@ -262,8 +263,6 @@ interface ViewerState { | ... | @@ -262,8 +263,6 @@ interface ViewerState { |
| 262 | readonly pause: () => Promise<void>; | 263 | readonly pause: () => Promise<void>; |
| 263 | readonly resume: () => Promise<void>; | 264 | readonly resume: () => Promise<void>; |
| 264 | scrolledAt: number; | 265 | scrolledAt: number; |
| 265 | /** Last input from this viewer; page copies reach its clipboard only right after. */ | | |
| 266 | inputAt: number; | | |
| 267 | /** Panel bounds, retained in fixed mode; passive viewers never request a size. */ | 266 | /** Panel bounds, retained in fixed mode; passive viewers never request a size. */ |
| 268 | requestedSize: { width: number; height: number; order: number } | null; | 267 | requestedSize: { width: number; height: number; order: number } | null; |
| 269 | } | 268 | } |
| ... | @@ -290,6 +289,8 @@ interface ServerTab { | ... | @@ -290,6 +289,8 @@ interface ServerTab { |
| 290 | readonly tabId: string; | 289 | readonly tabId: string; |
| 291 | readonly page: Page; | 290 | readonly page: Page; |
| 292 | readonly cdp: CDPSession; | 291 | readonly cdp: CDPSession; |
| | 292 | /** Sessions of the page's frames that run in their own process. */ |
| | 293 | readonly frameSessions: Map<Frame, CDPSession>; |
| 293 | readonly createdAt: number; | 294 | readonly createdAt: number; |
| 294 | /** The agent's latest request on this tab; idle agent tabs close. */ | 295 | /** The agent's latest request on this tab; idle agent tabs close. */ |
| 295 | usedAt: number; | 296 | usedAt: number; |
| ... | @@ -434,43 +435,50 @@ const editingCommand = (key: string, modifiers: number) => { | ... | @@ -434,43 +435,50 @@ const editingCommand = (key: string, modifiers: number) => { |
| 434 | }; | 435 | }; |
| 435 | | 436 | |
| 436 | const CLIPBOARD_TEXT_LIMIT = 1024 * 1024; | 437 | const CLIPBOARD_TEXT_LIMIT = 1024 * 1024; |
| 437 | /** Copies reach the viewer only this soon after it last touched the page. */ | 438 | const CLIPBOARD_WORLD = "t3-preview-clipboard"; |
| 438 | const CLIPBOARD_GESTURE_MS = 5_000; | 439 | // Headless Chromium shares one clipboard between every context, so the copy |
| 439 | const CLIPBOARD_BINDING = "__t3PreviewClipboard"; | 440 | // a viewer's copy or cut key makes is read from this world instead of the |
| 440 | // Headless Chromium shares one clipboard between every context, so pages | 441 | // clipboard. Page scripts cannot see the world, and only a copy made while a |
| 441 | // report their own copies instead of the clipboard being read back. | 442 | // real key is handled counts. |
| 442 | const CLIPBOARD_SCRIPT = `(() => { | 443 | const CLIPBOARD_SCRIPT = `(() => { |
| 443 | const send = (text) => { | 444 | let keying = false; |
| 444 | if (typeof text !== "string" || text.length === 0) return; | 445 | let copied = ""; |
| 445 | try { globalThis.${CLIPBOARD_BINDING}?.(text.slice(0, ${CLIPBOARD_TEXT_LIMIT})); } catch {} | 446 | // The same Ctrl or Cmd + C or X that editingCommand sends as copy or cut. |
| 446 | }; | 447 | addEventListener("keydown", (event) => { |
| | 448 | if (!event.isTrusted || event.altKey || event.ctrlKey === event.metaKey) return; |
| | 449 | const key = event.key.toLowerCase(); |
| | 450 | if (key !== "c" && key !== "x") return; |
| | 451 | keying = true; |
| | 452 | copied = ""; |
| | 453 | setTimeout(() => { keying = false; }); |
| | 454 | }, true); |
| 447 | const selection = () => { | 455 | const selection = () => { |
| 448 | const field = document.activeElement; | 456 | let field = document.activeElement; |
| | 457 | while (field?.shadowRoot?.activeElement) field = field.shadowRoot.activeElement; |
| 449 | if ((field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement) && field.selectionStart !== null) | 458 | if ((field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement) && field.selectionStart !== null) |
| 450 | return field.value.slice(field.selectionStart, field.selectionEnd ?? field.selectionStart); | 459 | return field.value.slice(field.selectionStart, field.selectionEnd ?? field.selectionStart); |
| 451 | return String(document.getSelection() ?? ""); | 460 | return String(document.getSelection() ?? ""); |
| 452 | }; | 461 | }; |
| 453 | // Bubbling to window runs after page handlers, before cut removes the selection. | 462 | const record = (text) => { |
| 454 | const copied = (event) => | 463 | copied = String(text ?? "").slice(0, ${CLIPBOARD_TEXT_LIMIT}); |
| 455 | send(event.defaultPrevented ? event.clipboardData?.getData("text/plain") : selection()); | 464 | }; |
| 456 | addEventListener("copy", copied); | 465 | // Capturing at window runs before page handlers can stop the event, and |
| 457 | addEventListener("cut", copied); | 466 | // before cut removes the selection. |
| 458 | const proto = globalThis.Clipboard?.prototype; | 467 | const snapshot = (event) => { |
| 459 | if (!proto) return; | 468 | if (keying && event.isTrusted) record(selection()); |
| 460 | const writeText = proto.writeText; | 469 | }; |
| 461 | proto.writeText = function (text) { | 470 | // Bubbling back to window picks up text a page handler set instead. |
| 462 | const result = writeText.call(this, text); | 471 | const replace = (event) => { |
| 463 | result.then(() => send(String(text)), () => {}); | 472 | if (keying && event.isTrusted && event.defaultPrevented) record(event.clipboardData?.getData("text/plain")); |
| 464 | return result; | | |
| 465 | }; | 473 | }; |
| 466 | const write = proto.write; | 474 | for (const type of ["copy", "cut"]) { |
| 467 | proto.write = function (items) { | 475 | addEventListener(type, snapshot, true); |
| 468 | const result = write.call(this, items); | 476 | addEventListener(type, replace); |
| 469 | result.then(async () => { | 477 | } |
| 470 | const item = [...items].find((candidate) => candidate.types.includes("text/plain")); | 478 | globalThis.takeCopy = () => { |
| 471 | if (item) send(await (await item.getType("text/plain")).text()); | 479 | const text = copied; |
| 472 | }, () => {}); | 480 | copied = ""; |
| 473 | return result; | 481 | return text; |
| 474 | }; | 482 | }; |
| 475 | })();`; | 483 | })();`; |
| 476 | | 484 | |
| ... | @@ -763,13 +771,14 @@ const make = Effect.gen(function* () { | ... | @@ -763,13 +771,14 @@ const make = Effect.gen(function* () { |
| 763 | isolatedContext ? tabKey(snapshot.threadId, snapshot.tabId) : undefined, | 771 | isolatedContext ? tabKey(snapshot.threadId, snapshot.tabId) : undefined, |
| 764 | )); | 772 | )); |
| 765 | if (adopted?.page.isClosed()) throw new Error("The popup closed before it opened."); | 773 | if (adopted?.page.isClosed()) throw new Error("The popup closed before it opened."); |
| 766 | // The desktop page already has its own clipboard; the bridge script is for headless tabs. | 774 | // The desktop page already has its own clipboard. |
| 767 | if (!desktop) await prepareContext(context); | 775 | if (!desktop) await prepareContext(context); |
| 768 | const page = adopted?.page ?? desktop?.page ?? (await context.newPage()); | 776 | const page = adopted?.page ?? desktop?.page ?? (await context.newPage()); |
| 769 | const cdp = await context.newCDPSession(page); | 777 | const cdp = await context.newCDPSession(page); |
| 770 | // The desktop's page is already a normal browser. A popup's first request | 778 | // The desktop's page is already a normal browser. A popup's first request |
| 771 | // went out before it became a tab; everything after presents as Chrome. | 779 | // went out before it became a tab; everything after presents as Chrome. |
| 772 | if (!desktop) await presentAsChrome(cdp, host); | 780 | if (!desktop) await presentAsChrome(cdp, host); |
| | 781 | if (!desktop) await recordCopies(cdp); |
| 773 | page.setDefaultTimeout(NAVIGATION_TIMEOUT_MS); | 782 | page.setDefaultTimeout(NAVIGATION_TIMEOUT_MS); |
| 774 | page.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS); | 783 | page.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS); |
| 775 | const control = new SessionControl(snapshot.automationOwner ?? null, () => | 784 | const control = new SessionControl(snapshot.automationOwner ?? null, () => |
| ... | @@ -780,6 +789,7 @@ const make = Effect.gen(function* () { | ... | @@ -780,6 +789,7 @@ const make = Effect.gen(function* () { |
| 780 | tabId: snapshot.tabId, | 789 | tabId: snapshot.tabId, |
| 781 | page, | 790 | page, |
| 782 | cdp, | 791 | cdp, |
| | 792 | frameSessions: new Map(), |
| 783 | createdAt: Date.now(), | 793 | createdAt: Date.now(), |
| 784 | usedAt: Date.now(), | 794 | usedAt: Date.now(), |
| 785 | viewers: new Set(), | 795 | viewers: new Set(), |
| ... | @@ -828,7 +838,10 @@ const make = Effect.gen(function* () { | ... | @@ -828,7 +838,10 @@ const make = Effect.gen(function* () { |
| 828 | page.on("framenavigated", (frame) => { | 838 | page.on("framenavigated", (frame) => { |
| 829 | // Same-document navigations (SPA routes) fire no load event. | 839 | // Same-document navigations (SPA routes) fire no load event. |
| 830 | if (frame === page.mainFrame() && !tab.loading) void reportLoaded(tab); | 840 | if (frame === page.mainFrame() && !tab.loading) void reportLoaded(tab); |
| | 841 | if (!desktop) void recordFrameCopies(tab, frame); |
| 831 | }); | 842 | }); |
| | 843 | // An adopted popup's frames may have loaded before the tab listened. |
| | 844 | if (adopted) for (const frame of page.frames()) void recordFrameCopies(tab, frame); |
| 832 | page.on("requestfailed", (request) => { | 845 | page.on("requestfailed", (request) => { |
| 833 | const errorText = request.failure()?.errorText ?? ""; | 846 | const errorText = request.failure()?.errorText ?? ""; |
| 834 | pushBounded(tab.networkEntries, { | 847 | pushBounded(tab.networkEntries, { |
| ... | @@ -1129,16 +1142,81 @@ const make = Effect.gen(function* () { | ... | @@ -1129,16 +1142,81 @@ const make = Effect.gen(function* () { |
| 1129 | if (preparedContexts.has(context)) return; | 1142 | if (preparedContexts.has(context)) return; |
| 1130 | preparedContexts.add(context); | 1143 | preparedContexts.add(context); |
| 1131 | await context.grantPermissions(["clipboard-write"]); | 1144 | await context.grantPermissions(["clipboard-write"]); |
| 1132 | await context.exposeBinding(CLIPBOARD_BINDING, ({ page }, text: unknown) => { | 1145 | }; |
| 1133 | if (typeof text !== "string") return; | 1146 | |
| 1134 | const tab = [...tabs.values()].find((candidate) => candidate.page === page); | 1147 | /** Records the copies made in each frame a session reaches. */ |
| 1135 | const controller = tab | 1148 | const recordCopies = async (cdp: CDPSession) => { |
| 1136 | ? [...tab.viewers].find((viewer) => viewer.id === tab.control.controller) | 1149 | // Scripts for new documents run only while this session's Page domain is on. |
| 1137 | : undefined; | 1150 | await cdp.send("Page.enable"); |
| 1138 | if (!controller || Date.now() - controller.inputAt > CLIPBOARD_GESTURE_MS) return; | 1151 | await cdp.send("Page.addScriptToEvaluateOnNewDocument", { |
| 1139 | controller.push({ _tag: "clipboard", text: text.slice(0, CLIPBOARD_TEXT_LIMIT) }); | 1152 | source: CLIPBOARD_SCRIPT, |
| | 1153 | worldName: CLIPBOARD_WORLD, |
| | 1154 | runImmediately: true, |
| | 1155 | }); |
| | 1156 | }; |
| | 1157 | |
| | 1158 | /** |
| | 1159 | * A frame in its own process, such as one with an `Origin-Agent-Cluster` |
| | 1160 | * header, is out of the page session's reach, so it records copies through |
| | 1161 | * its own session until that target closes. |
| | 1162 | */ |
| | 1163 | const recordFrameCopies = async (tab: ServerTab, frame: Frame) => { |
| | 1164 | if (frame === tab.page.mainFrame() || tab.frameSessions.has(frame)) return; |
| | 1165 | // A frame in its parent's process has no session of its own. |
| | 1166 | const cdp = await tab.page |
| | 1167 | .context() |
| | 1168 | .newCDPSession(frame) |
| | 1169 | .catch(() => null); |
| | 1170 | if (!cdp) return; |
| | 1171 | if (tab.frameSessions.has(frame) || frame.isDetached()) { |
| | 1172 | await cdp.detach().catch(constVoid); |
| | 1173 | return; |
| | 1174 | } |
| | 1175 | tab.frameSessions.set(frame, cdp); |
| | 1176 | cdp.on("close", () => { |
| | 1177 | if (tab.frameSessions.get(frame) !== cdp) return; |
| | 1178 | tab.frameSessions.delete(frame); |
| | 1179 | // A frame that moved to another process records through its new target. |
| | 1180 | if (!frame.isDetached()) void recordFrameCopies(tab, frame); |
| 1140 | }); | 1181 | }); |
| 1141 | await context.addInitScript(CLIPBOARD_SCRIPT); | 1182 | await recordCopies(cdp).catch(constVoid); |
| | 1183 | }; |
| | 1184 | |
| | 1185 | /** Clears and returns the copy of each frame a session reaches. */ |
| | 1186 | const takeSessionCopies = async (cdp: CDPSession) => { |
| | 1187 | // An out-of-process frame that closed meanwhile copied nothing. |
| | 1188 | const tree = await cdp.send("Page.getFrameTree").catch(() => null); |
| | 1189 | if (!tree) return []; |
| | 1190 | const frameIds = (node: typeof tree.frameTree): Array<string> => [ |
| | 1191 | node.frame.id, |
| | 1192 | ...(node.childFrames ?? []).flatMap(frameIds), |
| | 1193 | ]; |
| | 1194 | return Promise.all( |
| | 1195 | frameIds(tree.frameTree).map((frameId) => |
| | 1196 | cdp |
| | 1197 | .send("Page.createIsolatedWorld", { frameId, worldName: CLIPBOARD_WORLD }) |
| | 1198 | .then(({ executionContextId }) => |
| | 1199 | cdp.send("Runtime.evaluate", { |
| | 1200 | contextId: executionContextId, |
| | 1201 | expression: "globalThis.takeCopy?.()", |
| | 1202 | returnByValue: true, |
| | 1203 | }), |
| | 1204 | ) |
| | 1205 | .then( |
| | 1206 | ({ result }) => (typeof result.value === "string" ? result.value : ""), |
| | 1207 | // A frame that detached meanwhile copied nothing. |
| | 1208 | () => "", |
| | 1209 | ), |
| | 1210 | ), |
| | 1211 | ); |
| | 1212 | }; |
| | 1213 | |
| | 1214 | /** Clears every frame's copy and returns what the key just handled copied or cut. */ |
| | 1215 | const takeCopiedText = async (tab: ServerTab) => { |
| | 1216 | const copies = await Promise.all( |
| | 1217 | [tab.cdp, ...tab.frameSessions.values()].map(takeSessionCopies), |
| | 1218 | ); |
| | 1219 | return copies.flat().find((text) => text.length > 0); |
| 1142 | }; | 1220 | }; |
| 1143 | | 1221 | |
| 1144 | const adoptPopup = async (opener: ServerTab, popup: Page) => { | 1222 | const adoptPopup = async (opener: ServerTab, popup: Page) => { |
| ... | @@ -2058,7 +2136,6 @@ const make = Effect.gen(function* () { | ... | @@ -2058,7 +2136,6 @@ const make = Effect.gen(function* () { |
| 2058 | ) => { | 2136 | ) => { |
| 2059 | const message = asRecord(raw); | 2137 | const message = asRecord(raw); |
| 2060 | if (!message) return; | 2138 | if (!message) return; |
| 2061 | viewer.inputAt = Date.now(); | | |
| 2062 | const modifiers = modifiersOf(message); | 2139 | const modifiers = modifiersOf(message); |
| 2063 | switch (message.type) { | 2140 | switch (message.type) { |
| 2064 | case "mouse": { | 2141 | case "mouse": { |
| ... | @@ -2104,19 +2181,29 @@ const make = Effect.gen(function* () { | ... | @@ -2104,19 +2181,29 @@ const make = Effect.gen(function* () { |
| 2104 | viewer.pressedKeys.delete(code || key); | 2181 | viewer.pressedKeys.delete(code || key); |
| 2105 | return; | 2182 | return; |
| 2106 | } | 2183 | } |
| | 2184 | const command = editingCommand(key, modifiers); |
| | 2185 | // Only the controlling viewer's own copy and cut reach its clipboard. |
| | 2186 | const copying = |
| | 2187 | !tab.desktop && (command?.commands[0] === "copy" || command?.commands[0] === "cut"); |
| | 2188 | // Clear what any frame kept from an earlier key, so only this key's copy is read. |
| | 2189 | if (copying) await takeCopiedText(tab); |
| 2107 | await session.send("Input.dispatchKeyEvent", { | 2190 | await session.send("Input.dispatchKeyEvent", { |
| 2108 | type: text ? "keyDown" : "rawKeyDown", | 2191 | type: text ? "keyDown" : "rawKeyDown", |
| 2109 | key, | 2192 | key, |
| 2110 | code, | 2193 | code, |
| 2111 | modifiers, | 2194 | modifiers, |
| 2112 | ...(text ? { text, unmodifiedText: text } : {}), | 2195 | ...(text ? { text, unmodifiedText: text } : {}), |
| 2113 | ...editingCommand(key, modifiers), | 2196 | ...command, |
| 2114 | windowsVirtualKeyCode: num( | 2197 | windowsVirtualKeyCode: num( |
| 2115 | message.keyCode, | 2198 | message.keyCode, |
| 2116 | key.length === 1 ? key.toUpperCase().charCodeAt(0) : 0, | 2199 | key.length === 1 ? key.toUpperCase().charCodeAt(0) : 0, |
| 2117 | ), | 2200 | ), |
| 2118 | }); | 2201 | }); |
| 2119 | viewer.pressedKeys.set(code || key, { key, code }); | 2202 | viewer.pressedKeys.set(code || key, { key, code }); |
| | 2203 | if (copying) { |
| | 2204 | const copied = await takeCopiedText(tab); |
| | 2205 | if (copied) viewer.push({ _tag: "clipboard", text: copied }); |
| | 2206 | } |
| 2120 | return; | 2207 | return; |
| 2121 | } | 2208 | } |
| 2122 | case "text": | 2209 | case "text": |
| ... | @@ -2251,7 +2338,6 @@ const make = Effect.gen(function* () { | ... | @@ -2251,7 +2338,6 @@ const make = Effect.gen(function* () { |
| 2251 | }, | 2338 | }, |
| 2252 | resume: () => startScreencast(screencastScale), | 2339 | resume: () => startScreencast(screencastScale), |
| 2253 | scrolledAt: 0, | 2340 | scrolledAt: 0, |
| 2254 | inputAt: 0, | | |
| 2255 | requestedSize: null, | 2341 | requestedSize: null, |
| 2256 | }; | 2342 | }; |
| 2257 | yield* Effect.acquireRelease( | 2343 | yield* Effect.acquireRelease( |