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:
+49
-8
@@ -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
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user