mirror of
https://github.com/zen-browser/desktop.git
synced 2026-08-04 14:18:31 +00:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36c71c5c2b | ||
|
|
c74486102f | ||
|
|
045ebdf0ae | ||
|
|
bc540320f1 | ||
|
|
9714c15c7a | ||
|
|
ccbd934482 | ||
|
|
1d05184a2a | ||
|
|
45e47d56df | ||
|
|
0145cdfcbf | ||
|
|
e1189b2d0d | ||
|
|
b0041db68d | ||
|
|
346aa1ff9a | ||
|
|
95df1b3a9d | ||
|
|
8276c613ae | ||
|
|
92addf307d | ||
|
|
e474f1df3a | ||
|
|
9fbfb8eba7 | ||
|
|
a9e185681c | ||
|
|
a37423a961 | ||
|
|
929d85720e | ||
|
|
2a4eb08c00 | ||
|
|
768f45e265 | ||
|
|
c2504d12c0 |
@@ -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
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
b1d3fe8f65751e6d695fa9ef94b11ad81e70dac1
|
||||
30959a45eaaf0bc499f3054793c76c6d7f0c8bc2
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -20,6 +20,14 @@
|
||||
- name: zen.view.compact.sidebar-keep-hover.duration
|
||||
value: 150
|
||||
|
||||
# How far (in CSS pixels) the mouse may travel past the window bounds after
|
||||
# leaving the window before the hovered sidebar/toolbar is collapsed
|
||||
- name: zen.view.compact.outside-window-edge-offset.horizontal
|
||||
value: 200
|
||||
|
||||
- name: zen.view.compact.outside-window-edge-offset.vertical
|
||||
value: 100
|
||||
|
||||
- name: zen.view.compact.animate-sidebar
|
||||
value: true
|
||||
|
||||
|
||||
@@ -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: 10
|
||||
|
||||
- name: zen.view.borderless-fullscreen
|
||||
value: true
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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"
|
||||
|
||||
21
src/browser/themes/shared/places/organizer-shared-css.patch
Normal file
21
src/browser/themes/shared/places/organizer-shared-css.patch
Normal 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;
|
||||
@@ -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());
|
||||
|
||||
22
src/toolkit/mozapps/extensions/Blocklist-sys-mjs.patch
Normal file
22
src/toolkit/mozapps/extensions/Blocklist-sys-mjs.patch
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -34,6 +34,29 @@ XPCOMUtils.defineLazyPreferenceGetter(
|
||||
true
|
||||
);
|
||||
|
||||
// Distance (in CSS pixels) the mouse can travel past the window bounds after
|
||||
// leaving the window before the hovered element is collapsed
|
||||
XPCOMUtils.defineLazyPreferenceGetter(
|
||||
lazy,
|
||||
"COMPACT_MODE_OUTSIDE_WINDOW_HORIZONTAL_OFFSET",
|
||||
"zen.view.compact.outside-window-edge-offset.horizontal",
|
||||
250
|
||||
);
|
||||
|
||||
XPCOMUtils.defineLazyPreferenceGetter(
|
||||
lazy,
|
||||
"COMPACT_MODE_OUTSIDE_WINDOW_VERTICAL_OFFSET",
|
||||
"zen.view.compact.outside-window-edge-offset.vertical",
|
||||
150
|
||||
);
|
||||
|
||||
XPCOMUtils.defineLazyServiceGetter(
|
||||
lazy,
|
||||
"zenMouseTracker",
|
||||
"@mozilla.org/zen/mouse-tracker;1",
|
||||
Ci.nsIZenMouseTracker
|
||||
);
|
||||
|
||||
ChromeUtils.defineLazyGetter(lazy, "mainAppWrapper", () =>
|
||||
document.getElementById("zen-main-app-wrapper")
|
||||
);
|
||||
@@ -71,9 +94,21 @@ window.gZenCompactModeManager = {
|
||||
tabIsRightObserver
|
||||
);
|
||||
|
||||
const outsideMouseTrackerExitObserver =
|
||||
this._onOutsideMouseTrackerExit.bind(this);
|
||||
Services.obs.addObserver(
|
||||
outsideMouseTrackerExitObserver,
|
||||
"zen-mouse-tracker:exited"
|
||||
);
|
||||
|
||||
window.addEventListener(
|
||||
"unload",
|
||||
() => {
|
||||
this._stopTrackingMouseOutsideWindow();
|
||||
Services.obs.removeObserver(
|
||||
outsideMouseTrackerExitObserver,
|
||||
"zen-mouse-tracker:exited"
|
||||
);
|
||||
Services.prefs.removeObserver(
|
||||
"zen.tabs.vertical.right-side",
|
||||
tabIsRightObserver
|
||||
@@ -94,6 +129,10 @@ window.gZenCompactModeManager = {
|
||||
this._clearAllHoverStates()
|
||||
);
|
||||
|
||||
// Hide any element kept open by the outside mouse tracking as soon as the
|
||||
// window loses focus
|
||||
window.addEventListener("deactivate", () => this._collapseTrackedElement());
|
||||
|
||||
this._canShowBackgroundTabToast = Services.prefs.getBoolPref(
|
||||
"zen.view.compact.show-background-tab-toast",
|
||||
true
|
||||
@@ -149,6 +188,7 @@ window.gZenCompactModeManager = {
|
||||
return;
|
||||
}
|
||||
delete this._isTabBeingDragged;
|
||||
this._stopTrackingMouseOutsideWindow();
|
||||
this.sidebar.removeAttribute("zen-user-show");
|
||||
// We use this element in order to make it persis across restarts, by using the XULStore.
|
||||
// main-window can't store attributes other than window sizes, so we use this instead
|
||||
@@ -723,6 +763,9 @@ window.gZenCompactModeManager = {
|
||||
}
|
||||
} else {
|
||||
if (attr === "zen-has-hover") {
|
||||
if (element === this._outsideTrackedElement) {
|
||||
this._stopTrackingMouseOutsideWindow();
|
||||
}
|
||||
element.removeAttribute("zen-has-implicit-hover");
|
||||
gURLBar.updateTextOverflow();
|
||||
}
|
||||
@@ -886,18 +929,23 @@ window.gZenCompactModeManager = {
|
||||
}
|
||||
window.cancelAnimationFrame(this._removeHoverFrames[target.id]);
|
||||
|
||||
this.flashElement(
|
||||
target,
|
||||
this.hideAfterHoverDuration,
|
||||
"has-hover" + target.id,
|
||||
"zen-has-hover"
|
||||
);
|
||||
if (!this._trackMouseOutsideWindow(entry.screenEdge, target)) {
|
||||
// We can't track the mouse position outside of the window on
|
||||
// this platform, fall back to hiding after a fixed duration
|
||||
this.flashElement(
|
||||
target,
|
||||
this.hideAfterHoverDuration,
|
||||
"has-hover" + target.id,
|
||||
"zen-has-hover"
|
||||
);
|
||||
}
|
||||
document.addEventListener(
|
||||
"mousemove",
|
||||
() => {
|
||||
if (target.matches(":hover")) {
|
||||
return;
|
||||
}
|
||||
// Closing the element also stops the outside mouse tracking
|
||||
this._setElementExpandAttribute(target, false);
|
||||
this.clearFlashTimeout("has-hover" + target.id);
|
||||
},
|
||||
@@ -941,7 +989,55 @@ window.gZenCompactModeManager = {
|
||||
return bBox.left - error < x && x < bBox.right + error;
|
||||
},
|
||||
|
||||
_trackMouseOutsideWindow(screenEdge, target) {
|
||||
this._stopTrackingMouseOutsideWindow();
|
||||
const maxEdgeOffset =
|
||||
screenEdge === "left" || screenEdge === "right"
|
||||
? lazy.COMPACT_MODE_OUTSIDE_WINDOW_HORIZONTAL_OFFSET
|
||||
: lazy.COMPACT_MODE_OUTSIDE_WINDOW_VERTICAL_OFFSET;
|
||||
try {
|
||||
lazy.zenMouseTracker.registerWindow(window, screenEdge, maxEdgeOffset);
|
||||
} catch (e) {
|
||||
// The platform can't track the global mouse position (e.g. Linux)
|
||||
return false;
|
||||
}
|
||||
this._outsideTrackedElement = target;
|
||||
this.clearFlashTimeout("has-hover" + target.id);
|
||||
window.requestAnimationFrame(() => {
|
||||
if (this._outsideTrackedElement === target) {
|
||||
this._setElementExpandAttribute(target, true);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
},
|
||||
|
||||
_stopTrackingMouseOutsideWindow() {
|
||||
const target = this._outsideTrackedElement;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
this._outsideTrackedElement = null;
|
||||
lazy.zenMouseTracker.unregisterWindow(window);
|
||||
},
|
||||
|
||||
_collapseTrackedElement() {
|
||||
const target = this._outsideTrackedElement;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
// Closing the element also unregisters us from the mouse tracker
|
||||
this._setElementExpandAttribute(target, false);
|
||||
this.clearFlashTimeout("has-hover" + target.id);
|
||||
},
|
||||
|
||||
_onOutsideMouseTrackerExit(subject) {
|
||||
if (subject === window) {
|
||||
this._collapseTrackedElement();
|
||||
}
|
||||
},
|
||||
|
||||
_clearAllHoverStates() {
|
||||
this._stopTrackingMouseOutsideWindow();
|
||||
// Clear hover attributes from all hoverable elements
|
||||
for (let entry of this.hoverableElements) {
|
||||
const target = entry.element;
|
||||
@@ -977,6 +1073,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 +1092,7 @@ window.gZenCompactModeManager = {
|
||||
},
|
||||
};
|
||||
|
||||
if (beforeRouteResult.isRouteFound) {
|
||||
if (beforeRouteResult.isRouteFound && !isTabRoutedToCurrentSpace) {
|
||||
messageId = "zen-space-routing-tab-routed-toast";
|
||||
toastOptions = {
|
||||
l10nArgs: { targetWorkspace: beforeRouteResult.targetWorkspaceName },
|
||||
|
||||
246
src/zen/compact-mode/ZenMouseTracker.cpp
Normal file
246
src/zen/compact-mode/ZenMouseTracker.cpp
Normal file
@@ -0,0 +1,246 @@
|
||||
/* 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/. */
|
||||
|
||||
#include "ZenMouseTracker.h"
|
||||
#include "ZenMouseTrackerInternal.h"
|
||||
|
||||
#include <atomic>
|
||||
|
||||
#include "mozilla/Services.h"
|
||||
#include "mozilla/WidgetUtils.h"
|
||||
#include "nsIObserverService.h"
|
||||
#include "nsIWidget.h"
|
||||
#include "nsThreadUtils.h"
|
||||
|
||||
namespace zen {
|
||||
|
||||
NS_IMPL_ISUPPORTS(ZenMouseTracker, nsIZenMouseTracker, nsIObserver)
|
||||
|
||||
static ZenMouseTracker* sInstance = nullptr;
|
||||
|
||||
// Pointer moves are coalesced: the backends may report them faster than we
|
||||
// want to run the checks, and on Windows they arrive from a low level hook
|
||||
// where as little work as possible should happen. These are atomic since
|
||||
// some backends (e.g. the macOS global event monitor) may deliver the moves
|
||||
// off the main thread.
|
||||
static std::atomic<int32_t> sLatestPointerX{0};
|
||||
static std::atomic<int32_t> sLatestPointerY{0};
|
||||
static std::atomic<bool> sPendingEvaluation{false};
|
||||
|
||||
ZenMouseTracker::ZenMouseTracker() {
|
||||
MOZ_ASSERT(NS_IsMainThread());
|
||||
sInstance = this;
|
||||
}
|
||||
|
||||
ZenMouseTracker::~ZenMouseTracker() {
|
||||
ZenNativeMouseMonitor::Stop();
|
||||
if (sInstance == this) {
|
||||
sInstance = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
// static
|
||||
mozilla::Maybe<ZenMouseTracker::TrackedEdge> ZenMouseTracker::ParseEdge(
|
||||
const nsACString& aScreenEdge) {
|
||||
if (aScreenEdge.EqualsLiteral("left")) {
|
||||
return mozilla::Some(TrackedEdge::Left);
|
||||
}
|
||||
if (aScreenEdge.EqualsLiteral("right")) {
|
||||
return mozilla::Some(TrackedEdge::Right);
|
||||
}
|
||||
if (aScreenEdge.EqualsLiteral("top")) {
|
||||
return mozilla::Some(TrackedEdge::Top);
|
||||
}
|
||||
if (aScreenEdge.EqualsLiteral("bottom")) {
|
||||
return mozilla::Some(TrackedEdge::Bottom);
|
||||
}
|
||||
return mozilla::Nothing();
|
||||
}
|
||||
|
||||
NS_IMETHODIMP
|
||||
ZenMouseTracker::RegisterWindow(mozIDOMWindowProxy* aWindow,
|
||||
const nsACString& aScreenEdge,
|
||||
float aMaxEdgeOffset) {
|
||||
MOZ_ASSERT(NS_IsMainThread());
|
||||
const auto edge = ParseEdge(aScreenEdge);
|
||||
if (!aWindow || edge.isNothing() || aMaxEdgeOffset < 0.0f) {
|
||||
return NS_ERROR_INVALID_ARG;
|
||||
}
|
||||
nsCOMPtr<nsPIDOMWindowOuter> window = nsPIDOMWindowOuter::From(aWindow);
|
||||
if (!window) {
|
||||
return NS_ERROR_INVALID_ARG;
|
||||
}
|
||||
|
||||
nsresult rv = ZenNativeMouseMonitor::Start();
|
||||
if (NS_FAILED(rv)) {
|
||||
return rv;
|
||||
}
|
||||
|
||||
for (auto& tracked : mTracked) {
|
||||
if (tracked.mWindow == window) {
|
||||
tracked.mEdge = *edge;
|
||||
tracked.mMaxEdgeOffset = aMaxEdgeOffset;
|
||||
return NS_OK;
|
||||
}
|
||||
}
|
||||
mTracked.AppendElement(TrackedWindow{window, *edge, aMaxEdgeOffset});
|
||||
AddObservers();
|
||||
return NS_OK;
|
||||
}
|
||||
|
||||
void ZenMouseTracker::AddObservers() {
|
||||
if (mObserving) {
|
||||
return;
|
||||
}
|
||||
if (nsCOMPtr<nsIObserverService> obs =
|
||||
mozilla::services::GetObserverService()) {
|
||||
obs->AddObserver(this, "domwindowclosed", false);
|
||||
obs->AddObserver(this, NS_XPCOM_SHUTDOWN_OBSERVER_ID, false);
|
||||
mObserving = true;
|
||||
}
|
||||
}
|
||||
|
||||
void ZenMouseTracker::RemoveObservers() {
|
||||
if (!mObserving) {
|
||||
return;
|
||||
}
|
||||
if (nsCOMPtr<nsIObserverService> obs =
|
||||
mozilla::services::GetObserverService()) {
|
||||
obs->RemoveObserver(this, "domwindowclosed");
|
||||
obs->RemoveObserver(this, NS_XPCOM_SHUTDOWN_OBSERVER_ID);
|
||||
}
|
||||
mObserving = false;
|
||||
}
|
||||
|
||||
NS_IMETHODIMP
|
||||
ZenMouseTracker::UnregisterWindow(mozIDOMWindowProxy* aWindow) {
|
||||
MOZ_ASSERT(NS_IsMainThread());
|
||||
if (!aWindow) {
|
||||
return NS_ERROR_INVALID_ARG;
|
||||
}
|
||||
StopTrackingWindow(nsPIDOMWindowOuter::From(aWindow));
|
||||
return NS_OK;
|
||||
}
|
||||
|
||||
void ZenMouseTracker::StopTrackingWindow(nsPIDOMWindowOuter* aWindow) {
|
||||
for (size_t i = 0; i < mTracked.Length(); i++) {
|
||||
if (mTracked[i].mWindow == aWindow) {
|
||||
mTracked.RemoveElementAt(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
OnTrackedListChanged();
|
||||
}
|
||||
|
||||
void ZenMouseTracker::OnTrackedListChanged() {
|
||||
if (mTracked.IsEmpty()) {
|
||||
ZenNativeMouseMonitor::Stop();
|
||||
RemoveObservers();
|
||||
}
|
||||
}
|
||||
|
||||
// static
|
||||
void ZenMouseTracker::OnNativePointerMove(const mozilla::DesktopPoint& aPoint) {
|
||||
sLatestPointerX = int32_t(aPoint.x);
|
||||
sLatestPointerY = int32_t(aPoint.y);
|
||||
if (sPendingEvaluation.exchange(true)) {
|
||||
// An already queued evaluation will pick up the position we just stored
|
||||
return;
|
||||
}
|
||||
nsresult rv = NS_DispatchToMainThread(
|
||||
NS_NewRunnableFunction("zen::ZenMouseTracker::Evaluate", [] {
|
||||
sPendingEvaluation = false;
|
||||
if (RefPtr<ZenMouseTracker> tracker = sInstance) {
|
||||
tracker->Evaluate(mozilla::DesktopPoint(float(sLatestPointerX),
|
||||
float(sLatestPointerY)));
|
||||
}
|
||||
}));
|
||||
if (NS_FAILED(rv)) {
|
||||
// Don't let a failed dispatch (e.g. during shutdown) block all future
|
||||
// evaluations
|
||||
sPendingEvaluation = false;
|
||||
}
|
||||
}
|
||||
|
||||
void ZenMouseTracker::Evaluate(const mozilla::DesktopPoint& aPoint) {
|
||||
nsTArray<nsCOMPtr<nsPIDOMWindowOuter>> exited;
|
||||
for (size_t i = mTracked.Length(); i > 0; i--) {
|
||||
auto& tracked = mTracked[i - 1];
|
||||
RefPtr<nsIWidget> widget =
|
||||
mozilla::widget::WidgetUtils::DOMWindowToWidget(tracked.mWindow);
|
||||
if (!widget) {
|
||||
mTracked.RemoveElementAt(i - 1);
|
||||
continue;
|
||||
}
|
||||
if (!IsPointerWithinBounds(tracked, aPoint, widget)) {
|
||||
exited.AppendElement(std::move(tracked.mWindow));
|
||||
mTracked.RemoveElementAt(i - 1);
|
||||
}
|
||||
}
|
||||
OnTrackedListChanged();
|
||||
|
||||
if (exited.IsEmpty()) {
|
||||
return;
|
||||
}
|
||||
nsCOMPtr<nsIObserverService> obs = mozilla::services::GetObserverService();
|
||||
if (!obs) {
|
||||
return;
|
||||
}
|
||||
for (auto& window : exited) {
|
||||
obs->NotifyObservers(window, ZEN_MOUSE_TRACKER_EXITED_TOPIC, nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
// static
|
||||
bool ZenMouseTracker::IsPointerWithinBounds(const TrackedWindow& aTracked,
|
||||
const mozilla::DesktopPoint& aPoint,
|
||||
nsIWidget* aWidget) {
|
||||
const auto scale = aWidget->GetDesktopToDeviceScale();
|
||||
const float x = aPoint.x * scale.scale;
|
||||
const float y = aPoint.y * scale.scale;
|
||||
|
||||
const auto origin = aWidget->WidgetToScreenOffset();
|
||||
const auto size = aWidget->GetClientSize();
|
||||
const float left = origin.x;
|
||||
const float top = origin.y;
|
||||
const float right = left + size.width;
|
||||
const float bottom = top + size.height;
|
||||
|
||||
if (x >= left && x <= right && y >= top && y <= bottom) {
|
||||
// Back inside the window; the in-window hover logic owns this case
|
||||
return true;
|
||||
}
|
||||
|
||||
const float maxOffset =
|
||||
aTracked.mMaxEdgeOffset * float(aWidget->GetDefaultScale().scale);
|
||||
switch (aTracked.mEdge) {
|
||||
case TrackedEdge::Left:
|
||||
return x >= left - maxOffset && x < left && y >= top && y <= bottom;
|
||||
case TrackedEdge::Right:
|
||||
return x > right && x <= right + maxOffset && y >= top && y <= bottom;
|
||||
case TrackedEdge::Top:
|
||||
return y >= top - maxOffset && y < top && x >= left && x <= right;
|
||||
case TrackedEdge::Bottom:
|
||||
return y > bottom && y <= bottom + maxOffset && x >= left && x <= right;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
NS_IMETHODIMP
|
||||
ZenMouseTracker::Observe(nsISupports* aSubject, const char* aTopic,
|
||||
const char16_t* aData) {
|
||||
if (!strcmp(aTopic, "domwindowclosed")) {
|
||||
if (nsCOMPtr<nsPIDOMWindowOuter> window = do_QueryInterface(aSubject)) {
|
||||
StopTrackingWindow(window);
|
||||
}
|
||||
return NS_OK;
|
||||
}
|
||||
if (!strcmp(aTopic, NS_XPCOM_SHUTDOWN_OBSERVER_ID)) {
|
||||
mTracked.Clear();
|
||||
OnTrackedListChanged();
|
||||
}
|
||||
return NS_OK;
|
||||
}
|
||||
|
||||
} // namespace zen
|
||||
92
src/zen/compact-mode/ZenMouseTracker.h
Normal file
92
src/zen/compact-mode/ZenMouseTracker.h
Normal file
@@ -0,0 +1,92 @@
|
||||
/* 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/. */
|
||||
|
||||
#ifndef mozilla_ZenMouseTracker_h_
|
||||
#define mozilla_ZenMouseTracker_h_
|
||||
|
||||
#include "nsIZenMouseTracker.h"
|
||||
|
||||
#include "mozilla/Maybe.h"
|
||||
#include "nsIObserver.h"
|
||||
#include "nsCOMPtr.h"
|
||||
#include "nsPIDOMWindow.h"
|
||||
#include "nsTArray.h"
|
||||
#include "Units.h"
|
||||
|
||||
class nsIWidget;
|
||||
|
||||
// Fired with the tracked window as subject once the pointer breaks the
|
||||
// tracking conditions; tracking for that window has already stopped.
|
||||
#define ZEN_MOUSE_TRACKER_EXITED_TOPIC "zen-mouse-tracker:exited"
|
||||
|
||||
namespace zen {
|
||||
|
||||
/**
|
||||
* @brief Watches the global OS pointer position for registered windows and
|
||||
* notifies observers once the pointer moves too far away from the window
|
||||
* edge it left through.
|
||||
*/
|
||||
class ZenMouseTracker final : public nsIZenMouseTracker, public nsIObserver {
|
||||
public:
|
||||
NS_DECL_ISUPPORTS
|
||||
NS_DECL_NSIZENMOUSETRACKER
|
||||
NS_DECL_NSIOBSERVER
|
||||
|
||||
ZenMouseTracker();
|
||||
|
||||
/**
|
||||
* @brief Called by the platform backends whenever the OS pointer moves.
|
||||
* @param aPoint The pointer position in desktop pixels, relative to the
|
||||
* origin of the (primary) screen. May be called outside of a clean event
|
||||
* loop iteration (e.g. from a low level hook), evaluation is coalesced
|
||||
* onto the main thread.
|
||||
*/
|
||||
static void OnNativePointerMove(const mozilla::DesktopPoint& aPoint);
|
||||
|
||||
private:
|
||||
~ZenMouseTracker();
|
||||
|
||||
enum class TrackedEdge : uint8_t { Left, Right, Top, Bottom };
|
||||
|
||||
struct TrackedWindow {
|
||||
nsCOMPtr<nsPIDOMWindowOuter> mWindow;
|
||||
TrackedEdge mEdge;
|
||||
float mMaxEdgeOffset; // In CSS pixels
|
||||
};
|
||||
|
||||
static mozilla::Maybe<TrackedEdge> ParseEdge(const nsACString& aScreenEdge);
|
||||
|
||||
/**
|
||||
* @brief Check every tracked window against the given pointer position and
|
||||
* notify + stop tracking the ones the pointer moved too far away from.
|
||||
*/
|
||||
void Evaluate(const mozilla::DesktopPoint& aPoint);
|
||||
|
||||
/**
|
||||
* @brief Whether the pointer is still allowed to keep the window's edge
|
||||
* element open, given the window's bounds on screen.
|
||||
*/
|
||||
static bool IsPointerWithinBounds(const TrackedWindow& aTracked,
|
||||
const mozilla::DesktopPoint& aPoint,
|
||||
nsIWidget* aWidget);
|
||||
|
||||
void StopTrackingWindow(nsPIDOMWindowOuter* aWindow);
|
||||
|
||||
/**
|
||||
* @brief Tear down the native monitor and our observers once the last
|
||||
* tracked window is gone, so the service is fully idle while nothing is
|
||||
* being tracked.
|
||||
*/
|
||||
void OnTrackedListChanged();
|
||||
|
||||
void AddObservers();
|
||||
void RemoveObservers();
|
||||
|
||||
nsTArray<TrackedWindow> mTracked;
|
||||
bool mObserving = false;
|
||||
};
|
||||
|
||||
} // namespace zen
|
||||
|
||||
#endif
|
||||
65
src/zen/compact-mode/ZenMouseTrackerCocoa.mm
Normal file
65
src/zen/compact-mode/ZenMouseTrackerCocoa.mm
Normal file
@@ -0,0 +1,65 @@
|
||||
/* 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/. */
|
||||
|
||||
#include "ZenMouseTracker.h"
|
||||
#include "ZenMouseTrackerInternal.h"
|
||||
|
||||
#include "nsCocoaUtils.h"
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <AppKit/AppKit.h>
|
||||
|
||||
namespace zen {
|
||||
|
||||
// The global monitor only sees events delivered to other applications, so a
|
||||
// local monitor is needed as well for moves that AppKit routes to our own
|
||||
// app while the pointer is outside of the tracked window (AppKit sends mouse
|
||||
// moved events to the key window regardless of the pointer position).
|
||||
static id sLocalMonitor = nil;
|
||||
static id sGlobalMonitor = nil;
|
||||
|
||||
static void ReportPointerPosition() {
|
||||
NSPoint point = [NSEvent mouseLocation];
|
||||
point.y = nsCocoaUtils::FlippedScreenY(point.y);
|
||||
ZenMouseTracker::OnNativePointerMove(
|
||||
mozilla::DesktopPoint(float(point.x), float(point.y)));
|
||||
}
|
||||
|
||||
nsresult ZenNativeMouseMonitor::Start() {
|
||||
NS_OBJC_BEGIN_TRY_BLOCK_RETURN;
|
||||
if (sLocalMonitor || sGlobalMonitor) {
|
||||
return NS_OK;
|
||||
}
|
||||
const NSEventMask mask = NSEventMaskMouseMoved | NSEventMaskLeftMouseDragged |
|
||||
NSEventMaskRightMouseDragged |
|
||||
NSEventMaskOtherMouseDragged;
|
||||
sLocalMonitor =
|
||||
[NSEvent addLocalMonitorForEventsMatchingMask:mask
|
||||
handler:^(NSEvent* aEvent) {
|
||||
ReportPointerPosition();
|
||||
return aEvent;
|
||||
}];
|
||||
sGlobalMonitor =
|
||||
[NSEvent addGlobalMonitorForEventsMatchingMask:mask
|
||||
handler:^(NSEvent* aEvent) {
|
||||
ReportPointerPosition();
|
||||
}];
|
||||
return NS_OK;
|
||||
NS_OBJC_END_TRY_BLOCK_RETURN(NS_ERROR_NOT_AVAILABLE);
|
||||
}
|
||||
|
||||
void ZenNativeMouseMonitor::Stop() {
|
||||
NS_OBJC_BEGIN_TRY_IGNORE_BLOCK;
|
||||
if (sLocalMonitor) {
|
||||
[NSEvent removeMonitor:sLocalMonitor];
|
||||
sLocalMonitor = nil;
|
||||
}
|
||||
if (sGlobalMonitor) {
|
||||
[NSEvent removeMonitor:sGlobalMonitor];
|
||||
sGlobalMonitor = nil;
|
||||
}
|
||||
NS_OBJC_END_TRY_IGNORE_BLOCK;
|
||||
}
|
||||
|
||||
} // namespace zen
|
||||
48
src/zen/compact-mode/ZenMouseTrackerInternal.h
Normal file
48
src/zen/compact-mode/ZenMouseTrackerInternal.h
Normal file
@@ -0,0 +1,48 @@
|
||||
/* 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/. */
|
||||
|
||||
#ifndef mozilla_ZenMouseTrackerInternal_h_
|
||||
#define mozilla_ZenMouseTrackerInternal_h_
|
||||
|
||||
#include "nscore.h"
|
||||
|
||||
// On Linux there's no reliable way to observe the global pointer (Wayland
|
||||
// doesn't expose it at all), so we don't track there and callers fall back
|
||||
// to their timeout based behavior
|
||||
#if defined(XP_MACOSX) || defined(XP_WIN)
|
||||
# define NS_ZEN_CAN_TRACK_POINTER 1
|
||||
#endif
|
||||
|
||||
namespace zen {
|
||||
|
||||
/**
|
||||
* @brief Platform backend that delivers global pointer movements to
|
||||
* ZenMouseTracker::OnNativePointerMove, including movements outside of any
|
||||
* of our windows.
|
||||
*/
|
||||
class ZenNativeMouseMonitor final {
|
||||
public:
|
||||
#ifdef NS_ZEN_CAN_TRACK_POINTER
|
||||
/**
|
||||
* @brief Start delivering pointer moves. Safe to call while already
|
||||
* started.
|
||||
* @throws NS_ERROR_NOT_AVAILABLE when the platform cannot observe the
|
||||
* global pointer (e.g. on Linux).
|
||||
*/
|
||||
static nsresult Start();
|
||||
/**
|
||||
* @brief Stop delivering pointer moves. Safe to call while stopped.
|
||||
*/
|
||||
static void Stop();
|
||||
#else
|
||||
static nsresult Start() { return NS_ERROR_NOT_AVAILABLE; }
|
||||
static void Stop() {}
|
||||
#endif
|
||||
|
||||
ZenNativeMouseMonitor() = delete;
|
||||
};
|
||||
|
||||
} // namespace zen
|
||||
|
||||
#endif
|
||||
51
src/zen/compact-mode/ZenMouseTrackerWin.cpp
Normal file
51
src/zen/compact-mode/ZenMouseTrackerWin.cpp
Normal file
@@ -0,0 +1,51 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
#include "ZenMouseTracker.h"
|
||||
#include "ZenMouseTrackerInternal.h"
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
namespace zen {
|
||||
|
||||
static HHOOK sMouseHook = nullptr;
|
||||
|
||||
// Runs on the thread that installed the hook (the main thread). Keep this as
|
||||
// light as possible: the system silently removes hooks that take too long.
|
||||
static LRESULT CALLBACK MouseHookProc(int aCode, WPARAM aWParam,
|
||||
LPARAM aLParam) {
|
||||
if (aCode == HC_ACTION && aWParam == WM_MOUSEMOVE) {
|
||||
const auto* info = reinterpret_cast<MSLLHOOKSTRUCT*>(aLParam);
|
||||
// Screen physical pixels, which are what desktop pixels map to on Windows
|
||||
ZenMouseTracker::OnNativePointerMove(
|
||||
mozilla::DesktopPoint(float(info->pt.x), float(info->pt.y)));
|
||||
}
|
||||
return ::CallNextHookEx(nullptr, aCode, aWParam, aLParam);
|
||||
}
|
||||
|
||||
nsresult ZenNativeMouseMonitor::Start() {
|
||||
if (sMouseHook) {
|
||||
return NS_OK;
|
||||
}
|
||||
// Pass the module that actually contains the hook proc (xul.dll), not the
|
||||
// executable's module
|
||||
HMODULE module = nullptr;
|
||||
if (!::GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
|
||||
GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
|
||||
reinterpret_cast<LPCWSTR>(&MouseHookProc),
|
||||
&module)) {
|
||||
return NS_ERROR_NOT_AVAILABLE;
|
||||
}
|
||||
sMouseHook = ::SetWindowsHookExW(WH_MOUSE_LL, MouseHookProc, module, 0);
|
||||
return sMouseHook ? NS_OK : NS_ERROR_NOT_AVAILABLE;
|
||||
}
|
||||
|
||||
void ZenNativeMouseMonitor::Stop() {
|
||||
if (sMouseHook) {
|
||||
::UnhookWindowsHookEx(sMouseHook);
|
||||
sMouseHook = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace zen
|
||||
14
src/zen/compact-mode/components.conf
Normal file
14
src/zen/compact-mode/components.conf
Normal file
@@ -0,0 +1,14 @@
|
||||
# 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/.
|
||||
|
||||
Classes = [
|
||||
{
|
||||
'cid': '{03d900f7-639f-4c81-b029-2883dd893ccb}',
|
||||
'interfaces': ['nsIZenMouseTracker'],
|
||||
'contract_ids': ['@mozilla.org/zen/mouse-tracker;1'],
|
||||
'type': 'zen::ZenMouseTracker',
|
||||
'headers': ['mozilla/ZenMouseTracker.h'],
|
||||
'processes': ProcessSelector.MAIN_PROCESS_ONLY,
|
||||
},
|
||||
]
|
||||
37
src/zen/compact-mode/moz.build
Normal file
37
src/zen/compact-mode/moz.build
Normal file
@@ -0,0 +1,37 @@
|
||||
# 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/.
|
||||
|
||||
XPIDL_SOURCES += [
|
||||
"nsIZenMouseTracker.idl",
|
||||
]
|
||||
|
||||
EXPORTS.mozilla += [
|
||||
"ZenMouseTracker.h",
|
||||
"ZenMouseTrackerInternal.h",
|
||||
]
|
||||
|
||||
SOURCES += [
|
||||
"ZenMouseTracker.cpp",
|
||||
]
|
||||
|
||||
XPCOM_MANIFESTS += [
|
||||
"components.conf",
|
||||
]
|
||||
|
||||
LOCAL_INCLUDES += [
|
||||
"/widget",
|
||||
]
|
||||
|
||||
if CONFIG["MOZ_WIDGET_TOOLKIT"] == "cocoa":
|
||||
SOURCES += ["ZenMouseTrackerCocoa.mm"]
|
||||
LOCAL_INCLUDES += [
|
||||
"/widget/cocoa",
|
||||
"/xpcom/base",
|
||||
]
|
||||
|
||||
if CONFIG["MOZ_WIDGET_TOOLKIT"] == "windows":
|
||||
SOURCES += ["ZenMouseTrackerWin.cpp"]
|
||||
|
||||
FINAL_LIBRARY = "xul"
|
||||
XPIDL_MODULE = "zen_compact_mode"
|
||||
41
src/zen/compact-mode/nsIZenMouseTracker.idl
Normal file
41
src/zen/compact-mode/nsIZenMouseTracker.idl
Normal file
@@ -0,0 +1,41 @@
|
||||
/* 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/. */
|
||||
|
||||
#include "nsISupports.idl"
|
||||
|
||||
interface mozIDOMWindowProxy;
|
||||
|
||||
/**
|
||||
* @brief Tracks the OS pointer for windows the pointer has left.
|
||||
* Once the pointer either crosses the window bounds on the
|
||||
* axis perpendicular to the registered edge, or moves further than the
|
||||
* given offset past that edge, the "zen-mouse-tracker:exited" observer
|
||||
* notification is fired with the window as subject and tracking for that
|
||||
* window stops.
|
||||
*/
|
||||
[scriptable, uuid(023e85e6-44e1-46b3-9cfb-6f3c7fa075f8)]
|
||||
interface nsIZenMouseTracker : nsISupports {
|
||||
/**
|
||||
* @brief Start tracking the pointer for the given window.
|
||||
*
|
||||
* Re-registering a window updates its edge and offset. Tracking is
|
||||
* automatically cleared when the window is closed.
|
||||
*
|
||||
* @param window The window the pointer just left.
|
||||
* @param screenEdge The edge the pointer left through, one of "left",
|
||||
* "right", "top" or "bottom".
|
||||
* @param maxEdgeOffset How far (in CSS pixels) the pointer may travel past
|
||||
* screenEdge before the exit notification is fired.
|
||||
* @throws NS_ERROR_NOT_AVAILABLE when the platform cannot track the global
|
||||
* pointer position (e.g. on Linux).
|
||||
*/
|
||||
void registerWindow(in mozIDOMWindowProxy window, in ACString screenEdge,
|
||||
in float maxEdgeOffset);
|
||||
|
||||
/**
|
||||
* @brief Stop tracking the pointer for the given window. No-op if the
|
||||
* window isn't being tracked.
|
||||
*/
|
||||
void unregisterWindow(in mozIDOMWindowProxy window);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,57 @@
|
||||
#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>
|
||||
# include "mozilla/WidgetUtilsGtk.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);
|
||||
GdkDevice* pointer = mozilla::widget::GdkGetPointer();
|
||||
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 +94,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
|
||||
|
||||
44
src/zen/drag-and-drop/nsZenNativeWindowMove.mm
Normal file
44
src/zen/drag-and-drop/nsZenNativeWindowMove.mm
Normal 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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ EXTRA_PP_COMPONENTS += [
|
||||
DIRS += [
|
||||
"boosts",
|
||||
"common",
|
||||
"compact-mode",
|
||||
"drag-and-drop",
|
||||
"glance",
|
||||
"live-folders",
|
||||
@@ -21,4 +22,5 @@ DIRS += [
|
||||
"spaces",
|
||||
"space-routing",
|
||||
"sync",
|
||||
"window-drag",
|
||||
]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
11
src/zen/tests/mochitests/shell/canvas.html
Normal file
11
src/zen/tests/mochitests/shell/canvas.html
Normal 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>
|
||||
657
src/zen/tests/mochitests/shell/unit/test_dynamicLauncher.js
Normal file
657
src/zen/tests/mochitests/shell/unit/test_dynamicLauncher.js
Normal 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."
|
||||
);
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
@@ -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'"
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
10
src/zen/tests/window_drag/browser.toml
Normal file
10
src/zen/tests/window_drag/browser.toml
Normal 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"]
|
||||
109
src/zen/tests/window_drag/browser_window_drag_basic.js
Normal file
109
src/zen/tests/window_drag/browser_window_drag_basic.js
Normal 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"
|
||||
);
|
||||
});
|
||||
});
|
||||
57
src/zen/tests/window_drag/head.js
Normal file
57
src/zen/tests/window_drag/head.js
Normal 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);
|
||||
}
|
||||
330
src/zen/window-drag/actors/ZenWindowDragChild.sys.mjs
Normal file
330
src/zen/window-drag/actors/ZenWindowDragChild.sys.mjs
Normal file
@@ -0,0 +1,330 @@
|
||||
// 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",
|
||||
10
|
||||
);
|
||||
|
||||
XPCOMUtils.defineLazyServiceGetter(
|
||||
lazy,
|
||||
"zenWindowDragUtils",
|
||||
"@mozilla.org/zen/window-drag-utils;1",
|
||||
Ci.nsIZenWindowDragUtils
|
||||
);
|
||||
|
||||
// Movement below this is considered a click, not a window drag. Fast
|
||||
// clicks commonly slide a few pixels (especially on trackpads), and once
|
||||
// the native move starts the OS swallows the mouseup — so keep this
|
||||
// comfortably above click jitter or clicks in the region get lost.
|
||||
const DRAG_START_THRESHOLD_PX = 10;
|
||||
|
||||
// Content that drives its own mouse interaction without being
|
||||
// interactive HTML content in the spec sense.
|
||||
const kAppContentTags = new Set(["audio", "canvas", "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) || this.#isOverSelectableText(event)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A drag starting over selectable text should select it, not move the
|
||||
* window. rangeParent is the caret position Gecko computed for the
|
||||
* event, so this also covers empty space on the same line, where
|
||||
* dragging extends a selection.
|
||||
*
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
#isOverSelectableText(event) {
|
||||
const node = event.rangeParent;
|
||||
if (node?.nodeType !== Node.TEXT_NODE) {
|
||||
return false;
|
||||
}
|
||||
const parent = node.parentElement;
|
||||
return (
|
||||
!parent ||
|
||||
this.contentWindow.getComputedStyle(parent).userSelect !== "none"
|
||||
);
|
||||
}
|
||||
|
||||
#isInteractiveElement(element) {
|
||||
// Gecko's own notion of interactive, editable or draggable content.
|
||||
if (lazy.zenWindowDragUtils.isInteractiveContent(element)) {
|
||||
return true;
|
||||
}
|
||||
if (kAppContentTags.has(element.localName)) {
|
||||
return true;
|
||||
}
|
||||
// Declarative signals the engine check doesn't cover: ARIA widget
|
||||
// roles and explicit tab stops.
|
||||
if (
|
||||
element.tabIndex >= 0 &&
|
||||
element !== this.document.body &&
|
||||
element !== this.document.documentElement
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const role = element.getAttribute?.("role");
|
||||
return !!role && kInteractiveRoles.has(role.toLowerCase());
|
||||
}
|
||||
|
||||
#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);
|
||||
}
|
||||
}
|
||||
33
src/zen/window-drag/actors/ZenWindowDragParent.sys.mjs
Normal file
33
src/zen/window-drag/actors/ZenWindowDragParent.sys.mjs
Normal 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);
|
||||
}
|
||||
}
|
||||
13
src/zen/window-drag/components.conf
Normal file
13
src/zen/window-drag/components.conf
Normal file
@@ -0,0 +1,13 @@
|
||||
# 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/.
|
||||
|
||||
Classes = [
|
||||
{
|
||||
'cid': '{13df8214-70b4-4d7b-af8c-86081cbc5801}',
|
||||
'interfaces': ['nsIZenWindowDragUtils'],
|
||||
'contract_ids': ['@mozilla.org/zen/window-drag-utils;1'],
|
||||
'type': 'zen::nsZenWindowDragUtils',
|
||||
'headers': ['mozilla/nsZenWindowDragUtils.h'],
|
||||
},
|
||||
]
|
||||
27
src/zen/window-drag/moz.build
Normal file
27
src/zen/window-drag/moz.build
Normal file
@@ -0,0 +1,27 @@
|
||||
# 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",
|
||||
]
|
||||
|
||||
XPIDL_SOURCES += [
|
||||
"nsIZenWindowDragUtils.idl",
|
||||
]
|
||||
|
||||
EXPORTS.mozilla += [
|
||||
"nsZenWindowDragUtils.h",
|
||||
]
|
||||
|
||||
SOURCES += [
|
||||
"nsZenWindowDragUtils.cpp",
|
||||
]
|
||||
|
||||
XPCOM_MANIFESTS += [
|
||||
"components.conf",
|
||||
]
|
||||
|
||||
FINAL_LIBRARY = "xul"
|
||||
XPIDL_MODULE = "zen_window_drag"
|
||||
23
src/zen/window-drag/nsIZenWindowDragUtils.idl
Normal file
23
src/zen/window-drag/nsIZenWindowDragUtils.idl
Normal file
@@ -0,0 +1,23 @@
|
||||
/* 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/. */
|
||||
|
||||
#include "nsISupports.idl"
|
||||
|
||||
webidl Node;
|
||||
|
||||
/**
|
||||
* @brief Utilities for deciding when a content-area gesture may move
|
||||
* the window.
|
||||
*/
|
||||
[scriptable, uuid(595ba6e8-abe2-4619-acad-cf492306f608)]
|
||||
interface nsIZenWindowDragUtils : nsISupports {
|
||||
/**
|
||||
* @brief Whether the node is content the page expects the user to
|
||||
* interact with: interactive HTML content (links, form controls,
|
||||
* etc.), editable content, or content that can be dragged
|
||||
* (draggable attribute, links and loaded images).
|
||||
* @param node The node to check.
|
||||
*/
|
||||
boolean isInteractiveContent(in Node node);
|
||||
};
|
||||
33
src/zen/window-drag/nsZenWindowDragUtils.cpp
Normal file
33
src/zen/window-drag/nsZenWindowDragUtils.cpp
Normal 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/. */
|
||||
|
||||
#include "nsZenWindowDragUtils.h"
|
||||
|
||||
#include "mozilla/dom/Element.h"
|
||||
#include "nsContentUtils.h"
|
||||
#include "nsIContent.h"
|
||||
|
||||
namespace zen {
|
||||
|
||||
NS_IMPL_ISUPPORTS(nsZenWindowDragUtils, nsIZenWindowDragUtils)
|
||||
|
||||
NS_IMETHODIMP
|
||||
nsZenWindowDragUtils::IsInteractiveContent(nsINode* aNode, bool* aResult) {
|
||||
*aResult = false;
|
||||
NS_ENSURE_ARG_POINTER(aNode);
|
||||
|
||||
nsIContent* content = nsIContent::FromNode(aNode);
|
||||
if (!content) {
|
||||
return NS_OK;
|
||||
}
|
||||
if (content->IsEditable() || nsContentUtils::ContentIsDraggable(content)) {
|
||||
*aResult = true;
|
||||
return NS_OK;
|
||||
}
|
||||
mozilla::dom::Element* element = mozilla::dom::Element::FromNode(content);
|
||||
*aResult = element && element->IsInteractiveHTMLContent();
|
||||
return NS_OK;
|
||||
}
|
||||
|
||||
} // namespace zen
|
||||
27
src/zen/window-drag/nsZenWindowDragUtils.h
Normal file
27
src/zen/window-drag/nsZenWindowDragUtils.h
Normal file
@@ -0,0 +1,27 @@
|
||||
/* 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/. */
|
||||
|
||||
#ifndef zen_nsZenWindowDragUtils_h_
|
||||
#define zen_nsZenWindowDragUtils_h_
|
||||
|
||||
#include "nsIZenWindowDragUtils.h"
|
||||
|
||||
#define ZEN_WINDOW_DRAG_UTILS_CONTRACTID "@mozilla.org/zen/window-drag-utils;1"
|
||||
|
||||
namespace zen {
|
||||
|
||||
class nsZenWindowDragUtils final : public nsIZenWindowDragUtils {
|
||||
NS_DECL_ISUPPORTS
|
||||
NS_DECL_NSIZENWINDOWDRAGUTILS
|
||||
|
||||
public:
|
||||
nsZenWindowDragUtils() = default;
|
||||
|
||||
private:
|
||||
~nsZenWindowDragUtils() = default;
|
||||
};
|
||||
|
||||
} // namespace zen
|
||||
|
||||
#endif
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user