diff --git a/app/capture.js b/app/capture.js index e6080c3..100884c 100644 --- a/app/capture.js +++ b/app/capture.js @@ -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()); } diff --git a/app/main.js b/app/main.js index 2e52984..6b02d6c 100644 --- a/app/main.js +++ b/app/main.js @@ -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(); diff --git a/app/renderer/capture-worker.js b/app/renderer/capture-worker.js index 0f87084..8726474 100644 --- a/app/renderer/capture-worker.js +++ b/app/renderer/capture-worker.js @@ -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'); diff --git a/app/stream-backend.js b/app/stream-backend.js index dcf7369..32b91d2 100644 --- a/app/stream-backend.js +++ b/app/stream-backend.js @@ -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; } diff --git a/eng.traineddata b/eng.traineddata deleted file mode 100644 index 6d11002..0000000 Binary files a/eng.traineddata and /dev/null differ