diff --git a/app/renderer/capture-worker.js b/app/renderer/capture-worker.js index 6fc34c4..f65ecbf 100644 --- a/app/renderer/capture-worker.js +++ b/app/renderer/capture-worker.js @@ -47,6 +47,11 @@ async function startStream(cmd) { const key = String(cmd.displayId); stopStream(key); + const display = cmd.display || {}; + const scale = display.scaleFactor || 1; + const bounds = display.bounds || { width: 1280, height: 720 }; + const physWidth = Math.round(bounds.width * scale); + const physHeight = Math.round(bounds.height * scale); const state = { displayId: cmd.displayId, media: null, @@ -74,19 +79,25 @@ video: true, }); } else { - // 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). This - // path works on Windows and X11. + // Keep the legacy desktop-capture constraint wrapper here: it binds + // the stream to the exact desktop source chosen in the main process. + // Without it Chromium can treat the request like a normal media + // request and pick the default camera device instead. state.media = await navigator.mediaDevices.getUserMedia({ audio: false, video: { - 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. + 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. + }, }, }); } diff --git a/tests/unit/capture-worker.test.js b/tests/unit/capture-worker.test.js new file mode 100644 index 0000000..ebae4e7 --- /dev/null +++ b/tests/unit/capture-worker.test.js @@ -0,0 +1,145 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +test('capture worker requests the selected desktop source, not a plain camera stream', async () => { + const scriptPath = path.join(__dirname, '../../app/renderer/capture-worker.js'); + const script = fs.readFileSync(scriptPath, 'utf8'); + + const mediaCalls = []; + const messages = []; + let onCommand = null; + let resolveStreamReady; + const streamReady = new Promise((resolve) => { + resolveStreamReady = resolve; + }); + + const context = { + console: { + error() {}, + log() {}, + warn() {}, + }, + captureWorkerBridge: { + onCommand(fn) { + onCommand = fn; + }, + send(msg) { + messages.push(msg); + if (msg.type === 'stream-ready') resolveStreamReady(); + }, + }, + StepForgeClickFrames: { + FrameRing: class { + constructor() { + this._frames = []; + } + + push(frame) { + this._frames.push(frame); + } + + frames() { + return [...this._frames]; + } + + latest() { + return this._frames[this._frames.length - 1] || null; + } + + clear() { + this._frames = []; + } + }, + selectFrameForClick() { + return null; + }, + }, + navigator: { + mediaDevices: { + async getUserMedia(constraints) { + mediaCalls.push(constraints); + return { + getTracks() { + return [{ stop() {} }]; + }, + }; + }, + async getDisplayMedia() { + throw new Error('unexpected getDisplayMedia call'); + }, + }, + }, + document: { + createElement(tag) { + assert.equal(tag, 'video'); + return { + muted: false, + srcObject: null, + readyState: 2, + play: async () => {}, + videoWidth: 1920, + videoHeight: 1080, + }; + }, + }, + createImageBitmap: async () => ({ width: 1920, height: 1080, close() {} }), + setInterval: () => 1, + clearInterval: () => {}, + OffscreenCanvas: class { + constructor(width, height) { + this.width = width; + this.height = height; + } + + getContext() { + return { drawImage() {} }; + } + + async convertToBlob() { + return { + async arrayBuffer() { + return new ArrayBuffer(0); + }, + }; + } + }, + }; + + vm.createContext(context); + vm.runInContext(script, context, { filename: scriptPath }); + + assert.equal(typeof onCommand, 'function', 'worker should register a command handler'); + + onCommand({ + type: 'start-stream', + displayId: 7, + sourceId: 'screen:1:0', + display: { + bounds: { width: 1920, height: 1080 }, + scaleFactor: 1, + }, + sampleMs: 50, + }); + await streamReady; + + assert.equal(mediaCalls.length, 1); + assert.deepEqual(mediaCalls[0], { + audio: false, + video: { + mandatory: { + chromeMediaSource: 'desktop', + chromeMediaSourceId: 'screen:1:0', + minWidth: 1920, + maxWidth: 1920, + minHeight: 1080, + maxHeight: 1080, + }, + }, + }); + assert.ok(messages.some((msg) => msg.type === 'stream-ready')); +});