WindowManager.sys.mjs (18396B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; 6 7 const lazy = {}; 8 9 ChromeUtils.defineESModuleGetters(lazy, { 10 URILoadingHelper: "resource:///modules/URILoadingHelper.sys.mjs", 11 12 AnimationFramePromise: "chrome://remote/content/shared/Sync.sys.mjs", 13 AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs", 14 BiMap: "chrome://remote/content/shared/BiMap.sys.mjs", 15 BrowsingContextListener: 16 "chrome://remote/content/shared/listeners/BrowsingContextListener.sys.mjs", 17 ChromeWindowListener: 18 "chrome://remote/content/shared/listeners/ChromeWindowListener.sys.mjs", 19 DebounceCallback: "chrome://remote/content/marionette/sync.sys.mjs", 20 error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", 21 EventPromise: "chrome://remote/content/shared/Sync.sys.mjs", 22 Log: "chrome://remote/content/shared/Log.sys.mjs", 23 TimedPromise: "chrome://remote/content/shared/Sync.sys.mjs", 24 UserContextManager: 25 "chrome://remote/content/shared/UserContextManager.sys.mjs", 26 waitForObserverTopic: "chrome://remote/content/marionette/sync.sys.mjs", 27 }); 28 29 ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); 30 31 // Timeout used to abort fullscreen, maximize, and minimize 32 // commands if no window manager is present. 33 const TIMEOUT_NO_WINDOW_MANAGER = 5000; 34 35 /** 36 * Provides helpers to interact with Window objects. 37 * 38 * @class WindowManager 39 */ 40 class WindowManager { 41 #chromeWindowListener; 42 #clientWindowIds; 43 #contextListener; 44 #contextToWindowMap; 45 #tracking; 46 47 constructor() { 48 /** 49 * Keep track of the client window for any registered contexts. When the 50 * contextDestroyed event is fired, the context is already destroyed so 51 * we cannot query for the client window at that time. 52 */ 53 this.#clientWindowIds = new lazy.BiMap(); 54 55 // For content browsing contexts, the embedder element may already be 56 // gone by the time when it is getting discarded. To ensure we can still 57 // retrieve the corresponding chrome window, we maintain a mapping from 58 // each top-level content browsing context to its chrome window. 59 this.#contextToWindowMap = new WeakMap(); 60 this.#contextListener = new lazy.BrowsingContextListener(); 61 62 this.#tracking = false; 63 64 this.#chromeWindowListener = new lazy.ChromeWindowListener(); 65 } 66 67 destroy() { 68 this.stopTracking(); 69 } 70 71 startTracking() { 72 if (this.#tracking) { 73 return; 74 } 75 76 this.#chromeWindowListener.on("closed", this.#onChromeWindowClosed); 77 this.#chromeWindowListener.on("opened", this.#onChromeWindowOpened); 78 this.#chromeWindowListener.startListening(); 79 80 this.#contextListener.on("attached", this.#onContextAttached); 81 this.#contextListener.startListening(); 82 83 // Pre-fill the internal window id mapping. 84 this.windows.forEach(window => this.getIdForWindow(window)); 85 86 this.#tracking = true; 87 } 88 89 stopTracking() { 90 if (!this.#tracking) { 91 return; 92 } 93 94 this.#chromeWindowListener.stopListening(); 95 this.#chromeWindowListener.off("closed", this.#onChromeWindowClosed); 96 this.#chromeWindowListener.off("opened", this.#onChromeWindowOpened); 97 98 this.#contextListener.stopListening(); 99 this.#contextListener.off("attached", this.#onContextAttached); 100 101 this.#clientWindowIds = new lazy.BiMap(); 102 this.#contextToWindowMap = new WeakMap(); 103 104 this.#tracking = false; 105 } 106 107 /** 108 * Retrieve all the open windows. 109 * 110 * @returns {Array<Window>} 111 * All the open windows. Will return an empty list if no window is open. 112 */ 113 get windows() { 114 const windows = []; 115 116 for (const win of Services.wm.getEnumerator(null)) { 117 if (win.closed) { 118 continue; 119 } 120 windows.push(win); 121 } 122 123 return windows; 124 } 125 126 /** 127 * Retrieves an id for the given chrome window. The id is a dynamically 128 * generated uuid by the WindowManager and associated with the 129 * top-level browsing context of that chrome window. 130 * 131 * @param {ChromeWindow} win 132 * The chrome window for which we want to retrieve the id. 133 * 134 * @returns {string|null} 135 * The unique id for this chrome window or `null` if not a valid window. 136 */ 137 getIdForWindow(win) { 138 if (win) { 139 return this.#clientWindowIds.getOrInsert(win); 140 } 141 142 return null; 143 } 144 145 /** 146 * Retrieve the Chrome Window corresponding to the provided window id. 147 * 148 * @param {string} id 149 * A unique id for the chrome window. 150 * 151 * @returns {ChromeWindow|undefined} 152 * The chrome window found for this id, `null` if none 153 * was found. 154 */ 155 getWindowById(id) { 156 return this.#clientWindowIds.getObject(id); 157 } 158 159 /** 160 * Close the specified window. 161 * 162 * @param {window} win 163 * The window to close. 164 * @returns {Promise} 165 * A promise which is resolved when the current window has been closed. 166 */ 167 async closeWindow(win) { 168 const destroyed = lazy.waitForObserverTopic("xul-window-destroyed", { 169 checkFn: () => win && win.closed, 170 }); 171 172 win.close(); 173 174 return destroyed; 175 } 176 177 /** 178 * Adjusts the window geometry. 179 * 180 *@param {window} win 181 * The browser window to adjust. 182 * @param {number} x 183 * The x-coordinate of the window. 184 * @param {number} y 185 * The y-coordinate of the window. 186 * @param {number} width 187 * The width of the window. 188 * @param {number} height 189 * The height of the window. 190 * 191 * @returns {Promise} 192 * A promise that resolves when the window geometry has been adjusted. 193 * 194 * @throws {TimeoutError} 195 * Raised if the operating system fails to honor the requested move or resize. 196 */ 197 async adjustWindowGeometry(win, x, y, width, height) { 198 // we find a matching position on e.g. resize, then resolve, then a geometry 199 // change comes in, then the window pos listener runs, we might try to 200 // incorrectly reset the position without this check. 201 let foundMatch = false; 202 203 function geometryMatches() { 204 lazy.logger.trace( 205 `Checking window geometry ${win.outerWidth}x${win.outerHeight} @ (${win.screenX}, ${win.screenY})` 206 ); 207 208 if (foundMatch) { 209 lazy.logger.trace(`Already found a previous match for this request`); 210 return true; 211 } 212 213 let sizeMatches = true; 214 let posMatches = true; 215 216 if ( 217 width !== null && 218 height !== null && 219 (win.outerWidth !== width || win.outerHeight !== height) 220 ) { 221 sizeMatches = false; 222 } 223 224 // Wayland doesn't support getting the window position. 225 if ( 226 x !== null && 227 y !== null && 228 (win.screenX !== x || win.screenY !== y) 229 ) { 230 if (lazy.AppInfo.isWayland) { 231 lazy.logger.info( 232 `Wayland doesn't support setting the window position` 233 ); 234 } else { 235 posMatches = false; 236 } 237 } 238 239 if (sizeMatches && posMatches) { 240 lazy.logger.trace(`Requested window geometry matches`); 241 foundMatch = true; 242 return true; 243 } 244 245 return false; 246 } 247 248 if (!geometryMatches()) { 249 // There might be more than one resize or MozUpdateWindowPos event due 250 // to previous geometry changes, such as from restoreWindow(), so 251 // wait longer if window geometry does not match. 252 const options = { 253 checkFn: geometryMatches, 254 timeout: 500, 255 }; 256 const promises = []; 257 258 if (width !== null && height !== null) { 259 promises.push(new lazy.EventPromise(win, "resize", options)); 260 win.resizeTo(width, height); 261 } 262 263 // Wayland doesn't support setting the window position. 264 if (!lazy.AppInfo.isWayland && x !== null && y !== null) { 265 promises.push( 266 new lazy.EventPromise(win.windowRoot, "MozUpdateWindowPos", options) 267 ); 268 win.moveTo(x, y); 269 } 270 271 try { 272 await Promise.race(promises); 273 } catch (e) { 274 if (e instanceof lazy.error.TimeoutError) { 275 // The operating system might not honor the move or resize, in which 276 // case assume that geometry will have been adjusted "as close as 277 // possible" to that requested. There may be no event received if the 278 // geometry is already as close as possible. 279 } else { 280 throw e; 281 } 282 } 283 } 284 } 285 286 /** 287 * Focus the specified window. 288 * 289 * @param {window} win 290 * The window to focus. 291 * @returns {Promise} 292 * A promise which is resolved when the window has been focused. 293 */ 294 async focusWindow(win) { 295 if (Services.focus.activeWindow != win) { 296 let activated = new lazy.EventPromise(win, "activate"); 297 let focused = new lazy.EventPromise(win, "focus", { capture: true }); 298 299 win.focus(); 300 301 await Promise.all([activated, focused]); 302 } 303 } 304 305 /** 306 * Returns the chrome window for a specific browsing context. 307 * 308 * @param {BrowsingContext} context 309 * The browsing context for which we want to retrieve the window. 310 * 311 * @returns {ChromeWindow|null} 312 * The chrome window associated with the browsing context. 313 * Otherwise `null` is returned. 314 */ 315 getChromeWindowForBrowsingContext(context) { 316 if (!context.isContent) { 317 // Chrome browsing contexts always have a chrome window set. 318 return context.topChromeWindow; 319 } 320 321 if (this.#contextToWindowMap.has(context.top)) { 322 return this.#contextToWindowMap.get(context.top); 323 } 324 325 return this.#setChromeWindowForBrowsingContext(context); 326 } 327 328 /** 329 * Open a new browser window. 330 * 331 * @param {object=} options 332 * @param {boolean=} options.focus 333 * If true, the opened window will receive the focus. Defaults to false. 334 * @param {boolean=} options.isPrivate 335 * If true, the opened window will be a private window. Defaults to false. 336 * @param {ChromeWindow=} options.openerWindow 337 * Use this window as the opener of the new window. Defaults to the 338 * topmost window. 339 * @param {string=} options.userContextId 340 * The id of the user context which should own the initial tab of the new 341 * window. 342 * 343 * @returns {Promise<ChromeWindow>} 344 * A promise resolving to the newly created chrome window. 345 * 346 * @throws {UnsupportedOperationError} 347 * When opening a new browser window is not supported. 348 */ 349 async openBrowserWindow(options = {}) { 350 let { 351 focus = false, 352 isPrivate = false, 353 openerWindow = null, 354 userContextId = null, 355 } = options; 356 357 switch (lazy.AppInfo.name) { 358 case "Firefox": { 359 if (openerWindow === null) { 360 // If no opener was provided, fallback to the topmost window. 361 openerWindow = Services.wm.getMostRecentBrowserWindow(); 362 } 363 364 if (!openerWindow) { 365 throw new lazy.error.UnsupportedOperationError( 366 `openWindow() could not find a valid opener window` 367 ); 368 } 369 370 // Open new browser window, and wait until it is fully loaded. 371 // Also wait for the window to be focused and activated to prevent a 372 // race condition when promptly focusing to the original window again. 373 const browser = await new Promise(resolveOnContentBrowserCreated => 374 lazy.URILoadingHelper.openTrustedLinkIn( 375 openerWindow, 376 "about:blank", 377 "window", 378 { 379 private: isPrivate, 380 resolveOnContentBrowserCreated, 381 userContextId: 382 lazy.UserContextManager.getInternalIdById(userContextId), 383 } 384 ) 385 ); 386 387 // TODO: Both for WebDriver BiDi and classic, opening a new window 388 // should not run the focus steps. When focus is false we should avoid 389 // focusing the new window completely. See Bug 1766329 390 391 if (focus) { 392 // Focus the currently selected tab. 393 browser.focus(); 394 } else { 395 // If the new window shouldn't get focused, set the 396 // focus back to the opening window. 397 await this.focusWindow(openerWindow); 398 } 399 400 const chromeWindow = browser.ownerGlobal; 401 await this.waitForChromeWindowLoaded(chromeWindow); 402 403 return chromeWindow; 404 } 405 406 default: 407 throw new lazy.error.UnsupportedOperationError( 408 `openWindow() not supported in ${lazy.AppInfo.name}` 409 ); 410 } 411 } 412 413 supportsWindows() { 414 return !lazy.AppInfo.isAndroid; 415 } 416 417 /** 418 * Minimize the specified window. 419 * 420 * @param {window} win 421 * The window to minimize. 422 * 423 * @returns {Promise} 424 * A promise resolved when the window is minimized, or times out if no window manager is present. 425 */ 426 async minimizeWindow(win) { 427 if (WindowState.from(win.windowState) != WindowState.Minimized) { 428 await waitForWindowState(win, () => win.minimize()); 429 } 430 } 431 432 /** 433 * Maximize the specified window. 434 * 435 * @param {window} win 436 * The window to maximize. 437 * 438 * @returns {Promise} 439 * A promise resolved when the window is maximized, or times out if no window manager is present. 440 */ 441 async maximizeWindow(win) { 442 if (WindowState.from(win.windowState) != WindowState.Maximized) { 443 await waitForWindowState(win, () => win.maximize()); 444 } 445 } 446 447 /** 448 * Restores the specified window to its normal state. 449 * 450 * @param {window} win 451 * The window to restore. 452 * 453 * @returns {Promise} 454 * A promise resolved when the window is restored, or times out if no window manager is present. 455 */ 456 async restoreWindow(win) { 457 if (WindowState.from(win.windowState) !== WindowState.Normal) { 458 await waitForWindowState(win, () => win.restore()); 459 } 460 } 461 462 /** 463 * Sets the fullscreen state of the specified window. 464 * 465 * @param {window} win 466 * The target window. 467 * @param {boolean} enable 468 * Whether to enter fullscreen (true) or exit fullscreen (false). 469 * 470 * @returns {Promise} 471 * A promise resolved when the window enters or exits fullscreen mode. 472 */ 473 async setFullscreen(win, enable) { 474 const isFullscreen = 475 WindowState.from(win.windowState) === WindowState.Fullscreen; 476 if (enable !== isFullscreen) { 477 await waitForWindowState(win, () => (win.fullScreen = enable)); 478 } 479 } 480 481 /** 482 * Wait until the browser window is initialized and loaded. 483 * 484 * @param {ChromeWindow} window 485 * The chrome window to check for completed loading. 486 * 487 * @returns {Promise} 488 * A promise that resolves when the chrome window finished loading. 489 */ 490 async waitForChromeWindowLoaded(window) { 491 const loaded = 492 window.document.readyState === "complete" && 493 !window.document.isUncommittedInitialDocument; 494 495 if (!loaded) { 496 lazy.logger.trace( 497 `Chrome window not loaded yet. Waiting for "load" event` 498 ); 499 await new lazy.EventPromise(window, "load"); 500 } 501 502 // Only Firefox stores the delayed startup finished status, allowing 503 // it to be checked at any time. On Android, this is unnecessary 504 // because there is only a single window, and we already wait for 505 // that window during startup. 506 if ( 507 lazy.AppInfo.isFirefox && 508 window.document.documentURI === AppConstants.BROWSER_CHROME_URL && 509 !(window.gBrowserInit && window.gBrowserInit.delayedStartupFinished) 510 ) { 511 lazy.logger.trace( 512 `Browser window not initialized yet. Waiting for startup finished` 513 ); 514 515 // If it's a browser window wait for it to be fully initialized. 516 await lazy.waitForObserverTopic("browser-delayed-startup-finished", { 517 checkFn: subject => subject === window, 518 }); 519 } 520 } 521 522 #setChromeWindowForBrowsingContext(context) { 523 const chromeWindow = context.top.embedderElement?.ownerGlobal; 524 if (chromeWindow) { 525 return this.#contextToWindowMap.getOrInsert(context.top, chromeWindow); 526 } 527 528 return null; 529 } 530 531 /* Event handlers */ 532 533 #onContextAttached = (_, data = {}) => { 534 const { browsingContext } = data; 535 536 if (!browsingContext.isContent) { 537 return; 538 } 539 540 this.#setChromeWindowForBrowsingContext(browsingContext); 541 }; 542 543 #onChromeWindowClosed = (_, data = {}) => { 544 const { window } = data; 545 546 this.#clientWindowIds.deleteByObject(window); 547 }; 548 549 #onChromeWindowOpened = (_, data = {}) => { 550 const { window } = data; 551 552 this.getIdForWindow(window); 553 }; 554 } 555 556 // Expose a shared singleton. 557 export const windowManager = new WindowManager(); 558 559 /** 560 * Representation of the {@link ChromeWindow} window state. 561 * 562 * @enum {string} 563 */ 564 export const WindowState = { 565 Maximized: "maximized", 566 Minimized: "minimized", 567 Normal: "normal", 568 Fullscreen: "fullscreen", 569 570 /** 571 * Converts {@link Window.windowState} to WindowState. 572 * 573 * @param {number} windowState 574 * Attribute from {@link Window.windowState}. 575 * 576 * @returns {WindowState} 577 * JSON representation. 578 * 579 * @throws {TypeError} 580 * If <var>windowState</var> was unknown. 581 */ 582 from(windowState) { 583 switch (windowState) { 584 case 1: 585 return WindowState.Maximized; 586 587 case 2: 588 return WindowState.Minimized; 589 590 case 3: 591 return WindowState.Normal; 592 593 case 4: 594 return WindowState.Fullscreen; 595 596 default: 597 throw new TypeError(`Unknown window state: ${windowState}`); 598 } 599 }, 600 }; 601 602 /** 603 * Waits for the window to reach a specific state after invoking a callback. 604 * 605 * @param {window} win 606 * The target window. 607 * @param {Function} callback 608 * The function to invoke to change the window state. 609 * 610 * @returns {Promise} 611 * A promise resolved when the window reaches the target state, or times out if no window manager is present. 612 */ 613 async function waitForWindowState(win, callback) { 614 let cb; 615 // Use a timed promise to abort if no window manager is present 616 await new lazy.TimedPromise( 617 resolve => { 618 cb = new lazy.DebounceCallback(resolve); 619 win.addEventListener("sizemodechange", cb); 620 callback(); 621 }, 622 { throws: null, timeout: TIMEOUT_NO_WINDOW_MANAGER } 623 ); 624 win.removeEventListener("sizemodechange", cb); 625 await new lazy.AnimationFramePromise(win); 626 }