Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<FocusModeResult>;
setFocusMode: (enabled: boolean) => Promise<FocusModeResult>;
/** Subscribe to focus-mode state changes broadcast from the main process. Returns an unsubscribe function. */
onFocusModeChanged: (callback: (result: FocusModeResult) => void) => () => void;
};
}

Expand Down
2 changes: 2 additions & 0 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -71,4 +72,5 @@ export function registerIpcHandlers(
registerCaptionHandlers();
registerProjectHandlers();
registerSettingsHandlers();
registerFocusModeHandlers();
}
158 changes: 158 additions & 0 deletions electron/ipc/register/focusMode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

// ── In-memory settings store mock ─────────────────────────────────────────────
const settingsStore: Record<string, unknown> = {};

vi.mock("../../appSettingsStore", () => ({
readAppSetting: (key: string) => settingsStore[key] ?? null,
writeAppSetting: (key: string, value: unknown) => {
settingsStore[key] = value;
},
}));

// ── Electron mock ─────────────────────────────────────────────────────────────
const handlers: Record<string, (event: unknown, ...args: unknown[]) => 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);
});
});
});
86 changes: 86 additions & 0 deletions electron/ipc/register/focusMode.ts
Original file line number Diff line number Diff line change
@@ -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),
};
}
});
}
18 changes: 18 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
});
2 changes: 1 addition & 1 deletion src/components/announcements/AnnouncementDialog.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
2 changes: 1 addition & 1 deletion src/components/announcements/EditorAnnouncementBanner.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
Loading