diff --git a/.github/workflows/package-archlinux.yml b/.github/workflows/package-archlinux.yml new file mode 100644 index 00000000..dba6d2d0 --- /dev/null +++ b/.github/workflows/package-archlinux.yml @@ -0,0 +1,79 @@ +name: package archlinux + +on: + workflow_call: + +permissions: + contents: write + +env: + RELEASE: ${{ github.ref_type == 'tag' && github.ref_name || 'nightly' }} + +jobs: + package: + strategy: + fail-fast: false + matrix: + arch: [x86_64, aarch64] + + env: + ARCH: ${{ matrix.arch }} + OS: linux + + runs-on: ubuntu-22.04 + container: archlinux:latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v6 + + - name: Install packaging deps + run: pacman -Syu --noconfirm --needed base-devel sudo + + - name: Download linux binary + uses: actions/download-artifact@v4 + with: + name: lightpanda-${{ env.ARCH }}-${{ env.OS }} + path: . + + - name: Build Arch package + run: | + useradd -m builder + echo "builder ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers + + RAW_VERSION="${{ env.RELEASE }}" + PKGVER="${RAW_VERSION#v}" + PKGREL="1" + echo "PKGVER=${PKGVER}" >> "$GITHUB_ENV" + echo "PKGREL=${PKGREL}" >> "$GITHUB_ENV" + + mkdir -p pkg + cp lightpanda-${{ env.ARCH }}-${{ env.OS }} pkg/ + cp LICENSE pkg/ + + cat > pkg/PKGBUILD <> "$GITHUB_ENV" + + ROOT="lightpanda_${PKGVER}_${DEB_ARCH}" + mkdir -p "$ROOT/DEBIAN" "$ROOT/usr/bin" "$ROOT/usr/share/doc/lightpanda" + + install -m755 "lightpanda-${ARCH}-${OS}" "$ROOT/usr/bin/lightpanda" + install -m644 LICENSE "$ROOT/usr/share/doc/lightpanda/copyright" + + cat > "$ROOT/DEBIAN/control" <= 2.35) + Maintainer: Lightpanda + Homepage: https://lightpanda.io + Description: Lightpanda, headless browser built for AI and automation + EOF + + dpkg-deb --build --root-owner-group "$ROOT" + + - name: Upload Debian package to release + uses: ncipollo/release-action@v1 + with: + allowUpdates: true + artifacts: lightpanda_${{ env.PKGVER }}_${{ env.DEB_ARCH }}.deb + tag: ${{ env.RELEASE }} + makeLatest: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 62f75198..11e4b87e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -134,70 +134,10 @@ jobs: package-archlinux: if: github.ref_type == 'tag' - strategy: - fail-fast: false - matrix: - arch: [x86_64, aarch64] - - env: - ARCH: ${{ matrix.arch }} - OS: linux - needs: build-linux - runs-on: ubuntu-22.04 - container: archlinux:latest - timeout-minutes: 10 + uses: ./.github/workflows/package-archlinux.yml - steps: - - uses: actions/checkout@v6 - - - name: Install packaging deps - run: pacman -Syu --noconfirm --needed base-devel sudo - - - name: Download linux binary - uses: actions/download-artifact@v4 - with: - name: lightpanda-${{ env.ARCH }}-${{ env.OS }} - path: . - - - name: Build Arch package - run: | - useradd -m builder - echo "builder ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers - - RAW_VERSION="${{ env.RELEASE }}" - PKGVER="${RAW_VERSION#v}" - PKGREL="1" - echo "PKGVER=${PKGVER}" >> "$GITHUB_ENV" - echo "PKGREL=${PKGREL}" >> "$GITHUB_ENV" - - mkdir -p pkg - cp lightpanda-${{ env.ARCH }}-${{ env.OS }} pkg/ - cp LICENSE pkg/ - - cat > pkg/PKGBUILD < 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; @@ -3527,7 +3592,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; } @@ -3648,7 +3713,11 @@ pub fn handleClick(self: *Frame, target: *Node) !void { }, .input => |input| { try element.focus(self); - if (input._input_type == .submit) { + // Per HTML §4.10.18.6.4 "Image Button state (type=image)", clicking an + // image button submits its form. The form-data set already gets the + // submitter's coordinate fields appended via FormData.collectForm + // (see src/browser/webapi/net/FormData.zig). + if (input._input_type == .submit or input._input_type == .image) { return self.submitForm(element, input.getForm(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/StyleManager.zig b/src/browser/StyleManager.zig index d9672e67..723a9a51 100644 --- a/src/browser/StyleManager.zig +++ b/src/browser/StyleManager.zig @@ -45,6 +45,7 @@ pub const PointerEventsCache = std.AutoHashMapUnmanaged(*Element, bool); const StyleManager = @This(); const Tag = Element.Tag; +const Input = Element.Html.Input; const RuleList = std.MultiArrayList(VisibilityRule); frame: *Frame, @@ -234,14 +235,44 @@ pub fn isHidden(self: *StyleManager, el: *Element, cache: ?*VisibilityCache, opt } /// Computed display:none for a single element (own property, no ancestor walk). -/// Also honors the HTML `hidden` attribute, matching the UA stylesheet rule -/// `[hidden] { display: none }`. +/// Honors the UA stylesheet rules per HTML Rendering §15.3.1 "Hidden elements" +/// via `isElementHidden`. pub fn hasDisplayNone(self: *StyleManager, el: *Element) bool { self.rebuildIfDirty() catch return false; - if (el.hasAttributeSafe(comptime .wrap("hidden"))) return true; return self.isElementHidden(el, .{}); } +/// Centralizes UA-stylesheet display:none truth so `getComputedStyle().display` +/// (via `hasDisplayNone`) and `el.checkVisibility()` (via `isHidden`) agree. +/// Spec: HTML Rendering §15.3.1 "Hidden elements". +fn matchesUaDisplayNoneRule(el: *Element) bool { + // Tag check first: O(1) switch, exits for the ~95% of elements with + // ordinary tags before we touch the attribute list. + const tag = el.getTag(); + if (tag.isHiddenByUaStylesheet()) return true; + + if (el.hasAttributeSafe(comptime .wrap("hidden"))) return true; + + // input[type="hidden" i] { display: none !important } + // _input_type is parsed case-insensitively at attribute-set time. + if (tag == .input) { + if (el.is(Input)) |input| { + if (input._input_type == .hidden) return true; + } + } + + // details:not([open]) > *:not(summary) { display: none } + if (tag != .summary) { + if (el.parentElement()) |parent| { + if (parent.getTag() == .details and !parent.hasAttributeSafe(comptime .wrap("open"))) { + return true; + } + } + } + + return false; +} + /// Computed visibility:hidden for an element, considering only the `visibility` /// chain (walks ancestors since `visibility` inherits by default). Ignores /// display:none: an ancestor with display:none means the element isn't @@ -313,6 +344,16 @@ fn isElementHidden(self: *StyleManager, el: *Element, options: CheckVisibilityOp opacity_priority = INLINE_PRIORITY; } + // UA stylesheet display:none rules (HTML Rendering §15.3.1 "Hidden elements"). + // Skipped when an inline `display` is set so author overrides win per cascade. + // All UA rules are short-circuited here; the spec marks some as `!important` + // (`input[type=hidden]`, `noscript`) and others as normal-origin, but author + // CSS overriding ` + + + + + + + + + + + +
+
+ + + + + +
+ +
+ + +
+ + + + + + + +
+ +
x
+
y
+z + + + + + + + + + + + + diff --git a/src/browser/tests/element/html/input_image_submit.html b/src/browser/tests/element/html/input_image_submit.html new file mode 100644 index 00000000..f4909949 --- /dev/null +++ b/src/browser/tests/element/html/input_image_submit.html @@ -0,0 +1,109 @@ + + + +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ +
+ +
+ +
+ + + + + + + + + + + + + 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/Element.zig b/src/browser/webapi/Element.zig index 4729711f..a1f6eefd 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -600,11 +600,42 @@ pub fn hasAttributeSafe(self: *const Element, name: String) bool { return attributes.hasSafe(name); } +// Per HTML "concept-fe-disabled", only listed elements participate in the +// disabled concept. Anything else (e.g.
) has no disabled +// state and never matches :disabled / :enabled. +pub fn hasDisabledConcept(self: *const Element) bool { + return switch (self.getTag()) { + .button, .input, .select, .textarea, .optgroup, .option, .fieldset => true, + else => false, + }; +} + pub fn isDisabled(self: *Element) bool { + if (!self.hasDisabledConcept()) { + return false; + } + if (self.getAttributeSafe(comptime .wrap("disabled")) != null) { return true; } + // . It does NOT inherit from