Add native self-hosted instance connection to fluxer_desktop
Trimmed monorepo checkout (fluxer_desktop + packages/voice_engine_v2 + tools/ci) with a "Connect to a Different Server" menu item and popout that lets the desktop app switch to any self-hosted Fluxer instance, plus fixes for well-known discovery on single-domain self-hosted deployments and a false-positive ERR_ABORTED on same-origin client redirects during the switch. Defaults to chat.fluxr.chat and uses an isolated userData directory from the official build.
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import {mkdirSync, mkdtempSync, readFileSync, writeFileSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
import {afterEach, describe, it} from 'node:test';
|
||||
import linuxScreenCapture from './index.js';
|
||||
|
||||
const {getGameCaptureLaunchEnvironment} = linuxScreenCapture;
|
||||
|
||||
function makeFakeBinding({
|
||||
sources = [],
|
||||
availability = {
|
||||
available: true,
|
||||
backend: 'linux-pipewire-portal',
|
||||
detail: 'portal:5',
|
||||
capabilities: {process: true, system: true},
|
||||
},
|
||||
} = {}) {
|
||||
const calls = [];
|
||||
const frameSinkHandleCalls = [];
|
||||
const natives = [];
|
||||
class FakeNative {
|
||||
constructor() {
|
||||
this.lifecycleCallback = undefined;
|
||||
this.stopCount = 0;
|
||||
natives.push(this);
|
||||
}
|
||||
|
||||
setLifecycleCallback(callback) {
|
||||
this.lifecycleCallback = callback;
|
||||
}
|
||||
|
||||
setFrameSinkHandle(handle) {
|
||||
frameSinkHandleCalls.push(handle);
|
||||
}
|
||||
|
||||
async start(sourceId, sourceKind, width, height, frameRate, captureId, captureOptions) {
|
||||
calls.push({sourceId, sourceKind, width, height, frameRate, captureId, captureOptions});
|
||||
return {width: width || 1920, height: height || 1080, frameRate: frameRate || 30, pixelFormat: 'nv12'};
|
||||
}
|
||||
|
||||
async stop() {
|
||||
this.stopCount += 1;
|
||||
}
|
||||
|
||||
getDiagnostics() {
|
||||
return {
|
||||
portalSessionId: 'portal-session-1',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
binding: {
|
||||
ScreenCapture: FakeNative,
|
||||
listSources: async () => sources,
|
||||
getAvailability: async () => availability,
|
||||
getBackendInfo: () => ({
|
||||
backend: 'linux-pipewire-portal',
|
||||
supported: true,
|
||||
portalVersion: 5,
|
||||
pipewireReachable: true,
|
||||
}),
|
||||
},
|
||||
calls,
|
||||
frameSinkHandleCalls,
|
||||
natives,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
linuxScreenCapture.__setBindingForTests(null);
|
||||
});
|
||||
|
||||
describe('linux-screen-capture game capture launch environment', () => {
|
||||
it('enables OBS Vulkan capture and names the client', () => {
|
||||
const result = getGameCaptureLaunchEnvironment({
|
||||
env: {},
|
||||
name: 'fluxer-test',
|
||||
mode: 'vulkan',
|
||||
});
|
||||
assert.equal(result.env.OBS_VKCAPTURE, '1');
|
||||
assert.equal(result.env.OBS_VKCAPTURE_NAME, 'fluxer-test');
|
||||
assert.equal(result.env.LD_PRELOAD, undefined);
|
||||
assert.equal(result.diagnostics.mode, 'vulkan');
|
||||
assert.match(result.diagnostics.licenseBoundary, /GPL-covered runtime tools/);
|
||||
});
|
||||
|
||||
it('prefers bundled hook assets and can force PRIME/NVIDIA launch variables', () => {
|
||||
const nativeRoot = mkdtempSync(join(tmpdir(), 'fluxer-linux-screen-capture-'));
|
||||
const bundledRoot = join(nativeRoot, 'obs-vkcapture');
|
||||
const glRoot = join(bundledRoot, 'obs_glcapture');
|
||||
mkdirSync(glRoot, {recursive: true});
|
||||
writeFileSync(join(bundledRoot, 'obs_vkcapture_64.json'), '{}');
|
||||
writeFileSync(join(bundledRoot, 'libVkLayer_obs_vkcapture.so'), '');
|
||||
writeFileSync(join(glRoot, 'libobs_glcapture.so'), '');
|
||||
|
||||
const result = getGameCaptureLaunchEnvironment({
|
||||
env: {LD_PRELOAD: '/tmp/existing.so'},
|
||||
nativeRoot,
|
||||
preferDiscreteGpu: true,
|
||||
});
|
||||
|
||||
assert.equal(result.env.OBS_VKCAPTURE, '1');
|
||||
assert.equal(result.env.__NV_PRIME_RENDER_OFFLOAD, '1');
|
||||
assert.equal(result.env.__VK_LAYER_NV_optimus, 'NVIDIA_only');
|
||||
assert.equal(result.env.__GLX_VENDOR_LIBRARY_NAME, 'nvidia');
|
||||
assert.equal(result.env.DRI_PRIME, '1');
|
||||
assert.equal(result.env.VK_ADD_LAYER_PATH, bundledRoot);
|
||||
assert.equal(result.env.VK_INSTANCE_LAYERS, 'VK_LAYER_OBS_vkcapture_64');
|
||||
assert.equal(result.env.LD_PRELOAD, `/tmp/existing.so:${join(glRoot, 'libobs_glcapture.so')}`);
|
||||
assert.equal(result.diagnostics.forceNvidiaIcd, false);
|
||||
assert.equal(result.diagnostics.nvidiaIcdPath, null);
|
||||
assert.equal(result.diagnostics.bundledVulkanLayerDir, bundledRoot);
|
||||
assert.equal(result.diagnostics.bundledGlCaptureLib, join(glRoot, 'libobs_glcapture.so'));
|
||||
});
|
||||
|
||||
it('does not duplicate launch path entries or Vulkan layer names', () => {
|
||||
const nativeRoot = mkdtempSync(join(tmpdir(), 'fluxer-linux-screen-capture-'));
|
||||
const bundledRoot = join(nativeRoot, 'obs-vkcapture', 'vulkan');
|
||||
mkdirSync(bundledRoot, {recursive: true});
|
||||
writeFileSync(join(bundledRoot, 'obs_vkcapture_64.json'), '{}');
|
||||
writeFileSync(join(bundledRoot, 'libVkLayer_obs_vkcapture.so'), '');
|
||||
|
||||
const result = getGameCaptureLaunchEnvironment({
|
||||
env: {
|
||||
VK_ADD_LAYER_PATH: `/tmp/other:${bundledRoot}`,
|
||||
VK_INSTANCE_LAYERS: 'VK_LAYER_OBS_vkcapture_64:VK_LAYER_KHRONOS_validation',
|
||||
},
|
||||
nativeRoot,
|
||||
mode: 'vulkan',
|
||||
});
|
||||
|
||||
assert.equal(result.env.VK_ADD_LAYER_PATH, `/tmp/other:${bundledRoot}`);
|
||||
assert.equal(result.env.VK_INSTANCE_LAYERS, 'VK_LAYER_OBS_vkcapture_64:VK_LAYER_KHRONOS_validation');
|
||||
assert.equal(result.diagnostics.bundledVulkanLayerDir, bundledRoot);
|
||||
assert.equal(result.diagnostics.vulkanLayerName, 'VK_LAYER_OBS_vkcapture_64');
|
||||
});
|
||||
|
||||
it('can force a specific NVIDIA Vulkan ICD for hybrid GPU systems', () => {
|
||||
const result = getGameCaptureLaunchEnvironment({
|
||||
env: {},
|
||||
preferDiscreteGpu: true,
|
||||
forceNvidiaIcd: '/tmp/nvidia_icd.json',
|
||||
});
|
||||
|
||||
assert.equal(result.env.VK_ICD_FILENAMES, '/tmp/nvidia_icd.json');
|
||||
assert.equal(result.diagnostics.forceNvidiaIcd, true);
|
||||
assert.equal(result.diagnostics.nvidiaIcdPath, '/tmp/nvidia_icd.json');
|
||||
});
|
||||
|
||||
it('keeps obs-vkcapture runtime assets and license notes in the package surface', () => {
|
||||
const packageJson = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
|
||||
assert.equal(packageJson.license, 'AGPL-3.0-or-later');
|
||||
assert(packageJson.files.includes('THIRD_PARTY_OBS_VKCAPTURE.md'));
|
||||
assert(packageJson.files.includes('obs-vkcapture/**/*'));
|
||||
|
||||
const notice = readFileSync(new URL('./THIRD_PARTY_OBS_VKCAPTURE.md', import.meta.url), 'utf8');
|
||||
assert.match(notice, /separate third-party runtime component/);
|
||||
assert.match(notice, /exact upstream license text/);
|
||||
assert.match(notice, /corresponding source/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('linux-screen-capture loader wrapper', () => {
|
||||
it('forwards display and window sources from native binding without rewriting ids', async () => {
|
||||
const {binding} = makeFakeBinding({
|
||||
sources: [
|
||||
{kind: 'screen', id: 'pipewire:display:1', name: 'Display 1', width: 2560, height: 1440},
|
||||
{
|
||||
kind: 'window',
|
||||
id: 'pipewire:window:4242',
|
||||
name: 'Fluxer',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
appName: 'Fluxer',
|
||||
targetPid: 1234,
|
||||
},
|
||||
],
|
||||
});
|
||||
linuxScreenCapture.__setBindingForTests(binding);
|
||||
|
||||
assert.deepEqual(await linuxScreenCapture.listSources(), [
|
||||
{kind: 'screen', id: 'pipewire:display:1', name: 'Display 1', width: 2560, height: 1440},
|
||||
{
|
||||
kind: 'window',
|
||||
id: 'pipewire:window:4242',
|
||||
name: 'Fluxer',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
appName: 'Fluxer',
|
||||
targetPid: 1234,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('forwards source id, kind, dimensions, and diagnostics to native binding', async () => {
|
||||
const {binding, calls} = makeFakeBinding();
|
||||
linuxScreenCapture.__setBindingForTests(binding);
|
||||
const displayCapture = new linuxScreenCapture.ScreenCapture({
|
||||
sourceId: 'pipewire:display:1',
|
||||
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},
|
||||
});
|
||||
const windowCapture = new linuxScreenCapture.ScreenCapture({
|
||||
sourceId: 'pipewire:window:4242',
|
||||
sourceKind: 'window',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
frameRate: 30,
|
||||
});
|
||||
|
||||
await displayCapture.start();
|
||||
await windowCapture.start();
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
{
|
||||
sourceId: 'pipewire:display:1',
|
||||
sourceKind: 'screen',
|
||||
width: 2560,
|
||||
height: 1440,
|
||||
frameRate: 60,
|
||||
captureId: 'capture-1',
|
||||
captureOptions: {
|
||||
colorRange: 'full',
|
||||
colorSpace: 'rec709',
|
||||
showCursorClicks: true,
|
||||
captureRect: {x: 10, y: 20, width: 300, height: 200},
|
||||
},
|
||||
},
|
||||
{
|
||||
sourceId: 'pipewire:window:4242',
|
||||
sourceKind: 'window',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
frameRate: 30,
|
||||
captureId: undefined,
|
||||
captureOptions: {
|
||||
colorRange: undefined,
|
||||
colorSpace: undefined,
|
||||
showCursorClicks: false,
|
||||
captureRect: undefined,
|
||||
},
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(displayCapture.getDiagnostics(), {
|
||||
portalSessionId: 'portal-session-1',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
sourceId: 'pipewire:display:1',
|
||||
sourceKind: 'screen',
|
||||
});
|
||||
assert.deepEqual(windowCapture.getDiagnostics(), {
|
||||
portalSessionId: 'portal-session-1',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
sourceId: 'pipewire:window:4242',
|
||||
sourceKind: 'window',
|
||||
});
|
||||
});
|
||||
|
||||
it('emits closed once for native closed-clean lifecycle events', async () => {
|
||||
const {binding, natives} = makeFakeBinding();
|
||||
linuxScreenCapture.__setBindingForTests(binding);
|
||||
const capture = new linuxScreenCapture.ScreenCapture({
|
||||
sourceId: 'pipewire:display:1',
|
||||
sourceKind: 'screen',
|
||||
});
|
||||
let closed = 0;
|
||||
capture.on('closed', () => {
|
||||
closed += 1;
|
||||
});
|
||||
|
||||
await capture.start();
|
||||
natives[0].lifecycleCallback('closed-clean', 'capture stopped');
|
||||
await capture.stop();
|
||||
|
||||
assert.equal(closed, 1);
|
||||
assert.equal(natives[0].stopCount, 1);
|
||||
});
|
||||
|
||||
it('reports PipeWire portal capabilities from native binding', async () => {
|
||||
const {binding} = makeFakeBinding({
|
||||
availability: {
|
||||
available: true,
|
||||
backend: 'linux-pipewire-portal',
|
||||
detail: 'system capture disabled by portal',
|
||||
capabilities: {process: true, system: false},
|
||||
},
|
||||
});
|
||||
linuxScreenCapture.__setBindingForTests(binding);
|
||||
|
||||
assert.deepEqual(await linuxScreenCapture.getAvailability(), {
|
||||
available: true,
|
||||
backend: 'linux-pipewire-portal',
|
||||
detail: 'system capture disabled by portal',
|
||||
capabilities: {process: true, system: false},
|
||||
});
|
||||
});
|
||||
|
||||
it('installs a native frame sink handle once before start', async () => {
|
||||
const {binding, calls, frameSinkHandleCalls} = makeFakeBinding();
|
||||
linuxScreenCapture.__setBindingForTests(binding);
|
||||
const frameSinkHandle = {native: true};
|
||||
const capture = new linuxScreenCapture.ScreenCapture({
|
||||
sourceId: 'pipewire:display:1',
|
||||
sourceKind: 'screen',
|
||||
frameSinkHandle,
|
||||
nativeFrameSinkRequired: true,
|
||||
});
|
||||
|
||||
await capture.start();
|
||||
|
||||
assert.deepEqual(frameSinkHandleCalls, [frameSinkHandle]);
|
||||
assert.deepEqual(calls, [
|
||||
{
|
||||
sourceId: 'pipewire:display:1',
|
||||
sourceKind: 'screen',
|
||||
width: 0,
|
||||
height: 0,
|
||||
frameRate: 30,
|
||||
captureId: undefined,
|
||||
captureOptions: {
|
||||
colorRange: undefined,
|
||||
colorSpace: undefined,
|
||||
showCursorClicks: false,
|
||||
captureRect: undefined,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('fails before native start when a native frame sink is required but missing', async () => {
|
||||
const {binding, calls, frameSinkHandleCalls} = makeFakeBinding();
|
||||
linuxScreenCapture.__setBindingForTests(binding);
|
||||
const capture = new linuxScreenCapture.ScreenCapture({
|
||||
sourceId: 'pipewire:display:1',
|
||||
sourceKind: 'screen',
|
||||
nativeFrameSinkRequired: true,
|
||||
});
|
||||
|
||||
await assert.rejects(() => capture.start(), /native frame sink handle is required/);
|
||||
assert.deepEqual(frameSinkHandleCalls, []);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user