721 lines
20 KiB
JavaScript
721 lines
20 KiB
JavaScript
/* 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 { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
|
|
|
|
const lazy = {};
|
|
|
|
ChromeUtils.defineESModuleGetters(lazy, {
|
|
URILoadingHelper: "resource:///modules/URILoadingHelper.sys.mjs",
|
|
|
|
AnimationFramePromise: "chrome://remote/content/shared/Sync.sys.mjs",
|
|
AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs",
|
|
BiMap: "chrome://remote/content/shared/BiMap.sys.mjs",
|
|
BrowsingContextListener:
|
|
"chrome://remote/content/shared/listeners/BrowsingContextListener.sys.mjs",
|
|
ChromeWindowListener:
|
|
"chrome://remote/content/shared/listeners/ChromeWindowListener.sys.mjs",
|
|
DebounceCallback: "chrome://remote/content/shared/Sync.sys.mjs",
|
|
error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
|
|
EventPromise: "chrome://remote/content/shared/Sync.sys.mjs",
|
|
Log: "chrome://remote/content/shared/Log.sys.mjs",
|
|
TimedPromise: "chrome://remote/content/shared/Sync.sys.mjs",
|
|
UserContextManager:
|
|
"chrome://remote/content/shared/UserContextManager.sys.mjs",
|
|
waitForObserverTopic: "chrome://remote/content/shared/Sync.sys.mjs",
|
|
});
|
|
|
|
ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
|
|
|
|
// Timeout used to abort fullscreen, maximize, and minimize
|
|
// commands if no window manager is present.
|
|
const TIMEOUT_NO_WINDOW_MANAGER = 5000;
|
|
|
|
/**
|
|
* @typedef {object} WindowRect
|
|
*
|
|
* @property {number} x
|
|
* The x-coordinate of the window.
|
|
* @property {number} y
|
|
* The y-coordinate of the window.
|
|
* @property {number} width
|
|
* The width of the window.
|
|
* @property {number} height
|
|
* The height of the window.
|
|
*/
|
|
|
|
/**
|
|
* Provides helpers to interact with Window objects.
|
|
*
|
|
* @class WindowManager
|
|
*/
|
|
class WindowManager {
|
|
#chromeWindowListener;
|
|
#clientWindowIds;
|
|
#contextListener;
|
|
#contextToWindowMap;
|
|
#tracking;
|
|
|
|
constructor() {
|
|
/**
|
|
* Keep track of the client window for any registered contexts. When the
|
|
* contextDestroyed event is fired, the context is already destroyed so
|
|
* we cannot query for the client window at that time.
|
|
*/
|
|
this.#clientWindowIds = new lazy.BiMap();
|
|
|
|
// For content browsing contexts, the embedder element may already be
|
|
// gone by the time when it is getting discarded. To ensure we can still
|
|
// retrieve the corresponding chrome window, we maintain a mapping from
|
|
// each top-level content browsing context to its chrome window.
|
|
this.#contextToWindowMap = new WeakMap();
|
|
this.#contextListener = new lazy.BrowsingContextListener();
|
|
|
|
this.#tracking = false;
|
|
|
|
this.#chromeWindowListener = new lazy.ChromeWindowListener();
|
|
}
|
|
|
|
destroy() {
|
|
this.stopTracking();
|
|
}
|
|
|
|
startTracking() {
|
|
if (this.#tracking) {
|
|
return;
|
|
}
|
|
|
|
this.#chromeWindowListener.on("closed", this.#onChromeWindowClosed);
|
|
this.#chromeWindowListener.on("opened", this.#onChromeWindowOpened);
|
|
this.#chromeWindowListener.startListening();
|
|
|
|
this.#contextListener.on("attached", this.#onContextAttached);
|
|
this.#contextListener.startListening();
|
|
|
|
// Pre-fill the internal window id mapping.
|
|
this.windows.forEach(window => this.getIdForWindow(window));
|
|
|
|
this.#tracking = true;
|
|
}
|
|
|
|
stopTracking() {
|
|
if (!this.#tracking) {
|
|
return;
|
|
}
|
|
|
|
this.#chromeWindowListener.stopListening();
|
|
this.#chromeWindowListener.off("closed", this.#onChromeWindowClosed);
|
|
this.#chromeWindowListener.off("opened", this.#onChromeWindowOpened);
|
|
|
|
this.#contextListener.stopListening();
|
|
this.#contextListener.off("attached", this.#onContextAttached);
|
|
|
|
this.#clientWindowIds = new lazy.BiMap();
|
|
this.#contextToWindowMap = new WeakMap();
|
|
|
|
this.#tracking = false;
|
|
}
|
|
|
|
/**
|
|
* Retrieve all the open windows.
|
|
*
|
|
* @returns {Array<Window>}
|
|
* All the open windows. Will return an empty list if no window is open.
|
|
*/
|
|
get windows() {
|
|
const windows = [];
|
|
|
|
for (const win of Services.wm.getEnumerator(null)) {
|
|
if (win.closed) {
|
|
continue;
|
|
}
|
|
windows.push(win);
|
|
}
|
|
|
|
return windows;
|
|
}
|
|
|
|
/**
|
|
* Retrieves an id for the given chrome window. The id is a dynamically
|
|
* generated uuid by the WindowManager and associated with the
|
|
* top-level browsing context of that chrome window.
|
|
*
|
|
* @param {ChromeWindow} win
|
|
* The chrome window for which we want to retrieve the id.
|
|
*
|
|
* @returns {string|null}
|
|
* The unique id for this chrome window or `null` if not a valid window.
|
|
*/
|
|
getIdForWindow(win) {
|
|
if (win) {
|
|
return this.#clientWindowIds.getOrInsert(win);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Retrieve the Chrome Window corresponding to the provided window id.
|
|
*
|
|
* @param {string} id
|
|
* A unique id for the chrome window.
|
|
*
|
|
* @returns {ChromeWindow|undefined}
|
|
* The chrome window found for this id, `null` if none
|
|
* was found.
|
|
*/
|
|
getWindowById(id) {
|
|
return this.#clientWindowIds.getObject(id);
|
|
}
|
|
|
|
/**
|
|
* Close the specified window.
|
|
*
|
|
* @param {window} win
|
|
* The window to close.
|
|
* @returns {Promise}
|
|
* A promise which is resolved when the current window has been closed.
|
|
*/
|
|
async closeWindow(win) {
|
|
const destroyed = lazy.waitForObserverTopic("xul-window-destroyed", {
|
|
checkFn: () => win && win.closed,
|
|
});
|
|
|
|
win.close();
|
|
|
|
return destroyed;
|
|
}
|
|
|
|
/**
|
|
* Adjusts the window geometry.
|
|
*
|
|
*@param {window} win
|
|
* The browser window to adjust.
|
|
* @param {number} x
|
|
* The x-coordinate of the window.
|
|
* @param {number} y
|
|
* The y-coordinate of the window.
|
|
* @param {number} width
|
|
* The width of the window.
|
|
* @param {number} height
|
|
* The height of the window.
|
|
*
|
|
* @returns {Promise<WindowRect>}
|
|
* A promise that resolves to the window rect when the window geometry has been adjusted.
|
|
*
|
|
* @throws {TimeoutError}
|
|
* Raised if the operating system fails to honor the requested move or resize.
|
|
*/
|
|
async adjustWindowGeometry(win, x, y, width, height) {
|
|
// we find a matching position on e.g. resize, then resolve, then a geometry
|
|
// change comes in, then the window pos listener runs, we might try to
|
|
// incorrectly reset the position without this check.
|
|
let foundMatch = false;
|
|
|
|
function geometryMatches() {
|
|
lazy.logger.trace(
|
|
`Checking window geometry ${win.outerWidth}x${win.outerHeight} @ (${win.screenX}, ${win.screenY})`
|
|
);
|
|
|
|
if (foundMatch) {
|
|
lazy.logger.trace(`Already found a previous match for this request`);
|
|
return true;
|
|
}
|
|
|
|
let sizeMatches = true;
|
|
let posMatches = true;
|
|
|
|
if (
|
|
(width !== null && win.outerWidth !== width) ||
|
|
(height !== null && win.outerHeight !== height)
|
|
) {
|
|
sizeMatches = false;
|
|
}
|
|
|
|
if (
|
|
(x !== null && win.screenX !== x) ||
|
|
(y !== null && win.screenY !== y)
|
|
) {
|
|
if (lazy.AppInfo.isWayland && !lazy.AppInfo.isHeadless) {
|
|
lazy.logger.info(
|
|
`Wayland doesn't support setting the window position in headful mode`
|
|
);
|
|
} else {
|
|
posMatches = false;
|
|
}
|
|
}
|
|
|
|
if (sizeMatches && posMatches) {
|
|
lazy.logger.trace(`Requested window geometry matches`);
|
|
foundMatch = true;
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
if (WindowState.from(win.windowState) !== WindowState.Normal) {
|
|
await this.restoreWindow(win);
|
|
}
|
|
|
|
lazy.logger.trace(
|
|
`Setting window geometry to ${width}x${height} @ (${x}, ${y})`
|
|
);
|
|
|
|
if (!geometryMatches()) {
|
|
// There might be more than one resize or MozUpdateWindowPos event due
|
|
// to previous geometry changes, such as from restoreWindow(), so
|
|
// wait longer if window geometry does not match.
|
|
const options = {
|
|
checkFn: geometryMatches,
|
|
timeout: 500,
|
|
};
|
|
const promises = [];
|
|
|
|
const resize = width !== null || height !== null;
|
|
if (resize) {
|
|
promises.push(new lazy.EventPromise(win, "resize", options));
|
|
}
|
|
|
|
// Wayland doesn't support setting the window position in headful mode.
|
|
const move =
|
|
!(lazy.AppInfo.isWayland && !lazy.AppInfo.isHeadless) &&
|
|
(x !== null || y !== null);
|
|
|
|
if (move) {
|
|
promises.push(
|
|
new lazy.EventPromise(win.windowRoot, "MozUpdateWindowPos", options)
|
|
);
|
|
}
|
|
|
|
if (move && resize) {
|
|
win.moveResize(
|
|
x ?? win.screenX,
|
|
y ?? win.screenY,
|
|
width ?? win.outerWidth,
|
|
height ?? win.outerHeight
|
|
);
|
|
} else if (move) {
|
|
win.moveTo(x ?? win.screenX, y ?? win.screenY);
|
|
} else if (resize) {
|
|
win.resizeTo(width ?? win.outerWidth, height ?? win.outerHeight);
|
|
}
|
|
|
|
try {
|
|
await Promise.race(promises);
|
|
} catch (e) {
|
|
if (e instanceof lazy.error.TimeoutError) {
|
|
// The operating system might not honor the move or resize, in which
|
|
// case assume that geometry will have been adjusted "as close as
|
|
// possible" to that requested. There may be no event received if the
|
|
// geometry is already as close as possible.
|
|
} else {
|
|
throw e;
|
|
}
|
|
}
|
|
}
|
|
|
|
return this.getWindowRect(win);
|
|
}
|
|
|
|
/**
|
|
* Focus the specified window.
|
|
*
|
|
* @param {window} win
|
|
* The window to focus.
|
|
* @returns {Promise}
|
|
* A promise which is resolved when the window has been focused.
|
|
*/
|
|
async focusWindow(win) {
|
|
if (Services.focus.activeWindow != win) {
|
|
let activated = new lazy.EventPromise(win, "activate");
|
|
let focused = new lazy.EventPromise(win, "focus", { capture: true });
|
|
|
|
win.focus();
|
|
|
|
await Promise.all([activated, focused]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the chrome window for a specific browsing context.
|
|
*
|
|
* @param {BrowsingContext} context
|
|
* The browsing context for which we want to retrieve the window.
|
|
*
|
|
* @returns {ChromeWindow|null}
|
|
* The chrome window associated with the browsing context.
|
|
* Otherwise `null` is returned.
|
|
*/
|
|
getChromeWindowForBrowsingContext(context) {
|
|
if (!context.isContent) {
|
|
// Chrome browsing contexts always have a chrome window set.
|
|
return context.topChromeWindow;
|
|
}
|
|
|
|
if (this.#contextToWindowMap.has(context.top)) {
|
|
return this.#contextToWindowMap.get(context.top);
|
|
}
|
|
|
|
return this.#setChromeWindowForBrowsingContext(context);
|
|
}
|
|
|
|
/**
|
|
* Gets the position and dimensions of the top-level browsing context.
|
|
*
|
|
* @param {ChromeWindow} win
|
|
* The chrome window to get its rect from.
|
|
*
|
|
* @returns {WindowRect}
|
|
* An object with the window position and dimension.
|
|
*/
|
|
getWindowRect(win) {
|
|
return {
|
|
x: win.screenX,
|
|
y: win.screenY,
|
|
width: win.outerWidth,
|
|
height: win.outerHeight,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Open a new browser window.
|
|
*
|
|
* @param {object=} options
|
|
* @param {boolean=} options.focus
|
|
* If true, the opened window will receive the focus. Defaults to false.
|
|
* @param {boolean=} options.isPrivate
|
|
* If true, the opened window will be a private window. Defaults to false.
|
|
* @param {ChromeWindow=} options.openerWindow
|
|
* Use this window as the opener of the new window. Defaults to the
|
|
* topmost window.
|
|
* @param {string=} options.userContextId
|
|
* The id of the user context which should own the initial tab of the new
|
|
* window.
|
|
*
|
|
* @returns {Promise<ChromeWindow>}
|
|
* A promise resolving to the newly created chrome window.
|
|
*
|
|
* @throws {UnsupportedOperationError}
|
|
* When opening a new browser window is not supported.
|
|
*/
|
|
async openBrowserWindow(options = {}) {
|
|
let {
|
|
focus = false,
|
|
isPrivate = false,
|
|
openerWindow = null,
|
|
userContextId = null,
|
|
} = options;
|
|
|
|
switch (lazy.AppInfo.name) {
|
|
case "Firefox": {
|
|
if (openerWindow === null) {
|
|
// If no opener was provided, fallback to the topmost window.
|
|
openerWindow = Services.wm.getMostRecentBrowserWindow();
|
|
}
|
|
|
|
if (!openerWindow) {
|
|
throw new lazy.error.UnsupportedOperationError(
|
|
`openWindow() could not find a valid opener window`
|
|
);
|
|
}
|
|
|
|
// Open new browser window, and wait until it is fully loaded.
|
|
// Also wait for the window to be focused and activated to prevent a
|
|
// race condition when promptly focusing to the original window again.
|
|
const browser = await new Promise(resolveOnContentBrowserCreated =>
|
|
lazy.URILoadingHelper.openTrustedLinkIn(
|
|
openerWindow,
|
|
"about:blank",
|
|
"window",
|
|
{
|
|
private: isPrivate,
|
|
resolveOnContentBrowserCreated,
|
|
userContextId:
|
|
lazy.UserContextManager.getInternalIdById(userContextId),
|
|
}
|
|
)
|
|
);
|
|
|
|
// TODO: Both for WebDriver BiDi and classic, opening a new window
|
|
// should not run the focus steps. When focus is false we should avoid
|
|
// focusing the new window completely. See Bug 1766329
|
|
|
|
if (focus) {
|
|
// Focus the currently selected tab.
|
|
browser.focus();
|
|
} else {
|
|
// If the new window shouldn't get focused, set the
|
|
// focus back to the opening window.
|
|
await this.focusWindow(openerWindow);
|
|
}
|
|
|
|
const chromeWindow = browser.documentGlobal;
|
|
await this.waitForChromeWindowLoaded(chromeWindow);
|
|
|
|
return chromeWindow;
|
|
}
|
|
|
|
default:
|
|
throw new lazy.error.UnsupportedOperationError(
|
|
`openWindow() not supported in ${lazy.AppInfo.name}`
|
|
);
|
|
}
|
|
}
|
|
|
|
supportsWindows() {
|
|
return !lazy.AppInfo.isAndroid;
|
|
}
|
|
|
|
/**
|
|
* Fullscreen the specified window.
|
|
*
|
|
* @param {window} win
|
|
* The window to fullscreen.
|
|
*
|
|
* @returns {Promise<WindowRect>}
|
|
* A promise that resolves to the window rect when the window is fullscreen.
|
|
*/
|
|
async fullscreenWindow(win) {
|
|
const windowState = WindowState.from(win.windowState);
|
|
|
|
if (windowState !== WindowState.Fullscreen) {
|
|
switch (windowState) {
|
|
case WindowState.Maximized:
|
|
case WindowState.Minimized:
|
|
await this.restoreWindow(win);
|
|
break;
|
|
}
|
|
|
|
await waitForWindowState(win, () => (win.fullScreen = true));
|
|
}
|
|
|
|
return this.getWindowRect(win);
|
|
}
|
|
|
|
/**
|
|
* Maximize the specified window.
|
|
*
|
|
* @param {window} win
|
|
* The window to maximize.
|
|
*
|
|
* @returns {Promise<WindowRect>}
|
|
* A promise that resolves to the window rect when the window is maximized.
|
|
*/
|
|
async maximizeWindow(win) {
|
|
const windowState = WindowState.from(win.windowState);
|
|
|
|
if (windowState !== WindowState.Maximized) {
|
|
// Directly switching into maximize state does not always work.
|
|
// As such restore the window to normal state first.
|
|
switch (windowState) {
|
|
case WindowState.Fullscreen:
|
|
case WindowState.Minimized:
|
|
await this.restoreWindow(win);
|
|
break;
|
|
}
|
|
|
|
await waitForWindowState(win, () => win.maximize());
|
|
}
|
|
|
|
return this.getWindowRect(win);
|
|
}
|
|
|
|
/**
|
|
* Minimize the specified window.
|
|
*
|
|
* @param {window} win
|
|
* The window to minimize.
|
|
*
|
|
* @returns {Promise<WindowRect>}
|
|
* A promise that resolves to the window rect when the window is minimized.
|
|
*/
|
|
async minimizeWindow(win) {
|
|
const windowState = WindowState.from(win.windowState);
|
|
|
|
if (windowState !== WindowState.Minimized) {
|
|
switch (windowState) {
|
|
case WindowState.Fullscreen:
|
|
case WindowState.Maximized:
|
|
await this.restoreWindow(win);
|
|
break;
|
|
}
|
|
|
|
await waitForWindowState(win, () => win.minimize());
|
|
}
|
|
|
|
return this.getWindowRect(win);
|
|
}
|
|
|
|
/**
|
|
* Restores the specified window to its normal state.
|
|
*
|
|
* @param {window} win
|
|
* The window to restore.
|
|
*
|
|
* @returns {Promise<WindowRect>}
|
|
* A promise that resolves to the window rect when the window is restored.
|
|
*/
|
|
async restoreWindow(win) {
|
|
const windowState = WindowState.from(win.windowState);
|
|
|
|
if (windowState !== WindowState.Normal) {
|
|
const callback =
|
|
windowState === WindowState.Fullscreen
|
|
? () => (win.fullScreen = false)
|
|
: () => win.restore();
|
|
|
|
await waitForWindowState(win, callback);
|
|
}
|
|
|
|
return this.getWindowRect(win);
|
|
}
|
|
|
|
/**
|
|
* Wait until the browser window is initialized and loaded.
|
|
*
|
|
* @param {ChromeWindow} window
|
|
* The chrome window to check for completed loading.
|
|
*
|
|
* @returns {Promise}
|
|
* A promise that resolves when the chrome window finished loading.
|
|
*/
|
|
async waitForChromeWindowLoaded(window) {
|
|
const loaded =
|
|
window.document.readyState === "complete" &&
|
|
!window.document.isUncommittedInitialDocument;
|
|
|
|
if (!loaded) {
|
|
lazy.logger.trace(
|
|
`Chrome window not loaded yet. Waiting for "load" event`
|
|
);
|
|
await new lazy.EventPromise(window, "load");
|
|
}
|
|
|
|
// Only Firefox stores the delayed startup finished status, allowing
|
|
// it to be checked at any time. On Android, this is unnecessary
|
|
// because there is only a single window, and we already wait for
|
|
// that window during startup.
|
|
if (
|
|
lazy.AppInfo.isFirefox &&
|
|
window.document.documentURI === AppConstants.BROWSER_CHROME_URL &&
|
|
!(window.gBrowserInit && window.gBrowserInit.delayedStartupFinished)
|
|
) {
|
|
lazy.logger.trace(
|
|
`Browser window not initialized yet. Waiting for startup finished`
|
|
);
|
|
|
|
// If it's a browser window wait for it to be fully initialized.
|
|
await lazy.waitForObserverTopic("browser-delayed-startup-finished", {
|
|
checkFn: subject => subject === window,
|
|
});
|
|
}
|
|
}
|
|
|
|
#setChromeWindowForBrowsingContext(context) {
|
|
const chromeWindow = context.top.embedderElement?.documentGlobal;
|
|
if (chromeWindow) {
|
|
return this.#contextToWindowMap.getOrInsert(context.top, chromeWindow);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/* Event handlers */
|
|
|
|
#onContextAttached = (_, data = {}) => {
|
|
const { browsingContext } = data;
|
|
|
|
if (!browsingContext.isContent) {
|
|
return;
|
|
}
|
|
|
|
this.#setChromeWindowForBrowsingContext(browsingContext);
|
|
};
|
|
|
|
#onChromeWindowClosed = (_, data = {}) => {
|
|
const { window } = data;
|
|
|
|
this.#clientWindowIds.deleteByObject(window);
|
|
};
|
|
|
|
#onChromeWindowOpened = (_, data = {}) => {
|
|
const { window } = data;
|
|
|
|
this.getIdForWindow(window);
|
|
};
|
|
}
|
|
|
|
// Expose a shared singleton.
|
|
export const windowManager = new WindowManager();
|
|
|
|
/**
|
|
* Representation of the {@link ChromeWindow} window state.
|
|
*
|
|
* @enum {string}
|
|
*/
|
|
export const WindowState = {
|
|
Maximized: "maximized",
|
|
Minimized: "minimized",
|
|
Normal: "normal",
|
|
Fullscreen: "fullscreen",
|
|
|
|
/**
|
|
* Converts {@link Window.windowState} to WindowState.
|
|
*
|
|
* @param {number} windowState
|
|
* Attribute from {@link Window.windowState}.
|
|
*
|
|
* @returns {WindowState}
|
|
* JSON representation.
|
|
*
|
|
* @throws {TypeError}
|
|
* If <var>windowState</var> was unknown.
|
|
*/
|
|
from(windowState) {
|
|
switch (windowState) {
|
|
case 1:
|
|
return WindowState.Maximized;
|
|
|
|
case 2:
|
|
return WindowState.Minimized;
|
|
|
|
case 3:
|
|
return WindowState.Normal;
|
|
|
|
case 4:
|
|
return WindowState.Fullscreen;
|
|
|
|
default:
|
|
throw new TypeError(`Unknown window state: ${windowState}`);
|
|
}
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Waits for the window to reach a specific state after invoking a callback.
|
|
*
|
|
* @param {window} win
|
|
* The target window.
|
|
* @param {Function} callback
|
|
* The function to invoke to change the window state.
|
|
*
|
|
* @returns {Promise}
|
|
* A promise resolved when the window reaches the target state, or times out if no window manager is present.
|
|
*/
|
|
async function waitForWindowState(win, callback) {
|
|
let cb;
|
|
// Use a timed promise to abort if no window manager is present
|
|
await new lazy.TimedPromise(
|
|
resolve => {
|
|
cb = new lazy.DebounceCallback(resolve);
|
|
win.addEventListener("sizemodechange", cb);
|
|
callback();
|
|
},
|
|
{ throws: null, timeout: TIMEOUT_NO_WINDOW_MANAGER }
|
|
);
|
|
win.removeEventListener("sizemodechange", cb);
|
|
await new lazy.AnimationFramePromise(win);
|
|
}
|