/* 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;
// Note: params from extensions are in CSS pixels, not desktop pixels.
// This method clamps values as needed, still in CSS pixels.
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
);
// screenForRect and GetAvailRectDisplayPix expect desktop pixels, so we need
// to map to desktop pixels (and back to CSS pixels later).
const cssToDesktopScale = window
? window.devicePixelRatio / window.desktopToDeviceScale
:
1;
const desktopLeft = params.left * cssToDesktopScale;
const desktopTop = params.top * cssToDesktopScale;
const desktopWidth = width * cssToDesktopScale;
const desktopHeight = height * cssToDesktopScale;
const screen = screenManager.screenForRect(
desktopLeft,
desktopTop,
desktopWidth,
desktopHeight
);
const availLeft = {};
const availTop = {};
const availWidth = {};
const availHeight = {};
screen.GetAvailRectDisplayPix(availLeft, availTop, availWidth, availHeight);
const slopX = (window?.screenEdgeSlopX ||
0) * cssToDesktopScale;
const slopY = (window?.screenEdgeSlopY ||
0) * cssToDesktopScale;
const minLeft = availLeft.value - slopX;
const minTop = availTop.value - slopY;
const maxLeft = availLeft.value + availWidth.value - desktopWidth;
const maxTop = availTop.value + availHeight.value - desktopHeight;
const clampedLeft = Math.min(maxLeft, Math.max(minLeft, desktopLeft));
const clampedTop = Math.min(maxTop, Math.max(minTop, desktopTop));
params.left = Math.round(clampedLeft / cssToDesktopScale);
params.top = Math.round(clampedTop / cssToDesktopScale);
}
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.documentGlobal)) {
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"
);
}
// If the tab is part of a split view, move the whole split view
// instead of just that single tab.
const tabToAdopt = tab.splitview ?? tab;
// For tab adoption logic, see getTabToAdopt() in browser-init.js.
args.appendElement(tabToAdopt);
}
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 = ExtensionUtils.isExtensionUrl(url);
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 = ExtensionUtils.isExtensionUrl(url);
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);
}
// TODO bug 2049007: openWindow() with null parent currently
// interprets left/top as desktop pixels instead of CSS pixels.
const cssToDesktopScale = baseWindow
? baseWindow.devicePixelRatio / baseWindow.desktopToDeviceScale
:
1;
if (createData.left !==
null) {
features.push(
"left=" + Math.round(createData.left * cssToDesktopScale)
);
}
if (createData.top !==
null) {
features.push(
"top=" + Math.round(createData.top * cssToDesktopScale)
);
}
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);
});
},
},
};
}
};