mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
499 lines
15 KiB
JavaScript
499 lines
15 KiB
JavaScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import assert from 'node:assert/strict';
|
|
import {EventEmitter} from 'node:events';
|
|
import {readFileSync} from 'node:fs';
|
|
import {createRequire} from 'node:module';
|
|
import {describe, test} from 'node:test';
|
|
import {fileURLToPath} from 'node:url';
|
|
import vm from 'node:vm';
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const esbuild = require('esbuild');
|
|
|
|
const sourcePath = fileURLToPath(new URL('./NativeScreenCapture.ts', import.meta.url));
|
|
const source = readFileSync(sourcePath, 'utf8');
|
|
const transformedSource = esbuild.transformSync(source, {
|
|
loader: 'ts',
|
|
format: 'cjs',
|
|
platform: 'node',
|
|
target: 'node20',
|
|
}).code;
|
|
|
|
function plain(value) {
|
|
return structuredClone(value);
|
|
}
|
|
|
|
function makeSender() {
|
|
const sent = [];
|
|
const sender = new EventEmitter();
|
|
sender.id = 100;
|
|
sender.isDestroyed = () => false;
|
|
sender.send = (...args) => sent.push(args);
|
|
return {sender, sent};
|
|
}
|
|
|
|
function makeNativeAddon({
|
|
sources = [],
|
|
startResult = {width: 1280, height: 720, frameRate: 30, pixelFormat: 'nv12'},
|
|
macAvailability = {sck: {supported: true, macosVersion: '14.6'}, screenPermission: 'authorized'},
|
|
linuxAvailability = {
|
|
available: true,
|
|
backend: 'linux-pipewire-portal',
|
|
detail: 'portal:5',
|
|
capabilities: {process: true, system: true},
|
|
},
|
|
} = {}) {
|
|
const captures = [];
|
|
class FakeCapture extends EventEmitter {
|
|
constructor(options) {
|
|
super();
|
|
this.options = options;
|
|
this.stopCount = 0;
|
|
captures.push(this);
|
|
}
|
|
|
|
async start() {
|
|
return startResult;
|
|
}
|
|
|
|
async stop() {
|
|
this.stopCount += 1;
|
|
this.emit('closed');
|
|
}
|
|
|
|
getDiagnostics() {
|
|
return {
|
|
addonDiagnostic: 'fake',
|
|
width: startResult.width,
|
|
height: startResult.height,
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
addon: {
|
|
listSources: async () => sources,
|
|
getBackendAvailability: async () => macAvailability,
|
|
getAvailability: async () => linuxAvailability,
|
|
getBackendInfo: () => ({
|
|
backend: 'fake-screen-capture',
|
|
supported: true,
|
|
}),
|
|
ScreenCapture: FakeCapture,
|
|
},
|
|
captures,
|
|
};
|
|
}
|
|
|
|
function loadNativeScreenCapture({platform = 'linux', addon, tccStatus = 'not-determined'} = {}) {
|
|
const handlers = new Map();
|
|
const calls = {
|
|
logs: {debug: [], warn: []},
|
|
nativeModuleImports: [],
|
|
};
|
|
let uuidCounter = 0;
|
|
|
|
function requireStub(specifier) {
|
|
if (specifier === 'node:child_process') {
|
|
return {
|
|
execFile: (_file, _args, _options, callback) => {
|
|
callback(null, ' HwSchMode REG_DWORD 0x2\n', '');
|
|
},
|
|
};
|
|
}
|
|
if (specifier === 'node:crypto') {
|
|
return {randomUUID: () => `capture-${++uuidCounter}`};
|
|
}
|
|
if (specifier === 'node:module') {
|
|
return {
|
|
createRequire: () => (moduleSpecifier) => {
|
|
calls.nativeModuleImports.push(moduleSpecifier);
|
|
if (
|
|
moduleSpecifier === '@fluxer/linux-screen-capture' ||
|
|
moduleSpecifier === '@fluxer/mac-screen-capture' ||
|
|
moduleSpecifier === '@fluxer/win-game-capture'
|
|
) {
|
|
if (!addon) throw new Error(`No fake addon configured for ${moduleSpecifier}`);
|
|
return addon;
|
|
}
|
|
throw new Error(`Unexpected createRequire import: ${moduleSpecifier}`);
|
|
},
|
|
};
|
|
}
|
|
if (specifier === '@electron/common/Logger') {
|
|
return {
|
|
createChildLogger: () => ({
|
|
debug: (...args) => calls.logs.debug.push(args),
|
|
warn: (...args) => calls.logs.warn.push(args),
|
|
}),
|
|
};
|
|
}
|
|
if (specifier === 'electron') {
|
|
return {
|
|
ipcMain: {
|
|
handle(channel, handler) {
|
|
handlers.set(channel, handler);
|
|
},
|
|
removeHandler(channel) {
|
|
handlers.delete(channel);
|
|
},
|
|
},
|
|
};
|
|
}
|
|
if (specifier === '@electron/main/MacTcc') {
|
|
return {getTccStatus: () => tccStatus};
|
|
}
|
|
if (specifier === '@electron/main/NativeScreenCaptureValidation') {
|
|
return {
|
|
isValidStartOptions: () => true,
|
|
normalizeScreenCaptureDimension: (value) => value,
|
|
};
|
|
}
|
|
if (specifier === '@electron/main/PrivilegedRendererDocuments') {
|
|
return {requirePrivilegedRendererDocumentSender: () => {}};
|
|
}
|
|
throw new Error(`Unexpected import: ${specifier}`);
|
|
}
|
|
|
|
const module = {exports: {}};
|
|
const context = vm.createContext({
|
|
Buffer,
|
|
ArrayBuffer,
|
|
Error,
|
|
console,
|
|
clearTimeout,
|
|
exports: module.exports,
|
|
module,
|
|
process: {env: {}, platform},
|
|
require: requireStub,
|
|
setTimeout,
|
|
});
|
|
vm.runInContext(transformedSource, context, {filename: sourcePath});
|
|
|
|
return {calls, handlers, module: module.exports};
|
|
}
|
|
|
|
describe('NativeScreenCapture source identity and capability reporting', () => {
|
|
test('normalizes display and window sources without changing source ids', async () => {
|
|
const {addon} = makeNativeAddon({
|
|
sources: [
|
|
{kind: 'screen', id: 'display:69733632', name: 'Studio Display', width: 5120.9, height: 2880.2},
|
|
{
|
|
kind: 'window',
|
|
id: 'window:4242',
|
|
name: '',
|
|
width: 1439.8,
|
|
height: 899.9,
|
|
appName: 'Fluxer',
|
|
bundleId: 'app.fluxer.desktop',
|
|
targetPid: 1234.9,
|
|
},
|
|
{kind: 'window', id: 'window:no-pid', name: 'No PID', width: 800, height: 600, targetPid: -1},
|
|
{kind: 'screen', id: '', name: 'invalid', width: 1, height: 1},
|
|
{kind: 'browser-tab', id: 'tab:1', name: 'invalid', width: 1, height: 1},
|
|
],
|
|
});
|
|
const harness = loadNativeScreenCapture({platform: 'linux', addon});
|
|
harness.module.registerNativeScreenCaptureHandlers();
|
|
|
|
const sources = await harness.handlers.get('native-screen-capture:list-sources')();
|
|
|
|
assert.deepEqual(plain(sources), [
|
|
{
|
|
kind: 'screen',
|
|
id: 'display:69733632',
|
|
name: 'Studio Display',
|
|
width: 5120,
|
|
height: 2880,
|
|
appName: undefined,
|
|
bundleId: undefined,
|
|
targetPid: undefined,
|
|
},
|
|
{
|
|
kind: 'window',
|
|
id: 'window:4242',
|
|
name: 'window window:4242',
|
|
width: 1439,
|
|
height: 899,
|
|
appName: 'Fluxer',
|
|
bundleId: 'app.fluxer.desktop',
|
|
targetPid: 1234,
|
|
},
|
|
{
|
|
kind: 'window',
|
|
id: 'window:no-pid',
|
|
name: 'No PID',
|
|
width: 800,
|
|
height: 600,
|
|
appName: undefined,
|
|
bundleId: undefined,
|
|
targetPid: undefined,
|
|
},
|
|
]);
|
|
});
|
|
|
|
test('starts display and window captures with exact source id and kind and reports diagnostics', async () => {
|
|
const {addon, captures} = makeNativeAddon();
|
|
const harness = loadNativeScreenCapture({
|
|
platform: 'linux',
|
|
addon,
|
|
});
|
|
const {sender, sent} = makeSender();
|
|
harness.module.registerNativeScreenCaptureHandlers();
|
|
|
|
const displayResult = await harness.handlers.get('native-screen-capture:start')(
|
|
{sender},
|
|
{
|
|
sourceId: 'display:69733632',
|
|
sourceKind: 'screen',
|
|
width: 2560,
|
|
height: 1440,
|
|
frameRate: 60,
|
|
colorRange: 'full',
|
|
colorSpace: 'rec709',
|
|
showCursorClicks: true,
|
|
captureRect: {x: 10, y: 20, width: 300, height: 200},
|
|
nativeFrameSinkRequired: true,
|
|
},
|
|
);
|
|
const windowResult = await harness.handlers.get('native-screen-capture:start')(
|
|
{sender},
|
|
{
|
|
sourceId: 'window:4242',
|
|
sourceKind: 'window',
|
|
width: 1280,
|
|
height: 720,
|
|
frameRate: 30,
|
|
nativeFrameSinkRequired: true,
|
|
},
|
|
);
|
|
|
|
assert.deepEqual(plain(captures.map((capture) => capture.options)), [
|
|
{
|
|
sourceId: 'display:69733632',
|
|
sourceKind: 'screen',
|
|
width: 2560,
|
|
height: 1440,
|
|
frameRate: 60,
|
|
captureId: 'capture-1',
|
|
colorRange: 'full',
|
|
colorSpace: 'rec709',
|
|
showCursorClicks: true,
|
|
captureRect: {x: 10, y: 20, width: 300, height: 200},
|
|
nativeFrameSinkRequired: true,
|
|
},
|
|
{
|
|
sourceId: 'window:4242',
|
|
sourceKind: 'window',
|
|
width: 1280,
|
|
height: 720,
|
|
frameRate: 30,
|
|
captureId: 'capture-2',
|
|
colorRange: undefined,
|
|
colorSpace: undefined,
|
|
showCursorClicks: false,
|
|
captureRect: undefined,
|
|
nativeFrameSinkRequired: true,
|
|
},
|
|
]);
|
|
assert.equal(displayResult.captureId, 'capture-1');
|
|
assert.equal(windowResult.captureId, 'capture-2');
|
|
assert.deepEqual(sent, []);
|
|
|
|
const displayDiagnostics = await harness.handlers.get('native-screen-capture:get-diagnostics')(
|
|
{sender},
|
|
'capture-1',
|
|
);
|
|
const windowDiagnostics = await harness.handlers.get('native-screen-capture:get-diagnostics')(
|
|
{sender},
|
|
'capture-2',
|
|
);
|
|
|
|
assert.equal(displayDiagnostics.sourceId, 'display:69733632');
|
|
assert.equal(displayDiagnostics.sourceKind, 'screen');
|
|
assert.equal(displayDiagnostics.width, 1280);
|
|
assert.equal(displayDiagnostics.height, 720);
|
|
assert.equal(windowDiagnostics.sourceId, 'window:4242');
|
|
assert.equal(windowDiagnostics.sourceKind, 'window');
|
|
assert.equal(windowDiagnostics.width, 1280);
|
|
assert.equal(windowDiagnostics.height, 720);
|
|
|
|
await harness.handlers.get('native-screen-capture:stop')({sender}, 'capture-1');
|
|
await harness.handlers.get('native-screen-capture:stop')({sender}, 'capture-2');
|
|
assert.equal(captures[0].stopCount, 1);
|
|
assert.equal(captures[1].stopCount, 1);
|
|
});
|
|
|
|
test('passes caller-provided capture id to the platform wrapper', async () => {
|
|
const {addon, captures} = makeNativeAddon();
|
|
const harness = loadNativeScreenCapture({
|
|
platform: 'linux',
|
|
addon,
|
|
});
|
|
const {sender, sent} = makeSender();
|
|
harness.module.registerNativeScreenCaptureHandlers();
|
|
|
|
const result = await harness.handlers.get('native-screen-capture:start')(
|
|
{sender},
|
|
{
|
|
sourceId: 'window:4242',
|
|
sourceKind: 'window',
|
|
width: 1280,
|
|
height: 720,
|
|
captureId: 'preselected-capture-id',
|
|
nativeFrameSinkRequired: true,
|
|
},
|
|
);
|
|
|
|
assert.equal(result.captureId, 'preselected-capture-id');
|
|
assert.deepEqual(plain(captures[0].options), {
|
|
sourceId: 'window:4242',
|
|
sourceKind: 'window',
|
|
width: 1280,
|
|
height: 720,
|
|
frameRate: 30,
|
|
captureId: 'preselected-capture-id',
|
|
colorRange: undefined,
|
|
colorSpace: undefined,
|
|
showCursorClicks: false,
|
|
captureRect: undefined,
|
|
nativeFrameSinkRequired: true,
|
|
});
|
|
assert.deepEqual(sent, []);
|
|
});
|
|
|
|
test('reports macOS and Linux display/window capture capabilities from platform backends', async () => {
|
|
const macHarness = loadNativeScreenCapture({
|
|
platform: 'darwin',
|
|
addon: makeNativeAddon({
|
|
macAvailability: {sck: {supported: true, macosVersion: '15.0'}, screenPermission: 'authorized'},
|
|
}).addon,
|
|
tccStatus: 'authorized',
|
|
});
|
|
macHarness.module.registerNativeScreenCaptureHandlers();
|
|
const macAvailability = await macHarness.handlers.get('native-screen-capture:get-availability')();
|
|
assert.deepEqual(plain(macAvailability), {
|
|
available: true,
|
|
backend: 'macos-sck',
|
|
detail: '15.0',
|
|
capabilities: {hidesCursor: true, screens: true, windows: true},
|
|
});
|
|
|
|
const linuxHarness = loadNativeScreenCapture({
|
|
platform: 'linux',
|
|
addon: makeNativeAddon({
|
|
linuxAvailability: {
|
|
available: true,
|
|
backend: 'linux-pipewire-portal',
|
|
detail: 'portal system capture disabled',
|
|
capabilities: {process: true, system: false},
|
|
},
|
|
}).addon,
|
|
});
|
|
linuxHarness.module.registerNativeScreenCaptureHandlers();
|
|
const linuxAvailability = await linuxHarness.handlers.get('native-screen-capture:get-availability')();
|
|
assert.deepEqual(plain(linuxAvailability), {
|
|
available: true,
|
|
backend: 'linux-pipewire',
|
|
detail: 'portal system capture disabled',
|
|
capabilities: {hidesCursor: true, screens: false, windows: false},
|
|
});
|
|
});
|
|
|
|
test('reports a Windows native game capture load failure when requiring the addon throws', async () => {
|
|
const harness = loadNativeScreenCapture({platform: 'win32'});
|
|
harness.module.registerNativeScreenCaptureHandlers();
|
|
|
|
const availability = await harness.handlers.get('native-screen-capture:get-availability')();
|
|
assert.deepEqual(plain(availability), {
|
|
available: false,
|
|
backend: 'windows-game-capture',
|
|
reason: 'load-failed',
|
|
detail: 'No fake addon configured for @fluxer/win-game-capture',
|
|
windowsHagsState: 'enabled',
|
|
windowsHagsDetail: 'HwSchMode=2',
|
|
});
|
|
assert.deepEqual(harness.calls.nativeModuleImports, ['@fluxer/win-game-capture']);
|
|
assert.equal(harness.calls.logs.warn[0][0], 'Failed to load Windows native screen capture addon');
|
|
});
|
|
|
|
test('reports a Windows native game capture load failure surfaced by the addon loadError', async () => {
|
|
const {addon} = makeNativeAddon();
|
|
const loadError = new Error(
|
|
'@fluxer/win-game-capture native module failed to load.\nmodule=@fluxer/win-game-capture\nreason=native binary not found',
|
|
);
|
|
loadError.name = 'NativeModuleLoadError';
|
|
loadError.nativeDiagnostics = {
|
|
schemaVersion: 1,
|
|
moduleName: '@fluxer/win-game-capture',
|
|
reason: 'native binary not found',
|
|
};
|
|
addon.loadError = loadError;
|
|
const harness = loadNativeScreenCapture({platform: 'win32', addon});
|
|
harness.module.registerNativeScreenCaptureHandlers();
|
|
|
|
const availability = await harness.handlers.get('native-screen-capture:get-availability')();
|
|
|
|
assert.deepEqual(plain(availability), {
|
|
available: false,
|
|
backend: 'windows-game-capture',
|
|
reason: 'load-failed',
|
|
detail:
|
|
'@fluxer/win-game-capture native module failed to load.\nmodule=@fluxer/win-game-capture\nreason=native binary not found',
|
|
windowsHagsState: 'enabled',
|
|
windowsHagsDetail: 'HwSchMode=2',
|
|
});
|
|
assert.deepEqual(harness.calls.nativeModuleImports, ['@fluxer/win-game-capture']);
|
|
assert.equal(harness.calls.logs.warn[0][0], 'Windows native screen capture addon reported load error');
|
|
assert.equal(harness.calls.logs.warn[0][1], loadError);
|
|
});
|
|
|
|
test('loads Windows native game capture and reports its backend capabilities', async () => {
|
|
const {addon} = makeNativeAddon();
|
|
addon.getAvailability = () => ({available: true, backend: 'windows-game-capture'});
|
|
const harness = loadNativeScreenCapture({platform: 'win32', addon});
|
|
harness.module.registerNativeScreenCaptureHandlers();
|
|
|
|
const availability = await harness.handlers.get('native-screen-capture:get-availability')();
|
|
|
|
assert.deepEqual(plain(availability), {
|
|
available: true,
|
|
backend: 'windows-game-capture',
|
|
windowsHagsState: 'enabled',
|
|
windowsHagsDetail: 'HwSchMode=2',
|
|
capabilities: {hidesCursor: false, screens: true, windows: true},
|
|
});
|
|
assert.deepEqual(harness.calls.nativeModuleImports, ['@fluxer/win-game-capture']);
|
|
});
|
|
|
|
test('routes Windows display sources through the native screen path without remapping to game', async () => {
|
|
const {addon, captures} = makeNativeAddon();
|
|
addon.getAvailability = () => ({available: true, backend: 'windows-game-capture'});
|
|
const harness = loadNativeScreenCapture({
|
|
platform: 'win32',
|
|
addon,
|
|
});
|
|
harness.module.registerNativeScreenCaptureHandlers();
|
|
const {sender} = makeSender();
|
|
|
|
const result = await harness.handlers.get('native-screen-capture:start')(
|
|
{sender},
|
|
{
|
|
sourceId: 'screen:0:0',
|
|
sourceKind: 'screen',
|
|
width: 2560,
|
|
height: 1440,
|
|
frameRate: 60,
|
|
nativeFrameSinkRequired: true,
|
|
},
|
|
);
|
|
|
|
assert.equal(captures.length, 1);
|
|
assert.equal(captures[0].options.sourceKind, 'screen');
|
|
|
|
const diagnostics = await harness.handlers.get('native-screen-capture:get-diagnostics')({sender}, result.captureId);
|
|
assert.equal(diagnostics.sourceKind, 'screen');
|
|
});
|
|
});
|