@@ -43,7 +43,7 @@ import * as Queue from "effect/Queue";
4343import * as Schema from "effect/Schema" ;
4444import type * as Scope from "effect/Scope" ;
4545import * as Stream from "effect/Stream" ;
46- import type { CDPSession , Dialog , Page } from "playwright-core" ;
46+ import type { BrowserContext , CDPSession , Dialog , Page } from "playwright-core" ;
4747
4848import { PENDING_ATTACHMENT_THREAD_SEGMENT } from "../attachmentStore.ts" ;
4949import * as ServerConfig from "../config.ts" ;
@@ -139,6 +139,7 @@ export type ServerBrowserViewerOutput =
139139 readonly y : number ;
140140 readonly editable : boolean ;
141141 }
142+ | { readonly _tag : "clipboard" ; readonly text : string }
142143 | { readonly _tag : "gone" } ;
143144
144145export interface ServerBrowserViewer {
@@ -176,6 +177,8 @@ interface ViewerState {
176177 readonly pause : ( ) => Promise < void > ;
177178 readonly resume : ( ) => Promise < void > ;
178179 scrolledAt : number ;
180+ /** Last input from this viewer; page copies reach its clipboard only right after. */
181+ inputAt : number ;
179182 /** Panel bounds, retained in fixed mode; passive viewers never request a size. */
180183 requestedSize : { width : number ; height : number ; order : number } | null ;
181184}
@@ -253,15 +256,70 @@ const modifiersOf = (message: Record<string, unknown>) => {
253256} ;
254257
255258// Cmd shortcuts from Apple viewers are no editing shortcut for Linux or headless
256- // Chromium, so they carry the command. Ctrl already works natively on Linux.
257- const metaEditingCommand = ( key : string , modifiers : number ) => {
258- if ( ( modifiers & 0b0111 ) !== 4 ) return null ;
259+ // Chromium, so they carry the command. Ctrl already works natively on Linux,
260+ // except copy and cut, which headless Chromium only runs as commands.
261+ const editingCommand = ( key : string , modifiers : number ) => {
262+ const modifier = modifiers & 0b0111 ;
263+ if ( modifier !== 2 && modifier !== 4 ) return null ;
259264 const lower = key . toLowerCase ( ) ;
260265 const command =
261- lower === "a" ? "selectAll" : lower === "z" ? ( modifiers & 8 ? "redo" : "undo" ) : null ;
266+ lower === "c"
267+ ? "copy"
268+ : lower === "x"
269+ ? "cut"
270+ : modifier !== 4
271+ ? null
272+ : lower === "a"
273+ ? "selectAll"
274+ : lower === "z"
275+ ? modifiers & 8
276+ ? "redo"
277+ : "undo"
278+ : null ;
262279 return command ? { commands : [ command ] } : null ;
263280} ;
264281
282+ const CLIPBOARD_TEXT_LIMIT = 1024 * 1024 ;
283+ /** Copies reach the viewer only this soon after it last touched the page. */
284+ const CLIPBOARD_GESTURE_MS = 5_000 ;
285+ const CLIPBOARD_BINDING = "__t3PreviewClipboard" ;
286+ // Headless Chromium shares one clipboard between every context, so pages
287+ // report their own copies instead of the clipboard being read back.
288+ const CLIPBOARD_SCRIPT = `(() => {
289+ const send = (text) => {
290+ if (typeof text !== "string" || text.length === 0) return;
291+ try { globalThis.${ CLIPBOARD_BINDING } ?.(text.slice(0, ${ CLIPBOARD_TEXT_LIMIT } )); } catch {}
292+ };
293+ const selection = () => {
294+ const field = document.activeElement;
295+ if ((field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement) && field.selectionStart !== null)
296+ return field.value.slice(field.selectionStart, field.selectionEnd ?? field.selectionStart);
297+ return String(document.getSelection() ?? "");
298+ };
299+ // Bubbling to window runs after page handlers, before cut removes the selection.
300+ const copied = (event) =>
301+ send(event.defaultPrevented ? event.clipboardData?.getData("text/plain") : selection());
302+ addEventListener("copy", copied);
303+ addEventListener("cut", copied);
304+ const proto = globalThis.Clipboard?.prototype;
305+ if (!proto) return;
306+ const writeText = proto.writeText;
307+ proto.writeText = function (text) {
308+ const result = writeText.call(this, text);
309+ result.then(() => send(String(text)), () => {});
310+ return result;
311+ };
312+ const write = proto.write;
313+ proto.write = function (items) {
314+ const result = write.call(this, items);
315+ result.then(async () => {
316+ const item = [...items].find((candidate) => candidate.types.includes("text/plain"));
317+ if (item) send(await (await item.getType("text/plain")).text());
318+ }, () => {});
319+ return result;
320+ };
321+ })();` ;
322+
265323const make = Effect . gen ( function * ( ) {
266324 const config = yield * ServerConfig . ServerConfig ;
267325 const enabled = isServerBrowserEnabled ( config . mode ) ;
@@ -430,6 +488,7 @@ const make = Effect.gen(function* () {
430488 isolatedContext ? tabKey ( snapshot . threadId , snapshot . tabId ) : undefined ,
431489 ) ) ;
432490 if ( adopted ?. page . isClosed ( ) ) throw new Error ( "The popup closed before it opened." ) ;
491+ await prepareContext ( context ) ;
433492 const page = adopted ?. page ?? ( await context . newPage ( ) ) ;
434493 const cdp = await context . newCDPSession ( page ) ;
435494 page . setDefaultTimeout ( NAVIGATION_TIMEOUT_MS ) ;
@@ -543,6 +602,23 @@ const make = Effect.gen(function* () {
543602 return tab ;
544603 } ;
545604
605+ const preparedContexts = new WeakSet < BrowserContext > ( ) ;
606+ const prepareContext = async ( context : BrowserContext ) => {
607+ if ( preparedContexts . has ( context ) ) return ;
608+ preparedContexts . add ( context ) ;
609+ await context . grantPermissions ( [ "clipboard-write" ] ) ;
610+ await context . exposeBinding ( CLIPBOARD_BINDING , ( { page } , text : unknown ) => {
611+ if ( typeof text !== "string" ) return ;
612+ const tab = [ ...tabs . values ( ) ] . find ( ( candidate ) => candidate . page === page ) ;
613+ const controller = tab
614+ ? [ ...tab . viewers ] . find ( ( viewer ) => viewer . id === tab . control . controller )
615+ : undefined ;
616+ if ( ! controller || Date . now ( ) - controller . inputAt > CLIPBOARD_GESTURE_MS ) return ;
617+ controller . push ( { _tag : "clipboard" , text : text . slice ( 0 , CLIPBOARD_TEXT_LIMIT ) } ) ;
618+ } ) ;
619+ await context . addInitScript ( CLIPBOARD_SCRIPT ) ;
620+ } ;
621+
546622 const adoptPopup = async ( opener : ServerTab , popup : Page ) => {
547623 if ( opener . closing ) {
548624 await popup . close ( ) . catch ( constVoid ) ;
@@ -1216,6 +1292,7 @@ const make = Effect.gen(function* () {
12161292 ) => {
12171293 const message = asRecord ( raw ) ;
12181294 if ( ! message ) return ;
1295+ viewer . inputAt = Date . now ( ) ;
12191296 const modifiers = modifiersOf ( message ) ;
12201297 switch ( message . type ) {
12211298 case "mouse" : {
@@ -1267,7 +1344,7 @@ const make = Effect.gen(function* () {
12671344 code,
12681345 modifiers,
12691346 ...( text ? { text, unmodifiedText : text } : { } ) ,
1270- ...metaEditingCommand ( key , modifiers ) ,
1347+ ...editingCommand ( key , modifiers ) ,
12711348 windowsVirtualKeyCode : num (
12721349 message . keyCode ,
12731350 key . length === 1 ? key . toUpperCase ( ) . charCodeAt ( 0 ) : 0 ,
@@ -1410,6 +1487,7 @@ const make = Effect.gen(function* () {
14101487 } ,
14111488 resume : ( ) => startScreencast ( screencastScale ) ,
14121489 scrolledAt : 0 ,
1490+ inputAt : 0 ,
14131491 requestedSize : null ,
14141492 } ;
14151493 yield * Effect . acquireRelease (
0 commit comments