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:
2026-07-01 18:22:43 -04:00
commit 682afacd30
1763 changed files with 613720 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,42 @@
[package]
name = "fluxer_linux_screen_capture"
version = "0.0.0"
edition = "2024"
license = "AGPL-3.0-or-later"
publish = false
[workspace]
resolver = "2"
[lib]
crate-type = ["cdylib", "rlib"]
[features]
default = []
wgpu = ["dep:wgpu", "fluxer_gpu_rebuild/wgpu"]
[dependencies]
napi = {version = "3.9.1", default-features = false, features = ["dyn-symbols", "napi8", "async"]}
napi-derive = "3.5.6"
parking_lot = "0.12"
fluxer_screen_frame_bus = { path = "../screen-frame-bus" }
fluxer_gpu_rebuild = { path = "../gpu-rebuild" }
wgpu = { version = "29", optional = true, default-features = false }
[target.'cfg(target_os = "linux")'.dependencies]
async-io = "2.6.0"
dcv-color-primitives = "1.0"
futures-lite = "2.6.1"
libc = "0.2"
pipewire = { version = "0.10.0", features = ["v0_3_33"] }
zbus = "5.16.0"
[dev-dependencies]
criterion = { version = "0.8", default-features = false, features = ["cargo_bench_support"] }
[build-dependencies]
napi-build = "2.3.2"
[[bench]]
name = "pipewire_callback"
harness = false
@@ -0,0 +1,40 @@
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
# obs-vkcapture Runtime Assets
Fluxer can launch Linux games with OBS-compatible Vulkan/OpenGL capture enabled.
The Fluxer code in this package does not include obs-vkcapture source code; it only
sets launch environment variables and speaks the OBS-compatible capture socket
protocol implemented by the Rust backend.
The Rust receiver treats OBS import modes as a fallback ladder:
- `default-dmabuf`, `no-modifiers-dmabuf`, and `linear-dmabuf` request GPU
DMA-BUF descriptors from the hook. Until a local GPU importer validates those
descriptors end to end, Fluxer reports them as requested rather than available.
- `linear-host-mapped-dmabuf` is the conservative CPU fallback. Frames carry an
NV12 CPU payload, and may also expose source texture DMA-BUF metadata so the
downstream native WebRTC layer can opportunistically try GPU import before
falling back to the CPU payload.
- Unsupported host-mapped layouts, invalid file descriptors, unsupported fourcc
values, and invalid strides/offsets must surface as lifecycle diagnostics and
must not be treated as successful capture.
If Fluxer ships obs-vkcapture hook binaries under `obs-vkcapture/`, treat those
files as a separate third-party runtime component. The upstream project currently
ships GNU GPL version 2 license text, and distro metadata may label the package as
GPL-2.0-or-later. Use the more conservative GPL-2.0 boundary unless upstream
files in the vendored revision clearly state otherwise.
Distribution checklist for bundled hook assets:
- Keep the obs-vkcapture binaries and manifests under `obs-vkcapture/`.
- Include the exact upstream license text and copyright notices next to the
bundled assets.
- Record the upstream repository URL, revision, local patches, and build script.
- Provide corresponding source for the exact shipped binaries, or a compliant
written source offer when applicable.
- Keep package metadata including `THIRD_PARTY_OBS_VKCAPTURE.md` and
`obs-vkcapture/**/*`; loader tests assert those package entries.
- Do not copy obs-vkcapture implementation code into AGPL-licensed Fluxer modules
unless the license compatibility has been explicitly reviewed.
@@ -0,0 +1,29 @@
{
"measured_at": "85e057a273fd",
"host": "darwin-arm64-apple-silicon",
"regression_budget_percent": 5.0,
"criterion_args": {
"warm_up_time_sec": 2,
"measurement_time_sec": 5
},
"benches": {
"linux_screen_capture/pool_acquire_release/1080p_nv12": {
"median_ns": 14.252,
"low_ns": 14.201,
"high_ns": 14.316,
"budget_percent_override": 10.0,
"note": "~14ns hot loop; noise floor reasoning."
},
"linux_screen_capture/simulated_callback/1080p_nv12_copy": {
"median_ns": 36690.0,
"low_ns": 36375.0,
"high_ns": 37029.0
},
"linux_screen_capture/legacy_baseline/1080p_nv12_vec_clone": {
"median_ns": 36918.0,
"low_ns": 36634.0,
"high_ns": 37213.0,
"note": "Reference point: prior Vec::clone path that the pool was added to replace. Tracked so that any regression of the pool path back to clone cost is visible."
}
}
}
@@ -0,0 +1,120 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::hint::black_box;
use criterion::{Criterion, criterion_group, criterion_main};
use fluxer_linux_screen_capture::frame_buffer_pool::{
LINUX_SCREEN_FRAME_POOL_CAP, LinuxFrameBufferPool,
};
use fluxer_linux_screen_capture::nv12_packing::{Nv12Layout, bgra_to_nv12};
const BENCH_NV12_WIDTH: usize = 1920;
const BENCH_NV12_HEIGHT: usize = 1080;
const BENCH_NV12_BYTES: usize = BENCH_NV12_WIDTH * BENCH_NV12_HEIGHT * 3 / 2;
fn bench_pool_acquire_release_steady_state(c: &mut Criterion) {
assert_eq!(LINUX_SCREEN_FRAME_POOL_CAP, 8);
let pool = LinuxFrameBufferPool::new(BENCH_NV12_BYTES)
.expect("pool must allocate at construction time");
assert_eq!(pool.capacity(), LINUX_SCREEN_FRAME_POOL_CAP);
assert_eq!(pool.bytes_per_buffer(), BENCH_NV12_BYTES);
c.bench_function(
"linux_screen_capture/pool_acquire_release/1080p_nv12",
|b| {
b.iter(|| {
let mut pooled = pool
.try_acquire()
.expect("steady state single-thread must not starve");
let buf = pooled.buffer_mut();
buf[0] = black_box(buf.len() as u8);
pooled.set_len(BENCH_NV12_BYTES);
black_box(pooled.slot_index());
});
},
);
assert_eq!(pool.currently_in_flight(), 0);
}
fn bench_simulated_callback_fill_path(c: &mut Criterion) {
assert_eq!(LINUX_SCREEN_FRAME_POOL_CAP, 8);
let pool = LinuxFrameBufferPool::new(BENCH_NV12_BYTES).expect("pool init");
let source = vec![0xA5u8; BENCH_NV12_BYTES];
assert_eq!(source.len(), BENCH_NV12_BYTES);
c.bench_function(
"linux_screen_capture/simulated_callback/1080p_nv12_copy",
|b| {
b.iter(|| {
let mut pooled = pool.try_acquire().expect("pool capacity");
let buf = pooled.buffer_mut();
buf[..BENCH_NV12_BYTES].copy_from_slice(&source);
pooled.set_len(BENCH_NV12_BYTES);
black_box(pooled.as_slice().len());
});
},
);
assert_eq!(pool.currently_in_flight(), 0);
}
fn bench_legacy_vec_clone_baseline(c: &mut Criterion) {
let source = vec![0xA5u8; BENCH_NV12_BYTES];
assert_eq!(source.len(), BENCH_NV12_BYTES);
let mut scratch = vec![0u8; BENCH_NV12_BYTES];
c.bench_function(
"linux_screen_capture/legacy_baseline/1080p_nv12_vec_clone",
|b| {
b.iter(|| {
scratch.copy_from_slice(&source);
let cloned = scratch.clone();
black_box(cloned.len());
});
},
);
}
const BENCH_4K_WIDTH: usize = 3840;
const BENCH_4K_HEIGHT: usize = 2160;
fn bench_bgra_to_nv12_4k_conversion(c: &mut Criterion) {
let layout = Nv12Layout {
width: BENCH_4K_WIDTH as u32,
height: BENCH_4K_HEIGHT as u32,
stride_y: BENCH_4K_WIDTH as u32,
stride_uv: BENCH_4K_WIDTH as u32,
};
let bgra_stride = (BENCH_4K_WIDTH * 4) as u32;
let mut bgra = vec![0u8; BENCH_4K_WIDTH * BENCH_4K_HEIGHT * 4];
for (index, byte) in bgra.iter_mut().enumerate() {
*byte = (index % 253) as u8;
}
let total = layout.packed_size().expect("4K layout is valid");
let mut dst = vec![0u8; total];
c.bench_function("linux_screen_capture/bgra_to_nv12/4k_unflipped", |b| {
b.iter(|| {
let ok = bgra_to_nv12(layout, &bgra, bgra_stride, &mut dst, false);
assert!(ok);
black_box(dst[0]);
});
});
c.bench_function("linux_screen_capture/bgra_to_nv12/4k_flipped", |b| {
b.iter(|| {
let ok = bgra_to_nv12(layout, &bgra, bgra_stride, &mut dst, true);
assert!(ok);
black_box(dst[total - 1]);
});
});
}
criterion_group!(
benches,
bench_pool_acquire_release_steady_state,
bench_simulated_callback_fill_path,
bench_legacy_vec_clone_baseline,
bench_bgra_to_nv12_4k_conversion,
);
criterion_main!(benches);
@@ -0,0 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
fn main() {
napi_build::setup();
}
+171
View File
@@ -0,0 +1,171 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {EventEmitter} from 'node:events';
export type LinuxScreenCaptureSourceKind = 'screen' | 'window' | 'game';
export interface LinuxScreenCaptureSource {
kind: LinuxScreenCaptureSourceKind;
id: string;
name: string;
width: number;
height: number;
appName?: string;
bundleId?: string;
targetPid?: number;
}
export declare function listSources(): Promise<Array<LinuxScreenCaptureSource>>;
export interface LinuxScreenCaptureCapabilities {
process: boolean;
system: boolean;
}
export interface LinuxScreenCaptureAvailability {
available: boolean;
backend: 'linux-pipewire-portal';
reason?: string;
detail?: string;
portalVersion?: number;
capabilities: LinuxScreenCaptureCapabilities;
}
export declare function getAvailability(): Promise<LinuxScreenCaptureAvailability>;
export interface LinuxScreenCaptureBackendInfo {
backend: 'linux-pipewire-portal';
supported: boolean;
reason: string;
portalVersion?: number;
pipewireReachable: boolean;
}
export declare function getBackendInfo(): LinuxScreenCaptureBackendInfo;
export interface LinuxGameCaptureLaunchEnvironmentOptions {
env?: NodeJS.ProcessEnv;
nativeRoot?: string;
name?: string;
mode?: 'auto' | 'vulkan' | 'opengl';
preferDiscreteGpu?: boolean;
forceNvidiaIcd?: boolean | string;
}
export interface LinuxGameCaptureLaunchEnvironmentResult {
env: NodeJS.ProcessEnv;
diagnostics: {
mode: 'auto' | 'vulkan' | 'opengl';
preferDiscreteGpu: boolean;
forceNvidiaIcd: boolean;
nvidiaIcdPath: string | null;
bundledVulkanLayerDir: string | null;
systemVulkanLayerManifest: string | null;
vulkanLayerName: string | null;
bundledGlCaptureLib: string | null;
systemGlCaptureLib: string | null;
glCaptureLib: string | null;
licenseBoundary: string;
};
}
export declare function getGameCaptureLaunchEnvironment(
options?: LinuxGameCaptureLaunchEnvironmentOptions,
): LinuxGameCaptureLaunchEnvironmentResult;
export interface ScreenCaptureRect {
x: number;
y: number;
width: number;
height: number;
}
export interface ScreenCaptureOptions {
sourceId: string;
sourceKind: LinuxScreenCaptureSourceKind;
width?: number;
height?: number;
frameRate?: number;
captureId?: string;
colorRange?: 'full' | 'limited';
colorSpace?: 'rec709' | 'srgb';
showCursorClicks?: boolean;
captureRect?: ScreenCaptureRect;
frameSinkHandle?: unknown;
nativeFrameSinkRequired?: boolean;
}
export interface ScreenCaptureStartResult {
width: number;
height: number;
frameRate: number;
pixelFormat: 'nv12';
}
export interface LinuxScreenCaptureDiagnostics {
backend?: string;
activeStrategy?: string;
requestedInjectionMethod?: string;
injectionMethod?: string;
lastFallbackReason?: string;
frameTransport?: 'gpu-dmabuf-requested' | 'host-mapped-cpu-nv12-with-source-dmabuf';
hostMappedCpuFallback?: boolean;
sourceDmabufMetadataAvailable?: boolean;
requestedImportMode?: string;
importMode?: string;
mapHost?: boolean;
noModifiers?: boolean;
linear?: boolean;
zeroCopy?: boolean;
gpuImportAvailable?: boolean;
deviceUuidAdvertised?: boolean;
supportedImportModes?: Array<string>;
clientConnected?: boolean;
connectedClient?: string;
connectedPid?: number;
sourceId?: string;
sourceKind?: LinuxScreenCaptureSourceKind;
width?: number;
height?: number;
textureFormat?: string;
textureModifier?: string;
frameCounter?: number;
droppedFrameCounter?: number;
laggedFrameCounter?: number;
convertQueueDroppedFrameCounter?: number;
unsupportedFrameCounter?: number;
lastPresentTimestampUs?: number;
lastDiagnostic?: string;
lastAddonError?: string;
}
export declare const loadError: Error | null;
export declare function __setBindingForTests(binding: unknown): void;
export declare interface ScreenCapture {
on(event: 'error', listener: (err: Error) => void): this;
on(event: 'closed', listener: () => void): this;
on(event: 'stalled', listener: (message?: string) => void): this;
on(event: 'diagnostic', listener: (message?: string) => void): this;
on(event: string | symbol, listener: (...args: Array<unknown>) => void): this;
off(event: 'error', listener: (err: Error) => void): this;
off(event: 'closed', listener: () => void): this;
off(event: 'stalled', listener: (message?: string) => void): this;
off(event: 'diagnostic', listener: (message?: string) => void): this;
off(event: string | symbol, listener: (...args: Array<unknown>) => void): this;
emit(event: 'error', err: Error): boolean;
emit(event: 'closed'): boolean;
emit(event: 'stalled', message?: string): boolean;
emit(event: 'diagnostic', message?: string): boolean;
}
export declare class ScreenCapture extends EventEmitter {
constructor(options: ScreenCaptureOptions);
start(): Promise<ScreenCaptureStartResult>;
getDiagnostics(): LinuxScreenCaptureDiagnostics | null;
stop(): Promise<void>;
}
@@ -0,0 +1,349 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const {EventEmitter} = require('node:events');
const {existsSync} = require('node:fs');
const {delimiter, join, sep} = require('node:path');
const {createNativeLoadError, loadNativeBinding} = require('./loader-diagnostics.cjs');
const MODULE_NAME = '@fluxer/linux-screen-capture';
const SKIP_NATIVE_PROBE_ENV = 'FLUXER_LINUX_SCREEN_CAPTURE_SKIP_NATIVE_PROBE';
const OBS_VKCAPTURE_LAYER_NAME = 'VK_LAYER_OBS_vkcapture_64';
const DEFAULT_NVIDIA_VULKAN_ICD = '/usr/share/vulkan/icd.d/nvidia_icd.json';
function resolveNativeRoot() {
const asarSegment = `${sep}app.asar${sep}`;
if (!__dirname.includes(asarSegment)) return __dirname;
const unpackedDir = __dirname.replace(asarSegment, `${sep}app.asar.unpacked${sep}`);
return existsSync(unpackedDir) ? unpackedDir : __dirname;
}
function nativeFileName() {
if (process.platform !== 'linux') {
throw new Error(`@fluxer/linux-screen-capture is only supported on Linux, got ${process.platform}`);
}
switch (process.arch) {
case 'x64':
return 'linux-screen-capture.linux-x64-gnu.node';
case 'arm64':
return 'linux-screen-capture.linux-arm64-gnu.node';
default:
throw new Error(`Unsupported Linux architecture: ${process.arch}`);
}
}
let binding = null;
let loadError = null;
if (process.platform === 'linux') {
try {
const nativeRoot = resolveNativeRoot();
const nativePath = join(nativeRoot, nativeFileName());
const loaded = loadNativeBinding({
moduleName: MODULE_NAME,
nativePath,
nativeRoot,
packageDir: __dirname,
skipNativeProbeEnv: SKIP_NATIVE_PROBE_ENV,
});
binding = loaded.binding;
loadError = loaded.loadError;
if (loadError) throw loadError;
} catch (error) {
loadError = createNativeLoadError({
moduleName: MODULE_NAME,
nativeRoot: resolveNativeRoot(),
packageDir: __dirname,
reason: 'native loader threw before binding load completed',
cause: error,
skipNativeProbeEnv: SKIP_NATIVE_PROBE_ENV,
});
throw loadError;
}
}
function getBackendInfo() {
if (!binding) {
return {
backend: 'linux-pipewire-portal',
supported: false,
reason:
process.platform === 'linux'
? `@fluxer/linux-screen-capture native binary unavailable: ${loadError?.message ?? 'unknown reason'}`
: `@fluxer/linux-screen-capture is only supported on Linux, got ${process.platform}`,
portalVersion: undefined,
pipewireReachable: false,
};
}
return binding.getBackendInfo();
}
function getAvailability() {
if (!binding) {
return Promise.resolve({
available: false,
backend: 'linux-pipewire-portal',
reason: 'unsupported-platform',
capabilities: {process: false, system: false},
});
}
return binding.getAvailability();
}
function listSources() {
if (!binding) return Promise.resolve([]);
return binding.listSources();
}
function prependPathEnv(current, next) {
if (!next) return current;
if (!current) return next;
const parts = current.split(delimiter).filter(Boolean);
return parts.includes(next) ? current : `${next}${delimiter}${current}`;
}
function appendColonEnv(current, next) {
if (!next) return current;
if (!current) return next;
const parts = current.split(':').filter(Boolean);
return parts.includes(next) ? current : `${current}:${next}`;
}
function bundledObsVkcaptureRoots(nativeRoot) {
return [join(nativeRoot, 'obs-vkcapture'), join(nativeRoot, 'game-capture', 'obs-vkcapture')];
}
function resolveBundledVulkanLayerDir(nativeRoot) {
for (const root of bundledObsVkcaptureRoots(nativeRoot)) {
const jsonPath = join(root, 'obs_vkcapture_64.json');
const layerPath = join(root, 'libVkLayer_obs_vkcapture.so');
if (existsSync(jsonPath) && existsSync(layerPath)) return root;
const vulkanRoot = join(root, 'vulkan');
const vulkanJsonPath = join(vulkanRoot, 'obs_vkcapture_64.json');
const vulkanLayerPath = join(vulkanRoot, 'libVkLayer_obs_vkcapture.so');
if (existsSync(vulkanJsonPath) && existsSync(vulkanLayerPath)) return vulkanRoot;
}
return null;
}
function resolveBundledGlCaptureLib(nativeRoot) {
for (const root of bundledObsVkcaptureRoots(nativeRoot)) {
const candidates = [
join(root, 'obs_glcapture', 'libobs_glcapture.so'),
join(root, 'opengl', 'libobs_glcapture.so'),
join(root, 'libobs_glcapture.so'),
];
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate;
}
}
return null;
}
function resolveSystemVulkanLayerManifest() {
const candidates = [
'/usr/share/vulkan/implicit_layer.d/obs_vkcapture_64.json',
'/usr/local/share/vulkan/implicit_layer.d/obs_vkcapture_64.json',
];
return candidates.find((candidate) => existsSync(candidate)) ?? null;
}
function resolveSystemGlCaptureLib() {
const candidates = [
'/usr/lib/obs_glcapture/libobs_glcapture.so',
'/usr/lib64/obs_glcapture/libobs_glcapture.so',
'/usr/local/lib/obs_glcapture/libobs_glcapture.so',
'/usr/lib/x86_64-linux-gnu/obs_glcapture/libobs_glcapture.so',
'/usr/lib/aarch64-linux-gnu/obs_glcapture/libobs_glcapture.so',
];
return candidates.find((candidate) => existsSync(candidate)) ?? null;
}
function resolveNvidiaIcdPath(forceNvidiaIcd) {
if (typeof forceNvidiaIcd === 'string' && forceNvidiaIcd.length > 0) return forceNvidiaIcd;
if (forceNvidiaIcd !== true) return null;
return existsSync(DEFAULT_NVIDIA_VULKAN_ICD) ? DEFAULT_NVIDIA_VULKAN_ICD : null;
}
function addDiscreteGpuLaunchEnv(env, options = {}) {
env.DRI_PRIME = env.DRI_PRIME || '1';
env.__NV_PRIME_RENDER_OFFLOAD = '1';
env.__VK_LAYER_NV_optimus = 'NVIDIA_only';
env.__GLX_VENDOR_LIBRARY_NAME = 'nvidia';
const nvidiaIcdPath = resolveNvidiaIcdPath(options.forceNvidiaIcd);
if (nvidiaIcdPath) env.VK_ICD_FILENAMES = nvidiaIcdPath;
return nvidiaIcdPath;
}
function getGameCaptureLaunchEnvironment(options = {}) {
const baseEnv = options.env ?? process.env;
const nativeRoot = options.nativeRoot ?? resolveNativeRoot();
const mode = options.mode === 'vulkan' || options.mode === 'opengl' ? options.mode : 'auto';
const env = {...baseEnv, OBS_VKCAPTURE: '1'};
if (options.name) env.OBS_VKCAPTURE_NAME = String(options.name);
const nvidiaIcdPath = options.preferDiscreteGpu ? addDiscreteGpuLaunchEnv(env, options) : null;
const bundledVulkanLayerDir = resolveBundledVulkanLayerDir(nativeRoot);
const systemVulkanLayerManifest = resolveSystemVulkanLayerManifest();
if (mode !== 'opengl' && bundledVulkanLayerDir) {
env.VK_ADD_LAYER_PATH = prependPathEnv(env.VK_ADD_LAYER_PATH, bundledVulkanLayerDir);
env.VK_INSTANCE_LAYERS = appendColonEnv(env.VK_INSTANCE_LAYERS, OBS_VKCAPTURE_LAYER_NAME);
}
const bundledGlCaptureLib = resolveBundledGlCaptureLib(nativeRoot);
const systemGlCaptureLib = resolveSystemGlCaptureLib();
const glCaptureLib = bundledGlCaptureLib ?? systemGlCaptureLib;
if (mode !== 'vulkan' && glCaptureLib) {
env.LD_PRELOAD = appendColonEnv(env.LD_PRELOAD, glCaptureLib);
}
return {
env,
diagnostics: {
mode,
preferDiscreteGpu: options.preferDiscreteGpu === true,
forceNvidiaIcd: options.forceNvidiaIcd === true || typeof options.forceNvidiaIcd === 'string',
nvidiaIcdPath,
bundledVulkanLayerDir,
systemVulkanLayerManifest,
vulkanLayerName: bundledVulkanLayerDir ? OBS_VKCAPTURE_LAYER_NAME : null,
bundledGlCaptureLib,
systemGlCaptureLib,
glCaptureLib,
licenseBoundary:
'obs-vkcapture hook assets are separate GPL-covered runtime tools; Fluxer communicates through the OBS-compatible socket protocol.',
},
};
}
function __setBindingForTests(nextBinding) {
binding = nextBinding;
loadError = null;
}
class ScreenCapture extends EventEmitter {
constructor(options = {}) {
super();
if (!binding) {
throw loadError || new Error('@fluxer/linux-screen-capture binding unavailable');
}
this.sourceId = options.sourceId;
this.sourceKind = options.sourceKind ?? 'screen';
this.width = options.width ?? 0;
this.height = options.height ?? 0;
this.frameRate = options.frameRate ?? 30;
this.captureId = typeof options.captureId === 'string' ? options.captureId : undefined;
this.colorRange = options.colorRange;
this.colorSpace = options.colorSpace;
this.showCursorClicks = options.showCursorClicks === true;
this.captureRect = options.captureRect;
this.frameSinkHandle = options.frameSinkHandle;
this.nativeFrameSinkRequired = options.nativeFrameSinkRequired === true;
this.started = false;
this.stopped = false;
this.closedEmitted = false;
this.native = new binding.ScreenCapture();
this.native.setLifecycleCallback((type, message) => {
if (type === 'error') {
this.emit('error', new Error(message || 'Linux PipeWire screen capture stream stopped'));
return;
}
if (type === 'closed' || type === 'closed-clean') {
if (this.stopped) {
this.emitClosedOnce();
return;
}
this.stopped = true;
Promise.resolve()
.then(() => this.native.stop())
.catch(() => {});
this.emitClosedOnce();
return;
}
if (type === 'stalled' || type === 'diagnostic') {
this.emit(type, message);
}
});
}
emitClosedOnce() {
if (this.closedEmitted) return;
this.closedEmitted = true;
this.emit('closed');
}
async start() {
if (this.started || this.stopped) return;
this.started = true;
try {
if (this.frameSinkHandle != null) {
if (typeof this.native.setFrameSinkHandle !== 'function') {
throw new Error('@fluxer/linux-screen-capture native binding does not support frame sink handles');
}
this.native.setFrameSinkHandle(this.frameSinkHandle);
} else if (this.nativeFrameSinkRequired) {
throw new Error('@fluxer/linux-screen-capture native frame sink handle is required');
}
const result = await this.native.start(
String(this.sourceId ?? ''),
this.sourceKind,
this.width,
this.height,
this.frameRate,
this.captureId,
{
colorRange: this.colorRange,
colorSpace: this.colorSpace,
showCursorClicks: this.showCursorClicks,
captureRect: this.captureRect,
},
);
if (result) {
this.width = result.width ?? this.width;
this.height = result.height ?? this.height;
this.frameRate = result.frameRate ?? this.frameRate;
this.pixelFormat = result.pixelFormat ?? 'nv12';
}
return {
width: this.width,
height: this.height,
frameRate: this.frameRate,
pixelFormat: this.pixelFormat ?? 'nv12',
};
} catch (error) {
this.stopped = true;
this.emit('error', error instanceof Error ? error : new Error(String(error)));
throw error;
}
}
async stop() {
if (this.stopped) return;
this.stopped = true;
try {
await this.native.stop();
} finally {
this.emitClosedOnce();
}
}
getDiagnostics() {
const addonDiagnostics = typeof this.native.getDiagnostics === 'function' ? this.native.getDiagnostics() : null;
if (!addonDiagnostics) return null;
return {
...addonDiagnostics,
sourceId: String(this.sourceId ?? ''),
sourceKind: this.sourceKind,
width: addonDiagnostics.width ?? this.width,
height: addonDiagnostics.height ?? this.height,
};
}
}
module.exports = {
ScreenCapture,
getAvailability,
getBackendInfo,
getGameCaptureLaunchEnvironment,
listSources,
loadError,
__setBindingForTests,
};
@@ -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, []);
});
});
@@ -0,0 +1,524 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const {existsSync, readdirSync, readFileSync, statSync} = require('node:fs');
const os = require('node:os');
const {basename} = require('node:path');
const {spawnSync} = require('node:child_process');
const NATIVE_LOAD_ERROR_MARKER = Symbol.for('fluxer.nativeLoadError');
const MAX_TEXT_LENGTH = 6000;
const MAX_DIRECTORY_ENTRIES = 80;
function trimText(value, limit = MAX_TEXT_LENGTH) {
const text = Buffer.isBuffer(value) ? value.toString('utf8') : String(value ?? '');
const trimmed = text.trim();
if (!trimmed) return null;
return trimmed.length > limit ? `${trimmed.slice(0, limit)}\n...<truncated>` : trimmed;
}
function errorDiagnostic(error) {
if (!error) return null;
if (error instanceof Error) {
return {
name: error.name || 'Error',
message: error.message,
code: error.code || null,
stack: trimText(error.stack || error.message),
};
}
return {
name: typeof error,
message: trimText(String(error)),
code: null,
stack: null,
};
}
function formatErrorDiagnostic(diagnostic) {
if (!diagnostic) return null;
const lines = [];
if (diagnostic.code) lines.push(`code=${diagnostic.code}`);
if (diagnostic.stack) lines.push(diagnostic.stack);
else if (diagnostic.message) lines.push(diagnostic.message);
return trimText(lines.join('\n'));
}
function fileDiagnostic(filePath) {
if (!filePath) return {path: null, exists: false, error: 'not resolved'};
try {
const stat = statSync(filePath);
return {
path: filePath,
exists: true,
size: stat.size,
mode: `0${(stat.mode & 0o777).toString(8)}`,
mtime: stat.mtime.toISOString(),
isFile: stat.isFile(),
isDirectory: stat.isDirectory(),
};
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
return {path: filePath, exists: false, error: reason};
}
}
function formatFileDiagnostic(diagnostic) {
if (!diagnostic) return 'not resolved';
if (!diagnostic.exists) return `exists=false, statError=${diagnostic.error || '<unknown>'}`;
return [
`exists=true`,
`size=${diagnostic.size}`,
`mode=${diagnostic.mode}`,
`mtime=${diagnostic.mtime}`,
`isFile=${diagnostic.isFile}`,
].join(', ');
}
function directoryDiagnostic(dirPath) {
if (!dirPath) return {path: null, ok: false, error: 'not resolved', entries: [], total: 0, omitted: 0};
try {
const entries = readdirSync(dirPath, {withFileTypes: true}).map((entry) => ({
name: entry.name,
type: entry.isDirectory() ? 'directory' : entry.isFile() ? 'file' : 'other',
}));
entries.sort((a, b) => a.name.localeCompare(b.name));
const visible = entries.slice(0, MAX_DIRECTORY_ENTRIES);
return {
path: dirPath,
ok: true,
entries: visible,
total: entries.length,
omitted: Math.max(0, entries.length - visible.length),
};
} catch (error) {
return {
path: dirPath,
ok: false,
error: error instanceof Error ? error.message : String(error),
entries: [],
total: 0,
omitted: 0,
};
}
}
function formatDirectoryDiagnostic(diagnostic) {
if (!diagnostic) return '<unavailable>';
if (!diagnostic.ok) return `directory listing failed: ${diagnostic.error || '<unknown>'}`;
const entries = diagnostic.entries.map((entry) => `${entry.name}${entry.type === 'directory' ? '/' : ''}`);
const suffix = diagnostic.omitted > 0 ? [`...<${diagnostic.omitted} more entries>`] : [];
return [...entries, ...suffix].join('\n') || '<empty>';
}
function selectedEnvironmentNames(skipNativeProbeEnv) {
const names = [
'ELECTRON_RUN_AS_NODE',
'FLUXER_NATIVE_MODULE_PREFLIGHT_CHILD',
'LD_LIBRARY_PATH',
'DYLD_LIBRARY_PATH',
'DISPLAY',
'WAYLAND_DISPLAY',
'XDG_CURRENT_DESKTOP',
'XDG_SESSION_TYPE',
'DBUS_SESSION_BUS_ADDRESS',
'PULSE_SERVER',
'PIPEWIRE_REMOTE',
'PATH',
];
if (skipNativeProbeEnv) names.push(skipNativeProbeEnv);
return names;
}
function environmentDiagnostics(skipNativeProbeEnv) {
return Object.fromEntries(
selectedEnvironmentNames(skipNativeProbeEnv).map((name) => [name, process.env[name] ?? null]),
);
}
function formatEnvironment(diagnostic) {
return Object.entries(diagnostic)
.map(([name, value]) => `${name}=${value ?? '<unset>'}`)
.join('\n');
}
function runtimeDiagnostics() {
const versions = process.versions || {};
let reportHeader = null;
if (process.report && typeof process.report.getReport === 'function') {
try {
reportHeader = process.report.getReport().header || null;
} catch {
reportHeader = null;
}
}
const glibcRuntime = versions.glibcVersionRuntime || reportHeader?.glibcVersionRuntime || '<unknown>';
const glibcCompiler = versions.glibcVersionCompiler || reportHeader?.glibcVersionCompiler || '<unknown>';
return {
node: versions.node || null,
electron: versions.electron || null,
modules: versions.modules || null,
napi: versions.napi || null,
v8: versions.v8 || null,
uv: versions.uv || null,
openssl: versions.openssl || null,
glibcRuntime,
glibcCompiler,
platform: process.platform,
arch: process.arch,
osType: os.type(),
osRelease: os.release(),
osVersion: typeof os.version === 'function' ? os.version() : null,
execPath: process.execPath,
resourcesPath: process.resourcesPath || null,
cwd: process.cwd(),
};
}
function formatRuntimeDiagnostics(diagnostic) {
return [
`node=${diagnostic.node || '<unknown>'}`,
`electron=${diagnostic.electron || '<none>'}`,
`modules=${diagnostic.modules || '<unknown>'}`,
`napi=${diagnostic.napi || '<unknown>'}`,
`v8=${diagnostic.v8 || '<unknown>'}`,
`uv=${diagnostic.uv || '<unknown>'}`,
`openssl=${diagnostic.openssl || '<unknown>'}`,
`glibcRuntime=${diagnostic.glibcRuntime || '<unknown>'}`,
`glibcCompiler=${diagnostic.glibcCompiler || '<unknown>'}`,
`process=${diagnostic.platform}/${diagnostic.arch}`,
`os=${diagnostic.osType} ${diagnostic.osRelease} ${diagnostic.osVersion || '<unknown>'}`,
`execPath=${diagnostic.execPath}`,
`resourcesPath=${diagnostic.resourcesPath || '<unknown>'}`,
`cwd=${diagnostic.cwd}`,
].join('\n');
}
const REDISTRIBUTABLE_RUNTIME_PATTERNS = [
/^vcruntime\d+(?:_\d+)?\.dll$/i,
/^msvcp\d+(?:_\d+)?\.dll$/i,
/^msvcr\d+(?:_\d+)?\.dll$/i,
/^concrt\d+\.dll$/i,
/^vcamp\d+\.dll$/i,
/^vcomp\d+\.dll$/i,
];
function readPeImports(filePath) {
let buffer;
try {
buffer = readFileSync(filePath);
} catch {
return null;
}
if (buffer.length < 0x40) return null;
const peOffset = buffer.readUInt32LE(0x3c);
if (peOffset <= 0 || peOffset + 24 >= buffer.length) return null;
if (buffer.readUInt32LE(peOffset) !== 0x4550) return null;
const coffOffset = peOffset + 4;
const numberOfSections = buffer.readUInt16LE(coffOffset + 2);
const sizeOfOptionalHeader = buffer.readUInt16LE(coffOffset + 16);
const optionalHeaderOffset = coffOffset + 20;
if (optionalHeaderOffset + sizeOfOptionalHeader > buffer.length) return null;
const magic = buffer.readUInt16LE(optionalHeaderOffset);
if (magic !== 0x10b && magic !== 0x20b) return null;
const dataDirectoriesOffset = optionalHeaderOffset + (magic === 0x20b ? 112 : 96);
const importEntryOffset = dataDirectoriesOffset + 8;
if (importEntryOffset + 8 > buffer.length) return null;
const importRva = buffer.readUInt32LE(importEntryOffset);
if (importRva === 0) return [];
const sections = [];
const sectionTableOffset = optionalHeaderOffset + sizeOfOptionalHeader;
for (let i = 0; i < numberOfSections; i++) {
const base = sectionTableOffset + i * 40;
if (base + 40 > buffer.length) return null;
sections.push({
virtualSize: buffer.readUInt32LE(base + 8),
virtualAddress: buffer.readUInt32LE(base + 12),
rawSize: buffer.readUInt32LE(base + 16),
rawPointer: buffer.readUInt32LE(base + 20),
});
}
const rvaToOffset = (rva) => {
for (const s of sections) {
const span = Math.max(s.virtualSize, s.rawSize);
if (rva >= s.virtualAddress && rva < s.virtualAddress + span) {
return rva - s.virtualAddress + s.rawPointer;
}
}
return -1;
};
const readCString = (offset) => {
let end = offset;
while (end < buffer.length && buffer[end] !== 0) end++;
return buffer.toString('ascii', offset, end);
};
const importTableOffset = rvaToOffset(importRva);
if (importTableOffset < 0) return [];
const imports = new Set();
for (let i = 0; i < 1024; i++) {
const base = importTableOffset + i * 20;
if (base + 20 > buffer.length) break;
const lookupRva = buffer.readUInt32LE(base);
const nameRva = buffer.readUInt32LE(base + 12);
const iatRva = buffer.readUInt32LE(base + 16);
if (lookupRva === 0 && nameRva === 0 && iatRva === 0) break;
const nameOffset = rvaToOffset(nameRva);
if (nameOffset < 0) continue;
const name = readCString(nameOffset);
if (name) imports.add(name);
}
return Array.from(imports);
}
function windowsImportProbe(nativePath) {
const imports = readPeImports(nativePath);
if (imports === null) return null;
const sortedImports = [...imports].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
const redistributable = sortedImports.filter((dll) =>
REDISTRIBUTABLE_RUNTIME_PATTERNS.some((pattern) => pattern.test(dll)),
);
return {
command: ['pe-imports', nativePath],
status: 0,
signal: null,
error: null,
stdout: sortedImports.join('\n') || null,
stderr: null,
missing: [],
redistributable,
};
}
function dependencyProbe(nativePath) {
if (!nativePath || !existsSync(nativePath)) return null;
if (process.platform === 'win32') return windowsImportProbe(nativePath);
const command =
process.platform === 'linux'
? ['ldd', nativePath]
: process.platform === 'darwin'
? ['otool', '-L', nativePath]
: null;
if (!command) return null;
const [bin, ...args] = command;
const result = spawnSync(bin, args, {
encoding: 'utf8',
timeout: 4000,
stdio: ['ignore', 'pipe', 'pipe'],
});
const stdout = trimText(result.stdout);
const stderr = trimText(result.stderr);
const missing =
process.platform === 'linux' && stdout
? stdout
.split('\n')
.map((line) => line.trim())
.filter((line) => line.includes('not found'))
: [];
return {
command,
status: result.status,
signal: result.signal || null,
error: result.error ? result.error.message : null,
stdout,
stderr,
missing,
redistributable: [],
};
}
function formatDependencyProbe(diagnostic) {
if (!diagnostic) return null;
const status = diagnostic.error
? `error=${diagnostic.error}`
: diagnostic.signal
? `signal=${diagnostic.signal}`
: `status=${diagnostic.status}`;
return [
`$ ${diagnostic.command.join(' ')}`,
status,
diagnostic.missing?.length ? `missing:\n${diagnostic.missing.join('\n')}` : null,
diagnostic.redistributable?.length
? `redistributableRuntimeImports (require VC++ redist on host):\n${diagnostic.redistributable.join('\n')}`
: null,
diagnostic.stdout ? `stdout:\n${diagnostic.stdout}` : null,
diagnostic.stderr ? `stderr:\n${diagnostic.stderr}` : null,
]
.filter(Boolean)
.join('\n');
}
function formatExtraDiagnostic(diagnostic) {
if (!diagnostic) return null;
if (typeof diagnostic === 'string') return diagnostic;
if (typeof diagnostic === 'object' && diagnostic.name && diagnostic.text) {
return `${diagnostic.name}:\n${diagnostic.text}`;
}
return `extra:\n${trimText(JSON.stringify(diagnostic, null, 2))}`;
}
function collectNativeDiagnostics({
moduleName,
nativePath,
nativeRoot,
packageDir,
reason,
cause,
skipNativeProbeEnv,
extraDiagnostics = [],
}) {
return {
schemaVersion: 1,
moduleName,
reason,
target: {
platform: process.platform,
arch: process.arch,
},
packageDir: packageDir || null,
nativeRoot: nativeRoot || null,
nativePath: nativePath || null,
nativeFile: nativePath ? basename(nativePath) : null,
nativeFileStat: fileDiagnostic(nativePath),
runtime: runtimeDiagnostics(),
environment: environmentDiagnostics(skipNativeProbeEnv),
nativeRootEntries: directoryDiagnostic(nativeRoot),
dependencyProbe: dependencyProbe(nativePath),
extraDiagnostics: extraDiagnostics.filter(Boolean),
cause: errorDiagnostic(cause),
};
}
function formatNativeDiagnostics(diagnostics) {
const sections = [
`module=${diagnostics.moduleName}`,
`reason=${diagnostics.reason}`,
`target=${diagnostics.target.platform}/${diagnostics.target.arch}`,
`packageDir=${diagnostics.packageDir || '<unknown>'}`,
`nativeRoot=${diagnostics.nativeRoot || '<unknown>'}`,
`nativePath=${diagnostics.nativePath || '<unknown>'}`,
`nativeFile=${diagnostics.nativeFile || '<unknown>'}`,
`nativeFileStat=${formatFileDiagnostic(diagnostics.nativeFileStat)}`,
`runtime:\n${formatRuntimeDiagnostics(diagnostics.runtime)}`,
`environment:\n${formatEnvironment(diagnostics.environment)}`,
`nativeRootEntries:\n${formatDirectoryDiagnostic(diagnostics.nativeRootEntries)}`,
...diagnostics.extraDiagnostics.map(formatExtraDiagnostic).filter(Boolean),
];
const dependencyOutput = formatDependencyProbe(diagnostics.dependencyProbe);
if (dependencyOutput) sections.push(`dependencyProbe:\n${dependencyOutput}`);
const causeText = formatErrorDiagnostic(diagnostics.cause);
if (causeText) sections.push(`cause:\n${causeText}`);
return sections.join('\n');
}
function isNativeLoadError(error) {
return Boolean(error?.[NATIVE_LOAD_ERROR_MARKER]);
}
function createNativeLoadError({
moduleName,
nativePath,
nativeRoot,
packageDir,
reason,
cause,
skipNativeProbeEnv,
extraDiagnostics = [],
}) {
if (isNativeLoadError(cause)) return cause;
const diagnostics = collectNativeDiagnostics({
moduleName,
nativePath,
nativeRoot,
packageDir,
reason,
cause,
skipNativeProbeEnv,
extraDiagnostics,
});
const error = new Error(`${moduleName} native module failed to load.\n${formatNativeDiagnostics(diagnostics)}`);
error.name = 'NativeModuleLoadError';
error[NATIVE_LOAD_ERROR_MARKER] = true;
error.nativeDiagnostics = diagnostics;
error.toJSON = () => ({
name: error.name,
message: error.message,
nativeDiagnostics: diagnostics,
});
if (cause) error.cause = cause;
return error;
}
function probeNativeBinary({moduleName, nativePath, nativeRoot, packageDir, skipNativeProbeEnv, timeoutMs = 5000}) {
if (!skipNativeProbeEnv || process.env[skipNativeProbeEnv] === '1') {
return null;
}
const result = spawnSync(process.execPath, ['-e', 'require(process.argv[1])', nativePath], {
env: {...process.env, ELECTRON_RUN_AS_NODE: '1', [skipNativeProbeEnv]: '1'},
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: timeoutMs,
});
if (result.status === 0) return null;
const reason = result.error
? result.error.message
: result.signal
? `safety probe terminated by signal ${result.signal}`
: `safety probe exited with code ${result.status}`;
return createNativeLoadError({
moduleName,
nativePath,
nativeRoot,
packageDir,
reason,
skipNativeProbeEnv,
extraDiagnostics: [
result.stdout ? {name: 'probeStdout', text: trimText(result.stdout)} : null,
result.stderr ? {name: 'probeStderr', text: trimText(result.stderr)} : null,
],
});
}
function loadNativeBinding({moduleName, nativePath, nativeRoot, packageDir, skipNativeProbeEnv, probe = true}) {
if (!existsSync(nativePath)) {
return {
binding: null,
loadError: createNativeLoadError({
moduleName,
nativePath,
nativeRoot,
packageDir,
reason: 'native binary not found',
skipNativeProbeEnv,
}),
};
}
const nativeProbeError = probe
? probeNativeBinary({moduleName, nativePath, nativeRoot, packageDir, skipNativeProbeEnv})
: null;
if (nativeProbeError) {
return {binding: null, loadError: nativeProbeError};
}
try {
return {binding: require(nativePath), loadError: null};
} catch (error) {
return {
binding: null,
loadError: createNativeLoadError({
moduleName,
nativePath,
nativeRoot,
packageDir,
reason: 'require(nativePath) threw',
cause: error,
skipNativeProbeEnv,
}),
};
}
}
module.exports = {
collectNativeDiagnostics,
createNativeLoadError,
formatNativeDiagnostics,
isNativeLoadError,
loadNativeBinding,
probeNativeBinary,
};
@@ -0,0 +1,30 @@
{
"name": "@fluxer/linux-screen-capture",
"version": "0.0.0",
"description": "",
"private": true,
"license": "AGPL-3.0-or-later",
"os": [
"linux"
],
"cpu": [
"x64",
"arm64"
],
"main": "index.js",
"types": "index.d.ts",
"files": [
"index.js",
"index.d.ts",
"loader-diagnostics.cjs",
"THIRD_PARTY_OBS_VKCAPTURE.md",
"obs-vkcapture/**/*",
"linux-screen-capture.linux-x64-gnu.node",
"linux-screen-capture.linux-arm64-gnu.node"
],
"scripts": {
"build": "cargo run --locked --quiet --manifest-path ../../../tools/ci/Cargo.toml -- build-desktop-native-addon",
"test": "cargo test --manifest-path Cargo.toml && node --test index.test.mjs",
"test:loader": "node --test index.test.mjs"
}
}
@@ -0,0 +1,768 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
pub const MAX_RECONNECT_ATTEMPTS: u32 = 8;
pub const RECONNECT_BACKOFF_BASE_MS: u64 = 100;
pub const RECONNECT_BACKOFF_CAP_MS: u64 = 5_000;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LinuxCaptureFault {
StreamError(i32),
PortalSessionLost,
NodeRemoved,
PermissionRevoked,
BufferUnderrun,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LinuxCaptureState {
Connecting {
since_ns: u64,
},
Active {
since_ns: u64,
},
Reconnecting {
since_ns: u64,
attempts: u32,
last_fault: LinuxCaptureFault,
},
Failed {
since_ns: u64,
final_fault: LinuxCaptureFault,
total_attempts: u32,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LinuxCaptureEvent {
Connected,
Faulted(LinuxCaptureFault),
ReconnectAttempted,
Reset,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LinuxCaptureAction {
None,
EnterActive,
ScheduleReconnect { attempt: u32, backoff_ms: u64 },
ReportFailure,
RestartFromFailed,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LinuxCaptureFsmError {
InvalidInputState,
InvalidOutputState,
InvariantViolated,
}
pub fn reconnect_backoff_ms(attempts: u32) -> u64 {
assert!(attempts >= 1, "backoff attempts must be >= 1");
assert!(
attempts <= MAX_RECONNECT_ATTEMPTS,
"backoff attempts must be <= MAX_RECONNECT_ATTEMPTS"
);
let shift = attempts - 1;
if shift >= 64 {
return RECONNECT_BACKOFF_CAP_MS;
}
let raw = RECONNECT_BACKOFF_BASE_MS.saturating_mul(1u64 << shift);
let capped = if raw > RECONNECT_BACKOFF_CAP_MS {
RECONNECT_BACKOFF_CAP_MS
} else {
raw
};
assert!(
capped >= RECONNECT_BACKOFF_BASE_MS,
"backoff produced sub-base value"
);
assert!(capped <= RECONNECT_BACKOFF_CAP_MS, "backoff exceeded cap");
capped
}
pub fn transition_linux_capture_state(
state: LinuxCaptureState,
event: LinuxCaptureEvent,
now_ns: u64,
) -> Result<(LinuxCaptureState, LinuxCaptureAction), LinuxCaptureFsmError> {
assert_state_invariants(&state).map_err(|_| LinuxCaptureFsmError::InvalidInputState)?;
let (next, action) = dispatch(state, event, now_ns)?;
assert_state_invariants(&next).map_err(|_| LinuxCaptureFsmError::InvalidOutputState)?;
Ok((next, action))
}
fn dispatch(
state: LinuxCaptureState,
event: LinuxCaptureEvent,
now_ns: u64,
) -> Result<(LinuxCaptureState, LinuxCaptureAction), LinuxCaptureFsmError> {
match state {
LinuxCaptureState::Connecting { .. } => from_connecting(state, event, now_ns),
LinuxCaptureState::Active { .. } => from_active(state, event, now_ns),
LinuxCaptureState::Reconnecting { .. } => from_reconnecting(state, event, now_ns),
LinuxCaptureState::Failed { .. } => from_failed(state, event, now_ns),
}
}
fn from_connecting(
state: LinuxCaptureState,
event: LinuxCaptureEvent,
now_ns: u64,
) -> Result<(LinuxCaptureState, LinuxCaptureAction), LinuxCaptureFsmError> {
debug_assert!(matches!(state, LinuxCaptureState::Connecting { .. }));
match event {
LinuxCaptureEvent::Connected => Ok((
LinuxCaptureState::Active { since_ns: now_ns },
LinuxCaptureAction::EnterActive,
)),
LinuxCaptureEvent::Faulted(fault) => {
let next = LinuxCaptureState::Reconnecting {
since_ns: now_ns,
attempts: 1,
last_fault: fault,
};
let backoff = reconnect_backoff_ms(1);
Ok((
next,
LinuxCaptureAction::ScheduleReconnect {
attempt: 1,
backoff_ms: backoff,
},
))
}
LinuxCaptureEvent::ReconnectAttempted => Ok((state, LinuxCaptureAction::None)),
LinuxCaptureEvent::Reset => Ok((state, LinuxCaptureAction::None)),
}
}
fn from_active(
state: LinuxCaptureState,
event: LinuxCaptureEvent,
now_ns: u64,
) -> Result<(LinuxCaptureState, LinuxCaptureAction), LinuxCaptureFsmError> {
debug_assert!(matches!(state, LinuxCaptureState::Active { .. }));
match event {
LinuxCaptureEvent::Connected => Ok((state, LinuxCaptureAction::None)),
LinuxCaptureEvent::Faulted(fault) => {
let next = LinuxCaptureState::Reconnecting {
since_ns: now_ns,
attempts: 1,
last_fault: fault,
};
let backoff = reconnect_backoff_ms(1);
Ok((
next,
LinuxCaptureAction::ScheduleReconnect {
attempt: 1,
backoff_ms: backoff,
},
))
}
LinuxCaptureEvent::ReconnectAttempted => Ok((state, LinuxCaptureAction::None)),
LinuxCaptureEvent::Reset => Ok((state, LinuxCaptureAction::None)),
}
}
fn from_reconnecting(
state: LinuxCaptureState,
event: LinuxCaptureEvent,
now_ns: u64,
) -> Result<(LinuxCaptureState, LinuxCaptureAction), LinuxCaptureFsmError> {
let (since_ns, attempts, last_fault) = match state {
LinuxCaptureState::Reconnecting {
since_ns,
attempts,
last_fault,
} => (since_ns, attempts, last_fault),
_ => return Err(LinuxCaptureFsmError::InvariantViolated),
};
assert!(
(1..=MAX_RECONNECT_ATTEMPTS).contains(&attempts),
"attempts out of range"
);
match event {
LinuxCaptureEvent::Connected => Ok((
LinuxCaptureState::Active { since_ns: now_ns },
LinuxCaptureAction::EnterActive,
)),
LinuxCaptureEvent::Faulted(new_fault) => {
handle_fault_while_reconnecting(since_ns, attempts, new_fault, now_ns)
}
LinuxCaptureEvent::ReconnectAttempted => {
handle_reconnect_attempt(since_ns, attempts, last_fault)
}
LinuxCaptureEvent::Reset => Ok((state, LinuxCaptureAction::None)),
}
}
fn handle_fault_while_reconnecting(
since_ns: u64,
attempts: u32,
new_fault: LinuxCaptureFault,
now_ns: u64,
) -> Result<(LinuxCaptureState, LinuxCaptureAction), LinuxCaptureFsmError> {
assert!(attempts >= 1, "attempts must be >= 1");
assert!(
attempts <= MAX_RECONNECT_ATTEMPTS,
"attempts must be <= MAX_RECONNECT_ATTEMPTS"
);
if attempts >= MAX_RECONNECT_ATTEMPTS {
let next = LinuxCaptureState::Failed {
since_ns: now_ns,
final_fault: new_fault,
total_attempts: attempts,
};
return Ok((next, LinuxCaptureAction::ReportFailure));
}
let bumped = attempts + 1;
let next = LinuxCaptureState::Reconnecting {
since_ns,
attempts: bumped,
last_fault: new_fault,
};
let backoff = reconnect_backoff_ms(bumped);
Ok((
next,
LinuxCaptureAction::ScheduleReconnect {
attempt: bumped,
backoff_ms: backoff,
},
))
}
fn handle_reconnect_attempt(
since_ns: u64,
attempts: u32,
last_fault: LinuxCaptureFault,
) -> Result<(LinuxCaptureState, LinuxCaptureAction), LinuxCaptureFsmError> {
assert!(attempts >= 1, "attempts must be >= 1");
assert!(
attempts <= MAX_RECONNECT_ATTEMPTS,
"attempts must be <= MAX_RECONNECT_ATTEMPTS"
);
let capped = if attempts >= MAX_RECONNECT_ATTEMPTS {
MAX_RECONNECT_ATTEMPTS
} else {
attempts + 1
};
let next = LinuxCaptureState::Reconnecting {
since_ns,
attempts: capped,
last_fault,
};
let backoff = reconnect_backoff_ms(capped);
Ok((
next,
LinuxCaptureAction::ScheduleReconnect {
attempt: capped,
backoff_ms: backoff,
},
))
}
fn from_failed(
state: LinuxCaptureState,
event: LinuxCaptureEvent,
now_ns: u64,
) -> Result<(LinuxCaptureState, LinuxCaptureAction), LinuxCaptureFsmError> {
debug_assert!(matches!(state, LinuxCaptureState::Failed { .. }));
match event {
LinuxCaptureEvent::Connected => Ok((state, LinuxCaptureAction::None)),
LinuxCaptureEvent::Faulted(_) => Ok((state, LinuxCaptureAction::None)),
LinuxCaptureEvent::ReconnectAttempted => Ok((state, LinuxCaptureAction::None)),
LinuxCaptureEvent::Reset => Ok((
LinuxCaptureState::Connecting { since_ns: now_ns },
LinuxCaptureAction::RestartFromFailed,
)),
}
}
fn assert_state_invariants(state: &LinuxCaptureState) -> Result<(), LinuxCaptureFsmError> {
match state {
LinuxCaptureState::Connecting { .. } => Ok(()),
LinuxCaptureState::Active { .. } => Ok(()),
LinuxCaptureState::Reconnecting { attempts, .. } => {
if *attempts < 1 {
return Err(LinuxCaptureFsmError::InvariantViolated);
}
if *attempts > MAX_RECONNECT_ATTEMPTS {
return Err(LinuxCaptureFsmError::InvariantViolated);
}
Ok(())
}
LinuxCaptureState::Failed { total_attempts, .. } => {
if *total_attempts > MAX_RECONNECT_ATTEMPTS {
return Err(LinuxCaptureFsmError::InvariantViolated);
}
Ok(())
}
}
}
pub type LinuxCaptureListener = Box<dyn FnMut(LinuxCaptureEvent, &LinuxCaptureState) + Send>;
pub struct LinuxCaptureStateMachine {
state: LinuxCaptureState,
listener: Option<LinuxCaptureListener>,
}
impl LinuxCaptureStateMachine {
pub fn new(now_ns: u64) -> Self {
let initial = LinuxCaptureState::Connecting { since_ns: now_ns };
assert!(matches!(initial, LinuxCaptureState::Connecting { .. }));
Self {
state: initial,
listener: None,
}
}
pub fn with_listener<F>(now_ns: u64, listener: F) -> Self
where
F: FnMut(LinuxCaptureEvent, &LinuxCaptureState) + Send + 'static,
{
let initial = LinuxCaptureState::Connecting { since_ns: now_ns };
assert!(matches!(initial, LinuxCaptureState::Connecting { .. }));
Self {
state: initial,
listener: Some(Box::new(listener)),
}
}
pub fn state(&self) -> &LinuxCaptureState {
&self.state
}
pub fn dispatch(
&mut self,
event: LinuxCaptureEvent,
now_ns: u64,
) -> Result<LinuxCaptureAction, LinuxCaptureFsmError> {
assert_state_invariants(&self.state)?;
let (next, action) = transition_linux_capture_state(self.state, event, now_ns)?;
self.state = next;
if let Some(listener) = self.listener.as_mut() {
listener(event, &self.state);
}
assert_state_invariants(&self.state)?;
Ok(action)
}
}
#[cfg(test)]
mod tests {
use super::*;
const T0: u64 = 1_000;
const T1: u64 = 2_000;
const T2: u64 = 3_000;
fn connecting(t: u64) -> LinuxCaptureState {
LinuxCaptureState::Connecting { since_ns: t }
}
fn active(t: u64) -> LinuxCaptureState {
LinuxCaptureState::Active { since_ns: t }
}
fn reconnecting(t: u64, attempts: u32, fault: LinuxCaptureFault) -> LinuxCaptureState {
LinuxCaptureState::Reconnecting {
since_ns: t,
attempts,
last_fault: fault,
}
}
fn failed(t: u64, fault: LinuxCaptureFault, total: u32) -> LinuxCaptureState {
LinuxCaptureState::Failed {
since_ns: t,
final_fault: fault,
total_attempts: total,
}
}
#[test]
fn connecting_plus_connected_goes_active() {
let (next, action) =
transition_linux_capture_state(connecting(T0), LinuxCaptureEvent::Connected, T1)
.unwrap();
assert_eq!(next, active(T1));
assert_eq!(action, LinuxCaptureAction::EnterActive);
}
#[test]
fn connecting_plus_faulted_goes_reconnecting() {
let fault = LinuxCaptureFault::StreamError(-13);
let (next, action) =
transition_linux_capture_state(connecting(T0), LinuxCaptureEvent::Faulted(fault), T1)
.unwrap();
assert_eq!(next, reconnecting(T1, 1, fault));
assert_eq!(
action,
LinuxCaptureAction::ScheduleReconnect {
attempt: 1,
backoff_ms: 100
}
);
}
#[test]
fn active_plus_faulted_goes_reconnecting() {
let fault = LinuxCaptureFault::PortalSessionLost;
let (next, action) =
transition_linux_capture_state(active(T0), LinuxCaptureEvent::Faulted(fault), T1)
.unwrap();
assert_eq!(next, reconnecting(T1, 1, fault));
assert_eq!(
action,
LinuxCaptureAction::ScheduleReconnect {
attempt: 1,
backoff_ms: 100
}
);
}
#[test]
fn active_plus_reconnect_attempted_is_noop() {
let (next, action) =
transition_linux_capture_state(active(T0), LinuxCaptureEvent::ReconnectAttempted, T1)
.unwrap();
assert_eq!(next, active(T0));
assert_eq!(action, LinuxCaptureAction::None);
}
#[test]
fn active_plus_connected_is_noop() {
let (next, action) =
transition_linux_capture_state(active(T0), LinuxCaptureEvent::Connected, T1).unwrap();
assert_eq!(next, active(T0));
assert_eq!(action, LinuxCaptureAction::None);
}
#[test]
fn reconnecting_plus_connected_goes_active() {
let fault = LinuxCaptureFault::NodeRemoved;
let (next, action) = transition_linux_capture_state(
reconnecting(T0, 3, fault),
LinuxCaptureEvent::Connected,
T1,
)
.unwrap();
assert_eq!(next, active(T1));
assert_eq!(action, LinuxCaptureAction::EnterActive);
}
#[test]
fn reconnecting_plus_reconnect_attempted_increments() {
let fault = LinuxCaptureFault::NodeRemoved;
let (next, action) = transition_linux_capture_state(
reconnecting(T0, 2, fault),
LinuxCaptureEvent::ReconnectAttempted,
T1,
)
.unwrap();
assert_eq!(next, reconnecting(T0, 3, fault));
assert_eq!(
action,
LinuxCaptureAction::ScheduleReconnect {
attempt: 3,
backoff_ms: 400
}
);
}
#[test]
fn reconnecting_plus_reconnect_attempted_caps_at_max() {
let fault = LinuxCaptureFault::BufferUnderrun;
let (next, action) = transition_linux_capture_state(
reconnecting(T0, MAX_RECONNECT_ATTEMPTS, fault),
LinuxCaptureEvent::ReconnectAttempted,
T1,
)
.unwrap();
assert_eq!(next, reconnecting(T0, MAX_RECONNECT_ATTEMPTS, fault));
assert_eq!(
action,
LinuxCaptureAction::ScheduleReconnect {
attempt: MAX_RECONNECT_ATTEMPTS,
backoff_ms: RECONNECT_BACKOFF_CAP_MS,
}
);
}
#[test]
fn reconnecting_plus_faulted_below_max_increments() {
let old = LinuxCaptureFault::NodeRemoved;
let new_fault = LinuxCaptureFault::StreamError(-7);
let (next, action) = transition_linux_capture_state(
reconnecting(T0, 2, old),
LinuxCaptureEvent::Faulted(new_fault),
T1,
)
.unwrap();
assert_eq!(next, reconnecting(T0, 3, new_fault));
assert_eq!(
action,
LinuxCaptureAction::ScheduleReconnect {
attempt: 3,
backoff_ms: 400
}
);
}
#[test]
fn reconnecting_plus_faulted_at_max_goes_failed() {
let old = LinuxCaptureFault::NodeRemoved;
let new_fault = LinuxCaptureFault::PermissionRevoked;
let (next, action) = transition_linux_capture_state(
reconnecting(T0, MAX_RECONNECT_ATTEMPTS, old),
LinuxCaptureEvent::Faulted(new_fault),
T2,
)
.unwrap();
assert_eq!(next, failed(T2, new_fault, MAX_RECONNECT_ATTEMPTS));
assert_eq!(action, LinuxCaptureAction::ReportFailure);
}
#[test]
fn failed_plus_reset_goes_connecting() {
let fault = LinuxCaptureFault::StreamError(-99);
let (next, action) = transition_linux_capture_state(
failed(T0, fault, MAX_RECONNECT_ATTEMPTS),
LinuxCaptureEvent::Reset,
T1,
)
.unwrap();
assert_eq!(next, connecting(T1));
assert_eq!(action, LinuxCaptureAction::RestartFromFailed);
}
#[test]
fn failed_plus_connected_stays_failed() {
let fault = LinuxCaptureFault::StreamError(-99);
let prior = failed(T0, fault, MAX_RECONNECT_ATTEMPTS);
let (next, action) =
transition_linux_capture_state(prior, LinuxCaptureEvent::Connected, T1).unwrap();
assert_eq!(next, prior);
assert_eq!(action, LinuxCaptureAction::None);
}
#[test]
fn failed_plus_faulted_stays_failed() {
let fault = LinuxCaptureFault::StreamError(-99);
let prior = failed(T0, fault, MAX_RECONNECT_ATTEMPTS);
let (next, action) = transition_linux_capture_state(
prior,
LinuxCaptureEvent::Faulted(LinuxCaptureFault::NodeRemoved),
T1,
)
.unwrap();
assert_eq!(next, prior);
assert_eq!(action, LinuxCaptureAction::None);
}
#[test]
fn connecting_plus_reset_is_noop() {
let (next, action) =
transition_linux_capture_state(connecting(T0), LinuxCaptureEvent::Reset, T1).unwrap();
assert_eq!(next, connecting(T0));
assert_eq!(action, LinuxCaptureAction::None);
}
#[test]
fn backoff_increases_exponentially_and_caps() {
assert_eq!(reconnect_backoff_ms(1), 100);
assert_eq!(reconnect_backoff_ms(2), 200);
assert_eq!(reconnect_backoff_ms(3), 400);
assert_eq!(reconnect_backoff_ms(4), 800);
assert_eq!(reconnect_backoff_ms(5), 1_600);
assert_eq!(reconnect_backoff_ms(6), 3_200);
assert_eq!(reconnect_backoff_ms(7), RECONNECT_BACKOFF_CAP_MS);
assert_eq!(reconnect_backoff_ms(8), RECONNECT_BACKOFF_CAP_MS);
}
#[test]
fn backoff_is_monotonic_until_cap() {
let mut prev = 0u64;
for n in 1..=MAX_RECONNECT_ATTEMPTS {
let b = reconnect_backoff_ms(n);
assert!(b >= prev, "backoff must be non-decreasing");
assert!(b <= RECONNECT_BACKOFF_CAP_MS, "backoff must respect cap");
prev = b;
}
}
#[test]
fn max_reconnect_attempts_reached_via_faults_goes_failed() {
let mut state = active(T0);
let mut now = T0;
for expected_attempts in 1..=MAX_RECONNECT_ATTEMPTS {
now += 1;
let fault = LinuxCaptureFault::StreamError(expected_attempts as i32);
let (next, _action) =
transition_linux_capture_state(state, LinuxCaptureEvent::Faulted(fault), now)
.unwrap();
match next {
LinuxCaptureState::Reconnecting { attempts, .. } => {
assert_eq!(attempts, expected_attempts);
}
_ => panic!("expected Reconnecting, got {:?}", next),
}
state = next;
}
now += 1;
let final_fault = LinuxCaptureFault::PermissionRevoked;
let (next, action) =
transition_linux_capture_state(state, LinuxCaptureEvent::Faulted(final_fault), now)
.unwrap();
assert!(matches!(next, LinuxCaptureState::Failed { .. }));
assert_eq!(action, LinuxCaptureAction::ReportFailure);
}
#[test]
fn invalid_input_state_rejected() {
let bad = LinuxCaptureState::Reconnecting {
since_ns: T0,
attempts: 0,
last_fault: LinuxCaptureFault::NodeRemoved,
};
let err =
transition_linux_capture_state(bad, LinuxCaptureEvent::Connected, T1).unwrap_err();
assert_eq!(err, LinuxCaptureFsmError::InvalidInputState);
}
#[test]
fn invalid_input_state_attempts_too_high_rejected() {
let bad = LinuxCaptureState::Reconnecting {
since_ns: T0,
attempts: MAX_RECONNECT_ATTEMPTS + 1,
last_fault: LinuxCaptureFault::NodeRemoved,
};
let err =
transition_linux_capture_state(bad, LinuxCaptureEvent::Connected, T1).unwrap_err();
assert_eq!(err, LinuxCaptureFsmError::InvalidInputState);
}
#[test]
fn determinism_same_input_same_output() {
let state = reconnecting(T0, 3, LinuxCaptureFault::NodeRemoved);
let ev = LinuxCaptureEvent::Faulted(LinuxCaptureFault::StreamError(-5));
let a = transition_linux_capture_state(state, ev, T2).unwrap();
let b = transition_linux_capture_state(state, ev, T2).unwrap();
let c = transition_linux_capture_state(state, ev, T2).unwrap();
assert_eq!(a, b);
assert_eq!(b, c);
}
struct Lcg(u64);
impl Lcg {
fn next(&mut self) -> u64 {
self.0 = self
.0
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
self.0
}
fn next_u32(&mut self, bound: u32) -> u32 {
assert!(bound > 0);
(self.next() % bound as u64) as u32
}
}
fn random_event(rng: &mut Lcg) -> LinuxCaptureEvent {
let kind = rng.next_u32(4);
match kind {
0 => LinuxCaptureEvent::Connected,
1 => LinuxCaptureEvent::Faulted(random_fault(rng)),
2 => LinuxCaptureEvent::ReconnectAttempted,
3 => LinuxCaptureEvent::Reset,
_ => unreachable!("rng bounded to 4"),
}
}
fn random_fault(rng: &mut Lcg) -> LinuxCaptureFault {
let kind = rng.next_u32(5);
match kind {
0 => LinuxCaptureFault::StreamError(-(rng.next_u32(128) as i32)),
1 => LinuxCaptureFault::PortalSessionLost,
2 => LinuxCaptureFault::NodeRemoved,
3 => LinuxCaptureFault::PermissionRevoked,
4 => LinuxCaptureFault::BufferUnderrun,
_ => unreachable!("rng bounded to 5"),
}
}
#[test]
fn invariants_hold_across_random_transitions() {
let mut rng = Lcg(0x9E37_79B9_7F4A_7C15);
let mut state = connecting(T0);
let mut now = T0;
for _ in 0..1_000 {
now = now.wrapping_add(1);
let ev = random_event(&mut rng);
let result = transition_linux_capture_state(state, ev, now);
let next = result.expect("invariant must hold for any valid input");
state = next.0;
assert!(assert_state_invariants(&state).is_ok());
}
}
#[test]
fn state_machine_dispatches_through_recovery() {
use std::sync::{Arc, Mutex};
let log: Arc<Mutex<Vec<(LinuxCaptureEvent, LinuxCaptureState)>>> =
Arc::new(Mutex::new(Vec::with_capacity(8)));
let log_clone = log.clone();
let mut fsm = LinuxCaptureStateMachine::with_listener(T0, move |ev, st| {
log_clone.lock().unwrap().push((ev, *st));
});
assert!(matches!(fsm.state(), LinuxCaptureState::Connecting { .. }));
let a = fsm.dispatch(LinuxCaptureEvent::Connected, T1).unwrap();
assert_eq!(a, LinuxCaptureAction::EnterActive);
assert!(matches!(fsm.state(), LinuxCaptureState::Active { .. }));
let b = fsm
.dispatch(
LinuxCaptureEvent::Faulted(LinuxCaptureFault::PortalSessionLost),
T2,
)
.unwrap();
assert_eq!(
b,
LinuxCaptureAction::ScheduleReconnect {
attempt: 1,
backoff_ms: 100
}
);
assert!(matches!(
fsm.state(),
LinuxCaptureState::Reconnecting { .. }
));
let c = fsm.dispatch(LinuxCaptureEvent::Connected, T2 + 1).unwrap();
assert_eq!(c, LinuxCaptureAction::EnterActive);
assert!(matches!(fsm.state(), LinuxCaptureState::Active { .. }));
let events = log.lock().unwrap();
assert_eq!(events.len(), 3);
}
#[test]
fn state_machine_dispatches_failure_then_reset() {
let mut fsm = LinuxCaptureStateMachine::new(T0);
fsm.dispatch(LinuxCaptureEvent::Connected, T0 + 1).unwrap();
for i in 0..=MAX_RECONNECT_ATTEMPTS {
let fault = LinuxCaptureFault::StreamError(-(i as i32) - 1);
fsm.dispatch(LinuxCaptureEvent::Faulted(fault), T0 + 10 + i as u64)
.unwrap();
}
match fsm.state() {
LinuxCaptureState::Failed { total_attempts, .. } => {
assert_eq!(*total_attempts, MAX_RECONNECT_ATTEMPTS);
}
other => panic!("expected Failed, got {:?}", other),
}
let action = fsm.dispatch(LinuxCaptureEvent::Reset, T0 + 1_000).unwrap();
assert_eq!(action, LinuxCaptureAction::RestartFromFailed);
assert!(matches!(fsm.state(), LinuxCaptureState::Connecting { .. }));
}
}
@@ -0,0 +1,425 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::cell::UnsafeCell;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use fluxer_screen_frame_bus::frame_pool::{
CpuFrameBuilder, FramePool, FramePoolError, PooledFrame,
};
use fluxer_screen_frame_bus::{FrameData, SharedFrameBytes};
pub const LINUX_SCREEN_FRAME_POOL_CAP: usize = 8;
pub const LINUX_FRAME_DIM_MAX: usize = 8192;
pub const LINUX_FRAME_BYTES_MAX: usize = LINUX_FRAME_DIM_MAX * LINUX_FRAME_DIM_MAX * 4;
struct SlotCell {
bytes: UnsafeCell<Box<[u8]>>,
}
unsafe impl Send for SlotCell {}
unsafe impl Sync for SlotCell {}
impl SharedFrameBytes for SlotCell {
fn bytes(&self) -> &[u8] {
unsafe { (*self.bytes.get()).as_ref() }
}
}
pub struct LinuxFrameBufferPool {
capacity_pool: FramePool,
slot_buffers: Box<[Arc<SlotCell>]>,
bytes_per_buffer: usize,
frames_dropped_pool_exhausted: AtomicU64,
frames_dropped_oversized: AtomicU64,
}
#[derive(Debug)]
pub enum LinuxFrameBufferPoolError {
BytesPerBufferZero,
BytesPerBufferOverflow,
CapacityPoolFailed(FramePoolError),
}
impl std::fmt::Display for LinuxFrameBufferPoolError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::BytesPerBufferZero => f.write_str("bytes_per_buffer must be positive"),
Self::BytesPerBufferOverflow => {
f.write_str("bytes_per_buffer exceeds LINUX_FRAME_BYTES_MAX")
}
Self::CapacityPoolFailed(err) => write!(f, "capacity pool init failed: {err}"),
}
}
}
impl std::error::Error for LinuxFrameBufferPoolError {}
impl LinuxFrameBufferPool {
pub fn new(bytes_per_buffer: usize) -> Result<Arc<Self>, LinuxFrameBufferPoolError> {
assert!(bytes_per_buffer > 0);
assert!(bytes_per_buffer <= LINUX_FRAME_BYTES_MAX);
if bytes_per_buffer == 0 {
return Err(LinuxFrameBufferPoolError::BytesPerBufferZero);
}
if bytes_per_buffer > LINUX_FRAME_BYTES_MAX {
return Err(LinuxFrameBufferPoolError::BytesPerBufferOverflow);
}
let capacity_pool =
CpuFrameBuilder::build_pool_with_capacity(LINUX_SCREEN_FRAME_POOL_CAP, 1)
.map_err(LinuxFrameBufferPoolError::CapacityPoolFailed)?;
assert_eq!(capacity_pool.capacity(), LINUX_SCREEN_FRAME_POOL_CAP);
let mut buffers: Vec<Arc<SlotCell>> = Vec::with_capacity(LINUX_SCREEN_FRAME_POOL_CAP);
for _ in 0..LINUX_SCREEN_FRAME_POOL_CAP {
let buf: Box<[u8]> = vec![0u8; bytes_per_buffer].into_boxed_slice();
assert_eq!(buf.len(), bytes_per_buffer);
buffers.push(Arc::new(SlotCell {
bytes: UnsafeCell::new(buf),
}));
}
assert_eq!(buffers.len(), LINUX_SCREEN_FRAME_POOL_CAP);
Ok(Arc::new(Self {
capacity_pool,
slot_buffers: buffers.into_boxed_slice(),
bytes_per_buffer,
frames_dropped_pool_exhausted: AtomicU64::new(0),
frames_dropped_oversized: AtomicU64::new(0),
}))
}
pub fn capacity(&self) -> usize {
assert_eq!(self.slot_buffers.len(), LINUX_SCREEN_FRAME_POOL_CAP);
assert_eq!(self.capacity_pool.capacity(), LINUX_SCREEN_FRAME_POOL_CAP);
LINUX_SCREEN_FRAME_POOL_CAP
}
pub fn bytes_per_buffer(&self) -> usize {
assert!(self.bytes_per_buffer > 0);
assert!(self.bytes_per_buffer <= LINUX_FRAME_BYTES_MAX);
self.bytes_per_buffer
}
pub fn frames_dropped_pool_exhausted(&self) -> u64 {
let dropped = self.frames_dropped_pool_exhausted.load(Ordering::Relaxed);
assert!(dropped <= u64::MAX / 2);
dropped
}
pub fn frames_dropped_oversized(&self) -> u64 {
let dropped = self.frames_dropped_oversized.load(Ordering::Relaxed);
assert!(dropped <= u64::MAX / 2);
dropped
}
pub fn note_frame_dropped_oversized(&self) {
let before = self
.frames_dropped_oversized
.fetch_add(1, Ordering::Relaxed);
assert!(before < u64::MAX / 2);
}
pub fn currently_in_flight(&self) -> u64 {
let in_flight = self.capacity_pool.currently_in_flight();
assert!(in_flight as usize <= LINUX_SCREEN_FRAME_POOL_CAP);
in_flight
}
pub fn try_acquire(self: &Arc<Self>) -> Option<PooledFrameBuffer> {
let pool_arc = Arc::clone(self);
let pooled = match self.capacity_pool.try_acquire() {
Some(p) => p,
None => {
let before = self
.frames_dropped_pool_exhausted
.fetch_add(1, Ordering::Relaxed);
assert!(before < u64::MAX / 2);
return None;
}
};
let slot_index = pooled.slot_index();
assert!(slot_index < self.slot_buffers.len());
Some(PooledFrameBuffer {
pool: pool_arc,
capacity_token: pooled,
slot_index,
len: 0,
})
}
}
pub struct PooledFrameBuffer {
pool: Arc<LinuxFrameBufferPool>,
capacity_token: PooledFrame,
slot_index: usize,
len: usize,
}
impl PooledFrameBuffer {
pub fn buffer_mut(&mut self) -> &mut [u8] {
assert!(self.slot_index < self.pool.slot_buffers.len());
let cell = &self.pool.slot_buffers[self.slot_index];
let slice: &mut [u8] = unsafe { (*cell.bytes.get()).as_mut() };
assert_eq!(slice.len(), self.pool.bytes_per_buffer);
slice
}
pub fn set_len(&mut self, len: usize) {
assert!(len <= self.pool.bytes_per_buffer);
assert!(len <= LINUX_FRAME_BYTES_MAX);
self.len = len;
}
pub fn as_slice(&self) -> &[u8] {
assert!(self.slot_index < self.pool.slot_buffers.len());
assert!(self.len <= self.pool.bytes_per_buffer);
let cell = &self.pool.slot_buffers[self.slot_index];
let slice: &[u8] = unsafe { (*cell.bytes.get()).as_ref() };
&slice[..self.len]
}
pub fn into_shared_frame_data(self) -> FrameData {
assert!(self.slot_index < self.pool.slot_buffers.len());
assert!(self.len <= self.pool.bytes_per_buffer);
let Self {
pool,
capacity_token,
slot_index,
len,
} = self;
let slot: Arc<SlotCell> = Arc::clone(&pool.slot_buffers[slot_index]);
let source: Arc<dyn SharedFrameBytes> = slot;
FrameData::from_shared(source, len, Some(capacity_token))
}
pub fn len(&self) -> usize {
assert!(self.len <= self.pool.bytes_per_buffer);
self.len
}
pub fn is_empty(&self) -> bool {
let empty = self.len == 0;
assert!(empty == (self.len == 0));
empty
}
pub fn slot_index(&self) -> usize {
assert!(self.slot_index < self.pool.slot_buffers.len());
self.slot_index
}
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_BYTES_PER_BUFFER: usize = 64 * 64 * 3 / 2;
#[test]
fn pool_capacity_is_eight_to_match_obs_encode_ring() {
assert_eq!(LINUX_SCREEN_FRAME_POOL_CAP, 8);
let pool = LinuxFrameBufferPool::new(TEST_BYTES_PER_BUFFER).expect("pool init");
assert_eq!(pool.capacity(), LINUX_SCREEN_FRAME_POOL_CAP);
assert_eq!(pool.bytes_per_buffer(), TEST_BYTES_PER_BUFFER);
}
#[test]
#[should_panic]
fn pool_rejects_zero_bytes_per_buffer() {
let _ = LinuxFrameBufferPool::new(0);
}
#[test]
#[should_panic]
fn pool_rejects_overflow_bytes_per_buffer() {
let _ = LinuxFrameBufferPool::new(LINUX_FRAME_BYTES_MAX + 1);
}
#[test]
fn pool_supports_4k_nv12_frames() {
const NV12_4K_BYTES: usize = 3840 * 2160 * 3 / 2;
const { assert!(NV12_4K_BYTES <= LINUX_FRAME_BYTES_MAX) };
let pool = LinuxFrameBufferPool::new(NV12_4K_BYTES).expect("4K NV12 pool init");
assert_eq!(pool.bytes_per_buffer(), NV12_4K_BYTES);
}
#[test]
fn pool_byte_cap_covers_max_negotiable_stream_dimensions() {
assert_eq!(LINUX_FRAME_DIM_MAX, 8192);
const NV12_MAX_DIM_BYTES: usize = LINUX_FRAME_DIM_MAX * LINUX_FRAME_DIM_MAX * 3 / 2;
const { assert!(NV12_MAX_DIM_BYTES <= LINUX_FRAME_BYTES_MAX) };
}
#[test]
fn oversized_drop_counter_starts_at_zero_and_increments() {
let pool = LinuxFrameBufferPool::new(TEST_BYTES_PER_BUFFER).expect("pool init");
assert_eq!(pool.frames_dropped_oversized(), 0);
pool.note_frame_dropped_oversized();
assert_eq!(pool.frames_dropped_oversized(), 1);
pool.note_frame_dropped_oversized();
assert_eq!(pool.frames_dropped_oversized(), 2);
assert_eq!(pool.frames_dropped_pool_exhausted(), 0);
}
#[test]
fn acquire_release_cycle_returns_pooled_buffer_to_circulation() {
let pool = LinuxFrameBufferPool::new(TEST_BYTES_PER_BUFFER).expect("pool init");
assert_eq!(pool.currently_in_flight(), 0);
let mut pooled = pool.try_acquire().expect("slot available");
assert_eq!(pool.currently_in_flight(), 1);
let buf = pooled.buffer_mut();
assert_eq!(buf.len(), TEST_BYTES_PER_BUFFER);
buf[0] = 0xAB;
buf[TEST_BYTES_PER_BUFFER - 1] = 0xCD;
pooled.set_len(TEST_BYTES_PER_BUFFER);
assert_eq!(pooled.as_slice().len(), TEST_BYTES_PER_BUFFER);
assert_eq!(pooled.as_slice()[0], 0xAB);
assert_eq!(pooled.as_slice()[TEST_BYTES_PER_BUFFER - 1], 0xCD);
drop(pooled);
assert_eq!(pool.currently_in_flight(), 0);
let _again = pool.try_acquire().expect("slot returned to pool");
assert_eq!(pool.currently_in_flight(), 1);
}
#[test]
fn ninth_acquire_when_eight_in_flight_returns_none_and_increments_dropped_counter() {
let pool = LinuxFrameBufferPool::new(TEST_BYTES_PER_BUFFER).expect("pool init");
let mut held = Vec::with_capacity(LINUX_SCREEN_FRAME_POOL_CAP);
for i in 0..LINUX_SCREEN_FRAME_POOL_CAP {
let slot = pool.try_acquire().expect("first eight must acquire");
assert_eq!(pool.currently_in_flight() as usize, i + 1);
held.push(slot);
}
assert_eq!(held.len(), 8);
assert_eq!(pool.frames_dropped_pool_exhausted(), 0);
let ninth = pool.try_acquire();
assert!(ninth.is_none(), "ninth acquire must skip-don't-block");
assert_eq!(pool.frames_dropped_pool_exhausted(), 1);
let tenth = pool.try_acquire();
assert!(tenth.is_none(), "tenth acquire must skip-don't-block");
assert_eq!(pool.frames_dropped_pool_exhausted(), 2);
drop(held);
assert_eq!(pool.currently_in_flight(), 0);
let revived = pool.try_acquire().expect("slot returned after releases");
assert_eq!(pool.currently_in_flight(), 1);
drop(revived);
}
#[test]
fn buffer_contents_persist_across_acquires_when_slot_recycled() {
let pool = LinuxFrameBufferPool::new(TEST_BYTES_PER_BUFFER).expect("pool init");
let mut first = pool.try_acquire().expect("slot");
let slot_index_first = first.slot_index();
first.buffer_mut().fill(0x42);
first.set_len(TEST_BYTES_PER_BUFFER);
drop(first);
let mut second = pool.try_acquire().expect("slot returned");
assert_eq!(second.slot_index(), slot_index_first);
let slice = second.buffer_mut();
assert_eq!(slice[0], 0x42);
assert_eq!(slice[TEST_BYTES_PER_BUFFER / 2], 0x42);
slice.fill(0x00);
drop(second);
}
#[test]
fn into_shared_frame_data_round_trips_bytes_and_returns_slot_on_drop() {
let pool = LinuxFrameBufferPool::new(TEST_BYTES_PER_BUFFER).expect("pool init");
let mut pooled = pool.try_acquire().expect("slot");
pooled.buffer_mut().fill(0x5A);
pooled.set_len(TEST_BYTES_PER_BUFFER / 2);
let slot_index = pooled.slot_index();
let shared = pooled.into_shared_frame_data();
assert_eq!(pool.currently_in_flight(), 1);
assert!(shared.is_shared());
assert_eq!(shared.len(), TEST_BYTES_PER_BUFFER / 2);
assert!(shared.as_slice().iter().all(|b| *b == 0x5A));
drop(shared);
assert_eq!(pool.currently_in_flight(), 0);
let again = pool.try_acquire().expect("slot returned by shared drop");
assert_eq!(again.slot_index(), slot_index);
}
#[test]
fn shared_frame_data_keeps_slot_bytes_alive_after_pool_drop() {
let pool = LinuxFrameBufferPool::new(TEST_BYTES_PER_BUFFER).expect("pool init");
let mut pooled = pool.try_acquire().expect("slot");
pooled.buffer_mut().fill(0x3C);
pooled.set_len(8);
let shared = pooled.into_shared_frame_data();
drop(pool);
assert_eq!(shared.as_slice(), &[0x3C; 8]);
}
#[test]
fn exhaustion_drop_policy_and_counters_unchanged_while_shared_frames_held() {
let pool = LinuxFrameBufferPool::new(TEST_BYTES_PER_BUFFER).expect("pool init");
let mut held = Vec::with_capacity(LINUX_SCREEN_FRAME_POOL_CAP);
for _ in 0..LINUX_SCREEN_FRAME_POOL_CAP {
let mut pooled = pool.try_acquire().expect("slot within capacity");
pooled.set_len(1);
held.push(pooled.into_shared_frame_data());
}
assert_eq!(
pool.currently_in_flight() as usize,
LINUX_SCREEN_FRAME_POOL_CAP
);
assert!(pool.try_acquire().is_none());
assert_eq!(pool.frames_dropped_pool_exhausted(), 1);
held.clear();
assert_eq!(pool.currently_in_flight(), 0);
let revived = pool
.try_acquire()
.expect("slots returned after shared drops");
assert_eq!(pool.frames_dropped_pool_exhausted(), 1);
drop(revived);
}
#[test]
fn pool_arc_strong_count_stays_bounded_under_acquire_churn() {
let pool = LinuxFrameBufferPool::new(TEST_BYTES_PER_BUFFER).expect("pool init");
for _ in 0..256 {
let p = pool.try_acquire().expect("slot");
drop(p);
}
assert!(Arc::strong_count(&pool) <= 4);
assert_eq!(pool.currently_in_flight(), 0);
}
#[test]
fn multi_thread_acquire_release_never_deadlocks_and_balances_counts() {
use std::sync::Arc as StdArc;
use std::thread;
let pool = pool_for_multi_thread();
let mut threads = Vec::with_capacity(4);
for _ in 0..4 {
let pool = StdArc::clone(&pool);
threads.push(thread::spawn(move || {
for _ in 0..256 {
if let Some(p) = pool.try_acquire() {
std::hint::black_box(p.slot_index());
}
}
}));
}
for h in threads {
h.join().expect("worker completes");
}
assert_eq!(pool.currently_in_flight(), 0);
}
fn pool_for_multi_thread() -> Arc<LinuxFrameBufferPool> {
LinuxFrameBufferPool::new(TEST_BYTES_PER_BUFFER).expect("pool init")
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,357 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use fluxer_gpu_rebuild::{
GpuDevice, GpuLossCallback, GpuLossRegistry, GpuQueue, GpuRebuildError, RegistrationGuard,
};
pub const MAX_PENDING_DEVICE_LOSS_EVENTS: u64 = 1 << 32;
pub struct DeviceLossTelemetry {
device_loss_total: AtomicU64,
rebuild_total: AtomicU64,
rebuild_failed_total: AtomicU64,
registry_present: AtomicBool,
}
impl DeviceLossTelemetry {
pub fn new() -> Self {
let telemetry = Self {
device_loss_total: AtomicU64::new(0),
rebuild_total: AtomicU64::new(0),
rebuild_failed_total: AtomicU64::new(0),
registry_present: AtomicBool::new(false),
};
assert_eq!(telemetry.device_loss_total.load(Ordering::Relaxed), 0);
assert_eq!(telemetry.rebuild_total.load(Ordering::Relaxed), 0);
telemetry
}
pub fn record_device_loss(&self) {
let before = self.device_loss_total.fetch_add(1, Ordering::Relaxed);
assert!(before < MAX_PENDING_DEVICE_LOSS_EVENTS);
assert!(before.wrapping_add(1) > before);
}
pub fn record_rebuild(&self, rebuilt: u32, failed: u32) {
assert!(rebuilt as u64 <= MAX_PENDING_DEVICE_LOSS_EVENTS);
assert!(failed as u64 <= MAX_PENDING_DEVICE_LOSS_EVENTS);
self.rebuild_total
.fetch_add(rebuilt as u64, Ordering::Relaxed);
self.rebuild_failed_total
.fetch_add(failed as u64, Ordering::Relaxed);
}
pub fn device_loss_total(&self) -> u64 {
let v = self.device_loss_total.load(Ordering::Relaxed);
assert!(v <= MAX_PENDING_DEVICE_LOSS_EVENTS);
v
}
pub fn rebuild_total(&self) -> u64 {
let v = self.rebuild_total.load(Ordering::Relaxed);
assert!(self.registry_present.load(Ordering::Relaxed) || v == 0);
v
}
pub fn rebuild_failed_total(&self) -> u64 {
let v = self.rebuild_failed_total.load(Ordering::Relaxed);
assert!(v <= self.rebuild_total.load(Ordering::Relaxed));
v
}
pub fn mark_registry_attached(&self) {
let prior = self.registry_present.swap(true, Ordering::AcqRel);
assert!(!prior, "telemetry must not be attached twice");
assert!(self.registry_present.load(Ordering::Acquire));
}
}
impl Default for DeviceLossTelemetry {
fn default() -> Self {
Self::new()
}
}
pub struct DeviceLossBridge {
registry: Arc<GpuLossRegistry>,
telemetry: Arc<DeviceLossTelemetry>,
}
impl DeviceLossBridge {
pub fn new(registry: Arc<GpuLossRegistry>, telemetry: Arc<DeviceLossTelemetry>) -> Self {
assert!(
Arc::strong_count(&registry) >= 1,
"registry arc must be alive"
);
assert!(
Arc::strong_count(&telemetry) >= 1,
"telemetry arc must be alive"
);
telemetry.mark_registry_attached();
Self {
registry,
telemetry,
}
}
pub fn register(&self, callback: Box<dyn GpuLossCallback>) -> RegistrationGuard {
assert!(
Arc::strong_count(&self.registry) >= 1,
"registry alive on register"
);
assert!(
Arc::strong_count(&self.telemetry) >= 1,
"telemetry alive on register"
);
self.registry.register(callback)
}
pub fn dispatch_device_lost(&self, device: &GpuDevice, queue: &GpuQueue) {
assert!(
Arc::strong_count(&self.registry) >= 1,
"registry alive on dispatch"
);
self.telemetry.record_device_loss();
let report = self.registry.handle_device_lost(device, queue);
assert!(
report.rebuilt_count + report.failed_count + report.vacant_count
== report.outcomes.len() as u32,
"report totals must reconcile with outcome vector",
);
self.telemetry
.record_rebuild(report.rebuilt_count, report.failed_count);
}
pub fn dispatch_device_loss_stub(&self) {
assert!(
Arc::strong_count(&self.registry) >= 1,
"registry alive on stub dispatch"
);
self.telemetry.record_device_loss();
}
pub fn telemetry(&self) -> &Arc<DeviceLossTelemetry> {
assert!(
Arc::strong_count(&self.telemetry) >= 1,
"telemetry alive on read"
);
&self.telemetry
}
pub fn registry(&self) -> &Arc<GpuLossRegistry> {
assert!(
Arc::strong_count(&self.registry) >= 1,
"registry alive on read"
);
&self.registry
}
}
#[cfg(feature = "wgpu")]
pub fn attach_wgpu_device_loss_hook(
device: &wgpu::Device,
bridge: Arc<DeviceLossBridge>,
) -> Result<(), GpuRebuildError> {
assert!(
Arc::strong_count(&bridge) >= 1,
"bridge arc must be alive when attaching hook"
);
let captured = Arc::clone(&bridge);
device.set_device_lost_callback(move |reason, message| {
let _ = reason;
let _ = message;
captured.telemetry.record_device_loss();
});
let _ = device;
Ok(())
}
#[cfg(not(feature = "wgpu"))]
pub fn attach_wgpu_device_loss_hook(bridge: Arc<DeviceLossBridge>) -> Result<(), GpuRebuildError> {
assert!(
Arc::strong_count(&bridge) >= 1,
"bridge arc must be alive when attaching stub"
);
bridge.dispatch_device_loss_stub();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use parking_lot::Mutex;
struct MockOwner {
released: Arc<AtomicU64>,
rebuilt: Arc<AtomicU64>,
label_text: &'static str,
ready: Arc<AtomicBool>,
fail_rebuild: bool,
}
impl GpuLossCallback for MockOwner {
fn release(&mut self) {
self.released.fetch_add(1, Ordering::SeqCst);
self.ready.store(false, Ordering::SeqCst);
}
fn rebuild(
&mut self,
_device: &GpuDevice,
_queue: &GpuQueue,
) -> Result<(), GpuRebuildError> {
self.rebuilt.fetch_add(1, Ordering::SeqCst);
if self.fail_rebuild {
return Err(GpuRebuildError::ResourceCreateFailed { reason: "mock" });
}
self.ready.store(true, Ordering::SeqCst);
Ok(())
}
fn is_ready(&self) -> bool {
self.ready.load(Ordering::SeqCst)
}
fn debug_label(&self) -> &'static str {
self.label_text
}
}
fn make_mock_owner(fail_rebuild: bool) -> (Box<MockOwner>, Arc<AtomicU64>, Arc<AtomicU64>) {
let released = Arc::new(AtomicU64::new(0));
let rebuilt = Arc::new(AtomicU64::new(0));
let ready = Arc::new(AtomicBool::new(true));
let owner = Box::new(MockOwner {
released: Arc::clone(&released),
rebuilt: Arc::clone(&rebuilt),
label_text: "mock-linux-screen",
ready,
fail_rebuild,
});
(owner, released, rebuilt)
}
#[cfg(not(feature = "wgpu"))]
fn fresh_device_queue() -> (GpuDevice, GpuQueue) {
(GpuDevice { id: 1 }, GpuQueue { id: 1 })
}
#[test]
fn telemetry_starts_at_zero_counters() {
let telemetry = DeviceLossTelemetry::new();
assert_eq!(telemetry.device_loss_total(), 0);
assert_eq!(telemetry.rebuild_total(), 0);
assert_eq!(telemetry.rebuild_failed_total(), 0);
}
#[test]
fn record_device_loss_increments_counter_monotonically() {
let telemetry = DeviceLossTelemetry::new();
telemetry.record_device_loss();
telemetry.record_device_loss();
telemetry.record_device_loss();
assert_eq!(telemetry.device_loss_total(), 3);
}
#[cfg(not(feature = "wgpu"))]
#[test]
fn dispatch_device_lost_drives_registry_and_telemetry() {
let registry = Arc::new(GpuLossRegistry::new());
let telemetry = Arc::new(DeviceLossTelemetry::new());
let bridge = DeviceLossBridge::new(Arc::clone(&registry), Arc::clone(&telemetry));
let (owner_a, released_a, rebuilt_a) = make_mock_owner(false);
let (owner_b, released_b, rebuilt_b) = make_mock_owner(false);
let _g1 = bridge.register(owner_a);
let _g2 = bridge.register(owner_b);
let (device, queue) = fresh_device_queue();
bridge.dispatch_device_lost(&device, &queue);
assert_eq!(released_a.load(Ordering::SeqCst), 1);
assert_eq!(released_b.load(Ordering::SeqCst), 1);
assert_eq!(rebuilt_a.load(Ordering::SeqCst), 1);
assert_eq!(rebuilt_b.load(Ordering::SeqCst), 1);
assert_eq!(telemetry.device_loss_total(), 1);
assert_eq!(telemetry.rebuild_total(), 2);
assert_eq!(telemetry.rebuild_failed_total(), 0);
}
#[cfg(not(feature = "wgpu"))]
#[test]
fn dispatch_device_lost_accumulates_failures_in_telemetry() {
let registry = Arc::new(GpuLossRegistry::new());
let telemetry = Arc::new(DeviceLossTelemetry::new());
let bridge = DeviceLossBridge::new(Arc::clone(&registry), Arc::clone(&telemetry));
let (owner_a, _, _) = make_mock_owner(true);
let (owner_b, _, _) = make_mock_owner(false);
let _g1 = bridge.register(owner_a);
let _g2 = bridge.register(owner_b);
let (device, queue) = fresh_device_queue();
bridge.dispatch_device_lost(&device, &queue);
bridge.dispatch_device_lost(&device, &queue);
assert_eq!(telemetry.device_loss_total(), 2);
assert_eq!(telemetry.rebuild_total(), 2);
assert_eq!(telemetry.rebuild_failed_total(), 2);
}
#[test]
fn bridge_marks_telemetry_attached_exactly_once() {
let registry = Arc::new(GpuLossRegistry::new());
let telemetry = Arc::new(DeviceLossTelemetry::new());
let _bridge = DeviceLossBridge::new(Arc::clone(&registry), Arc::clone(&telemetry));
assert!(telemetry.registry_present.load(Ordering::SeqCst));
}
#[test]
#[should_panic(expected = "telemetry must not be attached twice")]
fn telemetry_cannot_be_attached_twice() {
let registry = Arc::new(GpuLossRegistry::new());
let telemetry = Arc::new(DeviceLossTelemetry::new());
let _first = DeviceLossBridge::new(Arc::clone(&registry), Arc::clone(&telemetry));
let _second = DeviceLossBridge::new(Arc::clone(&registry), Arc::clone(&telemetry));
}
#[test]
fn registered_owners_remain_under_concurrent_register_pressure() {
let registry = Arc::new(GpuLossRegistry::new());
let telemetry = Arc::new(DeviceLossTelemetry::new());
let bridge = Arc::new(DeviceLossBridge::new(
Arc::clone(&registry),
Arc::clone(&telemetry),
));
let collected: Arc<Mutex<Vec<RegistrationGuard>>> = Arc::new(Mutex::new(Vec::new()));
let mut handles = Vec::new();
for _ in 0..8 {
let bridge = Arc::clone(&bridge);
let collected = Arc::clone(&collected);
handles.push(std::thread::spawn(move || {
let (owner, _, _) = make_mock_owner(false);
let guard = bridge.register(owner);
collected.lock().push(guard);
}));
}
for h in handles {
h.join().expect("worker must complete");
}
assert_eq!(registry.len(), 8);
}
#[test]
fn stub_dispatch_increments_only_telemetry_counter() {
let registry = Arc::new(GpuLossRegistry::new());
let telemetry = Arc::new(DeviceLossTelemetry::new());
let bridge = DeviceLossBridge::new(Arc::clone(&registry), Arc::clone(&telemetry));
bridge.dispatch_device_loss_stub();
bridge.dispatch_device_loss_stub();
assert_eq!(telemetry.device_loss_total(), 2);
assert_eq!(telemetry.rebuild_total(), 0);
}
}
@@ -0,0 +1,27 @@
#![deny(clippy::all)]
// SPDX-License-Identifier: AGPL-3.0-or-later
#![allow(dead_code)]
#![allow(clippy::missing_safety_doc)]
#![allow(clippy::collapsible_if)]
#![allow(clippy::too_many_arguments)]
#![allow(clippy::missing_const_for_thread_local)]
#![allow(clippy::manual_is_multiple_of)]
#![allow(clippy::manual_saturating_arithmetic)]
pub mod capture_state;
pub mod frame_buffer_pool;
pub mod gpu_loss;
pub mod nv12_packing;
#[cfg(target_os = "linux")]
pub mod game_capture;
#[cfg(target_os = "linux")]
pub mod pipewire_stream;
#[cfg(target_os = "linux")]
pub mod portal;
#[cfg(target_os = "linux")]
mod napi_surface_linux;
#[cfg(not(target_os = "linux"))]
mod napi_surface_stub;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use napi::bindgen_prelude::{Error, Result, Status};
use napi_derive::napi;
fn unsupported() -> Error {
Error::new(
Status::GenericFailure,
"@fluxer/linux-screen-capture is only supported on Linux",
)
}
#[napi(object, js_name = "LinuxScreenCaptureSource")]
pub struct LinuxScreenCaptureSource {
pub kind: String,
pub id: String,
pub name: String,
pub width: u32,
pub height: u32,
pub app_name: Option<String>,
pub bundle_id: Option<String>,
pub target_pid: Option<u32>,
}
#[napi(js_name = "listSources")]
pub fn list_sources() -> Result<Vec<LinuxScreenCaptureSource>> {
Ok(Vec::new())
}
#[napi(object, js_name = "LinuxScreenCaptureCapabilities")]
pub struct Capabilities {
pub process: bool,
pub system: bool,
}
#[napi(object, js_name = "LinuxScreenCaptureAvailability")]
pub struct Availability {
pub available: bool,
pub backend: String,
pub reason: Option<String>,
pub detail: Option<String>,
pub portal_version: Option<u32>,
pub capabilities: Capabilities,
}
#[napi(js_name = "getAvailability")]
pub fn get_availability() -> Result<Availability> {
Ok(Availability {
available: false,
backend: "linux-pipewire-portal".to_string(),
reason: Some("unsupported-platform".to_string()),
detail: None,
portal_version: None,
capabilities: Capabilities {
process: false,
system: false,
},
})
}
#[napi(object, js_name = "LinuxScreenCaptureBackendInfo")]
pub struct BackendInfo {
pub backend: String,
pub supported: bool,
pub reason: String,
pub portal_version: Option<u32>,
pub pipewire_reachable: bool,
}
#[napi(js_name = "getBackendInfo")]
pub fn get_backend_info() -> BackendInfo {
BackendInfo {
backend: "linux-pipewire-portal".to_string(),
supported: false,
reason: "@fluxer/linux-screen-capture is only supported on Linux".to_string(),
portal_version: None,
pipewire_reachable: false,
}
}
#[napi]
pub struct ScreenCapture;
#[napi]
impl ScreenCapture {
#[napi(constructor)]
pub fn new() -> Result<Self> {
Err(unsupported())
}
}
@@ -0,0 +1,491 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#[derive(Debug, Clone, Copy)]
pub struct Nv12Layout {
pub width: u32,
pub height: u32,
pub stride_y: u32,
pub stride_uv: u32,
}
impl Nv12Layout {
pub fn packed_size(&self) -> Option<usize> {
if self.width == 0 || self.height == 0 || self.height % 2 != 0 {
return None;
}
let w = self.width as usize;
let h = self.height as usize;
let y_bytes = w.checked_mul(h)?;
let uv_bytes = w.checked_mul(h / 2)?;
y_bytes.checked_add(uv_bytes)
}
pub fn packed_stride_y(&self) -> u32 {
self.width
}
pub fn packed_stride_uv(&self) -> u32 {
self.width
}
}
pub fn pack_nv12(layout: Nv12Layout, y_plane: &[u8], uv_plane: &[u8], dst: &mut [u8]) -> bool {
let Some(total) = layout.packed_size() else {
return false;
};
if dst.len() != total {
return false;
}
let w = layout.width as usize;
let h = layout.height as usize;
let stride_y = layout.stride_y as usize;
let stride_uv = layout.stride_uv as usize;
if stride_y < w || stride_uv < w {
return false;
}
if y_plane.len() < stride_y * h {
return false;
}
if uv_plane.len() < stride_uv * (h / 2) {
return false;
}
let y_bytes = w * h;
let uv_bytes = w * (h / 2);
if stride_y == w && stride_uv == w {
dst[..y_bytes].copy_from_slice(&y_plane[..y_bytes]);
dst[y_bytes..y_bytes + uv_bytes].copy_from_slice(&uv_plane[..uv_bytes]);
} else {
let (dst_y, dst_uv) = dst.split_at_mut(y_bytes);
for row in 0..h {
let src_off = row * stride_y;
let dst_off = row * w;
dst_y[dst_off..dst_off + w].copy_from_slice(&y_plane[src_off..src_off + w]);
}
for row in 0..h / 2 {
let src_off = row * stride_uv;
let dst_off = row * w;
dst_uv[dst_off..dst_off + w].copy_from_slice(&uv_plane[src_off..src_off + w]);
}
}
true
}
#[cfg(target_os = "linux")]
fn bgra_to_nv12_dcp(w: u32, h: u32, bgra: &[u8], bgra_stride: usize, dst: &mut [u8]) -> bool {
use dcv_color_primitives as dcp;
let src_format = dcp::ImageFormat {
pixel_format: dcp::PixelFormat::Bgra,
color_space: dcp::ColorSpace::Rgb,
num_planes: 1,
};
let dst_format = dcp::ImageFormat {
pixel_format: dcp::PixelFormat::Nv12,
color_space: dcp::ColorSpace::Bt601,
num_planes: 2,
};
let y_bytes = (w as usize) * (h as usize);
let (dst_y, dst_uv) = dst.split_at_mut(y_bytes);
dcp::convert_image(
w,
h,
&src_format,
Some(&[bgra_stride]),
&[bgra],
&dst_format,
Some(&[w as usize, w as usize]),
&mut [dst_y, dst_uv],
)
.is_ok()
}
fn flip_nv12_vertical(w: usize, h: usize, dst: &mut [u8]) {
assert!(w > 0);
assert!(h % 2 == 0);
let y_bytes = w * h;
let uv_bytes = w * (h / 2);
assert!(dst.len() >= y_bytes + uv_bytes);
let (y_plane, rest) = dst.split_at_mut(y_bytes);
flip_plane_rows(y_plane, w, h);
flip_plane_rows(&mut rest[..uv_bytes], w, h / 2);
}
fn flip_plane_rows(plane: &mut [u8], row_bytes: usize, rows: usize) {
assert!(row_bytes > 0);
assert!(plane.len() >= row_bytes * rows);
for row in 0..rows / 2 {
let top_start = row * row_bytes;
let bottom_start = (rows - 1 - row) * row_bytes;
let (head, tail) = plane.split_at_mut(bottom_start);
head[top_start..top_start + row_bytes].swap_with_slice(&mut tail[..row_bytes]);
}
}
fn bgra_to_nv12_scalar(
w: usize,
h: usize,
bgra: &[u8],
bgra_row: usize,
dst: &mut [u8],
flip: bool,
) -> bool {
let y_bytes = w * h;
let (dst_y, dst_uv) = dst.split_at_mut(y_bytes);
for row in 0..h {
let dst_row_index = if flip { h - 1 - row } else { row };
let src_row = &bgra[row * bgra_row..row * bgra_row + w * 4];
let dst_row = &mut dst_y[dst_row_index * w..dst_row_index * w + w];
for col in 0..w {
let b = src_row[col * 4] as i32;
let g = src_row[col * 4 + 1] as i32;
let r = src_row[col * 4 + 2] as i32;
let y = (66 * r + 129 * g + 25 * b + 128) >> 8;
dst_row[col] = (y + 16).clamp(0, 255) as u8;
}
}
for row in 0..h / 2 {
let dst_row_index = if flip { h / 2 - 1 - row } else { row };
let r0 = &bgra[(row * 2) * bgra_row..(row * 2) * bgra_row + w * 4];
let r1 = &bgra[(row * 2 + 1) * bgra_row..(row * 2 + 1) * bgra_row + w * 4];
let dst_row = &mut dst_uv[dst_row_index * w..dst_row_index * w + w];
for col in 0..w / 2 {
let cx0 = col * 2 * 4;
let cx1 = (col * 2 + 1) * 4;
let b = (r0[cx0] as i32 + r0[cx1] as i32 + r1[cx0] as i32 + r1[cx1] as i32) >> 2;
let g =
(r0[cx0 + 1] as i32 + r0[cx1 + 1] as i32 + r1[cx0 + 1] as i32 + r1[cx1 + 1] as i32)
>> 2;
let r =
(r0[cx0 + 2] as i32 + r0[cx1 + 2] as i32 + r1[cx0 + 2] as i32 + r1[cx1 + 2] as i32)
>> 2;
let u = ((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128;
let v = ((112 * r - 94 * g - 18 * b + 128) >> 8) + 128;
dst_row[col * 2] = u.clamp(0, 255) as u8;
dst_row[col * 2 + 1] = v.clamp(0, 255) as u8;
}
}
true
}
pub fn bgra_to_nv12(
layout: Nv12Layout,
bgra: &[u8],
bgra_stride: u32,
dst: &mut [u8],
flip: bool,
) -> bool {
let Some(total) = layout.packed_size() else {
return false;
};
if dst.len() < total {
return false;
}
let w = layout.width as usize;
let h = layout.height as usize;
let bgra_row = bgra_stride as usize;
if bgra_row < w.checked_mul(4).unwrap_or(usize::MAX) {
return false;
}
if bgra.len() < bgra_row.checked_mul(h).unwrap_or(usize::MAX) {
return false;
}
#[cfg(target_os = "linux")]
if bgra_to_nv12_dcp(layout.width, layout.height, bgra, bgra_row, dst) {
if flip {
flip_nv12_vertical(w, h, dst);
}
return true;
}
bgra_to_nv12_scalar(w, h, bgra, bgra_row, dst, flip)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_y(width: usize, height: usize, stride: usize) -> Vec<u8> {
let mut v = vec![0u8; stride * height];
for row in 0..height {
for col in 0..width {
v[row * stride + col] = ((row * width + col) % 251) as u8;
}
}
v
}
fn make_uv(width: usize, height_half: usize, stride: usize) -> Vec<u8> {
let mut v = vec![0u8; stride * height_half];
for row in 0..height_half {
for col in 0..width {
v[row * stride + col] = ((row * width + col + 7) % 241) as u8;
}
}
v
}
#[test]
fn packed_size_rejects_odd_height() {
let layout = Nv12Layout {
width: 16,
height: 15,
stride_y: 16,
stride_uv: 16,
};
assert!(layout.packed_size().is_none());
}
#[test]
fn packed_size_rejects_zero_dims() {
let layout = Nv12Layout {
width: 0,
height: 4,
stride_y: 0,
stride_uv: 0,
};
assert!(layout.packed_size().is_none());
}
#[test]
fn packed_size_matches_yuv420_layout() {
let layout = Nv12Layout {
width: 1920,
height: 1080,
stride_y: 1920,
stride_uv: 1920,
};
assert_eq!(Some(1920 * 1080 * 3 / 2), layout.packed_size());
}
#[test]
fn pack_nv12_strips_row_padding() {
let layout = Nv12Layout {
width: 8,
height: 4,
stride_y: 16,
stride_uv: 16,
};
let y = make_y(8, 4, 16);
let uv = make_uv(8, 2, 16);
let mut dst = vec![0u8; layout.packed_size().unwrap()];
assert!(pack_nv12(layout, &y, &uv, &mut dst));
for row in 0..4 {
for col in 0..8 {
assert_eq!(dst[row * 8 + col], ((row * 8 + col) % 251) as u8);
}
}
for row in 0..2 {
for col in 0..8 {
let off = 8 * 4 + row * 8 + col;
assert_eq!(dst[off], ((row * 8 + col + 7) % 241) as u8);
}
}
}
#[test]
fn pack_nv12_zero_padding_is_identity() {
let layout = Nv12Layout {
width: 4,
height: 2,
stride_y: 4,
stride_uv: 4,
};
let y = vec![1, 2, 3, 4, 5, 6, 7, 8];
let uv = vec![10, 11, 12, 13];
let mut dst = vec![0u8; layout.packed_size().unwrap()];
assert!(pack_nv12(layout, &y, &uv, &mut dst));
assert_eq!(dst, vec![1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13]);
}
#[test]
fn pack_nv12_rejects_short_source() {
let layout = Nv12Layout {
width: 4,
height: 2,
stride_y: 4,
stride_uv: 4,
};
let y = vec![1, 2, 3];
let uv = vec![10, 11, 12, 13];
let mut dst = vec![0u8; layout.packed_size().unwrap()];
assert!(!pack_nv12(layout, &y, &uv, &mut dst));
}
#[test]
fn pack_nv12_rejects_undersized_stride() {
let layout = Nv12Layout {
width: 8,
height: 2,
stride_y: 4,
stride_uv: 4,
};
let y = vec![0; 8];
let uv = vec![0; 4];
let mut dst = vec![0u8; 24];
assert!(!pack_nv12(layout, &y, &uv, &mut dst));
}
#[test]
fn pack_nv12_rejects_wrong_dst_size() {
let layout = Nv12Layout {
width: 4,
height: 2,
stride_y: 4,
stride_uv: 4,
};
let y = vec![0; 8];
let uv = vec![0; 4];
let mut dst = vec![0u8; 11];
assert!(!pack_nv12(layout, &y, &uv, &mut dst));
}
#[test]
fn bgra_to_nv12_solid_black() {
let layout = Nv12Layout {
width: 4,
height: 2,
stride_y: 4,
stride_uv: 4,
};
let bgra = vec![0u8; 4 * 4 * 2];
let mut dst = vec![0u8; layout.packed_size().unwrap()];
assert!(bgra_to_nv12(layout, &bgra, 16, &mut dst, false));
for byte in &dst[..8] {
assert_eq!(*byte, 16);
}
for chunk in dst[8..].chunks_exact(2) {
assert_eq!(chunk[0], 128);
assert_eq!(chunk[1], 128);
}
}
#[test]
fn bgra_to_nv12_solid_white_is_in_range() {
let layout = Nv12Layout {
width: 4,
height: 2,
stride_y: 4,
stride_uv: 4,
};
let bgra = vec![255u8; 4 * 4 * 2];
let mut dst = vec![0u8; layout.packed_size().unwrap()];
assert!(bgra_to_nv12(layout, &bgra, 16, &mut dst, false));
for byte in &dst[..8] {
assert!(*byte >= 230 && *byte <= 240, "luma out of range: {byte}");
}
for chunk in dst[8..].chunks_exact(2) {
assert!(
chunk[0] >= 124 && chunk[0] <= 132,
"U out of range: {}",
chunk[0]
);
assert!(
chunk[1] >= 124 && chunk[1] <= 132,
"V out of range: {}",
chunk[1]
);
}
}
#[test]
fn bgra_to_nv12_rejects_short_stride() {
let layout = Nv12Layout {
width: 4,
height: 2,
stride_y: 4,
stride_uv: 4,
};
let bgra = vec![0u8; 8];
let mut dst = vec![0u8; layout.packed_size().unwrap()];
assert!(!bgra_to_nv12(layout, &bgra, 4, &mut dst, false));
}
fn deterministic_bgra_frame(w: usize, h: usize, seed: u64) -> Vec<u8> {
let mut state = seed;
let mut v = vec![0u8; w * h * 4];
for byte in v.iter_mut() {
state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
*byte = (state >> 56) as u8;
}
v
}
fn row_reversed(bgra: &[u8], w: usize, h: usize) -> Vec<u8> {
let row_bytes = w * 4;
assert_eq!(bgra.len(), row_bytes * h);
let mut reversed = vec![0u8; bgra.len()];
for row in 0..h {
let src = (h - 1 - row) * row_bytes;
reversed[row * row_bytes..(row + 1) * row_bytes]
.copy_from_slice(&bgra[src..src + row_bytes]);
}
reversed
}
#[test]
fn bgra_to_nv12_flip_matches_pre_reversed_rows() {
for (w, h, seed) in [(8usize, 4usize, 1u64), (16, 8, 2), (64, 32, 3), (12, 6, 4)] {
let layout = Nv12Layout {
width: w as u32,
height: h as u32,
stride_y: w as u32,
stride_uv: w as u32,
};
let bgra = deterministic_bgra_frame(w, h, seed);
let reversed = row_reversed(&bgra, w, h);
let total = layout.packed_size().expect("even dims");
let mut flipped = vec![0u8; total];
let mut reference = vec![0u8; total];
assert!(bgra_to_nv12(
layout,
&bgra,
(w * 4) as u32,
&mut flipped,
true
));
assert!(bgra_to_nv12(
layout,
&reversed,
(w * 4) as u32,
&mut reference,
false
));
assert_eq!(flipped, reference, "mismatch at {w}x{h} seed {seed}");
}
}
#[test]
fn bgra_to_nv12_flip_moves_top_row_luma_to_bottom() {
let layout = Nv12Layout {
width: 4,
height: 4,
stride_y: 4,
stride_uv: 4,
};
let mut bgra = vec![0u8; 4 * 4 * 4];
bgra[..16].fill(255);
let mut dst = vec![0u8; layout.packed_size().unwrap()];
assert!(bgra_to_nv12(layout, &bgra, 16, &mut dst, true));
let y = &dst[..16];
assert!(y[12] > 200, "bottom row should hold the white luma: {y:?}");
assert!(y[0] < 32, "top row should hold the black luma: {y:?}");
}
#[test]
fn bgra_to_nv12_unflipped_path_is_unchanged_by_flip_support() {
let layout = Nv12Layout {
width: 8,
height: 4,
stride_y: 8,
stride_uv: 8,
};
let bgra = deterministic_bgra_frame(8, 4, 9);
let total = layout.packed_size().unwrap();
let mut first = vec![0u8; total];
let mut second = vec![0u8; total];
assert!(bgra_to_nv12(layout, &bgra, 32, &mut first, false));
assert!(bgra_to_nv12(layout, &bgra, 32, &mut second, false));
assert_eq!(first, second);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,664 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::collections::HashMap;
use std::env;
use std::os::fd::OwnedFd;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, mpsc};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use futures_lite::{FutureExt, StreamExt, future};
use zbus::names::OwnedUniqueName;
use zbus::{
MatchRule, MessageStream,
blocking::{Connection as BlockingConnection, Proxy as BlockingProxy},
message::Type as MessageType,
zvariant::{OwnedObjectPath, OwnedValue, Value},
};
pub const PORTAL_DESTINATION: &str = "org.freedesktop.portal.Desktop";
pub const PORTAL_PATH: &str = "/org/freedesktop/portal/desktop";
pub const SCREEN_CAST_INTERFACE: &str = "org.freedesktop.portal.ScreenCast";
pub const REQUEST_INTERFACE: &str = "org.freedesktop.portal.Request";
pub const SESSION_INTERFACE: &str = "org.freedesktop.portal.Session";
pub const PROPERTIES_INTERFACE: &str = "org.freedesktop.DBus.Properties";
pub const REGISTRY_INTERFACE: &str = "org.freedesktop.host.portal.Registry";
pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(5 * 60);
const SIGNAL_POLL_INTERVAL: Duration = Duration::from_millis(200);
const MIN_PORTAL_VERSION: u32 = 4;
const DESKTOP_ENTRY_ID_ENV: &str = "FLUXER_LINUX_DESKTOP_ENTRY_ID";
pub const CURSOR_MODE_HIDDEN: u32 = 1;
pub const CURSOR_MODE_EMBEDDED: u32 = 2;
pub const CURSOR_MODE_METADATA: u32 = 4;
pub const SOURCE_TYPE_MONITOR: u32 = 1;
pub const SOURCE_TYPE_WINDOW: u32 = 2;
pub const SOURCE_TYPES_ALL: u32 = SOURCE_TYPE_MONITOR | SOURCE_TYPE_WINDOW;
static TOKEN_SEQ: AtomicU64 = AtomicU64::new(1);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PortalError {
DbusError,
PortalTimeout,
InvalidReply,
SendFailed,
Cancelled,
CursorModeUnavailable,
PortalTooOld(u32),
NoStreams,
}
impl std::fmt::Display for PortalError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::DbusError => f.write_str("DbusError"),
Self::PortalTimeout => f.write_str("PortalTimeout"),
Self::InvalidReply => f.write_str("InvalidReply"),
Self::SendFailed => f.write_str("SendFailed"),
Self::Cancelled => f.write_str("Cancelled"),
Self::CursorModeUnavailable => f.write_str("CursorModeUnavailable"),
Self::PortalTooOld(v) => write!(f, "PortalTooOld(version={v})"),
Self::NoStreams => f.write_str("NoStreams"),
}
}
}
pub fn mint_token(prefix: &str) -> String {
let seq = TOKEN_SEQ.fetch_add(1, Ordering::Relaxed);
let ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
format!("{prefix}_{ms:x}_{seq:x}")
}
pub fn request_path(unique_bus_name: &str, handle_token: &str) -> String {
let trimmed = unique_bus_name.strip_prefix(':').unwrap_or(unique_bus_name);
let mut out = String::with_capacity(40 + trimmed.len() + handle_token.len());
out.push_str("/org/freedesktop/portal/desktop/request/");
for ch in trimmed.chars() {
out.push(if ch == '.' { '_' } else { ch });
}
out.push('/');
out.push_str(handle_token);
out
}
pub fn session_path(unique_bus_name: &str, session_token: &str) -> String {
let trimmed = unique_bus_name.strip_prefix(':').unwrap_or(unique_bus_name);
let mut out = String::with_capacity(40 + trimmed.len() + session_token.len());
out.push_str("/org/freedesktop/portal/desktop/session/");
for ch in trimmed.chars() {
out.push(if ch == '.' { '_' } else { ch });
}
out.push('/');
out.push_str(session_token);
out
}
pub fn read_portal_version() -> Result<u32, PortalError> {
let conn = blocking_session_conn()?;
register_portal_app_id(&conn);
let proxy = BlockingProxy::new(&conn, PORTAL_DESTINATION, PORTAL_PATH, PROPERTIES_INTERFACE)
.map_err(|_| PortalError::DbusError)?;
let value: OwnedValue = proxy
.call("Get", &(SCREEN_CAST_INTERFACE, "version"))
.map_err(|_| PortalError::DbusError)?;
let v: &Value<'_> = &value;
match v {
Value::U32(n) => Ok(*n),
Value::Value(inner) => match inner.as_ref() {
Value::U32(n) => Ok(*n),
_ => Err(PortalError::InvalidReply),
},
_ => Err(PortalError::InvalidReply),
}
}
pub fn read_available_cursor_modes() -> Result<u32, PortalError> {
let conn = blocking_session_conn()?;
register_portal_app_id(&conn);
let proxy = BlockingProxy::new(&conn, PORTAL_DESTINATION, PORTAL_PATH, PROPERTIES_INTERFACE)
.map_err(|_| PortalError::DbusError)?;
let value: OwnedValue = proxy
.call("Get", &(SCREEN_CAST_INTERFACE, "AvailableCursorModes"))
.map_err(|_| PortalError::DbusError)?;
let v: &Value<'_> = &value;
match v {
Value::U32(n) => Ok(*n),
Value::Value(inner) => match inner.as_ref() {
Value::U32(n) => Ok(*n),
_ => Err(PortalError::InvalidReply),
},
_ => Err(PortalError::InvalidReply),
}
}
#[derive(Debug, Clone)]
pub struct StreamInfo {
pub node_id: u32,
pub source_type: u32,
pub mapping_id: Option<String>,
pub width: u32,
pub height: u32,
pub position_x: i32,
pub position_y: i32,
}
#[derive(Debug, Clone)]
pub struct StartedSession {
pub session_handle: String,
pub streams: Vec<StreamInfo>,
}
pub struct LiveSession {
pub handle: String,
pub conn: BlockingConnection,
}
impl LiveSession {
pub fn close(self) {
let path = OwnedObjectPath::try_from(self.handle.as_str())
.ok()
.and_then(|p| {
BlockingProxy::new(&self.conn, PORTAL_DESTINATION, p, SESSION_INTERFACE).ok()
});
if let Some(proxy) = path {
let _ = proxy.call::<_, _, ()>("Close", &());
}
}
}
fn blocking_session_conn() -> Result<BlockingConnection, PortalError> {
zbus::blocking::connection::Builder::session()
.map_err(|_| PortalError::DbusError)?
.method_timeout(Duration::from_secs(30))
.build()
.map_err(|_| PortalError::DbusError)
}
fn unique_name(conn: &BlockingConnection) -> Result<OwnedUniqueName, PortalError> {
conn.unique_name()
.ok_or(PortalError::DbusError)
.map(|n| n.to_owned())
}
fn normalize_desktop_entry_app_id(value: &str) -> Option<String> {
let trimmed = value.trim();
let app_id = trimmed.strip_suffix(".desktop").unwrap_or(trimmed);
if app_id.is_empty() || app_id.len() > 255 || app_id.starts_with('.') {
return None;
}
let valid = app_id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_'));
valid.then(|| app_id.to_string())
}
fn configured_desktop_entry_app_id() -> Option<String> {
env::var(DESKTOP_ENTRY_ID_ENV)
.ok()
.and_then(|value| normalize_desktop_entry_app_id(&value))
}
fn register_portal_app_id(conn: &BlockingConnection) {
let Some(app_id) = configured_desktop_entry_app_id() else {
return;
};
let Ok(proxy) = BlockingProxy::new(conn, PORTAL_DESTINATION, PORTAL_PATH, REGISTRY_INTERFACE)
else {
return;
};
let options: HashMap<&str, Value<'_>> = HashMap::new();
let _ = proxy.call::<_, _, ()>("Register", &(app_id.as_str(), options));
}
struct ResponseEnvelope {
code: u32,
results: HashMap<String, OwnedValue>,
}
struct PendingRequest {
rx: mpsc::Receiver<ResponseEnvelope>,
stop_flag: Arc<std::sync::atomic::AtomicBool>,
listener: std::thread::JoinHandle<()>,
}
impl PendingRequest {
fn wait(self) -> Result<ResponseEnvelope, PortalError> {
let result = self
.rx
.recv_timeout(REQUEST_TIMEOUT)
.map_err(|_| PortalError::PortalTimeout);
self.stop_flag
.store(true, std::sync::atomic::Ordering::Release);
let _ = self.listener.join();
result
}
}
fn watch_request(
conn: &BlockingConnection,
expected_path: &str,
) -> Result<PendingRequest, PortalError> {
let (tx, rx) = mpsc::sync_channel::<ResponseEnvelope>(1);
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<(), ()>>(1);
let stop_flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
let stop_for_thread = stop_flag.clone();
let expected_for_thread = expected_path.to_string();
let conn_for_thread = conn.clone();
let listener = std::thread::Builder::new()
.name("fluxer-linux-screen-capture-req".to_string())
.spawn(move || {
response_listener(
conn_for_thread,
&expected_for_thread,
tx,
ready_tx,
stop_for_thread,
);
})
.map_err(|_| PortalError::DbusError)?;
match ready_rx.recv_timeout(Duration::from_secs(5)) {
Ok(Ok(())) => {}
_ => {
stop_flag.store(true, std::sync::atomic::Ordering::Release);
let _ = listener.join();
return Err(PortalError::DbusError);
}
}
Ok(PendingRequest {
rx,
stop_flag,
listener,
})
}
fn response_listener(
conn: BlockingConnection,
expected_path: &str,
tx: mpsc::SyncSender<ResponseEnvelope>,
ready: mpsc::SyncSender<Result<(), ()>>,
stop: Arc<std::sync::atomic::AtomicBool>,
) {
let setup = future::block_on(async {
let conn: zbus::Connection = conn.into();
let rule = MatchRule::builder()
.msg_type(MessageType::Signal)
.interface(REQUEST_INTERFACE)?
.member("Response")?
.path(expected_path.to_string())?
.build();
let stream = MessageStream::for_match_rule(rule, &conn, Some(8)).await?;
zbus::Result::Ok((conn, stream))
});
let (_conn, mut stream) = match setup {
Ok(parts) => parts,
Err(_) => {
let _ = ready.send(Err(()));
return;
}
};
let _ = ready.send(Ok(()));
while !stop.load(std::sync::atomic::Ordering::Acquire) {
let timeout = async {
async_io::Timer::after(SIGNAL_POLL_INTERVAL).await;
None::<zbus::Result<zbus::Message>>
};
match future::block_on(stream.next().or(timeout)) {
Some(Ok(message)) => {
if let Some(env) = parse_response(&message) {
let _ = tx.send(env);
return;
}
}
Some(Err(_)) => return,
None => {}
}
}
}
fn parse_response(message: &zbus::Message) -> Option<ResponseEnvelope> {
let body = message.body();
let (code, results): (u32, HashMap<String, OwnedValue>) = body.deserialize().ok()?;
Some(ResponseEnvelope { code, results })
}
fn value_of(v: &OwnedValue) -> &Value<'_> {
use std::ops::Deref as _;
v.deref()
}
fn unwrap_variant<'a>(v: &'a Value<'a>) -> &'a Value<'a> {
match v {
Value::Value(boxed) => boxed.as_ref(),
other => other,
}
}
fn read_u32(value: &Value<'_>) -> Option<u32> {
match unwrap_variant(value) {
Value::U32(n) => Some(*n),
Value::U64(n) => Some(*n as u32),
Value::I32(n) => Some(*n as u32),
_ => None,
}
}
fn read_i32(value: &Value<'_>) -> Option<i32> {
match unwrap_variant(value) {
Value::I32(n) => Some(*n),
Value::U32(n) => Some(*n as i32),
_ => None,
}
}
fn read_str(value: &Value<'_>) -> Option<String> {
match unwrap_variant(value) {
Value::Str(s) => Some(s.as_str().to_string()),
_ => None,
}
}
fn read_pair_i32(value: &Value<'_>) -> Option<(i32, i32)> {
let Value::Structure(s) = unwrap_variant(value) else {
return None;
};
let fields = s.fields();
if fields.len() < 2 {
return None;
}
Some((read_i32(&fields[0])?, read_i32(&fields[1])?))
}
fn read_pair_u32(value: &Value<'_>) -> Option<(u32, u32)> {
let Value::Structure(s) = unwrap_variant(value) else {
return None;
};
let fields = s.fields();
if fields.len() < 2 {
return None;
}
Some((read_u32(&fields[0])?, read_u32(&fields[1])?))
}
fn parse_streams(results: &HashMap<String, OwnedValue>) -> Vec<StreamInfo> {
let Some(raw) = results.get("streams") else {
return Vec::new();
};
let val = value_of(raw);
let inner = unwrap_variant(val);
let Value::Array(arr) = inner else {
return Vec::new();
};
let mut out = Vec::new();
for entry in arr.iter() {
let entry_inner = unwrap_variant(entry);
let Value::Structure(s) = entry_inner else {
continue;
};
let fields = s.fields();
if fields.len() < 2 {
continue;
}
let Some(node_id) = read_u32(&fields[0]) else {
continue;
};
let Value::Dict(dict) = unwrap_variant(&fields[1]) else {
out.push(StreamInfo {
node_id,
source_type: 0,
mapping_id: None,
width: 0,
height: 0,
position_x: 0,
position_y: 0,
});
continue;
};
let mut source_type = 0u32;
let mut mapping_id: Option<String> = None;
let mut size = (0u32, 0u32);
let mut position = (0i32, 0i32);
for (k, v) in dict.iter() {
let Value::Str(key) = k else { continue };
match key.as_str() {
"source_type" => {
source_type = read_u32(v).unwrap_or(0);
}
"mapping_id" => {
mapping_id = read_str(v);
}
"size" => {
if let Some(p) = read_pair_u32(v) {
size = p;
}
}
"position" => {
if let Some(p) = read_pair_i32(v) {
position = p;
}
}
_ => {}
}
}
out.push(StreamInfo {
node_id,
source_type,
mapping_id,
width: size.0,
height: size.1,
position_x: position.0,
position_y: position.1,
});
}
out
}
fn cursor_mode_matches(results: &HashMap<String, OwnedValue>, expected: u32) -> bool {
let Some(raw) = results.get("cursor_mode") else {
return true;
};
matches!(read_u32(value_of(raw)), Some(mode) if mode == expected)
}
pub fn open_session_and_pick() -> Result<(LiveSession, Vec<StreamInfo>), PortalError> {
let version = read_portal_version()?;
if version < MIN_PORTAL_VERSION {
return Err(PortalError::PortalTooOld(version));
}
let available_cursor_modes = read_available_cursor_modes().unwrap_or(0);
if available_cursor_modes & CURSOR_MODE_HIDDEN == 0 {
return Err(PortalError::CursorModeUnavailable);
}
let conn = blocking_session_conn()?;
register_portal_app_id(&conn);
let unique = unique_name(&conn)?;
let unique_str = unique.as_str().to_string();
let proxy = BlockingProxy::new(
&conn,
PORTAL_DESTINATION,
PORTAL_PATH,
SCREEN_CAST_INTERFACE,
)
.map_err(|_| PortalError::DbusError)?;
let create_token = mint_token("fluxer_sc_create");
let session_token = mint_token("fluxer_sc_session");
let create_request_path = request_path(&unique_str, &create_token);
let expected_session_path = session_path(&unique_str, &session_token);
let create_pending = watch_request(&conn, &create_request_path)?;
let mut create_opts: HashMap<&str, Value<'_>> = HashMap::new();
create_opts.insert("handle_token", Value::new(create_token.as_str()));
create_opts.insert("session_handle_token", Value::new(session_token.as_str()));
let reply_path: OwnedObjectPath = proxy
.call("CreateSession", &(create_opts,))
.map_err(|_| PortalError::SendFailed)?;
if !reply_path.as_str().is_empty() && reply_path.as_str() != create_request_path {
return Err(PortalError::InvalidReply);
}
let envelope = create_pending.wait()?;
if envelope.code != 0 {
return Err(PortalError::Cancelled);
}
let returned_session_handle = envelope
.results
.get("session_handle")
.and_then(|v| read_str(value_of(v)))
.ok_or(PortalError::InvalidReply)?;
if returned_session_handle != expected_session_path {
return Err(PortalError::InvalidReply);
}
let session_handle = returned_session_handle;
let select_token = mint_token("fluxer_sc_select");
let select_request_path = request_path(&unique_str, &select_token);
let select_pending = watch_request(&conn, &select_request_path)?;
let mut select_opts: HashMap<&str, Value<'_>> = HashMap::new();
select_opts.insert("handle_token", Value::new(select_token.as_str()));
select_opts.insert("types", Value::new(SOURCE_TYPES_ALL));
select_opts.insert("multiple", Value::new(false));
select_opts.insert("cursor_mode", Value::new(CURSOR_MODE_HIDDEN));
let session_obj = OwnedObjectPath::try_from(session_handle.as_str())
.map_err(|_| PortalError::InvalidReply)?;
let select_reply: OwnedObjectPath = proxy
.call("SelectSources", &(&session_obj, select_opts))
.map_err(|_| PortalError::SendFailed)?;
if !select_reply.as_str().is_empty() && select_reply.as_str() != select_request_path {
return Err(PortalError::InvalidReply);
}
let select_envelope = select_pending.wait()?;
if select_envelope.code != 0 {
return Err(PortalError::Cancelled);
}
if !cursor_mode_matches(&select_envelope.results, CURSOR_MODE_HIDDEN) {
return Err(PortalError::CursorModeUnavailable);
}
let start_token = mint_token("fluxer_sc_start");
let start_request_path = request_path(&unique_str, &start_token);
let start_pending = watch_request(&conn, &start_request_path)?;
let mut start_opts: HashMap<&str, Value<'_>> = HashMap::new();
start_opts.insert("handle_token", Value::new(start_token.as_str()));
let start_reply: OwnedObjectPath = proxy
.call("Start", &(&session_obj, "", start_opts))
.map_err(|_| PortalError::SendFailed)?;
if !start_reply.as_str().is_empty() && start_reply.as_str() != start_request_path {
return Err(PortalError::InvalidReply);
}
let start_envelope = start_pending.wait()?;
if start_envelope.code != 0 {
return Err(PortalError::Cancelled);
}
if !cursor_mode_matches(&start_envelope.results, CURSOR_MODE_HIDDEN) {
return Err(PortalError::CursorModeUnavailable);
}
let streams = parse_streams(&start_envelope.results);
if streams.is_empty() {
return Err(PortalError::NoStreams);
}
Ok((
LiveSession {
handle: session_handle,
conn,
},
streams,
))
}
pub fn open_pipewire_remote(session: &LiveSession) -> Result<OwnedFd, PortalError> {
let proxy = BlockingProxy::new(
&session.conn,
PORTAL_DESTINATION,
PORTAL_PATH,
SCREEN_CAST_INTERFACE,
)
.map_err(|_| PortalError::DbusError)?;
let session_obj = OwnedObjectPath::try_from(session.handle.as_str())
.map_err(|_| PortalError::InvalidReply)?;
let opts: HashMap<&str, Value<'_>> = HashMap::new();
let fd: zbus::zvariant::OwnedFd = proxy
.call("OpenPipeWireRemote", &(&session_obj, opts))
.map_err(|_| PortalError::SendFailed)?;
Ok(OwnedFd::from(fd))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_path_sanitizes_unique_bus_name() {
assert_eq!(
request_path(":1.42", "fluxer_sc_create_1"),
"/org/freedesktop/portal/desktop/request/1_42/fluxer_sc_create_1"
);
}
#[test]
fn session_path_sanitizes_unique_bus_name() {
assert_eq!(
session_path(":1.42", "fluxer_sc_session_1"),
"/org/freedesktop/portal/desktop/session/1_42/fluxer_sc_session_1"
);
}
#[test]
fn mint_token_is_distinct_and_prefixed() {
let a = mint_token("fluxer_sc_create");
let b = mint_token("fluxer_sc_create");
assert_ne!(a, b);
assert!(a.starts_with("fluxer_sc_create_"));
}
#[test]
fn cursor_mode_constants_match_portal_spec() {
assert_eq!(CURSOR_MODE_HIDDEN, 1);
assert_eq!(CURSOR_MODE_EMBEDDED, 2);
assert_eq!(CURSOR_MODE_METADATA, 4);
}
#[test]
fn source_type_mask_combines_monitor_and_window() {
assert_eq!(SOURCE_TYPES_ALL, 3);
assert_eq!(SOURCE_TYPE_MONITOR | SOURCE_TYPE_WINDOW, SOURCE_TYPES_ALL);
}
#[test]
fn normalize_desktop_entry_app_id_accepts_fluxer_ids() {
assert_eq!(
normalize_desktop_entry_app_id("fluxer-canary"),
Some("fluxer-canary".to_string())
);
assert_eq!(
normalize_desktop_entry_app_id("fluxer-canary.desktop"),
Some("fluxer-canary".to_string())
);
assert_eq!(
normalize_desktop_entry_app_id("app.fluxer.canary"),
Some("app.fluxer.canary".to_string())
);
}
#[test]
fn normalize_desktop_entry_app_id_rejects_paths_and_empty_values() {
assert_eq!(normalize_desktop_entry_app_id(""), None);
assert_eq!(normalize_desktop_entry_app_id(".hidden"), None);
assert_eq!(normalize_desktop_entry_app_id("../fluxer-canary"), None);
assert_eq!(normalize_desktop_entry_app_id("fluxer canary"), None);
}
#[test]
fn cursor_mode_matches_treats_missing_key_as_honoured() {
let map: HashMap<String, OwnedValue> = HashMap::new();
assert!(cursor_mode_matches(&map, CURSOR_MODE_HIDDEN));
}
}