mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-06-11 09:35:59 -04:00
window.open
Adds limited support for window.open. This leverages the new page container and behaves similarly to an iframe. There are many things not implemented, but the most significant are: 1- target=window_name or target=_blank don't work (but this could be added pretty easily I think) 2- Windows (which are is just a Frame) are shutdown when the Page is shutdown. They would need to be owned by the Session (rather than the Page), but I'm not really confident in that right now. 3- No CDP testing. There are maybe CDP-specific messages we need to emit (or maybe messages that we shouldn't emit). As-is, this should help with common cases where: 1. window.open is called but doesn't matter (won't give a JS error) 2. window.open is short-lived, or, more specifically, lives only for the duration of the page that opened it (e.g. a login popup). 3. WPT tests! (because most of these fit in #2).
This commit is contained in:
@@ -532,6 +532,8 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo
|
||||
self.origin = try URL.getOrigin(self.arena, request_url[5.. :0]);
|
||||
} else if (self.parent) |parent| {
|
||||
self.origin = parent.origin;
|
||||
} else if (self.window._opener) |opener| {
|
||||
self.origin = opener._frame.origin;
|
||||
} else {
|
||||
self.origin = null;
|
||||
}
|
||||
@@ -1289,6 +1291,69 @@ pub fn iframeAddedCallback(self: *Frame, iframe: *IFrame) !void {
|
||||
}
|
||||
}
|
||||
|
||||
const OpenPopupOpts = struct {
|
||||
url: []const u8,
|
||||
name: []const u8,
|
||||
opener: ?*Window,
|
||||
};
|
||||
|
||||
// Create a new top-level browsing context as a sibling of the root frame.
|
||||
// The popup shares the Page's arena, factory, and identity map, but has no
|
||||
// parent and is not attached to the frame tree — it lives in page.popups.
|
||||
pub fn openPopup(self: *Frame, opts: OpenPopupOpts) !*Frame {
|
||||
const page = self._page;
|
||||
const session = self._session;
|
||||
|
||||
const resolved_url: [:0]const u8 = blk: {
|
||||
if (opts.url.len == 0) {
|
||||
break :blk "about:blank";
|
||||
}
|
||||
if (std.mem.eql(u8, opts.url, "about:blank")) {
|
||||
break :blk "about:blank";
|
||||
}
|
||||
const frame_base = base_blk: {
|
||||
var frame = self;
|
||||
while (true) {
|
||||
const maybe_base = frame.base();
|
||||
if (!std.mem.eql(u8, maybe_base, "about:blank")) {
|
||||
break :base_blk maybe_base;
|
||||
}
|
||||
frame = frame.parent orelse break :base_blk "";
|
||||
}
|
||||
};
|
||||
break :blk try URL.resolve(self.call_arena, frame_base, opts.url, .{ .always_dupe = true, .encoding = self.charset });
|
||||
};
|
||||
|
||||
const popup = try page.frame_arena.create(Frame);
|
||||
errdefer page.frame_arena.destroy(popup);
|
||||
|
||||
const frame_id = session.nextFrameId();
|
||||
try Frame.init(popup, frame_id, page, null);
|
||||
errdefer popup.deinit(true);
|
||||
|
||||
popup.window._opener = opts.opener;
|
||||
if (opts.name.len > 0 and
|
||||
!std.ascii.eqlIgnoreCase(opts.name, "_blank") and
|
||||
!std.ascii.eqlIgnoreCase(opts.name, "_self") and
|
||||
!std.ascii.eqlIgnoreCase(opts.name, "_parent") and
|
||||
!std.ascii.eqlIgnoreCase(opts.name, "_top"))
|
||||
{
|
||||
popup.window._name = try page.frame_arena.dupe(u8, opts.name);
|
||||
}
|
||||
|
||||
const popup_index = page.popups.items.len;
|
||||
try page.popups.append(page.frame_arena, popup);
|
||||
// not impossible that navigate adds popups, so remove by index
|
||||
errdefer _ = page.popups.swapRemove(popup_index);
|
||||
|
||||
popup.navigate(resolved_url, .{ .reason = .script }) catch |err| {
|
||||
log.warn(.frame, "popup navigate failure", .{ .url = resolved_url, .err = err });
|
||||
return err;
|
||||
};
|
||||
|
||||
return popup;
|
||||
}
|
||||
|
||||
pub fn domChanged(self: *Frame) void {
|
||||
self.version += 1;
|
||||
|
||||
@@ -3390,7 +3455,7 @@ pub const QueuedNavigation = struct {
|
||||
/// to the appropriateFrame to navigate.
|
||||
/// Returns null if the target is "_blank" (which would open a new window/tab).
|
||||
/// Note: Callers should handle empty target separately (for owner document resolution).
|
||||
fn resolveTargetFrame(self: *Frame, target_name: []const u8) ?*Frame {
|
||||
pub fn resolveTargetFrame(self: *Frame, target_name: []const u8) ?*Frame {
|
||||
if (std.ascii.eqlIgnoreCase(target_name, "_self")) {
|
||||
return self;
|
||||
}
|
||||
|
||||
@@ -88,6 +88,14 @@ queued_queued_navigation: std.ArrayList(*Frame) = .empty,
|
||||
// The root Frame of this Page. Non-optional — a Page always has a root frame.
|
||||
frame: Frame,
|
||||
|
||||
// Popup Frames opened by window.open. They are top-level browsing contexts
|
||||
// (parent == null, no iframe element) but share this Page's factory, arena,
|
||||
// and identity map.
|
||||
// Their lifetime is bound to the Page: on Page.deinit they
|
||||
// are torn down. TODO: this is far from correct. An new window shouldn't be tied
|
||||
// to the original page like this.
|
||||
popups: std.ArrayList(*Frame) = .empty,
|
||||
|
||||
// Initialize a Page and its root Frame.
|
||||
pub fn init(self: *Page, session: *Session, frame_id: u32) !void {
|
||||
const frame_arena = try session.arena_pool.acquire(.large, "Page.frame_arena");
|
||||
@@ -107,6 +115,11 @@ pub fn init(self: *Page, session: *Session, frame_id: u32) !void {
|
||||
// Tear down the Page and its root Frame. Equivalent to the old
|
||||
// Session.removePage + Session.resetFrameResources.
|
||||
pub fn deinit(self: *Page, abort_http: bool) void {
|
||||
for (self.popups.items) |popup| {
|
||||
popup.deinit(abort_http);
|
||||
}
|
||||
self.popups = .empty;
|
||||
|
||||
self.frame.deinit(abort_http);
|
||||
|
||||
const session = self.session;
|
||||
@@ -217,6 +230,16 @@ pub fn findFrameByFrameId(self: *Page, frame_id: u32) ?*Frame {
|
||||
return findFrameBy(&self.frame, "_frame_id", frame_id);
|
||||
}
|
||||
|
||||
// Returns the popup Frame registered under `name`, or null.
|
||||
pub fn findPopupByName(self: *Page, name: []const u8) ?*Frame {
|
||||
for (self.popups.items) |popup| {
|
||||
if (std.mem.eql(u8, popup.window._name, name)) {
|
||||
return popup;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn findFrameByLoaderId(self: *Page, loader_id: u32) ?*Frame {
|
||||
return findFrameBy(&self.frame, "_loader_id", loader_id);
|
||||
}
|
||||
|
||||
@@ -281,6 +281,13 @@ pub fn processQueuedNavigation(self: *Session) !void {
|
||||
}
|
||||
|
||||
fn processFrameNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation) !void {
|
||||
// Popups live on the Page as top-level browsing contexts without a
|
||||
// parent or iframe element. Their re-navigation path is simpler than
|
||||
// iframes — no parent bookkeeping to patch.
|
||||
if (frame.parent == null and frame.iframe == null) {
|
||||
return self.processPopupNavigation(frame, qn);
|
||||
}
|
||||
|
||||
lp.assert(frame.parent != null, "root queued navigation", .{});
|
||||
|
||||
const iframe = frame.iframe.?;
|
||||
@@ -331,6 +338,45 @@ fn processFrameNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation)
|
||||
};
|
||||
}
|
||||
|
||||
// Re-navigates a popup Frame in place. The Frame pointer stays stable
|
||||
// (scripts in the opener may hold a cached Window ref — though the Window
|
||||
// object inside is replaced, matching how iframes behave on navigation).
|
||||
fn processPopupNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation) !void {
|
||||
frame._queued_navigation = null;
|
||||
defer self.releaseArena(qn.arena);
|
||||
|
||||
// Preserve popup identity fields. _name lives in the Page arena and
|
||||
// survives Frame.deinit; _opener is just a pointer.
|
||||
const saved_name = frame.window._name;
|
||||
const saved_opener = frame.window._opener;
|
||||
const frame_id = frame._frame_id;
|
||||
const page = self.currentPage().?;
|
||||
|
||||
frame.deinit(true);
|
||||
frame.* = undefined;
|
||||
|
||||
errdefer {
|
||||
// If re-init fails, drop from popups so we don't leave a corpse.
|
||||
for (page.popups.items, 0..) |p, i| {
|
||||
if (p == frame) {
|
||||
_ = page.popups.swapRemove(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try Frame.init(frame, frame_id, page, null);
|
||||
errdefer frame.deinit(true);
|
||||
|
||||
frame.window._name = saved_name;
|
||||
frame.window._opener = saved_opener;
|
||||
|
||||
frame.navigate(qn.url, qn.opts) catch |err| {
|
||||
log.err(.browser, "queued popup navigation error", .{ .err = err });
|
||||
return err;
|
||||
};
|
||||
}
|
||||
|
||||
fn processRootQueuedNavigation(self: *Session) !void {
|
||||
const current_frame = &self.page.?.frame;
|
||||
const frame_id = current_frame._frame_id;
|
||||
|
||||
109
src/browser/tests/window/open.html
Normal file
109
src/browser/tests/window/open.html
Normal file
@@ -0,0 +1,109 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../testing.js"></script>
|
||||
<body></body>
|
||||
|
||||
<script id=basic>
|
||||
{
|
||||
// window.open returns a Window-like object.
|
||||
const w = window.open('about:blank');
|
||||
testing.expectEqual(true, w != null);
|
||||
testing.expectEqual(false, w.closed);
|
||||
|
||||
// The popup is a top-level browsing context.
|
||||
testing.expectEqual(w, w.self);
|
||||
testing.expectEqual(w, w.window);
|
||||
testing.expectEqual(w, w.top);
|
||||
testing.expectEqual(w, w.parent);
|
||||
|
||||
// Opener points back at us.
|
||||
testing.expectEqual(window, w.opener);
|
||||
|
||||
w.close();
|
||||
testing.expectEqual(true, w.closed);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=main_opener_is_null>
|
||||
testing.expectEqual(null, window.opener);
|
||||
</script>
|
||||
|
||||
<script id=about_blank_default>
|
||||
{
|
||||
// No-arg open defaults to about:blank.
|
||||
const w = window.open();
|
||||
testing.expectEqual(true, w != null);
|
||||
testing.expectEqual(false, w.closed);
|
||||
w.close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=noopener>
|
||||
{
|
||||
// noopener returns null.
|
||||
const w = window.open('about:blank', '_blank', 'noopener');
|
||||
testing.expectEqual(null, w);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=noreferrer>
|
||||
{
|
||||
// noreferrer also returns null (and implies noopener).
|
||||
const w = window.open('about:blank', '_blank', 'noreferrer');
|
||||
testing.expectEqual(null, w);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=named_reuse>
|
||||
{
|
||||
// Second open with the same name reuses the popup — same Window identity.
|
||||
const a = window.open('about:blank', 'myPopup');
|
||||
testing.expectEqual('myPopup', a.name);
|
||||
|
||||
const b = window.open('about:blank', 'myPopup');
|
||||
testing.expectEqual(a, b);
|
||||
|
||||
a.close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=close_noop_on_main>
|
||||
// close() on a non-popup window is a no-op.
|
||||
window.close();
|
||||
testing.expectEqual(false, window.closed);
|
||||
</script>
|
||||
|
||||
<script id=double_close>
|
||||
{
|
||||
const w = window.open('about:blank');
|
||||
w.close();
|
||||
testing.expectEqual(true, w.closed);
|
||||
// Second close is safe.
|
||||
w.close();
|
||||
testing.expectEqual(true, w.closed);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=name_setter>
|
||||
{
|
||||
const w = window.open('about:blank');
|
||||
testing.expectEqual('', w.name);
|
||||
w.name = 'renamed';
|
||||
testing.expectEqual('renamed', w.name);
|
||||
|
||||
// Now it can be looked up by name.
|
||||
const again = window.open('about:blank', 'renamed');
|
||||
testing.expectEqual(w, again);
|
||||
w.close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=close_drops_opener>
|
||||
{
|
||||
// Closing a popup that was opened from main shouldn't touch window.opener.
|
||||
const w = window.open('about:blank', 'outer');
|
||||
testing.expectEqual(window, w.opener);
|
||||
w.close();
|
||||
testing.expectEqual(true, w.closed);
|
||||
testing.expectEqual(null, window.opener);
|
||||
}
|
||||
</script>
|
||||
@@ -95,6 +95,18 @@ _scroll_pos: struct {
|
||||
// A cross origin wrapper for this window
|
||||
_cross_origin_wrapper: CrossOriginWindow,
|
||||
|
||||
// The Window that called window.open to create this one. Null for the root
|
||||
// window, for noopener popups, and cleared if the opener is torn down while
|
||||
// we're still alive. Only valid if `!_opener.?._closed`.
|
||||
_opener: ?*Window = null,
|
||||
|
||||
// True after our Frame has been deinit'd by window.close. Many things on the
|
||||
// window become invalid once this is true.
|
||||
_closed: bool = false,
|
||||
|
||||
// Popup name (owned by page.arena)
|
||||
_name: []const u8 = "",
|
||||
|
||||
pub fn asEventTarget(self: *Window) *EventTarget {
|
||||
return self._proto;
|
||||
}
|
||||
@@ -111,6 +123,25 @@ pub fn getWindow(self: *Window) *Window {
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn getOpener(self: *Window, frame: *Frame) ?Access {
|
||||
const opener = self._opener orelse return null;
|
||||
if (opener._closed) return null;
|
||||
return Access.init(frame.window, opener);
|
||||
}
|
||||
|
||||
pub fn getClosed(self: *const Window) bool {
|
||||
return self._closed;
|
||||
}
|
||||
|
||||
pub fn getName(self: *const Window) []const u8 {
|
||||
return self._name;
|
||||
}
|
||||
|
||||
pub fn setName(self: *Window, name: []const u8, frame: *Frame) !void {
|
||||
// Store in the Page's frame arena so the slice outlives any call_arena.
|
||||
self._name = try frame.arena.dupe(u8, name);
|
||||
}
|
||||
|
||||
pub fn getTop(self: *Window, frame: *Frame) Access {
|
||||
var p = self._frame;
|
||||
while (p.parent) |parent| {
|
||||
@@ -424,6 +455,108 @@ pub fn getComputedStyle(_: *const Window, element: *Element, pseudo_element: ?[]
|
||||
return CSSStyleProperties.init(element, true, frame);
|
||||
}
|
||||
|
||||
// window.open(url?, target?, features?) — v1 scope:
|
||||
// * Always creates a new popup Frame on the Page (sibling to the root).
|
||||
// * Honors `noopener` / `noreferrer` tokens in `features` (opener=null,
|
||||
// return value=null). Geometry (width, height, ...) ignored.
|
||||
// * `target` values `_self` / `_parent` / `_top` navigate the current frame.
|
||||
// Any other value is treated as a popup name; reusing a live name
|
||||
// navigates the existing popup instead of spawning a new one.
|
||||
// * `url` empty or missing opens about:blank.
|
||||
pub fn open(self: *Window, url_: ?[]const u8, target_: ?[]const u8, features_: ?[]const u8, frame: *Frame) !?Access {
|
||||
const raw_url = url_ orelse "";
|
||||
const target = target_ orelse "";
|
||||
const features = features_ orelse "";
|
||||
|
||||
const no_opener = hasFeatureToken(features, "noopener") or hasFeatureToken(features, "noreferrer");
|
||||
|
||||
// _self / _parent / _top navigate the current browsing context.
|
||||
if (std.ascii.eqlIgnoreCase(target, "_self") or
|
||||
std.ascii.eqlIgnoreCase(target, "_parent") or
|
||||
std.ascii.eqlIgnoreCase(target, "_top"))
|
||||
{
|
||||
const nav_target = frame.resolveTargetFrame(target) orelse frame;
|
||||
const nav_url = if (raw_url.len == 0) "about:blank" else raw_url;
|
||||
try frame.scheduleNavigation(nav_url, .{
|
||||
.reason = .script,
|
||||
.kind = .{ .push = null },
|
||||
}, .{ .script = nav_target });
|
||||
|
||||
if (no_opener) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Access.init(frame.window, nav_target.window);
|
||||
}
|
||||
|
||||
const page = frame._page;
|
||||
|
||||
// Name-based reuse: if a popup with this name already exists, reuse it.
|
||||
// `_blank` is reserved and never reuses.
|
||||
const is_named = target.len > 0 and !std.ascii.eqlIgnoreCase(target, "_blank");
|
||||
if (is_named) {
|
||||
if (page.findPopupByName(target)) |existing| {
|
||||
if (raw_url.len > 0) {
|
||||
try existing.scheduleNavigation(raw_url, .{
|
||||
.reason = .script,
|
||||
.kind = .{ .push = null },
|
||||
}, .{ .script = existing });
|
||||
}
|
||||
if (no_opener) {
|
||||
return null;
|
||||
}
|
||||
return Access.init(frame.window, existing.window);
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn a new popup Frame as a sibling of the root.
|
||||
const popup = try frame.openPopup(.{
|
||||
.url = raw_url,
|
||||
.name = target,
|
||||
.opener = if (no_opener) null else self,
|
||||
});
|
||||
|
||||
if (no_opener) {
|
||||
return null;
|
||||
}
|
||||
return Access.init(frame.window, popup.window);
|
||||
}
|
||||
|
||||
pub fn close(self: *Window) void {
|
||||
if (self._closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Per spec, close() is only honored on script-opened windows. That
|
||||
// maps exactly to membership in page.popups.
|
||||
const frame = self._frame;
|
||||
const page = frame._page;
|
||||
|
||||
var popup_index: usize = 0;
|
||||
while (popup_index < page.popups.items.len) : (popup_index += 1) {
|
||||
if (page.popups.items[popup_index] == frame) {
|
||||
break;
|
||||
}
|
||||
} else return;
|
||||
|
||||
self._closed = true;
|
||||
|
||||
// Any live Window holding us as its opener must drop the reference —
|
||||
// our Frame is about to go away, and a stale _frame deref on their
|
||||
// side would crash.
|
||||
for (page.popups.items) |popup| {
|
||||
if (popup.window._opener == self) {
|
||||
popup.window._opener = null;
|
||||
}
|
||||
}
|
||||
if (page.frame.window._opener == self) {
|
||||
page.frame.window._opener = null;
|
||||
}
|
||||
|
||||
_ = page.popups.swapRemove(popup_index);
|
||||
frame.deinit(true);
|
||||
}
|
||||
|
||||
pub fn postMessage(self: *Window, message: js.Value.Temp, target_origin: ?[]const u8, frame: *Frame) !void {
|
||||
// For now, we ignore targetOrigin checking and just dispatch the message
|
||||
// In a full implementation, we would validate the origin
|
||||
@@ -833,6 +966,20 @@ fn getFunctionFromSetter(setter_: ?FunctionSetter) ?js.Function.Global {
|
||||
};
|
||||
}
|
||||
|
||||
// Checks whether a window.open features string contains a token, matched
|
||||
// case-insensitively on whole-token boundaries (comma or whitespace separated).
|
||||
// The features syntax is legacy and loose; the only tokens we interpret are
|
||||
// noopener and noreferrer.
|
||||
fn hasFeatureToken(features: []const u8, token: []const u8) bool {
|
||||
var it = std.mem.tokenizeAny(u8, features, " \t\r\n,");
|
||||
while (it.next()) |raw| {
|
||||
// Trim a trailing =value if present — we only need the key.
|
||||
const key = if (std.mem.indexOfScalarPos(u8, raw, 0, '=')) |eq| raw[0..eq] else raw;
|
||||
if (std.ascii.eqlIgnoreCase(key, token)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(Window);
|
||||
|
||||
@@ -912,9 +1059,11 @@ pub const JsApi = struct {
|
||||
pub const innerHeight = bridge.property(1080, .{ .template = false });
|
||||
pub const devicePixelRatio = bridge.property(1, .{ .template = false });
|
||||
|
||||
// This should return a window-like object in specific conditions. Would be
|
||||
// pretty complicated to properly support I think.
|
||||
pub const opener = bridge.property(null, .{ .template = false });
|
||||
pub const opener = bridge.accessor(Window.getOpener, null, .{});
|
||||
pub const closed = bridge.accessor(Window.getClosed, null, .{});
|
||||
pub const name = bridge.accessor(Window.getName, Window.setName, .{});
|
||||
pub const open = bridge.function(Window.open, .{});
|
||||
pub const close = bridge.function(Window.close, .{});
|
||||
|
||||
pub const alert = bridge.function(struct {
|
||||
fn alert(_: *const Window, message: ?[]const u8, frame: *Frame) void {
|
||||
|
||||
Reference in New Issue
Block a user