ext-tabs.js (16866B)
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 5 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 6 7 "use strict"; 8 9 ChromeUtils.defineESModuleGetters(this, { 10 GeckoViewTabBridge: "resource://gre/modules/GeckoViewTab.sys.mjs", 11 mobileWindowTracker: "resource://gre/modules/GeckoViewWebExtension.sys.mjs", 12 }); 13 14 const getBrowserWindow = window => { 15 return window.browsingContext.topChromeWindow; 16 }; 17 18 const tabListener = { 19 tabReadyInitialized: false, 20 tabReadyPromises: new WeakMap(), 21 initializingTabs: new WeakSet(), 22 23 initTabReady() { 24 if (!this.tabReadyInitialized) { 25 windowTracker.addListener("progress", this); 26 27 this.tabReadyInitialized = true; 28 } 29 }, 30 31 onLocationChange(browser, webProgress, request) { 32 if (webProgress.isTopLevel) { 33 const { tab } = browser.ownerGlobal; 34 35 // Ignore initial about:blank 36 if (!request && this.initializingTabs.has(tab)) { 37 return; 38 } 39 40 // Now we are certain that the first page in the tab was loaded. 41 this.initializingTabs.delete(tab); 42 43 // browser.innerWindowID is now set, resolve the promises if any. 44 const deferred = this.tabReadyPromises.get(tab); 45 if (deferred) { 46 deferred.resolve(tab); 47 this.tabReadyPromises.delete(tab); 48 } 49 } 50 }, 51 52 /** 53 * Returns a promise that resolves when the tab is ready. 54 * Tabs created via the `tabs.create` method are "ready" once the location 55 * changes to the requested URL. Other tabs are assumed to be ready once their 56 * inner window ID is known. 57 * 58 * @param {NativeTab} nativeTab The native tab object. 59 * @returns {Promise} Resolves with the given tab once ready. 60 */ 61 awaitTabReady(nativeTab) { 62 let deferred = this.tabReadyPromises.get(nativeTab); 63 if (!deferred) { 64 deferred = Promise.withResolvers(); 65 if ( 66 !this.initializingTabs.has(nativeTab) && 67 (nativeTab.browser.innerWindowID || 68 nativeTab.browser.currentURI.spec === "about:blank") 69 ) { 70 deferred.resolve(nativeTab); 71 } else { 72 this.initTabReady(); 73 this.tabReadyPromises.set(nativeTab, deferred); 74 } 75 } 76 return deferred.promise; 77 }, 78 }; 79 80 this.tabs = class extends ExtensionAPIPersistent { 81 tabEventRegistrar({ event, listener }) { 82 const { extension } = this; 83 const { tabManager } = extension; 84 return ({ fire }) => { 85 const listener2 = (eventName, eventData, ...args) => { 86 if (!tabManager.canAccessTab(eventData.nativeTab)) { 87 return; 88 } 89 90 listener(fire, eventData, ...args); 91 }; 92 93 tabTracker.on(event, listener2); 94 return { 95 unregister() { 96 tabTracker.off(event, listener2); 97 }, 98 convert(_fire) { 99 fire = _fire; 100 }, 101 }; 102 }; 103 } 104 105 PERSISTENT_EVENTS = { 106 onActivated({ fire, context }) { 107 const listener = (eventName, event) => { 108 const { windowId, tabId, isPrivate } = event; 109 if (isPrivate && !context.privateBrowsingAllowed) { 110 return; 111 } 112 // In GeckoView each window has only one tab, so previousTabId is omitted. 113 fire.async({ windowId, tabId }); 114 }; 115 116 mobileWindowTracker.on("tab-activated", listener); 117 return { 118 unregister() { 119 mobileWindowTracker.off("tab-activated", listener); 120 }, 121 convert(_fire, _context) { 122 fire = _fire; 123 context = _context; 124 }, 125 }; 126 }, 127 onCreated: this.tabEventRegistrar({ 128 event: "tab-created", 129 listener: (fire, event) => { 130 const { tabManager } = this.extension; 131 fire.async(tabManager.convert(event.nativeTab)); 132 }, 133 }), 134 onRemoved: this.tabEventRegistrar({ 135 event: "tab-removed", 136 listener: (fire, event) => { 137 fire.async(event.tabId, { 138 windowId: event.windowId, 139 isWindowClosing: event.isWindowClosing, 140 }); 141 }, 142 }), 143 onUpdated({ fire }) { 144 const { tabManager } = this.extension; 145 const restricted = ["url", "favIconUrl", "title"]; 146 147 function sanitize(tab, changeInfo) { 148 const result = {}; 149 let nonempty = false; 150 for (const prop in changeInfo) { 151 // In practice, changeInfo contains at most one property from 152 // restricted. Therefore it is not necessary to cache the value 153 // of tab.hasTabPermission outside the loop. 154 if (!restricted.includes(prop) || tab.hasTabPermission) { 155 nonempty = true; 156 result[prop] = changeInfo[prop]; 157 } 158 } 159 return [nonempty, result]; 160 } 161 162 const fireForTab = (tab, changed) => { 163 const [needed, changeInfo] = sanitize(tab, changed); 164 if (needed) { 165 fire.async(tab.id, changeInfo, tab.convert()); 166 } 167 }; 168 169 const listener = event => { 170 const needed = []; 171 let nativeTab; 172 switch (event.type) { 173 case "pagetitlechanged": { 174 const window = getBrowserWindow(event.target.ownerGlobal); 175 nativeTab = window.tab; 176 177 needed.push("title"); 178 break; 179 } 180 181 case "DOMAudioPlaybackStarted": 182 case "DOMAudioPlaybackStopped": { 183 const window = event.target.ownerGlobal; 184 nativeTab = window.tab; 185 needed.push("audible"); 186 break; 187 } 188 } 189 190 if (!nativeTab) { 191 return; 192 } 193 194 const tab = tabManager.getWrapper(nativeTab); 195 const changeInfo = {}; 196 for (const prop of needed) { 197 changeInfo[prop] = tab[prop]; 198 } 199 200 fireForTab(tab, changeInfo); 201 }; 202 203 const statusListener = ({ browser, status, url }) => { 204 const { tab } = browser.ownerGlobal; 205 if (tab) { 206 const changed = { status }; 207 if (url) { 208 changed.url = url; 209 } 210 211 fireForTab(tabManager.wrapTab(tab), changed); 212 } 213 }; 214 215 windowTracker.addListener("status", statusListener); 216 windowTracker.addListener("pagetitlechanged", listener); 217 218 return { 219 unregister() { 220 windowTracker.removeListener("status", statusListener); 221 windowTracker.removeListener("pagetitlechanged", listener); 222 }, 223 convert(_fire) { 224 fire = _fire; 225 }, 226 }; 227 }, 228 }; 229 230 getAPI(context) { 231 const { extension } = context; 232 const { tabManager } = extension; 233 const extensionApi = this; 234 const module = "tabs"; 235 236 function getTabOrActive(tabId) { 237 if (tabId !== null) { 238 return tabTracker.getTab(tabId); 239 } 240 return tabTracker.activeTab; 241 } 242 243 async function promiseTabWhenReady(tabId) { 244 let tab; 245 if (tabId !== null) { 246 tab = tabManager.get(tabId); 247 } else { 248 tab = tabManager.getWrapper(tabTracker.activeTab); 249 } 250 if (!tab) { 251 throw new ExtensionError( 252 tabId == null ? "Cannot access activeTab" : `Invalid tab ID: ${tabId}` 253 ); 254 } 255 256 await tabListener.awaitTabReady(tab.nativeTab); 257 258 return tab; 259 } 260 261 function loadURIInTab(nativeTab, url) { 262 const { browser } = nativeTab; 263 264 let loadFlags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE; 265 let { principal } = context; 266 const isAboutUrl = url.startsWith("about:"); 267 if ( 268 isAboutUrl || 269 (ExtensionUtils.isExtensionUrl(url) && 270 !context.checkLoadURL(url, { dontReportErrors: true })) 271 ) { 272 // Falling back to content here as about: requires it, however is safe. 273 principal = 274 Services.scriptSecurityManager.getLoadContextContentPrincipal( 275 Services.io.newURI(url), 276 browser.loadContext 277 ); 278 } 279 if (isAboutUrl) { 280 // Make sure things like about:blank and other about: URIs never 281 // inherit, and instead always get a NullPrincipal. 282 loadFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_DISALLOW_INHERIT_PRINCIPAL; 283 } 284 285 browser.fixupAndLoadURIString(url, { 286 loadFlags, 287 triggeringPrincipal: principal, 288 }); 289 } 290 291 return { 292 tabs: { 293 onActivated: new EventManager({ 294 context, 295 module, 296 event: "onActivated", 297 extensionApi, 298 }).api(), 299 300 onCreated: new EventManager({ 301 context, 302 module, 303 event: "onCreated", 304 extensionApi, 305 }).api(), 306 307 /** 308 * Since multiple tabs currently can't be highlighted, onHighlighted 309 * essentially acts an alias for tabs.onActivated but returns 310 * the tabId in an array to match the API. 311 * 312 * @see https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/Tabs/onHighlighted 313 */ 314 onHighlighted: makeGlobalEvent( 315 context, 316 "tabs.onHighlighted", 317 "Tab:Selected", 318 (fire, data) => { 319 const tab = tabManager.get(data.id); 320 321 fire.async({ tabIds: [tab.id], windowId: tab.windowId }); 322 } 323 ), 324 325 // Some events below are not be persisted because they are not implemented. 326 // They do not have an "extensionApi" property with an entry in 327 // PERSISTENT_EVENTS, but instead an empty "register" method. 328 onAttached: new EventManager({ 329 context, 330 name: "tabs.onAttached", 331 register: () => { 332 return () => {}; 333 }, 334 }).api(), 335 336 onDetached: new EventManager({ 337 context, 338 name: "tabs.onDetached", 339 register: () => { 340 return () => {}; 341 }, 342 }).api(), 343 344 onRemoved: new EventManager({ 345 context, 346 module, 347 event: "onRemoved", 348 extensionApi, 349 }).api(), 350 351 onReplaced: new EventManager({ 352 context, 353 name: "tabs.onReplaced", 354 register: () => { 355 return () => {}; 356 }, 357 }).api(), 358 359 onMoved: new EventManager({ 360 context, 361 name: "tabs.onMoved", 362 register: () => { 363 return () => {}; 364 }, 365 }).api(), 366 367 onUpdated: new EventManager({ 368 context, 369 module, 370 event: "onUpdated", 371 extensionApi, 372 }).api(), 373 374 async create({ 375 active, 376 cookieStoreId, 377 discarded, 378 index, 379 openInReaderMode, 380 pinned, 381 url, 382 } = {}) { 383 if (active === null) { 384 active = true; 385 } 386 387 tabListener.initTabReady(); 388 389 if (url !== null) { 390 url = context.uri.resolve(url); 391 392 if ( 393 !ExtensionUtils.isExtensionUrl(url) && 394 !context.checkLoadURL(url, { dontReportErrors: true }) 395 ) { 396 return Promise.reject({ message: `Illegal URL: ${url}` }); 397 } 398 } 399 400 if (cookieStoreId) { 401 cookieStoreId = getUserContextIdForCookieStoreId( 402 extension, 403 cookieStoreId, 404 false // TODO bug 1372178: support creation of private browsing tabs 405 ); 406 } 407 cookieStoreId = cookieStoreId ? cookieStoreId.toString() : undefined; 408 409 const nativeTab = await GeckoViewTabBridge.createNewTab({ 410 extensionId: context.extension.id, 411 createProperties: { 412 active, 413 cookieStoreId, 414 discarded, 415 index, 416 openInReaderMode, 417 pinned, 418 url, 419 }, 420 }); 421 422 // The initial about:blank loads synchronously, so no listener is needed 423 if (url !== null && !url.startsWith("about:blank")) { 424 tabListener.initializingTabs.add(nativeTab); 425 } else { 426 url = "about:blank"; 427 } 428 429 loadURIInTab(nativeTab, url); 430 431 if (active) { 432 const newWindow = nativeTab.browser.ownerGlobal; 433 mobileWindowTracker.setTabActive(newWindow, true); 434 } 435 436 return tabManager.convert(nativeTab); 437 }, 438 439 async remove(tabs) { 440 if (!Array.isArray(tabs)) { 441 tabs = [tabs]; 442 } 443 444 await Promise.all( 445 tabs.map(async tabId => { 446 const windowId = GeckoViewTabBridge.tabIdToWindowId(tabId); 447 const window = windowTracker.getWindow(windowId, context, false); 448 if (!window) { 449 throw new ExtensionError(`Invalid tab ID ${tabId}`); 450 } 451 await GeckoViewTabBridge.closeTab({ 452 window, 453 extensionId: context.extension.id, 454 }); 455 }) 456 ); 457 }, 458 459 async update( 460 tabId, 461 { active, autoDiscardable, highlighted, muted, pinned, url } = {} 462 ) { 463 const nativeTab = getTabOrActive(tabId); 464 const window = nativeTab.browser.ownerGlobal; 465 466 if (url !== null) { 467 url = context.uri.resolve(url); 468 469 if ( 470 !ExtensionUtils.isExtensionUrl(url) && 471 !context.checkLoadURL(url, { dontReportErrors: true }) 472 ) { 473 return Promise.reject({ message: `Illegal URL: ${url}` }); 474 } 475 } 476 477 await GeckoViewTabBridge.updateTab({ 478 window, 479 extensionId: context.extension.id, 480 updateProperties: { 481 active, 482 autoDiscardable, 483 highlighted, 484 muted, 485 pinned, 486 url, 487 }, 488 }); 489 490 if (url !== null) { 491 loadURIInTab(nativeTab, url); 492 } 493 494 // FIXME: openerTabId, successorTabId 495 if (active) { 496 mobileWindowTracker.setTabActive(window, true); 497 } 498 499 return tabManager.convert(nativeTab); 500 }, 501 502 async reload(tabId, reloadProperties) { 503 const nativeTab = getTabOrActive(tabId); 504 505 let flags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE; 506 if (reloadProperties && reloadProperties.bypassCache) { 507 flags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE; 508 } 509 nativeTab.browser.reloadWithFlags(flags); 510 }, 511 512 async get(tabId) { 513 return tabManager.get(tabId).convert(); 514 }, 515 516 async getCurrent() { 517 if (context.tabId) { 518 return tabManager.get(context.tabId).convert(); 519 } 520 }, 521 522 async query(queryInfo) { 523 return Array.from(tabManager.query(queryInfo, context), tab => 524 tab.convert() 525 ); 526 }, 527 528 async captureTab(tabId, options) { 529 const nativeTab = getTabOrActive(tabId); 530 await tabListener.awaitTabReady(nativeTab); 531 532 const { browser } = nativeTab; 533 const tab = tabManager.wrapTab(nativeTab); 534 return tab.capture(context, browser.fullZoom, options); 535 }, 536 537 async captureVisibleTab(windowId, options) { 538 const window = 539 windowId == null 540 ? windowTracker.topWindow 541 : windowTracker.getWindow(windowId, context); 542 543 const tab = tabManager.getWrapper(window.tab); 544 if ( 545 !extension.hasPermission("<all_urls>") && 546 !tab.hasActiveTabPermission 547 ) { 548 throw new ExtensionError("Missing activeTab permission"); 549 } 550 await tabListener.awaitTabReady(tab.nativeTab); 551 const zoom = window.browsingContext.fullZoom; 552 553 return tab.capture(context, zoom, options); 554 }, 555 556 async detectLanguage(tabId) { 557 const tab = await promiseTabWhenReady(tabId); 558 const results = await tab.queryContent("DetectLanguage", {}); 559 return results[0]; 560 }, 561 562 async executeScript(tabId, details) { 563 const tab = await promiseTabWhenReady(tabId); 564 565 return tab.executeScript(context, details); 566 }, 567 568 async insertCSS(tabId, details) { 569 const tab = await promiseTabWhenReady(tabId); 570 571 return tab.insertCSS(context, details); 572 }, 573 574 async removeCSS(tabId, details) { 575 const tab = await promiseTabWhenReady(tabId); 576 577 return tab.removeCSS(context, details); 578 }, 579 580 goForward(tabId) { 581 const { browser } = getTabOrActive(tabId); 582 browser.goForward(false); 583 }, 584 585 goBack(tabId) { 586 const { browser } = getTabOrActive(tabId); 587 browser.goBack(false); 588 }, 589 }, 590 }; 591 } 592 };