authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-14 22:25:50+00:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 22:25:50+00:00
log78f4b69a1fc6c37e2bdbb3f8d02717a49d4ccacd
treeec18181ea085e1a6c6aa83fd9661ff40aa677d30
parentaa81d1203ea43c3b6cc09c5442559fa0cf18db63
signature Signed by PGP key B5690EEEBB952194

fix(server): preview copy only forwards text from the viewer's copy keys (#19561)


2 files changed, 227 insertions(+), 61 deletions(-)

apps/server/src/preview/ServerBrowser.test.ts+96-16
...@@ -60,14 +60,43 @@ vi.mock("./ServerBrowserContexts.ts", () => ({...@@ -60,14 +60,43 @@ vi.mock("./ServerBrowserContexts.ts", () => ({
60 },60 },
61}));61}));
6262
63function makeSession() {63/** A frame in its own process. Only its own session reaches the copy it holds. */
64function makeFrame() {
65 const frame = {
66 copy: "",
67 detached: false,
68 isDetached: () => frame.detached,
69 recording: Promise.withResolvers<void>(),
70 };
71 return frame;
72}
73
74function makeSession(frame?: ReturnType<typeof makeFrame>) {
75 const events = new NodeEvents.EventEmitter();
64 return {76 return {
65 on: vi.fn(),77 on: vi.fn((name: string, listener: () => void) => events.on(name, listener)),
78 emit: (name: string) => events.emit(name),
66 detach: vi.fn(async () => {}),79 detach: vi.fn(async () => {}),
67 send: vi.fn(async (method: string, _input?: unknown): Promise<Record<string, unknown>> => {80 send: vi.fn(async (method: string, input?: unknown): Promise<Record<string, unknown>> => {
68 if (method === "Page.getNavigationHistory") return { currentIndex: 0, entries: [{}] };81 if (method === "Page.getNavigationHistory") return { currentIndex: 0, entries: [{}] };
69 if (method === "Page.getLayoutMetrics") return { cssVisualViewport: { pageX: 0, pageY: 0 } };82 if (method === "Page.getLayoutMetrics") return { cssVisualViewport: { pageX: 0, pageY: 0 } };
70 if (method === "Page.captureScreenshot") return { data: "ZnJhbWU=" };83 if (method === "Page.captureScreenshot") return { data: "ZnJhbWU=" };
84 if (method === "Page.getFrameTree") return { frameTree: { frame: { id: "main" } } };
85 if (method === "Page.createIsolatedWorld") return { executionContextId: 7 };
86 if (method === "Page.addScriptToEvaluateOnNewDocument") frame?.recording.resolve();
87 // A copy or cut key copies keyCopy in the focused frame, or the page's process if none;
88 // one that copies nothing leaves the frames as they were.
89 if (method === "Input.dispatchKeyEvent" && (input as { commands?: unknown }).commands) {
90 if (focusedFrame) focusedFrame.copy = keyCopy || focusedFrame.copy;
91 else pageCopy = keyCopy || pageCopy;
92 }
93 // The clipboard world hands over what the session's frames copied, once.
94 if (method === "Runtime.evaluate" && (input as { contextId?: number }).contextId === 7) {
95 const value = frame ? frame.copy : pageCopy;
96 if (frame) frame.copy = "";
97 else pageCopy = "";
98 return { result: { value } };
99 }
71 return { result: { value: "evaluated" } };100 return { result: { value: "evaluated" } };
72 }),101 }),
73 };102 };
...@@ -91,6 +120,7 @@ function makeContext(onClose?: (context: BrowserContext) => void) {...@@ -91,6 +120,7 @@ function makeContext(onClose?: (context: BrowserContext) => void) {
91 setDefaultNavigationTimeout: vi.fn(),120 setDefaultNavigationTimeout: vi.fn(),
92 context: () => context,121 context: () => context,
93 mainFrame: () => page,122 mainFrame: () => page,
123 frames: () => [page],
94 url: () => url,124 url: () => url,
95 title: vi.fn(async () => "test page"),125 title: vi.fn(async () => "test page"),
96 viewportSize: () => viewport,126 viewportSize: () => viewport,
...@@ -130,12 +160,10 @@ function makeContext(onClose?: (context: BrowserContext) => void) {...@@ -130,12 +160,10 @@ function makeContext(onClose?: (context: BrowserContext) => void) {
130 sessions,160 sessions,
131 newPage: async () => page as unknown as Page,161 newPage: async () => page as unknown as Page,
132 grantPermissions: vi.fn(async () => {}),162 grantPermissions: vi.fn(async () => {}),
133 exposeBinding: vi.fn(async (_name: string, binding: ClipboardBinding) => {163 newCDPSession: async (target: unknown) => {
134 clipboardBinding = binding;164 const session = makeSession(
135 }),165 target === page ? undefined : (target as ReturnType<typeof makeFrame>),
136 addInitScript: vi.fn(async () => {}),166 );
137 newCDPSession: async () => {
138 const session = makeSession();
139 sessions.push(session);167 sessions.push(session);
140 return session;168 return session;
141 },169 },
...@@ -153,8 +181,12 @@ const contexts: ReturnType<typeof makeContext>[] = [];...@@ -153,8 +181,12 @@ const contexts: ReturnType<typeof makeContext>[] = [];
153/** The profile and isolation each headless tab asked its context for. */181/** The profile and isolation each headless tab asked its context for. */
154const contextRequests: Array<{ profileId: string; isolated: boolean }> = [];182const contextRequests: Array<{ profileId: string; isolated: boolean }> = [];
155let contextGate: PromiseWithResolvers<void> | null = null;183let contextGate: PromiseWithResolvers<void> | null = null;
156type ClipboardBinding = (source: { page: unknown }, text: unknown) => void;184/** The text the page's frames hold from their latest copy. */
157let clipboardBinding: ClipboardBinding | null = null;185let pageCopy = "";
186/** The text the next copy or cut key copies. */
187let keyCopy = "";
188/** The frame in its own process that has focus; otherwise keys copy in the page's process. */
189let focusedFrame: ReturnType<typeof makeFrame> | null = null;
158let contextFailure: Error | null = null;190let contextFailure: Error | null = null;
159/** Server tabs the fake desktop renders, and the endpoints the server connected to. */191/** Server tabs the fake desktop renders, and the endpoints the server connected to. */
160let desktopRendersNext = false;192let desktopRendersNext = false;
...@@ -272,6 +304,9 @@ beforeEach(() => {...@@ -272,6 +304,9 @@ beforeEach(() => {
272 contextRequests.length = 0;304 contextRequests.length = 0;
273 contextGate = null;305 contextGate = null;
274 contextFailure = null;306 contextFailure = null;
307 pageCopy = "";
308 keyCopy = "";
309 focusedFrame = null;
275 desktopTabs.clear();310 desktopTabs.clear();
276 desktopRendersNext = false;311 desktopRendersNext = false;
277 releasedDesktopTabs.length = 0;312 releasedDesktopTabs.length = 0;
...@@ -1007,11 +1042,10 @@ it.live("closing a tab while a viewer is still opening it does not leave its pag...@@ -1007,11 +1042,10 @@ it.live("closing a tab while a viewer is still opening it does not leave its pag
1007 ).pipe(Effect.provide(layer)),1042 ).pipe(Effect.provide(layer)),
1008);1043);
10091044
1010it.live("page copies reach only the controlling viewer right after its input", () =>1045it.live("only the controlling viewer's copy and cut keys reach its clipboard", () =>
1011 Effect.scoped(1046 Effect.scoped(
1012 Effect.gen(function* () {1047 Effect.gen(function* () {
1013 const { browser, tabId } = yield* ready;1048 const { browser, tabId } = yield* ready;
1014 const page = contexts[0]!.page;
1015 const watcher = yield* browser.attachViewer(viewerInput(tabId, false));1049 const watcher = yield* browser.attachViewer(viewerInput(tabId, false));
1016 const viewer = yield* browser.attachViewer(viewerInput(tabId, true));1050 const viewer = yield* browser.attachViewer(viewerInput(tabId, true));
1017 yield* viewer.input({ type: "takeControl" });1051 yield* viewer.input({ type: "takeControl" });
...@@ -1019,22 +1053,68 @@ it.live("page copies reach only the controlling viewer right after its input", (...@@ -1019,22 +1053,68 @@ it.live("page copies reach only the controlling viewer right after its input", (
1019 Queue.clear(queue).pipe(1053 Queue.clear(queue).pipe(
1020 Effect.map((items) => items.filter((item) => item._tag === "clipboard")),1054 Effect.map((items) => items.filter((item) => item._tag === "clipboard")),
1021 );1055 );
1022 // A page writing on its own, without a recent gesture, stays on the server.1056 // What the page copied on its own stays on the server through other input.
1023 clipboardBinding!({ page }, "unprompted");1057 pageCopy = "unprompted";
1058 yield* viewer.input({ type: "mouse", action: "move", x: 10, y: 10 });
1059 yield* viewer.input({ type: "wheel", x: 10, y: 10, deltaY: 40 });
1060 yield* viewer.input({ type: "key", action: "down", key: "v", code: "KeyV", modifiers: 4 });
1061 yield* viewer.input({ type: "unknown" });
1024 expect(yield* clipboard(viewer.output)).toEqual([]);1062 expect(yield* clipboard(viewer.output)).toEqual([]);
1063 keyCopy = "copied";
1025 yield* viewer.input({ type: "key", action: "down", key: "c", code: "KeyC", modifiers: 4 });1064 yield* viewer.input({ type: "key", action: "down", key: "c", code: "KeyC", modifiers: 4 });
1026 const cdp = contexts[0]!.sessions.at(-1)!;1065 const cdp = contexts[0]!.sessions.at(-1)!;
1027 expect(cdp.send).toHaveBeenCalledWith(1066 expect(cdp.send).toHaveBeenCalledWith(
1028 "Input.dispatchKeyEvent",1067 "Input.dispatchKeyEvent",
1029 expect.objectContaining({ key: "c", commands: ["copy"] }),1068 expect.objectContaining({ key: "c", commands: ["copy"] }),
1030 );1069 );
1031 clipboardBinding!({ page }, "copied");
1032 expect(yield* clipboard(viewer.output)).toEqual([{ _tag: "clipboard", text: "copied" }]);1070 expect(yield* clipboard(viewer.output)).toEqual([{ _tag: "clipboard", text: "copied" }]);
1071 keyCopy = "cut";
1072 yield* viewer.input({ type: "key", action: "down", key: "x", code: "KeyX", modifiers: 2 });
1073 expect(yield* clipboard(viewer.output)).toEqual([{ _tag: "clipboard", text: "cut" }]);
1074 // A copy key with nothing copied sends nothing, not what a frame kept from before.
1075 pageCopy = "left over";
1076 keyCopy = "";
1077 yield* viewer.input({ type: "key", action: "down", key: "c", code: "KeyC", modifiers: 4 });
1078 expect(yield* clipboard(viewer.output)).toEqual([]);
1033 expect(yield* clipboard(watcher.output)).toEqual([]);1079 expect(yield* clipboard(watcher.output)).toEqual([]);
1034 }),1080 }),
1035 ).pipe(Effect.provide(layer)),1081 ).pipe(Effect.provide(layer)),
1036);1082);
10371083
1084it.live("copy keys reach the viewer from a frame in its own process", () =>
1085 Effect.scoped(
1086 Effect.gen(function* () {
1087 const { browser, tabId } = yield* ready;
1088 const viewer = yield* browser.attachViewer(viewerInput(tabId, true));
1089 yield* viewer.input({ type: "takeControl" });
1090 const copy = () =>
1091 viewer.input({ type: "key", action: "down", key: "c", code: "KeyC", modifiers: 4 }).pipe(
1092 Effect.andThen(Queue.clear(viewer.output)),
1093 Effect.map((items) => items.filter((item) => item._tag === "clipboard")),
1094 );
1095 const frame = makeFrame();
1096 contexts[0]!.page.emit("framenavigated", frame);
1097 yield* Effect.promise(() => frame.recording.promise);
1098 const frameSession = contexts[0]!.sessions.at(-1)!;
1099 focusedFrame = frame;
1100 keyCopy = "framed";
1101 expect(yield* copy()).toEqual([{ _tag: "clipboard", text: "framed" }]);
1102 // What the frame kept from before is cleared, not sent by a key elsewhere.
1103 frame.copy = "left over";
1104 focusedFrame = null;
1105 keyCopy = "";
1106 expect(yield* copy()).toEqual([]);
1107 expect(frame.copy).toBe("");
1108 // A frame that went away is no longer read.
1109 frame.detached = true;
1110 frame.copy = "gone";
1111 frameSession.emit("close");
1112 expect(yield* copy()).toEqual([]);
1113 expect(frame.copy).toBe("gone");
1114 }),
1115 ).pipe(Effect.provide(layer)),
1116);
1117
1038it.live("a page download is saved, offered to the controller, and listed for the agent", () =>1118it.live("a page download is saved, offered to the controller, and listed for the agent", () =>
1039 Effect.scoped(1119 Effect.scoped(
1040 Effect.gen(function* () {1120 Effect.gen(function* () {
apps/server/src/preview/ServerBrowser.ts+131-45
...@@ -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";
7172
...@@ -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};
435436
436const CLIPBOARD_TEXT_LIMIT = 1024 * 1024;437const CLIPBOARD_TEXT_LIMIT = 1024 * 1024;
437/** Copies reach the viewer only this soon after it last touched the page. */438const CLIPBOARD_WORLD = "t3-preview-clipboard";
438const CLIPBOARD_GESTURE_MS = 5_000;439// Headless Chromium shares one clipboard between every context, so the copy
439const 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 pages441// 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.
442const CLIPBOARD_SCRIPT = `(() => {443const 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})();`;
476484
...@@ -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 request778 // 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 = tab1148 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 };
11431221
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(