feat(desktop): sign every Windows release artifact (#1548)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-10 23:22:19 +02:00
1 parent 873c203b5d
commit 6e66c92dca
89 files changed
+2408 -2740

No files matched your search

+1
View File
@@ -158,6 +158,7 @@ export declare function getAvailability(): AvailabilityInfo;
export declare function listSources(): Promise<Array<ScreenCaptureSourceDescriptor>>;
export declare function resolveGameHookPath(): string | null;
export declare function resolveGameHookPathX86(): string | null;
export declare function isGameCaptureHookAvailable(): boolean;
export declare function resolveVulkanLayerManifestPath(): string | null;
export declare function registerVulkanLayerManifest(): boolean;
export declare function unregisterVulkanLayerManifest(): boolean;
@@ -6,7 +6,6 @@ const {join, sep} = require('node:path');
const {createNativeLoadError, loadNativeBinding} = require('./loader-diagnostics.cjs');
const MODULE_NAME = '@fluxer/win-game-capture';
const WINDOWS_GAME_CAPTURE_MODULE_ENV = 'FLUXER_WINDOWS_GAME_CAPTURE_MODULE_ENABLED';
function resolveNativeRoot() {
const asarSegment = `${sep}app.asar${sep}`;
@@ -30,14 +29,7 @@ let binding = null;
let loadError = null;
const nativeRoot = resolveNativeRoot();
if (process.platform === 'win32' && process.env[WINDOWS_GAME_CAPTURE_MODULE_ENV] !== 'true') {
loadError = createNativeLoadError({
moduleName: MODULE_NAME,
nativeRoot,
packageDir: __dirname,
reason: 'Windows game capture is disabled in this build',
});
} else if (process.platform === 'win32') {
if (process.platform === 'win32') {
const fileName = nativeFileName(process.arch);
if (!fileName) {
loadError = createNativeLoadError({
@@ -117,8 +109,15 @@ function resolveVulkanLayerManifestPath(root = nativeRoot) {
return existsSync(manifestPath) ? manifestPath : null;
}
function isGameCaptureHookAvailable(root = nativeRoot) {
if (typeof binding?.isGameCaptureHookAvailable !== 'function') return false;
if (binding.isGameCaptureHookAvailable() !== true) return false;
return resolveGameHookPath(root) !== null;
}
function registerVulkanLayerManifest(root = nativeRoot) {
if (!binding?.registerVulkanLayerManifest) return false;
if (!isGameCaptureHookAvailable(root)) return false;
const manifestPath = resolveVulkanLayerManifestPath(root);
if (!manifestPath) return false;
binding.registerVulkanLayerManifest(manifestPath);
@@ -151,14 +150,6 @@ function getVulkanLayerRegistrationState(root = nativeRoot) {
}
}
if (process.platform === 'win32' && binding) {
try {
registerVulkanLayerManifest();
} catch (error) {
console.warn('[win-game-capture] registerVulkanLayerManifest failed:', error?.message || error);
}
}
class ScreenCapture extends EventEmitter {
constructor(options = {}) {
super();
@@ -219,9 +210,6 @@ class ScreenCapture extends EventEmitter {
async start() {
if (this.started || this.stopped) return undefined;
if (this.sourceKind === 'game' && !this.hookDllPath) {
throw new Error(`Game capture hook unavailable for ${process.platform}-${process.arch}`);
}
this.started = true;
try {
if (this.frameSinkHandle != null) {
@@ -453,6 +441,7 @@ module.exports = {
getAvailability,
resolveGameHookPath,
resolveGameHookPathX86,
isGameCaptureHookAvailable,
resolveVulkanLayerManifestPath,
registerVulkanLayerManifest,
unregisterVulkanLayerManifest,
@@ -10,6 +10,7 @@ const winGameCapture = require('./index.js');
const startupSupported = winGameCapture.isSupported();
const startupAvailability = winGameCapture.getAvailability();
const startupHookAvailable = winGameCapture.isGameCaptureHookAvailable();
const realBindingSkip = startupSupported
? false
: 'no native binding loaded at startup (binding-less platform / unbuilt addon)';
@@ -29,6 +30,8 @@ function makeFakeBinding() {
const calls = [];
const frameSinkHandleCalls = [];
const priorityCalls = [];
const vulkanCalls = [];
const hookAvailable = {value: false};
const natives = [];
const diagnostics = {
state: 1,
@@ -195,10 +198,19 @@ function makeFakeBinding() {
restoreGpuSchedulingPriority: (processId) => {
priorityCalls.push({type: 'restore', processId});
},
isGameCaptureHookAvailable: () => hookAvailable.value,
registerVulkanLayerManifest: (manifestPath) => {
vulkanCalls.push({type: 'register', manifestPath});
},
unregisterVulkanLayerManifest: (manifestPath) => {
vulkanCalls.push({type: 'unregister', manifestPath});
},
},
calls,
frameSinkHandleCalls,
priorityCalls,
vulkanCalls,
hookAvailable,
natives,
diagnostics,
encoderDiagnostics,
@@ -297,9 +309,63 @@ describe('win-game-capture loader wrapper -- real native binding (built Windows
assert.equal(typeof r, 'string', 'expected a Vulkan layer manifest path on a built Windows box');
assert.ok(existsSync(r), `Vulkan layer manifest should exist on disk: ${r}`);
});
test('the native binding reports hook-based game capture as unavailable', {skip: realBindingSkip}, () => {
assert.equal(startupHookAvailable, false, 'hook capture is force-disabled in the native crate');
});
test('requiring the module leaves the Vulkan implicit layer unregistered', {skip: realBindingSkip}, () => {
assert.equal(
winGameCapture.registerVulkanLayerManifest(),
false,
'the Vulkan implicit layer must not be registered while hook capture is disabled',
);
assert.equal(winGameCapture.getVulkanLayerRegistrationState().registered, false);
});
});
describe('win-game-capture loader wrapper -- injected fake binding', () => {
test('isGameCaptureHookAvailable() follows the native hook flag', {skip: injectionSkip}, () => {
const {binding} = makeFakeBinding();
winGameCapture.__setBindingForTests(binding);
assert.equal(winGameCapture.isGameCaptureHookAvailable(), false);
});
test(
'isGameCaptureHookAvailable() is false when the native binding predates the hook flag',
{skip: injectionSkip},
() => {
const {binding} = makeFakeBinding();
binding.isGameCaptureHookAvailable = undefined;
winGameCapture.__setBindingForTests(binding);
assert.equal(winGameCapture.isGameCaptureHookAvailable(), false);
},
);
test(
'registerVulkanLayerManifest() never touches the registry while hook capture is unavailable',
{skip: injectionSkip},
() => {
const {binding, vulkanCalls} = makeFakeBinding();
winGameCapture.__setBindingForTests(binding);
assert.equal(winGameCapture.registerVulkanLayerManifest(), false);
assert.deepEqual(vulkanCalls, [], 'the native registration entry point must not be called');
},
);
test(
'registerVulkanLayerManifest() still refuses when the hook DLL is missing for this host',
{skip: injectionSkip || (winGameCapture.resolveGameHookPath() !== null && 'host ships a game capture hook DLL')},
() => {
const {binding, hookAvailable, vulkanCalls} = makeFakeBinding();
hookAvailable.value = true;
winGameCapture.__setBindingForTests(binding);
assert.equal(winGameCapture.isGameCaptureHookAvailable(), false);
assert.equal(winGameCapture.registerVulkanLayerManifest(), false);
assert.deepEqual(vulkanCalls, []);
},
);
test(
'listSources() forwards sanitized screen/window sources from the native binding',
{skip: injectionSkip},
@@ -546,7 +612,7 @@ describe('win-game-capture loader wrapper -- injected fake binding', () => {
);
test(
'game sourceKind without a hook path fails closed without WGC/browser fallback',
'game sourceKind starts without a hook path because capture no longer injects',
{skip: injectionSkip},
async () => {
const {binding, calls} = makeFakeBinding();
@@ -558,8 +624,9 @@ describe('win-game-capture loader wrapper -- injected fake binding', () => {
hookDllPathX86: '',
});
capture.on('error', () => {});
await assert.rejects(() => capture.start(), /Game capture hook unavailable/);
assert.equal(calls.length, 0, 'native start must not be called when the game hook is unavailable');
await capture.start();
assert.equal(calls.length, 1, 'native start must be called for game capture without a hook');
assert.equal(calls[0].sourceKind, 'game');
},
);
@@ -27,62 +27,19 @@ const FRAME_RATE = Number.parseInt(process.env.FLUXER_WIN_GAME_CAPTURE_FIXTURE_F
const START_TIMEOUT_MS = Number.parseInt(process.env.FLUXER_WIN_GAME_CAPTURE_FIXTURE_START_TIMEOUT_MS ?? '15000', 10);
const FRAME_TIMEOUT_MS = Number.parseInt(process.env.FLUXER_WIN_GAME_CAPTURE_FIXTURE_FRAME_TIMEOUT_MS ?? '15000', 10);
const MIN_OBSERVED_FRAMES = 3;
const API_NONE = 0;
const TRANSPORT_MEMORY = 0;
const TRANSPORT_SHARED_TEXTURE = 1;
const API_OPENGL = 1;
const API_D3D9 = 3;
const API_D3D10 = 4;
const API_D3D11 = 5;
const API_D3D12 = 6;
const API_VULKAN = 7;
const FALLBACK_NONE = 0;
const FALLBACK_SHARED_TEXTURE_UNSUPPORTED = 1;
const DXGI_FORMAT_UNKNOWN = 0;
const EXPECTED_ACTIVE_STRATEGY = 'wgc';
const EXPECTED_DIAGNOSTICS = {
'd3d9-present-fixture': {
apiType: API_D3D9,
transport: TRANSPORT_SHARED_TEXTURE,
fallbackReason: FALLBACK_NONE,
requiresDxgiFormat: true,
},
'd3d10-present-fixture': {
apiType: API_D3D10,
transport: TRANSPORT_SHARED_TEXTURE,
fallbackReason: FALLBACK_NONE,
requiresDxgiFormat: true,
},
'd3d11-present-fixture': {
apiType: API_D3D11,
transport: TRANSPORT_SHARED_TEXTURE,
fallbackReason: FALLBACK_NONE,
requiresDxgiFormat: true,
},
'd3d12-present-fixture': {
apiType: API_D3D12,
transport: TRANSPORT_SHARED_TEXTURE,
fallbackReason: FALLBACK_NONE,
requiresDxgiFormat: true,
},
'opengl-swapbuffers-fixture': {
apiType: API_OPENGL,
transportOneOf: [TRANSPORT_SHARED_TEXTURE, TRANSPORT_MEMORY],
fallbackReasonOneOf: [FALLBACK_NONE, FALLBACK_SHARED_TEXTURE_UNSUPPORTED],
requiresDxgiFormatWhenShared: true,
},
'vulkan-present-fixture': {
apiType: API_VULKAN,
transport: TRANSPORT_SHARED_TEXTURE,
fallbackReason: FALLBACK_NONE,
requiresDxgiFormat: true,
},
'i686-present-fixture': {
apiType: API_D3D11,
transport: TRANSPORT_SHARED_TEXTURE,
fallbackReason: FALLBACK_NONE,
requiresDxgiFormat: true,
},
apiType: API_NONE,
transport: TRANSPORT_MEMORY,
fallbackReason: FALLBACK_NONE,
dxgiFormat: DXGI_FORMAT_UNKNOWN,
};
function envFlag(name) {
@@ -216,50 +173,29 @@ function waitForHwnd(child, fixture) {
});
}
function frameSignature(frame) {
const bytes = frame.data;
const width = Math.max(1, frame.width);
const height = Math.max(1, frame.height);
const stride = Math.max(1, frame.strideY || (frame.format === 'bgra' ? width * 4 : width));
const rows = frame.format === 'bgra' ? Math.min(height, 12) : Math.min(height, 32);
let hash = 2166136261;
for (let y = 0; y < rows; y += 1) {
const row = y * stride;
const rowBytes = frame.format === 'bgra' ? Math.min(stride, width * 4, 256) : Math.min(stride, width, 256);
for (let x = 0; x < rowBytes; x += 4) {
hash ^= bytes[row + x] ?? 0;
hash = Math.imul(hash, 16777619) >>> 0;
}
}
return hash >>> 0;
function observedFrameCount(diagnostics) {
if (!diagnostics) return 0;
const accepted = Number(diagnostics.frameSinkAccepted ?? 0);
const coalesced = Number(diagnostics.frameSinkCoalesced ?? 0);
const droppedWithoutSink = Number(diagnostics.mediaFramesDroppedWithoutSink ?? 0);
const total = accepted + coalesced + droppedWithoutSink;
return Number.isFinite(total) ? total : 0;
}
async function waitForAdvancingFrames(screenCapture, fixture) {
const signatures = new Set();
let frameCount = 0;
async function waitForAdvancingFrames(screenCapture, fixture, stalls) {
let lastDiagnostics = null;
const onFrame = (frame) => {
frameCount += 1;
signatures.add(frameSignature(frame));
lastDiagnostics = screenCapture.getDiagnostics?.() ?? lastDiagnostics;
};
screenCapture.on('frame', onFrame);
const start = Date.now();
try {
while (Date.now() - start < FRAME_TIMEOUT_MS) {
lastDiagnostics = screenCapture.getDiagnostics?.() ?? lastDiagnostics;
const nativeFrames = Number(lastDiagnostics?.frameCounter ?? 0);
if (frameCount >= 3 && (signatures.size >= 2 || nativeFrames >= 3)) {
return {frameCount, signatures: signatures.size, diagnostics: lastDiagnostics};
}
await delay(100);
while (Date.now() - start < FRAME_TIMEOUT_MS) {
lastDiagnostics = screenCapture.getDiagnostics?.() ?? lastDiagnostics;
const frameCount = observedFrameCount(lastDiagnostics);
if (frameCount >= MIN_OBSERVED_FRAMES) {
return {frameCount, diagnostics: lastDiagnostics};
}
throw new Error(
`${fixture} capture did not deliver advancing frames within ${FRAME_TIMEOUT_MS}ms (frames=${frameCount}, signatures=${signatures.size}, diagnostics=${JSON.stringify(lastDiagnostics)})`,
);
} finally {
screenCapture.off('frame', onFrame);
await delay(100);
}
throw new Error(
`${fixture} capture did not deliver advancing frames within ${FRAME_TIMEOUT_MS}ms (frames=${observedFrameCount(lastDiagnostics)}, stalls=${JSON.stringify(stalls)}, diagnostics=${JSON.stringify(lastDiagnostics)})`,
);
}
function assertEqualDiagnostic(fixture, diagnostics, key, expected) {
@@ -268,53 +204,25 @@ function assertEqualDiagnostic(fixture, diagnostics, key, expected) {
}
}
function assertOneOfDiagnostic(fixture, diagnostics, key, expected) {
if (!expected.includes(diagnostics?.[key])) {
throw new Error(
`${fixture} expected diagnostics.${key} in [${expected.join(', ')}], got ${JSON.stringify(diagnostics)}`,
);
}
}
function assertFixtureDiagnostics(fixture, diagnostics) {
const expectedBase = EXPECTED_DIAGNOSTICS[fixture];
if (!expectedBase) return;
const expected = {...expectedBase};
const expected = {...EXPECTED_DIAGNOSTICS};
const apiOverride = diagnosticOverride('FLUXER_WIN_GAME_CAPTURE_EXPECT_API_TYPE', fixture);
const transportOverride = diagnosticOverride('FLUXER_WIN_GAME_CAPTURE_EXPECT_TRANSPORT', fixture);
const fallbackOverride = diagnosticOverride('FLUXER_WIN_GAME_CAPTURE_EXPECT_FALLBACK_REASON', fixture);
if (apiOverride !== undefined) expected.apiType = apiOverride;
if (transportOverride !== undefined) {
expected.transport = transportOverride;
delete expected.transportOneOf;
expected.requiresDxgiFormat = expected.transport === TRANSPORT_SHARED_TEXTURE;
}
if (fallbackOverride !== undefined) {
expected.fallbackReason = fallbackOverride;
delete expected.fallbackReasonOneOf;
}
if (transportOverride !== undefined) expected.transport = transportOverride;
if (fallbackOverride !== undefined) expected.fallbackReason = fallbackOverride;
if (diagnostics?.activeStrategy !== EXPECTED_ACTIVE_STRATEGY) {
throw new Error(
`${fixture} expected activeStrategy=${EXPECTED_ACTIVE_STRATEGY}, got ${JSON.stringify(diagnostics)}`,
);
}
assertEqualDiagnostic(fixture, diagnostics, 'apiType', expected.apiType);
if (expected.transportOneOf) {
assertOneOfDiagnostic(fixture, diagnostics, 'transport', expected.transportOneOf);
} else {
assertEqualDiagnostic(fixture, diagnostics, 'transport', expected.transport);
}
if (expected.fallbackReason !== undefined) {
assertEqualDiagnostic(fixture, diagnostics, 'fallbackReason', expected.fallbackReason);
} else if (expected.fallbackReasonOneOf) {
assertOneOfDiagnostic(fixture, diagnostics, 'fallbackReason', expected.fallbackReasonOneOf);
}
if (diagnostics?.activeStrategy !== 'game-hook') {
throw new Error(`${fixture} expected activeStrategy=game-hook, got ${JSON.stringify(diagnostics)}`);
}
if (
(expected.requiresDxgiFormat ||
(expected.requiresDxgiFormatWhenShared && diagnostics?.transport === TRANSPORT_SHARED_TEXTURE)) &&
Number(diagnostics?.dxgiFormat ?? 0) === 0
) {
throw new Error(`${fixture} expected a non-zero shared texture DXGI format, got ${JSON.stringify(diagnostics)}`);
}
assertEqualDiagnostic(fixture, diagnostics, 'transport', expected.transport);
assertEqualDiagnostic(fixture, diagnostics, 'fallbackReason', expected.fallbackReason);
assertEqualDiagnostic(fixture, diagnostics, 'dxgiFormat', expected.dxgiFormat);
assertEqualDiagnostic(fixture, diagnostics, 'injectionMethod', '');
}
async function runFixture(fixture) {
@@ -338,14 +246,16 @@ async function runFixture(fixture) {
frameRate: FRAME_RATE,
injectionMethod: process.env.FLUXER_WIN_GAME_CAPTURE_INJECTION_METHOD || 'auto',
});
const stalls = [];
screenCapture.on('stalled', (message) => stalls.push(message ?? ''));
let result;
try {
result = await screenCapture.start();
started = true;
const observed = await waitForAdvancingFrames(screenCapture, fixture);
const observed = await waitForAdvancingFrames(screenCapture, fixture, stalls);
assertFixtureDiagnostics(fixture, observed.diagnostics);
console.log(
`[fixture-smoke] PASS ${fixture}: start=${JSON.stringify(result)} frames=${observed.frameCount} signatures=${observed.signatures} diagnostics=${JSON.stringify(observed.diagnostics)}`,
`[fixture-smoke] PASS ${fixture}: start=${JSON.stringify(result)} frames=${observed.frameCount} stalls=${JSON.stringify(stalls)} diagnostics=${JSON.stringify(observed.diagnostics)}`,
);
} finally {
if (started) await screenCapture.stop().catch(() => {});
@@ -54,7 +54,11 @@ use windows_sys::Win32::{
MEMORY_MAPPED_VIEW_ADDRESS, MapViewOfFile, PAGE_READWRITE, UnmapViewOfFile,
VirtualAllocEx, VirtualFreeEx,
},
SystemInformation::{IMAGE_FILE_MACHINE, IMAGE_FILE_MACHINE_UNKNOWN},
SystemInformation::{
IMAGE_FILE_MACHINE, IMAGE_FILE_MACHINE_AMD64, IMAGE_FILE_MACHINE_ARM,
IMAGE_FILE_MACHINE_ARM64, IMAGE_FILE_MACHINE_ARMNT, IMAGE_FILE_MACHINE_I386,
IMAGE_FILE_MACHINE_IA64, IMAGE_FILE_MACHINE_UNKNOWN,
},
Threading::{
CreateEventW, CreateMutexW, CreateRemoteThread, GetCurrentProcessId, IsWow64Process,
IsWow64Process2, OpenProcess, PROCESS_CREATE_THREAD, PROCESS_QUERY_INFORMATION,
@@ -294,13 +298,19 @@ impl GameCaptureSession {
InjectionPolicy::Allow => false,
};
let target_is_32_bit = target_process_is_32_bit(target_pid)?;
let target_machine = target_process_machine(target_pid)?;
let target_is_32_bit = machine_is_32_bit(target_machine)?;
let selected_hook_path = if target_is_32_bit {
hook_path_x86.ok_or(
"target game process is 32-bit but no 32-bit game capture hook DLL was provided",
)?
} else {
} else if target_machine == HOST_MACHINE {
hook_path
} else {
return Err(format!(
"target game process architecture (IMAGE_FILE_MACHINE 0x{target_machine:04x}) \
differs from the host architecture and no matching game capture hook DLL ships for it"
));
};
let (capture_width, capture_height) = window_capture_size(target_hwnd)?;
@@ -485,6 +495,15 @@ struct Injected {
}
const HOST_IS_32_BIT: bool = cfg!(target_pointer_width = "32");
const HOST_MACHINE: IMAGE_FILE_MACHINE = if cfg!(target_arch = "x86_64") {
IMAGE_FILE_MACHINE_AMD64
} else if cfg!(target_arch = "aarch64") {
IMAGE_FILE_MACHINE_ARM64
} else if cfg!(target_arch = "x86") {
IMAGE_FILE_MACHINE_I386
} else {
IMAGE_FILE_MACHINE_UNKNOWN
};
fn inject(
method: InjectionMethod,
@@ -816,7 +835,10 @@ fn parse_screen_ordinal(source_id: &str) -> Option<usize> {
token.parse::<usize>().ok()
}
fn resolve_game_capture_target(source_id: &str, source_kind: &str) -> Result<HWND, String> {
pub(crate) fn resolve_game_capture_target(
source_id: &str,
source_kind: &str,
) -> Result<HWND, String> {
if let Some(hwnd) = parse_hwnd_source_id(source_id) {
return Ok(hwnd);
}
@@ -883,7 +905,7 @@ fn target_process_id(hwnd: HWND) -> Result<u32, String> {
}
}
fn target_process_is_32_bit(target_pid: u32) -> Result<bool, String> {
fn target_process_machine(target_pid: u32) -> Result<IMAGE_FILE_MACHINE, String> {
let process = unsafe {
OpenProcess(
PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_QUERY_INFORMATION,
@@ -897,7 +919,12 @@ fn target_process_is_32_bit(target_pid: u32) -> Result<bool, String> {
let mut native_machine: IMAGE_FILE_MACHINE = IMAGE_FILE_MACHINE_UNKNOWN;
let ok = unsafe { IsWow64Process2(process.raw(), &mut process_machine, &mut native_machine) };
if ok != 0 {
return Ok(process_machine != IMAGE_FILE_MACHINE_UNKNOWN);
let effective_machine = if process_machine == IMAGE_FILE_MACHINE_UNKNOWN {
native_machine
} else {
process_machine
};
return Ok(effective_machine);
}
let mut is_wow64: windows_sys::core::BOOL = 0;
@@ -905,7 +932,21 @@ fn target_process_is_32_bit(target_pid: u32) -> Result<bool, String> {
if ok == 0 {
return Err("failed to query target process bitness".into());
}
Ok(is_wow64 != 0)
if is_wow64 != 0 {
return Ok(IMAGE_FILE_MACHINE_I386);
}
Ok(HOST_MACHINE)
}
fn machine_is_32_bit(machine: IMAGE_FILE_MACHINE) -> Result<bool, String> {
match machine {
IMAGE_FILE_MACHINE_I386 | IMAGE_FILE_MACHINE_ARM | IMAGE_FILE_MACHINE_ARMNT => Ok(true),
IMAGE_FILE_MACHINE_AMD64 | IMAGE_FILE_MACHINE_ARM64 | IMAGE_FILE_MACHINE_IA64 => Ok(false),
_ => Err(format!(
"unsupported target process architecture (IMAGE_FILE_MACHINE 0x{machine:04x}); \
cannot pick a game capture hook DLL for it"
)),
}
}
fn window_capture_size(hwnd: HWND) -> Result<(u32, u32), String> {
@@ -1697,4 +1738,22 @@ mod tests {
None
);
}
#[test]
fn emulated_x64_target_on_arm64_host_is_not_classified_as_32_bit() {
assert_eq!(machine_is_32_bit(IMAGE_FILE_MACHINE_AMD64), Ok(false));
assert_eq!(machine_is_32_bit(IMAGE_FILE_MACHINE_ARM64), Ok(false));
}
#[test]
fn wow64_guest_machines_are_classified_as_32_bit() {
assert_eq!(machine_is_32_bit(IMAGE_FILE_MACHINE_I386), Ok(true));
assert_eq!(machine_is_32_bit(IMAGE_FILE_MACHINE_ARMNT), Ok(true));
assert_eq!(machine_is_32_bit(IMAGE_FILE_MACHINE_ARM), Ok(true));
}
#[test]
fn unknown_target_machine_fails_loudly_instead_of_guessing_bitness() {
assert!(machine_is_32_bit(IMAGE_FILE_MACHINE_UNKNOWN).is_err());
}
}
@@ -17,6 +17,8 @@ mod hdr;
#[cfg(target_os = "windows")]
mod nv12_gpu;
mod sources;
#[cfg(any(target_os = "windows", test))]
mod stall;
#[cfg(target_os = "windows")]
mod vulkan_layer_registry;
#[cfg(target_os = "windows")]
@@ -46,6 +48,7 @@ use wgc_capture::WgcCaptureSession;
const LIFECYCLE_QUEUE_LIMIT: usize = 8;
const START_OPTION_UNSUPPORTED_LIMIT: usize = 4;
const GAME_CAPTURE_HOOK_FORCE_DISABLED: bool = true;
type LifecycleTsfn = Arc<
ThreadsafeFunction<
@@ -952,7 +955,7 @@ impl ScreenCapture {
return Err(napi::Error::from_reason("Capture already running"));
}
if source_kind == "game" {
if source_kind == "game" && !GAME_CAPTURE_HOOK_FORCE_DISABLED {
return self.start_windows_game(
source_id,
source_kind,
@@ -967,6 +970,7 @@ impl ScreenCapture {
}
let _ = (hook_path, hook_path_x86, injection_method);
let target_frame_rate = frame_rate.unwrap_or(30).clamp(1, 144);
let frame_interval =
std::time::Duration::from_nanos(1_000_000_000 / target_frame_rate as u64);
@@ -986,10 +990,17 @@ impl ScreenCapture {
return self.start_windows_wgc_session(session, target_frame_rate);
}
let hwnd =
let hwnd = if source_kind == "game" {
let target = game_capture::resolve_game_capture_target(&source_id, &source_kind)
.map_err(|e| {
napi::Error::from_reason(format!("Failed to resolve game capture target: {e}"))
})?;
windows::Win32::Foundation::HWND(target as *mut _)
} else {
dxgi_capture::parse_window_source_id(&source_id, &source_kind).ok_or_else(|| {
napi::Error::from_reason(format!("Invalid source: {source_kind}:{source_id}"))
})?;
})?
};
if let Some(result) = self.try_start_windows_wgc(hwnd, width, height, target_frame_rate)? {
return Ok(result);
@@ -1265,6 +1276,11 @@ pub fn is_supported() -> bool {
cfg!(target_os = "windows")
}
#[napi(js_name = "isGameCaptureHookAvailable")]
pub fn is_game_capture_hook_available() -> bool {
cfg!(target_os = "windows") && !GAME_CAPTURE_HOOK_FORCE_DISABLED
}
#[napi(js_name = "getAvailability")]
pub fn get_availability() -> AvailabilityInfo {
AvailabilityInfo {
@@ -0,0 +1,183 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::time::{Duration, Instant};
pub const MIN_STALL_THRESHOLD: Duration = Duration::from_millis(250);
pub const MAX_STALL_THRESHOLD: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StallSignal {
Quiet,
Stalled { frames_seen: u64, elapsed_ms: u64 },
Resumed { stalled_for_ms: u64 },
}
pub struct NoFrameStallTracker {
threshold: Duration,
last_frame_at: Instant,
frames_seen: u64,
reported: bool,
}
impl NoFrameStallTracker {
pub fn new(threshold: Duration, started_at: Instant) -> Self {
assert!(
threshold >= MIN_STALL_THRESHOLD,
"stall threshold at least the minimum"
);
assert!(
threshold <= MAX_STALL_THRESHOLD,
"stall threshold at most the maximum"
);
Self {
threshold,
last_frame_at: started_at,
frames_seen: 0,
reported: false,
}
}
pub fn observe(
&mut self,
now: Instant,
produced_frame: bool,
target_expects_frames: bool,
) -> StallSignal {
let idle = now.saturating_duration_since(self.last_frame_at);
if produced_frame {
self.frames_seen = self.frames_seen.saturating_add(1);
self.last_frame_at = now;
if !self.reported {
return StallSignal::Quiet;
}
self.reported = false;
return StallSignal::Resumed {
stalled_for_ms: duration_ms(idle),
};
}
if !target_expects_frames {
self.last_frame_at = now;
return StallSignal::Quiet;
}
if self.reported || idle < self.threshold {
return StallSignal::Quiet;
}
self.reported = true;
StallSignal::Stalled {
frames_seen: self.frames_seen,
elapsed_ms: duration_ms(idle),
}
}
}
fn duration_ms(duration: Duration) -> u64 {
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
}
#[cfg(test)]
mod tests {
use super::*;
const THRESHOLD: Duration = Duration::from_millis(3000);
fn tracker(start: Instant) -> NoFrameStallTracker {
NoFrameStallTracker::new(THRESHOLD, start)
}
#[test]
fn stays_quiet_before_the_threshold_elapses() {
let start = Instant::now();
let mut stall = tracker(start);
assert_eq!(
stall.observe(start + Duration::from_millis(2999), false, true),
StallSignal::Quiet
);
}
#[test]
fn reports_a_stall_once_when_no_frame_ever_arrives() {
let start = Instant::now();
let mut stall = tracker(start);
assert_eq!(
stall.observe(start + THRESHOLD, false, true),
StallSignal::Stalled {
frames_seen: 0,
elapsed_ms: 3000,
}
);
assert_eq!(
stall.observe(start + Duration::from_millis(9000), false, true),
StallSignal::Quiet,
"a stall episode is reported at most once"
);
}
#[test]
fn counts_delivered_frames_before_a_later_stall() {
let start = Instant::now();
let mut stall = tracker(start);
assert_eq!(
stall.observe(start + Duration::from_millis(16), true, true),
StallSignal::Quiet
);
assert_eq!(
stall.observe(start + Duration::from_millis(32), true, true),
StallSignal::Quiet
);
assert_eq!(
stall.observe(start + Duration::from_millis(3032), false, true),
StallSignal::Stalled {
frames_seen: 2,
elapsed_ms: 3000,
}
);
}
#[test]
fn resuming_frames_clears_the_episode_and_arms_the_next_one() {
let start = Instant::now();
let mut stall = tracker(start);
assert_eq!(
stall.observe(start + THRESHOLD, false, true),
StallSignal::Stalled {
frames_seen: 0,
elapsed_ms: 3000,
}
);
assert_eq!(
stall.observe(start + Duration::from_millis(4000), true, true),
StallSignal::Resumed {
stalled_for_ms: 4000,
}
);
assert_eq!(
stall.observe(start + Duration::from_millis(7000), false, true),
StallSignal::Stalled {
frames_seen: 1,
elapsed_ms: 3000,
}
);
}
#[test]
fn a_target_that_is_not_expected_to_produce_frames_never_stalls() {
let start = Instant::now();
let mut stall = tracker(start);
assert_eq!(
stall.observe(start + Duration::from_millis(60_000), false, false),
StallSignal::Quiet
);
assert_eq!(
stall.observe(start + Duration::from_millis(62_000), false, true),
StallSignal::Quiet,
"the idle window restarts once the target can produce frames again"
);
assert_eq!(
stall.observe(start + Duration::from_millis(63_000), false, true),
StallSignal::Stalled {
frames_seen: 0,
elapsed_ms: 3000,
}
);
}
}
@@ -23,7 +23,7 @@ use windows::Win32::System::WinRT::Direct3D11::{
};
use windows::Win32::System::WinRT::Graphics::Capture::IGraphicsCaptureItemInterop;
use windows::Win32::System::WinRT::{RO_INIT_MULTITHREADED, RoInitialize};
use windows::Win32::UI::WindowsAndMessaging::IsWindow;
use windows::Win32::UI::WindowsAndMessaging::{IsWindow, IsWindowVisible};
use windows::core::{BOOL, Interface};
use crate::dxgi_capture::{
@@ -31,6 +31,7 @@ use crate::dxgi_capture::{
pacing_sleep_and_next_deadline, resolve_output_size,
};
use crate::nv12_gpu::Nv12GpuConverter;
use crate::stall::{NoFrameStallTracker, StallSignal};
use crate::{
CaptureInner, emit_lifecycle, emit_shared_texture_frame, note_media_frame_without_sink,
resolve_frame_sink,
@@ -39,6 +40,7 @@ use crate::{
const WGC_FRAME_POOL_BUFFERS: i32 = 2;
const WGC_FRAME_DRAIN_LIMIT: u32 = 4;
const WGC_MONITOR_ENUM_LIMIT: usize = 16;
const WGC_STALL_THRESHOLD: std::time::Duration = std::time::Duration::from_millis(3000);
fn ensure_winrt_initialized() {
let result = unsafe { RoInitialize(RO_INIT_MULTITHREADED) };
@@ -81,6 +83,20 @@ impl WgcCaptureTarget {
}
}
fn expects_frames(self) -> bool {
match self {
Self::Window(hwnd) => unsafe { IsWindowVisible(hwnd) }.as_bool(),
Self::Monitor(monitor) => !monitor.is_invalid(),
}
}
fn label(self) -> &'static str {
match self {
Self::Window(_) => "window",
Self::Monitor(_) => "monitor",
}
}
fn closed_message(self) -> &'static str {
match self {
Self::Window(_) => "window closed",
@@ -339,6 +355,7 @@ pub fn capture_loop(inner: &Arc<CaptureInner>, frame_interval: std::time::Durati
let capture_start = std::time::Instant::now();
let mut next_frame_deadline = capture_start + frame_interval;
let mut frames_dropped_coalesced: u64 = 0;
let mut stall_tracker = NoFrameStallTracker::new(WGC_STALL_THRESHOLD, capture_start);
while inner.running.load(Ordering::Acquire) {
if !ctx.target.is_alive() {
@@ -377,6 +394,12 @@ pub fn capture_loop(inner: &Arc<CaptureInner>, frame_interval: std::time::Durati
capture_start,
&mut frames_dropped_coalesced,
);
let signal = stall_tracker.observe(
std::time::Instant::now(),
matches!(result, WgcFrameResult::Ok),
ctx.target.expects_frames(),
);
emit_stall_signal(inner, ctx.target, signal);
match handle_frame_result(inner, &ctx, &mut wgc_state, result, &mut recreate_backoff) {
LoopStep::Paced => {}
LoopStep::Restart => continue,
@@ -396,6 +419,46 @@ pub fn capture_loop(inner: &Arc<CaptureInner>, frame_interval: std::time::Durati
emit_lifecycle(inner, "closed-clean", "capture stopped");
}
fn emit_stall_signal(inner: &Arc<CaptureInner>, target: WgcCaptureTarget, signal: StallSignal) {
match signal {
StallSignal::Quiet => {}
StallSignal::Stalled {
frames_seen,
elapsed_ms,
} => {
emit_lifecycle(
inner,
"stalled",
&stall_detail(target, frames_seen, elapsed_ms),
);
}
StallSignal::Resumed { stalled_for_ms } => {
emit_lifecycle(
inner,
"diagnostic",
&format!(
"WGC {} capture frames resumed after {stalled_for_ms}ms without a frame",
target.label()
),
);
}
}
}
fn stall_detail(target: WgcCaptureTarget, frames_seen: u64, elapsed_ms: u64) -> String {
let label = target.label();
if frames_seen == 0 {
return format!(
"WGC {label} capture stalled: the target is still alive but Windows Graphics Capture \
has delivered no frame in the first {elapsed_ms}ms; the stream is blank"
);
}
format!(
"WGC {label} capture stalled: no new frame for {elapsed_ms}ms after {frames_seen} frames; \
the target may be paused, occluded, or no longer rendering"
)
}
fn handle_frame_result(
inner: &Arc<CaptureInner>,
ctx: &WgcLoopContext,