mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
feat(desktop): sign every Windows release artifact (#1548)
This commit is contained in:
1 parent
873c203b5d
commit
6e66c92dca
89 files changed
+2408
-2740
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user