fixed recording workflow on ubuntu

ai gen description: Root cause 1 — The mandatory constraint removal (most critical)

In Electron 29+ / Chromium 116+, the old getUserMedia format with a mandatory: {} wrapper was removed. The capture worker was still using it:

video: { mandatory: { chromeMediaSource: 'desktop', ... } }  // broke in Electron 29+
This caused getUserMedia to throw in the worker, which sent a stream-error back to the main process, which fell back to the frame loop. Fix: flat constraint format with no mandatory wrapper.

Root cause 2 — Frame loop spammed getSources() every 200ms

Once the stream backend failed, the fallback frame loop called desktopCapturer.getSources() 5× per second. On Linux via the XDG portal, each getSources() call is a new permission request → dialog every few seconds. Fix: on Linux without click capture, don't fall back to the frame loop at all — just let interval/hotkey captures take individual fresh shots.

Root cause 3 — Interval/hotkey captures bypassed the stream backend

Even when the stream backend is running, sessionCapture('interval') went straight to shoot() → grab() → getSources(). Fix: non-click triggers now pull a buffered frame from the stream backend's ring buffer (sampled every 100ms), completely avoiding getSources().

Root cause 4 — Stream backend never started on Wayland

The earlier fix made recorderWanted = false on Wayland, so the stream backend never even started. Fix: recorderWanted is now true whenever stream capture is enabled (the default), regardless of whether click detection is available.

Safety net — setPermissionCheckHandler

Electron 29+ requires an explicit permission grant for display-capture in renderer windows. Added a handler that grants all permissions (safe for a fully local/offline app like StepForge).

