commit 116b1706e6db02978586c01be0285e85f71f83d2
parent a9d97f006fbcde69fc94b7e1aab2b0998504e675
Author: Henrik Skupin <mail@hskupin.info>
Date: Tue, 23 Dec 2025 11:36:51 +0000
Bug 2002949 - [remote] Add ChromeWindowListener to listen for "opened" and "closed" events of Chrome windows. r=Sasha
Differential Revision: https://phabricator.services.mozilla.com/D277073
Diffstat:
4 files changed, 297 insertions(+), 0 deletions(-)
diff --git a/remote/jar.mn b/remote/jar.mn
@@ -48,6 +48,7 @@ remote.jar:
content/shared/listeners/BeforeStopRequestListener.sys.mjs (shared/listeners/BeforeStopRequestListener.sys.mjs)
content/shared/listeners/BrowsingContextListener.sys.mjs (shared/listeners/BrowsingContextListener.sys.mjs)
content/shared/listeners/CachedResourceListener.sys.mjs (shared/listeners/CachedResourceListener.sys.mjs)
+ content/shared/listeners/ChromeWindowListener.sys.mjs (shared/listeners/ChromeWindowListener.sys.mjs)
content/shared/listeners/ConsoleAPIListener.sys.mjs (shared/listeners/ConsoleAPIListener.sys.mjs)
content/shared/listeners/ConsoleListener.sys.mjs (shared/listeners/ConsoleListener.sys.mjs)
content/shared/listeners/ContextualIdentityListener.sys.mjs (shared/listeners/ContextualIdentityListener.sys.mjs)
diff --git a/remote/shared/listeners/ChromeWindowListener.sys.mjs b/remote/shared/listeners/ChromeWindowListener.sys.mjs
@@ -0,0 +1,119 @@
+/* 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/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs",
+
+ BrowsingContextListener:
+ "chrome://remote/content/shared/listeners/BrowsingContextListener.sys.mjs",
+});
+
+const OBSERVER_TOPIC_CLOSED = "domwindowclosed";
+const OBSERVER_TOPIC_OPENED = "domwindowopened";
+
+/**
+ * The ChromeWindowListener can be used to listen for notifications
+ * coming from Chrome windows that get opened or closed.
+ *
+ * Example:
+ * ```
+ * const listener = new ChromeWindowListener();
+ * listener.on("opened", onOpened);
+ * listener.startListening();
+ *
+ * const onOpened = (eventName, data = {}) => {
+ * const { window, why } = data;
+ * ...
+ * };
+ * ```
+ *
+ * @fires message
+ * The ChromeWindowListener emits "opened" and "closed" events,
+ * with the following object as payload:
+ * - {ChromeWindow} window
+ * Chrome window the notification relates to.
+ */
+export class ChromeWindowListener {
+ #closingWindows;
+ #contextListener;
+ #listening;
+
+ /**
+ * Create a new ChromeWindowListener instance.
+ */
+ constructor() {
+ lazy.EventEmitter.decorate(this);
+
+ // When the `domwindowclosed` notification is sent, the
+ // containing browsing contexts still exist. We must delay
+ // emitting the `closed` event until the corresponding
+ // top-level chrome browsing context has closed as well.
+ this.#closingWindows = new WeakSet();
+
+ this.#contextListener = new lazy.BrowsingContextListener();
+ this.#listening = false;
+ }
+
+ destroy() {
+ this.stopListening();
+ }
+
+ observe(subject, topic) {
+ switch (topic) {
+ case OBSERVER_TOPIC_OPENED: {
+ this.emit("opened", { window: subject });
+ break;
+ }
+ case OBSERVER_TOPIC_CLOSED: {
+ this.#closingWindows.add(subject);
+ break;
+ }
+ }
+ }
+
+ startListening() {
+ if (this.#listening) {
+ return;
+ }
+
+ Services.ww.registerNotification(this);
+
+ this.#contextListener.on("discarded", this.#onContextDiscarded);
+ this.#contextListener.startListening();
+
+ this.#listening = true;
+ }
+
+ stopListening() {
+ if (!this.#listening) {
+ return;
+ }
+
+ this.#contextListener.off("discarded", this.#onContextDiscarded);
+ this.#contextListener.stopListening();
+
+ Services.ww.unregisterNotification(this);
+
+ this.#closingWindows = new WeakSet();
+ this.#listening = false;
+ }
+
+ #onContextDiscarded = (_, data = {}) => {
+ const { browsingContext } = data;
+
+ if (browsingContext.isContent || browsingContext.parent) {
+ // We only care about top-level chrome browsing contexts
+ return;
+ }
+
+ const window = browsingContext.topChromeWindow;
+ if (this.#closingWindows.has(window)) {
+ this.#closingWindows.delete(window);
+
+ this.emit("closed", { window });
+ }
+ };
+}
diff --git a/remote/shared/listeners/test/browser/browser.toml b/remote/shared/listeners/test/browser/browser.toml
@@ -12,6 +12,8 @@ prefs = ["remote.messagehandler.modulecache.useBrowserTestRoot=true"]
["browser_CachedResourceListener.js"]
+["browser_ChromeWindowListener.js"]
+
["browser_ConsoleAPIListener.js"]
["browser_ConsoleAPIListener_cached_messages.js"]
diff --git a/remote/shared/listeners/test/browser/browser_ChromeWindowListener.js b/remote/shared/listeners/test/browser/browser_ChromeWindowListener.js
@@ -0,0 +1,175 @@
+/* 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/. */
+
+const { ChromeWindowListener } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/listeners/ChromeWindowListener.sys.mjs"
+);
+
+add_task(async function test_openedOnNewWindow() {
+ const listener = new ChromeWindowListener();
+ const opened = listener.once("opened");
+
+ listener.startListening();
+
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+
+ const { window } = await opened;
+ is(window, win, "Received expected window");
+
+ listener.stopListening();
+ await BrowserTestUtils.closeWindow(win);
+});
+
+add_task(async function test_closedOnWindowClose() {
+ const listener = new ChromeWindowListener();
+ const closed = listener.once("closed");
+
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+
+ listener.startListening();
+
+ await BrowserTestUtils.closeWindow(win);
+
+ const { window } = await closed;
+ is(window, win, "Received expected window");
+
+ listener.stopListening();
+});
+
+add_task(async function test_multipleWindows() {
+ const listener = new ChromeWindowListener();
+ const openedEvents = [];
+
+ listener.on("opened", (_eventName, data) => {
+ openedEvents.push(data);
+ });
+
+ listener.startListening();
+
+ const win1 = await BrowserTestUtils.openNewBrowserWindow();
+ const win2 = await BrowserTestUtils.openNewBrowserWindow();
+
+ await TestUtils.waitForCondition(() => openedEvents.length === 2);
+
+ is(openedEvents.length, 2, "Received two opened events");
+ is(openedEvents[0].window, win1, "First event has correct window");
+ is(openedEvents[1].window, win2, "Second event has correct window");
+
+ listener.stopListening();
+ await BrowserTestUtils.closeWindow(win1);
+ await BrowserTestUtils.closeWindow(win2);
+});
+
+add_task(async function test_noEventsBeforeStartListening() {
+ const listener = new ChromeWindowListener();
+ let eventReceived = false;
+
+ listener.on("opened", () => {
+ eventReceived = true;
+ });
+
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+
+ await TestUtils.waitForTick();
+
+ ok(!eventReceived, "No event received when not listening");
+
+ listener.startListening();
+
+ await TestUtils.waitForTick();
+
+ ok(
+ !eventReceived,
+ "No event received for windows opened before startListening"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+});
+
+add_task(async function test_noEventsAfterStopListening() {
+ const listener = new ChromeWindowListener();
+ let eventCount = 0;
+
+ listener.on("opened", () => {
+ eventCount++;
+ });
+
+ listener.startListening();
+
+ const win1 = await BrowserTestUtils.openNewBrowserWindow();
+
+ await TestUtils.waitForCondition(() => eventCount === 1);
+
+ listener.stopListening();
+
+ const win2 = await BrowserTestUtils.openNewBrowserWindow();
+
+ await TestUtils.waitForTick();
+
+ is(eventCount, 1, "Only one event received before stopListening");
+
+ await BrowserTestUtils.closeWindow(win1);
+ await BrowserTestUtils.closeWindow(win2);
+});
+
+add_task(async function test_startListeningIdempotent() {
+ const listener = new ChromeWindowListener();
+ let eventCount = 0;
+
+ listener.on("opened", () => {
+ eventCount++;
+ });
+
+ listener.startListening();
+ listener.startListening();
+
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+
+ await TestUtils.waitForCondition(() => eventCount === 1);
+
+ is(
+ eventCount,
+ 1,
+ "Only one event received despite multiple startListening calls"
+ );
+
+ listener.stopListening();
+ await BrowserTestUtils.closeWindow(win);
+});
+
+add_task(async function test_stopListeningIdempotent() {
+ const listener = new ChromeWindowListener();
+
+ listener.startListening();
+ listener.stopListening();
+ listener.stopListening();
+
+ ok(true, "Multiple stopListening calls do not throw");
+});
+
+add_task(async function test_destroyStopsListening() {
+ const listener = new ChromeWindowListener();
+ let eventCount = 0;
+
+ listener.on("opened", () => {
+ eventCount++;
+ });
+
+ listener.startListening();
+
+ const win1 = await BrowserTestUtils.openNewBrowserWindow();
+
+ await TestUtils.waitForCondition(() => eventCount === 1);
+
+ listener.destroy();
+
+ const win2 = await BrowserTestUtils.openNewBrowserWindow();
+
+ await TestUtils.waitForTick();
+
+ is(eventCount, 1, "No events received after destroy");
+
+ await BrowserTestUtils.closeWindow(win1);
+ await BrowserTestUtils.closeWindow(win2);
+});