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:
+2199
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();
|
||||
}
|
||||
@@ -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(®istry) >= 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(®istry), 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(®istry), 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(®istry), 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(®istry), Arc::clone(&telemetry));
|
||||
let _second = DeviceLossBridge::new(Arc::clone(®istry), 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(®istry),
|
||||
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(®istry), 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user