diff --git a/build.zig b/build.zig index c618a266..18c37eac 100644 --- a/build.zig +++ b/build.zig @@ -286,23 +286,42 @@ fn linkSqlite(b: *Build, mod: *Build.Module, enable_csan: ?std.zig.SanitizeC, is lib.root_module.sanitize_thread = is_tsan; const macros = [_]struct { []const u8, []const u8 }{ - .{ "SQLITE_DQS", "0" }, + .{ "SQLITE_DEFAULT_FILE_PERMISSIONS", "0600" }, + .{ "SQLITE_DEFAULT_MEMSTATUS", "0" }, .{ "SQLITE_DEFAULT_WAL_SYNCHRONOUS", "1" }, - .{ "SQLITE_USE_ALLOCA", "1" }, - .{ "SQLITE_THREADSAFE", "1" }, - .{ "SQLITE_TEMP_STORE", "3" }, + .{ "SQLITE_DQS", "0" }, .{ "SQLITE_ENABLE_API_ARMOR", "1" }, .{ "SQLITE_ENABLE_UNLOCK_NOTIFY", "1" }, - .{ "SQLITE_DEFAULT_FILE_PERMISSIONS", "0600" }, + .{ "SQLITE_TEMP_STORE", "3" }, + .{ "SQLITE_THREADSAFE", "1" }, + .{ "SQLITE_UNTESTABLE", "1" }, + .{ "SQLITE_USE_ALLOCA", "1" }, + .{ "SQLITE_OMIT_AUTHORIZATION", "1" }, + .{ "SQLITE_OMIT_AUTOMATIC_INDEX", "1" }, + .{ "SQLITE_OMIT_AUTORESET", "1" }, + .{ "SQLITE_OMIT_AUTOVACUUM", "1" }, + .{ "SQLITE_OMIT_BETWEEN_OPTIMIZATION", "1" }, + .{ "SQLITE_OMIT_CASE_SENSITIVE_LIKE_PRAGMA", "1" }, + .{ "SQLITE_OMIT_COMPLETE", "1" }, .{ "SQLITE_OMIT_DECLTYPE", "1" }, .{ "SQLITE_OMIT_DEPRECATED", "1" }, + .{ "SQLITE_OMIT_DESERIALIZE", "1" }, + .{ "SQLITE_OMIT_GET_TABLE", "1" }, + .{ "SQLITE_OMIT_INCRBLOB", "1" }, + .{ "SQLITE_OMIT_JSON", "1" }, + .{ "SQLITE_OMIT_LIKE_OPTIMIZATION", "1" }, .{ "SQLITE_OMIT_LOAD_EXTENSION", "1" }, .{ "SQLITE_OMIT_PROGRESS_CALLBACK", "1" }, .{ "SQLITE_OMIT_SHARED_CACHE", "1" }, + .{ "SQLITE_OMIT_TCL_VARIABLE", "1" }, + .{ "SQLITE_OMIT_TEMPDB", "1" }, .{ "SQLITE_OMIT_TRACE", "1" }, .{ "SQLITE_OMIT_UTF16", "1" }, + .{ "SQLITE_OMIT_XFER_OPT", "1" }, }; - for (macros) |m| lib.root_module.addCMacro(m[0], m[1]); + for (macros) |m| { + lib.root_module.addCMacro(m[0], m[1]); + } mod.linkLibrary(lib); } diff --git a/src/Config.zig b/src/Config.zig index 1a3f036e..22193848 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -582,8 +582,8 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { \\ Defaults to no caching. \\ \\--storage-engine - \\ The storage engine to use. Choices are: sqlite. - \\ Default to sqlite. + \\ The storage engine to use. Choices are: none, sqlite. + \\ Default to none. \\ \\--storage-sqlite-path \\ Path to SQLite database file for persistent storage. diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 7faedb4b..1ef6dbc8 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -721,7 +721,10 @@ fn scheduleNavigationWithArena(originator: *Frame, arena: Allocator, request_url }; const session = target._session; - if (!opts.force and URL.eqlDocument(target.url, resolved_url)) { + // Short-circuit only true fragment-only navigations (same path/query, different + // fragment). Identical URLs fall through and trigger a real reload. + const is_fragment_navigation = !std.mem.eql(u8, target.url, resolved_url) and URL.eqlDocument(target.url, resolved_url); + if (!opts.force and is_fragment_navigation) { target.url = try target.arena.dupeZ(u8, resolved_url); target.window._location = try Location.init(target.url, target); if (target.parent == null) { diff --git a/src/browser/HttpClient.zig b/src/browser/HttpClient.zig index fc217e3d..0832db1a 100644 --- a/src/browser/HttpClient.zig +++ b/src/browser/HttpClient.zig @@ -1572,7 +1572,19 @@ pub const Transfer = struct { } const base_url = try conn.getEffectiveUrl(); - break :blk try URL.resolve(arena, std.mem.span(base_url), location.value, .{}); + const resolved = try URL.resolve(arena, std.mem.span(base_url), location.value, .{}); + + // RFC 7231 §7.1.2: if the Location value has no fragment, the redirect + // inherits the fragment from the URI used to generate the request. + // URL.resolve follows RFC 3986 §5.3, which drops the base fragment when + // the relative ref has none, so we re-attach it here. + if (URL.getHash(resolved).len == 0) { + const original_hash = URL.getHash(transfer.url); + if (original_hash.len != 0) { + break :blk try std.mem.joinZ(arena, "", &.{ resolved, original_hash }); + } + } + break :blk resolved; }; try transfer.updateURL(url); diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index 8ea3c9bc..b2840e3c 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -91,6 +91,11 @@ private_symbols: PrivateSymbols, microtask_queues_are_running: bool, +// Serializes V8 calls that race with TerminateExecution (which can fire from +// the sighandler thread). Without this, a terminate landing between the +// IsExecutionTerminating check and PerformCheckpoint trips a V8 debug assert. +terminate_mutex: std.Thread.Mutex = .{}, + pub const InitOpts = struct { with_inspector: bool = false, }; @@ -360,6 +365,9 @@ pub fn destroyContext(self: *Env, context: *Context) void { pub fn runMicrotasks(self: *Env) void { if (self.microtask_queues_are_running == false) { + self.terminate_mutex.lock(); + defer self.terminate_mutex.unlock(); + const v8_isolate = self.isolate.handle; if (v8.v8__Isolate__IsExecutionTerminating(v8_isolate)) { @@ -489,14 +497,18 @@ pub fn dumpMemoryStats(self: *Env) void { , .{ stats.total_heap_size, stats.total_heap_size_executable, stats.total_physical_size, stats.total_available_size, stats.used_heap_size, stats.heap_size_limit, stats.malloced_memory, stats.external_memory, stats.peak_malloced_memory, stats.number_of_native_contexts, stats.number_of_detached_contexts, stats.total_global_handles_size, stats.used_global_handles_size, stats.does_zap_garbage }); } -pub fn terminate(self: *const Env) void { +pub fn terminate(self: *Env) void { + self.terminate_mutex.lock(); + defer self.terminate_mutex.unlock(); v8.v8__Isolate__TerminateExecution(self.isolate.handle); } /// Clears a pending termination so V8 calls (e.g. those made during cleanup) /// don't keep tripping over the terminating-state asserts. Safe to call /// unconditionally; a no-op if termination wasn't pending. -pub fn cancelTerminate(self: *const Env) void { +pub fn cancelTerminate(self: *Env) void { + self.terminate_mutex.lock(); + defer self.terminate_mutex.unlock(); v8.v8__Isolate__CancelTerminateExecution(self.isolate.handle); } diff --git a/src/browser/js/Snapshot.zig b/src/browser/js/Snapshot.zig index c625127c..533d479c 100644 --- a/src/browser/js/Snapshot.zig +++ b/src/browser/js/Snapshot.zig @@ -276,9 +276,11 @@ fn createSnapshotContext( const name = JsApi.Meta.name; const v8_class_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len)); var maybe_result: v8.MaybeBool = undefined; - var properties: v8.PropertyAttribute = v8.None; - if (@hasDecl(JsApi.Meta, "enumerable") and JsApi.Meta.enumerable == false) { - properties |= v8.DontEnum; + // Web IDL: interface objects on the global are non-enumerable + // by default. Opt back in via JsApi.Meta.enumerable = true. + var properties: v8.PropertyAttribute = v8.DontEnum; + if (@hasDecl(JsApi.Meta, "enumerable") and JsApi.Meta.enumerable == true) { + properties = v8.None; } v8.v8__Object__DefineOwnProperty(global_obj, context, v8_class_name, func, properties, &maybe_result); } @@ -578,6 +580,8 @@ pub fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *const v8 const name_str = if (@hasDecl(JsApi.Meta, "name")) JsApi.Meta.name else @typeName(JsApi); const class_name = v8.v8__String__NewFromUtf8(isolate, name_str.ptr, v8.kNormal, @intCast(name_str.len)); v8.v8__FunctionTemplate__SetClassName(template, class_name); + // Web IDL: interface object's `prototype` property is non-writable/non-configurable. + v8.v8__FunctionTemplate__ReadOnlyPrototype(template); return template; } @@ -615,13 +619,21 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *const v8.F .callback = value.getter, .signature = getter_signature, }).?; - const setter_callback = if (value.setter) |setter| - v8.v8__FunctionTemplate__New__Config(isolate, &.{ + // WebIDL: getter function's .name should be "get X" + const getter_name_str = "get " ++ name; + const getter_name_v8 = v8.v8__String__NewFromUtf8(isolate, getter_name_str.ptr, v8.kNormal, @intCast(getter_name_str.len)); + v8.v8__FunctionTemplate__SetClassName(getter_callback, getter_name_v8); + + const setter_callback = if (value.setter) |setter| blk: { + const cb = v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = setter, .signature = getter_signature, - }).? - else - null; + }).?; + const setter_name_str = "set " ++ name; + const setter_name_v8 = v8.v8__String__NewFromUtf8(isolate, setter_name_str.ptr, v8.kNormal, @intCast(setter_name_str.len)); + v8.v8__FunctionTemplate__SetClassName(cb, setter_name_v8); + break :blk cb; + } else null; var attribute: v8.PropertyAttribute = 0; if (value.setter == null) { @@ -655,6 +667,7 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *const v8.F .signature = func_signature, }).?; const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len)); + v8.v8__FunctionTemplate__SetClassName(function_template, js_name); if (value.static) { v8.v8__Template__Set(@ptrCast(template), js_name, @ptrCast(function_template), v8.None); } else { diff --git a/src/browser/tests/element/html/script/script.html b/src/browser/tests/element/html/script/script.html index 4f826fe7..d49d025a 100644 --- a/src/browser/tests/element/html/script/script.html +++ b/src/browser/tests/element/html/script/script.html @@ -25,5 +25,15 @@ testing.expectEqual(true, dom_load); testing.expectEqual(true, attribute_load); }); + + // Web IDL requires attribute getters/setters to be named "get X" / "set X" + // and methods to use their identifier. Sentinel for the binding-level naming + // fix in Snapshot.zig — Script.src exercises both accessor paths. + const desc = Object.getOwnPropertyDescriptor(HTMLScriptElement.prototype, 'src'); + testing.expectEqual('get src', desc.get.name); + testing.expectEqual('set src', desc.set.name); + + const append = Object.getOwnPropertyDescriptor(Element.prototype, 'append'); + testing.expectEqual('append', append.value.name); } diff --git a/src/browser/tests/event/composition.html b/src/browser/tests/event/composition.html index b5a6a710..e9602335 100644 --- a/src/browser/tests/event/composition.html +++ b/src/browser/tests/event/composition.html @@ -5,10 +5,13 @@ { let event = new CompositionEvent("test", {}); testing.expectEqual(true, event instanceof CompositionEvent); + testing.expectEqual(true, event instanceof UIEvent); testing.expectEqual(true, event instanceof Event); testing.expectEqual("test", event.type); testing.expectEqual("", event.data); + testing.expectEqual(0, event.detail); + testing.expectEqual(window, event.view); } @@ -34,3 +37,36 @@ } + + + + + + diff --git a/src/browser/tests/event/keyboard.html b/src/browser/tests/event/keyboard.html index 79baff7e..223a9e0b 100644 --- a/src/browser/tests/event/keyboard.html +++ b/src/browser/tests/event/keyboard.html @@ -116,3 +116,54 @@ testing.expectEqual(true, called); } + + + + + + + + diff --git a/src/browser/tests/event/mouse.html b/src/browser/tests/event/mouse.html index 83fc4045..5314889b 100644 --- a/src/browser/tests/event/mouse.html +++ b/src/browser/tests/event/mouse.html @@ -52,3 +52,67 @@ document.dispatchEvent(new MouseEvent('mousetest')); testing.expectEqual(false, mouseIsTrusted); + + + + + + + + + + diff --git a/src/browser/tests/event/text.html b/src/browser/tests/event/text.html index f885c293..61894502 100644 --- a/src/browser/tests/event/text.html +++ b/src/browser/tests/event/text.html @@ -1,17 +1,32 @@ + + diff --git a/src/browser/tests/event/ui.html b/src/browser/tests/event/ui.html index 50d880ec..f060622a 100644 --- a/src/browser/tests/event/ui.html +++ b/src/browser/tests/event/ui.html @@ -52,3 +52,33 @@ const evt7 = new UIEvent('custom', { bubbles: true }); testing.expectEqual(0, evt7.detail); + + + + + + diff --git a/src/browser/tests/frames/frames.html b/src/browser/tests/frames/frames.html index 0d11b1bb..98405cdc 100644 --- a/src/browser/tests/frames/frames.html +++ b/src/browser/tests/frames/frames.html @@ -151,8 +151,20 @@ } + + + diff --git a/src/browser/tests/window/location.html b/src/browser/tests/window/location.html index b82adfb4..546463ea 100644 --- a/src/browser/tests/window/location.html +++ b/src/browser/tests/window/location.html @@ -7,14 +7,14 @@ + + + + diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index 2fb80e0c..f6b28e42 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -396,6 +396,11 @@ pub fn createEvent(_: *const Document, event_type: []const u8, frame: *Frame) !* return (try TextEvent.init("", null, frame)).asEvent(); } + if (std.mem.eql(u8, normalized, "compositionevent")) { + const CompositionEvent = @import("event/CompositionEvent.zig"); + return (try CompositionEvent.init("", null, frame)).asEvent(); + } + return error.NotSupported; } diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index f08914e8..01a3617a 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -71,7 +71,6 @@ pub const Type = union(enum) { custom_event: *@import("event/CustomEvent.zig"), message_event: *@import("event/MessageEvent.zig"), progress_event: *@import("event/ProgressEvent.zig"), - composition_event: *@import("event/CompositionEvent.zig"), navigation_current_entry_change_event: *@import("event/NavigationCurrentEntryChangeEvent.zig"), page_transition_event: *@import("event/PageTransitionEvent.zig"), pop_state_event: *@import("event/PopStateEvent.zig"), @@ -163,7 +162,6 @@ pub fn is(self: *Event, comptime T: type) ?*T { .custom_event => |e| return if (T == @import("event/CustomEvent.zig")) e else null, .message_event => |e| return if (T == @import("event/MessageEvent.zig")) e else null, .progress_event => |e| return if (T == @import("event/ProgressEvent.zig")) e else null, - .composition_event => |e| return if (T == @import("event/CompositionEvent.zig")) e else null, .navigation_current_entry_change_event => |e| return if (T == @import("event/NavigationCurrentEntryChangeEvent.zig")) e else null, .page_transition_event => |e| return if (T == @import("event/PageTransitionEvent.zig")) e else null, .pop_state_event => |e| return if (T == @import("event/PopStateEvent.zig")) e else null, diff --git a/src/browser/webapi/Location.zig b/src/browser/webapi/Location.zig index 2a00d81e..f57db2c0 100644 --- a/src/browser/webapi/Location.zig +++ b/src/browser/webapi/Location.zig @@ -20,6 +20,7 @@ const std = @import("std"); const js = @import("../js/js.zig"); const URL = @import("URL.zig"); +const U = @import("../URL.zig"); const Frame = @import("../Frame.zig"); const Location = @This(); @@ -65,6 +66,22 @@ pub fn getHash(self: *const Location) []const u8 { return self._url.getHash(); } +pub fn setPathname(_: *const Location, pathname: []const u8, frame: *Frame) !void { + const new_url = try U.setPathname(frame.url, pathname, frame.call_arena); + return frame.scheduleNavigation(new_url, .{ + .reason = .script, + .kind = .{ .push = null }, + }, .{ .script = frame }); +} + +pub fn setSearch(_: *const Location, search: []const u8, frame: *Frame) !void { + const new_url = try U.setSearch(frame.url, search, frame.call_arena); + return frame.scheduleNavigation(new_url, .{ + .reason = .script, + .kind = .{ .push = null }, + }, .{ .script = frame }); +} + pub fn setHash(_: *const Location, hash: []const u8, frame: *Frame) !void { const normalized_hash = blk: { if (hash.len == 0) { @@ -117,9 +134,9 @@ pub const JsApi = struct { return self.assign(url, frame); } - pub const search = bridge.accessor(Location.getSearch, null, .{}); + pub const search = bridge.accessor(Location.getSearch, Location.setSearch, .{}); pub const hash = bridge.accessor(Location.getHash, Location.setHash, .{}); - pub const pathname = bridge.accessor(Location.getPathname, null, .{}); + pub const pathname = bridge.accessor(Location.getPathname, Location.setPathname, .{}); pub const hostname = bridge.accessor(Location.getHostname, null, .{}); pub const host = bridge.accessor(Location.getHost, null, .{}); pub const port = bridge.accessor(Location.getPort, null, .{}); diff --git a/src/browser/webapi/canvas/WebGLRenderingContext.zig b/src/browser/webapi/canvas/WebGLRenderingContext.zig index ba4b6938..28dcce39 100644 --- a/src/browser/webapi/canvas/WebGLRenderingContext.zig +++ b/src/browser/webapi/canvas/WebGLRenderingContext.zig @@ -204,7 +204,8 @@ pub const JsApi = struct { pub const getSupportedExtensions = bridge.function(WebGLRenderingContext.getSupportedExtensions, .{}); }; -const testing = @import("../../../testing.zig"); -test "WebApi: WebGLRenderingContext" { - try testing.htmlRunner("canvas/webgl_rendering_context.html", .{}); -} +// getContext('web-gl') currently returns null, so this cannot be tested +// const testing = @import("../../../testing.zig"); +// test "WebApi: WebGLRenderingContext" { +// try testing.htmlRunner("canvas/webgl_rendering_context.html", .{}); +// } diff --git a/src/browser/webapi/element/html/Canvas.zig b/src/browser/webapi/element/html/Canvas.zig index e1648f5d..da2bfb43 100644 --- a/src/browser/webapi/element/html/Canvas.zig +++ b/src/browser/webapi/element/html/Canvas.zig @@ -85,9 +85,17 @@ pub fn getContext(self: *Canvas, context_type: []const u8, frame: *Frame) !?Draw break :blk .{ .@"2d" = ctx }; } + // We only stub a tiny slice of the WebGL API (getParameter, + // getExtension, getSupportedExtensions). Real WebGL consumers like + // Three.js immediately call createTexture/createBuffer/etc. and + // throw `TypeError: e.createTexture is not a function`. Pretending + // WebGL works until the first non-stubbed call is the worst of both + // worlds: pages that have an error boundary above the WebGL widget + // catch the throw, reset, re-render, and loop forever. + // Spec-correct signal for "no WebGL" is null, so apps that check + // (Three.js does) can degrade gracefully. if (std.mem.eql(u8, context_type, "webgl") or std.mem.eql(u8, context_type, "experimental-webgl")) { - const ctx = try frame._factory.create(WebGLRenderingContext{}); - break :blk .{ .webgl = ctx }; + return null; } return null; }; diff --git a/src/browser/webapi/event/CompositionEvent.zig b/src/browser/webapi/event/CompositionEvent.zig index b0492a92..3d25a156 100644 --- a/src/browser/webapi/event/CompositionEvent.zig +++ b/src/browser/webapi/event/CompositionEvent.zig @@ -22,12 +22,14 @@ const js = @import("../../js/js.zig"); const Frame = @import("../../Frame.zig"); const Event = @import("../Event.zig"); +const UIEvent = @import("UIEvent.zig"); +const Window = @import("../Window.zig"); const String = lp.String; const CompositionEvent = @This(); -_proto: *Event, +_proto: *UIEvent, _data: []const u8 = "", const CompositionEventOptions = struct { @@ -42,7 +44,7 @@ pub fn init(typ: []const u8, opts_: ?Options, frame: *Frame) !*CompositionEvent const type_string = try String.init(arena, typ, .{}); const opts = opts_ orelse Options{}; - const event = try frame._factory.event( + const event = try frame._factory.uiEvent( arena, type_string, CompositionEvent{ @@ -56,13 +58,35 @@ pub fn init(typ: []const u8, opts_: ?Options, frame: *Frame) !*CompositionEvent } pub fn asEvent(self: *CompositionEvent) *Event { - return self._proto; + return self._proto.asEvent(); } pub fn getData(self: *const CompositionEvent) []const u8 { return self._data; } +pub fn initCompositionEvent( + self: *CompositionEvent, + typ: []const u8, + bubbles: ?bool, + cancelable: ?bool, + view: ?*Window, + data: ?[]const u8, +) !void { + const ui = self._proto; + const event = ui._proto; + if (event._event_phase != .none) { + return; + } + + const arena = event._arena; + event._type_string = try String.init(arena, typ, .{}); + event._bubbles = bubbles orelse false; + event._cancelable = cancelable orelse false; + ui._view = view; + self._data = if (data) |d| try arena.dupe(u8, d) else ""; +} + pub const JsApi = struct { pub const bridge = js.Bridge(CompositionEvent); @@ -74,6 +98,7 @@ pub const JsApi = struct { pub const constructor = bridge.constructor(CompositionEvent.init, .{}); pub const data = bridge.accessor(CompositionEvent.getData, null, .{}); + pub const initCompositionEvent = bridge.function(CompositionEvent.initCompositionEvent, .{}); }; const testing = @import("../../../testing.zig"); diff --git a/src/browser/webapi/event/KeyboardEvent.zig b/src/browser/webapi/event/KeyboardEvent.zig index b2658d02..979b5c29 100644 --- a/src/browser/webapi/event/KeyboardEvent.zig +++ b/src/browser/webapi/event/KeyboardEvent.zig @@ -270,6 +270,49 @@ pub fn getShiftKey(self: *const KeyboardEvent) bool { return self._shift_key; } +// Deprecated: tracked as 0 since we don't synthesise legacy character codes. +pub fn getCharCode(self: *const KeyboardEvent) u32 { + _ = self; + return 0; +} + +pub fn getKeyCode(self: *const KeyboardEvent) u32 { + _ = self; + return 0; +} + +pub fn initKeyboardEvent( + self: *KeyboardEvent, + typ: []const u8, + bubbles: ?bool, + cancelable: ?bool, + view: ?*@import("../Window.zig"), + key: ?[]const u8, + location: ?u32, + ctrl_key: ?bool, + alt_key: ?bool, + shift_key: ?bool, + meta_key: ?bool, +) !void { + const ui = self._proto; + const event = ui._proto; + if (event._event_phase != .none) { + return; + } + + const arena = event._arena; + event._type_string = try String.init(arena, typ, .{}); + event._bubbles = bubbles orelse false; + event._cancelable = cancelable orelse false; + ui._view = view; + self._key = try Key.fromString(arena, key orelse ""); + self._location = std.meta.intToEnum(Location, location orelse 0) catch return error.TypeError; + self._ctrl_key = ctrl_key orelse false; + self._alt_key = alt_key orelse false; + self._shift_key = shift_key orelse false; + self._meta_key = meta_key orelse false; +} + pub fn getModifierState(self: *const KeyboardEvent, str: []const u8) !bool { const key = try Key.fromString(self._proto._proto._arena, str); @@ -309,7 +352,15 @@ pub const JsApi = struct { pub const metaKey = bridge.accessor(KeyboardEvent.getMetaKey, null, .{}); pub const repeat = bridge.accessor(KeyboardEvent.getRepeat, null, .{}); pub const shiftKey = bridge.accessor(KeyboardEvent.getShiftKey, null, .{}); + pub const charCode = bridge.accessor(KeyboardEvent.getCharCode, null, .{}); + pub const keyCode = bridge.accessor(KeyboardEvent.getKeyCode, null, .{}); pub const getModifierState = bridge.function(KeyboardEvent.getModifierState, .{}); + pub const initKeyboardEvent = bridge.function(KeyboardEvent.initKeyboardEvent, .{}); + + pub const DOM_KEY_LOCATION_STANDARD = bridge.property(@intFromEnum(Location.DOM_KEY_LOCATION_STANDARD), .{ .template = true }); + pub const DOM_KEY_LOCATION_LEFT = bridge.property(@intFromEnum(Location.DOM_KEY_LOCATION_LEFT), .{ .template = true }); + pub const DOM_KEY_LOCATION_RIGHT = bridge.property(@intFromEnum(Location.DOM_KEY_LOCATION_RIGHT), .{ .template = true }); + pub const DOM_KEY_LOCATION_NUMPAD = bridge.property(@intFromEnum(Location.DOM_KEY_LOCATION_NUMPAD), .{ .template = true }); }; const testing = @import("../../../testing.zig"); diff --git a/src/browser/webapi/event/MouseEvent.zig b/src/browser/webapi/event/MouseEvent.zig index eaa0315e..240ce6c8 100644 --- a/src/browser/webapi/event/MouseEvent.zig +++ b/src/browser/webapi/event/MouseEvent.zig @@ -193,6 +193,65 @@ pub fn getShiftKey(self: *const MouseEvent) bool { return self._shift_key; } +// Deprecated: tracks the same value as offsetX/clientX in the absence of layout. +pub fn getLayerX(self: *const MouseEvent) f64 { + return self._client_x; +} + +pub fn getLayerY(self: *const MouseEvent) f64 { + return self._client_y; +} + +pub fn getModifierState(self: *const MouseEvent, key: []const u8) bool { + if (std.mem.eql(u8, key, "Alt") or std.mem.eql(u8, key, "AltGraph")) return self._alt_key; + if (std.mem.eql(u8, key, "Control")) return self._ctrl_key; + if (std.mem.eql(u8, key, "Shift")) return self._shift_key; + if (std.mem.eql(u8, key, "Meta")) return self._meta_key; + if (std.mem.eql(u8, key, "Accel")) return self._ctrl_key or self._meta_key; + return false; +} + +pub fn initMouseEvent( + self: *MouseEvent, + typ: []const u8, + bubbles: ?bool, + cancelable: ?bool, + view: ?*@import("../Window.zig"), + detail: ?i32, + screen_x: ?i32, + screen_y: ?i32, + client_x: ?i32, + client_y: ?i32, + ctrl_key: ?bool, + alt_key: ?bool, + shift_key: ?bool, + meta_key: ?bool, + button: ?i16, + related_target: ?*EventTarget, +) !void { + const ui = self._proto; + const event = ui._proto; + if (event._event_phase != .none) { + return; + } + + event._type_string = try String.init(event._arena, typ, .{}); + event._bubbles = bubbles orelse false; + event._cancelable = cancelable orelse false; + ui._view = view; + ui._detail = if (detail) |d| @intCast(@max(d, 0)) else 0; + self._screen_x = @floatFromInt(screen_x orelse 0); + self._screen_y = @floatFromInt(screen_y orelse 0); + self._client_x = @floatFromInt(client_x orelse 0); + self._client_y = @floatFromInt(client_y orelse 0); + self._ctrl_key = ctrl_key orelse false; + self._alt_key = alt_key orelse false; + self._shift_key = shift_key orelse false; + self._meta_key = meta_key orelse false; + self._button = std.meta.intToEnum(MouseButton, button orelse 0) catch return error.TypeError; + self._related_target = related_target; +} + pub const JsApi = struct { pub const bridge = js.Bridge(MouseEvent); @@ -218,8 +277,12 @@ pub const JsApi = struct { pub const screenX = bridge.accessor(getScreenX, null, .{}); pub const screenY = bridge.accessor(getScreenY, null, .{}); pub const shiftKey = bridge.accessor(getShiftKey, null, .{}); + pub const layerX = bridge.accessor(getLayerX, null, .{}); + pub const layerY = bridge.accessor(getLayerY, null, .{}); pub const x = bridge.accessor(getClientX, null, .{}); pub const y = bridge.accessor(getClientY, null, .{}); + pub const getModifierState = bridge.function(MouseEvent.getModifierState, .{}); + pub const initMouseEvent = bridge.function(MouseEvent.initMouseEvent, .{}); }; const testing = @import("../../../testing.zig"); diff --git a/src/browser/webapi/event/TextEvent.zig b/src/browser/webapi/event/TextEvent.zig index 74dc18fc..7662320f 100644 --- a/src/browser/webapi/event/TextEvent.zig +++ b/src/browser/webapi/event/TextEvent.zig @@ -72,24 +72,23 @@ pub fn getData(self: *const TextEvent) []const u8 { pub fn initTextEvent( self: *TextEvent, typ: []const u8, - bubbles: bool, - cancelable: bool, + bubbles: ?bool, + cancelable: ?bool, view: ?*@import("../Window.zig"), - data: []const u8, + data: ?[]const u8, ) !void { - _ = view; // view parameter is ignored in modern implementations - - const event = self._proto._proto; + const ui = self._proto; + const event = ui._proto; if (event._event_phase != .none) { - // Only allow initialization if event hasn't been dispatched return; } const arena = event._arena; event._type_string = try String.init(arena, typ, .{}); - event._bubbles = bubbles; - event._cancelable = cancelable; - self._data = try arena.dupe(u8, data); + event._bubbles = bubbles orelse false; + event._cancelable = cancelable orelse false; + ui._view = view; + self._data = if (data) |d| try arena.dupe(u8, d) else ""; } pub const JsApi = struct { diff --git a/src/browser/webapi/event/UIEvent.zig b/src/browser/webapi/event/UIEvent.zig index 4122edde..13671c5e 100644 --- a/src/browser/webapi/event/UIEvent.zig +++ b/src/browser/webapi/event/UIEvent.zig @@ -40,6 +40,7 @@ pub const Type = union(enum) { focus_event: *@import("FocusEvent.zig"), text_event: *@import("TextEvent.zig"), input_event: *@import("InputEvent.zig"), + composition_event: *@import("CompositionEvent.zig"), }; pub const UIEventOptions = struct { @@ -88,6 +89,7 @@ pub fn is(self: *UIEvent, comptime T: type) ?*T { .focus_event => |e| return if (T == @import("FocusEvent.zig")) e else null, .text_event => |e| return if (T == @import("TextEvent.zig")) e else null, .input_event => |e| return if (T == @import("InputEvent.zig")) e else null, + .composition_event => |e| return if (T == @import("CompositionEvent.zig")) e else null, } return null; } @@ -111,7 +113,34 @@ pub fn getView(self: *UIEvent, frame: *Frame) *Window { return self._view orelse frame.window; } -// deprecated `initUIEvent()` not implemented +// Legacy: see https://w3c.github.io/uievents/#dom-uievent-which +pub fn getWhich(self: *const UIEvent) u32 { + return switch (self._type) { + .mouse_event => |me| @as(u32, @intCast(me.getButton())) + 1, + .keyboard_event => 0, + else => 0, + }; +} + +pub fn initUIEvent( + self: *UIEvent, + typ: []const u8, + bubbles: ?bool, + cancelable: ?bool, + view: ?*Window, + detail: ?i32, +) !void { + const event = self._proto; + if (event._event_phase != .none) { + return; + } + + event._type_string = try String.init(event._arena, typ, .{}); + event._bubbles = bubbles orelse false; + event._cancelable = cancelable orelse false; + self._view = view; + self._detail = if (detail) |d| @intCast(@max(d, 0)) else 0; +} pub const JsApi = struct { pub const bridge = js.Bridge(UIEvent); @@ -125,6 +154,8 @@ pub const JsApi = struct { pub const constructor = bridge.constructor(UIEvent.init, .{}); pub const detail = bridge.accessor(UIEvent.getDetail, null, .{}); pub const view = bridge.accessor(UIEvent.getView, null, .{}); + pub const which = bridge.accessor(UIEvent.getWhich, null, .{}); + pub const initUIEvent = bridge.function(UIEvent.initUIEvent, .{}); }; const testing = @import("../../../testing.zig"); diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index b0aae79e..2bfb8988 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -1003,6 +1003,60 @@ test "cdp.frame: reload" { } } +test "cdp.frame: navigate inherits original fragment across redirect" { + // RFC 7231 §7.1.2: when a 3xx Location header has no fragment, the redirect + // inherits the fragment of the request URL. + var ctx = try testing.context(); + defer ctx.deinit(); + + var bc = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* }); + + { + // Location: /redirect-target (no fragment) — must inherit #myfrag. + try ctx.processMessage(.{ + .id = 40, + .method = "Page.navigate", + .params = .{ .url = "http://127.0.0.1:9582/redirect-no-fragment#myfrag" }, + }); + + var runner = try bc.session.runner(.{}); + try runner.wait(.{ .ms = 2000 }); + + const frame = bc.session.currentFrame() orelse unreachable; + try testing.expectEqualSlices(u8, "http://127.0.0.1:9582/redirect-target#myfrag", frame.url); + } + + { + // Location: /redirect-target#target_fragment — target's fragment wins. + try ctx.processMessage(.{ + .id = 41, + .method = "Page.navigate", + .params = .{ .url = "http://127.0.0.1:9582/redirect-with-fragment#requested" }, + }); + + var runner = try bc.session.runner(.{}); + try runner.wait(.{ .ms = 2000 }); + + const frame = bc.session.currentFrame() orelse unreachable; + try testing.expectEqualSlices(u8, "http://127.0.0.1:9582/redirect-target#target_fragment", frame.url); + } + + { + // No fragment on either side — final URL has no fragment. + try ctx.processMessage(.{ + .id = 42, + .method = "Page.navigate", + .params = .{ .url = "http://127.0.0.1:9582/redirect-no-fragment" }, + }); + + var runner = try bc.session.runner(.{}); + try runner.wait(.{ .ms = 2000 }); + + const frame = bc.session.currentFrame() orelse unreachable; + try testing.expectEqualSlices(u8, "http://127.0.0.1:9582/redirect-target", frame.url); + } +} + test "cdp.frame: addScriptToEvaluateOnNewDocument" { var ctx = try testing.context(); defer ctx.deinit(); diff --git a/src/cli.zig b/src/cli.zig index 061fa784..a7d8f955 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -405,7 +405,7 @@ pub fn Builder(comptime commands: anytype) type { error.Overflow => log.fatal(.app, "range overflow", .{ .arg = kebab_cased, .value = str }), error.InvalidCharacter => log.fatal(.app, "invalid character", .{ .arg = kebab_cased, .value = str }), } - continue :iter_args; + return error.InvalidArgument; }; if (is_multiple) { @@ -481,6 +481,7 @@ pub fn Builder(comptime commands: anytype) type { // Invalid option choice. log.fatal(.app, "invalid option choice", .{ .arg = kebab_cased, .value = trimmed }); + return error.InvalidArgument; } } }, @@ -493,7 +494,7 @@ pub fn Builder(comptime commands: anytype) type { const str = args.next() orelse return error.MissingArgument; const v = std.meta.stringToEnum(E, str) orelse { log.fatal(.app, "invalid option choice", .{ .arg = kebab_cased, .value = str }); - continue :iter_args; + return error.InvalidArgument; }; if (is_multiple) { diff --git a/src/storage/Blackhole.zig b/src/storage/Blackhole.zig new file mode 100644 index 00000000..8da0dc8e --- /dev/null +++ b/src/storage/Blackhole.zig @@ -0,0 +1,24 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const Blackhole = @This(); + +pub fn deinit(_: *Blackhole, _: Allocator) void {} diff --git a/src/storage/Storage.zig b/src/storage/Storage.zig index 7a04769d..54cefdaf 100644 --- a/src/storage/Storage.zig +++ b/src/storage/Storage.zig @@ -19,6 +19,7 @@ const std = @import("std"); const log = @import("../log.zig"); const Config = @import("../Config.zig"); +const Blackhole = @import("Blackhole.zig"); const Sqlite = @import("sqlite/Sqlite.zig"); const Allocator = std.mem.Allocator; @@ -26,17 +27,19 @@ const Allocator = std.mem.Allocator; const Storage = @This(); pub const EngineType = enum { + none, sqlite, }; const Engine = union(EngineType) { + none: Blackhole, sqlite: Sqlite, }; engine: Engine, pub fn init(allocator: Allocator, config: *const Config) !Storage { - const engine_type = config.storageEngine() orelse .sqlite; + const engine_type = config.storageEngine() orelse .none; const engine = initEngine(allocator, engine_type, config) catch |err| { log.fatal(.storage, "storage setup", .{ .engine = engine_type, .err = err }); return err; @@ -49,6 +52,7 @@ pub fn init(allocator: Allocator, config: *const Config) !Storage { fn initEngine(allocator: Allocator, engine_type: EngineType, config: *const Config) !Engine { switch (engine_type) { + .none => return .{ .none = Blackhole{} }, .sqlite => { const sqlite_path = config.storageSqlitePath(); return .{ .sqlite = try Sqlite.init(allocator, sqlite_path) }; diff --git a/src/storage/sqlite/Sqlite.zig b/src/storage/sqlite/Sqlite.zig index 36b7af99..e020b5a0 100644 --- a/src/storage/sqlite/Sqlite.zig +++ b/src/storage/sqlite/Sqlite.zig @@ -29,8 +29,9 @@ const Sqlite = @This(); pool: Pool, -pub fn init(allocator: Allocator, path: ?[:0]const u8) !Sqlite { - var pool = try Pool.init(allocator, path orelse ":memory:"); +pub fn init(allocator: Allocator, path_: ?[:0]const u8) !Sqlite { + const path = path_ orelse ":memory:"; + var pool = try Pool.init(allocator, path); errdefer pool.deinit(allocator); { @@ -39,7 +40,9 @@ pub fn init(allocator: Allocator, path: ?[:0]const u8) !Sqlite { // pool to the return value (copy A) and then release the original const conn = try pool.acquire(); defer pool.release(conn); - try @import("migrations.zig").run(conn); + + const version = try @import("migrations.zig").run(conn); + log.info(.storage, "storage initialized", .{ .engine = "sqlite", .version = version, .path = path }); } return .{ diff --git a/src/storage/sqlite/migrations.zig b/src/storage/sqlite/migrations.zig index b0eb89f1..e431c443 100644 --- a/src/storage/sqlite/migrations.zig +++ b/src/storage/sqlite/migrations.zig @@ -22,9 +22,9 @@ const Sqlite = @import("Sqlite.zig"); const log = lp.log; -pub fn run(conn: Sqlite.Conn) !void { +pub fn run(conn: Sqlite.Conn) !i64 { const version = try getVersion(conn); - log.info(.storage, "migration version", .{ .engine = "sqlite", .version = version }); + return version; } fn getVersion(conn: Sqlite.Conn) !i64 { diff --git a/src/testing.zig b/src/testing.zig index e2500f81..73defeed 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -610,6 +610,32 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void { }); } + if (std.mem.eql(u8, path, "/redirect-no-fragment")) { + return req.respond("", .{ + .status = .found, + .extra_headers = &.{ + .{ .name = "Location", .value = "/redirect-target" }, + }, + }); + } + + if (std.mem.eql(u8, path, "/redirect-target")) { + return req.respond("landed", .{ + .extra_headers = &.{ + .{ .name = "Content-Type", .value = "text/html; charset=utf-8" }, + }, + }); + } + + if (std.mem.eql(u8, path, "/redirect-with-fragment")) { + return req.respond("", .{ + .status = .found, + .extra_headers = &.{ + .{ .name = "Location", .value = "/redirect-target#target_fragment" }, + }, + }); + } + if (std.mem.eql(u8, path, "/xhr/404")) { return req.respond("Not Found", .{ .status = .not_found,