Compare commits

...

20 Commits

Author SHA1 Message Date
mr. m
c06aae839f fix comments 2026-08-01 22:16:50 +02:00
mr. m
4ab322dd3b closes #14703: Allow top section of sites be draggable 2026-08-01 20:10:42 +02:00
Ashvin Jangid
ccbd934482 gh-14805: Fix blank labels in keyboard shortcuts page (gh-14807)
Co-authored-by: mr. m <91018726+mr-cheffy@users.noreply.github.com>
2026-07-31 23:37:23 +02:00
Connor Griffin
1d05184a2a gh-9918: Fixes issue setting keybinds with shift + digit or symbol (gh-14357) 2026-07-31 23:23:55 +02:00
Brandon Quon
45e47d56df gh-14770: Fix Bookmark menu styling (gh-14782)
restores the normal Firefox layout in Bookmarks > Manage Bookmarks.

Zen's field wrappers were treated as single grid cells, misaligning the
labels and inputs. it also hides the unused Space selector controls in
the Library. `editBookmark.js `disables the selector in this context but
its related elements remained visible.


https://github.com/user-attachments/assets/f241f8f5-ff91-450a-9f53-9bfd74e117f4

(the before styling in the original issue is also on macOS which I
reproduced on my own)
closes #14770
2026-07-30 13:21:33 +02:00
Mark Sharland
0145cdfcbf gh-14409: Fix compact sidebar activation at right screen edge (gh-14769) 2026-07-29 10:50:50 +02:00
mr. m
e1189b2d0d gh-14765: Fix patches failing for 153.0.1 (gh-14768) 2026-07-28 21:57:41 +02:00
mr. m
b0041db68d gh-14765: Sync upstream Firefox to RC version 153.0.1 (gh-14767) 2026-07-28 20:10:03 +02:00
Ashvin Jangid
346aa1ff9a gh-14766: prevent space routing toast for tabs routed to current space (gh-14504) 2026-07-28 20:05:10 +02:00
mr. m
95df1b3a9d gh-14765: Sync upstream Firefox to version 153.0.1 (gh-14764) 2026-07-28 19:55:41 +02:00
Tryanks
8276c613ae gh-14292: Fix remote blocklist matching against Zen version instead of Firefox version (gh-14758) 2026-07-28 19:23:05 +02:00
Brandon Quon
92addf307d gh-14057: Fix unclosable tab when dropping files into the browser area (gh-14753) 2026-07-28 10:25:26 +02:00
Brandon Quon
e474f1df3a gh-14726: Fix essential tab reordering at maximum capacity (gh-14729) 2026-07-27 14:48:18 +02:00
Brandon Quon
9fbfb8eba7 gh-14724: Fix non-tab drags switching workspaces (gh-14731) 2026-07-27 14:47:24 +02:00
mr. m
a9e185681c gh-14717: Fixed extra divider when urlbar has no results (gh-14723) 2026-07-26 11:54:14 +02:00
Brandon Quon
a37423a961 gh-14713: Fix workspace creation crash (gh-14715)
Co-authored-by: mr. m <91018726+mr-cheffy@users.noreply.github.com>
Co-authored-by: mr. m <mr.m@tuta.com>
2026-07-26 11:43:07 +02:00
mr. m
929d85720e gh-14721: Fixed accessing invalid tab filters (gh-14722) 2026-07-26 11:20:46 +02:00
Ashvin Jangid
2a4eb08c00 gh-14720: Fix space switching on dnd in customize mode (gh-14719) 2026-07-26 11:17:30 +02:00
Ashvin Jangid
768f45e265 gh-14461: Fix boost applying wrong size override (gh-14701) 2026-07-26 10:29:15 +02:00
mr. m
c2504d12c0 gh-14717: Fixed extra divider when urlbar has no results (gh-14718) 2026-07-26 10:19:55 +02:00
47 changed files with 2332 additions and 255 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
- [`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`!
- [`Release`](https://zen-browser.app/download) - Is currently built using Firefox version `153.0.1`! 🚀
- [`Twilight`](https://zen-browser.app/download?twilight) - Is currently built using Firefox version `RC 153.0.1`!
### Contributing

View File

@@ -1 +1 @@
b1d3fe8f65751e6d695fa9ef94b11ad81e70dac1
30959a45eaaf0bc499f3054793c76c6d7f0c8bc2

View File

@@ -283,7 +283,8 @@ zen-page-info-shortcut = View Page Info
zen-find-shortcut = Find on Page
zen-search-find-again-shortcut = Find Again
zen-search-find-again-shortcut-prev = Find Previous
zen-search-find-again-shortcut-2 = Find Again (Alt)
zen-search-find-again-shortcut-alt = Find Again (Alt)
zen-search-find-again-shortcut-prev-alt = Find Previous (Alt)
zen-bookmark-this-page-shortcut = Bookmark This Page
zen-bookmark-show-library-shortcut = Show Bookmarks Library
zen-key-stop = Stop Loading

View File

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

View File

@@ -46,6 +46,12 @@
- name: zen.view.context-menu.refresh
value: "@IS_TWILIGHT@"
- name: zen.view.drag-window-from-content
value: true
- name: zen.view.drag-window-from-content.height-percentage
value: 30
- name: zen.view.borderless-fullscreen
value: true

View File

@@ -803,6 +803,7 @@ const zenMissingKeyboardShortcutL10n = {
key_inspectorMac: "zen-key-inspector-mac",
key_findSelection: "zen-key-find-selection",
key_findPrevious2: "zen-search-find-again-shortcut-prev-alt",
// Devtools
key_toggleToolbox: "zen-devtools-toggle-shortcut",
@@ -826,6 +827,9 @@ var zenIgnoreKeyboardShortcutIDs = [
"key_exitFullScreen_old",
"key_exitFullScreen_compat",
"key_duplicateTab",
"key_addTabSplitView",
"key_separateTabSplitView",
"viewOpenTabsSidebarKb",
];
var zenIgnoreKeyboardShortcutL10n = [

View File

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

View File

@@ -0,0 +1,21 @@
diff --git a/browser/themes/shared/places/organizer-shared.css b/browser/themes/shared/places/organizer-shared.css
index fe6b79cfc2453927c324a044bd62040f866def95..a93813c76ba3633c365a739289b39187c0d1e881 100644
--- a/browser/themes/shared/places/organizer-shared.css
+++ b/browser/themes/shared/places/organizer-shared.css
@@ -52,6 +52,16 @@
}
}
+#places .zenEditBMPanel_fieldContainer {
+ display: contents;
+}
+
+#places label[control="editBMPanel_workspacesSelectorExpander"],
+#places #editBMPanel_workspaceDropdown,
+#places #editBMPanel_workspaceList {
+ display: none;
+}
+
#editBMPanel_itemsCountText {
grid-column: auto / span 2;
justify-self: center;

View File

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

View File

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

View File

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

View File

@@ -353,3 +353,11 @@ body,
:root[zen-right-side="true"] #zen-appcontent-navbar-wrapper #PanelUI-button {
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 */
.urlbarView[noresults] > .urlbarView-body-outer > .urlbarView-body-inner,
.urlbar[noresults] .urlbarView > .urlbarView-body-outer > .urlbarView-body-inner,
#urlbar-search-mode-indicator-close {
display: none;
}

