* In ext-windows.js, set the position and size on creation. This moves
the burden of doing so to widget, and could avoid flickering.
* In browser_ext_windows_size.js, we cope with the fact that on linux
the specified size might be the inner rather than outer size. This
was already the case before, but outer{Width,Height} reported always
the inner size so it was papered.
* In browser_unified_extensions_overflowable_toolbar.js and the
relevant head file, we make the code a bit more reliable. This is all
to workaround a mutter but on automation, where if a window was
already maximized and you request a resize, mutter will (correctly)
restore() the window, but will lose the resize information. This
doesn't happen on newer Mutter versions. Still, it seems easier to
use window.maximize(), etc than manually trying to maximize the
window.
* In head_unified_extensions.js we also remove the code to ensure
stable positions since it was broken (it was checking
win.screen.{top,left}, rather than win.screen{X,Y}). We can always
bring it back fixed if really needed.
Differential Revision: https://phabricator.services.mozilla.com/D234903
557 lines
19 KiB
JavaScript
557 lines
19 KiB
JavaScript
/* -*- 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 = () => {
|
|
// 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);
|
|
|
|
if (createData.width !== null) {
|
|
features.push("outerWidth=" + createData.width);
|
|
}
|
|
if (createData.height !== null) {
|
|
features.push("outerHeight=" + createData.height);
|
|
}
|
|
if (createData.left !== null) {
|
|
features.push("left=" + createData.left);
|
|
}
|
|
if (createData.top !== null) {
|
|
features.push("top=" + createData.top);
|
|
}
|
|
|
|
let window = Services.ww.openWindow(
|
|
null,
|
|
AppConstants.BROWSER_CHROME_URL,
|
|
"_blank",
|
|
features.join(","),
|
|
args
|
|
);
|
|
|
|
let win = windowManager.getWrapper(window);
|
|
|
|
// 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);
|
|
});
|
|
},
|
|
},
|
|
};
|
|
}
|
|
};
|