Compare commits

..

32 Commits

Author SHA1 Message Date
mr. m
73ed322695 gh-14843: Prevent quick audio tracks from creating a new controller (gh-14871) 2026-08-07 02:01:57 +02:00
Ashvin Jangid
e7a3e1ee29 gh-14862: Fix tab switching when switching spaces with mouse wheel (gh-14863)
Co-authored-by: mr. m <91018726+mr-cheffy@users.noreply.github.com>
2026-08-06 20:29:00 +02:00
Ashvin Jangid
d6c86f570a gh-13761: Fix closing split tab in one window changing focus of other windows (gh-14855)
Co-authored-by: mr. m <mr.m@tuta.com>
2026-08-06 02:04:58 +02:00
mr. m
0e5f2dcd11 gh-14843: Fix media players height looking off if they differ from each other (gh-14853) 2026-08-05 16:19:13 +02:00
mr. m
c0ade4d063 gh-14841: Bump version (gh-14852) 2026-08-05 15:11:41 +02:00
Lokendra singh
11acda2db3 gh-14483: Fix boost font override breaking certain icons (gh-14819) 2026-08-05 11:00:46 +02:00
dpkass
77aa6ff6c9 gh-14847: Add draft filtering to GitHub Live Folders (gh-14839) 2026-08-05 02:52:14 +02:00
mr. m
871bdde4cc gh-14841: Sync upstream Firefox to version 153.0.3 (gh-14842) 2026-08-05 02:44:01 +02:00
mr. m
b3bd688ca4 gh-14843: Allow multiple media controllers (gh-14844) 2026-08-04 19:55:53 +02:00
mr. m
36c71c5c2b gh-14830: Fixed dragging on text makes the entire window to move (gh-14833) 2026-08-04 01:32:36 +02:00
mr. m
c74486102f gh-13108: Use mozilla API for gdkGetPointer (gh-14823) 2026-08-03 03:53:25 +02:00
mr. m
045ebdf0ae gh-13108: Start using std::atomic instead of mozilla::Atomic (gh-14820) 2026-08-02 21:14:14 +02:00
mr. m
bc540320f1 gh-14703: Allow top section of sites be draggable (gh-14816) 2026-08-02 20:28:06 +02:00
mr. m
9714c15c7a gh-13108: Make compact mode track mouse moves outside of window (gh-14818) 2026-08-02 20:24:58 +02:00
Ashvin Jangid
ccbd934482 gh-14805: Fix blank labels in keyboard shortcuts page (gh-14807)
Co-authored-by: mr. m <91018726+mr-cheffy@users.noreply.github.com>
2026-07-31 23:37:23 +02:00
Connor Griffin
1d05184a2a gh-9918: Fixes issue setting keybinds with shift + digit or symbol (gh-14357) 2026-07-31 23:23:55 +02:00
Brandon Quon
45e47d56df gh-14770: Fix Bookmark menu styling (gh-14782)
restores the normal Firefox layout in Bookmarks > Manage Bookmarks.

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


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

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

View File

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

View File

@@ -1 +1 @@
b1d3fe8f65751e6d695fa9ef94b11ad81e70dac1
8a0a534248f80d8c53a8997f24683521c3a50ebc

View File

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

View File

@@ -151,6 +151,9 @@ zen-sidebar-notification-updated-heading = Update Complete!
zen-sidebar-notification-updated-label = What's new in { -brand-short-name }
zen-sidebar-notification-updated-tooltip =
.title = View Release Notes
zen-sidebar-notification-donate-label = Support { -brand-short-name }
zen-sidebar-notification-donate-tooltip =
.title = Donate to the project
zen-sidebar-notification-restart-safe-mode-label = Something broke?
zen-sidebar-notification-restart-safe-mode-tooltip =
.title = Restart in Safe Mode

View File

@@ -20,6 +20,9 @@ zen-live-folder-github-option-assigned-self =
zen-live-folder-github-option-review-requested =
.label = Review Requests
zen-live-folder-github-option-include-drafts =
.label = Include Draft Pull Requests
zen-live-folder-type-rss =
.label = RSS Feed

View File

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

View File