View File

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

View File

@@ -57,6 +57,22 @@ let JSWINDOWACTORS = {
remoteTypes: ["web", "file"],
enablePreference: "zen.glance.enabled",
},
ZenWindowDrag: {
parent: {
esModuleURI: "resource:///actors/ZenWindowDragParent.sys.mjs",
},
child: {
esModuleURI: "resource:///actors/ZenWindowDragChild.sys.mjs",
events: {
mousedown: {
mozSystemGroup: true,
},
},
},
messageManagerGroups: ["browsers"],
remoteTypes: ["web", "file"],
enablePreference: "zen.view.drag-window-from-content",
},
};
if (!Services.appinfo.inSafeMode) {

View File

@@ -977,6 +977,14 @@ window.gZenCompactModeManager = {
!gZenGlanceManager._animating &&
!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 toastOptions = {
button: {
@@ -988,7 +996,7 @@ window.gZenCompactModeManager = {
},
};
if (beforeRouteResult.isRouteFound) {
if (beforeRouteResult.isRouteFound && !isTabRoutedToCurrentSpace) {
messageId = "zen-space-routing-tab-routed-toast";
toastOptions = {
l10nArgs: { targetWorkspace: beforeRouteResult.targetWorkspaceName },

View File

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

View File

@@ -15,6 +15,11 @@ SOURCES += [
"nsZenDragAndDrop.cpp",
]
if CONFIG["MOZ_WIDGET_TOOLKIT"] == "cocoa":
SOURCES += ["nsZenNativeWindowMove.mm"]
elif CONFIG["MOZ_WIDGET_TOOLKIT"] == "gtk":
CXXFLAGS += CONFIG["MOZ_GTK3_CFLAGS"]
XPCOM_MANIFESTS += [
"components.conf",
]

View File

@@ -4,6 +4,8 @@
#include "nsISupports.idl"
interface mozIDOMWindowProxy;
/**
* @brief Interface for Zen's drag and drop functionality.
*/
@@ -21,4 +23,14 @@ interface nsIZenDragAndDrop : nsISupports {
* @brief Indicate that a drag operation has ended.
*/
void onDragEnd();
/**
* @brief Start a native, OS-driven interactive move of the window,
* as if the user had grabbed its titlebar. Must be called while the
* primary mouse button is held down. The OS takes over the drag,
* providing native behaviors like macOS window tiling or Windows
* snap layouts, and ends it when the button is released.
* @param window The chrome window to move.
*/
void beginNativeWindowMove(in mozIDOMWindowProxy window);
};

View File

@@ -5,7 +5,58 @@
#include "nsZenDragAndDrop.h"
#include "nsBaseDragService.h"
#include "mozilla/WidgetUtils.h"
#include "nsIWidget.h"
#include "nsPIDOMWindow.h"
#if defined(XP_WIN)
# include <windows.h>
#elif defined(MOZ_WIDGET_GTK)
# include <gdk/gdk.h>
#endif
namespace zen {
#if defined(XP_MACOSX)
nsresult StartNativeWindowMoveCocoa(nsIWidget* aWidget);
#endif
/**
* @brief Start a native, OS-driven move of the window backing aWidget.
* @see nsIZenDragAndDrop::beginNativeWindowMove for more details.
*/
static nsresult StartNativeWindowMove(nsIWidget* aWidget) {
#if defined(XP_MACOSX)
return StartNativeWindowMoveCocoa(aWidget);
#elif defined(XP_WIN)
HWND hwnd = static_cast<HWND>(aWidget->GetNativeData(NS_NATIVE_WINDOW));
NS_ENSURE_TRUE(hwnd, NS_ERROR_FAILURE);
// Hand the drag over to the OS as if the titlebar was grabbed; this
// enters the native move loop.
::ReleaseCapture();
::PostMessageW(hwnd, WM_NCLBUTTONDOWN, HTCAPTION, 0);
return NS_OK;
#elif defined(MOZ_WIDGET_GTK)
auto* gdkWindow =
static_cast<GdkWindow*>(aWidget->GetNativeData(NS_NATIVE_WINDOW));
NS_ENSURE_TRUE(gdkWindow, NS_ERROR_FAILURE);
GdkWindow* toplevel = gdk_window_get_toplevel(gdkWindow);
GdkSeat* seat =
gdk_display_get_default_seat(gdk_window_get_display(toplevel));
GdkDevice* pointer = seat ? gdk_seat_get_pointer(seat) : nullptr;
NS_ENSURE_TRUE(pointer, NS_ERROR_FAILURE);
// A native move works on both X11 and Wayland, unlike moving the
// window programmatically.
gint rootX = 0;
gint rootY = 0;
gdk_device_get_position(pointer, nullptr, &rootX, &rootY);
gdk_window_begin_move_drag_for_device(toplevel, pointer, 1, rootX, rootY,
GDK_CURRENT_TIME);
return NS_OK;
#else
return NS_ERROR_NOT_IMPLEMENTED;
#endif
}
namespace {
static constexpr auto kZenDefaultDragImageOpacity =
@@ -44,4 +95,15 @@ nsZenDragAndDrop::OnDragEnd() {
return NS_OK;
}
NS_IMETHODIMP
nsZenDragAndDrop::BeginNativeWindowMove(mozIDOMWindowProxy* aWindow) {
NS_ENSURE_ARG_POINTER(aWindow);
nsCOMPtr<nsPIDOMWindowOuter> outer = nsPIDOMWindowOuter::From(aWindow);
NS_ENSURE_TRUE(outer, NS_ERROR_INVALID_ARG);
RefPtr<nsIWidget> widget =
mozilla::widget::WidgetUtils::DOMWindowToWidget(outer);
NS_ENSURE_TRUE(widget, NS_ERROR_FAILURE);
return StartNativeWindowMove(widget);
}
} // namespace zen

View File

@@ -0,0 +1,44 @@
/* 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 <Cocoa/Cocoa.h>
#include "nsDebug.h"
#include "nsIWidget.h"
#include "nsObjCExceptions.h"
namespace zen {
// Cocoa side of StartNativeWindowMove()
nsresult StartNativeWindowMoveCocoa(nsIWidget* aWidget) {
NS_OBJC_BEGIN_TRY_BLOCK_RETURN;
NSWindow* window =
static_cast<NSWindow*>(aWidget->GetNativeData(NS_NATIVE_WINDOW));
NS_ENSURE_TRUE(window, NS_ERROR_FAILURE);
// performWindowDragWithEvent: needs a left-button event to start the
// WindowServer drag session. The triggering mousedown happened in a
// content process, so synthesize an equivalent event at the current
// mouse location.
NSPoint location = [window convertPointFromScreen:[NSEvent mouseLocation]];
NSEvent* event =
[NSEvent mouseEventWithType:NSEventTypeLeftMouseDragged
location:location
modifierFlags:0
timestamp:NSProcessInfo.processInfo.systemUptime
windowNumber:window.windowNumber
context:nil
eventNumber:0
clickCount:1
pressure:1.0];
NS_ENSURE_TRUE(event, NS_ERROR_FAILURE);
[window performWindowDragWithEvent:event];
return NS_OK;
NS_OBJC_END_TRY_BLOCK_RETURN(NS_ERROR_FAILURE);
}
} // namespace zen

View File

@@ -85,9 +85,10 @@ const defaultKeyboardGroups = {
"zen-search-focus-shortcut",
"zen-search-focus-shortcut-alt",
"zen-find-shortcut",
"zen-search-find-again-shortcut-2",
"zen-search-find-again-shortcut",
"zen-search-find-again-shortcut-alt",
"zen-search-find-again-shortcut-prev",
"zen-search-find-again-shortcut-prev-alt",
],
pageOperations: [
"zen-text-action-copy-url-markdown-shortcut",
@@ -305,6 +306,29 @@ export class nsKeyShortcutModifiers {
}
class KeyShortcut {
static SHIFTED_SYMBOLS = {
1: "!",
2: "@",
3: "#",
4: "$",
5: "%",
6: "^",
7: "&",
8: "*",
9: "(",
0: ")",
"`": "~",
"-": "_",
"=": "+",
"[": "{",
"]": "}",
"\\": "|",
";": ":",
"'": '"',
",": "<",
".": ">",
"/": "?",
};
#id = "";
#key = "";
#keycode = "";
@@ -430,13 +454,35 @@ class KeyShortcut {
replaceWithChild(key) {
key.id = this.#id;
// When shift is pressed and the char changes when shifted (like 1 -> !),
// the XUL matches the shifted character so we need to emit the shifted character
// and drop the shift modifier so XUL can match
// This problem is also windows specific
let keyName = this.#key;
let modifiers = this.#modifiers;
if (AppConstants.platform == "win") {
const shiftedKey = KeyShortcut.SHIFTED_SYMBOLS[keyName];
if (shiftedKey && modifiers.shift) {
keyName = shiftedKey;
modifiers = new nsKeyShortcutModifiers(
modifiers.control,
modifiers.alt,
false, // -> for shift key
modifiers.meta,
modifiers.accel
);
}
}
if (this.#keycode) {
key.setAttribute("keycode", this.#keycode);
key.removeAttribute("key");
} else if (this.#key) {
} else if (keyName) {
// note to "mr. macos": Better use setAttribute, because without it, there's a
// risk of malforming the XUL element.
key.setAttribute("key", this.#key);
key.setAttribute("key", keyName);
key.removeAttribute("keycode");
} else {
key.removeAttribute("key");
@@ -451,7 +497,7 @@ class KeyShortcut {
if (this.#l10nId) {
// key.setAttribute('data-l10n-id', this.#l10nId);
}
key.setAttribute("modifiers", this.#modifiers.toString());
key.setAttribute("modifiers", modifiers.toString());
if (this.#action) {
key.setAttribute("command", this.#action);
}
@@ -848,7 +894,7 @@ class nsZenKeyboardShortcutsLoader {
}
class nsZenKeyboardShortcutsVersioner {
static LATEST_KBS_VERSION = 19;
static LATEST_KBS_VERSION = 20;
constructor() {}
@@ -1256,6 +1302,24 @@ class nsZenKeyboardShortcutsVersioner {
}
}
if (version < 20) {
// Migrate from version 19 to 20.
// - Disable "key_addTabSplitView" and "key_separateTabSplitView"
// since we already had "cmd_zenNewEmptySplit" and "cmd_zenSplitViewUnsplit" before Firefox 153.
// - Disable firefox's "viewOpenTabsSidebarKb" as it depends on firefox's native sidebar feature
const shouldBeDisabledShortcuts = [
"key_addTabSplitView",
"key_separateTabSplitView",
"viewOpenTabsSidebarKb",
];
for (let shortcut of data) {
if (shouldBeDisabledShortcuts.includes(shortcut.getID())) {
shortcut.shouldBeEmpty = true;
shortcut.setDisabled(true);
}
}
}
return data;
}
}

View File

@@ -21,4 +21,5 @@ DIRS += [
"spaces",
"space-routing",
"sync",
"window-drag",
]

View File

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

View File

@@ -2,6 +2,12 @@
* 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.defineLazyGetter(lazy, "l10n", () => {
return new Localization(["browser/zen-workspaces.ftl"], true);
});
class nsZenWorkspaceCreation extends MozXULElement {
#wasInCollapsedMode = false;
@@ -192,7 +198,7 @@ class nsZenWorkspaceCreation extends MozXULElement {
this.currentProfile = {
id: 0,
name: ContextualIdentityService.formatContextLabel("user-context-none"),
name: lazy.l10n.formatValueSync("zen-workspace-default-profile"),
};
} else {
this.inputProfile.parentNode.hidden = true;
@@ -297,10 +303,19 @@ class nsZenWorkspaceCreation extends MozXULElement {
}
onProfilePopupShowing(event) {
return window.createUserContextMenu(event, {
window.createUserContextMenu(event, {
isContextMenu: 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) {

View File

@@ -22,6 +22,10 @@ ChromeUtils.defineLazyGetter(lazy, "toolbarBackgroundElement", () => {
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
* and user interactions but it also contains some logic to manage
@@ -1263,11 +1267,19 @@ class nsZenWorkspaces {
workspace = this.getActiveWorkspaceFromCache();
}
let containerTabId = workspace.containerTabId;
return window.createUserContextMenu(event, {
window.createUserContextMenu(event, {
isContextMenu: true,
excludeUserContextId: containerTabId,
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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -0,0 +1,293 @@
/* 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

@@ -0,0 +1,11 @@
<!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

@@ -0,0 +1,657 @@
/* 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

@@ -1,109 +0,0 @@
/* 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,6 +10,21 @@ run-if = [
"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"]
run-if = [
"os == 'linux'",
@@ -20,11 +35,6 @@ run-if = [
"os == 'mac'",
]
["test_maybeCreateLaunchOnLoginOnFirstRun.js"]
run-if = [
"os == 'win'"
]
["test_secondaryTileJs.js"]
run-if = [
"os == 'win'"

View File

@@ -20,6 +20,7 @@ BROWSER_CHROME_MANIFESTS += [
"ub-actions/browser.toml",
"urlbar/browser.toml",
"welcome/browser.toml",
"window_drag/browser.toml",
"window_sync/browser.toml",
]

View File

@@ -0,0 +1,10 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
[DEFAULT]
support-files = [
"head.js",
]
["browser_window_drag_basic.js"]

View File

@@ -0,0 +1,109 @@
/* Any copyright is dedicated to the Public Domain.
https://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
let gDragStartCount = 0;
add_setup(async function () {
const observer = () => gDragStartCount++;
Services.obs.addObserver(observer, WINDOW_DRAG_TOPIC);
registerCleanupFunction(() => {
Services.obs.removeObserver(observer, WINDOW_DRAG_TOPIC);
});
});
add_task(async function test_drag_from_empty_top_area() {
await BrowserTestUtils.withNewTab(WINDOW_DRAG_TEST_PAGE, async browser => {
await synthesizeContentDrag(browser, 50, 50);
await TestUtils.waitForCondition(
() => gDragStartCount === 1,
"Dragging an empty area in the top region should start a window drag"
);
is(gDragStartCount, 1, "Exactly one window drag started");
});
});
add_task(async function test_no_drag_on_interactive_target() {
await BrowserTestUtils.withNewTab(WINDOW_DRAG_TEST_PAGE, async browser => {
// Start the gesture on top of the link. Messages from the same actor
// pair are ordered, so the control drag afterwards would arrive later.
await BrowserTestUtils.synthesizeMouse(
"#link",
5,
5,
{ type: "mousedown" },
browser
);
await BrowserTestUtils.synthesizeMouse(
"#link",
35,
15,
{ type: "mousemove", buttons: 1 },
browser
);
await BrowserTestUtils.synthesizeMouse(
"#link",
45,
20,
{ type: "mouseup" },
browser
);
// Control drag from an eligible area.
await synthesizeContentDrag(browser, 50, 50);
await TestUtils.waitForCondition(
() => gDragStartCount >= 2,
"The control drag should start a window drag"
);
is(
gDragStartCount,
2,
"Dragging a link must not start a window drag (only the control did)"
);
});
});
add_task(async function test_no_drag_below_top_region() {
await BrowserTestUtils.withNewTab(WINDOW_DRAG_TEST_PAGE, async browser => {
const innerHeight = await getContentInnerHeight(browser);
await synthesizeContentDrag(browser, 50, Math.floor(innerHeight * 0.6));
// Control drag from inside the top region.
await synthesizeContentDrag(browser, 50, 50);
await TestUtils.waitForCondition(
() => gDragStartCount >= 3,
"The control drag should start a window drag"
);
is(
gDragStartCount,
3,
"Dragging below the top region must not start a window drag"
);
});
});
add_task(async function test_pref_disables_window_drag() {
await SpecialPowers.pushPrefEnv({
set: [["zen.view.drag-window-from-content", false]],
});
await BrowserTestUtils.withNewTab(WINDOW_DRAG_TEST_PAGE, async browser => {
await synthesizeContentDrag(browser, 50, 50);
});
await SpecialPowers.popPrefEnv();
// Control drag with the pref back on, in a fresh tab so the actor is
// created again.
await BrowserTestUtils.withNewTab(WINDOW_DRAG_TEST_PAGE, async browser => {
await synthesizeContentDrag(browser, 50, 50);
await TestUtils.waitForCondition(
() => gDragStartCount >= 4,
"The control drag should start a window drag"
);
is(
gDragStartCount,
4,
"Dragging with the pref disabled must not start a window drag"
);
});
});

View File

@@ -0,0 +1,57 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const WINDOW_DRAG_TOPIC = "zen-window-drag-started";
const WINDOW_DRAG_TEST_PAGE = `https://example.com/document-builder.sjs?html=${encodeURIComponent(`
<!doctype html>
<style>
body { margin: 0; }
#link {
position: fixed;
top: 20px;
left: 300px;
width: 100px;
height: 30px;
display: block;
}
</style>
<a id="link" href="https://example.com/">a link</a>
`)}`;
/**
* Synthesizes a primary-button drag gesture inside the content area,
* starting at (x, y) and moving well past the drag threshold.
*/
async function synthesizeContentDrag(browser, x, y) {
await BrowserTestUtils.synthesizeMouse(
null,
x,
y,
{ type: "mousedown" },
browser
);
for (let i = 1; i <= 3; i++) {
await BrowserTestUtils.synthesizeMouse(
null,
x + i * 10,
y + i * 5,
{ type: "mousemove", buttons: 1 },
browser
);
}
await BrowserTestUtils.synthesizeMouse(
null,
x + 40,
y + 20,
{ type: "mouseup" },
browser
);
}
function getContentInnerHeight(browser) {
return SpecialPowers.spawn(browser, [], () => content.innerHeight);
}

View File

@@ -0,0 +1,333 @@
// 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {};
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"dragRegionHeightPercentage",
"zen.view.drag-window-from-content.height-percentage",
30
);
// A small threshold to allow for minor mouse jitter during a normal click.
// Anything beyond this is considered an intentional window drag.
const DRAG_START_THRESHOLD_PX = 4;
const kInteractiveTags = new Set([
"a",
"area",
"audio",
"button",
"canvas",
"details",
"dialog",
"embed",
"frame",
"iframe",
"img",
"input",
"label",
"menu",
"object",
"optgroup",
"option",
"select",
"summary",
"textarea",
"video",
]);
const kInteractiveRoles = new Set([
"button",
"checkbox",
"combobox",
"grid",
"link",
"listbox",
"menu",
"menubar",
"menuitem",
"option",
"radio",
"scrollbar",
"searchbox",
"slider",
"spinbutton",
"switch",
"tab",
"textbox",
"toolbar",
"tree",
"treegrid",
]);
// Cursors that signal the page considers the area interactive or draggable.
const kInteractiveCursors = new Set([
"pointer",
"grab",
"grabbing",
"move",
"all-scroll",
"text",
"vertical-text",
"cell",
"crosshair",
"col-resize",
"row-resize",
"n-resize",
"e-resize",
"s-resize",
"w-resize",
"ne-resize",
"nw-resize",
"se-resize",
"sw-resize",
"ew-resize",
"ns-resize",
"nesw-resize",
"nwse-resize",
]);
const kGestureListenerOptions = { mozSystemGroup: true, capture: true };
const kGestureEvents = ["mousemove", "mouseup", "dragstart", "unload"];
export class ZenWindowDragChild extends JSWindowActorChild {
#tracking = false;
#dragging = false;
#startScreenX = 0;
#startScreenY = 0;
handleEvent(event) {
// Never let pages spoof the gesture with synthetic events.
if (!event.isTrusted) {
return;
}
switch (event.type) {
case "mousedown":
this.#onMouseDown(event);
break;
case "mousemove":
this.#onMouseMove(event);
break;
case "mouseup":
this.#onMouseUp(event);
break;
case "dragstart":
// The gesture turned out to be a real content drag. Let it win.
this.#reset();
break;
case "unload":
this.#reset();
break;
}
}
get #dragRegionHeight() {
const percentage =
Math.max(0, Math.min(100, lazy.dragRegionHeightPercentage)) / 100;
return this.contentWindow.innerHeight * percentage;
}
#screenPoint(event) {
const dpr = this.contentWindow.devicePixelRatio;
return {
screenX: Math.round(event.screenX * dpr),
screenY: Math.round(event.screenY * dpr),
};
}
#onMouseDown(event) {
if (this.#tracking || this.#dragging) {
// The native OS move swallows the mouseup, so a stale gesture may
// still be tracked. Recover and evaluate this mousedown normally.
this.#reset();
}
if (
event.button !== 0 ||
event.buttons !== 1 ||
event.detail !== 1 ||
event.defaultPrevented ||
event.ctrlKey ||
event.altKey ||
event.shiftKey ||
event.metaKey
) {
return;
}
const doc = this.document;
if (doc.fullscreenElement || doc.pointerLockElement) {
return;
}
// Only handle events from the top document itself; anything inside an
// (i)frame belongs to the page.
if (event.composedTarget?.ownerDocument !== doc) {
return;
}
if (event.clientY > this.#dragRegionHeight) {
return;
}
let overContent = true;
try {
overContent = this.#isEventOverDraggableContent(event);
} catch (e) {
console.error("ZenWindowDrag: eligibility check failed", e);
}
if (overContent) {
return;
}
const { screenX, screenY } = this.#screenPoint(event);
this.#startScreenX = screenX;
this.#startScreenY = screenY;
this.#tracking = true;
this.#addGestureListeners();
}
#onMouseMove(event) {
if (!this.#tracking) {
return;
}
// We missed the mouseup (e.g. the OS consumed it during the native
// move, or it happened outside the window).
if (!(event.buttons & 1)) {
this.#reset();
return;
}
if (this.#dragging) {
// Keep the page from extending a text selection under the gesture.
event.preventDefault();
return;
}
const point = this.#screenPoint(event);
const threshold =
DRAG_START_THRESHOLD_PX * this.contentWindow.devicePixelRatio;
if (
Math.hypot(
point.screenX - this.#startScreenX,
point.screenY - this.#startScreenY
) < threshold
) {
return;
}
this.#dragging = true;
// The mousedown may have moved the caret or started a stray selection.
this.contentWindow.getSelection()?.removeAllRanges();
// The OS takes over the drag from here.
this.sendAsyncMessage("ZenWindowDrag:StartDrag");
event.preventDefault();
}
#onMouseUp(event) {
if (!this.#tracking || event.button !== 0) {
return;
}
if (this.#dragging) {
event.preventDefault();
event.preventClickEvent();
}
this.#reset();
}
#reset() {
this.#tracking = false;
this.#dragging = false;
this.#removeGestureListeners();
}
#addGestureListeners() {
const win = this.contentWindow;
for (const type of kGestureEvents) {
win.addEventListener(type, this, kGestureListenerOptions);
}
}
#removeGestureListeners() {
const win = this.contentWindow;
if (!win) {
return;
}
for (const type of kGestureEvents) {
win.removeEventListener(type, this, kGestureListenerOptions);
}
}
/**
* Returns true if starting a window drag here would fight with content
* that the page wants to be clickable, draggable or selectable.
*
* @param {MouseEvent} event
*/
#isEventOverDraggableContent(event) {
// Scrollbars and other native anonymous parts only dispatch to the
// system group, which is us. Never treat those as a window drag.
if (event.originalTarget?.isNativeAnonymous) {
return true;
}
let target = event.composedTarget;
if (target?.nodeType === Node.TEXT_NODE) {
target = target.parentElement;
}
if (!target || target.nodeType !== Node.ELEMENT_NODE) {
return true;
}
for (let node = target; node; node = node.flattenedTreeParentNode) {
if (node.nodeType !== Node.ELEMENT_NODE) {
continue;
}
if (this.#isInteractiveElement(node)) {
return true;
}
}
return this.#hasInteractiveCursor(target);
}
#isInteractiveElement(element) {
if (kInteractiveTags.has(element.localName)) {
return true;
}
// Covers [draggable="true"] and elements draggable by default,
// like links and images.
if (element.draggable) {
return true;
}
if (element.isContentEditable) {
return true;
}
if (
element.tabIndex >= 0 &&
element !== this.document.body &&
element !== this.document.documentElement
) {
return true;
}
const role = element.getAttribute?.("role");
if (role && kInteractiveRoles.has(role.toLowerCase())) {
return true;
}
// Inline event handlers are a strong hint of a custom widget.
if (
element.onclick ||
element.onmousedown ||
element.onpointerdown ||
element.ondragstart
) {
return true;
}
return false;
}
#hasInteractiveCursor(element) {
const style = element.ownerGlobal?.getComputedStyle(element);
if (!style) {
return false;
}
// The keyword is always the last component of the computed value.
// Don't split on "," as url() cursor images may contain commas.
const cursor = style.cursor.match(/[a-z-]+$/)?.[0];
return kInteractiveCursors.has(cursor);
}
}

View File

@@ -0,0 +1,33 @@
// 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {};
XPCOMUtils.defineLazyServiceGetter(
lazy,
"zenDragAndDropService",
"@mozilla.org/zen/drag-and-drop;1",
Ci.nsIZenDragAndDrop
);
export class ZenWindowDragParent extends JSWindowActorParent {
receiveMessage(message) {
if (message.name !== "ZenWindowDrag:StartDrag") {
return;
}
const win = this.browsingContext.topChromeWindow;
if (!win || win.closed || win.windowState === win.STATE_FULLSCREEN) {
return;
}
if (Cu.isInAutomation) {
// Tests can't exercise a real OS drag session; let them observe the
// decision instead.
Services.obs.notifyObservers(win, "zen-window-drag-started");
return;
}
lazy.zenDragAndDropService.beginNativeWindowMove(win);
}
}

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/.
FINAL_TARGET_FILES.actors += [
"actors/ZenWindowDragChild.sys.mjs",
"actors/ZenWindowDragParent.sys.mjs",
]

View File

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