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 desktop/windows/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions desktop/windows/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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."
]
}
89 changes: 89 additions & 0 deletions desktop/windows/docs/linux-screen-recording.md
Original file line number Diff line number Diff line change
@@ -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/<compositor>-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.<backend>` line appears for your
compositor's expected backend, the backend isn't installed, running, or
D-Bus-activatable — start from step 1 above.
20 changes: 13 additions & 7 deletions desktop/windows/electron-builder.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions desktop/windows/src/main/ipc/rewind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ipcMain, BrowserWindow } from 'electron'
import {
getPrimarySourceId,
getRewindCaptureSourceId,
getRewindCaptureDiagnostics,
isCurrentRewindCaptureSource
} from '../rewind/sourceId'
import {
Expand Down Expand Up @@ -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) => {
Expand Down
64 changes: 63 additions & 1 deletion desktop/windows/src/main/rewind/sourceId.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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?.()

Expand Down
61 changes: 56 additions & 5 deletions desktop/windows/src/main/rewind/sourceId.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,36 @@ type SourceIdentity = { id: string; displayId: string }
let cached: SourceIdentity[] | null = null
let inflight: Promise<SourceIdentity[]> | 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<SourceIdentity[]> {
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<SourceIdentity[]> {
Expand Down Expand Up @@ -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<RewindCaptureDiagnostics> {
await getPrimarySourceId()
const reason = getSourceFetchError()
return {
available: !reason,
reason,
likelyMissingLinuxPortal: !!reason && process.platform === 'linux'
}
}
1 change: 1 addition & 0 deletions desktop/windows/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
3 changes: 3 additions & 0 deletions desktop/windows/src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -122,6 +123,8 @@ function AppShellInner(): React.JSX.Element {
<DbRecoveryNotice />
{/* Only renders during a backend 429 storm; self-clears on recovery. */}
<DegradedModeNotice />
{/* Only renders when Rewind is enabled but can't get a screen source. */}
<RewindCaptureNotice />
<AppChrome>
<MainViews />
</AppChrome>
Expand Down
Loading