Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions extension/dist/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,46 @@ async function insertText(tabId, text) {
await ensureAttached(tabId);
await chrome.debugger.sendCommand({ tabId }, "Input.insertText", { text });
}
async function pasteClipboardFiles(tabId, files, selector) {
await ensureAttached(tabId);
const targetExpr = selector ? `document.querySelector(${JSON.stringify(selector)})` : "document.activeElement && document.activeElement !== document.body ? document.activeElement : document.body";
const expression = `
(() => {
const target = ${targetExpr};
if (!target) return { ok: false, reason: 'no_target' };
const files = ${JSON.stringify(files)};
const dt = new DataTransfer();
for (const f of files) {
const binary = atob(f.base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
const blob = new Blob([bytes], { type: f.mimeType });
dt.items.add(new File([blob], f.name, { type: f.mimeType }));
}
const event = new ClipboardEvent('paste', {
clipboardData: dt,
bubbles: true,
cancelable: true,
});
const delivered = target.dispatchEvent(event);
return { ok: true, count: files.length, delivered };
})()
`;
const result = await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", {
expression,
returnByValue: true
});
if (result.exceptionDetails) {
const description = result.exceptionDetails.exception?.description ?? result.exceptionDetails.text ?? "Unknown error";
throw new Error(`paste-files evaluate failed: ${description}`);
}
const value = result.result?.value;
if (!value?.ok) {
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";
throw new Error(reason);
}
return value.count ?? files.length;
}
function registerFrameTracking() {
registerFrameTargetCleanup();
chrome.debugger.onEvent.addListener((source, method, params) => {
Expand Down Expand Up @@ -1475,6 +1515,8 @@ async function handleCommand(cmd) {
return await handleSetFileInput(cmd, leaseKey);
case "insert-text":
return await handleInsertText(cmd, leaseKey);
case "paste-files":
return await handlePasteFiles(cmd, leaseKey);
case "bind":
return await handleBind(cmd, leaseKey);
case "network-capture-start":
Expand Down Expand Up @@ -2020,6 +2062,20 @@ async function handleInsertText(cmd, leaseKey) {
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
async function handlePasteFiles(cmd, leaseKey) {
const files = cmd.clipboardFiles;
if (!Array.isArray(files) || files.length === 0) {
return { id: cmd.id, ok: false, error: "Missing or empty clipboardFiles array" };
}
const cmdTabId = await resolveCommandTabId(cmd);
const tabId = await resolveTabId(cmdTabId, leaseKey);
try {
const count = await pasteClipboardFiles(tabId, files, cmd.selector);
return pageScopedResult(cmd.id, tabId, { count });
} catch (err) {
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
async function handleNetworkCaptureStart(cmd, leaseKey) {
const cmdTabId = await resolveCommandTabId(cmd);
const tabId = await resolveTabId(cmdTabId, leaseKey);
Expand Down
17 changes: 17 additions & 0 deletions extension/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1117,6 +1117,8 @@ async function handleCommand(cmd: Command): Promise<Result> {
return await handleSetFileInput(cmd, leaseKey);
case 'insert-text':
return await handleInsertText(cmd, leaseKey);
case 'paste-files':
return await handlePasteFiles(cmd, leaseKey);
case 'bind':
return await handleBind(cmd, leaseKey);
case 'network-capture-start':
Expand Down Expand Up @@ -1769,6 +1771,21 @@ async function handleInsertText(cmd: Command, leaseKey: string): Promise<Result>
}
}

async function handlePasteFiles(cmd: Command, leaseKey: string): Promise<Result> {
const files = cmd.clipboardFiles;
if (!Array.isArray(files) || files.length === 0) {
return { id: cmd.id, ok: false, error: 'Missing or empty clipboardFiles array' };
}
const cmdTabId = await resolveCommandTabId(cmd);
const tabId = await resolveTabId(cmdTabId, leaseKey);
try {
const count = await executor.pasteClipboardFiles(tabId, files, cmd.selector);
return pageScopedResult(cmd.id, tabId, { count });
} catch (err) {
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
}
}

async function handleNetworkCaptureStart(cmd: Command, leaseKey: string): Promise<Result> {
const cmdTabId = await resolveCommandTabId(cmd);
const tabId = await resolveTabId(cmdTabId, leaseKey);
Expand Down
62 changes: 62 additions & 0 deletions extension/src/cdp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,68 @@ export async function insertText(
await chrome.debugger.sendCommand({ tabId }, 'Input.insertText', { text });
}

/**
* Dispatch a synthesized ClipboardEvent('paste') with a DataTransfer payload
* built from the supplied files on the currently focused element, or on the
* element matched by `selector` when provided. Targets web apps whose upload
* flow only listens to clipboard paste events.
*
* @param tabId - Target tab ID
* @param files - Array of file entries (name, mimeType, base64 content)
* @param selector - Optional CSS selector for the paste target; defaults to document.activeElement
*/
export async function pasteClipboardFiles(
tabId: number,
files: Array<{ name: string; mimeType: string; base64: string }>,
selector?: string,
): Promise<number> {
await ensureAttached(tabId);
const targetExpr = selector
? `document.querySelector(${JSON.stringify(selector)})`
: 'document.activeElement && document.activeElement !== document.body ? document.activeElement : document.body';
const expression = `
(() => {
const target = ${targetExpr};
if (!target) return { ok: false, reason: 'no_target' };
const files = ${JSON.stringify(files)};
const dt = new DataTransfer();
for (const f of files) {
const binary = atob(f.base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
const blob = new Blob([bytes], { type: f.mimeType });
dt.items.add(new File([blob], f.name, { type: f.mimeType }));
}
const event = new ClipboardEvent('paste', {
clipboardData: dt,
bubbles: true,
cancelable: true,
});
const delivered = target.dispatchEvent(event);
return { ok: true, count: files.length, delivered };
})()
`;
Comment on lines +529 to +553
const result = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
expression,
returnByValue: true,
}) as {
result?: { value?: { ok?: boolean; reason?: string; count?: number; delivered?: boolean } };
exceptionDetails?: { exception?: { description?: string }; text?: string };
};
if (result.exceptionDetails) {
const description = result.exceptionDetails.exception?.description ?? result.exceptionDetails.text ?? 'Unknown error';
throw new Error(`paste-files evaluate failed: ${description}`);
}
const value = result.result?.value;
if (!value?.ok) {
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';
throw new Error(reason);
}
return value.count ?? files.length;
}

export function registerFrameTracking(): void {
registerFrameTargetCleanup();
chrome.debugger.onEvent.addListener((source, method, params: any) => {
Expand Down
3 changes: 3 additions & 0 deletions extension/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type Action =
| 'sessions'
| 'set-file-input'
| 'insert-text'
| 'paste-files'
| 'bind'
| 'network-capture-start'
| 'network-capture-read'
Expand Down Expand Up @@ -61,6 +62,8 @@ export interface Command {
selector?: string;
/** Raw text payload for insert-text action */
text?: string;
/** Base64-encoded files for paste-files action (name, mimeType, base64 content per entry) */
clipboardFiles?: Array<{ name: string; mimeType: string; base64: string }>;
/** URL substring filter pattern for network capture actions */
pattern?: string;
/** Download wait timeout in milliseconds */
Expand Down
4 changes: 3 additions & 1 deletion src/browser/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ function generateId(): string {

export interface DaemonCommand {
id: string;
action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'set-file-input' | 'insert-text' | 'bind' | 'network-capture-start' | 'network-capture-read' | 'wait-download' | 'cdp' | 'frames';
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';
/** Target page identity (targetId). Cross-layer contract with the extension. */
page?: string;
code?: string;
Expand All @@ -47,6 +47,8 @@ export interface DaemonCommand {
selector?: string;
/** Raw text payload for insert-text action */
text?: string;
/** Base64-encoded files for paste-files action (name, mimeType, base64 content per entry) */
clipboardFiles?: Array<{ name: string; mimeType: string; base64: string }>;
/** URL substring filter pattern for network capture */
pattern?: string;
/** Download wait timeout in milliseconds */
Expand Down
54 changes: 54 additions & 0 deletions src/browser/page.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { beforeEach, describe, expect, it, vi } from 'vitest';

const { sendCommandMock, sendCommandFullMock } = vi.hoisted(() => ({
Expand Down Expand Up @@ -472,3 +475,54 @@ describe('Page.screenshot', () => {
expect(args.fullPage).toBeUndefined();
});
});

describe('Page.pasteFiles', () => {
beforeEach(() => {
sendCommandMock.mockReset();
sendCommandFullMock.mockReset();
warnMock.mockReset();
});

it('base64-encodes provided files and forwards them as clipboardFiles', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-paste-'));
const filePath = path.join(dir, 'note.png');
fs.writeFileSync(filePath, Buffer.from([0x89, 0x50, 0x4e, 0x47]));

try {
sendCommandMock.mockResolvedValueOnce({ count: 1 });

const page = new Page('default');
await expect(page.pasteFiles([filePath], '#composer')).resolves.toBeUndefined();

const call = sendCommandMock.mock.calls.at(-1);
expect(call?.[0]).toBe('paste-files');
const args = call?.[1] as Record<string, unknown>;
expect(args.selector).toBe('#composer');
expect(args.clipboardFiles).toEqual([
{ name: 'note.png', mimeType: 'image/png', base64: 'iVBORw==' },
]);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});

it('rejects an empty files list before reaching the daemon', async () => {
const page = new Page('default');
await expect(page.pasteFiles([])).rejects.toThrow('pasteFiles requires at least one file path');
expect(sendCommandMock).not.toHaveBeenCalled();
});

it('throws when the extension returns no count (unsupported action)', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-paste-'));
const filePath = path.join(dir, 'note.txt');
fs.writeFileSync(filePath, 'hi');

try {
sendCommandMock.mockResolvedValueOnce({});
const page = new Page('default');
await expect(page.pasteFiles([filePath])).rejects.toThrow(/no count/);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
53 changes: 53 additions & 0 deletions src/browser/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
* page-scoped operations target the correct page without guessing.
*/

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

/**
* File-extension to MIME-type lookup used by `pasteFiles` to populate
* DataTransfer item types. Unknown extensions fall back to
* `application/octet-stream`, which browser paste handlers usually still
* accept because they sniff content themselves.
*/
const CLIPBOARD_MIME_BY_EXT: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.bmp': 'image/bmp',
'.svg': 'image/svg+xml',
'.pdf': 'application/pdf',
'.txt': 'text/plain',
'.md': 'text/markdown',
'.json': 'application/json',
'.csv': 'text/csv',
'.html': 'text/html',
'.htm': 'text/html',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
};

function isUnsupportedNetworkCaptureError(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err);
const normalized = message.toLowerCase();
Expand Down Expand Up @@ -323,6 +352,30 @@ export class Page extends BasePage {
}
}

async pasteFiles(files: string[], selector?: string): Promise<void> {
if (!Array.isArray(files) || files.length === 0) {
throw new Error('pasteFiles requires at least one file path');
}
const clipboardFiles = files.map((filePath) => {
const absPath = path.resolve(filePath);
const buffer = fs.readFileSync(absPath);
const ext = path.extname(absPath).toLowerCase();
return {
name: path.basename(absPath),
mimeType: CLIPBOARD_MIME_BY_EXT[ext] ?? 'application/octet-stream',
base64: buffer.toString('base64'),
};
});
Comment on lines +355 to +368
const result = await sendCommand('paste-files', {
clipboardFiles,
selector,
...this._cmdOpts(),
}) as { count?: number };
if (!result?.count) {
throw new Error('pasteFiles returned no count; command may not be supported by the extension');
}
Comment on lines +369 to +376
}

async frames(): Promise<Array<{ index: number; frameId: string; url: string; name: string }>> {
const result = await sendCommand('frames', { ...this._cmdOpts() });
return Array.isArray(result) ? result : [];
Expand Down
Loading
Loading