diff options
Diffstat (limited to 'browser/components/extensions/parent/ext-windows.js')
-rw-r--r-- | browser/components/extensions/parent/ext-windows.js | 544 |
1 files changed, 544 insertions, 0 deletions
diff --git a/browser/components/extensions/parent/ext-windows.js b/browser/components/extensions/parent/ext-windows.js new file mode 100644 index 0000000000..3691ecdf56 --- /dev/null +++ b/browser/components/extensions/parent/ext-windows.js @@ -0,0 +1,544 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +ChromeUtils.defineESModuleGetters(this, { + HomePage: "resource:///modules/HomePage.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +var { ExtensionError, promiseObserved } = ExtensionUtils; + +function sanitizePositionParams(params, window = null, positionOffset = 0) { + if (params.left === null && params.top === null) { + return; + } + + if (params.left === null) { + const baseLeft = window ? window.screenX : 0; + params.left = baseLeft + positionOffset; + } + if (params.top === null) { + const baseTop = window ? window.screenY : 0; + params.top = baseTop + positionOffset; + } + + // boundary check: don't put window out of visible area + const baseWidth = window ? window.outerWidth : 0; + const baseHeight = window ? window.outerHeight : 0; + // Secure minimum size of an window should be same to the one + // defined at nsGlobalWindowOuter::CheckSecurityWidthAndHeight. + const minWidth = 100; + const minHeight = 100; + const width = Math.max( + minWidth, + params.width !== null ? params.width : baseWidth + ); + const height = Math.max( + minHeight, + params.height !== null ? params.height : baseHeight + ); + const screenManager = Cc["@mozilla.org/gfx/screenmanager;1"].getService( + Ci.nsIScreenManager + ); + const screen = screenManager.screenForRect( + params.left, + params.top, + width, + height + ); + const availDeviceLeft = {}; + const availDeviceTop = {}; + const availDeviceWidth = {}; + const availDeviceHeight = {}; + screen.GetAvailRect( + availDeviceLeft, + availDeviceTop, + availDeviceWidth, + availDeviceHeight + ); + const slopX = window?.screenEdgeSlopX || 0; + const slopY = window?.screenEdgeSlopY || 0; + const factor = screen.defaultCSSScaleFactor; + const availLeft = Math.floor(availDeviceLeft.value / factor) - slopX; + const availTop = Math.floor(availDeviceTop.value / factor) - slopY; + const availWidth = Math.floor(availDeviceWidth.value / factor) + slopX; + const availHeight = Math.floor(availDeviceHeight.value / factor) + slopY; + params.left = Math.min( + availLeft + availWidth - width, + Math.max(availLeft, params.left) + ); + params.top = Math.min( + availTop + availHeight - height, + Math.max(availTop, params.top) + ); +} + +this.windows = class extends ExtensionAPIPersistent { + windowEventRegistrar(event, listener) { + let { extension } = this; + return ({ fire }) => { + let listener2 = (window, ...args) => { + if (extension.canAccessWindow(window)) { + listener(fire, window, ...args); + } + }; + + windowTracker.addListener(event, listener2); + return { + unregister() { + windowTracker.removeListener(event, listener2); + }, + convert(_fire) { + fire = _fire; + }, + }; + }; + } + + PERSISTENT_EVENTS = { + onCreated: this.windowEventRegistrar("domwindowopened", (fire, window) => { + fire.async(this.extension.windowManager.convert(window)); + }), + onRemoved: this.windowEventRegistrar("domwindowclosed", (fire, window) => { + fire.async(windowTracker.getId(window)); + }), + onFocusChanged({ fire }) { + let { extension } = this; + // Keep track of the last windowId used to fire an onFocusChanged event + let lastOnFocusChangedWindowId; + + let listener = event => { + // Wait a tick to avoid firing a superfluous WINDOW_ID_NONE + // event when switching focus between two Firefox windows. + Promise.resolve().then(() => { + let windowId = Window.WINDOW_ID_NONE; + let window = Services.focus.activeWindow; + if (window && extension.canAccessWindow(window)) { + windowId = windowTracker.getId(window); + } + if (windowId !== lastOnFocusChangedWindowId) { + fire.async(windowId); + lastOnFocusChangedWindowId = windowId; + } + }); + }; + windowTracker.addListener("focus", listener); + windowTracker.addListener("blur", listener); + return { + unregister() { + windowTracker.removeListener("focus", listener); + windowTracker.removeListener("blur", listener); + }, + convert(_fire) { + fire = _fire; + }, + }; + }, + }; + + getAPI(context) { + let { extension } = context; + + const { windowManager } = extension; + + return { + windows: { + onCreated: new EventManager({ + context, + module: "windows", + event: "onCreated", + extensionApi: this, + }).api(), + + onRemoved: new EventManager({ + context, + module: "windows", + event: "onRemoved", + extensionApi: this, + }).api(), + + onFocusChanged: new EventManager({ + context, + module: "windows", + event: "onFocusChanged", + extensionApi: this, + }).api(), + + get: function (windowId, getInfo) { + let window = windowTracker.getWindow(windowId, context); + if (!window || !context.canAccessWindow(window)) { + return Promise.reject({ + message: `Invalid window ID: ${windowId}`, + }); + } + return Promise.resolve(windowManager.convert(window, getInfo)); + }, + + getCurrent: function (getInfo) { + let window = context.currentWindow || windowTracker.topWindow; + if (!context.canAccessWindow(window)) { + return Promise.reject({ message: `Invalid window` }); + } + return Promise.resolve(windowManager.convert(window, getInfo)); + }, + + getLastFocused: function (getInfo) { + let window = windowTracker.topWindow; + if (!context.canAccessWindow(window)) { + return Promise.reject({ message: `Invalid window` }); + } + return Promise.resolve(windowManager.convert(window, getInfo)); + }, + + getAll: function (getInfo) { + let doNotCheckTypes = + getInfo === null || getInfo.windowTypes === null; + let windows = []; + // incognito access is checked in getAll + for (let win of windowManager.getAll()) { + if (doNotCheckTypes || getInfo.windowTypes.includes(win.type)) { + windows.push(win.convert(getInfo)); + } + } + return windows; + }, + + create: async function (createData) { + let needResize = + createData.left !== null || + createData.top !== null || + createData.width !== null || + createData.height !== null; + if (createData.incognito && !context.privateBrowsingAllowed) { + throw new ExtensionError( + "Extension does not have permission for incognito mode" + ); + } + + if (needResize) { + if (createData.state !== null && createData.state != "normal") { + throw new ExtensionError( + `"state": "${createData.state}" may not be combined with "left", "top", "width", or "height"` + ); + } + createData.state = "normal"; + } + + function mkstr(s) { + let result = Cc["@mozilla.org/supports-string;1"].createInstance( + Ci.nsISupportsString + ); + result.data = s; + return result; + } + + let args = Cc["@mozilla.org/array;1"].createInstance( + Ci.nsIMutableArray + ); + + // Whether there is only one URL to load, and it is a moz-extension:-URL. + let isOnlyMozExtensionUrl = false; + + // Creating a new window allows one single triggering principal for all tabs that + // are created in the window. Due to that, if we need a browser principal to load + // some urls, we fallback to using a content principal like we do in the tabs api. + // Throws if url is an array and any url can't be loaded by the extension principal. + let principal = context.principal; + function setContentTriggeringPrincipal(url) { + principal = Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI(url), + { + // Note: privateBrowsingAllowed was already checked before. + privateBrowsingId: createData.incognito ? 1 : 0, + } + ); + } + + if (createData.tabId !== null) { + if (createData.url !== null) { + throw new ExtensionError( + "`tabId` may not be used in conjunction with `url`" + ); + } + + if (createData.allowScriptsToClose) { + throw new ExtensionError( + "`tabId` may not be used in conjunction with `allowScriptsToClose`" + ); + } + + let tab = tabTracker.getTab(createData.tabId); + if (!context.canAccessWindow(tab.ownerGlobal)) { + throw new ExtensionError(`Invalid tab ID: ${createData.tabId}`); + } + // Private browsing tabs can only be moved to private browsing + // windows. + let incognito = PrivateBrowsingUtils.isBrowserPrivate( + tab.linkedBrowser + ); + if ( + createData.incognito !== null && + createData.incognito != incognito + ) { + throw new ExtensionError( + "`incognito` property must match the incognito state of tab" + ); + } + createData.incognito = incognito; + + if ( + createData.cookieStoreId && + createData.cookieStoreId !== + getCookieStoreIdForTab(createData, tab) + ) { + throw new ExtensionError( + "`cookieStoreId` must match the tab's cookieStoreId" + ); + } + + args.appendElement(tab); + } else if (createData.url !== null) { + if (Array.isArray(createData.url)) { + let array = Cc["@mozilla.org/array;1"].createInstance( + Ci.nsIMutableArray + ); + for (let url of createData.url.map(u => context.uri.resolve(u))) { + // We can only provide a single triggering principal when + // opening a window, so if the extension cannot normally + // access a url, we fail. This includes about and moz-ext + // urls. + if (!context.checkLoadURL(url, { dontReportErrors: true })) { + return Promise.reject({ message: `Illegal URL: ${url}` }); + } + array.appendElement(mkstr(url)); + } + args.appendElement(array); + // TODO bug 1780583: support multiple triggeringPrincipals to + // avoid having to use the system principal here. + principal = Services.scriptSecurityManager.getSystemPrincipal(); + } else { + let url = context.uri.resolve(createData.url); + args.appendElement(mkstr(url)); + isOnlyMozExtensionUrl = url.startsWith("moz-extension://"); + if (!context.checkLoadURL(url, { dontReportErrors: true })) { + if (isOnlyMozExtensionUrl) { + // For backwards-compatibility (also in tabs APIs), we allow + // extensions to open other moz-extension:-URLs even if that + // other resource is not listed in web_accessible_resources. + setContentTriggeringPrincipal(url); + } else { + throw new ExtensionError(`Illegal URL: ${url}`); + } + } + } + } else { + let url = + createData.incognito && + !PrivateBrowsingUtils.permanentPrivateBrowsing + ? "about:privatebrowsing" + : HomePage.get().split("|", 1)[0]; + args.appendElement(mkstr(url)); + isOnlyMozExtensionUrl = url.startsWith("moz-extension://"); + + if (!context.checkLoadURL(url, { dontReportErrors: true })) { + // The extension principal cannot directly load about:-URLs, + // except for about:blank, or other moz-extension:-URLs that are + // not in web_accessible_resources. Ensure any page set as a home + // page will load by using a content principal. + setContentTriggeringPrincipal(url); + } + } + + args.appendElement(null); // extraOptions + args.appendElement(null); // referrerInfo + args.appendElement(null); // postData + args.appendElement(null); // allowThirdPartyFixup + + if (createData.cookieStoreId) { + let userContextIdSupports = Cc[ + "@mozilla.org/supports-PRUint32;1" + ].createInstance(Ci.nsISupportsPRUint32); + // May throw if validation fails. + userContextIdSupports.data = getUserContextIdForCookieStoreId( + extension, + createData.cookieStoreId, + createData.incognito + ); + + args.appendElement(userContextIdSupports); // userContextId + } else { + args.appendElement(null); + } + + args.appendElement(context.principal); // originPrincipal - not important. + args.appendElement(context.principal); // originStoragePrincipal - not important. + args.appendElement(principal); // triggeringPrincipal + args.appendElement( + Cc["@mozilla.org/supports-PRBool;1"].createInstance( + Ci.nsISupportsPRBool + ) + ); // allowInheritPrincipal + // There is no CSP associated with this extension, hence we explicitly pass null as the CSP argument. + args.appendElement(null); // csp + + let features = ["chrome"]; + + if (createData.type === null || createData.type == "normal") { + features.push("dialog=no", "all"); + } else { + // All other types create "popup"-type windows by default. + features.push( + "dialog", + "resizable", + "minimizable", + "titlebar", + "close" + ); + if (createData.left === null && createData.top === null) { + features.push("centerscreen"); + } + } + + if (createData.incognito !== null) { + if (createData.incognito) { + if (!PrivateBrowsingUtils.enabled) { + throw new ExtensionError( + "`incognito` cannot be used if incognito mode is disabled" + ); + } + features.push("private"); + } else { + features.push("non-private"); + } + } + + const baseWindow = windowTracker.getTopNormalWindow(context); + // 10px offset is same to Chromium + sanitizePositionParams(createData, baseWindow, 10); + + let window = Services.ww.openWindow( + null, + AppConstants.BROWSER_CHROME_URL, + "_blank", + features.join(","), + args + ); + + let win = windowManager.getWrapper(window); + win.updateGeometry(createData); + + // TODO: focused, type + + const contentLoaded = new Promise(resolve => { + window.addEventListener( + "DOMContentLoaded", + function () { + let { allowScriptsToClose } = createData; + if (allowScriptsToClose === null && isOnlyMozExtensionUrl) { + allowScriptsToClose = true; + } + if (allowScriptsToClose) { + window.gBrowserAllowScriptsToCloseInitialTabs = true; + } + resolve(); + }, + { once: true } + ); + }); + + const startupFinished = promiseObserved( + "browser-delayed-startup-finished", + win => win == window + ); + + await contentLoaded; + await startupFinished; + + if ( + [ + "minimized", + "fullscreen", + "docked", + "normal", + "maximized", + ].includes(createData.state) + ) { + await win.setState(createData.state); + } + + if (createData.titlePreface !== null) { + win.setTitlePreface(createData.titlePreface); + } + return win.convert({ populate: true }); + }, + + update: async function (windowId, updateInfo) { + if (updateInfo.state !== null && updateInfo.state != "normal") { + if ( + updateInfo.left !== null || + updateInfo.top !== null || + updateInfo.width !== null || + updateInfo.height !== null + ) { + throw new ExtensionError( + `"state": "${updateInfo.state}" may not be combined with "left", "top", "width", or "height"` + ); + } + } + + let win = windowManager.get(windowId, context); + if (!win) { + throw new ExtensionError(`Invalid window ID: ${windowId}`); + } + if (updateInfo.focused) { + win.window.focus(); + } + + if (updateInfo.state !== null) { + await win.setState(updateInfo.state); + } + + if (updateInfo.drawAttention) { + // Bug 1257497 - Firefox can't cancel attention actions. + win.window.getAttention(); + } + + sanitizePositionParams(updateInfo, win.window); + win.updateGeometry(updateInfo); + + if (updateInfo.titlePreface !== null) { + win.setTitlePreface(updateInfo.titlePreface); + win.window.gBrowser.updateTitlebar(); + } + + // TODO: All the other properties, focused=false... + + return win.convert(); + }, + + remove: function (windowId) { + let window = windowTracker.getWindow(windowId, context); + if (!context.canAccessWindow(window)) { + return Promise.reject({ + message: `Invalid window ID: ${windowId}`, + }); + } + window.close(); + + return new Promise(resolve => { + let listener = () => { + windowTracker.removeListener("domwindowclosed", listener); + resolve(); + }; + windowTracker.addListener("domwindowclosed", listener); + }); + }, + }, + }; + } +}; |