diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index dedb522e..bb23a452 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -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; } diff --git a/src/browser/Page.zig b/src/browser/Page.zig index af58f34a..929cd07c 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -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); } diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 17f2e221..421b9b25 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -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; diff --git a/src/browser/tests/window/open.html b/src/browser/tests/window/open.html new file mode 100644 index 00000000..4bc0b72d --- /dev/null +++ b/src/browser/tests/window/open.html @@ -0,0 +1,109 @@ + + +
+ + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 31efb8ff..e02ba550 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -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 {