Skip to content

Commit 5f0b71f

Browse files
committed
feat(browser): add paste-files command for clipboard-paste file uploads
Wires a new IPage.pasteFiles primitive plus a browser paste-files CLI verb so adapters can attach local files into chat composers and rich editors whose upload flow only listens to clipboard paste events. Closes #1843
1 parent ec3edde commit 5f0b71f

10 files changed

Lines changed: 316 additions & 1 deletion

File tree

extension/dist/background.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,46 @@ async function insertText(tabId, text) {
345345
await ensureAttached(tabId);
346346
await chrome.debugger.sendCommand({ tabId }, "Input.insertText", { text });
347347
}
348+
async function pasteClipboardFiles(tabId, files, selector) {
349+
await ensureAttached(tabId);
350+
const targetExpr = selector ? `document.querySelector(${JSON.stringify(selector)})` : "document.activeElement && document.activeElement !== document.body ? document.activeElement : document.body";
351+
const expression = `
352+
(() => {
353+
const target = ${targetExpr};
354+
if (!target) return { ok: false, reason: 'no_target' };
355+
const files = ${JSON.stringify(files)};
356+
const dt = new DataTransfer();
357+
for (const f of files) {
358+
const binary = atob(f.base64);
359+
const bytes = new Uint8Array(binary.length);
360+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
361+
const blob = new Blob([bytes], { type: f.mimeType });
362+
dt.items.add(new File([blob], f.name, { type: f.mimeType }));
363+
}
364+
const event = new ClipboardEvent('paste', {
365+
clipboardData: dt,
366+
bubbles: true,
367+
cancelable: true,
368+
});
369+
const delivered = target.dispatchEvent(event);
370+
return { ok: true, count: files.length, delivered };
371+
})()
372+
`;
373+
const result = await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", {
374+
expression,
375+
returnByValue: true
376+
});
377+
if (result.exceptionDetails) {
378+
const description = result.exceptionDetails.exception?.description ?? result.exceptionDetails.text ?? "Unknown error";
379+
throw new Error(`paste-files evaluate failed: ${description}`);
380+
}
381+
const value = result.result?.value;
382+
if (!value?.ok) {
383+
const reason = value?.reason === "no_target" ? "paste-files target not found (no focused element and no --target selector match)" : "paste-files dispatch returned no acknowledgement";
384+
throw new Error(reason);
385+
}
386+
return value.count ?? files.length;
387+
}
348388
function registerFrameTracking() {
349389
registerFrameTargetCleanup();
350390
chrome.debugger.onEvent.addListener((source, method, params) => {
@@ -1475,6 +1515,8 @@ async function handleCommand(cmd) {
14751515
return await handleSetFileInput(cmd, leaseKey);
14761516
case "insert-text":
14771517
return await handleInsertText(cmd, leaseKey);
1518+
case "paste-files":
1519+
return await handlePasteFiles(cmd, leaseKey);
14781520
case "bind":
14791521
return await handleBind(cmd, leaseKey);
14801522
case "network-capture-start":
@@ -2020,6 +2062,20 @@ async function handleInsertText(cmd, leaseKey) {
20202062
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
20212063
}
20222064
}
2065+
async function handlePasteFiles(cmd, leaseKey) {
2066+
const files = cmd.clipboardFiles;
2067+
if (!Array.isArray(files) || files.length === 0) {
2068+
return { id: cmd.id, ok: false, error: "Missing or empty clipboardFiles array" };
2069+
}
2070+
const cmdTabId = await resolveCommandTabId(cmd);
2071+
const tabId = await resolveTabId(cmdTabId, leaseKey);
2072+
try {
2073+
const count = await pasteClipboardFiles(tabId, files, cmd.selector);
2074+
return pageScopedResult(cmd.id, tabId, { count });
2075+
} catch (err) {
2076+
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
2077+
}
2078+
}
20232079
async function handleNetworkCaptureStart(cmd, leaseKey) {
20242080
const cmdTabId = await resolveCommandTabId(cmd);
20252081
const tabId = await resolveTabId(cmdTabId, leaseKey);

extension/src/background.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1117,6 +1117,8 @@ async function handleCommand(cmd: Command): Promise<Result> {
11171117
return await handleSetFileInput(cmd, leaseKey);
11181118
case 'insert-text':
11191119
return await handleInsertText(cmd, leaseKey);
1120+
case 'paste-files':
1121+
return await handlePasteFiles(cmd, leaseKey);
11201122
case 'bind':
11211123
return await handleBind(cmd, leaseKey);
11221124
case 'network-capture-start':
@@ -1769,6 +1771,21 @@ async function handleInsertText(cmd: Command, leaseKey: string): Promise<Result>
17691771
}
17701772
}
17711773

1774+
async function handlePasteFiles(cmd: Command, leaseKey: string): Promise<Result> {
1775+
const files = cmd.clipboardFiles;
1776+
if (!Array.isArray(files) || files.length === 0) {
1777+
return { id: cmd.id, ok: false, error: 'Missing or empty clipboardFiles array' };
1778+
}
1779+
const cmdTabId = await resolveCommandTabId(cmd);
1780+
const tabId = await resolveTabId(cmdTabId, leaseKey);
1781+
try {
1782+
const count = await executor.pasteClipboardFiles(tabId, files, cmd.selector);
1783+
return pageScopedResult(cmd.id, tabId, { count });
1784+
} catch (err) {
1785+
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
1786+
}
1787+
}
1788+
17721789
async function handleNetworkCaptureStart(cmd: Command, leaseKey: string): Promise<Result> {
17731790
const cmdTabId = await resolveCommandTabId(cmd);
17741791
const tabId = await resolveTabId(cmdTabId, leaseKey);

extension/src/cdp.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,65 @@ export async function insertText(
510510
await chrome.debugger.sendCommand({ tabId }, 'Input.insertText', { text });
511511
}
512512

513+
/**
514+
* Dispatch a synthesized ClipboardEvent('paste') with a DataTransfer payload
515+
* built from the supplied files on the focused element, or on the element
516+
* matched by `selector` when provided. Mirrors the inline DataTransfer +
517+
* dispatch flow that adapters such as instagram/post, twitter/post,
518+
* chatgpt/utils and xiaohongshu/publish currently reinvent.
519+
*/
520+
export async function pasteClipboardFiles(
521+
tabId: number,
522+
files: Array<{ name: string; mimeType: string; base64: string }>,
523+
selector?: string,
524+
): Promise<number> {
525+
await ensureAttached(tabId);
526+
const targetExpr = selector
527+
? `document.querySelector(${JSON.stringify(selector)})`
528+
: 'document.activeElement && document.activeElement !== document.body ? document.activeElement : document.body';
529+
const expression = `
530+
(() => {
531+
const target = ${targetExpr};
532+
if (!target) return { ok: false, reason: 'no_target' };
533+
const files = ${JSON.stringify(files)};
534+
const dt = new DataTransfer();
535+
for (const f of files) {
536+
const binary = atob(f.base64);
537+
const bytes = new Uint8Array(binary.length);
538+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
539+
const blob = new Blob([bytes], { type: f.mimeType });
540+
dt.items.add(new File([blob], f.name, { type: f.mimeType }));
541+
}
542+
const event = new ClipboardEvent('paste', {
543+
clipboardData: dt,
544+
bubbles: true,
545+
cancelable: true,
546+
});
547+
const delivered = target.dispatchEvent(event);
548+
return { ok: true, count: files.length, delivered };
549+
})()
550+
`;
551+
const result = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
552+
expression,
553+
returnByValue: true,
554+
}) as {
555+
result?: { value?: { ok?: boolean; reason?: string; count?: number; delivered?: boolean } };
556+
exceptionDetails?: { exception?: { description?: string }; text?: string };
557+
};
558+
if (result.exceptionDetails) {
559+
const description = result.exceptionDetails.exception?.description ?? result.exceptionDetails.text ?? 'Unknown error';
560+
throw new Error(`paste-files evaluate failed: ${description}`);
561+
}
562+
const value = result.result?.value;
563+
if (!value?.ok) {
564+
const reason = value?.reason === 'no_target'
565+
? 'paste-files target not found (no focused element and no --target selector match)'
566+
: 'paste-files dispatch returned no acknowledgement';
567+
throw new Error(reason);
568+
}
569+
return value.count ?? files.length;
570+
}
571+
513572
export function registerFrameTracking(): void {
514573
registerFrameTargetCleanup();
515574
chrome.debugger.onEvent.addListener((source, method, params: any) => {

extension/src/protocol.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export type Action =
1515
| 'sessions'
1616
| 'set-file-input'
1717
| 'insert-text'
18+
| 'paste-files'
1819
| 'bind'
1920
| 'network-capture-start'
2021
| 'network-capture-read'
@@ -61,6 +62,8 @@ export interface Command {
6162
selector?: string;
6263
/** Raw text payload for insert-text action */
6364
text?: string;
65+
/** Base64-encoded files for paste-files action (name, mimeType, base64 content per entry) */
66+
clipboardFiles?: Array<{ name: string; mimeType: string; base64: string }>;
6467
/** URL substring filter pattern for network capture actions */
6568
pattern?: string;
6669
/** Download wait timeout in milliseconds */

src/browser/daemon-client.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ function generateId(): string {
2121

2222
export interface DaemonCommand {
2323
id: string;
24-
action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'set-file-input' | 'insert-text' | 'bind' | 'network-capture-start' | 'network-capture-read' | 'wait-download' | 'cdp' | 'frames';
24+
action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'set-file-input' | 'insert-text' | 'paste-files' | 'bind' | 'network-capture-start' | 'network-capture-read' | 'wait-download' | 'cdp' | 'frames';
2525
/** Target page identity (targetId). Cross-layer contract with the extension. */
2626
page?: string;
2727
code?: string;
@@ -47,6 +47,8 @@ export interface DaemonCommand {
4747
selector?: string;
4848
/** Raw text payload for insert-text action */
4949
text?: string;
50+
/** Base64-encoded files for paste-files action (name, mimeType, base64 content per entry) */
51+
clipboardFiles?: Array<{ name: string; mimeType: string; base64: string }>;
5052
/** URL substring filter pattern for network capture */
5153
pattern?: string;
5254
/** Download wait timeout in milliseconds */

src/browser/page.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import * as fs from 'node:fs';
2+
import * as os from 'node:os';
3+
import * as path from 'node:path';
14
import { beforeEach, describe, expect, it, vi } from 'vitest';
25

36
const { sendCommandMock, sendCommandFullMock } = vi.hoisted(() => ({
@@ -472,3 +475,54 @@ describe('Page.screenshot', () => {
472475
expect(args.fullPage).toBeUndefined();
473476
});
474477
});
478+
479+
describe('Page.pasteFiles', () => {
480+
beforeEach(() => {
481+
sendCommandMock.mockReset();
482+
sendCommandFullMock.mockReset();
483+
warnMock.mockReset();
484+
});
485+
486+
it('base64-encodes provided files and forwards them as clipboardFiles', async () => {
487+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-paste-'));
488+
const filePath = path.join(dir, 'note.png');
489+
fs.writeFileSync(filePath, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
490+
491+
try {
492+
sendCommandMock.mockResolvedValueOnce({ count: 1 });
493+
494+
const page = new Page('default');
495+
await expect(page.pasteFiles([filePath], '#composer')).resolves.toBeUndefined();
496+
497+
const call = sendCommandMock.mock.calls.at(-1);
498+
expect(call?.[0]).toBe('paste-files');
499+
const args = call?.[1] as Record<string, unknown>;
500+
expect(args.selector).toBe('#composer');
501+
expect(args.clipboardFiles).toEqual([
502+
{ name: 'note.png', mimeType: 'image/png', base64: 'iVBORw==' },
503+
]);
504+
} finally {
505+
fs.rmSync(dir, { recursive: true, force: true });
506+
}
507+
});
508+
509+
it('rejects an empty files list before reaching the daemon', async () => {
510+
const page = new Page('default');
511+
await expect(page.pasteFiles([])).rejects.toThrow('pasteFiles requires at least one file path');
512+
expect(sendCommandMock).not.toHaveBeenCalled();
513+
});
514+
515+
it('throws when the extension returns no count (unsupported action)', async () => {
516+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-paste-'));
517+
const filePath = path.join(dir, 'note.txt');
518+
fs.writeFileSync(filePath, 'hi');
519+
520+
try {
521+
sendCommandMock.mockResolvedValueOnce({});
522+
const page = new Page('default');
523+
await expect(page.pasteFiles([filePath])).rejects.toThrow(/no count/);
524+
} finally {
525+
fs.rmSync(dir, { recursive: true, force: true });
526+
}
527+
});
528+
});

src/browser/page.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
* page-scoped operations target the correct page without guessing.
1010
*/
1111

12+
import * as fs from 'node:fs';
13+
import * as path from 'node:path';
1214
import type { BrowserCookie, BrowserDownloadWaitResult, BrowserEvaluateFunction, ScreenshotOptions } from '../types.js';
1315
import { sendCommand, sendCommandFull } from './daemon-client.js';
1416
import { buildEvaluateExpression } from './utils.js';
@@ -19,6 +21,33 @@ import { BasePage } from './base-page.js';
1921
import { classifyBrowserError } from './errors.js';
2022
import { log } from '../logger.js';
2123

24+
/**
25+
* Extension-to-MIME map for `pasteFiles`. Covers the file types adapters in
26+
* this repo currently DataTransfer into chat composers and rich editors;
27+
* unknown extensions fall back to `application/octet-stream`, which browser
28+
* paste handlers usually still accept because they sniff content themselves.
29+
*/
30+
const CLIPBOARD_MIME_BY_EXT: Record<string, string> = {
31+
'.jpg': 'image/jpeg',
32+
'.jpeg': 'image/jpeg',
33+
'.png': 'image/png',
34+
'.gif': 'image/gif',
35+
'.webp': 'image/webp',
36+
'.bmp': 'image/bmp',
37+
'.svg': 'image/svg+xml',
38+
'.pdf': 'application/pdf',
39+
'.txt': 'text/plain',
40+
'.md': 'text/markdown',
41+
'.json': 'application/json',
42+
'.csv': 'text/csv',
43+
'.html': 'text/html',
44+
'.htm': 'text/html',
45+
'.mp4': 'video/mp4',
46+
'.webm': 'video/webm',
47+
'.mp3': 'audio/mpeg',
48+
'.wav': 'audio/wav',
49+
};
50+
2251
function isUnsupportedNetworkCaptureError(err: unknown): boolean {
2352
const message = err instanceof Error ? err.message : String(err);
2453
const normalized = message.toLowerCase();
@@ -323,6 +352,30 @@ export class Page extends BasePage {
323352
}
324353
}
325354

355+
async pasteFiles(files: string[], selector?: string): Promise<void> {
356+
if (!Array.isArray(files) || files.length === 0) {
357+
throw new Error('pasteFiles requires at least one file path');
358+
}
359+
const clipboardFiles = files.map((filePath) => {
360+
const absPath = path.resolve(filePath);
361+
const buffer = fs.readFileSync(absPath);
362+
const ext = path.extname(absPath).toLowerCase();
363+
return {
364+
name: path.basename(absPath),
365+
mimeType: CLIPBOARD_MIME_BY_EXT[ext] ?? 'application/octet-stream',
366+
base64: buffer.toString('base64'),
367+
};
368+
});
369+
const result = await sendCommand('paste-files', {
370+
clipboardFiles,
371+
selector,
372+
...this._cmdOpts(),
373+
}) as { count?: number };
374+
if (!result?.count) {
375+
throw new Error('pasteFiles returned no count; command may not be supported by the extension');
376+
}
377+
}
378+
326379
async frames(): Promise<Array<{ index: number; frameId: string; url: string; name: string }>> {
327380
const result = await sendCommand('frames', { ...this._cmdOpts() });
328381
return Array.isArray(result) ? result : [];

0 commit comments

Comments
 (0)