diff --git a/desktop/windows/.gitignore b/desktop/windows/.gitignore index f020a28fa2a..40280b8456a 100644 --- a/desktop/windows/.gitignore +++ b/desktop/windows/.gitignore @@ -53,6 +53,7 @@ docs/* !docs/perf-startup-burst-2026-07-19.md !docs/mac-parity-audit !docs/perf-invisible-wins.md +!docs/linux-screen-recording.md skills-lock.json # Visual brainstorming companion diff --git a/desktop/windows/AGENTS.md b/desktop/windows/AGENTS.md index 147f3088919..ac79c709320 100644 --- a/desktop/windows/AGENTS.md +++ b/desktop/windows/AGENTS.md @@ -116,8 +116,9 @@ starting this from scratch. - `docs/conversation-sync.md` — offline-retry outbox design. - `docs/multi-worktree-dev.md` — parallel-worktree port/profile isolation, dev env var reference. -- `docs/perf-invisible-wins.md`, `docs/perf-startup-burst-2026-07-19.md` — - perf investigation notes. +- `docs/linux-screen-recording.md` — Rewind needs a Wayland desktop portal; + wlroots compositors (niri, Sway, Hyprland) often ship none configured. +- `docs/perf-invisible-wins.md`, `docs/perf-startup-burst-2026-07-19.md` — perf notes. ## Changelog Entries diff --git a/desktop/windows/changelog/unreleased/2026-08-rewind-capture-error-notice.json b/desktop/windows/changelog/unreleased/2026-08-rewind-capture-error-notice.json new file mode 100644 index 00000000000..d6276f44045 --- /dev/null +++ b/desktop/windows/changelog/unreleased/2026-08-rewind-capture-error-notice.json @@ -0,0 +1,5 @@ +{ + "changes": [ + "Rewind now shows a real in-app error when it can't get a screen source (e.g. no Wayland desktop portal configured), instead of silently never starting." + ] +} diff --git a/desktop/windows/docs/linux-screen-recording.md b/desktop/windows/docs/linux-screen-recording.md new file mode 100644 index 00000000000..4ecaab2b4c7 --- /dev/null +++ b/desktop/windows/docs/linux-screen-recording.md @@ -0,0 +1,89 @@ +# Linux screen recording (Rewind) troubleshooting + +Rewind needs `desktopCapturer.getSources()` to resolve, which on Wayland goes +through the `org.freedesktop.portal.ScreenCast` D-Bus interface. If no portal +backend implements it for the running compositor, Electron fails immediately +(`Failed to get sources.`), Rewind never starts, and — before this doc's +companion fix — nothing told the user why. + +## What Omi does automatically + +- `RewindCaptureNotice` (`src/renderer/src/components/ui/RewindCaptureNotice.tsx`) + shows an in-app banner when Rewind is enabled but `desktopCapturer.getSources()` + failed, with Linux-specific guidance when `process.platform === 'linux'` (see + `getRewindCaptureDiagnostics` in `src/main/rewind/sourceId.ts`). +- The `.deb` package `Recommends` (not `Depends`) the generic `xdg-desktop-portal` + front-end (`electron-builder.config.mjs`) — the universal front-end almost every + desktop already has. It deliberately does **not** depend on a specific backend + package, because the correct one is compositor-specific (see below) and there is + no single correct hard dependency across GNOME/KDE/wlroots-family desktops. + +## Why this can't be fully automated + +A portal **backend** (`xdg-desktop-portal-wlr`/`-gnome`/`-kde`/…) has to run on the +host, register on the system D-Bus session bus, and integrate with whichever +compositor is actually running. That's true regardless of how Omi is packaged — +`.deb`, `AppImage`, or a hypothetical future Flatpak — none of these formats can +bundle or install a working backend themselves: + +- **Flatpak** sandboxes the app; it can only *ask* the host's portal dispatcher. + It cannot bundle a backend — that must already be installed and preferred on + the host. +- **`.deb`** dependencies resolve once at install time, but the correct backend + depends on which compositor the user runs, which the package can't know in + advance. +- **AppImage** has zero ability to install or configure anything system-level. + +Mainstream desktop environments (GNOME, KDE) avoid this because their distro +installs *and* preconfigures the matching portal by default. Smaller / newer +wlroots-family compositors (niri, Sway, Hyprland, …) are the common exception — +their distro packaging often ships a portal config that doesn't route +`ScreenCast` anywhere, even when a working backend package is available. + +## Fixing it on a wlroots-family compositor (niri, Sway, Hyprland, …) + +Confirmed live on Fedora Asahi Remix + niri, where `niri-portals.conf` shipped +with no `ScreenCast` route at all: + +1. Install the wlr portal backend: + ``` + sudo dnf install xdg-desktop-portal-wlr # Fedora + sudo apt install xdg-desktop-portal-wlr # Debian/Ubuntu + ``` +2. Add a user-level override (safer than editing the system file, which a + package update can overwrite) at + `~/.config/xdg-desktop-portal/-portals.conf` (e.g. + `niri-portals.conf`): + ```ini + [preferred] + default=gnome;gtk; + org.freedesktop.impl.portal.ScreenCast=wlr + org.freedesktop.impl.portal.Screenshot=wlr + ``` +3. Restart the portal so it picks up both the new backend and the config: + ``` + systemctl --user restart xdg-desktop-portal + ``` + (a full logout/login also works if that doesn't take effect) +4. Restart Omi. `getRewindCaptureSourceId()`'s screen-source lookup is cached + for the whole process lifetime (`src/main/rewind/sourceId.ts` — deliberately, + `desktopCapturer.getSources()` is slow), so a running instance won't pick up + a newly-fixed portal without a restart. + +## What to expect on first launch after the fix + +The portal's interactive picker for a wlroots-family compositor isn't a dialog +window — it's a compositor-driven interactive selection (a changed cursor to +click a window, or click-drag a region), similar to a screenshot tool. Select +once; the grant is then cached for the process lifetime, so later Rewind +toggles won't show it again — that's expected, not a regression. + +## Diagnosing on your own machine + +``` +wpctl status # confirm PipeWire even has an audio Source (mic issues) +busctl --user list | grep portal # confirm a backend (org.freedesktop.impl.portal.desktop.*) is registered +``` +If no `org.freedesktop.impl.portal.desktop.` line appears for your +compositor's expected backend, the backend isn't installed, running, or +D-Bus-activatable — start from step 1 above. diff --git a/desktop/windows/electron-builder.config.mjs b/desktop/windows/electron-builder.config.mjs index 18f38b136f5..18151f22357 100644 --- a/desktop/windows/electron-builder.config.mjs +++ b/desktop/windows/electron-builder.config.mjs @@ -155,13 +155,19 @@ export default { // Runtime tools/libs the Linux platform seams call out of process. Missing // ones degrade gracefully (OCR/active-window return empty), but packaging the // depends keeps the shipped App experience complete on Debian/Ubuntu. - depends: [ - 'tesseract-ocr', - 'tesseract-ocr-eng', - 'libnotify4', - 'libxss1', - 'x11-utils' - ] + depends: ['tesseract-ocr', 'tesseract-ocr-eng', 'libnotify4', 'libxss1', 'x11-utils'], + // A soft dependency, not `depends`: xdg-desktop-portal is the universal + // front-end virtually every desktop already has, but the BACKEND that + // actually answers ScreenCast (xdg-desktop-portal-gnome/-kde/-wlr/…) is + // compositor-specific and can't be a single correct hard dependency — + // forcing e.g. -gnome onto a KDE or wlroots-compositor user would be + // wrong. Rewind's screen recording surfaces a real in-app error (see + // RewindCaptureNotice.tsx) when no backend answers; this Recommends just + // narrows the common "portal front-end isn't even installed" case. + // electron-builder's `recommends` REPLACES its own default + // (["libappindicator3-1"], needed for the tray icon) rather than + // appending, so it must be listed explicitly here too. + recommends: ['libappindicator3-1', 'xdg-desktop-portal'] }, npmRebuild: false, // See scripts/fix-pimono-chalk-unpack.mjs: corrects chalk's packaged version for diff --git a/desktop/windows/src/main/ipc/rewind.ts b/desktop/windows/src/main/ipc/rewind.ts index f3daafa426e..d987aa22f91 100644 --- a/desktop/windows/src/main/ipc/rewind.ts +++ b/desktop/windows/src/main/ipc/rewind.ts @@ -2,6 +2,7 @@ import { ipcMain, BrowserWindow } from 'electron' import { getPrimarySourceId, getRewindCaptureSourceId, + getRewindCaptureDiagnostics, isCurrentRewindCaptureSource } from '../rewind/sourceId' import { @@ -152,6 +153,10 @@ export function registerRewindHandlers(): void { // Rewind follows the foreground window across displays while retaining one // persistent stream. Source enumeration is cached; each lookup is cheap. ipcMain.handle('rewind:captureSourceId', async () => getRewindCaptureSourceId()) + // UI-facing seam for a getSources() failure (see sourceId.ts): the Rewind tab + // calls this once on mount to show a real error instead of capture just + // never starting with only a console line. + ipcMain.handle('rewind:captureDiagnostics', async () => getRewindCaptureDiagnostics()) // Receive a sampled JPEG frame from the renderer capture host and store it // (after foreground-window metadata + idle/lock/dup gating). ipcMain.handle('rewind:saveFrame', async (_e, data: Uint8Array, sourceId: string) => { diff --git a/desktop/windows/src/main/rewind/sourceId.test.ts b/desktop/windows/src/main/rewind/sourceId.test.ts index d9be5fefabf..8598fb6082f 100644 --- a/desktop/windows/src/main/rewind/sourceId.test.ts +++ b/desktop/windows/src/main/rewind/sourceId.test.ts @@ -78,6 +78,67 @@ describe('getPrimarySourceId', () => { }) }) +describe('getRewindCaptureDiagnostics — desktopCapturer failure classification', () => { + beforeEach(() => { + vi.clearAllMocks() + getPrimaryDisplay.mockReturnValue({ id: 2 }) + getForegroundWindowRect.mockReturnValue({ rect: null, className: null, exePath: null }) + getCursorScreenPoint.mockReturnValue({ x: 0, y: 0 }) + getDisplayNearestPoint.mockReturnValue({ id: 2 }) + }) + + it('reports available with no reason when sources resolve normally', async () => { + getSources.mockResolvedValue([source('screen:0:0', '2')]) + const { getRewindCaptureDiagnostics } = await loadModule() + + expect(await getRewindCaptureDiagnostics()).toEqual({ + available: true, + reason: null, + likelyMissingLinuxPortal: false + }) + }) + + it('does NOT throw/reject when desktopCapturer.getSources() fails — resolves to unavailable instead', async () => { + // Live bug: this used to reject out of the 'rewind:captureSourceId' IPC + // handler uncaught, and Rewind just never started with no UI signal at all. + getSources.mockRejectedValue(new Error('Failed to get sources.')) + const { getPrimarySourceId, getRewindCaptureSourceId, getRewindCaptureDiagnostics } = + await loadModule() + + await expect(getPrimarySourceId()).resolves.toBeNull() + await expect(getRewindCaptureSourceId()).resolves.toBeNull() + expect(await getRewindCaptureDiagnostics()).toEqual({ + available: false, + reason: 'Failed to get sources.', + likelyMissingLinuxPortal: process.platform === 'linux' + }) + }) + + it('flags likelyMissingLinuxPortal only on linux', async () => { + getSources.mockRejectedValue(new Error('Failed to get sources.')) + const { getRewindCaptureDiagnostics } = await loadModule() + const original = process.platform + Object.defineProperty(process, 'platform', { value: 'linux' }) + try { + expect((await getRewindCaptureDiagnostics()).likelyMissingLinuxPortal).toBe(true) + } finally { + Object.defineProperty(process, 'platform', { value: original }) + } + }) + + it('does not flag likelyMissingLinuxPortal on win32', async () => { + getSources.mockRejectedValue(new Error('Failed to get sources.')) + const { getRewindCaptureDiagnostics } = await loadModule() + const original = process.platform + Object.defineProperty(process, 'platform', { value: 'win32' }) + try { + expect((await getRewindCaptureDiagnostics()).likelyMissingLinuxPortal).toBe(false) + } finally { + Object.defineProperty(process, 'platform', { value: original }) + } + }) +}) + describe('prewarmPrimarySourceId', () => { beforeEach(() => { vi.clearAllMocks() @@ -96,7 +157,8 @@ describe('prewarmPrimarySourceId', () => { getPrimaryDisplay.mockReturnValue({ id: 3 }) const invalidate = on.mock.calls.find(([event]) => event === 'display-metrics-changed')?.[1] as - (() => void) | undefined + | (() => void) + | undefined expect(invalidate).toBeTypeOf('function') invalidate?.() diff --git a/desktop/windows/src/main/rewind/sourceId.ts b/desktop/windows/src/main/rewind/sourceId.ts index 305de641f61..715d1df0793 100644 --- a/desktop/windows/src/main/rewind/sourceId.ts +++ b/desktop/windows/src/main/rewind/sourceId.ts @@ -13,12 +13,36 @@ type SourceIdentity = { id: string; displayId: string } let cached: SourceIdentity[] | null = null let inflight: Promise | null = null +// The most recent fetch failure, if any — cleared on a successful fetch. On +// Linux this is almost always a Wayland desktop-portal gap (no +// org.freedesktop.portal.ScreenCast implementation registered for the running +// compositor — confirmed live: niri + no xdg-desktop-portal-wlr produced +// "Failed to get sources." here with no further detail reaching JS; the real +// GDBus error only appears in Chromium's native stderr log, not this +// exception). getRewindCaptureDiagnostics() surfaces this to the UI instead of +// the previous behavior: an uncaught rejection out of the +// 'rewind:captureSourceId' IPC handler and a silently-never-starting capture. +let lastFetchError: Error | null = null + +export function getSourceFetchError(): string | null { + return lastFetchError?.message ?? null +} + async function fetchSourceIdentities(): Promise { - const sources = await desktopCapturer.getSources({ - types: ['screen'], - thumbnailSize: { width: 0, height: 0 } // ids only - no screen bitmap - }) - return sources.map((source) => ({ id: source.id, displayId: source.display_id })) + try { + const sources = await desktopCapturer.getSources({ + types: ['screen'], + thumbnailSize: { width: 0, height: 0 } // ids only - no screen bitmap + }) + lastFetchError = null + return sources.map((source) => ({ id: source.id, displayId: source.display_id })) + } catch (e) { + // Cache the empty result like any other outcome (see the module header) — + // a portal gap is a launch-time environment fact, not a transient blip; + // retrying every call would just re-hit the same missing D-Bus interface. + lastFetchError = e as Error + return [] + } } async function getSourceIdentities(): Promise { @@ -106,3 +130,30 @@ export function prewarmPrimarySourceId(): void { } void getPrimarySourceId() } + +export type RewindCaptureDiagnostics = { + /** Whether at least one screen source resolved. */ + available: boolean + /** The underlying fetch error's message, present only when unavailable. */ + reason: string | null + /** Linux desktopCapturer.getSources() has one dominant failure mode: no + * org.freedesktop.portal.ScreenCast implementation registered for the + * running Wayland compositor (confirmed live on niri without + * xdg-desktop-portal-wlr installed/preferred). The JS-catchable error + * message is a generic "Failed to get sources." either way — Chromium logs + * the real GDBus detail only to its own stderr, never into the exception — + * so this is a platform heuristic, not a message-content match. */ + likelyMissingLinuxPortal: boolean +} + +/** Ensure a fetch attempt has happened, then report whether it succeeded — for + * the UI to show a real error instead of Rewind silently never starting. */ +export async function getRewindCaptureDiagnostics(): Promise { + await getPrimarySourceId() + const reason = getSourceFetchError() + return { + available: !reason, + reason, + likelyMissingLinuxPortal: !!reason && process.platform === 'linux' + } +} diff --git a/desktop/windows/src/preload/index.ts b/desktop/windows/src/preload/index.ts index be0c98270f4..1ff9f7e8b7a 100644 --- a/desktop/windows/src/preload/index.ts +++ b/desktop/windows/src/preload/index.ts @@ -372,6 +372,7 @@ const omi: OmiBridgeApi = { rewindRebuildIndex: () => ipcRenderer.invoke('rewind:rebuildIndex'), rewindPrimarySourceId: () => ipcRenderer.invoke('rewind:primarySourceId'), rewindCaptureSourceId: () => ipcRenderer.invoke('rewind:captureSourceId'), + rewindCaptureDiagnostics: () => ipcRenderer.invoke('rewind:captureDiagnostics'), rewindSaveFrame: (data: Uint8Array, sourceId: string) => ipcRenderer.invoke('rewind:saveFrame', data, sourceId), screenReadText: () => ipcRenderer.invoke('screen:readNow'), diff --git a/desktop/windows/src/renderer/src/App.tsx b/desktop/windows/src/renderer/src/App.tsx index f1823d2d90b..dfbf5e74e36 100644 --- a/desktop/windows/src/renderer/src/App.tsx +++ b/desktop/windows/src/renderer/src/App.tsx @@ -9,6 +9,7 @@ import { TitleBar } from './components/layout/TitleBar' import { Spinner } from './components/ui/Spinner' import { DbRecoveryNotice } from './components/ui/DbRecoveryNotice' import { DegradedModeNotice } from './components/ui/DegradedModeNotice' +import { RewindCaptureNotice } from './components/ui/RewindCaptureNotice' import { ToastHost } from './components/ui/ToastHost' import { purgeAppMemoriesOnce } from './lib/appMemories' import { AppStateProvider } from './state/AppStateProvider' @@ -122,6 +123,8 @@ function AppShellInner(): React.JSX.Element { {/* Only renders during a backend 429 storm; self-clears on recovery. */} + {/* Only renders when Rewind is enabled but can't get a screen source. */} + diff --git a/desktop/windows/src/renderer/src/components/ui/RewindCaptureNotice.test.tsx b/desktop/windows/src/renderer/src/components/ui/RewindCaptureNotice.test.tsx new file mode 100644 index 00000000000..f7c50a70d48 --- /dev/null +++ b/desktop/windows/src/renderer/src/components/ui/RewindCaptureNotice.test.tsx @@ -0,0 +1,94 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, cleanup, screen, act, fireEvent } from '@testing-library/react' +import type { RewindCaptureDiagnostics, RewindSettings } from '../../../../shared/types' + +import { RewindCaptureNotice } from './RewindCaptureNotice' + +const AVAILABLE: RewindCaptureDiagnostics = { + available: true, + reason: null, + likelyMissingLinuxPortal: false +} + +const settings = (captureEnabled: boolean): RewindSettings => ({ + captureEnabled, + intervalMs: 30_000, + retentionDays: 30, + excludedApps: [], + captureQuality: 'standard' +}) + +function mockOmi(diagnostics: RewindCaptureDiagnostics, captureEnabled: boolean): void { + ;( + window as unknown as { + omi: { + rewindGetSettings: () => Promise + rewindCaptureDiagnostics: () => Promise + } + } + ).omi = { + rewindGetSettings: () => Promise.resolve(settings(captureEnabled)), + rewindCaptureDiagnostics: () => Promise.resolve(diagnostics) + } +} + +async function renderNotice(): Promise { + await act(async () => { + render() + }) +} + +beforeEach(() => { + vi.restoreAllMocks() +}) + +afterEach(() => { + cleanup() +}) + +describe('RewindCaptureNotice', () => { + it('renders nothing when capture is available (the overwhelmingly common case)', async () => { + mockOmi(AVAILABLE, true) + await renderNotice() + expect(screen.queryByRole('status')).toBeNull() + }) + + it('renders nothing when Rewind is disabled, even if capture would fail', async () => { + // Showing this to someone who never turned Rewind on would be noise, not help. + mockOmi( + { available: false, reason: 'Failed to get sources.', likelyMissingLinuxPortal: true }, + false + ) + await renderNotice() + expect(screen.queryByRole('status')).toBeNull() + }) + + it('shows the Linux-portal-specific message when likelyMissingLinuxPortal is set', async () => { + mockOmi( + { available: false, reason: 'Failed to get sources.', likelyMissingLinuxPortal: true }, + true + ) + await renderNotice() + expect(screen.getByText(/screen recording isn't working/i)).toBeTruthy() + expect(screen.getByText(/xdg-desktop-portal-wlr/i)).toBeTruthy() + }) + + it('shows a generic message with the reason when it is not a Linux-portal case', async () => { + mockOmi( + { available: false, reason: 'some other failure', likelyMissingLinuxPortal: false }, + true + ) + await renderNotice() + expect(screen.getByText(/some other failure/i)).toBeTruthy() + expect(screen.queryByText(/xdg-desktop-portal-wlr/i)).toBeNull() + }) + + it('can be dismissed', async () => { + mockOmi({ available: false, reason: 'x', likelyMissingLinuxPortal: false }, true) + await renderNotice() + expect(screen.getByRole('status')).toBeTruthy() + fireEvent.click(screen.getByLabelText('Dismiss')) + expect(screen.queryByRole('status')).toBeNull() + }) +}) diff --git a/desktop/windows/src/renderer/src/components/ui/RewindCaptureNotice.tsx b/desktop/windows/src/renderer/src/components/ui/RewindCaptureNotice.tsx new file mode 100644 index 00000000000..a98155ca82c --- /dev/null +++ b/desktop/windows/src/renderer/src/components/ui/RewindCaptureNotice.tsx @@ -0,0 +1,73 @@ +import { useEffect, useState } from 'react' +import { MonitorX, X } from 'lucide-react' +import type { RewindCaptureDiagnostics } from '../../../../shared/types' + +// Shown once, at the top of the main window, when Rewind is enabled but can't +// actually get a screen source. Before this, the failure (desktopCapturer's +// getSources() throwing) surfaced only as a console.error the user never +// sees — Rewind would just never start capturing, silently. Same "make the +// failure honest" spirit as DbRecoveryNotice. +// +// Neutral/white styling only — no purple (INV-UI-1), no alarm-red for what is +// an environment/config gap, not data loss. + +export function RewindCaptureNotice(): React.JSX.Element | null { + const [captureEnabled, setCaptureEnabled] = useState(false) + const [diagnostics, setDiagnostics] = useState(null) + const [dismissed, setDismissed] = useState(false) + + useEffect(() => { + let alive = true + void window.omi + .rewindGetSettings() + .then((s) => { + if (alive) setCaptureEnabled(s.captureEnabled) + }) + .catch(() => { + // A missing/failing settings channel must never break the app shell. + }) + void window.omi + .rewindCaptureDiagnostics() + .then((d) => { + if (alive) setDiagnostics(d) + }) + .catch(() => { + // A missing/failing diagnostics channel must never break the app shell. + }) + return () => { + alive = false + } + }, []) + + // Only relevant when the user actually wants Rewind on — showing this to + // someone who has it off would just be irrelevant noise. + if (dismissed || !captureEnabled || !diagnostics || diagnostics.available) return null + + const body = diagnostics.likelyMissingLinuxPortal + ? "Your Linux desktop doesn't have a working screen-sharing portal for this " + + "compositor, so Omi can't get a screen source. Install a matching " + + 'xdg-desktop-portal backend (e.g. xdg-desktop-portal-wlr for niri/Sway/' + + "Hyprland), make sure it's preferred for ScreenCast, then restart Omi." + : `Omi couldn't get a screen source${diagnostics.reason ? ` (${diagnostics.reason})` : ''}. ` + + 'Recording will stay off until this is resolved.' + + return ( +
+ +
+
Screen recording isn't working
+
{body}
+
+ +
+ ) +} diff --git a/desktop/windows/src/shared/types.ts b/desktop/windows/src/shared/types.ts index a61dde974f4..426ad8ee95e 100644 --- a/desktop/windows/src/shared/types.ts +++ b/desktop/windows/src/shared/types.ts @@ -1018,6 +1018,8 @@ export type OmiBridgeApi = { rewindPrimarySourceId: () => Promise /** Display source containing the foreground window, with cursor/primary fallbacks. */ rewindCaptureSourceId: () => Promise + /** Whether Rewind can actually get a screen source right now, and why not. */ + rewindCaptureDiagnostics: () => Promise rewindSaveFrame: ( data: Uint8Array, sourceId: string @@ -2076,6 +2078,19 @@ export type RewindSettings = { captureQuality: RewindCaptureQuality } +/** Whether Rewind can actually get a screen source, and why not — see + * main/rewind/sourceId.ts's getRewindCaptureDiagnostics. Read once on mount + * (mirrors DbRecoveryStatus's "what happened at startup" shape): the + * underlying fetch is cached for the process lifetime, so this is a stable + * launch-time fact, not something that needs a push channel. */ +export type RewindCaptureDiagnostics = { + available: boolean + reason: string | null + /** Linux-only: the dominant real-world cause is a missing/misconfigured + * Wayland desktop-portal ScreenCast backend for the running compositor. */ + likelyMissingLinuxPortal: boolean +} + /** Runtime capture directive pushed main→renderer, derived from OS power/lock state. * `paused` tears down the capture stream (sleep/lock); `intervalMs` is the effective * cadence (base × battery multiplier). Separate from the persisted RewindSettings. */