diff --git a/apps/desktop/src/lib/trpc/routers/browser/browser.ts b/apps/desktop/src/lib/trpc/routers/browser/browser.ts index f7d03d2a59f..f9a34e92329 100644 --- a/apps/desktop/src/lib/trpc/routers/browser/browser.ts +++ b/apps/desktop/src/lib/trpc/routers/browser/browser.ts @@ -12,6 +12,7 @@ import { z } from "zod"; import { publicProcedure, router } from "../.."; import { createBrowserExtensionsRouter } from "./extensions"; +/** Creates the tRPC router for browser pane management. */ export const createBrowserRouter = () => { return router({ extensions: createBrowserExtensionsRouter(), @@ -49,6 +50,30 @@ export const createBrowserRouter = () => { return { success: true }; }), + /** Sets whether audio is muted for the given browser pane. */ + setAudioMuted: publicProcedure + .input( + z.object({ + paneId: z.string(), + muted: z.boolean(), + }), + ) + .mutation(({ input }) => { + browserManager.setAudioMuted(input.paneId, input.muted); + return { success: true }; + }), + + /** Queries whether audio is muted for the given browser pane. */ + isAudioMuted: publicProcedure + .input( + z.object({ + paneId: z.string(), + }), + ) + .query(({ input }) => { + return browserManager.isAudioMuted(input.paneId); + }), + goBack: publicProcedure .input(z.object({ paneId: z.string() })) .mutation(({ input }) => { diff --git a/apps/desktop/src/main/lib/browser/browser-manager.test.ts b/apps/desktop/src/main/lib/browser/browser-manager.test.ts index 775336d74c9..292b4dfa4e5 100644 --- a/apps/desktop/src/main/lib/browser/browser-manager.test.ts +++ b/apps/desktop/src/main/lib/browser/browser-manager.test.ts @@ -8,6 +8,13 @@ import { mock, test, } from "bun:test"; + +mock.module("@lingui/core/macro", () => ({ + msg: (descriptor: unknown) => descriptor, + t: (descriptor: unknown) => descriptor, +})); + +import { i18n } from "@superset/i18n"; import { webContents } from "electron"; import { PROTOCOL_SCHEME } from "shared/constants"; import { browserManager } from "./browser-manager"; @@ -19,6 +26,10 @@ const preloadFromId = fromIdMock.getMockImplementation(); beforeAll(() => { fromIdMock.mockImplementation((id: number) => wcById.get(id) ?? null); + if (!i18n.locale) { + i18n.load("en", {}); + i18n.activate("en"); + } }); afterAll(() => { @@ -45,6 +56,9 @@ type WcListener = (...args: unknown[]) => void; interface FakeWebContents { throttlingCalls: boolean[]; + audioMuted: boolean; + setAudioMuted: (muted: boolean) => void; + isAudioMuted: () => boolean; isDestroyed: () => boolean; isCrashed: () => boolean; setBackgroundThrottling: (allowed: boolean) => void; @@ -65,13 +79,18 @@ interface FakeWebContents { sendCommand: ReturnType; }; } - let nextId = 1; +/** Creates a fake WebContents test stub with event emitter and mute tracking. */ function makeWc(): { wc: FakeWebContents; id: number } { const throttlingCalls: boolean[] = []; const wc: FakeWebContents = { throttlingCalls, + audioMuted: false, + setAudioMuted: (muted: boolean) => { + wc.audioMuted = muted; + }, + isAudioMuted: () => wc.audioMuted, isDestroyed: () => false, isCrashed: () => false, setBackgroundThrottling: (allowed: boolean) => { @@ -878,3 +897,105 @@ describe("right-click menu", () => { expect(popup).toHaveBeenCalled(); }); }); + +describe("audio muting", () => { + test("sets audio muted on guest webContents and persists across registrations", () => { + const paneId = "test-audio-pane"; + registered.push(paneId); + try { + const { wc: wc1, id: id1 } = makeWc(); + + // 1. Initial register: default unmuted + browserManager.register(paneId, id1); + expect(browserManager.isAudioMuted(paneId)).toBe(false); + expect(wc1.audioMuted).toBe(false); + + // 2. Explicit mute: updates both manager and webContents + browserManager.setAudioMuted(paneId, true); + expect(browserManager.isAudioMuted(paneId)).toBe(true); + expect(wc1.audioMuted).toBe(true); + + // 3. Re-register with a new webContents ID (reloads / navigation / crash recovery) + const { wc: wc2, id: id2 } = makeWc(); + browserManager.register(paneId, id2); + + // Mute state must persist and be applied to the new webContents + expect(browserManager.isAudioMuted(paneId)).toBe(true); + expect(wc2.audioMuted).toBe(true); + + // 4. Explicit unmute + browserManager.setAudioMuted(paneId, false); + expect(browserManager.isAudioMuted(paneId)).toBe(false); + expect(wc2.audioMuted).toBe(false); + } finally { + browserManager.unregister(paneId); + } + }); + + test("applies mute state to child popups both at creation and dynamically", () => { + const paneId = "test-popup-audio-pane"; + registered.push(paneId); + try { + const { wc: guestWc, id: guestId } = makeWc(); + browserManager.register(paneId, guestId); + + const { wc: popupWc } = makeWc(); + const windowListeners = new Map void)[]>(); + const fakeWindow = { + webContents: popupWc, + on: (event: string, fn: () => void) => { + windowListeners.set(event, [ + ...(windowListeners.get(event) ?? []), + fn, + ]); + }, + once: (event: string, fn: () => void) => { + windowListeners.set(event, [ + ...(windowListeners.get(event) ?? []), + fn, + ]); + }, + close: () => { + for (const fn of windowListeners.get("closed") ?? []) { + fn(); + } + }, + }; + + // Simulate child popup creation while unmuted + for (const listener of guestWc.listeners.get("did-create-window") ?? []) { + listener(fakeWindow); + } + expect(popupWc.audioMuted).toBe(false); + + // Muting pane updates the existing popup + browserManager.setAudioMuted(paneId, true); + expect(popupWc.audioMuted).toBe(true); + + // Unmuting pane updates the existing popup + browserManager.setAudioMuted(paneId, false); + expect(popupWc.audioMuted).toBe(false); + + // Muting pane again, then creating a second popup opens it muted + browserManager.setAudioMuted(paneId, true); + const { wc: popupWc2 } = makeWc(); + const fakeWindow2 = { + webContents: popupWc2, + on: () => {}, + once: () => {}, + }; + for (const listener of guestWc.listeners.get("did-create-window") ?? []) { + listener(fakeWindow2); + } + expect(popupWc2.audioMuted).toBe(true); + + // Closing window unhooks it + fakeWindow.close(); + browserManager.setAudioMuted(paneId, false); + // popupWc was closed, so it stays as it was + expect(popupWc2.audioMuted).toBe(false); + } finally { + browserManager.unregister(paneId); + } + }); +}); diff --git a/apps/desktop/src/main/lib/browser/browser-manager.ts b/apps/desktop/src/main/lib/browser/browser-manager.ts index 4ca934043cc..98eb5e9d091 100644 --- a/apps/desktop/src/main/lib/browser/browser-manager.ts +++ b/apps/desktop/src/main/lib/browser/browser-manager.ts @@ -260,6 +260,8 @@ class BrowserManager extends EventEmitter { private popupListeners = new Map void>(); private focusListeners = new Map void>(); private cdpDetachers = new Map void>(); + private mutedPanes = new Map(); + private popupWebContents = new Map>(); // Ref-count of in-flight agent work per pane (a live CDP session, a // screenshot capture). While present the guest renderer stays // un-throttled — see acquireAgentWake. The entry object's identity ties @@ -270,6 +272,35 @@ class BrowserManager extends EventEmitter { private forwardableChords = new Set(); private designMode = new DesignModeController(); + /** Sets audio muted state for the given pane and its child popups. */ + setAudioMuted(paneId: string, muted: boolean): void { + this.mutedPanes.set(paneId, muted); + const entry = this.panes.get(paneId); + if (entry) { + const guest = webContents.fromId(entry.webContentsId); + if ( + guest && + !guest.isDestroyed() && + typeof guest.setAudioMuted === "function" + ) { + guest.setAudioMuted(muted); + } + } + const popups = this.popupWebContents.get(paneId); + if (popups) { + for (const popup of popups) { + if (!popup.isDestroyed() && typeof popup.setAudioMuted === "function") { + popup.setAudioMuted(muted); + } + } + } + } + + /** Returns true if the pane is currently set to mute audio. */ + isAudioMuted(paneId: string): boolean { + return this.mutedPanes.get(paneId) ?? false; + } + setForwardableChords(chords: string[]): void { this.forwardableChords = new Set(chords); } @@ -300,6 +331,12 @@ class BrowserManager extends EventEmitter { }); const wc = webContents.fromId(webContentsId); if (wc) { + // Apply persisted or default audio mute state + const isMuted = this.isAudioMuted(paneId); + if (typeof wc.setAudioMuted === "function") { + wc.setAudioMuted(isMuted); + } + // Throttling stays enabled by default so parked/offscreen persistent // webviews don't run at full speed in the background — except while // agent work is in flight on the pane (see acquireAgentWake), where a @@ -338,6 +375,8 @@ class BrowserManager extends EventEmitter { this.designMode.cancel(paneId, "destroyed"); this.panes.delete(paneId); this.consoleLogs.delete(paneId); + this.mutedPanes.delete(paneId); + this.popupWebContents.delete(paneId); // Tell subscribers when a live wake dies with the pane, so the renderer // doesn't keep a stale pane id in its exemption set. if (this.agentWakes.delete(paneId)) this.emitAgentActive(); @@ -1081,6 +1120,16 @@ class BrowserManager extends EventEmitter { ): void { const wc = window.webContents; markBrowserPanePopup(wc); + if (typeof wc.setAudioMuted === "function") { + wc.setAudioMuted(this.isAudioMuted(paneId)); + } + let popups = this.popupWebContents.get(paneId); + if (!popups) { + popups = new Set(); + this.popupWebContents.set(paneId, popups); + } + popups.add(wc); + const detachGuard = attachNavigationGuard(wc, (url) => this.emit("deep-link", url), ); @@ -1090,7 +1139,13 @@ class BrowserManager extends EventEmitter { wc.on("did-create-window", (child) => { this.configurePopupWindow(paneId, child); }); - window.on("closed", detachGuard); + window.once("closed", () => { + popups?.delete(wc); + if (popups?.size === 0) { + this.popupWebContents.delete(paneId); + } + detachGuard(); + }); } /** Items extensions add with `chrome.contextMenus`, for the pane's right-click menu. */ diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserOverflowMenu/BrowserOverflowMenu.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserOverflowMenu/BrowserOverflowMenu.tsx index 3cc4063be2c..9463aca5388 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserOverflowMenu/BrowserOverflowMenu.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserOverflowMenu/BrowserOverflowMenu.tsx @@ -8,7 +8,14 @@ import { } from "@superset/ui/dropdown-menu"; import { toast } from "@superset/ui/sonner"; import { useNavigate } from "@tanstack/react-router"; -import { CheckIcon, MinusIcon, PlusIcon, RotateCcwIcon } from "lucide-react"; +import { + CheckIcon, + MinusIcon, + PlusIcon, + RotateCcwIcon, + Volume2Icon, + VolumeXIcon, +} from "lucide-react"; import { useEffect, useState } from "react"; import { TbDots } from "react-icons/tb"; import { ImportHistoryDialog } from "renderer/components/ImportHistoryDialog"; @@ -53,6 +60,10 @@ function openAfterClose(setOpen: (open: boolean) => void) { }; } +/** + * Renders the overflow dropdown menu for browser pane actions, including + * zoom controls, developer tools, audio muting, navigation history, and clear data. + */ export function BrowserOverflowMenu({ paneId, currentUrl, @@ -72,6 +83,49 @@ export function BrowserOverflowMenu({ const [isScreenshotsOpen, setIsScreenshotsOpen] = useState(false); const [isClearDataOpen, setIsClearDataOpen] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false); + const [audioMuteState, setAudioMuteState] = useState<{ + paneId: string; + muted: boolean; + } | null>(null); + + const isAudioMuteLoaded = audioMuteState?.paneId === paneId; + const isAudioMuted = isAudioMuteLoaded ? audioMuteState.muted : false; + + useEffect(() => { + if (!paneId) return; + let cancelled = false; + electronTrpcClient.browser.isAudioMuted + .query({ paneId }) + .then((muted) => { + if (!cancelled) { + setAudioMuteState({ paneId, muted }); + } + }) + .catch((error) => { + console.warn("[BrowserOverflowMenu] could not read audio mute state", { + paneId, + error, + }); + }); + return () => { + cancelled = true; + }; + }, [paneId]); + + /** Toggles the audio mute state for the active browser pane. */ + const handleToggleMute = () => { + if (!paneId || !isAudioMuteLoaded) return; + const next = !isAudioMuted; + electronTrpcClient.browser.setAudioMuted + .mutate({ paneId, muted: next }) + .then(() => setAudioMuteState({ paneId, muted: next })) + .catch((error) => { + console.warn("[BrowserOverflowMenu] could not set audio mute state", { + paneId, + error, + }); + }); + }; // The webview swallows pointer events, so a click on the page would never // reach the document listener Radix dismisses on; passing the click @@ -257,6 +311,18 @@ export function BrowserOverflowMenu({ Take a screenshot + + {isAudioMuted ? Unmute : Mute} + {isAudioMuted ? ( + + ) : ( + + )} + Hard reload diff --git a/scripts/test-preload.ts b/scripts/test-preload.ts index faa10dac8bb..c8dd3a3c37e 100644 --- a/scripts/test-preload.ts +++ b/scripts/test-preload.ts @@ -26,11 +26,135 @@ * ever loses it. */ -import { afterAll } from "bun:test"; +import { afterAll, mock } from "bun:test"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +// Mock Electron so tests importing 'electron' don't fail in headless Bun runtime +mock.module("electron", () => ({ + webContents: { + fromId: mock(() => null), + }, + clipboard: { + write: mock(() => Promise.resolve()), + writeText: mock(() => Promise.resolve()), + readText: mock(() => ""), + writeImage: mock(), + }, + Menu: { + buildFromTemplate: mock((items: unknown) => ({ + popup: mock(), + items, + })), + setApplicationMenu: mock(), + }, + shell: { + openExternal: mock(async () => {}), + openPath: mock(async () => ""), + showItemInFolder: mock(() => {}), + beep: mock(() => {}), + }, + app: { + isPackaged: false, + getVersion: () => "0.0.0", + getName: () => "test-app", + getAppPath: () => "/tmp/app", + getPath: (name: string) => `/tmp/${name}`, + on: mock(), + whenReady: mock(async () => {}), + }, + BrowserWindow: Object.assign( + mock(() => ({ + webContents: { + on: mock(), + off: mock(), + setAudioMuted: mock(), + setWindowOpenHandler: mock(), + send: mock(), + }, + on: mock(), + once: mock(), + loadURL: mock(async () => {}), + close: mock(), + destroy: mock(), + })), + { getFocusedWindow: mock(() => null), getAllWindows: mock(() => []) }, + ), + WebContentsView: mock(() => ({ + webContents: { + send: mock(), + once: mock(), + loadURL: mock(() => Promise.resolve()), + close: mock(), + }, + })), + nativeImage: { + createEmpty: () => ({ + isEmpty: () => true, + toPNG: () => Buffer.from(""), + toJPEG: () => Buffer.from(""), + getSize: () => ({ width: 0, height: 0 }), + }), + createFromBuffer: () => ({ + isEmpty: () => false, + toPNG: () => Buffer.from(""), + toJPEG: () => Buffer.from(""), + getSize: () => ({ width: 100, height: 100 }), + }), + }, + ipcMain: { + on: mock(), + handle: mock(), + removeHandler: mock(), + }, + dialog: { + showOpenDialog: mock(() => + Promise.resolve({ canceled: false, filePaths: [] }), + ), + showSaveDialog: mock(() => + Promise.resolve({ canceled: false, filePath: "" }), + ), + showErrorBox: mock(), + showMessageBox: mock(async () => ({ response: 0 })), + }, + session: { + defaultSession: { + webRequest: { + onBeforeSendHeaders: mock(), + onHeadersReceived: mock(), + }, + }, + fromPartition: mock(() => ({})), + }, + screen: { + getPrimaryDisplay: mock(() => ({ + workAreaSize: { width: 1920, height: 1080 }, + bounds: { x: 0, y: 0, width: 1920, height: 1080 }, + })), + getAllDisplays: mock(() => [ + { + bounds: { x: 0, y: 0, width: 1920, height: 1080 }, + workAreaSize: { width: 1920, height: 1080 }, + }, + ]), + }, + Notification: mock(() => ({ + show: mock(), + on: mock(), + })), + powerMonitor: { + getSystemIdleState: mock(() => "active"), + }, + contextBridge: { + executeInMainWorld: mock(), + exposeInMainWorld: mock(), + }, + ipcRenderer: undefined, + net: {}, + ClipboardItem: class ClipboardItem {}, +})); + const testSupersetHome = mkdtempSync(join(tmpdir(), "superset-test-home-")); process.env.SUPERSET_HOME_DIR = testSupersetHome; Reflect.set( @@ -39,10 +163,14 @@ Reflect.set( testSupersetHome, ); -// Test processes are short-lived, but a full local run creates enough state -// here to make relying on the OS's eventual temp cleanup needlessly noisy. -// The exit hook is synchronous because Node/Bun cannot await async work once -// process shutdown has begun; best-effort cleanup must never mask test status. +/** + * Cleans up the isolated test superset home directory synchronously on exit. + * + * Test processes are short-lived, but a full local run creates enough state + * here to make relying on the OS's eventual temp cleanup needlessly noisy. + * The exit hook is synchronous because Node/Bun cannot await async work once + * process shutdown has begun; best-effort cleanup must never mask test status. + */ function cleanupTestSupersetHome(): void { try { rmSync(testSupersetHome, { recursive: true, force: true });