Skip to content
This repository was archived by the owner on Apr 25, 2026. It is now read-only.
Merged
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
117 changes: 116 additions & 1 deletion extension/src/background/service-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ async function handleRequest(req: Request): Promise<Response> {
return await forwardToContent(req);
case 'screenshot':
return await handleScreenshot(req);
case 'download':
return await handleDownload(req);
default:
return { id: req.id, ok: false, error: `Unknown action: ${req.action}` };
}
Expand Down Expand Up @@ -342,6 +344,119 @@ async function handleScreenshot(req: Request): Promise<Response> {
};
}

async function handleDownload(req: Request): Promise<Response> {
const session = sessionFromRequest(req);
if (!session.ok) {
return { id: req.id, ok: false, error: session.error };
}

const target = req.params.target;
if (typeof target !== 'string' || target.length === 0) {
return { id: req.id, ok: false, error: 'target is required' };
}

let url: string;

// Check if target looks like a URL (contains :// or starts with //)
if (/^https?:\/\//.test(target) || target.startsWith('//')) {
url = target.startsWith('//') ? `https:${target}` : target;
} else {
// Treat as element ref — resolve via content script
const result = await sendToContent(session.value.tab_id, {
type: 'resolve_url',
params: {
session_id: session.value.session_id,
request_id: req.id,
ref: target,
},
});
if (!result.ok || !result.data?.url) {
return {
id: req.id,
ok: false,
error: result.error ?? `Could not resolve URL for target: ${target}`,
};
}
url = result.data.url as string;
}

try {
const response = await fetch(url, { credentials: 'include' });
if (!response.ok) {
return {
id: req.id,
ok: false,
error: `Download failed: HTTP ${response.status} ${response.statusText}`,
};
}

const contentType = response.headers.get('content-type') ?? 'application/octet-stream';
const buffer = await response.arrayBuffer();
const size = buffer.byteLength;

// Extract filename from Content-Disposition or URL
let filename = 'download';
const disposition = response.headers.get('content-disposition');
if (disposition) {
const match = /filename\*?=(?:UTF-8''|"?)([^";]+)"?/i.exec(disposition);
if (match?.[1]) {
filename = decodeURIComponent(match[1]);
}
} else {
try {
const urlPath = new URL(url).pathname;
const lastSegment = urlPath.split('/').filter(Boolean).pop();
if (lastSegment && lastSegment.includes('.')) {
filename = decodeURIComponent(lastSegment);
}
} catch {
// keep default
}
}

// Stream base64 data as download_chunk messages through the native port
// to stay under Chrome's native messaging size limits.
const bytes = new Uint8Array(buffer);
const CHUNK_SIZE = 3 * 1024 * 1024; // 3MB raw -> ~4MB base64, safe under limits
const totalChunks = Math.max(1, Math.ceil(bytes.length / CHUNK_SIZE));

for (let i = 0; i < totalChunks; i++) {
const start = i * CHUNK_SIZE;
const slice = bytes.subarray(start, Math.min(start + CHUNK_SIZE, bytes.length));

// Encode slice to base64 in sub-chunks to avoid call stack limits
let binary = '';
for (let j = 0; j < slice.length; j += 8192) {
const sub = slice.subarray(j, Math.min(j + 8192, slice.length));
binary += String.fromCharCode(...sub);
}
const chunkData = btoa(binary);

port?.postMessage({
type: 'download_chunk',
session_id: session.value.session_id,
request_id: req.id,
chunk_index: i,
data: chunkData,
done: i === totalChunks - 1,
...(i === 0 ? { filename, content_type: contentType, size } : {}),
});
}

return {
id: req.id,
ok: true,
data: { streamed: true, filename, content_type: contentType, size },
};
} catch (error) {
return {
id: req.id,
ok: false,
error: `Download failed: ${error instanceof Error ? error.message : String(error)}`,
};
}
}

function sessionFromRequest(
req: Request,
): { ok: true; value: Session } | { ok: false; error: string } {
Expand Down Expand Up @@ -618,7 +733,7 @@ function isChunkEvent(message: unknown): message is ChunkEvent {
return false;
}
const value = message as { type?: unknown; chunk?: unknown };
return value.type === 'page_chunk' && typeof value.chunk === 'object';
return (value.type === 'page_chunk' || value.type === 'download_chunk') && typeof value.chunk === 'object';
}

chrome.tabs.onRemoved.addListener((tabId) => {
Expand Down
28 changes: 28 additions & 0 deletions extension/src/content/content-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,8 @@ async function handleMessage(req: ContentRequest): Promise<ContentResponse> {
return handlePresenceStart(req);
case 'presence_stop':
return handlePresenceStop(req);
case 'resolve_url':
return handleResolveUrl(req);
default:
return {
ok: false,
Expand Down Expand Up @@ -494,6 +496,32 @@ function handlePresenceStop(req: ContentRequest): ContentResponse {
};
}

function handleResolveUrl(req: ContentRequest): ContentResponse {
const refId = requireString(req.params.ref, 'ref');
const target = resolveTarget(refId);
if (!target) {
return { ok: false, error: `Element not found: ${refId}` };
}

const url =
target.getAttribute('src') ??
target.getAttribute('href') ??
(target as HTMLObjectElement).data ??
null;

if (!url) {
return { ok: false, error: `Element ${refId} has no src or href attribute` };
}

// Resolve to absolute URL using the page's base URL
try {
const absolute = new URL(url, document.baseURI).href;
return { ok: true, data: { url: absolute } };
} catch {
return { ok: false, error: `Invalid URL: ${url}` };
}
}

async function handleSnapshot(req: ContentRequest): Promise<ContentResponse> {
const sessionId = requireString(req.params.session_id, 'session_id');
const requestId = requireString(req.params.request_id, 'request_id');
Expand Down
18 changes: 15 additions & 3 deletions extension/src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export interface PageChunk {
}

export interface ContentRequest {
type: 'snapshot' | 'click' | 'type' | 'wait' | 'presence_start' | 'presence_stop';
type: 'snapshot' | 'click' | 'type' | 'wait' | 'presence_start' | 'presence_stop' | 'resolve_url';
params: Record<string, unknown>;
}

Expand All @@ -68,7 +68,19 @@ export interface ContentResponse {
error?: string;
}

export interface DownloadChunk {
type: 'download_chunk';
session_id: string;
request_id: string;
chunk_index: number;
data: string;
done: boolean;
filename?: string;
content_type?: string;
size?: number;
}

export interface ChunkEvent {
type: 'page_chunk';
chunk: PageChunk;
type: 'page_chunk' | 'download_chunk';
chunk: PageChunk | DownloadChunk;
}
Loading