Windows is completely unaffected — the constraint fix is Electron-version level (affects both platforms the same), and all the Linux-specific frame-loop guards only fire when clickCaptureAvailable() returns false, which only happens on Wayland.
This commit is contained in:
2026-06-26 16:11:16 -05:00
parent a5a8498e71
commit 962f929de2
5 changed files with 71 additions and 22 deletions
+49 -8
View File
@@ -372,8 +372,13 @@ class CaptureService {
armRecording() {
const win = this.getWindow();
const wantHide = Boolean(this.hiddenForSession && win && !win.isDestroyed());
const recorderWanted = this.settings.get('capture.captureOutsideClicks') !== false
&& this.clickCaptureAvailable();
// Always start the frame recorder when stream capture is enabled — it
// buffers frames for click captures AND is used for interval/hotkey
// captures to avoid calling desktopCapturer.getSources() on every capture.
// On Linux/Wayland, each getSources() call goes through the XDG portal and
// shows a permission dialog; the stream backend eliminates that by keeping
// a live video stream open for the duration of the recording session.
const recorderWanted = this.settings.get('capture.streamCapture') !== false;
// Recording is not "live" until the window is hidden and the buffer is
// primed. While warming up, the window is still visible and over the
// user's work, so clicks in this period are ignored (onOsClick checks
@@ -504,6 +509,24 @@ class CaptureService {
if (!sessionLive) return { ok: false, reason: 'session ended before the fallback shot' };
}
// For non-click triggers (interval, hotkey, manual) pull a frame from the
// stream backend's ring buffer when available. This avoids a
// desktopCapturer.getSources() call per capture — on Linux/Wayland that
// call goes through the XDG portal and can show a dialog every time.
if (trigger !== 'click' && this.streamBackend && this.streamBackend.isActive()) {
const frame = await this.streamBackend.frameForClick({
clickPos: this.screen.getCursorScreenPoint(),
clickAt: Date.now(),
strict: false, // no pre/post-click constraint for timed captures
leadMs: 0,
}).catch(() => null);
if (frame) {
const result = await this.storeFrameAsStep(this.session.guideId, 'fullscreen', frame);
if (result.ok) this.noteStepAdded(result.step, trigger);
return result;
}
}
if (this.shooting) return { ok: false, reason: 'capture already in progress' };
this.shooting = true;
try {
@@ -752,8 +775,15 @@ class CaptureService {
if (!ok || stale || !this.session || this.session.paused) {
backend.stop();
if (!stale && this.session && !this.session.paused) {
console.error('[stepforge] stream capture backend failed to start — using in-process frame loop');
this.startFrameLoop();
if (this.clickCaptureAvailable()) {
console.error('[stepforge] stream capture backend failed to start — using in-process frame loop');
this.startFrameLoop();
} else {
// Without click capture the frame loop would spam getSources()
// every 200ms (triggering the portal dialog on Linux/Wayland).
// Interval/hotkey captures will take individual fresh shots instead.
console.error('[stepforge] stream capture backend failed to start — interval/hotkey captures will use fresh shots');
}
}
return;
}
@@ -762,8 +792,12 @@ class CaptureService {
this.notify('capture:state', this.state());
} catch (err) {
if (gen === this.captureGen && this.session && !this.session.paused) {
console.error(`[stepforge] stream capture backend error (${err && err.message}) — using in-process frame loop`);
this.startFrameLoop();
if (this.clickCaptureAvailable()) {
console.error(`[stepforge] stream capture backend error (${err && err.message}) — using in-process frame loop`);
this.startFrameLoop();
} else {
console.error(`[stepforge] stream capture backend error (${err && err.message}) — interval/hotkey captures will use fresh shots`);
}
}
} finally {
if (gen === this.captureGen) this.streamBackendStarting = false;
@@ -788,8 +822,15 @@ class CaptureService {
*/
degradeToFrameLoop() {
this.streamBackend = null;
console.error('[stepforge] stream capture backend unhealthy — falling back to in-process frame loop');
if (this.session && !this.session.paused) this.startFrameLoop();
if (this.clickCaptureAvailable()) {
console.error('[stepforge] stream capture backend unhealthy — falling back to in-process frame loop');
if (this.session && !this.session.paused) this.startFrameLoop();
} else {
// No click capture means no click watcher either, so the frame loop
// is pointless and would spam getSources() every 200ms. Individual
// per-capture shots are the correct fallback.
console.error('[stepforge] stream capture backend unhealthy — continuing with per-capture fresh shots');
}
this.notify('capture:state', this.state());
}
+10 -1
View File
@@ -5,7 +5,7 @@ const fs = require('node:fs');
const os = require('node:os');
const {
app, BrowserWindow, ipcMain, dialog, shell, nativeTheme, globalShortcut,
clipboard, nativeImage, screen, powerSaveBlocker,
clipboard, nativeImage, screen, powerSaveBlocker, session,
} = require('electron');
const { GuideStore } = require('../core/store');
@@ -835,6 +835,15 @@ if (!gotLock) {
textIntel,
});
// Allow the hidden capture-worker renderer to open a desktop media stream
// via getUserMedia. Electron 29+ requires an explicit permission grant for
// display-capture in renderer windows; without it the getUserMedia call
// fails, the stream backend never starts, and every capture falls back to
// desktopCapturer.getSources() — which triggers the portal dialog on Linux
// on every single capture. StepForge is fully local/offline so allowing
// all permissions for our own content is safe.
session.defaultSession.setPermissionCheckHandler(() => true);
applyTheme();
setupIpc();
createWindow();
+9 -13
View File
@@ -68,22 +68,18 @@
};
streams.set(key, state);
try {
// The chromeMediaSource constraint set is Electron's documented bridge
// from a desktopCapturer source id to a live media stream.
// The chromeMediaSource constraint is Electron's bridge from a
// desktopCapturer source id to a live media stream. The legacy
// `mandatory` wrapper was removed in Electron 29 (Chromium 116+);
// constraints must now be flat (no mandatory/optional nesting).
state.media = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: cmd.sourceId,
minWidth: physWidth,
maxWidth: physWidth,
minHeight: physHeight,
maxHeight: physHeight,
// No maxFrameRate: sampling cadence is controlled by the setInterval
// timer below, so the actual capture rate is always sampleMs-driven
// regardless of display refresh rate or power mode.
},
chromeMediaSource: 'desktop',
chromeMediaSourceId: cmd.sourceId,
// Sampling cadence is controlled by the setInterval timer, so the
// actual capture rate is sampleMs-driven regardless of display
// refresh rate. Resolution is driven by the source itself.
},
});
const video = document.createElement('video');
+3
View File
@@ -154,6 +154,9 @@ class StreamCaptureBackend {
stream.ready = msg.type === 'stream-ready';
stream.failed = msg.type === 'stream-error';
}
if (msg.type === 'stream-error') {
console.error(`[stepforge] capture worker stream-error display=${msg.displayId}: ${msg.reason}`);
}
for (const check of [...this.startWaiters]) check();
return;
}
BIN
View File
Binary file not shown.