@@ -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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
diff --git a/browser/components/sessionstore/SessionStore.sys.mjs b/browser/components/sessionstore/SessionStore.sys.mjs
index 183543c5f29ff4c879d3058e4c09faf376a69cb7..ab482c32cc697ccef82bf0aeaa0e3a1db40aecce 100644
index 11191a82a0f0dc6eee60f9de52a63bc6085c3981..f995c3b297c53aa6ee2219bfc09515ee4aa3be28 100644
--- a/browser/components/sessionstore/SessionStore.sys.mjs
+++ b/browser/components/sessionstore/SessionStore.sys.mjs
@@ -129,6 +129,9 @@ const TAB_EVENTS = [
@@ -12,7 +12,7 @@ index 183543c5f29ff4c879d3058e4c09faf376a69cb7..ab482c32cc697ccef82bf0aeaa0e3a1d
];
const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
@@ -199,6 +202,8 @@ ChromeUtils.defineESModuleGetters(lazy, {
@@ -201,6 +204,8 @@ ChromeUtils.defineESModuleGetters(lazy, {
TabStateCache: "resource:///modules/sessionstore/TabStateCache.sys.mjs",
TabStateFlusher: "resource:///modules/sessionstore/TabStateFlusher.sys.mjs",
setTimeout: "resource://gre/modules/Timer.sys.mjs",
@@ -21,7 +21,7 @@ index 183543c5f29ff4c879d3058e4c09faf376a69cb7..ab482c32cc697ccef82bf0aeaa0e3a1d
});
ChromeUtils.defineLazyGetter(lazy, "blankURI", () => {
@@ -1287,10 +1292,7 @@ var SessionStoreInternal = {
@@ -1300,10 +1305,7 @@ var SessionStoreInternal = {
*/
get willAutoRestore() {
return (
@@ -33,7 +33,7 @@ index 183543c5f29ff4c879d3058e4c09faf376a69cb7..ab482c32cc697ccef82bf0aeaa0e3a1d
);
},
@@ -1965,6 +1967,9 @@ var SessionStoreInternal = {
@@ -1990,6 +1992,9 @@ var SessionStoreInternal = {
case "TabPinned":
case "TabUnpinned":
case "SwapDocShells":
@@ -43,7 +43,7 @@ index 183543c5f29ff4c879d3058e4c09faf376a69cb7..ab482c32cc697ccef82bf0aeaa0e3a1d
this.saveStateDelayed(win);
break;
case "TabGroupCreate":
@@ -2074,6 +2079,10 @@ var SessionStoreInternal = {
@@ -2099,6 +2104,10 @@ var SessionStoreInternal = {
this._windows[aWindow.__SSi].isTaskbarTab = true;
}
@@ -54,7 +54,7 @@ index 183543c5f29ff4c879d3058e4c09faf376a69cb7..ab482c32cc697ccef82bf0aeaa0e3a1d
if (lazy.AIWindow.isAIWindowActiveAndEnabled(aWindow)) {
this._windows[aWindow.__SSi].isAIWindow = true;
}
@@ -2110,7 +2119,7 @@ var SessionStoreInternal = {
@@ -2135,7 +2144,7 @@ var SessionStoreInternal = {
let isTaskbarTab = this._windows[aWindow.__SSi].isTaskbarTab;
// A regular window is not a private window, taskbar tab window, or popup window
let isRegularWindow =
@@ -63,7 +63,7 @@ index 183543c5f29ff4c879d3058e4c09faf376a69cb7..ab482c32cc697ccef82bf0aeaa0e3a1d
// perform additional initialization when the first window is loading
if (lazy.RunState.isStopped) {
@@ -2122,7 +2131,7 @@ var SessionStoreInternal = {
@@ -2147,7 +2156,7 @@ var SessionStoreInternal = {
// to disk to NOW() to enforce a full interval before the next write.
lazy.SessionSaver.updateLastSaveTime();
@@ -72,7 +72,7 @@ index 183543c5f29ff4c879d3058e4c09faf376a69cb7..ab482c32cc697ccef82bf0aeaa0e3a1d
this._log.debug(
"initializeWindow, the window is private or a web app. Saving SessionStartup.state for possibly restoring later"
);
@@ -2164,6 +2173,7 @@ var SessionStoreInternal = {
@@ -2191,6 +2200,7 @@ var SessionStoreInternal = {
null,
"sessionstore-one-or-no-tab-restored"
);
@@ -80,7 +80,7 @@ index 183543c5f29ff4c879d3058e4c09faf376a69cb7..ab482c32cc697ccef82bf0aeaa0e3a1d
this._deferredAllWindowsRestored.resolve();
}
// this window was opened by _openWindowWithState
@@ -2213,7 +2223,6 @@ var SessionStoreInternal = {
@@ -2240,7 +2250,6 @@ var SessionStoreInternal = {
if (closedWindowState) {
let newWindowState;
if (
@@ -88,7 +88,7 @@ index 183543c5f29ff4c879d3058e4c09faf376a69cb7..ab482c32cc697ccef82bf0aeaa0e3a1d
!lazy.SessionStartup.willRestore()
) {
// We want to split the window up into pinned tabs and unpinned tabs.
@@ -2249,6 +2258,7 @@ var SessionStoreInternal = {
@@ -2276,6 +2285,7 @@ var SessionStoreInternal = {
}
if (newWindowState) {
@@ -96,23 +96,17 @@ index 183543c5f29ff4c879d3058e4c09faf376a69cb7..ab482c32cc697ccef82bf0aeaa0e3a1d
// Ensure that the window state isn't hidden
this._restoreCount = 1;
let state = { windows: [newWindowState] };
@@ -2277,6 +2287,15 @@ var SessionStoreInternal = {
@@ -2312,6 +2322,9 @@ var SessionStoreInternal = {
});
this._shouldRestoreLastSession = false;
}
+ else if (!aInitialState && isRegularWindow) {
+ let windowPromises = [];
+ for (let window of this._browserWindows) {
+ windowPromises.push(lazy.TabStateFlusher.flushWindow(window));
+ }
+ Promise.all(windowPromises).finally(() => {
+ lazy.ZenSessionStore.restoreNewWindow(aWindow, this);
+ });
+ lazy.ZenSessionStore.restoreNewWindow(aWindow, this);
+ }
if (this._restoreLastWindow && aWindow.toolbar.visible) {
// always reset (if not a popup window)
@@ -2427,7 +2446,7 @@ var SessionStoreInternal = {
@@ -2462,7 +2475,7 @@ var SessionStoreInternal = {
var tabbrowser = aWindow.gBrowser;
@@ -121,7 +115,7 @@ index 183543c5f29ff4c879d3058e4c09faf376a69cb7..ab482c32cc697ccef82bf0aeaa0e3a1d
TAB_EVENTS.forEach(function (aEvent) {
tabbrowser.tabContainer.removeEventListener(aEvent, this, true);
@@ -2478,7 +2497,7 @@ var SessionStoreInternal = {
@@ -2513,7 +2526,7 @@ var SessionStoreInternal = {
let isLastRegularWindow =
Object.values(this._windows).filter(
@@ -130,7 +124,7 @@ index 183543c5f29ff4c879d3058e4c09faf376a69cb7..ab482c32cc697ccef82bf0aeaa0e3a1d
).length == 1;
this._log.debug(
`onClose, closing window isLastRegularWindow? ${isLastRegularWindow}`
@@ -2535,8 +2554,8 @@ var SessionStoreInternal = {
@@ -2570,8 +2583,8 @@ var SessionStoreInternal = {
// 2) Flush the window.
// 3) When the flush is complete, revisit our decision to store the window
// in _closedWindows, and add/remove as necessary.
@@ -141,7 +135,7 @@ index 183543c5f29ff4c879d3058e4c09faf376a69cb7..ab482c32cc697ccef82bf0aeaa0e3a1d
}
completionPromise = lazy.TabStateFlusher.flushWindow(aWindow).then(() => {
@@ -2556,8 +2575,9 @@ var SessionStoreInternal = {
@@ -2591,8 +2604,9 @@ var SessionStoreInternal = {
// Save non-private windows if they have at
// least one saveable tab or are the last window.
@@ -153,7 +147,7 @@ index 183543c5f29ff4c879d3058e4c09faf376a69cb7..ab482c32cc697ccef82bf0aeaa0e3a1d
if (!isLastWindow && winData.closedId > -1) {
this._addClosedAction(
@@ -2633,7 +2653,7 @@ var SessionStoreInternal = {
@@ -2668,7 +2682,7 @@ var SessionStoreInternal = {
* to call this method again asynchronously (for example, after
* a window flush).
*/
@@ -162,7 +156,7 @@ index 183543c5f29ff4c879d3058e4c09faf376a69cb7..ab482c32cc697ccef82bf0aeaa0e3a1d
// Make sure SessionStore is still running, and make sure that we
// haven't chosen to forget this window.
if (
@@ -2650,6 +2670,7 @@ var SessionStoreInternal = {
@@ -2685,6 +2699,7 @@ var SessionStoreInternal = {
// _closedWindows from a previous call to this function.
let winIndex = this._closedWindows.indexOf(winData);
let alreadyStored = winIndex != -1;
@@ -170,7 +164,7 @@ index 183543c5f29ff4c879d3058e4c09faf376a69cb7..ab482c32cc697ccef82bf0aeaa0e3a1d
// If sidebar command is truthy, i.e. sidebar is open, store sidebar settings
let shouldStore = hasSaveableTabs || isLastWindow;
@@ -3467,7 +3488,7 @@ var SessionStoreInternal = {
@@ -3507,7 +3522,7 @@ var SessionStoreInternal = {
if (!isPrivateWindow && tabState.isPrivate) {
return;
}
@@ -179,7 +173,7 @@ index 183543c5f29ff4c879d3058e4c09faf376a69cb7..ab482c32cc697ccef82bf0aeaa0e3a1d
return;
}
@@ -4206,6 +4227,12 @@ var SessionStoreInternal = {
@@ -4246,6 +4261,12 @@ var SessionStoreInternal = {
Math.min(tabState.index, tabState.entries.length)
);
tabState.pinned = false;
@@ -192,7 +186,7 @@ index 183543c5f29ff4c879d3058e4c09faf376a69cb7..ab482c32cc697ccef82bf0aeaa0e3a1d
if (inBackground === false) {
aWindow.gBrowser.selectedTab = newTab;
@@ -4642,6 +4669,8 @@ var SessionStoreInternal = {
@@ -4679,6 +4700,8 @@ var SessionStoreInternal = {
// Append the tab if we're opening into a different window,
tabIndex: aSource == aTargetWindow ? pos : Infinity,
pinned: state.pinned,
@@ -201,7 +195,7 @@ index 183543c5f29ff4c879d3058e4c09faf376a69cb7..ab482c32cc697ccef82bf0aeaa0e3a1d
userContextId: state.userContextId,
skipLoad: true,
preferredRemoteType,
@@ -5146,9 +5175,10 @@ var SessionStoreInternal = {
@@ -5176,9 +5199,10 @@ var SessionStoreInternal = {
if (activePageData.title && activePageData.title != activePageData.url) {
win.gBrowser.setInitialTabTitle(tab, activePageData.title, {
isContentTitle: true,
@@ -213,7 +207,7 @@ index 183543c5f29ff4c879d3058e4c09faf376a69cb7..ab482c32cc697ccef82bf0aeaa0e3a1d
}
}
@@ -5513,7 +5543,7 @@ var SessionStoreInternal = {
@@ -5543,7 +5567,7 @@ var SessionStoreInternal = {
for (let i = tabbrowser.pinnedTabCount; i < tabbrowser.tabs.length; i++) {
let tab = tabbrowser.tabs[i];
@@ -222,7 +216,7 @@ index 183543c5f29ff4c879d3058e4c09faf376a69cb7..ab482c32cc697ccef82bf0aeaa0e3a1d
removableTabs.push(tab);
}
}
@@ -5626,7 +5656,7 @@ var SessionStoreInternal = {
@@ -5656,7 +5680,7 @@ var SessionStoreInternal = {
// collect the data for all windows
for (ix in this._windows) {
@@ -231,7 +225,7 @@ index 183543c5f29ff4c879d3058e4c09faf376a69cb7..ab482c32cc697ccef82bf0aeaa0e3a1d
// window data is still in _statesToRestore
continue;
}
@@ -5770,11 +5800,12 @@ var SessionStoreInternal = {
@@ -5800,11 +5824,12 @@ var SessionStoreInternal = {
}
let tabbrowser = aWindow.gBrowser;
@@ -245,7 +239,7 @@ index 183543c5f29ff4c879d3058e4c09faf376a69cb7..ab482c32cc697ccef82bf0aeaa0e3a1d
// update the internal state data for this window
for (let tab of tabs) {
if (tab == aWindow.FirefoxViewHandler.tab) {
@@ -5785,6 +5816,9 @@ var SessionStoreInternal = {
@@ -5815,6 +5840,9 @@ var SessionStoreInternal = {
tabsData.push(tabData);
}
@@ -255,7 +249,7 @@ index 183543c5f29ff4c879d3058e4c09faf376a69cb7..ab482c32cc697ccef82bf0aeaa0e3a1d
// update tab group state for this window
winData.groups = [];
for (let tabGroup of aWindow.gBrowser.tabGroups) {
@@ -5801,7 +5835,7 @@ var SessionStoreInternal = {
@@ -5831,7 +5859,7 @@ var SessionStoreInternal = {
// a window is closed, point to the first item in the tab strip instead (it will never be the Firefox View tab,
// since it's only inserted into the tab strip after it's selected).
if (aWindow.FirefoxViewHandler.tab?.selected) {
@@ -264,7 +258,7 @@ index 183543c5f29ff4c879d3058e4c09faf376a69cb7..ab482c32cc697ccef82bf0aeaa0e3a1d
winData.title = tabbrowser.tabs[0].label;
}
winData.selected = selectedIndex;
@@ -6003,8 +6037,8 @@ var SessionStoreInternal = {
@@ -6033,8 +6061,8 @@ var SessionStoreInternal = {
// selectTab represents.
let selectTab = 0;
if (overwriteTabs) {
@@ -275,7 +269,7 @@ index 183543c5f29ff4c879d3058e4c09faf376a69cb7..ab482c32cc697ccef82bf0aeaa0e3a1d
selectTab = Math.min(selectTab, winData.tabs.length);
}
@@ -6026,6 +6060,7 @@ var SessionStoreInternal = {
@@ -6056,6 +6084,7 @@ var SessionStoreInternal = {
if (overwriteTabs) {
for (let i = tabbrowser.browsers.length - 1; i >= 0; i--) {
if (!tabbrowser.tabs[i].selected) {
@@ -283,7 +277,7 @@ index 183543c5f29ff4c879d3058e4c09faf376a69cb7..ab482c32cc697ccef82bf0aeaa0e3a1d
tabbrowser.removeTab(tabbrowser.tabs[i]);
}
}
@@ -6060,6 +6095,12 @@ var SessionStoreInternal = {
@@ -6090,6 +6119,12 @@ var SessionStoreInternal = {
savedTabGroup => !openTabGroupIdsInWindow.has(savedTabGroup.id)
);
}
@@ -296,7 +290,7 @@ index 183543c5f29ff4c879d3058e4c09faf376a69cb7..ab482c32cc697ccef82bf0aeaa0e3a1d
// Move the originally open tabs to the end.
if (initialTabs) {
@@ -6626,6 +6667,25 @@ var SessionStoreInternal = {
@@ -6656,6 +6691,25 @@ var SessionStoreInternal = {
// Most of tabData has been restored, now continue with restoring
// attributes that may trigger external events.
@@ -322,7 +316,7 @@ index 183543c5f29ff4c879d3058e4c09faf376a69cb7..ab482c32cc697ccef82bf0aeaa0e3a1d
if (tabData.pinned) {
tabbrowser.pinTab(tab);
@@ -6793,6 +6853,9 @@ var SessionStoreInternal = {
@@ -6838,6 +6892,9 @@ var SessionStoreInternal = {
aWindow.gURLBar.readOnly = false;
}
}
@@ -332,7 +326,7 @@ index 183543c5f29ff4c879d3058e4c09faf376a69cb7..ab482c32cc697ccef82bf0aeaa0e3a1d
let promiseParts = Promise.withResolvers();
aWindow.setTimeout(() => {
@@ -7588,7 +7651,7 @@ var SessionStoreInternal = {
@@ -7633,7 +7690,7 @@ var SessionStoreInternal = {
let groupsToSave = new Map();
for (let tIndex = 0; tIndex < window.tabs.length; ) {
@@ -341,7 +335,7 @@ index 183543c5f29ff4c879d3058e4c09faf376a69cb7..ab482c32cc697ccef82bf0aeaa0e3a1d
// Adjust window.selected
if (tIndex + 1 < window.selected) {
window.selected -= 1;
@@ -7603,7 +7666,7 @@ var SessionStoreInternal = {
@@ -7648,7 +7705,7 @@ var SessionStoreInternal = {
);
// We don't want to increment tIndex here.
continue;
@@ -350,7 +344,7 @@ index 183543c5f29ff4c879d3058e4c09faf376a69cb7..ab482c32cc697ccef82bf0aeaa0e3a1d
// Convert any open groups into saved groups.
let groupStateToSave = window.groups.find(
groupState => groupState.id == window.tabs[tIndex].groupId
@@ -8062,7 +8125,6 @@ var SessionStoreInternal = {
@@ -8193,7 +8250,6 @@ var SessionStoreInternal = {
timer.initWithCallback(
function () {
if (beats <= 0) {
@@ -358,7 +352,7 @@ index 183543c5f29ff4c879d3058e4c09faf376a69cb7..ab482c32cc697ccef82bf0aeaa0e3a1d
Glean.sessionRestore.shutdownFlushAllOutcomes.timed_out.add(1);
deferred.resolve();
}
@@ -8540,6 +8602,7 @@ var SessionStoreInternal = {
@@ -8671,6 +8727,7 @@ var SessionStoreInternal = {
if (
!savedTabGroupState.tabs.length ||
this.getSavedTabGroup(savedTabGroupState.id)

View File

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

View File

@@ -1,5 +1,5 @@
diff --git a/browser/components/urlbar/content/UrlbarInput.mjs b/browser/components/urlbar/content/UrlbarInput.mjs
index 0b1b4b2f0f8d6b0fb126aa1224409ab6f1ffb8d8..942c6e1e3b6cd9de65df54a1d83884f2633c1284 100644
index 0b1b4b2f0f8d6b0fb126aa1224409ab6f1ffb8d8..4405e1bf385477c2161ae3a5a62e32c1403a3630 100644
--- a/browser/components/urlbar/content/UrlbarInput.mjs
+++ b/browser/components/urlbar/content/UrlbarInput.mjs
@@ -98,6 +98,13 @@ const lazy = XPCOMUtils.declareLazy({
@@ -292,7 +292,11 @@ index 0b1b4b2f0f8d6b0fb126aa1224409ab6f1ffb8d8..942c6e1e3b6cd9de65df54a1d83884f2
switch (event.target) {
case this.inputField:
case this._inputContainer:
@@ -5374,7 +5495,7 @@ ${
@@ -5371,10 +5492,11 @@ ${
}
if (untrim) {
this._setValue(this._untrimmedValue);
+ this.setSelectionRange(0, this.value.length);
}
}
@@ -301,7 +305,7 @@ index 0b1b4b2f0f8d6b0fb126aa1224409ab6f1ffb8d8..942c6e1e3b6cd9de65df54a1d83884f2
this.view.autoOpen({ event });
} else {
if (this._untrimOnFocusAfterKeydown) {
@@ -5414,9 +5535,16 @@ ${
@@ -5414,9 +5536,16 @@ ${
}
_on_mousedown(event) {
@@ -319,7 +323,7 @@ index 0b1b4b2f0f8d6b0fb126aa1224409ab6f1ffb8d8..942c6e1e3b6cd9de65df54a1d83884f2
if (
event.composedTarget != this.inputField &&
event.composedTarget != this._inputContainer
@@ -5426,6 +5554,10 @@ ${
@@ -5426,6 +5555,10 @@ ${
this.focusedViaMousedown = !this.focused;
this.#preventClickSelectsAll = this.focused;
@@ -330,7 +334,7 @@ index 0b1b4b2f0f8d6b0fb126aa1224409ab6f1ffb8d8..942c6e1e3b6cd9de65df54a1d83884f2
// Keep the focus status, since the attribute may be changed
// upon calling this.focus().
@@ -5461,7 +5593,7 @@ ${
@@ -5461,7 +5594,7 @@ ${
}
// Don't close the view when clicking on a tab; we may want to keep the
// view open on tab switch, and the TabSelect event arrived earlier.
@@ -339,7 +343,7 @@ index 0b1b4b2f0f8d6b0fb126aa1224409ab6f1ffb8d8..942c6e1e3b6cd9de65df54a1d83884f2
break;
}
@@ -5750,7 +5882,7 @@ ${
@@ -5750,7 +5883,7 @@ ${
// When we are in actions search mode we can show more results so
// increase the limit.
let maxResults =

View File

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

View File

@@ -816,61 +816,57 @@
display: none !important;
}
#zen-media-playpause-button {
.zen-media-playpause-button {
list-style-image: url("media-play.svg");
}
#zen-media-controls-toolbar.playing #zen-media-playpause-button {
.zen-media-card.playing .zen-media-playpause-button {
list-style-image: url("media-pause.svg");
}
#zen-media-nexttrack-button {
.zen-media-nexttrack-button {
list-style-image: url("media-next.svg");
}
#zen-media-previoustrack-button {
.zen-media-previoustrack-button {
list-style-image: url("media-previous.svg");
}
#zen-media-controls-toolbar[muted] #zen-media-mute-button {
.zen-media-card[muted] .zen-media-mute-button {
list-style-image: url("media-mute.svg");
}
#zen-media-mute-button {
.zen-media-mute-button {
list-style-image: url("media-unmute.svg");
}
#zen-media-close-button {
.zen-media-close-button {
list-style-image: url("close.svg");
}
#zen-media-focus-button:hover {
.zen-media-focus-button:hover {
list-style-image: url("screen.svg");
}
#zen-media-close-button {
list-style-image: url("close.svg");
}
#zen-media-mute-mic-button {
.zen-media-mute-mic-button {
list-style-image: url("microphone-fill.svg");
}
#zen-media-controls-toolbar[mic-muted] #zen-media-mute-mic-button {
.zen-media-card[mic-muted] .zen-media-mute-mic-button {
list-style-image: url("microphone-blocked-fill.svg");
fill: rgb(224, 41, 29);
}
#zen-media-mute-camera-button {
.zen-media-mute-camera-button {
list-style-image: url("video-fill.svg");
}
#zen-media-controls-toolbar[camera-muted] #zen-media-mute-camera-button {
.zen-media-card[camera-muted] .zen-media-mute-camera-button {
list-style-image: url("video-blocked-fill.svg");
fill: rgb(224, 41, 29);
}
#zen-media-pip-button {
.zen-media-pip-button {
list-style-image: url("chrome://global/skin/media/picture-in-picture-open.svg");
}

View File

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

View File

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

View File

@@ -81,8 +81,15 @@ export class nsZenBoostStyles {
}
if (fontCase != "" || fontFamily != "") {
const ignoredElements = [
".google-symbols",
"gf-load-icon-font",
"mat-icon",
".google-material-icons",
"span",
];
style += `/* Text Format */\n`;
style += `body *:not(.google-symbols, gf-load-icon-font, mat-icon, .google-material-icons) {\n`;
style += `body *:not(${ignoredElements.join(",")}) {\n`;
style += `${fontFamily}\n`;
style += `${fontCase}\n`;
style += `}\n`;

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ import checkForZenUpdates, {
} from "chrome://browser/content/ZenUpdates.mjs";
class ZenStartup {
#watermarkIgnoreElements = ["zen-toast-container"];
#watermarkIgnoreElements = ["zen-toast-container", "zen-browser-background"];
#hasInitializedLayout = false;
isReady = false;
@@ -53,12 +53,11 @@ class ZenStartup {
// overlap and interaction issues with vertical tabs
document.getElementById("browser").prepend(deckTemplate);
gZenWorkspaces.init();
setTimeout(() => {
gZenWorkspaces.init().then(() => {
gZenUIManager.init();
this.#initUIComponents();
this.#checkForWelcomePage();
}, 0);
});
} catch (e) {
console.error("ZenThemeModifier: Error initializing browser layout", e);
}
@@ -119,7 +118,9 @@ class ZenStartup {
document.documentElement.removeAttribute("zen-before-loaded");
return;
}
for (let elem of document.querySelectorAll("#browser > *, #urlbar")) {
for (let elem of document.querySelectorAll(
`#browser > *:not(${this.#watermarkIgnoreElements.map(id => "#" + id).join(", ")}), #urlbar`
)) {
elem.style.opacity = 0;
}
}

View File

@@ -32,6 +32,11 @@ export default function checkForZenUpdates() {
),
l10nId: "zen-sidebar-notification-updated",
special: true,
icon: "chrome://browser/skin/zen-icons/sparkles.svg",
},
{
url: "https://www.zen-browser.app/donate",
l10nId: "zen-sidebar-notification-donate",
icon: "chrome://browser/skin/zen-icons/heart-circle-fill.svg",
},
{

View File

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

View File

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

View File

@@ -19,6 +19,8 @@
flex-direction: column;
min-width: 0;
border-radius: var(--border-radius-medium);
outline: var(--zen-sidebar-notification-outline);
outline-offset: -1.5px;
box-shadow: var(--zen-sidebar-notification-shadow);
font-size: 12px;
}

View File

@@ -190,7 +190,7 @@
--urlbar-box-active-bgcolor: var(--toolbarbutton-hover-background) !important;
--zen-input-border-color: light-dark(rgb(204, 204, 204), rgb(66, 65, 77));
--urlbar-box-hover-bgcolor: var(--toolbarbutton-hover-background) !important;
--input-bgcolor: light-dark(rgba(255, 255, 255, 0.2), rgba(0, 0, 0, 0.2)) !important;
--input-text-background-color: light-dark(rgba(255, 255, 255, 0.2), rgba(0, 0, 0, 0.2)) !important;
--toolbar-field-focus-background-color: light-dark(
rgba(255, 255, 255, 0.25),
rgba(0, 0, 0, 0.3)
@@ -262,6 +262,7 @@
light-dark(white, black)
);
--zen-sidebar-notification-shadow: 0 0 6px light-dark(rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.3));
--zen-sidebar-notification-outline: 1px solid light-dark(transparent, rgba(255, 255, 255, 0.1));
--zen-sidebar-themed-icon-fill: light-dark(color-mix(in srgb, var(--zen-primary-color) 50%, black), color-mix(in srgb, var(--zen-colors-primary) 15%, #ebebeb));

View File

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

View File

@@ -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 },

View 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

View 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

View 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

View 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

View 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

View 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,
},
]

View 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"

View 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);
};

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,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

View File

@@ -0,0 +1,44 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
#import <Cocoa/Cocoa.h>
#include "nsDebug.h"
#include "nsIWidget.h"
#include "nsObjCExceptions.h"
namespace zen {
// Cocoa side of StartNativeWindowMove()
nsresult StartNativeWindowMoveCocoa(nsIWidget* aWidget) {
NS_OBJC_BEGIN_TRY_BLOCK_RETURN;
NSWindow* window =
static_cast<NSWindow*>(aWidget->GetNativeData(NS_NATIVE_WINDOW));
NS_ENSURE_TRUE(window, NS_ERROR_FAILURE);
// performWindowDragWithEvent: needs a left-button event to start the
// WindowServer drag session. The triggering mousedown happened in a
// content process, so synthesize an equivalent event at the current
// mouse location.
NSPoint location = [window convertPointFromScreen:[NSEvent mouseLocation]];
NSEvent* event =
[NSEvent mouseEventWithType:NSEventTypeLeftMouseDragged
location:location
modifierFlags:0
timestamp:NSProcessInfo.processInfo.systemUptime
windowNumber:window.windowNumber
context:nil
eventNumber:0
clickCount:1
pressure:1.0];
NS_ENSURE_TRUE(event, NS_ERROR_FAILURE);
[window performWindowDragWithEvent:event];
return NS_OK;
NS_OBJC_END_TRY_BLOCK_RETURN(NS_ERROR_FAILURE);
}
} // namespace zen

View File

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

View File

@@ -253,6 +253,13 @@ export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider {
"sort:updated-desc",
];
if (
this.state.type === "pull-requests" &&
!(this.state.options.includeDrafts ?? true)
) {
baseQuery.push("draft:false");
}
const options = [
{
value: "author:@me",
@@ -336,6 +343,12 @@ export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider {
checked: this.state.options.reviewRequested ?? false,
hidden: this.state.type === "issues",
},
{
l10nId: "zen-live-folder-github-option-include-drafts",
key: "includeDrafts",
checked: this.state.options.includeDrafts ?? true,
hidden: this.state.type !== "pull-requests",
},
{ type: "separator" },
{
l10nId: "zen-live-folder-github-option-repo-filter",

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,8 @@
#zen-media-controls-toolbar {
--progress-height: 4px;
--button-spacing: 2px;
--zen-media-stack-peek: 8px;
--zen-media-stack-gap: 6px;
display: flex;
min-width: 0;
@@ -18,50 +20,23 @@
border-radius: 5px;
}
#zen-media-buttons-hbox {
.zen-media-buttons-hbox {
align-items: start;
margin-top: -4px;
--toolbarbutton-padding-outer: 2px;
}
&:not([media-sharing]) {
#media-device-buttons {
display: none;
}
}
#media-device-buttons {
.media-device-buttons {
gap: 2px;
}
&[media-sharing] :is(#zen-media-playback-buttons, #zen-media-mute-button) {
display: none;
}
&:not([can-pip]) {
#zen-media-info-vbox {
width: calc(100% - 26px);
flex-shrink: 0;
}
#zen-media-pip-button {
display: none;
}
}
#zen-media-prev-button,
#zen-media-play-pause-button,
#zen-media-next-button {
margin: 0;
}
image.toolbarbutton-icon {
padding: 5px;
width: 26px;
height: 26px;
}
#zen-media-progress-bar {
.zen-media-progress-bar {
appearance: none;
width: 100%;
height: var(--progress-height);
@@ -109,56 +84,27 @@
pointer-events: auto;
}
& #zen-media-info-vbox label[overflow] {
& .zen-media-info-vbox label[overflow] {
animation: zen-back-and-forth-text 10s infinite ease-in-out;
}
}
& #zen-media-focus-button::after {
content: "";
position: absolute;
background: url("chrome://browser/content/zen-images/note-indicator.svg") no-repeat;
top: -70%;
left: 0;
right: 0;
bottom: 0;
z-index: 0;
pointer-events: none;
transition: opacity 0.8s ease;
opacity: 1;
will-change: opacity;
}
&:is(:not(.playing:not([muted])), :hover) #zen-media-focus-button::after {
opacity: 0;
}
#zen-media-focus-button {
align-self: center;
transition:
opacity 0.2s ease,
transform 0.2s ease;
position: relative;
& image {
&:-moz-broken {
content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3C/svg%3E") !important;
background: color-mix(in srgb, var(--zen-primary-color) 70%, transparent 30%);
}
& .zen-media-card {
transform: none;
opacity: 1;
}
}
& > toolbaritem {
flex-grow: 1;
transition: padding 0.3s ease-out;
position: absolute;
left: 0;
bottom: 0;
padding: 4px 6px;
border-radius: var(--border-radius-medium);
box-shadow: var(--zen-sidebar-notification-shadow);
background-color: var(--zen-sidebar-notification-bg);
width: 100%;
& .zen-media-focus-button::after {
display: none;
}
& .zen-media-card::after {
content: "";
position: absolute;
top: 100%;
left: 0;
right: 0;
height: calc(var(--zen-media-stack-gap) + var(--zen-media-stack-peek));
}
}
.show-on-hover {
@@ -174,8 +120,8 @@
padding 0.2s ease;
}
#zen-media-current-time,
#zen-media-duration {
.zen-media-current-time,
.zen-media-duration {
margin: 0 0 0 1px;
font-size: x-small;
opacity: 0.7;
@@ -184,6 +130,105 @@
}
}
.zen-media-card {
position: relative;
flex-shrink: 0;
order: var(--zen-media-card-index, 0);
width: 100%;
padding: 4px 6px;
border-radius: var(--border-radius-medium);
outline: var(--zen-sidebar-notification-outline);
outline-offset: -1.5px;
box-shadow: var(--zen-sidebar-notification-shadow);
background-color: var(--zen-sidebar-notification-bg);
transform-origin: top center;
transform: translateY(
calc(
var(--zen-media-card-index, 0) * (100% + var(--zen-media-stack-gap)) -
var(--zen-media-stack-peek) * var(--zen-media-card-peek-level, 0)
)
)
scale(calc(1 - 0.04 * var(--zen-media-card-peek-level, 0)));
opacity: calc(1 - 0.4 * var(--zen-media-card-peek-level, 0));
transition:
transform 0.1s ease-out,
opacity 0.2s ease-out,
padding 0.3s ease-out;
&[stack-overflow] {
display: none;
}
&:not([media-sharing]) .media-device-buttons {
display: none;
}
&[media-sharing] :is(.zen-media-playback-buttons, .zen-media-mute-button) {
display: none;
}
&:not([can-pip]) {
.zen-media-info-vbox {
width: calc(100% - 26px);
flex-shrink: 0;
}
.zen-media-pip-button {
display: none;
}
}
&[can-pip] {
.zen-media-info-vbox {
flex-shrink: 1;
}
.zen-media-pip-button {
display: flex;
}
}
& .zen-media-focus-button::after {
content: "";
position: absolute;
background: url("chrome://browser/content/zen-images/note-indicator.svg") no-repeat;
top: -70%;
left: 0;
right: 0;
bottom: 0;
z-index: 0;
pointer-events: none;
transition: opacity 0.8s ease;
opacity: 1;
will-change: opacity;
}
&:not(.playing:not([muted])) .zen-media-focus-button::after {
opacity: 0;
}
/* Background cards never show the floating notes — only their top sliver
is visible and the notes would poke through it */
&[stacked-behind] .zen-media-focus-button::after {
display: none;
}
.zen-media-focus-button {
align-self: center;
transition:
opacity 0.2s ease,
transform 0.2s ease;
position: relative;
& image {
&:-moz-broken {
content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3C/svg%3E") !important;
background: color-mix(in srgb, var(--zen-primary-color) 70%, transparent 30%);
}
}
}
}
#zen-media-controls-toolbar {
display: none;
animation: none;
@@ -191,19 +236,29 @@
&:not([hidden]) {
display: flex;
height: 2.5rem;
flex-direction: column-reverse;
gap: var(--zen-media-stack-gap);
/* The toolbar keeps its collapsed height even while expanded: the
cards' flow positions overflow above the box, so the expanded stack
floats over the sidebar instead of pushing it around. The collapsed
height includes the room for the peeking stacked cards. */
height: calc(
var(--zen-media-collapsed-height, 2.5rem) + var(--zen-media-stack-peek) *
var(--zen-media-stack-behind, 0)
);
overflow: visible;
position: relative;
z-index: 2;
transition: height 0.2s ease-out;
}
}
#zen-media-title,
#zen-media-artist {
.zen-media-title,
.zen-media-artist {
align-self: start;
}
#zen-media-artist {
.zen-media-artist {
opacity: 0.7;
font-size: smaller;
@@ -212,19 +267,19 @@
}
}
#zen-media-title {
.zen-media-title {
height: var(--size-item-small);
font-size: math;
}
#zen-media-main-vbox,
#zen-media-info-vbox,
#zen-media-progress-hbox {
.zen-media-main-vbox,
.zen-media-info-vbox,
.zen-media-progress-hbox {
width: 100%;
}
#zen-media-info-vbox {
#zen-media-controls-toolbar:not([media-position-hidden]) & {
.zen-media-info-vbox {
.zen-media-card:not([media-position-hidden]) & {
transition-delay: 0.01s !important;
}
overflow-x: hidden;
@@ -254,23 +309,23 @@
}
}
#zen-media-main-vbox {
.zen-media-main-vbox {
height: 100%;
justify-content: space-between;
}
#zen-media-progress-hbox {
.zen-media-progress-hbox {
flex-grow: 1;
height: var(--size-item-large);
align-items: center;
padding-top: 0 !important;
#zen-media-controls-toolbar[media-position-hidden] & {
.zen-media-card[media-position-hidden] & {
display: none;
}
}
#zen-media-controls-hbox {
.zen-media-controls-hbox {
align-items: flex-end;
justify-content: space-between;
max-width: 100%;
@@ -279,33 +334,23 @@
gap: 0.7rem;
}
#zen-media-playback-buttons {
.zen-media-playback-buttons {
justify-content: space-between;
max-width: 10em;
width: 100%;
}
/* Hide #zen-media-focus-button if it doesn't fit in the toolbar */
/* Hide .zen-media-focus-button if it doesn't fit in the toolbar */
@container media-controls (max-width: 165px) {
#zen-media-focus-button {
.zen-media-focus-button {
display: none;
}
}
#zen-media-info-container {
.zen-media-info-container {
padding-right: 0 !important;
}
#zen-media-controls-toolbar[can-pip] {
#zen-media-info-vbox {
flex-shrink: 1;
}
#zen-media-pip-button {
display: flex;
}
}
:root:not([zen-sidebar-expanded="true"]) {
#zen-media-controls-toolbar {
display: none;

View File

@@ -8,48 +8,41 @@
mode="icons"
fullscreentoolbar="true"
hidden="true">
<toolbaritem>
<vbox id="zen-media-main-vbox">
<vbox>
<hbox id="zen-media-info-container" class="show-on-hover">
<vbox id="zen-media-info-vbox">
<label id="zen-media-title" fadein="true"/>
<label id="zen-media-artist" fadein="true"/>
</vbox>
<hbox id="zen-media-buttons-hbox">
<toolbarbutton id="zen-media-pip-button"
class="toolbarbutton-1" />
<toolbarbutton id="zen-media-close-button"
class="toolbarbutton-1" />
<html:template id="zen-media-card-template">
<toolbaritem class="zen-media-card">
<vbox class="zen-media-main-vbox">
<vbox>
<hbox class="zen-media-info-container show-on-hover">
<vbox class="zen-media-info-vbox">
<label class="zen-media-title" fadein="true"/>
<label class="zen-media-artist" fadein="true"/>
</vbox>
<hbox class="zen-media-buttons-hbox">
<toolbarbutton class="zen-media-pip-button toolbarbutton-1" />
<toolbarbutton class="zen-media-close-button toolbarbutton-1" />
</hbox>
</hbox>
<hbox class="zen-media-progress-hbox show-on-hover">
<label class="zen-media-current-time">0:00</label>
<html:input type="range" class="zen-media-progress-bar"
value="0" min="0" max="100" step="0.1"/>
<label class="zen-media-duration">0:00</label>
</hbox>
</vbox>
<hbox class="zen-media-controls-hbox">
<toolbarbutton class="zen-media-focus-button toolbarbutton-1" />
<hbox class="zen-media-playback-buttons">
<toolbarbutton class="zen-media-previoustrack-button toolbarbutton-1" />
<toolbarbutton class="zen-media-playpause-button toolbarbutton-1" />
<toolbarbutton class="zen-media-nexttrack-button toolbarbutton-1" />
</hbox>
<toolbarbutton class="zen-media-mute-button toolbarbutton-1" />
<hbox class="media-device-buttons">
<toolbarbutton class="zen-media-mute-mic-button toolbarbutton-1" />
<toolbarbutton class="zen-media-mute-camera-button toolbarbutton-1" />
</hbox>
</hbox>
<hbox id="zen-media-progress-hbox" class="show-on-hover">
<label id="zen-media-current-time">0:00</label>
<html:input type="range" id="zen-media-progress-bar"
value="0" min="0" max="100" step="0.1"/>
<label id="zen-media-duration">0:00</label>
</hbox>
</vbox>
<hbox id="zen-media-controls-hbox">
<toolbarbutton id="zen-media-focus-button"
class="toolbarbutton-1" />
<hbox id="zen-media-playback-buttons">
<toolbarbutton id="zen-media-previoustrack-button"
class="toolbarbutton-1" />
<toolbarbutton id="zen-media-playpause-button"
class="toolbarbutton-1" />
<toolbarbutton id="zen-media-nexttrack-button"
class="toolbarbutton-1" />
</hbox>
<toolbarbutton id="zen-media-mute-button"
class="toolbarbutton-1" />
<hbox id="media-device-buttons">
<toolbarbutton id="zen-media-mute-mic-button"
class="toolbarbutton-1" />
<toolbarbutton id="zen-media-mute-camera-button"
class="toolbarbutton-1" />
</hbox>
</hbox>
</vbox>
</toolbaritem>
</toolbaritem>
</html:template>
</toolbar>

View File

@@ -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",
]

View File

@@ -14,6 +14,7 @@ ChromeUtils.defineESModuleGetters(lazy, {
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs",
SessionStartup: "resource:///modules/sessionstore/SessionStartup.sys.mjs",
TabStateFlusher: "resource:///modules/sessionstore/TabStateFlusher.sys.mjs",
gWindowSyncEnabled: "resource:///modules/zen/ZenWindowSync.sys.mjs",
gSyncOnlyPinnedTabs: "resource:///modules/zen/ZenWindowSync.sys.mjs",
DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
@@ -914,6 +915,79 @@ export class nsZenSessionManager {
aWindow.__isNewZenWindow = true;
SessionStoreInternal._deferredInitialState = newState;
SessionStoreInternal.initializeWindow(aWindow, newState);
this.#refreshRestoredTabsState(aWindow, SessionStoreInternal).catch(e =>
console.error("ZenSessionManager: Failed to refresh restored tabs", e)
);
}
/**
* The state we clone into a new window comes from the parent process
* cache, which may lag behind the content processes (e.g. for a tab that
* was opened or navigated right before opening the new window). After the
* new window has been restored, flush the previous window and update each
* counterpart tab that hasn't started loading yet.
*
* @param {Window} aWindow
* The newly restored window.
* @param {object} SessionStoreInternal
* The SessionStore module instance.
*/
async #refreshRestoredTabsState(aWindow, SessionStoreInternal) {
// Wait until the tabs have actually been created in the new window.
await aWindow.gZenWorkspaces.promiseInitialized;
if (aWindow.closed) {
return;
}
// Only refresh from the most recently used window: any other window
// already reported its state when it lost focus, so the cloned data
// is up to date for it.
let previousWindow = null;
for (const win of SessionStoreInternal._browserWindows) {
if (win !== aWindow && !win.closed) {
previousWindow = win;
break;
}
}
if (!previousWindow) {
return;
}
for (const tab of previousWindow.gZenWorkspaces.allStoredTabs) {
const syncId = tab.getAttribute("id");
// A tab that has never loaded has no state in the content process,
// so there's nothing newer to flush out of it.
if (!syncId || tab.closing || !tab.linkedPanel) {
continue;
}
const targetTab = aWindow.document.getElementById(syncId);
// Only refresh a tab that hasn't started loading: setting the state
// of a loaded tab would reload it.
if (
!aWindow.gBrowser.isTab(targetTab) ||
targetTab.linkedPanel ||
targetTab.closing
) {
continue;
}
lazy.TabStateFlusher.flush(tab.linkedBrowser)
.then(() => {
// Things may have changed while the flush was in flight.
if (
aWindow.closed ||
tab.closing ||
targetTab.linkedPanel ||
targetTab.closing
) {
return;
}
lazy.SessionStore.setTabState(
targetTab,
lazy.SessionStore.getTabState(tab)
);
})
.catch(e =>
console.error("ZenSessionManager: Failed to refresh tab state", e)
);
}
}
/**

View File

@@ -769,8 +769,8 @@ class nsZenWindowSync {
const otherBrowser = aTab.linkedBrowser;
// We aren't closing the other tab so, we also need to swap its tablisteners.
let filter = otherTabBrowser._tabFilters.get(aTab);
let tabListener = otherTabBrowser._tabListeners.get(aTab);
let filter = otherTabBrowser._getTabProgressFilter(aTab);
let tabListener = otherTabBrowser._getTabProgressListener(aTab);
try {
otherBrowser.webProgress.removeProgressListener(filter);
filter.removeProgressListener(tabListener);
@@ -792,7 +792,7 @@ class nsZenWindowSync {
true,
false
);
otherTabBrowser._tabListeners.set(aTab, tabListener);
otherTabBrowser._setTabProgressListener(aTab, tabListener);
const notifyAll = Ci.nsIWebProgress.NOTIFY_ALL;
filter.addProgressListener(tabListener, notifyAll);
@@ -1488,9 +1488,16 @@ class nsZenWindowSync {
const window = tab.documentGlobal;
this.#runOnAllWindows(window, win => {
const targetTab = this.getItemFromWindow(win, tab.id);
if (targetTab) {
win.gBrowser.removeTab(targetTab, { animate: true });
if (!targetTab) {
return;
}
if (targetTab.splitView) {
win.gZenViewSplitter.removeTabFromGroup(targetTab, undefined, {
forUnsplit: true,
changeTab: false,
});
}
win.gBrowser.removeTab(targetTab, { animate: true });
});
}

View File

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

View File

@@ -22,6 +22,10 @@ ChromeUtils.defineLazyGetter(lazy, "toolbarBackgroundElement", () => {
return document.getElementById("zen-toolbar-background");
});
ChromeUtils.defineLazyGetter(lazy, "l10n", () => {
return new Localization(["browser/zen-workspaces.ftl"], true);
});
/**
* Zen Spaces manager. This class is mainly responsible for the UI
* and user interactions but it also contains some logic to manage
@@ -645,6 +649,7 @@ class nsZenWorkspaces {
if (Math.abs(delta) < scrollThreshold) {
return;
}
event.preventDefault();
// Determine scroll direction
let rawDirection = delta > 0 ? 1 : -1;
@@ -775,6 +780,7 @@ class nsZenWorkspaces {
restoreWorkspacesFromSessionStore(aWinData = {}) {
if (this.#hasInitialized || !this.workspaceEnabled) {
this._resolveInitialized?.();
return Promise.resolve();
}
const spacesFromStore = aWinData.spaces || [];
@@ -1263,11 +1269,19 @@ class nsZenWorkspaces {
workspace = this.getActiveWorkspaceFromCache();
}
let containerTabId = workspace.containerTabId;
return window.createUserContextMenu(event, {
window.createUserContextMenu(event, {
isContextMenu: true,
excludeUserContextId: containerTabId,
showDefaultTab: true,
});
const defaultItem = event.target.querySelector('[data-usercontextid="0"]');
if (defaultItem) {
defaultItem.removeAttribute("data-l10n-id");
defaultItem.label = lazy.l10n.formatValueSync(
"zen-workspace-default-profile"
);
}
}
saveWorkspace(workspaceData) {

View File

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

View File

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

View File

@@ -76,6 +76,10 @@ add_task(async function test_fetch_items_url_construction() {
!query.includes("review-requested:@me"),
"Should NOT include review-requested"
);
Assert.ok(
!query.includes("draft:false"),
"Should include draft pull requests by default"
);
sandbox.restore();
});
@@ -88,6 +92,7 @@ add_task(async function test_fetch_items_url_complex_options() {
let instance = getGithubProviderForTest(sandbox, {
authorMe: true,
assignedMe: true,
includeDrafts: false,
reviewRequested: true,
});
@@ -107,6 +112,10 @@ add_task(async function test_fetch_items_url_complex_options() {
query.includes("review-requested:@me"),
"Should include review-requested"
);
Assert.ok(
query.includes("draft:false"),
"Should exclude draft pull requests"
);
Assert.ok(query.includes(" OR "), "Should contain OR operators");
sandbox.restore();

View File

@@ -14,3 +14,5 @@ support-files = [
["browser_media_next_track.js"]
["browser_media_shows_on_tab_switch.js"]
["browser_media_stack.js"]

View File

@@ -3,17 +3,6 @@
"use strict";
// User flow:
// 1. A page (think Spotify, YouTube Music) plays media and publishes
// title/artist via navigator.mediaSession.metadata.
// 2. User switches off that tab, media bar appears.
// 3. The title and artist labels in the bar show what the page published.
// 4. The page then updates the metadata mid-playback (next song starts).
// 5. The bar updates live, without the user having to switch tabs again.
//
// This is what makes the bar feel connected to the playing page instead of
// a generic "something is playing" indicator.
add_task(async function test_media_bar_shows_metadata_from_page() {
const originalTab = gBrowser.selectedTab;
const mediaTab = await addMediaTab();
@@ -28,8 +17,9 @@ add_task(async function test_media_bar_shows_metadata_from_page() {
await BrowserTestUtils.switchTab(gBrowser, originalTab);
await waitForMediaBarVisible();
const titleEl = document.getElementById("zen-media-title");
const artistEl = document.getElementById("zen-media-artist");
const card = frontMediaCard().element;
const titleEl = card.querySelector(".zen-media-title");
const artistEl = card.querySelector(".zen-media-artist");
await BrowserTestUtils.waitForCondition(
() => titleEl.textContent === "Sandstorm",

View File

@@ -3,18 +3,6 @@
"use strict";
// User flow:
// 1. User plays a video, switches tabs, media bar appears.
// 2. User clicks the mute button on the Zen media bar.
// 3. The underlying tab actually goes silent (browser.audioMuted flips).
// 4. The media bar reflects that with the `muted` attribute so the icon
// changes.
// 5. Clicking again unmutes.
//
// If this breaks, the user sees a mute button that looks toggled but the
// audio keeps playing — or worse, the tab is muted but the button still
// says "unmuted".
add_task(async function test_mute_from_media_bar() {
const originalTab = gBrowser.selectedTab;
const mediaTab = await addMediaTab();
@@ -29,9 +17,10 @@ add_task(async function test_mute_from_media_bar() {
!mediaTab.linkedBrowser.audioMuted,
"precondition: playing tab starts unmuted"
);
const card = frontMediaCard().element;
ok(
!mediaBar().hasAttribute("muted"),
"precondition: media bar has no muted attribute"
!card.hasAttribute("muted"),
"precondition: media card has no muted attribute"
);
clickMediaButton("zen-media-mute-button");
@@ -40,8 +29,8 @@ add_task(async function test_mute_from_media_bar() {
"tab becomes muted after clicking the media bar mute button"
);
ok(
mediaBar().hasAttribute("muted"),
"media bar reflects the muted state in its attribute"
card.hasAttribute("muted"),
"media card reflects the muted state in its attribute"
);
clickMediaButton("zen-media-mute-button");
@@ -49,10 +38,7 @@ add_task(async function test_mute_from_media_bar() {
() => !mediaTab.linkedBrowser.audioMuted,
"clicking again unmutes the tab"
);
ok(
!mediaBar().hasAttribute("muted"),
"media bar drops the muted attribute"
);
ok(!card.hasAttribute("muted"), "media card drops the muted attribute");
} finally {
if (mediaTab.linkedBrowser.audioMuted) {
mediaTab.toggleMuteAudio();

View File

@@ -3,19 +3,6 @@
"use strict";
// User flow:
// 1. A music page registers a "nexttrack" action handler (like most
// streaming sites do).
// 2. User is on another tab, media bar is showing with the next-track
// button enabled.
// 3. User clicks next-track.
// 4. The action fires inside the page — the page is responsible for
// loading the next song. Zen's job here is to relay the click.
//
// Also guards the button-enablement logic: if the page does NOT register a
// handler, the next-track button must be disabled. Otherwise clicks go
// nowhere and users think the bar is broken.
add_task(async function test_next_track_relays_to_page() {
const originalTab = gBrowser.selectedTab;
const mediaTab = await addMediaTab();
@@ -28,7 +15,9 @@ add_task(async function test_next_track_relays_to_page() {
await BrowserTestUtils.switchTab(gBrowser, originalTab);
await waitForMediaBarVisible();
const nextButton = document.getElementById("zen-media-nexttrack-button");
const nextButton = frontMediaCard().element.querySelector(
".zen-media-nexttrack-button"
);
// supportedkeyschange propagates asynchronously; wait for the bar's
// next-track button to become enabled before clicking.
@@ -60,7 +49,9 @@ add_task(async function test_next_track_button_disabled_without_handler() {
await BrowserTestUtils.switchTab(gBrowser, originalTab);
await waitForMediaBarVisible();
const nextButton = document.getElementById("zen-media-nexttrack-button");
const nextButton = frontMediaCard().element.querySelector(
".zen-media-nexttrack-button"
);
Assert.equal(
nextButton.disabled,
true,

View File

@@ -3,26 +3,8 @@
"use strict";
// User flow:
// 1. User opens a page with audio and hits play.
// 2. User switches to a different tab.
// 3. The Zen media control bar should appear (so the user can still
// pause/skip without going back to the noisy tab).
// 4. User switches back to the audio tab.
// 5. The media bar should hide again — it's redundant next to the real
// page controls.
//
// This covers the real contract users see: the DOMAudioPlaybackStarted →
// TabSelect → showMediaControls chain in nsZenMediaController, plus the
// inverse path on selecting the playing tab. A regression anywhere in that
// chain (event wiring, the 500ms tab-switch debounce, the hidden attribute
// flip) surfaces as a bar that either never shows or never hides.
// note: We keep setting timeouts because media player takes a bit to
// get removed (after the animation, more specifically)
add_task(async function test_media_bar_shows_when_switching_off_playing_tab() {
gZenMediaController.onControllerClose();
gZenMediaController.closeAllCards();
await BrowserTestUtils.waitForCondition(
() => !isMediaBarVisible(),
"media bar hides again once the playing tab regains focus"
@@ -55,9 +37,9 @@ add_task(async function test_media_bar_shows_when_switching_off_playing_tab() {
);
Assert.equal(
gZenMediaController._currentBrowser?.browserId,
frontMediaCard()?.browser.browserId,
mediaTab.linkedBrowser.browserId,
"media controller is bound to the media tab's browser, not the selected tab"
"media card is bound to the media tab's browser, not the selected tab"
);
await BrowserTestUtils.switchTab(gBrowser, mediaTab);

View File

@@ -0,0 +1,61 @@
/* Any copyright is dedicated to the Public Domain.
https://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
add_task(async function test_media_stack_shows_multiple_cards() {
const originalTab = gBrowser.selectedTab;
const tabA = await addMediaTab();
const tabB = await addMediaTab();
try {
await BrowserTestUtils.switchTab(gBrowser, tabA);
await setMediaSessionMetadata(tabA, { title: "Song A", artist: "A" });
await playVideoIn(tabA);
await BrowserTestUtils.switchTab(gBrowser, tabB);
await setMediaSessionMetadata(tabB, { title: "Song B", artist: "B" });
await playVideoIn(tabB);
await BrowserTestUtils.switchTab(gBrowser, originalTab);
await waitForMediaBarVisible();
// Cards reappear on the 500ms tab-switch debounce; wait for both.
await BrowserTestUtils.waitForCondition(
() => visibleMediaCards().length === 2,
"both playing tabs get their own card in the stack"
);
Assert.equal(
frontMediaCard().browser.browserId,
tabB.linkedBrowser.browserId,
"front card belongs to the most recently started media"
);
await BrowserTestUtils.waitForCondition(() => {
const titles = visibleMediaCards().map(
card => card.querySelector(".zen-media-title").textContent
);
return titles.includes("Song A") && titles.includes("Song B");
}, "each card shows its own session's metadata");
// Closing the front card only removes that session.
clickMediaButton("zen-media-close-button");
await BrowserTestUtils.waitForCondition(
() => visibleMediaCards().length === 1,
"closing the front card only removes that card"
);
ok(isMediaBarVisible(), "media bar stays visible with the remaining card");
Assert.equal(
frontMediaCard().browser.browserId,
tabA.linkedBrowser.browserId,
"remaining card takes the front slot"
);
} finally {
await pauseVideoIn(tabA);
await pauseVideoIn(tabB);
BrowserTestUtils.removeTab(tabA);
BrowserTestUtils.removeTab(tabB);
gBrowser.selectedTab = originalTab;
}
});

View File

@@ -63,12 +63,21 @@ async function waitForMediaBarVisible() {
);
}
// Click a toolbarbutton on the media bar. We dispatch a "command" event
// directly because that's what the controller listens for and it sidesteps
// the flakiness of synthesizing a mouse click on a small toolbar button.
function clickMediaButton(id) {
const button = document.getElementById(id);
ok(button, `media bar button ${id} exists`);
function frontMediaCard() {
return gZenMediaController.frontCard;
}
function visibleMediaCards() {
return [...mediaBar().querySelectorAll(".zen-media-card")].filter(
card => !card.hidden
);
}
function clickMediaButton(className) {
const card = frontMediaCard();
ok(card, "front media card exists");
const button = card.element.querySelector(`.${className}`);
ok(button, `media bar button ${className} exists`);
button.dispatchEvent(new Event("command", { bubbles: true }));
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,288 @@
// 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 = 4;
// 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",
]);
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.explicitOriginalTarget;
if (target?.nodeType === Node.TEXT_NODE) {
// The hit-test landed on rendered text; a drag here should select
// it instead, unless it isn't selectable.
if (this.#isSelectableText(target)) {
return true;
}
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;
}
}
// The effective cursor, resolved the same way it is shown to the user.
return lazy.zenWindowDragUtils.isInteractiveCursor(
event.composedTarget,
event.clientX,
event.clientY
);
}
#isSelectableText(node) {
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());
}
}

View File

@@ -0,0 +1,33 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {};
XPCOMUtils.defineLazyServiceGetter(
lazy,
"zenDragAndDropService",
"@mozilla.org/zen/drag-and-drop;1",
Ci.nsIZenDragAndDrop
);
export class ZenWindowDragParent extends JSWindowActorParent {
receiveMessage(message) {
if (message.name !== "ZenWindowDrag:StartDrag") {
return;
}
const win = this.browsingContext.topChromeWindow;
if (!win || win.closed || win.windowState === win.STATE_FULLSCREEN) {
return;
}
if (Cu.isInAutomation) {
// Tests can't exercise a real OS drag session; let them observe the
// decision instead.
Services.obs.notifyObservers(win, "zen-window-drag-started");
return;
}
lazy.zenDragAndDropService.beginNativeWindowMove(win);
}
}

View File

@@ -0,0 +1,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'],
},
]

View 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"

View File

@@ -0,0 +1,34 @@
/* 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);
/**
* @brief Whether the effective cursor for the given node signals
* interactive or draggable content (pointer, text, move, resize, ...).
* Unlike computed style, this resolves cursor: auto the same way the
* cursor actually shown to the user is chosen.
* @param node The deepest node hit by the event.
* @param clientX Viewport X coordinate of the event, in CSS pixels.
* @param clientY Viewport Y coordinate of the event, in CSS pixels.
*/
boolean isInteractiveCursor(in Node node, in float clientX, in float clientY);
};

View File

@@ -0,0 +1,90 @@
/* 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 "Units.h"
#include "mozilla/PresShell.h"
#include "mozilla/ServoStyleConsts.h"
#include "mozilla/dom/Element.h"
#include "nsContentUtils.h"
#include "nsIContent.h"
#include "nsIFrame.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;
}
NS_IMETHODIMP
nsZenWindowDragUtils::IsInteractiveCursor(nsINode* aNode, float aClientX,
float aClientY, bool* aResult) {
using mozilla::StyleCursorKind;
*aResult = false;
NS_ENSURE_ARG_POINTER(aNode);
nsIContent* content = nsIContent::FromNode(aNode);
nsIFrame* frame = content ? content->GetPrimaryFrame() : nullptr;
if (!frame) {
return NS_OK;
}
nsIFrame* rootFrame = frame->PresShell()->GetRootFrame();
if (!rootFrame) {
return NS_OK;
}
nsPoint point(mozilla::CSSPixel::ToAppUnits(aClientX),
mozilla::CSSPixel::ToAppUnits(aClientY));
point -= frame->GetOffsetTo(rootFrame);
switch (frame->GetCursor(point).mCursor) {
case StyleCursorKind::Pointer:
case StyleCursorKind::Cell:
case StyleCursorKind::Crosshair:
case StyleCursorKind::Text:
case StyleCursorKind::VerticalText:
case StyleCursorKind::Move:
case StyleCursorKind::Grab:
case StyleCursorKind::Grabbing:
case StyleCursorKind::EResize:
case StyleCursorKind::NResize:
case StyleCursorKind::NeResize:
case StyleCursorKind::NwResize:
case StyleCursorKind::SResize:
case StyleCursorKind::SeResize:
case StyleCursorKind::SwResize:
case StyleCursorKind::WResize:
case StyleCursorKind::EwResize:
case StyleCursorKind::NsResize:
case StyleCursorKind::NeswResize:
case StyleCursorKind::NwseResize:
case StyleCursorKind::ColResize:
case StyleCursorKind::RowResize:
case StyleCursorKind::AllScroll:
*aResult = true;
break;
default:
break;
}
return NS_OK;
}
} // namespace zen

View 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

View File

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