diff --git a/.gitignore b/.gitignore index 3784c0cf8..ac93fba57 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ electron/native/gpu-export-probe/build/ electron/native/nvidia-cuda-compositor/build/ electron/native/bin/*/whisper-* electron/native/bin/*/whisper-runtime.json +electron/native/bin/*/*.dll # Local debug helpers tmp-*.ps1 diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 1a926718f..2d47e3246 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -67,6 +67,14 @@ interface UpdateStatusSummary { type RendererRecordingSessionData = import("./ipc/types").RecordingSessionData; +interface FocusModeResult { + success: boolean; + enabled: boolean; + /** Always true for in-app suppression (supported on all platforms). */ + supported: boolean; + error?: string; +} + interface RendererFfmpegAudioMuxMetrics { tempVideoWriteMs?: number; tempEditedAudioWriteMs?: number; @@ -930,6 +938,11 @@ interface Window { cancelCountdown: () => Promise<{ success: boolean }>; getActiveCountdown: () => Promise<{ success: boolean; seconds: number | null }>; onCountdownTick: (callback: (seconds: number) => void) => () => void; + /** Focus mode — in-app notification suppression */ + getFocusModeStatus: () => Promise; + setFocusMode: (enabled: boolean) => Promise; + /** Subscribe to focus-mode state changes broadcast from the main process. Returns an unsubscribe function. */ + onFocusModeChanged: (callback: (result: FocusModeResult) => void) => () => void; }; } diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 2a0f998eb..6fe88a8a5 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -3,6 +3,7 @@ import { registerAnnouncementHandlers } from "./register/announcements"; import { registerAssetHandlers } from "./register/assets"; import { registerCaptionHandlers } from "./register/captions"; import { registerExportHandlers } from "./register/export"; +import { registerFocusModeHandlers } from "./register/focusMode"; import { registerPermissionHandlers } from "./register/permissions"; import { registerProjectHandlers } from "./register/project"; import { registerRecordingHandlers } from "./register/recording"; @@ -71,4 +72,5 @@ export function registerIpcHandlers( registerCaptionHandlers(); registerProjectHandlers(); registerSettingsHandlers(); + registerFocusModeHandlers(); } diff --git a/electron/ipc/register/focusMode.test.ts b/electron/ipc/register/focusMode.test.ts new file mode 100644 index 000000000..b0e195917 --- /dev/null +++ b/electron/ipc/register/focusMode.test.ts @@ -0,0 +1,158 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// ── In-memory settings store mock ───────────────────────────────────────────── +const settingsStore: Record = {}; + +vi.mock("../../appSettingsStore", () => ({ + readAppSetting: (key: string) => settingsStore[key] ?? null, + writeAppSetting: (key: string, value: unknown) => { + settingsStore[key] = value; + }, +})); + +// ── Electron mock ───────────────────────────────────────────────────────────── +const handlers: Record unknown> = {}; +const sentMessages: Array<{ channel: string; payload: unknown }> = []; + +const mockWebContents = { + isDestroyed: () => false, + send: (channel: string, payload: unknown) => { + sentMessages.push({ channel, payload }); + }, +}; + +vi.mock("electron", () => ({ + ipcMain: { + handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => { + handlers[channel] = handler; + }, + }, + webContents: { + getAllWebContents: () => [mockWebContents], + }, +})); + +// ── Import after mocks are set up ───────────────────────────────────────────── +import { registerFocusModeHandlers } from "./focusMode"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── +function invoke(channel: string, ...args: unknown[]) { + const handler = handlers[channel]; + if (!handler) throw new Error(`No handler registered for channel "${channel}"`); + return handler(null, ...args); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── +describe("focus mode IPC handlers", () => { + beforeEach(() => { + // Register handlers fresh for each test + registerFocusModeHandlers(); + // Clear sent messages + sentMessages.length = 0; + // Reset the settings store + for (const k of Object.keys(settingsStore)) { + delete settingsStore[k]; + } + }); + + afterEach(() => { + // Clean up registered handlers between tests + for (const k of Object.keys(handlers)) { + delete handlers[k]; + } + }); + + describe("get-focus-mode-status", () => { + it("returns enabled:false when no stored value exists", () => { + const result = invoke("get-focus-mode-status"); + expect(result).toEqual({ success: true, enabled: false, supported: true }); + }); + + it("returns enabled:true when the setting is persisted as true", () => { + settingsStore["focusModeEnabled"] = true; + const result = invoke("get-focus-mode-status"); + expect(result).toEqual({ success: true, enabled: true, supported: true }); + }); + + it("coerces a non-boolean truthy stored value to false (strict equality check)", () => { + // The handler uses `stored === true` — only exact boolean true is accepted. + settingsStore["focusModeEnabled"] = 1; + const result = invoke("get-focus-mode-status"); + expect(result).toEqual({ success: true, enabled: false, supported: true }); + }); + }); + + describe("set-focus-mode", () => { + it("enables focus mode and persists the setting", () => { + const result = invoke("set-focus-mode", true); + expect(result).toEqual({ success: true, enabled: true, supported: true }); + expect(settingsStore["focusModeEnabled"]).toBe(true); + }); + + it("disables focus mode and persists the setting", () => { + settingsStore["focusModeEnabled"] = true; + const result = invoke("set-focus-mode", false); + expect(result).toEqual({ success: true, enabled: false, supported: true }); + expect(settingsStore["focusModeEnabled"]).toBe(false); + }); + + it("rejects a non-boolean payload (string)", () => { + const result = invoke("set-focus-mode", "true") as { + success: boolean; + error?: string; + }; + expect(result.success).toBe(false); + expect(typeof result.error).toBe("string"); + // Setting should remain unchanged + expect(settingsStore["focusModeEnabled"]).toBeUndefined(); + }); + + it("rejects a non-boolean payload (number)", () => { + const result = invoke("set-focus-mode", 1) as { success: boolean }; + expect(result.success).toBe(false); + }); + + it("rejects null payload", () => { + const result = invoke("set-focus-mode", null) as { success: boolean }; + expect(result.success).toBe(false); + }); + + it("broadcasts focus-mode-changed to all renderer windows on success", () => { + invoke("set-focus-mode", true); + expect(sentMessages).toHaveLength(1); + expect(sentMessages[0].channel).toBe("focus-mode-changed"); + expect(sentMessages[0].payload).toEqual({ + success: true, + enabled: true, + supported: true, + }); + }); + + it("does not broadcast on rejected non-boolean input", () => { + invoke("set-focus-mode", "yes"); + expect(sentMessages).toHaveLength(0); + }); + }); + + describe("crash / restart recovery via persisted state", () => { + it("restores enabled:true from the settings store on next get after abnormal exit", () => { + // Simulate: the setting was persisted during a previous session + settingsStore["focusModeEnabled"] = true; + // On next app launch the handler reads the store + const result = invoke("get-focus-mode-status"); + expect(result).toEqual({ success: true, enabled: true, supported: true }); + }); + }); + + describe("supported is always true (in-app suppression)", () => { + it("get always returns supported:true", () => { + const result = invoke("get-focus-mode-status") as { supported: boolean }; + expect(result.supported).toBe(true); + }); + + it("set always returns supported:true on success", () => { + const result = invoke("set-focus-mode", false) as { supported: boolean }; + expect(result.supported).toBe(true); + }); + }); +}); diff --git a/electron/ipc/register/focusMode.ts b/electron/ipc/register/focusMode.ts new file mode 100644 index 000000000..937c05059 --- /dev/null +++ b/electron/ipc/register/focusMode.ts @@ -0,0 +1,86 @@ +import { ipcMain, webContents } from "electron"; +import { readAppSetting, writeAppSetting } from "../../appSettingsStore"; + +const FOCUS_MODE_SETTING_KEY = "focusModeEnabled"; + +/** The result shape returned by all focus-mode IPC handlers. */ +interface FocusModeResult { + success: boolean; + enabled: boolean; + /** Always true: in-app suppression is supported on every platform. */ + supported: boolean; + error?: string; +} + +function readFocusModeEnabled(): boolean { + const stored = readAppSetting(FOCUS_MODE_SETTING_KEY); + return stored === true; +} + +function broadcastFocusModeChanged(result: FocusModeResult) { + for (const wc of webContents.getAllWebContents()) { + if (!wc.isDestroyed()) { + wc.send("focus-mode-changed", result); + } + } +} + +export function registerFocusModeHandlers() { + // ── get-focus-mode-status ───────────────────────────────────────────────── + ipcMain.handle("get-focus-mode-status", (): FocusModeResult => { + try { + return { + success: true, + enabled: readFocusModeEnabled(), + supported: true, + }; + } catch (error) { + console.error("[focus-mode] Failed to read focus mode status:", error); + return { + success: false, + enabled: false, + supported: true, + error: String(error), + }; + } + }); + + // ── set-focus-mode ──────────────────────────────────────────────────────── + ipcMain.handle("set-focus-mode", (_event, enabled: unknown): FocusModeResult => { + // Validate: reject non-boolean payloads rather than coercing. + if (typeof enabled !== "boolean") { + const error = `set-focus-mode: expected boolean, received ${typeof enabled}`; + console.warn(`[focus-mode] ${error}`); + return { + success: false, + enabled: readFocusModeEnabled(), + supported: true, + error, + }; + } + + try { + writeAppSetting(FOCUS_MODE_SETTING_KEY, enabled); + + const result: FocusModeResult = { + success: true, + enabled, + supported: true, + }; + + // Broadcast to all renderer windows so multi-window state stays in sync. + broadcastFocusModeChanged(result); + + return result; + } catch (error) { + console.error("[focus-mode] Failed to set focus mode:", error); + // Return last-known state so the renderer can revert correctly. + return { + success: false, + enabled: readFocusModeEnabled(), + supported: true, + error: String(error), + }; + } + }); +} diff --git a/electron/preload.ts b/electron/preload.ts index 990ee7a8f..8f2ddc3fc 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1019,4 +1019,22 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.on("countdown-tick", listener); return () => ipcRenderer.removeListener("countdown-tick", listener); }, + // Focus mode — in-app notification suppression + getFocusModeStatus: () => ipcRenderer.invoke("get-focus-mode-status"), + setFocusMode: (enabled: boolean) => ipcRenderer.invoke("set-focus-mode", enabled), + onFocusModeChanged: ( + callback: (result: { + success: boolean; + enabled: boolean; + supported: boolean; + error?: string; + }) => void, + ) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: { success: boolean; enabled: boolean; supported: boolean; error?: string }, + ) => callback(payload); + ipcRenderer.on("focus-mode-changed", listener); + return () => ipcRenderer.removeListener("focus-mode-changed", listener); + }, }); diff --git a/src/components/announcements/AnnouncementDialog.tsx b/src/components/announcements/AnnouncementDialog.tsx index a9bc0fcfb..51b4ef61f 100644 --- a/src/components/announcements/AnnouncementDialog.tsx +++ b/src/components/announcements/AnnouncementDialog.tsx @@ -1,6 +1,6 @@ import { ArrowLeft, ArrowRight, ArrowSquareOut, Megaphone } from "@phosphor-icons/react"; import { useEffect, useRef, useState } from "react"; -import { toast } from "sonner"; +import { toast } from "@/lib/toast"; import { BUNDLED_ANNOUNCEMENT_FEED } from "@/content/announcements"; import { useI18n } from "@/contexts/I18nContext"; import { runAnnouncementAction } from "@/lib/announcementActions"; diff --git a/src/components/announcements/EditorAnnouncementBanner.tsx b/src/components/announcements/EditorAnnouncementBanner.tsx index 6180c17b9..a714b8198 100644 --- a/src/components/announcements/EditorAnnouncementBanner.tsx +++ b/src/components/announcements/EditorAnnouncementBanner.tsx @@ -1,6 +1,6 @@ import { ArrowRight, ArrowSquareOut, X } from "@phosphor-icons/react"; import { useEffect, useRef, useState } from "react"; -import { toast } from "sonner"; +import { toast } from "@/lib/toast"; import { Button } from "@/components/ui/button"; import { BUNDLED_ANNOUNCEMENT_FEED } from "@/content/announcements"; import { useI18n } from "@/contexts/I18nContext"; diff --git a/src/components/announcements/LiveAnnouncementNotifications.tsx b/src/components/announcements/LiveAnnouncementNotifications.tsx index 3d6bce30f..ff91f5d1a 100644 --- a/src/components/announcements/LiveAnnouncementNotifications.tsx +++ b/src/components/announcements/LiveAnnouncementNotifications.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef } from "react"; -import { toast } from "sonner"; +import { toast } from "@/lib/toast"; import { BUNDLED_ANNOUNCEMENT_FEED } from "@/content/announcements"; import { useI18n } from "@/contexts/I18nContext"; import { runAnnouncementAction } from "@/lib/announcementActions"; diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 66cbe608b..27690f39b 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,5 +1,7 @@ import { ArrowClockwiseIcon, + BellSimpleIcon, + BellSimpleSlashIcon, CaretUpIcon, DotsThreeVerticalIcon, MicrophoneIcon, @@ -42,6 +44,7 @@ import { ProjectPopover } from "./popovers/ProjectPopover"; import { SourcePopover } from "./popovers/SourcePopover"; import { WebcamPopover } from "./popovers/WebcamPopover"; import { RecordingControls } from "./RecordingControls"; +import { useFocusMode } from "./hooks/useFocusMode"; const SHOW_DEV_UPDATE_PREVIEW = import.meta.env.DEV; @@ -85,6 +88,9 @@ function LaunchWindowContent() { const hudContentRef = useRef(null); const hudBarRef = useRef(null); + const { focusModeEnabled, focusModeSupported, focusModeLoading, toggleFocusMode } = + useFocusMode(); + const { selectedSource, hasSelectedSource, @@ -345,6 +351,38 @@ function LaunchWindowContent() { } /> + +