diff --git a/prefs/zen/view.yaml b/prefs/zen/view.yaml index 69066e600..e555e631b 100644 --- a/prefs/zen/view.yaml +++ b/prefs/zen/view.yaml @@ -46,6 +46,12 @@ - name: zen.view.context-menu.refresh value: "@IS_TWILIGHT@" +- name: zen.view.drag-window-from-content + value: true + +- name: zen.view.drag-window-from-content.height-percentage + value: 30 + - name: zen.view.borderless-fullscreen value: true diff --git a/src/zen/common/sys/ZenActorsManager.sys.mjs b/src/zen/common/sys/ZenActorsManager.sys.mjs index 759167674..59cac7eba 100644 --- a/src/zen/common/sys/ZenActorsManager.sys.mjs +++ b/src/zen/common/sys/ZenActorsManager.sys.mjs @@ -57,6 +57,22 @@ let JSWINDOWACTORS = { remoteTypes: ["web", "file"], enablePreference: "zen.glance.enabled", }, + ZenWindowDrag: { + parent: { + esModuleURI: "resource:///actors/ZenWindowDragParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/ZenWindowDragChild.sys.mjs", + events: { + mousedown: { + mozSystemGroup: true, + }, + }, + }, + messageManagerGroups: ["browsers"], + remoteTypes: ["web", "file"], + enablePreference: "zen.view.drag-window-from-content", + }, }; if (!Services.appinfo.inSafeMode) { diff --git a/src/zen/drag-and-drop/moz.build b/src/zen/drag-and-drop/moz.build index 9b7aa4564..19fa1df8c 100644 --- a/src/zen/drag-and-drop/moz.build +++ b/src/zen/drag-and-drop/moz.build @@ -15,6 +15,11 @@ SOURCES += [ "nsZenDragAndDrop.cpp", ] +if CONFIG["MOZ_WIDGET_TOOLKIT"] == "cocoa": + SOURCES += ["nsZenNativeWindowMove.mm"] +elif CONFIG["MOZ_WIDGET_TOOLKIT"] == "gtk": + CXXFLAGS += CONFIG["MOZ_GTK3_CFLAGS"] + XPCOM_MANIFESTS += [ "components.conf", ] diff --git a/src/zen/drag-and-drop/nsIZenDragAndDrop.idl b/src/zen/drag-and-drop/nsIZenDragAndDrop.idl index af92f943b..bc379458a 100644 --- a/src/zen/drag-and-drop/nsIZenDragAndDrop.idl +++ b/src/zen/drag-and-drop/nsIZenDragAndDrop.idl @@ -4,6 +4,8 @@ #include "nsISupports.idl" +interface mozIDOMWindowProxy; + /** * @brief Interface for Zen's drag and drop functionality. */ @@ -21,4 +23,14 @@ interface nsIZenDragAndDrop : nsISupports { * @brief Indicate that a drag operation has ended. */ void onDragEnd(); + + /** + * @brief Start a native, OS-driven interactive move of the window, + * as if the user had grabbed its titlebar. Must be called while the + * primary mouse button is held down. The OS takes over the drag, + * providing native behaviors like macOS window tiling or Windows + * snap layouts, and ends it when the button is released. + * @param window The chrome window to move. + */ + void beginNativeWindowMove(in mozIDOMWindowProxy window); }; diff --git a/src/zen/drag-and-drop/nsZenDragAndDrop.cpp b/src/zen/drag-and-drop/nsZenDragAndDrop.cpp index ec2dcc957..1a50d62c9 100644 --- a/src/zen/drag-and-drop/nsZenDragAndDrop.cpp +++ b/src/zen/drag-and-drop/nsZenDragAndDrop.cpp @@ -5,7 +5,58 @@ #include "nsZenDragAndDrop.h" #include "nsBaseDragService.h" +#include "mozilla/WidgetUtils.h" +#include "nsIWidget.h" +#include "nsPIDOMWindow.h" + +#if defined(XP_WIN) +# include +#elif defined(MOZ_WIDGET_GTK) +# include +#endif + namespace zen { + +#if defined(XP_MACOSX) +nsresult StartNativeWindowMoveCocoa(nsIWidget* aWidget); +#endif + +/** + * @brief Start a native, OS-driven move of the window backing aWidget. + * @see nsIZenDragAndDrop::beginNativeWindowMove for more details. + */ +static nsresult StartNativeWindowMove(nsIWidget* aWidget) { +#if defined(XP_MACOSX) + return StartNativeWindowMoveCocoa(aWidget); +#elif defined(XP_WIN) + HWND hwnd = static_cast(aWidget->GetNativeData(NS_NATIVE_WINDOW)); + NS_ENSURE_TRUE(hwnd, NS_ERROR_FAILURE); + // Hand the drag over to the OS as if the titlebar was grabbed; this + // enters the native move loop. + ::ReleaseCapture(); + ::PostMessageW(hwnd, WM_NCLBUTTONDOWN, HTCAPTION, 0); + return NS_OK; +#elif defined(MOZ_WIDGET_GTK) + auto* gdkWindow = + static_cast(aWidget->GetNativeData(NS_NATIVE_WINDOW)); + NS_ENSURE_TRUE(gdkWindow, NS_ERROR_FAILURE); + GdkWindow* toplevel = gdk_window_get_toplevel(gdkWindow); + GdkSeat* seat = + gdk_display_get_default_seat(gdk_window_get_display(toplevel)); + GdkDevice* pointer = seat ? gdk_seat_get_pointer(seat) : nullptr; + NS_ENSURE_TRUE(pointer, NS_ERROR_FAILURE); + // A native move works on both X11 and Wayland, unlike moving the + // window programmatically. + gint rootX = 0; + gint rootY = 0; + gdk_device_get_position(pointer, nullptr, &rootX, &rootY); + gdk_window_begin_move_drag_for_device(toplevel, pointer, 1, rootX, rootY, + GDK_CURRENT_TIME); + return NS_OK; +#else + return NS_ERROR_NOT_IMPLEMENTED; +#endif +} namespace { static constexpr auto kZenDefaultDragImageOpacity = @@ -44,4 +95,15 @@ nsZenDragAndDrop::OnDragEnd() { return NS_OK; } +NS_IMETHODIMP +nsZenDragAndDrop::BeginNativeWindowMove(mozIDOMWindowProxy* aWindow) { + NS_ENSURE_ARG_POINTER(aWindow); + nsCOMPtr outer = nsPIDOMWindowOuter::From(aWindow); + NS_ENSURE_TRUE(outer, NS_ERROR_INVALID_ARG); + RefPtr widget = + mozilla::widget::WidgetUtils::DOMWindowToWidget(outer); + NS_ENSURE_TRUE(widget, NS_ERROR_FAILURE); + return StartNativeWindowMove(widget); +} + } // namespace zen diff --git a/src/zen/drag-and-drop/nsZenNativeWindowMove.mm b/src/zen/drag-and-drop/nsZenNativeWindowMove.mm new file mode 100644 index 000000000..0ad97583f --- /dev/null +++ b/src/zen/drag-and-drop/nsZenNativeWindowMove.mm @@ -0,0 +1,44 @@ +/* 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/. */ + +#import + +#include "nsDebug.h" +#include "nsIWidget.h" +#include "nsObjCExceptions.h" + +namespace zen { + +// Cocoa side of StartNativeWindowMove() +nsresult StartNativeWindowMoveCocoa(nsIWidget* aWidget) { + NS_OBJC_BEGIN_TRY_BLOCK_RETURN; + + NSWindow* window = + static_cast(aWidget->GetNativeData(NS_NATIVE_WINDOW)); + NS_ENSURE_TRUE(window, NS_ERROR_FAILURE); + + // performWindowDragWithEvent: needs a left-button event to start the + // WindowServer drag session. The triggering mousedown happened in a + // content process, so synthesize an equivalent event at the current + // mouse location. + NSPoint location = [window convertPointFromScreen:[NSEvent mouseLocation]]; + NSEvent* event = + [NSEvent mouseEventWithType:NSEventTypeLeftMouseDragged + location:location + modifierFlags:0 + timestamp:NSProcessInfo.processInfo.systemUptime + windowNumber:window.windowNumber + context:nil + eventNumber:0 + clickCount:1 + pressure:1.0]; + NS_ENSURE_TRUE(event, NS_ERROR_FAILURE); + + [window performWindowDragWithEvent:event]; + return NS_OK; + + NS_OBJC_END_TRY_BLOCK_RETURN(NS_ERROR_FAILURE); +} + +} // namespace zen diff --git a/src/zen/moz.build b/src/zen/moz.build index 87a233ebb..1a67f5456 100644 --- a/src/zen/moz.build +++ b/src/zen/moz.build @@ -22,4 +22,5 @@ DIRS += [ "spaces", "space-routing", "sync", + "window-drag", ] diff --git a/src/zen/tests/moz.build b/src/zen/tests/moz.build index fbbf132a8..3e3969fdf 100644 --- a/src/zen/tests/moz.build +++ b/src/zen/tests/moz.build @@ -20,6 +20,7 @@ BROWSER_CHROME_MANIFESTS += [ "ub-actions/browser.toml", "urlbar/browser.toml", "welcome/browser.toml", + "window_drag/browser.toml", "window_sync/browser.toml", ] diff --git a/src/zen/tests/window_drag/browser.toml b/src/zen/tests/window_drag/browser.toml new file mode 100644 index 000000000..6e8dac13c --- /dev/null +++ b/src/zen/tests/window_drag/browser.toml @@ -0,0 +1,10 @@ +# 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/. + +[DEFAULT] +support-files = [ + "head.js", +] + +["browser_window_drag_basic.js"] diff --git a/src/zen/tests/window_drag/browser_window_drag_basic.js b/src/zen/tests/window_drag/browser_window_drag_basic.js new file mode 100644 index 000000000..7887fb68b --- /dev/null +++ b/src/zen/tests/window_drag/browser_window_drag_basic.js @@ -0,0 +1,109 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +let gDragStartCount = 0; + +add_setup(async function () { + const observer = () => gDragStartCount++; + Services.obs.addObserver(observer, WINDOW_DRAG_TOPIC); + registerCleanupFunction(() => { + Services.obs.removeObserver(observer, WINDOW_DRAG_TOPIC); + }); +}); + +add_task(async function test_drag_from_empty_top_area() { + await BrowserTestUtils.withNewTab(WINDOW_DRAG_TEST_PAGE, async browser => { + await synthesizeContentDrag(browser, 50, 50); + await TestUtils.waitForCondition( + () => gDragStartCount === 1, + "Dragging an empty area in the top region should start a window drag" + ); + is(gDragStartCount, 1, "Exactly one window drag started"); + }); +}); + +add_task(async function test_no_drag_on_interactive_target() { + await BrowserTestUtils.withNewTab(WINDOW_DRAG_TEST_PAGE, async browser => { + // Start the gesture on top of the link. Messages from the same actor + // pair are ordered, so the control drag afterwards would arrive later. + await BrowserTestUtils.synthesizeMouse( + "#link", + 5, + 5, + { type: "mousedown" }, + browser + ); + await BrowserTestUtils.synthesizeMouse( + "#link", + 35, + 15, + { type: "mousemove", buttons: 1 }, + browser + ); + await BrowserTestUtils.synthesizeMouse( + "#link", + 45, + 20, + { type: "mouseup" }, + browser + ); + + // Control drag from an eligible area. + await synthesizeContentDrag(browser, 50, 50); + await TestUtils.waitForCondition( + () => gDragStartCount >= 2, + "The control drag should start a window drag" + ); + is( + gDragStartCount, + 2, + "Dragging a link must not start a window drag (only the control did)" + ); + }); +}); + +add_task(async function test_no_drag_below_top_region() { + await BrowserTestUtils.withNewTab(WINDOW_DRAG_TEST_PAGE, async browser => { + const innerHeight = await getContentInnerHeight(browser); + await synthesizeContentDrag(browser, 50, Math.floor(innerHeight * 0.6)); + + // Control drag from inside the top region. + await synthesizeContentDrag(browser, 50, 50); + await TestUtils.waitForCondition( + () => gDragStartCount >= 3, + "The control drag should start a window drag" + ); + is( + gDragStartCount, + 3, + "Dragging below the top region must not start a window drag" + ); + }); +}); + +add_task(async function test_pref_disables_window_drag() { + await SpecialPowers.pushPrefEnv({ + set: [["zen.view.drag-window-from-content", false]], + }); + await BrowserTestUtils.withNewTab(WINDOW_DRAG_TEST_PAGE, async browser => { + await synthesizeContentDrag(browser, 50, 50); + }); + await SpecialPowers.popPrefEnv(); + + // Control drag with the pref back on, in a fresh tab so the actor is + // created again. + await BrowserTestUtils.withNewTab(WINDOW_DRAG_TEST_PAGE, async browser => { + await synthesizeContentDrag(browser, 50, 50); + await TestUtils.waitForCondition( + () => gDragStartCount >= 4, + "The control drag should start a window drag" + ); + is( + gDragStartCount, + 4, + "Dragging with the pref disabled must not start a window drag" + ); + }); +}); diff --git a/src/zen/tests/window_drag/head.js b/src/zen/tests/window_drag/head.js new file mode 100644 index 000000000..cd7e1dccb --- /dev/null +++ b/src/zen/tests/window_drag/head.js @@ -0,0 +1,57 @@ +/* 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"; + +const WINDOW_DRAG_TOPIC = "zen-window-drag-started"; + +const WINDOW_DRAG_TEST_PAGE = `https://example.com/document-builder.sjs?html=${encodeURIComponent(` + + + a link +`)}`; + +/** + * Synthesizes a primary-button drag gesture inside the content area, + * starting at (x, y) and moving well past the drag threshold. + */ +async function synthesizeContentDrag(browser, x, y) { + await BrowserTestUtils.synthesizeMouse( + null, + x, + y, + { type: "mousedown" }, + browser + ); + for (let i = 1; i <= 3; i++) { + await BrowserTestUtils.synthesizeMouse( + null, + x + i * 10, + y + i * 5, + { type: "mousemove", buttons: 1 }, + browser + ); + } + await BrowserTestUtils.synthesizeMouse( + null, + x + 40, + y + 20, + { type: "mouseup" }, + browser + ); +} + +function getContentInnerHeight(browser) { + return SpecialPowers.spawn(browser, [], () => content.innerHeight); +} diff --git a/src/zen/window-drag/actors/ZenWindowDragChild.sys.mjs b/src/zen/window-drag/actors/ZenWindowDragChild.sys.mjs new file mode 100644 index 000000000..1ef721739 --- /dev/null +++ b/src/zen/window-drag/actors/ZenWindowDragChild.sys.mjs @@ -0,0 +1,333 @@ +// 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/. + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "dragRegionHeightPercentage", + "zen.view.drag-window-from-content.height-percentage", + 30 +); + +// A small threshold to allow for minor mouse jitter during a normal click. +// Anything beyond this is considered an intentional window drag. +const DRAG_START_THRESHOLD_PX = 4; + +const kInteractiveTags = new Set([ + "a", + "area", + "audio", + "button", + "canvas", + "details", + "dialog", + "embed", + "frame", + "iframe", + "img", + "input", + "label", + "menu", + "object", + "optgroup", + "option", + "select", + "summary", + "textarea", + "video", +]); + +const kInteractiveRoles = new Set([ + "button", + "checkbox", + "combobox", + "grid", + "link", + "listbox", + "menu", + "menubar", + "menuitem", + "option", + "radio", + "scrollbar", + "searchbox", + "slider", + "spinbutton", + "switch", + "tab", + "textbox", + "toolbar", + "tree", + "treegrid", +]); + +// Cursors that signal the page considers the area interactive or draggable. +const kInteractiveCursors = new Set([ + "pointer", + "grab", + "grabbing", + "move", + "all-scroll", + "text", + "vertical-text", + "cell", + "crosshair", + "col-resize", + "row-resize", + "n-resize", + "e-resize", + "s-resize", + "w-resize", + "ne-resize", + "nw-resize", + "se-resize", + "sw-resize", + "ew-resize", + "ns-resize", + "nesw-resize", + "nwse-resize", +]); + +const kGestureListenerOptions = { mozSystemGroup: true, capture: true }; + +const kGestureEvents = ["mousemove", "mouseup", "dragstart", "unload"]; + +export class ZenWindowDragChild extends JSWindowActorChild { + #tracking = false; + #dragging = false; + #startScreenX = 0; + #startScreenY = 0; + + handleEvent(event) { + // Never let pages spoof the gesture with synthetic events. + if (!event.isTrusted) { + return; + } + switch (event.type) { + case "mousedown": + this.#onMouseDown(event); + break; + case "mousemove": + this.#onMouseMove(event); + break; + case "mouseup": + this.#onMouseUp(event); + break; + case "dragstart": + // The gesture turned out to be a real content drag. Let it win. + this.#reset(); + break; + case "unload": + this.#reset(); + break; + } + } + + get #dragRegionHeight() { + const percentage = + Math.max(0, Math.min(100, lazy.dragRegionHeightPercentage)) / 100; + return this.contentWindow.innerHeight * percentage; + } + + #screenPoint(event) { + const dpr = this.contentWindow.devicePixelRatio; + return { + screenX: Math.round(event.screenX * dpr), + screenY: Math.round(event.screenY * dpr), + }; + } + + #onMouseDown(event) { + if (this.#tracking || this.#dragging) { + // The native OS move swallows the mouseup, so a stale gesture may + // still be tracked. Recover and evaluate this mousedown normally. + this.#reset(); + } + if ( + event.button !== 0 || + event.buttons !== 1 || + event.detail !== 1 || + event.defaultPrevented || + event.ctrlKey || + event.altKey || + event.shiftKey || + event.metaKey + ) { + return; + } + const doc = this.document; + if (doc.fullscreenElement || doc.pointerLockElement) { + return; + } + // Only handle events from the top document itself; anything inside an + // (i)frame belongs to the page. + if (event.composedTarget?.ownerDocument !== doc) { + return; + } + if (event.clientY > this.#dragRegionHeight) { + return; + } + let overContent = true; + try { + overContent = this.#isEventOverDraggableContent(event); + } catch (e) { + console.error("ZenWindowDrag: eligibility check failed", e); + } + if (overContent) { + return; + } + const { screenX, screenY } = this.#screenPoint(event); + this.#startScreenX = screenX; + this.#startScreenY = screenY; + this.#tracking = true; + this.#addGestureListeners(); + } + + #onMouseMove(event) { + if (!this.#tracking) { + return; + } + // We missed the mouseup (e.g. the OS consumed it during the native + // move, or it happened outside the window). + if (!(event.buttons & 1)) { + this.#reset(); + return; + } + if (this.#dragging) { + // Keep the page from extending a text selection under the gesture. + event.preventDefault(); + return; + } + const point = this.#screenPoint(event); + const threshold = + DRAG_START_THRESHOLD_PX * this.contentWindow.devicePixelRatio; + if ( + Math.hypot( + point.screenX - this.#startScreenX, + point.screenY - this.#startScreenY + ) < threshold + ) { + return; + } + this.#dragging = true; + // The mousedown may have moved the caret or started a stray selection. + this.contentWindow.getSelection()?.removeAllRanges(); + // The OS takes over the drag from here. + this.sendAsyncMessage("ZenWindowDrag:StartDrag"); + event.preventDefault(); + } + + #onMouseUp(event) { + if (!this.#tracking || event.button !== 0) { + return; + } + if (this.#dragging) { + event.preventDefault(); + event.preventClickEvent(); + } + this.#reset(); + } + + #reset() { + this.#tracking = false; + this.#dragging = false; + this.#removeGestureListeners(); + } + + #addGestureListeners() { + const win = this.contentWindow; + for (const type of kGestureEvents) { + win.addEventListener(type, this, kGestureListenerOptions); + } + } + + #removeGestureListeners() { + const win = this.contentWindow; + if (!win) { + return; + } + for (const type of kGestureEvents) { + win.removeEventListener(type, this, kGestureListenerOptions); + } + } + + /** + * Returns true if starting a window drag here would fight with content + * that the page wants to be clickable, draggable or selectable. + * + * @param {MouseEvent} event + */ + #isEventOverDraggableContent(event) { + // Scrollbars and other native anonymous parts only dispatch to the + // system group, which is us. Never treat those as a window drag. + if (event.originalTarget?.isNativeAnonymous) { + return true; + } + let target = event.composedTarget; + if (target?.nodeType === Node.TEXT_NODE) { + target = target.parentElement; + } + if (!target || target.nodeType !== Node.ELEMENT_NODE) { + return true; + } + for (let node = target; node; node = node.flattenedTreeParentNode) { + if (node.nodeType !== Node.ELEMENT_NODE) { + continue; + } + if (this.#isInteractiveElement(node)) { + return true; + } + } + return this.#hasInteractiveCursor(target); + } + + #isInteractiveElement(element) { + if (kInteractiveTags.has(element.localName)) { + return true; + } + // Covers [draggable="true"] and elements draggable by default, + // like links and images. + if (element.draggable) { + return true; + } + if (element.isContentEditable) { + return true; + } + if ( + element.tabIndex >= 0 && + element !== this.document.body && + element !== this.document.documentElement + ) { + return true; + } + const role = element.getAttribute?.("role"); + if (role && kInteractiveRoles.has(role.toLowerCase())) { + return true; + } + // Inline event handlers are a strong hint of a custom widget. + if ( + element.onclick || + element.onmousedown || + element.onpointerdown || + element.ondragstart + ) { + return true; + } + return false; + } + + #hasInteractiveCursor(element) { + const style = element.ownerGlobal?.getComputedStyle(element); + if (!style) { + return false; + } + // The keyword is always the last component of the computed value. + // Don't split on "," as url() cursor images may contain commas. + const cursor = style.cursor.match(/[a-z-]+$/)?.[0]; + return kInteractiveCursors.has(cursor); + } +} diff --git a/src/zen/window-drag/actors/ZenWindowDragParent.sys.mjs b/src/zen/window-drag/actors/ZenWindowDragParent.sys.mjs new file mode 100644 index 000000000..3d92ab614 --- /dev/null +++ b/src/zen/window-drag/actors/ZenWindowDragParent.sys.mjs @@ -0,0 +1,33 @@ +// 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/. + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "zenDragAndDropService", + "@mozilla.org/zen/drag-and-drop;1", + Ci.nsIZenDragAndDrop +); + +export class ZenWindowDragParent extends JSWindowActorParent { + receiveMessage(message) { + if (message.name !== "ZenWindowDrag:StartDrag") { + return; + } + const win = this.browsingContext.topChromeWindow; + if (!win || win.closed || win.windowState === win.STATE_FULLSCREEN) { + return; + } + if (Cu.isInAutomation) { + // Tests can't exercise a real OS drag session; let them observe the + // decision instead. + Services.obs.notifyObservers(win, "zen-window-drag-started"); + return; + } + lazy.zenDragAndDropService.beginNativeWindowMove(win); + } +} diff --git a/src/zen/window-drag/moz.build b/src/zen/window-drag/moz.build new file mode 100644 index 000000000..2b3da954b --- /dev/null +++ b/src/zen/window-drag/moz.build @@ -0,0 +1,8 @@ +# 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/. + +FINAL_TARGET_FILES.actors += [ + "actors/ZenWindowDragChild.sys.mjs", + "actors/ZenWindowDragParent.sys.mjs", +]