feat(bsk): fix upload function that cannot bypass layout

This commit is contained in:
Ljy-0827
2026-08-31 17:58:33 +08:00
parent 995061514c
commit d5de9b0154
20 changed files with 1033 additions and 630 deletions
+41 -108
View File
@@ -1,31 +1,16 @@
// One-click/one-file browser download capture. chrome.downloads events are
// browser-global, so a coordinator prevents two bsk sessions from racing.
// Download orchestration: validate session/tab ownership, then delegate one
// browser-global chrome.downloads transaction to download-capture.ts.
import type { SessionManager } from "@/session-manager/manager";
import type { ClickParams, DownloadParams, DownloadResult, RpcError } from "@/transport/types";
import { handleClick, type InteractionDeps } from "./interaction";
import { captureBrowserDownload, chromeDownloadsApi, type DownloadsApi } from "./download-capture";
import { handleClick, type InteractionDeps, resolveBackendNode } from "./interaction";
import { enforceAgentWindow, isRpcError, lookupSession, resolveTargetTab } from "./shared";
export interface DownloadsApi {
onCreated: chrome.events.Event<(item: chrome.downloads.DownloadItem) => void>;
onChanged: chrome.events.Event<(delta: chrome.downloads.DownloadDelta) => void>;
search(query: chrome.downloads.DownloadQuery): Promise<chrome.downloads.DownloadItem[]>;
cancel(downloadId: number): Promise<void>;
}
const chromeDownloadsApi: DownloadsApi = {
get onCreated() {
return chrome.downloads.onCreated;
},
get onChanged() {
return chrome.downloads.onChanged;
},
search: (query) => chrome.downloads.search(query),
cancel: (id) => chrome.downloads.cancel(id),
};
let downloadActive = false;
export type { DownloadsApi } from "./download-capture";
export interface DownloadDeps extends InteractionDeps {
downloads?: DownloadsApi;
}
@@ -37,11 +22,6 @@ export async function handleDownload(
): Promise<DownloadResult | RpcError> {
if (downloadActive) return { code: "invalid_params", message: "another bsk download is active" };
downloadActive = true;
const downloads = deps.downloads ?? chromeDownloadsApi;
let capturedId: number | undefined;
let createdListener: ((item: chrome.downloads.DownloadItem) => void) | undefined;
let changedListener: ((delta: chrome.downloads.DownloadDelta) => void) | undefined;
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const ctx = lookupSession(manager, params, "download");
if (isRpcError(ctx)) return ctx;
@@ -49,90 +29,43 @@ export async function handleDownload(
if (isRpcError(target)) return target;
const denied = enforceAgentWindow(ctx, target, "download");
if (denied) return denied;
if (!params.staging_path)
return { code: "invalid_params", message: "download requires daemon staging" };
const normalisePath = (path: string) => path.replaceAll("\\", "/");
const normalisedStaging = normalisePath(params.staging_path);
const stagingPrefix = normalisedStaging.endsWith("/")
? normalisedStaging
: `${normalisedStaging}/`;
let rejectCompleted!: (error: Error) => void;
const completed = new Promise<chrome.downloads.DownloadItem>((resolve, reject) => {
rejectCompleted = reject;
createdListener = (item) => {
if (!normalisePath(item.filename).startsWith(stagingPrefix)) return;
if (capturedId !== undefined && capturedId !== item.id) {
reject(new Error("download trigger produced more than one file"));
return;
}
capturedId = item.id;
if (item.state === "complete") resolve(item);
};
changedListener = async (delta) => {
if (capturedId === undefined || delta.id !== capturedId) return;
if (delta.state?.current === "interrupted" || delta.error?.current) {
reject(new Error(delta.error?.current ?? "download interrupted"));
return;
}
if (delta.state?.current === "complete") {
const [item] = await downloads.search({ id: delta.id });
if (item) resolve(item);
else reject(new Error("completed download disappeared"));
}
};
downloads.onCreated.addListener(createdListener);
downloads.onChanged.addListener(changedListener);
timer = setTimeout(
() => reject(new Error("download did not complete before timeout")),
params.timeout_ms ?? 120_000,
);
});
const onAbort = () => {
if (capturedId !== undefined) void downloads.cancel(capturedId).catch(() => undefined);
rejectCompleted(new DOMException("aborted", "AbortError"));
};
deps.signal?.addEventListener("abort", onAbort, { once: true });
try {
await deps.cdp.send(target.tabId, "Page.setDownloadBehavior", {
behavior: "allow",
downloadPath: params.staging_path,
});
const clickParams: ClickParams = {
session_id: params.session_id,
ref: params.ref,
selector: params.selector,
tab_id: params.tab_id,
timeout_ms: params.timeout_ms,
};
const clicked = await handleClick(manager, clickParams, deps);
if (isRpcError(clicked)) {
void completed.catch(() => undefined);
return clicked;
}
const item = await completed;
return {
tab_id: target.tabId,
used_ref: clicked.used_ref,
used_selector: clicked.used_selector,
suggested_filename: item.filename.split(/[\\/]/).pop() ?? "download",
byte_size: item.fileSize >= 0 ? item.fileSize : item.totalBytes,
mime: item.mime || undefined,
danger: item.danger,
staged_path: item.filename,
};
} finally {
deps.signal?.removeEventListener("abort", onAbort);
try {
await deps.cdp.send(target.tabId, "Page.setDownloadBehavior", { behavior: "default" });
} catch {}
if (!params.browser_relative_dir) {
return { code: "invalid_params", message: "download requires a daemon capability directory" };
}
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") throw err;
return { code: "cdp_failed", message: err instanceof Error ? err.message : String(err) };
const address = await resolveBackendNode(deps.cdp, ctx, target, params, "download");
if (isRpcError(address)) return address;
const capture = await captureBrowserDownload({
cdp: deps.cdp,
target: address.cdpTarget,
downloads: deps.downloads ?? chromeDownloadsApi,
browserRelativeDir: params.browser_relative_dir,
timeoutMs: params.timeout_ms ?? 120_000,
signal: deps.signal,
trigger: () => {
const clickParams: ClickParams = {
session_id: params.session_id,
ref: params.ref,
selector: params.selector,
tab_id: params.tab_id,
timeout_ms: params.timeout_ms,
};
return handleClick(manager, clickParams, deps);
},
});
if (isRpcError(capture)) return capture;
const { click, item } = capture;
return {
tab_id: target.tabId,
used_ref: click.used_ref,
used_selector: click.used_selector,
suggested_filename: item.filename.split(/[\\/]/).pop() ?? "download",
byte_size: item.fileSize >= 0 ? item.fileSize : item.totalBytes,
mime: item.mime || undefined,
danger: item.danger,
browser_path: item.filename,
};
} finally {
if (timer) clearTimeout(timer);
if (createdListener) downloads.onCreated.removeListener(createdListener);
if (changedListener) downloads.onChanged.removeListener(changedListener);
downloadActive = false;
}
}