Compare commits

..

7 Commits

44 changed files with 1199 additions and 1556 deletions

View File

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

View File

@@ -1 +1 @@
30959a45eaaf0bc499f3054793c76c6d7f0c8bc2 b1d3fe8f65751e6d695fa9ef94b11ad81e70dac1

View File

@@ -82,7 +82,6 @@ zen-workspace-creation-profile = Profile
.tooltiptext = Profiles are used to separate cookies and site data between spaces. .tooltiptext = Profiles are used to separate cookies and site data between spaces.
zen-workspace-creation-header = Create a Space zen-workspace-creation-header = Create a Space
zen-workspace-creation-label = Spaces are used to organize your tabs and sessions. zen-workspace-creation-label = Spaces are used to organize your tabs and sessions.
zen-workspace-default-profile = Default
zen-workspaces-delete-workspace-title = Delete Space? 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-delete-workspace-body = Are you sure you want to delete { $name }? This action cannot be undone.

12
prefs/zen/sync.yaml Normal file
View File

@@ -0,0 +1,12 @@
# 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/.
- name: services.sync.engine.spaces
value: true
condition: "@IS_TWILIGHT@"
- name: services.sync.engine.spaces
value: false
locked: true
condition: "!@IS_TWILIGHT@"

View File

@@ -32,9 +32,6 @@
- name: zen.workspaces.scroll-modifier-key - name: zen.workspaces.scroll-modifier-key
value: ctrl value: ctrl
- name: services.sync.engine.workspaces
value: false
- name: zen.workspaces.separate-essentials - name: zen.workspaces.separate-essentials
value: true value: true

View File

