mirror of
https://github.com/lightpanda-io/browser.git
synced 2025-10-29 15:13:28 +00:00
518 lines
18 KiB
Zig
518 lines
18 KiB
Zig
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
|
|
//
|
|
// Francis Bouvier <francis@lightpanda.io>
|
|
// Pierre Tachoire <pierre@lightpanda.io>
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as
|
|
// published by the Free Software Foundation, either version 3 of the
|
|
// License, or (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
const std = @import("std");
|
|
|
|
const log = @import("../../log.zig");
|
|
const parser = @import("../netsurf.zig");
|
|
const Env = @import("../env.zig").Env;
|
|
const Page = @import("../page.zig").Page;
|
|
|
|
const Navigator = @import("navigator.zig").Navigator;
|
|
const History = @import("history.zig").History;
|
|
const Location = @import("location.zig").Location;
|
|
const Crypto = @import("../crypto/crypto.zig").Crypto;
|
|
const Console = @import("../console/console.zig").Console;
|
|
const EventTarget = @import("../dom/event_target.zig").EventTarget;
|
|
const MediaQueryList = @import("media_query_list.zig").MediaQueryList;
|
|
const Performance = @import("../dom/performance.zig").Performance;
|
|
const CSSStyleDeclaration = @import("../cssom/CSSStyleDeclaration.zig");
|
|
const Screen = @import("screen.zig").Screen;
|
|
const Css = @import("../css/css.zig").Css;
|
|
|
|
const Function = Env.Function;
|
|
const JsObject = Env.JsObject;
|
|
|
|
const storage = @import("../storage/storage.zig");
|
|
|
|
// https://dom.spec.whatwg.org/#interface-window-extensions
|
|
// https://html.spec.whatwg.org/multipage/nav-history-apis.html#window
|
|
pub const Window = struct {
|
|
pub const prototype = *EventTarget;
|
|
|
|
// Extend libdom event target for pure zig struct.
|
|
base: parser.EventTargetTBase = parser.EventTargetTBase{ .internal_target_type = .window },
|
|
|
|
document: *parser.DocumentHTML,
|
|
target: []const u8 = "",
|
|
history: History = .{},
|
|
location: Location = .{},
|
|
storage_shelf: ?*storage.Shelf = null,
|
|
|
|
// counter for having unique timer ids
|
|
timer_id: u30 = 0,
|
|
timers: std.AutoHashMapUnmanaged(u32, void) = .{},
|
|
|
|
crypto: Crypto = .{},
|
|
console: Console = .{},
|
|
navigator: Navigator = .{},
|
|
performance: Performance,
|
|
screen: Screen = .{},
|
|
css: Css = .{},
|
|
|
|
pub fn create(target: ?[]const u8, navigator: ?Navigator) !Window {
|
|
var fbs = std.io.fixedBufferStream("");
|
|
const html_doc = try parser.documentHTMLParse(fbs.reader(), "utf-8");
|
|
const doc = parser.documentHTMLToDocument(html_doc);
|
|
try parser.documentSetDocumentURI(doc, "about:blank");
|
|
|
|
return .{
|
|
.document = html_doc,
|
|
.target = target orelse "",
|
|
.navigator = navigator orelse .{},
|
|
.performance = .{ .time_origin = try std.time.Timer.start() },
|
|
};
|
|
}
|
|
|
|
pub fn replaceLocation(self: *Window, loc: Location) !void {
|
|
self.location = loc;
|
|
try parser.documentHTMLSetLocation(Location, self.document, &self.location);
|
|
}
|
|
|
|
pub fn replaceDocument(self: *Window, doc: *parser.DocumentHTML) !void {
|
|
self.performance.time_origin.reset(); // When to reset see: https://developer.mozilla.org/en-US/docs/Web/API/Performance/timeOrigin
|
|
self.document = doc;
|
|
try parser.documentHTMLSetLocation(Location, doc, &self.location);
|
|
}
|
|
|
|
pub fn setStorageShelf(self: *Window, shelf: *storage.Shelf) void {
|
|
self.storage_shelf = shelf;
|
|
}
|
|
|
|
pub fn get_window(self: *Window) *Window {
|
|
return self;
|
|
}
|
|
|
|
pub fn get_navigator(self: *Window) *Navigator {
|
|
return &self.navigator;
|
|
}
|
|
|
|
pub fn get_location(self: *Window) *Location {
|
|
return &self.location;
|
|
}
|
|
|
|
pub fn set_location(_: *const Window, url: []const u8, page: *Page) !void {
|
|
return page.navigateFromWebAPI(url, .{ .reason = .script });
|
|
}
|
|
|
|
pub fn get_console(self: *Window) *Console {
|
|
return &self.console;
|
|
}
|
|
|
|
pub fn get_crypto(self: *Window) *Crypto {
|
|
return &self.crypto;
|
|
}
|
|
|
|
pub fn get_self(self: *Window) *Window {
|
|
return self;
|
|
}
|
|
|
|
pub fn get_parent(self: *Window) *Window {
|
|
return self;
|
|
}
|
|
|
|
// TODO: frames
|
|
pub fn get_top(self: *Window) *Window {
|
|
return self;
|
|
}
|
|
|
|
pub fn get_document(self: *Window) ?*parser.DocumentHTML {
|
|
return self.document;
|
|
}
|
|
|
|
pub fn get_history(self: *Window) *History {
|
|
return &self.history;
|
|
}
|
|
|
|
// The interior height of the window in pixels, including the height of the horizontal scroll bar, if present.
|
|
pub fn get_innerHeight(_: *Window, page: *Page) u32 {
|
|
// We do not have scrollbars or padding so this is the same as Element.clientHeight
|
|
return page.renderer.height();
|
|
}
|
|
|
|
// The interior width of the window in pixels. That includes the width of the vertical scroll bar, if one is present.
|
|
pub fn get_innerWidth(_: *Window, page: *Page) u32 {
|
|
// We do not have scrollbars or padding so this is the same as Element.clientWidth
|
|
return page.renderer.width();
|
|
}
|
|
|
|
pub fn get_name(self: *Window) []const u8 {
|
|
return self.target;
|
|
}
|
|
|
|
pub fn get_localStorage(self: *Window) !*storage.Bottle {
|
|
if (self.storage_shelf == null) return parser.DOMError.NotSupported;
|
|
return &self.storage_shelf.?.bucket.local;
|
|
}
|
|
|
|
pub fn get_sessionStorage(self: *Window) !*storage.Bottle {
|
|
if (self.storage_shelf == null) return parser.DOMError.NotSupported;
|
|
return &self.storage_shelf.?.bucket.session;
|
|
}
|
|
|
|
pub fn get_performance(self: *Window) *Performance {
|
|
return &self.performance;
|
|
}
|
|
|
|
pub fn get_screen(self: *Window) *Screen {
|
|
return &self.screen;
|
|
}
|
|
|
|
pub fn get_CSS(self: *Window) *Css {
|
|
return &self.css;
|
|
}
|
|
|
|
pub fn _requestAnimationFrame(self: *Window, cbk: Function, page: *Page) !u32 {
|
|
return self.createTimeout(cbk, 5, page, .{ .animation_frame = true, .name = "animationFrame" });
|
|
}
|
|
|
|
pub fn _cancelAnimationFrame(self: *Window, id: u32) !void {
|
|
_ = self.timers.remove(id);
|
|
}
|
|
|
|
pub fn _setTimeout(self: *Window, cbk: Function, delay: ?u32, params: []Env.JsObject, page: *Page) !u32 {
|
|
return self.createTimeout(cbk, delay, page, .{ .args = params, .name = "setTimeout" });
|
|
}
|
|
|
|
pub fn _setInterval(self: *Window, cbk: Function, delay: ?u32, params: []Env.JsObject, page: *Page) !u32 {
|
|
return self.createTimeout(cbk, delay, page, .{ .repeat = true, .args = params, .name = "setInterval" });
|
|
}
|
|
|
|
pub fn _clearTimeout(self: *Window, id: u32) !void {
|
|
_ = self.timers.remove(id);
|
|
}
|
|
|
|
pub fn _clearInterval(self: *Window, id: u32) !void {
|
|
_ = self.timers.remove(id);
|
|
}
|
|
|
|
pub fn _queueMicrotask(self: *Window, cbk: Function, page: *Page) !u32 {
|
|
return self.createTimeout(cbk, 0, page, .{ .name = "queueMicrotask" });
|
|
}
|
|
|
|
pub fn _matchMedia(_: *const Window, media: []const u8, page: *Page) !MediaQueryList {
|
|
return .{
|
|
.matches = false, // TODO?
|
|
.media = try page.arena.dupe(u8, media),
|
|
};
|
|
}
|
|
|
|
pub fn _btoa(_: *const Window, value: []const u8, page: *Page) ![]const u8 {
|
|
const Encoder = std.base64.standard.Encoder;
|
|
const out = try page.call_arena.alloc(u8, Encoder.calcSize(value.len));
|
|
return Encoder.encode(out, value);
|
|
}
|
|
|
|
pub fn _atob(_: *const Window, value: []const u8, page: *Page) ![]const u8 {
|
|
const Decoder = std.base64.standard.Decoder;
|
|
const size = Decoder.calcSizeForSlice(value) catch return error.InvalidCharacterError;
|
|
|
|
const out = try page.call_arena.alloc(u8, size);
|
|
Decoder.decode(out, value) catch return error.InvalidCharacterError;
|
|
return out;
|
|
}
|
|
|
|
const CreateTimeoutOpts = struct {
|
|
name: []const u8,
|
|
args: []Env.JsObject = &.{},
|
|
repeat: bool = false,
|
|
animation_frame: bool = false,
|
|
};
|
|
fn createTimeout(self: *Window, cbk: Function, delay_: ?u32, page: *Page, opts: CreateTimeoutOpts) !u32 {
|
|
const delay = delay_ orelse 0;
|
|
if (delay > 5000) {
|
|
log.warn(.user_script, "long timeout ignored", .{ .delay = delay, .interval = opts.repeat });
|
|
// self.timer_id is u30, so the largest value we can generate is
|
|
// 1_073_741_824. Returning 2_000_000_000 makes sure that clients
|
|
// can call cancelTimer/cancelInterval without breaking anything.
|
|
return 2_000_000_000;
|
|
}
|
|
|
|
if (self.timers.count() > 512) {
|
|
return error.TooManyTimeout;
|
|
}
|
|
const timer_id = self.timer_id +% 1;
|
|
self.timer_id = timer_id;
|
|
|
|
const arena = page.arena;
|
|
|
|
const gop = try self.timers.getOrPut(arena, timer_id);
|
|
if (gop.found_existing) {
|
|
// this can only happen if we've created 2^31 timeouts.
|
|
return error.TooManyTimeout;
|
|
} else {
|
|
gop.value_ptr.* = {};
|
|
}
|
|
errdefer _ = self.timers.remove(timer_id);
|
|
|
|
const args = opts.args;
|
|
var persisted_args: []Env.JsObject = &.{};
|
|
if (args.len > 0) {
|
|
persisted_args = try page.arena.alloc(Env.JsObject, args.len);
|
|
for (args, persisted_args) |a, *ca| {
|
|
ca.* = try a.persist();
|
|
}
|
|
}
|
|
|
|
const callback = try arena.create(TimerCallback);
|
|
callback.* = .{
|
|
.cbk = cbk,
|
|
.window = self,
|
|
.timer_id = timer_id,
|
|
.args = persisted_args,
|
|
.animation_frame = opts.animation_frame,
|
|
// setting a repeat time of 0 is illegal, doing + 1 is a simple way to avoid that
|
|
.repeat = if (opts.repeat) delay + 1 else null,
|
|
};
|
|
|
|
try page.scheduler.add(callback, TimerCallback.run, delay, .{ .name = opts.name });
|
|
|
|
return timer_id;
|
|
}
|
|
|
|
// TODO: getComputedStyle should return a read-only CSSStyleDeclaration.
|
|
// We currently don't have a read-only one, so we return a new instance on
|
|
// each call.
|
|
pub fn _getComputedStyle(_: *const Window, element: *parser.Element, pseudo_element: ?[]const u8) !CSSStyleDeclaration {
|
|
_ = element;
|
|
_ = pseudo_element;
|
|
return .empty;
|
|
}
|
|
|
|
const ScrollToOpts = union(enum) {
|
|
x: i32,
|
|
opts: Opts,
|
|
|
|
const Opts = struct {
|
|
top: i32,
|
|
left: i32,
|
|
behavior: []const u8,
|
|
};
|
|
};
|
|
pub fn _scrollTo(self: *Window, opts: ScrollToOpts, y: ?u32) !void {
|
|
_ = opts;
|
|
_ = y;
|
|
|
|
{
|
|
const scroll_event = try parser.eventCreate();
|
|
defer parser.eventDestroy(scroll_event);
|
|
|
|
try parser.eventInit(scroll_event, "scroll", .{});
|
|
_ = try parser.eventTargetDispatchEvent(
|
|
parser.toEventTarget(Window, self),
|
|
scroll_event,
|
|
);
|
|
}
|
|
|
|
{
|
|
const scroll_end = try parser.eventCreate();
|
|
defer parser.eventDestroy(scroll_end);
|
|
|
|
try parser.eventInit(scroll_end, "scrollend", .{});
|
|
_ = try parser.eventTargetDispatchEvent(
|
|
parser.toEventTarget(parser.DocumentHTML, self.document),
|
|
scroll_end,
|
|
);
|
|
}
|
|
}
|
|
|
|
// libdom's document doesn't have a parent, which is correct, but
|
|
// breaks the event bubbling that happens for many events from
|
|
// document -> window.
|
|
// We need to force dispatch this event on the window, with the
|
|
// document target.
|
|
// In theory, we should do this for a lot of events and might need
|
|
// to come up with a good way to solve this more generically. But
|
|
// this specific event, and maybe a few others in the near future,
|
|
// are blockers.
|
|
// Worth noting that NetSurf itself appears to do something similar:
|
|
// https://github.com/netsurf-browser/netsurf/blob/a32e1a03e1c91ee9f0aa211937dbae7a96831149/content/handlers/html/html.c#L380
|
|
pub fn dispatchForDocumentTarget(self: *Window, evt: *parser.Event) !void {
|
|
// we assume that this evt has already been dispatched on the document
|
|
// and thus the target has already been set to the document.
|
|
return self.base.redispatchEvent(evt);
|
|
}
|
|
};
|
|
|
|
const TimerCallback = struct {
|
|
// the id of our timer (windows.timers key)
|
|
timer_id: u31,
|
|
|
|
// if false, we'll remove the timer_id from the window.timers lookup on run
|
|
repeat: ?u32,
|
|
|
|
// The JavaScript callback to execute
|
|
cbk: Function,
|
|
|
|
animation_frame: bool = false,
|
|
|
|
window: *Window,
|
|
|
|
args: []Env.JsObject = &.{},
|
|
|
|
fn run(ctx: *anyopaque) ?u32 {
|
|
const self: *TimerCallback = @alignCast(@ptrCast(ctx));
|
|
if (self.repeat != null) {
|
|
if (self.window.timers.contains(self.timer_id) == false) {
|
|
// it was called
|
|
return null;
|
|
}
|
|
} else if (self.window.timers.remove(self.timer_id) == false) {
|
|
// it was cancelled
|
|
return null;
|
|
}
|
|
|
|
var result: Function.Result = undefined;
|
|
|
|
var call: anyerror!void = undefined;
|
|
if (self.animation_frame) {
|
|
call = self.cbk.tryCall(void, .{self.window.performance._now()}, &result);
|
|
} else {
|
|
call = self.cbk.tryCall(void, self.args, &result);
|
|
}
|
|
|
|
call catch {
|
|
log.warn(.user_script, "callback error", .{
|
|
.err = result.exception,
|
|
.stack = result.stack,
|
|
.source = "window timeout",
|
|
});
|
|
};
|
|
|
|
return self.repeat;
|
|
}
|
|
};
|
|
|
|
const testing = @import("../../testing.zig");
|
|
test "Browser.HTML.Window" {
|
|
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
|
defer runner.deinit();
|
|
|
|
// try runner.testCases(&.{
|
|
// .{ "window.parent === window", "true" },
|
|
// .{ "window.top === window", "true" },
|
|
// }, .{});
|
|
|
|
try runner.testCases(&.{
|
|
.{
|
|
\\ let start = 0;
|
|
\\ function step(timestamp) {
|
|
\\ start = timestamp;
|
|
\\ }
|
|
,
|
|
null,
|
|
},
|
|
.{ "requestAnimationFrame(step);", null }, // returned id is checked in the next test
|
|
.{ " start > 0", "true" },
|
|
}, .{});
|
|
|
|
// cancelAnimationFrame should be able to cancel a request with the given id
|
|
try runner.testCases(&.{
|
|
.{ "let request_id = requestAnimationFrame(timestamp => {});", null },
|
|
.{ "cancelAnimationFrame(request_id);", "undefined" },
|
|
}, .{});
|
|
|
|
try runner.testCases(&.{
|
|
.{ "innerHeight", "1" },
|
|
.{ "innerWidth", "1" }, // Width is 1 even if there are no elements
|
|
.{
|
|
\\ let div1 = document.createElement('div');
|
|
\\ document.body.appendChild(div1);
|
|
\\ div1.getClientRects();
|
|
,
|
|
null,
|
|
},
|
|
.{
|
|
\\ let div2 = document.createElement('div');
|
|
\\ document.body.appendChild(div2);
|
|
\\ div2.getClientRects();
|
|
,
|
|
null,
|
|
},
|
|
.{ "innerHeight", "1" },
|
|
.{ "innerWidth", "2" },
|
|
}, .{});
|
|
|
|
try runner.testCases(&.{
|
|
.{ "let longCall = false;", null },
|
|
.{ "window.setTimeout(() => {longCall = true}, 5001);", null },
|
|
.{ "longCall;", "false" },
|
|
|
|
.{ "let wst = 0;", null },
|
|
.{ "window.setTimeout(() => {wst += 1}, 1)", null },
|
|
.{ "wst", "1" },
|
|
|
|
.{ "window.setTimeout((a, b) => {wst = a + b}, 1, 2, 3)", null },
|
|
.{ "wst", "5" },
|
|
}, .{});
|
|
|
|
// window event target
|
|
try runner.testCases(&.{
|
|
.{
|
|
\\ let called = false;
|
|
\\ window.addEventListener("ready", (e) => {
|
|
\\ called = (e.currentTarget == window);
|
|
\\ }, {capture: false, once: false});
|
|
\\ const evt = new Event("ready", { bubbles: true, cancelable: false });
|
|
\\ window.dispatchEvent(evt);
|
|
\\ called;
|
|
,
|
|
"true",
|
|
},
|
|
}, .{});
|
|
|
|
try runner.testCases(&.{
|
|
.{ "const b64 = btoa('https://ziglang.org/documentation/master/std/#std.base64.Base64Decoder')", "undefined" },
|
|
.{ "b64", "aHR0cHM6Ly96aWdsYW5nLm9yZy9kb2N1bWVudGF0aW9uL21hc3Rlci9zdGQvI3N0ZC5iYXNlNjQuQmFzZTY0RGVjb2Rlcg==" },
|
|
.{ "const str = atob(b64)", "undefined" },
|
|
.{ "str", "https://ziglang.org/documentation/master/std/#std.base64.Base64Decoder" },
|
|
.{ "try { atob('b') } catch (e) { e } ", "Error: InvalidCharacterError" },
|
|
}, .{});
|
|
|
|
try runner.testCases(&.{
|
|
.{ "let scroll = false; let scrolend = false", null },
|
|
.{ "window.addEventListener('scroll', () => {scroll = true});", null },
|
|
.{ "document.addEventListener('scrollend', () => {scrollend = true});", null },
|
|
.{ "window.scrollTo(0)", null },
|
|
.{ "scroll", "true" },
|
|
.{ "scrollend", "true" },
|
|
}, .{});
|
|
|
|
try runner.testCases(&.{
|
|
.{ "var qm = false; window.queueMicrotask(() => {qm = true });", null },
|
|
.{ "qm", "true" },
|
|
}, .{});
|
|
|
|
{
|
|
try runner.testCases(&.{
|
|
.{
|
|
\\ let dcl = false;
|
|
\\ window.addEventListener('DOMContentLoaded', (e) => {
|
|
\\ dcl = e.target == document;
|
|
\\ });
|
|
,
|
|
null,
|
|
},
|
|
}, .{});
|
|
try runner.dispatchDOMContentLoaded();
|
|
try runner.testCases(&.{
|
|
.{ "dcl", "true" },
|
|
}, .{});
|
|
}
|
|
}
|