feat(cdp): implement Browser.setDownloadBehavior file downloads

Browser.setDownloadBehavior was a noop, so Lightpanda had no file-download
path. A response with Content-Disposition: attachment is now streamed to disk
under downloadPath, and Page.downloadWillBegin / Browser.downloadProgress are
emitted when eventsEnabled.

Fixes #2701
This commit is contained in:
Armaan Sandhu
2026-06-12 14:50:05 +05:30
committed by Pierre Tachoire
parent de046cec36
commit 8ba54850db
8 changed files with 493 additions and 8 deletions

View File

@@ -96,6 +96,8 @@ const EventListeners = struct {
model_context_tool_added: List = .{},
model_context_tool_removed: List = .{},
cookie_changed: List = .{},
download_will_begin: List = .{},
download_progress: List = .{},
};
const Events = union(enum) {
@@ -123,6 +125,8 @@ const Events = union(enum) {
model_context_tool_added: *const ModelContextToolEvent,
model_context_tool_removed: *const ModelContextToolEvent,
cookie_changed: *const CookieChanged,
download_will_begin: *const DownloadWillBegin,
download_progress: *const DownloadProgress,
};
const EventType = std.meta.FieldEnum(Events);
@@ -271,6 +275,34 @@ pub const CookieChanged = struct {
pub const Kind = enum { changed, deleted };
};
// Emitted by Frame when a navigation response is treated as a file download
// (Content-Disposition: attachment) under an `allow`/`allowAndName`
// `Browser.setDownloadBehavior`. The CDP page domain turns this into a
// `Page.downloadWillBegin` event. All fields point at Frame-owned memory that
// outlives the synchronous dispatch.
pub const DownloadWillBegin = struct {
frame_id: u32,
guid: []const u8,
url: []const u8,
suggested_filename: []const u8,
};
// Emitted by Frame as a download is written to disk: once when it starts
// (`.in_progress`) and once when the body is fully written (`.completed`).
// The CDP browser domain turns this into a `Browser.downloadProgress` event.
pub const DownloadProgress = struct {
guid: []const u8,
total_bytes: u64,
received_bytes: u64,
state: State,
pub const State = enum {
in_progress,
completed,
canceled,
};
};
pub const DialogResponse = struct {
accept: bool = false,
// Set when the CDP client sent a `promptText` with `accept: true`. Memory

View File

@@ -1198,9 +1198,167 @@ fn frameHeaderDoneCallback(response: HttpClient.Response) !HttpClient.HeaderResu
});
}
// If the response is a file download, stream its body to disk instead of
// parsing it as a page. This sets _parse_state to .download, which the
// data/done callbacks below special-case.
_ = try self.maybeStartDownload(response);
return .proceed;
}
// Returns true when the response was set up as a file download. A response is
// treated as a download when Browser.setDownloadBehavior opted in
// (allow/allowAndName) and the response carries Content-Disposition: attachment.
// See issue #2701.
fn maybeStartDownload(self: *Frame, response: HttpClient.Response) !bool {
const session = self._session;
switch (session.download_behavior) {
.allow, .allow_and_name => {},
.default, .deny => return false,
}
const disposition = blk: {
var it = response.headerIterator();
while (it.next()) |hdr| {
if (std.ascii.eqlIgnoreCase(hdr.name, "content-disposition")) {
break :blk hdr.value;
}
}
return false;
};
if (isAttachment(disposition) == false) {
return false;
}
const download_path = session.download_path orelse {
log.warn(.frame, "download without downloadPath", .{ .url = self.url });
return false;
};
var guid_buf: [36]u8 = undefined;
@import("../id.zig").uuidv4(&guid_buf);
const guid = try self.arena.dupe(u8, &guid_buf);
const suggested = dispositionFilename(disposition) orelse urlBasename(self.url) orelse guid;
const suggested_filename = try self.arena.dupe(u8, suggested);
// allowAndName stores the file under its guid; allow uses the suggested name.
const on_disk_name = switch (session.download_behavior) {
.allow_and_name => guid,
else => suggested_filename,
};
std.fs.cwd().makePath(download_path) catch |err| {
log.err(.frame, "download makePath", .{ .err = err, .path = download_path });
return false;
};
var dir = std.fs.cwd().openDir(download_path, .{}) catch |err| {
log.err(.frame, "download openDir", .{ .err = err, .path = download_path });
return false;
};
defer dir.close();
const file = dir.createFile(on_disk_name, .{ .truncate = true }) catch |err| {
log.err(.frame, "download createFile", .{ .err = err, .name = on_disk_name });
return false;
};
const total: ?u64 = if (response.contentLength()) |cl| cl else null;
self._parse_state = .{ .download = .{
.guid = guid,
.file = file,
.filename = suggested_filename,
.received = 0,
.total = total,
} };
if (session.download_events_enabled) {
session.notification.dispatch(.download_will_begin, &.{
.frame_id = self._frame_id,
.guid = guid,
.url = self.url,
.suggested_filename = suggested_filename,
});
session.notification.dispatch(.download_progress, &.{
.guid = guid,
.total_bytes = total orelse 0,
.received_bytes = 0,
.state = .in_progress,
});
}
return true;
}
// True when a Content-Disposition value's type token is "attachment".
fn isAttachment(disposition: []const u8) bool {
const semi = std.mem.indexOfScalar(u8, disposition, ';') orelse disposition.len;
const token = std.mem.trim(u8, disposition[0..semi], " \t");
return std.ascii.eqlIgnoreCase(token, "attachment");
}
// Extracts the filename from a Content-Disposition value, handling the quoted,
// unquoted, and RFC 5987 (filename*=charset''value) forms. Path components are
// stripped so the result is always a bare basename.
fn dispositionFilename(disposition: []const u8) ?[]const u8 {
// Prefer the extended filename*= form when present, per RFC 6266.
if (paramValue(disposition, "filename*")) |ext| {
// charset'lang'value — take everything after the second quote.
if (std.mem.indexOfScalar(u8, ext, '\'')) |first| {
if (std.mem.indexOfScalarPos(u8, ext, first + 1, '\'')) |second| {
return basename(ext[second + 1 ..]);
}
}
return basename(ext);
}
if (paramValue(disposition, "filename")) |name| {
return basename(name);
}
return null;
}
// Returns the (optionally quoted) value of `key=` within a parameter list.
fn paramValue(disposition: []const u8, key: []const u8) ?[]const u8 {
var rest = disposition;
while (std.mem.indexOfScalar(u8, rest, ';')) |semi| {
const param = std.mem.trim(u8, rest[semi + 1 ..][0 .. (std.mem.indexOfScalarPos(u8, rest, semi + 1, ';') orelse rest.len) - semi - 1], " \t");
if (std.mem.indexOfScalar(u8, param, '=')) |eq| {
const name = std.mem.trim(u8, param[0..eq], " \t");
if (std.ascii.eqlIgnoreCase(name, key)) {
var value = std.mem.trim(u8, param[eq + 1 ..], " \t");
if (value.len >= 2 and value[0] == '"' and value[value.len - 1] == '"') {
value = value[1 .. value.len - 1];
}
if (value.len > 0) {
return value;
}
}
}
rest = rest[semi + 1 ..];
}
return null;
}
// Strips any directory components, guarding against path traversal.
fn basename(name: []const u8) ?[]const u8 {
var out = name;
if (std.mem.lastIndexOfAny(u8, out, "/\\")) |i| {
out = out[i + 1 ..];
}
if (out.len == 0 or std.mem.eql(u8, out, ".") or std.mem.eql(u8, out, "..")) {
return null;
}
return out;
}
// Derives a filename from a URL's last path segment, ignoring query/fragment.
fn urlBasename(url: []const u8) ?[]const u8 {
var path = url;
if (std.mem.indexOfScalar(u8, path, '?')) |i| path = path[0..i];
if (std.mem.indexOfScalar(u8, path, '#')) |i| path = path[0..i];
return basename(path);
}
fn frameDataCallback(response: HttpClient.Response, data: []const u8) !void {
var self: *Frame = @ptrCast(@alignCast(response.ctx));
@@ -1279,6 +1437,13 @@ fn frameDataCallback(response: HttpClient.Response, data: []const u8) !void {
}
},
.raw, .image => |*buf| try buf.appendSlice(self.arena, data),
.download => |*download| {
download.file.writeAll(data) catch |err| {
log.err(.frame, "download write", .{ .err = err, .guid = download.guid });
return;
};
download.received += data.len;
},
.pre => unreachable,
.complete => unreachable,
.err => unreachable,
@@ -1373,6 +1538,30 @@ fn frameDoneCallback(ctx: *anyopaque) !void {
self._parse_state = .complete;
self.documentIsComplete();
},
.download => |*download| {
download.file.close();
// Capture before invalidating the union below.
const guid = download.guid;
const received = download.received;
const total = download.total orelse download.received;
self._parse_state = .complete;
const session = self._session;
if (session.download_events_enabled) {
session.notification.dispatch(.download_progress, &.{
.guid = guid,
.total_bytes = total,
.received_bytes = received,
.state = .completed,
});
}
// The body went to disk; commit an empty document so the frame
// navigation still completes cleanly (mirrors the .raw path).
parser.parse("<html><head><meta charset=\"utf-8\"></head><body></body></html>");
self.documentIsComplete();
},
else => unreachable,
}
}
@@ -3793,15 +3982,34 @@ const ParseState = union(enum) {
image: std.ArrayList(u8),
raw: std.ArrayList(u8),
raw_done: []const u8,
download: Download,
fn deinit(self: *ParseState, frame: *Frame) void {
switch (self.*) {
.html => |html| frame.releaseArena(html.arena),
// Only reached when a frame is torn down mid-download (the normal
// completion path in frameDoneCallback already closes the file and
// transitions to .complete).
.download => |*download| download.file.close(),
else => {},
}
}
};
// An in-flight file download (Content-Disposition: attachment under an
// allow/allowAndName Browser.setDownloadBehavior). The response body is
// streamed straight to `file` rather than parsed as a page. See issue #2701.
const Download = struct {
// uuidv4, arena-owned. Matches the guid reported in the CDP events.
guid: []const u8,
file: std.fs.File,
// suggested filename surfaced to the client, arena-owned.
filename: []const u8,
received: u64,
// from Content-Length, when the response advertised one.
total: ?u64,
};
const LoadState = enum {
// waiting for the main HTML
waiting,
@@ -4644,6 +4852,35 @@ fn asUint(comptime string: anytype) std.meta.Int(
}
const testing = @import("../testing.zig");
test "Frame: isAttachment" {
try testing.expectEqual(true, isAttachment("attachment"));
try testing.expectEqual(true, isAttachment("attachment; filename=\"a.csv\""));
try testing.expectEqual(true, isAttachment(" ATTACHMENT ; filename=a.csv"));
try testing.expectEqual(false, isAttachment("inline"));
try testing.expectEqual(false, isAttachment("inline; filename=a.csv"));
try testing.expectEqual(false, isAttachment(""));
}
test "Frame: dispositionFilename" {
try testing.expectEqualSlices(u8, "report.csv", dispositionFilename("attachment; filename=\"report.csv\"").?);
try testing.expectEqualSlices(u8, "report.csv", dispositionFilename("attachment; filename=report.csv").?);
try testing.expectEqualSlices(u8, "r e.csv", dispositionFilename("attachment; filename=\"r e.csv\"").?);
// RFC 5987 extended form is preferred over the plain filename when present
// (the value is taken verbatim after the charset'lang' prefix).
try testing.expectEqualSlices(u8, "extended.txt", dispositionFilename("attachment; filename=\"fallback.txt\"; filename*=UTF-8''extended.txt").?);
try testing.expect(dispositionFilename("attachment") == null);
// Path components are stripped to guard against traversal.
try testing.expectEqualSlices(u8, "evil.sh", dispositionFilename("attachment; filename=\"../../evil.sh\"").?);
try testing.expect(dispositionFilename("attachment; filename=\"..\"") == null);
}
test "Frame: urlBasename" {
try testing.expectEqualSlices(u8, "report.csv", urlBasename("http://x.com/a/b/report.csv").?);
try testing.expectEqualSlices(u8, "report.csv", urlBasename("http://x.com/report.csv?v=1#x").?);
try testing.expect(urlBasename("http://x.com/") == null);
}
test "WebApi: Frame" {
const filter: testing.LogFilter = .init(&.{.http});
defer filter.deinit();

View File

@@ -164,7 +164,7 @@ fn _tick(self: *Runner, comptime is_cdp: bool, opts: TickOpts) !TickResult {
const http_client = self.http_client;
switch (frame._parse_state) {
.pre, .raw, .text, .image => {
.pre, .raw, .text, .image, .download => {
// The main frame hasn't started/finished navigating.
// There's no JS to run, and no reason to run the scheduler
// — unless we're the CDP worker, in which case we want

View File

@@ -112,6 +112,24 @@ load_external_stylesheets: bool = false,
/// timeout.
cancel_hook: ?CancelHook = null,
// Download handling configured via the `Browser.setDownloadBehavior` CDP
// method (see issue #2701). When `download_behavior` is `.allow` or
// `.allow_and_name`, a navigation whose response carries
// `Content-Disposition: attachment` is written to `download_path` instead
// of being parsed as a page, and (when `download_events_enabled` is set)
// `Page.downloadWillBegin` / `Browser.downloadProgress` events are emitted.
// `download_path` is duped into the Session arena.
download_behavior: DownloadBehavior = .default,
download_path: ?[]const u8 = null,
download_events_enabled: bool = false,
pub const DownloadBehavior = enum {
default,
allow,
allow_and_name,
deny,
};
pub const CancelHook = struct {
context: *anyopaque,
check: *const fn (*anyopaque) bool,

View File

@@ -526,6 +526,10 @@ pub const BrowserContext = struct {
http_proxy_changed: bool = false,
user_agent_changed: bool = false,
// True once we've registered for the download notifications, so repeated
// Browser.setDownloadBehavior calls don't add duplicate listeners.
download_events_registered: bool = false,
// Extra headers to add to all requests.
extra_headers: std.ArrayList([*c]const u8) = .empty,
@@ -797,6 +801,36 @@ pub const BrowserContext = struct {
self.notification.unregister(.runtime_console_message, self);
}
// Registers for the download notifications dispatched by Frame when a
// navigation is treated as a file download. Idempotent. See issue #2701.
pub fn downloadEventsEnable(self: *BrowserContext) !void {
if (self.download_events_registered) {
return;
}
try self.notification.register(.download_will_begin, self, onDownloadWillBegin);
try self.notification.register(.download_progress, self, onDownloadProgress);
self.download_events_registered = true;
}
pub fn downloadEventsDisable(self: *BrowserContext) void {
if (self.download_events_registered == false) {
return;
}
self.notification.unregister(.download_will_begin, self);
self.notification.unregister(.download_progress, self);
self.download_events_registered = false;
}
pub fn onDownloadWillBegin(ctx: *anyopaque, msg: *const Notification.DownloadWillBegin) !void {
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
return @import("domains/page.zig").downloadWillBegin(self, msg);
}
pub fn onDownloadProgress(ctx: *anyopaque, msg: *const Notification.DownloadProgress) !void {
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
return @import("domains/browser.zig").downloadProgress(self, msg);
}
pub fn webmcpEnable(self: *BrowserContext) !void {
try self.notification.register(.model_context_tool_added, self, onModelContextToolAdded);
try self.notification.register(.model_context_tool_removed, self, onModelContextToolRemoved);

View File

@@ -19,6 +19,8 @@
const std = @import("std");
const lp = @import("lightpanda");
const CDP = @import("../CDP.zig");
const Session = @import("../../browser/Session.zig");
const Notification = @import("../../Notification.zig");
const log = lp.log;
const PermissionState = @import("../../browser/webapi/Permissions.zig").State;
@@ -73,18 +75,61 @@ fn getVersion(cmd: *CDP.Command) !void {
}, .{ .include_session_id = false });
}
// TODO: noop method
// https://chromedevtools.github.io/devtools-protocol/tot/Browser/#method-setDownloadBehavior
fn setDownloadBehavior(cmd: *CDP.Command) !void {
// const params = (try cmd.params(struct {
// behavior: []const u8,
// browserContextId: ?[]const u8 = null,
// downloadPath: ?[]const u8 = null,
// eventsEnabled: ?bool = null,
// })) orelse return error.InvalidParams;
const params = (try cmd.params(struct {
behavior: []const u8,
browserContextId: ?[]const u8 = null,
downloadPath: ?[]const u8 = null,
eventsEnabled: ?bool = null,
})) orelse return error.InvalidParams;
const behavior: Session.DownloadBehavior = if (std.mem.eql(u8, params.behavior, "allow"))
.allow
else if (std.mem.eql(u8, params.behavior, "allowAndName"))
.allow_and_name
else if (std.mem.eql(u8, params.behavior, "deny"))
.deny
else if (std.mem.eql(u8, params.behavior, "default"))
.default
else
return error.InvalidParams;
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const session = bc.session;
session.download_behavior = behavior;
// downloadPath comes from the (transient) command arena; persist it on the
// session arena since Frame reads it on later navigations.
session.download_path = if (params.downloadPath) |p| try session.arena.dupe(u8, p) else null;
session.download_events_enabled = params.eventsEnabled orelse false;
if (session.download_events_enabled) {
try bc.downloadEventsEnable();
} else {
bc.downloadEventsDisable();
}
return cmd.sendResult(null, .{ .include_session_id = false });
}
// https://chromedevtools.github.io/devtools-protocol/tot/Browser/#event-downloadProgress
// Dispatched by Frame as a download is written to disk (see issue #2701).
pub fn downloadProgress(bc: *CDP.BrowserContext, msg: *const Notification.DownloadProgress) !void {
const session_id = bc.session_id orelse return;
return bc.cdp.sendEvent("Browser.downloadProgress", .{
.guid = msg.guid,
.totalBytes = msg.total_bytes,
.receivedBytes = msg.received_bytes,
.state = switch (msg.state) {
.in_progress => "inProgress",
.completed => "completed",
.canceled => "canceled",
},
}, .{ .session_id = session_id });
}
fn getWindowForTarget(cmd: *CDP.Command) !void {
// const params = (try cmd.params(struct {
// targetId: ?[]const u8 = null,
@@ -230,3 +275,97 @@ test "cdp.browser: grant/set/reset permissions reach navigator.permissions" {
try ctx.expectSentResult(null, .{ .id = 42, .session_id = null });
try testing.expectEqual(@as(usize, 0), browser.permissions.count());
}
test "cdp.browser: setDownloadBehavior stores config on the session" {
var ctx = try testing.context();
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{ .session_id = "SID-CFG" });
try ctx.processMessage(.{
.id = 34,
.method = "Browser.setDownloadBehavior",
.params = .{
.behavior = "allowAndName",
.downloadPath = "/tmp/lp-downloads",
.eventsEnabled = true,
},
});
try ctx.expectSentResult(null, .{ .id = 34, .session_id = null });
try testing.expectEqual(.allow_and_name, bc.session.download_behavior);
try testing.expectEqualSlices(u8, "/tmp/lp-downloads", bc.session.download_path.?);
try testing.expect(bc.session.download_events_enabled);
try testing.expect(bc.download_events_registered);
// Flipping back to default tears the registration down again.
try ctx.processMessage(.{
.id = 35,
.method = "Browser.setDownloadBehavior",
.params = .{ .behavior = "default" },
});
try testing.expectEqual(.default, bc.session.download_behavior);
try testing.expect(bc.session.download_path == null);
try testing.expect(bc.session.download_events_enabled == false);
try testing.expect(bc.download_events_registered == false);
}
test "cdp.browser: setDownloadBehavior rejects an unknown behavior" {
var ctx = try testing.context();
defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .session_id = "SID-BAD" });
try ctx.processMessage(.{
.id = 36,
.method = "Browser.setDownloadBehavior",
.params = .{ .behavior = "sometimes" },
});
try ctx.expectSentError(-31998, "InvalidParams", .{ .id = 36 });
}
test "cdp.browser: setDownloadBehavior writes an attachment to disk and emits events" {
var ctx = try testing.context();
defer ctx.deinit();
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const download_path = try tmp.dir.realpathAlloc(testing.allocator, ".");
defer testing.allocator.free(download_path);
const bc = try ctx.loadBrowserContext(.{
.session_id = "SID-DL",
.target_id = "TID-00000000DL".*,
});
try ctx.processMessage(.{
.id = 37,
.method = "Browser.setDownloadBehavior",
.params = .{
.behavior = "allow",
.downloadPath = download_path,
.eventsEnabled = true,
},
});
try ctx.expectSentResult(null, .{ .id = 37, .session_id = null });
const frame = try bc.session.createPage();
try frame.navigate("http://127.0.0.1:9582/download/report.csv", .{});
var runner = try bc.session.runner(.{});
try runner.wait(.{ .ms = 2000 });
// The guid is random, so it's omitted from these subset matches.
try ctx.expectSentEvent("Page.downloadWillBegin", .{
.url = "http://127.0.0.1:9582/download/report.csv",
.suggestedFilename = "report.csv",
}, .{ .session_id = "SID-DL" });
try ctx.expectSentEvent("Browser.downloadProgress", .{
.state = "completed",
.totalBytes = 30,
.receivedBytes = 30,
}, .{ .session_id = "SID-DL" });
const written = try tmp.dir.readFileAlloc(testing.allocator, "report.csv", 1024);
defer testing.allocator.free(written);
try testing.expectEqualSlices(u8, "col1,col2\nhello,world\n42,1337\n", written);
}

View File

@@ -806,6 +806,20 @@ pub fn frameNetworkAlmostIdle(bc: *CDP.BrowserContext, event: *const Notificatio
return sendPageLifecycle(bc, "networkAlmostIdle", event.timestamp, &id.toFrameId(event.frame_id), &id.toLoaderId(event.loader_id));
}
// https://chromedevtools.github.io/devtools-protocol/tot/Page/#event-downloadWillBegin
// Dispatched by Frame when a navigation response is treated as a file download
// (see issue #2701).
pub fn downloadWillBegin(bc: *CDP.BrowserContext, event: *const Notification.DownloadWillBegin) !void {
const session_id = bc.session_id orelse return;
return bc.cdp.sendEvent("Page.downloadWillBegin", .{
.frameId = &id.toFrameId(event.frame_id),
.guid = event.guid,
.url = event.url,
.suggestedFilename = event.suggested_filename,
}, .{ .session_id = session_id });
}
fn sendPageLifecycle(bc: *CDP.BrowserContext, name: []const u8, timestamp: u64, frame_id: []const u8, loader_id: []const u8) !void {
// detachTarget could be called, in which case, we still have a frame doing
// things, but no session.

View File

@@ -821,6 +821,17 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void {
});
}
if (std.mem.eql(u8, path, "/download/report.csv")) {
// A file download: Content-Disposition: attachment drives the
// Browser.setDownloadBehavior path (issue #2701).
return req.respond("col1,col2\nhello,world\n42,1337\n", .{
.extra_headers = &.{
.{ .name = "Content-Type", .value = "text/csv" },
.{ .name = "Content-Disposition", .value = "attachment; filename=\"report.csv\"" },
},
});
}
if (std.mem.startsWith(u8, path, "/src/browser/tests/")) {
// strip off leading / so that it's relative to CWD
return TestHTTPServer.sendFile(req, path[1..]);