@@ -0,0 +1,20 @@
diff --git a/browser/components/preferences/config/account-sync.mjs b/browser/components/preferences/config/account-sync.mjs
index b503987a08e2ce64bfc01c4e3a21e5e19ef834f0..b1c03a0b219fd3eddd93c1deaeb18ab79c85f168 100644
--- a/browser/components/preferences/config/account-sync.mjs
+++ b/browser/components/preferences/config/account-sync.mjs
@@ -42,6 +42,7 @@ Preferences.addAll([
{ id: "services.sync.engine.creditcards", type: "bool" },
{ id: "services.sync.engine.addons", type: "bool" },
{ id: "services.sync.engine.prefs", type: "bool" },
+ { id: "services.sync.engine.spaces", type: "bool" },
]);
/**
@@ -546,6 +547,7 @@ const SYNC_ENGINE_SETTINGS = [
type: "payments",
},
{ id: "syncAddons", pref: "services.sync.engine.addons", type: "addons" },
+ { id: "syncSpaces", pref: "services.sync.engine.spaces", type: "workspaces" },
{ id: "syncSettings", pref: "services.sync.engine.prefs", type: "settings" },
];

View File

@@ -6,7 +6,7 @@ index 64aa0d98a0622c01f3dcfff1a04bfcda368354d2..2013e04b0881ad2295d6897b91e1573c
{ id: "services.sync.engine.passwords", type: "bool" }, { id: "services.sync.engine.passwords", type: "bool" },
{ id: "services.sync.engine.addresses", type: "bool" }, { id: "services.sync.engine.addresses", type: "bool" },
{ id: "services.sync.engine.creditcards", type: "bool" }, { id: "services.sync.engine.creditcards", type: "bool" },
+ { id: "services.sync.engine.workspaces", type: "bool" }, + { id: "services.sync.engine.spaces", type: "bool" },
]); ]);
let gSyncChooseWhatToSync = { let gSyncChooseWhatToSync = {

View File

@@ -20,7 +20,7 @@ index a893c5ec3d007820d98f5d92dd039640faa2c181..9cbd00102e44ccf98b37845474d92d57
+ <html:div class="sync-engine-workspaces"> + <html:div class="sync-engine-workspaces">
+ <checkbox + <checkbox
+ data-l10n-id="sync-engine-workspaces" + data-l10n-id="sync-engine-workspaces"
+ preference="services.sync.engine.workspaces" + preference="services.sync.engine.spaces"
+ /> + />
+ </html:div> + </html:div>
</html:div> </html:div>

View File

@@ -6,7 +6,7 @@ index c379e1a5f82692406a92d9fcd3bca2769dfac5b2..af037dd3d995813d966524ac44a3795d
<image class="sync-engine-image sync-engine-prefs" alt=""/> <image class="sync-engine-image sync-engine-prefs" alt=""/>
<label data-l10n-id="sync-currently-syncing-settings"/> <label data-l10n-id="sync-currently-syncing-settings"/>
</html:div> </html:div>
+ <html:div engine_preference="services.sync.engine.workspaces"> + <html:div engine_preference="services.sync.engine.spaces">
+ <image class="sync-engine-image sync-engine-workspaces" alt=""/> + <image class="sync-engine-image sync-engine-workspaces" alt=""/>
+ <label data-l10n-id="sync-currently-syncing-workspaces"/> + <label data-l10n-id="sync-currently-syncing-workspaces"/>
+ </html:div> + </html:div>

View File

@@ -236,7 +236,7 @@ index c42b1a1a8df6b38886c17f71ea88e5aaa7eebc80..c741404ed973ee3ac246fe246c0f645e
} }
- if (replace) { - if (replace) {
+ if (replace && !((targetTab || this.selectedTab)?.hasAttribute('zen-empty-tab'))) { + if (replace && !(!targetTab && this.selectedTab?.hasAttribute('zen-empty-tab'))) {
if (this.isTabGroupLabel(targetTab)) { if (this.isTabGroupLabel(targetTab)) {
throw new Error( throw new Error(
"Replacing a tab group label with a tab is not supported" "Replacing a tab group label with a tab is not supported"

View File

@@ -1,8 +1,8 @@
diff --git a/dom/base/Document.cpp b/dom/base/Document.cpp diff --git a/dom/base/Document.cpp b/dom/base/Document.cpp
index 73eb08f648d55121a0cd96cd808f932fb14bfee6..efa99861a5da12ad8d0daa2c0eb1473c57810397 100644 index 328b7524810322561700347fa7f26403897635a8..58d33d68e050d11ea712413ad5c2170a112e404a 100644
--- a/dom/base/Document.cpp --- a/dom/base/Document.cpp
+++ b/dom/base/Document.cpp +++ b/dom/base/Document.cpp
@@ -471,6 +471,7 @@ @@ -467,6 +467,7 @@
#include "prtime.h" #include "prtime.h"
#include "prtypes.h" #include "prtypes.h"
#include "xpcpublic.h" #include "xpcpublic.h"
@@ -10,15 +10,14 @@ index 73eb08f648d55121a0cd96cd808f932fb14bfee6..efa99861a5da12ad8d0daa2c0eb1473c
// clang-format off // clang-format off
#include "mozilla/Encoding.h" #include "mozilla/Encoding.h"
@@ -3164,6 +3165,11 @@ void Document::FillStyleSetUserAndUASheets() { @@ -3198,6 +3199,10 @@ void Document::FillStyleSetUserAndUASheets() {
} for (StyleSheet* sheet : *sheetService->UserStyleSheets()) {
}; styleSet.AppendStyleSheet(*sheet);
}
+ if (IsInChromeDocShell()) { + if (auto sheet = zen::ZenStyleSheetCache::Singleton()->GetModsSheet(); sheet && IsInChromeDocShell()) {
+ // The mods sheet is only used in the chrome docshell. + // The mods sheet is only used in the chrome docshell.
+ MaybeAppend(zen::ZenStyleSheetCache::Singleton()->GetModsSheet()); + styleSet.AppendStyleSheet(*sheet);
+ } + }
+
MaybeAppend(IsInChromeDocShell() ? cache->GetUserChromeSheet() StyleSheet* sheet = IsInChromeDocShell() ? cache->GetUserChromeSheet()
: cache->GetUserContentSheet()); : cache->GetUserContentSheet();
MaybeAppend(cache->GetUASheet());

View File

@@ -0,0 +1,167 @@
diff --git a/gfx/webrender_bindings/WebRenderAPI.cpp b/gfx/webrender_bindings/WebRenderAPI.cpp
--- a/gfx/webrender_bindings/WebRenderAPI.cpp
+++ b/gfx/webrender_bindings/WebRenderAPI.cpp
@@ -298,11 +298,13 @@
panic_on_gl_error, picTileWidth, picTileHeight,
gfx::gfxVars::WebRenderRequiresHardwareDriver(),
StaticPrefs::gfx_webrender_low_quality_pinch_zoom_AtStartup(),
StaticPrefs::gfx_webrender_max_shared_surface_size_AtStartup(),
StaticPrefs::gfx_webrender_enable_subpixel_aa_AtStartup(),
- compositor->ShouldUseLayerCompositor())) {
+ compositor->ShouldUseLayerCompositor(),
+ StaticPrefs::
+ gfx_webrender_opaque_backdrop_fallback_AtStartup())) {
// wr_window_new puts a message into gfxCriticalNote if it returns
// false
MOZ_ASSERT(errorMessage);
error.AssignASCII(errorMessage);
wr_api_free_error_msg(errorMessage);
diff --git a/gfx/webrender_bindings/src/bindings.rs b/gfx/webrender_bindings/src/bindings.rs
--- a/gfx/webrender_bindings/src/bindings.rs
+++ b/gfx/webrender_bindings/src/bindings.rs
@@ -1998,10 +1998,11 @@
reject_software_rasterizer: bool,
low_quality_pinch_zoom: bool,
max_shared_surface_size: i32,
enable_subpixel_aa: bool,
use_layer_compositor: bool,
+ opaque_backdrop_fallback: bool,
) -> bool {
assert!(unsafe { is_in_render_thread() });
// Ensure the WR profiler callbacks are hooked up to the Gecko profiler.
set_profiler_hooks(Some(&PROFILER_HOOKS));
@@ -2164,10 +2165,11 @@
texture_cache_config,
reject_software_rasterizer,
low_quality_pinch_zoom,
max_shared_surface_size,
enable_dithering,
+ opaque_backdrop_fallback,
..Default::default()
};
let window_size = DeviceIntSize::new(window_width, window_height);
let notifier = Box::new(CppNotifier { window_id });
diff --git a/gfx/wr/webrender/src/device/gl.rs b/gfx/wr/webrender/src/device/gl.rs
--- a/gfx/wr/webrender/src/device/gl.rs
+++ b/gfx/wr/webrender/src/device/gl.rs
@@ -3982,10 +3982,14 @@
pub fn disable_color_write(&self) {
self.gl.color_mask(false, false, false, false);
}
+ pub fn set_color_mask(&self, r: bool, g: bool, b: bool, a: bool) {
+ self.gl.color_mask(r, g, b, a);
+ }
+
pub fn set_blend(&mut self, enable: bool) {
if enable {
self.gl.enable(gl::BLEND);
} else {
self.gl.disable(gl::BLEND);
diff --git a/gfx/wr/webrender/src/renderer/init.rs b/gfx/wr/webrender/src/renderer/init.rs
--- a/gfx/wr/webrender/src/renderer/init.rs
+++ b/gfx/wr/webrender/src/renderer/init.rs
@@ -204,6 +204,8 @@
pub low_quality_pinch_zoom: bool,
pub max_shared_surface_size: i32,
/// If true, open a debug socket to listen for remote debugger.
/// Relies on `debugger` cargo feature being enabled.
pub enable_debugger: bool,
+ /// See explanation of `gfx.webrender.opaque-backdrop-fallback`.
+ pub opaque_backdrop_fallback: bool,
}
@@ -277,9 +279,10 @@
enable_instancing: true,
reject_software_rasterizer: false,
low_quality_pinch_zoom: false,
max_shared_surface_size: 2048,
enable_debugger: true,
+ opaque_backdrop_fallback: false,
}
}
}
@@ -802,10 +805,11 @@
allocated_native_surfaces: FastHashSet::default(),
debug_overlay_state: DebugOverlayState::new(),
buffer_damage_tracker: BufferDamageTracker::default(),
max_primitive_instance_count,
enable_instancing: options.enable_instancing,
+ opaque_backdrop_fallback: options.opaque_backdrop_fallback,
consecutive_oom_frames: 0,
target_frame_publish_id: None,
pending_result_msg: None,
layer_compositor_frame_state_in_prev_frame: None,
external_composite_debug_items: Vec::new(),
diff --git a/gfx/wr/webrender/src/renderer/mod.rs b/gfx/wr/webrender/src/renderer/mod.rs
--- a/gfx/wr/webrender/src/renderer/mod.rs
+++ b/gfx/wr/webrender/src/renderer/mod.rs
@@ -867,10 +867,12 @@
buffer_damage_tracker: BufferDamageTracker,
max_primitive_instance_count: usize,
enable_instancing: bool,
+ opaque_backdrop_fallback: bool,
+
/// Count consecutive oom frames to detectif we are stuck unable to render
/// in a loop.
consecutive_oom_frames: u32,
/// update() defers processing of ResultMsg, if frame_publish_id of
@@ -2787,18 +2789,29 @@
let read_target = ReadTarget::from_texture(cache_texture);
// Should always be drawing to picture cache tiles or off-screen surface!
debug_assert!(!draw_target.is_default());
let device_to_framebuffer = Scale::new(1i32);
+ let dest_fb_rect = dest * device_to_framebuffer;
self.device.blit_render_target(
read_target,
src * device_to_framebuffer,
draw_target,
- dest * device_to_framebuffer,
+ dest_fb_rect,
TextureFilter::Linear,
);
+
+ if self.opaque_backdrop_fallback {
+ self.device.set_color_mask(false, false, false, true);
+ self.device.clear_target(
+ Some([0.0, 0.0, 0.0, 1.0]),
+ None,
+ Some(dest_fb_rect),
+ );
+ self.device.set_color_mask(true, true, true, true);
+ }
}
}
}
fn draw_picture_cache_target(
diff --git a/modules/libpref/init/StaticPrefList.yaml b/modules/libpref/init/StaticPrefList.yaml
--- a/modules/libpref/init/StaticPrefList.yaml
+++ b/modules/libpref/init/StaticPrefList.yaml
@@ -8439,10 +8439,17 @@
#else
value: false
#endif
mirror: once
+# Make backdrop-filter treat its captured backdrop as if it had been
+# composited over an opaque-black background. (See bug 2036640)
+- name: gfx.webrender.opaque-backdrop-fallback
+ type: bool
+ value: true
+ mirror: once
+
# Disable wait of GPU execution completion
- name: gfx.webrender.wait-gpu-finished.disabled
type: bool
value: false
mirror: once

View File

@@ -0,0 +1,15 @@
diff --git a/services/sync/modules/service.sys.mjs b/services/sync/modules/service.sys.mjs
index 4f4efe572909441abdd81909282936702c225621..81ab06a9414ed656b644a21b40b683f06a4f8dbf 100644
--- a/services/sync/modules/service.sys.mjs
+++ b/services/sync/modules/service.sys.mjs
@@ -99,6 +99,10 @@ function getEngineModules() {
whenTrue: "ExtensionStorageEngineKinto",
whenFalse: "ExtensionStorageEngineBridge",
};
+ result.Spaces = {
+ module: "resource:///modules/zen/ZenWorkspacesSync.sys.mjs",
+ symbol: "ZenWorkspacesEngine",
+ };
return result;
}

View File

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

View File

@@ -1,22 +0,0 @@
diff --git a/toolkit/mozapps/extensions/Blocklist.sys.mjs b/toolkit/mozapps/extensions/Blocklist.sys.mjs
index 4e1e0aac91612c846bb664efe3dbb50ecf63597d..93eb9a05520198eb3f9af35aa58d789719169434 100644
--- a/toolkit/mozapps/extensions/Blocklist.sys.mjs
+++ b/toolkit/mozapps/extensions/Blocklist.sys.mjs
@@ -411,7 +411,7 @@ class TargetAppFilter {
if (!Array.isArray(versionRange)) {
const { maxVersion = "*" } = versionRange;
const matchesRange =
- Services.vc.compare(lazy.gApp.version, maxVersion) <= 0;
+ Services.vc.compare(AppConstants.ZEN_FIREFOX_VERSION, maxVersion) <= 0;
return matchesRange ? entry : null;
}
@@ -433,7 +433,7 @@ class TargetAppFilter {
const { maxVersion = "*" } = ta;
if (
guid == lazy.gAppID &&
- Services.vc.compare(lazy.gApp.version, maxVersion) <= 0
+ Services.vc.compare(AppConstants.ZEN_FIREFOX_VERSION, maxVersion) <= 0
) {
return entry;
}

View File

@@ -64,11 +64,11 @@ export class nsZenBoostEditor {
} }
/** /**
* Returns the parent Boost JSActor for the currently selected tab. * Returns the ZenBoosts JSWindowActor child for the currently selected tab.
* *
* @returns {ZenBoostsParent} Boost JSActor parent * @returns {ZenBoostsChild} zenBoostsChild Boost JSActor child
*/ */
get zenBoostsParent() { get zenBoostsChild() {
const linkedBrowser = this.openerWindow.gBrowser.selectedTab.linkedBrowser; const linkedBrowser = this.openerWindow.gBrowser.selectedTab.linkedBrowser;
const actor = const actor =
linkedBrowser.browsingContext.currentWindowGlobal.getActor("ZenBoosts"); linkedBrowser.browsingContext.currentWindowGlobal.getActor("ZenBoosts");
@@ -466,13 +466,13 @@ export class nsZenBoostEditor {
} }
async onZapButtonPressed() { async onZapButtonPressed() {
this.zenBoostsParent.sendQuery("ZenBoost:ToggleZapMode"); this.zenBoostsChild.sendQuery("ZenBoost:ToggleZapMode");
// Focus the parent browser window // Focus the parent browser window
this.openerWindow.focus(); this.openerWindow.focus();
} }
async onPickerButtonPressed() { async onPickerButtonPressed() {
this.zenBoostsParent.sendQuery("ZenBoost:TogglePickerMode"); this.zenBoostsChild.sendQuery("ZenBoost:TogglePickerMode");
this.openerWindow.focus(); this.openerWindow.focus();
} }
@@ -497,11 +497,11 @@ ${cssSelector} {
} }
onInspectorButtonPressed() { onInspectorButtonPressed() {
this.zenBoostsParent.sendQuery("ZenBoost:OpenInspector"); this.zenBoostsChild.sendQuery("ZenBoost:OpenInspector");
} }
async onUpdateZapButtonVisual() { async onUpdateZapButtonVisual() {
const actor = this.zenBoostsParent; const actor = this.zenBoostsChild;
const zapButton = this.doc.getElementById("zen-boost-zap"); const zapButton = this.doc.getElementById("zen-boost-zap");
const zapEnabled = await actor.sendQuery("ZenBoost:ZapModeEnabled"); const zapEnabled = await actor.sendQuery("ZenBoost:ZapModeEnabled");
@@ -513,7 +513,7 @@ ${cssSelector} {
async onUpdatePickerButtonVisual() { async onUpdatePickerButtonVisual() {
const pickerButton = this.doc.getElementById("zen-boost-css-picker"); const pickerButton = this.doc.getElementById("zen-boost-css-picker");
const selectEnabled = await this.zenBoostsParent.sendQuery( const selectEnabled = await this.zenBoostsChild.sendQuery(
"ZenBoost:SelectorPickerModeEnabled" "ZenBoost:SelectorPickerModeEnabled"
); );
@@ -660,7 +660,7 @@ ${cssSelector} {
/** /**
* Handles the size toggle button press, cycling through size override options * Handles the size toggle button press, cycling through size override options
*/ */
onBoostSizePressed() { async onBoostSizePressed() {
if (this.currentBoostData.sizeOverride == 1) { if (this.currentBoostData.sizeOverride == 1) {
this.currentBoostData.sizeOverride = 1.1; this.currentBoostData.sizeOverride = 1.1;
} else if (this.currentBoostData.sizeOverride == 1.1) { } else if (this.currentBoostData.sizeOverride == 1.1) {
@@ -671,7 +671,7 @@ ${cssSelector} {
this.currentBoostData.sizeOverride = 0.9; this.currentBoostData.sizeOverride = 0.9;
} else if (this.currentBoostData.sizeOverride == 0.9) { } else if (this.currentBoostData.sizeOverride == 0.9) {
this.currentBoostData.sizeOverride = 1; this.currentBoostData.sizeOverride = 1;
this.zenBoostsParent.updateBoostSizeOverride( await this.zenBoostsChild.updateBoostSizeOverride(
this.currentBoostData.sizeOverride, this.currentBoostData.sizeOverride,
/* disable: */ true /* disable: */ true
); );

View File

@@ -85,7 +85,7 @@ export class ZenBoostsParent extends JSWindowActorParent {
(sizeOverride !== 1 || disable) (sizeOverride !== 1 || disable)
) { ) {
const fullZoom = this.browsingContext.topChromeWindow.FullZoom; const fullZoom = this.browsingContext.topChromeWindow.FullZoom;
fullZoom.setZoom(sizeOverride, this.browsingContext.top.embedderElement); fullZoom.setZoom(sizeOverride);
} }
} }

View File

@@ -353,11 +353,3 @@ body,
:root[zen-right-side="true"] #zen-appcontent-navbar-wrapper #PanelUI-button { :root[zen-right-side="true"] #zen-appcontent-navbar-wrapper #PanelUI-button {
order: 2; order: 2;
} }
/* See #14409: firefox 150's body paint containment prevents the right side compact
sidebar from remaining active at the screen edge in macOS fullscreen */
@media (-moz-platform: macos) {
:root[macOSNativeFullscreen][zen-compact-mode][zen-right-side] body {
contain: none;
}
}

View File

@@ -712,7 +712,7 @@
} }
/* Hide the results body when there are no results, to avoid showing an empty box */ /* Hide the results body when there are no results, to avoid showing an empty box */
.urlbar[noresults] .urlbarView > .urlbarView-body-outer > .urlbarView-body-inner, .urlbarView[noresults] > .urlbarView-body-outer > .urlbarView-body-inner,
#urlbar-search-mode-indicator-close { #urlbar-search-mode-indicator-close {
display: none; display: none;
} }

View File

@@ -190,7 +190,7 @@
--urlbar-box-active-bgcolor: var(--toolbarbutton-hover-background) !important; --urlbar-box-active-bgcolor: var(--toolbarbutton-hover-background) !important;
--zen-input-border-color: light-dark(rgb(204, 204, 204), rgb(66, 65, 77)); --zen-input-border-color: light-dark(rgb(204, 204, 204), rgb(66, 65, 77));
--urlbar-box-hover-bgcolor: var(--toolbarbutton-hover-background) !important; --urlbar-box-hover-bgcolor: var(--toolbarbutton-hover-background) !important;
--input-text-background-color: light-dark(rgba(255, 255, 255, 0.2), rgba(0, 0, 0, 0.2)) !important; --input-bgcolor: light-dark(rgba(255, 255, 255, 0.2), rgba(0, 0, 0, 0.2)) !important;
--toolbar-field-focus-background-color: light-dark( --toolbar-field-focus-background-color: light-dark(
rgba(255, 255, 255, 0.25), rgba(255, 255, 255, 0.25),
rgba(0, 0, 0, 0.3) rgba(0, 0, 0, 0.3)

View File

@@ -977,14 +977,6 @@ window.gZenCompactModeManager = {
!gZenGlanceManager._animating && !gZenGlanceManager._animating &&
!this._nextTimeWillBeActive !this._nextTimeWillBeActive
) { ) {
const isTabRoutedToCurrentSpace =
beforeRouteResult.targetRoute === gZenWorkspaces.activeWorkspace;
// Do not show the toast if the sidebar is not hidden and
// the tab is being routed to the current space
if (!isSidebarHidden && isTabRoutedToCurrentSpace) {
return;
}
let messageId = "zen-background-tab-opened-toast"; let messageId = "zen-background-tab-opened-toast";
let toastOptions = { let toastOptions = {
button: { button: {
@@ -996,7 +988,7 @@ window.gZenCompactModeManager = {
}, },
}; };
if (beforeRouteResult.isRouteFound && !isTabRoutedToCurrentSpace) { if (beforeRouteResult.isRouteFound) {
messageId = "zen-space-routing-tab-routed-toast"; messageId = "zen-space-routing-tab-routed-toast";
toastOptions = { toastOptions = {
l10nArgs: { targetWorkspace: beforeRouteResult.targetWorkspaceName }, l10nArgs: { targetWorkspace: beforeRouteResult.targetWorkspaceName },

View File

@@ -654,10 +654,6 @@
} }
#shouldSwitchSpace(event) { #shouldSwitchSpace(event) {
if (document.documentElement.hasAttribute("customizing")) {
return { isNearLeftEdge: false, isNearRightEdge: false };
}
const padding = Services.prefs.getIntPref( const padding = Services.prefs.getIntPref(
"zen.workspaces.dnd-switch-padding" "zen.workspaces.dnd-switch-padding"
); );
@@ -726,11 +722,10 @@
#handle_sidebarDragOver(event) { #handle_sidebarDragOver(event) {
const dt = event.dataTransfer; const dt = event.dataTransfer;
const isTabDrag = dt.mozTypesAt(0)[0] === TAB_DROP_TYPE;
const { isNearLeftEdge, isNearRightEdge } = const { isNearLeftEdge, isNearRightEdge } =
this.#shouldSwitchSpace(event); this.#shouldSwitchSpace(event);
if (isTabDrag && (isNearLeftEdge || isNearRightEdge)) { if (isNearLeftEdge || isNearRightEdge) {
if (!this.#changeSpaceTimer && !this.#isOutOfWindow) { if (!this.#changeSpaceTimer && !this.#isOutOfWindow) {
this.#changeSpaceTimer = setTimeout(() => { this.#changeSpaceTimer = setTimeout(() => {
this.clearDragOverVisuals(); this.clearDragOverVisuals();
@@ -754,10 +749,6 @@
handle_spaceIconDragOver(event) { handle_spaceIconDragOver(event) {
const dt = event.dataTransfer; const dt = event.dataTransfer;
if (dt.mozTypesAt(0)[0] !== TAB_DROP_TYPE) {
return;
}
const draggedTab = dt.mozGetDataAt(TAB_DROP_TYPE, 0); const draggedTab = dt.mozGetDataAt(TAB_DROP_TYPE, 0);
if (draggedTab.hasAttribute("zen-essential")) { if (draggedTab.hasAttribute("zen-essential")) {
return; return;

View File

@@ -20,4 +20,5 @@ DIRS += [
"share", "share",
"spaces", "spaces",
"space-routing", "space-routing",
"sync",
] ]

View File

@@ -10,6 +10,7 @@ const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, { ChromeUtils.defineESModuleGetters(lazy, {
ZenLiveFoldersManager: ZenLiveFoldersManager:
"resource:///modules/zen/ZenLiveFoldersManager.sys.mjs", "resource:///modules/zen/ZenLiveFoldersManager.sys.mjs",
ZenSyncStore: "resource:///modules/zen/ZenSyncManager.sys.mjs",
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs",
SessionStartup: "resource:///modules/sessionstore/SessionStartup.sys.mjs", SessionStartup: "resource:///modules/sessionstore/SessionStartup.sys.mjs",
@@ -609,6 +610,7 @@ export class nsZenSessionManager {
} }
); );
this.#collectWindowData(windows); this.#collectWindowData(windows);
lazy.ZenSyncStore.notifyAboutChanges();
// This would save the data to disk asynchronously or when quitting the app. // This would save the data to disk asynchronously or when quitting the app.
let sidebar = this.#sidebarWithoutCloning; let sidebar = this.#sidebarWithoutCloning;
this.#file.data = sidebar; this.#file.data = sidebar;
@@ -622,6 +624,15 @@ export class nsZenSessionManager {
this.log(`Saving Zen session data with ${sidebar.tabs?.length || 0} tabs`); this.log(`Saving Zen session data with ${sidebar.tabs?.length || 0} tabs`);
} }
/**
* Returns the sidebar data object.
*
* @returns {object}
*/
getSidebarData() {
return this.#sidebarWithoutCloning;
}
/** /**
* Called when the last known backup should be deleted and a new one * Called when the last known backup should be deleted and a new one
* created. This uses the #deferredBackupTask to debounce clusters of * created. This uses the #deferredBackupTask to debounce clusters of

View File

@@ -769,8 +769,8 @@ class nsZenWindowSync {
const otherBrowser = aTab.linkedBrowser; const otherBrowser = aTab.linkedBrowser;
// We aren't closing the other tab so, we also need to swap its tablisteners. // We aren't closing the other tab so, we also need to swap its tablisteners.
let filter = otherTabBrowser._getTabProgressFilter(aTab); let filter = otherTabBrowser._tabFilters.get(aTab);
let tabListener = otherTabBrowser._getTabProgressListener(aTab); let tabListener = otherTabBrowser._tabListeners.get(aTab);
try { try {
otherBrowser.webProgress.removeProgressListener(filter); otherBrowser.webProgress.removeProgressListener(filter);
filter.removeProgressListener(tabListener); filter.removeProgressListener(tabListener);
@@ -792,7 +792,7 @@ class nsZenWindowSync {
true, true,
false false
); );
otherTabBrowser._setTabProgressListener(aTab, tabListener); otherTabBrowser._tabListeners.set(aTab, tabListener);
const notifyAll = Ci.nsIWebProgress.NOTIFY_ALL; const notifyAll = Ci.nsIWebProgress.NOTIFY_ALL;
filter.addProgressListener(tabListener, notifyAll); filter.addProgressListener(tabListener, notifyAll);

View File

@@ -2,12 +2,6 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * 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/. */ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const lazy = {};
ChromeUtils.defineLazyGetter(lazy, "l10n", () => {
return new Localization(["browser/zen-workspaces.ftl"], true);
});
class nsZenWorkspaceCreation extends MozXULElement { class nsZenWorkspaceCreation extends MozXULElement {
#wasInCollapsedMode = false; #wasInCollapsedMode = false;
@@ -198,7 +192,7 @@ class nsZenWorkspaceCreation extends MozXULElement {
this.currentProfile = { this.currentProfile = {
id: 0, id: 0,
name: lazy.l10n.formatValueSync("zen-workspace-default-profile"), name: ContextualIdentityService.formatContextLabel("user-context-none"),
}; };
} else { } else {
this.inputProfile.parentNode.hidden = true; this.inputProfile.parentNode.hidden = true;
@@ -303,19 +297,10 @@ class nsZenWorkspaceCreation extends MozXULElement {
} }
onProfilePopupShowing(event) { onProfilePopupShowing(event) {
window.createUserContextMenu(event, { return window.createUserContextMenu(event, {
isContextMenu: true, isContextMenu: true,
showDefaultTab: true, showDefaultTab: true,
showManageContainers: false,
}); });
const defaultItem = event.target.querySelector('[data-usercontextid="0"]');
if (defaultItem) {
defaultItem.removeAttribute("data-l10n-id");
defaultItem.label = lazy.l10n.formatValueSync(
"zen-workspace-default-profile"
);
}
} }
onProfilePopupCommand(event) { onProfilePopupCommand(event) {

View File

@@ -11,6 +11,7 @@ const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, { ChromeUtils.defineESModuleGetters(lazy, {
ZenSessionStore: "resource:///modules/zen/ZenSessionManager.sys.mjs", ZenSessionStore: "resource:///modules/zen/ZenSessionManager.sys.mjs",
ZenSyncStore: "resource:///modules/zen/ZenSyncManager.sys.mjs",
}); });
ChromeUtils.defineLazyGetter(lazy, "browserBackgroundElement", () => { ChromeUtils.defineLazyGetter(lazy, "browserBackgroundElement", () => {
@@ -21,10 +22,6 @@ ChromeUtils.defineLazyGetter(lazy, "toolbarBackgroundElement", () => {
return document.getElementById("zen-toolbar-background"); return document.getElementById("zen-toolbar-background");
}); });
ChromeUtils.defineLazyGetter(lazy, "l10n", () => {
return new Localization(["browser/zen-workspaces.ftl"], true);
});
/** /**
* Zen Spaces manager. This class is mainly responsible for the UI * Zen Spaces manager. This class is mainly responsible for the UI
* and user interactions but it also contains some logic to manage * and user interactions but it also contains some logic to manage
@@ -152,7 +149,7 @@ class nsZenWorkspaces {
if (!this.privateWindowOrDisabled) { if (!this.privateWindowOrDisabled) {
const observerFunction = async () => { const observerFunction = async () => {
delete this._workspaceBookmarksCache; delete this._workspaceBookmarksCache;
await this.workspaceBookmarks(); await this.#initializeWorkspaceBookmarks();
this._invalidateBookmarkContainers(); this._invalidateBookmarkContainers();
}; };
Services.obs.addObserver(observerFunction, "workspace-bookmarks-updated"); Services.obs.addObserver(observerFunction, "workspace-bookmarks-updated");
@@ -172,6 +169,63 @@ 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() { #afterLoadInit() {
const onResize = (...args) => { const onResize = (...args) => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -698,17 +752,17 @@ class nsZenWorkspaces {
return spacesForSS; return spacesForSS;
} }
async workspaceBookmarks() { async #initializeWorkspaceBookmarks() {
if (this.privateWindowOrDisabled) { if (this.privateWindowOrDisabled) {
this._workspaceBookmarksCache = { this._workspaceBookmarksCache = {
bookmarks: [], bookmarks: [],
lastChangeTimestamp: 0, lastChangeTimestamp: 0,
}; };
return this._workspaceBookmarksCache; return;
} }
if (this._workspaceBookmarksCache) { if (this._workspaceBookmarksCache) {
return this._workspaceBookmarksCache; return;
} }
const [bookmarks, lastChangeTimestamp] = await Promise.all([ const [bookmarks, lastChangeTimestamp] = await Promise.all([
@@ -717,8 +771,6 @@ class nsZenWorkspaces {
]); ]);
this._workspaceBookmarksCache = { bookmarks, lastChangeTimestamp }; this._workspaceBookmarksCache = { bookmarks, lastChangeTimestamp };
return this._workspaceCache;
} }
restoreWorkspacesFromSessionStore(aWinData = {}) { restoreWorkspacesFromSessionStore(aWinData = {}) {
@@ -778,7 +830,7 @@ class nsZenWorkspaces {
return (async () => { return (async () => {
await this.#waitForPromises(); await this.#waitForPromises();
this.#afterLoadInit(); this.#afterLoadInit();
await this.workspaceBookmarks(); await this.#initializeWorkspaceBookmarks();
await this.changeWorkspace(activeWorkspace, { onInit: true }); await this.changeWorkspace(activeWorkspace, { onInit: true });
this.#fixTabPositions(); this.#fixTabPositions();
this.onWindowResize(); this.onWindowResize();
@@ -804,6 +856,13 @@ class nsZenWorkspaces {
})(); })();
} }
#markWorkspaceChanged(workspaceId) {
lazy.ZenSyncStore.markItemChanged({
type: "space",
id: workspaceId,
});
}
async selectStartPage() { async selectStartPage() {
if (!this.workspaceEnabled || gZenUIManager.testingEnabled) { if (!this.workspaceEnabled || gZenUIManager.testingEnabled) {
return; return;
@@ -1204,19 +1263,11 @@ class nsZenWorkspaces {
workspace = this.getActiveWorkspaceFromCache(); workspace = this.getActiveWorkspaceFromCache();
} }
let containerTabId = workspace.containerTabId; let containerTabId = workspace.containerTabId;
window.createUserContextMenu(event, { return window.createUserContextMenu(event, {
isContextMenu: true, isContextMenu: true,
excludeUserContextId: containerTabId, excludeUserContextId: containerTabId,
showDefaultTab: true, showDefaultTab: true,
}); });
const defaultItem = event.target.querySelector('[data-usercontextid="0"]');
if (defaultItem) {
defaultItem.removeAttribute("data-l10n-id");
defaultItem.label = lazy.l10n.formatValueSync(
"zen-workspace-default-profile"
);
}
} }
saveWorkspace(workspaceData) { saveWorkspace(workspaceData) {
@@ -1232,12 +1283,19 @@ class nsZenWorkspaces {
} else { } else {
workspacesData.push(workspaceData); workspacesData.push(workspaceData);
} }
// mark item as changed for sync
this.#markWorkspaceChanged(workspaceData.uuid);
this.#propagateWorkspaceData(); this.#propagateWorkspaceData();
} }
removeWorkspace(windowID) { removeWorkspace(windowID) {
let { promise, resolve } = Promise.withResolvers(); let { promise, resolve } = Promise.withResolvers();
this.#deleteWorkspaceOwnedTabs(windowID); this.#deleteWorkspaceOwnedTabs(windowID);
// mark item as changed for sync
this.#markWorkspaceChanged(windowID);
let workspacesData = this.getWorkspaces(); let workspacesData = this.getWorkspaces();
// Remove the workspace from the cache // Remove the workspace from the cache
workspacesData = workspacesData.filter( workspacesData = workspacesData.filter(
@@ -1369,30 +1427,50 @@ class nsZenWorkspaces {
if (this.privateWindowOrDisabled) { if (this.privateWindowOrDisabled) {
return; return;
} }
const workspaces = this._workspaceCache; 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); const workspace = workspaces.find(w => w.uuid === id);
if (!workspace) { if (!workspace) {
console.warn(`Workspace with ID ${id} not found for reordering.`); console.warn(`Workspace with ID ${id} not found for reordering.`);
return; return;
} }
// Remove the workspace from its current position // Remove the workspace from its current position
const currentIndex = workspaces.indexOf(workspace); const currentIndex = workspaces.indexOf(workspace);
if (currentIndex === -1) { if (currentIndex === -1) {
console.warn(`Workspace with ID ${id} not found in the list.`); console.warn(`Workspace with ID ${id} not found in the list.`);
return; return;
} }
workspaces.splice(currentIndex, 1);
// Insert the workspace at the new position // Insert the workspace at the new position
if (newPosition < 0 || newPosition > workspaces.length) { if (newPosition < 0 || newPosition >= workspaces.length) {
console.warn( console.warn(
`Invalid position ${newPosition} for reordering workspace with ID ${id}.` `Invalid position ${newPosition} for reordering workspace with ID ${id}.`
); );
return; return;
} }
workspaces.splice(currentIndex, 1);
workspaces.splice(newPosition, 0, workspace); workspaces.splice(newPosition, 0, workspace);
// Propagate the changes if the order has changed // Propagate the changes if the order has changed
if (currentIndex !== newPosition) { if (currentIndex !== newPosition) {
this.#propagateWorkspaceData(); 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);
} }
} }
@@ -2365,7 +2443,7 @@ class nsZenWorkspaces {
for (const tab of gBrowser.tabs) { for (const tab of gBrowser.tabs) {
if ( if (
!tab.hasAttribute("zen-workspace-id") && !tab.hasAttribute("zen-workspace-id") &&
!tab.hasAttribute("zen-workspace-id") !tab.hasAttribute("zen-essential")
) { ) {
tab.setAttribute("zen-workspace-id", workspace.uuid); tab.setAttribute("zen-workspace-id", workspace.uuid);
} }

View File

@@ -26,7 +26,7 @@ zen-workspace-creation {
} }
& form { & form {
--input-text-background-color: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)); --input-bgcolor: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -53,7 +53,7 @@ zen-workspace-creation {
padding: 9px 6px; padding: 9px 6px;
border-radius: 8px !important; border-radius: 8px !important;
margin: 0; margin: 0;
background-color: var(--input-text-background-color); background-color: var(--input-bgcolor);
gap: 8px; gap: 8px;
align-items: center; align-items: center;
padding-left: 8px; padding-left: 8px;
@@ -108,7 +108,7 @@ zen-workspace-creation {
& .zen-workspace-creation-name { & .zen-workspace-creation-name {
--input-border-color: transparent; --input-border-color: transparent;
--input-text-background-color: transparent; --input-bgcolor: transparent;
padding: 0 !important; padding: 0 !important;
width: 100%; width: 100%;
outline: none; outline: none;
@@ -120,7 +120,7 @@ zen-workspace-creation {
padding: 4px; padding: 4px;
border-radius: 8px !important; border-radius: 8px !important;
margin: 0; margin: 0;
background-color: var(--input-text-background-color); background-color: var(--input-bgcolor);
gap: 4px; gap: 4px;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
@@ -149,7 +149,7 @@ zen-workspace-creation {
& .zen-workspace-creation-edit-theme-button { & .zen-workspace-creation-edit-theme-button {
border-radius: 8px !important; border-radius: 8px !important;
margin: 0; margin: 0;
background-color: var(--input-text-background-color); background-color: var(--input-bgcolor);
justify-content: center; justify-content: center;
align-items: center; align-items: center;
appearance: none; appearance: none;

View File

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

View File

@@ -0,0 +1,404 @@
/* 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;
}
}

8
src/zen/sync/moz.build Normal file
View File

@@ -0,0 +1,8 @@
# 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/.
EXTRA_JS_MODULES.zen += [
"ZenSyncManager.sys.mjs",
"ZenWorkspacesSync.sys.mjs",
]

View File

@@ -1019,15 +1019,12 @@ class nsZenPinnedTabManager extends nsZenDOMOperatedFeature {
} }
canEssentialBeAdded(tab) { canEssentialBeAdded(tab) {
const isExistingEssentialTab = tab.hasAttribute("zen-essential");
return ( return (
!( !(
(tab.getAttribute("usercontextid") || 0) != (tab.getAttribute("usercontextid") || 0) !=
(gZenWorkspaces.getActiveWorkspaceFromCache().containerTabId || 0) && (gZenWorkspaces.getActiveWorkspaceFromCache().containerTabId || 0) &&
gZenWorkspaces.containerSpecificEssentials gZenWorkspaces.containerSpecificEssentials
) && ) && gBrowser._numZenEssentials < this.maxEssentialTabs
(isExistingEssentialTab ||
gBrowser._numZenEssentials < this.maxEssentialTabs)
); );
} }

View File

@@ -118,19 +118,7 @@ run-if = [
] ]
tags = "os_integration" tags = "os_integration"
["browser_setDefaultProtocolHandler.js"]
run-if = [
"os == 'win'",
]
tags = "os_integration"
["browser_setDesktopBackgroundPreview.js"] ["browser_setDesktopBackgroundPreview.js"]
disabled="Disabled by import_external_tests.py" disabled="Disabled by import_external_tests.py"
support-files = ["large.png", "canvas.html"] support-files = ["large.png"]
tags = "os_integration"
["browser_windowsSetDefaultAppCmdHandler.js"]
run-if = [
"os == 'win'",
]
tags = "os_integration" tags = "os_integration"

View File

@@ -71,10 +71,4 @@ add_task(async function () {
], ],
screenshotPath screenshotPath
); );
// Regression test for bug 2003265, likely to become flaky if it regresses.
await testFileCreationPositive(
["-url", "about:blank", "-screenshot", screenshotPath],
screenshotPath
);
}); });

View File

@@ -8,9 +8,6 @@ ChromeUtils.defineESModuleGetters(this, {
sinon: "resource://testing-common/Sinon.sys.mjs", sinon: "resource://testing-common/Sinon.sys.mjs",
}); });
const confusedFoxPath = ShellService.getBundledPdfFile("confused_fox.pdf").path;
const SET_HANDLER_WIN11 = Ci.nsIWindowsShellService.OPEN_WITH_SET_HANDLER;
const setDefaultBrowserUserChoiceStub = sinon.stub(); const setDefaultBrowserUserChoiceStub = sinon.stub();
const setDefaultExtensionHandlersUserChoiceStub = sinon const setDefaultExtensionHandlersUserChoiceStub = sinon
.stub() .stub()
@@ -37,15 +34,17 @@ const _userChoiceImpossibleTelemetryResultStub = sinon
const setDefaultStub = sinon.stub(); const setDefaultStub = sinon.stub();
// We'll dynamically update this as needed during the tests. // We'll dynamically update this as needed during the tests.
const queryCurrentDefaultHandlerForStub = sinon.stub(); const queryCurrentDefaultHandlerForStub = sinon.stub();
const launchSetDefaultAppPickerStub = sinon.stub(); const launchOpenWithDefaultPickerForFileTypeStub = sinon.stub();
const launchModernSettingsDialogDefaultAppsStub = sinon.stub(); const launchModernSettingsDialogDefaultAppsStub = sinon.stub();
const shellStub = sinon.stub(ShellService, "shellService").value({ const shellStub = sinon.stub(ShellService, "shellService").value({
setDefaultBrowser: setDefaultStub, setDefaultBrowser: setDefaultStub,
queryCurrentDefaultHandlerFor: queryCurrentDefaultHandlerForStub, queryCurrentDefaultHandlerFor: queryCurrentDefaultHandlerForStub,
launchSetDefaultAppPicker: launchSetDefaultAppPickerStub, QueryInterface: () => ({
launchModernSettingsDialogDefaultApps: launchOpenWithDefaultPickerForFileType:
launchModernSettingsDialogDefaultAppsStub, launchOpenWithDefaultPickerForFileTypeStub,
QueryInterface: ChromeUtils.generateQI([]), launchModernSettingsDialogDefaultApps:
launchModernSettingsDialogDefaultAppsStub,
}),
}); });
registerCleanupFunction(() => { registerCleanupFunction(() => {
@@ -229,7 +228,7 @@ add_task(async function test_setAsDefaultPDFHandler_knownBrowser() {
const expectedArguments = [aumi, [".pdf", "FirefoxPDF"]]; const expectedArguments = [aumi, [".pdf", "FirefoxPDF"]];
const resetStubs = () => { const resetStubs = () => {
setDefaultExtensionHandlersUserChoiceStub.resetHistory(); setDefaultExtensionHandlersUserChoiceStub.resetHistory();
launchSetDefaultAppPickerStub.resetHistory(); launchOpenWithDefaultPickerForFileTypeStub.resetHistory();
launchModernSettingsDialogDefaultAppsStub.resetHistory(); launchModernSettingsDialogDefaultAppsStub.resetHistory();
}; };
@@ -251,7 +250,7 @@ add_task(async function test_setAsDefaultPDFHandler_knownBrowser() {
"Called default browser agent with expected arguments" "Called default browser agent with expected arguments"
); );
Assert.ok( Assert.ok(
launchSetDefaultAppPickerStub.notCalled, launchOpenWithDefaultPickerForFileTypeStub.notCalled,
"Did not fall back to open-with picker" "Did not fall back to open-with picker"
); );
Assert.ok( Assert.ok(
@@ -272,7 +271,7 @@ add_task(async function test_setAsDefaultPDFHandler_knownBrowser() {
"Called default browser agent with expected arguments" "Called default browser agent with expected arguments"
); );
Assert.ok( Assert.ok(
launchSetDefaultAppPickerStub.notCalled, launchOpenWithDefaultPickerForFileTypeStub.notCalled,
"Did not fall back to open-with picker" "Did not fall back to open-with picker"
); );
Assert.ok( Assert.ok(
@@ -290,7 +289,7 @@ add_task(async function test_setAsDefaultPDFHandler_knownBrowser() {
"Did not use userChoice" "Did not use userChoice"
); );
Assert.ok( Assert.ok(
launchSetDefaultAppPickerStub.notCalled, launchOpenWithDefaultPickerForFileTypeStub.notCalled,
"Did not fall back to open-with picker" "Did not fall back to open-with picker"
); );
Assert.ok( Assert.ok(
@@ -311,7 +310,7 @@ add_task(async function test_setAsDefaultPDFHandler_knownBrowser() {
"Called default browser agent with expected arguments" "Called default browser agent with expected arguments"
); );
Assert.ok( Assert.ok(
launchSetDefaultAppPickerStub.notCalled, launchOpenWithDefaultPickerForFileTypeStub.notCalled,
"Did not fall back to open-with picker" "Did not fall back to open-with picker"
); );
Assert.ok( Assert.ok(
@@ -366,11 +365,8 @@ add_task(async function test_setAsDefaultPDFHandler_fallback() {
Assert.ok(userChoiceStub.called, "Attempted userChoice"); Assert.ok(userChoiceStub.called, "Attempted userChoice");
Assert.ok( Assert.ok(
launchSetDefaultAppPickerStub.calledWith( launchOpenWithDefaultPickerForFileTypeStub.calledWith(".pdf"),
confusedFoxPath, "Fell back to open-with picker for .pdf"
SET_HANDLER_WIN11
),
"Fell back to open-with picker with bundled PDF path and Win11 flag"
); );
Assert.ok( Assert.ok(
launchModernSettingsDialogDefaultAppsStub.notCalled, launchModernSettingsDialogDefaultAppsStub.notCalled,
@@ -396,7 +392,7 @@ add_task(async function test_setAsDefaultPDFHandler_fallback() {
); );
userChoiceStub.resetHistory(); userChoiceStub.resetHistory();
isDefaultHandlerForStub.resetHistory(); isDefaultHandlerForStub.resetHistory();
launchSetDefaultAppPickerStub.resetHistory(); launchOpenWithDefaultPickerForFileTypeStub.resetHistory();
launchModernSettingsDialogDefaultAppsStub.resetHistory(); launchModernSettingsDialogDefaultAppsStub.resetHistory();
info( info(
@@ -417,25 +413,22 @@ add_task(async function test_setAsDefaultPDFHandler_fallback() {
isDefaultHandlerForStub.returns(true); isDefaultHandlerForStub.returns(true);
userChoiceStub.resetHistory(); userChoiceStub.resetHistory();
isDefaultHandlerForStub.resetHistory(); isDefaultHandlerForStub.resetHistory();
launchSetDefaultAppPickerStub.resetHistory(); launchOpenWithDefaultPickerForFileTypeStub.resetHistory();
launchModernSettingsDialogDefaultAppsStub.resetHistory(); launchModernSettingsDialogDefaultAppsStub.resetHistory();
info( info(
"When userChoice fails and open-with picker fails, should fall back to settings dialog" "When userChoice fails and open-with picker fails, should fall back to settings dialog"
); );
Services.fog.testResetFOG(); Services.fog.testResetFOG();
launchSetDefaultAppPickerStub.throws( launchOpenWithDefaultPickerForFileTypeStub.throws(
new Error("mock IOpenWithLauncher failure") new Error("mock IOpenWithLauncher failure")
); );
await ShellService.setAsDefaultPDFHandler(false); await ShellService.setAsDefaultPDFHandler(false);
Assert.ok(userChoiceStub.called, "Attempted userChoice"); Assert.ok(userChoiceStub.called, "Attempted userChoice");
Assert.ok( Assert.ok(
launchSetDefaultAppPickerStub.calledWith( launchOpenWithDefaultPickerForFileTypeStub.calledWith(".pdf"),
confusedFoxPath, "Attempted open-with picker for .pdf"
SET_HANDLER_WIN11
),
"Attempted open-with picker with bundled PDF path and Win11 flag"
); );
Assert.ok( Assert.ok(
launchModernSettingsDialogDefaultAppsStub.called, launchModernSettingsDialogDefaultAppsStub.called,
@@ -470,7 +463,7 @@ add_task(async function test_setAsDefaultPDFHandler_fallback() {
); );
userChoiceStub.resetHistory(); userChoiceStub.resetHistory();
isDefaultHandlerForStub.resetHistory(); isDefaultHandlerForStub.resetHistory();
launchSetDefaultAppPickerStub.resetHistory(); launchOpenWithDefaultPickerForFileTypeStub.resetHistory();
launchModernSettingsDialogDefaultAppsStub.resetHistory(); launchModernSettingsDialogDefaultAppsStub.resetHistory();
info( info(
@@ -506,7 +499,7 @@ add_task(async function test_setAsDefaultPDFHandler_fallback() {
"Event result_is_default is false when no method set the default" "Event result_is_default is false when no method set the default"
); );
} finally { } finally {
launchSetDefaultAppPickerStub.reset(); launchOpenWithDefaultPickerForFileTypeStub.reset();
launchModernSettingsDialogDefaultAppsStub.reset(); launchModernSettingsDialogDefaultAppsStub.reset();
sandbox.restore(); sandbox.restore();
await SpecialPowers.popPrefEnv(); await SpecialPowers.popPrefEnv();
@@ -536,7 +529,7 @@ add_task(async function test_setAsDefaultPDFHandler_useOpenWithDisabled() {
await ShellService.setAsDefaultPDFHandler(false); await ShellService.setAsDefaultPDFHandler(false);
Assert.ok( Assert.ok(
launchSetDefaultAppPickerStub.notCalled, launchOpenWithDefaultPickerForFileTypeStub.notCalled,
"Did not invoke open-with picker when pref is disabled" "Did not invoke open-with picker when pref is disabled"
); );
Assert.ok( Assert.ok(
@@ -557,7 +550,7 @@ add_task(async function test_setAsDefaultPDFHandler_useOpenWithDisabled() {
"Event result_is_default reflects isDefaultHandlerFor" "Event result_is_default reflects isDefaultHandlerFor"
); );
} finally { } finally {
launchSetDefaultAppPickerStub.reset(); launchOpenWithDefaultPickerForFileTypeStub.reset();
launchModernSettingsDialogDefaultAppsStub.reset(); launchModernSettingsDialogDefaultAppsStub.reset();
sandbox.restore(); sandbox.restore();
await SpecialPowers.popPrefEnv(); await SpecialPowers.popPrefEnv();

View File

@@ -1,321 +0,0 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
ChromeUtils.defineESModuleGetters(this, {
DEFAULT_PROTOCOL_URLS:
"moz-src:///browser/components/shell/ShellService.sys.mjs",
SET_DEFAULT_REDIRECT_PREF:
"moz-src:///browser/components/shell/WindowsSetDefaultRedirect.sys.mjs",
WindowsSetDefaultRedirect:
"moz-src:///browser/components/shell/WindowsSetDefaultRedirect.sys.mjs",
sinon: "resource://testing-common/Sinon.sys.mjs",
});
// Everything here is Windows.
Assert.equal(AppConstants.platform, "win", "Platform is Windows");
// Protocol defaults carry OPEN_WITH_PROTOCOL_MESSAGING on top of the
// set-handler flag, unlike the file-type flags in browser_setDefaultPDFHandler.
const SET_PROTOCOL_WIN11 =
Ci.nsIWindowsShellService.OPEN_WITH_SET_HANDLER |
Ci.nsIWindowsShellService.OPEN_WITH_PROTOCOL_MESSAGING;
const SET_PROTOCOL_WIN10 =
Ci.nsIWindowsShellService.OPEN_WITH_SET_HANDLER_WIN10 |
Ci.nsIWindowsShellService.OPEN_WITH_PROTOCOL_MESSAGING;
const launchSetDefaultAppPickerStub = sinon.stub();
const launchModernSettingsDialogDefaultAppsStub = sinon.stub();
const shellStub = sinon.stub(ShellService, "shellService").value({
launchSetDefaultAppPicker: launchSetDefaultAppPickerStub,
launchModernSettingsDialogDefaultApps:
launchModernSettingsDialogDefaultAppsStub,
QueryInterface: ChromeUtils.generateQI([]),
});
registerCleanupFunction(() => {
shellStub.restore();
Services.prefs.clearUserPref(SET_DEFAULT_REDIRECT_PREF);
});
function resetState() {
launchSetDefaultAppPickerStub.reset();
launchModernSettingsDialogDefaultAppsStub.reset();
Services.prefs.clearUserPref(SET_DEFAULT_REDIRECT_PREF);
}
function readPrefObject() {
return JSON.parse(
Services.prefs.getStringPref(SET_DEFAULT_REDIRECT_PREF, "")
);
}
add_task(async function test_default_https_uses_url_and_win11_flag() {
resetState();
const sandbox = sinon.createSandbox();
sandbox.stub(ShellService, "_isWindows11").returns(true);
try {
await ShellService.setAsDefaultProtocolHandler("https");
Assert.ok(
launchSetDefaultAppPickerStub.calledOnce,
"Picker invoked once for the https default"
);
Assert.deepEqual(
launchSetDefaultAppPickerStub.firstCall.args,
[DEFAULT_PROTOCOL_URLS.https, SET_PROTOCOL_WIN11],
"Picker called with https URL and SET_HANDLER | PROTOCOL_MESSAGING"
);
Assert.ok(
launchModernSettingsDialogDefaultAppsStub.notCalled,
"Modern settings not invoked when launcher succeeds"
);
Assert.deepEqual(
readPrefObject(),
{
openWithArg: DEFAULT_PROTOCOL_URLS.https,
overrideUri: null,
type: WindowsSetDefaultRedirect.TYPE.PROTOCOL,
},
"Pref records the URL; overrideUri null so the round-trip is suppressed"
);
} finally {
sandbox.restore();
}
});
add_task(async function test_custom_url_overrides_protocol_default() {
resetState();
const sandbox = sinon.createSandbox();
sandbox.stub(ShellService, "_isWindows11").returns(true);
try {
const customUrl = "https://custom.example.com/handler-check";
await ShellService.setAsDefaultProtocolHandler("https", customUrl, true);
Assert.deepEqual(
launchSetDefaultAppPickerStub.firstCall.args,
[customUrl, SET_PROTOCOL_WIN11],
"Picker called with caller-provided URL, not the protocol default"
);
Assert.deepEqual(
readPrefObject(),
{
openWithArg: customUrl,
overrideUri: DEFAULT_PROTOCOL_URLS.https,
type: WindowsSetDefaultRedirect.TYPE.PROTOCOL,
},
"Pref stashes the custom URL + protocol-default overrideUri for the round-trip"
);
} finally {
sandbox.restore();
}
});
add_task(async function test_win10_uses_set_handler_win10_flag() {
resetState();
const sandbox = sinon.createSandbox();
sandbox.stub(ShellService, "_isWindows11").returns(false);
try {
await ShellService.setAsDefaultProtocolHandler("https");
Assert.deepEqual(
launchSetDefaultAppPickerStub.firstCall.args,
[DEFAULT_PROTOCOL_URLS.https, SET_PROTOCOL_WIN10],
"Picker called with SET_HANDLER_WIN10 | PROTOCOL_MESSAGING when not on Win11"
);
} finally {
sandbox.restore();
}
});
add_task(async function test_falls_back_to_settings_when_picker_throws() {
resetState();
const sandbox = sinon.createSandbox();
sandbox.stub(ShellService, "_isWindows11").returns(true);
launchSetDefaultAppPickerStub.throws(new Error("mock launcher failure"));
try {
await ShellService.setAsDefaultProtocolHandler("https", undefined, true);
Assert.ok(launchSetDefaultAppPickerStub.called, "Tried picker first");
Assert.ok(
launchModernSettingsDialogDefaultAppsStub.called,
"Fell through to modern settings when launcher threw"
);
Assert.ok(
!Services.prefs.prefHasUserValue(SET_DEFAULT_REDIRECT_PREF),
"Pending redirect cleared on launcher failure so a later round-trip can't pick up stale intent"
);
} finally {
sandbox.restore();
}
});
add_task(async function test_unknown_protocol_with_no_url_throws() {
resetState();
await Assert.rejects(
ShellService.setAsDefaultProtocolHandler("notaprotocol"),
/No URL provided and no DEFAULT_PROTOCOL_URLS fallback/,
"Throws when neither URL nor protocol fallback resolves to a URL"
);
Assert.ok(
launchSetDefaultAppPickerStub.notCalled,
"Picker not invoked when args don't resolve to a URL"
);
Assert.ok(
!Services.prefs.prefHasUserValue(SET_DEFAULT_REDIRECT_PREF),
"Pref not touched when validation fails before any side effects"
);
});
// Wait for the deferred set_default_protocol_handler_attempt event to be
// recorded, then return the single event emitted by the most recent call.
async function awaitAttemptEvent() {
await TestUtils.waitForCondition(() => {
const events =
Glean.browser.setDefaultProtocolHandlerAttempt.testGetValue();
return events && events.length;
}, "Recorded set_default_protocol_handler_attempt event");
const events = Glean.browser.setDefaultProtocolHandlerAttempt.testGetValue();
Assert.equal(events.length, 1, "Recorded exactly one attempt event");
return events[0];
}
add_task(async function test_telemetry_records_open_with_success() {
resetState();
const sandbox = sinon.createSandbox();
sandbox.stub(ShellService, "_isWindows11").returns(true);
const isDefaultHandlerForStub = sandbox
.stub(ShellService, "isDefaultHandlerFor")
.returns(true);
await SpecialPowers.pushPrefEnv({
set: [["browser.shell.setDefaultProtocolHandler.attemptWaitTimeMs", 0]],
});
try {
Services.fog.testResetFOG();
await ShellService.setAsDefaultProtocolHandler("https");
const event = await awaitAttemptEvent();
Assert.equal(event.extra.method, "open_with", "Event method is open_with");
Assert.equal(event.extra.success, "true", "Event success is true");
Assert.equal(event.extra.protocol, "https", "Event protocol is https");
Assert.equal(
event.extra.result_is_default,
"true",
"Event result_is_default reflects isDefaultHandlerFor"
);
Assert.ok(
isDefaultHandlerForStub.calledWith("https"),
"Sampled isDefaultHandlerFor with the protocol"
);
} finally {
sandbox.restore();
await SpecialPowers.popPrefEnv();
}
});
add_task(async function test_telemetry_records_settings_fallback() {
resetState();
const sandbox = sinon.createSandbox();
sandbox.stub(ShellService, "_isWindows11").returns(true);
sandbox.stub(ShellService, "isDefaultHandlerFor").returns(true);
launchSetDefaultAppPickerStub.throws(new Error("mock launcher failure"));
await SpecialPowers.pushPrefEnv({
set: [["browser.shell.setDefaultProtocolHandler.attemptWaitTimeMs", 0]],
});
try {
Services.fog.testResetFOG();
await ShellService.setAsDefaultProtocolHandler("mailto");
Assert.equal(
Glean.browser.setDefaultProtocolHandlerModernSettingsResult.Success.testGetValue(),
1,
"Recorded modern settings success"
);
const event = await awaitAttemptEvent();
Assert.equal(
event.extra.method,
"settings",
"Event method is settings (last attempted)"
);
Assert.equal(
event.extra.success,
"true",
"Event success reflects modern settings launch"
);
Assert.equal(event.extra.protocol, "mailto", "Event protocol is mailto");
} finally {
sandbox.restore();
await SpecialPowers.popPrefEnv();
}
});
add_task(async function test_telemetry_records_complete_failure() {
resetState();
const sandbox = sinon.createSandbox();
sandbox.stub(ShellService, "_isWindows11").returns(true);
sandbox.stub(ShellService, "isDefaultHandlerFor").returns(false);
launchSetDefaultAppPickerStub.throws(new Error("mock launcher failure"));
launchModernSettingsDialogDefaultAppsStub.throws(
new Error("mock modern settings failure")
);
await SpecialPowers.pushPrefEnv({
set: [["browser.shell.setDefaultProtocolHandler.attemptWaitTimeMs", 0]],
});
try {
Services.fog.testResetFOG();
await ShellService.setAsDefaultProtocolHandler("https");
Assert.equal(
Glean.browser.setDefaultProtocolHandlerModernSettingsResult.Failure.testGetValue(),
1,
"Recorded modern settings failure"
);
const event = await awaitAttemptEvent();
Assert.equal(event.extra.method, "settings", "Event method is settings");
Assert.equal(
event.extra.success,
"false",
"Event success is false when every method failed"
);
Assert.equal(
event.extra.result_is_default,
"false",
"Event result_is_default is false when no method set the default"
);
} finally {
sandbox.restore();
await SpecialPowers.popPrefEnv();
}
});
add_task(async function test_overwrites_existing_pending_redirect() {
resetState();
const sandbox = sinon.createSandbox();
sandbox.stub(ShellService, "_isWindows11").returns(true);
try {
// A differently-typed leftover value on the pref must not trip the
// setStringPref in WindowsSetDefaultRedirect.arm (it clears first).
Services.prefs.setBoolPref(SET_DEFAULT_REDIRECT_PREF, true);
await ShellService.setAsDefaultProtocolHandler("https", undefined, false);
Assert.deepEqual(
readPrefObject(),
{
openWithArg: DEFAULT_PROTOCOL_URLS.https,
overrideUri: null,
type: WindowsSetDefaultRedirect.TYPE.PROTOCOL,
},
"Stale value replaced with the structured redirect on the next call"
);
} finally {
sandbox.restore();
}
});

View File

@@ -3,7 +3,7 @@
/** /**
* Check whether the preview image for setDesktopBackground is rendered * Check whether the preview image for setDesktopBackground is rendered
* correctly, without stretching, for both <img> and <canvas> targets. * correctly, without stretching
*/ */
add_setup(async function () { add_setup(async function () {
@@ -12,85 +12,82 @@ add_setup(async function () {
}); });
}); });
async function testPreview(url, targetSelector) { add_task(async function () {
await BrowserTestUtils.withNewTab({ gBrowser, url }, async browser => { await BrowserTestUtils.withNewTab(
const dialogLoad = BrowserTestUtils.domWindowOpened(null, async win => { {
await BrowserTestUtils.waitForEvent(win, "load"); gBrowser,
Assert.equal( url: getRootDirectory(gTestPath) + "large.png",
win.document.documentElement.getAttribute("windowtype"), },
"Shell:SetDesktopBackground", async () => {
"Opened correct window" const dialogLoad = BrowserTestUtils.domWindowOpened(null, async win => {
); await BrowserTestUtils.waitForEvent(win, "load");
return true; Assert.equal(
}); win.document.documentElement.getAttribute("windowtype"),
"Shell:SetDesktopBackground",
"Opened correct window"
);
return true;
});
await BrowserTestUtils.synthesizeMouseAtCenter( const image = content.document.images[0];
targetSelector, EventUtils.synthesizeMouseAtCenter(image, { type: "contextmenu" });
{ type: "contextmenu" },
browser
);
const menu = document.getElementById("contentAreaContextMenu"); const menu = document.getElementById("contentAreaContextMenu");
await BrowserTestUtils.waitForPopupEvent(menu, "shown"); await BrowserTestUtils.waitForPopupEvent(menu, "shown");
const menuClosed = BrowserTestUtils.waitForPopupEvent(menu, "hidden"); const menuClosed = BrowserTestUtils.waitForPopupEvent(menu, "hidden");
const menuItem = document.getElementById("context-setDesktopBackground"); const menuItem = document.getElementById("context-setDesktopBackground");
try { try {
menu.activateItem(menuItem); menu.activateItem(menuItem);
} catch (ex) { } catch (ex) {
ok( ok(
menuItem.hidden, menuItem.hidden,
"should only fail to activate when menu item is hidden" "should only fail to activate when menu item is hidden"
); );
ok( ok(
!ShellService.canSetDesktopBackground, !ShellService.canSetDesktopBackground,
"Should only hide when not able to set the desktop background" "Should only hide when not able to set the desktop background"
); );
is( is(
AppConstants.platform, AppConstants.platform,
"linux", "linux",
"Should always be able to set desktop background on non-linux platforms" "Should always be able to set desktop background on non-linux platforms"
); );
todo(false, "Skipping test on this configuration"); todo(false, "Skipping test on this configuration");
menu.hidePopup();
await menuClosed;
return;
}
menu.hidePopup();
await menuClosed; await menuClosed;
return;
}
await menuClosed; const win = await dialogLoad;
const win = await dialogLoad; /* setDesktopBackground.js does a setTimeout to wait for correct
/* setDesktopBackground.js does a setTimeout to wait for correct
dimensions. If we don't wait here we could read the preview dimensions dimensions. If we don't wait here we could read the preview dimensions
before they're changed to match the screen */ before they're changed to match the screen */
await TestUtils.waitForTick(); await TestUtils.waitForTick();
const canvas = win.document.getElementById("screen"); const canvas = win.document.getElementById("screen");
const screenRatio = screen.width / screen.height; const screenRatio = screen.width / screen.height;
const previewRatio = canvas.clientWidth / canvas.clientHeight; const previewRatio = canvas.clientWidth / canvas.clientHeight;
info(`Screen dimensions are ${screen.width}x${screen.height}`); info(`Screen dimensions are ${screen.width}x${screen.height}`);
info(`Screen's raw ratio is ${screenRatio}`); info(`Screen's raw ratio is ${screenRatio}`);
info(`Preview dimensions are ${canvas.clientWidth}x${canvas.clientHeight}`); info(
info(`Preview's raw ratio is ${previewRatio}`); `Preview dimensions are ${canvas.clientWidth}x${canvas.clientHeight}`
);
info(`Preview's raw ratio is ${previewRatio}`);
Assert.ok( Assert.ok(
previewRatio < screenRatio + 0.01 && previewRatio > screenRatio - 0.01, previewRatio < screenRatio + 0.01 && previewRatio > screenRatio - 0.01,
"Preview's aspect ratio is within ±.01 of screen's" "Preview's aspect ratio is within ±.01 of screen's"
); );
win.close(); win.close();
await menuClosed; await menuClosed;
}); }
} );
add_task(async function test_image() {
await testPreview(getRootDirectory(gTestPath) + "large.png", "img");
});
add_task(async function test_canvas() {
await testPreview(getRootDirectory(gTestPath) + "canvas.html", "canvas");
}); });

View File

@@ -1,293 +0,0 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
const { CommandLineHandler } = ChromeUtils.importESModule(
"moz-src:///browser/components/shell/WindowsSetDefaultAppCmdHandler.sys.mjs"
);
ChromeUtils.defineESModuleGetters(this, {
BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs",
SET_DEFAULT_REDIRECT_PREF:
"moz-src:///browser/components/shell/WindowsSetDefaultRedirect.sys.mjs",
ShellService: "moz-src:///browser/components/shell/ShellService.sys.mjs",
WindowsSetDefaultRedirect:
"moz-src:///browser/components/shell/WindowsSetDefaultRedirect.sys.mjs",
sinon: "resource://testing-common/Sinon.sys.mjs",
});
Assert.equal(AppConstants.platform, "win", "Platform is Windows");
const confusedFoxPath = ShellService.getBundledPdfFile("confused_fox.pdf").path;
const blankURISpec = Services.io.newFileURI(
ShellService.getBundledPdfFile("blank.pdf")
).spec;
const workingDir = Services.dirsvc.get("GreD", Ci.nsIFile);
// Build a real nsICommandLine from an args array, so we exercise the same
// flag-parsing the OS-initiated launch goes through
function makeCmdLine(args, state = Ci.nsICommandLine.STATE_INITIAL_LAUNCH) {
return Cu.createCommandLine(args, workingDir, state);
}
// Arm the one-shot redirect via the real ShellService helper, the way
// setAsDefault{*}Handler does right before launching the OS picker. These
// tests all use file openWithArgs, so default the type.
function armRedirect(
openWithArg,
overrideUri,
type = WindowsSetDefaultRedirect.TYPE.FILE
) {
WindowsSetDefaultRedirect.arm(openWithArg, overrideUri, type);
}
let fakeWin;
let getTopWindowStub;
let openWindowStub;
add_setup(function () {
fakeWin = { openTrustedLinkIn: sinon.stub() };
// Stubbed rather than spied: getTopWindow must return our fake window (and
// null on demand), and openWindow must not actually open a window in the
// test harness. We only inspect the calls.
getTopWindowStub = sinon.stub(BrowserWindowTracker, "getTopWindow");
openWindowStub = sinon.stub(BrowserWindowTracker, "openWindow");
});
registerCleanupFunction(() => {
sinon.restore();
Services.prefs.clearUserPref(SET_DEFAULT_REDIRECT_PREF);
});
function resetState() {
fakeWin.openTrustedLinkIn.resetHistory();
getTopWindowStub.reset();
getTopWindowStub.returns(fakeWin);
openWindowStub.reset();
Services.prefs.clearUserPref(SET_DEFAULT_REDIRECT_PREF);
}
add_task(async function test_no_osint_returns_early() {
resetState();
armRedirect(confusedFoxPath, blankURISpec);
const cmdLine = makeCmdLine(["-url", confusedFoxPath]);
new CommandLineHandler().handle(cmdLine);
Assert.equal(
cmdLine.preventDefault,
false,
"preventDefault left untouched without -osint"
);
Assert.greaterOrEqual(
cmdLine.findFlag("url", false),
0,
"-url remains for the next handler"
);
Assert.ok(
fakeWin.openTrustedLinkIn.notCalled,
"No tab opened without -osint"
);
Assert.ok(openWindowStub.notCalled, "No window opened without -osint");
});
add_task(async function test_osint_without_url_returns_early() {
resetState();
armRedirect(confusedFoxPath, blankURISpec);
const cmdLine = makeCmdLine(["-osint"]);
new CommandLineHandler().handle(cmdLine);
Assert.equal(
cmdLine.preventDefault,
false,
"preventDefault left untouched without -url"
);
Assert.ok(fakeWin.openTrustedLinkIn.notCalled, "No tab opened without -url");
Assert.ok(openWindowStub.notCalled, "No window opened without -url");
});
add_task(async function test_no_pending_redirect_leaves_arg() {
// No armed redirect: even a -url that looks like our stub PDF is a real,
// user-initiated open (e.g. they double-clicked the file), so we must leave
// it for BrowserContentHandler.
resetState();
const cmdLine = makeCmdLine(["-osint", "-url", confusedFoxPath]);
new CommandLineHandler().handle(cmdLine);
Assert.equal(
cmdLine.preventDefault,
false,
"preventDefault not set when no redirect is pending"
);
Assert.greaterOrEqual(
cmdLine.findFlag("url", false),
0,
"-url preserved for BrowserContentHandler"
);
Assert.ok(
fakeWin.openTrustedLinkIn.notCalled,
"No tab opened when no redirect is pending"
);
Assert.ok(
openWindowStub.notCalled,
"No window opened when no redirect is pending"
);
});
add_task(async function test_unrelated_url_arg_is_ignored() {
resetState();
armRedirect(confusedFoxPath, blankURISpec);
const cmdLine = makeCmdLine([
"-osint",
"-url",
"https://example.com/some-page",
]);
new CommandLineHandler().handle(cmdLine);
Assert.equal(
cmdLine.preventDefault,
false,
"preventDefault not set for a -url that isn't the pending openWithArg"
);
Assert.greaterOrEqual(
cmdLine.findFlag("url", false),
0,
"-url preserved for subsequent handler"
);
Assert.ok(
fakeWin.openTrustedLinkIn.notCalled,
"No tab opened for an unrelated -url"
);
Assert.ok(
Services.prefs.prefHasUserValue(SET_DEFAULT_REDIRECT_PREF),
"Pending redirect untouched when the openWithArg doesn't match"
);
});
add_task(async function test_suppress_only_when_target_null() {
resetState();
armRedirect(confusedFoxPath, null);
const cmdLine = makeCmdLine(["-osint", "-url", confusedFoxPath]);
new CommandLineHandler().handle(cmdLine);
Assert.equal(
cmdLine.preventDefault,
true,
"openWithArg suppressed so BrowserContentHandler skips it"
);
Assert.equal(
cmdLine.findFlag("url", false),
-1,
"-url consumed even with no redirect target"
);
Assert.ok(
fakeWin.openTrustedLinkIn.notCalled,
"No redirect when target is null"
);
Assert.ok(openWindowStub.notCalled, "No fallback window when target is null");
Assert.ok(
!Services.prefs.prefHasUserValue(SET_DEFAULT_REDIRECT_PREF),
"Redirect intent consumed"
);
});
add_task(async function test_redirects_to_top_window() {
resetState();
armRedirect(confusedFoxPath, blankURISpec);
const cmdLine = makeCmdLine(["-osint", "-url", confusedFoxPath]);
new CommandLineHandler().handle(cmdLine);
Assert.equal(
cmdLine.preventDefault,
true,
"Default open suppressed for the pending openWithArg"
);
Assert.equal(cmdLine.findFlag("url", false), -1, "-url consumed");
Assert.ok(
fakeWin.openTrustedLinkIn.calledOnce,
"Redirected into the top window"
);
Assert.deepEqual(fakeWin.openTrustedLinkIn.firstCall.args, [
blankURISpec,
"tab",
]);
Assert.ok(openWindowStub.notCalled, "No new window when one exists");
Assert.ok(
!Services.prefs.prefHasUserValue(SET_DEFAULT_REDIRECT_PREF),
"Redirect intent is one-shot and cleared after use"
);
});
add_task(async function test_opens_new_window_when_no_top() {
resetState();
getTopWindowStub.returns(null);
armRedirect(confusedFoxPath, blankURISpec);
const cmdLine = makeCmdLine(["-osint", "-url", confusedFoxPath]);
new CommandLineHandler().handle(cmdLine);
Assert.equal(
cmdLine.preventDefault,
true,
"Default open suppressed even when no top window exists"
);
Assert.ok(
fakeWin.openTrustedLinkIn.notCalled,
"Top-window path skipped when getTopWindow returns null"
);
Assert.ok(openWindowStub.calledOnce, "Falls back to openWindow");
const opts = openWindowStub.firstCall.args[0];
Assert.ok(
opts && opts.args,
"openWindow called with a {args} options object"
);
Assert.ok(
opts.args instanceof Ci.nsISupportsString,
"args is an nsISupportsString"
);
Assert.equal(
opts.args.data,
blankURISpec,
"nsISupportsString carries the redirect URI"
);
Assert.ok(
!Services.prefs.prefHasUserValue(SET_DEFAULT_REDIRECT_PREF),
"Redirect intent is one-shot and cleared after use"
);
});
add_task(async function test_intent_is_one_shot() {
resetState();
armRedirect(confusedFoxPath, blankURISpec);
new CommandLineHandler().handle(
makeCmdLine(["-osint", "-url", confusedFoxPath])
);
Assert.ok(
fakeWin.openTrustedLinkIn.calledOnce,
"First call honors the pending redirect"
);
fakeWin.openTrustedLinkIn.resetHistory();
const second = makeCmdLine(["-osint", "-url", confusedFoxPath]);
new CommandLineHandler().handle(second);
Assert.equal(
second.preventDefault,
false,
"Second call leaves the openWithArg alone because the intent was consumed"
);
Assert.greaterOrEqual(
second.findFlag("url", false),
0,
"-url preserved on the second call (no pending redirect)"
);
Assert.ok(
fakeWin.openTrustedLinkIn.notCalled,
"Second call does not redirect"
);
});

View File

@@ -1,11 +0,0 @@
<!DOCTYPE html>
<html>
<body>
<canvas id="c" width="200" height="100"></canvas>
<script>
const ctx = document.getElementById("c").getContext("2d");
ctx.fillStyle = "red";
ctx.fillRect(0, 0, 200, 100);
</script>
</body>
</html>

View File

@@ -1,657 +0,0 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
ChromeUtils.defineESModuleGetters(this, {
ShellService: "moz-src:///browser/components/shell/ShellService.sys.mjs",
Subprocess: "resource://gre/modules/Subprocess.sys.mjs",
TestUtils: "resource://testing-common/TestUtils.sys.mjs",
XPCOMUtils: "resource://gre/modules/XPCOMUtils.sys.mjs",
});
// This tests the browser's interaction with the Dynamic Launcher portal, which
// is documented at
//
// https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.DynamicLauncher
XPCOMUtils.defineLazyServiceGetter(
this,
"IniParserFactory",
"@mozilla.org/xpcom/ini-parser-factory;1",
Ci.nsIINIParserFactory
);
function desktopEntryFromObject(object) {
let ini = IniParserFactory.createINIParser().QueryInterface(
Ci.nsIINIParserWriter
);
for (const property of Object.getOwnPropertyNames(object)) {
ini.setString("Desktop Entry", property, "" + object[property]);
}
return ini;
}
function objectFromDesktopEntry(aINI) {
let ini = IniParserFactory.createINIParser();
ini.initFromString(aINI);
let sections = ini.getSections();
ok(sections.hasMore(), "There is at least one section");
Assert.equal(sections.getNext(), "Desktop Entry", "First key is correct");
Assert.equal(sections.hasMore(), false, "...and there aren't others");
let keys = ini.getKeys("Desktop Entry");
let result = Object.create(null);
while (keys.hasMore()) {
let key = keys.getNext();
result[key] = ini.getString("Desktop Entry", key);
}
return result;
}
const portalBusName = "org.freedesktop.portal.Desktop";
const portalObjectPath = "/org/freedesktop/portal/desktop";
const portalInterfaceName = "org.freedesktop.portal.DynamicLauncher";
const dbusMockInterface = "org.freedesktop.DBus.Mock";
const addObjectMethod = `${dbusMockInterface}.AddObject`;
const addMethodMethod = `${dbusMockInterface}.AddMethod`;
const emitSignalDetailedMethod = `${dbusMockInterface}.EmitSignalDetailed`;
const getCallsMethod = `${dbusMockInterface}.GetCalls`;
const clearCallsMethod = `${dbusMockInterface}.ClearCalls`;
const resetMethod = `${dbusMockInterface}.Reset`;
const mockRequestObjectPath = "/org/freedesktop/portal/desktop/request";
var DBUS_SESSION_BUS_ADDRESS = "";
var DBUS_SESSION_BUS_PID = 0; // eslint-disable-line no-unused-vars
var DBUS_MOCK = null;
var gdbusCmd = null;
async function callDbusMethod(objectPath, methodName, args) {
let mockProcess = await Subprocess.call({
command: gdbusCmd,
arguments: [
"call",
"--session",
"-d",
portalBusName,
"-o",
objectPath,
"-m",
methodName,
...args,
],
});
return mockProcess.wait();
}
// Launches a D-Bus daemon that the tests will run under, and sets it (via
// environment variables) to be used as the D-Bus connection. Additionally, sets
// up dbusmock to allow the test to create fake D-Bus objects; this avoids
// needing to display UI to the user, or depending on the details of the
// specific portal they have installed.
add_setup(async function setup() {
// Start and use a separate message bus for the tests, to not interfere with
// the current's session message bus.
let dbus = await Subprocess.call({
command: await Subprocess.pathSearch("dbus-launch"),
});
await dbus.wait();
let stdout = await dbus.stdout.readString();
let lines = stdout.split("\n");
for (let i in lines) {
let tokens = lines[i].split("=");
switch (tokens.shift()) {
case "DBUS_SESSION_BUS_ADDRESS":
DBUS_SESSION_BUS_ADDRESS = tokens.join("=");
break;
case "DBUS_SESSION_BUS_PID":
DBUS_SESSION_BUS_PID = tokens.join();
break;
default:
}
}
gdbusCmd = await Subprocess.pathSearch("gdbus");
Services.env.set("DBUS_SESSION_BUS_ADDRESS", DBUS_SESSION_BUS_ADDRESS);
Services.env.set("GTK_USE_PORTAL", "1");
// dbusmock is used to mock the native messaging portal's D-Bus API.
DBUS_MOCK = await Subprocess.call({
command: await Subprocess.pathSearch("python3"),
arguments: [
"-m",
"dbusmock",
portalBusName,
portalObjectPath,
portalInterfaceName,
// vvvvvvvvvvvvv why is this needed?!
"-l",
"/dev/null",
],
});
// Wait until dbusmock is ready
await TestUtils.waitForCondition(async () => {
let res = await callDbusMethod(
portalObjectPath,
"org.freedesktop.DBus.Mock.GetCalls",
[]
);
return res.exitCode == 0;
}, "waiting for dbusmock");
registerCleanupFunction(async function () {
await DBUS_MOCK.kill();
// XXX: While this works locally, it consistently fails when tests are run
// in CI, with "xpcshell return code: -15". This needs to be investigated
// further. This leaves a stray dbus-daemon process behind,
// which isn't ideal, but is harmless.
/*await lazy.Subprocess.call({
command: await lazy.Subprocess.pathSearch("kill"),
arguments: ["-SIGQUIT", DBUS_SESSION_BUS_PID],
});*/
});
});
/**
* Determines who called the corresponding D-Bus method. dbusmock doesn't
* provide this information, but we need it to create a portal Request path, so
* this uses a one-off dbus-monitor instead.
*
* This function has two phases, determined by the promises in its return value:
*
* - monitorReady will resolve when dbus-monitor appears to be awake. At this
* point, the method call should happen.
*
* - senderPromise will resolve when the call is detected. This will resolve
* to the sender of the method call.
*
* @param {string} method
* The method call to look for. (Currently you can't key based on the object
* path.)
* @returns {{monitorReady:Promise<undefined>,senderPromise:Promise<string>}}
* A set of promises corresponding to the current 'phase' of the check.
*/
function sniffDbusMethodCaller(method) {
let readyResolvers = Promise.withResolvers();
let doneResolvers = Promise.withResolvers();
// Use .then here so this can be a synchronous function, so you don't
// additionally have to worry about the promise returned.
Subprocess.pathSearch("dbus-monitor")
.then(command =>
Subprocess.call({
command,
arguments: ["--session"],
})
)
.then(async process => {
while (true) {
for (const line of (await process.stdout.readString()).split("\n")) {
readyResolvers.resolve();
// Extract the sender name from the dbus-monitor process's output.
if (
line.startsWith("method call") &&
line.includes(`member=${method}`)
) {
let match = line.match(/sender=(\S*)/);
ok(match, "dbus-monitor output informs us of the sender");
doneResolvers.resolve(match[1]);
await process.kill();
return;
}
}
}
})
.finally(err => {
readyResolvers.reject(err);
doneResolvers.reject(err);
});
return {
monitorReady: readyResolvers.promise,
senderPromise: doneResolvers.promise,
};
}
/**
* Waits for a D-Bus method call with the given object path, method, and
* position in time (in case multiple calls happened).
*
* @param {string} objectPath
* The object path the call should be for. This must be a dbusmock-based mock
* object.
* @param {string} method
* The method to get information for.
* @param {number} offset
* Indicates that information should be returned for the nth (zero-based)
* call.
* @returns {{offset:number,params:string}}
* The offset after this call as a number (zero if it wasn't called), and the
* string representation of the arguments; e.g. "'abc', 'def'".
*/
async function expectDbusMockCall(objectPath, method, offset) {
let getCalls = await Subprocess.call({
command: gdbusCmd,
arguments: [
"call",
"--session",
"-d",
portalBusName,
"-o",
objectPath,
"-m",
getCallsMethod,
],
});
let out = "";
while (!out.endsWith("\n")) {
out += await getCalls.stdout.readString();
}
// The other regexes are fragile, so remove the byte arrays that break them.
out = out.replaceAll(/<\('bytes', <\[byte 0x[0-9a-fx, ]+\]>\)>/g, "<bytes>");
out = out.match(/\((@a\(tsav\) )?\[(.*)\],\)/)[2];
let calls = out.matchAll(/\(.*?\),?/g);
let methodCalled = false;
let params = {};
let i = 0;
for (let call of calls) {
if (i++ < offset) {
continue;
}
let matches = call[0].match(
/\((uint64 )?(?<timestamp>\d+), '(?<method>\w+)', (@av )?\[(?<params>.*)\]\),?/
);
ok(parseFloat(matches.groups.timestamp), "timestamp is valid");
if (matches.groups.method == method) {
methodCalled = true;
params = matches.groups.params;
break;
}
}
if (method) {
ok(methodCalled, `The ${method} mock was called`);
} else {
equal(i, 0, "No method mock was called");
}
await getCalls.wait();
return { offset: i, params };
}
add_task(async function test_successful_creation() {
await callDbusMethod(portalObjectPath, resetMethod, []);
await callDbusMethod(portalObjectPath, clearCallsMethod, []);
await callDbusMethod(portalObjectPath, addMethodMethod, [
portalInterfaceName,
"PrepareInstall",
"ssva{sv}",
"o",
`ret = "/should/not/be/used"`,
]);
let object = {
Version: "1.5",
Type: "Application",
Name: "Example Launcher Name",
Icon:
Services.dirsvc.get("CurWorkD", Ci.nsIFile).path +
"/favicon-normal16.png",
Exec: "browser command",
};
let ini = desktopEntryFromObject(object);
let { monitorReady, senderPromise } = sniffDbusMethodCaller("PrepareInstall");
await monitorReady;
let promise = ShellService.requestInstallDynamicLauncher("a.b.c", ini, null);
let senderName = await senderPromise;
let result = await expectDbusMockCall(portalObjectPath, "PrepareInstall", 0);
let [parentWindow, name, icon] = result.params.split(", ");
Assert.equal(parentWindow, "<''>", "No parent window was provided");
Assert.equal(name, "<'Example Launcher Name'>", "Launcher name was provided");
Assert.equal(icon, "<bytes>", "Icon was some sequence of bytes");
let match = result.params.match(/'handle_token': <'(?<token>.*)'>/);
ok(match, "Start arguments contain a handle token");
let handleToken = match.groups.token;
await callDbusMethod(portalObjectPath, addMethodMethod, [
portalInterfaceName,
"Install",
"sssa{sv}",
"",
"",
]);
// Mock the Request object that is expected to be created in response to
// calling the Start method on the native messaging portal, wait for it to be
// available, and emit its Response signal.
let requestPath = `${mockRequestObjectPath}/${senderName
.slice(1)
.replace(".", "_")}/${handleToken}`;
await callDbusMethod(portalObjectPath, addObjectMethod, [
requestPath,
"org.freedesktop.portal.Request",
"@a{sv} {}",
"@a(ssss) []",
]);
await callDbusMethod(requestPath, emitSignalDetailedMethod, [
"org.freedesktop.portal.Request",
"Response",
"ua{sv}",
`[<uint32 0>, <@a{sv} {'token': <'qwerty'>}>]`,
`{'destination': <'${senderName}'>}`,
]);
// Verify that the Install method was called as expected after the Start
// request completed.
result = await expectDbusMockCall(portalObjectPath, "Install", result.offset);
let [token, desktopFileId, desktopEntry] = result.params.split(", ");
Assert.equal(token, "<'qwerty'>", "Correct token is provided");
Assert.equal(
desktopFileId,
"<'a.b.c.desktop'>",
"Correct desktop file ID is provided"
);
desktopEntry = desktopEntry
.replace(/^<'/, "")
.replace(/'>$/, "")
.replaceAll(/\\n/g, "\n");
let entry = objectFromDesktopEntry(desktopEntry);
Assert.deepEqual(entry, object, "Desktop entry contains expected values");
// Check for exceptions etc.
await promise;
});
add_task(async function test_prepareinstall_error() {
await callDbusMethod(portalObjectPath, resetMethod, []);
await callDbusMethod(portalObjectPath, clearCallsMethod, []);
await callDbusMethod(portalObjectPath, addMethodMethod, [
portalInterfaceName,
"PrepareInstall",
"ssva{sv}",
"o",
`raise dbus.exceptions.DBusException("prepareinstall failed", name="org.mozilla.Error.Mocked")`,
]);
let object = {
Version: "1.5",
Type: "Application",
Name: "Example Launcher Name",
Icon:
Services.dirsvc.get("CurWorkD", Ci.nsIFile).path +
"/favicon-normal16.png",
Exec: "browser command",
};
let ini = desktopEntryFromObject(object);
await Assert.rejects(
(async () =>
ShellService.requestInstallDynamicLauncher("a.b.c", ini, null))(),
/.*prepareinstall failed.*/,
"Failure was propagated from D-Bus"
);
});
add_task(async function test_install_error() {
await callDbusMethod(portalObjectPath, resetMethod, []);
await callDbusMethod(portalObjectPath, clearCallsMethod, []);
await callDbusMethod(portalObjectPath, addMethodMethod, [
portalInterfaceName,
"PrepareInstall",
"ssva{sv}",
"o",
`ret = "/should/not/be/used"`,
]);
let object = {
Version: "1.5",
Type: "Application",
Name: "Example Launcher Name",
Icon:
Services.dirsvc.get("CurWorkD", Ci.nsIFile).path +
"/favicon-normal16.png",
Exec: "browser command",
};
let ini = desktopEntryFromObject(object);
let { monitorReady, senderPromise } = sniffDbusMethodCaller("PrepareInstall");
await monitorReady;
let promise = ShellService.requestInstallDynamicLauncher("a.b.c", ini, null);
let senderName = await senderPromise;
let result = await expectDbusMockCall(portalObjectPath, "PrepareInstall", 0);
let match = result.params.match(/'handle_token': <'(?<token>.*)'>/);
ok(match, "Start arguments contain a handle token");
let handleToken = match.groups.token;
await callDbusMethod(portalObjectPath, addMethodMethod, [
portalInterfaceName,
"Install",
"sssa{sv}",
"",
`raise dbus.exceptions.DBusException("plain install failed", name="org.mozilla.Error.Mocked")`,
]);
// Mock the Request object that is expected to be created in response to
// calling the Start method on the native messaging portal, wait for it to be
// available, and emit its Response signal.
let requestPath = `${mockRequestObjectPath}/${senderName
.slice(1)
.replace(".", "_")}/${handleToken}`;
await callDbusMethod(portalObjectPath, addObjectMethod, [
requestPath,
"org.freedesktop.portal.Request",
"@a{sv} {}",
"@a(ssss) []",
]);
await callDbusMethod(requestPath, emitSignalDetailedMethod, [
"org.freedesktop.portal.Request",
"Response",
"ua{sv}",
`[<uint32 0>, <@a{sv} {'token': <'qwerty'>}>]`,
`{'destination': <'${senderName}'>}`,
]);
result = await expectDbusMockCall(portalObjectPath, "Install", result.offset);
await Assert.rejects(
promise,
/.*plain install failed.*/,
"Promise rejected with expected content."
);
});
add_task(async function test_negativeResponseFromPrepareInstall() {
await callDbusMethod(portalObjectPath, resetMethod, []);
await callDbusMethod(portalObjectPath, clearCallsMethod, []);
await callDbusMethod(portalObjectPath, addMethodMethod, [
portalInterfaceName,
"PrepareInstall",
"ssva{sv}",
"o",
`ret = "/should/not/be/used"`,
]);
let object = {
Version: "1.5",
Type: "Application",
Name: "Example Launcher Name",
Icon:
Services.dirsvc.get("CurWorkD", Ci.nsIFile).path +
"/favicon-normal16.png",
Exec: "browser command",
};
let ini = desktopEntryFromObject(object);
let { monitorReady, senderPromise } = sniffDbusMethodCaller("PrepareInstall");
await monitorReady;
let promise = ShellService.requestInstallDynamicLauncher("a.b.c", ini, null);
let senderName = await senderPromise;
let result = await expectDbusMockCall(portalObjectPath, "PrepareInstall", 0);
let match = result.params.match(/'handle_token': <'(?<token>.*)'>/);
ok(match, "Start arguments contain a handle token");
let handleToken = match.groups.token;
let requestPath = `${mockRequestObjectPath}/${senderName
.slice(1)
.replace(".", "_")}/${handleToken}`;
await callDbusMethod(portalObjectPath, addObjectMethod, [
requestPath,
"org.freedesktop.portal.Request",
"@a{sv} {}",
"@a(ssss) []",
]);
await callDbusMethod(requestPath, emitSignalDetailedMethod, [
"org.freedesktop.portal.Request",
"Response",
"ua{sv}",
`[<uint32 2>, <@a{sv} {}>]`,
`{'destination': <'${senderName}'>}`,
]);
await Assert.rejects(
promise,
/.*Response was non-zero.*/,
"Promise rejected with expected content."
);
});
add_task(async function test_missingTokenFromPrepareInstall() {
await callDbusMethod(portalObjectPath, resetMethod, []);
await callDbusMethod(portalObjectPath, clearCallsMethod, []);
await callDbusMethod(portalObjectPath, addMethodMethod, [
portalInterfaceName,
"PrepareInstall",
"ssva{sv}",
"o",
`ret = "/should/not/be/used"`,
]);
let object = {
Version: "1.5",
Type: "Application",
Name: "Example Launcher Name",
Icon:
Services.dirsvc.get("CurWorkD", Ci.nsIFile).path +
"/favicon-normal16.png",
Exec: "browser command",
};
let ini = desktopEntryFromObject(object);
let { monitorReady, senderPromise } = sniffDbusMethodCaller("PrepareInstall");
await monitorReady;
let promise = ShellService.requestInstallDynamicLauncher("a.b.c", ini, null);
let senderName = await senderPromise;
let result = await expectDbusMockCall(portalObjectPath, "PrepareInstall", 0);
let match = result.params.match(/'handle_token': <'(?<token>.*)'>/);
ok(match, "Start arguments contain a handle token");
let handleToken = match.groups.token;
let requestPath = `${mockRequestObjectPath}/${senderName
.slice(1)
.replace(".", "_")}/${handleToken}`;
await callDbusMethod(portalObjectPath, addObjectMethod, [
requestPath,
"org.freedesktop.portal.Request",
"@a{sv} {}",
"@a(ssss) []",
]);
await callDbusMethod(requestPath, emitSignalDetailedMethod, [
"org.freedesktop.portal.Request",
"Response",
"ua{sv}",
`[<uint32 0>, <@a{sv} {}>]`,
`{'destination': <'${senderName}'>}`,
]);
await Assert.rejects(
promise,
/.*No token was provided from the portal.*/,
"Promise rejected with expected content."
);
});
add_task(async function test_bad_icon() {
await callDbusMethod(portalObjectPath, resetMethod, []);
await callDbusMethod(portalObjectPath, clearCallsMethod, []);
let object = {
Version: "1.5",
Type: "Application",
Name: "Example Launcher Name",
Icon:
Services.dirsvc.get("CurWorkD", Ci.nsIFile).path + "/does-not-exist.png",
Exec: "browser command",
};
let ini = desktopEntryFromObject(object);
await Assert.rejects(
(async () =>
ShellService.requestInstallDynamicLauncher("a.b.c", ini, null))(),
/.*Failed to open .*png.*/,
"The resulting error should indicate that the file wasn't found."
);
});
add_task(async function test_uninstall_success() {
await callDbusMethod(portalObjectPath, resetMethod, []);
await callDbusMethod(portalObjectPath, clearCallsMethod, []);
await callDbusMethod(portalObjectPath, addMethodMethod, [
portalInterfaceName,
"Uninstall",
"sa{sv}",
"",
``,
]);
await ShellService.requestUninstallDynamicLauncher("a.b.c");
let result = await expectDbusMockCall(portalObjectPath, "Uninstall", 0);
Assert.equal(
result.params,
"<'a.b.c.desktop'>, <@a{sv} {}>",
"Correct desktop entry ID was provided"
);
});
add_task(async function test_uninstall_failure() {
await callDbusMethod(portalObjectPath, resetMethod, []);
await callDbusMethod(portalObjectPath, clearCallsMethod, []);
await callDbusMethod(portalObjectPath, addMethodMethod, [
portalInterfaceName,
"Uninstall",
"sa{sv}",
"",
`raise dbus.exceptions.DBusException("uninstall dbus failed", name="org.mozilla.Error.Mocked")`,
]);
await Assert.rejects(
(async () => ShellService.requestUninstallDynamicLauncher("a.b.c"))(),
/.*uninstall dbus failed.*/,
"The uninstall request should fail."
);
});

View File

@@ -0,0 +1,109 @@
/* Any copyright is dedicated to the Public Domain.
* https://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
ChromeUtils.defineESModuleGetters(this, {
StartupOSIntegration:
"moz-src:///browser/components/shell/StartupOSIntegration.sys.mjs",
WindowsLaunchOnLogin: "resource://gre/modules/WindowsLaunchOnLogin.sys.mjs",
sinon: "resource://testing-common/Sinon.sys.mjs",
});
const PREF = "browser.startup.windowsLaunchOnLogin.defaultEnabled";
async function runWith({ isFirstRun, prefValue, approved }) {
let sandbox = sinon.createSandbox();
let approvedStub = sandbox
.stub(WindowsLaunchOnLogin, "getLaunchOnLoginApproved")
.resolves(approved);
let createStub = sandbox
.stub(WindowsLaunchOnLogin, "createLaunchOnLogin")
.resolves();
if (prefValue === null) {
Services.prefs.clearUserPref(PREF);
} else {
Services.prefs.setBoolPref(PREF, prefValue);
}
try {
await StartupOSIntegration.maybeCreateLaunchOnLoginOnFirstRun(isFirstRun);
return { approvedStub, createStub };
} finally {
sandbox.restore();
Services.prefs.clearUserPref(PREF);
}
}
add_task(async function test_creates_when_all_conditions_true() {
let { createStub } = await runWith({
isFirstRun: true,
prefValue: true,
approved: true,
});
Assert.ok(
createStub.calledOnce,
"createLaunchOnLogin should be called when isFirstRun, pref, and approval are all true"
);
});
add_task(async function test_skips_when_not_first_run() {
let { createStub, approvedStub } = await runWith({
isFirstRun: false,
prefValue: true,
approved: true,
});
Assert.ok(
!createStub.called,
"createLaunchOnLogin should not be called when isFirstRun is false"
);
Assert.ok(
!approvedStub.called,
"getLaunchOnLoginApproved should be short-circuited when isFirstRun is false"
);
});
add_task(async function test_skips_when_pref_disabled() {
let { createStub, approvedStub } = await runWith({
isFirstRun: true,
prefValue: false,
approved: true,
});
Assert.ok(
!createStub.called,
"createLaunchOnLogin should not be called when pref is false"
);
Assert.ok(
!approvedStub.called,
"getLaunchOnLoginApproved should be short-circuited when pref is false"
);
});
add_task(async function test_skips_when_windows_policy_denies() {
let { createStub, approvedStub } = await runWith({
isFirstRun: true,
prefValue: true,
approved: false,
});
Assert.ok(
approvedStub.calledOnce,
"getLaunchOnLoginApproved should be consulted when pref and isFirstRun are true"
);
Assert.ok(
!createStub.called,
"createLaunchOnLogin should not be called when Windows policy denies"
);
});
add_task(async function test_uses_pref_default_when_unset() {
let { createStub } = await runWith({
isFirstRun: true,
prefValue: null,
approved: true,
});
Assert.ok(
createStub.calledOnce,
"createLaunchOnLogin should be called when pref is at its built-in default of true"
);
});

View File

@@ -10,21 +10,6 @@ run-if = [
"os == 'linux'", "os == 'linux'",
] ]
["test_dynamicLauncher.js"]
environment = [
"XPCSHELL_TEST=1",
# The following prevents gsettings from initializing dbus before our mock is
# ready.
"GSETTINGS_BACKEND=memory",
]
run-if = [
"os == 'linux'",
]
support-files = [
"../../../places/tests/browser/favicon-normal16.png",
]
tags = ["portal"]
["test_linuxDesktopEntry.js"] ["test_linuxDesktopEntry.js"]
run-if = [ run-if = [
"os == 'linux'", "os == 'linux'",
@@ -35,6 +20,11 @@ run-if = [
"os == 'mac'", "os == 'mac'",
] ]
["test_maybeCreateLaunchOnLoginOnFirstRun.js"]
run-if = [
"os == 'win'"
]
["test_secondaryTileJs.js"] ["test_secondaryTileJs.js"]
run-if = [ run-if = [
"os == 'win'" "os == 'win'"

View File

@@ -5,8 +5,8 @@
"binaryName": "zen", "binaryName": "zen",
"version": { "version": {
"product": "firefox", "product": "firefox",
"version": "153.0.1", "version": "153.0",
"candidate": "153.0.1", "candidate": "153.0",
"candidateBuild": 1 "candidateBuild": 1
}, },
"buildOptions": { "buildOptions": {
@@ -20,7 +20,7 @@
"brandShortName": "Zen", "brandShortName": "Zen",
"brandFullName": "Zen Browser", "brandFullName": "Zen Browser",
"release": { "release": {
"displayVersion": "1.21.10b", "displayVersion": "1.21.9b",
"github": { "github": {
"repo": "zen-browser/desktop" "repo": "zen-browser/desktop"
}, },

View File

@@ -346,6 +346,17 @@ fn expand_pref_values(prefs: &mut [Preference]) {
} }
pref.value = new_value.clone(); pref.value = new_value.clone();
} }
// Also change the condition if it contains placeholders
if let Some(condition) = &pref.condition {
let mut new_condition = condition.clone();
for (key, value) in &env_values {
let placeholder = format!("@{}@", key);
if new_condition.contains(&placeholder) {
new_condition = new_condition.replace(&placeholder, if *value { "1" } else { "0" });
}
pref.condition = Some(new_condition.clone());
}
}
} }
} }