mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +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
@@ -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