Detect games with native process exit watchers, own OBS on a background worker, and persist settings and recoverable session timelines. Place segments by split frame counts, recover capture with honest gaps, and finalise on game exit or explicit Quit. Keep the current recording alive after GPUI closes its last window, then exit when the game ends. Add an end-to-end rig covering recording, recovery, exact packet-count offsets, disk library reload and clean Quit.
165 lines
5.7 KiB
Rust
165 lines
5.7 KiB
Rust
//! Small Windows process bridge. The rest of the detection code stays safe Rust.
|
|
#![allow(unsafe_code)]
|
|
use std::{
|
|
io,
|
|
path::Path,
|
|
sync::{
|
|
Arc,
|
|
atomic::{AtomicBool, Ordering},
|
|
mpsc::{self, Receiver},
|
|
},
|
|
thread::{self, JoinHandle},
|
|
};
|
|
use windows_sys::Win32::{
|
|
Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE, WAIT_OBJECT_0, WAIT_TIMEOUT},
|
|
System::{
|
|
Diagnostics::ToolHelp::{
|
|
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
|
|
TH32CS_SNAPPROCESS,
|
|
},
|
|
Threading::{
|
|
OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE,
|
|
QueryFullProcessImageNameW, WaitForSingleObject,
|
|
},
|
|
},
|
|
};
|
|
|
|
// The numeric handle can be transferred to the waiting thread; Windows process
|
|
// handles are valid across threads. Ownership still has exactly one Drop.
|
|
struct Handle(usize);
|
|
impl Handle {
|
|
fn raw(&self) -> HANDLE {
|
|
self.0 as HANDLE
|
|
}
|
|
fn process(pid: u32) -> io::Result<Self> {
|
|
// SAFETY: valid access flags and PID; ownership of the result is taken here.
|
|
let raw = unsafe {
|
|
OpenProcess(
|
|
PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION,
|
|
0,
|
|
pid,
|
|
)
|
|
};
|
|
if raw.is_null() {
|
|
Err(io::Error::last_os_error())
|
|
} else {
|
|
Ok(Self(raw as usize))
|
|
}
|
|
}
|
|
fn path(&self) -> io::Result<String> {
|
|
let mut buffer = vec![0u16; 32768];
|
|
let mut length = buffer.len() as u32;
|
|
// SAFETY: the handle is owned, and the writable buffer has the stated capacity.
|
|
if unsafe { QueryFullProcessImageNameW(self.raw(), 0, buffer.as_mut_ptr(), &mut length) }
|
|
== 0
|
|
{
|
|
return Err(io::Error::last_os_error());
|
|
}
|
|
Ok(String::from_utf16_lossy(&buffer[..length as usize]))
|
|
}
|
|
}
|
|
impl Drop for Handle {
|
|
fn drop(&mut self) {
|
|
// SAFETY: Handle owns a successful API result and closes it exactly once.
|
|
unsafe {
|
|
CloseHandle(self.raw());
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn processes_in(directory: &Path) -> io::Result<Vec<(u32, String)>> {
|
|
// SAFETY: flags request process enumeration; no caller pointers are involved.
|
|
let raw = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
|
|
if raw == INVALID_HANDLE_VALUE {
|
|
return Err(io::Error::last_os_error());
|
|
}
|
|
let snapshot = Handle(raw as usize);
|
|
let mut entry = PROCESSENTRY32W {
|
|
dwSize: std::mem::size_of::<PROCESSENTRY32W>() as u32,
|
|
..Default::default()
|
|
};
|
|
let prefix = format!(
|
|
"{}\\",
|
|
directory.to_string_lossy().trim_end_matches(['\\', '/'])
|
|
)
|
|
.to_lowercase();
|
|
let mut result = vec![];
|
|
// SAFETY: snapshot remains live and entry is correctly sized and writable.
|
|
let mut found = unsafe { Process32FirstW(snapshot.raw(), &mut entry) } != 0;
|
|
while found {
|
|
// Access-denied system processes and processes exiting during enumeration
|
|
// are expected and cannot belong to the selected installation.
|
|
if let Ok(process) = Handle::process(entry.th32ProcessID)
|
|
&& let Ok(path) = process.path()
|
|
&& path.to_lowercase().starts_with(&prefix)
|
|
&& let Some(executable) = Path::new(&path).file_name().and_then(|s| s.to_str())
|
|
{
|
|
result.push((entry.th32ProcessID, executable.into()));
|
|
}
|
|
// SAFETY: as for Process32FirstW; snapshot and entry are still live.
|
|
found = unsafe { Process32NextW(snapshot.raw(), &mut entry) } != 0;
|
|
}
|
|
result.sort_by_key(|(pid, _)| *pid);
|
|
Ok(result)
|
|
}
|
|
|
|
pub struct ExitWatch {
|
|
events: Receiver<io::Result<()>>,
|
|
cancel: Arc<AtomicBool>,
|
|
thread: Option<JoinHandle<()>>,
|
|
}
|
|
impl ExitWatch {
|
|
pub fn open(pid: u32, executable: &str) -> io::Result<Self> {
|
|
let handle = Handle::process(pid)?;
|
|
if !Path::new(&handle.path()?)
|
|
.file_name()
|
|
.is_some_and(|name| name.to_string_lossy().eq_ignore_ascii_case(executable))
|
|
{
|
|
return Err(io::Error::other("Game exited during discovery"));
|
|
}
|
|
let (sender, events) = mpsc::channel();
|
|
let cancel = Arc::new(AtomicBool::new(false));
|
|
let cancelled = cancel.clone();
|
|
let thread = thread::Builder::new()
|
|
.name("clipbin-game-exit".into())
|
|
.spawn(move || {
|
|
while !cancelled.load(Ordering::Relaxed) {
|
|
// SAFETY: the thread owns a live process handle with SYNCHRONIZE
|
|
// access. A finite wait lets Drop cancel without leaking a waiter.
|
|
let result = unsafe { WaitForSingleObject(handle.raw(), 250) };
|
|
match result {
|
|
WAIT_OBJECT_0 => {
|
|
let _ = sender.send(Ok(()));
|
|
break;
|
|
}
|
|
WAIT_TIMEOUT => {}
|
|
_ => {
|
|
let _ = sender.send(Err(io::Error::last_os_error()));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
})?;
|
|
Ok(Self {
|
|
events,
|
|
cancel,
|
|
thread: Some(thread),
|
|
})
|
|
}
|
|
pub fn exited(&self) -> io::Result<bool> {
|
|
match self.events.try_recv() {
|
|
Ok(result) => result.map(|()| true),
|
|
Err(mpsc::TryRecvError::Empty) => Ok(false),
|
|
Err(mpsc::TryRecvError::Disconnected) => Ok(true),
|
|
}
|
|
}
|
|
}
|
|
impl Drop for ExitWatch {
|
|
fn drop(&mut self) {
|
|
self.cancel.store(true, Ordering::Relaxed);
|
|
if let Some(thread) = self.thread.take() {
|
|
let _ = thread.join();
|
|
}
|
|
}
|
|
}
|