From 76b7bc96ef1e2d690ee86b62b926276a9af2a6a3 Mon Sep 17 00:00:00 2001 From: JDX50S <71799746+smoke-wolf@users.noreply.github.com> Date: Sat, 11 Apr 2026 07:39:44 -0300 Subject: [PATCH 01/16] gh-10746: Fix SMAuthorizedClients wrong Team ID (gh-13191) --- .../moz-configure/update-programs-configure.patch | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/build/moz-configure/update-programs-configure.patch diff --git a/src/build/moz-configure/update-programs-configure.patch b/src/build/moz-configure/update-programs-configure.patch new file mode 100644 index 000000000..a3b7d8feb --- /dev/null +++ b/src/build/moz-configure/update-programs-configure.patch @@ -0,0 +1,13 @@ +diff --git a/build/moz.configure/update-programs.configure b/build/moz.configure/update-programs.configure +index d87fd6dd35b1211a7ef8f64c2e2af18b91dc1f43..e6691d50e6b2ee05828a25b2dc3cb71895ff6bd7 100644 +--- a/build/moz.configure/update-programs.configure ++++ b/build/moz.configure/update-programs.configure +@@ -73,7 +73,7 @@ def mac_prod_requirements_string(identifier): + f'identifier "{identifier}" and anchor apple generic and ' + "certificate 1[field.1.2.840.113635.100.6.2.6] and " + "certificate leaf[field.1.2.840.113635.100.6.1.13] and " +- 'certificate leaf[subject.OU] = "43AQ936H96"' ++ 'certificate leaf[subject.OU] = "9V5K9TP787"' + ) + + From a4f0d01a88d10447682cfa29dde8b6aa16441c2a Mon Sep 17 00:00:00 2001 From: "mr. m" <91018726+mr-cheffy@users.noreply.github.com> Date: Sat, 11 Apr 2026 16:45:24 +0200 Subject: [PATCH 02/16] no-bug: Sign mars after building them (gh-13213) --- .github/workflows/build.yml | 14 ----- .github/workflows/linux-release-build.yml | 17 ++--- .../macos-universal-release-build.yml | 9 +++ .github/workflows/windows-release-build.yml | 10 +++ prefs/zen/split-view.yaml | 4 +- prefs/zen/workspaces.yaml | 2 +- prefs/zen/zen.yaml | 2 +- scripts/mar_sign.sh | 62 ++++++++----------- 8 files changed, 59 insertions(+), 61 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 00e8d76f1..3d0fe531c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -505,20 +505,6 @@ jobs: run: | git clone https://github.com/zen-browser/windows-binaries.git .github/workflows/object --depth 1 - - name: Download signmar-linux-x86_64 from artifacts - uses: actions/download-artifact@v4 - with: - name: signmar-linux-x86_64 - - - name: Sign MAR files - env: - SIGNMAR: ${{ github.workspace }}/signmar-linux-x86_64/signmar - ZEN_MAR_SIGNING_PASSWORD: ${{ secrets.ZEN_MAR_SIGNING_PASSWORD }} - ZEN_SIGNING_CERT_PEM_BASE64: ${{ secrets.ZEN_SIGNING_CERT_PEM_BASE64 }} - ZEN_SIGNING_PRIVATE_KEY_PEM_BASE64: ${{ secrets.ZEN_SIGNING_PRIVATE_KEY_PEM_BASE64 }} - run: | - bash scripts/mar_sign.sh -s - - name: Copy update manifests env: RELEASE_BRANCH: ${{ inputs.update_branch }} diff --git a/.github/workflows/linux-release-build.yml b/.github/workflows/linux-release-build.yml index 2ce7906cc..50a7b954c 100644 --- a/.github/workflows/linux-release-build.yml +++ b/.github/workflows/linux-release-build.yml @@ -153,6 +153,15 @@ jobs: mv dist/zen-*.tar.xz "zen.linux-${{ matrix.arch }}.tar.xz" mv dist/output.mar linux${{ matrix.arch == 'aarch64' && '-aarch64' || '' }}.mar + - name: Sign MAR + env: + SIGNMAR: engine/obj-${{ matrix.arch == 'aarch64' && 'aarch64-unknown' || 'x86_64-pc' }}-linux-gnu/dist/bin/signmar + ZEN_MAR_SIGNING_PASSWORD: ${{ secrets.ZEN_MAR_SIGNING_PASSWORD }} + ZEN_SIGNING_CERT_PEM_BASE64: ${{ secrets.ZEN_SIGNING_CERT_PEM_BASE64 }} + ZEN_SIGNING_PRIVATE_KEY_PEM_BASE64: ${{ secrets.ZEN_SIGNING_PRIVATE_KEY_PEM_BASE64 }} + run: | + bash scripts/mar_sign.sh -s ./linux${{ matrix.arch == 'aarch64' && '-aarch64' || '' }}.mar + - name: Upload build artifact (binary) uses: actions/upload-artifact@v4 with: @@ -173,11 +182,3 @@ jobs: retention-days: 5 name: linux_update_manifest_${{ matrix.arch }} path: ./dist/update - - - name: Upload signmar - if: ${{ matrix.arch == 'x86_64' }} - uses: actions/upload-artifact@v4 - with: - retention-days: 2 - name: signmar-linux-x86_64 - path: engine/obj-x86_64-pc-linux-gnu/dist/bin/signmar diff --git a/.github/workflows/macos-universal-release-build.yml b/.github/workflows/macos-universal-release-build.yml index dbc2130f0..ea62c9667 100644 --- a/.github/workflows/macos-universal-release-build.yml +++ b/.github/workflows/macos-universal-release-build.yml @@ -247,6 +247,15 @@ jobs: npm run package -- --verbose mv ./dist/output.mar ./macos.mar + - name: Sign MAR + env: + SIGNMAR: engine/obj-x86_64-apple-darwin/dist/bin/signmar + ZEN_MAR_SIGNING_PASSWORD: ${{ secrets.ZEN_MAR_SIGNING_PASSWORD }} + ZEN_SIGNING_CERT_PEM_BASE64: ${{ secrets.ZEN_SIGNING_CERT_PEM_BASE64 }} + ZEN_SIGNING_PRIVATE_KEY_PEM_BASE64: ${{ secrets.ZEN_SIGNING_PRIVATE_KEY_PEM_BASE64 }} + run: | + bash scripts/mar_sign.sh -s ./macos.mar + - name: Upload build artifact (.mar) uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/windows-release-build.yml b/.github/workflows/windows-release-build.yml index a7260a733..353732b61 100644 --- a/.github/workflows/windows-release-build.yml +++ b/.github/workflows/windows-release-build.yml @@ -281,6 +281,16 @@ jobs: mv ./dist/output.mar windows${{ matrix.arch == 'aarch64' && '-arm64' || '' }}.mar mv ./dist/zen.installer.exe ./zen.installer${{ matrix.arch == 'aarch64' && '-arm64' || '' }}.exe + - name: Sign MAR + if: ${{ !inputs.generate-gpo }} + env: + SIGNMAR: engine/obj-${{ matrix.arch }}-pc-windows-msvc/dist/bin/signmar + ZEN_MAR_SIGNING_PASSWORD: ${{ secrets.ZEN_MAR_SIGNING_PASSWORD }} + ZEN_SIGNING_CERT_PEM_BASE64: ${{ secrets.ZEN_SIGNING_CERT_PEM_BASE64 }} + ZEN_SIGNING_PRIVATE_KEY_PEM_BASE64: ${{ secrets.ZEN_SIGNING_PRIVATE_KEY_PEM_BASE64 }} + run: | + bash scripts/mar_sign.sh -s ./windows${{ matrix.arch == 'aarch64' && '-arm64' || '' }}.mar + - name: Upload artifact (PGO) uses: actions/upload-artifact@v4 if: ${{ inputs.generate-gpo && matrix.arch == 'x86_64' }} diff --git a/prefs/zen/split-view.yaml b/prefs/zen/split-view.yaml index 869b4c459..aa02b6def 100644 --- a/prefs/zen/split-view.yaml +++ b/prefs/zen/split-view.yaml @@ -15,7 +15,7 @@ value: true - name: zen.splitView.drag-over-split-delayMC - value: 1000 + value: 350 - name: zen.splitView.drag-over-split-threshold - value: 40 + value: 25 diff --git a/prefs/zen/workspaces.yaml b/prefs/zen/workspaces.yaml index 400a4bc9e..58a0499ac 100644 --- a/prefs/zen/workspaces.yaml +++ b/prefs/zen/workspaces.yaml @@ -33,7 +33,7 @@ value: true - name: zen.workspaces.dnd-switch-padding - value: 5 + value: 20 - name: zen.workspaces.debug value: "@cond" diff --git a/prefs/zen/zen.yaml b/prefs/zen/zen.yaml index fd0dbd67f..685bb3866 100644 --- a/prefs/zen/zen.yaml +++ b/prefs/zen/zen.yaml @@ -27,7 +27,7 @@ value: 20 # Percentage of folder height to trigger dragover - name: zen.tabs.dnd-switch-space-delay - value: 800 # milliseconds + value: 500 # milliseconds - name: zen.ctrlTab.show-pending-tabs value: false diff --git a/scripts/mar_sign.sh b/scripts/mar_sign.sh index 8b12a5d68..43b9eb7e7 100644 --- a/scripts/mar_sign.sh +++ b/scripts/mar_sign.sh @@ -84,7 +84,7 @@ create_nss_config_dir() { openssl pkcs12 -export \ -inkey "$CERT_PATH_DIR/private_key.pem" \ -in "$CERT_PATH_DIR/cert.pem" \ - -name "private_key" \ + -name "mar_cert" \ -passout pass:"$ZEN_MAR_SIGNING_PASSWORD" \ -out "$CERT_PATH_DIR/private_key.p12" @@ -105,7 +105,19 @@ cleanup_certs() { rm -f "$CERT_PATH_DIR/cert.pem" } -sign_mars() { +sign_mar() { + local mar_file="$1" + + if [ -z "$mar_file" ]; then + echo "Error: .mar file path is required. Usage: $0 -s " >&2 + exit 1 + fi + + if [ ! -f "$mar_file" ]; then + echo "Error: .mar file not found at $mar_file" >&2 + exit 1 + fi + if [ ! -f "$SIGNMAR" ]; then echo "Error: signmar not found at $SIGNMAR. Build the engine first." >&2 exit 1 @@ -115,34 +127,14 @@ sign_mars() { create_nss_config_dir - folders=( - linux.mar - linux-aarch64.mar - windows.mar - windows-arm64 - macos.mar - ) - # each folder will contain the .mar files for that platform, and the signature will be written in-place - for folder in "${folders[@]}"; do - if [ -d "$folder" ]; then - for mar_file in "$folder"/*.mar; do - if [ -f "$mar_file" ]; then - echo "" - echo "Signing $mar_file..." - # mar [-C workingDir] -d NSSConfigDir -n certname -s archive.mar out_signed_archive.mar - "$SIGNMAR" -d "$NSS_CONFIG_DIR" -n "private_key" -s "$mar_file" "$mar_file".signed - echo "Signed $mar_file. Verifying signature..." - "$SIGNMAR" -d "$NSS_CONFIG_DIR" -n "private_key" -v "$mar_file".signed - mv "$mar_file".signed "$mar_file" - echo "Successfully signed $mar_file" - else - echo "No .mar files found in $folder, skipping." - fi - done - else - echo "Directory $folder not found, skipping." - fi - done + echo "" + echo "Signing $mar_file..." + # mar [-C workingDir] -d NSSConfigDir -n certname -s archive.mar out_signed_archive.mar + "$SIGNMAR" -d "$NSS_CONFIG_DIR" -n "mar_cert" -s "$mar_file" "$mar_file".signed + echo "Signed $mar_file. Verifying signature..." + "$SIGNMAR" -d "$NSS_CONFIG_DIR" -n "mar_cert" -v "$mar_file".signed + mv "$mar_file".signed "$mar_file" + echo "Successfully signed $mar_file" cleanup_certs } @@ -155,13 +147,13 @@ case "$1" in import_cert ;; -s) - sign_mars + sign_mar "$2" ;; *) - echo "Usage: $0 [-g] [-i] [-s]" >&2 - echo " -g Generate MAR signing certificates" >&2 - echo " -i Import the certificate into the updater (release_primary.der)" >&2 - echo " -s Sign *.mar files in the current directory in-place" >&2 + echo "Usage: $0 [-g] [-i] [-s ]" >&2 + echo " -g Generate MAR signing certificates" >&2 + echo " -i Import the certificate into the updater (release_primary.der)" >&2 + echo " -s Sign the given .mar file in-place" >&2 exit 1 ;; esac From cc46a1ee551900a19b920cc755e0100037687be1 Mon Sep 17 00:00:00 2001 From: Phannawich Jadpotwanich <40864901+phannawich@users.noreply.github.com> Date: Sat, 11 Apr 2026 08:32:23 -0700 Subject: [PATCH 03/16] gh-11835: resolve copy URL return about:blank during pending navigations (gh-12521) Co-authored-by: mr. m <91018726+mr-cheffy@users.noreply.github.com> --- .../urlbar/content/UrlbarInput-mjs.patch | 56 +++++++++++-------- 1 file changed, 33 insertions(+), 23 deletions(-) diff --git a/src/browser/components/urlbar/content/UrlbarInput-mjs.patch b/src/browser/components/urlbar/content/UrlbarInput-mjs.patch index 4bc827b9e..0e2a7888a 100644 --- a/src/browser/components/urlbar/content/UrlbarInput-mjs.patch +++ b/src/browser/components/urlbar/content/UrlbarInput-mjs.patch @@ -1,5 +1,5 @@ diff --git a/browser/components/urlbar/content/UrlbarInput.mjs b/browser/components/urlbar/content/UrlbarInput.mjs -index 2e6e2be9d7e28c3f189131ec19a26d552d13af99..101b5a3a70c24f28a755f2ca6630a6bc6b01181b 100644 +index 2e6e2be9d7e28c3f189131ec19a26d552d13af99..c17b4553829854340ef512dcb90bfd387069c8da 100644 --- a/browser/components/urlbar/content/UrlbarInput.mjs +++ b/browser/components/urlbar/content/UrlbarInput.mjs @@ -68,6 +68,13 @@ const lazy = XPCOMUtils.declareLazy({ @@ -75,7 +75,7 @@ index 2e6e2be9d7e28c3f189131ec19a26d552d13af99..101b5a3a70c24f28a755f2ca6630a6bc } if (isCanonized) { -@@ -2598,6 +2630,32 @@ export class UrlbarInput extends HTMLElement { +@@ -2598,6 +2630,42 @@ export class UrlbarInput extends HTMLElement { await this.#updateLayoutBreakoutDimensions(); } @@ -92,9 +92,19 @@ index 2e6e2be9d7e28c3f189131ec19a26d552d13af99..101b5a3a70c24f28a755f2ca6630a6bc + + get zenStrippedURI() { + let strippedURI = null; ++ let activeBrowser = this.window.gBrowser?.selectedBrowser; ++ let uriString = activeBrowser.userTypedValue || ++ (activeBrowser.currentURI ? activeBrowser.currentURI.spec : ""); ++ ++ let uri; ++ try { ++ uri = Services.io.newURI(uriString); ++ } catch (e) { ++ // Fallback if the provisional string isn't a valid URI yet ++ uri = activeBrowser.currentURI; ++ } + + // Error check occurs during isClipboardURIValid -+ let uri = this.window.gBrowser.currentURI; + try { + strippedURI = lazy.QueryStringStripper.stripForCopyOrShare(uri); + } catch (e) { @@ -108,7 +118,7 @@ index 2e6e2be9d7e28c3f189131ec19a26d552d13af99..101b5a3a70c24f28a755f2ca6630a6bc startLayoutExtend() { if (!this.#allowBreakout || this.hasAttribute("breakout-extend")) { // Do not expand if the Urlbar does not support being expanded or it is -@@ -2612,6 +2670,13 @@ export class UrlbarInput extends HTMLElement { +@@ -2612,6 +2680,13 @@ export class UrlbarInput extends HTMLElement { this.setAttribute("breakout-extend", "true"); @@ -122,7 +132,7 @@ index 2e6e2be9d7e28c3f189131ec19a26d552d13af99..101b5a3a70c24f28a755f2ca6630a6bc // Enable the animation only after the first extend call to ensure it // doesn't run when opening a new window. if (!this.hasAttribute("breakout-extend-animate")) { -@@ -2631,6 +2696,27 @@ export class UrlbarInput extends HTMLElement { +@@ -2631,6 +2706,27 @@ export class UrlbarInput extends HTMLElement { return; } @@ -150,7 +160,7 @@ index 2e6e2be9d7e28c3f189131ec19a26d552d13af99..101b5a3a70c24f28a755f2ca6630a6bc this.removeAttribute("breakout-extend"); this.#updateTextboxPosition(); } -@@ -2661,7 +2747,7 @@ export class UrlbarInput extends HTMLElement { +@@ -2661,7 +2757,7 @@ export class UrlbarInput extends HTMLElement { forceUnifiedSearchButtonAvailable = false ) { let prevState = this.getAttribute("pageproxystate"); @@ -159,7 +169,7 @@ index 2e6e2be9d7e28c3f189131ec19a26d552d13af99..101b5a3a70c24f28a755f2ca6630a6bc this.setAttribute("pageproxystate", state); this._inputContainer.setAttribute("pageproxystate", state); this._identityBox?.setAttribute("pageproxystate", state); -@@ -2915,10 +3001,12 @@ export class UrlbarInput extends HTMLElement { +@@ -2915,10 +3011,12 @@ export class UrlbarInput extends HTMLElement { return; } this.style.top = px( @@ -172,7 +182,7 @@ index 2e6e2be9d7e28c3f189131ec19a26d552d13af99..101b5a3a70c24f28a755f2ca6630a6bc ); } -@@ -2977,9 +3065,10 @@ export class UrlbarInput extends HTMLElement { +@@ -2977,9 +3075,10 @@ export class UrlbarInput extends HTMLElement { return; } @@ -184,7 +194,7 @@ index 2e6e2be9d7e28c3f189131ec19a26d552d13af99..101b5a3a70c24f28a755f2ca6630a6bc ); this.style.setProperty( "--urlbar-height", -@@ -3413,6 +3502,7 @@ export class UrlbarInput extends HTMLElement { +@@ -3413,6 +3512,7 @@ export class UrlbarInput extends HTMLElement { } _toggleActionOverride(event) { @@ -192,7 +202,7 @@ index 2e6e2be9d7e28c3f189131ec19a26d552d13af99..101b5a3a70c24f28a755f2ca6630a6bc if ( event.keyCode == KeyEvent.DOM_VK_SHIFT || event.keyCode == KeyEvent.DOM_VK_ALT || -@@ -3516,8 +3606,8 @@ export class UrlbarInput extends HTMLElement { +@@ -3516,8 +3616,8 @@ export class UrlbarInput extends HTMLElement { if (!this.#isAddressbar) { return val; } @@ -203,7 +213,7 @@ index 2e6e2be9d7e28c3f189131ec19a26d552d13af99..101b5a3a70c24f28a755f2ca6630a6bc : val; // Only trim value if the directionality doesn't change to RTL and we're not // showing a strikeout https protocol. -@@ -3824,6 +3914,7 @@ export class UrlbarInput extends HTMLElement { +@@ -3824,6 +3924,7 @@ export class UrlbarInput extends HTMLElement { resultDetails = null, browser = this.window.gBrowser.selectedBrowser ) { @@ -211,7 +221,7 @@ index 2e6e2be9d7e28c3f189131ec19a26d552d13af99..101b5a3a70c24f28a755f2ca6630a6bc if (this.#isAddressbar) { this.#prepareAddressbarLoad( url, -@@ -3935,6 +4026,10 @@ export class UrlbarInput extends HTMLElement { +@@ -3935,6 +4036,10 @@ export class UrlbarInput extends HTMLElement { } reuseEmpty = true; } @@ -222,7 +232,7 @@ index 2e6e2be9d7e28c3f189131ec19a26d552d13af99..101b5a3a70c24f28a755f2ca6630a6bc if ( where == "tab" && reuseEmpty && -@@ -3942,6 +4037,9 @@ export class UrlbarInput extends HTMLElement { +@@ -3942,6 +4047,9 @@ export class UrlbarInput extends HTMLElement { ) { where = "current"; } @@ -232,7 +242,7 @@ index 2e6e2be9d7e28c3f189131ec19a26d552d13af99..101b5a3a70c24f28a755f2ca6630a6bc return where; } -@@ -4196,6 +4294,7 @@ export class UrlbarInput extends HTMLElement { +@@ -4196,6 +4304,7 @@ export class UrlbarInput extends HTMLElement { this.setResultForCurrentValue(null); this.handleCommand(); this.controller.clearLastQueryContextCache(); @@ -240,7 +250,7 @@ index 2e6e2be9d7e28c3f189131ec19a26d552d13af99..101b5a3a70c24f28a755f2ca6630a6bc this._suppressStartQuery = false; }); -@@ -4203,7 +4302,6 @@ export class UrlbarInput extends HTMLElement { +@@ -4203,7 +4312,6 @@ export class UrlbarInput extends HTMLElement { contextMenu.addEventListener("popupshowing", () => { // Close the results pane when the input field contextual menu is open, // because paste and go doesn't want a result selection. @@ -248,7 +258,7 @@ index 2e6e2be9d7e28c3f189131ec19a26d552d13af99..101b5a3a70c24f28a755f2ca6630a6bc let controller = this.document.commandDispatcher.getControllerForCommand("cmd_paste"); -@@ -4319,7 +4417,11 @@ export class UrlbarInput extends HTMLElement { +@@ -4319,7 +4427,11 @@ export class UrlbarInput extends HTMLElement { if (!engineName && !source && !this.hasAttribute("searchmode")) { return; } @@ -261,7 +271,7 @@ index 2e6e2be9d7e28c3f189131ec19a26d552d13af99..101b5a3a70c24f28a755f2ca6630a6bc if (this._searchModeIndicatorTitle) { this._searchModeIndicatorTitle.textContent = ""; this._searchModeIndicatorTitle.removeAttribute("data-l10n-id"); -@@ -4629,6 +4731,7 @@ export class UrlbarInput extends HTMLElement { +@@ -4629,6 +4741,7 @@ export class UrlbarInput extends HTMLElement { this.document.l10n.setAttributes( this.inputField, @@ -269,7 +279,7 @@ index 2e6e2be9d7e28c3f189131ec19a26d552d13af99..101b5a3a70c24f28a755f2ca6630a6bc l10nId, l10nId == "urlbar-placeholder-with-name" ? { name: engineName } -@@ -4742,6 +4845,11 @@ export class UrlbarInput extends HTMLElement { +@@ -4742,6 +4855,11 @@ export class UrlbarInput extends HTMLElement { } _on_click(event) { @@ -281,7 +291,7 @@ index 2e6e2be9d7e28c3f189131ec19a26d552d13af99..101b5a3a70c24f28a755f2ca6630a6bc switch (event.target) { case this.inputField: case this._inputContainer: -@@ -4820,7 +4928,7 @@ export class UrlbarInput extends HTMLElement { +@@ -4820,7 +4938,7 @@ export class UrlbarInput extends HTMLElement { } } @@ -290,7 +300,7 @@ index 2e6e2be9d7e28c3f189131ec19a26d552d13af99..101b5a3a70c24f28a755f2ca6630a6bc this.view.autoOpen({ event }); } else { if (this._untrimOnFocusAfterKeydown) { -@@ -4860,9 +4968,16 @@ export class UrlbarInput extends HTMLElement { +@@ -4860,9 +4978,16 @@ export class UrlbarInput extends HTMLElement { } _on_mousedown(event) { @@ -308,7 +318,7 @@ index 2e6e2be9d7e28c3f189131ec19a26d552d13af99..101b5a3a70c24f28a755f2ca6630a6bc if ( event.composedTarget != this.inputField && event.composedTarget != this._inputContainer -@@ -4872,6 +4987,10 @@ export class UrlbarInput extends HTMLElement { +@@ -4872,6 +4997,10 @@ export class UrlbarInput extends HTMLElement { this.focusedViaMousedown = !this.focused; this._preventClickSelectsAll = this.focused; @@ -319,7 +329,7 @@ index 2e6e2be9d7e28c3f189131ec19a26d552d13af99..101b5a3a70c24f28a755f2ca6630a6bc // Keep the focus status, since the attribute may be changed // upon calling this.focus(). -@@ -4907,7 +5026,7 @@ export class UrlbarInput extends HTMLElement { +@@ -4907,7 +5036,7 @@ export class UrlbarInput extends HTMLElement { } // Don't close the view when clicking on a tab; we may want to keep the // view open on tab switch, and the TabSelect event arrived earlier. @@ -328,7 +338,7 @@ index 2e6e2be9d7e28c3f189131ec19a26d552d13af99..101b5a3a70c24f28a755f2ca6630a6bc break; } -@@ -5235,7 +5354,7 @@ export class UrlbarInput extends HTMLElement { +@@ -5235,7 +5364,7 @@ export class UrlbarInput extends HTMLElement { // When we are in actions search mode we can show more results so // increase the limit. let maxResults = From 7bbbdd3c4b8ea787b00220b074d46b9311571848 Mon Sep 17 00:00:00 2001 From: "mr. m" <91018726+mr-cheffy@users.noreply.github.com> Date: Sun, 12 Apr 2026 22:55:36 +0200 Subject: [PATCH 04/16] gh-9540: Fixed duplicate bookmarks appearing on startup (gh-13232) --- src/zen/common/modules/ZenStartup.mjs | 4 ++++ src/zen/spaces/ZenSpaceBookmarksStorage.js | 2 +- src/zen/spaces/ZenSpaceManager.mjs | 9 +++++---- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/zen/common/modules/ZenStartup.mjs b/src/zen/common/modules/ZenStartup.mjs index bfa20f596..664d4bc96 100644 --- a/src/zen/common/modules/ZenStartup.mjs +++ b/src/zen/common/modules/ZenStartup.mjs @@ -98,6 +98,10 @@ class ZenStartup { this.isReady = true; this.promiseInitializedResolve(); delete this.promiseInitializedResolve; + + setTimeout(() => { + gZenWorkspaces._invalidateBookmarkContainers(); + }); }); } diff --git a/src/zen/spaces/ZenSpaceBookmarksStorage.js b/src/zen/spaces/ZenSpaceBookmarksStorage.js index 59da45f61..acfd1ec2e 100644 --- a/src/zen/spaces/ZenSpaceBookmarksStorage.js +++ b/src/zen/spaces/ZenSpaceBookmarksStorage.js @@ -49,7 +49,7 @@ window.ZenWorkspaceBookmarksStorage = { timestamp INTEGER NOT NULL, UNIQUE(bookmark_guid), FOREIGN KEY(bookmark_guid) REFERENCES moz_bookmarks(guid) ON DELETE CASCADE - ) + ) `); // Create index for changes tracking diff --git a/src/zen/spaces/ZenSpaceManager.mjs b/src/zen/spaces/ZenSpaceManager.mjs index 53dcf5bed..c0df1329b 100644 --- a/src/zen/spaces/ZenSpaceManager.mjs +++ b/src/zen/spaces/ZenSpaceManager.mjs @@ -2475,14 +2475,14 @@ class nsZenWorkspaces { this._workspaceBookmarksCache?.bookmarks || {} ).length; if (!onInit || hasWorkspaceBookmarks) { - this.#invalidateBookmarkContainers(); + this._invalidateBookmarkContainers(); } // Update workspace indicator await this.updateWorkspaceIndicator(workspace, this.workspaceIndicator); // Fix ctrl+tab behavior. Note, we dont call it with "await" because we dont want to wait for it - this._fixCtrlTabBehavior(); + this.#fixCtrlTabBehavior(); // Bug: When updating from previous versions, we used to hide the tabs not used in the new workspace // we now need to show them again. @@ -2512,12 +2512,13 @@ class nsZenWorkspaces { ); } - async _fixCtrlTabBehavior() { + // Intentionally keep it as async! + async #fixCtrlTabBehavior() { ctrlTab.uninit(); ctrlTab.readPref(); } - #invalidateBookmarkContainers() { + _invalidateBookmarkContainers() { for (let i = 0, len = this.bookmarkMenus.length; i < len; i++) { const element = document.getElementById(this.bookmarkMenus[i]); if (element && element._placesView) { From adc8c92816f7fbb0665ae5f360714ce15c4de422 Mon Sep 17 00:00:00 2001 From: "mr. m" <91018726+mr-cheffy@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:49:24 +0200 Subject: [PATCH 05/16] gh-9836: Finish the MAR signing workflow (gh-13216) --- .github/workflows/build.yml | 22 +++- .github/workflows/linux-release-build.yml | 17 ++- .../macos-universal-release-build.yml | 9 -- .github/workflows/windows-release-build.yml | 20 +--- scripts/mar_sign.sh | 110 ++++++++++++++---- .../shared/preferences/zen-preferences.css | 1 + 6 files changed, 113 insertions(+), 66 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3d0fe531c..3bd0e581d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -126,7 +126,7 @@ jobs: permissions: contents: write name: Generate build data - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ubuntu-latest needs: buildid outputs: build_date: ${{ steps.data.outputs.builddate }} @@ -359,7 +359,7 @@ jobs: name: AppImage build - Linux ${{ matrix.arch }} permissions: contents: write - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ubuntu-latest strategy: matrix: arch: [x86_64, aarch64] @@ -439,7 +439,7 @@ jobs: path: ./dist/zen-${{ matrix.arch }}.AppImage.zsync stop-self-hosted: - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ubuntu-latest needs: [windows-step-3, linux] if: always() steps: @@ -505,10 +505,22 @@ jobs: run: | git clone https://github.com/zen-browser/windows-binaries.git .github/workflows/object --depth 1 + - name: Sign MAR files + env: + SIGNMAR: ${{ github.workspace }}/linux-bin-x86_64/signmar + ZEN_MAR_SIGNING_PASSWORD: ${{ secrets.ZEN_MAR_SIGNING_PASSWORD }} + ZEN_SIGNING_CERT_PEM_BASE64: ${{ secrets.ZEN_SIGNING_CERT_PEM_BASE64 }} + ZEN_SIGNING_PRIVATE_KEY_PEM_BASE64: ${{ secrets.ZEN_SIGNING_PRIVATE_KEY_PEM_BASE64 }} + run: | + bash scripts/mar_sign.sh -s + - name: Copy update manifests env: RELEASE_BRANCH: ${{ inputs.update_branch }} run: | + # IMPORTANT: If changing any of these names, + # make sure to also update the paths in the mar_sign.sh script + cd updates-server mkdir -p updates cp -a ../linux_update_manifest_x86_64/. updates/ @@ -602,7 +614,7 @@ jobs: permissions: write-all name: Prepare Flatpak needs: [release, linux, build-data] - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ubuntu-latest steps: - name: Checkout Flatpak repository @@ -661,7 +673,7 @@ jobs: permissions: write-all name: Release Flatpak needs: [prepare-flatpak, build-data] - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ubuntu-latest steps: - name: Checkout Flatpak repository diff --git a/.github/workflows/linux-release-build.yml b/.github/workflows/linux-release-build.yml index 50a7b954c..2fba0e880 100644 --- a/.github/workflows/linux-release-build.yml +++ b/.github/workflows/linux-release-build.yml @@ -153,15 +153,6 @@ jobs: mv dist/zen-*.tar.xz "zen.linux-${{ matrix.arch }}.tar.xz" mv dist/output.mar linux${{ matrix.arch == 'aarch64' && '-aarch64' || '' }}.mar - - name: Sign MAR - env: - SIGNMAR: engine/obj-${{ matrix.arch == 'aarch64' && 'aarch64-unknown' || 'x86_64-pc' }}-linux-gnu/dist/bin/signmar - ZEN_MAR_SIGNING_PASSWORD: ${{ secrets.ZEN_MAR_SIGNING_PASSWORD }} - ZEN_SIGNING_CERT_PEM_BASE64: ${{ secrets.ZEN_SIGNING_CERT_PEM_BASE64 }} - ZEN_SIGNING_PRIVATE_KEY_PEM_BASE64: ${{ secrets.ZEN_SIGNING_PRIVATE_KEY_PEM_BASE64 }} - run: | - bash scripts/mar_sign.sh -s ./linux${{ matrix.arch == 'aarch64' && '-aarch64' || '' }}.mar - - name: Upload build artifact (binary) uses: actions/upload-artifact@v4 with: @@ -182,3 +173,11 @@ jobs: retention-days: 5 name: linux_update_manifest_${{ matrix.arch }} path: ./dist/update + + - name: Upload linux bin + if: ${{ matrix.arch == 'x86_64' }} + uses: actions/upload-artifact@v4 + with: + retention-days: 2 + name: linux-bin-x86_64 + path: engine/obj-x86_64-pc-linux-gnu/dist/bin/ diff --git a/.github/workflows/macos-universal-release-build.yml b/.github/workflows/macos-universal-release-build.yml index ea62c9667..dbc2130f0 100644 --- a/.github/workflows/macos-universal-release-build.yml +++ b/.github/workflows/macos-universal-release-build.yml @@ -247,15 +247,6 @@ jobs: npm run package -- --verbose mv ./dist/output.mar ./macos.mar - - name: Sign MAR - env: - SIGNMAR: engine/obj-x86_64-apple-darwin/dist/bin/signmar - ZEN_MAR_SIGNING_PASSWORD: ${{ secrets.ZEN_MAR_SIGNING_PASSWORD }} - ZEN_SIGNING_CERT_PEM_BASE64: ${{ secrets.ZEN_SIGNING_CERT_PEM_BASE64 }} - ZEN_SIGNING_PRIVATE_KEY_PEM_BASE64: ${{ secrets.ZEN_SIGNING_PRIVATE_KEY_PEM_BASE64 }} - run: | - bash scripts/mar_sign.sh -s ./macos.mar - - name: Upload build artifact (.mar) uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/windows-release-build.yml b/.github/workflows/windows-release-build.yml index 353732b61..10c1da6d3 100644 --- a/.github/workflows/windows-release-build.yml +++ b/.github/workflows/windows-release-build.yml @@ -281,21 +281,11 @@ jobs: mv ./dist/output.mar windows${{ matrix.arch == 'aarch64' && '-arm64' || '' }}.mar mv ./dist/zen.installer.exe ./zen.installer${{ matrix.arch == 'aarch64' && '-arm64' || '' }}.exe - - name: Sign MAR - if: ${{ !inputs.generate-gpo }} - env: - SIGNMAR: engine/obj-${{ matrix.arch }}-pc-windows-msvc/dist/bin/signmar - ZEN_MAR_SIGNING_PASSWORD: ${{ secrets.ZEN_MAR_SIGNING_PASSWORD }} - ZEN_SIGNING_CERT_PEM_BASE64: ${{ secrets.ZEN_SIGNING_CERT_PEM_BASE64 }} - ZEN_SIGNING_PRIVATE_KEY_PEM_BASE64: ${{ secrets.ZEN_SIGNING_PRIVATE_KEY_PEM_BASE64 }} - run: | - bash scripts/mar_sign.sh -s ./windows${{ matrix.arch == 'aarch64' && '-arm64' || '' }}.mar - - name: Upload artifact (PGO) uses: actions/upload-artifact@v4 if: ${{ inputs.generate-gpo && matrix.arch == 'x86_64' }} with: - retention-days: 5 + retention-days: 2 name: ${{ matrix.arch == 'aarch64' && 'arm64' || matrix.arch }}-${{ inputs.profile-data-path-archive }} path: ./zen.win64-pgo-stage-1.zip @@ -322,14 +312,6 @@ jobs: name: windows-x64-obj-${{ matrix.arch == 'aarch64' && 'arm64' || matrix.arch }} path: obj-${{ matrix.arch }}-pc-windows-msvc - - name: Upload artifact (if Twilight branch, binary) - if: ${{ inputs.release-branch == 'twilight' && !inputs.generate-gpo }} - uses: actions/upload-artifact@v4 - with: - retention-days: 5 - name: zen.win-${{ matrix.arch == 'aarch64' && 'arm64' || matrix.arch }}.zip - path: ./zen.win-${{ matrix.arch == 'aarch64' && 'arm64' || matrix.arch }}.zip - - name: Upload artifact (if Twilight branch, installer) if: ${{ inputs.release-branch == 'twilight' && !inputs.generate-gpo }} uses: actions/upload-artifact@v4 diff --git a/scripts/mar_sign.sh b/scripts/mar_sign.sh index 43b9eb7e7..fb47c6a80 100644 --- a/scripts/mar_sign.sh +++ b/scripts/mar_sign.sh @@ -84,7 +84,7 @@ create_nss_config_dir() { openssl pkcs12 -export \ -inkey "$CERT_PATH_DIR/private_key.pem" \ -in "$CERT_PATH_DIR/cert.pem" \ - -name "mar_cert" \ + -name "mar_sig" \ -passout pass:"$ZEN_MAR_SIGNING_PASSWORD" \ -out "$CERT_PATH_DIR/private_key.p12" @@ -105,19 +105,58 @@ cleanup_certs() { rm -f "$CERT_PATH_DIR/cert.pem" } -sign_mar() { - local mar_file="$1" - - if [ -z "$mar_file" ]; then - echo "Error: .mar file path is required. Usage: $0 -s " >&2 - exit 1 - fi - - if [ ! -f "$mar_file" ]; then - echo "Error: .mar file not found at $mar_file" >&2 +update_manifests() { + mar_file=$(basename "$1") + if [[ "$mar_file" == "linux.mar" ]]; then + manifest="linux_update_manifest_x86_64" + elif [[ "$mar_file" == "linux-aarch64.mar" ]]; then + manifest="linux_update_manifest_aarch64" + elif [[ "$mar_file" == "windows.mar" ]]; then + manifest=".github/workflows/object/windows-x64-signed-x86_64/update_manifest" + if [ ! -f "$manifest" ]; then + manifest="windows_update_manifest_x86_64" + fi + elif [[ "$mar_file" == "windows-arm64.mar" ]]; then + manifest=".github/workflows/object/windows-x64-signed-arm64/update_manifest" + if [ ! -f "$manifest" ]; then + manifest="windows_update_manifest_arm64" + fi + elif [[ "$mar_file" == "macos.mar" ]]; then + manifest="macos_update_manifest" + else + echo "Unknown MAR file name format: $mar_file. Skipping manifest update." >&2 exit 1 fi + # There can be any update.xml file, lets just recursively search for the one + manifest_files=$(find "$manifest" -type f -name "update.xml") + for manifest_file in $manifest_files; do + # Example manifest: + # + # + # + # + # When signing the mar, hashValue and size will change, so we need to update the manifest with + # the new values. We can get the new values by running "mar -i signed_mar_file.mar" + echo "Updating manifest $manifest_file with new hash and size for $mar_file" + size=$(wc -c < "$1" | tr -d ' ') + hashValue=$(sha512sum "$1" | awk '{print $1}') + # Update the manifest with the new values. We can use sed to do this. + # We need to find the line that contains the URL of the mar file, and update the hashValue and size attributes in the same element. + old_hashValue=$(grep -oP 'hashValue="\K[^"]+' "$manifest_file") + old_size=$(grep -oP 'size="\K[^"]+' "$manifest_file") + if [ -z "$old_hashValue" ] || [ -z "$old_size" ]; then + echo "Could not find old hashValue or size in manifest. Skipping manifest update." >&2 + exit 1 + fi + echo "Old hashValue: $old_hashValue, Old size: $old_size" + echo "New hashValue: $hashValue, New size: $size" + sed -i.bak "s/hashValue=\"$old_hashValue\"/hashValue=\"$hashValue\"/g; s/size=\"$old_size\"/size=\"$size\"/g" "$manifest_file" + rm "$manifest_file.bak" + echo "Manifest updated with new hashValue and size for $mar_file" + done +} +sign_mars() { if [ ! -f "$SIGNMAR" ]; then echo "Error: signmar not found at $SIGNMAR. Build the engine first." >&2 exit 1 @@ -127,14 +166,37 @@ sign_mar() { create_nss_config_dir - echo "" - echo "Signing $mar_file..." - # mar [-C workingDir] -d NSSConfigDir -n certname -s archive.mar out_signed_archive.mar - "$SIGNMAR" -d "$NSS_CONFIG_DIR" -n "mar_cert" -s "$mar_file" "$mar_file".signed - echo "Signed $mar_file. Verifying signature..." - "$SIGNMAR" -d "$NSS_CONFIG_DIR" -n "mar_cert" -v "$mar_file".signed - mv "$mar_file".signed "$mar_file" - echo "Successfully signed $mar_file" + folders=( + linux.mar + linux-aarch64.mar + windows.mar + windows-arm64.mar + macos.mar + ) + # each folder will contain the .mar files for that platform, and the signature will be written in-place + for folder in "${folders[@]}"; do + if [ -d "$folder" ]; then + for mar_file in "$folder"/*.mar; do + if [ -f "$mar_file" ]; then + echo "" + echo "Signing $mar_file..." + # mar [-C workingDir] -d NSSConfigDir -n certname -s archive.mar out_signed_archive.mar + "$SIGNMAR" -d "$NSS_CONFIG_DIR" -n "mar_sig" -s "$mar_file" "$mar_file".signed + echo "Signed $mar_file. Verifying signature..." + "$SIGNMAR" -d "$NSS_CONFIG_DIR" -n "mar_sig" -v "$mar_file".signed + mv "$mar_file".signed "$mar_file" + echo "Successfully signed $mar_file" + update_manifests "$mar_file" + else + echo "No .mar files found in $folder, skipping." + exit 1 + fi + done + else + echo "Directory $folder not found, skipping." + exit 1 + fi + done cleanup_certs } @@ -147,13 +209,13 @@ case "$1" in import_cert ;; -s) - sign_mar "$2" + sign_mars ;; *) - echo "Usage: $0 [-g] [-i] [-s ]" >&2 - echo " -g Generate MAR signing certificates" >&2 - echo " -i Import the certificate into the updater (release_primary.der)" >&2 - echo " -s Sign the given .mar file in-place" >&2 + echo "Usage: $0 [-g] [-i] [-s]" >&2 + echo " -g Generate MAR signing certificates" >&2 + echo " -i Import the certificate into the updater (release_primary.der)" >&2 + echo " -s Sign *.mar files in the current directory in-place" >&2 exit 1 ;; esac diff --git a/src/browser/themes/shared/preferences/zen-preferences.css b/src/browser/themes/shared/preferences/zen-preferences.css index bae330acc..788ad5c76 100644 --- a/src/browser/themes/shared/preferences/zen-preferences.css +++ b/src/browser/themes/shared/preferences/zen-preferences.css @@ -504,6 +504,7 @@ groupbox h2 { #setting-control-sidebarChatbotFieldset, #aiControlsDescription, #category-ai-features, +#setting-control-supportFirefox, .mission-message, html|setting-group[data-subcategory="layout"] { display: none !important; From d540c6cddfd46f2739f88afc7246adc72d85dd15 Mon Sep 17 00:00:00 2001 From: "mr. m" <91018726+mr-cheffy@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:33:01 +0200 Subject: [PATCH 06/16] no-bug: Block loading glance from unmatching principals (gh-13237) --- src/zen/glance/ZenGlanceManager.mjs | 181 +++++++++++-------- src/zen/glance/actors/ZenGlanceChild.sys.mjs | 85 ++++++--- src/zen/glance/zen-glance.css | 5 +- 3 files changed, 165 insertions(+), 106 deletions(-) diff --git a/src/zen/glance/ZenGlanceManager.mjs b/src/zen/glance/ZenGlanceManager.mjs index a63ccb234..0f3112570 100644 --- a/src/zen/glance/ZenGlanceManager.mjs +++ b/src/zen/glance/ZenGlanceManager.mjs @@ -34,7 +34,7 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { // Arc animation configuration #ARC_CONFIG = Object.freeze({ - ARC_STEPS: 400, // Increased for smoother bounce + ARC_STEPS: 80, // Browser interpolates between keyframes natively MAX_ARC_HEIGHT: 25, ARC_HEIGHT_RATIO: 0.2, // Arc height = distance * ratio (capped at MAX_ARC_HEIGHT) }); @@ -78,7 +78,11 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { menuitem.setAttribute("data-l10n-id", "zen-open-link-in-glance"); menuitem.addEventListener("command", () => - this.openGlance({ url: gContextMenu.linkURL }) + this.openGlance({ + url: gContextMenu.linkURL, + triggeringPrincipal: + Services.scriptSecurityManager.getSystemPrincipal(), + }) ); document @@ -171,19 +175,20 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { /** * Create a new browser element for a glance * - * @param {string} url - The URL to load + * @param {object} data - Glance data including URL and dimensions * @param {Tab} currentTab - The current tab - * @param {Tab} existingTab - Optional existing tab to reuse + * @param {Tab|null} existingTab - Optional existing tab to reuse * @returns {Browser} The created browser element */ - #createBrowserElement(url, currentTab, existingTab = null) { - const newTabOptions = this.#createTabOptions(currentTab); + #createBrowserElement(data, currentTab, existingTab = null) { + const url = data.url; + const newTabOptions = this.#createTabOptions(currentTab, data); const newUUID = gZenUIManager.generateUuidv4(); currentTab._selected = true; const newTab = existingTab ?? - gBrowser.addTrustedTab(Services.io.newURI(url).spec, newTabOptions); + gBrowser.addTab(Services.io.newURI(url).spec, newTabOptions); this.#configureNewTab(newTab, currentTab, newUUID); this.#registerGlance(newTab, currentTab, newUUID); @@ -196,14 +201,18 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { * Create tab options for a new glance tab * * @param {Tab} currentTab - The current tab + * @param {object} data - Glance data for the new tab * @returns {object} Tab options */ - #createTabOptions(currentTab) { + #createTabOptions(currentTab, data) { return { userContextId: currentTab.getAttribute("usercontextid") || "", skipBackgroundNotify: true, insertTab: true, skipLoad: false, + skipAnimation: true, + ownerTab: currentTab, + triggeringPrincipal: data.triggeringPrincipal, }; } @@ -364,7 +373,7 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { this.#setAnimationState(true); const currentTab = ownerTab ?? gBrowser.selectedTab; const browserElement = this.#createBrowserElement( - data.url, + data, currentTab, existingTab ); @@ -393,7 +402,7 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { * @returns {Promise} Promise that resolves to the glance tab */ #animateGlanceOpening(data, browserElement) { - this.#prepareGlanceAnimation(data, browserElement); + this.#prepareGlanceAnimation(data); // FIXME(cheffy): We *must* have the call back async (at least, // until a better solution is found). If we do it inside the requestAnimationFrame, // we see flashing and if we do it directly, the animation does not play at all. @@ -417,15 +426,13 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { * Prepare the glance for animation * * @param {object} data - Glance data - * @param {Browser} browserElement - The browser element */ - #prepareGlanceAnimation(data, browserElement) { + #prepareGlanceAnimation(data) { this.quickOpenGlance(); const newButtons = this.#createNewOverlayButtons(); this.browserWrapper.appendChild(newButtons); this.#setupGlancePositioning(data); - this.#configureBrowserElement(browserElement); } /** @@ -463,9 +470,6 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { this.overlay.removeAttribute("fade-out"); this.browserWrapper.setAttribute("animate", true); - this.browserWrapper.style.transform = `translate(${left - width / 2}px, ${top - height / 2}px)`; - this.browserWrapper.style.width = `${width}px`; - this.browserWrapper.style.height = `${height}px`; this.#storeOriginalPosition({ top, left, width, height }); this.overlay.style.overflow = "visible"; @@ -510,33 +514,6 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { return imageDataElement; } - /** - * Configure browser element for animation - * - * @param {Browser} browserElement - The browser element - */ - #configureBrowserElement(browserElement) { - const rect = window.windowUtils.getBoundsWithoutFlushing( - this.browserWrapper.parentElement - ); - const minWidth = rect.width * 0.8; - const minHeight = rect.height * 0.8; - - browserElement.style.minWidth = `${minWidth}px`; - browserElement.style.minHeight = `${minHeight}px`; - } - - /** - * Get the transform origin for the animation - * - * @param {object} data - Glance data with position and dimensions - * @returns {string} The transform origin CSS value - */ - #getTransformOrigin(data) { - const { clientX, clientY } = data; - return `${clientX}px ${clientY}px`; - } - /** * Execute the main glance animation * @@ -547,11 +524,13 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { #executeGlanceAnimation(data, browserElement, resolve) { const imageDataElement = this.#handleElementPreview(data); - // Create curved animation sequence - const arcSequence = this.#createGlanceArcSequence(data, "opening"); - const transformOrigin = this.#getTransformOrigin(data); - - this.browserWrapper.style.transformOrigin = transformOrigin; + // Create the curved animation sequence. The transform origin is handled + // separately (for example via CSS on the wrapper). + const arcSequence = this.#createGlanceArcSequence( + data, + "opening", + imageDataElement + ); // Only animate if there is element data, so we can apply a // nice fade-in effect to the content. But if it doesn't exist, @@ -601,10 +580,41 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { * * @param {object} data - Glance data with position and dimensions * @param {string} direction - 'opening' or 'closing' + * @param {Element|null} imageDataElement - The image data element for preview (optional) * @returns {object} Animation sequence object */ - #createGlanceArcSequence(data, direction) { - const { clientX, clientY, width, height } = data; + #createGlanceArcSequence(data, direction, imageDataElement = null) { + let { clientX, clientY, width, height } = data; + if (imageDataElement?.parentElement) { + // Since we are animating scale transforms on the wrapper, we need to + // adjust the width/height to match the scaled size of the element preview, + // so the image preview properly matches the size of the animating browser + // during the animation. + // For example: + // +-- wrapper --------------------------+ + // | | + // | +--- element preview -------------+ | + // | | | | + // | +---------------------------------+ | + // | | + // +-------------------------------------+ + // We are scaling the wrapper while having only the element preview size + // in mind, so we need to adjust the width/height to match the size of the element preview + const rect = imageDataElement.getBoundingClientRect(); + const imageRect = + imageDataElement.firstElementChild.getBoundingClientRect(); + const widthRatio = rect.width / imageRect.width; + // Since the image hasn't loaded at this point, so the image's height is 0 + // we need to calculate the height ratio based on the original aspect ratio of the image + const aspectRatio = width / height; + const heightRatio = rect.height / (rect.width / aspectRatio); + const originalWidth = width; + const originalHeight = height; + width *= widthRatio; + height *= heightRatio; + clientX -= (width - originalWidth) / 2; + clientY -= (height - originalHeight) / 2; + } // Calculate start and end positions based on direction let startPosition, endPosition; @@ -643,6 +653,12 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { }; } + // Reference size used as the scale(1, 1) baseline — this matches the + // wrapper's natural CSS size (80% x 100% of the tab panels) so the + // animation can run entirely on the compositor via transform. + const refWidth = tabPanelsRect.width * widthPercent; + const refHeight = tabPanelsRect.height; + // Calculate distance and arc parameters const distance = this.#calculateDistance(startPosition, endPosition); const { arcHeight, shouldArcDownward } = this.#calculateOptimalArc( @@ -652,9 +668,10 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { ); const sequence = { - transform: [], - width: [], - height: [], + x: [], + y: [], + scaleY: [], + scaleX: [], }; const steps = this.#ARC_CONFIG.ARC_STEPS; @@ -684,6 +701,8 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { const currentHeight = startPosition.height + (endPosition.height - startPosition.height) * eased; + const scaleX = currentWidth / refWidth; + const scaleY = currentHeight / refHeight; // Calculate position on arc const distanceX = endPosition.x - startPosition.x; @@ -695,11 +714,12 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { distanceY * eased + arcDirection * arcHeight * (1 - (2 * eased - 1) ** 2); - sequence.transform.push( - `translate(${x - currentWidth / 2}px, ${y - currentHeight / 2}px)` - ); - sequence.width.push(`${currentWidth}px`); - sequence.height.push(`${currentHeight}px`); + let translateX = x - currentWidth / 2; + let translateY = y - currentHeight / 2; + sequence.x.push(translateX); + sequence.y.push(translateY); + sequence.scaleX.push(scaleX); + sequence.scaleY.push(scaleY); } return sequence; @@ -763,19 +783,16 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { imageDataElement.remove(); } + // Batch all style/attribute writes together to avoid interleaved + // read/write layout thrashing. this.browserWrapper.style.transformOrigin = ""; - - browserElement.style.minWidth = ""; - browserElement.style.minHeight = ""; - this.browserWrapper.style.height = "100%"; this.browserWrapper.style.width = "80%"; - - gBrowser.tabContainer._invalidateCachedTabs(); - this.overlay.style.removeProperty("overflow"); this.browserWrapper.removeAttribute("animate"); this.browserWrapper.setAttribute("has-finished-animation", true); + this.overlay.style.removeProperty("overflow"); + gBrowser.tabContainer._invalidateCachedTabs(); this.#setAnimationState(false); this.#currentTab.dispatchEvent(new Event("GlanceOpen", { bubbles: true })); resolve(this.#currentTab); @@ -970,19 +987,14 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { } } - #imageBitmapToBase64(imageBitmap) { - // 1. Create a canvas with the same size as the ImageBitmap - const canvas = document.createElement("canvas"); - canvas.width = imageBitmap.width; - canvas.height = imageBitmap.height; - - // 2. Draw the ImageBitmap onto the canvas + async #imageBitmapToBase64(imageBitmap) { + // Use OffscreenCanvas + blob URL to avoid blocking the main thread + // with synchronous base64 encoding from toDataURL(). + const canvas = new OffscreenCanvas(imageBitmap.width, imageBitmap.height); const ctx = canvas.getContext("2d"); ctx.drawImage(imageBitmap, 0, 0); - - // 3. Convert the canvas content to a Base64 string (PNG by default) - const base64String = canvas.toDataURL("image/png"); - return base64String; + const blob = await canvas.convertToBlob({ type: "image/png" }); + return URL.createObjectURL(blob); } /** @@ -1027,12 +1039,20 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { this.#currentGlanceID ).elementImageData; - this.#addElementPreview(elementImageData); + const imageDataElement = this.#addElementPreview(elementImageData); // Create curved closing animation sequence const closingData = this.#createClosingDataFromOriginalPosition(originalPosition); - const arcSequence = this.#createGlanceArcSequence(closingData, "closing"); + const arcSequence = this.#createGlanceArcSequence( + closingData, + "closing", + imageDataElement + ); + + // Batch style writes before starting animation to avoid layout thrashing + this.browserWrapper.style.width = ""; + this.browserWrapper.style.height = ""; gZenUIManager.motion .animate(this.browserWrapper, arcSequence, { @@ -1084,6 +1104,7 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { const imageDataElement = this.#createGlancePreviewElement(elementImageData); this.browserWrapper.prepend(imageDataElement); + return imageDataElement; } } @@ -1506,6 +1527,7 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { this.openGlance( { url: undefined, + // No need for triggeringPrincipal here }, tab, tab.owner @@ -1692,6 +1714,7 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { clientY: top, width: rect.width, height: rect.height, + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), }; } @@ -1871,6 +1894,8 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { ...clickPosition, width: 0, height: 0, + triggeringPrincipal: + Services.scriptSecurityManager.getSystemPrincipal(), }, currentTab, parentTab diff --git a/src/zen/glance/actors/ZenGlanceChild.sys.mjs b/src/zen/glance/actors/ZenGlanceChild.sys.mjs index 6583c9ef2..45a3b520a 100644 --- a/src/zen/glance/actors/ZenGlanceChild.sys.mjs +++ b/src/zen/glance/actors/ZenGlanceChild.sys.mjs @@ -2,6 +2,21 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "blockJavascript", + "browser.link.alternative_click.block_javascript", + true +); + export class ZenGlanceChild extends JSWindowActorChild { #activationMethod; @@ -26,31 +41,27 @@ export class ZenGlanceChild extends JSWindowActorChild { return !(event.ctrlKey ^ event.altKey ^ event.shiftKey ^ event.metaKey); } - #openGlance(target) { - let url = target.href; - // Add domain to relative URLs - if (!url.match(/^(?:[a-z]+:)?\/\//i)) { - url = this.contentWindow.location.origin + url; - } + #openGlance(href, principal) { this.sendAsyncMessage("ZenGlance:OpenGlance", { - url, + url: href, + triggeringPrincipal: principal, }); } - #sendClickDataToParent(target, element) { - if (!element && !target) { + #sendClickDataToParent(node, originalTarget) { + if (!node) { return; } - if (!target) { - target = element; - } // Get the largest element we can get. If the `A` element // is a parent of the original target, use the anchor element, // otherwise use the original target. - let rect = element.getBoundingClientRect(); - const anchorRect = target.getBoundingClientRect(); - if (anchorRect.width * anchorRect.height > rect.width * rect.height) { - rect = anchorRect; + let rect = node.getBoundingClientRect(); + const originalTargetRect = originalTarget.getBoundingClientRect(); + if ( + originalTargetRect.width * originalTargetRect.height > + rect.width * rect.height + ) { + rect = originalTargetRect; } this.sendAsyncMessage("ZenGlance:RecordLinkClickData", { clientX: rect.left, @@ -68,29 +79,50 @@ export class ZenGlanceChild extends JSWindowActorChild { */ #getTargetFromEvent(event) { // get closest A element - const target = event.target.closest("A"); - const elementToRecord = event.originalTarget || event.target; + let [href, node, principal] = + lazy.BrowserUtils.hrefAndLinkNodeForClickEvent(event); return { - target, - elementToRecord, + href, + node, + principal, }; } + #checkSecurity(href, principal) { + if ( + lazy.blockJavascript && + Services.io.extractScheme(href) == "javascript" + ) { + // We don't want to open new tabs or windows for javascript: links. + return true; + } + + try { + Services.scriptSecurityManager.checkLoadURIStrWithPrincipal( + principal, + href + ); + } catch (e) { + return true; + } + return false; + } + on_mousedown(event) { - const { target, elementToRecord } = this.#getTargetFromEvent(event); + const { node } = this.#getTargetFromEvent(event); // We record the link data anyway, even if the glance may be invoked // or not. We have some cases where glance would open, for example, // when clicking on a link with a different domain where glance would open. // The problem is that at that stage we don't know the rect or even what // element has been clicked, so we send the data here. - this.#sendClickDataToParent(target, elementToRecord); + this.#sendClickDataToParent(node, event.target); } on_click(event) { - const { target } = this.#getTargetFromEvent(event); + const { node, href, principal } = this.#getTargetFromEvent(event); if ( event.button !== 0 || - !target || + !node || event.defaultPrevented || this.#ensureOnlyKeyModifiers(event) ) { @@ -106,9 +138,12 @@ export class ZenGlanceChild extends JSWindowActorChild { } else if (activationMethod === "meta" && !event.metaKey) { return; } + if (this.#checkSecurity(href, principal)) { + return; + } event.preventDefault(); event.stopPropagation(); - this.#openGlance(target); + this.#openGlance(href, principal); } on_keydown(event) { diff --git a/src/zen/glance/zen-glance.css b/src/zen/glance/zen-glance.css index 1257bcdc7..807aea8c2 100644 --- a/src/zen/glance/zen-glance.css +++ b/src/zen/glance/zen-glance.css @@ -120,14 +120,14 @@ } & .browserContainer { - transform: translate(-50%, -50%); position: fixed; flex: unset !important; width: 80%; height: 100%; &:not([has-finished-animation="true"]) { - will-change: width, height, transform; + will-change: transform; + transform-origin: 0 0; #statuspanel { display: none; @@ -178,7 +178,6 @@ top: 50%; left: 50%; translate: -50% -50%; - background: rgba(255, 255, 255, 0.1); display: flex; align-items: center; From 7ed7b63b084b453d2b90230da09002f3bc642292 Mon Sep 17 00:00:00 2001 From: "mr. m" <91018726+mr-cheffy@users.noreply.github.com> Date: Tue, 14 Apr 2026 01:03:47 +0200 Subject: [PATCH 07/16] no-bug: Adress possible memory leaks (gh-13242) --- prefs/zen/glance.yaml | 2 +- src/zen/glance/ZenGlanceManager.mjs | 28 +++++-- src/zen/glance/actors/ZenGlanceParent.sys.mjs | 14 ++-- src/zen/mods/ZenMods.mjs | 82 ++++++++++++------- 4 files changed, 82 insertions(+), 44 deletions(-) diff --git a/prefs/zen/glance.yaml b/prefs/zen/glance.yaml index ef01648c1..299e616c7 100644 --- a/prefs/zen/glance.yaml +++ b/prefs/zen/glance.yaml @@ -15,7 +15,7 @@ value: "alt" # ctrl, alt, shift - name: zen.glance.animation-duration - value: 350 # in milliseconds + value: 300 # in milliseconds - name: zen.glance.deactivate-docshell-during-animation value: true diff --git a/src/zen/glance/ZenGlanceManager.mjs b/src/zen/glance/ZenGlanceManager.mjs index 0f3112570..877b35fbb 100644 --- a/src/zen/glance/ZenGlanceManager.mjs +++ b/src/zen/glance/ZenGlanceManager.mjs @@ -318,7 +318,7 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { data.width, data.height ); - return await this.#imageBitmapToBase64( + return await this.#imageBitmapToObjectURL( await window.browsingContext.currentWindowGlobal.drawSnapshot( rect, 1, @@ -785,7 +785,6 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { // Batch all style/attribute writes together to avoid interleaved // read/write layout thrashing. - this.browserWrapper.style.transformOrigin = ""; this.browserWrapper.style.height = "100%"; this.browserWrapper.style.width = "80%"; this.browserWrapper.removeAttribute("animate"); @@ -987,16 +986,31 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { } } - async #imageBitmapToBase64(imageBitmap) { - // Use OffscreenCanvas + blob URL to avoid blocking the main thread - // with synchronous base64 encoding from toDataURL(). + async #imageBitmapToObjectURL(imageBitmap) { + // OffscreenCanvas + convertToBlob avoids the synchronous PNG re-encode + // and base64 string copy that toDataURL performs on the main thread. + // Callers must release the URL via #deleteGlance when the glance entry + // is removed so the blob can be freed. const canvas = new OffscreenCanvas(imageBitmap.width, imageBitmap.height); const ctx = canvas.getContext("2d"); ctx.drawImage(imageBitmap, 0, 0); const blob = await canvas.convertToBlob({ type: "image/png" }); + imageBitmap.close(); return URL.createObjectURL(blob); } + #deleteGlance(glanceID) { + const entry = this.#glances.get(glanceID); + if (!entry) { + return; + } + this.#glances.delete(glanceID); + const url = entry.elementData ?? entry.elementImageData; + if (typeof url === "string") { + URL.revokeObjectURL(url); + } + } + /** * Animate parent background restoration * @@ -1196,7 +1210,7 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { */ #resetGlanceState(setNewID) { this.#currentParentTab.removeAttribute("glance-id"); - this.#glances.delete(this.#currentGlanceID); + this.#deleteGlance(this.#currentGlanceID); this.#currentGlanceID = setNewID; this.#duringOpening = false; } @@ -1545,7 +1559,7 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { this.animatingFullOpen = false; const glanceID = this.#currentGlanceID; this.closeGlance({ noAnimation: true, skipPermitUnload: true }); - this.#glances.delete(glanceID); + this.#deleteGlance(glanceID); } /** diff --git a/src/zen/glance/actors/ZenGlanceParent.sys.mjs b/src/zen/glance/actors/ZenGlanceParent.sys.mjs index e5a366867..748c18912 100644 --- a/src/zen/glance/actors/ZenGlanceParent.sys.mjs +++ b/src/zen/glance/actors/ZenGlanceParent.sys.mjs @@ -22,13 +22,15 @@ export class ZenGlanceParent extends JSWindowActorParent { break; } case "ZenGlance:CloseGlance": { - const params = { + // Explicitly allowlist fields from content; never forward + // skipPermitUnload or other privileged flags. + const { noAnimation, setNewID, hasFocused } = message.data ?? {}; + this.browsingContext.topChromeWindow.gZenGlanceManager.closeGlance({ onTabClose: true, - ...message.data, - }; - this.browsingContext.topChromeWindow.gZenGlanceManager.closeGlance( - params - ); + noAnimation: !!noAnimation, + setNewID: typeof setNewID === "string" ? setNewID : null, + hasFocused: !!hasFocused, + }); break; } case "ZenGlance:RecordLinkClickData": { diff --git a/src/zen/mods/ZenMods.mjs b/src/zen/mods/ZenMods.mjs index a050c92d3..0fa521b1d 100644 --- a/src/zen/mods/ZenMods.mjs +++ b/src/zen/mods/ZenMods.mjs @@ -7,6 +7,10 @@ import { nsZenMultiWindowFeature, } from "chrome://browser/content/zen-components/ZenCommonUtils.mjs"; +const DOT_RE = /\./g; +const WHITESPACE_RE = /\s/g; +const NON_NAME_RE = /[^A-Za-z_-]+/g; + /** * Zen Mods Manager, handles downloading, updating and applying Zen Mods. * @@ -171,47 +175,57 @@ class nsZenMods extends nsZenPreloadedFeature { } #writeToDom(modsWithPreferences) { - for (const browser of nsZenMultiWindowFeature.browsers) { + // Precompute per-mod data once; values are global prefs and sanitized + // names don't vary per browser window, so this hoists O(windows) work. + const prepared = modsWithPreferences.map( // eslint-disable-next-line no-shadow - for (const { enabled, preferences, name } of modsWithPreferences) { - const sanitizedName = this.sanitizeModName(name); + ({ enabled, preferences, name }) => ({ + enabled, + sanitizedName: this.sanitizeModName(name), + prefs: preferences.map(({ property, type }) => ({ + property, + type, + sanitizedProperty: property?.replaceAll(DOT_RE, "-"), + value: + enabled === undefined || enabled + ? Services.prefs.getStringPref(property, "") + : "", + })), + }) + ); + for (const browser of nsZenMultiWindowFeature.browsers) { + const doc = browser.document; + const root = doc.documentElement; + + for (const { enabled, sanitizedName, prefs } of prepared) { if (enabled !== undefined && !enabled) { - const element = browser.document.getElementById(sanitizedName); - + const element = doc.getElementById(sanitizedName); if (element) { element.remove(); } - for (const { property } of preferences.filter( - ({ type }) => type !== "checkbox" - )) { - const sanitizedProperty = property?.replaceAll(/\./g, "-"); - - browser.document - .querySelector(":root") - .style.removeProperty(`--${sanitizedProperty}`); + for (const { type, sanitizedProperty } of prefs) { + if (type === "checkbox") { + continue; + } + root.style.removeProperty(`--${sanitizedProperty}`); } continue; } - for (const { property, type } of preferences) { - const value = Services.prefs.getStringPref(property, ""); - const sanitizedProperty = property?.replaceAll(/\./g, "-"); - + for (const { type, sanitizedProperty, value } of prefs) { switch (type) { case "dropdown": { if (value !== "") { - let element = browser.document.getElementById(sanitizedName); + let element = doc.getElementById(sanitizedName); if (!element) { - element = browser.document.createElement("div"); - + element = doc.createElement("div"); element.style.display = "none"; element.setAttribute("id", sanitizedName); - - browser.document.body.appendChild(element); + doc.body.appendChild(element); } element.setAttribute(sanitizedProperty, value); @@ -221,13 +235,9 @@ class nsZenMods extends nsZenPreloadedFeature { case "string": { if (value === "") { - browser.document - .querySelector(":root") - .style.removeProperty(`--${sanitizedProperty}`); + root.style.removeProperty(`--${sanitizedProperty}`); } else { - browser.document - .querySelector(":root") - .style.setProperty(`--${sanitizedProperty}`, value); + root.style.setProperty(`--${sanitizedProperty}`, value); } break; } @@ -308,6 +318,19 @@ class nsZenMods extends nsZenPreloadedFeature { } async #downloadUrlToFile(url, path, maxRetries = 3, retryDelayMs = 500) { + // Mod assets must come over HTTPS. Without a signing/hash scheme this + // is the minimum guard against MITM or a store-hosted HTTP redirect + // serving attacker-controlled CSS/JSON into the chrome profile. + let parsed; + try { + parsed = new URL(url); + } catch { + throw new Error(`[ZenMods]: Invalid mod asset URL: ${url}`); + } + if (parsed.protocol !== "https:") { + throw new Error(`[ZenMods]: Refusing non-HTTPS mod asset URL: ${url}`); + } + let attempt = 0; while (attempt < maxRetries) { @@ -377,8 +400,7 @@ class nsZenMods extends nsZenPreloadedFeature { sanitizeModName(aName) { // Do not change to "mod-" for backwards compatibility - // eslint-disable-next-line no-shadow - return `theme-${aName?.replaceAll(/\s/g, "-")?.replaceAll(/[^A-Za-z_-]+/g, "")}`; + return `theme-${aName?.replaceAll(WHITESPACE_RE, "-")?.replaceAll(NON_NAME_RE, "")}`; } get updatePref() { From 826f1f355f97a2ad5b1d21fac4e128354407383b Mon Sep 17 00:00:00 2001 From: "mr. m" <91018726+mr-cheffy@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:17:41 +0200 Subject: [PATCH 08/16] no-bug: New popup layout and stylings (gh-13245) --- .../prompts/content/commonDialog-css.patch | 7 ++-- src/zen/common/styles/zen-animations.css | 4 +-- src/zen/common/styles/zen-buttons.css | 32 +++++++++++-------- src/zen/common/styles/zen-panels/dialog.css | 6 ++-- src/zen/common/styles/zen-theme.css | 4 +-- 5 files changed, 30 insertions(+), 23 deletions(-) diff --git a/src/toolkit/components/prompts/content/commonDialog-css.patch b/src/toolkit/components/prompts/content/commonDialog-css.patch index ad3ab9c8c..3a55f9f89 100644 --- a/src/toolkit/components/prompts/content/commonDialog-css.patch +++ b/src/toolkit/components/prompts/content/commonDialog-css.patch @@ -1,5 +1,5 @@ diff --git a/toolkit/components/prompts/content/commonDialog.css b/toolkit/components/prompts/content/commonDialog.css -index d811fb62d502cf6fc0bf8163f11e1d264dee9e82..9aa4dc90c73e2f1e2fdcb6bdc26a505402a5c28f 100644 +index d811fb62d502cf6fc0bf8163f11e1d264dee9e82..88e9a4f508f3d640bdbb3a31928e777629afcd80 100644 --- a/toolkit/components/prompts/content/commonDialog.css +++ b/toolkit/components/prompts/content/commonDialog.css @@ -3,7 +3,8 @@ @@ -12,12 +12,13 @@ index d811fb62d502cf6fc0bf8163f11e1d264dee9e82..9aa4dc90c73e2f1e2fdcb6bdc26a5054 } dialog[insecureauth] { -@@ -91,7 +92,7 @@ dialog[insecureauth] { +@@ -91,7 +92,8 @@ dialog[insecureauth] { --grid-padding: 16px; /* All the inner items should have 4px inline margin, leading to 1.16em spacing * between the dialog and its contents, and 8px horizontal spacing between items. */ - padding: var(--grid-padding) calc(var(--grid-padding) - 4px); -+ padding: 26px 22px; ++ padding: 30px 26px; ++ gap: 4px; &::part(dialog-button) { /* Adjust vertical margins for buttons in subdialogs. */ diff --git a/src/zen/common/styles/zen-animations.css b/src/zen/common/styles/zen-animations.css index 7f08377be..9756f8894 100644 --- a/src/zen/common/styles/zen-animations.css +++ b/src/zen/common/styles/zen-animations.css @@ -39,12 +39,12 @@ @keyframes zen-dialog-fade-in { from { opacity: 0; - transform: translateY(-10px); + transform: translateY(calc(-10% - 10px)); } to { opacity: 1; - transform: translateY(0); + transform: translateY(-10%); } } diff --git a/src/zen/common/styles/zen-buttons.css b/src/zen/common/styles/zen-buttons.css index 0c1f6afde..d94c2c367 100644 --- a/src/zen/common/styles/zen-buttons.css +++ b/src/zen/common/styles/zen-buttons.css @@ -18,21 +18,22 @@ xul|button { } dialog::part(dialog-button) { - --button-background-color: light-dark(white, #302f63); + --button-background-color: light-dark(rgba(28, 28, 28, 0.1), rgba(255, 255, 255, .1)) !important; --button-background-color-hover: color-mix(in srgb, var(--button-background-color) 90%, transparent); --button-background-color-active: color-mix(in srgb, var(--button-background-color) 80%, transparent); - --button-background-color-primary: #3138fc; + --button-background-color-primary: light-dark(#1c1c1c, #dddddd); --button-background-color-primary-hover: color-mix(in srgb, var(--button-background-color-primary) 95%, transparent); - --button-text-color-primary: white; - --button-text-color-primary-hover: var(--button-text-color-primary); - border-color: light-dark(rgba(0, 0, 0, 0.2), rgba(255, 255, 255, 0.2)) !important; - border-bottom-width: 2px !important; + --button-background-color-primary-active: color-mix(in srgb, var(--button-background-color-primary) 90%, transparent); + position: relative; + border: none; + border-radius: 10px; } dialog[defaultButton="accept"]::part(dialog-button) { &:is([dlgtype="accept"], [dlgtype="cancel"]) { padding-inline-end: 3.7em; + @media (-moz-platform: windows) { padding-inline-end: 4em; } @@ -41,25 +42,27 @@ dialog[defaultButton="accept"]::part(dialog-button) { border-radius: 4px; font-weight: 600; position: absolute; - right: 0.6em; padding: 3px 0; top: 50%; transform: translateY(-50%); - font-size: 12px; - width: 34px; text-align: center; } &[dlgtype="accept"]::after { content: "⏎"; - background: rgba(255, 255, 255, 0.1); - outline: 2px solid color-mix(in srgb, currentColor 20%, transparent); - outline-offset: -2px; + right: 0.6em; + font-size: 12px; + width: 36px; + color: light-dark(rgba(255, 255, 255, 0.80), rgba(0, 0, 0, 0.60)); } &[dlgtype="cancel"]::after { content: "ESC"; - background: light-dark(rgba(0, 0, 0, 0.1), rgba(255, 255, 255, 0.1)); + right: 1em; + font-size: 8px; + width: 30px; + color: light-dark(rgba(28, 28, 28, 0.7), rgba(255, 255, 255, 0.6)) !important; + outline: 1px solid light-dark(rgba(28, 28, 28, 0.1), rgba(255, 255, 255, 0.1)) !important; } } } @@ -68,7 +71,8 @@ xul|button:not(#zen-workspaces-button):active { transform: scale(0.98); } -.footer-button { +.footer-button, +dialog::part(dialog-button) { font-weight: 500 !important; transition: scale 0.2s; diff --git a/src/zen/common/styles/zen-panels/dialog.css b/src/zen/common/styles/zen-panels/dialog.css index 4b41dd0dc..d149f6648 100644 --- a/src/zen/common/styles/zen-panels/dialog.css +++ b/src/zen/common/styles/zen-panels/dialog.css @@ -4,9 +4,11 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ .dialogBox { - outline: 1px solid light-dark(transparent, #646464); animation: zen-dialog-fade-in 0.3s ease-out; border-radius: 12px !important; - border: 1px solid light-dark(#a8a8a9, black); + border: 1px solid light-dark(rgba(168, 168, 169, 0.50), var(--zen-dialog-background)) !important; + outline: 1px solid light-dark(transparent, rgba(168, 168, 169, 0.50)) !important; + box-shadow: 0 10px 8px rgba(0, 0 , 0, 0.15) !important; outline-offset: -1.5px; + transform: translateY(-10%); } diff --git a/src/zen/common/styles/zen-theme.css b/src/zen/common/styles/zen-theme.css index 7b333d1db..85abf3bc3 100644 --- a/src/zen/common/styles/zen-theme.css +++ b/src/zen/common/styles/zen-theme.css @@ -59,7 +59,7 @@ color-mix(in srgb, var(--zen-primary-color) 1%, var(--zen-branding-bg) 99%) ); - --zen-dialog-background: light-dark(#FAFBFF, #161C31); + --zen-dialog-background: light-dark(#fafbff, #1c1c1c); --zen-urlbar-background: light-dark( color-mix(in srgb, var(--zen-primary-color) 3%, #f4f4f4 97%), color-mix(in srgb, var(--zen-primary-color) 4%, rgb(24, 24, 24) 96%) @@ -113,7 +113,7 @@ --button-border-color: var(--in-content-primary-button-border-color) !important; --button-font-weight: 500 !important; - --button-border-radius: 6px; + --button-border-radius: 8px; --button-text-color-primary: var(--in-content-primary-button-text-color) !important; --button-background-color: var(--in-content-button-background) !important; From 767dfce556f27fc7d2d98fcccc86fc642d24904e Mon Sep 17 00:00:00 2001 From: rain capsule <29630035+busybox11@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:11:18 +0200 Subject: [PATCH 09/16] gh-7099: slight CSS and SVG optimizations on notes anim loop (gh-13246) --- src/zen/images/note-indicator.svg | 106 +++++++++++++-------------- src/zen/media/zen-media-controls.css | 8 +- 2 files changed, 57 insertions(+), 57 deletions(-) diff --git a/src/zen/images/note-indicator.svg b/src/zen/images/note-indicator.svg index 02ff00771..1c69eae03 100644 --- a/src/zen/images/note-indicator.svg +++ b/src/zen/images/note-indicator.svg @@ -6,13 +6,44 @@ - - - - + + - - - + + - - - + + - - - + + - - - + + - - - + + diff --git a/src/zen/media/zen-media-controls.css b/src/zen/media/zen-media-controls.css index 8b23e50ee..9cabf107e 100644 --- a/src/zen/media/zen-media-controls.css +++ b/src/zen/media/zen-media-controls.css @@ -117,16 +117,16 @@ & #zen-media-focus-button::after { content: ""; position: absolute; - width: 110%; - height: 110%; background: url("chrome://browser/content/zen-images/note-indicator.svg") no-repeat; top: -70%; - left: 50%; - transform: translateX(-50%); + left: 0; + right: 0; + bottom: 0; z-index: 0; pointer-events: none; transition: opacity 0.8s ease; opacity: 1; + will-change: opacity; } &:is(:not(.playing:not([muted])), :hover) #zen-media-focus-button::after { From c128b79723dfa4509c016baaf14652e97708e7ce Mon Sep 17 00:00:00 2001 From: "mr. m" <91018726+mr-cheffy@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:49:52 +0200 Subject: [PATCH 10/16] gh-13258: Implement new loading indicator (gh-13259) --- prefs/zen/view.yaml | 3 + src/browser/base/content/browser-js.patch | 19 +-- src/zen/common/modules/ZenStartup.mjs | 11 ++ src/zen/common/moz.build | 5 + src/zen/common/styles/zen-animations.css | 35 +++++ src/zen/common/styles/zen-omnibox.css | 9 +- .../common/styles/zen-single-components.css | 44 ++++++ src/zen/common/sys/ui/ZenProgressBar.sys.mjs | 127 ++++++++++++++++++ src/zen/common/sys/ui/ZenUIComponent.sys.mjs | 61 +++++++++ src/zen/glance/zen-glance.css | 6 - src/zen/tabs/ZenPinnedTabManager.mjs | 19 +-- 11 files changed, 307 insertions(+), 32 deletions(-) create mode 100644 src/zen/common/sys/ui/ZenProgressBar.sys.mjs create mode 100644 src/zen/common/sys/ui/ZenUIComponent.sys.mjs diff --git a/prefs/zen/view.yaml b/prefs/zen/view.yaml index d990c13fa..1f36da3f0 100644 --- a/prefs/zen/view.yaml +++ b/prefs/zen/view.yaml @@ -57,3 +57,6 @@ - name: zen.view.overflow-webext-toolbar value: "@IS_TWILIGHT@" + +- name: zen.view.enable-loading-indicator + value: true diff --git a/src/browser/base/content/browser-js.patch b/src/browser/base/content/browser-js.patch index 15dfe2198..a36248923 100644 --- a/src/browser/base/content/browser-js.patch +++ b/src/browser/base/content/browser-js.patch @@ -1,5 +1,5 @@ diff --git a/browser/base/content/browser.js b/browser/base/content/browser.js -index 0ea3d82b88819c41ffd866ae9533ebb5a7bff957..37db181d7e71fb6250df5bae363e9cf984b44f79 100644 +index 0ea3d82b88819c41ffd866ae9533ebb5a7bff957..3ed2fc4d08b20883e0587e4435daacd86ad603de 100644 --- a/browser/base/content/browser.js +++ b/browser/base/content/browser.js @@ -33,6 +33,7 @@ ChromeUtils.defineESModuleGetters(this, { @@ -24,16 +24,7 @@ index 0ea3d82b88819c41ffd866ae9533ebb5a7bff957..37db181d7e71fb6250df5bae363e9cf9 if (backDisabled) { backCommand.removeAttribute("disabled"); } else { -@@ -2305,6 +2311,8 @@ var XULBrowserWindow = { - AboutReaderParent.updateReaderButton(gBrowser.selectedBrowser); - TranslationsParent.onLocationChange(gBrowser.selectedBrowser); - -+ gZenPinnedTabManager.onLocationChange(gBrowser.selectedBrowser, location); -+ - PictureInPicture.updateUrlbarToggle(gBrowser.selectedBrowser); - - if (!gMultiProcessBrowser) { -@@ -3820,7 +3828,7 @@ function warnAboutClosingWindow() { +@@ -3820,7 +3826,7 @@ function warnAboutClosingWindow() { if (!isPBWindow && !toolbar.visible) { return gBrowser.warnAboutClosingTabs( @@ -42,7 +33,7 @@ index 0ea3d82b88819c41ffd866ae9533ebb5a7bff957..37db181d7e71fb6250df5bae363e9cf9 gBrowser.closingTabsEnum.ALL ); } -@@ -3860,7 +3868,7 @@ function warnAboutClosingWindow() { +@@ -3860,7 +3866,7 @@ function warnAboutClosingWindow() { return ( isPBWindow || gBrowser.warnAboutClosingTabs( @@ -51,7 +42,7 @@ index 0ea3d82b88819c41ffd866ae9533ebb5a7bff957..37db181d7e71fb6250df5bae363e9cf9 gBrowser.closingTabsEnum.ALL ) ); -@@ -3885,7 +3893,7 @@ function warnAboutClosingWindow() { +@@ -3885,7 +3891,7 @@ function warnAboutClosingWindow() { AppConstants.platform != "macosx" || isPBWindow || gBrowser.warnAboutClosingTabs( @@ -60,7 +51,7 @@ index 0ea3d82b88819c41ffd866ae9533ebb5a7bff957..37db181d7e71fb6250df5bae363e9cf9 gBrowser.closingTabsEnum.ALL ) ); -@@ -4825,6 +4833,9 @@ var ConfirmationHint = { +@@ -4825,6 +4831,9 @@ var ConfirmationHint = { } document.l10n.setAttributes(this._message, messageId, options.l10nArgs); diff --git a/src/zen/common/modules/ZenStartup.mjs b/src/zen/common/modules/ZenStartup.mjs index 664d4bc96..a0611a8df 100644 --- a/src/zen/common/modules/ZenStartup.mjs +++ b/src/zen/common/modules/ZenStartup.mjs @@ -57,6 +57,7 @@ class ZenStartup { gZenWorkspaces.init(); setTimeout(() => { gZenUIManager.init(); + this.#initUIComponents(); this.#checkForWelcomePage(); }, 0); } catch (e) { @@ -161,6 +162,16 @@ class ZenStartup { } } + #initUIComponents() { + const kUIComponents = ["ZenProgressBar"]; + for (let component of kUIComponents) { + const module = ChromeUtils.importESModule( + "resource:///modules/zen/ui/" + component + ".sys.mjs" + ); + new module[component](window); + } + } + #checkForWelcomePage() { const kWelcomeScreenSeenPref = "zen.welcome-screen.seen"; if (Services.env.get("MOZ_HEADLESS")) { diff --git a/src/zen/common/moz.build b/src/zen/common/moz.build index 8dc973b44..115234dad 100644 --- a/src/zen/common/moz.build +++ b/src/zen/common/moz.build @@ -7,3 +7,8 @@ EXTRA_JS_MODULES += [ "sys/ZenCustomizableUI.sys.mjs", "sys/ZenUIMigration.sys.mjs", ] + +EXTRA_JS_MODULES.zen.ui += [ + "sys/ui/ZenProgressBar.sys.mjs", + "sys/ui/ZenUIComponent.sys.mjs", +] diff --git a/src/zen/common/styles/zen-animations.css b/src/zen/common/styles/zen-animations.css index 9756f8894..9b0185ada 100644 --- a/src/zen/common/styles/zen-animations.css +++ b/src/zen/common/styles/zen-animations.css @@ -56,3 +56,38 @@ background-position: -400% 50%; } } + +@keyframes zen-progress-bar-pulse { + 0% { + transform: scale(0.8) translate(-50%, -50%); + opacity: 0.6; + } + 50% { + transform: scale(0.95) translate(-50%, -50%); + opacity: 1; + } + 100% { + transform: scale(0.8) translate(-50%, -50%); + opacity: 0.6; + } +} + +@keyframes zen-progress-bar-long-load { + 0% { + left: -100%; + opacity: 1; + } + + 100% { + left: 100%; + opacity: 1; + } +} + +@keyframes zen-progress-bar-settle { + to { + transform: translate(-50%, -50%) scale(1); + opacity: 1; + width: 10rem; + } +} diff --git a/src/zen/common/styles/zen-omnibox.css b/src/zen/common/styles/zen-omnibox.css index a334a3d04..0a29c1936 100644 --- a/src/zen/common/styles/zen-omnibox.css +++ b/src/zen/common/styles/zen-omnibox.css @@ -486,11 +486,12 @@ margin-block: -1px !important; } } +} - & #identity-icon-box { - --urlbar-box-hover-bgcolor: transparent; - margin-inline: 2px 8px; - } +#urlbar[open][zen-floating-urlbar="true"] #identity-icon-box, +:root[zen-single-toolbar="true"] #urlbar[breakout-extend="true"] #identity-icon-box { + --urlbar-box-hover-bgcolor: transparent; + margin-inline: 2px 8px; } /* stylelint-disable-next-line media-query-no-invalid */ diff --git a/src/zen/common/styles/zen-single-components.css b/src/zen/common/styles/zen-single-components.css index f40e64210..f4a6d582a 100644 --- a/src/zen/common/styles/zen-single-components.css +++ b/src/zen/common/styles/zen-single-components.css @@ -683,3 +683,47 @@ } } } + +/* Loading progress bar */ +#zen-loading-progress-bar { + position: fixed; + top: max(calc(var(--zen-element-separation) / -2), -4px); + left: 50%; + transform: translate(-50%, -50%) scale(0); + background: light-dark(rgba(0, 0, 0, 0.7), rgba(255, 255, 255, 0.7)); + height: .4rem; + width: 5rem; + z-index: 9; + border-radius: 100px; + transition: opacity .3s ease-in-out, + background-color .3s ease-in-out, + transform .3s ease-in-out; + overflow: clip; + pointer-events: none; + animation: zen-progress-bar-pulse 1.5s linear infinite forwards; + transform-origin: 0 0; + + &[long-load="true"] { + opacity: 1; + animation: zen-progress-bar-settle .3s ease-out forwards; + background: light-dark(rgba(0, 0, 0, 0.1), rgba(255, 255, 255, 0.1)); + + &::before { + content: ""; + position: absolute; + top: 0; + height: 100%; + width: 75%; + opacity: 0; + animation: zen-progress-bar-long-load 1s ease-in-out infinite; + animation-delay: 0.3s; + background: light-dark(rgba(0, 0, 0, 0.7), rgba(255, 255, 255, 0.7)); + border-radius: inherit; + } + } + + /* stylelint-disable-next-line media-query-no-invalid */ + @media not -moz-pref("zen.view.enable-loading-indicator") { + display: none; + } +} diff --git a/src/zen/common/sys/ui/ZenProgressBar.sys.mjs b/src/zen/common/sys/ui/ZenProgressBar.sys.mjs new file mode 100644 index 000000000..e8fa8157e --- /dev/null +++ b/src/zen/common/sys/ui/ZenProgressBar.sys.mjs @@ -0,0 +1,127 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import { ZenUIComponent } from "resource:///modules/zen/ui/ZenUIComponent.sys.mjs"; + +const WAIT_BEFORE_SHOWING_LONG_LOAD = 3000; + +export class ZenProgressBar extends ZenUIComponent { + #element = null; + #loadingTab = null; + #longLoadTimer = null; + + init() { + this.listenBrowserTabsProgress(); + this.addEventListener("TabSelect"); + } + + onStateChange(aWebProgress) { + this.#checkBrowserProgress(aWebProgress); + } + + onLocationChange(webProgress) { + this.#checkBrowserProgress(webProgress); + } + + on_TabSelect() { + const gBrowser = this.window.gBrowser; + const selectedTab = gBrowser.selectedTab; + this.onLocationChange(gBrowser.getBrowserForTab(selectedTab)); + } + + get #progressBar() { + if (!this.#loadingTab) { + return null; + } + if (!this.#element) { + this.#element = this.window.document.createXULElement("hbox"); + this.#element.id = "zen-loading-progress-bar"; + } + if ( + this.#element._loadingTab?.deref() !== this.#loadingTab && + this.#loadingTab + ) { + this.#element._loadingTab = new WeakRef(this.#loadingTab); + const container = this.window.document.getElementById( + this.#loadingTab.linkedPanel + ); + container.firstChild.before(this.#element); + this.window.gZenUIManager.elementAnimate( + this.#element, + { + opacity: [0, 0.6], + }, + { + duration: 400, + } + ); + } + return this.#element; + } + + #checkBrowserProgress(webProgress) { + const window = this.window; + const gBrowser = window.gBrowser; + const tab = gBrowser.getTabForBrowser(webProgress); + const isLoading = + tab?.selected && + (tab.hasAttribute("busy") || tab.hasAttribute("progress")); + if (isLoading) { + this.#showProgressBar(tab); + } else { + this.#hideProgressBar(); + } + } + + #hideProgressBar() { + const progressBar = this.#element; + const window = this.window; + if (this.#longLoadTimer) { + window.clearTimeout(this.#longLoadTimer); + this.#longLoadTimer = null; + } + + this.#loadingTab = null; + if (!progressBar) { + return; + } + const callback = () => { + delete progressBar._loadingTab; + progressBar.remove(); + this.#element = null; + }; + if (this.window.gReduceMotion) { + callback(); + return; + } + this.window.gZenUIManager + .elementAnimate( + progressBar, + { + transform: ["scaleX(0.8) translate(-50%, -50%)"], + opacity: [0], + }, + { + duration: 300, + } + ) + .then(callback); + } + + #showProgressBar(aTab) { + if (this.#loadingTab === aTab) { + return; + } + this.#loadingTab = aTab; + const progressBar = this.#progressBar; + progressBar.removeAttribute("fade-out"); + progressBar.removeAttribute("long-load"); + this.#longLoadTimer = this.window.setTimeout(() => { + if (this.#loadingTab === aTab) { + progressBar.setAttribute("long-load", "true"); + } + this.#longLoadTimer = null; + }, WAIT_BEFORE_SHOWING_LONG_LOAD); + } +} diff --git a/src/zen/common/sys/ui/ZenUIComponent.sys.mjs b/src/zen/common/sys/ui/ZenUIComponent.sys.mjs new file mode 100644 index 000000000..7a26773ea --- /dev/null +++ b/src/zen/common/sys/ui/ZenUIComponent.sys.mjs @@ -0,0 +1,61 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +/** + * Base class for UI components in Zen. + * UI components are responsible for managing their own event listeners + * and providing a consistent interface for handling events. + */ +export class ZenUIComponent { + #window = null; + #eventListeners = new Set(); + + constructor(aWindow) { + this.#window = aWindow; + this.init(); + this.#window.addEventListener("unload", () => { + if (typeof this.uninit === "function") { + this.uninit(); + } + for (const { type, options } of this.#eventListeners) { + this.#window.removeEventListener(type, this, options); + } + this.#eventListeners.clear(); + }); + } + + get window() { + return this.#window; + } + + /** + * Adds an event listener to the component that will automatically be removed when the window unloads. + * + * @param {string} type - The event type to listen for. + * @param {object} options - The event listener function or an object containing options. + * @returns {void} + */ + addEventListener(type, options = {}) { + this.#window.addEventListener(type, this, options); + if (options?.once) { + return; + } + this.#eventListeners.add({ type, options }); + } + + listenBrowserTabsProgress() { + this.#window.gBrowser.addTabsProgressListener(this); + } + + listenBrowserProgress() { + this.#window.gBrowser.addProgressListener(this); + } + + handleEvent(event) { + const handlerName = "on_" + event.type; + if (typeof this[handlerName] === "function") { + this[handlerName](event); + } + } +} diff --git a/src/zen/glance/zen-glance.css b/src/zen/glance/zen-glance.css index 807aea8c2..bca099ab7 100644 --- a/src/zen/glance/zen-glance.css +++ b/src/zen/glance/zen-glance.css @@ -96,12 +96,6 @@ .browserSidebarContainer.zen-glance-background { box-shadow: var(--zen-big-shadow); - - & .browserContainer { - /* For rounding the corners of the content to work while - * applying a transformation to the container. */ - will-change: transform; - } } .browserSidebarContainer.zen-glance-background, diff --git a/src/zen/tabs/ZenPinnedTabManager.mjs b/src/zen/tabs/ZenPinnedTabManager.mjs index 8a8e4e832..2c048c8cc 100644 --- a/src/zen/tabs/ZenPinnedTabManager.mjs +++ b/src/zen/tabs/ZenPinnedTabManager.mjs @@ -79,8 +79,9 @@ class nsZenPinnedTabManager extends nsZenDOMOperatedFeature { this._zenClickEventListener = this._onTabClick.bind(this); gZenWorkspaces._resolvePinnedInitialized(); - if (lazy.zenPinnedTabRestorePinnedTabsToPinnedUrl) { - gZenWorkspaces.promiseInitialized.then(() => { + gZenWorkspaces.promiseInitialized.then(() => { + gBrowser.addTabsProgressListener(this); + if (lazy.zenPinnedTabRestorePinnedTabsToPinnedUrl) { for (const tab of gZenWorkspaces.allStoredTabs) { try { this.resetPinnedTab(tab); @@ -88,8 +89,8 @@ class nsZenPinnedTabManager extends nsZenDOMOperatedFeature { console.error("Error restoring pinned tab:", ex); } } - }); - } + } + }); } log(message) { @@ -833,11 +834,13 @@ class nsZenPinnedTabManager extends nsZenDOMOperatedFeature { } } - onLocationChange(aBrowser, aLocation) { + onLocationChange(aBrowser, aWebProgress, aRequest, aLocationURI) { + // eslint-disable-next-line no-shadow + let location = aLocationURI ? aLocationURI.spec : ""; if ( - (aLocation == "about:blank" && + (location == "about:blank" && BrowserUIUtils.checkEmptyPageOrigin(aBrowser)) || - aLocation == "" + location == "" ) { return; } @@ -852,7 +855,7 @@ class nsZenPinnedTabManager extends nsZenDOMOperatedFeature { } // Remove # and ? from the URL const pinUrl = tab._zenPinnedInitialState.entry.url.split("#")[0]; - const currentUrl = aLocation.split("#")[0]; + const currentUrl = location.split("#")[0]; // Add an indicator that the pin has been changed if (pinUrl === currentUrl) { this.resetPinChangedUrl(tab); From b988f23a14df2ca0b60d6fd61f6226524aeffdf9 Mon Sep 17 00:00:00 2001 From: "mr. m" <91018726+mr-cheffy@users.noreply.github.com> Date: Wed, 15 Apr 2026 18:59:40 +0200 Subject: [PATCH 11/16] gh-10649: Implement JSON store length hints (gh-13260) --- scripts/update_external_patches.py | 36 ++- .../session_store_use_size_hint/D247145.patch | 61 ++++ .../session_store_use_size_hint/D247215.patch | 251 ++++++++++++++++ .../session_store_use_size_hint/D247217.patch | 279 ++++++++++++++++++ src/external-patches/manifest.json | 9 + src/toolkit/modules/JSONFile-sys-mjs.patch | 29 ++ .../sessionstore/ZenSessionManager.sys.mjs | 22 +- 7 files changed, 667 insertions(+), 20 deletions(-) create mode 100644 src/external-patches/firefox/session_store_use_size_hint/D247145.patch create mode 100644 src/external-patches/firefox/session_store_use_size_hint/D247215.patch create mode 100644 src/external-patches/firefox/session_store_use_size_hint/D247217.patch create mode 100644 src/toolkit/modules/JSONFile-sys-mjs.patch diff --git a/scripts/update_external_patches.py b/scripts/update_external_patches.py index 3e4c1057b..341cb77f7 100644 --- a/scripts/update_external_patches.py +++ b/scripts/update_external_patches.py @@ -24,6 +24,9 @@ def download_phab_patch(phab_id, output_file): print(f"Downloading patch from {patch_url}") response = requests.get(patch_url) response.raise_for_status() # Raise an error for bad responses + folder = os.path.dirname(output_file) + if not os.path.exists(folder): + os.makedirs(folder) with open(output_file, 'wb') as f: f.write(response.content) print(f"Patch saved to {output_file}") @@ -51,24 +54,27 @@ def main(): expected_files = set() for patch in manifest: if patch.get("type") == "phabricator": - phab_id = patch.get("id") + phab_ids = [patch.get("id")] if patch.get("id") else patch.get("ids", []) name = patch.get("name") - if not phab_id or not name: + if not phab_ids or not name: die(f"Patch entry missing 'id' or 'name': {patch}") name = name.replace(" ", "_").replace(".", "_").lower() - output_file = os.path.join(OUTPUT_DIR, "firefox", f"{name}.patch") - print(f"Processing Phabricator patch: {phab_id} -> {output_file}") - download_phab_patch(phab_id, output_file) - replaces = patch.get("replaces", {}) - for replace in replaces.keys(): - value = replaces[replace] - with open(output_file, 'r') as f: - content = f.read() - if replace not in content: - die(f"Replace string '{replace}' not found in {output_file}") - with open(output_file, 'w') as f: - f.write(content.replace(replace, value)) - expected_files.add(output_file) + for phab_id in phab_ids: + output_file = ( + os.path.join(OUTPUT_DIR, "firefox", f"{name}.patch") + if len(phab_ids) == 1 else + os.path.join(OUTPUT_DIR, "firefox", name, f"{phab_id}.patch") + ) + print(f"Processing Phabricator patch: {phab_id} -> {output_file}") + download_phab_patch(phab_id, output_file) + replaces = patch.get("replaces", {}) + for replace in replaces.keys(): + value = replaces[replace] + with open(output_file, 'r') as f: + content = f.read() + with open(output_file, 'w') as f: + f.write(content.replace(replace, value)) + expected_files.add(output_file) elif patch.get("type") == "local": print(f"Local patch: {patch.get('path')}") expected_files.add(os.path.join(OUTPUT_DIR, patch.get("path"))) diff --git a/src/external-patches/firefox/session_store_use_size_hint/D247145.patch b/src/external-patches/firefox/session_store_use_size_hint/D247145.patch new file mode 100644 index 000000000..c52eb4b92 --- /dev/null +++ b/src/external-patches/firefox/session_store_use_size_hint/D247145.patch @@ -0,0 +1,61 @@ +diff --git a/js/public/JSON.h b/js/public/JSON.h +--- a/js/public/JSON.h ++++ b/js/public/JSON.h +@@ -24,16 +24,23 @@ + * writing stringified data by exactly one call of |callback|, passing |data| as + * argument. + * + * In cases where JSON.stringify would return undefined, this function calls + * |callback| with the string "null". ++ * ++ * If a length hint is passed, space will be reserved for at least that many ++ * characters. + */ + extern JS_PUBLIC_API bool JS_Stringify(JSContext* cx, + JS::MutableHandle value, + JS::Handle replacer, + JS::Handle space, + JSONWriteCallback callback, void* data); ++extern JS_PUBLIC_API bool JS_StringifyWithLengthHint( ++ JSContext* cx, JS::MutableHandle value, ++ JS::Handle replacer, JS::Handle space, ++ JSONWriteCallback callback, void* data, size_t lengthHint); + + namespace JS { + + /** + * An API akin to JS_Stringify but with the goal of not having observable +diff --git a/js/src/jsapi.cpp b/js/src/jsapi.cpp +--- a/js/src/jsapi.cpp ++++ b/js/src/jsapi.cpp +@@ -3663,17 +3663,29 @@ + } + + JS_PUBLIC_API bool JS_Stringify(JSContext* cx, MutableHandleValue vp, + HandleObject replacer, HandleValue space, + JSONWriteCallback callback, void* data) { ++ return JS_StringifyWithLengthHint(cx, vp, replacer, space, callback, data, 0); ++} ++ ++JS_PUBLIC_API bool JS_StringifyWithLengthHint(JSContext* cx, ++ MutableHandleValue vp, ++ HandleObject replacer, ++ HandleValue space, ++ JSONWriteCallback callback, ++ void* data, size_t lengthHint) { + AssertHeapIsIdle(); + CHECK_THREAD(cx); + cx->check(replacer, space); + StringBuilder sb(cx); + if (!sb.ensureTwoByteChars()) { + return false; + } ++ if (lengthHint && !sb.reserve(lengthHint)) { ++ return false; ++ } + if (!Stringify(cx, vp, replacer, space, sb, StringifyBehavior::Normal)) { + return false; + } + if (sb.empty() && !sb.append(cx->names().null)) { + return false; + diff --git a/src/external-patches/firefox/session_store_use_size_hint/D247215.patch b/src/external-patches/firefox/session_store_use_size_hint/D247215.patch new file mode 100644 index 000000000..53e619863 --- /dev/null +++ b/src/external-patches/firefox/session_store_use_size_hint/D247215.patch @@ -0,0 +1,251 @@ +diff --git a/dom/chrome-webidl/IOUtils.webidl b/dom/chrome-webidl/IOUtils.webidl +--- a/dom/chrome-webidl/IOUtils.webidl ++++ b/dom/chrome-webidl/IOUtils.webidl +@@ -94,23 +94,23 @@ + * otherwise rejects with a DOMException. + */ + [NewObject] + Promise writeUTF8(DOMString path, UTF8String string, optional WriteOptions options = {}); + /** +- * Attempts to serialize |value| into a JSON string and encode it as into a +- * UTF-8 string, then safely write the result to a file at |path|. Works +- * exactly like |write|. ++ * Attempts to serialize |value| into a JSON string and encode it as a UTF-8 ++ * string, then safely write the result to a file at |path|. Works exactly ++ * like |write|. + * + * @param path An absolute file path + * @param value The value to be serialized. + * @param options Options for writing the file. The "append" mode is not supported. + * + * @return Resolves with the number of bytes successfully written to the file, + * otherwise rejects with a DOMException. + */ + [NewObject] +- Promise writeJSON(DOMString path, any value, optional WriteOptions options = {}); ++ Promise writeJSON(DOMString path, any value, optional WriteJSONOptions options = {}); + /** + * Moves the file from |sourcePath| to |destPath|, creating necessary parents. + * If |destPath| is a directory, then the source file will be moved into the + * destination directory. + * +@@ -567,10 +567,39 @@ + * If true, compress the data with LZ4-encoding before writing to the file. + */ + boolean compress = false; + }; + ++/** ++ * Options to be passed to the |IOUtils.writeJSON| method. ++ */ ++dictionary WriteJSONOptions: WriteOptions { ++ /** ++ * An optional length hint that will be used to pre-allocate the buffer that ++ * will hold the stringified JSON. ++ * ++ * This is the *length* and not the size (i.e., it is the number of UTF-16 ++ * codepoints and not the number of bytes). ++ */ ++ unsigned long long lengthHint = 0; ++}; ++ ++/** ++ * Information about a WriteJSON operation. ++ */ ++dictionary WriteJSONResult { ++ /** ++ * The number of bytes written. ++ */ ++ required unsigned long long size; ++ ++ /** ++ * The length of the stringified JSON (in UTF-16 codepoints). ++ */ ++ required unsigned long long jsonLength; ++}; ++ + /** + * Options to be passed to the |IOUtils.move| method. + */ + dictionary MoveOptions { + /** +diff --git a/xpcom/ioutils/IOUtils.h b/xpcom/ioutils/IOUtils.h +--- a/xpcom/ioutils/IOUtils.h ++++ b/xpcom/ioutils/IOUtils.h +@@ -94,11 +94,11 @@ + const nsACString& aString, const dom::WriteOptions& aOptions, + ErrorResult& aError); + + static already_AddRefed WriteJSON( + dom::GlobalObject& aGlobal, const nsAString& aPath, +- JS::Handle aValue, const dom::WriteOptions& aOptions, ++ JS::Handle aValue, const dom::WriteJSONOptions& aOptions, + ErrorResult& aError); + + static already_AddRefed Move(dom::GlobalObject& aGlobal, + const nsAString& aSourcePath, + const nsAString& aDestPath, +@@ -736,13 +736,16 @@ + RefPtr mBackupFile; + RefPtr mTmpFile; + dom::WriteMode mMode; + bool mFlush = false; + bool mCompress = false; ++ size_t mLengthHint = 0; + + static Result FromBinding( + const dom::WriteOptions& aOptions); ++ static Result FromBinding( ++ const dom::WriteJSONOptions& aOptions); + }; + + /** + * Re-implements the file compression and decompression utilities found + * in toolkit/components/lz4/lz4.js +diff --git a/xpcom/ioutils/IOUtils.cpp b/xpcom/ioutils/IOUtils.cpp +--- a/xpcom/ioutils/IOUtils.cpp ++++ b/xpcom/ioutils/IOUtils.cpp +@@ -589,15 +589,21 @@ + return WriteSync(file, AsBytes(Span(str)), opts); + }); + }); + } + ++static bool AppendJSON(const char16_t* aBuf, uint32_t aLen, void* aStr) { ++ nsAString* str = static_cast(aStr); ++ ++ return str->Append(aBuf, aLen, fallible); ++} ++ + /* static */ + already_AddRefed IOUtils::WriteJSON(GlobalObject& aGlobal, + const nsAString& aPath, + JS::Handle aValue, +- const WriteOptions& aOptions, ++ const WriteJSONOptions& aOptions, + ErrorResult& aError) { + return WithPromiseAndState( + aGlobal, aError, [&](Promise* promise, auto& state) { + nsCOMPtr file = new nsLocalFile(); + REJECT_IF_INIT_PATH_FAILED(file, aPath, promise, +@@ -623,14 +629,15 @@ + file->HumanReadablePath().get())); + return; + } + + JSContext* cx = aGlobal.Context(); +- JS::Rooted rootedValue(cx, aValue); ++ JS::Rooted value(cx, aValue); + nsString string; +- if (!nsContentUtils::StringifyJSON(cx, aValue, string, +- UndefinedIsNullStringLiteral)) { ++ if (!JS_StringifyWithLengthHint(cx, &value, nullptr, ++ JS::NullHandleValue, AppendJSON, ++ &string, opts.mLengthHint)) { + JS::Rooted exn(cx, JS::UndefinedValue()); + if (JS_GetPendingException(cx, &exn)) { + JS_ClearPendingException(cx); + promise->MaybeReject(exn); + } else { +@@ -639,22 +646,29 @@ + "Could not serialize object to JSON"_ns)); + } + return; + } + +- DispatchAndResolve( ++ DispatchAndResolve( + state->mEventQueue, promise, + [file = std::move(file), string = std::move(string), +- opts = std::move(opts)]() -> Result { ++ opts = std::move(opts)]() -> Result { + nsAutoCString utf8Str; + if (!CopyUTF16toUTF8(string, utf8Str, fallible)) { + return Err(IOError( + NS_ERROR_OUT_OF_MEMORY, + "Failed to write to `%s': could not allocate buffer", + file->HumanReadablePath().get())); + } +- return WriteSync(file, AsBytes(Span(utf8Str)), opts); ++ ++ uint32_t size = ++ MOZ_TRY(WriteSync(file, AsBytes(Span(utf8Str)), opts)); ++ ++ dom::WriteJSONResult result; ++ result.mSize = size; ++ result.mJsonLength = static_cast(string.Length()); ++ return result; + }); + }); + } + + /* static */ +@@ -2840,10 +2854,20 @@ + + opts.mCompress = aOptions.mCompress; + return opts; + } + ++Result ++IOUtils::InternalWriteOpts::FromBinding(const WriteJSONOptions& aOptions) { ++ InternalWriteOpts opts = ++ MOZ_TRY(FromBinding(static_cast(aOptions))); ++ ++ opts.mLengthHint = aOptions.mLengthHint; ++ ++ return opts; ++} ++ + /* static */ + Result IOUtils::JsBuffer::Create( + IOUtils::BufferKind aBufferKind, size_t aCapacity) { + JsBuffer buffer(aBufferKind, aCapacity); + if (aCapacity != 0 && !buffer.mBuffer) { +diff --git a/xpcom/ioutils/tests/test_ioutils_read_write_json.html b/xpcom/ioutils/tests/test_ioutils_read_write_json.html +--- a/xpcom/ioutils/tests/test_ioutils_read_write_json.html ++++ b/xpcom/ioutils/tests/test_ioutils_read_write_json.html +@@ -140,10 +140,43 @@ + ); + + await cleanup(filename); + }); + ++ add_task(async function test_writeJSON_return() { ++ const filename = PathUtils.join(PathUtils.tempDir, "test_ioutils_writeJSON_return.tmp"); ++ ++ const obj = { emoji: "☕️ ⚧️ 😀 🖖🏿 🤠 🏳️‍🌈 🥠 🏴‍☠️ 🪐" }; ++ ++ const expectedJson = JSON.stringify(obj); ++ const size = new TextEncoder().encode(expectedJson).byteLength; ++ ++ { ++ const result = await IOUtils.writeJSON(filename, obj, { lengthHint: 0 }); ++ ++ is(await IOUtils.readUTF8(filename), expectedJson, "should have written expected JSON"); ++ ++ is(typeof result, "object", "writeJSON returns an object"); ++ ok(result !== null, "writeJSON returns non-null"); ++ ++ ok(Object.hasOwn(result, "size"), "result has size property"); ++ ok(Object.hasOwn(result, "jsonLength"), "result has jsonLength property"); ++ ++ is(result.size, size, "Should have written the expected number of bytes"); ++ is(result.jsonLength, expectedJson.length, "Should have written the expected number of UTF-16 codepoints"); ++ } ++ ++ { ++ const result = await IOUtils.writeJSON(filename, obj, { lengthHint: expectedJson.length, compress: true }); ++ ++ isnot(result.size, size, "Should have written a different number of bytes due to compression"); ++ is(result.jsonLength, expectedJson.length, "Should have written the same number of UTF-16 codepoints"); ++ } ++ ++ await cleanup(filename); ++ }); ++ + add_task(async function test_append_json() { + const filename = PathUtils.join(PathUtils.tempDir, "test_ioutils_append_json.tmp"); + + await IOUtils.writeJSON(filename, OBJECT); + + diff --git a/src/external-patches/firefox/session_store_use_size_hint/D247217.patch b/src/external-patches/firefox/session_store_use_size_hint/D247217.patch new file mode 100644 index 000000000..31ffe06f9 --- /dev/null +++ b/src/external-patches/firefox/session_store_use_size_hint/D247217.patch @@ -0,0 +1,279 @@ +diff --git a/browser/components/sessionstore/SessionFile.sys.mjs b/browser/components/sessionstore/SessionFile.sys.mjs +--- a/browser/components/sessionstore/SessionFile.sys.mjs ++++ b/browser/components/sessionstore/SessionFile.sys.mjs +@@ -503,10 +503,12 @@ + if (isFinalWrite) { + Services.obs.notifyObservers( + null, + "sessionstore-final-state-write-complete" + ); ++ ++ lazy.SessionWriter.deinit(); + } + }); + }, + + async wipe() { +diff --git a/browser/components/sessionstore/SessionWriter.sys.mjs b/browser/components/sessionstore/SessionWriter.sys.mjs +--- a/browser/components/sessionstore/SessionWriter.sys.mjs ++++ b/browser/components/sessionstore/SessionWriter.sys.mjs +@@ -6,10 +6,12 @@ + + ChromeUtils.defineESModuleGetters(lazy, { + sessionStoreLogger: "resource:///modules/sessionstore/SessionLogger.sys.mjs", + }); + ++const BROWSER_PURGE_SESSION_HISTORY = "browser:purge-session-history"; ++ + /** + * We just started (we haven't written anything to disk yet) from + * `Paths.clean`. The backup directory may not exist. + */ + const STATE_CLEAN = "clean"; +@@ -58,10 +60,14 @@ + export const SessionWriter = { + init(origin, useOldExtension, paths, prefs = {}) { + return SessionWriterInternal.init(origin, useOldExtension, paths, prefs); + }, + ++ deinit() { ++ return SessionWriterInternal.deinit(); ++ }, ++ + /** + * Write the contents of the session file. + * + * @param state - May get changed on shutdown. + */ +@@ -80,10 +86,17 @@ + return await SessionWriterInternal.wipe(); + } finally { + unlock(); + } + }, ++ ++ /** ++ * *Test Only* Return the SessionWriter's length hint for writing JSON. ++ */ ++ get _jsonLengthHint() { ++ return SessionWriterInternal.jsonLengthHint; ++ }, + }; + + const SessionWriterInternal = { + // Path to the files used by the SessionWriter + Paths: null, +@@ -104,10 +117,19 @@ + /** + * Number of old upgrade backups that are being kept + */ + maxUpgradeBackups: null, + ++ /** ++ * The size of the last write with IOUtils.writeJSON. ++ * ++ * Because SessionWriter writes such a large object graph we will otherwise ++ * spend a large portion of `write()` doing memory allocations and memcpy ++ * when serializing the session file to disk. ++ */ ++ jsonLengthHint: 0, ++ + /** + * Initialize (or reinitialize) the writer. + * + * @param {string} origin Which of sessionstore.js or its backups + * was used. One of the `STATE_*` constants defined above. +@@ -136,13 +158,20 @@ + this.Paths = paths; + this.maxUpgradeBackups = prefs.maxUpgradeBackups; + this.maxSerializeBack = prefs.maxSerializeBack; + this.maxSerializeForward = prefs.maxSerializeForward; + this.upgradeBackupNeeded = paths.nextUpgradeBackup != paths.upgradeBackup; ++ ++ Services.obs.addObserver(this, BROWSER_PURGE_SESSION_HISTORY); ++ + return { result: true }; + }, + ++ deinit() { ++ Services.obs.removeObserver(this, BROWSER_PURGE_SESSION_HISTORY); ++ }, ++ + /** + * Write the session to disk. + * Write the session to disk, performing any necessary backup + * along the way. + * +@@ -208,36 +237,42 @@ + // We are shutting down. At this stage, we know that + // $Paths.clean is either absent or corrupted. If it was + // originally present and valid, it has been moved to + // $Paths.cleanBackup a long time ago. We can therefore write + // with the guarantees that we erase no important data. +- await IOUtils.writeJSON(this.Paths.clean, state, { ++ const result = await IOUtils.writeJSON(this.Paths.clean, state, { + tmpPath: this.Paths.clean + ".tmp", + compress: true, ++ lengthHint: this.jsonLengthHint, + }); ++ this.jsonLengthHint = result.jsonLength; + fileStat = await IOUtils.stat(this.Paths.clean); + } else if (this.state == STATE_RECOVERY) { + // At this stage, either $Paths.recovery was written >= 15 + // seconds ago during this session or we have just started + // from $Paths.recovery left from the previous session. Either + // way, $Paths.recovery is good. We can move $Path.backup to + // $Path.recoveryBackup without erasing a good file with a bad + // file. +- await IOUtils.writeJSON(this.Paths.recovery, state, { ++ const result = await IOUtils.writeJSON(this.Paths.recovery, state, { + tmpPath: this.Paths.recovery + ".tmp", + backupFile: this.Paths.recoveryBackup, + compress: true, ++ lengthHint: this.jsonLengthHint, + }); ++ this.jsonLengthHint = result.jsonLength; + fileStat = await IOUtils.stat(this.Paths.recovery); + } else { + // In other cases, either $Path.recovery is not necessary, or + // it doesn't exist or it has been corrupted. Regardless, + // don't backup $Path.recovery. +- await IOUtils.writeJSON(this.Paths.recovery, state, { ++ const result = await IOUtils.writeJSON(this.Paths.recovery, state, { + tmpPath: this.Paths.recovery + ".tmp", + compress: true, ++ lengthHint: this.jsonLengthHint, + }); ++ this.jsonLengthHint = result.jsonLength; + fileStat = await IOUtils.stat(this.Paths.recovery); + } + + telemetry.writeFileMs = Date.now() - startWriteMs; + telemetry.fileSizeBytes = fileStat.size; +@@ -420,6 +455,18 @@ + + if (exn) { + throw exn; + } + }, ++ ++ observe(_subject, topic, _data) { ++ switch (topic) { ++ case BROWSER_PURGE_SESSION_HISTORY: ++ this._onPurgeSessionHistory(); ++ break; ++ } ++ }, ++ ++ _onPurgeSessionHistory() { ++ this.jsonLengthHint = 0; ++ }, + }; +diff --git a/browser/components/sessionstore/test/unit/test_write_json_length_hint.js b/browser/components/sessionstore/test/unit/test_write_json_length_hint.js +new file mode 100644 +--- /dev/null ++++ b/browser/components/sessionstore/test/unit/test_write_json_length_hint.js +@@ -0,0 +1,91 @@ ++/* Any copyright is dedicated to the Public Domain. ++ http://creativecommons.org/publicdomain/zero/1.0/ */ ++ ++"use strict"; ++ ++const { updateAppInfo } = ChromeUtils.importESModule( ++ "resource://testing-common/AppInfo.sys.mjs" ++); ++ ++const profile = do_get_profile(); ++ ++updateAppInfo({ ++ name: "SessionRestoreTest", ++ ID: "{230de50e-4cd1-11dc-8314-0800200c9a66}", ++ version: "1", ++ platformVersion: "", ++}); ++ ++const { SessionFile } = ChromeUtils.importESModule( ++ "resource:///modules/sessionstore/SessionFile.sys.mjs" ++); ++const { SessionWriter } = ChromeUtils.importESModule( ++ "resource:///modules/sessionstore/SessionWriter.sys.mjs" ++); ++ ++add_setup(async function setup() { ++ const source = do_get_file("data/sessionstore_valid.js"); ++ source.copyTo(profile, "sessionstore.js"); ++ ++ await writeCompressedFile( ++ SessionFile.Paths.clean.replace("jsonlz4", "js"), ++ SessionFile.Paths.clean ++ ); ++ ++ await SessionFile.read(); ++}); ++ ++add_task(async function test_json_length_hint() { ++ await IOUtils.writeJSON(PathUtils.join(PathUtils.profileDir, "dingus"), { ++ gunk: true, ++ }); ++ ++ Assert.equal( ++ SessionWriter._jsonLengthHint, ++ 0, ++ "SessionWriter length hint starts at 0" ++ ); ++ ++ await SessionFile.write({}); ++ ++ const lengthHint = SessionWriter._jsonLengthHint; ++ ++ Assert.equal( ++ SessionWriter._jsonLengthHint, ++ JSON.stringify({}).length, ++ "SessionWriter should cache length hint" ++ ); ++ ++ const contents = await IOUtils.readJSON( ++ PathUtils.join(do_get_cwd().path, "data", "sessionstore_complete.json") ++ ); ++ await SessionFile.write(contents); ++ ++ Assert.notEqual( ++ SessionWriter._jsonLengthHint, ++ lengthHint, ++ "SessionWriter length hint updated" ++ ); ++ ++ Assert.greater( ++ SessionWriter._jsonLengthHint, ++ lengthHint, ++ "SessionWriteLength hint is now larger" ++ ); ++ ++ Services.obs.notifyObservers(null, "browser:purge-session-history"); ++ ++ Assert.equal( ++ SessionWriter._jsonLengthHint, ++ 0, ++ "browser:purge-session-history notification cleans length hint" ++ ); ++ ++ await SessionFile.write(contents); ++ ++ Assert.notEqual( ++ SessionWriter._jsonLengthHint, ++ lengthHint, ++ "SessionWriter length hint updated" ++ ); ++}); +diff --git a/browser/components/sessionstore/test/unit/xpcshell.toml b/browser/components/sessionstore/test/unit/xpcshell.toml +--- a/browser/components/sessionstore/test/unit/xpcshell.toml ++++ b/browser/components/sessionstore/test/unit/xpcshell.toml +@@ -39,5 +39,7 @@ + skip-if = [ + "condprof", # Bug 1769154 + ] + + ["test_startup_session_async.js"] ++ ++["test_write_json_length_hint.js"] + diff --git a/src/external-patches/manifest.json b/src/external-patches/manifest.json index ff81da911..89232ce68 100644 --- a/src/external-patches/manifest.json +++ b/src/external-patches/manifest.json @@ -55,5 +55,14 @@ "type": "phabricator", "id": "D281762", "name": "FF150 3 PGO patch for bug-2014422" + }, + { + "type": "phabricator", + "ids": [ + "D247145", + "D247215", + "D247217" + ], + "name": "Session store use size hint" } ] diff --git a/src/toolkit/modules/JSONFile-sys-mjs.patch b/src/toolkit/modules/JSONFile-sys-mjs.patch new file mode 100644 index 000000000..ca854e039 --- /dev/null +++ b/src/toolkit/modules/JSONFile-sys-mjs.patch @@ -0,0 +1,29 @@ +diff --git a/toolkit/modules/JSONFile.sys.mjs b/toolkit/modules/JSONFile.sys.mjs +index 397991e4af8f49b6365d729fc11267b5c1113400..1955b7ff1d428e891f5ef066e7a4ac25aa5ec9b4 100644 +--- a/toolkit/modules/JSONFile.sys.mjs ++++ b/toolkit/modules/JSONFile.sys.mjs +@@ -132,6 +132,7 @@ export function JSONFile(config) { + this._finalizeInternalBound, + () => ({ sanitizedBasename: this.sanitizedBasename }) + ); ++ this._useSizeHints = config.useSizeHints ?? false; + } + + JSONFile.prototype = { +@@ -423,11 +424,15 @@ JSONFile.prototype = { + } + + try { +- await IOUtils.writeJSON( ++ if (this._useSizeHints && this._lastSavedSize) { ++ this._options.lengthHint = this._lastSavedSize; ++ } ++ const result = await IOUtils.writeJSON( + this.path, + this._data, + Object.assign({ tmpPath: this.path + ".tmp" }, this._options) + ); ++ this._lastSavedSize = this._useSizeHints ? result.jsonLength : null; + } catch (ex) { + if (typeof this._data.toJSONSafe == "function") { + // If serialization fails, try fallback safe JSON converter. diff --git a/src/zen/sessionstore/ZenSessionManager.sys.mjs b/src/zen/sessionstore/ZenSessionManager.sys.mjs index ac23ac37c..6ff9860c7 100644 --- a/src/zen/sessionstore/ZenSessionManager.sys.mjs +++ b/src/zen/sessionstore/ZenSessionManager.sys.mjs @@ -64,6 +64,10 @@ class nsZenSidebarObject { return Cu.cloneInto(this.#sidebar, {}); } + get dataWithoutCloning() { + return this.#sidebar; + } + set data(data) { if (typeof data !== "object") { throw new Error("Sidebar data must be an object"); @@ -100,6 +104,10 @@ export class nsZenSessionManager { path: this.#storeFilePath, compression: "lz4", backupTo, + useSizeHints: Services.prefs.getBoolPref( + "zen.session-store.use-size-hints", + true + ), }); this.log("Session file path:", this.#file.path); this.#deferredBackupTask = new lazy.DeferredTask(async () => { @@ -412,10 +420,10 @@ export class nsZenSessionManager { if ( this.#shouldRestoreOnlyPinned && !this.#shouldRestoreFromCrash && - this.#sidebar?.tabs + this.#sidebarWithoutCloning?.tabs ) { this.log("Restoring only pinned tabs into windows"); - const sidebar = this.#sidebar; + const sidebar = this.#sidebarWithoutCloning; sidebar.tabs = (sidebar.tabs || []).filter(tab => tab.pinned); this.#sidebar = sidebar; } @@ -449,6 +457,10 @@ export class nsZenSessionManager { return this.#sidebarObject.data; } + get #sidebarWithoutCloning() { + return this.#sidebarObject.dataWithoutCloning; + } + set #sidebar(data) { this.#sidebarObject.data = data; } @@ -590,7 +602,7 @@ export class nsZenSessionManager { ); this.#collectWindowData(windows); // This would save the data to disk asynchronously or when quitting the app. - let sidebar = this.#sidebar; + let sidebar = this.#sidebarWithoutCloning; this.#file.data = sidebar; if (soon) { this.#file.saveSoon(); @@ -897,7 +909,7 @@ export class nsZenSessionManager { onNewEmptySession(aWindow) { this.log("Restoring empty session with Zen session data"); aWindow.gZenWorkspaces.restoreWorkspacesFromSessionStore({ - spaces: this.#sidebar.spaces || [], + spaces: this.#sidebarWithoutCloning.spaces || [], }); } @@ -909,7 +921,7 @@ export class nsZenSessionManager { * @returns {Array} The cloned spaces data. */ getClonedSpaces() { - const sidebar = this.#sidebar; + const sidebar = this.#sidebarWithoutCloning; if (!sidebar || !sidebar.spaces) { return []; } From 29e7fe12a88021122bd85103b2115d68c28230a8 Mon Sep 17 00:00:00 2001 From: "mr. m" <91018726+mr-cheffy@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:25:10 +0200 Subject: [PATCH 12/16] gh-13264: Add tests for media player (gh-13262) --- src/zen/common/styles/zen-buttons.css | 4 + src/zen/tests/manifest.toml | 4 + src/zen/tests/media/browser.toml | 16 ++ src/zen/tests/media/browser_media_metadata.js | 63 +++++ src/zen/tests/media/browser_media_mute.js | 64 +++++ .../tests/media/browser_media_next_track.js | 74 +++++ .../browser_media_shows_on_tab_switch.js | 74 +++++ src/zen/tests/media/head.js | 96 +++++++ src/zen/tests/mochitests/moz.build | 1 + .../almostSilentAudioTrack.webm | Bin 0 -> 1699661 bytes .../mochitests/tabMediaIndicator/audio.ogg | Bin 0 -> 14293 bytes .../audioEndedDuringPlaying.webm | Bin 0 -> 109366 bytes .../mochitests/tabMediaIndicator/browser.toml | 44 +++ .../browser_destroy_iframe.js | 50 ++++ .../browser_mediaPlayback.js | 42 +++ .../browser_mediaPlayback_mute.js | 118 ++++++++ ...browser_mediaplayback_audibility_change.js | 258 ++++++++++++++++++ .../tabMediaIndicator/browser_mute.js | 19 ++ .../tabMediaIndicator/browser_mute2.js | 32 +++ .../browser_mute_webAudio.js | 72 +++++ .../browser_sound_indicator_silent_video.js | 95 +++++++ .../browser_webAudio_hideSoundPlayingIcon.js | 60 ++++ .../browser_webAudio_silentData.js | 57 ++++ .../browser_webaudio_audibility_change.js | 172 ++++++++++++ .../file_almostSilentAudioTrack.html | 18 ++ .../file_autoplay_media.html | 9 + .../tabMediaIndicator/file_empty.html | 8 + .../tabMediaIndicator/file_mediaPlayback.html | 9 + .../file_mediaPlayback2.html | 14 + .../file_mediaPlaybackFrame.html | 2 + .../file_mediaPlaybackFrame2.html | 2 + .../file_silentAudioTrack.html | 18 ++ .../tabMediaIndicator/file_webAudio.html | 29 ++ .../mochitests/tabMediaIndicator/gizmo.mp4 | Bin 0 -> 455255 bytes .../mochitests/tabMediaIndicator/head.js | 165 +++++++++++ .../mochitests/tabMediaIndicator/noaudio.webm | Bin 0 -> 105755 bytes .../tabMediaIndicator/silentAudioTrack.webm | Bin 0 -> 224800 bytes src/zen/tests/moz.build | 1 + 38 files changed, 1690 insertions(+) create mode 100644 src/zen/tests/media/browser.toml create mode 100644 src/zen/tests/media/browser_media_metadata.js create mode 100644 src/zen/tests/media/browser_media_mute.js create mode 100644 src/zen/tests/media/browser_media_next_track.js create mode 100644 src/zen/tests/media/browser_media_shows_on_tab_switch.js create mode 100644 src/zen/tests/media/head.js create mode 100644 src/zen/tests/mochitests/tabMediaIndicator/almostSilentAudioTrack.webm create mode 100644 src/zen/tests/mochitests/tabMediaIndicator/audio.ogg create mode 100644 src/zen/tests/mochitests/tabMediaIndicator/audioEndedDuringPlaying.webm create mode 100644 src/zen/tests/mochitests/tabMediaIndicator/browser.toml create mode 100644 src/zen/tests/mochitests/tabMediaIndicator/browser_destroy_iframe.js create mode 100644 src/zen/tests/mochitests/tabMediaIndicator/browser_mediaPlayback.js create mode 100644 src/zen/tests/mochitests/tabMediaIndicator/browser_mediaPlayback_mute.js create mode 100644 src/zen/tests/mochitests/tabMediaIndicator/browser_mediaplayback_audibility_change.js create mode 100644 src/zen/tests/mochitests/tabMediaIndicator/browser_mute.js create mode 100644 src/zen/tests/mochitests/tabMediaIndicator/browser_mute2.js create mode 100644 src/zen/tests/mochitests/tabMediaIndicator/browser_mute_webAudio.js create mode 100644 src/zen/tests/mochitests/tabMediaIndicator/browser_sound_indicator_silent_video.js create mode 100644 src/zen/tests/mochitests/tabMediaIndicator/browser_webAudio_hideSoundPlayingIcon.js create mode 100644 src/zen/tests/mochitests/tabMediaIndicator/browser_webAudio_silentData.js create mode 100644 src/zen/tests/mochitests/tabMediaIndicator/browser_webaudio_audibility_change.js create mode 100644 src/zen/tests/mochitests/tabMediaIndicator/file_almostSilentAudioTrack.html create mode 100644 src/zen/tests/mochitests/tabMediaIndicator/file_autoplay_media.html create mode 100644 src/zen/tests/mochitests/tabMediaIndicator/file_empty.html create mode 100644 src/zen/tests/mochitests/tabMediaIndicator/file_mediaPlayback.html create mode 100644 src/zen/tests/mochitests/tabMediaIndicator/file_mediaPlayback2.html create mode 100644 src/zen/tests/mochitests/tabMediaIndicator/file_mediaPlaybackFrame.html create mode 100644 src/zen/tests/mochitests/tabMediaIndicator/file_mediaPlaybackFrame2.html create mode 100644 src/zen/tests/mochitests/tabMediaIndicator/file_silentAudioTrack.html create mode 100644 src/zen/tests/mochitests/tabMediaIndicator/file_webAudio.html create mode 100644 src/zen/tests/mochitests/tabMediaIndicator/gizmo.mp4 create mode 100644 src/zen/tests/mochitests/tabMediaIndicator/head.js create mode 100644 src/zen/tests/mochitests/tabMediaIndicator/noaudio.webm create mode 100644 src/zen/tests/mochitests/tabMediaIndicator/silentAudioTrack.webm diff --git a/src/zen/common/styles/zen-buttons.css b/src/zen/common/styles/zen-buttons.css index d94c2c367..a3dfd404a 100644 --- a/src/zen/common/styles/zen-buttons.css +++ b/src/zen/common/styles/zen-buttons.css @@ -38,6 +38,10 @@ dialog[defaultButton="accept"]::part(dialog-button) { padding-inline-end: 4em; } + @media (-moz-platform: linux) { + padding-inline-end: 3.1em; + } + &::after { border-radius: 4px; font-weight: 600; diff --git a/src/zen/tests/manifest.toml b/src/zen/tests/manifest.toml index 60c0975dc..18334b2f8 100644 --- a/src/zen/tests/manifest.toml +++ b/src/zen/tests/manifest.toml @@ -25,5 +25,9 @@ disable = [ "browser_setDesktopBackgroundPreview.js", ] +[tabMediaIndicator] +source = "browser/components/tabbrowser/test/browser/tabMediaIndicator" +is_direct_path = true + [tooltiptext] source = "toolkit/components/tooltiptext" diff --git a/src/zen/tests/media/browser.toml b/src/zen/tests/media/browser.toml new file mode 100644 index 000000000..52752f239 --- /dev/null +++ b/src/zen/tests/media/browser.toml @@ -0,0 +1,16 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +[DEFAULT] +support-files = [ + "head.js", +] + +["browser_media_metadata.js"] + +["browser_media_mute.js"] + +["browser_media_next_track.js"] + +["browser_media_shows_on_tab_switch.js"] diff --git a/src/zen/tests/media/browser_media_metadata.js b/src/zen/tests/media/browser_media_metadata.js new file mode 100644 index 000000000..a048b8cac --- /dev/null +++ b/src/zen/tests/media/browser_media_metadata.js @@ -0,0 +1,63 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// User flow: +// 1. A page (think Spotify, YouTube Music) plays media and publishes +// title/artist via navigator.mediaSession.metadata. +// 2. User switches off that tab, media bar appears. +// 3. The title and artist labels in the bar show what the page published. +// 4. The page then updates the metadata mid-playback (next song starts). +// 5. The bar updates live, without the user having to switch tabs again. +// +// This is what makes the bar feel connected to the playing page instead of +// a generic "something is playing" indicator. + +add_task(async function test_media_bar_shows_metadata_from_page() { + const originalTab = gBrowser.selectedTab; + const mediaTab = await addMediaTab(); + await BrowserTestUtils.switchTab(gBrowser, mediaTab); + + try { + await setMediaSessionMetadata(mediaTab, { + title: "Sandstorm", + artist: "Darude", + }); + await playVideoIn(mediaTab); + await BrowserTestUtils.switchTab(gBrowser, originalTab); + await waitForMediaBarVisible(); + + const titleEl = document.getElementById("zen-media-title"); + const artistEl = document.getElementById("zen-media-artist"); + + await BrowserTestUtils.waitForCondition( + () => titleEl.textContent === "Sandstorm", + "title label reflects the page's mediaSession metadata" + ); + Assert.equal( + artistEl.textContent, + "Darude", + "artist label reflects the page's mediaSession metadata" + ); + + // Page updates metadata mid-playback. + await setMediaSessionMetadata(mediaTab, { + title: "Levels", + artist: "Avicii", + }); + await BrowserTestUtils.waitForCondition( + () => titleEl.textContent === "Levels", + "title updates live when the page changes its mediaSession metadata" + ); + Assert.equal( + artistEl.textContent, + "Avicii", + "artist updates live alongside the title" + ); + } finally { + await pauseVideoIn(mediaTab); + BrowserTestUtils.removeTab(mediaTab); + gBrowser.selectedTab = originalTab; + } +}); diff --git a/src/zen/tests/media/browser_media_mute.js b/src/zen/tests/media/browser_media_mute.js new file mode 100644 index 000000000..766059fd0 --- /dev/null +++ b/src/zen/tests/media/browser_media_mute.js @@ -0,0 +1,64 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// User flow: +// 1. User plays a video, switches tabs, media bar appears. +// 2. User clicks the mute button on the Zen media bar. +// 3. The underlying tab actually goes silent (browser.audioMuted flips). +// 4. The media bar reflects that with the `muted` attribute so the icon +// changes. +// 5. Clicking again unmutes. +// +// If this breaks, the user sees a mute button that looks toggled but the +// audio keeps playing — or worse, the tab is muted but the button still +// says "unmuted". + +add_task(async function test_mute_from_media_bar() { + const originalTab = gBrowser.selectedTab; + const mediaTab = await addMediaTab(); + await BrowserTestUtils.switchTab(gBrowser, mediaTab); + + try { + await playVideoIn(mediaTab); + await BrowserTestUtils.switchTab(gBrowser, originalTab); + await waitForMediaBarVisible(); + + ok( + !mediaTab.linkedBrowser.audioMuted, + "precondition: playing tab starts unmuted" + ); + ok( + !mediaBar().hasAttribute("muted"), + "precondition: media bar has no muted attribute" + ); + + clickMediaButton("zen-media-mute-button"); + await BrowserTestUtils.waitForCondition( + () => mediaTab.linkedBrowser.audioMuted, + "tab becomes muted after clicking the media bar mute button" + ); + ok( + mediaBar().hasAttribute("muted"), + "media bar reflects the muted state in its attribute" + ); + + clickMediaButton("zen-media-mute-button"); + await BrowserTestUtils.waitForCondition( + () => !mediaTab.linkedBrowser.audioMuted, + "clicking again unmutes the tab" + ); + ok( + !mediaBar().hasAttribute("muted"), + "media bar drops the muted attribute" + ); + } finally { + if (mediaTab.linkedBrowser.audioMuted) { + mediaTab.toggleMuteAudio(); + } + await pauseVideoIn(mediaTab); + BrowserTestUtils.removeTab(mediaTab); + gBrowser.selectedTab = originalTab; + } +}); diff --git a/src/zen/tests/media/browser_media_next_track.js b/src/zen/tests/media/browser_media_next_track.js new file mode 100644 index 000000000..f4797e7e0 --- /dev/null +++ b/src/zen/tests/media/browser_media_next_track.js @@ -0,0 +1,74 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// User flow: +// 1. A music page registers a "nexttrack" action handler (like most +// streaming sites do). +// 2. User is on another tab, media bar is showing with the next-track +// button enabled. +// 3. User clicks next-track. +// 4. The action fires inside the page — the page is responsible for +// loading the next song. Zen's job here is to relay the click. +// +// Also guards the button-enablement logic: if the page does NOT register a +// handler, the next-track button must be disabled. Otherwise clicks go +// nowhere and users think the bar is broken. + +add_task(async function test_next_track_relays_to_page() { + const originalTab = gBrowser.selectedTab; + const mediaTab = await addMediaTab(); + await BrowserTestUtils.switchTab(gBrowser, mediaTab); + + try { + await playVideoIn(mediaTab); + await setMediaSessionActionHandler(mediaTab, "nexttrack"); + + await BrowserTestUtils.switchTab(gBrowser, originalTab); + await waitForMediaBarVisible(); + + const nextButton = document.getElementById("zen-media-nexttrack-button"); + + // supportedkeyschange propagates asynchronously; wait for the bar's + // next-track button to become enabled before clicking. + await BrowserTestUtils.waitForCondition( + () => !nextButton.disabled, + "next-track button becomes enabled once the page registers a handler" + ); + + const actionFired = waitForMediaSessionAction(mediaTab); + clickMediaButton("zen-media-nexttrack-button"); + + const result = await actionFired; + ok(result, "page's nexttrack MediaSession handler was invoked"); + } finally { + await pauseVideoIn(mediaTab); + BrowserTestUtils.removeTab(mediaTab); + gBrowser.selectedTab = originalTab; + } +}); + +add_task(async function test_next_track_button_disabled_without_handler() { + const originalTab = gBrowser.selectedTab; + const mediaTab = await addMediaTab(); + await BrowserTestUtils.switchTab(gBrowser, mediaTab); + + try { + // Deliberately do NOT install a nexttrack handler. + await playVideoIn(mediaTab); + await BrowserTestUtils.switchTab(gBrowser, originalTab); + await waitForMediaBarVisible(); + + const nextButton = document.getElementById("zen-media-nexttrack-button"); + Assert.equal( + nextButton.disabled, + true, + "next-track button stays disabled when the page registers no handler" + ); + } finally { + await pauseVideoIn(mediaTab); + BrowserTestUtils.removeTab(mediaTab); + gBrowser.selectedTab = originalTab; + } +}); diff --git a/src/zen/tests/media/browser_media_shows_on_tab_switch.js b/src/zen/tests/media/browser_media_shows_on_tab_switch.js new file mode 100644 index 000000000..783a88af1 --- /dev/null +++ b/src/zen/tests/media/browser_media_shows_on_tab_switch.js @@ -0,0 +1,74 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// User flow: +// 1. User opens a page with audio and hits play. +// 2. User switches to a different tab. +// 3. The Zen media control bar should appear (so the user can still +// pause/skip without going back to the noisy tab). +// 4. User switches back to the audio tab. +// 5. The media bar should hide again — it's redundant next to the real +// page controls. +// +// This covers the real contract users see: the DOMAudioPlaybackStarted → +// TabSelect → showMediaControls chain in nsZenMediaController, plus the +// inverse path on selecting the playing tab. A regression anywhere in that +// chain (event wiring, the 500ms tab-switch debounce, the hidden attribute +// flip) surfaces as a bar that either never shows or never hides. + +// note: We keep setting timeouts because media player takes a bit to +// get removed (after the animation, more specifically) + +add_task(async function test_media_bar_shows_when_switching_off_playing_tab() { + gZenMediaController.onControllerClose(); + await BrowserTestUtils.waitForCondition( + () => !isMediaBarVisible(), + "media bar hides again once the playing tab regains focus" + ); + + const originalTab = gBrowser.selectedTab; + const mediaTab = await addMediaTab(); + await BrowserTestUtils.switchTab(gBrowser, mediaTab); + + ok( + !isMediaBarVisible(), + "media bar is hidden while the playing tab is the active tab" + ); + + try { + await playVideoIn(mediaTab); + + ok( + !isMediaBarVisible(), + "media bar remains hidden while focused on the playing tab" + ); + + // Switch away. The controller schedules showMediaControls() on a 500ms + // timer; wait for the visibility flip rather than racing it. + await BrowserTestUtils.switchTab(gBrowser, originalTab); + await new Promise(r => setTimeout(r, 1000)); + await BrowserTestUtils.waitForCondition( + isMediaBarVisible, + "media bar becomes visible after switching off the playing tab" + ); + + Assert.equal( + gZenMediaController._currentBrowser?.browserId, + mediaTab.linkedBrowser.browserId, + "media controller is bound to the media tab's browser, not the selected tab" + ); + + await BrowserTestUtils.switchTab(gBrowser, mediaTab); + await new Promise(r => setTimeout(r, 1000)); + await BrowserTestUtils.waitForCondition( + () => !isMediaBarVisible(), + "media bar hides again once the playing tab regains focus" + ); + } finally { + await pauseVideoIn(mediaTab); + BrowserTestUtils.removeTab(mediaTab); + gBrowser.selectedTab = originalTab; + } +}); diff --git a/src/zen/tests/media/head.js b/src/zen/tests/media/head.js new file mode 100644 index 000000000..82de52c6c --- /dev/null +++ b/src/zen/tests/media/head.js @@ -0,0 +1,96 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Shared mozilla-central fixture from the Picture-in-Picture tests: an HTML +// page with two looping