diff --git a/src-tauri/src/commands/settings_window.rs b/src-tauri/src/commands/settings_window.rs index ce5f3cc..1d18bdb 100644 --- a/src-tauri/src/commands/settings_window.rs +++ b/src-tauri/src/commands/settings_window.rs @@ -37,6 +37,7 @@ fn create_settings_window( .title("Talkis") .visible(visible) .focused(visible) + .background_throttling(tauri::utils::config::BackgroundThrottlingPolicy::Disabled) .inner_size(920.0, 680.0) .min_inner_size(820.0, 560.0) .center(); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e09dabb..6c52ac6 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -15,6 +15,7 @@ { "label": "widget", "visible": false, + "backgroundThrottling": "disabled", "title": "Talkis", "url": "index.html?window=widget", "width": 129, diff --git a/src/lib/backgroundActivity.test.ts b/src/lib/backgroundActivity.test.ts new file mode 100644 index 0000000..b3e6e6f --- /dev/null +++ b/src/lib/backgroundActivity.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test"; + +import { holdBackgroundActivity } from "./backgroundActivity"; + +describe("background activity lifetime", () => { + test("keeps a granted shared lock pending until the window unmounts", async () => { + let settled = false; + let signal: AbortSignal | undefined; + let request: Promise = Promise.resolve(); + const release = holdBackgroundActivity("widget", { + request: (name, options, callback): Promise => { + expect(name).toBe("talkis:widget:background"); + expect(options.mode).toBe("shared"); + signal = options.signal; + request = callback({ name, mode: "shared" }).then(() => { + settled = true; + }); + + return request; + }, + }); + + await Promise.resolve(); + expect(settled).toBe(false); + expect(signal?.aborted).toBe(false); + + release(); + release(); + await request; + + expect(settled).toBe(true); + expect(signal?.aborted).toBe(true); + }); + + test("releases a late grant when cleanup precedes acquisition", async () => { + let grant: (() => Promise) | undefined; + let signal: AbortSignal | undefined; + const release = holdBackgroundActivity("settings", { + request: (name, options, callback): Promise => { + signal = options.signal; + grant = (): Promise => callback({ name, mode: "shared" }); + + return Promise.resolve(); + }, + }); + + release(); + expect(signal?.aborted).toBe(true); + expect(grant).toBeDefined(); + await grant?.(); + }); + + test("does not block window startup if the browser rejects the request", async () => { + const release = holdBackgroundActivity("widget", { + request: (): Promise => Promise.reject(new Error("locks disabled")), + }); + + await Promise.resolve(); + expect(release).toBeFunction(); + release(); + }); + + test("supports browsers without the Locks API", () => { + const release = holdBackgroundActivity("settings"); + + expect(release).toBeFunction(); + release(); + }); +}); diff --git a/src/lib/backgroundActivity.ts b/src/lib/backgroundActivity.ts new file mode 100644 index 0000000..2cf347b --- /dev/null +++ b/src/lib/backgroundActivity.ts @@ -0,0 +1,58 @@ +import { logError, logInfo } from "./logger"; + +interface BackgroundLockManager { + request( + name: string, + options: LockOptions, + callback: (lock: Lock | null) => Promise, + ): Promise; +} + +/** Keep event-driven background windows eligible to receive hotkeys and IPC. */ +export function holdBackgroundActivity( + windowLabel: "widget" | "settings", + locks: BackgroundLockManager | undefined = + typeof navigator === "undefined" ? undefined : navigator.locks, +): () => void { + if (!locks) { + return (): void => {}; + } + + const controller = new AbortController(); + let released = false; + let finish: () => void = (): void => {}; + const lifetime = new Promise((resolve) => { + finish = resolve; + }); + + try { + void locks.request( + `talkis:${windowLabel}:background`, + { mode: "shared", signal: controller.signal }, + async (): Promise => { + if (released) return; + + void logInfo("BACKGROUND", `Activity lock acquired: ${windowLabel}`); + await lifetime; + }, + ).catch((error: unknown) => { + if (!released) { + void logError( + "BACKGROUND", + `Activity lock unavailable (${windowLabel}): ${String(error)}`, + ); + } + }); + } catch (error) { + void logError( + "BACKGROUND", + `Failed to request activity lock (${windowLabel}): ${String(error)}`, + ); + } + + return (): void => { + released = true; + finish(); + controller.abort(); + }; +} diff --git a/src/windows/settings/SettingsApp.tsx b/src/windows/settings/SettingsApp.tsx index 36ccf02..f98f637 100644 --- a/src/windows/settings/SettingsApp.tsx +++ b/src/windows/settings/SettingsApp.tsx @@ -45,6 +45,7 @@ import { } from "../../lib/store"; import { checkAllPermissions } from "../../lib/permissions"; import { logError, logInfo } from "../../lib/logger"; +import { holdBackgroundActivity } from "../../lib/backgroundActivity"; import { UserPanel } from "../../components/UserPanel"; import { watchThemePreference } from "../../lib/theme"; import { syncWindowsTitlebarTheme } from "../../lib/windowsTitlebar"; @@ -381,6 +382,8 @@ function getCurrentDomSelectionText(): string { } export function SettingsApp(): ReactElement { + useEffect(() => holdBackgroundActivity("settings"), []); + const { t } = useI18n(); const initialQuery = new URLSearchParams(window.location.search); const checkUpdateAtStartup = initialQuery.get("checkUpdate") === "1"; diff --git a/src/windows/widget/Widget.tsx b/src/windows/widget/Widget.tsx index 67d13cb..a1d10e1 100644 --- a/src/windows/widget/Widget.tsx +++ b/src/windows/widget/Widget.tsx @@ -54,6 +54,7 @@ import { transcribeFilePathOnly, } from "../../lib/fileTranscription"; import { logError, logInfo } from "../../lib/logger"; +import { holdBackgroundActivity } from "../../lib/backgroundActivity"; import { tn, useI18n } from "../../lib/i18n"; import { requestSystemAudioPermission, @@ -339,6 +340,8 @@ function isCallCapturePermissionError(error: unknown): boolean { } export function Widget() { + useEffect(() => holdBackgroundActivity("widget"), []); + const { t } = useI18n(); const widgetWindow = getCurrentWindow(); const dragStartRef = useRef<{ x: number; y: number } | null>(null);