Add native self-hosted instance connection to fluxer_desktop

Trimmed monorepo checkout (fluxer_desktop + packages/voice_engine_v2 +
tools/ci) with a "Connect to a Different Server" menu item and popout
that lets the desktop app switch to any self-hosted Fluxer instance,
plus fixes for well-known discovery on single-domain self-hosted
deployments and a false-positive ERR_ABORTED on same-origin client
redirects during the switch. Defaults to chat.fluxr.chat and uses an
isolated userData directory from the official build.
This commit is contained in:
2026-07-01 18:22:43 -04:00
commit 682afacd30
1763 changed files with 613720 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,45 @@
[package]
name = "fluxer_mac_screen_capture"
version = "0.0.0"
edition = "2024"
license = "AGPL-3.0-or-later"
publish = false
[workspace]
resolver = "2"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
napi = {version = "3.9.1", default-features = false, features = ["dyn-symbols", "napi8", "async"]}
napi-derive = "3.5.6"
fluxer_encoder_ring = {path = "../encoder-ring"}
parking_lot = "0.12"
[target.'cfg(target_os = "macos")'.dependencies.fluxer_screen_frame_bus]
path = "../screen-frame-bus"
[target.'cfg(target_os = "macos")'.dependencies]
libc = "0.2.186"
objc2 = "0.6"
objc2-foundation = {version = "0.3", features = ["NSString", "NSArray", "NSDictionary", "NSError", "NSValue", "NSBundle", "NSProcessInfo"]}
objc2-screen-capture-kit = {version = "0.3", features = ["SCStream", "SCShareableContent", "objc2-core-graphics", "objc2-core-media", "objc2-core-foundation", "block2", "dispatch2", "libc"]}
objc2-core-media = {version = "0.3", features = ["CMSampleBuffer", "CMBlockBuffer", "CMFormatDescription", "CMTime", "objc2-core-audio-types"]}
objc2-core-audio-types = {version = "0.3", features = ["CoreAudioBaseTypes", "objc2"]}
objc2-core-video = {version = "0.3", features = ["CVPixelBuffer", "CVPixelBufferIOSurface", "CVImageBuffer", "CVBuffer", "CVPixelFormatDescription", "CVReturn", "CVBase", "objc2-io-surface"]}
objc2-io-surface = {version = "0.3", features = ["IOSurfaceRef", "objc2-core-foundation"]}
objc2-core-foundation = {version = "0.3", features = ["CFArray", "CFDictionary", "CFNumber", "CFString", "CFBase"]}
objc2-core-graphics = {version = "0.3.2", default-features = false, features = ["std", "CGWindow"]}
block2 = "0.6"
dispatch2 = "0.3"
[build-dependencies]
napi-build = "2.3.2"
[dev-dependencies]
criterion = {version = "0.8", default-features = false, features = ["cargo_bench_support"]}
[[bench]]
name = "audio_pool"
harness = false
@@ -0,0 +1,66 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::hint::black_box;
use criterion::{Criterion, criterion_group, criterion_main};
use fluxer_mac_screen_capture::audio_pool::{
MAC_AUDIO_POOL_CAP, MAX_FRAME_BYTES_PER_SLOT, MacAudioFramePool,
};
fn bench_acquire_write_release_960_floats(c: &mut Criterion) {
let pool = MacAudioFramePool::new(MAC_AUDIO_POOL_CAP, MAX_FRAME_BYTES_PER_SLOT)
.expect("pool must build");
let payload = vec![0xCC_u8; 960 * 4];
c.bench_function("audio_pool/acquire_write_release/960_floats", |b| {
b.iter(|| {
let mut slot = pool
.try_acquire()
.expect("steady state single-thread never starves");
slot.write(&payload).expect("write fits");
black_box(slot.data_slice().len());
drop(slot);
});
});
assert_eq!(pool.stats().in_flight, 0);
}
fn bench_acquire_release_only(c: &mut Criterion) {
let pool = MacAudioFramePool::new(MAC_AUDIO_POOL_CAP, MAX_FRAME_BYTES_PER_SLOT)
.expect("pool must build");
c.bench_function("audio_pool/acquire_release", |b| {
b.iter(|| {
let slot = pool.try_acquire().expect("slot");
black_box(slot.slot_index());
});
});
assert_eq!(pool.stats().in_flight, 0);
}
fn bench_acquire_write_into_external_parts(c: &mut Criterion) {
let pool = MacAudioFramePool::new(MAC_AUDIO_POOL_CAP, MAX_FRAME_BYTES_PER_SLOT)
.expect("pool must build");
let payload = vec![0xCC_u8; 960 * 4];
c.bench_function(
"audio_pool/acquire_write_into_external_parts/960_floats",
|b| {
b.iter(|| {
let mut slot = pool
.try_acquire()
.expect("steady state single-thread never starves");
slot.write(&payload).expect("write fits");
let (ptr, len, owned) = slot.into_external_parts();
black_box((ptr, len));
drop(owned);
});
},
);
assert_eq!(pool.stats().in_flight, 0);
}
criterion_group!(
benches,
bench_acquire_write_release_960_floats,
bench_acquire_release_only,
bench_acquire_write_into_external_parts,
);
criterion_main!(benches);
@@ -0,0 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
fn main() {
napi_build::setup();
#[cfg(target_os = "macos")]
{
println!("cargo:rustc-link-lib=framework=AppKit");
}
}
+107
View File
@@ -0,0 +1,107 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {EventEmitter} from 'node:events';
export interface MacScreenCaptureBackendInfo {
backend: string;
supported: boolean;
reason: string;
minMacosVersion: string;
detectedMacosVersion?: string;
sckAvailable: boolean;
}
export declare function getBackendInfo(): MacScreenCaptureBackendInfo;
export interface MacScreenCaptureBackendAvailability {
sck?: {
supported: boolean;
macosVersion?: string;
};
screenPermission?: string;
}
export declare function getBackendAvailability(): Promise<MacScreenCaptureBackendAvailability>;
export type MacScreenCaptureSourceKind = 'screen' | 'window';
export interface MacScreenCaptureSource {
kind: MacScreenCaptureSourceKind;
id: string;
name: string;
width: number;
height: number;
appName?: string;
bundleId?: string;
targetPid?: number;
}
export declare function listSources(): Promise<Array<MacScreenCaptureSource>>;
export interface ScreenCaptureRect {
x: number;
y: number;
width: number;
height: number;
}
export interface ScreenCaptureOptions {
sourceId: string;
sourceKind: MacScreenCaptureSourceKind;
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' | 'bgra';
}
export interface FrameSinkDiagnostics {
accepted: number;
coalesced: number;
rejected: number;
mediaFramesDroppedWithoutSink: number;
}
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: '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: '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: 'diagnostic', message?: string): boolean;
}
export declare class ScreenCapture extends EventEmitter {
constructor(options: ScreenCaptureOptions);
start(): Promise<ScreenCaptureStartResult>;
stop(): Promise<void>;
attachEncoder(width: number, height: number, frameRate?: number): void;
detachEncoder(): void;
isEncoderAttached(): boolean;
encoderRingFullCount(): number;
getFrameSinkDiagnostics(): FrameSinkDiagnostics;
}
@@ -0,0 +1,233 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const {EventEmitter} = require('node:events');
const {existsSync} = require('node:fs');
const {join, sep} = require('node:path');
const {createNativeLoadError, loadNativeBinding} = require('./loader-diagnostics.cjs');
const MODULE_NAME = '@fluxer/mac-screen-capture';
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 !== 'darwin') {
throw new Error(`@fluxer/mac-screen-capture is only supported on macOS, got ${process.platform}`);
}
switch (process.arch) {
case 'x64':
return 'mac-screen-capture.darwin-x64.node';
case 'arm64':
return 'mac-screen-capture.darwin-arm64.node';
default:
throw new Error(`Unsupported macOS architecture: ${process.arch}`);
}
}
let binding = null;
let loadError = null;
if (process.platform === 'darwin') {
try {
const nativeRoot = resolveNativeRoot();
const nativePath = join(nativeRoot, nativeFileName());
const loaded = loadNativeBinding({
moduleName: MODULE_NAME,
nativePath,
nativeRoot,
packageDir: __dirname,
probe: false,
});
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,
});
throw loadError;
}
}
function getBackendInfo() {
if (!binding) {
return {
backend: 'mac-screen-capture',
supported: false,
reason:
process.platform === 'darwin'
? `@fluxer/mac-screen-capture native binary unavailable: ${loadError?.message ?? 'unknown reason'}`
: `@fluxer/mac-screen-capture is only supported on macOS, got ${process.platform}`,
minMacosVersion: '12.3',
detectedMacosVersion: undefined,
sckAvailable: false,
};
}
return binding.getBackendInfo();
}
function getBackendAvailability() {
if (!binding) {
return Promise.resolve({
sck: {supported: false},
screenPermission: 'not-determined',
});
}
return binding.getBackendAvailability();
}
function listSources() {
if (!binding) return Promise.resolve([]);
return binding.listSources();
}
function __setBindingForTests(nextBinding) {
binding = nextBinding;
loadError = null;
}
class ScreenCapture extends EventEmitter {
constructor(options = {}) {
super();
if (!binding) {
throw loadError || new Error('@fluxer/mac-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 === 'diagnostic') {
if (this.stopped) return;
this.emit('diagnostic', message);
return;
}
if (type === 'error') {
this.emit('error', new Error(message || 'macOS screen capture stream stopped'));
return;
}
if (type === 'closed') {
if (this.stopped) {
this.emitClosedOnce();
return;
}
this.stopped = true;
Promise.resolve()
.then(() => this.native.stop())
.catch(() => {});
this.emitClosedOnce();
}
});
}
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/mac-screen-capture native binding does not support native frame sink handles');
}
this.native.setFrameSinkHandle(this.frameSinkHandle);
} else if (this.nativeFrameSinkRequired) {
throw new Error('Native frame sink handle is required for macOS screen capture');
}
const result = await this.native.start(
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();
}
}
getFrameSinkDiagnostics() {
if (!this.native || typeof this.native.getFrameSinkDiagnostics !== 'function') {
return {
accepted: 0,
coalesced: 0,
rejected: 0,
mediaFramesDroppedWithoutSink: 0,
};
}
try {
return this.native.getFrameSinkDiagnostics();
} catch (error) {
console.warn('[mac-screen-capture] getFrameSinkDiagnostics failed:', error?.message || error);
return {
accepted: 0,
coalesced: 0,
rejected: 0,
mediaFramesDroppedWithoutSink: 0,
};
}
}
}
module.exports = {
ScreenCapture,
getBackendAvailability,
getBackendInfo,
listSources,
loadError,
__setBindingForTests,
};
@@ -0,0 +1,248 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
import {afterEach, describe, test} from 'node:test';
const require = createRequire(import.meta.url);
const macScreenCapture = require('./index.js');
function makeFakeBinding({sources = [], availability = {sck: {supported: true}, screenPermission: 'authorized'}} = {}) {
const calls = [];
const frameSinkHandleCalls = [];
const natives = [];
const frameSinkDiagnostics = {
accepted: 0,
coalesced: 0,
rejected: 0,
mediaFramesDroppedWithoutSink: 0,
};
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;
}
getFrameSinkDiagnostics() {
return {...frameSinkDiagnostics};
}
}
return {
binding: {
ScreenCapture: FakeNative,
listSources: async () => sources,
getBackendAvailability: async () => availability,
getBackendInfo: () => ({
backend: 'mac-screen-capture',
supported: true,
reason: '',
minMacosVersion: '12.3',
detectedMacosVersion: '14.0',
sckAvailable: true,
}),
},
calls,
frameSinkHandleCalls,
frameSinkDiagnostics,
natives,
};
}
afterEach(() => {
macScreenCapture.__setBindingForTests(null);
});
describe('mac-screen-capture loader wrapper', () => {
test('forwards source id, kind, and dimensions to native binding', async () => {
const {binding, calls} = makeFakeBinding();
macScreenCapture.__setBindingForTests(binding);
const capture = new macScreenCapture.ScreenCapture({
sourceId: '12345',
sourceKind: 'window',
width: 1280,
height: 720,
frameRate: 30,
colorRange: 'full',
colorSpace: 'rec709',
showCursorClicks: true,
captureRect: {x: 10, y: 20, width: 300, height: 200},
});
capture.on('error', () => {});
const result = await capture.start();
assert.deepEqual(calls, [
{
sourceId: '12345',
sourceKind: 'window',
width: 1280,
height: 720,
frameRate: 30,
captureId: undefined,
captureOptions: {
colorRange: 'full',
colorSpace: 'rec709',
showCursorClicks: true,
captureRect: {x: 10, y: 20, width: 300, height: 200},
},
},
]);
assert.equal(result.pixelFormat, 'nv12');
assert.equal(result.width, 1280);
assert.equal(result.height, 720);
});
test('defaults sourceKind to screen and frameRate to 30', async () => {
const {binding, calls} = makeFakeBinding();
macScreenCapture.__setBindingForTests(binding);
const capture = new macScreenCapture.ScreenCapture({sourceId: '1'});
capture.on('error', () => {});
await capture.start();
assert.equal(calls[0].sourceKind, 'screen');
assert.equal(calls[0].frameRate, 30);
});
test('forwards display and window sources from native binding without rewriting ids', async () => {
const {binding} = makeFakeBinding({
sources: [
{kind: 'screen', id: 'display:69733632', name: 'Studio Display', width: 5120, height: 2880},
{
kind: 'window',
id: 'window:4242',
name: 'Fluxer',
width: 1440,
height: 900,
appName: 'Fluxer',
bundleId: 'app.fluxer.desktop',
targetPid: 1234,
},
],
});
macScreenCapture.__setBindingForTests(binding);
const sources = await macScreenCapture.listSources();
assert.deepEqual(sources, [
{kind: 'screen', id: 'display:69733632', name: 'Studio Display', width: 5120, height: 2880},
{
kind: 'window',
id: 'window:4242',
name: 'Fluxer',
width: 1440,
height: 900,
appName: 'Fluxer',
bundleId: 'app.fluxer.desktop',
targetPid: 1234,
},
]);
});
test('reports ScreenCaptureKit support and permission from native binding', async () => {
const {binding} = makeFakeBinding({
availability: {
sck: {supported: true, macosVersion: '15.0'},
screenPermission: 'authorized',
},
});
macScreenCapture.__setBindingForTests(binding);
assert.deepEqual(await macScreenCapture.getBackendAvailability(), {
sck: {supported: true, macosVersion: '15.0'},
screenPermission: 'authorized',
});
});
test('installs a native frame sink handle once before start', async () => {
const {binding, calls, frameSinkHandleCalls} = makeFakeBinding();
macScreenCapture.__setBindingForTests(binding);
const frameSinkHandle = {native: true};
const capture = new macScreenCapture.ScreenCapture({
sourceId: '12345',
sourceKind: 'window',
frameSinkHandle,
nativeFrameSinkRequired: true,
});
capture.on('error', () => {});
await capture.start();
assert.deepEqual(frameSinkHandleCalls, [frameSinkHandle]);
assert.equal(calls.length, 1);
});
test('fails before native start when a native frame sink is required but missing', async () => {
const {binding, calls, frameSinkHandleCalls} = makeFakeBinding();
macScreenCapture.__setBindingForTests(binding);
const capture = new macScreenCapture.ScreenCapture({
sourceId: '12345',
sourceKind: 'window',
nativeFrameSinkRequired: true,
});
capture.on('error', () => {});
await assert.rejects(() => capture.start(), /native frame sink handle is required/i);
assert.deepEqual(frameSinkHandleCalls, []);
assert.deepEqual(calls, []);
});
test('frame sink diagnostics are forwarded by the wrapper', () => {
const {binding, frameSinkDiagnostics} = makeFakeBinding();
macScreenCapture.__setBindingForTests(binding);
const capture = new macScreenCapture.ScreenCapture({sourceId: '1'});
frameSinkDiagnostics.accepted = 5;
frameSinkDiagnostics.coalesced = 1;
frameSinkDiagnostics.rejected = 2;
frameSinkDiagnostics.mediaFramesDroppedWithoutSink = 3;
assert.deepEqual(capture.getFrameSinkDiagnostics(), {
accepted: 5,
coalesced: 1,
rejected: 2,
mediaFramesDroppedWithoutSink: 3,
});
});
test('emits closed once when native lifecycle closes and stop is called later', async () => {
const {binding, natives} = makeFakeBinding();
macScreenCapture.__setBindingForTests(binding);
const capture = new macScreenCapture.ScreenCapture({sourceId: '1'});
let closed = 0;
capture.on('closed', () => {
closed += 1;
});
natives[0].lifecycleCallback('closed', '');
await Promise.resolve();
await capture.stop();
assert.equal(closed, 1);
assert.equal(natives[0].stopCount, 1);
});
test('lifecycle error emits Error event', async () => {
const {binding, natives} = makeFakeBinding();
macScreenCapture.__setBindingForTests(binding);
const capture = new macScreenCapture.ScreenCapture({sourceId: '1'});
const errors = [];
capture.on('error', (err) => errors.push(err));
natives[0].lifecycleCallback('error', 'permission lost mid-stream');
assert.equal(errors.length, 1);
assert.equal(errors[0].message, 'permission lost mid-stream');
});
test('lifecycle diagnostic emits diagnostic event', () => {
const {binding, natives} = makeFakeBinding();
macScreenCapture.__setBindingForTests(binding);
const capture = new macScreenCapture.ScreenCapture({sourceId: '1'});
const diagnostics = [];
capture.on('diagnostic', (message) => diagnostics.push(message));
natives[0].lifecycleCallback('diagnostic', 'frame sink rejected a frame');
assert.deepEqual(diagnostics, ['frame sink rejected a frame']);
});
});
@@ -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,34 @@
{
"name": "@fluxer/mac-screen-capture",
"version": "0.0.0",
"description": "",
"private": true,
"license": "AGPL-3.0-or-later",
"os": [
"darwin"
],
"cpu": [
"x64",
"arm64"
],
"main": "index.js",
"types": "index.d.ts",
"files": [
"index.js",
"index.d.ts",
"loader-diagnostics.cjs",
"mac-screen-capture.darwin-x64.node",
"mac-screen-capture.darwin-arm64.node"
],
"scripts": {
"build": "cargo run --locked --quiet --manifest-path ../../../tools/ci/Cargo.toml -- build-desktop-native-addon",
"test": "pnpm test:cargo && node --test index.test.mjs",
"test:cargo": "cargo test --manifest-path Cargo.toml"
},
"binary": {
"napi_versions": [
8
]
},
"devDependencies": {}
}
@@ -0,0 +1,596 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use parking_lot::Mutex;
use std::cell::UnsafeCell;
use std::fmt;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
pub const MAC_AUDIO_POOL_CAP: usize = 16;
pub const MAX_FRAME_BYTES_PER_SLOT: usize = 8192;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MacAudioError {
ZeroCapacity,
ZeroBytesPerSlot,
BytesPerSlotTooLarge(usize),
PayloadTooLarge { offered: usize, capacity: usize },
}
impl fmt::Display for MacAudioError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ZeroCapacity => write!(f, "MacAudioFramePool capacity must be > 0"),
Self::ZeroBytesPerSlot => write!(f, "MacAudioFramePool bytes_per_slot must be > 0"),
Self::BytesPerSlotTooLarge(n) => write!(
f,
"MacAudioFramePool bytes_per_slot {n} exceeds MAX_FRAME_BYTES_PER_SLOT={MAX_FRAME_BYTES_PER_SLOT}"
),
Self::PayloadTooLarge { offered, capacity } => write!(
f,
"MacAudioFramePool payload {offered} bytes exceeds slot capacity {capacity}"
),
}
}
}
impl std::error::Error for MacAudioError {}
struct PoolSlotCell {
inner: UnsafeCell<Box<[u8]>>,
}
unsafe impl Send for PoolSlotCell {}
unsafe impl Sync for PoolSlotCell {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MacAudioPoolStats {
pub acquired: u64,
pub released: u64,
pub dropped: u64,
pub in_flight: u32,
}
pub(crate) struct MacAudioFramePoolInner {
slots: Vec<PoolSlotCell>,
free: Mutex<Vec<usize>>,
capacity: u32,
bytes_per_slot: u32,
acquired_total: AtomicU64,
released_total: AtomicU64,
dropped_total: AtomicU64,
in_flight: AtomicU32,
}
pub struct MacAudioFramePool {
inner: Arc<MacAudioFramePoolInner>,
}
impl fmt::Debug for MacAudioFramePool {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let stats = self.stats();
f.debug_struct("MacAudioFramePool")
.field("capacity", &self.inner.capacity)
.field("bytes_per_slot", &self.inner.bytes_per_slot)
.field("stats", &stats)
.finish()
}
}
impl MacAudioFramePool {
pub fn new(capacity: usize, bytes_per_slot: usize) -> Result<Self, MacAudioError> {
if capacity == 0 {
return Err(MacAudioError::ZeroCapacity);
}
if bytes_per_slot == 0 {
return Err(MacAudioError::ZeroBytesPerSlot);
}
if bytes_per_slot > MAX_FRAME_BYTES_PER_SLOT {
return Err(MacAudioError::BytesPerSlotTooLarge(bytes_per_slot));
}
assert!(capacity > 0);
assert!(bytes_per_slot > 0);
assert!(bytes_per_slot <= MAX_FRAME_BYTES_PER_SLOT);
let mut slots: Vec<PoolSlotCell> = Vec::with_capacity(capacity);
for _ in 0..capacity {
let buf: Box<[u8]> = vec![0u8; bytes_per_slot].into_boxed_slice();
assert_eq!(buf.len(), bytes_per_slot);
slots.push(PoolSlotCell {
inner: UnsafeCell::new(buf),
});
}
assert_eq!(slots.len(), capacity);
let mut free: Vec<usize> = Vec::with_capacity(capacity);
for index in 0..capacity {
free.push(index);
}
assert_eq!(free.len(), capacity);
let cap_u32 = u32::try_from(capacity).map_err(|_| MacAudioError::ZeroCapacity)?;
let bps_u32 = u32::try_from(bytes_per_slot).map_err(|_| MacAudioError::ZeroBytesPerSlot)?;
let inner = MacAudioFramePoolInner {
slots,
free: Mutex::new(free),
capacity: cap_u32,
bytes_per_slot: bps_u32,
acquired_total: AtomicU64::new(0),
released_total: AtomicU64::new(0),
dropped_total: AtomicU64::new(0),
in_flight: AtomicU32::new(0),
};
Ok(Self {
inner: Arc::new(inner),
})
}
pub fn try_acquire(&self) -> Option<PooledMacAudioFrame> {
assert!(self.inner.capacity > 0);
assert!(self.inner.bytes_per_slot > 0);
let mut free = self.inner.free.lock();
assert!(free.len() <= self.inner.capacity as usize);
let index = match free.pop() {
Some(idx) => idx,
None => {
drop(free);
self.inner.dropped_total.fetch_add(1, Ordering::Relaxed);
return None;
}
};
assert!(index < self.inner.capacity as usize);
self.inner.acquired_total.fetch_add(1, Ordering::Relaxed);
let after = self.inner.in_flight.fetch_add(1, Ordering::AcqRel) + 1;
assert!(after <= self.inner.capacity);
drop(free);
Some(PooledMacAudioFrame {
slot_index: index,
filled_len: 0,
pool: Arc::clone(&self.inner),
})
}
pub fn capacity(&self) -> u32 {
let cap = self.inner.capacity;
assert!(cap > 0);
assert!(cap as usize == self.inner.slots.len());
cap
}
pub fn bytes_per_slot(&self) -> u32 {
let bps = self.inner.bytes_per_slot;
assert!(bps > 0);
assert!(bps as usize <= MAX_FRAME_BYTES_PER_SLOT);
bps
}
pub fn stats(&self) -> MacAudioPoolStats {
assert!(self.inner.capacity > 0);
let in_flight = self.inner.in_flight.load(Ordering::Acquire);
assert!(in_flight <= self.inner.capacity);
let acquired = self.inner.acquired_total.load(Ordering::Relaxed);
let released = self.inner.released_total.load(Ordering::Relaxed);
let dropped = self.inner.dropped_total.load(Ordering::Relaxed);
assert!(released <= acquired);
MacAudioPoolStats {
acquired,
released,
dropped,
in_flight,
}
}
}
impl Clone for MacAudioFramePool {
fn clone(&self) -> Self {
Self {
inner: Arc::clone(&self.inner),
}
}
}
pub struct PooledMacAudioFrame {
slot_index: usize,
filled_len: usize,
pool: Arc<MacAudioFramePoolInner>,
}
impl PooledMacAudioFrame {
pub fn write(&mut self, samples: &[u8]) -> Result<(), MacAudioError> {
assert!(self.slot_index < self.pool.capacity as usize);
let cap = self.pool.bytes_per_slot as usize;
if samples.len() > cap {
return Err(MacAudioError::PayloadTooLarge {
offered: samples.len(),
capacity: cap,
});
}
assert!(samples.len() <= cap);
let cell = &self.pool.slots[self.slot_index];
let buf: &mut [u8] = unsafe { &mut *cell.inner.get() };
assert_eq!(buf.len(), cap);
if !samples.is_empty() {
buf[..samples.len()].copy_from_slice(samples);
}
self.filled_len = samples.len();
assert!(self.filled_len <= cap);
Ok(())
}
pub fn append(&mut self, samples: &[u8]) -> Result<(), MacAudioError> {
assert!(self.slot_index < self.pool.capacity as usize);
let cap = self.pool.bytes_per_slot as usize;
let new_len =
self.filled_len
.checked_add(samples.len())
.ok_or(MacAudioError::PayloadTooLarge {
offered: usize::MAX,
capacity: cap,
})?;
if new_len > cap {
return Err(MacAudioError::PayloadTooLarge {
offered: new_len,
capacity: cap,
});
}
assert!(new_len <= cap);
let cell = &self.pool.slots[self.slot_index];
let buf: &mut [u8] = unsafe { &mut *cell.inner.get() };
assert_eq!(buf.len(), cap);
if !samples.is_empty() {
buf[self.filled_len..new_len].copy_from_slice(samples);
}
self.filled_len = new_len;
Ok(())
}
pub fn data_slice(&self) -> &[u8] {
assert!(self.slot_index < self.pool.capacity as usize);
assert!(self.filled_len <= self.pool.bytes_per_slot as usize);
let cell = &self.pool.slots[self.slot_index];
let buf: &[u8] = unsafe { &*cell.inner.get() };
&buf[..self.filled_len]
}
pub fn filled_len(&self) -> usize {
assert!(self.slot_index < self.pool.capacity as usize);
assert!(self.filled_len <= self.pool.bytes_per_slot as usize);
self.filled_len
}
pub fn capacity(&self) -> usize {
let cap = self.pool.bytes_per_slot as usize;
assert!(cap > 0);
assert!(cap <= MAX_FRAME_BYTES_PER_SLOT);
cap
}
pub fn slot_index(&self) -> usize {
assert!(self.slot_index < self.pool.capacity as usize);
self.slot_index
}
pub fn as_mut_ptr(&mut self) -> *mut u8 {
assert!(self.slot_index < self.pool.capacity as usize);
assert!(self.filled_len <= self.pool.bytes_per_slot as usize);
let cell = &self.pool.slots[self.slot_index];
let buf: &mut [u8] = unsafe { &mut *cell.inner.get() };
assert_eq!(buf.len(), self.pool.bytes_per_slot as usize);
buf.as_mut_ptr()
}
pub fn into_external_parts(mut self) -> (*mut u8, usize, Self) {
assert!(self.slot_index < self.pool.capacity as usize);
assert!(self.filled_len <= self.pool.bytes_per_slot as usize);
let len = self.filled_len;
let ptr = self.as_mut_ptr();
assert!(!ptr.is_null());
(ptr, len, self)
}
}
impl Drop for PooledMacAudioFrame {
fn drop(&mut self) {
assert!(self.slot_index < self.pool.capacity as usize);
let mut free = self.pool.free.lock();
assert!(free.len() < self.pool.capacity as usize);
free.push(self.slot_index);
let before = self.pool.in_flight.fetch_sub(1, Ordering::AcqRel);
assert!(before >= 1);
self.pool.released_total.fetch_add(1, Ordering::Relaxed);
drop(free);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Barrier;
use std::sync::atomic::AtomicUsize;
use std::thread;
use std::time::{Duration, Instant};
const SLOT_BYTES: usize = 3840;
fn default_pool() -> MacAudioFramePool {
MacAudioFramePool::new(MAC_AUDIO_POOL_CAP, SLOT_BYTES).expect("default pool builds")
}
#[test]
fn new_rejects_zero_capacity() {
let err = MacAudioFramePool::new(0, SLOT_BYTES).unwrap_err();
assert_eq!(err, MacAudioError::ZeroCapacity);
}
#[test]
fn new_rejects_zero_bytes_per_slot() {
let err = MacAudioFramePool::new(4, 0).unwrap_err();
assert_eq!(err, MacAudioError::ZeroBytesPerSlot);
}
#[test]
fn new_rejects_bytes_per_slot_above_max() {
let err = MacAudioFramePool::new(4, MAX_FRAME_BYTES_PER_SLOT + 1).unwrap_err();
assert_eq!(
err,
MacAudioError::BytesPerSlotTooLarge(MAX_FRAME_BYTES_PER_SLOT + 1)
);
}
#[test]
fn acquire_release_cycle_increments_counters() {
let pool = MacAudioFramePool::new(4, SLOT_BYTES).expect("pool");
let stats_before = pool.stats();
assert_eq!(stats_before.acquired, 0);
assert_eq!(stats_before.released, 0);
assert_eq!(stats_before.in_flight, 0);
{
let _slot = pool.try_acquire().expect("slot");
let stats_held = pool.stats();
assert_eq!(stats_held.acquired, 1);
assert_eq!(stats_held.in_flight, 1);
}
let stats_after = pool.stats();
assert_eq!(stats_after.acquired, 1);
assert_eq!(stats_after.released, 1);
assert_eq!(stats_after.in_flight, 0);
}
#[test]
fn pool_exhausts_at_cap_seventeenth_acquire_returns_none() {
let pool = default_pool();
let mut held = Vec::with_capacity(MAC_AUDIO_POOL_CAP);
for _ in 0..MAC_AUDIO_POOL_CAP {
held.push(pool.try_acquire().expect("slot in capacity"));
}
assert!(pool.try_acquire().is_none());
let stats = pool.stats();
assert_eq!(stats.dropped, 1);
assert_eq!(stats.acquired as usize, MAC_AUDIO_POOL_CAP);
assert_eq!(stats.in_flight as usize, MAC_AUDIO_POOL_CAP);
}
#[test]
fn drop_returns_slot_to_free_list() {
let pool = MacAudioFramePool::new(2, SLOT_BYTES).expect("pool");
let first = pool.try_acquire().expect("first");
let second = pool.try_acquire().expect("second");
assert!(pool.try_acquire().is_none());
drop(first);
let revived = pool.try_acquire().expect("revived");
assert_eq!(pool.stats().in_flight, 2);
drop(second);
drop(revived);
assert_eq!(pool.stats().in_flight, 0);
}
#[test]
fn write_then_data_slice_matches_payload() {
let pool = MacAudioFramePool::new(2, 64).expect("pool");
let mut slot = pool.try_acquire().expect("slot");
let payload = [0xAB_u8; 32];
slot.write(&payload).expect("payload fits");
assert_eq!(slot.data_slice(), &payload[..]);
assert_eq!(slot.filled_len(), 32);
}
#[test]
fn write_rejects_payload_larger_than_slot() {
let pool = MacAudioFramePool::new(2, 64).expect("pool");
let mut slot = pool.try_acquire().expect("slot");
let big = [0u8; 128];
let err = slot.write(&big).unwrap_err();
assert!(matches!(err, MacAudioError::PayloadTooLarge { .. }));
}
#[test]
fn append_concatenates_planar_to_interleaved_like_payload() {
let pool = MacAudioFramePool::new(1, 32).expect("pool");
let mut slot = pool.try_acquire().expect("slot");
let left = [1_u8, 2, 3, 4];
let right = [5_u8, 6, 7, 8];
slot.append(&left).expect("left fits");
slot.append(&right).expect("right fits");
assert_eq!(slot.data_slice(), &[1, 2, 3, 4, 5, 6, 7, 8]);
assert_eq!(slot.filled_len(), 8);
}
#[test]
fn append_rejects_overflow() {
let pool = MacAudioFramePool::new(1, 4).expect("pool");
let mut slot = pool.try_acquire().expect("slot");
slot.append(&[1, 2, 3]).expect("3 of 4 fits");
let err = slot.append(&[4, 5]).unwrap_err();
assert!(matches!(err, MacAudioError::PayloadTooLarge { .. }));
}
#[test]
fn recycled_slot_persists_buffer_storage_until_overwritten() {
let pool = MacAudioFramePool::new(1, 16).expect("pool");
let mut slot = pool.try_acquire().expect("first");
let payload = [0x42_u8; 8];
slot.write(&payload).expect("write");
let observed_first = slot.data_slice().to_vec();
drop(slot);
let slot2 = pool.try_acquire().expect("recycled");
assert_eq!(slot2.filled_len(), 0);
assert_eq!(observed_first, payload.to_vec());
}
#[test]
fn multi_thread_acquire_release_stress_8_threads_1000_ops_no_deadlock() {
const THREADS: usize = 8;
const OPS_PER_THREAD: usize = 1000;
const POOL_CAP: usize = 4;
let pool = Arc::new(MacAudioFramePool::new(POOL_CAP, 64).expect("pool"));
let barrier = Arc::new(Barrier::new(THREADS));
let acquired_obs = Arc::new(AtomicUsize::new(0));
let dropped_obs = Arc::new(AtomicUsize::new(0));
let mut handles = Vec::with_capacity(THREADS);
for _ in 0..THREADS {
let pool = Arc::clone(&pool);
let barrier = Arc::clone(&barrier);
let acquired_obs = Arc::clone(&acquired_obs);
let dropped_obs = Arc::clone(&dropped_obs);
handles.push(thread::spawn(move || {
barrier.wait();
let mut local_acq: usize = 0;
let mut local_drop: usize = 0;
for _ in 0..OPS_PER_THREAD {
match pool.try_acquire() {
Some(mut slot) => {
local_acq += 1;
let _ = slot.write(&[0xAA; 16]);
drop(slot);
}
None => {
local_drop += 1;
}
}
}
acquired_obs.fetch_add(local_acq, Ordering::Relaxed);
dropped_obs.fetch_add(local_drop, Ordering::Relaxed);
}));
}
let deadline = Instant::now() + Duration::from_secs(30);
for h in handles {
assert!(Instant::now() < deadline, "stress test exceeded 30s budget");
h.join().expect("worker panicked");
}
let stats = pool.stats();
let total = stats.acquired + stats.dropped;
assert_eq!(total as usize, THREADS * OPS_PER_THREAD);
assert_eq!(
stats.acquired as usize,
acquired_obs.load(Ordering::Relaxed)
);
assert_eq!(stats.dropped as usize, dropped_obs.load(Ordering::Relaxed));
assert_eq!(stats.in_flight, 0);
assert_eq!(stats.acquired, stats.released);
}
#[test]
fn default_pool_dimensions_match_constants() {
let pool = default_pool();
assert_eq!(pool.capacity() as usize, MAC_AUDIO_POOL_CAP);
assert_eq!(pool.bytes_per_slot() as usize, SLOT_BYTES);
}
#[test]
fn stats_in_flight_matches_simultaneous_holders() {
let pool = MacAudioFramePool::new(8, 64).expect("pool");
let a = pool.try_acquire().expect("a");
let b = pool.try_acquire().expect("b");
let c = pool.try_acquire().expect("c");
assert_eq!(pool.stats().in_flight, 3);
drop(b);
assert_eq!(pool.stats().in_flight, 2);
drop(a);
drop(c);
assert_eq!(pool.stats().in_flight, 0);
}
#[test]
fn into_external_parts_exposes_filled_pointer_and_length() {
let pool = MacAudioFramePool::new(2, 64).expect("pool");
let mut slot = pool.try_acquire().expect("slot");
let payload = [0x5A_u8; 16];
slot.write(&payload).expect("write fits");
let (ptr, len, owned) = slot.into_external_parts();
assert!(!ptr.is_null());
assert_eq!(len, 16);
let observed = unsafe { core::slice::from_raw_parts(ptr, len) };
assert_eq!(observed, &payload[..]);
assert_eq!(pool.stats().in_flight, 1);
drop(owned);
assert_eq!(pool.stats().in_flight, 0);
}
#[test]
fn into_external_parts_drop_returns_slot_to_pool() {
let pool = MacAudioFramePool::new(1, 32).expect("pool");
let mut slot = pool.try_acquire().expect("slot");
slot.write(&[0xCC_u8; 8]).expect("write");
let (_ptr, _len, owned) = slot.into_external_parts();
assert!(pool.try_acquire().is_none());
drop(owned);
let revived = pool.try_acquire().expect("revived");
assert_eq!(revived.filled_len(), 0);
drop(revived);
assert_eq!(pool.stats().in_flight, 0);
}
#[test]
fn into_external_parts_holds_slot_across_send() {
let pool = MacAudioFramePool::new(2, 64).expect("pool");
let mut slot = pool.try_acquire().expect("slot");
slot.write(&[0x77_u8; 32]).expect("write");
let (ptr, len, owned) = slot.into_external_parts();
let ptr_addr = ptr as usize;
let handle = thread::spawn(move || {
let owned = owned;
assert_eq!(owned.filled_len(), 32);
let observed = unsafe { core::slice::from_raw_parts(ptr_addr as *const u8, len) };
assert_eq!(observed[0], 0x77);
drop(owned);
});
handle.join().expect("worker");
assert_eq!(pool.stats().in_flight, 0);
}
#[test]
fn as_mut_ptr_returns_slot_base_address() {
let pool = MacAudioFramePool::new(1, 64).expect("pool");
let mut slot = pool.try_acquire().expect("slot");
let payload = [0x11_u8; 8];
slot.write(&payload).expect("write");
let ptr = slot.as_mut_ptr();
assert!(!ptr.is_null());
unsafe { ptr.add(0).write(0x22) };
unsafe { ptr.add(1).write(0x33) };
assert_eq!(&slot.data_slice()[..2], &[0x22, 0x33]);
}
#[test]
fn capacity_one_pool_round_trips() {
let pool = MacAudioFramePool::new(1, 32).expect("pool");
for _ in 0..5 {
let mut slot = pool.try_acquire().expect("slot");
slot.write(&[1, 2, 3]).expect("write");
assert_eq!(slot.data_slice(), &[1, 2, 3]);
drop(slot);
}
let stats = pool.stats();
assert_eq!(stats.acquired, 5);
assert_eq!(stats.released, 5);
assert_eq!(stats.in_flight, 0);
}
}
@@ -0,0 +1,769 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::fmt;
pub const FPS_MIN: u32 = 1;
pub const FPS_MAX: u32 = 120;
pub const MAX_OUTPUT_WIDTH_DEFAULT: u32 = 3840;
pub const MAX_OUTPUT_HEIGHT_DEFAULT: u32 = 2160;
pub const OUTPUT_DIMENSION_MIN: u32 = 2;
pub const QUEUE_DEPTH_MIN: u32 = 1;
pub const QUEUE_DEPTH_MAX: u32 = 16;
pub const QUEUE_DEPTH_DEFAULT: u32 = 8;
pub const FPS_DEFAULT: u32 = 30;
pub const FRAME_INTERVAL_FACTOR_NUM: u64 = 9;
pub const FRAME_INTERVAL_FACTOR_DEN: u64 = 10;
pub const PIXEL_FORMAT_BGRA_FOURCC: u32 = u32::from_be_bytes(*b"BGRA");
pub const PIXEL_FORMAT_L10R_FOURCC: u32 = u32::from_be_bytes(*b"l10r");
pub const PIXEL_FORMAT_420V_FOURCC: u32 = u32::from_be_bytes(*b"420v");
pub const PIXEL_FORMAT_420F_FOURCC: u32 = u32::from_be_bytes(*b"420f");
pub const AUDIO_SAMPLE_RATE_DEFAULT_HZ: u32 = 48_000;
pub const AUDIO_CHANNEL_COUNT_DEFAULT: u32 = 2;
pub const AUDIO_SAMPLE_RATE_MIN_HZ: u32 = 8_000;
pub const AUDIO_SAMPLE_RATE_MAX_HZ: u32 = 192_000;
pub const AUDIO_CHANNEL_COUNT_MIN: u32 = 1;
pub const AUDIO_CHANNEL_COUNT_MAX: u32 = 8;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SckPixelFormat {
Bgra8,
L10rHdr,
Nv12VideoRange,
Nv12FullRange,
}
impl SckPixelFormat {
pub fn as_fourcc(self) -> u32 {
let value = match self {
SckPixelFormat::Bgra8 => PIXEL_FORMAT_BGRA_FOURCC,
SckPixelFormat::L10rHdr => PIXEL_FORMAT_L10R_FOURCC,
SckPixelFormat::Nv12VideoRange => PIXEL_FORMAT_420V_FOURCC,
SckPixelFormat::Nv12FullRange => PIXEL_FORMAT_420F_FOURCC,
};
assert!(value != 0);
value
}
pub fn is_hdr(self) -> bool {
matches!(self, SckPixelFormat::L10rHdr)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SckColorSpace {
DisplayP3,
SrgbBt709,
}
impl SckColorSpace {
pub fn as_cf_name(self) -> &'static str {
let name = match self {
SckColorSpace::DisplayP3 => "kCGColorSpaceDisplayP3",
SckColorSpace::SrgbBt709 => "kCGColorSpaceSRGB",
};
assert!(!name.is_empty());
name
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SckError {
InvalidFps(u32),
InvalidQueueDepth(u32),
HdrRequiresWideColorSpace,
InvalidAudioSampleRate(u32),
InvalidAudioChannelCount(u32),
}
impl fmt::Display for SckError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SckError::InvalidFps(v) => write!(
f,
"SckCaptureConfig: target_fps={v} out of range [{FPS_MIN}..={FPS_MAX}]"
),
SckError::InvalidQueueDepth(v) => write!(
f,
"SckCaptureConfig: queue_depth={v} out of range [{QUEUE_DEPTH_MIN}..={QUEUE_DEPTH_MAX}]"
),
SckError::HdrRequiresWideColorSpace => write!(
f,
"SckCaptureConfig: l10r HDR pixel format requires DisplayP3 color space"
),
SckError::InvalidAudioSampleRate(v) => write!(
f,
"SckCaptureConfig: audio_sample_rate_hz={v} out of range [{AUDIO_SAMPLE_RATE_MIN_HZ}..={AUDIO_SAMPLE_RATE_MAX_HZ}]"
),
SckError::InvalidAudioChannelCount(v) => write!(
f,
"SckCaptureConfig: audio_channels={v} out of range [{AUDIO_CHANNEL_COUNT_MIN}..={AUDIO_CHANNEL_COUNT_MAX}]"
),
}
}
}
impl std::error::Error for SckError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SckCaptureConfig {
target_fps: u32,
queue_depth: u32,
pixel_format: SckPixelFormat,
color_space: SckColorSpace,
captures_audio: bool,
audio_sample_rate_hz: u32,
audio_channels: u32,
}
impl SckCaptureConfig {
pub fn new(
target_fps: u32,
queue_depth: u32,
pixel_format: SckPixelFormat,
color_space: SckColorSpace,
) -> Result<Self, SckError> {
Self::new_with_audio(
target_fps,
queue_depth,
pixel_format,
color_space,
false,
AUDIO_SAMPLE_RATE_DEFAULT_HZ,
AUDIO_CHANNEL_COUNT_DEFAULT,
)
}
pub fn new_with_audio(
target_fps: u32,
queue_depth: u32,
pixel_format: SckPixelFormat,
color_space: SckColorSpace,
captures_audio: bool,
audio_sample_rate_hz: u32,
audio_channels: u32,
) -> Result<Self, SckError> {
if !(FPS_MIN..=FPS_MAX).contains(&target_fps) {
return Err(SckError::InvalidFps(target_fps));
}
if !(QUEUE_DEPTH_MIN..=QUEUE_DEPTH_MAX).contains(&queue_depth) {
return Err(SckError::InvalidQueueDepth(queue_depth));
}
if pixel_format.is_hdr() && color_space != SckColorSpace::DisplayP3 {
return Err(SckError::HdrRequiresWideColorSpace);
}
if !(AUDIO_SAMPLE_RATE_MIN_HZ..=AUDIO_SAMPLE_RATE_MAX_HZ).contains(&audio_sample_rate_hz) {
return Err(SckError::InvalidAudioSampleRate(audio_sample_rate_hz));
}
if !(AUDIO_CHANNEL_COUNT_MIN..=AUDIO_CHANNEL_COUNT_MAX).contains(&audio_channels) {
return Err(SckError::InvalidAudioChannelCount(audio_channels));
}
let cfg = Self {
target_fps,
queue_depth,
pixel_format,
color_space,
captures_audio,
audio_sample_rate_hz,
audio_channels,
};
assert!(cfg.target_fps >= FPS_MIN);
assert!(cfg.target_fps <= FPS_MAX);
assert!(cfg.queue_depth >= QUEUE_DEPTH_MIN);
assert!(cfg.queue_depth <= QUEUE_DEPTH_MAX);
assert!(cfg.audio_sample_rate_hz >= AUDIO_SAMPLE_RATE_MIN_HZ);
assert!(cfg.audio_channels >= AUDIO_CHANNEL_COUNT_MIN);
Ok(cfg)
}
pub fn builder() -> SckCaptureConfigBuilder {
SckCaptureConfigBuilder::default()
}
pub fn target_fps(&self) -> u32 {
assert!(self.target_fps >= FPS_MIN);
assert!(self.target_fps <= FPS_MAX);
self.target_fps
}
pub fn queue_depth(&self) -> u32 {
assert!(self.queue_depth >= QUEUE_DEPTH_MIN);
assert!(self.queue_depth <= QUEUE_DEPTH_MAX);
self.queue_depth
}
pub fn pixel_format(&self) -> SckPixelFormat {
let pf = self.pixel_format;
assert!(pf.as_fourcc() != 0);
pf
}
pub fn color_space(&self) -> SckColorSpace {
let cs = self.color_space;
assert!(!cs.as_cf_name().is_empty());
cs
}
pub fn minimum_frame_interval_ns(&self) -> u64 {
assert!(self.target_fps >= FPS_MIN);
assert!(self.target_fps <= FPS_MAX);
let base_ns: u64 = 1_000_000_000 / (self.target_fps as u64);
let scaled = base_ns * FRAME_INTERVAL_FACTOR_NUM / FRAME_INTERVAL_FACTOR_DEN;
assert!(scaled > 0);
assert!(scaled <= 1_000_000_000);
scaled
}
pub fn captures_audio(&self) -> bool {
self.captures_audio
}
pub fn audio_sample_rate_hz(&self) -> u32 {
assert!(self.audio_sample_rate_hz >= AUDIO_SAMPLE_RATE_MIN_HZ);
assert!(self.audio_sample_rate_hz <= AUDIO_SAMPLE_RATE_MAX_HZ);
self.audio_sample_rate_hz
}
pub fn audio_channels(&self) -> u32 {
assert!(self.audio_channels >= AUDIO_CHANNEL_COUNT_MIN);
assert!(self.audio_channels <= AUDIO_CHANNEL_COUNT_MAX);
self.audio_channels
}
}
impl Default for SckCaptureConfig {
fn default() -> Self {
let cfg = Self::new_with_audio(
FPS_DEFAULT,
QUEUE_DEPTH_DEFAULT,
SckPixelFormat::Nv12VideoRange,
SckColorSpace::SrgbBt709,
false,
AUDIO_SAMPLE_RATE_DEFAULT_HZ,
AUDIO_CHANNEL_COUNT_DEFAULT,
);
match cfg {
Ok(c) => c,
Err(_) => unreachable!("default SckCaptureConfig must validate"),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct SckCaptureConfigBuilder {
target_fps: u32,
queue_depth: u32,
pixel_format: SckPixelFormat,
color_space: SckColorSpace,
captures_audio: bool,
audio_sample_rate_hz: u32,
audio_channels: u32,
}
impl Default for SckCaptureConfigBuilder {
fn default() -> Self {
Self {
target_fps: FPS_DEFAULT,
queue_depth: QUEUE_DEPTH_DEFAULT,
pixel_format: SckPixelFormat::Nv12VideoRange,
color_space: SckColorSpace::SrgbBt709,
captures_audio: false,
audio_sample_rate_hz: AUDIO_SAMPLE_RATE_DEFAULT_HZ,
audio_channels: AUDIO_CHANNEL_COUNT_DEFAULT,
}
}
}
impl SckCaptureConfigBuilder {
pub fn target_fps(mut self, target_fps: u32) -> Self {
self.target_fps = target_fps;
self
}
pub fn queue_depth(mut self, queue_depth: u32) -> Self {
self.queue_depth = queue_depth;
self
}
pub fn pixel_format(mut self, pixel_format: SckPixelFormat) -> Self {
self.pixel_format = pixel_format;
self
}
pub fn color_space(mut self, color_space: SckColorSpace) -> Self {
self.color_space = color_space;
self
}
pub fn captures_audio(mut self, captures_audio: bool) -> Self {
self.captures_audio = captures_audio;
self
}
pub fn audio_sample_rate_hz(mut self, audio_sample_rate_hz: u32) -> Self {
self.audio_sample_rate_hz = audio_sample_rate_hz;
self
}
pub fn audio_channels(mut self, audio_channels: u32) -> Self {
self.audio_channels = audio_channels;
self
}
pub fn build(self) -> Result<SckCaptureConfig, SckError> {
SckCaptureConfig::new_with_audio(
self.target_fps,
self.queue_depth,
self.pixel_format,
self.color_space,
self.captures_audio,
self.audio_sample_rate_hz,
self.audio_channels,
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SckCaptureFailure {
StreamStoppedWithError(String),
StreamStartFailed(String),
SystemDeniedAccess,
DisplayDisconnected,
Unknown(String),
}
impl SckCaptureFailure {
pub fn reason(&self) -> &str {
match self {
SckCaptureFailure::StreamStoppedWithError(m) => m.as_str(),
SckCaptureFailure::StreamStartFailed(m) => m.as_str(),
SckCaptureFailure::SystemDeniedAccess => "screen recording permission denied",
SckCaptureFailure::DisplayDisconnected => "captured display was disconnected",
SckCaptureFailure::Unknown(m) => m.as_str(),
}
}
}
pub trait CaptureFailureSurface: Send + Sync {
fn on_failure(&self, reason: SckCaptureFailure);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u32)]
pub enum AudioSampleFormat {
F32Planar = 0,
F32Interleaved = 1,
I16Interleaved = 2,
Unknown = 3,
}
pub const AUDIO_SAMPLE_FORMAT_CODE_MAX: u32 = 3;
impl AudioSampleFormat {
pub fn code(self) -> u32 {
let code = match self {
AudioSampleFormat::F32Planar => 0,
AudioSampleFormat::F32Interleaved => 1,
AudioSampleFormat::I16Interleaved => 2,
AudioSampleFormat::Unknown => 3,
};
assert!(code <= AUDIO_SAMPLE_FORMAT_CODE_MAX);
assert_eq!(code, self as u32);
code
}
pub fn as_str(self) -> &'static str {
let s = match self {
AudioSampleFormat::F32Planar => "f32_planar",
AudioSampleFormat::F32Interleaved => "f32_interleaved",
AudioSampleFormat::I16Interleaved => "i16_interleaved",
AudioSampleFormat::Unknown => "unknown",
};
assert!(!s.is_empty());
s
}
pub fn bytes_per_sample(self) -> u32 {
let bytes = match self {
AudioSampleFormat::F32Planar => 4,
AudioSampleFormat::F32Interleaved => 4,
AudioSampleFormat::I16Interleaved => 2,
AudioSampleFormat::Unknown => 0,
};
assert!(bytes <= 4);
bytes
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct MacScreenShareAudioFrame {
pub sample_rate_hz: u32,
pub channels: u32,
pub num_samples_per_channel: u32,
pub pts_us: i64,
}
impl MacScreenShareAudioFrame {
pub fn new(
sample_rate_hz: u32,
channels: u32,
num_samples_per_channel: u32,
pts_us: i64,
) -> Result<Self, SckError> {
if !(AUDIO_SAMPLE_RATE_MIN_HZ..=AUDIO_SAMPLE_RATE_MAX_HZ).contains(&sample_rate_hz) {
return Err(SckError::InvalidAudioSampleRate(sample_rate_hz));
}
if !(AUDIO_CHANNEL_COUNT_MIN..=AUDIO_CHANNEL_COUNT_MAX).contains(&channels) {
return Err(SckError::InvalidAudioChannelCount(channels));
}
assert!(sample_rate_hz >= AUDIO_SAMPLE_RATE_MIN_HZ);
assert!(channels >= AUDIO_CHANNEL_COUNT_MIN);
Ok(Self {
sample_rate_hz,
channels,
num_samples_per_channel,
pts_us,
})
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct MacScreenShareAudioFrameWithBytes {
pub sample_rate_hz: u32,
pub channels: u32,
pub num_samples_per_channel: u32,
pub pts_us: i64,
pub format: AudioSampleFormat,
pub samples: Vec<u8>,
}
impl MacScreenShareAudioFrameWithBytes {
pub fn new(
sample_rate_hz: u32,
channels: u32,
num_samples_per_channel: u32,
pts_us: i64,
format: AudioSampleFormat,
samples: Vec<u8>,
) -> Result<Self, SckError> {
if !(AUDIO_SAMPLE_RATE_MIN_HZ..=AUDIO_SAMPLE_RATE_MAX_HZ).contains(&sample_rate_hz) {
return Err(SckError::InvalidAudioSampleRate(sample_rate_hz));
}
if !(AUDIO_CHANNEL_COUNT_MIN..=AUDIO_CHANNEL_COUNT_MAX).contains(&channels) {
return Err(SckError::InvalidAudioChannelCount(channels));
}
assert!(sample_rate_hz >= AUDIO_SAMPLE_RATE_MIN_HZ);
assert!(channels >= AUDIO_CHANNEL_COUNT_MIN);
Ok(Self {
sample_rate_hz,
channels,
num_samples_per_channel,
pts_us,
format,
samples,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builder_defaults_produce_valid_config() {
let cfg = SckCaptureConfig::builder()
.build()
.expect("default builder");
assert_eq!(cfg.target_fps(), FPS_DEFAULT);
assert_eq!(cfg.queue_depth(), QUEUE_DEPTH_DEFAULT);
assert_eq!(cfg.pixel_format(), SckPixelFormat::Nv12VideoRange);
assert_eq!(cfg.color_space(), SckColorSpace::SrgbBt709);
}
#[test]
fn builder_rejects_fps_zero() {
let err = SckCaptureConfig::builder()
.target_fps(0)
.build()
.unwrap_err();
assert_eq!(err, SckError::InvalidFps(0));
}
#[test]
fn builder_rejects_fps_above_max() {
let err = SckCaptureConfig::builder()
.target_fps(FPS_MAX + 1)
.build()
.unwrap_err();
assert_eq!(err, SckError::InvalidFps(FPS_MAX + 1));
}
#[test]
fn builder_accepts_fps_120() {
let cfg = SckCaptureConfig::builder()
.target_fps(FPS_MAX)
.build()
.expect("120 fps ok");
assert_eq!(cfg.target_fps(), FPS_MAX);
}
#[test]
fn builder_rejects_queue_depth_zero() {
let err = SckCaptureConfig::builder()
.queue_depth(0)
.build()
.unwrap_err();
assert_eq!(err, SckError::InvalidQueueDepth(0));
}
#[test]
fn builder_rejects_queue_depth_above_max() {
let err = SckCaptureConfig::builder()
.queue_depth(QUEUE_DEPTH_MAX + 1)
.build()
.unwrap_err();
assert_eq!(err, SckError::InvalidQueueDepth(QUEUE_DEPTH_MAX + 1));
}
#[test]
fn builder_rejects_hdr_without_displayp3() {
let err = SckCaptureConfig::builder()
.pixel_format(SckPixelFormat::L10rHdr)
.color_space(SckColorSpace::SrgbBt709)
.build()
.unwrap_err();
assert_eq!(err, SckError::HdrRequiresWideColorSpace);
}
#[test]
fn builder_accepts_hdr_with_displayp3() {
let cfg = SckCaptureConfig::builder()
.pixel_format(SckPixelFormat::L10rHdr)
.color_space(SckColorSpace::DisplayP3)
.build()
.expect("hdr with p3 ok");
assert!(cfg.pixel_format().is_hdr());
assert_eq!(cfg.color_space(), SckColorSpace::DisplayP3);
}
#[test]
fn minimum_frame_interval_60_fps_is_15_ms() {
let cfg = SckCaptureConfig::builder()
.target_fps(60)
.build()
.expect("60 fps");
let ns = cfg.minimum_frame_interval_ns();
let expected = (1_000_000_000_u64 / 60) * 9 / 10;
assert_eq!(ns, expected);
assert!((14_000_000..=16_000_000).contains(&ns));
}
#[test]
fn minimum_frame_interval_30_fps_factor_applied() {
let cfg = SckCaptureConfig::builder()
.target_fps(30)
.build()
.expect("30 fps");
let ns = cfg.minimum_frame_interval_ns();
let base = 1_000_000_000_u64 / 30;
assert_eq!(ns, base * 9 / 10);
assert!(ns < base);
}
#[test]
fn minimum_frame_interval_120_fps_under_8_3ms() {
let cfg = SckCaptureConfig::builder()
.target_fps(120)
.build()
.expect("120 fps");
let ns = cfg.minimum_frame_interval_ns();
assert!(ns < 8_400_000);
assert!(ns > 6_000_000);
}
#[test]
fn pixel_format_fourcc_matches_obs_constants() {
assert_eq!(SckPixelFormat::Bgra8.as_fourcc(), PIXEL_FORMAT_BGRA_FOURCC);
assert_eq!(
SckPixelFormat::L10rHdr.as_fourcc(),
PIXEL_FORMAT_L10R_FOURCC
);
assert_eq!(
SckPixelFormat::Nv12VideoRange.as_fourcc(),
PIXEL_FORMAT_420V_FOURCC
);
assert_eq!(
SckPixelFormat::Nv12FullRange.as_fourcc(),
PIXEL_FORMAT_420F_FOURCC
);
}
#[test]
fn color_space_names_present() {
assert_eq!(
SckColorSpace::DisplayP3.as_cf_name(),
"kCGColorSpaceDisplayP3"
);
assert_eq!(SckColorSpace::SrgbBt709.as_cf_name(), "kCGColorSpaceSRGB");
}
#[test]
fn failure_reason_strings_non_empty() {
let f = SckCaptureFailure::StreamStoppedWithError("oops".into());
assert_eq!(f.reason(), "oops");
let f = SckCaptureFailure::SystemDeniedAccess;
assert!(!f.reason().is_empty());
let f = SckCaptureFailure::DisplayDisconnected;
assert!(!f.reason().is_empty());
}
#[test]
fn default_config_validates() {
let cfg = SckCaptureConfig::default();
assert_eq!(cfg.target_fps(), FPS_DEFAULT);
assert_eq!(cfg.queue_depth(), QUEUE_DEPTH_DEFAULT);
}
#[test]
fn captures_audio_defaults_to_false() {
let cfg = SckCaptureConfig::default();
assert!(!cfg.captures_audio());
assert_eq!(cfg.audio_sample_rate_hz(), AUDIO_SAMPLE_RATE_DEFAULT_HZ);
assert_eq!(cfg.audio_channels(), AUDIO_CHANNEL_COUNT_DEFAULT);
}
#[test]
fn builder_captures_audio_toggle_preserves_frame_interval() {
let cfg_off = SckCaptureConfig::builder()
.target_fps(60)
.build()
.expect("60 fps off");
let cfg_on = SckCaptureConfig::builder()
.target_fps(60)
.captures_audio(true)
.build()
.expect("60 fps on");
assert!(!cfg_off.captures_audio());
assert!(cfg_on.captures_audio());
assert_eq!(
cfg_off.minimum_frame_interval_ns(),
cfg_on.minimum_frame_interval_ns(),
"captures_audio toggle must not alter minimum_frame_interval_ns"
);
}
#[test]
fn builder_rejects_audio_sample_rate_zero() {
let err = SckCaptureConfig::builder()
.audio_sample_rate_hz(0)
.build()
.unwrap_err();
assert_eq!(err, SckError::InvalidAudioSampleRate(0));
}
#[test]
fn builder_rejects_audio_channel_count_zero() {
let err = SckCaptureConfig::builder()
.audio_channels(0)
.build()
.unwrap_err();
assert_eq!(err, SckError::InvalidAudioChannelCount(0));
}
#[test]
fn builder_accepts_48khz_stereo_audio() {
let cfg = SckCaptureConfig::builder()
.captures_audio(true)
.audio_sample_rate_hz(48_000)
.audio_channels(2)
.build()
.expect("48k stereo ok");
assert!(cfg.captures_audio());
assert_eq!(cfg.audio_sample_rate_hz(), 48_000);
assert_eq!(cfg.audio_channels(), 2);
}
#[test]
fn mac_screen_share_audio_frame_constructs_valid() {
let frame =
MacScreenShareAudioFrame::new(48_000, 2, 1024, 12_345).expect("valid audio frame");
assert_eq!(frame.sample_rate_hz, 48_000);
assert_eq!(frame.channels, 2);
assert_eq!(frame.num_samples_per_channel, 1024);
assert_eq!(frame.pts_us, 12_345);
}
#[test]
fn mac_screen_share_audio_frame_rejects_invalid_sample_rate() {
let err = MacScreenShareAudioFrame::new(0, 2, 1024, 0).expect_err("0 hz invalid");
assert_eq!(err, SckError::InvalidAudioSampleRate(0));
}
#[test]
fn mac_screen_share_audio_frame_rejects_too_many_channels() {
let err = MacScreenShareAudioFrame::new(48_000, 16, 1024, 0).expect_err("16 ch invalid");
assert_eq!(err, SckError::InvalidAudioChannelCount(16));
}
#[test]
fn audio_sample_format_strings_and_widths() {
assert_eq!(AudioSampleFormat::F32Planar.as_str(), "f32_planar");
assert_eq!(
AudioSampleFormat::F32Interleaved.as_str(),
"f32_interleaved"
);
assert_eq!(
AudioSampleFormat::I16Interleaved.as_str(),
"i16_interleaved"
);
assert_eq!(AudioSampleFormat::Unknown.as_str(), "unknown");
assert_eq!(AudioSampleFormat::F32Planar.bytes_per_sample(), 4);
assert_eq!(AudioSampleFormat::F32Interleaved.bytes_per_sample(), 4);
assert_eq!(AudioSampleFormat::I16Interleaved.bytes_per_sample(), 2);
assert_eq!(AudioSampleFormat::Unknown.bytes_per_sample(), 0);
}
#[test]
fn audio_sample_format_codes_are_stable_and_bounded() {
assert_eq!(AudioSampleFormat::F32Planar.code(), 0);
assert_eq!(AudioSampleFormat::F32Interleaved.code(), 1);
assert_eq!(AudioSampleFormat::I16Interleaved.code(), 2);
assert_eq!(AudioSampleFormat::Unknown.code(), 3);
assert!(AudioSampleFormat::Unknown.code() <= AUDIO_SAMPLE_FORMAT_CODE_MAX);
}
#[test]
fn mac_screen_share_audio_frame_with_bytes_round_trip() {
let payload = vec![0xAA_u8; 16];
let f = MacScreenShareAudioFrameWithBytes::new(
48_000,
2,
4,
999,
AudioSampleFormat::F32Planar,
payload.clone(),
)
.expect("valid frame with bytes");
assert_eq!(f.sample_rate_hz, 48_000);
assert_eq!(f.channels, 2);
assert_eq!(f.num_samples_per_channel, 4);
assert_eq!(f.pts_us, 999);
assert_eq!(f.format, AudioSampleFormat::F32Planar);
assert_eq!(f.samples, payload);
}
#[test]
fn mac_screen_share_audio_frame_with_bytes_rejects_bad_sample_rate() {
let err = MacScreenShareAudioFrameWithBytes::new(
0,
2,
4,
0,
AudioSampleFormat::F32Planar,
Vec::new(),
)
.expect_err("zero hz invalid");
assert_eq!(err, SckError::InvalidAudioSampleRate(0));
}
}
@@ -0,0 +1,853 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::collections::VecDeque;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::Duration;
use parking_lot::{Condvar, Mutex};
pub const CAPTURE_PTS_MAP_CAP: usize = RING_SIZE;
pub const EXTERNAL_SURFACE_QUEUE_CAP: usize = RING_SIZE;
pub const READY_WAIT_TIMEOUT_US_MAX: u64 = 16_667;
use fluxer_encoder_ring::{
EncoderFrameRate, EncoderInputRing, EncoderReady, IoSurfaceSlotHandle,
MetalSharedTextureBackend, RING_SIZE, RingError, TextureFormat,
};
#[cfg(target_os = "macos")]
use fluxer_encoder_ring::FillReservation;
#[cfg(target_os = "macos")]
use fluxer_encoder_ring::VtPixelTransfer;
#[cfg(target_os = "macos")]
use crate::iosurface_pair::{IoSurfaceRaw, iosurface_decrement_use_count};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EncoderAttachError {
AlreadyAttached,
InvalidDimensions { width: u32, height: u32 },
RingInitFailed,
NotAttached,
}
impl std::fmt::Display for EncoderAttachError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::AlreadyAttached => write!(f, "encoder ring already attached"),
Self::InvalidDimensions { width, height } => {
write!(f, "encoder ring invalid dimensions {width}x{height}")
}
Self::RingInitFailed => write!(f, "encoder ring initialise failed"),
Self::NotAttached => write!(f, "encoder ring not attached"),
}
}
}
impl std::error::Error for EncoderAttachError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct EncoderAttachStats {
pub frames_submitted: u64,
pub frames_dropped: u64,
pub ring_full_events: u64,
pub failed_blits: u64,
}
#[cfg(target_os = "macos")]
pub struct ExternalSurfaceFrame {
surface: IoSurfaceRaw,
sequence: u64,
capture_pts_us: i64,
}
#[cfg(target_os = "macos")]
unsafe impl Send for ExternalSurfaceFrame {}
#[cfg(target_os = "macos")]
impl ExternalSurfaceFrame {
pub fn surface(&self) -> IoSurfaceRaw {
assert!(self.sequence > 0, "external frame sequence starts at one");
assert!(self.surface.as_ptr() as usize != 0, "surface ptr non-null");
self.surface
}
pub fn sequence(&self) -> u64 {
assert!(self.sequence > 0, "external frame sequence starts at one");
assert!(self.surface.as_ptr() as usize != 0, "surface ptr non-null");
self.sequence
}
pub fn capture_pts_us(&self) -> i64 {
assert!(self.sequence > 0, "external frame sequence starts at one");
assert!(self.surface.as_ptr() as usize != 0, "surface ptr non-null");
self.capture_pts_us
}
}
#[cfg(target_os = "macos")]
impl Drop for ExternalSurfaceFrame {
fn drop(&mut self) {
unsafe { iosurface_decrement_use_count(self.surface) };
unsafe { CFRelease(self.surface.as_ptr()) };
}
}
#[cfg(target_os = "macos")]
struct BlitDestination {
pixel_buffer: *mut core::ffi::c_void,
}
#[cfg(target_os = "macos")]
unsafe impl Send for BlitDestination {}
#[cfg(target_os = "macos")]
unsafe impl Sync for BlitDestination {}
#[cfg(target_os = "macos")]
impl Drop for BlitDestination {
fn drop(&mut self) {
unsafe { release_pixel_buffer(self.pixel_buffer) };
}
}
pub struct EncoderAttachment {
ring: Mutex<EncoderInputRing<MetalSharedTextureBackend>>,
ready_condvar: Condvar,
attached: AtomicBool,
frames_submitted: AtomicU64,
frames_dropped: AtomicU64,
ring_full_events: AtomicU64,
failed_blits: AtomicU64,
width: u32,
height: u32,
frame_rate: EncoderFrameRate,
capture_pts_by_sequence: Mutex<VecDeque<(u64, i64)>>,
#[cfg(target_os = "macos")]
pixel_transfer: Mutex<Option<VtPixelTransfer>>,
#[cfg(target_os = "macos")]
blit_destinations: Vec<BlitDestination>,
#[cfg(target_os = "macos")]
external_surfaces: Mutex<VecDeque<ExternalSurfaceFrame>>,
#[cfg(target_os = "macos")]
external_sequence: AtomicU64,
}
impl EncoderAttachment {
pub fn try_new(width: u32, height: u32) -> Result<Arc<Self>, EncoderAttachError> {
Self::try_new_with_frame_rate(width, height, EncoderFrameRate::default())
}
pub fn try_new_with_frame_rate(
width: u32,
height: u32,
frame_rate: EncoderFrameRate,
) -> Result<Arc<Self>, EncoderAttachError> {
if width == 0 || height == 0 {
return Err(EncoderAttachError::InvalidDimensions { width, height });
}
assert!(frame_rate.numerator > 0, "frame rate numerator positive");
assert!(
frame_rate.denominator > 0,
"frame rate denominator positive"
);
let mut ring = EncoderInputRing::new(MetalSharedTextureBackend::new());
ring.initialise(width, height, TextureFormat::Nv12)
.map_err(|_| EncoderAttachError::RingInitFailed)?;
#[cfg(target_os = "macos")]
let blit_destinations = build_blit_destinations(&mut ring, width, height)?;
#[cfg(target_os = "macos")]
let pixel_transfer = VtPixelTransfer::new().ok();
let attachment = Self {
ring: Mutex::new(ring),
ready_condvar: Condvar::new(),
attached: AtomicBool::new(true),
frames_submitted: AtomicU64::new(0),
frames_dropped: AtomicU64::new(0),
ring_full_events: AtomicU64::new(0),
failed_blits: AtomicU64::new(0),
width,
height,
frame_rate,
capture_pts_by_sequence: Mutex::new(VecDeque::with_capacity(CAPTURE_PTS_MAP_CAP)),
#[cfg(target_os = "macos")]
pixel_transfer: Mutex::new(pixel_transfer),
#[cfg(target_os = "macos")]
blit_destinations,
#[cfg(target_os = "macos")]
external_surfaces: Mutex::new(VecDeque::with_capacity(EXTERNAL_SURFACE_QUEUE_CAP)),
#[cfg(target_os = "macos")]
external_sequence: AtomicU64::new(0),
};
assert!(
attachment.attached.load(Ordering::Acquire),
"attachment is attached"
);
assert!(attachment.width > 0, "attachment width positive");
assert!(
attachment.frame_rate.numerator > 0,
"attachment fps positive"
);
Ok(Arc::new(attachment))
}
pub fn width(&self) -> u32 {
let w = self.width;
assert!(w > 0, "attachment width positive");
assert!(self.height > 0, "attachment height positive");
w
}
pub fn height(&self) -> u32 {
let h = self.height;
assert!(h > 0, "attachment height positive");
assert!(self.width > 0, "attachment width positive");
h
}
pub fn frame_rate(&self) -> EncoderFrameRate {
let rate = self.frame_rate;
assert!(rate.numerator > 0, "attachment fps numerator positive");
assert!(rate.denominator > 0, "attachment fps denominator positive");
rate
}
pub fn is_attached(&self) -> bool {
let a = self.attached.load(Ordering::Acquire);
assert!(self.width > 0, "width intact while reading attached");
assert!(self.height > 0, "height intact while reading attached");
a
}
pub fn detach(&self) {
self.attached.store(false, Ordering::Release);
#[cfg(target_os = "macos")]
{
let drained = core::mem::take(&mut *self.external_surfaces.lock());
assert!(
drained.len() <= EXTERNAL_SURFACE_QUEUE_CAP,
"external queue bounded at detach"
);
drop(drained);
}
self.ready_condvar.notify_all();
}
pub fn capacity(&self) -> usize {
let cap = RING_SIZE;
assert!(cap > 0, "ring capacity positive");
assert_eq!(cap, 8, "ring capacity matches RING_SIZE");
cap
}
pub fn submit_iosurface_frame(&self, _iosurface_handle: u64) -> Result<(), EncoderAttachError> {
if !self.attached.load(Ordering::Acquire) {
return Err(EncoderAttachError::NotAttached);
}
let mut ring = self.ring.lock();
let result: Result<(), RingError> = ring.submit_skip_oldest(|_handle| {});
drop(ring);
match result {
Ok(()) => {
self.frames_submitted.fetch_add(1, Ordering::Relaxed);
self.ready_condvar.notify_one();
Ok(())
}
Err(RingError::FullDropped { .. }) => {
self.frames_dropped.fetch_add(1, Ordering::Relaxed);
self.ring_full_events.fetch_add(1, Ordering::Relaxed);
Ok(())
}
Err(_) => Err(EncoderAttachError::RingInitFailed),
}
}
#[cfg(target_os = "macos")]
pub fn submit_with_blit(
&self,
source_pixel_buffer: *mut core::ffi::c_void,
capture_pts_us: i64,
) -> Result<(), EncoderAttachError> {
assert!(
!source_pixel_buffer.is_null(),
"blit source must be non-null"
);
assert!(self.width > 0, "attachment width positive");
if !self.attached.load(Ordering::Acquire) {
return Err(EncoderAttachError::NotAttached);
}
let transfer_guard = self.pixel_transfer.lock();
let Some(transfer) = transfer_guard.as_ref() else {
self.failed_blits.fetch_add(1, Ordering::Relaxed);
return Err(EncoderAttachError::RingInitFailed);
};
let Some(reservation) = self.reserve_blit_slot()? else {
return Ok(());
};
let slot_index = reservation.slot_index() as usize;
assert!(
slot_index < self.blit_destinations.len(),
"slot index within cached destinations"
);
let dest_pb = self.blit_destinations[slot_index].pixel_buffer;
assert!(
!dest_pb.is_null(),
"cached destination pixel buffer non-null"
);
let blit_ok = unsafe { transfer.transfer(source_pixel_buffer, dest_pb) }.is_ok();
if blit_ok {
self.commit_blit_slot(reservation, capture_pts_us)
} else {
let cancelled = self.ring.lock().cancel(reservation);
assert!(cancelled.is_ok(), "cancel of filling reservation succeeds");
self.failed_blits.fetch_add(1, Ordering::Relaxed);
Err(EncoderAttachError::RingInitFailed)
}
}
#[cfg(target_os = "macos")]
fn reserve_blit_slot(
&self,
) -> Result<Option<FillReservation<IoSurfaceSlotHandle>>, EncoderAttachError> {
assert!(self.width > 0, "attachment width positive");
assert!(self.height > 0, "attachment height positive");
let mut ring = self.ring.lock();
match ring.reserve_skip_oldest() {
Ok(reservation) => {
assert!(
(reservation.slot_index() as usize) < RING_SIZE,
"reserved slot within ring"
);
Ok(Some(reservation))
}
Err(RingError::FullDropped { .. }) => {
self.frames_dropped.fetch_add(1, Ordering::Relaxed);
self.ring_full_events.fetch_add(1, Ordering::Relaxed);
Ok(None)
}
Err(_) => Err(EncoderAttachError::RingInitFailed),
}
}
#[cfg(target_os = "macos")]
fn commit_blit_slot(
&self,
reservation: FillReservation<IoSurfaceSlotHandle>,
capture_pts_us: i64,
) -> Result<(), EncoderAttachError> {
assert!(
(reservation.slot_index() as usize) < RING_SIZE,
"slot within ring"
);
let sequence = {
let mut ring = self.ring.lock();
let sequence = match ring.commit(reservation) {
Ok(sequence) => sequence,
Err(_) => return Err(EncoderAttachError::RingInitFailed),
};
self.record_capture_pts(sequence, capture_pts_us);
sequence
};
assert!(sequence > 0, "committed sequence positive");
self.frames_submitted.fetch_add(1, Ordering::Relaxed);
self.ready_condvar.notify_one();
Ok(())
}
pub fn wait_next_ready(&self, timeout: Duration) -> Option<EncoderReady<IoSurfaceSlotHandle>> {
assert!(self.width > 0, "attachment width positive");
let timeout_bound = Duration::from_micros(READY_WAIT_TIMEOUT_US_MAX);
let bounded_timeout = timeout.min(timeout_bound);
assert!(
bounded_timeout <= timeout_bound,
"wait bounded to one frame interval"
);
let mut ring = self.ring.lock();
if let Some(ready) = ring.poll_next_ready() {
return Some(ready);
}
if !self.attached.load(Ordering::Acquire) {
return None;
}
let _ = self.ready_condvar.wait_for(&mut ring, bounded_timeout);
ring.poll_next_ready()
}
#[cfg(target_os = "macos")]
pub unsafe fn submit_external_surface(
&self,
surface: IoSurfaceRaw,
capture_pts_us: i64,
) -> Result<(), EncoderAttachError> {
assert!(surface.as_ptr() as usize != 0, "external surface non-null");
assert!(self.width > 0, "attachment width positive");
if !self.attached.load(Ordering::Acquire) {
unsafe { CFRelease(surface.as_ptr()) };
return Err(EncoderAttachError::NotAttached);
}
unsafe { crate::iosurface_pair::iosurface_increment_use_count(surface) };
let sequence = self.external_sequence.fetch_add(1, Ordering::AcqRel) + 1;
assert!(sequence > 0, "external sequence starts at one");
let frame = ExternalSurfaceFrame {
surface,
sequence,
capture_pts_us,
};
let evicted = {
let mut queue = self.external_surfaces.lock();
let evicted = if queue.len() >= EXTERNAL_SURFACE_QUEUE_CAP {
queue.pop_front()
} else {
None
};
queue.push_back(frame);
assert!(
queue.len() <= EXTERNAL_SURFACE_QUEUE_CAP,
"external queue bounded"
);
evicted
};
if let Some(oldest) = evicted {
drop(oldest);
self.frames_dropped.fetch_add(1, Ordering::Relaxed);
self.ring_full_events.fetch_add(1, Ordering::Relaxed);
}
self.frames_submitted.fetch_add(1, Ordering::Relaxed);
Ok(())
}
#[cfg(target_os = "macos")]
pub fn take_external_surface(&self) -> Option<ExternalSurfaceFrame> {
let frame = {
let mut queue = self.external_surfaces.lock();
assert!(
queue.len() <= EXTERNAL_SURFACE_QUEUE_CAP,
"external queue bounded"
);
queue.pop_front()
};
if let Some(ref taken) = frame {
assert!(taken.sequence > 0, "popped frame has real sequence");
}
frame
}
#[cfg(target_os = "macos")]
pub fn external_surface_queue_len(&self) -> usize {
let len = self.external_surfaces.lock().len();
assert!(len <= EXTERNAL_SURFACE_QUEUE_CAP, "external queue bounded");
assert!(self.width > 0, "attachment width intact");
len
}
fn record_capture_pts(&self, sequence: u64, capture_pts_us: i64) {
assert!(sequence > 0, "ring sequences start at one");
let mut map = self.capture_pts_by_sequence.lock();
while map.len() >= CAPTURE_PTS_MAP_CAP {
map.pop_front();
}
map.push_back((sequence, capture_pts_us));
assert!(map.len() <= CAPTURE_PTS_MAP_CAP, "pts map bounded");
}
pub fn capture_pts_us_for_sequence(&self, sequence: u64) -> Option<i64> {
let map = self.capture_pts_by_sequence.lock();
assert!(map.len() <= CAPTURE_PTS_MAP_CAP, "pts map bounded");
map.iter()
.find(|(seq, _)| *seq == sequence)
.map(|(_, pts)| *pts)
}
pub fn note_ring_full(&self) {
self.ring_full_events.fetch_add(1, Ordering::Relaxed);
}
pub fn stats(&self) -> EncoderAttachStats {
let stats = EncoderAttachStats {
frames_submitted: self.frames_submitted.load(Ordering::Relaxed),
frames_dropped: self.frames_dropped.load(Ordering::Relaxed),
ring_full_events: self.ring_full_events.load(Ordering::Relaxed),
failed_blits: self.failed_blits.load(Ordering::Relaxed),
};
assert!(
stats.frames_dropped <= stats.ring_full_events + stats.frames_dropped,
"drop counter consistent"
);
assert!(
stats.frames_submitted <= u64::MAX / 2,
"submitted within plausible bound"
);
stats
}
}
#[cfg(target_os = "macos")]
fn build_blit_destinations(
ring: &mut EncoderInputRing<MetalSharedTextureBackend>,
width: u32,
height: u32,
) -> Result<Vec<BlitDestination>, EncoderAttachError> {
assert!(width > 0, "blit destination width positive");
assert!(height > 0, "blit destination height positive");
let mut destinations: Vec<BlitDestination> = Vec::with_capacity(RING_SIZE);
for slot_index in 0..RING_SIZE {
let surface_ptr = ring
.backend_mut()
.slot_iosurface_ptr(slot_index as u32)
.ok_or(EncoderAttachError::RingInitFailed)?;
assert!(!surface_ptr.is_null(), "slot iosurface ptr non-null");
let pixel_buffer = unsafe { VtPixelTransfer::wrap_iosurface(surface_ptr, width, height) }
.map_err(|_| EncoderAttachError::RingInitFailed)?;
destinations.push(BlitDestination { pixel_buffer });
}
assert_eq!(destinations.len(), RING_SIZE, "one destination per slot");
Ok(destinations)
}
#[cfg(target_os = "macos")]
#[link(name = "CoreVideo", kind = "framework")]
unsafe extern "C" {
fn CVPixelBufferRelease(buffer: *mut core::ffi::c_void);
}
#[cfg(target_os = "macos")]
#[link(name = "CoreFoundation", kind = "framework")]
unsafe extern "C" {
fn CFRelease(cf: *const core::ffi::c_void);
fn CFRetain(cf: *const core::ffi::c_void) -> *const core::ffi::c_void;
}
#[cfg(target_os = "macos")]
unsafe fn release_pixel_buffer(pb: *mut core::ffi::c_void) {
if !pb.is_null() {
unsafe { CVPixelBufferRelease(pb) };
}
}
impl Drop for EncoderAttachment {
fn drop(&mut self) {
self.detach();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_zero_dimensions() {
let err = EncoderAttachment::try_new(0, 720).err();
assert!(matches!(
err,
Some(EncoderAttachError::InvalidDimensions { .. })
));
}
#[cfg(not(target_os = "macos"))]
#[test]
fn off_macos_init_fails() {
let err = EncoderAttachment::try_new(640, 480).err();
assert!(matches!(err, Some(EncoderAttachError::RingInitFailed)));
}
#[cfg(target_os = "macos")]
#[test]
fn macos_attach_then_detach() {
let attach = EncoderAttachment::try_new(640, 480).expect("attach ok");
assert!(attach.is_attached());
assert_eq!(attach.width(), 640);
assert_eq!(attach.height(), 480);
assert_eq!(attach.capacity(), 8);
attach.detach();
assert!(!attach.is_attached());
}
#[cfg(target_os = "macos")]
#[test]
fn macos_submit_records_stats() {
let attach = EncoderAttachment::try_new(64, 64).expect("attach ok");
for _ in 0..3 {
attach
.submit_iosurface_frame(0xdead_beef)
.expect("submit ok");
}
let stats = attach.stats();
assert_eq!(stats.frames_submitted, 3);
assert_eq!(stats.frames_dropped, 0);
assert_eq!(stats.ring_full_events, 0);
}
#[cfg(target_os = "macos")]
#[test]
fn wait_next_ready_returns_submitted_frame() {
let attach = EncoderAttachment::try_new(64, 64).expect("attach ok");
attach
.submit_iosurface_frame(0xdead_beef)
.expect("submit ok");
let ready = attach
.wait_next_ready(Duration::from_millis(5))
.expect("frame ready");
assert_eq!(ready.sequence, 1);
assert_eq!(ready.duplicate_count, 0);
assert!(attach.wait_next_ready(Duration::from_millis(1)).is_none());
}
#[cfg(target_os = "macos")]
#[test]
fn wait_next_ready_timeout_is_capped_at_frame_interval() {
let attach = EncoderAttachment::try_new(64, 64).expect("attach ok");
let start = std::time::Instant::now();
let ready = attach.wait_next_ready(Duration::from_secs(60));
assert!(ready.is_none());
assert!(
start.elapsed() < Duration::from_millis(500),
"wait returned within the named frame-interval bound"
);
}
#[cfg(target_os = "macos")]
#[test]
fn wait_next_ready_wakes_for_concurrent_submit() {
let attach = EncoderAttachment::try_new(64, 64).expect("attach ok");
let waiter = {
let attach = Arc::clone(&attach);
std::thread::spawn(move || attach.wait_next_ready(Duration::from_millis(15)))
};
std::thread::sleep(Duration::from_millis(3));
attach
.submit_iosurface_frame(0xdead_beef)
.expect("submit ok");
let ready = waiter.join().expect("waiter joins");
let sequence = ready.map(|r| r.sequence);
assert!(sequence == Some(1) || sequence.is_none());
if sequence.is_none() {
let retry = attach.wait_next_ready(Duration::from_millis(5));
assert_eq!(retry.map(|r| r.sequence), Some(1));
}
}
#[cfg(target_os = "macos")]
#[test]
fn wait_next_ready_returns_none_after_detach() {
let attach = EncoderAttachment::try_new(64, 64).expect("attach ok");
attach.detach();
assert!(!attach.is_attached());
assert!(attach.wait_next_ready(Duration::from_millis(5)).is_none());
}
#[cfg(target_os = "macos")]
#[test]
fn macos_submit_rejected_when_detached() {
let attach = EncoderAttachment::try_new(64, 64).expect("attach ok");
attach.detach();
let err = attach.submit_iosurface_frame(0xdead_beef).err();
assert!(matches!(err, Some(EncoderAttachError::NotAttached)));
}
#[cfg(target_os = "macos")]
#[test]
fn submit_encoder_ring_frame_blits_iosurface_to_slot() {
use core::ptr::NonNull;
use objc2_core_foundation::{CFDictionary, CFRetained};
use objc2_core_video::{
CVPixelBuffer, CVPixelBufferGetBaseAddressOfPlane, CVPixelBufferGetBytesPerRowOfPlane,
CVPixelBufferGetHeightOfPlane, CVPixelBufferLockBaseAddress, CVPixelBufferLockFlags,
CVPixelBufferUnlockBaseAddress, kCVPixelBufferIOSurfacePropertiesKey,
};
let attach = EncoderAttachment::try_new(256, 256).expect("attach ok");
let mut empty_keys: [*const core::ffi::c_void; 0] = [];
let mut empty_vals: [*const core::ffi::c_void; 0] = [];
let iosurf_dict: CFRetained<CFDictionary> = unsafe {
CFDictionary::new(
None,
empty_keys.as_mut_ptr(),
empty_vals.as_mut_ptr(),
0,
&objc2_core_foundation::kCFTypeDictionaryKeyCallBacks,
&objc2_core_foundation::kCFTypeDictionaryValueCallBacks,
)
.expect("iosurf empty dict")
};
let key_ref: &objc2_core_foundation::CFString =
unsafe { kCVPixelBufferIOSurfacePropertiesKey };
let key_ptr: *const core::ffi::c_void = key_ref as *const _ as *const core::ffi::c_void;
let val_ptr: *const core::ffi::c_void =
&*iosurf_dict as *const _ as *const core::ffi::c_void;
let mut keys = [key_ptr];
let mut vals = [val_ptr];
let attrs: CFRetained<CFDictionary> = unsafe {
CFDictionary::new(
None,
keys.as_mut_ptr(),
vals.as_mut_ptr(),
1,
&objc2_core_foundation::kCFTypeDictionaryKeyCallBacks,
&objc2_core_foundation::kCFTypeDictionaryValueCallBacks,
)
.expect("attrs dict")
};
let nv12: u32 = u32::from_be_bytes(*b"420v");
let mut pb_out: *mut CVPixelBuffer = core::ptr::null_mut();
let status = unsafe {
objc2_core_video::CVPixelBufferCreate(
None,
256,
256,
nv12,
Some(&attrs),
NonNull::new(&mut pb_out).expect("pb_out non-null"),
)
};
assert_eq!(status, 0, "CVPixelBufferCreate ok");
let source_pb: CFRetained<CVPixelBuffer> =
unsafe { CFRetained::from_raw(NonNull::new(pb_out).expect("pb non-null")) };
let lock_flags = CVPixelBufferLockFlags(0);
let lock_st = unsafe { CVPixelBufferLockBaseAddress(&source_pb, lock_flags) };
assert_eq!(lock_st, 0, "lock ok");
let y_ptr = CVPixelBufferGetBaseAddressOfPlane(&source_pb, 0);
let y_stride = CVPixelBufferGetBytesPerRowOfPlane(&source_pb, 0);
let y_h = CVPixelBufferGetHeightOfPlane(&source_pb, 0);
assert!(!y_ptr.is_null(), "Y plane base non-null");
assert!(y_stride > 0, "Y stride positive");
for row in 0..y_h {
for col in 0..y_stride {
unsafe {
(y_ptr as *mut u8).add(row * y_stride + col).write(0x55);
}
}
}
let _ = unsafe { CVPixelBufferUnlockBaseAddress(&source_pb, lock_flags) };
let src_ptr = &*source_pb as *const CVPixelBuffer as *mut core::ffi::c_void;
attach
.submit_with_blit(src_ptr, 41_500)
.expect("submit_with_blit ok");
let stats = attach.stats();
assert_eq!(stats.frames_submitted, 1, "one frame submitted");
assert_eq!(stats.failed_blits, 0, "no failed blits");
assert_eq!(
attach.capture_pts_us_for_sequence(1),
Some(41_500),
"capture pts recorded for ring sequence"
);
assert_eq!(attach.capture_pts_us_for_sequence(2), None);
}
#[cfg(target_os = "macos")]
#[test]
fn external_surface_submit_take_round_trip() {
use core::ptr::NonNull;
use fluxer_encoder_ring::metal_iosurface_macos::OwnedIoSurface;
let attach = EncoderAttachment::try_new(64, 64).expect("attach ok");
let owned = OwnedIoSurface::create_nv12(64, 64).expect("surface");
let raw = NonNull::new(owned.as_ptr()).expect("non-null surface");
let use_before = unsafe { crate::iosurface_pair::iosurface_use_count(raw) };
unsafe { CFRetain(raw.as_ptr()) };
unsafe { attach.submit_external_surface(raw, 41_500) }.expect("submit ok");
assert_eq!(attach.external_surface_queue_len(), 1);
let use_during = unsafe { crate::iosurface_pair::iosurface_use_count(raw) };
assert_eq!(use_during, use_before + 1);
let stats = attach.stats();
assert_eq!(stats.frames_submitted, 1);
assert_eq!(stats.frames_dropped, 0);
assert_eq!(stats.ring_full_events, 0);
let frame = attach.take_external_surface().expect("frame queued");
assert_eq!(frame.surface(), raw);
assert_eq!(frame.sequence(), 1);
assert_eq!(frame.capture_pts_us(), 41_500);
drop(frame);
let use_after = unsafe { crate::iosurface_pair::iosurface_use_count(raw) };
assert_eq!(use_after, use_before);
assert_eq!(attach.external_surface_queue_len(), 0);
}
#[cfg(target_os = "macos")]
#[test]
fn external_surface_queue_evicts_oldest_when_full() {
use core::ptr::NonNull;
use fluxer_encoder_ring::metal_iosurface_macos::OwnedIoSurface;
let attach = EncoderAttachment::try_new(64, 64).expect("attach ok");
let total = EXTERNAL_SURFACE_QUEUE_CAP + 2;
let mut owned: Vec<OwnedIoSurface> = Vec::with_capacity(total);
for i in 0..total {
let surface = OwnedIoSurface::create_nv12(64, 64).expect("surface");
let raw = NonNull::new(surface.as_ptr()).expect("non-null surface");
unsafe { CFRetain(raw.as_ptr()) };
unsafe { attach.submit_external_surface(raw, (i as i64) * 1_000) }.expect("submit ok");
owned.push(surface);
}
assert_eq!(
attach.external_surface_queue_len(),
EXTERNAL_SURFACE_QUEUE_CAP
);
let stats = attach.stats();
assert_eq!(stats.frames_submitted, total as u64);
assert_eq!(stats.frames_dropped, 2);
assert_eq!(stats.ring_full_events, 2);
let oldest_remaining = attach.take_external_surface().expect("frame queued");
assert_eq!(oldest_remaining.sequence(), 3);
assert_eq!(oldest_remaining.capture_pts_us(), 2_000);
}
#[cfg(target_os = "macos")]
#[test]
fn detach_drains_external_surface_queue_and_rejects_submit() {
use core::ptr::NonNull;
use fluxer_encoder_ring::metal_iosurface_macos::OwnedIoSurface;
let attach = EncoderAttachment::try_new(64, 64).expect("attach ok");
let owned = OwnedIoSurface::create_nv12(64, 64).expect("surface");
let raw = NonNull::new(owned.as_ptr()).expect("non-null surface");
let use_before = unsafe { crate::iosurface_pair::iosurface_use_count(raw) };
unsafe { CFRetain(raw.as_ptr()) };
unsafe { attach.submit_external_surface(raw, 7) }.expect("submit ok");
assert_eq!(attach.external_surface_queue_len(), 1);
attach.detach();
assert_eq!(attach.external_surface_queue_len(), 0);
assert_eq!(
unsafe { crate::iosurface_pair::iosurface_use_count(raw) },
use_before
);
unsafe { CFRetain(raw.as_ptr()) };
let err = unsafe { attach.submit_external_surface(raw, 8) }.err();
assert!(matches!(err, Some(EncoderAttachError::NotAttached)));
assert_eq!(attach.external_surface_queue_len(), 0);
assert_eq!(
unsafe { crate::iosurface_pair::iosurface_use_count(raw) },
use_before
);
}
#[cfg(target_os = "macos")]
#[test]
fn capture_pts_map_stays_bounded_and_evicts_oldest() {
let attach = EncoderAttachment::try_new(64, 64).expect("attach ok");
let total = (CAPTURE_PTS_MAP_CAP as u64) + 4;
for sequence in 1..=total {
attach.record_capture_pts(sequence, (sequence as i64) * 1_000);
}
assert_eq!(
attach.capture_pts_us_for_sequence(1),
None,
"oldest evicted"
);
assert_eq!(
attach.capture_pts_us_for_sequence(4),
None,
"oldest evicted"
);
assert_eq!(
attach.capture_pts_us_for_sequence(5),
Some(5_000),
"newest retained"
);
assert_eq!(
attach.capture_pts_us_for_sequence(total),
Some((total as i64) * 1_000)
);
}
}
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use objc2_foundation::{NSError, NSProcessInfo};
pub fn operating_system_version_string() -> String {
let info = NSProcessInfo::processInfo();
info.operatingSystemVersionString().to_string()
}
pub fn ns_error_localized_description(err: &NSError) -> String {
err.localizedDescription().to_string()
}
@@ -0,0 +1,331 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use core::ffi::c_void;
use core::ptr::NonNull;
pub type IoSurfaceRaw = NonNull<c_void>;
#[cfg(target_os = "macos")]
#[link(name = "IOSurface", kind = "framework")]
unsafe extern "C" {
fn IOSurfaceIncrementUseCount(buffer: *mut c_void);
fn IOSurfaceDecrementUseCount(buffer: *mut c_void);
fn IOSurfaceGetUseCount(buffer: *mut c_void) -> i32;
}
#[cfg(target_os = "macos")]
pub unsafe fn iosurface_increment_use_count(surface: IoSurfaceRaw) {
unsafe { IOSurfaceIncrementUseCount(surface.as_ptr()) };
}
#[cfg(target_os = "macos")]
pub unsafe fn iosurface_decrement_use_count(surface: IoSurfaceRaw) {
unsafe { IOSurfaceDecrementUseCount(surface.as_ptr()) };
}
#[cfg(target_os = "macos")]
pub unsafe fn iosurface_use_count(surface: IoSurfaceRaw) -> i32 {
unsafe { IOSurfaceGetUseCount(surface.as_ptr()) }
}
#[cfg(not(target_os = "macos"))]
pub unsafe fn iosurface_increment_use_count(_surface: IoSurfaceRaw) {}
#[cfg(not(target_os = "macos"))]
pub unsafe fn iosurface_decrement_use_count(_surface: IoSurfaceRaw) {}
#[cfg(not(target_os = "macos"))]
pub unsafe fn iosurface_use_count(_surface: IoSurfaceRaw) -> i32 {
0
}
pub struct IoSurfacePair {
current: Option<IoSurfaceRaw>,
prev: Option<IoSurfaceRaw>,
}
unsafe impl Send for IoSurfacePair {}
impl IoSurfacePair {
pub fn new() -> Self {
let pair = Self {
current: None,
prev: None,
};
assert!(pair.current.is_none());
assert!(pair.prev.is_none());
pair
}
pub fn has_current(&self) -> bool {
let has = self.current.is_some();
assert!(has == self.current.is_some());
has
}
pub fn has_prev(&self) -> bool {
let has = self.prev.is_some();
assert!(has == self.prev.is_some());
has
}
pub unsafe fn push(&mut self, new: IoSurfaceRaw) {
unsafe { iosurface_increment_use_count(new) };
let evicted = self.prev.take();
let rotated = self.current.take();
self.prev = rotated;
self.current = Some(new);
assert!(self.current.is_some());
if let Some(old) = evicted {
unsafe { iosurface_decrement_use_count(old) };
}
}
pub fn take_current(&mut self) -> Option<IoSurfaceRaw> {
let taken = self.current.take();
assert!(self.current.is_none());
taken
}
pub fn peek_current(&self) -> Option<IoSurfaceRaw> {
self.current
}
pub fn peek_prev(&self) -> Option<IoSurfaceRaw> {
self.prev
}
pub fn clear(&mut self) {
let cur = self.current.take();
let prev = self.prev.take();
assert!(self.current.is_none());
assert!(self.prev.is_none());
if let Some(s) = cur {
unsafe { iosurface_decrement_use_count(s) };
}
if let Some(s) = prev {
unsafe { iosurface_decrement_use_count(s) };
}
}
}
impl Default for IoSurfacePair {
fn default() -> Self {
Self::new()
}
}
impl Drop for IoSurfacePair {
fn drop(&mut self) {
let cur = self.current.take();
let prev = self.prev.take();
if let Some(s) = cur {
unsafe { iosurface_decrement_use_count(s) };
}
if let Some(s) = prev {
unsafe { iosurface_decrement_use_count(s) };
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use core::ptr::NonNull;
fn fake_surface(addr: usize) -> IoSurfaceRaw {
assert!(addr != 0);
NonNull::new(addr as *mut c_void).expect("non-null fake surface")
}
#[test]
fn new_pair_is_empty() {
let pair = IoSurfacePair::new();
assert!(!pair.has_current());
assert!(!pair.has_prev());
}
#[test]
fn take_current_on_empty_returns_none() {
let mut pair = IoSurfacePair::new();
assert!(pair.take_current().is_none());
}
#[cfg(not(target_os = "macos"))]
#[test]
fn push_rotates_current_to_prev_offplatform() {
let mut pair = IoSurfacePair::new();
let a = fake_surface(0x1);
let b = fake_surface(0x2);
unsafe {
pair.push(a);
}
assert_eq!(pair.peek_current(), Some(a));
assert!(pair.peek_prev().is_none());
unsafe {
pair.push(b);
}
assert_eq!(pair.peek_current(), Some(b));
assert_eq!(pair.peek_prev(), Some(a));
}
#[cfg(not(target_os = "macos"))]
#[test]
fn take_current_clears_slot_offplatform() {
let mut pair = IoSurfacePair::new();
let a = fake_surface(0x10);
unsafe {
pair.push(a);
}
let taken = pair.take_current().expect("current is set");
assert_eq!(taken, a);
assert!(pair.peek_current().is_none());
}
#[cfg(not(target_os = "macos"))]
#[test]
fn drop_pair_is_safe_offplatform() {
let mut pair = IoSurfacePair::new();
unsafe {
pair.push(fake_surface(0x100));
pair.push(fake_surface(0x200));
}
drop(pair);
}
}
#[cfg(all(test, target_os = "macos"))]
mod macos_tests {
use super::*;
use core::ffi::c_void;
use core::ptr::NonNull;
use objc2_core_foundation::{
CFDictionary, CFNumber, CFRetained, CFString, kCFAllocatorDefault,
kCFTypeDictionaryKeyCallBacks, kCFTypeDictionaryValueCallBacks,
};
#[link(name = "IOSurface", kind = "framework")]
unsafe extern "C" {
fn IOSurfaceCreate(properties: *const CFDictionary) -> *mut c_void;
}
fn cf_number_i32(v: i32) -> CFRetained<CFNumber> {
CFNumber::new_i32(v)
}
fn cf_string(s: &'static str) -> CFRetained<CFString> {
CFString::from_static_str(s)
}
fn make_iosurface() -> IoSurfaceRaw {
let width_key = cf_string("IOSurfaceWidth");
let height_key = cf_string("IOSurfaceHeight");
let bpe_key = cf_string("IOSurfaceBytesPerElement");
let pf_key = cf_string("IOSurfacePixelFormat");
let width_val = cf_number_i32(32);
let height_val = cf_number_i32(32);
let bpe_val = cf_number_i32(4);
let pf_val = cf_number_i32(i32::from_be_bytes(*b"BGRA"));
let keys: [*const c_void; 4] = [
CFRetained::as_ptr(&width_key).as_ptr() as *const c_void,
CFRetained::as_ptr(&height_key).as_ptr() as *const c_void,
CFRetained::as_ptr(&bpe_key).as_ptr() as *const c_void,
CFRetained::as_ptr(&pf_key).as_ptr() as *const c_void,
];
let vals: [*const c_void; 4] = [
CFRetained::as_ptr(&width_val).as_ptr() as *const c_void,
CFRetained::as_ptr(&height_val).as_ptr() as *const c_void,
CFRetained::as_ptr(&bpe_val).as_ptr() as *const c_void,
CFRetained::as_ptr(&pf_val).as_ptr() as *const c_void,
];
let dict_opt = unsafe {
CFDictionary::new(
kCFAllocatorDefault,
keys.as_ptr() as *mut *const c_void,
vals.as_ptr() as *mut *const c_void,
4,
&kCFTypeDictionaryKeyCallBacks,
&kCFTypeDictionaryValueCallBacks,
)
};
let dict = dict_opt.expect("CFDictionary::new returned non-null");
let dict_ptr = CFRetained::as_ptr(&dict).as_ptr() as *const CFDictionary;
let raw = unsafe { IOSurfaceCreate(dict_ptr) };
assert!(!raw.is_null(), "IOSurfaceCreate must produce a surface");
NonNull::new(raw).expect("non-null IOSurface")
}
#[test]
fn push_increments_use_count_and_take_current_does_not_release() {
let a = make_iosurface();
let before = unsafe { iosurface_use_count(a) };
let mut pair = IoSurfacePair::new();
unsafe {
pair.push(a);
}
let after_push = unsafe { iosurface_use_count(a) };
assert_eq!(after_push, before + 1);
let taken = pair.take_current().expect("current set");
assert_eq!(taken, a);
let after_take = unsafe { iosurface_use_count(a) };
assert_eq!(after_take, before + 1);
unsafe { iosurface_decrement_use_count(a) };
let after_balance = unsafe { iosurface_use_count(a) };
assert_eq!(after_balance, before);
}
#[test]
fn push_rotates_and_drop_releases_both_slots() {
let a = make_iosurface();
let b = make_iosurface();
let before_a = unsafe { iosurface_use_count(a) };
let before_b = unsafe { iosurface_use_count(b) };
let mut pair = IoSurfacePair::new();
unsafe {
pair.push(a);
pair.push(b);
}
assert_eq!(pair.peek_current(), Some(b));
assert_eq!(pair.peek_prev(), Some(a));
assert_eq!(unsafe { iosurface_use_count(a) }, before_a + 1);
assert_eq!(unsafe { iosurface_use_count(b) }, before_b + 1);
drop(pair);
assert_eq!(unsafe { iosurface_use_count(a) }, before_a);
assert_eq!(unsafe { iosurface_use_count(b) }, before_b);
}
#[test]
fn third_push_evicts_oldest_and_releases_it() {
let a = make_iosurface();
let b = make_iosurface();
let c = make_iosurface();
let before_a = unsafe { iosurface_use_count(a) };
let mut pair = IoSurfacePair::new();
unsafe {
pair.push(a);
pair.push(b);
pair.push(c);
}
assert_eq!(unsafe { iosurface_use_count(a) }, before_a);
assert_eq!(pair.peek_current(), Some(c));
assert_eq!(pair.peek_prev(), Some(b));
}
#[test]
fn clear_releases_both_slots() {
let a = make_iosurface();
let b = make_iosurface();
let before_a = unsafe { iosurface_use_count(a) };
let before_b = unsafe { iosurface_use_count(b) };
let mut pair = IoSurfacePair::new();
unsafe {
pair.push(a);
pair.push(b);
}
pair.clear();
assert_eq!(unsafe { iosurface_use_count(a) }, before_a);
assert_eq!(unsafe { iosurface_use_count(b) }, before_b);
assert!(pair.peek_current().is_none());
assert!(pair.peek_prev().is_none());
}
}
@@ -0,0 +1,44 @@
#![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::manual_is_multiple_of)]
#![allow(clippy::manual_slice_size_calculation)]
#![allow(clippy::unnecessary_cast)]
#![allow(clippy::not_unsafe_ptr_arg_deref)]
#![allow(clippy::missing_transmute_annotations)]
#![allow(clippy::missing_const_for_thread_local)]
#![allow(clippy::too_many_arguments)]
pub mod audio_pool;
pub mod config;
pub mod encoder_attach;
pub mod iosurface_pair;
pub mod os_version;
pub use audio_pool::{
MAC_AUDIO_POOL_CAP, MAX_FRAME_BYTES_PER_SLOT, MacAudioError, MacAudioFramePool,
MacAudioPoolStats, PooledMacAudioFrame,
};
pub use config::{
AUDIO_CHANNEL_COUNT_DEFAULT, AUDIO_CHANNEL_COUNT_MAX, AUDIO_CHANNEL_COUNT_MIN,
AUDIO_SAMPLE_RATE_DEFAULT_HZ, AUDIO_SAMPLE_RATE_MAX_HZ, AUDIO_SAMPLE_RATE_MIN_HZ,
AudioSampleFormat, CaptureFailureSurface, FPS_DEFAULT, FPS_MAX, FPS_MIN,
MacScreenShareAudioFrame, MacScreenShareAudioFrameWithBytes, QUEUE_DEPTH_DEFAULT,
QUEUE_DEPTH_MAX, QUEUE_DEPTH_MIN, SckCaptureConfig, SckCaptureConfigBuilder, SckCaptureFailure,
SckColorSpace, SckError, SckPixelFormat,
};
pub use encoder_attach::{EncoderAttachError, EncoderAttachStats, EncoderAttachment};
pub use iosurface_pair::{IoSurfacePair, IoSurfaceRaw};
#[cfg(target_os = "macos")]
pub mod foundation;
#[cfg(target_os = "macos")]
pub mod sck;
#[cfg(target_os = "macos")]
mod napi_surface_macos;
#[cfg(not(target_os = "macos"))]
mod napi_surface_stub;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,82 @@
// 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/mac-screen-capture is only supported on macOS",
)
}
#[napi(object, js_name = "MacScreenCaptureSource")]
pub struct MacScreenCaptureSource {
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<i32>,
}
#[napi(js_name = "listSources")]
pub fn list_sources() -> Result<Vec<MacScreenCaptureSource>> {
Ok(Vec::new())
}
#[napi(object, js_name = "MacScreenCaptureBackendSckAvailability")]
pub struct SckAvailability {
pub supported: bool,
pub macos_version: Option<String>,
}
#[napi(object, js_name = "MacScreenCaptureBackendAvailability")]
pub struct BackendAvailability {
pub sck: SckAvailability,
pub screen_permission: String,
}
#[napi(js_name = "getBackendAvailability")]
pub fn get_backend_availability() -> Result<BackendAvailability> {
Err(unsupported())
}
#[napi(object, js_name = "MacScreenCaptureBackendInfo")]
pub struct MacScreenCaptureBackendInfo {
pub backend: String,
pub supported: bool,
pub reason: String,
#[napi(js_name = "minMacosVersion")]
pub min_macos_version: String,
#[napi(js_name = "detectedMacosVersion")]
pub detected_macos_version: Option<String>,
#[napi(js_name = "sckAvailable")]
pub sck_available: bool,
}
#[napi(js_name = "getBackendInfo")]
pub fn get_backend_info() -> MacScreenCaptureBackendInfo {
use crate::os_version::{SCK_MIN_MACOS, format_version};
MacScreenCaptureBackendInfo {
backend: "mac-screen-capture".to_owned(),
supported: false,
reason: "@fluxer/mac-screen-capture is only supported on macOS".to_owned(),
min_macos_version: format_version(SCK_MIN_MACOS),
detected_macos_version: None,
sck_available: false,
}
}
#[napi]
pub struct ScreenCapture;
#[napi]
impl ScreenCapture {
#[napi(constructor)]
pub fn new() -> Result<Self> {
Err(unsupported())
}
}
@@ -0,0 +1,131 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
pub const SCK_MIN_MACOS: (i64, i64, i64) = (12, 3, 0);
pub fn meets_floor(version: (i64, i64, i64), floor: (i64, i64, i64)) -> bool {
if version.0 != floor.0 {
return version.0 > floor.0;
}
if version.1 != floor.1 {
return version.1 > floor.1;
}
version.2 >= floor.2
}
pub fn format_version(version: (i64, i64, i64)) -> String {
if version.2 == 0 {
format!("{}.{}", version.0, version.1)
} else {
format!("{}.{}.{}", version.0, version.1, version.2)
}
}
#[cfg(target_os = "macos")]
pub fn current_macos_version() -> Option<(i64, i64, i64)> {
use objc2_foundation::NSProcessInfo;
let info = NSProcessInfo::processInfo();
let v = info.operatingSystemVersion();
Some((
v.majorVersion as i64,
v.minorVersion as i64,
v.patchVersion as i64,
))
}
#[cfg(not(target_os = "macos"))]
pub fn current_macos_version() -> Option<(i64, i64, i64)> {
None
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SupportClassification {
pub supported: bool,
pub sck_available: bool,
pub reason: String,
}
pub fn classify_support(detected: Option<(i64, i64, i64)>) -> SupportClassification {
let min_sck = format_version(SCK_MIN_MACOS);
match detected {
None => SupportClassification {
supported: false,
sck_available: false,
reason: "mac-screen-capture could not detect the running macOS version. \
Native screen capture unavailable."
.to_owned(),
},
Some(v) => {
let detected_str = format_version(v);
let sck_ok = meets_floor(v, SCK_MIN_MACOS);
let reason = if sck_ok {
format!(
"mac-screen-capture supported on macOS {detected_str} \
(ScreenCaptureKit, requires macOS {min_sck}+)."
)
} else {
format!(
"mac-screen-capture requires macOS {min_sck}+ (ScreenCaptureKit). \
This Mac is running macOS {detected_str}. Native cursor-hidden \
screen capture unavailable; fall back to getDisplayMedia."
)
};
SupportClassification {
supported: sck_ok,
sck_available: sck_ok,
reason,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn meets_floor_exact_match() {
assert!(meets_floor((12, 3, 0), SCK_MIN_MACOS));
}
#[test]
fn meets_floor_higher_major() {
assert!(meets_floor((14, 0, 0), SCK_MIN_MACOS));
}
#[test]
fn rejects_below_floor() {
assert!(!meets_floor((12, 2, 9), SCK_MIN_MACOS));
assert!(!meets_floor((11, 7, 10), SCK_MIN_MACOS));
}
#[test]
fn format_version_trims_zero_patch() {
assert_eq!("12.3", format_version((12, 3, 0)));
assert_eq!("14.2.1", format_version((14, 2, 1)));
}
#[test]
fn classify_unknown_version_is_unsupported() {
let c = classify_support(None);
assert!(!c.supported);
assert!(!c.sck_available);
assert!(c.reason.contains("could not detect"));
}
#[test]
fn classify_macos_11_is_unsupported() {
let c = classify_support(Some((11, 7, 10)));
assert!(!c.supported);
assert!(!c.sck_available);
assert!(c.reason.contains("macOS 12.3+"));
assert!(c.reason.contains("macOS 11.7.10"));
}
#[test]
fn classify_macos_12_3_is_supported() {
let c = classify_support(Some((12, 3, 0)));
assert!(c.supported);
assert!(c.sck_available);
assert!(c.reason.contains("ScreenCaptureKit"));
}
}
@@ -0,0 +1,322 @@
#![allow(non_snake_case)]
// SPDX-License-Identifier: AGPL-3.0-or-later
use objc2::rc::Retained;
use objc2_core_foundation::{CFString, CGFloat, CGRect};
use objc2_core_media::{CMTime, CMTimeFlags};
use objc2_foundation::NSString;
use objc2_screen_capture_kit::{
SCCaptureDynamicRange, SCContentFilter, SCDisplay, SCRunningApplication, SCStream,
SCStreamConfiguration, SCWindow,
};
use crate::config::{SckCaptureConfig, SckColorSpace, SckPixelFormat};
pub use objc2_screen_capture_kit::SCStreamOutputType;
pub fn cmtime_seconds(value: i64, timescale: i32) -> CMTime {
CMTime {
value,
timescale,
flags: CMTimeFlags(1),
epoch: 0,
}
}
pub fn sc_running_application_process_id(app: &SCRunningApplication) -> i32 {
unsafe { app.processID() }
}
pub fn sc_running_application_bundle_identifier(app: &SCRunningApplication) -> Retained<NSString> {
unsafe { app.bundleIdentifier() }
}
pub fn sc_running_application_name(app: &SCRunningApplication) -> Retained<NSString> {
unsafe { app.applicationName() }
}
pub fn sc_display_frame(display: &SCDisplay) -> CGRect {
unsafe { display.frame() }
}
pub fn sc_display_display_id(display: &SCDisplay) -> u32 {
unsafe { display.displayID() }
}
pub fn sc_display_width(display: &SCDisplay) -> isize {
unsafe { display.width() }
}
pub fn sc_display_height(display: &SCDisplay) -> isize {
unsafe { display.height() }
}
pub fn sc_window_window_id(win: &SCWindow) -> u32 {
unsafe { win.windowID() }
}
pub fn sc_window_owning_application(win: &SCWindow) -> Option<Retained<SCRunningApplication>> {
unsafe { win.owningApplication() }
}
pub fn sc_window_frame(win: &SCWindow) -> CGRect {
unsafe { win.frame() }
}
pub fn sc_window_title(win: &SCWindow) -> Option<Retained<NSString>> {
unsafe { win.title() }
}
pub fn sc_window_is_on_screen(win: &SCWindow) -> bool {
unsafe { win.isOnScreen() }
}
pub fn filter_content_rect_if_available(filter: &SCContentFilter) -> Option<CGRect> {
use objc2::runtime::NSObjectProtocol;
use objc2::{msg_send, sel};
let obj: &objc2::runtime::NSObject = filter.as_ref();
if !obj.respondsToSelector(sel!(contentRect)) {
return None;
}
let rect: CGRect = unsafe { msg_send![obj, contentRect] };
if rect.size.width <= 0.0 || rect.size.height <= 0.0 {
return None;
}
Some(rect)
}
pub fn filter_point_pixel_scale_if_available(filter: &SCContentFilter) -> Option<f32> {
use objc2::runtime::NSObjectProtocol;
use objc2::{msg_send, sel};
let obj: &objc2::runtime::NSObject = filter.as_ref();
if !obj.respondsToSelector(sel!(pointPixelScale)) {
return None;
}
let scale: CGFloat = unsafe { msg_send![obj, pointPixelScale] };
if !scale.is_finite() || scale <= 0.0 {
return None;
}
Some(scale as f32)
}
pub fn cfg_set_scales_to_fit_if_available(cfg: &SCStreamConfiguration, scales_to_fit: bool) {
use objc2::runtime::NSObjectProtocol;
use objc2::{msg_send, sel};
let obj: &objc2::runtime::NSObject = cfg.as_ref();
if obj.respondsToSelector(sel!(setScalesToFit:)) {
unsafe {
let _: () = msg_send![obj, setScalesToFit: scales_to_fit];
}
}
}
pub fn cfg_set_width(cfg: &SCStreamConfiguration, v: usize) {
unsafe { cfg.setWidth(v) }
}
pub fn cfg_set_height(cfg: &SCStreamConfiguration, v: usize) {
unsafe { cfg.setHeight(v) }
}
pub fn cfg_set_queue_depth(cfg: &SCStreamConfiguration, v: isize) {
unsafe { cfg.setQueueDepth(v) }
}
pub fn cfg_set_shows_cursor(cfg: &SCStreamConfiguration, v: bool) {
unsafe { cfg.setShowsCursor(v) }
}
pub fn cfg_set_minimum_frame_interval(cfg: &SCStreamConfiguration, t: CMTime) {
unsafe { cfg.setMinimumFrameInterval(t) }
}
pub fn cfg_set_pixel_format(cfg: &SCStreamConfiguration, format: u32) {
unsafe { cfg.setPixelFormat(format) }
}
pub fn cfg_set_source_rect_if_available(cfg: &SCStreamConfiguration, rect: CGRect) -> bool {
use objc2::runtime::NSObjectProtocol;
use objc2::{msg_send, sel};
assert!(rect.size.width > 0.0);
assert!(rect.size.height > 0.0);
let obj: &objc2::runtime::NSObject = cfg.as_ref();
if !obj.respondsToSelector(sel!(setSourceRect:)) {
return false;
}
unsafe {
let _: () = msg_send![obj, setSourceRect: rect];
}
true
}
pub fn cfg_set_capture_dynamic_range_sdr_if_available(cfg: &SCStreamConfiguration) {
use objc2::runtime::NSObjectProtocol;
use objc2::sel;
let obj: &objc2::runtime::NSObject = cfg.as_ref();
if obj.respondsToSelector(sel!(setCaptureDynamicRange:)) {
unsafe {
cfg.setCaptureDynamicRange(SCCaptureDynamicRange::SDR);
}
}
}
pub fn cfg_set_capture_dynamic_range_hdr_if_available(cfg: &SCStreamConfiguration) {
use objc2::runtime::NSObjectProtocol;
use objc2::sel;
let obj: &objc2::runtime::NSObject = cfg.as_ref();
if obj.respondsToSelector(sel!(setCaptureDynamicRange:)) {
unsafe {
cfg.setCaptureDynamicRange(SCCaptureDynamicRange::HDRLocalDisplay);
}
}
}
pub fn cfg_set_color_space_name_if_available(cfg: &SCStreamConfiguration, name: &str) {
use objc2::runtime::NSObjectProtocol;
use objc2::sel;
assert!(!name.is_empty());
let obj: &objc2::runtime::NSObject = cfg.as_ref();
if obj.respondsToSelector(sel!(setColorSpaceName:)) {
let cf = CFString::from_str(name);
unsafe {
cfg.setColorSpaceName(&cf);
}
}
}
pub fn obs_minimum_frame_interval(target_fps: u32) -> CMTime {
assert!(target_fps >= crate::config::FPS_MIN);
assert!(target_fps <= crate::config::FPS_MAX);
CMTime {
value: crate::config::FRAME_INTERVAL_FACTOR_NUM as i64,
timescale: (crate::config::FRAME_INTERVAL_FACTOR_DEN as i32)
.saturating_mul(target_fps as i32),
flags: CMTimeFlags(1),
epoch: 0,
}
}
pub fn apply_capture_config(cfg: &SCStreamConfiguration, capture: &SckCaptureConfig) {
assert!(capture.target_fps() >= crate::config::FPS_MIN);
assert!(capture.queue_depth() >= crate::config::QUEUE_DEPTH_MIN);
cfg_set_queue_depth(cfg, capture.queue_depth() as isize);
cfg_set_pixel_format(cfg, capture.pixel_format().as_fourcc());
cfg_set_minimum_frame_interval(cfg, obs_minimum_frame_interval(capture.target_fps()));
cfg_set_color_space_name_if_available(cfg, capture.color_space().as_cf_name());
match capture.pixel_format() {
SckPixelFormat::L10rHdr => {
assert!(capture.color_space() == SckColorSpace::DisplayP3);
cfg_set_capture_dynamic_range_hdr_if_available(cfg);
}
SckPixelFormat::Bgra8 | SckPixelFormat::Nv12VideoRange | SckPixelFormat::Nv12FullRange => {
cfg_set_capture_dynamic_range_sdr_if_available(cfg);
}
}
if capture.captures_audio() {
cfg_set_captures_audio_if_available(cfg, true);
cfg_set_audio_sample_rate_if_available(cfg, capture.audio_sample_rate_hz());
cfg_set_audio_channel_count_if_available(cfg, capture.audio_channels());
cfg_set_excludes_current_process_audio_if_available(cfg, true);
} else {
cfg_set_captures_audio_if_available(cfg, false);
}
}
pub fn cfg_set_captures_audio_if_available(cfg: &SCStreamConfiguration, captures_audio: bool) {
use objc2::runtime::NSObjectProtocol;
use objc2::sel;
let obj: &objc2::runtime::NSObject = cfg.as_ref();
if obj.respondsToSelector(sel!(setCapturesAudio:)) {
unsafe {
cfg.setCapturesAudio(captures_audio);
}
}
}
pub fn cfg_set_audio_sample_rate_if_available(cfg: &SCStreamConfiguration, sample_rate_hz: u32) {
use objc2::runtime::NSObjectProtocol;
use objc2::sel;
assert!(sample_rate_hz >= crate::config::AUDIO_SAMPLE_RATE_MIN_HZ);
assert!(sample_rate_hz <= crate::config::AUDIO_SAMPLE_RATE_MAX_HZ);
let obj: &objc2::runtime::NSObject = cfg.as_ref();
if obj.respondsToSelector(sel!(setSampleRate:)) {
unsafe {
cfg.setSampleRate(sample_rate_hz as isize);
}
}
}
pub fn cfg_set_audio_channel_count_if_available(cfg: &SCStreamConfiguration, channels: u32) {
use objc2::runtime::NSObjectProtocol;
use objc2::sel;
assert!(channels >= crate::config::AUDIO_CHANNEL_COUNT_MIN);
assert!(channels <= crate::config::AUDIO_CHANNEL_COUNT_MAX);
let obj: &objc2::runtime::NSObject = cfg.as_ref();
if obj.respondsToSelector(sel!(setChannelCount:)) {
unsafe {
cfg.setChannelCount(channels as isize);
}
}
}
pub fn cfg_set_excludes_current_process_audio_if_available(
cfg: &SCStreamConfiguration,
excludes_self: bool,
) {
use objc2::runtime::NSObjectProtocol;
use objc2::sel;
let obj: &objc2::runtime::NSObject = cfg.as_ref();
if obj.respondsToSelector(sel!(setExcludesCurrentProcessAudio:)) {
unsafe {
cfg.setExcludesCurrentProcessAudio(excludes_self);
}
}
}
pub fn cfg_set_stream_name_if_available(cfg: &SCStreamConfiguration, name: &NSString) {
use objc2::runtime::NSObjectProtocol;
use objc2::sel;
let obj: &objc2::runtime::NSObject = cfg.as_ref();
if obj.respondsToSelector(sel!(setStreamName:)) {
unsafe {
cfg.setStreamName(Some(name));
}
}
}
pub fn sc_stream_add_stream_output(
stream: &SCStream,
output: &objc2::runtime::ProtocolObject<dyn objc2_screen_capture_kit::SCStreamOutput>,
kind: SCStreamOutputType,
queue: Option<&dispatch2::DispatchQueue>,
) -> Result<(), Retained<objc2_foundation::NSError>> {
unsafe { stream.addStreamOutput_type_sampleHandlerQueue_error(output, kind, queue) }
}
#[cfg(test)]
mod tests {
use super::obs_minimum_frame_interval;
use crate::config::{FPS_MAX, FPS_MIN, SckCaptureConfig};
#[test]
fn minimum_frame_interval_is_strictly_shorter_than_frame_time() {
for fps in [FPS_MIN, 30, 60, FPS_MAX] {
let t = obs_minimum_frame_interval(fps);
assert!(t.value > 0);
assert!(t.timescale > 0);
let interval_ns = (t.value as u64) * 1_000_000_000 / (t.timescale as u64);
let frame_ns = 1_000_000_000 / (fps as u64);
assert!(interval_ns < frame_ns, "fps={fps}");
}
}
#[test]
fn minimum_frame_interval_matches_config_minimum_frame_interval_ns() {
for fps in [FPS_MIN, 30, 60, FPS_MAX] {
let t = obs_minimum_frame_interval(fps);
let cm_interval_ns = (t.value as u64) * 1_000_000_000 / (t.timescale as u64);
let cfg = SckCaptureConfig::builder()
.target_fps(fps)
.build()
.expect("config builds");
let cfg_interval_ns = cfg.minimum_frame_interval_ns();
assert!(cm_interval_ns.abs_diff(cfg_interval_ns) <= 1, "fps={fps}");
}
}
}