Compare commits

..

23 Commits

Author SHA1 Message Date
Nikola Hristov
8df6fef1ac gh-15115: Tests for urlbar close and split view (gh-15118)
Co-authored-by: mr. m <91018726+mr-cheffy@users.noreply.github.com>
2026-08-27 23:45:37 +02:00
mr. m
4746d36699 gh-15088: Prevent sync if engine is already applying (gh-15137) 2026-08-27 23:38:47 +02:00
Nikola Hristov
9e0c790602 gh-15134: Fixes tab removed from group getting focused (gh-15135)
Closes https://github.com/zen-browser/desktop/issues/15134

Fix video:

https://github.com/user-attachments/assets/1c06ea7f-d482-4d4a-8bbe-df41daa27616
2026-08-27 22:40:43 +02:00
mr. m
3d53b37e8b gh-15119: Update windows registry with old certificates (gh-15120) 2026-08-26 17:51:09 +02:00
Nikola Hristov
e7ddfb52db gh-15115: Fix creating empty split view with url bar open (gh-15116) 2026-08-26 13:16:13 +02:00
mr. m
ba70449bdd gh-15088: Add debug log for space sync (gh-15117) 2026-08-26 12:23:52 +02:00
mr. m
bf40c31149 gh-14710: Fixed minimizing PiP window failing to collapse into the sidebar (gh-15111) 2026-08-26 08:30:00 +02:00
mr. m
7a093beae4 gh-15084: Fixed incoming tab icons applying to active tabs (gh-15107) 2026-08-25 22:31:50 +02:00
mr. m
03b4e934fe gh-15091: Fixed race condition when syncing new space and container (gh-15106) 2026-08-25 22:21:19 +02:00
mr. m
b93d19b873 gh-15088: Fixed syncing essentials with different containers (gh-15105) 2026-08-25 22:05:54 +02:00
mr. m
b9c4000fe2 gh-15101: Sync upstream Firefox to version 154.0.1 (gh-15100) 2026-08-25 19:37:07 +02:00
mr. m
0b7687ec6d gh-14990: Prevent policyContainer from bloating the session file (gh-15098) 2026-08-25 13:41:10 +02:00
mr. m
d70b3d902c gh-15085: Import mochitests for remote settings (gh-15086) 2026-08-24 22:52:33 +02:00
mr. m
f9f8f2f372 gh-15075: Fixed syncing new tabs not using static labels (gh-15079)
Fixes https://github.com/zen-browser/desktop/issues/15075
2026-08-24 18:27:50 +02:00
mr. m
99dca42723 gh-14470: Prevent state override if url changed (gh-15070) 2026-08-23 16:40:30 +02:00
Henrik Nyh
ed0a6fd447 gh-15039: Use Title Case for split view labels (gh-15059) 2026-08-23 12:21:34 +02:00
CocoLng
88c6611f1e gh-15039: Fix New Blank Window menu entry not in Title Case (gh-15056) 2026-08-22 10:08:37 +02:00
mr. m
e66e763e63 gh-14470: Fixed wrong data image being used for sync (gh-15048) 2026-08-21 09:44:49 +02:00
mr. m
721135f576 gh-14470: Fixed moz-remote-urls not being synced correctly (gh-15043) 2026-08-21 01:24:25 +02:00
mr. m
d8a934d2ec gh-15029: Fixed urlbar suggestion context not working (gh-15037) 2026-08-20 21:57:35 +02:00
mr. m
91a1d4d2ca gh-14470: Run sweep animation on first sync (gh-15036) 2026-08-20 21:38:09 +02:00
mr. m
97577dac26 gh-15031: Fixed duplicate separator on tab context menu (gh-15035) 2026-08-20 21:18:13 +02:00
Kristijan Ribarić
5723a89bcd gh-14470: Add tab and folder sync support (gh-13984)
Co-authored-by: mr. m <91018726+mr-cheffy@users.noreply.github.com>
2026-08-20 19:16:13 +02:00
583 changed files with 84430 additions and 1007 deletions

2
.gitattributes vendored
View File

@@ -5,4 +5,4 @@
*.patch linguist-language=C++
*.d.ts linguist-language=TypeScript
src/zen/tests/*.js linguist-language=Test
src/zen/tests/** linguist-language=C++

View File

@@ -105,6 +105,8 @@ jobs:
SURFER_COMPAT: ${{ matrix.arch }}
SURFER_CERT_PATCH_ISSUER: ${{ secrets.SURFER_CERT_PATCH_ISSUER }}
SURFER_CERT_PATCH_NAME: ${{ secrets.SURFER_CERT_PATCH_NAME }}
SURFER_CERT_PATCH_NAME_PREV: ${{ secrets.SURFER_CERT_PATCH_NAME_PREV }}
SURFER_CERT_PATCH_ISSUER_PREV: ${{ secrets.SURFER_CERT_PATCH_ISSUER_PREV }}
run: |
. "$HOME/.cargo/env"
npm run import

View File

@@ -122,6 +122,8 @@ jobs:
SURFER_COMPAT: ${{ matrix.arch }}
SURFER_CERT_PATCH_ISSUER: ${{ secrets.SURFER_CERT_PATCH_ISSUER }}
SURFER_CERT_PATCH_NAME: ${{ secrets.SURFER_CERT_PATCH_NAME }}
SURFER_CERT_PATCH_NAME_PREV: ${{ secrets.SURFER_CERT_PATCH_NAME_PREV }}
SURFER_CERT_PATCH_ISSUER_PREV: ${{ secrets.SURFER_CERT_PATCH_ISSUER_PREV }}
SURFER_PLATFORM: darwin
run: |
. "$HOME/.cargo/env"

View File

@@ -166,6 +166,8 @@ jobs:
SURFER_COMPAT: ${{ matrix.arch }}
SURFER_CERT_PATCH_ISSUER: ${{ secrets.SURFER_CERT_PATCH_ISSUER }}
SURFER_CERT_PATCH_NAME: ${{ secrets.SURFER_CERT_PATCH_NAME }}
SURFER_CERT_PATCH_NAME_PREV: ${{ secrets.SURFER_CERT_PATCH_NAME_PREV }}
SURFER_CERT_PATCH_ISSUER_PREV: ${{ secrets.SURFER_CERT_PATCH_ISSUER_PREV }}
run: |
. "$HOME/.cargo/env"
npm run import -- --verbose

View File

@@ -34,8 +34,8 @@ Zen is a firefox-based browser with the aim of pushing your productivity to a ne
### Firefox Versions
- [`Release`](https://zen-browser.app/download) - Is currently built using Firefox version `154.0`! 🚀
- [`Twilight`](https://zen-browser.app/download?twilight) - Is currently built using Firefox version `RC 154.0`!
- [`Release`](https://zen-browser.app/download) - Is currently built using Firefox version `154.0.1`!
- [`Twilight`](https://zen-browser.app/download?twilight) - Is currently built using Firefox version `RC 154.0.1`!
### Contributing

View File

@@ -1 +1 @@
1fb0703fa9723fd9c400ad60d18e64ee4f9bf125
bf4b57fd7fe94054457be7018e8e7beaf754c520

View File

@@ -3,8 +3,6 @@
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
param(
[string][Parameter(Mandatory=$true)]$SignIdentity,
[string][Parameter(Mandatory=$true)]$SignIdentityIssuer,
[string][Parameter(Mandatory=$true)]$GithubRunId
)
@@ -24,11 +22,15 @@ mkdir windsign-temp -ErrorAction SilentlyContinue
# echo "Downloaded git objects repo to"
#} -Verbose -ArgumentList $PWD -Debug
$token = gh auth token
$env:SURFER_MOZCONFIG_ONLY="1"
$env:SURFER_SIGNING_MODE=""
$env:SURFER_CERT_PATCH_ISSUER=$SignIdentityIssuer
$env:SURFER_CERT_PATCH_NAME=$SignIdentity
get-content "$PSScriptRoot/../.env" | foreach {
$name, $value = $_.split('=')
set-content env:\$name $value
}
Start-Job -Name "DownloadGitl10n" -ScriptBlock {
param($PWD)
@@ -46,7 +48,6 @@ npm run build
echo "Downloading artifacts info"
$artifactsInfo=gh api repos/zen-browser/desktop/actions/runs/$GithubRunId/artifacts
$token = gh auth token
function New-TemporaryDirectory {
$tmp = [System.IO.Path]::GetTempPath() # Not $env:TEMP, see https://stackoverflow.com/a/946017

View File

@@ -164,5 +164,5 @@ zen-window-sync-migration-dialog-learn-more = Learn More
zen-window-sync-migration-dialog-accept = Got It
zen-appmenu-new-blank-window =
.label = New blank window
.label = New Blank Window

View File

@@ -5,14 +5,14 @@
tab-zen-split-tabs =
.label =
{ $tabCount ->
[-1] Split out tab
[1] Add split view...
[-1] Split Out Tab
[1] Add Split View...
*[other] Join { $tabCount } Tabs
}
.accesskey = S
zen-split-link =
.label = Split link to new tab
.label = Split Link to New Tab
.accesskey = S
zen-split-view-modifier-header = Split View

View File

@@ -86,6 +86,8 @@ zen-workspace-default-profile = Default
zen-workspaces-delete-workspace-title = Delete Space?
zen-workspaces-delete-workspace-body = Are you sure you want to delete { $name }? This action cannot be undone.
zen-workspaces-remote-delete-title = Delete Synced Space?
zen-workspaces-remote-delete-body = { $name } was deleted on another device. Delete it here too? Keeping it will restore it on your other devices.
# Note that the html tag MUST not be changed or removed, as it is used to better
# display the shortcut in the toast notification.

8
package-lock.json generated
View File

@@ -11,7 +11,7 @@
"devDependencies": {
"@babel/preset-typescript": "^7.27.0",
"@zen-browser/prettier": "^3.9.3",
"@zen-browser/surfer": "^1.14.7",
"@zen-browser/surfer": "^1.14.8",
"formal-git": "^1.2.9",
"globals": "^16.3.0",
"husky": "^9.1.7",
@@ -860,9 +860,9 @@
}
},
"node_modules/@zen-browser/surfer": {
"version": "1.14.7",
"resolved": "https://registry.npmjs.org/@zen-browser/surfer/-/surfer-1.14.7.tgz",
"integrity": "sha512-Ui2Chqvr4/+FmHM54eKOv7cQu8DRAK/uCX9o1GNi81hszxo/RSQwaw71eNW43Qcj7giK4U4x0IdCMht98iQ0rA==",
"version": "1.14.8",
"resolved": "https://registry.npmjs.org/@zen-browser/surfer/-/surfer-1.14.8.tgz",
"integrity": "sha512-5HHcfibwHIK5n3huhJL2TLBX2+8odj6ebCMA6U2EcUj9ROSrcz7/bjY2n5MIa9oIRL9gtV3ott0jwJj6SlapxA==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {

View File

@@ -49,7 +49,7 @@
"devDependencies": {
"@babel/preset-typescript": "^7.27.0",
"@zen-browser/prettier": "^3.9.3",
"@zen-browser/surfer": "^1.14.7",
"@zen-browser/surfer": "^1.14.8",
"formal-git": "^1.2.9",
"globals": "^16.3.0",
"husky": "^9.1.7",

View File

@@ -10,3 +10,6 @@
value: false
locked: true
condition: "!@IS_TWILIGHT@"
- name: zen.spaces-sync.debug
value: false

View File

@@ -8,4 +8,4 @@ packaging==24.2
pathspec==0.12.1
platformdirs==4.3.6
pycodestyle==2.12.1
requests==2.33.0
requests==2.34.2

View File

@@ -1,8 +1,8 @@
diff --git a/.prettierignore b/.prettierignore
index 949896ff064ae0b54b6a657ea074bc88e12820f7..5249f420972667bece4d85fe8d35073afaebeb8a 100644
index bd8ab46ff7175f5039b69b04c1afe53ab8efe746..7ae12a1ff3724e99aeec2e93518640a2fa9611e6 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1803,3 +1803,12 @@ tools/ts/test/baselines/
@@ -1807,3 +1807,12 @@ tools/ts/test/baselines/
try_task_config.json
xpcom/idl-parser/xpidl/fixtures/xpctest.d.json
**/package-lock.json
@@ -12,6 +12,6 @@ index 949896ff064ae0b54b6a657ea074bc88e12820f7..5249f420972667bece4d85fe8d35073a
+*.min.js
+*.min.mjs
+*.inc
+*/mochitests/*
+**/mochitests/**
+*.svg
+

View File

@@ -1,8 +1,8 @@
diff --git a/browser/components/preferences/preferences.js b/browser/components/preferences/preferences.js
index c76a0647e14bac806d9428483bb565b3e10c31e7..2188f7808aca090f92574c7f5c55a28a9c5bb3f8 100644
index a76699af75252bec31e17b562463c37236cbbfe5..6355a6ee92754846ffcdd123ac49545d520470c7 100644
--- a/browser/components/preferences/preferences.js
+++ b/browser/components/preferences/preferences.js
@@ -131,6 +131,7 @@ ChromeUtils.defineLazyGetter(this, "gSubDialog", function () {
@@ -132,6 +132,7 @@ ChromeUtils.defineLazyGetter(this, "gSubDialog", function () {
styleSheets: [
"chrome://browser/skin/preferences/dialog.css",
"chrome://browser/skin/preferences/preferences.css",
@@ -10,7 +10,7 @@ index c76a0647e14bac806d9428483bb565b3e10c31e7..2188f7808aca090f92574c7f5c55a28a
],
resizeCallback: async ({ title, frame }) => {
// Search within main document and highlight matched keyword.
@@ -437,6 +438,8 @@ const CONFIG_PANES = Object.freeze({
@@ -462,6 +463,8 @@ const CONFIG_PANES = Object.freeze({
tabsBrowsing: {
l10nId: "tabs-browsing-section",
groupIds: [
@@ -19,7 +19,7 @@ index c76a0647e14bac806d9428483bb565b3e10c31e7..2188f7808aca090f92574c7f5c55a28a
"browserLayout",
"tabs",
"pageNavigation",
@@ -483,7 +486,7 @@ function register_module(categoryName, categoryObject) {
@@ -509,7 +512,7 @@ function register_module(categoryName, categoryObject) {
}
this._initted = true;
let template = document.getElementById("template-" + categoryName);
@@ -28,7 +28,7 @@ index c76a0647e14bac806d9428483bb565b3e10c31e7..2188f7808aca090f92574c7f5c55a28a
// Replace the template element with the nodes inside of it.
template.replaceWith(template.content);
@@ -527,6 +530,10 @@ function init_all() {
@@ -553,6 +556,10 @@ function init_all() {
register_module("paneHome", gHomePane);
register_module("paneSearch", gSearchPane);
register_module("panePrivacy", gPrivacyPane);
@@ -39,15 +39,3 @@ index c76a0647e14bac806d9428483bb565b3e10c31e7..2188f7808aca090f92574c7f5c55a28a
// Restore the cached Firefox Labs nav button visibility so it shows
// immediately when recipes are expected to be available, before
@@ -656,9 +663,9 @@ async function gotoPref(
let redesignEnabled = srdSectionPrefs.all;
let categories = document.getElementById("categories");
const kDefaultCategoryInternalName = redesignEnabled
- ? "paneSync"
+ ? "paneTabsBrowsing"
: "paneGeneral";
- const kDefaultCategory = redesignEnabled ? "sync" : "general";
+ const kDefaultCategory = redesignEnabled ? "tabsBrowsing" : "general";
let hash = document.location.hash;
let category = aCategory || hash.substring(1) || kDefaultCategoryInternalName;

View File

@@ -0,0 +1,15 @@
diff --git a/browser/components/preferences/widgets/sync-engine-list/sync-engines-list.mjs b/browser/components/preferences/widgets/sync-engine-list/sync-engines-list.mjs
index 9359a485ed6212c7df49a8d4b1e4a8febbc1c9b6..132fea440cfa535e78b35f706e7ef2d6881f9ba6 100644
--- a/browser/components/preferences/widgets/sync-engine-list/sync-engines-list.mjs
+++ b/browser/components/preferences/widgets/sync-engine-list/sync-engines-list.mjs
@@ -50,6 +50,10 @@ const engineTypeToMetadata = {
iconSrc: "chrome://global/skin/icons/settings.svg",
l10nId: "sync-currently-syncing-settings",
},
+ workspaces: {
+ iconSrc: "chrome://devtools/skin/images/tool-storage.svg",
+ l10nId: "sync-currently-syncing-workspaces",
+ },
};
/**

View File

@@ -1,18 +1,20 @@
diff --git a/browser/components/sessionstore/SessionStore.sys.mjs b/browser/components/sessionstore/SessionStore.sys.mjs
index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee4aa3be28 100644
index 536e06eec9f35bad3b8366733b23b5a8697df9c8..631a16bc7518108641993836f0c52e2ab6e6c967 100644
--- a/browser/components/sessionstore/SessionStore.sys.mjs
+++ b/browser/components/sessionstore/SessionStore.sys.mjs
@@ -129,6 +129,9 @@ const TAB_EVENTS = [
@@ -129,6 +129,11 @@ const TAB_EVENTS = [
"TabSplitViewActivate",
"SplitViewRemoved",
"SplitViewCreated",
+ "TabAddedToEssentials",
+ "TabRemovedFromEssentials",
+ "TabMove",
+ "TabGroupMoved",
+ "ZenWorkspaceDataChanged",
];
const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
@@ -201,6 +204,8 @@ ChromeUtils.defineESModuleGetters(lazy, {
@@ -201,6 +206,8 @@ ChromeUtils.defineESModuleGetters(lazy, {
TabStateCache: "resource:///modules/sessionstore/TabStateCache.sys.mjs",
TabStateFlusher: "resource:///modules/sessionstore/TabStateFlusher.sys.mjs",
setTimeout: "resource://gre/modules/Timer.sys.mjs",
@@ -21,7 +23,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee
});
ChromeUtils.defineLazyGetter(lazy, "blankURI", () => {
@@ -1300,10 +1305,7 @@ var SessionStoreInternal = {
@@ -1317,10 +1324,7 @@ var SessionStoreInternal = {
*/
get willAutoRestore() {
return (
@@ -33,17 +35,19 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee
);
},
@@ -1990,6 +1992,9 @@ var SessionStoreInternal = {
@@ -2008,6 +2012,11 @@ var SessionStoreInternal = {
case "TabPinned":
case "TabUnpinned":
case "SwapDocShells":
+ case "TabRemovedFromEssentials":
+ case "TabAddedToEssentials":
+ case "TabMove":
+ case "TabGroupMoved":
+ case "ZenWorkspaceDataChanged":
this.saveStateDelayed(win);
break;
case "TabGroupCreate":
@@ -2099,6 +2104,10 @@ var SessionStoreInternal = {
@@ -2117,6 +2126,10 @@ var SessionStoreInternal = {
this._windows[aWindow.__SSi].isTaskbarTab = true;
}
@@ -54,7 +58,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee
if (lazy.AIWindow.isAIWindowActiveAndEnabled(aWindow)) {
this._windows[aWindow.__SSi].isAIWindow = true;
}
@@ -2135,7 +2144,7 @@ var SessionStoreInternal = {
@@ -2153,7 +2166,7 @@ var SessionStoreInternal = {
let isTaskbarTab = this._windows[aWindow.__SSi].isTaskbarTab;
// A regular window is not a private window, taskbar tab window, or popup window
let isRegularWindow =
@@ -63,7 +67,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee
// perform additional initialization when the first window is loading
if (lazy.RunState.isStopped) {
@@ -2147,7 +2156,7 @@ var SessionStoreInternal = {
@@ -2165,7 +2178,7 @@ var SessionStoreInternal = {
// to disk to NOW() to enforce a full interval before the next write.
lazy.SessionSaver.updateLastSaveTime();
@@ -72,7 +76,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee
this._log.debug(
"initializeWindow, the window is private or a web app. Saving SessionStartup.state for possibly restoring later"
);
@@ -2191,6 +2200,7 @@ var SessionStoreInternal = {
@@ -2209,6 +2222,7 @@ var SessionStoreInternal = {
null,
"sessionstore-one-or-no-tab-restored"
);
@@ -80,7 +84,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee
this._deferredAllWindowsRestored.resolve();
}
// this window was opened by _openWindowWithState
@@ -2240,7 +2250,6 @@ var SessionStoreInternal = {
@@ -2258,7 +2272,6 @@ var SessionStoreInternal = {
if (closedWindowState) {
let newWindowState;
if (
@@ -88,7 +92,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee
!lazy.SessionStartup.willRestore()
) {
// We want to split the window up into pinned tabs and unpinned tabs.
@@ -2276,6 +2285,7 @@ var SessionStoreInternal = {
@@ -2294,6 +2307,7 @@ var SessionStoreInternal = {
}
if (newWindowState) {
@@ -96,7 +100,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee
// Ensure that the window state isn't hidden
this._restoreCount = 1;
let state = { windows: [newWindowState] };
@@ -2312,6 +2322,9 @@ var SessionStoreInternal = {
@@ -2330,6 +2344,9 @@ var SessionStoreInternal = {
});
this._shouldRestoreLastSession = false;
}
@@ -106,7 +110,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee
if (this._restoreLastWindow && aWindow.toolbar.visible) {
// always reset (if not a popup window)
@@ -2462,7 +2475,7 @@ var SessionStoreInternal = {
@@ -2480,7 +2497,7 @@ var SessionStoreInternal = {
var tabbrowser = aWindow.gBrowser;
@@ -115,7 +119,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee
TAB_EVENTS.forEach(function (aEvent) {
tabbrowser.tabContainer.removeEventListener(aEvent, this, true);
@@ -2513,7 +2526,7 @@ var SessionStoreInternal = {
@@ -2531,7 +2548,7 @@ var SessionStoreInternal = {
let isLastRegularWindow =
Object.values(this._windows).filter(
@@ -124,7 +128,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee
).length == 1;
this._log.debug(
`onClose, closing window isLastRegularWindow? ${isLastRegularWindow}`
@@ -2570,8 +2583,8 @@ var SessionStoreInternal = {
@@ -2588,8 +2605,8 @@ var SessionStoreInternal = {
// 2) Flush the window.
// 3) When the flush is complete, revisit our decision to store the window
// in _closedWindows, and add/remove as necessary.
@@ -135,7 +139,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee
}
completionPromise = lazy.TabStateFlusher.flushWindow(aWindow).then(() => {
@@ -2591,8 +2604,9 @@ var SessionStoreInternal = {
@@ -2609,8 +2626,9 @@ var SessionStoreInternal = {
// Save non-private windows if they have at
// least one saveable tab or are the last window.
@@ -147,7 +151,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee
if (!isLastWindow && winData.closedId > -1) {
this._addClosedAction(
@@ -2668,7 +2682,7 @@ var SessionStoreInternal = {
@@ -2686,7 +2704,7 @@ var SessionStoreInternal = {
* to call this method again asynchronously (for example, after
* a window flush).
*/
@@ -156,7 +160,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee
// Make sure SessionStore is still running, and make sure that we
// haven't chosen to forget this window.
if (
@@ -2685,6 +2699,7 @@ var SessionStoreInternal = {
@@ -2703,6 +2721,7 @@ var SessionStoreInternal = {
// _closedWindows from a previous call to this function.
let winIndex = this._closedWindows.indexOf(winData);
let alreadyStored = winIndex != -1;
@@ -164,7 +168,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee
// If sidebar command is truthy, i.e. sidebar is open, store sidebar settings
let shouldStore = hasSaveableTabs || isLastWindow;
@@ -3507,7 +3522,7 @@ var SessionStoreInternal = {
@@ -3525,7 +3544,7 @@ var SessionStoreInternal = {
if (!isPrivateWindow && tabState.isPrivate) {
return;
}
@@ -173,7 +177,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee
return;
}
@@ -4246,6 +4261,12 @@ var SessionStoreInternal = {
@@ -4264,6 +4283,12 @@ var SessionStoreInternal = {
Math.min(tabState.index, tabState.entries.length)
);
tabState.pinned = false;
@@ -186,7 +190,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee
if (inBackground === false) {
aWindow.gBrowser.selectedTab = newTab;
@@ -4679,6 +4700,8 @@ var SessionStoreInternal = {
@@ -4697,6 +4722,8 @@ var SessionStoreInternal = {
// Append the tab if we're opening into a different window,
tabIndex: aSource == aTargetWindow ? pos : Infinity,
pinned: state.pinned,
@@ -195,7 +199,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee
userContextId: state.userContextId,
skipLoad: true,
preferredRemoteType,
@@ -5176,9 +5199,10 @@ var SessionStoreInternal = {
@@ -5194,9 +5221,10 @@ var SessionStoreInternal = {
if (activePageData.title && activePageData.title != activePageData.url) {
win.gBrowser.setInitialTabTitle(tab, activePageData.title, {
isContentTitle: true,
@@ -207,7 +211,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee
}
}
@@ -5543,7 +5567,7 @@ var SessionStoreInternal = {
@@ -5561,7 +5589,7 @@ var SessionStoreInternal = {
for (let i = tabbrowser.pinnedTabCount; i < tabbrowser.tabs.length; i++) {
let tab = tabbrowser.tabs[i];
@@ -216,7 +220,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee
removableTabs.push(tab);
}
}
@@ -5656,7 +5680,7 @@ var SessionStoreInternal = {
@@ -5674,7 +5702,7 @@ var SessionStoreInternal = {
// collect the data for all windows
for (ix in this._windows) {
@@ -225,7 +229,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee
// window data is still in _statesToRestore
continue;
}
@@ -5800,11 +5824,12 @@ var SessionStoreInternal = {
@@ -5818,11 +5846,12 @@ var SessionStoreInternal = {
}
let tabbrowser = aWindow.gBrowser;
@@ -239,7 +243,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee
// update the internal state data for this window
for (let tab of tabs) {
if (tab == aWindow.FirefoxViewHandler.tab) {
@@ -5815,6 +5840,9 @@ var SessionStoreInternal = {
@@ -5833,6 +5862,9 @@ var SessionStoreInternal = {
tabsData.push(tabData);
}
@@ -249,7 +253,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee
// update tab group state for this window
winData.groups = [];
for (let tabGroup of aWindow.gBrowser.tabGroups) {
@@ -5831,7 +5859,7 @@ var SessionStoreInternal = {
@@ -5849,7 +5881,7 @@ var SessionStoreInternal = {
// a window is closed, point to the first item in the tab strip instead (it will never be the Firefox View tab,
// since it's only inserted into the tab strip after it's selected).
if (aWindow.FirefoxViewHandler.tab?.selected) {
@@ -258,7 +262,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee
winData.title = tabbrowser.tabs[0].label;
}
winData.selected = selectedIndex;
@@ -6033,8 +6061,8 @@ var SessionStoreInternal = {
@@ -6051,8 +6083,8 @@ var SessionStoreInternal = {
// selectTab represents.
let selectTab = 0;
if (overwriteTabs) {
@@ -269,7 +273,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee
selectTab = Math.min(selectTab, winData.tabs.length);
}
@@ -6056,6 +6084,7 @@ var SessionStoreInternal = {
@@ -6074,6 +6106,7 @@ var SessionStoreInternal = {
if (overwriteTabs) {
for (let i = tabbrowser.browsers.length - 1; i >= 0; i--) {
if (!tabbrowser.tabs[i].selected) {
@@ -277,7 +281,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee
tabbrowser.removeTab(tabbrowser.tabs[i]);
}
}
@@ -6090,6 +6119,12 @@ var SessionStoreInternal = {
@@ -6108,6 +6141,12 @@ var SessionStoreInternal = {
savedTabGroup => !openTabGroupIdsInWindow.has(savedTabGroup.id)
);
}
@@ -290,7 +294,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee
// Move the originally open tabs to the end.
if (initialTabs) {
@@ -6656,6 +6691,25 @@ var SessionStoreInternal = {
@@ -6674,6 +6713,25 @@ var SessionStoreInternal = {
// Most of tabData has been restored, now continue with restoring
// attributes that may trigger external events.
@@ -316,7 +320,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee
if (tabData.pinned) {
tabbrowser.pinTab(tab);
@@ -6838,6 +6892,9 @@ var SessionStoreInternal = {
@@ -6859,6 +6917,9 @@ var SessionStoreInternal = {
aWindow.gURLBar.readOnly = false;
}
}
@@ -326,7 +330,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee
let promiseParts = Promise.withResolvers();
aWindow.setTimeout(() => {
@@ -7633,7 +7690,7 @@ var SessionStoreInternal = {
@@ -7654,7 +7715,7 @@ var SessionStoreInternal = {
let groupsToSave = new Map();
for (let tIndex = 0; tIndex < window.tabs.length; ) {
@@ -335,7 +339,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee
// Adjust window.selected
if (tIndex + 1 < window.selected) {
window.selected -= 1;
@@ -7648,7 +7705,7 @@ var SessionStoreInternal = {
@@ -7669,7 +7730,7 @@ var SessionStoreInternal = {
);
// We don't want to increment tIndex here.
continue;
@@ -344,7 +348,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee
// Convert any open groups into saved groups.
let groupStateToSave = window.groups.find(
groupState => groupState.id == window.tabs[tIndex].groupId
@@ -8193,7 +8250,6 @@ var SessionStoreInternal = {
@@ -8233,7 +8294,6 @@ var SessionStoreInternal = {
timer.initWithCallback(
function () {
if (beats <= 0) {
@@ -352,7 +356,7 @@ index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee
Glean.sessionRestore.shutdownFlushAllOutcomes.timed_out.add(1);
deferred.resolve();
}
@@ -8671,6 +8727,7 @@ var SessionStoreInternal = {
@@ -8711,6 +8771,7 @@ var SessionStoreInternal = {
if (
!savedTabGroupState.tabs.length ||
this.getSavedTabGroup(savedTabGroupState.id)

View File

@@ -1,8 +1,8 @@
diff --git a/browser/components/tabbrowser/content/tab-context-menu.js b/browser/components/tabbrowser/content/tab-context-menu.js
index 4a7cb33a4d9067d469bbe09d4cd8844581642f7a..2e2404b78deb428e57d01b8b929231f675a21323 100644
index 4a7cb33a4d9067d469bbe09d4cd8844581642f7a..d7f75471a207cccac6ca3dec49037ac30dd76163 100644
--- a/browser/components/tabbrowser/content/tab-context-menu.js
+++ b/browser/components/tabbrowser/content/tab-context-menu.js
@@ -839,7 +839,8 @@ var TabContextMenu = {
@@ -839,13 +839,15 @@ var TabContextMenu = {
);
contextUnpinSelectedTabs.hidden =
!this.contextTab.pinned || !this.multiselected;
@@ -12,3 +12,10 @@ index 4a7cb33a4d9067d469bbe09d4cd8844581642f7a..2e2404b78deb428e57d01b8b929231f6
// Build Ask Chat items. The classic layout shows the full ask-chat submenu
// (#context_askChat); the alt layout shows only a flat "Summarize Page" item
// (#context_askChatSummarize). Hide whichever one this layout doesn't use so
// a pref toggle reverses cleanly.
if (!this._altTabContextMenu) {
document.getElementById("context_askChatSummarize").hidden = true;
+ document.getElementById("context_aiSeparator").hidden = true;
TabContextMenu.GenAI.buildTabMenu(
document.getElementById("context_askChat"),
this

View File

@@ -1,8 +1,18 @@
diff --git a/browser/components/urlbar/UrlbarPrefs.sys.mjs b/browser/components/urlbar/UrlbarPrefs.sys.mjs
index 2d21248256c6c2bfb8dac958133c10e3251ef564..f788bd10ec2c08e4b27b77cd3bb0489fb04e8b7a 100644
index 89f0d44d462de3d7d0f581d6371606f475443822..0d45ce5f6455e9c7276c54f504906bade5b3d286 100644
--- a/browser/components/urlbar/UrlbarPrefs.sys.mjs
+++ b/browser/components/urlbar/UrlbarPrefs.sys.mjs
@@ -462,6 +462,7 @@ const PREF_URLBAR_DEFAULTS = /** @type {PreferenceDefinition[]} */ ([
@@ -163,6 +163,9 @@ const PREF_URLBAR_DEFAULTS = /** @type {PreferenceDefinition[]} */ ([
// Feature gate pref for flight status suggestions in the urlbar.
["flightStatus.featureGate", false],
+ // Whether to close the urlbar when blurring the window while the urlbar is focused.
+ ["closeOnWindowBlur", true],
+
// The minimum prefix length of a flight status keyword the user must type to
// trigger the suggestion. 0 means the min length should be taken from Nimbus
// or remote settings.
@@ -482,6 +485,7 @@ const PREF_URLBAR_DEFAULTS = /** @type {PreferenceDefinition[]} */ ([
["shortcuts.tabs", true],
["shortcuts.history", true],
["shortcuts.actions", true],
@@ -10,7 +20,7 @@ index 2d21248256c6c2bfb8dac958133c10e3251ef564..f788bd10ec2c08e4b27b77cd3bb0489f
// Boolean to determine if the providers defined in `exposureResults`
// should be displayed in search results. This can be set by a
@@ -799,6 +800,8 @@ function makeDefaultResultGroups({ showSearchSuggestionsFirst }) {
@@ -840,6 +844,8 @@ function makeDefaultResultGroups({
*/
let rootGroup = {
children: [

View File

@@ -1,5 +1,5 @@
diff --git a/browser/components/urlbar/content/UrlbarInput.mjs b/browser/components/urlbar/content/UrlbarInput.mjs
index e7e9495e56076cd112beafb8297dece4ae4cea58..330104d245ddd0956b95377082a6182579cbaf4d 100644
index e7e9495e56076cd112beafb8297dece4ae4cea58..95e4333866a5f0d41fbdbe23910c396406a247e9 100644
--- a/browser/components/urlbar/content/UrlbarInput.mjs
+++ b/browser/components/urlbar/content/UrlbarInput.mjs
@@ -101,6 +101,13 @@ const lazy = XPCOMUtils.declareLazy({
@@ -280,7 +280,20 @@ index e7e9495e56076cd112beafb8297dece4ae4cea58..330104d245ddd0956b95377082a61825
l10nId,
l10nId == "urlbar-placeholder-with-name"
? { name: engineName }
@@ -5424,6 +5540,11 @@ ${
@@ -5347,6 +5463,12 @@ ${
}
lazy.logger.debug("Blur Event");
+ if (
+ this.document.commandDispatcher.focusedElement == this.inputField &&
+ !lazy.UrlbarPrefs.get("closeOnWindowBlur")
+ ) {
+ return;
+ }
// We cannot count every blur events after a missed engagement as abandoment
// because the user may have clicked on some view element that executes
// a command causing a focus change. For example opening preferences from
@@ -5424,6 +5546,11 @@ ${
}
_on_click(event) {
@@ -292,7 +305,7 @@ index e7e9495e56076cd112beafb8297dece4ae4cea58..330104d245ddd0956b95377082a61825
switch (event.target) {
case this.inputField:
case this._inputContainer:
@@ -5513,10 +5634,11 @@ ${
@@ -5513,10 +5640,11 @@ ${
}
if (untrim) {
this.setValue(this._untrimmedValue);
@@ -305,7 +318,7 @@ index e7e9495e56076cd112beafb8297dece4ae4cea58..330104d245ddd0956b95377082a61825
this.view.autoOpen({ event });
} else {
if (this._untrimOnFocusAfterKeydown) {
@@ -5556,9 +5678,16 @@ ${
@@ -5556,9 +5684,16 @@ ${
}
_on_mousedown(event) {
@@ -323,7 +336,7 @@ index e7e9495e56076cd112beafb8297dece4ae4cea58..330104d245ddd0956b95377082a61825
if (
event.composedTarget != this.inputField &&
event.composedTarget != this._inputContainer
@@ -5568,6 +5697,10 @@ ${
@@ -5568,6 +5703,10 @@ ${
this.focusedViaMousedown = !this.focused;
this.#preventClickSelectsAll = this.focused;
@@ -334,7 +347,7 @@ index e7e9495e56076cd112beafb8297dece4ae4cea58..330104d245ddd0956b95377082a61825
// Keep the focus status, since the attribute may be changed
// upon calling this.focus().
@@ -5605,7 +5738,7 @@ ${
@@ -5605,7 +5744,7 @@ ${
// view open on tab switch, and the TabSelect event arrived earlier.
// Also ignore mousedown on the urlbarView context menu: opening/closing the
// view is already handled by the result opening flow.
@@ -343,7 +356,7 @@ index e7e9495e56076cd112beafb8297dece4ae4cea58..330104d245ddd0956b95377082a61825
break;
}
@@ -5894,7 +6027,7 @@ ${
@@ -5894,7 +6033,7 @@ ${
// When we are in actions search mode we can show more results so
// increase the limit.
let maxResults =

View File

@@ -1,8 +1,8 @@
diff --git a/browser/components/urlbar/content/UrlbarView.mjs b/browser/components/urlbar/content/UrlbarView.mjs
index 8afb2bf4a757b3ef7902f5a6e6eed6b70ca2845e..440dea1e66540ca1998cb7464b6695ac823fedcb 100644
index e465806c4ae4fc372d9aa2b71aa7f0e74c65d026..c0b3781901298bedee2736819a93edbf545507a7 100644
--- a/browser/components/urlbar/content/UrlbarView.mjs
+++ b/browser/components/urlbar/content/UrlbarView.mjs
@@ -674,7 +674,7 @@ export class UrlbarView {
@@ -803,7 +803,7 @@ export class UrlbarView {
!this.input.value ||
this.input.getAttribute("pageproxystate") == "valid"
) {
@@ -11,7 +11,7 @@ index 8afb2bf4a757b3ef7902f5a6e6eed6b70ca2845e..440dea1e66540ca1998cb7464b6695ac
// Try to reuse the cached top-sites context. If it's not cached, then
// there will be a gap of time between when the input is focused and
// when the view opens that can be perceived as flicker.
@@ -812,10 +812,6 @@ export class UrlbarView {
@@ -941,10 +941,6 @@ export class UrlbarView {
}
// If search mode isn't active, close the view.
@@ -22,7 +22,7 @@ index 8afb2bf4a757b3ef7902f5a6e6eed6b70ca2845e..440dea1e66540ca1998cb7464b6695ac
// Search mode is active. If the one-offs should be shown, make sure they
// are enabled and show the view.
@@ -3031,6 +3027,8 @@ export class UrlbarView {
@@ -3203,6 +3199,8 @@ export class UrlbarView {
if (row?.hasAttribute("row-selectable")) {
row?.toggleAttribute("selected", true);
}
@@ -31,7 +31,7 @@ index 8afb2bf4a757b3ef7902f5a6e6eed6b70ca2845e..440dea1e66540ca1998cb7464b6695ac
if (element != row) {
row?.toggleAttribute("descendant-selected", true);
}
@@ -3544,7 +3542,7 @@ export class UrlbarView {
@@ -3715,7 +3713,7 @@ export class UrlbarView {
}
#enableOrDisableRowWrap() {
@@ -40,3 +40,16 @@ index 8afb2bf4a757b3ef7902f5a6e6eed6b70ca2845e..440dea1e66540ca1998cb7464b6695ac
this.#rows.toggleAttribute("wrap", wrap);
this.oneOffSearchButtons?.container.toggleAttribute("wrap", wrap);
}
@@ -4144,6 +4142,12 @@ export class UrlbarView {
}
on_blur() {
+ if (
+ this.document.commandDispatcher.focusedElement == this.input.inputField &&
+ !lazy.UrlbarPrefs.get("closeOnWindowBlur")
+ ) {
+ return;
+ }
// If the view is open without the input being focused, it will not close
// automatically when the window loses focus. We might be in this state
// after a Search Tip is shown on an engine homepage.

View File

@@ -1,76 +0,0 @@
diff --git a/browser/app/profile/firefox.js b/browser/app/profile/firefox.js
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -411,10 +411,13 @@
pref("browser.urlbar.maxRichResults", 10);
// The maximum number of historical search results to show.
pref("browser.urlbar.maxHistoricalSearchSuggestions", 2);
+// Whether to close the urlbar when blurring the window while the urlbar is focused.
+pref("browser.urlbar.closeOnWindowBlur", true);
+
// The default behavior for the urlbar can be configured to use any combination
// of the match filters with each additional filter adding more results (union).
pref("browser.urlbar.suggest.bookmark", true);
pref("browser.urlbar.suggest.clipboard", true);
pref("browser.urlbar.suggest.history", true);
diff --git a/browser/components/urlbar/UrlbarPrefs.sys.mjs b/browser/components/urlbar/UrlbarPrefs.sys.mjs
--- a/browser/components/urlbar/UrlbarPrefs.sys.mjs
+++ b/browser/components/urlbar/UrlbarPrefs.sys.mjs
@@ -152,10 +152,13 @@
["filter.javascript", true],
// Feature gate pref for flight status suggestions in the urlbar.
["flightStatus.featureGate", false],
+ // Whether to close the urlbar when blurring the window while the urlbar is focused.
+ ["closeOnWindowBlur", true],
+
// The minimum prefix length of a flight status keyword the user must type to
// trigger the suggestion. 0 means the min length should be taken from Nimbus
// or remote settings.
["flightStatus.minKeywordLength", 0],
diff --git a/browser/components/urlbar/content/UrlbarView.mjs b/browser/components/urlbar/content/UrlbarView.mjs
--- a/browser/components/urlbar/content/UrlbarView.mjs
+++ b/browser/components/urlbar/content/UrlbarView.mjs
@@ -3909,10 +3909,16 @@
}
}
}
on_blur() {
+ if (
+ this.document.commandDispatcher.focusedElement == this.input.inputField &&
+ !lazy.UrlbarPrefs.get("closeOnWindowBlur")
+ ) {
+ return;
+ }
// If the view is open without the input being focused, it will not close
// automatically when the window loses focus. We might be in this state
// after a Search Tip is shown on an engine homepage.
if (!lazy.UrlbarPrefs.get("ui.popup.disable_autohide")) {
this.close();
diff --git a/browser/components/urlbar/content/UrlbarInput.mjs b/browser/components/urlbar/content/UrlbarInput.mjs
--- a/browser/components/urlbar/content/UrlbarInput.mjs
+++ b/browser/components/urlbar/content/UrlbarInput.mjs
@@ -5460,11 +5460,17 @@
_on_blur(event) {
if (this.view.resultMenu.hasAttribute("open")) {
return;
}
lazy.logger.debug("Blur Event");
+ if (
+ this.document.commandDispatcher.focusedElement == this.inputField &&
+ !lazy.UrlbarPrefs.get("closeOnWindowBlur")
+ ) {
+ return;
+ }
// We cannot count every blur events after a missed engagement as abandoment
// because the user may have clicked on some view element that executes
// a command causing a focus change. For example opening preferences from
// the oneoff settings button.
// For now we detect that case by discarding the event on command, but we
// may want to figure out a more robust way to detect abandonment.

View File

@@ -0,0 +1,166 @@
diff --git a/browser/components/sessionstore/test/browser.toml b/browser/components/sessionstore/test/browser.toml
--- a/browser/components/sessionstore/test/browser.toml
+++ b/browser/components/sessionstore/test/browser.toml
@@ -14,10 +14,12 @@
"browser_frame_history_c.html",
"browser_frame_history_c1.html",
"browser_frame_history_c2.html",
"browser_formdata_format_sample.html",
"browser_sessionHistory_slow.sjs",
+ "browser_policy_container_sample.html",
+ "browser_policy_container_sample_frame.html",
"browser_scrollPositions_sample.html",
"browser_scrollPositions_sample2.html",
"browser_scrollPositions_sample_frameset.html",
"browser_scrollPositions_readerModeArticle.html",
"browser_sessionStorage.html",
@@ -226,10 +228,12 @@
["browser_pinned_tabs.js"]
skip-if = [
"ccov", # Bug 1625525
]
+["browser_policy_container_not_stored.js"]
+
["browser_privatetabs.js"]
["browser_purge_domaindata.js"]
["browser_purge_shistory.js"]
diff --git a/browser/components/sessionstore/test/browser_policy_container_not_stored.js b/browser/components/sessionstore/test/browser_policy_container_not_stored.js
new file mode 100644
--- /dev/null
+++ b/browser/components/sessionstore/test/browser_policy_container_not_stored.js
@@ -0,0 +1,51 @@
+/* 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/. */
+
+"use strict";
+
+/**
+ * A session history entry only needs to retain the policy container for loads
+ * that inherit their policies (about:blank, about:srcdoc, blob:, data:, ...).
+ * Anything fetched over the network gets its policies from the response again,
+ * so storing them would only bloat the session store (bug 2011236).
+ */
+
+const TEST_ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+const PARENT_URL = TEST_ROOT + "browser_policy_container_sample.html";
+const FRAME_URL = TEST_ROOT + "browser_policy_container_sample_frame.html";
+
+add_task(async function test_policy_container_only_for_inheriting_loads() {
+ const tab = BrowserTestUtils.addTab(gBrowser, PARENT_URL);
+ gBrowser.selectedTab = tab;
+ await promiseBrowserLoaded(tab.linkedBrowser, true, PARENT_URL);
+ await TabStateFlusher.flush(tab.linkedBrowser);
+
+ const state = JSON.parse(ss.getTabState(tab));
+ const topEntry = state.entries.at(-1);
+ is(topEntry.url, PARENT_URL, "collected the parent entry");
+
+ const children = topEntry.children ?? [];
+ const networkFrame = children.find(child => child.url === FRAME_URL);
+ const blankFrame = children.find(child => child.url === "about:blank");
+
+ ok(networkFrame, "collected the network-scheme subframe entry");
+ ok(blankFrame, "collected the about:blank subframe entry");
+
+ // The parent has a CSP, so both subframes inherit a policy container onto
+ // their load state. Only the one that cannot recover it from a response
+ // should keep it in session history.
+ ok(
+ !("policyContainer" in networkFrame),
+ "https subframe entry does not store a policyContainer"
+ );
+ ok(
+ "policyContainer" in blankFrame,
+ "about:blank subframe entry still stores its inherited policyContainer"
+ );
+
+ await BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/components/sessionstore/test/browser_policy_container_sample.html b/browser/components/sessionstore/test/browser_policy_container_sample.html
new file mode 100644
--- /dev/null
+++ b/browser/components/sessionstore/test/browser_policy_container_sample.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <meta http-equiv="Content-Security-Policy" content="default-src * data: blob: 'unsafe-inline'">
+ <title>policyContainer session store sample</title>
+</head>
+<body>
+ <iframe id="network" src="browser_policy_container_sample_frame.html"></iframe>
+ <iframe id="blank" src="about:blank"></iframe>
+</body>
+</html>
diff --git a/browser/components/sessionstore/test/browser_policy_container_sample_frame.html b/browser/components/sessionstore/test/browser_policy_container_sample_frame.html
new file mode 100644
--- /dev/null
+++ b/browser/components/sessionstore/test/browser_policy_container_sample_frame.html
@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>policyContainer session store sample frame</title>
+</head>
+<body>
+ frame
+</body>
+</html>
diff --git a/docshell/shistory/SessionHistoryEntry.cpp b/docshell/shistory/SessionHistoryEntry.cpp
--- a/docshell/shistory/SessionHistoryEntry.cpp
+++ b/docshell/shistory/SessionHistoryEntry.cpp
@@ -41,10 +41,18 @@
extern mozilla::LazyLogModule gSHLog;
namespace mozilla {
namespace dom {
+// Only store policy container for loads that can't carry it themselves
+// (about:blank, about:srcdoc, blob:, data:, ...)
+// bug 1867137, bug 2011236
+static nsIPolicyContainer* PolicyContainerToStore(
+ nsIURI* aURI, nsIPolicyContainer* aPolicyContainer) {
+ return CSP_ShouldURIInheritCSP(aURI) ? aPolicyContainer : nullptr;
+}
+
SessionHistoryInfo::SessionHistoryInfo(nsDocShellLoadState* aLoadState,
nsIChannel* aChannel)
: mURI(aLoadState->URI()),
mOriginalURI(aLoadState->OriginalURI()),
mResultPrincipalURI(aLoadState->ResultPrincipalURI()),
@@ -59,9 +67,10 @@
mLoadReplace(aLoadState->LoadReplace()),
mHasUserActivation(aLoadState->HasValidUserGestureActivation()),
mSharedState(SharedState::Create(
aLoadState->TriggeringPrincipal(), aLoadState->PrincipalToInherit(),
aLoadState->PartitionedPrincipalToInherit(),
- aLoadState->PolicyContainer(),
+ PolicyContainerToStore(aLoadState->URI(),
+ aLoadState->PolicyContainer()),
/* FIXME Is this correct? */
aLoadState->TypeHint())) {
// Pull the upload stream off of the channel instead of the load state, as
@@ -124,11 +133,12 @@
loadInfo->GetPrincipalToInherit(
getter_AddRefs(mSharedState.Get()->mPrincipalToInherit));
mSharedState.Get()->mPartitionedPrincipalToInherit =
aPartitionedPrincipalToInherit;
- mSharedState.Get()->mPolicyContainer = aPolicyContainer;
+ mSharedState.Get()->mPolicyContainer =
+ PolicyContainerToStore(mURI, aPolicyContainer);
aChannel->GetContentType(mSharedState.Get()->mContentType);
aChannel->GetOriginalURI(getter_AddRefs(mOriginalURI));
uint32_t loadFlags;
aChannel->GetLoadFlags(&loadFlags);

View File

@@ -0,0 +1,55 @@
diff --git a/toolkit/components/pictureinpicture/PictureInPicture.sys.mjs b/toolkit/components/pictureinpicture/PictureInPicture.sys.mjs
--- a/toolkit/components/pictureinpicture/PictureInPicture.sys.mjs
+++ b/toolkit/components/pictureinpicture/PictureInPicture.sys.mjs
@@ -119,10 +119,19 @@
}
case "PictureInPicture:VideoTabHidden": {
if (!lazy.PIP_ENABLED || !lazy.PIP_WHEN_SWITCHING_TABS) {
break;
}
+
+ // Suppress auto-toggle if the user deliberately closed this PiP window
+ if (
+ PictureInPicture.weakAutoPipBrowserClosedDeliberately.has(browser)
+ ) {
+ PictureInPicture.weakAutoPipBrowserClosedDeliberately.delete(browser);
+ break;
+ }
+
// If the tab is still selected, then we can ignore this event
if (browser.documentGlobal.gBrowser.selectedBrowser == browser) {
break;
}
let actor = browsingContext.currentWindowGlobal.getActor(
@@ -261,10 +270,12 @@
maxConcurrentPlayerCount: 0,
// Maps auto pip browser to PictureInPictureParent actor
weakAutoPipBrowserToParent: new WeakMap(),
+ weakAutoPipBrowserClosedDeliberately: new WeakSet(),
+
/**
* Returns the player window if one exists and if it hasn't yet been closed.
*
* @param {PictureInPictureParent} pipActorRef
* Reference to the calling PictureInPictureParent actor
@@ -899,10 +910,17 @@
const { reason, actorRef } = closeData;
const win = this.getWeakPipPlayer(actorRef);
if (!win) {
return;
}
+
+ const browser = this.weakWinToBrowser.get(win);
+
+ if (reason === "CloseButton") {
+ PictureInPicture.weakAutoPipBrowserClosedDeliberately.add(browser);
+ }
+
this.removePiPBrowserFromWeakMap(this.weakWinToBrowser.get(win));
this.weakAutoPipBrowserToParent.delete(this.weakWinToBrowser.get(win));
Glean.pictureinpicture["closedMethod" + reason].record();
await this.closePipWindow(win);

View File

@@ -7,14 +7,6 @@
"id": "D299584",
"name": "Native MacOS popovers fix"
},
{
"type": "phabricator",
"id": "D284404",
"name": "Add urlbar closeOnWindowBlur preference",
"replaces": {
"\n\n": "\n // may want to figure out a more robust way to detect abandonment."
}
},
{
"type": "local",
// TODO: Convert into https://phabricator.services.mozilla.com/D298079
@@ -48,5 +40,21 @@
"this._lastJsonLength = uncompressedBytes;": "this._lastJsonLength = uncompressedBytes.jsonLength;",
" jsonLengthHint,": " lengthHint: jsonLengthHint,"
}
},
{
"type": "phabricator",
"id": "D320897",
"name": "Bug 2011236",
"replaces": {
// The upstream patch context includes a MOZ_DIAGNOSTIC_ASSERT that
// doesn't exist yet in our Firefox version.
"@@ -59,11 +67,12 @@": "@@ -59,9 +67,10 @@",
"aLoadState->TypeHint())) {\n MOZ_DIAGNOSTIC_ASSERT(!mURI->SchemeIs(\"javascript\"));\n \n // Pull": "aLoadState->TypeHint())) {\n // Pull"
}
},
{
"type": "phabricator",
"id": "D321446",
"name": "Issue 14710"
}
]

View File

@@ -1,5 +1,5 @@
diff --git a/services/sync/modules/service.sys.mjs b/services/sync/modules/service.sys.mjs
index 4f4efe572909441abdd81909282936702c225621..81ab06a9414ed656b644a21b40b683f06a4f8dbf 100644
index 4f4efe572909441abdd81909282936702c225621..2b47a2c9e97cf87641a0be9814f9a6be4d43f6ea 100644
--- a/services/sync/modules/service.sys.mjs
+++ b/services/sync/modules/service.sys.mjs
@@ -99,6 +99,10 @@ function getEngineModules() {
@@ -7,8 +7,8 @@ index 4f4efe572909441abdd81909282936702c225621..81ab06a9414ed656b644a21b40b683f0
whenFalse: "ExtensionStorageEngineBridge",
};
+ result.Spaces = {
+ module: "resource:///modules/zen/ZenWorkspacesSync.sys.mjs",
+ symbol: "ZenWorkspacesEngine",
+ module: "resource:///modules/zen/ZenSpacesSync.sys.mjs",
+ symbol: "ZenSpacesSyncEngine",
+ };
return result;
}

View File

@@ -0,0 +1,14 @@
diff --git a/testing/xpcshell/runxpcshelltests.py b/testing/xpcshell/runxpcshelltests.py
index 403401613189ffb758037850dc6bbb1d55f194ab..cad28e245155eb54775e551885532026e56c5fa9 100755
--- a/testing/xpcshell/runxpcshelltests.py
+++ b/testing/xpcshell/runxpcshelltests.py
@@ -2275,6 +2275,9 @@ class XPCShellTests:
appDirKey = None
if "appname" in self.mozInfo:
appDirKey = self.mozInfo["appname"] + "-appdir"
+ if appDirKey == "zen-appdir":
+ # Inherited manifests use firefox-appdir.
+ appDirKey = "firefox-appdir"
# We have to do this before we run tests that depend on having the node
# http/2 server.

View File

@@ -1,37 +0,0 @@
diff --git a/toolkit/components/contextualidentity/ContextualIdentityService.sys.mjs b/toolkit/components/contextualidentity/ContextualIdentityService.sys.mjs
index 5702ff28cc22206f5ce16584dac8a78d816562ce..3d08ecc97ce5995b30d8a4af0c33df329b428008 100644
--- a/toolkit/components/contextualidentity/ContextualIdentityService.sys.mjs
+++ b/toolkit/components/contextualidentity/ContextualIdentityService.sys.mjs
@@ -270,11 +270,29 @@ _ContextualIdentityService.prototype = {
});
},
- create(name, icon, color) {
+ create(name, icon, color, id = null) {
this.ensureDataReady();
- // Retrieve the next userContextId available.
- let userContextId = ++this._lastUserContextId;
+ if (id !== null) {
+ id = typeof id == "string" ? Number(id) : id;
+ if (!Number.isSafeInteger(id) || id <= 0) {
+ throw new Error(`Invalid userContextId '${id}'`);
+ }
+ }
+
+ let userContextId;
+ if (
+ id !== null &&
+ !this._identities.some(identity => identity.userContextId === id)
+ ) {
+ userContextId = id;
+ this._lastUserContextId = Math.max(
+ this._lastUserContextId,
+ userContextId
+ );
+ } else {
+ userContextId = ++this._lastUserContextId;
+ }
// Throw an error if the next userContextId available is invalid (the one associated to
// MAX_USER_CONTEXT_ID is already reserved to "userContextIdInternal.webextStorageLocal", which

View File

@@ -0,0 +1,29 @@
diff --git a/toolkit/content/widgets/panel-list/panel-list.mjs b/toolkit/content/widgets/panel-list/panel-list.mjs
index 68ad2fafc8b31dfff541cf8381d9b9533e376d1c..fa62fec6bfb33338091fc06d3736fb76fd5c47c4 100644
--- a/toolkit/content/widgets/panel-list/panel-list.mjs
+++ b/toolkit/content/widgets/panel-list/panel-list.mjs
@@ -223,7 +223,12 @@ export class PanelList extends HTMLElement {
this.setAttribute("showing", "true");
// Tell the host element to hide any overflow in case the panel extends off
// the page before the alignment is set.
- hostElement.style.overflow = "hidden";
+ const hostHidesOverflow = !getComputedStyle(hostElement)
+ .overflow.split(" ")
+ .includes("visible");
+ if (!hostHidesOverflow) {
+ hostElement.style.overflow = "hidden";
+ }
// Wait for a layout flush, then find the bounds.
let {
@@ -344,7 +349,9 @@ export class PanelList extends HTMLElement {
// Set the alignments and show the panel.
this.setAttribute("align", align);
this.setAttribute("valign", valign);
- hostElement.style.overflow = "";
+ if (!hostHidesOverflow) {
+ hostElement.style.overflow = "";
+ }
// Decide positioning based on where this panel will be rendered
const offsetParentIsBody =
this.supportsPopover() ||

View File

@@ -0,0 +1,29 @@
diff --git a/widget/gtk/nsDragService.cpp b/widget/gtk/nsDragService.cpp
index f760a6bb887b321376761431886c771f14fd03af..d52110ba52a95ef7c7bfb915e120b8dd636c9448 100644
--- a/widget/gtk/nsDragService.cpp
+++ b/widget/gtk/nsDragService.cpp
@@ -23,6 +23,7 @@
#include "mozilla/StaticPrefs_widget.h"
#include "mozilla/WidgetUtils.h"
#include "mozilla/WidgetUtilsGtk.h"
+#include "mozilla/nsZenDragAndDrop.h"
#include "nsAppShell.h"
#include "nsArrayUtils.h"
#include "nsCRT.h"
@@ -2304,11 +2305,15 @@ bool nsDragSession::SetAlphaPixmap(SourceSurface* aSurface,
cairo_image_surface_get_stride(surf), SurfaceFormat::B8G8R8A8);
if (!dt) return false;
+ auto drag_translucency = DRAG_IMAGE_ALPHA_LEVEL;
+ if (auto zenDragAndDrop = zen::nsZenDragAndDrop::GetZenDragAndDropInstance()) {
+ drag_translucency = zenDragAndDrop->GetDragImageOpacity();
+ }
dt->ClearRect(Rect(0, 0, dragRect.width, dragRect.height));
dt->DrawSurface(
aSurface, Rect(0, 0, dragRect.width, dragRect.height),
Rect(0, 0, dragRect.width, dragRect.height), DrawSurfaceOptions(),
- DrawOptions(DRAG_IMAGE_ALPHA_LEVEL, CompositionOp::OP_SOURCE));
+ DrawOptions(drag_translucency, CompositionOp::OP_SOURCE));
cairo_surface_mark_dirty(surf);
cairo_surface_set_device_offset(surf, -aXOffset, -aYOffset);

View File

@@ -4,6 +4,7 @@
import checkForZenUpdates, {
createWindowUpdateAnimation,
playWindowSweepAnimation,
} from "chrome://browser/content/ZenUpdates.mjs";
class ZenStartup {
@@ -195,6 +196,14 @@ class ZenStartup {
checkForZenUpdates();
return await createWindowUpdateAnimation();
}
/**
* Entry point for privileged modules (e.g. the sync applier) to play the
* update sweep animation in this window.
*/
playWindowSweepAnimation() {
return playWindowSweepAnimation();
}
}
window.gZenStartup = new ZenStartup();

View File

@@ -493,6 +493,13 @@ window.gZenUIManager = {
_clearTimeout: null,
_lastTab: null,
_urlbarSessionCounter: 0,
_urlbarOwner: null,
_isOwner(closeSeq) {
return this._urlbarOwner === closeSeq;
},
// Check if browser elements are in a valid state for tab operations
_validateBrowserState() {
// Check if browser window is still open
@@ -517,8 +524,15 @@ window.gZenUIManager = {
werePassedURL,
searchClipboard,
where,
overridePreferance = false
overridePreferance = false,
closeToken = {}
) {
// Keep this here. It's better to make sure every
// call has a different token rather then forgetting
// to increment it in one of the early returns.
const closeSeq = ++this._urlbarSessionCounter;
closeToken.id = closeSeq;
// Validate browser state first
if (!this._validateBrowserState()) {
console.warn("Browser state invalid for new tab operation");
@@ -542,7 +556,13 @@ window.gZenUIManager = {
// Close the new tab popup on cmd/ctrl + t
if (!overridePreferance && gURLBar.hasAttribute("zen-newtab")) {
this.handleUrlbarClose();
// The _zenHandleUrlbarClose holds a token in it's closure.
// Call that instead.
if (gURLBar._zenHandleUrlbarClose) {
gURLBar._zenHandleUrlbarClose();
} else {
this.handleUrlbarClose(closeSeq);
}
return true;
}
@@ -568,7 +588,9 @@ window.gZenUIManager = {
this._prevUrlbarLabel = gURLBar._untrimmedValue || "";
// Set up URL bar for new tab
gURLBar._zenHandleUrlbarClose = this.handleUrlbarClose.bind(this);
gURLBar._zenHandleUrlbarClose = (onSwitch, onElementPicked) =>
this.handleUrlbarClose(closeSeq, onSwitch, onElementPicked);
this._urlbarOwner = closeSeq;
gURLBar.setAttribute("zen-newtab", true);
// Update newtab buttons
@@ -582,18 +604,33 @@ window.gZenUIManager = {
document.getElementById("Browser:OpenLocation").doCommand();
} catch (e) {
console.error("Error opening location in new tab:", e);
this.handleUrlbarClose(false);
this.handleUrlbarClose(closeSeq, false, false);
return false;
}
return true;
},
/**
* Check whether the closeToken given by handleNewTab
* corresponds to a close event.
*
* @param {object} closeToken - Token previously passed to handleNewTab.
* @param {CustomEvent} event - A ZenURLBarClosed event.
* @returns {boolean}
*/
matchesCloseToken(closeToken, event) {
return (
typeof closeToken?.id === "number" &&
event?.detail?.closeSeq === closeToken.id
);
},
clearUrlbarData() {
this._prevUrlbarLabel = null;
this._lastSearch = "";
},
handleUrlbarClose(onSwitch = false, onElementPicked = false) {
handleUrlbarClose(closeSeq, onSwitch = false, onElementPicked = false) {
// Validate browser state first
if (!this._validateBrowserState()) {
console.warn("Browser state invalid for URL bar close operation");
@@ -607,59 +644,69 @@ window.gZenUIManager = {
const isFocusedBefore = gURLBar.focused;
setTimeout(() => {
// We use this attribute on Tabbrowser::addTab
gURLBar.removeAttribute("zen-newtab");
/* If someone else opened the url bar in the meantime don't be
* stingy and leave it open for them.*/
if (this._isOwner(closeSeq)) {
// We use this attribute on Tabbrowser::addTab
gURLBar.removeAttribute("zen-newtab");
// Safely restore tab visual state with proper validation
if (
this._lastTab &&
!this._lastTab.closing &&
this._lastTab.documentGlobal &&
!this._lastTab.documentGlobal.closed &&
gBrowser.selectedTab === this._lastTab
) {
this._lastTab._visuallySelected = true;
this._lastTab = null;
}
// Reset newtab buttons
for (const button of this.newtabButtons) {
button.removeAttribute("in-urlbar");
}
// Handle search data
if (onSwitch) {
this.clearUrlbarData();
} else {
this._lastSearch = gURLBar._untrimmedValue || "";
if (this._clearTimeout) {
clearTimeout(this._clearTimeout);
// Safely restore tab visual state with proper validation
if (
this._lastTab &&
!this._lastTab.closing &&
this._lastTab.documentGlobal &&
!this._lastTab.documentGlobal.closed &&
gBrowser.selectedTab === this._lastTab
) {
this._lastTab._visuallySelected = true;
this._lastTab = null;
}
this._clearTimeout = setTimeout(() => {
// Reset newtab buttons
for (const button of this.newtabButtons) {
button.removeAttribute("in-urlbar");
}
// Handle search data
if (onSwitch) {
this.clearUrlbarData();
}, this.urlbarWaitToClear);
}
} else {
this._lastSearch = gURLBar._untrimmedValue || "";
// Safely restore URL bar state with proper validation
if (this._prevUrlbarLabel) {
gURLBar.setURI({
uri: this._prevUrlbarLabel,
dueToTabSwitch: onSwitch,
isSameDocument: !onSwitch,
});
}
if (this._clearTimeout) {
clearTimeout(this._clearTimeout);
}
gURLBar.handleRevert();
this._clearTimeout = setTimeout(() => {
this.clearUrlbarData();
}, this.urlbarWaitToClear);
}
// Safely restore URL bar state with proper validation
if (this._prevUrlbarLabel) {
gURLBar.setURI({
uri: this._prevUrlbarLabel,
dueToTabSwitch: onSwitch,
isSameDocument: !onSwitch,
});
}
gURLBar.handleRevert();
}
if (isFocusedBefore) {
setTimeout(() => {
window.dispatchEvent(
new CustomEvent("ZenURLBarClosed", {
detail: { onSwitch, onElementPicked },
detail: { onSwitch, onElementPicked, closeSeq },
})
);
if (!this._isOwner(closeSeq)) {
return;
}
this._urlbarOwner = null;
gURLBar.view.close({ elementPicked: onElementPicked });
gURLBar.updateTextOverflow();
@@ -683,6 +730,8 @@ window.gZenUIManager = {
}
}
}, 0);
} else if (this._isOwner(closeSeq)) {
this._urlbarOwner = null;
}
}, 0);
},

View File

@@ -61,6 +61,14 @@ export async function createWindowUpdateAnimation() {
return;
}
Services.prefs.setStringPref(ZEN_BUILD_ID_PREF, appID);
await playWindowSweepAnimation();
}
/**
* Plays the full-window sweep shown after updates. Also used the first
* time incoming sync data is applied on this profile.
*/
export async function playWindowSweepAnimation() {
await gZenWorkspaces.promiseInitialized;
const appWrapper = document.getElementById("zen-main-app-wrapper");
const element = document.createElement("div");

View File

@@ -78,13 +78,13 @@
}
.zen-sidebar-notification-progress {
height: 2px;
height: 1px;
overflow: hidden;
transform: translateY(-1px);
& .zen-sidebar-notification-progress-bar {
height: 100%;
background: light-dark(color-mix(in srgb, var(--zen-primary-color) 50%, black), color-mix(in srgb, var(--zen-colors-primary) 15%, #ebebeb));
background: light-dark(color-mix(in srgb, var(--zen-primary-color) 50%, black), color-mix(in srgb, var(--zen-colors-primary) 15%, transparent 10%, #6b6b6b));
transform-origin: left center;
}
}

View File

@@ -92,7 +92,11 @@
--in-content-accent-color-active: currentColor !important;
/* This is like the secondary button */
--in-content-button-background: transparent !important;
--in-content-button-background: color-mix(
in srgb,
currentColor,
transparent 90%
) !important;
--in-content-button-text-color: var(--zen-secondary-btn-color) !important;
--in-content-button-background-hover: color-mix(
in srgb,

View File

@@ -832,8 +832,9 @@ window.gZenCompactModeManager = {
const onLeave = event => {
if (AppConstants.platform == "macosx") {
const buttonRect =
gZenVerticalTabsManager.actualWindowButtons.getBoundingClientRect();
const buttonRect = window.windowUtils.getBoundsWithoutFlushing(
gZenVerticalTabsManager.actualWindowButtons
);
const MAC_WINDOW_BUTTONS_X_BORDER = buttonRect.width + buttonRect.x;
const MAC_WINDOW_BUTTONS_Y_BORDER = buttonRect.height + buttonRect.y;
if (

View File

@@ -183,10 +183,10 @@
if (tabClone.hasAttribute("visuallyselected")) {
tabClone.style.transform = "translate(-50%, -50%)";
}
} else if (AppConstants.platform !== "macosx" && !tab.isZenFolder) {
// On windows and linux, we still don't add some extra opaqueness
// for the tab to be more visible. This is a hacky workaround.
// TODO: Make windows and linux DnD use nsZenDragAndDrop::mDragImageOpacity
} else if (AppConstants.platform == "win" && !tab.isZenFolder) {
// On windows, the shell applies its own translucency to the drag
// image which we can't control, so force some extra contrast for
// the tab to be more visible. This is a hacky workaround.
tabClone.style.colorScheme = "light";
tabClone.style.setProperty("--tab-selected-textcolor", "black");
}

View File

@@ -200,6 +200,50 @@ class nsZenLiveFoldersManager {
return this.liveFolders.get(id);
}
/**
* Returns the provider config needed to recreate this live folder on
* another device, or null for non-live folders. Fetch bookkeeping fields
* are stripped since they are device-local.
*
* @param {string} id - The folder ID.
*/
getSyncableFolderData(id) {
const liveFolder = this.liveFolders.get(id);
if (!liveFolder) {
return null;
}
const state = { ...liveFolder.serialize().state };
delete state.lastFetched;
delete state.lastErrorId;
return { type: liveFolder.constructor.type, state };
}
/**
* Registers a live folder provider for a folder that arrived from
* Firefox Sync. The folder element must already exist.
*
* @param {string} id - The folder ID.
* @param {{ type: string, state: object }} config - Synced provider config.
*/
async adoptSyncedFolder(id, { type, state } = {}) {
await this.stateRestored.promise;
if (this.liveFolders.has(id)) {
return;
}
const ProviderClass = this.registry.get(type);
if (!ProviderClass || !state) {
return;
}
const liveFolder = new ProviderClass({
id,
state: this.#applyDefaultStateValues({ ...state }),
manager: this,
});
this.liveFolders.set(id, liveFolder);
liveFolder.start();
this.saveState();
}
async createFolder(type) {
const [provider, providerType] = type.split(":");
let ProviderClass = this.registry.get(provider);
@@ -324,7 +368,7 @@ class nsZenLiveFoldersManager {
},
],
content: {
width: "310px",
width: "312px",
position: "callout",
title_logo: {
imageURL: icon,
@@ -619,6 +663,7 @@ class nsZenLiveFoldersManager {
async #restoreState() {
let data = await this.#readStateFromDisk();
if (!Array.isArray(data)) {
this.stateRestored.resolve();
return;
}

View File

@@ -346,7 +346,7 @@ class ZenMediaCard {
onToggle() {
if (this.controller?.isPlaying) {
this.controller.pause();
this.controller.pause("user");
} else {
this.controller?.play();
}
@@ -365,7 +365,7 @@ class ZenMediaCard {
}
onSeekDrag(event) {
this.controller?.pause();
this.controller?.pause("user");
const newTime = (event.target.value / 100) * this.duration;
this.currentTimeEl.textContent = this.formatSecondsToTime(newTime);
}
@@ -408,7 +408,7 @@ class ZenMediaCard {
onClose() {
if (this.controller) {
this.controller.pause();
this.controller.pause("user");
} else {
const webRTC =
this.browser.browsingContext.currentWindowGlobal.getActor("WebRTC");

View File

@@ -10,7 +10,6 @@ const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
ZenLiveFoldersManager:
"resource:///modules/zen/ZenLiveFoldersManager.sys.mjs",
ZenSyncStore: "resource:///modules/zen/ZenSyncManager.sys.mjs",
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs",
SessionStartup: "resource:///modules/sessionstore/SessionStartup.sys.mjs",
@@ -611,7 +610,9 @@ export class nsZenSessionManager {
}
);
this.#collectWindowData(windows);
lazy.ZenSyncStore.notifyAboutChanges();
// Let interested consumers (e.g. Firefox Sync) know fresh sidebar data
// is available, without this module knowing anything about them.
Services.obs.notifyObservers(null, "zen-sidebar-data-collected");
// This would save the data to disk asynchronously or when quitting the app.
let sidebar = this.#sidebarWithoutCloning;
this.#file.data = sidebar;

View File

@@ -606,7 +606,9 @@ class nsZenWindowSync {
if (isTab) {
if (originalIsEssential !== targetIsEssential) {
if (originalIsEssential) {
gZenPinnedTabManager.addToEssentials(aTargetItem);
gZenPinnedTabManager.addToEssentials(aTargetItem, {
replicating: true,
});
} else {
gZenPinnedTabManager.removeEssentials(
aTargetItem,
@@ -1232,7 +1234,7 @@ class nsZenWindowSync {
activeIndex = Math.min(activeIndex, entries.length - 1);
activeIndex = Math.max(activeIndex, 0);
let entryToUse = (entries[activeIndex] || entries[0]) ?? null;
this.#setPinnedInitialState(
this.setPinnedInitialState(
aTab,
{ url: entryToUse?.url, title: entryToUse?.title },
image
@@ -1250,14 +1252,22 @@ class nsZenWindowSync {
*/
setPinnedUrl(aTab, aUrl, aImage) {
this.log(`Setting pinned url for tab ${aTab.id}`);
this.#setPinnedInitialState(
this.setPinnedInitialState(
aTab,
{ url: aUrl, title: aTab.zenStaticLabel },
aImage
);
}
#setPinnedInitialState(aTab, aEntry, aImage) {
/**
* Sets the pinned initial state (canonical entry and icon) for a tab's
* instances across all windows.
*
* @param {object} aTab - Any window's instance of the tab.
* @param {object} aEntry - The canonical { url, title } entry.
* @param {string} [aImage] - Optional icon to store.
*/
setPinnedInitialState(aTab, aEntry, aImage) {
const initialState = { entry: aEntry, image: aImage };
this.#runOnAllWindows(null, win => {
const targetTab = this.getItemFromWindow(win, aTab.id);
@@ -1362,7 +1372,9 @@ class nsZenWindowSync {
return;
}
tab._zenContentsVisible = true;
tab.id = this.#newTabSyncId;
if (!tab.id) {
tab.id = this.#newTabSyncId;
}
if (lazy.gSyncOnlyPinnedTabs && !tab.pinned) {
return;
}

View File

@@ -27,7 +27,7 @@ class nsZenSpaceRoutingManager {
const element = window.MozXULElement.parseXULToFragment(`
<menuseparator/>
<menuitem id="context_zen-add-domain-to-routing"
data-lazy-l10n-id="tab-context-zen-add-domain-to-sr"
data-l10n-id="tab-context-zen-add-domain-to-sr"
data-l10n-args='{"tabCount": 1}'/>
`);
window.document.getElementById("context_undoCloseTab").after(element);

View File

@@ -11,7 +11,6 @@ const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
ZenSessionStore: "resource:///modules/zen/ZenSessionManager.sys.mjs",
ZenSyncStore: "resource:///modules/zen/ZenSyncManager.sys.mjs",
});
ChromeUtils.defineLazyGetter(lazy, "browserBackgroundElement", () => {
@@ -173,63 +172,6 @@ class nsZenWorkspaces {
}
}
/**
* Applies live sync changes: updates workspace cache, removes deleted items,
* then creates/updates pulled items.
*
* @param {{ spaces: Array}} pulled Reconcile-pulled items.
* @param {{ spaces: Array}} removals Items to remove.
*/
async _applySyncChanges(pulled, removals = {}) {
if (!this.shouldHaveWorkspaces || this.privateWindowOrDisabled) {
return;
}
await this.promiseInitialized;
// 1. Update workspace cache (remove deleted, merge pulled)
const removedSpaceIds = new Set((removals.spaces || []).map(s => s.uuid));
if (removedSpaceIds.size || pulled.spaces?.length) {
const localMap = new Map(
this.getWorkspaces()
.filter(w => !removedSpaceIds.has(w.uuid))
.map(w => [w.uuid, w])
);
for (const space of pulled.spaces || []) {
if (!space?.uuid) {
continue;
}
const existing = localMap.get(space.uuid);
localMap.set(space.uuid, existing ? { ...existing, ...space } : space);
}
await this.propagateWorkspaces(
this.#getOrderedWorkspacesByPosition(Array.from(localMap.values()))
);
this.#propagateWorkspaceData();
}
}
#getOrderedWorkspacesByPosition(workspaces) {
return [...workspaces]
.map((workspace, index) => ({ workspace, index }))
.sort((a, b) => {
const aPosition =
typeof a.workspace.position === "number"
? a.workspace.position
: a.index;
const bPosition =
typeof b.workspace.position === "number"
? b.workspace.position
: b.index;
return aPosition - bPosition || a.index - b.index;
})
.map(({ workspace }) => {
// strip the position property that comes from pulled workspaces
const rest = { ...workspace };
delete rest.position;
return rest;
});
}
#afterLoadInit() {
const onResize = (...args) => {
requestAnimationFrame(() => {
@@ -862,13 +804,6 @@ class nsZenWorkspaces {
})();
}
#markWorkspaceChanged(workspaceId) {
lazy.ZenSyncStore.markItemChanged({
type: "space",
id: workspaceId,
});
}
async selectStartPage() {
if (!this.workspaceEnabled || gZenUIManager.testingEnabled) {
return;
@@ -1297,8 +1232,6 @@ class nsZenWorkspaces {
} else {
workspacesData.push(workspaceData);
}
// mark item as changed for sync
this.#markWorkspaceChanged(workspaceData.uuid);
this.#propagateWorkspaceData();
}
@@ -1307,9 +1240,6 @@ class nsZenWorkspaces {
let { promise, resolve } = Promise.withResolvers();
this.#deleteWorkspaceOwnedTabs(windowID);
// mark item as changed for sync
this.#markWorkspaceChanged(windowID);
let workspacesData = this.getWorkspaces();
// Remove the workspace from the cache
workspacesData = workspacesData.filter(
@@ -1442,11 +1372,6 @@ class nsZenWorkspaces {
return;
}
const workspaces = this.getWorkspaces();
// Track previous positions so we only notify observers for workspaces whose
// position changed during the reorder.
const previousPositions = new Map(
workspaces.map((workspace, index) => [workspace.uuid, index])
);
const workspace = workspaces.find(w => w.uuid === id);
if (!workspace) {
@@ -1475,15 +1400,6 @@ class nsZenWorkspaces {
// Propagate the changes if the order has changed
if (currentIndex !== newPosition) {
this._workspaceCache = workspaces;
for (const [i, ws] of workspaces.entries()) {
if (previousPositions.get(ws.uuid) === i) {
continue;
}
// mark item as changed for sync
this.#markWorkspaceChanged(ws.uuid);
}
this.#propagateWorkspaceData(workspaces);
}
}
@@ -1790,6 +1706,15 @@ class nsZenWorkspaces {
}
makeSureEmptyTabIsFirst() {
if (
gZenUIManager.testingEnabled &&
this._emptyTab &&
(this._emptyTab.closing || !this._emptyTab.isConnected)
) {
this._emptyTab = gBrowser.tabs.find(
tab => tab.hasAttribute("zen-empty-tab") && !tab.closing
);
}
const emptyTab = this._emptyTab;
if (emptyTab) {
emptyTab.setAttribute("zen-workspace-id", this.activeWorkspace);
@@ -3320,8 +3245,10 @@ class nsZenWorkspaces {
return width;
}, 0);
// Check if the total width exceeds the parent's width
if (totalWidth > parent.clientWidth) {
// Check if the total width exceeds the parent's width.
const parentWidth =
window.windowUtils.getBoundsWithoutFlushing(parent).width;
if (totalWidth > parentWidth) {
parent.setAttribute("icons-overflow", "true");
} else {
parent.removeAttribute("icons-overflow");
@@ -3329,7 +3256,7 @@ class nsZenWorkspaces {
// Set the width of each icon to the maximum size they can fit on
const widthPerButton = Math.max(
(parent.clientWidth - separation * (parent.children.length - 1)) /
(parentWidth - separation * (parent.children.length - 1)) /
parent.children.length,
minButtonSize
);

View File

@@ -230,7 +230,10 @@ class nsZenViewSplitter extends nsZenDOMOperatedFeature {
}
this.removeGroup(groupIndex);
if (changeTab) {
gBrowser.selectedTab = remainingTabs[0];
const tabToSelect = remainingTabs.find(remaining => remaining !== tab);
if (tabToSelect) {
gBrowser.selectedTab = tabToSelect;
}
document
.getElementById("cmd_zenNewEmptySplit")
.removeAttribute("disabled");
@@ -2530,22 +2533,33 @@ class nsZenViewSplitter extends nsZenDOMOperatedFeature {
this.activateSplitView(data);
gBrowser.selectedTab = emptyTab;
setTimeout(() => {
const closeToken = {};
const controller = new AbortController();
const cleanup = (onSwitch = false, groupIndex = null) => {
if (groupIndex === null) {
groupIndex = this._data.findIndex(group =>
group.tabs.includes(emptyTab)
);
}
this.removeTabFromGroup(emptyTab, groupIndex, {
changeTab: !onSwitch,
forUnsplit: true,
});
const command = document.getElementById("cmd_zenNewEmptySplit");
command.removeAttribute("disabled");
};
window.addEventListener(
"ZenURLBarClosed",
event => {
if (!gZenUIManager.matchesCloseToken(closeToken, event)) {
return;
}
controller.abort();
const { onElementPicked, onSwitch } = event.detail;
const groupIndex = this._data.findIndex(group =>
group.tabs.includes(emptyTab)
);
const newSelectedTab = gBrowser.selectedTab;
const cleanup = () => {
this.removeTabFromGroup(emptyTab, groupIndex, {
changeTab: !onSwitch,
forUnsplit: true,
});
const command = document.getElementById("cmd_zenNewEmptySplit");
command.removeAttribute("disabled");
};
if (onElementPicked) {
if (
newSelectedTab === emptyTab ||
@@ -2553,7 +2567,7 @@ class nsZenViewSplitter extends nsZenDOMOperatedFeature {
selectedTab.getAttribute("zen-workspace-id") !==
newSelectedTab.getAttribute("zen-workspace-id")
) {
cleanup();
cleanup(onSwitch, groupIndex);
return;
}
this.removeTabFromGroup(emptyTab, groupIndex, {
@@ -2569,12 +2583,17 @@ class nsZenViewSplitter extends nsZenDOMOperatedFeature {
topOrLeft ? 0 : 1
);
} else {
cleanup();
cleanup(onSwitch, groupIndex);
}
},
{ once: true }
{ signal: controller.signal }
);
gZenUIManager.handleNewTab(false, false, "tab", true);
if (
!gZenUIManager.handleNewTab(false, false, "tab", true, closeToken)
) {
controller.abort();
cleanup();
}
});
});
}

View File

@@ -0,0 +1,182 @@
/* 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 {
Store,
SyncEngine,
Tracker,
} from "resource://services-sync/engines.sys.mjs";
import { CryptoWrapper } from "resource://services-sync/record.sys.mjs";
import { SCORE_INCREMENT_XLARGE } from "resource://services-sync/constants.sys.mjs";
import {
SIDEBAR_COLLECTED_TOPIC,
syncLog,
ZenSpacesSyncModel,
} from "resource:///modules/zen/ZenSpacesSyncModel.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
ZenSpacesSyncApplier: "resource:///modules/zen/ZenSpacesSyncApplier.sys.mjs",
});
const TRACKED_TOPICS = [
SIDEBAR_COLLECTED_TOPIC,
"contextual-identity-created",
"contextual-identity-updated",
"contextual-identity-deleted",
];
export class ZenSpacesSyncRecord extends CryptoWrapper {
_logName = "Sync.Record.ZenSpaces";
}
ZenSpacesSyncRecord.prototype.type = "spaces";
/**
* Store for the Spaces engine. Outgoing records are pure projections of
* the session-store sidebar data; incoming batches are handed to the
* applier, which reconciles them into the live browser.
*/
class ZenSpacesSyncStore extends Store {
async getAllIDs() {
return ZenSpacesSyncModel.getAllRecordIds();
}
async itemExists(id) {
return ZenSpacesSyncModel.itemExists(id);
}
async createRecord(id, collection) {
const record = new ZenSpacesSyncRecord(collection, id);
const projected = ZenSpacesSyncModel.projectRecord(id);
if (!projected) {
record.deleted = true;
return record;
}
record.cleartext = { id, kind: projected.kind, data: projected.data };
return record;
}
async applyIncomingBatch(records, countTelemetry) {
const failed = await lazy.ZenSpacesSyncApplier.applyBatch(records);
for (const id of failed) {
countTelemetry?.addIncomingFailedReason(`failed to apply ${id}`);
}
return failed;
}
async wipe() {
// Never delete user data on an engine wipe.
}
async changeItemID() {}
}
/**
* Tracker for the Spaces engine. There is no per-event bookkeeping: every
* time fresh sidebar data is collected (or a container changes), the model
* diffs the current projections against the last-uploaded snapshot; the
* score is only bumped when something actually differs.
*/
class ZenSpacesSyncTracker extends Tracker {
_ignoreAll = false;
get ignoreAll() {
return this._ignoreAll;
}
set ignoreAll(value) {
this._ignoreAll = value;
}
onStart() {
for (const topic of TRACKED_TOPICS) {
Services.obs.addObserver(this, topic);
}
}
onStop() {
for (const topic of TRACKED_TOPICS) {
Services.obs.removeObserver(this, topic);
}
}
observe(subject, topic) {
if (this.ignoreAll) {
return;
}
if (topic !== SIDEBAR_COLLECTED_TOPIC) {
ZenSpacesSyncModel.invalidate();
}
try {
if (ZenSpacesSyncModel.hasPendingChanges()) {
syncLog(`tracker: pending changes after ${topic}, requesting sync`);
this.score += SCORE_INCREMENT_XLARGE;
}
} catch (e) {
console.error("ZenSpacesSync: change detection failed:", e);
}
}
}
export class ZenSpacesSyncEngine extends SyncEngine {
constructor(service) {
super("Spaces", service);
}
get _storeObj() {
return ZenSpacesSyncStore;
}
get _trackerObj() {
return ZenSpacesSyncTracker;
}
get _recordObj() {
return ZenSpacesSyncRecord;
}
get version() {
return 1;
}
get syncPriority() {
return 8;
}
get allowSkippedRecord() {
// A record that persistently fails to upload is skipped; the snapshot
// diff retries it on every following sync anyway.
return true;
}
async getChangedIDs() {
return ZenSpacesSyncModel.computeChangedIDs();
}
async _reconcile(item) {
// Incoming records always apply, and any local divergence re-uploads afterwards
// (see ZenSpacesSyncModel.noteApplied). The base reconciliation would
// instead drop incoming changes whenever the id is also in the local
// changed set, because the snapshot diff stamps its changes with "now"
// and the local side would always win the age comparison.
//
// An id we accept must also leave the outgoing set. Projections only
// reflect the applied state after the delayed session collection, so
// uploading the id later in this same sync would overwrite the record
// we just accepted with the stale pre-apply state (and the other
// device would then apply that, endlessly trading states back and
// forth). Divergence that survives the collection re-uploads on a
// later sync through the snapshot diff.
this._modified.delete(item.id);
return true;
}
async trackRemainingChanges() {}
async _onRecordsWritten(succeeded) {
ZenSpacesSyncModel.markUploaded(succeeded);
}
}

View File

@@ -0,0 +1,915 @@
/* 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 {
canonicalJSON,
LAYOUT_RECORD_ID,
RECORD_KINDS,
syncableIconUrl,
syncLog,
ZenSpacesSyncModel,
} from "resource:///modules/zen/ZenSpacesSyncModel.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
SessionSaver: "resource:///modules/sessionstore/SessionSaver.sys.mjs",
E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs",
TabStateCache: "resource:///modules/sessionstore/TabStateCache.sys.mjs",
ZenWindowSync: "resource:///modules/zen/ZenWindowSync.sys.mjs",
ZenLiveFoldersManager:
"resource:///modules/zen/ZenLiveFoldersManager.sys.mjs",
ContextualIdentityService:
"moz-src:///toolkit/components/contextualidentity/ContextualIdentityService.sys.mjs",
});
const FIRST_SYNC_ANIMATION_PREF = "zen.spaces-sync.first-sync-animation-shown";
/**
* Applies batches of incoming Spaces records to the local browser.
*/
class nsZenSpacesSyncApplier {
#itemIn(win, id) {
return id ? win.document.getElementById(id) : null;
}
/**
* Maps a record's container guid to the local userContextId. A guid with
* no local mapping (its container record hasn't applied here yet) throws,
* failing the record so it redelivers once the container exists .
*
* @param {?string} containerGuid
* @returns {number}
*/
#resolveContainerId(containerGuid) {
if (!containerGuid) {
return 0;
}
const mapped = ZenSpacesSyncModel.contextIdForGuid(containerGuid);
if (mapped === null) {
throw new Error(`unknown container guid ${containerGuid}`);
}
return mapped;
}
/**
* @param {Array} records - decrypted incoming records.
* @returns {Array<string>} ids of records that failed to apply.
*/
async applyBatch(records) {
const incoming = {
containers: [],
spaces: [],
tabs: [],
folders: [],
splits: [],
layout: null,
deleted: [],
};
const handled = new Set();
for (const record of records) {
if (typeof record.id !== "string" || !record.id) {
continue;
}
if (record.deleted) {
if (record.id !== LAYOUT_RECORD_ID) {
incoming.deleted.push({ key: record.id, record });
}
handled.add(record);
continue;
}
const data = record.cleartext?.data;
if (!data) {
continue;
}
const entry = { key: record.id, data, record };
switch (record.cleartext.kind) {
case RECORD_KINDS.CONTAINER:
incoming.containers.push(entry);
break;
case RECORD_KINDS.SPACE:
incoming.spaces.push(entry);
break;
case RECORD_KINDS.TAB:
incoming.tabs.push(entry);
break;
case RECORD_KINDS.FOLDER:
incoming.folders.push(entry);
break;
case RECORD_KINDS.SPLIT:
incoming.splits.push(entry);
break;
case RECORD_KINDS.LAYOUT:
incoming.layout = entry;
break;
default:
// Unknown kind (newer client): leave untouched and unacknowledged.
continue;
}
handled.add(record);
}
syncLog(
`incoming batch: ${incoming.containers.length} containers, ` +
`${incoming.spaces.length} spaces, ${incoming.tabs.length} tabs, ` +
`${incoming.folders.length} folders, ${incoming.splits.length} splits, ` +
`layout=${!!incoming.layout}, ${incoming.deleted.length} tombstones`
);
const failed = new Set();
const fail = (record, e) => {
failed.add(record.id);
console.error(`ZenSpacesSync: failed to apply ${record.id}:`, e);
};
const deletions = this.#applyContainers(incoming, fail);
const win = lazy.ZenWindowSync.firstSyncedWindow;
if (!win) {
// Report the records as failed so the engine redelivers the batch on a later sync.
const noWindow = new Error("no synced window to apply into");
for (const entry of [
...incoming.spaces,
...incoming.folders,
...incoming.tabs,
...incoming.splits,
...(incoming.layout ? [incoming.layout] : []),
...deletions,
]) {
fail(entry.record, noWindow);
}
} else {
await win.gZenWorkspaces.promiseInitialized;
this.#maybePlayFirstSyncAnimation(win);
const removals = this.#routeTombstones(win, deletions);
this.#deleteTabs(win, removals.tabs, fail);
this.#deleteSplits(win, removals.splits, fail);
await this.#applySpaces(win, incoming.spaces, fail);
await this.#applyFolders(win, incoming.folders, fail);
this.#applyTabs(win, incoming.tabs, fail);
this.#applySplits(win, incoming.splits, fail);
await this.#deleteFolders(win, removals.folders, fail);
await this.#deleteSpaces(win, removals.spaces, fail);
this.#applyOrdering(win, incoming, fail);
// Collect the session soon so the stored sidebar (and with it the
// sync projections) reflects the applied state instead of re-uploading
// the pre-apply one.
lazy.SessionSaver.runDelayed();
}
ZenSpacesSyncModel.invalidate();
for (const record of records) {
if (!handled.has(record) || failed.has(record.id)) {
continue;
}
ZenSpacesSyncModel.noteApplied(
record.id,
record.deleted ? null : record.cleartext
);
}
return [...failed];
}
/**
* Plays the update sweep animation over the window the first time
* incoming sync data lands on this profile.
*
* @param {Window} win
*/
#maybePlayFirstSyncAnimation(win) {
if (Services.prefs.getBoolPref(FIRST_SYNC_ANIMATION_PREF, false)) {
return;
}
Services.prefs.setBoolPref(FIRST_SYNC_ANIMATION_PREF, true);
win.gZenStartup.playWindowSweepAnimation();
}
/**
* Tombstones have no payload. Whatever local thing owns the id decides
* what kind of deletion this is. Ids with no local counterpart need no
* work at all.
*
* @param {Window} win
* @param {Array<{key: string, record: object}>} deletions
*/
#routeTombstones(win, deletions) {
const routed = { tabs: [], folders: [], splits: [], spaces: [] };
const spaces = win.gZenWorkspaces.getWorkspaces();
for (const entry of deletions) {
const el = this.#itemIn(win, entry.key);
if (win.gBrowser.isTab(el)) {
routed.tabs.push(entry);
} else if (el?.isZenFolder) {
routed.folders.push(entry);
} else if (el?.hasAttribute?.("split-view-group")) {
routed.splits.push(entry);
} else if (spaces.some(s => s.uuid === entry.key)) {
routed.spaces.push(entry);
}
}
if (deletions.length) {
const matched = new Set([
...routed.tabs,
...routed.folders,
...routed.splits,
...routed.spaces,
]);
syncLog("tombstones routed:", {
tabs: routed.tabs.map(e => e.key),
folders: routed.folders.map(e => e.key),
splits: routed.splits.map(e => e.key),
spaces: routed.spaces.map(e => e.key),
unmatched: deletions.filter(e => !matched.has(e)).map(e => e.key),
});
}
return routed;
}
/* Mark: containers */
#applyContainers(incoming, fail) {
for (const { key: guid, data, record } of incoming.containers) {
try {
if (!data.name) {
continue;
}
const mapped = ZenSpacesSyncModel.contextIdForGuid(guid);
const existing =
mapped !== null
? lazy.ContextualIdentityService.getPublicIdentityFromId(mapped)
: null;
if (existing) {
lazy.ContextualIdentityService.update(
mapped,
data.name,
data.icon,
data.color
);
} else {
const identity = lazy.ContextualIdentityService.create(
data.name,
data.icon,
data.color
);
ZenSpacesSyncModel.registerContainerGuid(
guid,
identity.userContextId
);
}
} catch (e) {
fail(record, e);
}
}
const rest = [];
for (const entry of incoming.deleted) {
const mapped = ZenSpacesSyncModel.contextIdForGuid(entry.key);
if (mapped === null) {
rest.push(entry);
continue;
}
try {
if (lazy.ContextualIdentityService.getPublicIdentityFromId(mapped)) {
lazy.ContextualIdentityService.remove(mapped);
}
ZenSpacesSyncModel.forgetContainerGuid(entry.key);
} catch (e) {
fail(entry.record, e);
}
}
return rest;
}
/* Mark: spaces */
async #applySpaces(win, spaces, fail) {
if (!spaces.length) {
return;
}
const list = win.gZenWorkspaces.getWorkspaces();
let changed = false;
const repaint = [];
for (const { data, record } of spaces) {
try {
if (!data.uuid) {
continue;
}
const fields = {
uuid: data.uuid,
name: data.name ?? "",
icon: data.icon ?? undefined,
theme: data.theme ?? null,
containerTabId: this.#resolveContainerId(data.containerGuid),
};
// A space record re-syncs whenever its child ordering changes, so
// only propagate (and especially repaint) when its own fields did.
const index = list.findIndex(s => s.uuid === data.uuid);
const current = index === -1 ? null : list[index];
const visualChanged =
!current ||
(current.name ?? "") !== fields.name ||
(current.icon ?? null) !== (fields.icon ?? null) ||
canonicalJSON(current.theme ?? null) !==
canonicalJSON(fields.theme ?? null);
const containerChanged =
!current || (current.containerTabId ?? 0) !== fields.containerTabId;
if (containerChanged) {
syncLog(
`space ${data.uuid} containerTabId ` +
`${current?.containerTabId ?? 0} -> ${fields.containerTabId} ` +
`(guid ${data.containerGuid ?? "none"})`
);
}
if (!current) {
list.push(fields);
changed = true;
} else if (visualChanged || containerChanged) {
list[index] = { ...current, ...fields };
changed = true;
}
if (visualChanged) {
repaint.push(data.uuid);
}
} catch (e) {
fail(record, e);
}
}
if (changed) {
await win.gZenWorkspaces.propagateWorkspaces(list);
win.gZenWindowSync.propagateWorkspacesToAllWindows(list);
for (const uuid of repaint) {
const updated = list.find(s => s.uuid === uuid);
if (updated) {
win.gZenThemePicker.onWorkspaceChange(updated);
}
}
}
}
async #deleteSpaces(win, removals, fail) {
for (const { key: uuid, record } of removals) {
try {
const spaces = win.gZenWorkspaces.getWorkspaces();
if (!spaces.some(s => s.uuid === uuid)) {
continue;
}
if (spaces.length <= 1) {
// Never delete the last space; the local copy revives it remotely.
continue;
}
if (!(await this.#confirmRemoteSpaceDelete(win, uuid))) {
continue;
}
await win.gZenWorkspaces.removeWorkspace(uuid);
} catch (e) {
fail(record, e);
}
}
}
/**
* @param {Window} win
* @param {string} uuid
* @returns {Promise<boolean>} Whether the user confirmed the deletion.
*/
async #confirmRemoteSpaceDelete(win, uuid) {
const name = win.gZenWorkspaces.getWorkspaceFromId(uuid)?.name || uuid;
const [title, body] = await win.document.l10n.formatValues([
{ id: "zen-workspaces-remote-delete-title" },
{ id: "zen-workspaces-remote-delete-body", args: { name } },
]);
const result = await Services.prompt.asyncConfirmEx(
win.browsingContext,
Services.prompt.MODAL_TYPE_WINDOW,
title,
body,
Services.prompt.STD_YES_NO_BUTTONS,
null,
null,
null,
null,
false
);
return result.get("buttonNumClicked") === 0;
}
/* Mark: folders */
async #applyFolders(win, folders, fail) {
// Parents before children so nesting targets exist.
const depths = new Map(folders.map(f => [f.key, f.data.parentFolderId]));
const depthOf = key => {
let depth = 0;
let parent = depths.get(key);
while (parent && depths.has(parent) && depth < 10) {
depth++;
parent = depths.get(parent);
}
return depth;
};
const ordered = [...folders].sort(
(a, b) => depthOf(a.key) - depthOf(b.key)
);
for (const { key: folderId, data, record } of ordered) {
try {
let folder = this.#itemIn(win, folderId);
if (!folder?.isZenFolder) {
folder = win.gZenFolders.createFolder([], {
id: folderId,
label: data.name || "Folder",
workspaceId:
data.workspaceUuid || win.gZenWorkspaces.activeWorkspace,
isLiveFolder: !!data.live,
});
} else if (data.name && folder.label !== data.name) {
folder.label = data.name;
}
if (data.live && !folder.isLiveFolder) {
// The folder synced before its provider config was available and
// materialized as a plain folder; upgrade it so this profile
// projects the config too instead of clobbering it with live: null.
folder.isLiveFolder = true;
}
if ((folder.iconURL || null) !== (data.icon || null)) {
win.gZenFolders.setFolderUserIcon(folder, data.icon || null);
// Window sync picks this up and mirrors the icon everywhere else.
folder.dispatchEvent(
new win.CustomEvent("TabGroupUpdate", { bubbles: true })
);
}
const desiredParent = data.parentFolderId
? this.#itemIn(win, data.parentFolderId)
: null;
const currentParent = folder.group;
if ((currentParent?.id || null) !== (data.parentFolderId || null)) {
if (desiredParent?.isZenFolder) {
win.gBrowser.zenHandleTabMove(folder, () => {
if (desiredParent.tabs.length) {
desiredParent.tabs[0].after(folder);
} else {
desiredParent.appendChild(folder);
}
});
} else if (!data.parentFolderId && currentParent?.isZenFolder) {
const ws = data.workspaceUuid || win.gZenWorkspaces.activeWorkspace;
const container =
win.gZenWorkspaces.workspaceElement(ws)?.pinnedTabsContainer;
if (container) {
win.gBrowser.zenHandleTabMove(folder, () => {
container.insertBefore(
folder,
container.querySelector(".pinned-tabs-container-separator")
);
});
}
}
}
if (
data.workspaceUuid &&
folder.getAttribute("zen-workspace-id") !== data.workspaceUuid
) {
if (!folder.group) {
win.gZenFolders.changeFolderToSpace(folder, data.workspaceUuid);
} else {
folder.setAttribute("zen-workspace-id", data.workspaceUuid);
}
}
if (data.live) {
await lazy.ZenLiveFoldersManager.adoptSyncedFolder(
folderId,
data.live
);
}
} catch (e) {
fail(record, e);
}
}
}
async #deleteFolders(win, removals, fail) {
for (const { key: folderId, record } of removals) {
try {
const folder = this.#itemIn(win, folderId);
if (!folder?.isZenFolder) {
continue;
}
// Members without their own tombstone survive: unpack, then delete
// the (now empty) folder.
await folder.unpackTabs();
await folder.delete();
} catch (e) {
fail(record, e);
}
}
}
/* Mark: tabs */
#applyTabs(win, tabs, fail) {
for (const { key: tabId, data, record } of tabs) {
try {
if (!data.url || data.url === "about:blank") {
continue;
}
const existing = this.#itemIn(win, tabId);
if (win.gBrowser.isTab(existing)) {
this.#updateTab(win, existing, data);
} else {
this.#createTab(win, tabId, data);
}
} catch (e) {
fail(record, e);
}
}
}
#createTab(win, tabId, data) {
const userContextId = this.#resolveContainerId(data.containerGuid);
syncLog(
`creating tab ${tabId} ` +
`(essential=${!!data.essential}, container=${userContextId})`,
data.url
);
const tab = win.gBrowser.addTrustedTab(data.url, {
createLazyBrowser: true,
inBackground: true,
skipAnimation: true,
skipBackgroundNotify: true,
lazyTabTitle: data.title || undefined,
userContextId,
});
// Setting the sync id before the queued TabOpen handler runs makes
// window sync treat this tab as already replicated.
tab.id = tabId;
tab._zenContentsVisible = true;
this.#updateTabIdentity(win, tab, data);
if (data.essential) {
win.gZenPinnedTabManager.addToEssentials(tab, { replicating: true });
} else {
if (data.workspaceUuid) {
tab.setAttribute("zen-workspace-id", data.workspaceUuid);
}
win.gBrowser.pinTab(tab);
if (data.workspaceUuid) {
win.gZenWorkspaces.moveTabToWorkspace(tab, data.workspaceUuid);
}
const folder = data.folderId && this.#itemIn(win, data.folderId);
if (folder?.isZenFolder) {
folder.addTabs([tab]);
}
}
lazy.ZenWindowSync.on_TabOpen({ target: tab }, { ignoreExistingId: true });
}
/**
* Reconciles the tab's synced identity: pinned initial state, static
* label/icon, the live icon and the default-container marker.
*
* @param {Window} win
* @param {MozTabbrowserTab} tab
* @param {object} data
*/
#updateTabIdentity(win, tab, data) {
const icon = syncableIconUrl(data.icon);
const initial = tab._zenPinnedInitialState;
const identityChanged =
initial?.entry?.url !== data.url ||
(initial?.entry?.title || "") !== (data.title || "");
if (identityChanged || syncableIconUrl(initial?.image || "") !== icon) {
lazy.ZenWindowSync.setPinnedInitialState(
tab,
{ url: data.url, title: data.title || "" },
icon || undefined
);
}
if (identityChanged && !tab.linkedPanel) {
// An unloaded tab has no live state to lose: retarget its lazy session
// state so the next activation loads the new canonical url directly
// instead of whatever the tab was created with.
this.#retargetUnloadedTab(win, tab, data, initial?.entry?.url);
}
const staticLabel =
typeof data.staticLabel === "string" ? data.staticLabel : null;
const localLabel =
typeof tab.zenStaticLabel === "string" ? tab.zenStaticLabel : null;
if (staticLabel !== localLabel) {
tab.zenStaticLabel = staticLabel ?? undefined;
if (staticLabel) {
tab._zenChangeLabelFlag = true;
try {
win.gBrowser._setTabLabel(tab, staticLabel);
} finally {
delete tab._zenChangeLabelFlag;
}
}
}
tab.zenStaticIcon = data.hasStaticIcon && icon ? icon : undefined;
const iconDiffers =
icon && syncableIconUrl(tab.getAttribute("image")) !== icon;
if (iconDiffers && (data.hasStaticIcon || !tab.linkedPanel)) {
try {
syncLog(
`setting synced${data.hasStaticIcon ? " static" : ""} ` +
`icon on tab ${tab.id}`
);
win.gBrowser.setIcon(tab, icon);
lazy.TabStateCache.update(tab.linkedBrowser.permanentKey, {
image: null,
});
} catch (e) {
console.error("ZenSpacesSync: failed to set tab icon", e);
}
}
if (data.defaultContainer) {
tab.setAttribute("zenDefaultUserContextId", "true");
} else {
tab.removeAttribute("zenDefaultUserContextId");
}
}
/**
* Points an unloaded tab's session state at the record's canonical url,
* mirroring ZenPinnedTabManager's reset-to-stored-state shape.
*
* @param {Window} win
* @param {MozTabbrowserTab} tab
* @param {object} data
* @param {?string} previousPinUrl - The pin url before this record applied.
*/
#retargetUnloadedTab(win, tab, data, previousPinUrl) {
try {
const state = JSON.parse(win.SessionStore.getTabState(tab));
const entries = state.entries || [];
let currentUrl = null;
if (entries.length) {
const index = Math.min(
Math.max((state.index || entries.length) - 1, 0),
entries.length - 1
);
currentUrl = entries[index]?.url || null;
}
if (
currentUrl === data.url ||
(currentUrl && previousPinUrl && currentUrl !== previousPinUrl)
) {
return;
}
state.entries = [
{
url: data.url,
title: data.title || "",
triggeringPrincipal_base64: lazy.E10SUtils.serializePrincipal(
Services.scriptSecurityManager.createContentPrincipal(
Services.io.newURI(data.url),
{}
)
),
},
];
state.index = 1;
state.image = syncableIconUrl(data.icon) || undefined;
delete state.scroll;
win.SessionStore.setTabState(tab, state);
} catch (e) {
console.error("ZenSpacesSync: failed to retarget unloaded tab", e);
}
}
/**
* Puts a tab or split group under the folder the record names, or back at
* top level when it names none.
*
* @param {Window} win
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} item
* @param {?string} folderId
*/
#applyFolderMembership(win, item, folderId) {
const currentFolder = item.group?.isZenFolder ? item.group.id : null;
const wantFolder = folderId || null;
if (currentFolder === wantFolder) {
return;
}
const target = wantFolder && this.#itemIn(win, wantFolder);
if (target?.isZenFolder) {
target.addTabs([item]);
} else if (!wantFolder && currentFolder) {
// ungroupTab pops a single nesting level. An item inside a subfolder
// lands inside the parent folder, so keep going until it is actually
// top-level.
while (win.gBrowser.isTabGroup(item.group) && item.group.isZenFolder) {
win.gBrowser.ungroupTab(item);
}
}
}
#updateTab(win, tab, data) {
this.#updateTabIdentity(win, tab, data);
const isEssential = tab.hasAttribute("zen-essential");
if (data.essential && !isEssential) {
syncLog(`incoming record promotes tab ${tab.id} to essential`);
win.gZenPinnedTabManager.addToEssentials(tab, { replicating: true });
return;
}
if (!data.essential && isEssential) {
console.warn(
`ZenSpacesSync: incoming record demotes essential tab ${tab.id}`
);
win.gZenPinnedTabManager.removeEssentials(tab, /* unpin */ false);
}
if (data.essential) {
return;
}
const inSplit = tab.group?.hasAttribute("split-view-group");
if (inSplit) {
// The split record governs placement of its members.
return;
}
this.#applyFolderMembership(win, tab, data.folderId);
if (
!data.folderId &&
data.workspaceUuid &&
tab.getAttribute("zen-workspace-id") !== data.workspaceUuid
) {
win.gZenWorkspaces.moveTabToWorkspace(tab, data.workspaceUuid);
}
}
#removeTab(win, tab) {
if (tab.splitView) {
win.gZenViewSplitter.removeTabFromGroup(tab, undefined, {
forUnsplit: true,
changeTab: false,
});
}
win.gBrowser.removeTab(tab, { animate: true });
}
#deleteTabs(win, removals, fail) {
for (const { key: tabId, record } of removals) {
try {
const tab = this.#itemIn(win, tabId);
if (win.gBrowser.isTab(tab)) {
if (tab.hasAttribute("zen-essential")) {
console.warn(
`ZenSpacesSync: incoming tombstone removes essential tab ${tabId}`,
tab.linkedBrowser?.currentURI?.spec ?? ""
);
} else {
syncLog(`incoming tombstone removes tab ${tabId}`);
}
this.#removeTab(win, tab);
}
} catch (e) {
fail(record, e);
}
}
}
/* Mark: splits */
#applySplits(win, splits, fail) {
for (const { key: splitId, data, record } of splits) {
try {
const members = (data.tabs || [])
.map(id => this.#itemIn(win, id))
.filter(tab => win.gBrowser.isTab(tab));
const existing = this.#itemIn(win, splitId);
const hasGroup = existing?.hasAttribute?.("split-view-group");
if (hasGroup) {
const wanted = new Set(data.tabs || []);
for (const tab of [...existing.tabs]) {
if (tab.id && !wanted.has(tab.id)) {
win.gZenViewSplitter.removeTabFromGroup(tab, undefined, {
changeTab: false,
});
}
}
}
if (members.length >= 2) {
win.gZenViewSplitter.splitTabs(members, data.gridType, -1, {
groupFetchId: splitId,
});
}
const group = this.#itemIn(win, splitId);
if (group?.hasAttribute?.("split-view-group")) {
this.#applyFolderMembership(win, group, data.folderId);
}
} catch (e) {
fail(record, e);
}
}
}
#deleteSplits(win, removals, fail) {
for (const { key: splitId, record } of removals) {
try {
const index = win.gZenViewSplitter._data.findIndex(
group => group.groupId === splitId
);
if (index >= 0) {
win.gZenViewSplitter.removeGroup(index);
}
} catch (e) {
fail(record, e);
}
}
}
/* Mark: ordering */
#applyRelativeOrder(win, elements) {
let prev = null;
for (const el of elements) {
if (!el) {
continue;
}
if (
prev &&
prev.parentNode &&
prev.parentNode === el.parentNode &&
prev.nextElementSibling !== el
) {
win.gBrowser.zenHandleTabMove(el, () => prev.after(el));
}
prev = el;
}
}
/**
* Ordering runs in the first synced window only: every move goes through
* zenHandleTabMove, whose TabMove events window sync replicates into the
* other windows.
*
* @param {Window} win
* @param {object} incoming
* @param {Function} fail
*/
#applyOrdering(win, incoming, fail) {
for (const { key, data, record } of [
...incoming.spaces,
...incoming.folders,
]) {
try {
if (Array.isArray(data.children) && data.children.length) {
// A folder's empty-tab placeholder anchors its first child: without
// it, relative ordering could never move anything to the start.
const folder = data.folderId ? this.#itemIn(win, key) : null;
const start = folder?.isZenFolder
? folder.tabs.find(t => t.hasAttribute("zen-empty-tab"))
: null;
this.#applyRelativeOrder(win, [
start ?? null,
...data.children.map(id => this.#itemIn(win, id)),
]);
}
} catch (e) {
fail(record, e);
}
}
if (!incoming.layout) {
return;
}
const { data, record } = incoming.layout;
try {
for (const tabIds of Object.values(data.essentials || {})) {
if (Array.isArray(tabIds) && tabIds.length > 1) {
this.#applyRelativeOrder(
win,
tabIds.map(id => this.#itemIn(win, id))
);
}
}
if (Array.isArray(data.spaces) && data.spaces.length > 1) {
const current = win.gZenWorkspaces.getWorkspaces();
const byUuid = new Map(current.map(s => [s.uuid, s]));
const ordered = data.spaces
.map(uuid => byUuid.get(uuid))
.filter(Boolean);
for (const space of current) {
if (!data.spaces.includes(space.uuid)) {
ordered.push(space);
}
}
const changedOrder = ordered.some(
(space, i) => current[i]?.uuid !== space.uuid
);
if (changedOrder && ordered.length === current.length) {
win.gZenWindowSync.propagateWorkspacesToAllWindows(ordered);
}
}
} catch (e) {
fail(record, e);
}
}
}
export const ZenSpacesSyncApplier = new nsZenSpacesSyncApplier();

View File

@@ -0,0 +1,722 @@
/* 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 { JSONFile } from "resource://gre/modules/JSONFile.sys.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
ZenSessionStore: "resource:///modules/zen/ZenSessionManager.sys.mjs",
ZenLiveFoldersManager:
"resource:///modules/zen/ZenLiveFoldersManager.sys.mjs",
ContextualIdentityService:
"moz-src:///toolkit/components/contextualidentity/ContextualIdentityService.sys.mjs",
});
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"syncDebug",
"zen.spaces-sync.debug",
false
);
/**
* Debug logging for the whole Spaces sync pipeline.
*
* @param {string} message
* @param {...any} args
*/
export function syncLog(message, ...args) {
if (lazy.syncDebug) {
// eslint-disable-next-line no-console
console.debug(`ZenSpacesSync: ${message}`, ...args);
}
}
export const SIDEBAR_COLLECTED_TOPIC = "zen-sidebar-data-collected";
export const RECORD_KINDS = Object.freeze({
CONTAINER: "container",
SPACE: "space",
TAB: "tab",
FOLDER: "folder",
SPLIT: "split",
LAYOUT: "layout",
});
export const LAYOUT_RECORD_ID = "layout";
// Firefox ships four built-in containers with fixed userContextIds. They
// exist on every device, so they map to well-known guids instead of the
// per-profile generated ones.
const BUILTIN_CONTAINER_MAX = 4;
const BUILTIN_GUID_PREFIX = "builtin-";
const STORE_FILE_NAME = "zen-spaces-sync.json";
const STORE_VERSION = 1;
// Everything setIcon accepts without a loading principal.
const LOCAL_ICON_PROTOCOLS = ["data:", "chrome:", "about:", "resource:"];
/**
* Normalizes a tab icon for syncing. The image attribute can hold a
* moz-remote-image: wrapper around the actual (svg data:) favicon, carrying
* a per-process contentParentId that means nothing on another device — sync
* the wrapped URL instead. Anything else setIcon would reject as remote is
* dropped.
*
* @param {?string} icon
* @returns {string}
*/
export function syncableIconUrl(icon) {
if (!icon || typeof icon !== "string") {
return "";
}
if (icon.startsWith("moz-remote-image:")) {
try {
const uri = Services.io.newURI(icon);
icon = new URLSearchParams(uri.query).get("url") || "";
} catch (e) {
return "";
}
}
return LOCAL_ICON_PROTOCOLS.some(protocol => icon.startsWith(protocol))
? icon
: "";
}
function sortedClone(value) {
if (Array.isArray(value)) {
return value.map(sortedClone);
}
if (value && typeof value === "object") {
const out = {};
for (const key of Object.keys(value).sort()) {
out[key] = sortedClone(value[key]);
}
return out;
}
return value === undefined ? null : value;
}
/**
* Deterministic JSON serialization (recursively sorted keys), so that two
* structurally equal payloads always stringify identically.
*
* @param {object} value
*/
export function canonicalJSON(value) {
return JSON.stringify(sortedClone(value));
}
/**
* Digest of a record's canonical form. The uploaded-state snapshot only ever
* needs equality checks, so it stores these short digests instead of the
* full canonical strings, keeping the store file small and cheap to write.
*
* @param {string} kind
* @param {object} data
* @returns {string} base64 SHA-256 of the canonical record payload.
*/
const textEncoder = new TextEncoder();
export function recordDigest(kind, data) {
const hasher = Cc["@mozilla.org/security/hash;1"].createInstance(
Ci.nsICryptoHash
);
hasher.init(Ci.nsICryptoHash.SHA256);
const bytes = textEncoder.encode(canonicalJSON({ kind, data }));
hasher.update(bytes, bytes.length);
return hasher.finish(/* base64 = */ true);
}
/**
* Owns the synced-data model for the Spaces engine.
*/
class nsZenSpacesSyncModel {
#file = null;
#cache = null;
#data() {
if (!this.#file) {
this.#file = new JSONFile({
path: PathUtils.join(PathUtils.profileDir, STORE_FILE_NAME),
dataPostProcessor: data => {
if (data.version !== STORE_VERSION) {
// Drop the uploaded snapshot so everything re-diffs against
// the (also wiped) server, but keep the container identity mappings.
data.uploaded = {};
}
data.version = STORE_VERSION;
data.uploaded ||= {};
data.containers ||= {};
// The container map used to be keyed by guid. It is keyed by
// userContextId now so that registering a guid replaces any
// previous one for the same container. Flip old stores over
// (old values are numeric ids, new values are guid strings).
for (const [key, value] of Object.entries(data.containers)) {
if (typeof value === "number") {
delete data.containers[key];
data.containers[value] = key;
}
}
return data;
},
});
this.#file.ensureDataReady();
}
return this.#file.data;
}
invalidate() {
this.#cache = null;
}
/* Mark: container identity */
guidForContextId(userContextId, { create = false } = {}) {
const id = Number(userContextId);
if (!Number.isSafeInteger(id) || id <= 0) {
return null;
}
if (id <= BUILTIN_CONTAINER_MAX) {
return `${BUILTIN_GUID_PREFIX}${id}`;
}
const data = this.#data();
const existing = data.containers[id];
if (existing) {
return existing;
}
if (!create) {
return null;
}
const guid = Services.uuid.generateUUID().toString().slice(1, -1);
data.containers[id] = guid;
this.#file.saveSoon();
return guid;
}
contextIdForGuid(guid) {
if (typeof guid !== "string" || !guid) {
return null;
}
if (guid.startsWith(BUILTIN_GUID_PREFIX)) {
const id = Number(guid.slice(BUILTIN_GUID_PREFIX.length));
return Number.isSafeInteger(id) && id > 0 && id <= BUILTIN_CONTAINER_MAX
? id
: null;
}
for (const [id, mapped] of Object.entries(this.#data().containers)) {
if (mapped === guid) {
return Number(id);
}
}
return null;
}
/**
* Adopts an incoming guid as the identity's synced name.
*
* @param {string} guid
* @param {number} userContextId
*/
registerContainerGuid(guid, userContextId) {
if (guid.startsWith(BUILTIN_GUID_PREFIX)) {
return;
}
this.#data().containers[userContextId] = guid;
this.#file.saveSoon();
}
forgetContainerGuid(guid) {
const data = this.#data();
for (const [id, mapped] of Object.entries(data.containers)) {
if (mapped === guid) {
delete data.containers[id];
this.#file.saveSoon();
return;
}
}
}
/* Mark: projections */
#isSyncableTab(tabData) {
return !!(
tabData &&
tabData.zenSyncId &&
(tabData.pinned || tabData.zenEssential) &&
!tabData.zenIsEmpty &&
!tabData.zenIsGlance &&
!tabData.zenLiveFolderItemId
);
}
#tabIdentity(tabData) {
const initial = tabData._zenPinnedInitialState;
let url = initial?.entry?.url;
let title = initial?.entry?.title;
if (!url || url === "about:blank") {
const entries = tabData.entries || [];
if (entries.length) {
let index = Math.min(
Math.max((tabData.index || entries.length) - 1, 0),
entries.length - 1
);
const entry = entries[index] || entries[0];
if (entry?.url && entry.url !== "about:blank") {
url = entry.url;
title ??= entry.title;
}
}
}
if (!url || url === "about:blank") {
return null;
}
const icon = syncableIconUrl(
(tabData.zenHasStaticIcon
? tabData.image
: initial?.image || tabData.image) || ""
);
return { url, title: title || "", icon };
}
/**
* Builds the ordered child id list for one parent (a space's pinned
* section or a folder). Tabs and splits are ordered by their position in
* the collected tab list; folders are spliced in next to their recorded
* previous sibling.
*
* @param {object} root0
* @param {Array<object>} root0.tabs
* @param {Array<object>} root0.folders
* @param {Set<string>} root0.splitIds
* @param {Map<string, ?string>} root0.splitParents
* @param {Map<string, ?string>} root0.splitWs
* @param {object} root0.scope
*/
#childSequence({ tabs, folders, splitIds, splitParents, splitWs, scope }) {
const seq = [];
const seenSplits = new Set();
for (const tab of tabs) {
const groupId = tab.groupId || null;
if (groupId && splitIds.has(groupId)) {
if (
!seenSplits.has(groupId) &&
scope.matchSplit(
splitParents.get(groupId) ?? null,
splitWs.get(groupId) ?? null
)
) {
seenSplits.add(groupId);
seq.push(groupId);
}
continue;
}
if (scope.matchTab(tab, groupId)) {
seq.push(tab.zenSyncId);
}
}
for (const folder of folders) {
if (!scope.matchFolder(folder)) {
continue;
}
let index = seq.length;
const prev = folder.prevSiblingInfo;
if (!prev || prev.type === "start") {
// No stored sibling means the folder is the first child of its
// container (the section rebuild on startup places content before
// the fake start element, so the first item has no sibling at all).
index = 0;
} else if (prev.id) {
const at = seq.indexOf(prev.id);
if (at !== -1) {
index = at + 1;
} else if (prev.type === "tab") {
// An unsynced tab sibling (empty placeholders, live folder items)
// always sits at the start of its container.
index = 0;
}
}
seq.splice(index, 0, folder.id);
}
return seq;
}
/**
* Derives the shared projection context from one sidebar collection.
* Syncable tabs, folders, the syncable splits and their parents and
* workspaces, plus the folder-membership resolver.
*
* @param {object} sidebar
*/
#projectionContext(sidebar) {
const tabs = (sidebar.tabs || []).filter(t => this.#isSyncableTab(t));
const folders = (sidebar.folders || []).filter(
f => f?.id && !f.splitViewGroup
);
const folderIds = new Set(folders.map(f => f.id));
const allSplits = (sidebar.splitViewData || []).filter(g => g?.groupId);
const splitParents = new Map();
for (const entry of sidebar.folders || []) {
if (entry?.splitViewGroup && entry.id) {
splitParents.set(entry.id, entry.parentId || null);
}
}
// Only splits whose members are all synced tabs are synced themselves.
const syncableTabIds = new Set(tabs.map(t => t.zenSyncId));
const splits = allSplits.filter(
g =>
Array.isArray(g.tabs) &&
g.tabs.length >= 2 &&
g.tabs.every(id => syncableTabIds.has(id))
);
const splitIds = new Set(splits.map(g => g.groupId));
const splitWs = new Map();
for (const tab of tabs) {
const groupId = tab.groupId || null;
if (groupId && splitIds.has(groupId) && !splitWs.has(groupId)) {
splitWs.set(groupId, tab.zenWorkspace || null);
}
}
const folderOf = groupId => {
if (!groupId) {
return null;
}
if (splitIds.has(groupId)) {
return splitParents.get(groupId) || null;
}
return folderIds.has(groupId) ? groupId : null;
};
return { tabs, folders, splits, splitIds, splitParents, splitWs, folderOf };
}
/**
* @param {Map} map
* @param {object} ctx - The projection context.
*/
#projectTabs(map, ctx) {
for (const tab of ctx.tabs) {
const identity = this.#tabIdentity(tab);
if (!identity) {
continue;
}
const essential = !!tab.zenEssential;
map.set(tab.zenSyncId, {
kind: RECORD_KINDS.TAB,
data: {
tabId: tab.zenSyncId,
url: identity.url,
title: identity.title,
icon: identity.icon,
containerGuid: this.guidForContextId(tab.userContextId, {
create: true,
}),
essential,
workspaceUuid: essential ? null : tab.zenWorkspace || null,
folderId: ctx.folderOf(tab.groupId || null),
staticLabel:
typeof tab.zenStaticLabel === "string" ? tab.zenStaticLabel : null,
hasStaticIcon: !!tab.zenHasStaticIcon,
defaultContainer: !!tab.zenDefaultUserContextId,
},
});
}
}
projections() {
const sidebar = lazy.ZenSessionStore.getSidebarData() || {};
const stamp = sidebar.lastCollected || 0;
if (this.#cache && this.#cache.stamp === stamp) {
return this.#cache.map;
}
const map = new Map();
const pending = new Set();
const ctx = this.#projectionContext(sidebar);
const { tabs, folders, splits, splitIds, splitParents, splitWs } = ctx;
for (const identity of lazy.ContextualIdentityService.getPublicIdentities()) {
if (!identity.name) {
continue;
}
const guid = this.guidForContextId(identity.userContextId, {
create: true,
});
if (!guid) {
continue;
}
map.set(guid, {
kind: RECORD_KINDS.CONTAINER,
data: {
guid,
name: identity.name,
icon: identity.icon || "",
color: identity.color || "",
},
});
}
const spaces = sidebar.spaces || [];
for (const space of spaces) {
if (!space?.uuid) {
continue;
}
const uuid = space.uuid;
map.set(uuid, {
kind: RECORD_KINDS.SPACE,
data: {
uuid,
name: space.name ?? "",
icon: space.icon ?? null,
theme: space.theme ?? null,
containerGuid: this.guidForContextId(space.containerTabId, {
create: true,
}),
children: this.#childSequence({
tabs,
folders,
splitIds,
splitParents,
splitWs,
scope: {
matchTab: (t, groupId) =>
!groupId &&
!t.zenEssential &&
(t.zenWorkspace || null) === uuid,
matchSplit: (parent, ws) => !parent && ws === uuid,
matchFolder: f => !f.parentId && (f.workspaceId || null) === uuid,
},
}),
},
});
}
for (const folder of folders) {
const fid = folder.id;
let live = null;
if (folder.isLiveFolder) {
try {
live = lazy.ZenLiveFoldersManager.getSyncableFolderData(fid);
} catch (e) {
console.error("ZenSpacesSync: failed to project live folder", e);
}
if (!live) {
pending.add(fid);
continue;
}
}
map.set(fid, {
kind: RECORD_KINDS.FOLDER,
data: {
folderId: fid,
name: folder.name ?? "",
icon: folder.userIcon || null,
workspaceUuid: folder.workspaceId || null,
parentFolderId: folder.parentId || null,
live,
children: this.#childSequence({
tabs,
folders,
splitIds,
splitParents,
splitWs,
scope: {
matchTab: (t, groupId) => groupId === fid,
matchSplit: parent => parent === fid,
matchFolder: f => f.parentId === fid,
},
}),
},
});
}
this.#projectTabs(map, ctx);
for (const split of splits) {
map.set(split.groupId, {
kind: RECORD_KINDS.SPLIT,
data: {
splitId: split.groupId,
gridType: split.gridType || "grid",
tabs: [...split.tabs],
workspaceUuid: splitWs.get(split.groupId) ?? null,
folderId: splitParents.get(split.groupId) || null,
},
});
}
if (spaces.length) {
const essentials = {};
for (const tab of tabs) {
if (!tab.zenEssential) {
continue;
}
const key =
this.guidForContextId(tab.userContextId, { create: true }) ||
"default";
(essentials[key] ||= []).push(tab.zenSyncId);
}
map.set(LAYOUT_RECORD_ID, {
kind: RECORD_KINDS.LAYOUT,
data: {
spaces: spaces.map(s => s.uuid).filter(Boolean),
essentials,
},
});
}
this.#cache = { stamp, map, pending };
return map;
}
/**
* Ids that are locally present but deliberately not projected this cycle
* (live folders whose provider config isn't available yet). The diff must
* not read their absence as a deletion.
*/
#pendingIds() {
this.projections();
return this.#cache.pending;
}
#digestCache = null;
/**
* Digests of all current projections. Change detection runs several times per
* sync cycle (tracker observe, changed-id computation, upload bookkeeping)
* and only needs to hash again after a fresh sidebar collection.
*/
#digestAll() {
const map = this.projections();
if (this.#digestCache?.map === map) {
return this.#digestCache.digests;
}
const digests = new Map();
for (const [id, projected] of map) {
digests.set(id, recordDigest(projected.kind, projected.data));
}
this.#digestCache = { map, digests };
return digests;
}
/* Mark: engine-facing API */
getAllRecordIds() {
const ids = {};
for (const id of this.projections().keys()) {
ids[id] = true;
}
return ids;
}
itemExists(id) {
return this.projections().has(id);
}
projectRecord(id) {
return this.projections().get(id) ?? null;
}
/**
* Changes = diff between the current projections and the last state the
* server acknowledged. Ids present locally with different content are
* modified; ids only present in the uploaded snapshot are deletions.
*/
computeChangedIDs() {
const uploaded = this.#data().uploaded;
const current = this.#digestAll();
const pending = this.#pendingIds();
const now = Date.now() / 1000;
const changes = {};
for (const [id, digest] of current) {
if (uploaded[id] !== digest) {
changes[id] = now;
}
}
for (const id of Object.keys(uploaded)) {
if (!current.has(id) && !pending.has(id)) {
changes[id] = now;
}
}
if (lazy.syncDebug && Object.keys(changes).length) {
const map = this.projections();
syncLog(
"outgoing diff:",
Object.keys(changes).map(id =>
current.has(id) ? `${map.get(id)?.kind} ${id}` : `TOMBSTONE ${id}`
)
);
}
return changes;
}
hasPendingChanges() {
const uploaded = this.#data().uploaded;
const current = this.#digestAll();
const pending = this.#pendingIds();
for (const [id, digest] of current) {
if (uploaded[id] !== digest) {
return true;
}
}
for (const id of Object.keys(uploaded)) {
if (!current.has(id) && !pending.has(id)) {
return true;
}
}
return false;
}
/**
* Called after records were successfully uploaded. Remember exactly what
* the server now holds for them.
*
* @param {Array<string>} ids
*/
markUploaded(ids) {
const data = this.#data();
const current = this.#digestAll();
for (const id of ids) {
if (current.has(id)) {
data.uploaded[id] = current.get(id);
} else {
delete data.uploaded[id];
}
}
if (lazy.syncDebug && ids.length) {
syncLog(
"server acknowledged upload:",
ids.map(id => (current.has(id) ? id : `${id} (tombstone)`))
);
}
this.#file.saveSoon();
}
/**
* Called after an incoming record was applied locally. Storing the
* incoming payload as the uploaded state means a faithful local
* materialization produces no re-upload, while a divergent one re-uploads
* the local truth (self-healing).
*
* @param {string} id
* @param {?object} cleartext - null for tombstones.
*/
noteApplied(id, cleartext) {
const data = this.#data();
if (!cleartext) {
delete data.uploaded[id];
} else {
data.uploaded[id] = recordDigest(cleartext.kind, cleartext.data);
}
syncLog(
`acknowledged incoming ${cleartext ? cleartext.kind : "tombstone"} ${id}`
);
this.#file.saveSoon();
}
}
export const ZenSpacesSyncModel = new nsZenSpacesSyncModel();

View File

@@ -1,160 +0,0 @@
/* 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/. */
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
ZenSessionStore: "resource:///modules/zen/ZenSessionManager.sys.mjs",
ContextualIdentityService:
"resource://gre/modules/ContextualIdentityService.sys.mjs",
ZenWindowSync: "resource:///modules/zen/ZenWindowSync.sys.mjs",
});
function normalizeUserContextId(value) {
const normalized = typeof value === "string" ? Number(value) : value;
if (!Number.isSafeInteger(normalized) || normalized <= 0) {
return null;
}
return normalized;
}
class ZenSyncManager {
getSidebarData() {
return lazy.ZenSessionStore.getSidebarData();
}
/**
* Whether to ignore changes to items. This is used to prevent
* infinite loops when applying incoming sync changes.
*
* @type {boolean}
*/
#ignoreChanges = false;
#changedItems = new Map();
markItemChanged(item) {
if (item.type && item.id && !this.#ignoreChanges) {
const key = `${item.type}~${item.id}`;
this.#changedItems.set(key, { type: item.type, id: item.id });
}
}
#getChangedItems() {
return Array.from(this.#changedItems.values());
}
#clearChangedItems() {
this.#changedItems.clear();
}
notifyAboutChanges() {
const changedItems = this.#getChangedItems();
for (const item of changedItems) {
Services.obs.notifyObservers(
{ wrappedJSObject: item },
"zen-workspace-item-changed"
);
}
this.#clearChangedItems();
}
async applyIncomingBatch(pulled, removals) {
try {
this.#ignoreChanges = true;
this.#applyIncomingContainers(
pulled.containers || [],
removals.containers || []
);
const win = lazy.ZenWindowSync.firstSyncedWindow;
if (win?.gZenWorkspaces) {
await win.gZenWorkspaces._applySyncChanges(pulled, removals);
}
} catch (e) {
console.error("ZenSyncManager: Failed to apply incoming sync data:", e);
throw e;
} finally {
this.#ignoreChanges = false;
}
}
#applyIncomingContainers(pulledContainers, removedContainers) {
const localContainersById = new Map(
lazy.ContextualIdentityService.getPublicIdentities().map(container => [
container.userContextId,
container,
])
);
for (const container of pulledContainers) {
if (!container.name) {
continue;
}
const userContextId = normalizeUserContextId(container.userContextId);
if (userContextId === null) {
console.warn(
"ZenSyncManager: Ignoring incoming container with invalid userContextId",
{ container }
);
continue;
}
const existsLocally = localContainersById.has(userContextId);
if (existsLocally) {
lazy.ContextualIdentityService.update(
userContextId,
container.name,
container.icon,
container.color
);
continue;
}
const createdIdentity = lazy.ContextualIdentityService.create(
container.name,
container.icon,
container.color,
userContextId
);
if (createdIdentity) {
localContainersById.set(createdIdentity.userContextId, createdIdentity);
}
if (createdIdentity && createdIdentity.userContextId !== userContextId) {
console.warn("ZenSyncManager: Container sync created unexpected ID", {
requestedId: userContextId,
createdId: createdIdentity.userContextId,
name: container.name,
});
}
}
for (const container of removedContainers) {
const userContextId = normalizeUserContextId(container.userContextId);
if (userContextId === null) {
console.warn(
"ZenSyncManager: Ignoring container removal with invalid userContextId",
{ container }
);
continue;
}
if (!localContainersById.has(userContextId)) {
continue;
}
try {
lazy.ContextualIdentityService.remove(userContextId);
localContainersById.delete(userContextId);
} catch {
// Container may already be gone locally.
}
}
}
}
export const ZenSyncStore = new ZenSyncManager();

View File

@@ -1,404 +0,0 @@
/* 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 {
Store,
SyncEngine,
Tracker,
} from "resource://services-sync/engines.sys.mjs";
import { CryptoWrapper } from "resource://services-sync/record.sys.mjs";
import { SCORE_INCREMENT_XLARGE } from "resource://services-sync/constants.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
ZenSyncStore: "resource:///modules/zen/ZenSyncManager.sys.mjs",
ContextualIdentityService:
"resource://gre/modules/ContextualIdentityService.sys.mjs",
});
const RECORD_ID_PREFIX_BY_TYPE = Object.freeze({
space: "s",
container: "c",
});
const RECORD_TYPE_BY_PREFIX = Object.freeze({
s: "space",
c: "container",
});
/**
* Sync record wrapper for workspace and container items stored in the
* Workspaces engine collection.
*/
export class ZenWorkspacesRecord extends CryptoWrapper {
_logName = "Sync.Record.ZenSpaces";
}
ZenWorkspacesRecord.prototype.type = "spaces";
function parseRecordId(id) {
const sep = id.indexOf("~");
if (sep === -1) {
return null;
}
const prefix = id.slice(0, sep);
const key = id.slice(sep + 1);
return { type: RECORD_TYPE_BY_PREFIX[prefix] || prefix, key };
}
function createRecordId(type, id) {
const prefix = RECORD_ID_PREFIX_BY_TYPE[type];
if (!prefix) {
throw new Error(`Unknown Spaces Sync record type: ${type}`);
}
return `${prefix}~${id}`;
}
function normalizeUserContextId(value) {
const normalized = typeof value === "string" ? Number(value) : value;
if (!Number.isSafeInteger(normalized) || normalized <= 0) {
return null;
}
return normalized;
}
/**
* Strips the sync-envelope fields (`id` and `type`) from incoming record data
* and restores the item's real identity key where needed
*
* @param {object} data
*/
function stripSyncFields(data) {
const rest = { ...data };
delete rest.id;
delete rest.type;
return rest;
}
/**
* Sync store implementation that serializes local workspace and container
* state into records and applies incoming remote changes.
*/
class ZenWorkspacesStore extends Store {
constructor(name, engine) {
super(name, engine);
}
async getAllIDs() {
const ids = {};
const sidebar = lazy.ZenSyncStore.getSidebarData();
for (const space of sidebar.spaces || []) {
if (space.uuid) {
ids[createRecordId("space", space.uuid)] = true;
}
}
for (const c of lazy.ContextualIdentityService.getPublicIdentities()) {
ids[createRecordId("container", c.userContextId)] = true;
}
return ids;
}
async itemExists(id) {
const parsed = parseRecordId(id);
if (!parsed) {
return false;
}
const sidebar = lazy.ZenSyncStore.getSidebarData();
switch (parsed.type) {
case "space":
return (sidebar.spaces || []).some(s => s.uuid === parsed.key);
case "container":
return lazy.ContextualIdentityService.getPublicIdentities().some(
c => String(c.userContextId) === parsed.key
);
default:
return false;
}
}
async createRecord(id, collection) {
const record = new ZenWorkspacesRecord(collection, id);
const parsed = parseRecordId(id);
if (!parsed) {
record.deleted = true;
return record;
}
const sidebar = lazy.ZenSyncStore.getSidebarData();
switch (parsed.type) {
case "space": {
const spaces = sidebar.spaces || [];
const idx = spaces.findIndex(s => s.uuid === parsed.key);
if (idx === -1) {
record.deleted = true;
return record;
}
const rest = { ...spaces[idx] };
delete rest.syncStatus;
record.cleartext = { id, type: "space", ...rest, position: idx };
break;
}
case "container": {
const container =
lazy.ContextualIdentityService.getPublicIdentities().find(
c => String(c.userContextId) === parsed.key
);
if (!container) {
record.deleted = true;
return record;
}
record.cleartext = {
id,
type: "container",
userContextId: container.userContextId,
name: container.name,
icon: container.icon,
color: container.color,
};
break;
}
default:
record.deleted = true;
}
return record;
}
async applyIncomingBatch(records, _countTelemetry) {
const pulled = { spaces: [], containers: [] };
const removals = { spaces: [], containers: [] };
for (const record of records) {
if (record.deleted) {
this._collectRemoval(record.id, removals);
continue;
}
const data = record.cleartext;
if (!data?.type) {
continue;
}
const clean = stripSyncFields(data);
switch (data.type) {
case "space":
pulled.spaces.push(clean);
break;
case "container":
pulled.containers.push(clean);
break;
}
}
// Suppress change tracking while applying incoming data to prevent
// feedback loops where applied items get re-uploaded immediately.
this.engine._tracker.ignoreAll = true;
try {
await lazy.ZenSyncStore.applyIncomingBatch(pulled, removals);
} finally {
this.engine._tracker.ignoreAll = false;
}
return [];
}
_collectRemoval(id, removals) {
const parsed = parseRecordId(id);
if (!parsed) {
return;
}
switch (parsed.type) {
case "space":
removals.spaces.push({ uuid: parsed.key });
break;
case "container": {
const userContextId = normalizeUserContextId(parsed.key);
if (userContextId === null) {
console.warn(
"ZenWorkspacesStore: Ignoring container removal with invalid userContextId",
{ id }
);
break;
}
removals.containers.push({ userContextId });
break;
}
}
}
async create(record) {
await this._applySingle(record);
}
async update(record) {
await this._applySingle(record);
}
async _applySingle(record) {
this.engine._tracker.ignoreAll = true;
try {
if (record.deleted) {
const removals = { spaces: [], containers: [] };
this._collectRemoval(record.id, removals);
await lazy.ZenSyncStore.applyIncomingBatch(
{ spaces: [], containers: [] },
removals
);
return;
}
const data = record.cleartext;
if (!data?.type) {
return;
}
const clean = stripSyncFields(data);
const pulled = { spaces: [], containers: [] };
switch (data.type) {
case "space":
pulled.spaces.push(clean);
break;
case "container":
pulled.containers.push(clean);
break;
}
await lazy.ZenSyncStore.applyIncomingBatch(pulled, {
spaces: [],
containers: [],
});
} finally {
this.engine._tracker.ignoreAll = false;
}
}
async remove() {
// No-op: never delete user data on wipe
}
async wipe() {
// No-op: never delete user data on wipe
}
changeItemID() {
// No-op
}
}
/**
* Sync tracker that watches workspace and contextual identity observers and
* marks the corresponding record IDs as changed.
*/
class ZenWorkspacesTracker extends Tracker {
#changedIDs = {};
#ignoreAll = false;
get ignoreAll() {
return this.#ignoreAll;
}
set ignoreAll(value) {
this.#ignoreAll = value;
}
onStart() {
Services.obs.addObserver(this, "zen-workspace-item-changed");
Services.obs.addObserver(this, "contextual-identity-created");
Services.obs.addObserver(this, "contextual-identity-updated");
Services.obs.addObserver(this, "contextual-identity-deleted");
}
onStop() {
Services.obs.removeObserver(this, "zen-workspace-item-changed");
Services.obs.removeObserver(this, "contextual-identity-created");
Services.obs.removeObserver(this, "contextual-identity-updated");
Services.obs.removeObserver(this, "contextual-identity-deleted");
}
observe(subject, topic, _data) {
if (this.#ignoreAll) {
return;
}
if (topic === "zen-workspace-item-changed") {
const type = subject?.wrappedJSObject?.type;
const id = subject?.wrappedJSObject?.id;
if (type && id) {
this._trackChange({ type, id });
}
} else if (topic.startsWith("contextual-identity-")) {
const id = subject?.wrappedJSObject?.userContextId;
if (id && normalizeUserContextId(id) !== null) {
this._trackChange({ type: "container", id });
}
}
}
_trackChange(data) {
if (data.type && data.id) {
const id = createRecordId(data.type, data.id);
this.#changedIDs[id] = Date.now() / 1000;
this.score += SCORE_INCREMENT_XLARGE;
}
}
async getChangedIDs() {
return { ...this.#changedIDs };
}
async addChangedID(id, when) {
this.#changedIDs[id] = when;
return true;
}
async removeChangedID(...ids) {
for (const id of ids) {
delete this.#changedIDs[id];
}
return true;
}
clearChangedIDs() {
this.#changedIDs = {};
}
}
/**
* Sync engine entrypoint that wires the Workspaces record, store, and tracker
* implementations into Firefox Sync.
*/
export class ZenWorkspacesEngine extends SyncEngine {
static get name() {
return "Spaces";
}
constructor(service) {
super("Spaces", service);
}
get _storeObj() {
return ZenWorkspacesStore;
}
get _trackerObj() {
return ZenWorkspacesTracker;
}
get _recordObj() {
return ZenWorkspacesRecord;
}
get version() {
return 2;
}
get syncPriority() {
return 8;
}
get allowSkippedRecord() {
return false;
}
}

View File

@@ -3,6 +3,7 @@
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
EXTRA_JS_MODULES.zen += [
"ZenSyncManager.sys.mjs",
"ZenWorkspacesSync.sys.mjs",
"ZenSpacesSync.sys.mjs",
"ZenSpacesSyncApplier.sys.mjs",
"ZenSpacesSyncModel.sys.mjs",
]

View File

@@ -503,7 +503,7 @@ class nsZenPinnedTabManager extends nsZenDOMOperatedFeature {
}
}
addToEssentials(tab) {
addToEssentials(tab, { replicating = false } = {}) {
// eslint-disable-next-line no-nested-ternary
const tabs = tab
? // if it's already an array, dont make it [tab]
@@ -518,7 +518,12 @@ class nsZenPinnedTabManager extends nsZenDOMOperatedFeature {
// eslint-disable-next-line no-shadow
let tab = tabs[i];
const section = gZenWorkspaces.getEssentialsSection(tab);
if (!this.canEssentialBeAdded(tab)) {
// canEssentialBeAdded gates user-initiated adds on the *active* space's
// container. A replicated add mirrors a decision already made in another
// window or on another device, always into the tab's own container
// section.
if (!replicating && !this.canEssentialBeAdded(tab)) {
this.log(`addToEssentials rejected for ${tab.id}`);
movedAll = false;
continue;
}
@@ -570,6 +575,13 @@ class nsZenPinnedTabManager extends nsZenDOMOperatedFeature {
for (let i = 0; i < tabs.length; i++) {
// eslint-disable-next-line no-shadow
const tab = tabs[i];
if (this._canLog) {
// eslint-disable-next-line no-console
console.trace(
`ZenPinnedTabManager: removing tab ${tab.id} from essentials ` +
`(unpin=${unpin})`
);
}
tab.removeAttribute("zen-essential");
if (
gZenWorkspaces.workspaceEnabled &&

View File

@@ -538,8 +538,7 @@
:root[zen-single-toolbar="true"]:not([zen-has-empty-tab="true"]) & {
& #urlbar:not([breakout-extend]) .urlbar-input-container {
padding-left: 8px;
padding-right: 4px;
padding-inline: 4px;
}
}
}

View File

@@ -3,6 +3,36 @@
# 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/.
[alerts]
source = "browser/base/content/test/alerts"
is_direct_path = true
[backforward]
source = "browser/base/content/test/backforward"
is_direct_path = true
[caps]
source = "browser/base/content/test/caps"
is_direct_path = true
[gesture]
source = "browser/base/content/test/gesture"
is_direct_path = true
[gesture.replace-manifest]
"[DEFAULT]" = '''[DEFAULT]
prefs = [
"zen.window-sync.enabled=false",
]'''
[linkHandling]
source = "browser/base/content/test/linkHandling"
is_direct_path = true
disable = [
# Glance opens with alt+click, firefox doesnt know that.
"browser_javascript_links.js",
]
[readermode]
source = "toolkit/components/reader/tests/browser"
is_direct_path = true
@@ -13,10 +43,88 @@ disable = [
"browser_readerMode_colorSchemePref.js",
]
[remote-settings]
source = "services/settings/test/unit"
is_direct_path = true
xpcshell = true
[remote-settings.replace-manifest]
"../../../" = "../../../../services/"
[safebrowsing]
source = "browser/components/safebrowsing/content/test"
is_direct_path = true
[services-crypto]
source = "services/crypto/tests/unit"
is_direct_path = true
xpcshell = true
[services-crypto.replace-manifest]
"../../../" = "../../../../services/"
[sessionstore]
source = "browser/components/sessionstore/test"
is_direct_path = true
disable = [
"browser_1933485_tab_groups_history.js",
"browser_1953801_tab_groups_history_close_other.js",
"browser_aboutSessionRestore.js",
"browser_bfcache_telemetry.js",
"browser_closedId.js",
"browser_closed_objects_changed_notifications_tabs.js",
"browser_closed_objects_changed_notifications_windows.js",
"browser_closed_tabs_closed_windows.js",
"browser_closed_tabs_windows.js",
"browser_cookies_partitioned.js",
"browser_crashedTabs.js",
"browser_dying_cache.js",
"browser_firefoxView_restore.js",
"browser_firefoxView_selected_restore.js",
"browser_forget_closed_tab_window_byId.js",
"browser_movePendingTabToNewWindow.js",
"browser_newtab_userTypedValue.js",
"browser_pinned_tabs.js",
"browser_purge_domaindata.js",
"browser_reopen_all_windows.js",
"browser_restoreLastActionCorrectOrder.js",
"browser_restoreLastClosedTabOrWindowOrSession.js",
"browser_restore_container_tabs_oa.js",
"browser_restore_tabless_window.js",
"browser_restore_verticalPinnedTabs.js",
"browser_restored_window_features.js",
"browser_scrollPositions.js",
"browser_scrollPositionsReaderMode.js",
"browser_searchModeSwitcher_restore.js",
"browser_speculative_connect.js",
"browser_splitview_integer_ids.js",
"browser_splitview_restore_in_closed_window.js",
"browser_splitview_string_migration.js",
"browser_swapDocShells.js",
"browser_tab_groups_restore_closed_in_closed_window.js",
"browser_tab_groups_restore_closed_in_open_window.js",
"browser_tab_groups_restore_closed_many_tabs.js",
"browser_tab_groups_closed.js",
"browser_tab_groups_restore_multiple.js",
"browser_tab_groups_restore_saved.js",
"browser_tab_groups_restore_simple.js",
"browser_tab_groups_save_on_removeAllTabsBut.js",
"browser_tab_groups_save_on_removeTabsToTheEnd.js",
"browser_tab_groups_save_on_removeTabsToTheStart.js",
"browser_tab_groups_save_on_window_close.js",
"browser_tab_groups_state.js",
"browser_tab_groups_undo.js",
"browser_tab_label_during_restore.js",
"browser_undoCloseById.js",
"browser_urlbarSearchMode.js",
"browser_windowStateContainer.js",
"browser_wireframe_basic.js",
]
[sessionstore.replace-manifest]
'prefs = [' = '''prefs = [
"zen.window-sync.enabled=false",'''
[shell]
source = "browser/components/shell/test"
is_direct_path = true
@@ -25,6 +133,15 @@ disable = [
"browser_setDesktopBackgroundPreview.js",
]
[sync]
source = "services/sync/tests/unit"
is_direct_path = true
xpcshell = true
[sync.replace-manifest]
"../../../" = "../../../../services/"
'"identity.fxaccounts.enabled=true"' = '"identity.fxaccounts.enabled=true", "browser.formfill.enable=true"'
[tabMediaIndicator]
source = "browser/components/tabbrowser/test/browser/tabMediaIndicator"
is_direct_path = true
@@ -34,3 +151,10 @@ source = "toolkit/components/tooltiptext"
disable = [
"browser_input_file_tooltips.js",
]
[zoom]
source = "browser/base/content/test/zoom"
is_direct_path = true
[zoom.replace-manifest]
"../general/" = "../../../../browser/base/content/test/general/"

View File

@@ -0,0 +1,32 @@
[DEFAULT]
support-files = [
"head.js",
"file_dom_notifications.html",
]
["browser_notification_close.js"]
https_first_disabled = true
skip-if = [
"os == 'win'", # Bug 1227785
]
["browser_notification_do_not_disturb.js"]
https_first_disabled = true
["browser_notification_open_settings.js"]
https_first_disabled = true
skip-if = [
"os == 'win'", # Bug 1411118
]
["browser_notification_remove_permission.js"]
https_first_disabled = true
skip-if = [
"os == 'win'", # Bug 1411118
]
["browser_notification_tab_switching.js"]
https_first_disabled = true
skip-if = [
"os == 'win'", # Bug 1243263
]

View File

@@ -0,0 +1,78 @@
"use strict";
const { PlacesTestUtils } = ChromeUtils.importESModule(
"resource://testing-common/PlacesTestUtils.sys.mjs"
);
const { PermissionTestUtils } = ChromeUtils.importESModule(
"resource://testing-common/PermissionTestUtils.sys.mjs"
);
let notificationURL =
"https://example.org/browser/browser/base/content/test/alerts/file_dom_notifications.html";
add_task(async function test_notificationClose() {
await addNotificationPermission(notificationURL);
await BrowserTestUtils.withNewTab(
{
gBrowser,
url: notificationURL,
},
async function dummyTabTask(aBrowser) {
await openNotification(aBrowser, "showNotification2");
info("Notification alert showing");
let alertWindow = Services.wm.getMostRecentWindow("alert:alert");
if (!alertWindow) {
ok(true, "Notifications don't use XUL windows on all platforms.");
await closeNotification(aBrowser);
return;
}
let alertTitleLabel =
alertWindow.document.getElementById("alertTitleLabel");
is(
alertTitleLabel.value,
"Test title",
"Title text of notification should be present"
);
let alertTextLabel =
alertWindow.document.getElementById("alertTextLabel");
is(
alertTextLabel.textContent,
"Test body 2",
"Body text of notification should be present"
);
let alertCloseButton = alertWindow.document.querySelector(".close-icon");
is(alertCloseButton.localName, "toolbarbutton", "close button found");
let promiseBeforeUnloadEvent = BrowserTestUtils.waitForEvent(
alertWindow,
"beforeunload"
);
let closedTime = alertWindow.Date.now();
alertCloseButton.click();
info("Clicked on close button");
await promiseBeforeUnloadEvent;
ok(true, "Alert should close when the close button is clicked");
let currentTime = alertWindow.Date.now();
// The notification will self-close at 12 seconds, so this checks
// that the notification closed before the timeout.
Assert.less(
currentTime - closedTime,
5000,
"Close requested at " +
closedTime +
", actually closed at " +
currentTime
);
}
);
});
add_task(async function cleanup() {
PermissionTestUtils.remove(notificationURL, "desktop-notification");
});

View File

@@ -0,0 +1,159 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
/**
* Tests that notifications can be silenced using nsIAlertsDoNotDisturb
* on systems where that interface and its methods are implemented for
* the nsIAlertService.
*/
const ALERT_SERVICE = Cc["@mozilla.org/alerts-service;1"]
.getService(Ci.nsIAlertsService)
.QueryInterface(Ci.nsIAlertsDoNotDisturb);
const PAGE =
"https://example.org/browser/browser/base/content/test/alerts/file_dom_notifications.html";
// The amount of time in seconds that we will wait for a notification
// to show up before we decide that it's not coming.
const NOTIFICATION_TIMEOUT_SECS = 2000;
add_setup(async function () {
await addNotificationPermission(PAGE);
});
/**
* Test that the manualDoNotDisturb attribute can prevent
* notifications from appearing.
*/
add_task(async function test_manualDoNotDisturb() {
try {
// Only run the test if the do-not-disturb
// interface has been implemented.
ALERT_SERVICE.manualDoNotDisturb;
ok(true, "Alert service implements do-not-disturb interface");
} catch (e) {
ok(
true,
"Alert service doesn't implement do-not-disturb interface, exiting test"
);
return;
}
// In the event that something goes wrong during this test, make sure
// we put the attribute back to the default setting when this test file
// exits.
registerCleanupFunction(() => {
ALERT_SERVICE.manualDoNotDisturb = false;
});
// Make sure that do-not-disturb is not enabled before we start.
ok(
!ALERT_SERVICE.manualDoNotDisturb,
"Alert service should not be disabled when test starts"
);
await BrowserTestUtils.withNewTab(PAGE, async browser => {
await openNotification(browser, "showNotification2");
info("Notification alert showing");
let alertWindow = Services.wm.getMostRecentWindow("alert:alert");
// For now, only the XUL alert backend implements the manualDoNotDisturb
// method for nsIAlertsDoNotDisturb, so we expect there to be a XUL alert
// window. If the method gets implemented by native backends in the future,
// we'll probably want to branch here and set the manualDoNotDisturb
// attribute manually.
ok(alertWindow, "Expected a XUL alert window.");
// We're using the XUL notification backend. This means that there's
// a menuitem for enabling manualDoNotDisturb. We exercise that
// menuitem here.
let doNotDisturbMenuItem = alertWindow.document.getElementById(
"doNotDisturbMenuItem"
);
is(doNotDisturbMenuItem.localName, "menuitem", "menuitem found");
let unloadPromise = BrowserTestUtils.waitForEvent(
alertWindow,
"beforeunload"
);
doNotDisturbMenuItem.click();
info("Clicked on do-not-disturb menuitem");
await unloadPromise;
// At this point, we should be configured to not display notifications
// to the user.
ok(
ALERT_SERVICE.manualDoNotDisturb,
"Alert service should be disabled after clicking menuitem"
);
// The notification should not appear, but there is no way from the
// client-side to know that it was blocked, except for waiting some time
// and realizing that the "onshow" event never fired.
await Assert.rejects(
openNotification(browser, "showNotification2", NOTIFICATION_TIMEOUT_SECS),
/timed out/,
"The notification should never display."
);
ALERT_SERVICE.manualDoNotDisturb = false;
});
});
/**
* Test that the suppressForScreenSharing attribute can prevent
* notifications from appearing.
*/
add_task(async function test_suppressForScreenSharing() {
try {
// Only run the test if the do-not-disturb
// interface has been implemented.
ALERT_SERVICE.suppressForScreenSharing;
ok(true, "Alert service implements do-not-disturb interface");
} catch (e) {
ok(
true,
"Alert service doesn't implement do-not-disturb interface, exiting test"
);
return;
}
// In the event that something goes wrong during this test, make sure
// we put the attribute back to the default setting when this test file
// exits.
registerCleanupFunction(() => {
ALERT_SERVICE.suppressForScreenSharing = false;
});
// Make sure that do-not-disturb is not enabled before we start.
ok(
!ALERT_SERVICE.suppressForScreenSharing,
"Alert service should not be suppressing for screen sharing when test " +
"starts"
);
await BrowserTestUtils.withNewTab(PAGE, async browser => {
await openNotification(browser, "showNotification2");
info("Notification alert showing");
await closeNotification(browser);
ALERT_SERVICE.suppressForScreenSharing = true;
// The notification should not appear, but there is no way from the
// client-side to know that it was blocked, except for waiting some time
// and realizing that the "onshow" event never fired.
await Assert.rejects(
openNotification(browser, "showNotification2", NOTIFICATION_TIMEOUT_SECS),
/timed out/,
"The notification should never display."
);
});
ALERT_SERVICE.suppressForScreenSharing = false;
});

View File

@@ -0,0 +1,87 @@
"use strict";
var notificationURL =
"https://example.org/browser/browser/base/content/test/alerts/file_dom_notifications.html";
// BrowserGlue passes "privacy-permissions" to openPreferences when the
// notifications-open-settings observer fires; the Settings Redesign
// LegacyPaneMappings shim routes that to the permissionsData pane.
var expectedURL = Services.prefs.getBoolPref(
"browser.settings-redesign.enabled",
false
)
? "about:preferences#permissionsData"
: "about:preferences#privacy";
add_task(async function test_settingsOpen_observer() {
info(
"Opening a dummy tab so openPreferences=>switchToTabHavingURI doesn't use the blank tab."
);
await BrowserTestUtils.withNewTab(
{
gBrowser,
url: "about:robots",
},
async function dummyTabTask() {
// Ensure preferences is loaded before removing the tab.
let syncPaneLoadedPromise = TestUtils.topicObserved(
"sync-pane-loaded",
() => true
);
let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, expectedURL);
info("simulate a notifications-open-settings notification");
let uri = NetUtil.newURI("https://example.com");
let principal = Services.scriptSecurityManager.createContentPrincipal(
uri,
{}
);
Services.obs.notifyObservers(principal, "notifications-open-settings");
let tab = await tabPromise;
ok(tab, "The notification settings tab opened");
await syncPaneLoadedPromise;
BrowserTestUtils.removeTab(tab);
}
);
});
add_task(async function test_settingsOpen_button() {
info("Adding notification permission");
await addNotificationPermission(notificationURL);
await BrowserTestUtils.withNewTab(
{
gBrowser,
url: notificationURL,
},
async function tabTask(aBrowser) {
info("Waiting for notification");
await openNotification(aBrowser, "showNotification2");
let alertWindow = Services.wm.getMostRecentWindow("alert:alert");
if (!alertWindow) {
ok(true, "Notifications don't use XUL windows on all platforms.");
await closeNotification(aBrowser);
return;
}
// Ensure preferences is loaded before removing the tab.
let syncPaneLoadedPromise = TestUtils.topicObserved(
"sync-pane-loaded",
() => true
);
let closePromise = promiseWindowClosed(alertWindow);
let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, expectedURL);
let openSettingsMenuItem = alertWindow.document.getElementById(
"openSettingsMenuItem"
);
openSettingsMenuItem.click();
info("Waiting for notification settings tab");
let tab = await tabPromise;
ok(tab, "The notification settings tab opened");
await syncPaneLoadedPromise;
await closePromise;
BrowserTestUtils.removeTab(tab);
}
);
});

View File

@@ -0,0 +1,85 @@
"use strict";
const { PermissionTestUtils } = ChromeUtils.importESModule(
"resource://testing-common/PermissionTestUtils.sys.mjs"
);
var tab;
var notificationURL =
"https://example.org/browser/browser/base/content/test/alerts/file_dom_notifications.html";
var alertWindowClosed = false;
var permRemoved = false;
function test() {
waitForExplicitFinish();
registerCleanupFunction(function () {
gBrowser.removeTab(tab);
window.restore();
});
addNotificationPermission(notificationURL).then(function openTab() {
tab = BrowserTestUtils.addTab(gBrowser, notificationURL);
gBrowser.selectedTab = tab;
BrowserTestUtils.browserLoaded(tab.linkedBrowser).then(() => onLoad());
});
}
function onLoad() {
openNotification(tab.linkedBrowser, "showNotification2").then(onAlertShowing);
}
function onAlertShowing() {
info("Notification alert showing");
let alertWindow = Services.wm.getMostRecentWindow("alert:alert");
if (!alertWindow) {
ok(true, "Notifications don't use XUL windows on all platforms.");
closeNotification(tab.linkedBrowser).then(finish);
return;
}
ok(
PermissionTestUtils.testExactPermission(
notificationURL,
"desktop-notification"
),
"Permission should exist prior to removal"
);
let disableForOriginMenuItem = alertWindow.document.getElementById(
"disableForOriginMenuItem"
);
is(disableForOriginMenuItem.localName, "menuitem", "menuitem found");
Services.obs.addObserver(permObserver, "perm-changed");
alertWindow.addEventListener("beforeunload", onAlertClosing);
disableForOriginMenuItem.click();
info("Clicked on disable-for-origin menuitem");
}
function permObserver(subject, topic, data) {
if (topic != "perm-changed") {
return;
}
let permission = subject.QueryInterface(Ci.nsIPermission);
is(
permission.type,
"desktop-notification",
"desktop-notification permission changed"
);
is(data, "deleted", "desktop-notification permission deleted");
Services.obs.removeObserver(permObserver, "perm-changed");
permRemoved = true;
if (alertWindowClosed) {
finish();
}
}
function onAlertClosing(event) {
event.target.removeEventListener("beforeunload", onAlertClosing);
alertWindowClosed = true;
if (permRemoved) {
finish();
}
}

View File

@@ -0,0 +1,116 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/
*/
"use strict";
const { PermissionTestUtils } = ChromeUtils.importESModule(
"resource://testing-common/PermissionTestUtils.sys.mjs"
);
var tab;
var notification;
var notificationURL =
"https://example.org/browser/browser/base/content/test/alerts/file_dom_notifications.html";
var newWindowOpenedFromTab;
add_task(async function test_notificationPreventDefaultAndSwitchTabs() {
await addNotificationPermission(notificationURL);
let originalTab = gBrowser.selectedTab;
await BrowserTestUtils.withNewTab(
{
gBrowser,
url: notificationURL,
},
async function dummyTabTask(aBrowser) {
// Put new tab in background so it is obvious when it is re-focused.
await BrowserTestUtils.switchTab(gBrowser, originalTab);
isnot(
gBrowser.selectedBrowser,
aBrowser,
"Notification page loaded as a background tab"
);
// First, show a notification that will be have the tab-switching prevented.
function promiseNotificationEvent(evt) {
return SpecialPowers.spawn(
aBrowser,
[evt],
async function (contentEvt) {
return new Promise(resolve => {
let contentNotification = content.wrappedJSObject._notification;
contentNotification.addEventListener(
contentEvt,
function (event) {
resolve({ defaultPrevented: event.defaultPrevented });
},
{ once: true }
);
});
}
);
}
await openNotification(aBrowser, "showNotification1");
info("Notification alert showing");
let alertWindow = Services.wm.getMostRecentWindow("alert:alert");
if (!alertWindow) {
ok(true, "Notifications don't use XUL windows on all platforms.");
await closeNotification(aBrowser);
return;
}
info("Clicking on notification");
let promiseClickEvent = promiseNotificationEvent("click");
// NB: This executeSoon is needed to allow the non-e10s runs of this test
// a chance to set the event listener on the page. Otherwise, we
// synchronously fire the click event before we listen for the event.
executeSoon(() => {
EventUtils.synthesizeMouseAtCenter(
alertWindow.document.getElementById("alertTitleLabel"),
{},
alertWindow
);
});
let clickEvent = await promiseClickEvent;
ok(
clickEvent.defaultPrevented,
"The event handler for the first notification cancels the event"
);
isnot(
gBrowser.selectedBrowser,
aBrowser,
"Notification page still a background tab"
);
let notificationClosed = promiseNotificationEvent("close");
await closeNotification(aBrowser);
await notificationClosed;
// Second, show a notification that will cause the tab to get switched.
await openNotification(aBrowser, "showNotification2");
alertWindow = Services.wm.getMostRecentWindow("alert:alert");
let promiseTabSelect = BrowserTestUtils.waitForEvent(
gBrowser.tabContainer,
"TabSelect"
);
EventUtils.synthesizeMouseAtCenter(
alertWindow.document.getElementById("alertTitleLabel"),
{},
alertWindow
);
await promiseTabSelect;
is(
gBrowser.selectedBrowser.currentURI.spec,
notificationURL,
"Clicking on the second notification should select its originating tab"
);
notificationClosed = promiseNotificationEvent("close");
await closeNotification(aBrowser);
await notificationClosed;
}
);
});
add_task(async function cleanup() {
PermissionTestUtils.remove(notificationURL, "desktop-notification");
});

View File

@@ -0,0 +1,39 @@
<html>
<head>
<meta charset="utf-8">
<script>
"use strict";
function showNotification1() {
var options = {
dir: undefined,
lang: undefined,
body: "Test body 1",
tag: "Test tag",
icon: undefined,
};
var n = new Notification("Test title", options);
n.addEventListener("click", function(event) {
event.preventDefault();
});
return n;
}
function showNotification2() {
var options = {
dir: undefined,
lang: undefined,
body: "Test body 2",
tag: "Test tag",
icon: undefined,
};
return new Notification("Test title", options);
}
</script>
</head>
<body>
<form id="notificationForm" onsubmit="showNotification();">
<input type="submit" value="Show notification" id="submit"/>
</form>
</body>
</html>

View File

@@ -0,0 +1,73 @@
// Platforms may default to reducing motion. We override this to ensure the
// alert slide animation is enabled in tests.
SpecialPowers.pushPrefEnv({
set: [["ui.prefersReducedMotion", 0]],
});
async function addNotificationPermission(originString) {
return SpecialPowers.pushPermissions([
{
type: "desktop-notification",
allow: true,
context: originString,
},
]);
}
/**
* Similar to `BrowserTestUtils.closeWindow`, but
* doesn't call `window.close()`.
*/
function promiseWindowClosed(window) {
return new Promise(function (resolve) {
Services.ww.registerNotification(function observer(subject, topic) {
if (topic == "domwindowclosed" && subject == window) {
Services.ww.unregisterNotification(observer);
resolve();
}
});
});
}
/**
* These two functions work with file_dom_notifications.html to open the
* notification and close it.
*
* |fn| can be showNotification1 or showNotification2.
* if |timeout| is passed, then the promise returned from this function is
* rejected after the requested number of miliseconds.
*/
function openNotification(aBrowser, fn, timeout) {
info(`openNotification: ${fn}`);
return SpecialPowers.spawn(
aBrowser,
[fn, timeout],
async function (contentFn, contentTimeout) {
await new Promise((resolve, reject) => {
let win = content.wrappedJSObject;
let notification = win[contentFn]();
win._notification = notification;
function listener() {
notification.removeEventListener("show", listener);
resolve();
}
notification.addEventListener("show", listener);
if (contentTimeout) {
content.setTimeout(() => {
notification.removeEventListener("show", listener);
reject("timed out");
}, contentTimeout);
}
});
}
);
}
function closeNotification(aBrowser) {
return SpecialPowers.spawn(aBrowser, [], function () {
content.wrappedJSObject._notification.close();
});
}

View File

@@ -0,0 +1,7 @@
[DEFAULT]
["browser_history_menu.js"]
fail-if = [
"a11y_checks", # Bug 1854233 navigator-toolbox may not be focusable
]
https_first_disabled = true

View File

@@ -0,0 +1,179 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
// This test verifies that the back forward button long-press menu and context menu
// shows the correct history items.
add_task(async function mousedown_back() {
await testBackForwardMenu(false);
});
add_task(async function contextmenu_back() {
await testBackForwardMenu(true);
});
async function openHistoryMenu(useContextMenu) {
let backButton = document.getElementById("back-button");
let rect = backButton.getBoundingClientRect();
info("waiting for the history menu to open");
let popupShownPromise = BrowserTestUtils.waitForEvent(
useContextMenu ? document.getElementById("backForwardMenu") : backButton,
"popupshown"
);
if (useContextMenu) {
EventUtils.synthesizeMouseAtCenter(backButton, {
type: "contextmenu",
button: 2,
});
} else {
EventUtils.synthesizeMouseAtCenter(backButton, { type: "mousedown" });
}
EventUtils.synthesizeMouse(backButton, rect.width / 2, rect.height, {
type: "mouseup",
});
let popupEvent = await popupShownPromise;
ok(true, "history menu opened");
return popupEvent;
}
async function testBackForwardMenu(useContextMenu) {
let tab = await BrowserTestUtils.openNewForegroundTab(
gBrowser,
// eslint-disable-next-line @microsoft/sdl/no-insecure-url
"http://example.com"
);
for (let iter = 2; iter <= 4; iter++) {
// Iterate three times. For the first two times through the loop, add a new history item.
// But for the last iteration, go back in the history instead.
await SpecialPowers.spawn(
gBrowser.selectedBrowser,
[iter],
async function (iterChild) {
if (iterChild == 4) {
let popStatePromise = new Promise(function (resolve) {
content.onpopstate = resolve;
});
content.history.back();
await popStatePromise;
} else {
// With the back-button intervention enabled, we need to make sure to
// trigger a user activation on each history entry, or they won't
// show in the menu.
content.document.notifyUserGestureActivation();
content.history.pushState({}, "" + iterChild, iterChild + ".html");
}
}
);
// Wait for the session data to be flushed before continuing the test
await new Promise(resolve =>
SessionStore.getSessionHistory(gBrowser.selectedTab, resolve)
);
let popupEvent = await openHistoryMenu(useContextMenu);
// Wait for the session data to be flushed before continuing the test
await new Promise(resolve =>
SessionStore.getSessionHistory(gBrowser.selectedTab, resolve)
);
is(
popupEvent.target.children.length,
iter > 3 ? 3 : iter,
"Correct number of history items"
);
let node = popupEvent.target.lastElementChild;
// eslint-disable-next-line @microsoft/sdl/no-insecure-url
is(node.getAttribute("uri"), "http://example.com/", "'1' item uri");
is(node.getAttribute("index"), "0", "'1' item index");
is(
node.getAttribute("historyindex"),
iter == 3 ? "-2" : "-1",
"'1' item historyindex"
);
node = node.previousElementSibling;
// eslint-disable-next-line @microsoft/sdl/no-insecure-url
is(node.getAttribute("uri"), "http://example.com/2.html", "'2' item uri");
is(node.getAttribute("index"), "1", "'2' item index");
is(
node.getAttribute("historyindex"),
iter == 3 ? "-1" : "0",
"'2' item historyindex"
);
if (iter >= 3) {
node = node.previousElementSibling;
// eslint-disable-next-line @microsoft/sdl/no-insecure-url
is(node.getAttribute("uri"), "http://example.com/3.html", "'3' item uri");
is(node.getAttribute("index"), "2", "'3' item index");
is(
node.getAttribute("historyindex"),
iter == 4 ? "1" : "0",
"'3' item historyindex"
);
}
// Close the popup, but on the last iteration, click on one of the history items
// to ensure it opens in a new tab.
let popupHiddenPromise = BrowserTestUtils.waitForEvent(
popupEvent.target,
"popuphidden"
);
if (iter < 4) {
popupEvent.target.hidePopup();
} else {
let newTabPromise = BrowserTestUtils.waitForNewTab(
gBrowser,
// eslint-disable-next-line @microsoft/sdl/no-insecure-url
url => url == "http://example.com/"
);
popupEvent.target.activateItem(popupEvent.target.children[2], {
button: 1,
});
let newtab = await newTabPromise;
gBrowser.removeTab(newtab);
}
await popupHiddenPromise;
}
gBrowser.removeTab(tab);
}
// Make sure that the history popup appears after navigating around in a preferences page.
add_task(async function test_preferences_page() {
let tab = await BrowserTestUtils.openNewForegroundTab(
gBrowser,
"about:preferences"
);
openPreferences("search");
let popupEvent = await openHistoryMenu(true);
// Wait for the session data to be flushed before continuing the test
await new Promise(resolve =>
SessionStore.getSessionHistory(gBrowser.selectedTab, resolve)
);
is(popupEvent.target.children.length, 2, "Correct number of history items");
let popupHiddenPromise = BrowserTestUtils.waitForEvent(
popupEvent.target,
"popuphidden"
);
popupEvent.target.hidePopup();
await popupHiddenPromise;
gBrowser.removeTab(tab);
});

View File

@@ -0,0 +1,10 @@
[DEFAULT]
["browser_principalSerialization_csp.js"]
["browser_principalSerialization_json.js"]
skip-if = [
"debug", # deliberately bypass assertions when deserializing. Bug 965637 removed the CSP from Principals, but the remaining bits in such Principals should deserialize correctly.
]
["browser_principalSerialization_version1.js"]

View File

@@ -0,0 +1,106 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
/*
* Within Bug 965637 we move the CSP away from the Principal. Serialized Principals however
* might still have CSPs serialized within them. This tests ensures that we do not
* encounter a memory corruption when deserializing. It's fine that the deserialized
* CSP is null, but the Principal itself should deserialize correctly.
*/
add_task(async function test_deserialize_principal_with_csp() {
/*
This test should be resilient to changes in principal serialization, if these are failing then it's likely the code will break session storage.
To recreate this for another version, copy the function into the browser console, browse some pages and printHistory.
Generated with:
function printHistory() {
let tests = [];
let entries = SessionStore.getSessionHistory(gBrowser.selectedTab).entries.map((entry) => { return entry.triggeringPrincipal_base64 });
entries.push(E10SUtils.serializePrincipal(gBrowser.selectedTab.linkedBrowser._contentPrincipal));
for (let entry of entries) {
console.log(entry);
let testData = {};
testData.input = entry;
let principal = E10SUtils.deserializePrincipal(testData.input);
testData.output = {};
if (principal.URI === null) {
testData.output.URI = false;
} else {
testData.output.URISpec = principal.URI.spec;
}
testData.output.originAttributes = principal.originAttributes;
testData.output.cspJSON = principal.cspJSON;
tests.push(testData);
}
return tests;
}
printHistory(); // Copy this into: serializedPrincipalsFromFirefox
*/
let serializedPrincipalsFromFirefox = [
{
input:
"ZT4OTT7kRfqycpfCC8AeuAAAAAAAAAAAwAAAAAAAAEYB3pRy0IA0EdOTmQAQS6D9QJIHOlRteE8wkTq4cYEyCMYAAAAC/////wAAAbsBAAAAHmh0dHBzOi8vd3d3Lm1vemlsbGEub3JnL2VuLVVTLwAAAAAAAAAFAAAACAAAAA8AAAAA/////wAAAAD/////AAAACAAAAA8AAAAXAAAABwAAABcAAAAHAAAAFwAAAAcAAAAeAAAAAAAAAAD/////AAAAAP////8AAAAA/////wAAAAD/////AQAAAAAAAAAAAAAAAQnZ7Rrl1EAEv+Anzrkj2ayzxMCuvV5MrYfgjSENuz+fAd6UctCANBHTk5kAEEug/UCSBzpUbXhPMJE6uHGBMgjGAAAAAv////8AAAG7AQAAAB5odHRwczovL3d3dy5tb3ppbGxhLm9yZy9lbi1VUy8AAAAAAAAABQAAAAgAAAAPAAAAAP////8AAAAA/////wAAAAgAAAAPAAAAFwAAAAcAAAAXAAAABwAAABcAAAAHAAAAHgAAAAAAAAAA/////wAAAAD/////AAAAAP////8AAAAA/////wEAAAAAAAAAAAABAAAFtgBzAGMAcgBpAHAAdAAtAHMAcgBjACAAJwBzAGUAbABmACcAIABoAHQAdABwAHMAOgAvAC8AKgAuAG0AbwB6AGkAbABsAGEALgBuAGUAdAAgAGgAdAB0AHAAcwA6AC8ALwAqAC4AbQBvAHoAaQBsAGwAYQAuAG8AcgBnACAAaAB0AHQAcABzADoALwAvACoALgBtAG8AegBpAGwAbABhAC4AYwBvAG0AIAAnAHUAbgBzAGEAZgBlAC0AaQBuAGwAaQBuAGUAJwAgACcAdQBuAHMAYQBmAGUALQBlAHYAYQBsACcAIABoAHQAdABwAHMAOgAvAC8AdwB3AHcALgBnAG8AbwBnAGwAZQB0AGEAZwBtAGEAbgBhAGcAZQByAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AdwB3AHcALgBnAG8AbwBnAGwAZQAtAGEAbgBhAGwAeQB0AGkAYwBzAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AdABhAGcAbQBhAG4AYQBnAGUAcgAuAGcAbwBvAGcAbABlAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AdwB3AHcALgB5AG8AdQB0AHUAYgBlAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AcwAuAHkAdABpAG0AZwAuAGMAbwBtADsAIABpAG0AZwAtAHMAcgBjACAAJwBzAGUAbABmACcAIABoAHQAdABwAHMAOgAvAC8AKgAuAG0AbwB6AGkAbABsAGEALgBuAGUAdAAgAGgAdAB0AHAAcwA6AC8ALwAqAC4AbQBvAHoAaQBsAGwAYQAuAG8AcgBnACAAaAB0AHQAcABzADoALwAvACoALgBtAG8AegBpAGwAbABhAC4AYwBvAG0AIABkAGEAdABhADoAIABoAHQAdABwAHMAOgAvAC8AbQBvAHoAaQBsAGwAYQAuAG8AcgBnACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AZwBvAG8AZwBsAGUAdABhAGcAbQBhAG4AYQBnAGUAcgAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AZwBvAG8AZwBsAGUALQBhAG4AYQBsAHkAdABpAGMAcwAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAGEAZABzAGUAcgB2AGkAYwBlAC4AZwBvAG8AZwBsAGUALgBjAG8AbQAgAGgAdAB0AHAAcwA6AC8ALwBhAGQAcwBlAHIAdgBpAGMAZQAuAGcAbwBvAGcAbABlAC4AZABlACAAaAB0AHQAcABzADoALwAvAGEAZABzAGUAcgB2AGkAYwBlAC4AZwBvAG8AZwBsAGUALgBkAGsAIABoAHQAdABwAHMAOgAvAC8AYwByAGUAYQB0AGkAdgBlAGMAbwBtAG0AbwBuAHMALgBvAHIAZwAgAGgAdAB0AHAAcwA6AC8ALwBhAGQALgBkAG8AdQBiAGwAZQBjAGwAaQBjAGsALgBuAGUAdAA7ACAAZABlAGYAYQB1AGwAdAAtAHMAcgBjACAAJwBzAGUAbABmACcAIABoAHQAdABwAHMAOgAvAC8AKgAuAG0AbwB6AGkAbABsAGEALgBuAGUAdAAgAGgAdAB0AHAAcwA6AC8ALwAqAC4AbQBvAHoAaQBsAGwAYQAuAG8AcgBnACAAaAB0AHQAcABzADoALwAvACoALgBtAG8AegBpAGwAbABhAC4AYwBvAG0AOwAgAGYAcgBhAG0AZQAtAHMAcgBjACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AZwBvAG8AZwBsAGUAdABhAGcAbQBhAG4AYQBnAGUAcgAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AZwBvAG8AZwBsAGUALQBhAG4AYQBsAHkAdABpAGMAcwAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AeQBvAHUAdAB1AGIAZQAtAG4AbwBjAG8AbwBrAGkAZQAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAHQAcgBhAGMAawBlAHIAdABlAHMAdAAuAG8AcgBnACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AcwB1AHIAdgBlAHkAZwBpAHoAbQBvAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AYQBjAGMAbwB1AG4AdABzAC4AZgBpAHIAZQBmAG8AeAAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAGEAYwBjAG8AdQBuAHQAcwAuAGYAaQByAGUAZgBvAHgALgBjAG8AbQAuAGMAbgAgAGgAdAB0AHAAcwA6AC8ALwB3AHcAdwAuAHkAbwB1AHQAdQBiAGUALgBjAG8AbQA7ACAAcwB0AHkAbABlAC0AcwByAGMAIAAnAHMAZQBsAGYAJwAgAGgAdAB0AHAAcwA6AC8ALwAqAC4AbQBvAHoAaQBsAGwAYQAuAG4AZQB0ACAAaAB0AHQAcABzADoALwAvACoALgBtAG8AegBpAGwAbABhAC4AbwByAGcAIABoAHQAdABwAHMAOgAvAC8AKgAuAG0AbwB6AGkAbABsAGEALgBjAG8AbQAgACcAdQBuAHMAYQBmAGUALQBpAG4AbABpAG4AZQAnADsAIABjAG8AbgBuAGUAYwB0AC0AcwByAGMAIAAnAHMAZQBsAGYAJwAgAGgAdAB0AHAAcwA6AC8ALwAqAC4AbQBvAHoAaQBsAGwAYQAuAG4AZQB0ACAAaAB0AHQAcABzADoALwAvACoALgBtAG8AegBpAGwAbABhAC4AbwByAGcAIABoAHQAdABwAHMAOgAvAC8AKgAuAG0AbwB6AGkAbABsAGEALgBjAG8AbQAgAGgAdAB0AHAAcwA6AC8ALwB3AHcAdwAuAGcAbwBvAGcAbABlAHQAYQBnAG0AYQBuAGEAZwBlAHIALgBjAG8AbQAgAGgAdAB0AHAAcwA6AC8ALwB3AHcAdwAuAGcAbwBvAGcAbABlAC0AYQBuAGEAbAB5AHQAaQBjAHMALgBjAG8AbQAgAGgAdAB0AHAAcwA6AC8ALwBhAGMAYwBvAHUAbgB0AHMALgBmAGkAcgBlAGYAbwB4AC4AYwBvAG0ALwAgAGgAdAB0AHAAcwA6AC8ALwBhAGMAYwBvAHUAbgB0AHMALgBmAGkAcgBlAGYAbwB4AC4AYwBvAG0ALgBjAG4ALwA7ACAAYwBoAGkAbABkAC0AcwByAGMAIABoAHQAdABwAHMAOgAvAC8AdwB3AHcALgBnAG8AbwBnAGwAZQB0AGEAZwBtAGEAbgBhAGcAZQByAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AdwB3AHcALgBnAG8AbwBnAGwAZQAtAGEAbgBhAGwAeQB0AGkAYwBzAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AdwB3AHcALgB5AG8AdQB0AHUAYgBlAC0AbgBvAGMAbwBvAGsAaQBlAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AdAByAGEAYwBrAGUAcgB0AGUAcwB0AC4AbwByAGcAIABoAHQAdABwAHMAOgAvAC8AdwB3AHcALgBzAHUAcgB2AGUAeQBnAGkAegBtAG8ALgBjAG8AbQAgAGgAdAB0AHAAcwA6AC8ALwBhAGMAYwBvAHUAbgB0AHMALgBmAGkAcgBlAGYAbwB4AC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AYQBjAGMAbwB1AG4AdABzAC4AZgBpAHIAZQBmAG8AeAAuAGMAbwBtAC4AYwBuACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AeQBvAHUAdAB1AGIAZQAuAGMAbwBtAAA=",
output: {
// Within Bug 965637 we removed CSP from Principals. Already serialized Principals however should still deserialize correctly (just without the CSP).
// "cspJSON": "{\"csp-policies\":[{\"child-src\":[\"https://www.googletagmanager.com\",\"https://www.google-analytics.com\",\"https://www.youtube-nocookie.com\",\"https://trackertest.org\",\"https://www.surveygizmo.com\",\"https://accounts.firefox.com\",\"https://accounts.firefox.com.cn\",\"https://www.youtube.com\"],\"connect-src\":[\"'self'\",\"https://*.mozilla.net\",\"https://*.mozilla.org\",\"https://*.mozilla.com\",\"https://www.googletagmanager.com\",\"https://www.google-analytics.com\",\"https://accounts.firefox.com/\",\"https://accounts.firefox.com.cn/\"],\"default-src\":[\"'self'\",\"https://*.mozilla.net\",\"https://*.mozilla.org\",\"https://*.mozilla.com\"],\"frame-src\":[\"https://www.googletagmanager.com\",\"https://www.google-analytics.com\",\"https://www.youtube-nocookie.com\",\"https://trackertest.org\",\"https://www.surveygizmo.com\",\"https://accounts.firefox.com\",\"https://accounts.firefox.com.cn\",\"https://www.youtube.com\"],\"img-src\":[\"'self'\",\"https://*.mozilla.net\",\"https://*.mozilla.org\",\"https://*.mozilla.com\",\"data:\",\"https://mozilla.org\",\"https://www.googletagmanager.com\",\"https://www.google-analytics.com\",\"https://adservice.google.com\",\"https://adservice.google.de\",\"https://adservice.google.dk\",\"https://creativecommons.org\",\"https://ad.doubleclick.net\"],\"report-only\":false,\"script-src\":[\"'self'\",\"https://*.mozilla.net\",\"https://*.mozilla.org\",\"https://*.mozilla.com\",\"'unsafe-inline'\",\"'unsafe-eval'\",\"https://www.googletagmanager.com\",\"https://www.google-analytics.com\",\"https://tagmanager.google.com\",\"https://www.youtube.com\",\"https://s.ytimg.com\"],\"style-src\":[\"'self'\",\"https://*.mozilla.net\",\"https://*.mozilla.org\",\"https://*.mozilla.com\",\"'unsafe-inline'\"]}]}",
URISpec: "https://www.mozilla.org/en-US/",
originAttributes: {
firstPartyDomain: "",
inIsolatedMozBrowser: false,
privateBrowsingId: 0,
userContextId: 0,
geckoViewSessionContextId: "",
partitionKey: "",
},
},
},
{
input:
"ZT4OTT7kRfqycpfCC8AeuAAAAAAAAAAAwAAAAAAAAEYB3pRy0IA0EdOTmQAQS6D9QJIHOlRteE8wkTq4cYEyCMYAAAAC/////wAAAbsBAAAAL2h0dHBzOi8vd3d3Lm1vemlsbGEub3JnL2VuLVVTL2ZpcmVmb3gvYWNjb3VudHMvAAAAAAAAAAUAAAAIAAAADwAAAAj/////AAAACP////8AAAAIAAAADwAAABcAAAAYAAAAFwAAABgAAAAXAAAAGAAAAC8AAAAAAAAAL/////8AAAAA/////wAAABf/////AAAAF/////8BAAAAAAAAAAAAAAABCdntGuXUQAS/4CfOuSPZrLPEwK69Xkyth+CNIQ27P58B3pRy0IA0EdOTmQAQS6D9QJIHOlRteE8wkTq4cYEyCMYAAAAC/////wAAAbsBAAAAL2h0dHBzOi8vd3d3Lm1vemlsbGEub3JnL2VuLVVTL2ZpcmVmb3gvYWNjb3VudHMvAAAAAAAAAAUAAAAIAAAADwAAAAj/////AAAACP////8AAAAIAAAADwAAABcAAAAYAAAAFwAAABgAAAAXAAAAGAAAAC8AAAAAAAAAL/////8AAAAA/////wAAABf/////AAAAF/////8BAAAAAAAAAAAAAQAABbYAcwBjAHIAaQBwAHQALQBzAHIAYwAgACcAcwBlAGwAZgAnACAAaAB0AHQAcABzADoALwAvACoALgBtAG8AegBpAGwAbABhAC4AbgBlAHQAIABoAHQAdABwAHMAOgAvAC8AKgAuAG0AbwB6AGkAbABsAGEALgBvAHIAZwAgAGgAdAB0AHAAcwA6AC8ALwAqAC4AbQBvAHoAaQBsAGwAYQAuAGMAbwBtACAAJwB1AG4AcwBhAGYAZQAtAGkAbgBsAGkAbgBlACcAIAAnAHUAbgBzAGEAZgBlAC0AZQB2AGEAbAAnACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AZwBvAG8AZwBsAGUAdABhAGcAbQBhAG4AYQBnAGUAcgAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AZwBvAG8AZwBsAGUALQBhAG4AYQBsAHkAdABpAGMAcwAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAHQAYQBnAG0AYQBuAGEAZwBlAHIALgBnAG8AbwBnAGwAZQAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AeQBvAHUAdAB1AGIAZQAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAHMALgB5AHQAaQBtAGcALgBjAG8AbQA7ACAAaQBtAGcALQBzAHIAYwAgACcAcwBlAGwAZgAnACAAaAB0AHQAcABzADoALwAvACoALgBtAG8AegBpAGwAbABhAC4AbgBlAHQAIABoAHQAdABwAHMAOgAvAC8AKgAuAG0AbwB6AGkAbABsAGEALgBvAHIAZwAgAGgAdAB0AHAAcwA6AC8ALwAqAC4AbQBvAHoAaQBsAGwAYQAuAGMAbwBtACAAZABhAHQAYQA6ACAAaAB0AHQAcABzADoALwAvAG0AbwB6AGkAbABsAGEALgBvAHIAZwAgAGgAdAB0AHAAcwA6AC8ALwB3AHcAdwAuAGcAbwBvAGcAbABlAHQAYQBnAG0AYQBuAGEAZwBlAHIALgBjAG8AbQAgAGgAdAB0AHAAcwA6AC8ALwB3AHcAdwAuAGcAbwBvAGcAbABlAC0AYQBuAGEAbAB5AHQAaQBjAHMALgBjAG8AbQAgAGgAdAB0AHAAcwA6AC8ALwBhAGQAcwBlAHIAdgBpAGMAZQAuAGcAbwBvAGcAbABlAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AYQBkAHMAZQByAHYAaQBjAGUALgBnAG8AbwBnAGwAZQAuAGQAZQAgAGgAdAB0AHAAcwA6AC8ALwBhAGQAcwBlAHIAdgBpAGMAZQAuAGcAbwBvAGcAbABlAC4AZABrACAAaAB0AHQAcABzADoALwAvAGMAcgBlAGEAdABpAHYAZQBjAG8AbQBtAG8AbgBzAC4AbwByAGcAIABoAHQAdABwAHMAOgAvAC8AYQBkAC4AZABvAHUAYgBsAGUAYwBsAGkAYwBrAC4AbgBlAHQAOwAgAGQAZQBmAGEAdQBsAHQALQBzAHIAYwAgACcAcwBlAGwAZgAnACAAaAB0AHQAcABzADoALwAvACoALgBtAG8AegBpAGwAbABhAC4AbgBlAHQAIABoAHQAdABwAHMAOgAvAC8AKgAuAG0AbwB6AGkAbABsAGEALgBvAHIAZwAgAGgAdAB0AHAAcwA6AC8ALwAqAC4AbQBvAHoAaQBsAGwAYQAuAGMAbwBtADsAIABmAHIAYQBtAGUALQBzAHIAYwAgAGgAdAB0AHAAcwA6AC8ALwB3AHcAdwAuAGcAbwBvAGcAbABlAHQAYQBnAG0AYQBuAGEAZwBlAHIALgBjAG8AbQAgAGgAdAB0AHAAcwA6AC8ALwB3AHcAdwAuAGcAbwBvAGcAbABlAC0AYQBuAGEAbAB5AHQAaQBjAHMALgBjAG8AbQAgAGgAdAB0AHAAcwA6AC8ALwB3AHcAdwAuAHkAbwB1AHQAdQBiAGUALQBuAG8AYwBvAG8AawBpAGUALgBjAG8AbQAgAGgAdAB0AHAAcwA6AC8ALwB0AHIAYQBjAGsAZQByAHQAZQBzAHQALgBvAHIAZwAgAGgAdAB0AHAAcwA6AC8ALwB3AHcAdwAuAHMAdQByAHYAZQB5AGcAaQB6AG0AbwAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAGEAYwBjAG8AdQBuAHQAcwAuAGYAaQByAGUAZgBvAHgALgBjAG8AbQAgAGgAdAB0AHAAcwA6AC8ALwBhAGMAYwBvAHUAbgB0AHMALgBmAGkAcgBlAGYAbwB4AC4AYwBvAG0ALgBjAG4AIABoAHQAdABwAHMAOgAvAC8AdwB3AHcALgB5AG8AdQB0AHUAYgBlAC4AYwBvAG0AOwAgAHMAdAB5AGwAZQAtAHMAcgBjACAAJwBzAGUAbABmACcAIABoAHQAdABwAHMAOgAvAC8AKgAuAG0AbwB6AGkAbABsAGEALgBuAGUAdAAgAGgAdAB0AHAAcwA6AC8ALwAqAC4AbQBvAHoAaQBsAGwAYQAuAG8AcgBnACAAaAB0AHQAcABzADoALwAvACoALgBtAG8AegBpAGwAbABhAC4AYwBvAG0AIAAnAHUAbgBzAGEAZgBlAC0AaQBuAGwAaQBuAGUAJwA7ACAAYwBvAG4AbgBlAGMAdAAtAHMAcgBjACAAJwBzAGUAbABmACcAIABoAHQAdABwAHMAOgAvAC8AKgAuAG0AbwB6AGkAbABsAGEALgBuAGUAdAAgAGgAdAB0AHAAcwA6AC8ALwAqAC4AbQBvAHoAaQBsAGwAYQAuAG8AcgBnACAAaAB0AHQAcABzADoALwAvACoALgBtAG8AegBpAGwAbABhAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AdwB3AHcALgBnAG8AbwBnAGwAZQB0AGEAZwBtAGEAbgBhAGcAZQByAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AdwB3AHcALgBnAG8AbwBnAGwAZQAtAGEAbgBhAGwAeQB0AGkAYwBzAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AYQBjAGMAbwB1AG4AdABzAC4AZgBpAHIAZQBmAG8AeAAuAGMAbwBtAC8AIABoAHQAdABwAHMAOgAvAC8AYQBjAGMAbwB1AG4AdABzAC4AZgBpAHIAZQBmAG8AeAAuAGMAbwBtAC4AYwBuAC8AOwAgAGMAaABpAGwAZAAtAHMAcgBjACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AZwBvAG8AZwBsAGUAdABhAGcAbQBhAG4AYQBnAGUAcgAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AZwBvAG8AZwBsAGUALQBhAG4AYQBsAHkAdABpAGMAcwAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AeQBvAHUAdAB1AGIAZQAtAG4AbwBjAG8AbwBrAGkAZQAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAHQAcgBhAGMAawBlAHIAdABlAHMAdAAuAG8AcgBnACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AcwB1AHIAdgBlAHkAZwBpAHoAbQBvAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AYQBjAGMAbwB1AG4AdABzAC4AZgBpAHIAZQBmAG8AeAAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAGEAYwBjAG8AdQBuAHQAcwAuAGYAaQByAGUAZgBvAHgALgBjAG8AbQAuAGMAbgAgAGgAdAB0AHAAcwA6AC8ALwB3AHcAdwAuAHkAbwB1AHQAdQBiAGUALgBjAG8AbQAA",
output: {
URISpec: "https://www.mozilla.org/en-US/firefox/accounts/",
originAttributes: {
firstPartyDomain: "",
inIsolatedMozBrowser: false,
privateBrowsingId: 0,
userContextId: 0,
geckoViewSessionContextId: "",
partitionKey: "",
},
// Within Bug 965637 we removed CSP from Principals. Already serialized Principals however should still deserialize correctly (just without the CSP).
// "cspJSON": "{\"csp-policies\":[{\"child-src\":[\"https://www.googletagmanager.com\",\"https://www.google-analytics.com\",\"https://www.youtube-nocookie.com\",\"https://trackertest.org\",\"https://www.surveygizmo.com\",\"https://accounts.firefox.com\",\"https://accounts.firefox.com.cn\",\"https://www.youtube.com\"],\"connect-src\":[\"'self'\",\"https://*.mozilla.net\",\"https://*.mozilla.org\",\"https://*.mozilla.com\",\"https://www.googletagmanager.com\",\"https://www.google-analytics.com\",\"https://accounts.firefox.com/\",\"https://accounts.firefox.com.cn/\"],\"default-src\":[\"'self'\",\"https://*.mozilla.net\",\"https://*.mozilla.org\",\"https://*.mozilla.com\"],\"frame-src\":[\"https://www.googletagmanager.com\",\"https://www.google-analytics.com\",\"https://www.youtube-nocookie.com\",\"https://trackertest.org\",\"https://www.surveygizmo.com\",\"https://accounts.firefox.com\",\"https://accounts.firefox.com.cn\",\"https://www.youtube.com\"],\"img-src\":[\"'self'\",\"https://*.mozilla.net\",\"https://*.mozilla.org\",\"https://*.mozilla.com\",\"data:\",\"https://mozilla.org\",\"https://www.googletagmanager.com\",\"https://www.google-analytics.com\",\"https://adservice.google.com\",\"https://adservice.google.de\",\"https://adservice.google.dk\",\"https://creativecommons.org\",\"https://ad.doubleclick.net\"],\"report-only\":false,\"script-src\":[\"'self'\",\"https://*.mozilla.net\",\"https://*.mozilla.org\",\"https://*.mozilla.com\",\"'unsafe-inline'\",\"'unsafe-eval'\",\"https://www.googletagmanager.com\",\"https://www.google-analytics.com\",\"https://tagmanager.google.com\",\"https://www.youtube.com\",\"https://s.ytimg.com\"],\"style-src\":[\"'self'\",\"https://*.mozilla.net\",\"https://*.mozilla.org\",\"https://*.mozilla.com\",\"'unsafe-inline'\"]}]}",
},
},
];
for (let test of serializedPrincipalsFromFirefox) {
let principal = E10SUtils.deserializePrincipal(test.input);
for (let key in principal.originAttributes) {
is(
principal.originAttributes[key],
test.output.originAttributes[key],
`Ensure value of ${key} is ${test.output.originAttributes[key]}`
);
}
if ("URI" in test.output && test.output.URI === false) {
is(
principal.isContentPrincipal,
false,
"Should have not have a URI for system"
);
} else {
is(
principal.spec,
test.output.URISpec,
`Should have spec ${test.output.URISpec}`
);
}
}
});

View File

@@ -0,0 +1,161 @@
"use strict";
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
/*
This test file exists to ensure whenever changes to principal serialization happens,
we guarantee that the data can be restored and generated into a new principal.
The tests are written to be brittle so we encode all versions of the changes into the tests.
*/
add_task(async function test_nullPrincipal() {
const nullId = "0";
// fields
const uri = 0;
const suffix = 1;
const nullReplaceRegex =
/moz-nullprincipal:{[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}}/;
const NULL_REPLACE = "NULL_PRINCIPAL_URL";
/*
This test should NOT be resilient to changes in versioning,
however it exists purely to verify the code doesn't unintentionally change without updating versioning and migration code.
*/
let tests = [
{
input: { OA: {} },
expected: `{"${nullId}":{"${uri}":"${NULL_REPLACE}"}}`,
},
{
input: { OA: {} },
expected: `{"${nullId}":{"${uri}":"${NULL_REPLACE}"}}`,
},
{
input: { OA: { userContextId: 0 } },
expected: `{"${nullId}":{"${uri}":"${NULL_REPLACE}"}}`,
},
{
input: { OA: { userContextId: 2 } },
expected: `{"${nullId}":{"${uri}":"${NULL_REPLACE}","${suffix}":"^userContextId=2"}}`,
},
{
input: { OA: { privateBrowsingId: 1 } },
expected: `{"${nullId}":{"${uri}":"${NULL_REPLACE}","${suffix}":"^privateBrowsingId=1"}}`,
},
{
input: { OA: { privateBrowsingId: 0 } },
expected: `{"${nullId}":{"${uri}":"${NULL_REPLACE}"}}`,
},
];
for (let test of tests) {
let p = Services.scriptSecurityManager.createNullPrincipal(test.input.OA);
let sp = E10SUtils.serializePrincipal(p);
// Not sure why cppjson is adding a \n here
let spr = sp.replace(nullReplaceRegex, NULL_REPLACE);
is(
test.expected,
spr,
"Expected serialized object for " + JSON.stringify(test.input)
);
let dp = E10SUtils.deserializePrincipal(sp);
// Check all the origin attributes
for (let key in test.input.OA) {
is(
dp.originAttributes[key],
test.input.OA[key],
"Ensure value of " + key + " is " + test.input.OA[key]
);
}
}
});
add_task(async function test_contentPrincipal() {
const contentId = "1";
// fields
const content = 0;
// const domain = 1;
const suffix = 2;
// const csp = 3;
/*
This test should NOT be resilient to changes in versioning,
however it exists purely to verify the code doesn't unintentionally change without updating versioning and migration code.
*/
let tests = [
{
// eslint-disable-next-line @microsoft/sdl/no-insecure-url
input: { uri: "http://example.com/", OA: {} },
expected: `{"${contentId}":{"${content}":"http://example.com/"}}`,
},
{
// eslint-disable-next-line @microsoft/sdl/no-insecure-url
input: { uri: "http://mozilla1.com/", OA: {} },
expected: `{"${contentId}":{"${content}":"http://mozilla1.com/"}}`,
},
{
// eslint-disable-next-line @microsoft/sdl/no-insecure-url
input: { uri: "http://mozilla2.com/", OA: { userContextId: 0 } },
expected: `{"${contentId}":{"${content}":"http://mozilla2.com/"}}`,
},
{
// eslint-disable-next-line @microsoft/sdl/no-insecure-url
input: { uri: "http://mozilla3.com/", OA: { userContextId: 2 } },
expected: `{"${contentId}":{"${content}":"http://mozilla3.com/","${suffix}":"^userContextId=2"}}`,
},
{
// eslint-disable-next-line @microsoft/sdl/no-insecure-url
input: { uri: "http://mozilla4.com/", OA: { privateBrowsingId: 1 } },
expected: `{"${contentId}":{"${content}":"http://mozilla4.com/","${suffix}":"^privateBrowsingId=1"}}`,
},
{
// eslint-disable-next-line @microsoft/sdl/no-insecure-url
input: { uri: "http://mozilla5.com/", OA: { privateBrowsingId: 0 } },
expected: `{"${contentId}":{"${content}":"http://mozilla5.com/"}}`,
},
];
for (let test of tests) {
let uri = Services.io.newURI(test.input.uri);
let p = Services.scriptSecurityManager.createContentPrincipal(
uri,
test.input.OA
);
let sp = E10SUtils.serializePrincipal(p);
is(test.expected, sp, "Expected serialized object for " + test.input.uri);
let dp = E10SUtils.deserializePrincipal(sp);
is(dp.URI.spec, test.input.uri, "Ensure spec is the same");
// Check all the origin attributes
for (let key in test.input.OA) {
is(
dp.originAttributes[key],
test.input.OA[key],
"Ensure value of " + key + " is " + test.input.OA[key]
);
}
}
});
add_task(async function test_systemPrincipal() {
const systemId = "3";
/*
This test should NOT be resilient to changes in versioning,
however it exists purely to verify the code doesn't unintentionally change without updating versioning and migration code.
*/
const expected = `{"${systemId}":{}}`;
let p = Services.scriptSecurityManager.getSystemPrincipal();
let sp = E10SUtils.serializePrincipal(p);
is(expected, sp, "Expected serialized object for system principal");
let dp = E10SUtils.deserializePrincipal(sp);
is(
dp,
Services.scriptSecurityManager.getSystemPrincipal(),
"Deserialized the system principal"
);
});

View File

@@ -0,0 +1,159 @@
"use strict";
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
/*
This test file exists to ensure whenever changes to principal serialization happens,
we guarantee that the data can be restored and generated into a new principal.
The tests are written to be brittle so we encode all versions of the changes into the tests.
*/
add_task(function test_nullPrincipal() {
/*
As Null principals are designed to be non deterministic we just need to ensure that
a previous serialized version matches what it was generated as.
This test should be resilient to changes in versioning, however it should also be duplicated for a new serialization change.
*/
// Principal created with: E10SUtils.serializePrincipal(Services.scriptSecurityManager.createNullPrincipal({ }));
let p = E10SUtils.deserializePrincipal(
"vQZuXxRvRHKDMXv9BbHtkAAAAAAAAAAAwAAAAAAAAEYAAAA4bW96LW51bGxwcmluY2lwYWw6ezU2Y2FjNTQwLTg2NGQtNDdlNy04ZTI1LTE2MTRlYWI1MTU1ZX0AAAAA"
);
is(
"moz-nullprincipal:{56cac540-864d-47e7-8e25-1614eab5155e}",
p.URI.spec,
"Deserialized principal doesn't have the correct URI"
);
// Principal created with: E10SUtils.serializePrincipal(Services.scriptSecurityManager.createNullPrincipal({ userContextId: 2 }));
let p2 = E10SUtils.deserializePrincipal(
"vQZuXxRvRHKDMXv9BbHtkAAAAAAAAAAAwAAAAAAAAEYAAAA4bW96LW51bGxwcmluY2lwYWw6ezA1ZjllN2JhLWIwODMtNDJhMi1iNDdkLTZiODRmNmYwYTM3OX0AAAAQXnVzZXJDb250ZXh0SWQ9Mg=="
);
is(
"moz-nullprincipal:{05f9e7ba-b083-42a2-b47d-6b84f6f0a379}",
p2.URI.spec,
"Deserialized principal doesn't have the correct URI"
);
is(p2.originAttributes.userContextId, 2, "Expected a userContextId of 2");
});
add_task(async function test_realHistoryCheck() {
/*
This test should be resilient to changes in principal serialization, if these are failing then it's likely the code will break session storage.
To recreate this for another version, copy the function into the browser console, browse some pages and printHistory.
Generated with:
function printHistory() {
let tests = [];
let entries = SessionStore.getSessionHistory(gBrowser.selectedTab).entries.map((entry) => { return entry.triggeringPrincipal_base64 });
entries.push(E10SUtils.serializePrincipal(gBrowser.selectedTab.linkedBrowser._contentPrincipal));
for (let entry of entries) {
console.log(entry);
let testData = {};
testData.input = entry;
let principal = E10SUtils.deserializePrincipal(testData.input);
testData.output = {};
if (principal.URI === null) {
testData.output.URI = false;
} else {
testData.output.URISpec = principal.URI.spec;
}
testData.output.originAttributes = principal.originAttributes;
tests.push(testData);
}
return tests;
}
printHistory(); // Copy this into: serializedPrincipalsFromFirefox
*/
let serializedPrincipalsFromFirefox = [
{
input: "SmIS26zLEdO3ZQBgsLbOywAAAAAAAAAAwAAAAAAAAEY=",
output: {
URI: false,
originAttributes: {
firstPartyDomain: "",
inIsolatedMozBrowser: false,
privateBrowsingId: 0,
userContextId: 0,
geckoViewSessionContextId: "",
partitionKey: "",
},
},
},
{
input:
"ZT4OTT7kRfqycpfCC8AeuAAAAAAAAAAAwAAAAAAAAEYB3pRy0IA0EdOTmQAQS6D9QJIHOlRteE8wkTq4cYEyCMYAAAAC/////wAAAbsBAAAAe2h0dHBzOi8vZGV2ZWxvcGVyLm1vemlsbGEub3JnL2VuLVVTLz91dG1fc291cmNlPXd3dy5tb3ppbGxhLm9yZyZ1dG1fbWVkaXVtPXJlZmVycmFsJnV0bV9jYW1wYWlnbj1uYXYmdXRtX2NvbnRlbnQ9ZGV2ZWxvcGVycwAAAAAAAAAFAAAACAAAABUAAAAA/////wAAAAD/////AAAACAAAABUAAAAdAAAAXgAAAB0AAAAHAAAAHQAAAAcAAAAkAAAAAAAAAAD/////AAAAAP////8AAAAlAAAAVgAAAAD/////AQAAAAAAAAAAAAAAAA==",
output: {
URISpec:
"https://developer.mozilla.org/en-US/?utm_source=www.mozilla.org&utm_medium=referral&utm_campaign=nav&utm_content=developers",
originAttributes: {
firstPartyDomain: "",
inIsolatedMozBrowser: false,
privateBrowsingId: 0,
userContextId: 0,
geckoViewSessionContextId: "",
partitionKey: "",
},
},
},
{
input: "SmIS26zLEdO3ZQBgsLbOywAAAAAAAAAAwAAAAAAAAEY=",
output: {
URI: false,
originAttributes: {
firstPartyDomain: "",
inIsolatedMozBrowser: false,
privateBrowsingId: 0,
userContextId: 0,
geckoViewSessionContextId: "",
partitionKey: "",
},
},
},
{
input:
"vQZuXxRvRHKDMXv9BbHtkAAAAAAAAAAAwAAAAAAAAEYAAAA4bW96LW51bGxwcmluY2lwYWw6ezA0NWNhMThkLTQzNmMtNDc0NC1iYmI2LWIxYTE1MzY2ZGY3OX0AAAAA",
output: {
URISpec: "moz-nullprincipal:{045ca18d-436c-4744-bbb6-b1a15366df79}",
originAttributes: {
firstPartyDomain: "",
inIsolatedMozBrowser: false,
privateBrowsingId: 0,
userContextId: 0,
geckoViewSessionContextId: "",
partitionKey: "",
},
},
},
];
for (let test of serializedPrincipalsFromFirefox) {
let principal = E10SUtils.deserializePrincipal(test.input);
for (let key in principal.originAttributes) {
is(
principal.originAttributes[key],
test.output.originAttributes[key],
`Ensure value of ${key} is ${test.output.originAttributes[key]}`
);
}
if ("URI" in test.output && test.output.URI === false) {
is(
principal.isContentPrincipal,
false,
"Should have not have a URI for system"
);
} else {
is(
principal.spec,
test.output.URISpec,
`Should have spec ${test.output.URISpec}`
);
}
}
});

View File

@@ -0,0 +1,11 @@
[DEFAULT]
prefs = [
"zen.window-sync.enabled=false",
]
["browser_gesture_navigation.js"]
["browser_gesture_scroll.js"]
skip-if = [
"os == 'mac' && os_version == '15.30' && arch == 'aarch64'", # Bug 1988390
]

View File

@@ -0,0 +1,231 @@
"use strict";
add_setup(async () => {
// Disable window occlusion. See bug 1733955 / bug 1779559.
if (navigator.platform.indexOf("Win") == 0) {
await SpecialPowers.pushPrefEnv({
set: [["widget.windows.window_occlusion_tracking.enabled", false]],
});
}
});
add_task(async () => {
// Open a new browser window to make sure there is no navigation history.
const newBrowser = await BrowserTestUtils.openNewBrowserWindow({});
let event = {
direction: SimpleGestureEvent.DIRECTION_LEFT,
};
ok(!newBrowser.gGestureSupport._shouldDoSwipeGesture(event));
event = {
direction: SimpleGestureEvent.DIRECTION_RIGHT,
};
ok(!newBrowser.gGestureSupport._shouldDoSwipeGesture(event));
await BrowserTestUtils.closeWindow(newBrowser);
});
function createSimpleGestureEvent(type, direction) {
let event = document.createEvent("SimpleGestureEvent");
event.initSimpleGestureEvent(
type,
false /* canBubble */,
false /* cancelableArg */,
window,
0 /* detail */,
0 /* screenX */,
0 /* screenY */,
0 /* clientX */,
0 /* clientY */,
false /* ctrlKey */,
false /* altKey */,
false /* shiftKey */,
false /* metaKey */,
0 /* button */,
null /* relatedTarget */,
0 /* allowedDirections */,
direction,
1 /* delta */
);
return event;
}
add_task(async () => {
await SpecialPowers.pushPrefEnv({
set: [["ui.swipeAnimationEnabled", false]],
});
// Open a new browser window and load two pages so that the browser can go
// back but can't go forward.
const newWindow = await BrowserTestUtils.openNewBrowserWindow({});
// gHistroySwipeAnimation gets initialized in a requestIdleCallback so we need
// to wait for the initialization.
await TestUtils.waitForCondition(() => {
return (
// There's no explicit notification for the initialization, so we wait
// until `isLTR` matches the browser locale state.
newWindow.gHistorySwipeAnimation.isLTR != Services.locale.isAppLocaleRTL
);
});
BrowserTestUtils.startLoadingURIString(
newWindow.gBrowser.selectedBrowser,
"about:mozilla"
);
await BrowserTestUtils.browserLoaded(
newWindow.gBrowser.selectedBrowser,
false,
"about:mozilla"
);
BrowserTestUtils.startLoadingURIString(
newWindow.gBrowser.selectedBrowser,
"about:about"
);
await BrowserTestUtils.browserLoaded(
newWindow.gBrowser.selectedBrowser,
false,
"about:about"
);
let event = createSimpleGestureEvent(
"MozSwipeGestureMayStart",
SimpleGestureEvent.DIRECTION_LEFT
);
newWindow.gGestureSupport._shouldDoSwipeGesture(event);
// Assuming we are on LTR environment.
is(
event.allowedDirections,
SimpleGestureEvent.DIRECTION_LEFT,
"Allows only swiping to left, i.e. backward"
);
event = createSimpleGestureEvent(
"MozSwipeGestureMayStart",
SimpleGestureEvent.DIRECTION_RIGHT
);
newWindow.gGestureSupport._shouldDoSwipeGesture(event);
is(
event.allowedDirections,
SimpleGestureEvent.DIRECTION_LEFT,
"Allows only swiping to left, i.e. backward"
);
await BrowserTestUtils.closeWindow(newWindow);
});
add_task(async () => {
await SpecialPowers.pushPrefEnv({
set: [["ui.swipeAnimationEnabled", true]],
});
// Open a new browser window and load two pages so that the browser can go
// back but can't go forward.
const newWindow = await BrowserTestUtils.openNewBrowserWindow({});
if (!newWindow.gHistorySwipeAnimation._isSupported()) {
await BrowserTestUtils.closeWindow(newWindow);
return;
}
function sendSwipeSequence(sendEnd) {
let event = createSimpleGestureEvent(
"MozSwipeGestureMayStart",
SimpleGestureEvent.DIRECTION_LEFT
);
newWindow.gGestureSupport.handleEvent(event);
event = createSimpleGestureEvent(
"MozSwipeGestureStart",
SimpleGestureEvent.DIRECTION_LEFT
);
newWindow.gGestureSupport.handleEvent(event);
event = createSimpleGestureEvent(
"MozSwipeGestureUpdate",
SimpleGestureEvent.DIRECTION_LEFT
);
newWindow.gGestureSupport.handleEvent(event);
event = createSimpleGestureEvent(
"MozSwipeGestureUpdate",
SimpleGestureEvent.DIRECTION_LEFT
);
newWindow.gGestureSupport.handleEvent(event);
if (sendEnd) {
sendSwipeEnd();
}
}
function sendSwipeEnd() {
let event = createSimpleGestureEvent(
"MozSwipeGestureEnd",
SimpleGestureEvent.DIRECTION_LEFT
);
newWindow.gGestureSupport.handleEvent(event);
}
// gHistroySwipeAnimation gets initialized in a requestIdleCallback so we need
// to wait for the initialization.
await TestUtils.waitForCondition(() => {
return (
// There's no explicit notification for the initialization, so we wait
// until `isLTR` matches the browser locale state.
newWindow.gHistorySwipeAnimation.isLTR != Services.locale.isAppLocaleRTL
);
});
BrowserTestUtils.startLoadingURIString(
newWindow.gBrowser.selectedBrowser,
"about:mozilla"
);
await BrowserTestUtils.browserLoaded(
newWindow.gBrowser.selectedBrowser,
false,
"about:mozilla"
);
BrowserTestUtils.startLoadingURIString(
newWindow.gBrowser.selectedBrowser,
"about:about"
);
await BrowserTestUtils.browserLoaded(
newWindow.gBrowser.selectedBrowser,
false,
"about:about"
);
// Start a swipe that's not enough to navigate
sendSwipeSequence(/* sendEnd = */ true);
// Wait two frames
await new Promise(r =>
window.requestAnimationFrame(() => window.requestAnimationFrame(r))
);
// The transition to fully stopped shouldn't have had enough time yet to
// become fully stopped.
ok(
newWindow.gHistorySwipeAnimation._isStoppingAnimation,
"should be stopping anim"
);
// Start another swipe.
sendSwipeSequence(/* sendEnd = */ false);
// Wait two frames
await new Promise(r =>
window.requestAnimationFrame(() => window.requestAnimationFrame(r))
);
// We should have started a new swipe, ie we shouldn't be stopping.
ok(
!newWindow.gHistorySwipeAnimation._isStoppingAnimation,
"should not be stopping anim"
);
sendSwipeEnd();
await BrowserTestUtils.closeWindow(newWindow);
});

View File

@@ -0,0 +1,43 @@
/* Any copyright is dedicated to the Public Domain.
https://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
const URL = `data:text/html,
<!doctype html>
<meta charset="utf-8">
<div style="height: 200vh"></div>
Some content
<div style="height: 200vh"></div>
`;
function getScrollPos(browser) {
return SpecialPowers.spawn(browser, [], () => {
return {
scrollY: Math.round(content.scrollY),
scrollMaxY: Math.round(content.scrollMaxY),
};
});
}
// Bug 1964901
add_task(async function test_scroll_command() {
await BrowserTestUtils.withNewTab(URL, async function (browser) {
{
let { scrollY, scrollMaxY } = await getScrollPos(browser);
is(scrollY, 0, "Should be scrolled to the top");
isnot(scrollMaxY, 0, "Should be scrollable");
}
let scrollEvent = SpecialPowers.spawn(browser, [], async () => {
await new Promise(r => {
content.addEventListener("scrollend", r, { once: true });
});
});
goDoCommand("cmd_scrollBottom");
await scrollEvent;
{
let { scrollY, scrollMaxY } = await getScrollPos(browser);
is(scrollY, scrollMaxY, "Should be scrolled to the bottom");
}
});
});

View File

@@ -0,0 +1,12 @@
[DEFAULT]
["browser_contentAreaClick_subframe_javascript.js"]
support-files = [
"file_contentAreaClick_subframe_javascript.html"
]
["browser_javascript_links.js"]
disabled="Disabled by import_external_tests.py"
support-files = [
"file_javascript_links_subframe.html"
]

View File

@@ -0,0 +1,49 @@
const gExampleComRoot = getRootDirectory(gTestPath).replace(
"chrome://mochitests/content/",
"https://example.com/"
);
const IFRAME_FILE = "file_contentAreaClick_subframe_javascript.html";
add_task(async function () {
await SpecialPowers.pushPrefEnv({
set: [["browser.link.alternative_click.block_javascript", false]],
});
await BrowserTestUtils.withNewTab(
`data:text/html,<iframe src="${gExampleComRoot + IFRAME_FILE}"></iframe>`,
async browser => {
let newTabPromise = BrowserTestUtils.waitForNewTab(
gBrowser,
"about:blank"
);
let javascriptRanPromise = TestUtils.topicObserved(
"contentAreaClick-javascriptRan"
);
// ctrl/cmd-click the link in the subframe. This should cause it to be
// loaded in a new tab.
info("Clicking link");
let expectedRemoteType =
browser.browsingContext.children[0].currentRemoteType;
await BrowserTestUtils.synthesizeMouseAtCenter(
"a",
{ ctrlKey: true, metaKey: true },
browser.browsingContext.children[0]
);
info("Waiting for new tab");
let newTab = await newTabPromise;
info("Waiting to be notified that the javascript ran");
await javascriptRanPromise;
is(
newTab.linkedBrowser.remoteType,
expectedRemoteType,
"new tab was loaded in expected process"
);
info("Removing the tab");
BrowserTestUtils.removeTab(newTab);
}
);
});

View File

@@ -0,0 +1,88 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
const TEST_PATH = getRootDirectory(gTestPath).replace(
"chrome://mochitests/content/",
"https://example.com/"
);
const IFRAME_PATH = TEST_PATH + "file_javascript_links_subframe.html";
add_setup(async function () {
await SpecialPowers.pushPrefEnv({
set: [
["browser.link.alternative_click.block_javascript", true],
["browser.tabs.opentabfor.middleclick", true],
["middlemouse.paste", false],
["middlemouse.contentLoadURL", false],
["general.autoScroll", false],
],
});
});
add_task(async function () {
await BrowserTestUtils.withNewTab(
`data:text/html,<a href="javascript:alert(1);">click me`,
async browser => {
await BrowserTestUtils.synthesizeMouseAtCenter(
"a",
{ button: 0, ctrlKey: true, metaKey: true },
browser
);
is(
gBrowser.tabs.length,
2,
"Accel+click on javascript: link shouldn't open a new tab"
);
await BrowserTestUtils.synthesizeMouseAtCenter(
"a",
{ button: 1 },
browser
);
is(
gBrowser.tabs.length,
2,
"Middle click on javascript: link shouldn't open a new tab"
);
await BrowserTestUtils.synthesizeMouseAtCenter(
"a",
{ button: 0, shiftKey: true },
browser
);
// This is fragile and might miss the new window, but the test will fail
// anyway when finishing with an extra window left behind.
// eslint-disable-next-line mozilla/no-arbitrary-setTimeout
await new Promise(resolve => setTimeout(resolve, 200));
is(
BrowserWindowTracker.windowCount,
1,
"Shift+click on javascript: link shouldn't open a new window"
);
}
);
});
add_task(async function iframe_link() {
await BrowserTestUtils.withNewTab(
`data:text/html,<iframe src="${IFRAME_PATH}"></iframe>`,
async browser => {
// ctrl/cmd-click the link in the subframe.
await BrowserTestUtils.synthesizeMouseAtCenter(
"a",
{ ctrlKey: true, metaKey: true },
browser.browsingContext.children[0]
);
// eslint-disable-next-line mozilla/no-arbitrary-setTimeout
await new Promise(resolve => setTimeout(resolve, 200));
is(
gBrowser.tabs.length,
2,
"Click on javascript: link in iframe shouldn't open a new tab"
);
}
);
});

View File

@@ -0,0 +1,2 @@
<!DOCTYPE html>
<a href="javascript:SpecialPowers.notifyObserversInParentProcess(null, 'contentAreaClick-javascriptRan');">link</a>

View File

@@ -0,0 +1,2 @@
<!DOCTYPE html>
<a href="javascript:alert(1)">click me

View File

@@ -8,11 +8,21 @@
BROWSER_CHROME_MANIFESTS += [
"alerts/browser.toml",
"backforward/browser.toml",
"caps/browser.toml",
"gesture/browser.toml",
"linkHandling/browser.toml",
"readermode/browser.toml",
"safebrowsing/browser.toml",
"sessionstore/browser.toml",
"shell/browser.toml",
"tabMediaIndicator/browser.toml",
"tooltiptext/browser.toml",
"zoom/browser.toml",
]
XPCSHELL_TESTS_MANIFESTS += [
"remote-settings/xpcshell.toml",
"services-crypto/xpcshell.toml",
"sync/xpcshell.toml",
]

View File

@@ -0,0 +1,86 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
/* import-globals-from ../../../common/tests/unit/head_global.js */
/* import-globals-from ../../../common/tests/unit/head_helpers.js */
"use strict";
ChromeUtils.defineESModuleGetters(this, {
AppConstants: "resource://gre/modules/AppConstants.sys.mjs",
Database: "resource://services-settings/Database.sys.mjs",
RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
RemoteSettingsClient:
"resource://services-settings/RemoteSettingsClient.sys.mjs",
RemoteSettingsWorker:
"resource://services-settings/RemoteSettingsWorker.sys.mjs",
setTimeout: "resource://gre/modules/Timer.sys.mjs",
SharedUtils: "resource://services-settings/SharedUtils.sys.mjs",
SyncHistory: "resource://services-settings/SyncHistory.sys.mjs",
TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
TestUtils: "resource://testing-common/TestUtils.sys.mjs",
UptakeTelemetry: "resource://services-settings/UptakeTelemetry.sys.mjs",
Utils: "resource://services-settings/Utils.sys.mjs",
});
const IS_ANDROID = AppConstants.platform == "android";
function arrayEqual(a, b) {
return JSON.stringify(a) == JSON.stringify(b);
}
add_setup(function () {
Services.fog.initializeFOG();
});
function enableUptakeMetric() {
Services.fog.applyServerKnobsConfig(
JSON.stringify({
metrics_enabled: {
"uptake.remotecontent.result.uptake_remotesettings": true,
},
})
);
}
function assertTelemetryEvents(expectedEvents) {
if (IS_ANDROID) {
/*
Since `Services.fog.applyServerKnobsConfig()` has no effect on Android
https://searchfox.org/firefox-main/rev/e28b34ab33dbf49364999070168cbb7e11e8e5bd/toolkit/components/glean/xpcom/FOG.cpp#379-390
we skip assertions on events on this platform.
Alternatively we could have disabled the whole tests suites or introduced `if IS_ANDROID` on each
test involving telemetry, but this seems the most reasonable way.
See Bug 2028765 and Bug 2042499
*/
Assert.ok(
true,
"Glean Uptake Telemetry assertions are ALWAYS true on Android"
);
return;
}
const events =
Glean.uptakeRemotecontentResult.uptakeRemotesettings.testGetValue() ?? [];
const receivedValues = events.map(e => e.extra.value);
Assert.equal(
events.length,
expectedEvents.length,
`number of uptake events (${receivedValues})`
);
for (let i = 0; i < expectedEvents.length; i++) {
for (const [key, expected] of Object.entries(expectedEvents[i])) {
if (typeof expected === "function") {
Assert.ok(
expected(events[i].extra[key]),
`event[${i}].extra.${key} passes validator`
);
} else {
Assert.equal(
events[i].extra[key],
expected,
`event[${i}].extra.${key}`
);
}
}
}
}

View File

@@ -0,0 +1,896 @@
const { Downloader } = ChromeUtils.importESModule(
"resource://services-settings/Attachments.sys.mjs"
);
const RECORD = {
id: "1f3a0802-648d-11ea-bd79-876a8b69c377",
attachment: {
hash: "f41ed47d0f43325c9f089d03415c972ce1d3f1ecab6e4d6260665baf3db3ccee",
size: 1597,
filename: "test_file.pem",
location:
"main-workspace/some-collection/65650a0f-7c22-4c10-9744-2d67e301f5f4.pem",
mimetype: "application/x-pem-file",
},
};
const RECORD_OF_DUMP = {
id: "filename-of-dump.txt",
attachment: {
filename: "filename-of-dump.txt",
hash: "4c46ef7e4f1951d210fe54c21e07c09bab265fd122580083ed1d6121547a8c6b",
size: 25,
},
last_modified: 1234567,
some_key: "some metadata",
};
let downloader;
let server;
add_setup(() => {
server = new HttpServer();
server.start(-1);
registerCleanupFunction(() => server.stop(() => {}));
server.registerDirectory(
"/cdn/main-workspace/some-collection/",
do_get_file("test_attachments_downloader")
);
server.registerDirectory(
"/cdn/bundles/",
do_get_file("test_attachments_downloader")
);
// For this test, we are using a server other than production. Force
// LOAD_DUMPS to true so that we can still load attachments from dumps.
delete Utils.LOAD_DUMPS;
Utils.LOAD_DUMPS = true;
});
async function clear_state() {
Services.fog.testResetFOG();
enableUptakeMetric();
Services.prefs.setStringPref(
"services.settings.server",
`http://localhost:${server.identity.primaryPort}/v1`
);
downloader = new Downloader("main", "some-collection");
downloader.cache = {};
const memCacheImpl = {
get: async id => {
return downloader.cache[id];
},
set: async (id, obj) => {
downloader.cache[id] = obj;
},
setMultiple: async idsObjs => {
idsObjs.forEach(([id, obj]) => (downloader.cache[id] = obj));
},
delete: async id => {
delete downloader.cache[id];
},
deleteMultiple: async ids => {
ids.forEach(id => delete downloader.cache[id]);
},
hasData: async () => {
return !!Object.keys(downloader.cache).length;
},
};
// The download() method requires a cacheImpl, but the Downloader
// class does not have one. Define a dummy no-op one.
Object.defineProperty(downloader, "cacheImpl", {
value: memCacheImpl,
// Writable to allow specific tests to override cacheImpl.
writable: true,
});
await downloader.deleteDownloaded(RECORD);
server.registerPathHandler("/v1/", (request, response) => {
response.write(
JSON.stringify({
capabilities: {
attachments: {
base_url: `http://localhost:${server.identity.primaryPort}/cdn/`,
},
},
})
);
response.setHeader("Content-Type", "application/json; charset=UTF-8");
response.setStatusLine(null, 200, "OK");
});
// For tests that use a real client and DB cache, clear the local DB too.
const client = RemoteSettings("some-collection");
await client.db.clear();
await client.db.pruneAttachments([]);
}
add_task(clear_state);
add_task(
async function test_download_throws_server_info_error_if_invalid_response() {
server.registerPathHandler("/v1/", (request, response) => {
response.write("{bad json content");
response.setHeader("Content-Type", "application/json; charset=UTF-8");
response.setStatusLine(null, 200, "OK");
});
let error;
try {
await downloader.download(RECORD);
} catch (e) {
error = e;
}
Assert.ok(error instanceof Downloader.ServerInfoError);
}
);
add_task(clear_state);
add_task(async function test_download_is_retried_3_times_if_download_fails() {
const record = {
id: "abc",
attachment: {
...RECORD.attachment,
location: "404-error.pem",
},
};
let called = 0;
const _fetchAttachment = downloader._fetchAttachment;
downloader._fetchAttachment = async url => {
called++;
return _fetchAttachment(url);
};
let error;
try {
await downloader.download(record);
} catch (e) {
error = e;
}
Assert.equal(called, 4); // 1 + 3 retries
Assert.ok(error instanceof Downloader.DownloadError);
});
add_task(clear_state);
add_task(async function test_download_as_bytes() {
const bytes = await downloader.downloadAsBytes(RECORD);
// See *.pem file in tests data.
Assert.greater(
bytes.byteLength,
1500,
`Wrong bytes size: ${bytes.byteLength}`
);
});
add_task(clear_state);
add_task(async function test_download_is_retried_3_times_if_content_fails() {
const record = {
id: "abc",
attachment: {
...RECORD.attachment,
hash: "always-wrong",
},
};
let called = 0;
downloader._fetchAttachment = async () => {
called++;
return new ArrayBuffer();
};
let error;
try {
await downloader.download(record);
} catch (e) {
error = e;
}
Assert.equal(called, 4); // 1 + 3 retries
Assert.ok(error instanceof Downloader.BadContentError);
});
add_task(clear_state);
add_task(async function test_delete_all() {
const client = RemoteSettings("some-collection");
await client.db.create(RECORD);
await downloader.download(RECORD);
await client.attachments.deleteAll();
Assert.ok(!(await client.attachments.cacheImpl.get(RECORD.id)));
});
add_task(clear_state);
add_task(async function test_downloader_reports_download_errors() {
const client = RemoteSettings("some-collection");
const record = {
attachment: {
...RECORD.attachment,
location: "404-error.pem",
},
};
try {
await client.attachments.download(record, { retry: 0 });
} catch (e) {}
assertTelemetryEvents([
{
value: UptakeTelemetry.STATUS.DOWNLOAD_START,
source: client.identifier,
},
{
value: UptakeTelemetry.STATUS.DOWNLOAD_ERROR,
source: client.identifier,
},
]);
});
add_task(clear_state);
add_task(async function test_downloader_reports_offline_error() {
const backupOffline = Services.io.offline;
Services.io.offline = true;
try {
const client = RemoteSettings("some-collection");
const record = {
attachment: {
...RECORD.attachment,
location: "will-try-and-fail.pem",
},
};
try {
await client.attachments.download(record, { retry: 0 });
} catch (e) {}
assertTelemetryEvents([
{
value: UptakeTelemetry.STATUS.DOWNLOAD_START,
source: client.identifier,
},
{
value: UptakeTelemetry.STATUS.NETWORK_OFFLINE_ERROR,
source: client.identifier,
},
]);
} finally {
Services.io.offline = backupOffline;
}
});
add_task(clear_state);
// Common code for test_download_cache_hit and test_download_cache_corruption.
async function doTestDownloadCacheImpl({
simulateCorruption,
expectedReads = 1,
expectedWrites = 1,
downloadOptions = {},
}) {
let readCount = 0;
let writeCount = 0;
const cacheImpl = {
async get(attachmentId) {
Assert.equal(attachmentId, RECORD.id, "expected attachmentId");
++readCount;
if (simulateCorruption) {
throw new Error("Simulation of corrupted cache (read)");
}
},
async set(attachmentId, attachment) {
Assert.equal(attachmentId, RECORD.id, "expected attachmentId");
Assert.deepEqual(attachment.record, RECORD, "expected record");
++writeCount;
if (simulateCorruption) {
throw new Error("Simulation of corrupted cache (write)");
}
},
async delete() {},
};
Object.defineProperty(downloader, "cacheImpl", { value: cacheImpl });
let downloadResult = await downloader.download(RECORD, downloadOptions);
Assert.equal(downloadResult._source, "remote_match", "expected source");
Assert.equal(downloadResult.buffer.byteLength, 1597, "expected result");
Assert.equal(readCount, expectedReads, "expected cache read attempts");
Assert.equal(writeCount, expectedWrites, "expected cache write attempts");
}
add_task(async function test_download_cache_hit() {
await doTestDownloadCacheImpl({ simulateCorruption: false });
});
add_task(clear_state);
// Verify that the downloader works despite a broken cache implementation.
add_task(async function test_download_cache_corruption() {
await doTestDownloadCacheImpl({ simulateCorruption: true });
});
add_task(clear_state);
add_task(async function test_download_with_cache_enabled() {
await doTestDownloadCacheImpl({
simulateCorruption: false,
downloadOptions: {
cacheResult: true,
},
});
});
add_task(clear_state);
add_task(async function test_download_with_cache_disabled() {
await doTestDownloadCacheImpl({
simulateCorruption: false,
expectedWrites: 0,
downloadOptions: {
cacheResult: false,
},
});
});
add_task(clear_state);
add_task(async function test_download_cached() {
const client = RemoteSettings("main", "some-collection");
const attachmentId = "dummy filename";
const badRecord = {
attachment: {
...RECORD.attachment,
hash: "non-matching hash",
location: "non-existing-location-should-fail.bin",
},
};
async function downloadWithCache(record, options) {
options = { ...options, useCache: true };
return client.attachments.download(record, options);
}
function checkInfo(downloadResult, expectedSource, msg) {
Assert.deepEqual(
downloadResult.record,
RECORD,
`${msg} : expected identical record`
);
// Simple check: assume that content is identical if the size matches.
Assert.equal(
downloadResult.buffer.byteLength,
RECORD.attachment.size,
`${msg} : expected buffer`
);
Assert.equal(
downloadResult._source,
expectedSource,
`${msg} : expected source of the result`
);
}
await Assert.rejects(
downloadWithCache(null, { attachmentId }),
/DownloadError: Could not download dummy filename/,
"Download without record or cache should fail."
);
// Populate cache.
const info1 = await downloadWithCache(RECORD, { attachmentId });
checkInfo(info1, "remote_match", "first time download");
await Assert.rejects(
downloadWithCache(null, { attachmentId }),
/DownloadError: Could not download dummy filename/,
"Download without record still fails even if there is a cache."
);
await Assert.rejects(
downloadWithCache(badRecord, { attachmentId }),
/DownloadError: Could not download .*non-existing-location-should-fail.bin/,
"Download with non-matching record still fails even if there is a cache."
);
// Download from cache.
const info2 = await downloadWithCache(RECORD, { attachmentId });
checkInfo(info2, "cache_match", "download matching record from cache");
const info3 = await downloadWithCache(RECORD, {
attachmentId,
fallbackToCache: true,
});
checkInfo(info3, "cache_match", "fallbackToCache accepts matching record");
const info4 = await downloadWithCache(null, {
attachmentId,
fallbackToCache: true,
});
checkInfo(info4, "cache_fallback", "fallbackToCache accepts null record");
const info5 = await downloadWithCache(badRecord, {
attachmentId,
fallbackToCache: true,
});
checkInfo(info5, "cache_fallback", "fallbackToCache ignores bad record");
// Bye bye cache.
await client.attachments.deleteDownloaded({ id: attachmentId });
await Assert.rejects(
downloadWithCache(null, { attachmentId, fallbackToCache: true }),
/DownloadError: Could not download dummy filename/,
"Download without cache should fail again."
);
await Assert.rejects(
downloadWithCache(badRecord, { attachmentId, fallbackToCache: true }),
/DownloadError: Could not download .*non-existing-location-should-fail.bin/,
"Download should fail to fall back to a download of a non-existing record"
);
});
add_task(clear_state);
add_task(async function test_download_from_dump() {
const client = RemoteSettings("dump-collection", {
bucketName: "dump-bucket",
});
// Temporarily replace the resource:-URL with another resource:-URL.
const orig_RESOURCE_BASE_URL = Downloader._RESOURCE_BASE_URL;
Downloader._RESOURCE_BASE_URL = "resource://rs-downloader-test";
const resProto = Services.io
.getProtocolHandler("resource")
.QueryInterface(Ci.nsIResProtocolHandler);
resProto.setSubstitution(
"rs-downloader-test",
Services.io.newFileURI(do_get_file("test_attachments_downloader"))
);
function checkInfo(result, expectedSource, expectedRecord = RECORD_OF_DUMP) {
Assert.equal(
new TextDecoder().decode(new Uint8Array(result.buffer)),
"This would be a RS dump.\n",
"expected content from dump"
);
Assert.deepEqual(result.record, expectedRecord, "expected record for dump");
Assert.equal(result._source, expectedSource, "expected source of dump");
}
// If record matches, should happen before network request.
const dump1 = await client.attachments.download(RECORD_OF_DUMP, {
// Note: attachmentId not set, so should fall back to record.id.
fallbackToDump: true,
});
checkInfo(dump1, "dump_match");
// If no record given, should try network first, but then fall back to dump.
const dump2 = await client.attachments.download(null, {
attachmentId: RECORD_OF_DUMP.id,
fallbackToDump: true,
});
checkInfo(dump2, "dump_fallback");
// Fill the cache with the same data as the dump for the next part.
await client.db.saveAttachment(RECORD_OF_DUMP.id, {
record: RECORD_OF_DUMP,
blob: new Blob([dump1.buffer]),
});
// The dump should take precedence over the cache.
const dump3 = await client.attachments.download(RECORD_OF_DUMP, {
fallbackToCache: true,
fallbackToDump: true,
});
checkInfo(dump3, "dump_match");
// When the record is not given, the dump takes precedence over the cache
// as a fallback (when the cache and dump are identical).
const dump4 = await client.attachments.download(null, {
attachmentId: RECORD_OF_DUMP.id,
fallbackToCache: true,
fallbackToDump: true,
});
checkInfo(dump4, "dump_fallback");
// Store a record in the cache that is newer than the dump.
const RECORD_NEWER_THAN_DUMP = {
...RECORD_OF_DUMP,
last_modified: RECORD_OF_DUMP.last_modified + 1,
};
await client.db.saveAttachment(RECORD_OF_DUMP.id, {
record: RECORD_NEWER_THAN_DUMP,
blob: new Blob([dump1.buffer]),
});
// When the record is not given, use the cache if it has a more recent record.
const dump5 = await client.attachments.download(null, {
attachmentId: RECORD_OF_DUMP.id,
fallbackToCache: true,
fallbackToDump: true,
});
checkInfo(dump5, "cache_fallback", RECORD_NEWER_THAN_DUMP);
// When a record is given, use whichever that has the matching last_modified.
const dump6 = await client.attachments.download(RECORD_OF_DUMP, {
fallbackToCache: true,
fallbackToDump: true,
});
checkInfo(dump6, "dump_match");
const dump7 = await client.attachments.download(RECORD_NEWER_THAN_DUMP, {
fallbackToCache: true,
fallbackToDump: true,
});
checkInfo(dump7, "cache_match", RECORD_NEWER_THAN_DUMP);
await client.attachments.deleteDownloaded(RECORD_OF_DUMP);
await Assert.rejects(
client.attachments.download(null, {
attachmentId: "filename-without-meta.txt",
fallbackToDump: true,
}),
/DownloadError: Could not download filename-without-meta.txt/,
"Cannot download dump that lacks a .meta.json file"
);
await Assert.rejects(
client.attachments.download(null, {
attachmentId: "filename-without-content.txt",
fallbackToDump: true,
}),
/Could not download resource:\/\/rs-downloader-test\/settings\/dump-bucket\/dump-collection\/filename-without-content\.txt(?!\.meta\.json)/,
"Cannot download dump that is missing, despite the existing .meta.json"
);
// Restore, just in case.
Downloader._RESOURCE_BASE_URL = orig_RESOURCE_BASE_URL;
resProto.setSubstitution("rs-downloader-test", null);
});
// Not really needed because the last test doesn't modify the main collection,
// but added for consistency with other tests tasks around here.
add_task(clear_state);
add_task(
async function test_download_from_dump_fails_when_load_dumps_is_false() {
const client = RemoteSettings("dump-collection", {
bucketName: "dump-bucket",
});
// Temporarily replace the resource:-URL with another resource:-URL.
const orig_RESOURCE_BASE_URL = Downloader._RESOURCE_BASE_URL;
Downloader._RESOURCE_BASE_URL = "resource://rs-downloader-test";
const resProto = Services.io
.getProtocolHandler("resource")
.QueryInterface(Ci.nsIResProtocolHandler);
resProto.setSubstitution(
"rs-downloader-test",
Services.io.newFileURI(do_get_file("test_attachments_downloader"))
);
function checkInfo(
result,
expectedSource,
expectedRecord = RECORD_OF_DUMP
) {
Assert.equal(
new TextDecoder().decode(new Uint8Array(result.buffer)),
"This would be a RS dump.\n",
"expected content from dump"
);
Assert.deepEqual(
result.record,
expectedRecord,
"expected record for dump"
);
Assert.equal(result._source, expectedSource, "expected source of dump");
}
// Download the dump so that we can use it to fill the cache.
const dump1 = await client.attachments.download(RECORD_OF_DUMP, {
// Note: attachmentId not set, so should fall back to record.id.
fallbackToDump: true,
});
checkInfo(dump1, "dump_match");
// Fill the cache with the same data as the dump for the next part.
await client.db.saveAttachment(RECORD_OF_DUMP.id, {
record: RECORD_OF_DUMP,
blob: new Blob([dump1.buffer]),
});
// Now turn off loading dumps, and check we no longer load from the dump,
// but use the cache instead.
Utils.LOAD_DUMPS = false;
const dump2 = await client.attachments.download(RECORD_OF_DUMP, {
// Note: attachmentId not set, so should fall back to record.id.
fallbackToDump: true,
});
checkInfo(dump2, "cache_match");
// When the record is not given, the dump would take precedence over the
// cache but we have disabled dumps, so we should load from the cache.
const dump4 = await client.attachments.download(null, {
attachmentId: RECORD_OF_DUMP.id,
fallbackToCache: true,
fallbackToDump: true,
});
checkInfo(dump4, "cache_fallback");
// Restore, just in case.
Utils.LOAD_DUMPS = true;
Downloader._RESOURCE_BASE_URL = orig_RESOURCE_BASE_URL;
resProto.setSubstitution("rs-downloader-test", null);
}
);
add_task(async function test_attachment_get() {
// Since get() is largely a wrapper around the same code as download(),
// we only test a couple of parts to check it functions as expected, and
// rely on the download() testing for the rest.
await Assert.rejects(
downloader.get(RECORD),
/NotFoundError: Could not find /,
"get() fails when there is no local cache nor dump"
);
const client = RemoteSettings("dump-collection", {
bucketName: "dump-bucket",
});
// Temporarily replace the resource:-URL with another resource:-URL.
const orig_RESOURCE_BASE_URL = Downloader._RESOURCE_BASE_URL;
Downloader._RESOURCE_BASE_URL = "resource://rs-downloader-test";
const resProto = Services.io
.getProtocolHandler("resource")
.QueryInterface(Ci.nsIResProtocolHandler);
resProto.setSubstitution(
"rs-downloader-test",
Services.io.newFileURI(do_get_file("test_attachments_downloader"))
);
function checkInfo(result, expectedSource, expectedRecord = RECORD_OF_DUMP) {
Assert.equal(
new TextDecoder().decode(new Uint8Array(result.buffer)),
"This would be a RS dump.\n",
"expected content from dump"
);
Assert.deepEqual(result.record, expectedRecord, "expected record for dump");
Assert.equal(result._source, expectedSource, "expected source of dump");
}
// When a record is given, use whichever that has the matching last_modified.
const dump = await client.attachments.get(RECORD_OF_DUMP);
checkInfo(dump, "dump_match");
await client.attachments.deleteDownloaded(RECORD_OF_DUMP);
await Assert.rejects(
client.attachments.get(null, {
attachmentId: "filename-without-meta.txt",
fallbackToDump: true,
}),
/NotFoundError: Could not find filename-without-meta.txt in cache or dump/,
"Cannot download dump that lacks a .meta.json file"
);
await Assert.rejects(
client.attachments.get(null, {
attachmentId: "filename-without-content.txt",
fallbackToDump: true,
}),
/Could not download resource:\/\/rs-downloader-test\/settings\/dump-bucket\/dump-collection\/filename-without-content\.txt(?!\.meta\.json)/,
"Cannot download dump that is missing, despite the existing .meta.json"
);
// Restore, just in case.
Downloader._RESOURCE_BASE_URL = orig_RESOURCE_BASE_URL;
resProto.setSubstitution("rs-downloader-test", null);
});
// Not really needed because the last test doesn't modify the main collection,
// but added for consistency with other tests tasks around here.
add_task(clear_state);
add_task(async function test_obsolete_attachments_are_pruned() {
const RECORD2 = {
...RECORD,
id: "another-id",
};
const client = RemoteSettings("some-collection");
// Store records and related attachments directly in the cache.
await client.db.importChanges({}, 42, [RECORD, RECORD2], { clear: true });
await client.db.saveAttachment(RECORD.id, {
record: RECORD,
blob: new Blob(["123"]),
});
await client.db.saveAttachment("custom-id", {
record: RECORD2,
blob: new Blob(["456"]),
});
// Store an extraneous cached attachment.
await client.db.saveAttachment("bar", {
record: { id: "bar" },
blob: new Blob(["789"]),
});
const recordAttachment = await client.attachments.cacheImpl.get(RECORD.id);
Assert.equal(
await recordAttachment.blob.text(),
"123",
"Record has a cached attachment"
);
const record2Attachment = await client.attachments.cacheImpl.get("custom-id");
Assert.equal(
await record2Attachment.blob.text(),
"456",
"Record 2 has a cached attachment"
);
const { blob: cachedExtra } = await client.attachments.cacheImpl.get("bar");
Assert.equal(await cachedExtra.text(), "789", "There is an extra attachment");
await client.attachments.prune([]);
Assert.ok(
await client.attachments.cacheImpl.get(RECORD.id),
"Record attachment was kept"
);
Assert.ok(
await client.attachments.cacheImpl.get("custom-id"),
"Record 2 attachment was kept"
);
Assert.ok(
!(await client.attachments.cacheImpl.get("bar")),
"Extra was deleted"
);
});
add_task(clear_state);
add_task(
async function test_obsolete_attachments_listed_as_excluded_are_not_pruned() {
const client = RemoteSettings("some-collection");
// Store records and related attachments directly in the cache.
await client.db.importChanges({}, 42, [], { clear: true });
await client.db.saveAttachment(RECORD.id, {
record: RECORD,
blob: new Blob(["123"]),
});
const recordAttachment = await client.attachments.cacheImpl.get(RECORD.id);
Assert.equal(
await recordAttachment.blob.text(),
"123",
"Record has a cached attachment"
);
await client.attachments.prune([RECORD.id]);
Assert.ok(
await client.attachments.cacheImpl.get(RECORD.id),
"Record attachment was kept"
);
}
);
add_task(clear_state);
add_task(async function test_cacheAll_happy_path() {
// verify bundle is downloaded succesfully
const allSuccess = await downloader.cacheAll();
Assert.ok(
allSuccess,
"Attachments cacheAll succesfully downloaded a bundle and saved all attachments"
);
// verify accuracy of attachments downloaded
Assert.equal(
downloader.cache["1"].record.title,
"test1",
"Test record 1 meta content appears accurate."
);
Assert.equal(
await downloader.cache["1"].blob.text(),
"test1\n",
"Test file 1 content is accurate."
);
Assert.equal(
downloader.cache["2"].record.title,
"test2",
"Test record 2 meta content appears accurate."
);
Assert.equal(
await downloader.cache["2"].blob.text(),
"test2\n",
"Test file 2 content is accurate."
);
});
add_task(async function test_cacheAll_using_real_db() {
const client = RemoteSettings("some-collection");
const allSuccess = await client.attachments.cacheAll();
Assert.ok(
allSuccess,
"Attachments cacheAll succesfully downloaded a bundle and saved all attachments"
);
Assert.equal(
(await client.attachments.cacheImpl.get("2")).record.title,
"test2",
"Test record 2 meta content appears accurate."
);
Assert.equal(
await (await client.attachments.cacheImpl.get("2")).blob.text(),
"test2\n",
"Test file 2 content is accurate."
);
});
add_task(clear_state);
add_task(async function test_cacheAll_skips_with_existing_data() {
downloader.cache = {
1: "1",
};
const allSuccess = await downloader.cacheAll();
Assert.equal(
allSuccess,
null,
"Attachments cacheAll skips downloads if data already exists"
);
});
add_task(async function test_cacheAll_does_not_skip_if_force_is_true() {
downloader.cache = {
1: "1",
};
const allSuccess = await downloader.cacheAll(true);
Assert.equal(
allSuccess,
true,
"Attachments cacheAll does not skip downloads if force is true"
);
});
add_task(clear_state);
add_task(async function test_cacheAll_failed_request() {
downloader.bucketName = "fake-bucket";
downloader.collectionName = "fake-collection";
const allSuccess = await downloader.cacheAll();
Assert.equal(
allSuccess,
false,
"Attachments cacheAll request failed to download a bundle and returned false"
);
});
add_task(clear_state);
add_task(async function test_cacheAll_failed_unzip() {
downloader.bucketName = "error-bucket";
downloader.collectionName = "bad-zip";
const allSuccess = await downloader.cacheAll();
Assert.equal(
allSuccess,
false,
"Attachments cacheAll request failed to extract a bundle and returned false"
);
});
add_task(clear_state);
add_task(async function test_cacheAll_failed_save() {
const client = RemoteSettings("some-collection");
const backup = client.db.saveAttachments;
client.db.saveAttachments = () => {
throw new Error("boom");
};
const allSuccess = await client.attachments.cacheAll();
Assert.equal(
allSuccess,
false,
"Attachments cacheAll failed to save entries in DB and returned false"
);
client.db.saveAttachments = backup;
});
add_task(clear_state);

View File

@@ -0,0 +1,26 @@
-----BEGIN CERTIFICATE-----
MIIEbjCCA1agAwIBAgIQBg3WwdBnkBtUdfz/wp4xNzANBgkqhkiG9w0BAQsFADBa
MQswCQYDVQQGEwJJRTESMBAGA1UEChMJQmFsdGltb3JlMRMwEQYDVQQLEwpDeWJl
clRydXN0MSIwIAYDVQQDExlCYWx0aW1vcmUgQ3liZXJUcnVzdCBSb290MB4XDTE1
MTAxNDEyMDAwMFoXDTIwMTAxNDEyMDAwMFowbzELMAkGA1UEBhMCVVMxCzAJBgNV
BAgTAkNBMRYwFAYDVQQHEw1TYW4gRnJhbmNpc2NvMRkwFwYDVQQKExBDbG91ZEZs
YXJlLCBJbmMuMSAwHgYDVQQDExdDbG91ZEZsYXJlIEluYyBSU0EgQ0EtMTCCASIw
DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJGiNOIE4s0M4wdhDeV9aMfAYY9l
yG9cfGQqt7a5UgrRA81bi4istCyhzfzRWUW+NAmf6X2HEnA3xLI1M+pH/xEbk9pw
jc8/1CPy9jUjBwb89zt5PWh2I1KxZVg/Bnx2yYdVcKTUMKt0GLDXfZXN+RYZHJQo
lDlzjH5xV0IpDMv/FsMEZWcfx1JorBf08bRnRVkl9RY00y2ujVr+492ze+zYQ9s7
HcidpR+7ret3jzLSvojsaA5+fOaCG0ctVJcLfnkQ5lWR95ByBdO1NapfqZ1+kmCL
3baVSeUpYQriBwznxfLuGs8POo4QdviYVtSPBWjOEfb+o1c6Mbo8p4noFzUCAwEA
AaOCARkwggEVMBIGA1UdEwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgGGMDQG
CCsGAQUFBwEBBCgwJjAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQu
Y29tMDoGA1UdHwQzMDEwL6AtoCuGKWh0dHA6Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9P
bW5pcm9vdDIwMjUuY3JsMD0GA1UdIAQ2MDQwMgYEVR0gADAqMCgGCCsGAQUFBwIB
FhxodHRwczovL3d3dy5kaWdpY2VydC5jb20vQ1BTMB0GA1UdDgQWBBSRBYrfTCLG
bYuUTBZFfu5vAvu3wDAfBgNVHSMEGDAWgBTlnVkwgkdYzKz6CFQ2hns6tQRN8DAN
BgkqhkiG9w0BAQsFAAOCAQEAVJle3ar9NSnTrLAhgfkcpClIY6/kabDIEa8cOnu1
SOXf4vbtZakSmmIbFbmYDUGIU5XwwVdF/FKNzNBRf9G4EL/S0NXytBKj4A34UGQA
InaV+DgVLzCifN9cAHi8EFEAfbglUvPvLPFXF0bwffElYm7QBSiHYSZmfOKLCyiv
3zlQsf7ozNBAxfbmnRMRSUBcIhRwnaFoFgDs7yU6R1Yk4pO7eMgWpdPGhymDTIvv
RnauKStzKsAli9i5hQ4nTDITUpMAmeJoXodgwRkC3Civw32UR2rxObIyxPpbfODb
sZKNGO9K5Sjj6turB1zwbd2wI8MhtUCY9tGmSYhe7G6Bkw==
-----END CERTIFICATE-----

View File

@@ -0,0 +1,10 @@
{
"id": "filename-of-dump.txt",
"attachment": {
"filename": "filename-of-dump.txt",
"hash": "4c46ef7e4f1951d210fe54c21e07c09bab265fd122580083ed1d6121547a8c6b",
"size": 25
},
"last_modified": 1234567,
"some_key": "some metadata"
}

View File

@@ -0,0 +1,8 @@
{
"fyi": "This .meta.json file describes an attachment, but that attachment is missing.",
"attachment": {
"filename": "filename-without-content.txt",
"hash": "...",
"size": "..."
}
}

View File

@@ -0,0 +1 @@
The filename-without-meta.txt.meta.json file is missing.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,55 @@
"use strict";
async function getLocalDumpLastModified(bucket, collection) {
let res;
try {
res = await fetch(
`resource://app/defaults/settings/${bucket}/${collection}.json`
);
} catch (e) {
return -1;
}
const { timestamp } = await res.json();
Assert.greaterOrEqual(
timestamp,
0,
`${bucket}/${collection} dump has timestamp`
);
return timestamp;
}
add_task(async function lastModified_of_non_existing_dump() {
ok(!Utils._dumpStats, "_dumpStats not initialized");
equal(
await Utils.getLocalDumpLastModified("did not", "exist"),
-1,
"A non-existent dump has value -1"
);
ok(Utils._dumpStats, "_dumpStats was initialized");
ok("did not/exist" in Utils._dumpStats, "cached non-existing dump result");
delete Utils._dumpStats["did not/exist"];
});
add_task(async function lastModified_summary_is_correct() {
ok(!!Object.keys(Utils._dumpStats).length, "Contains summary of dumps");
let checked = 0;
for (let [identifier, lastModified] of Object.entries(Utils._dumpStats)) {
let [bucket, collection] = identifier.split("/");
let actual = await getLocalDumpLastModified(bucket, collection);
if (actual < 0) {
info(`${identifier} has no dump, skip.`);
continue;
}
info(`Checking correctness of ${identifier}`);
equal(
await Utils.getLocalDumpLastModified(bucket, collection),
lastModified,
`Expected last_modified value for ${identifier}`
);
equal(lastModified, actual, `last_modified should match collection`);
checked++;
}
Assert.greater(checked, 0, "At least one dump was packaged and checked.");
});

View File

@@ -0,0 +1,210 @@
let client;
async function createRecords(records) {
await client.db.importChanges(
{},
42,
records.map((record, i) => ({
id: `record-${i}`,
...record,
})),
{
clear: true,
}
);
}
add_setup(() => {
client = RemoteSettings("some-key");
});
add_task(async function test_returns_all_without_target() {
await createRecords([
{
passwordSelector: "#pass-signin",
},
{
filter_expression: null,
},
{
filter_expression: "",
},
]);
const list = await client.get();
equal(list.length, 3);
});
add_task(async function test_filters_can_be_disabled() {
const c = RemoteSettings("no-jexl", { filterCreator: null });
await c.db.importChanges({}, 42, [
{
id: "abc",
filter_expression: "1 == 2",
},
]);
const list = await c.get();
equal(list.length, 1);
});
add_task(async function test_returns_entries_where_jexl_is_true() {
await createRecords([
{
willMatch: true,
filter_expression: "1",
},
{
willMatch: true,
filter_expression: "[42]",
},
{
willMatch: true,
filter_expression: "1 == 2 || 1 == 1",
},
{
willMatch: true,
filter_expression: 'env.appinfo.ID == "xpcshell@tests.mozilla.org"',
},
{
willMatch: false,
filter_expression: "env.version == undefined",
},
{
willMatch: true,
filter_expression: "env.unknown == undefined",
},
{
willMatch: false,
filter_expression: "1 == 2",
},
]);
const list = await client.get();
equal(list.length, 5);
ok(list.every(e => e.willMatch));
});
add_task(async function test_ignores_entries_where_jexl_is_invalid() {
await createRecords([
{
filter_expression: "true === true", // JavaScript Error: "Invalid expression token: ="
},
{
filter_expression: "Objects.keys({}) == []", // Token ( (openParen) unexpected in expression
},
]);
const list = await client.get();
equal(list.length, 0);
});
add_task(async function test_support_of_date_filters() {
await createRecords([
{
willMatch: true,
filter_expression: '"1982-05-08"|date < "2016-03-22"|date',
},
{
willMatch: false,
filter_expression: '"2000-01-01"|date < "1970-01-01"|date',
},
]);
const list = await client.get();
equal(list.length, 1);
ok(list.every(e => e.willMatch));
});
add_task(async function test_support_of_preferences_filters() {
await createRecords([
{
willMatch: true,
filter_expression: '"services.settings.last_etag"|preferenceValue == 42',
},
{
willMatch: true,
filter_expression:
'"services.settings.poll_interval"|preferenceExists == true',
},
{
willMatch: true,
filter_expression:
'"services.settings.poll_interval"|preferenceIsUserSet == false',
},
{
willMatch: true,
filter_expression:
'"services.settings.last_etag"|preferenceIsUserSet == true',
},
]);
// Set a pref for the user.
Services.prefs.setIntPref("services.settings.last_etag", 42);
const list = await client.get();
equal(list.length, 4);
ok(list.every(e => e.willMatch));
});
add_task(async function test_support_of_intersect_operator() {
await createRecords([
{
willMatch: true,
filter_expression: '{foo: 1, bar: 2}|keys intersect ["foo"]',
},
{
willMatch: true,
filter_expression: '(["a", "b"] intersect ["a", 1, 4]) == "a"',
},
{
willMatch: false,
filter_expression: '(["a", "b"] intersect [3, 1, 4]) == "c"',
},
{
willMatch: true,
filter_expression: `
[1, 2, 3]
intersect
[3, 4, 5]
`,
},
]);
const list = await client.get();
equal(list.length, 3);
ok(list.every(e => e.willMatch));
});
add_task(async function test_support_of_samples() {
await createRecords([
{
willMatch: true,
filter_expression: '"always-true"|stableSample(1)',
},
{
willMatch: false,
filter_expression: '"always-false"|stableSample(0)',
},
{
willMatch: true,
filter_expression: '"turns-to-true-0"|stableSample(0.5)',
},
{
willMatch: false,
filter_expression: '"turns-to-false-1"|stableSample(0.5)',
},
{
willMatch: true,
filter_expression: '"turns-to-true-0"|bucketSample(0, 50, 100)',
},
{
willMatch: false,
filter_expression: '"turns-to-false-1"|bucketSample(0, 50, 100)',
},
]);
const list = await client.get();
equal(list.length, 3);
ok(list.every(e => e.willMatch));
});

View File

@@ -0,0 +1,139 @@
// A collection with a dump that's packaged on all builds where this test runs,
// including on Android at mobile/android/installer/package-manifest.in
const TEST_BUCKET = "main";
const TEST_COLLECTION = "password-recipes";
let client;
let DUMP_RECORDS;
let DUMP_LAST_MODIFIED;
add_setup(async () => {
// "services.settings.server" pref is not set.
// Test defaults to an unreachable server,
// and will only load from the dump if any.
client = new RemoteSettingsClient(TEST_COLLECTION, {
bucketName: TEST_BUCKET,
});
const dump = await SharedUtils.loadJSONDump(TEST_BUCKET, TEST_COLLECTION);
DUMP_RECORDS = dump.data;
DUMP_LAST_MODIFIED = dump.timestamp;
// Dumps are fetched via the following, which sorts the records, newest first.
// https://searchfox.org/mozilla-central/rev/5b3444ad300e244b5af4214212e22bd9e4b7088a/taskcluster/docker/periodic-updates/scripts/periodic_file_updates.sh#304
equal(
DUMP_LAST_MODIFIED,
DUMP_RECORDS[0].last_modified,
"records in dump ought to be sorted by last_modified"
);
});
async function importData(records) {
await RemoteSettingsWorker._execute("_test_only_import", [
TEST_BUCKET,
TEST_COLLECTION,
records,
records[0]?.last_modified || 0,
]);
}
async function clear_state() {
await client.db.clear();
}
add_task(async function test_load_from_dump_when_offline() {
// Baseline: verify that the collection is empty at first,
// but non-empty after loading from the dump.
const beforeTimestamp = await client.db.getLastModified();
equal(beforeTimestamp, null, "collection empty when offline");
// should import from dump since collection was not initialized.
const after = await client.get();
equal(after.length, DUMP_RECORDS.length, "collection loaded from dump");
equal(await client.getLastModified(), DUMP_LAST_MODIFIED, "dump's timestamp");
});
add_task(clear_state);
add_task(async function test_optional_skip_dump_after_empty_import() {
// clear_state should have wiped the database.
const beforeTimestamp = await client.db.getLastModified();
equal(beforeTimestamp, null, "collection empty after clearing");
// Verify that the dump is not imported again by client.get()
// when the database is initialized with an empty dump
// with `loadDumpIfNewer` disabled.
await importData([]); // <-- Empty set of records.
const after = await client.get({ loadDumpIfNewer: false });
equal(after.length, 0, "collection still empty due to import");
equal(await client.getLastModified(), 0, "Empty dump has no timestamp");
});
add_task(clear_state);
add_task(async function test_optional_skip_dump_after_non_empty_import() {
await importData([{ last_modified: 1234, id: "dummy" }]);
const after = await client.get({ loadDumpIfNewer: false });
equal(after.length, 1, "Imported dummy data");
equal(await client.getLastModified(), 1234, "Expected timestamp of import");
await importData([]);
const after2 = await client.get({ loadDumpIfNewer: false });
equal(after2.length, 0, "Previous data wiped on duplicate import");
equal(await client.getLastModified(), 0, "Timestamp of empty collection");
});
add_task(clear_state);
add_task(async function test_load_dump_after_empty_import() {
await importData([]); // <-- Empty set of records, i.e. last_modified = 0.
const after = await client.get();
equal(after.length, DUMP_RECORDS.length, "Imported dump");
equal(await client.getLastModified(), DUMP_LAST_MODIFIED, "dump's timestamp");
});
add_task(clear_state);
add_task(async function test_load_dump_after_non_empty_import() {
// Dump is updated regularly, verify that the dump matches our expectations
// before running the test.
Assert.greater(
DUMP_LAST_MODIFIED,
1234,
"Assuming dump to be newer than dummy 1234"
);
await importData([{ last_modified: 1234, id: "dummy" }]);
const after = await client.get();
equal(after.length, DUMP_RECORDS.length, "Imported dump");
equal(await client.getLastModified(), DUMP_LAST_MODIFIED, "dump's timestamp");
});
add_task(clear_state);
add_task(async function test_load_dump_after_import_from_broken_distro() {
// Dump is updated regularly, verify that the dump matches our expectations
// before running the test.
Assert.greater(
DUMP_LAST_MODIFIED,
1234,
"Assuming dump to be newer than dummy 1234"
);
// No last_modified time.
await importData([{ id: "dummy" }]);
const after = await client.get();
equal(after.length, DUMP_RECORDS.length, "Imported dump");
equal(await client.getLastModified(), DUMP_LAST_MODIFIED, "dump's timestamp");
});
add_task(clear_state);
add_task(async function test_skip_dump_if_same_last_modified() {
await importData([{ last_modified: DUMP_LAST_MODIFIED, id: "dummy" }]);
const after = await client.get();
equal(after.length, 1, "Not importing dump when time matches");
equal(await client.getLastModified(), DUMP_LAST_MODIFIED, "Same timestamp");
});
add_task(clear_state);

View File

@@ -0,0 +1,149 @@
const PREF_SETTINGS_SERVER = "services.settings.server";
let server;
let client;
async function clear_state() {
await client.db.clear();
}
function setJSONResponse(response, changeset) {
response.setStatusLine(null, 200, "OK");
response.setHeader("Content-Type", "application/json; charset=UTF-8");
response.setHeader("Date", new Date().toUTCString());
response.write(JSON.stringify(changeset));
}
add_setup(() => {
Services.prefs.setStringPref("services.settings.loglevel", "debug");
// Set up an HTTP Server
server = new HttpServer();
server.start(-1);
Services.prefs.setStringPref(
PREF_SETTINGS_SERVER,
`http://localhost:${server.identity.primaryPort}/v1`
);
client = RemoteSettings("some-cid");
const bodies = {
// First successful sync at timestamp=333
"/v1/buckets/main/collections/some-cid/changeset?_expected=333": {
timestamp: 333,
metadata: {
signatures: [{ signature: "abc", x5u: "data:text/plain;base64,pem" }],
},
changes: [
{ id: "rid2", last_modified: 22 },
{ id: "rid1", last_modified: 11 },
],
},
// Client fetches with _expected=222&_since=333
"/v1/buckets/main/collections/some-cid/changeset?_expected=222&_since=333":
{
timestamp: 222,
metadata: {
signatures: [{ signature: "ghi", x5u: "data:text/plain;base64,pem" }],
},
changes: [{ id: "rid1", last_modified: 11 }],
},
// Client refetches full collection with _expected=222 after signature fails.
"/v1/buckets/main/collections/some-cid/changeset?_expected=222": {
timestamp: 222,
metadata: {
signatures: [{ signature: "ghi", x5u: "data:text/plain;base64,pem" }],
},
changes: [{ id: "rid1", last_modified: 11 }],
},
};
const handler = (request, response) => {
const body = bodies[`${request.path}?${request.queryString}`];
if (!body) {
const err = new Error(
`Unexpected request ${request.path}?${request.queryString}`
);
console.error(err);
throw err;
}
setJSONResponse(response, body);
};
Object.keys(bodies).map(path =>
server.registerPathHandler(path.split("?")[0], handler)
);
// In this test suite, the only valid data is the one at timestamp=222 or timestamp=333, but not a mix of the two.
let backup = client._verifier;
client._verifier = {
asyncVerifyContentSignature: serialized => {
return (
serialized ==
'{"data":[{"id":"rid2","last_modified":22}],"last_modified":"444"}' ||
serialized ==
'{"data":[{"id":"rid1","last_modified":11},{"id":"rid2","last_modified":22}],"last_modified":"333"}' ||
serialized ==
'{"data":[{"id":"rid1","last_modified":11}],"last_modified":"222"}'
);
},
};
registerCleanupFunction(() => {
client._verifier = backup;
server.stop(() => {});
Services.prefs.clearUserPref(PREF_SETTINGS_SERVER);
Services.prefs.clearUserPref("services.settings.loglevel");
});
});
add_task(clear_state);
add_task(async function test_ignores_if_local_signature_is_valid() {
await client.maybeSync(333);
Assert.deepEqual(
[
await client.db.getLastModified(),
(await client.get()).map(({ id }) => id),
],
[333, ["rid1", "rid2"]]
);
await client.maybeSync(222);
// The client should detect that timestamp is older,
// but it ignores it because local data is good.
Assert.deepEqual(
[
await client.db.getLastModified(),
(await client.get()).map(({ id }) => id),
],
[333, ["rid1", "rid2"]]
);
});
add_task(clear_state);
add_task(async function test_uses_old_data_if_local_signature_is_invalid() {
// Import some data in the local DB. Signature will be bad.
await client.db.importChanges({}, 333, [{ id: "myid", last_modified: 1234 }]);
Assert.deepEqual(
[
await client.db.getLastModified(),
(await client.get()).map(({ id }) => id),
],
[333, ["myid"]]
);
await client.maybeSync(222);
// The client should detect that timestamp is older,
// but will replace local data with this old data.
Assert.deepEqual(
[
await client.db.getLastModified(),
(await client.get()).map(({ id }) => id),
],
[222, ["rid1"]]
);
});
add_task(clear_state);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,138 @@
const PREF_SETTINGS_SERVER = "services.settings.server";
const CHANGES_PATH = "/v1" + Utils.CHANGES_PATH;
const BROKEN_SYNC_THRESHOLD = 10; // See default pref value
let server;
let client;
let maybeSyncBackup;
async function clear_state() {
// Disable logging output.
Services.prefs.setStringPref("services.settings.loglevel", "critical");
// Pull data from the test server.
Services.prefs.setStringPref(
PREF_SETTINGS_SERVER,
`http://localhost:${server.identity.primaryPort}/v1`
);
// Clear sync history.
await new SyncHistory("").clear();
// Simulate a response whose ETag gets incremented on each call
// (in order to generate several history entries, indexed by timestamp).
let timestamp = 1337;
server.registerPathHandler(CHANGES_PATH, (request, response) => {
response.setStatusLine(null, 200, "OK");
response.setHeader("Content-Type", "application/json; charset=UTF-8");
response.setHeader("Date", new Date(1000000).toUTCString());
response.setHeader("ETag", `"${timestamp}"`);
response.write(
JSON.stringify({
timestamp,
changes: [
{
last_modified: ++timestamp,
bucket: "main",
collection: "desktop-manager",
},
],
})
);
});
// Restore original maybeSync() method between each test.
client.maybeSync = maybeSyncBackup;
}
function run_test() {
// Set up an HTTP Server
server = new HttpServer();
server.start(-1);
client = RemoteSettings("desktop-manager");
maybeSyncBackup = client.maybeSync;
run_next_test();
registerCleanupFunction(() => {
server.stop(() => {});
// Restore original maybeSync() method when test suite is done.
client.maybeSync = maybeSyncBackup;
});
}
add_task(clear_state);
add_task(async function test_db_is_destroyed_when_sync_is_broken() {
// Simulate a successful sync.
client.maybeSync = async () => {
// Store some data in local DB.
await client.db.importChanges({}, 1515, []);
};
await RemoteSettings.pollChanges({ trigger: "timer" });
// Register a client with a failing sync method.
client.maybeSync = () => {
throw new RemoteSettingsClient.InvalidSignatureError(
"main/desktop-manager"
);
};
// Now obtain several failures in a row.
for (var i = 0; i < BROKEN_SYNC_THRESHOLD; i++) {
try {
await RemoteSettings.pollChanges({ trigger: "timer" });
} catch (e) {}
}
// Synchronization is in broken state.
Assert.equal(
await client.db.getLastModified(),
1515,
"Local DB was not destroyed yet"
);
// Synchronize again. Broken state will be detected.
try {
await RemoteSettings.pollChanges({ trigger: "timer" });
} catch (e) {}
// DB was destroyed.
Assert.equal(
await client.db.getLastModified(),
null,
"Local DB was destroyed"
);
});
add_task(clear_state);
add_task(async function test_db_is_not_destroyed_when_state_is_server_error() {
// Since we don't mock the server endpoints to obtain the changeset of this
// collection, the call to `maybeSync()` will fail with network errors.
// Store some data in local DB.
await client.db.importChanges({}, 1515, []);
// Now obtain several failures in a row.
let lastError;
for (var i = 0; i < BROKEN_SYNC_THRESHOLD + 1; i++) {
try {
await RemoteSettings.pollChanges({ trigger: "timer" });
} catch (e) {
lastError = e;
}
}
Assert.ok(
/Cannot parse server content/.test(lastError.message),
"Error is about server"
);
// DB was not destroyed.
Assert.equal(
await client.db.getLastModified(),
1515,
"Local DB was not destroyed"
);
});
add_task(clear_state);

View File

@@ -0,0 +1,252 @@
"use strict";
var nextUniqId = 0;
function getNewUtils() {
const { Utils } = ChromeUtils.importESModule(
`resource://services-settings/Utils.sys.mjs?_${++nextUniqId}`
);
return Utils;
}
// A collection with a dump that's packaged on all builds where this test runs,
// including on Android at mobile/android/installer/package-manifest.in
const TEST_BUCKET = "main";
const TEST_COLLECTION = "password-recipes";
async function importData(records) {
await RemoteSettingsWorker._execute("_test_only_import", [
TEST_BUCKET,
TEST_COLLECTION,
records,
records[0]?.last_modified || 0,
]);
}
async function clear_state() {
Services.env.set("MOZ_REMOTE_SETTINGS_DEVTOOLS", "0");
Services.prefs.clearUserPref("services.settings.server");
Services.prefs.clearUserPref("services.settings.preview_enabled");
}
add_setup(async function () {
// Set this env vars in order to test the code path where the
// server URL can only be overridden from Dev Tools.
// See `isRunningTests` in `services/settings/Utils.sys.mjs`.
const before = Services.env.get("MOZ_DISABLE_NONLOCAL_CONNECTIONS");
Services.env.set("MOZ_DISABLE_NONLOCAL_CONNECTIONS", "0");
registerCleanupFunction(() => {
clear_state();
Services.env.set("MOZ_DISABLE_NONLOCAL_CONNECTIONS", before);
});
});
add_task(clear_state);
add_task(
{
skip_if: () => !AppConstants.RELEASE_OR_BETA,
},
async function test_server_url_cannot_be_toggled_in_release() {
Services.prefs.setStringPref(
"services.settings.server",
"http://localhost:8888/v1"
);
const Utils = getNewUtils();
Assert.equal(
Utils.SERVER_URL,
AppConstants.REMOTE_SETTINGS_SERVER_URLS[0],
"Server url pref was not read in release"
);
}
);
add_task(
{
skip_if: () => AppConstants.RELEASE_OR_BETA,
},
async function test_server_url_cannot_be_toggled_in_dev_nightly() {
Services.prefs.setStringPref(
"services.settings.server",
"http://localhost:8888/v1"
);
const Utils = getNewUtils();
Assert.notEqual(
Utils.SERVER_URL,
AppConstants.REMOTE_SETTINGS_SERVER_URLS[0],
"Server url pref was read in nightly/dev"
);
}
);
add_task(clear_state);
add_task(
{
skip_if: () => !AppConstants.RELEASE_OR_BETA,
},
async function test_preview_mode_cannot_be_toggled_in_release() {
Services.prefs.setBoolPref("services.settings.preview_enabled", true);
const Utils = getNewUtils();
Assert.ok(!Utils.PREVIEW_MODE, "Preview mode pref was not read in release");
}
);
add_task(clear_state);
add_task(
{
skip_if: () => AppConstants.RELEASE_OR_BETA,
},
async function test_preview_mode_cannot_be_toggled_in_dev_nightly() {
Services.prefs.setBoolPref("services.settings.preview_enabled", true);
const Utils = getNewUtils();
Assert.ok(Utils.PREVIEW_MODE, "Preview mode pref is read in dev/nightly");
}
);
add_task(clear_state);
add_task(
{
skip_if: () => !AppConstants.RELEASE_OR_BETA,
},
async function test_load_dumps_will_always_be_loaded_in_release() {
Services.prefs.setStringPref(
"services.settings.server",
"http://localhost:8888/v1"
);
const Utils = getNewUtils();
Assert.equal(
Utils.SERVER_URL,
AppConstants.REMOTE_SETTINGS_SERVER_URLS[0],
"Server url pref was not read"
);
Assert.ok(Utils.LOAD_DUMPS, "Dumps will always be loaded");
}
);
add_task(
{
skip_if: () => AppConstants.RELEASE_OR_BETA,
},
async function test_load_dumps_can_be_disabled_in_dev_nightly() {
Services.prefs.setStringPref(
"services.settings.server",
"http://localhost:8888/v1"
);
const Utils = getNewUtils();
Assert.notEqual(
Utils.SERVER_URL,
AppConstants.REMOTE_SETTINGS_SERVER_URLS[0],
"Server url pref was read"
);
Assert.ok(!Utils.LOAD_DUMPS, "Dumps are not loaded if server is not prod");
}
);
add_task(clear_state);
add_task(
async function test_server_url_can_be_changed_in_all_versions_if_running_for_devtools() {
Services.env.set("MOZ_REMOTE_SETTINGS_DEVTOOLS", "1");
Services.prefs.setStringPref(
"services.settings.server",
"http://localhost:8888/v1"
);
const Utils = getNewUtils();
Assert.notEqual(
Utils.SERVER_URL,
AppConstants.REMOTE_SETTINGS_SERVER_URLS[0],
"Server url pref was read"
);
}
);
add_task(clear_state);
add_task(
async function test_preview_mode_can_be_changed_in_all_versions_if_running_for_devtools() {
Services.env.set("MOZ_REMOTE_SETTINGS_DEVTOOLS", "1");
Services.prefs.setBoolPref("services.settings.preview_enabled", true);
const Utils = getNewUtils();
Assert.ok(Utils.PREVIEW_MODE, "Preview mode pref was read");
}
);
add_task(clear_state);
add_task(
async function test_dumps_are_not_loaded_if_server_is_not_prod_if_running_for_devtools() {
Services.env.set("MOZ_REMOTE_SETTINGS_DEVTOOLS", "1");
Services.prefs.setStringPref(
"services.settings.server",
"http://localhost:8888/v1"
);
const Utils = getNewUtils();
Assert.ok(!Utils.LOAD_DUMPS, "Dumps won't be loaded");
// The section below ensures that the LOAD_DUMPS flag properly takes effect.
// The client is set up here rather than add_setup to avoid triggering the
// lazy getters that are behind the global Utils.LOAD_DUMPS. If they are
// triggered too early, then they will potentially cache different values
// for the server urls and environment variables and this test then won't be
// testing what we expect it to.
let client = new RemoteSettingsClient(TEST_COLLECTION);
const dump = await SharedUtils.loadJSONDump(TEST_BUCKET, TEST_COLLECTION);
let DUMP_LAST_MODIFIED = dump.timestamp;
// Dump is updated regularly, verify that the dump matches our expectations
// before running the test.
Assert.greater(
DUMP_LAST_MODIFIED,
1234,
"Assuming dump to be newer than dummy 1234"
);
await client.db.clear();
await importData([{ last_modified: 1234, id: "dummy" }]);
const after = await client.get();
Assert.deepEqual(
after,
[{ last_modified: 1234, id: "dummy" }],
"Should have kept the original import"
);
Assert.equal(
await client.getLastModified(),
1234,
"Should have kept the import's timestamp"
);
await client.db.clear();
}
);
add_task(clear_state);
add_task(
async function test_dumps_are_loaded_if_server_is_prod_if_running_for_devtools() {
Services.env.set("MOZ_REMOTE_SETTINGS_DEVTOOLS", "1");
Services.prefs.setStringPref(
"services.settings.server",
AppConstants.REMOTE_SETTINGS_SERVER_URLS[0]
);
const Utils = getNewUtils();
Assert.ok(Utils.LOAD_DUMPS, "dumps are loaded if prod");
}
);
add_task(clear_state);

View File

@@ -0,0 +1,952 @@
/* import-globals-from ../../../common/tests/unit/head_helpers.js */
"use strict";
const PREF_SETTINGS_SERVER = "services.settings.server";
const SIGNER_NAME = "onecrl.content-signature.mozilla.org";
const CERT_DIR = "test_remote_settings_signatures/";
const CHAIN_FILES = ["collection_signing_ee.pem", "collection_signing_int.pem"];
function getFileData(file) {
const stream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
Ci.nsIFileInputStream
);
stream.init(file, -1, 0, 0);
const data = NetUtil.readInputStreamToString(stream, stream.available());
stream.close();
return data;
}
function getCertChain() {
const chain = [];
for (let file of CHAIN_FILES) {
chain.push(getFileData(do_get_file(CERT_DIR + file)));
}
return chain.join("\n");
}
let server;
let client;
add_setup(() => {
// Signature verification is enabled by default. We use a custom signer
// because these tests were originally written for OneCRL.
client = RemoteSettings("signed", { signerName: SIGNER_NAME });
Services.prefs.setStringPref("services.settings.loglevel", "debug");
// Set up an HTTP Server
server = new HttpServer();
server.start(-1);
registerCleanupFunction(() => {
Services.prefs.clearUserPref("services.settings.loglevel");
Services.prefs.clearUserPref(PREF_SETTINGS_SERVER);
server.stop(() => {});
});
});
add_task(async function test_check_signatures() {
// First, perform a signature verification with known data and signature
// to ensure things are working correctly
let verifier = Cc[
"@mozilla.org/security/contentsignatureverifier;1"
].createInstance(Ci.nsIContentSignatureVerifier);
const emptyData = "[]";
const emptySignature =
"p384ecdsa=zbugm2FDitsHwk5-IWsas1PpWwY29f0Fg5ZHeqD8fzep7AVl2vfcaHA7LdmCZ28qZLOioGKvco3qT117Q4-HlqFTJM7COHzxGyU2MMJ0ZTnhJrPOC1fP3cVQjU1PTWi9";
ok(
await verifier.asyncVerifyContentSignature(
emptyData,
emptySignature,
getCertChain(),
SIGNER_NAME,
Ci.nsIX509CertDB.AppXPCShellRoot
)
);
const collectionData =
'[{"details":{"bug":"https://bugzilla.mozilla.org/show_bug.cgi?id=1155145","created":"2016-01-18T14:43:37Z","name":"GlobalSign certs","who":".","why":"."},"enabled":true,"id":"97fbf7c4-3ef2-f54f-0029-1ba6540c63ea","issuerName":"MHExKDAmBgNVBAMTH0dsb2JhbFNpZ24gUm9vdFNpZ24gUGFydG5lcnMgQ0ExHTAbBgNVBAsTFFJvb3RTaWduIFBhcnRuZXJzIENBMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMQswCQYDVQQGEwJCRQ==","last_modified":2000,"serialNumber":"BAAAAAABA/A35EU="},{"details":{"bug":"https://bugzilla.mozilla.org/show_bug.cgi?id=1155145","created":"2016-01-18T14:48:11Z","name":"GlobalSign certs","who":".","why":"."},"enabled":true,"id":"e3bd531e-1ee4-7407-27ce-6fdc9cecbbdc","issuerName":"MIGBMQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTElMCMGA1UECxMcUHJpbWFyeSBPYmplY3QgUHVibGlzaGluZyBDQTEwMC4GA1UEAxMnR2xvYmFsU2lnbiBQcmltYXJ5IE9iamVjdCBQdWJsaXNoaW5nIENB","last_modified":3000,"serialNumber":"BAAAAAABI54PryQ="}]';
const collectionSignature =
"p384ecdsa=f4pA2tYM5jQgWY6YUmhUwQiBLj6QO5sHLD_5MqLePz95qv-7cNCuQoZnPQwxoptDtW8hcWH3kLb0quR7SB-r82gkpR9POVofsnWJRA-ETb0BcIz6VvI3pDT49ZLlNg3p";
ok(
await verifier.asyncVerifyContentSignature(
collectionData,
collectionSignature,
getCertChain(),
SIGNER_NAME,
Ci.nsIX509CertDB.AppXPCShellRoot
)
);
});
add_task(async function test_bad_signature_does_not_lead_to_empty_list() {
Services.prefs.setStringPref(
PREF_SETTINGS_SERVER,
`http://localhost:${server.identity.primaryPort}/v1`
);
const x5u = `http://localhost:${server.identity.primaryPort}/x5u.pem`;
const networkCalls = [];
server.registerPathHandler(
"/v1/buckets/monitor/collections/changes/changeset",
(request, response) => {
response.write(
JSON.stringify({
changes: [
{
bucket: "main",
collection: "no-dump-no-local-data",
last_modified: 42,
},
],
})
);
response.setHeader("Content-Type", "application/json; charset=UTF-8");
response.setStatusLine(null, 200, "OK");
}
);
server.registerPathHandler(
"/v1/buckets/monitor/collections/changes/changeset",
(request, response) => {
networkCalls.push(request);
response.write(
JSON.stringify({
changes: [
{
bucket: "main",
collection: "no-dump-no-local-data",
last_modified: 42,
},
],
})
);
response.setHeader("Content-Type", "application/json; charset=UTF-8");
response.setStatusLine(null, 200, "OK");
}
);
server.registerPathHandler(
"/v1/buckets/main/collections/no-dump-no-local-data/changeset",
(request, response) => {
response.write(
JSON.stringify({
timestamp: 42,
changes: [],
metadata: {
signatures: [
{
signature: "bad-signature",
x5u,
},
],
},
})
);
response.setHeader("Content-Type", "application/json; charset=UTF-8");
response.setStatusLine(null, 200, "OK");
}
);
server.registerPathHandler("/x5u.pem", (request, response) => {
response.write(getCertChain()); // At least cert will be valid.
response.setHeader("Content-Type", "text/plain; charset=UTF-8");
response.setStatusLine(null, 200, "OK");
});
const clientEmpty = RemoteSettings("no-dump-no-local-data");
clientEmpty.verifySignature = true; // default
// Check that client.get() will initiate a sync,
// and that it will throw since the signature is bad,
// and not return an empty list (`emptyListFallback: false`)
let error;
try {
await clientEmpty.get({
emptyListFallback: false,
syncIfEmpty: true, // default value
});
} catch (exc) {
error = exc;
}
equal(error.name, "InvalidSignatureError");
// Even running client.sync() will throw and won't leave
// anything in the database.
error = null;
try {
await clientEmpty.sync();
} catch (exc) {
error = exc;
}
equal(error.name, "InvalidSignatureError");
equal(await clientEmpty.db.getLastModified(), null);
// Call .get() again will initiate another sync.
networkCalls.length = 0;
try {
await clientEmpty.get({
emptyListFallback: false,
syncIfEmpty: true, // default value
});
} catch (exc) {
error = exc;
}
Assert.greater(networkCalls.length, 0, "Network calls were made");
equal(error.name, "InvalidSignatureError");
});
add_task(async function test_check_synchronization_with_signatures() {
const port = server.identity.primaryPort;
const x5u = `http://localhost:${port}/test_remote_settings_signatures/test_cert_chain.pem`;
// Telemetry reports.
const TELEMETRY_SOURCE = client.identifier;
function registerHandlers(responses) {
function handleResponse(serverTimeMillis, request, response) {
const key = `${request.method}:${request.path}?${request.queryString}`;
const available = responses[key];
const sampled = available.length > 1 ? available.shift() : available[0];
if (!sampled) {
do_throw(
`unexpected ${request.method} request for ${request.path}?${request.queryString}`
);
}
response.setStatusLine(
null,
sampled.status.status,
sampled.status.statusText
);
// send the headers
for (let headerLine of sampled.sampleHeaders) {
let headerElements = headerLine.split(":");
response.setHeader(headerElements[0], headerElements[1].trimLeft());
}
// set the server date
response.setHeader("Date", new Date(serverTimeMillis).toUTCString());
response.write(sampled.responseBody);
}
for (let key of Object.keys(responses)) {
const keyParts = key.split(":");
const valueParts = keyParts[1].split("?");
const path = valueParts[0];
server.registerPathHandler(path, handleResponse.bind(null, 2000));
}
}
// set up prefs so the kinto updater talks to the test server
Services.prefs.setStringPref(
PREF_SETTINGS_SERVER,
`http://localhost:${server.identity.primaryPort}/v1`
);
// These are records we'll use in the test collections
const RECORD1 = {
details: {
bug: "https://bugzilla.mozilla.org/show_bug.cgi?id=1155145",
created: "2016-01-18T14:43:37Z",
name: "GlobalSign certs",
who: ".",
why: ".",
},
enabled: true,
id: "97fbf7c4-3ef2-f54f-0029-1ba6540c63ea",
issuerName:
"MHExKDAmBgNVBAMTH0dsb2JhbFNpZ24gUm9vdFNpZ24gUGFydG5lcnMgQ0ExHTAbBgNVBAsTFFJvb3RTaWduIFBhcnRuZXJzIENBMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMQswCQYDVQQGEwJCRQ==",
last_modified: 2000,
serialNumber: "BAAAAAABA/A35EU=",
};
const RECORD2 = {
details: {
bug: "https://bugzilla.mozilla.org/show_bug.cgi?id=1155145",
created: "2016-01-18T14:48:11Z",
name: "GlobalSign certs",
who: ".",
why: ".",
},
enabled: true,
id: "e3bd531e-1ee4-7407-27ce-6fdc9cecbbdc",
issuerName:
"MIGBMQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTElMCMGA1UECxMcUHJpbWFyeSBPYmplY3QgUHVibGlzaGluZyBDQTEwMC4GA1UEAxMnR2xvYmFsU2lnbiBQcmltYXJ5IE9iamVjdCBQdWJsaXNoaW5nIENB",
last_modified: 3000,
serialNumber: "BAAAAAABI54PryQ=",
};
const RECORD3 = {
details: {
bug: "https://bugzilla.mozilla.org/show_bug.cgi?id=1155145",
created: "2016-01-18T14:48:11Z",
name: "GlobalSign certs",
who: ".",
why: ".",
},
enabled: true,
id: "c7c49b69-a4ab-418e-92a9-e1961459aa7f",
issuerName:
"MIGBMQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTElMCMGA1UECxMcUHJpbWFyeSBPYmplY3QgUHVibGlzaGluZyBDQTEwMC4GA1UEAxMnR2xvYmFsU2lnbiBQcmltYXJ5IE9iamVjdCBQdWJsaXNoaW5nIENB",
last_modified: 4000,
serialNumber: "BAAAAAABI54PryQ=",
};
const RECORD1_DELETION = {
deleted: true,
enabled: true,
id: "97fbf7c4-3ef2-f54f-0029-1ba6540c63ea",
last_modified: 3500,
};
// Check that a signature on an empty collection is OK
// We need to set up paths on the HTTP server to return specific data from
// specific paths for each test. Here we prepare data for each response.
// A cert chain response (this the cert chain that contains the signing
// cert, the root and any intermediates in between). This is used in each
// sync.
const RESPONSE_CERT_CHAIN = {
comment: "RESPONSE_CERT_CHAIN",
sampleHeaders: ["Content-Type: text/plain; charset=UTF-8"],
status: { status: 200, statusText: "OK" },
responseBody: getCertChain(),
};
// A server settings response. This is used in each sync.
const RESPONSE_SERVER_SETTINGS = {
comment: "RESPONSE_SERVER_SETTINGS",
sampleHeaders: [
"Access-Control-Allow-Origin: *",
"Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
"Content-Type: application/json; charset=UTF-8",
"Server: waitress",
],
status: { status: 200, statusText: "OK" },
responseBody: JSON.stringify({
settings: {
batch_max_requests: 25,
},
url: `http://localhost:${port}/v1/`,
documentation: "https://kinto.readthedocs.org/",
version: "1.5.1",
commit: "cbc6f58",
hello: "kinto",
}),
};
// This is the initial, empty state of the collection. This is only used
// for the first sync.
const RESPONSE_EMPTY_INITIAL = {
comment: "RESPONSE_EMPTY_INITIAL",
sampleHeaders: [
"Content-Type: application/json; charset=UTF-8",
'ETag: "1000"',
],
status: { status: 200, statusText: "OK" },
responseBody: JSON.stringify({
timestamp: 1000,
metadata: {
signatures: [
{
x5u,
signature:
"vxuAg5rDCB-1pul4a91vqSBQRXJG_j7WOYUTswxRSMltdYmbhLRH8R8brQ9YKuNDF56F-w6pn4HWxb076qgKPwgcEBtUeZAO_RtaHXRkRUUgVzAr86yQL4-aJTbv3D6u",
},
],
},
changes: [],
}),
};
// Here, we map request method and path to the available responses
const emptyCollectionResponses = {
"GET:/test_remote_settings_signatures/test_cert_chain.pem?": [
RESPONSE_CERT_CHAIN,
],
"GET:/v1/?": [RESPONSE_SERVER_SETTINGS],
"GET:/v1/buckets/main/collections/signed/changeset?_expected=1000": [
RESPONSE_EMPTY_INITIAL,
],
};
//
// 1.
// - collection: undefined -> []
// - timestamp: undefined -> 1000
//
// .. and use this map to register handlers for each path
registerHandlers(emptyCollectionResponses);
// Clear events snapshot.
Services.telemetry.snapshotEvents(Ci.nsITelemetry.DATASET_ALL_CHANNELS, true);
Services.fog.testResetFOG();
enableUptakeMetric();
// With all of this set up, we attempt a sync. This will resolve if all is
// well and throw if something goes wrong.
await client.maybeSync(1000);
equal((await client.get()).length, 0);
assertTelemetryEvents([
{
value: UptakeTelemetry.STATUS.SYNC_START,
source: TELEMETRY_SOURCE,
trigger: "manual",
},
{
value: UptakeTelemetry.STATUS.SUCCESS,
source: TELEMETRY_SOURCE,
trigger: "manual",
},
]);
//
// 2.
// - collection: [] -> [RECORD2, RECORD1]
// - timestamp: 1000 -> 3000
//
// Check that some additions (2 records) to the collection have a valid
// signature.
// This response adds two entries (RECORD1 and RECORD2) to the collection
const RESPONSE_TWO_ADDED = {
comment: "RESPONSE_TWO_ADDED",
sampleHeaders: [
"Content-Type: application/json; charset=UTF-8",
'ETag: "3000"',
],
status: { status: 200, statusText: "OK" },
responseBody: JSON.stringify({
timestamp: 3000,
metadata: {
signatures: [
{
x5u,
signature:
"dwhJeypadNIyzGj3QdI0KMRTPnHhFPF_j73mNrsPAHKMW46S2Ftf4BzsPMvPMB8h0TjDus13wo_R4l432DHe7tYyMIWXY0PBeMcoe5BREhFIxMxTsh9eGVXBD1e3UwRy",
},
],
},
changes: [RECORD2, RECORD1],
}),
};
const twoItemsResponses = {
"GET:/v1/buckets/main/collections/signed/changeset?_expected=3000&_since=1000":
[RESPONSE_TWO_ADDED],
};
registerHandlers(twoItemsResponses);
await client.maybeSync(3000);
equal((await client.get()).length, 2);
//
// 3.
// - collection: [RECORD2, RECORD1] -> [RECORD2, RECORD3]
// - timestamp: 3000 -> 4000
//
// Check the collection with one addition and one removal has a valid
// signature
const THREE_ITEMS_SIG =
"MIEmNghKnkz12UodAAIc3q_Y4a3IJJ7GhHF4JYNYmm8avAGyPM9fYU7NzVo94pzjotG7vmtiYuHyIX2rTHTbT587w0LdRWxipgFd_PC1mHiwUyjFYNqBBG-kifYk7kEw";
// Remove RECORD1, add RECORD3
const RESPONSE_ONE_ADDED_ONE_REMOVED = {
comment: "RESPONSE_ONE_ADDED_ONE_REMOVED ",
sampleHeaders: [
"Content-Type: application/json; charset=UTF-8",
'ETag: "4000"',
],
status: { status: 200, statusText: "OK" },
responseBody: JSON.stringify({
timestamp: 4000,
metadata: {
signatures: [
{
x5u,
signature: THREE_ITEMS_SIG,
},
],
},
changes: [RECORD3, RECORD1_DELETION],
}),
};
const oneAddedOneRemovedResponses = {
"GET:/v1/buckets/main/collections/signed/changeset?_expected=4000&_since=3000":
[RESPONSE_ONE_ADDED_ONE_REMOVED],
};
registerHandlers(oneAddedOneRemovedResponses);
await client.maybeSync(4000);
equal((await client.get()).length, 2);
//
// 4.
// - collection: [RECORD2, RECORD3] -> [RECORD2, RECORD3]
// - timestamp: 4000 -> 4100
//
// Check the signature is still valid with no operation (no changes)
// Leave the collection unchanged
const RESPONSE_EMPTY_NO_UPDATE = {
comment: "RESPONSE_EMPTY_NO_UPDATE ",
sampleHeaders: [
"Content-Type: application/json; charset=UTF-8",
'ETag: "4000"',
],
status: { status: 200, statusText: "OK" },
responseBody: JSON.stringify({
timestamp: 4000,
metadata: {
signatures: [
{
x5u,
signature: THREE_ITEMS_SIG,
},
],
},
changes: [],
}),
};
const noOpResponses = {
"GET:/v1/buckets/main/collections/signed/changeset?_expected=4100&_since=4000":
[RESPONSE_EMPTY_NO_UPDATE],
};
registerHandlers(noOpResponses);
await client.maybeSync(4100);
equal((await client.get()).length, 2);
//
// 5.
// - collection: [RECORD2, RECORD3] -> [RECORD2, RECORD3]
// - timestamp: 4000 -> 5000
//
// Check the collection is reset when the signature is invalid.
// Client will:
// - Fetch metadata (with bad signature)
// - Perform the sync (fetch empty changes)
// - Refetch the metadata and the whole collection
// - Validate signature successfully, but with no changes to emit.
const RESPONSE_COMPLETE_INITIAL = {
comment: "RESPONSE_COMPLETE_INITIAL ",
sampleHeaders: [
"Content-Type: application/json; charset=UTF-8",
'ETag: "4000"',
],
status: { status: 200, statusText: "OK" },
responseBody: JSON.stringify({
timestamp: 4000,
metadata: {
signatures: [
{
x5u,
signature: THREE_ITEMS_SIG,
},
],
},
changes: [RECORD2, RECORD3],
}),
};
const RESPONSE_EMPTY_NO_UPDATE_BAD_SIG = {
...RESPONSE_EMPTY_NO_UPDATE,
responseBody: JSON.stringify({
timestamp: 4000,
metadata: {
signatures: [
{
x5u,
signature: "aW52YWxpZCBzaWduYXR1cmUK",
},
],
},
changes: [],
}),
};
const badSigGoodSigResponses = {
// The first collection state is the three item collection (since
// there was sync with no updates before) - but, since the signature is wrong,
// another request will be made...
"GET:/v1/buckets/main/collections/signed/changeset?_expected=5000&_since=4000":
[RESPONSE_EMPTY_NO_UPDATE_BAD_SIG],
// Subsequent signature returned is a valid one for the three item
// collection.
"GET:/v1/buckets/main/collections/signed/changeset?_expected=5000": [
RESPONSE_COMPLETE_INITIAL,
],
};
registerHandlers(badSigGoodSigResponses);
Services.telemetry.snapshotEvents(Ci.nsITelemetry.DATASET_ALL_CHANNELS, true);
Services.fog.testResetFOG();
enableUptakeMetric();
let syncEventSent = false;
client.on("sync", () => {
syncEventSent = true;
});
await client.maybeSync(5000);
equal((await client.get()).length, 2);
// since we only fixed the signature, and no data was changed, the sync event
// was not sent.
equal(syncEventSent, false);
// ensure that the failure count is incremented for a succesful sync with an
// (initial) bad signature - only SERVICES_SETTINGS_SYNC_SIG_FAIL should
// increment.
assertTelemetryEvents([
{
value: UptakeTelemetry.STATUS.SYNC_START,
source: TELEMETRY_SOURCE,
trigger: "manual",
},
{
value: UptakeTelemetry.STATUS.SIGNATURE_ERROR,
source: TELEMETRY_SOURCE,
trigger: "manual",
},
]);
//
// 6.
// - collection: [RECORD2, RECORD3] -> [RECORD2, RECORD3]
// - timestamp: 4000 -> 5000
//
// Check the collection is reset when the signature is invalid.
// Client will:
// - Fetch metadata (with bad signature)
// - Perform the sync (fetch empty changes)
// - Refetch the whole collection and metadata
// - Sync will be no-op since local is equal to server, no changes to emit.
const badSigGoodOldResponses = {
// The first collection state is the current state (since there's no update
// - but, since the signature is wrong, another request will be made)
"GET:/v1/buckets/main/collections/signed/changeset?_expected=5000&_since=4000":
[RESPONSE_EMPTY_NO_UPDATE_BAD_SIG],
// The next request is for the full collection. This will be
// checked against the valid signature and last_modified times will be
// compared. Sync should be a no-op, even though the signature is good,
// because the local collection is newer.
"GET:/v1/buckets/main/collections/signed/changeset?_expected=5000": [
RESPONSE_EMPTY_INITIAL,
],
};
// ensure our collection hasn't been replaced with an older, empty one
equal((await client.get()).length, 2, "collection was restored");
registerHandlers(badSigGoodOldResponses);
syncEventSent = false;
client.on("sync", () => {
syncEventSent = true;
});
await client.maybeSync(5000);
// Local data was unchanged, since it was never than the one returned by the server,
// thus the sync event is not sent.
equal(syncEventSent, false, "event was not sent");
//
// 7.
// - collection: [RECORD2, RECORD3] -> [RECORD2, RECORD3]
// - timestamp: 4000 -> 5000
//
// Check that a tampered local DB will be overwritten and
// sync event contain the appropriate data.
const RESPONSE_COMPLETE_BAD_SIG = {
...RESPONSE_EMPTY_NO_UPDATE,
responseBody: JSON.stringify({
timestamp: 5000,
metadata: {
signatures: [
{
x5u,
signature: "aW52YWxpZCBzaWduYXR1cmUK",
},
],
},
changes: [RECORD2, RECORD3],
}),
};
const badLocalContentGoodSigResponses = {
"GET:/v1/buckets/main/collections/signed/changeset?_expected=5000&_since=3900":
[RESPONSE_COMPLETE_BAD_SIG],
"GET:/v1/buckets/main/collections/signed/changeset?_expected=5000": [
RESPONSE_COMPLETE_INITIAL,
],
};
registerHandlers(badLocalContentGoodSigResponses);
// we create a local state manually here, in order to test that the sync event data
// properly contains created, updated, and deleted records.
// the local DB contains same id as RECORD2 and a fake record.
// the final server collection contains RECORD2 and RECORD3
const localId = "0602b1b2-12ab-4d3a-b6fb-593244e7b035";
await client.db.importChanges(
{ signatures: [{ x5u, signature: "abc" }] },
3900,
[
{ ...RECORD2, last_modified: 1234567890, serialNumber: "abc" },
{ id: localId },
],
{
clear: true,
}
);
let syncData = null;
client.on("sync", ({ data }) => {
syncData = data;
});
// Clear events snapshot.
Services.telemetry.snapshotEvents(Ci.nsITelemetry.DATASET_ALL_CHANNELS, true);
Services.fog.testResetFOG();
enableUptakeMetric();
// Events telemetry is sampled on released, use fake channel.
await client.maybeSync(5000);
// We should report a corruption_error.
assertTelemetryEvents([
{
value: UptakeTelemetry.STATUS.SYNC_START,
source: client.identifier,
trigger: "manual",
},
{
value: UptakeTelemetry.STATUS.CORRUPTION_ERROR,
source: client.identifier,
trigger: "manual",
duration: d => parseInt(d) > 0,
},
]);
// The local data was corrupted, and the Telemetry status reflects it.
// But the sync overwrote the bad data and was eventually a success.
// Since local data was replaced, we use records IDs to determine
// what was created and deleted. And bad local data will appear
// in the sync event as deleted.
equal(syncData.current.length, 2);
equal(syncData.created.length, 1);
equal(syncData.created[0].id, RECORD3.id);
equal(syncData.updated.length, 1);
equal(syncData.updated[0].old.serialNumber, "abc");
equal(syncData.updated[0].new.serialNumber, RECORD2.serialNumber);
equal(syncData.deleted.length, 1);
equal(syncData.deleted[0].id, localId);
//
// 8.
// - collection: [RECORD2, RECORD3] -> [RECORD2, RECORD3] (unchanged because of error)
// - timestamp: 4000 -> 6000
//
// Check that a failing signature throws after retry, and that sync changes
// are not applied.
const RESPONSE_ONLY_RECORD4_BAD_SIG = {
comment: "Create RECORD4",
sampleHeaders: [
"Content-Type: application/json; charset=UTF-8",
'ETag: "6000"',
],
status: { status: 200, statusText: "OK" },
responseBody: JSON.stringify({
timestamp: 6000,
metadata: {
signatures: [
{
x5u,
signature: "aaaaaaaaaaaaaaaaaaaaaaaa", // sig verifier wants proper length or will crash.
},
],
},
changes: [
{
id: "f765df30-b2f1-42f6-9803-7bd5a07b5098",
last_modified: 6000,
},
],
}),
};
const RESPONSE_EMPTY_NO_UPDATE_BAD_SIG_6000 = {
...RESPONSE_EMPTY_NO_UPDATE,
responseBody: JSON.stringify({
timestamp: 6000,
metadata: {
signatures: [
{
x5u,
signature: "aW52YWxpZCBzaWduYXR1cmUK",
},
],
},
changes: [],
}),
};
const allBadSigResponses = {
"GET:/v1/buckets/main/collections/signed/changeset?_expected=6000&_since=4000":
[RESPONSE_EMPTY_NO_UPDATE_BAD_SIG_6000],
"GET:/v1/buckets/main/collections/signed/changeset?_expected=6000": [
RESPONSE_ONLY_RECORD4_BAD_SIG,
],
};
// Reset telemetry capture.
Services.telemetry.snapshotEvents(Ci.nsITelemetry.DATASET_ALL_CHANNELS, true);
Services.fog.testResetFOG();
enableUptakeMetric();
registerHandlers(allBadSigResponses);
await Assert.rejects(
client.maybeSync(6000),
RemoteSettingsClient.InvalidSignatureError,
"Sync failed as expected (bad signature after retry)"
);
// Ensure that the failure is reflected in the accumulated telemetry:
assertTelemetryEvents([
{
value: UptakeTelemetry.STATUS.SYNC_START,
source: TELEMETRY_SOURCE,
trigger: "manual",
},
{
value: UptakeTelemetry.STATUS.SIGNATURE_RETRY_ERROR,
source: TELEMETRY_SOURCE,
trigger: "manual",
},
]);
// When signature fails after retry, the local data present before sync
// should be maintained (if its signature is valid).
ok(
arrayEqual(
(await client.get()).map(r => r.id),
[RECORD3.id, RECORD2.id]
),
"Local records were not changed"
);
// And local data should still be valid.
await client.get({ verifySignature: true }); // Not raising.
//
// 9.
// - collection: [RECORD2, RECORD3] -> [] (cleared)
// - timestamp: 4000 -> 6000
//
// Check that local data is cleared during sync if signature is not valid.
await client.db.create({
id: "c6b19c67-2e0e-4a82-b7f7-1777b05f3e81",
last_modified: 42,
tampered: true,
});
await Assert.rejects(
client.maybeSync(6000),
RemoteSettingsClient.InvalidSignatureError,
"Sync failed as expected (bad signature after retry)"
);
// Since local data was tampered, it was cleared.
equal((await client.get()).length, 0, "Local database is now empty.");
//
// 10.
// - collection: [RECORD2, RECORD3] -> [] (cleared)
// - timestamp: 4000 -> 6000
//
// Check that local data is cleared during sync if signature is not valid.
await client.db.create({
id: "c6b19c67-2e0e-4a82-b7f7-1777b05f3e81",
last_modified: 42,
tampered: true,
});
await Assert.rejects(
client.maybeSync(6000),
RemoteSettingsClient.InvalidSignatureError,
"Sync failed as expected (bad signature after retry)"
);
// Since local data was tampered, it was cleared.
equal((await client.get()).length, 0, "Local database is now empty.");
//
// 11.
// - collection: [RECORD2, RECORD3] -> [RECORD2, RECORD3]
// - timestamp: 4000 -> 6000
//
// Check that local data is restored if signature was valid before sync.
const sigCalls = [];
let i = 0;
client._verifier = {
async asyncVerifyContentSignature(serialized) {
sigCalls.push(serialized);
console.log(`verify call ${i}`);
return [
false, // After importing changes.
true, // When checking previous local data.
false, // Still fail after retry.
true, // When checking previous local data again.
][i++];
},
};
// Create an extra record. It will have a valid signature locally
// thanks to the verifier mock.
await client.db.importChanges(
{
signatures: [{ x5u, signature: "aa" }],
},
4000,
[
{
id: "extraId",
last_modified: 42,
},
]
);
equal((await client.get()).length, 1);
// Now sync, but importing changes will have failing signature,
// and so will retry (see `sigResults`).
await Assert.rejects(
client.maybeSync(6000),
RemoteSettingsClient.InvalidSignatureError,
"Sync failed as expected (bad signature after retry)"
);
equal(i, 4, "sync has retried as expected");
// Make sure that we retried on a blank DB. The extra record should
// have been deleted when we validated the signature the second time.
// Since local data was tampered, it was cleared.
ok(/extraId/.test(sigCalls[0]), "extra record when importing changes");
ok(/extraId/.test(sigCalls[1]), "extra record when checking local");
ok(!/extraId/.test(sigCalls[2]), "db was flushed before retry");
ok(/extraId/.test(sigCalls[3]), "when checking local after retry");
});

View File

@@ -0,0 +1,16 @@
-----BEGIN CERTIFICATE-----
MIICdTCCAV2gAwIBAgIUdVZo8xeazUY3Fzm6cy61HaSjFJUwDQYJKoZIhvcNAQEL
BQAwIzEhMB8GA1UEAwwYY29sbGVjdGlvbi1zaWduZXItaW50LUNBMCIYDzIwMjQx
MTI3MDAwMDAwWhgPMjAyNzAyMDUwMDAwMDBaMCYxJDAiBgNVBAMMG2NvbGxlY3Rp
b24tc2lnbmVyLWVlLWludC1DQTB2MBAGByqGSM49AgEGBSuBBAAiA2IABKFockM2
K1x7GInzeRVGFaHHP7SN7oY+AikV22COJS3ktxMtqM6Y6DFTTmqcDAsJyNY5regy
BuW6gTRzoR+jMOBdqMluQ4P+J4c9qXEDviiIz/AC8Fr3Gh/dzIN0qm6pzqNIMEYw
EwYDVR0lBAwwCgYIKwYBBQUHAwMwLwYDVR0RBCgwJoIkb25lY3JsLmNvbnRlbnQt
c2lnbmF0dXJlLm1vemlsbGEub3JnMA0GCSqGSIb3DQEBCwUAA4IBAQBVHMPKgXKX
EVVTDRw8lF0MQBHUPA2+4f9JDsaq5AexGpFqfDzFobrzZnzKQyaODeoalhASraWs
GvhXrXFeSkSLqNpw657JXuIvVOULJjr2h9uA9N837KgFosvLLIIoKs1zeXG0LSYy
NSJBSme+n8ETV2d8RJ/w575rhnheDz6GcVH0hduvQR0g7dg8ZMtdfi16sjKaJRup
coH4N8t4YhLqM4PyrEATZyTEW5jOCKCb91FWgPu0ovfQz+t3jjS7TPpUa3IYJS8V
eFUHXUAXFFgD0xCl7P/V+kopWSijZHT8mwnPfn2z9EmV5ijioIsw2MF+FACkPWa1
o2NzujCdDAEl
-----END CERTIFICATE-----

View File

@@ -0,0 +1,5 @@
issuer:collection-signer-int-CA
subject:collection-signer-ee-int-CA
subjectKey:secp384r1
extension:extKeyUsage:codeSigning
extension:subjectAlternativeName:onecrl.content-signature.mozilla.org

View File

@@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDAzCCAeugAwIBAgIUf3MHKqXo3U9dG7tkPLy8t7D+1o4wDQYJKoZIhvcNAQEL
BQAwKTEnMCUGA1UEAwweeHBjc2hlbGwgc2lnbmVkIGFwcHMgdGVzdCByb290MCIY
DzIwMjQxMTI3MDAwMDAwWhgPMjAyNzAyMDUwMDAwMDBaMCMxITAfBgNVBAMMGGNv
bGxlY3Rpb24tc2lnbmVyLWludC1DQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC
AQoCggEBALqIUahEjhbWQf1utogGNhA9PBPZ6uQ1SrTs9WhXbCR7wcclqODYH72x
nAabbhqG8mvir1p1a2pkcQh6pVqnRYf3HNUknAJ+zUP8HmnQOCApk6sgw0nk27lM
wmtsDu0Vgg/xfq1pGrHTAjqLKkHup3DgDw2N/WYLK7AkkqR9uYhheZCxV5A90jvF
4LhIH6g304hD7ycW2FW3ZlqqfgKQLzp7EIAGJMwcbJetlmFbt+KWEsB1MaMMkd20
yvf8rR0l0wnvuRcOp2jhs3svIm9p47SKlWEd7ibWJZ2rkQhONsscJAQsvxaLL+Xx
j5kXMbiz/kkj+nJRxDHVA6zaGAo17Y0CAwEAAaMlMCMwDAYDVR0TBAUwAwEB/zAT
BgNVHSUEDDAKBggrBgEFBQcDAzANBgkqhkiG9w0BAQsFAAOCAQEAjJAZX8SZOh1H
xLqKHgt2EiFloIW3k3aAnTFQPAEUp+IBXjJdA8cFzEDSHo0dTr8jBhR8V1gwy8+Z
1i6loA0rgJUdDhUP24SqbfsdF6kcltlBfjR4lBfbZq0BClMiNDL2ZOQS4neF15fN
WXUv2UE9ooKNUjgIzXHbU/ykbwTKB7Bmsh+r5CYs9LMNF5P/QDAlDNwvZglxiyyq
61fi06lnBXff0zObSdV/8Wm0PQyHEBfNY3L94wuIFxGsX0YngO7CCtaIFIeymzob
8SvH4W0vfeqYzDFM4PfgFbDs4KnAzJS+WwWq2HRDsHMZTDZVVXVA61LXYhFEOam1
ASCDwhnClA==
-----END CERTIFICATE-----

View File

@@ -0,0 +1,4 @@
issuer:xpcshell signed apps test root
subject:collection-signer-int-CA
extension:basicConstraints:cA,
extension:extKeyUsage:codeSigning

View File

@@ -0,0 +1,152 @@
const { Database } = ChromeUtils.importESModule(
"resource://services-settings/Database.sys.mjs"
);
const { RemoteSettings } = ChromeUtils.importESModule(
"resource://services-settings/remote-settings.sys.mjs"
);
let server;
let startupClients;
add_setup(async () => {
// Disable signature verification of collections listed in test startup.json.mzlz4
// since their metadata will eventually expire.
startupClients = [
"message-groups",
"nimbus-desktop-experiments",
"search-categorization",
"tracking-protection-lists",
"query-stripping",
"cfr",
].map(id => {
const c = RemoteSettings(id);
c.verifySignature = false;
return c;
});
// Setup an HTTP server to serve the 'startup.json.mzlz4' bundle.
server = new HttpServer();
server.start(-1);
server.registerDirectory(
"/cdn/bundles/",
do_get_file("test_remote_settings_startup_bundle")
);
server.registerPathHandler("/v1/", (request, response) => {
response.write(
JSON.stringify({
capabilities: {
attachments: {
base_url: `http://localhost:${server.identity.primaryPort}/cdn/`,
},
},
})
);
response.setHeader("Content-Type", "application/json; charset=UTF-8");
response.setStatusLine(null, 200, "OK");
});
Services.prefs.setStringPref(
"services.settings.server",
`http://localhost:${server.identity.primaryPort}/v1`
);
registerCleanupFunction(() => {
server.stop(() => {});
Services.prefs.clearUserPref("services.settings.loglevel");
});
});
async function clear_state() {
await Database.destroy();
RemoteSettings._ongoingExtractBundlePromise = null;
}
add_task(async function test_bundle_is_pulled_when_get_needs_sync() {
const client = startupClients[0];
Assert.ok(
!(await Utils.hasLocalDump(client.bucketName, client.collectionName)),
"Client has no packaged dump"
);
const records = await client.get();
Assert.equal(records.length, 6, "Records were read from startup bundle");
});
add_task(clear_state);
add_task(
async function test_signature_of_extracted_data_from_bundle_is_verified() {
const c = RemoteSettings("tracking-protection-lists"); // part of startup.json.mzlz4
c.verifySignature = true;
let called = null;
c.validateCollectionSignature = (records, timestamp, metadata) => {
called = { records, timestamp, metadata };
};
await c.get();
Assert.ok(!!called.records.length);
Assert.greaterOrEqual(called.timestamp, 1694684362860);
Assert.ok(called.metadata.flags.includes("startup"));
}
);
add_task(clear_state);
add_task(async function test_bundle_is_not_importent_when_signature_fails() {
const c = RemoteSettings("tracking-protection-lists"); // part of startup.json.mzlz4
c.verifySignature = true;
let called = false;
c.validateCollectionSignature = () => {
called = true;
throw new Error("boom!");
};
let error;
try {
await c.get({ emptyListFallback: false });
Assert.ok(false, ".get() should fail without network");
} catch (e) {
error = e;
}
Assert.ok(called, "Signature was verified");
Assert.equal(
error.name,
"UnknownCollectionError",
".get() fails without network and with bad startup bundle"
);
});
add_task(clear_state);
add_task(async function test_sync_occurs_if_collection_not_part_of_bundle() {
const c = RemoteSettings("foo");
let error;
try {
await c.get({ emptyListFallback: false });
Assert.ok(false, ".get() should fail when bundle disabled");
} catch (e) {
error = e;
}
Assert.equal(
error.name,
"UnknownCollectionError",
".get() fails to sync data"
);
});
add_task(clear_state);
add_task(async function test_several_clients_wait_for_bundle() {
// several clients calling .get() in parallel will all take content from bundle.
const results = await Promise.allSettled(
startupClients.map(c => c.get({ emptyListFallback: false }))
);
Assert.deepEqual(
[6, 70, 5, "UnknownCollectionError", 3, 11],
results.map(({ status, value, reason }) =>
status == "fulfilled" ? value.length : reason.name
)
);
});
add_task(clear_state);

Some files were not shown because too many files have changed in this diff Show More