mirror of
https://github.com/zen-browser/desktop.git
synced 2026-08-28 17:31:32 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f28e0688fb | ||
|
|
8df6fef1ac | ||
|
|
4746d36699 | ||
|
|
9e0c790602 | ||
|
|
3d53b37e8b | ||
|
|
e7ddfb52db | ||
|
|
ba70449bdd | ||
|
|
bf40c31149 | ||
|
|
7a093beae4 | ||
|
|
03b4e934fe | ||
|
|
b93d19b873 | ||
|
|
b9c4000fe2 | ||
|
|
0b7687ec6d | ||
|
|
d70b3d902c | ||
|
|
f9f8f2f372 | ||
|
|
99dca42723 | ||
|
|
ed0a6fd447 | ||
|
|
88c6611f1e | ||
|
|
e66e763e63 | ||
|
|
721135f576 | ||
|
|
d8a934d2ec | ||
|
|
91a1d4d2ca | ||
|
|
97577dac26 | ||
|
|
5723a89bcd |
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -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++
|
||||
|
||||
2
.github/workflows/linux-release-build.yml
vendored
2
.github/workflows/linux-release-build.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/macos-release-build.yml
vendored
2
.github/workflows/macos-release-build.yml
vendored
@@ -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"
|
||||
|
||||
2
.github/workflows/windows-release-build.yml
vendored
2
.github/workflows/windows-release-build.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
1fb0703fa9723fd9c400ad60d18e64ee4f9bf125
|
||||
bf4b57fd7fe94054457be7018e8e7beaf754c520
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
8
package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -10,3 +10,6 @@
|
||||
value: false
|
||||
locked: true
|
||||
condition: "!@IS_TWILIGHT@"
|
||||
|
||||
- name: zen.spaces-sync.debug
|
||||
value: false
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
+
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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",
|
||||
+ },
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
166
src/external-patches/firefox/bug_2011236.patch
Normal file
166
src/external-patches/firefox/bug_2011236.patch
Normal 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);
|
||||
|
||||
55
src/external-patches/firefox/issue_14710.patch
Normal file
55
src/external-patches/firefox/issue_14710.patch
Normal 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);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
14
src/testing/xpcshell/runxpcshelltests-py.patch
Normal file
14
src/testing/xpcshell/runxpcshelltests-py.patch
Normal 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.
|
||||
@@ -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
|
||||
29
src/toolkit/content/widgets/panel-list/panel-list-mjs.patch
Normal file
29
src/toolkit/content/widgets/panel-list/panel-list-mjs.patch
Normal 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() ||
|
||||
45
src/toolkit/mozapps/update/updater/updater-cpp.patch
Normal file
45
src/toolkit/mozapps/update/updater/updater-cpp.patch
Normal file
@@ -0,0 +1,45 @@
|
||||
diff --git a/toolkit/mozapps/update/updater/updater.cpp b/toolkit/mozapps/update/updater/updater.cpp
|
||||
index 5042328c0ee521300a6897b21849abf8dcbd9d45..a7898bcf5f2a15119b8c5cf9ae71f2a1a749e56d 100644
|
||||
--- a/toolkit/mozapps/update/updater/updater.cpp
|
||||
+++ b/toolkit/mozapps/update/updater/updater.cpp
|
||||
@@ -2436,15 +2436,37 @@ bool LaunchWinPostProcess(const WCHAR* installationDir,
|
||||
// N.b.: two null terminating characters! The first terminates a non-existent
|
||||
// key-value pair, the second (automatically added) terminates the block of
|
||||
// key-value pairs.
|
||||
- const WCHAR* emptyEnvironment = L"\0";
|
||||
+ // %SystemRoot% must be preserved, however. Without it the NSIS post-update
|
||||
+ // helper cannot load its plugin DLLs, so
|
||||
+ // ServicesHelper::PathToUniqueRegistryPath returns an empty string and
|
||||
+ // AddMaintCertKeys silently does nothing. That leaves the maintenance
|
||||
+ // service's allowed-certificate registry keys stale forever, which means a
|
||||
+ // code signing certificate rotation can never complete through updates.
|
||||
+ const WCHAR* environment = L"\0";
|
||||
+ WCHAR environmentBlock[MAX_PATH + 16] = {L'\0'};
|
||||
+ WCHAR systemRoot[MAX_PATH + 1] = {L'\0'};
|
||||
+ DWORD systemRootLen =
|
||||
+ GetEnvironmentVariableW(L"SystemRoot", systemRoot, MAX_PATH + 1);
|
||||
+ if (systemRootLen > 0 && systemRootLen <= MAX_PATH) {
|
||||
+ // environmentBlock is zero initialized and oversized relative to
|
||||
+ // "SystemRoot=" plus a MAX_PATH value, so the second terminating null
|
||||
+ // character is already present after the string's own terminator.
|
||||
+ wcsncpy(environmentBlock, L"SystemRoot=",
|
||||
+ sizeof(environmentBlock) / sizeof(environmentBlock[0]) - 1);
|
||||
+ wcscat(environmentBlock, systemRoot);
|
||||
+ environment = environmentBlock;
|
||||
+ } else {
|
||||
+ LOG(("LaunchWinPostProcess could not read SystemRoot; the post update "
|
||||
+ "process will not be able to load its plugins"));
|
||||
+ }
|
||||
|
||||
bool ok =
|
||||
CreateProcessW(exefullpath, cmdline,
|
||||
nullptr, // no special security attributes
|
||||
nullptr, // no special thread attributes
|
||||
false, // don't inherit filehandles
|
||||
- 0, // No special process creation flags
|
||||
- (LPVOID)emptyEnvironment, workingDirectory, &si, &pi);
|
||||
+ CREATE_UNICODE_ENVIRONMENT, (LPVOID)environment,
|
||||
+ workingDirectory, &si, &pi);
|
||||
free(cmdline);
|
||||
if (ok) {
|
||||
LOG(("LaunchWinPostProcess - Waiting for process to complete"));
|
||||
29
src/widget/gtk/nsDragService-cpp.patch
Normal file
29
src/widget/gtk/nsDragService-cpp.patch
Normal 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);
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
182
src/zen/sync/ZenSpacesSync.sys.mjs
Normal file
182
src/zen/sync/ZenSpacesSync.sys.mjs
Normal 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);
|
||||
}
|
||||
}
|
||||
915
src/zen/sync/ZenSpacesSyncApplier.sys.mjs
Normal file
915
src/zen/sync/ZenSpacesSyncApplier.sys.mjs
Normal 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();
|
||||
722
src/zen/sync/ZenSpacesSyncModel.sys.mjs
Normal file
722
src/zen/sync/ZenSpacesSyncModel.sys.mjs
Normal 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();
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/"
|
||||
|
||||
32
src/zen/tests/mochitests/alerts/browser.toml
Normal file
32
src/zen/tests/mochitests/alerts/browser.toml
Normal 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
|
||||
]
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
39
src/zen/tests/mochitests/alerts/file_dom_notifications.html
Normal file
39
src/zen/tests/mochitests/alerts/file_dom_notifications.html
Normal 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>
|
||||
73
src/zen/tests/mochitests/alerts/head.js
Normal file
73
src/zen/tests/mochitests/alerts/head.js
Normal 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();
|
||||
});
|
||||
}
|
||||
7
src/zen/tests/mochitests/backforward/browser.toml
Normal file
7
src/zen/tests/mochitests/backforward/browser.toml
Normal 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
|
||||
179
src/zen/tests/mochitests/backforward/browser_history_menu.js
Normal file
179
src/zen/tests/mochitests/backforward/browser_history_menu.js
Normal 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);
|
||||
});
|
||||
10
src/zen/tests/mochitests/caps/browser.toml
Normal file
10
src/zen/tests/mochitests/caps/browser.toml
Normal 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"]
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
11
src/zen/tests/mochitests/gesture/browser.toml
Normal file
11
src/zen/tests/mochitests/gesture/browser.toml
Normal 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
|
||||
]
|
||||
231
src/zen/tests/mochitests/gesture/browser_gesture_navigation.js
Normal file
231
src/zen/tests/mochitests/gesture/browser_gesture_navigation.js
Normal 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);
|
||||
});
|
||||
43
src/zen/tests/mochitests/gesture/browser_gesture_scroll.js
Normal file
43
src/zen/tests/mochitests/gesture/browser_gesture_scroll.js
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
12
src/zen/tests/mochitests/linkHandling/browser.toml
Normal file
12
src/zen/tests/mochitests/linkHandling/browser.toml
Normal 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"
|
||||
]
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
<!DOCTYPE html>
|
||||
<a href="javascript:SpecialPowers.notifyObserversInParentProcess(null, 'contentAreaClick-javascriptRan');">link</a>
|
||||
@@ -0,0 +1,2 @@
|
||||
<!DOCTYPE html>
|
||||
<a href="javascript:alert(1)">click me
|
||||
@@ -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",
|
||||
]
|
||||
86
src/zen/tests/mochitests/remote-settings/head_settings.js
Normal file
86
src/zen/tests/mochitests/remote-settings/head_settings.js
Normal 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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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-----
|
||||
@@ -0,0 +1 @@
|
||||
not a zip
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This would be a RS dump.
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
The filename-without-meta.txt.meta.json file is missing.
|
||||
1798
src/zen/tests/mochitests/remote-settings/test_remote_settings.js
Normal file
1798
src/zen/tests/mochitests/remote-settings/test_remote_settings.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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.");
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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-----
|
||||
@@ -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
|
||||
@@ -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-----
|
||||
@@ -0,0 +1,4 @@
|
||||
issuer:xpcshell signed apps test root
|
||||
subject:collection-signer-int-CA
|
||||
extension:basicConstraints:cA,
|
||||
extension:extKeyUsage:codeSigning
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user