ext-windows.js (19394B)
1 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ 2 /* vim: set sts=2 sw=2 et tw=80: */ 3 /* This Source Code Form is subject to the terms of the Mozilla Public 4 * License, v. 2.0. If a copy of the MPL was not distributed with this file, 5 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 6 7 "use strict"; 8 9 ChromeUtils.defineESModuleGetters(this, { 10 HomePage: "resource:///modules/HomePage.sys.mjs", 11 PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", 12 }); 13 14 var { ExtensionError, promiseObserved } = ExtensionUtils; 15 16 function sanitizePositionParams(params, window = null, positionOffset = 0) { 17 if (params.left === null && params.top === null) { 18 return; 19 } 20 21 if (params.left === null) { 22 const baseLeft = window ? window.screenX : 0; 23 params.left = baseLeft + positionOffset; 24 } 25 if (params.top === null) { 26 const baseTop = window ? window.screenY : 0; 27 params.top = baseTop + positionOffset; 28 } 29 30 // boundary check: don't put window out of visible area 31 const baseWidth = window ? window.outerWidth : 0; 32 const baseHeight = window ? window.outerHeight : 0; 33 // Secure minimum size of an window should be same to the one 34 // defined at nsGlobalWindowOuter::CheckSecurityWidthAndHeight. 35 const minWidth = 100; 36 const minHeight = 100; 37 const width = Math.max( 38 minWidth, 39 params.width !== null ? params.width : baseWidth 40 ); 41 const height = Math.max( 42 minHeight, 43 params.height !== null ? params.height : baseHeight 44 ); 45 const screenManager = Cc["@mozilla.org/gfx/screenmanager;1"].getService( 46 Ci.nsIScreenManager 47 ); 48 const screen = screenManager.screenForRect( 49 params.left, 50 params.top, 51 width, 52 height 53 ); 54 const availDeviceLeft = {}; 55 const availDeviceTop = {}; 56 const availDeviceWidth = {}; 57 const availDeviceHeight = {}; 58 screen.GetAvailRect( 59 availDeviceLeft, 60 availDeviceTop, 61 availDeviceWidth, 62 availDeviceHeight 63 ); 64 const slopX = window?.screenEdgeSlopX || 0; 65 const slopY = window?.screenEdgeSlopY || 0; 66 const factor = screen.defaultCSSScaleFactor; 67 const availLeft = Math.floor(availDeviceLeft.value / factor) - slopX; 68 const availTop = Math.floor(availDeviceTop.value / factor) - slopY; 69 const availWidth = Math.floor(availDeviceWidth.value / factor) + slopX; 70 const availHeight = Math.floor(availDeviceHeight.value / factor) + slopY; 71 params.left = Math.min( 72 availLeft + availWidth - width, 73 Math.max(availLeft, params.left) 74 ); 75 params.top = Math.min( 76 availTop + availHeight - height, 77 Math.max(availTop, params.top) 78 ); 79 } 80 81 this.windows = class extends ExtensionAPIPersistent { 82 windowEventRegistrar(event, listener) { 83 let { extension } = this; 84 return ({ fire }) => { 85 let listener2 = (window, ...args) => { 86 if (extension.canAccessWindow(window)) { 87 listener(fire, window, ...args); 88 } 89 }; 90 91 windowTracker.addListener(event, listener2); 92 return { 93 unregister() { 94 windowTracker.removeListener(event, listener2); 95 }, 96 convert(_fire) { 97 fire = _fire; 98 }, 99 }; 100 }; 101 } 102 103 PERSISTENT_EVENTS = { 104 onCreated: this.windowEventRegistrar("domwindowopened", (fire, window) => { 105 fire.async(this.extension.windowManager.convert(window)); 106 }), 107 onRemoved: this.windowEventRegistrar("domwindowclosed", (fire, window) => { 108 fire.async(windowTracker.getId(window)); 109 }), 110 onFocusChanged({ fire }) { 111 let { extension } = this; 112 // Keep track of the last windowId used to fire an onFocusChanged event 113 let lastOnFocusChangedWindowId; 114 115 let listener = () => { 116 // Wait a tick to avoid firing a superfluous WINDOW_ID_NONE 117 // event when switching focus between two Firefox windows. 118 Promise.resolve().then(() => { 119 let windowId = Window.WINDOW_ID_NONE; 120 let window = Services.focus.activeWindow; 121 if (window && extension.canAccessWindow(window)) { 122 windowId = windowTracker.getId(window); 123 } 124 if (windowId !== lastOnFocusChangedWindowId) { 125 fire.async(windowId); 126 lastOnFocusChangedWindowId = windowId; 127 } 128 }); 129 }; 130 windowTracker.addListener("focus", listener); 131 windowTracker.addListener("blur", listener); 132 return { 133 unregister() { 134 windowTracker.removeListener("focus", listener); 135 windowTracker.removeListener("blur", listener); 136 }, 137 convert(_fire) { 138 fire = _fire; 139 }, 140 }; 141 }, 142 }; 143 144 getAPI(context) { 145 let { extension } = context; 146 147 const { windowManager } = extension; 148 149 return { 150 windows: { 151 onCreated: new EventManager({ 152 context, 153 module: "windows", 154 event: "onCreated", 155 extensionApi: this, 156 }).api(), 157 158 onRemoved: new EventManager({ 159 context, 160 module: "windows", 161 event: "onRemoved", 162 extensionApi: this, 163 }).api(), 164 165 onFocusChanged: new EventManager({ 166 context, 167 module: "windows", 168 event: "onFocusChanged", 169 extensionApi: this, 170 }).api(), 171 172 get: function (windowId, getInfo) { 173 let window = windowTracker.getWindow(windowId, context); 174 if (!window || !context.canAccessWindow(window)) { 175 return Promise.reject({ 176 message: `Invalid window ID: ${windowId}`, 177 }); 178 } 179 return Promise.resolve(windowManager.convert(window, getInfo)); 180 }, 181 182 getCurrent: function (getInfo) { 183 let window = context.currentWindow || windowTracker.topWindow; 184 if (!context.canAccessWindow(window)) { 185 return Promise.reject({ message: `Invalid window` }); 186 } 187 return Promise.resolve(windowManager.convert(window, getInfo)); 188 }, 189 190 getLastFocused: function (getInfo) { 191 let window = windowTracker.topWindow; 192 if (!context.canAccessWindow(window)) { 193 return Promise.reject({ message: `Invalid window` }); 194 } 195 return Promise.resolve(windowManager.convert(window, getInfo)); 196 }, 197 198 getAll: function (getInfo) { 199 let doNotCheckTypes = 200 getInfo === null || getInfo.windowTypes === null; 201 let windows = []; 202 // incognito access is checked in getAll 203 for (let win of windowManager.getAll()) { 204 if (doNotCheckTypes || getInfo.windowTypes.includes(win.type)) { 205 windows.push(win.convert(getInfo)); 206 } 207 } 208 return windows; 209 }, 210 211 create: async function (createData) { 212 let needResize = 213 createData.left !== null || 214 createData.top !== null || 215 createData.width !== null || 216 createData.height !== null; 217 if (createData.incognito && !context.privateBrowsingAllowed) { 218 throw new ExtensionError( 219 "Extension does not have permission for incognito mode" 220 ); 221 } 222 223 if (needResize) { 224 if (createData.state !== null && createData.state != "normal") { 225 throw new ExtensionError( 226 `"state": "${createData.state}" may not be combined with "left", "top", "width", or "height"` 227 ); 228 } 229 createData.state = "normal"; 230 } 231 232 function mkstr(s) { 233 let result = Cc["@mozilla.org/supports-string;1"].createInstance( 234 Ci.nsISupportsString 235 ); 236 result.data = s; 237 return result; 238 } 239 240 let args = Cc["@mozilla.org/array;1"].createInstance( 241 Ci.nsIMutableArray 242 ); 243 244 // Whether there is only one URL to load, and it is a moz-extension:-URL. 245 let isOnlyMozExtensionUrl = false; 246 247 // Creating a new window allows one single triggering principal for all tabs that 248 // are created in the window. Due to that, if we need a browser principal to load 249 // some urls, we fallback to using a content principal like we do in the tabs api. 250 // Throws if url is an array and any url can't be loaded by the extension principal. 251 let principal = context.principal; 252 function setContentTriggeringPrincipal(url) { 253 principal = Services.scriptSecurityManager.createContentPrincipal( 254 Services.io.newURI(url), 255 { 256 // Note: privateBrowsingAllowed was already checked before. 257 privateBrowsingId: createData.incognito ? 1 : 0, 258 } 259 ); 260 } 261 262 if (createData.tabId !== null) { 263 if (createData.url !== null) { 264 throw new ExtensionError( 265 "`tabId` may not be used in conjunction with `url`" 266 ); 267 } 268 269 if (createData.allowScriptsToClose) { 270 throw new ExtensionError( 271 "`tabId` may not be used in conjunction with `allowScriptsToClose`" 272 ); 273 } 274 275 let tab = tabTracker.getTab(createData.tabId); 276 if (!context.canAccessWindow(tab.ownerGlobal)) { 277 throw new ExtensionError(`Invalid tab ID: ${createData.tabId}`); 278 } 279 // Private browsing tabs can only be moved to private browsing 280 // windows. 281 let incognito = PrivateBrowsingUtils.isBrowserPrivate( 282 tab.linkedBrowser 283 ); 284 if ( 285 createData.incognito !== null && 286 createData.incognito != incognito 287 ) { 288 throw new ExtensionError( 289 "`incognito` property must match the incognito state of tab" 290 ); 291 } 292 createData.incognito = incognito; 293 294 if ( 295 createData.cookieStoreId && 296 createData.cookieStoreId !== 297 getCookieStoreIdForTab(createData, tab) 298 ) { 299 throw new ExtensionError( 300 "`cookieStoreId` must match the tab's cookieStoreId" 301 ); 302 } 303 304 args.appendElement(tab); 305 } else if (createData.url !== null) { 306 if (Array.isArray(createData.url)) { 307 let array = Cc["@mozilla.org/array;1"].createInstance( 308 Ci.nsIMutableArray 309 ); 310 for (let url of createData.url.map(u => context.uri.resolve(u))) { 311 // We can only provide a single triggering principal when 312 // opening a window, so if the extension cannot normally 313 // access a url, we fail. This includes about and moz-ext 314 // urls. 315 if (!context.checkLoadURL(url, { dontReportErrors: true })) { 316 return Promise.reject({ message: `Illegal URL: ${url}` }); 317 } 318 array.appendElement(mkstr(url)); 319 } 320 args.appendElement(array); 321 // TODO bug 1780583: support multiple triggeringPrincipals to 322 // avoid having to use the system principal here. 323 principal = Services.scriptSecurityManager.getSystemPrincipal(); 324 } else { 325 let url = context.uri.resolve(createData.url); 326 args.appendElement(mkstr(url)); 327 isOnlyMozExtensionUrl = ExtensionUtils.isExtensionUrl(url); 328 if (!context.checkLoadURL(url, { dontReportErrors: true })) { 329 if (isOnlyMozExtensionUrl) { 330 // For backwards-compatibility (also in tabs APIs), we allow 331 // extensions to open other moz-extension:-URLs even if that 332 // other resource is not listed in web_accessible_resources. 333 setContentTriggeringPrincipal(url); 334 } else { 335 throw new ExtensionError(`Illegal URL: ${url}`); 336 } 337 } 338 } 339 } else { 340 let url = 341 createData.incognito && 342 !PrivateBrowsingUtils.permanentPrivateBrowsing 343 ? "about:privatebrowsing" 344 : HomePage.get().split("|", 1)[0]; 345 args.appendElement(mkstr(url)); 346 isOnlyMozExtensionUrl = ExtensionUtils.isExtensionUrl(url); 347 348 if (!context.checkLoadURL(url, { dontReportErrors: true })) { 349 // The extension principal cannot directly load about:-URLs, 350 // except for about:blank, or other moz-extension:-URLs that are 351 // not in web_accessible_resources. Ensure any page set as a home 352 // page will load by using a content principal. 353 setContentTriggeringPrincipal(url); 354 } 355 } 356 357 args.appendElement(null); // extraOptions 358 args.appendElement(null); // referrerInfo 359 args.appendElement(null); // postData 360 args.appendElement(null); // allowThirdPartyFixup 361 362 if (createData.cookieStoreId) { 363 let userContextIdSupports = Cc[ 364 "@mozilla.org/supports-PRUint32;1" 365 ].createInstance(Ci.nsISupportsPRUint32); 366 // May throw if validation fails. 367 userContextIdSupports.data = getUserContextIdForCookieStoreId( 368 extension, 369 createData.cookieStoreId, 370 createData.incognito 371 ); 372 373 args.appendElement(userContextIdSupports); // userContextId 374 } else { 375 args.appendElement(null); 376 } 377 378 args.appendElement(context.principal); // originPrincipal - not important. 379 args.appendElement(context.principal); // originStoragePrincipal - not important. 380 args.appendElement(principal); // triggeringPrincipal 381 args.appendElement( 382 Cc["@mozilla.org/supports-PRBool;1"].createInstance( 383 Ci.nsISupportsPRBool 384 ) 385 ); // allowInheritPrincipal 386 // There is no CSP associated with this extension, hence we explicitly pass null as the CSP argument. 387 args.appendElement(null); // csp 388 389 let features = ["chrome"]; 390 391 if (createData.type === null || createData.type == "normal") { 392 features.push("dialog=no", "all"); 393 } else { 394 // All other types create "popup"-type windows by default. 395 features.push( 396 "dialog", 397 "resizable", 398 "minimizable", 399 "titlebar", 400 "close" 401 ); 402 if (createData.left === null && createData.top === null) { 403 features.push("centerscreen"); 404 } 405 } 406 407 if (createData.incognito !== null) { 408 if (createData.incognito) { 409 if (!PrivateBrowsingUtils.enabled) { 410 throw new ExtensionError( 411 "`incognito` cannot be used if incognito mode is disabled" 412 ); 413 } 414 features.push("private"); 415 } else { 416 features.push("non-private"); 417 } 418 } 419 420 const baseWindow = windowTracker.getTopNormalWindow(context); 421 // 10px offset is same to Chromium 422 sanitizePositionParams(createData, baseWindow, 10); 423 424 if (createData.width !== null) { 425 features.push("outerWidth=" + createData.width); 426 } 427 if (createData.height !== null) { 428 features.push("outerHeight=" + createData.height); 429 } 430 if (createData.left !== null) { 431 features.push("left=" + createData.left); 432 } 433 if (createData.top !== null) { 434 features.push("top=" + createData.top); 435 } 436 437 let window = Services.ww.openWindow( 438 null, 439 AppConstants.BROWSER_CHROME_URL, 440 "_blank", 441 features.join(","), 442 args 443 ); 444 445 let win = windowManager.getWrapper(window); 446 447 // TODO: focused, type 448 449 const contentLoaded = new Promise(resolve => { 450 window.addEventListener( 451 "DOMContentLoaded", 452 function () { 453 let { allowScriptsToClose } = createData; 454 if (allowScriptsToClose === null && isOnlyMozExtensionUrl) { 455 allowScriptsToClose = true; 456 } 457 if (allowScriptsToClose) { 458 window.gBrowserAllowScriptsToCloseInitialTabs = true; 459 } 460 resolve(); 461 }, 462 { once: true } 463 ); 464 }); 465 466 const startupFinished = promiseObserved( 467 "browser-delayed-startup-finished", 468 win => win == window 469 ); 470 471 await contentLoaded; 472 await startupFinished; 473 474 if ( 475 [ 476 "minimized", 477 "fullscreen", 478 "docked", 479 "normal", 480 "maximized", 481 ].includes(createData.state) 482 ) { 483 await win.setState(createData.state); 484 } 485 486 if (createData.titlePreface !== null) { 487 win.setTitlePreface(createData.titlePreface); 488 } 489 return win.convert({ populate: true }); 490 }, 491 492 update: async function (windowId, updateInfo) { 493 if (updateInfo.state !== null && updateInfo.state != "normal") { 494 if ( 495 updateInfo.left !== null || 496 updateInfo.top !== null || 497 updateInfo.width !== null || 498 updateInfo.height !== null 499 ) { 500 throw new ExtensionError( 501 `"state": "${updateInfo.state}" may not be combined with "left", "top", "width", or "height"` 502 ); 503 } 504 } 505 506 let win = windowManager.get(windowId, context); 507 if (!win) { 508 throw new ExtensionError(`Invalid window ID: ${windowId}`); 509 } 510 if (updateInfo.focused) { 511 win.window.focus(); 512 } 513 514 if (updateInfo.state !== null) { 515 await win.setState(updateInfo.state); 516 } 517 518 if (updateInfo.drawAttention) { 519 // Bug 1257497 - Firefox can't cancel attention actions. 520 win.window.getAttention(); 521 } 522 523 sanitizePositionParams(updateInfo, win.window); 524 win.updateGeometry(updateInfo); 525 526 if (updateInfo.titlePreface !== null) { 527 win.setTitlePreface(updateInfo.titlePreface); 528 win.window.gBrowser.updateTitlebar(); 529 } 530 531 // TODO: All the other properties, focused=false... 532 533 return win.convert(); 534 }, 535 536 remove: function (windowId) { 537 let window = windowTracker.getWindow(windowId, context); 538 if (!context.canAccessWindow(window)) { 539 return Promise.reject({ 540 message: `Invalid window ID: ${windowId}`, 541 }); 542 } 543 window.close(); 544 545 return new Promise(resolve => { 546 let listener = () => { 547 windowTracker.removeListener("domwindowclosed", listener); 548 resolve(); 549 }; 550 windowTracker.addListener("domwindowclosed", listener); 551 }); 552 }, 553 }, 554 }; 555 } 556 };