Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 47587827ec | |||
| e06ab59652 | |||
| f2a58f3a91 | |||
| 87114ab186 | |||
| aaa3dcec32 | |||
| 991d28909b | |||
| 9ea5c2a129 | |||
| 880634b4c1 | |||
| 265554b755 | |||
| cb7091e1d5 | |||
| dd462787ec | |||
| 63efe0ecd5 | |||
| 384f8e00aa | |||
| 46c0e0e483 | |||
| f7ca641d76 | |||
| 2067b5ac81 | |||
| 09600163e2 | |||
| ea23408d1d | |||
| 9bc70e59fc | |||
| 393b47a062 | |||
| 329cf7b5d5 | |||
| 68bcfdac3e | |||
| ff55d0a608 |
Generated
+1
@@ -3106,6 +3106,7 @@ dependencies = [
|
|||||||
"ffmpeg-next",
|
"ffmpeg-next",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
|
"hmac",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
|
|||||||
+986
-3
File diff suppressed because it is too large
Load Diff
@@ -320,6 +320,42 @@ impl InputEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One decoded GameStream (Moonlight-plane) controller event. Shared vocabulary: the host's
|
||||||
|
/// GameStream/Moonlight decode path produces these, and the platform-neutral input injectors
|
||||||
|
/// (`pf-inject`) consume them — so the type lives in `core::input`, below both, rather than in
|
||||||
|
/// either plane. The `buttons` bitmask uses the same [`gamepad`] `BTN_*` layout as the native
|
||||||
|
/// [`GamepadSnapshot`] (GameStream's `buttonFlags | buttonFlags2 << 16` is bit-identical).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum GamepadEvent {
|
||||||
|
/// Full state of one controller + the set of attached controllers.
|
||||||
|
State(GamepadFrame),
|
||||||
|
/// Sunshine arrival metadata (precedes the first State for that pad).
|
||||||
|
Arrival {
|
||||||
|
index: u8,
|
||||||
|
/// 0 unknown, 1 xbox, 2 ps, 3 nintendo.
|
||||||
|
kind: u8,
|
||||||
|
/// LI_CCAP_* bits (0x02 = rumble).
|
||||||
|
capabilities: u16,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot of one controller's inputs (Moonlight conventions: sticks −32768..32767 with +Y
|
||||||
|
/// up, triggers 0..255, buttons = `buttonFlags | buttonFlags2 << 16`). The decoded-frame twin of
|
||||||
|
/// [`GamepadSnapshot`] on the GameStream/Moonlight plane; see [`GamepadEvent`] for why it lives here.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct GamepadFrame {
|
||||||
|
pub index: i16,
|
||||||
|
/// Bit n set = controller n attached; a clear bit for an allocated pad means unplug.
|
||||||
|
pub active_mask: u16,
|
||||||
|
pub buttons: u32,
|
||||||
|
pub left_trigger: u8,
|
||||||
|
pub right_trigger: u8,
|
||||||
|
pub ls_x: i16,
|
||||||
|
pub ls_y: i16,
|
||||||
|
pub rs_x: i16,
|
||||||
|
pub rs_y: i16,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -56,6 +56,12 @@ tokio-rustls = "0.26"
|
|||||||
hyper = { version = "1", features = ["server", "http1", "http2"] }
|
hyper = { version = "1", features = ["server", "http1", "http2"] }
|
||||||
hyper-util = { version = "0.1", features = ["server", "server-auto", "tokio", "service"] }
|
hyper-util = { version = "0.1", features = ["server", "server-auto", "tokio", "service"] }
|
||||||
tower = { version = "0.5", features = ["util"] }
|
tower = { version = "0.5", features = ["util"] }
|
||||||
|
# Stream combinators for the mgmt API's SSE event feed (`GET /api/v1/events`) — already in the
|
||||||
|
# tree transitively (axum/hyper) and as a Linux target dep; control plane only.
|
||||||
|
futures-util = "0.3"
|
||||||
|
# Webhook signing (X-Punktfunk-Signature: sha256=<hex HMAC>) for operator hooks; pairs with
|
||||||
|
# the existing sha2. Already in the lockfile transitively.
|
||||||
|
hmac = "0.12"
|
||||||
rusty_enet = "0.4"
|
rusty_enet = "0.4"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|||||||
@@ -81,12 +81,14 @@ pub struct OutputFormat {
|
|||||||
|
|
||||||
impl OutputFormat {
|
impl OutputFormat {
|
||||||
/// Resolve the output format for an entry point that doesn't build a full [`SessionPlan`]
|
/// Resolve the output format for an entry point that doesn't build a full [`SessionPlan`]
|
||||||
/// (`crate::session_plan`) — the GameStream + spike paths: `gpu` from the resolved encode backend,
|
/// (`crate::session_plan`) — the GameStream + spike paths. `gpu` is the encoder's GPU-residency,
|
||||||
/// `hdr` as given. The native punktfunk/1 path uses `SessionPlan::output_format()` instead (it already
|
/// resolved by the caller via [`crate::encode::resolved_backend_is_gpu`] and passed **in** (capture
|
||||||
/// resolved the encoder), so neither path makes a capturer re-derive it.
|
/// never re-derives the backend — the one-way capture→encode edge, plan §2.4 / §W4); `hdr` as given.
|
||||||
pub fn resolve(hdr: bool) -> Self {
|
/// The native punktfunk/1 path uses `SessionPlan::output_format()` instead (it already resolved the
|
||||||
|
/// encoder), so neither path makes a capturer re-derive it.
|
||||||
|
pub fn resolve(hdr: bool, gpu: bool) -> Self {
|
||||||
OutputFormat {
|
OutputFormat {
|
||||||
gpu: gpu_encode(),
|
gpu,
|
||||||
hdr,
|
hdr,
|
||||||
// The GameStream + spike paths are always 4:2:0 (4:4:4 is punktfunk/1-native only).
|
// The GameStream + spike paths are always 4:2:0 (4:4:4 is punktfunk/1-native only).
|
||||||
chroma_444: false,
|
chroma_444: false,
|
||||||
@@ -94,26 +96,6 @@ impl OutputFormat {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True if the resolved encode backend produces GPU frames (anything but the software encoder). The single
|
|
||||||
/// source for [`OutputFormat::resolve`]'s `gpu`; on Linux always true (the portal/VAAPI/CUDA path is GPU).
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
pub(crate) fn gpu_encode() -> bool {
|
|
||||||
!matches!(
|
|
||||||
crate::encode::windows_resolved_backend(),
|
|
||||||
crate::encode::WindowsBackend::Software
|
|
||||||
)
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
pub(crate) fn gpu_encode() -> bool {
|
|
||||||
// The GPU-less software encoder (openh264) needs CPU-staged RGB frames; every other Linux
|
|
||||||
// backend (NVENC/CUDA, VAAPI) is GPU-resident. Mirrors `session_plan::resolve_encoder`, for the
|
|
||||||
// GameStream/spike entry points that use `OutputFormat::resolve` instead of a full `SessionPlan`.
|
|
||||||
!matches!(
|
|
||||||
crate::config::config().encoder_pref.as_str(),
|
|
||||||
"software" | "sw" | "openh264"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A mouse-cursor overlay to composite onto a frame at encode time (cursor-as-metadata). Rides on
|
/// A mouse-cursor overlay to composite onto a frame at encode time (cursor-as-metadata). Rides on
|
||||||
/// [`CapturedFrame::cursor`] for the GPU zero-copy payloads (Cuda/Dmabuf), whose pixels never touch
|
/// [`CapturedFrame::cursor`] for the GPU zero-copy payloads (Cuda/Dmabuf), whose pixels never touch
|
||||||
/// the CPU — the encoder blends this small bitmap into its owned surface (Vulkan CSC image / CUDA
|
/// the CPU — the encoder blends this small bitmap into its owned surface (Vulkan CSC image / CUDA
|
||||||
@@ -453,25 +435,27 @@ pub fn capture_virtual_output(
|
|||||||
|
|
||||||
/// Whether the active capturer can deliver a full-chroma (RGB) source for a 4:4:4 HEVC encode. The
|
/// Whether the active capturer can deliver a full-chroma (RGB) source for a 4:4:4 HEVC encode. The
|
||||||
/// negotiator gates 4:4:4 on this so the host honestly downgrades to 4:2:0 when the capturer can only
|
/// negotiator gates 4:4:4 on this so the host honestly downgrades to 4:2:0 when the capturer can only
|
||||||
/// produce subsampled frames. Linux (the portal capturer feeding CPU RGB → `yuv444p`) can; the Windows
|
/// produce subsampled frames. `encoder_ingests_rgb_444` is the encoder half of the gate, resolved by
|
||||||
/// IDD-push path delivers subsampled NV12/P010 today, so full-chroma capture there is a follow-up.
|
/// the caller ([`crate::encode::resolved_backend_ingests_rgb_444`]) and passed **in** so capture never
|
||||||
|
/// re-derives the backend (the one-way capture→encode edge, plan §2.4 / §W4). Linux (the portal capturer
|
||||||
|
/// feeding CPU RGB → `yuv444p`) can regardless; the Windows IDD-push path delivers subsampled NV12/P010
|
||||||
|
/// today, so full-chroma capture there rides entirely on the encoder gate.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub(crate) fn capturer_supports_444() -> bool {
|
pub(crate) fn capturer_supports_444(_encoder_ingests_rgb_444: bool) -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub(crate) fn capturer_supports_444() -> bool {
|
pub(crate) fn capturer_supports_444(encoder_ingests_rgb_444: bool) -> bool {
|
||||||
// IDD-push delivers full-chroma BGRA for an SDR 4:4:4 session (skipping the NV12
|
// IDD-push delivers full-chroma BGRA for an SDR 4:4:4 session (skipping the NV12 VideoConverter),
|
||||||
// VideoConverter) — but only the direct-NVENC backend ingests RGB and CSCs it to 4:4:4
|
// but only a backend that ingests RGB and CSCs it to 4:4:4 itself can use it — today just
|
||||||
// (measured on-glass: true full chroma, matrix follows the configured VUI), so gate on it
|
// direct-NVENC (AMF can't 4:4:4 at all; the QSV/ffmpeg path has no RGB-input 4:4:4 wiring). An HDR
|
||||||
// (AMF can't 4:4:4 at all; the QSV/ffmpeg path has no RGB-input 4:4:4 wiring). An HDR
|
|
||||||
// display can't be known here (the virtual display's mode settles after the Welcome); that
|
// display can't be known here (the virtual display's mode settles after the Welcome); that
|
||||||
// combination downgrades at capture time — the capturer emits P010 and the encoder's caps
|
// combination downgrades at capture time — the capturer emits P010 and the encoder's caps
|
||||||
// cross-check reports the 4:2:0 truth (the in-band SPS keeps the client correct either way).
|
// cross-check reports the 4:2:0 truth (the in-band SPS keeps the client correct either way).
|
||||||
crate::encode::windows_resolved_backend() == crate::encode::WindowsBackend::Nvenc
|
encoder_ingests_rgb_444
|
||||||
}
|
}
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||||
pub(crate) fn capturer_supports_444() -> bool {
|
pub(crate) fn capturer_supports_444(_encoder_ingests_rgb_444: bool) -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1657,7 +1657,7 @@ mod pipewire {
|
|||||||
sample = ?&modifiers[..modifiers.len().min(6)],
|
sample = ?&modifiers[..modifiers.len().min(6)],
|
||||||
"zero-copy: advertising EGL-importable dmabuf modifiers"
|
"zero-copy: advertising EGL-importable dmabuf modifiers"
|
||||||
);
|
);
|
||||||
} else if backend_is_vaapi && crate::capture::gpu_encode() {
|
} else if backend_is_vaapi && crate::encode::resolved_backend_is_gpu() {
|
||||||
// A VAAPI session on the CPU path pays three full-frame CPU touches (mmap de-pad +
|
// A VAAPI session on the CPU path pays three full-frame CPU touches (mmap de-pad +
|
||||||
// swscale RGB→NV12 + surface upload) — make the silent fallback visible.
|
// swscale RGB→NV12 + surface upload) — make the silent fallback visible.
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
|
|||||||
@@ -323,384 +323,15 @@ pub(crate) unsafe fn verify_is_wudfhost(process: HANDLE, wudf_pid: u32, what: &s
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The sealed channel's handle-duplication broker (`design/idd-push-security.md`): the frame objects
|
#[path = "idd_push/channel.rs"]
|
||||||
/// are unnamed, so the ONLY way the driver can reach them is handles this broker duplicates into its
|
mod channel;
|
||||||
/// WUDFHost process and delivers — as bare handle VALUES — over the SYSTEM-only control device
|
#[path = "idd_push/descriptor.rs"]
|
||||||
/// (`IOCTL_SET_FRAME_CHANNEL`). Ownership is a strict hand-off: on IOCTL success the DRIVER owns the
|
mod descriptor;
|
||||||
/// duplicates (it closes them); on any failure [`Self::send`] reaps every duplicate it already made
|
#[path = "idd_push/stall.rs"]
|
||||||
/// (`DUPLICATE_CLOSE_SOURCE`), so a half-delivered channel never leaks handles in WUDFHost.
|
mod stall;
|
||||||
struct ChannelBroker {
|
use channel::ChannelBroker;
|
||||||
/// `PROCESS_DUP_HANDLE | SYNCHRONIZE` handle to the driver's WUDFHost (pid from the ADD reply;
|
use descriptor::{DescriptorPoller, DisplayDescriptor};
|
||||||
/// `ProcessSharingDisabled` makes that process exclusively pf-vdisplay's). `SYNCHRONIZE` lets the
|
use stall::StallWatch;
|
||||||
/// handle double as the driver-death probe ([`Self::driver_alive`]).
|
|
||||||
process: OwnedHandle,
|
|
||||||
/// The WUDFHost pid `process` refers to (diagnostics for the driver-death bail).
|
|
||||||
wudf_pid: u32,
|
|
||||||
/// The pf-vdisplay control device — owned by the `VirtualDisplayManager`, never closed for the
|
|
||||||
/// process lifetime (a dead one is retired, kept alive), so holding the bare `HANDLE` is sound.
|
|
||||||
control: HANDLE,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ChannelBroker {
|
|
||||||
/// Open the duplication target. Fails when the driver predates the sealed channel (`wudf_pid == 0`
|
|
||||||
/// can't survive the v2 version handshake, but guard anyway) or the WUDFHost is gone (device
|
|
||||||
/// restart mid-open) — either way the caller fails the capture open cleanly.
|
|
||||||
///
|
|
||||||
/// `wudf_pid` comes from the driver's ADD reply, so before we duplicate whole-desktop frame handles
|
|
||||||
/// INTO it we VERIFY it is a genuine system WUDFHost ([`verify_is_wudfhost`]). Without that check a
|
|
||||||
/// spoofed devnode (same interface GUID) could name an arbitrary process and receive the frames; a
|
|
||||||
/// fully-compromised REAL pf_vdisplay driver is already a frame endpoint, so this specifically closes
|
|
||||||
/// the reachable-without-owning-the-driver case (`design/idd-push-security.md` §hardening).
|
|
||||||
fn open(wudf_pid: u32) -> Result<Self> {
|
|
||||||
if wudf_pid == 0 {
|
|
||||||
bail!("driver reported no WUDFHost pid for the frame channel");
|
|
||||||
}
|
|
||||||
let control = crate::vdisplay::manager::control_device_handle().context(
|
|
||||||
"pf-vdisplay control device not open (monitor not created via the manager?)",
|
|
||||||
)?;
|
|
||||||
// SAFETY: plain FFI; `wudf_pid` is a copy. The handle (checked by `?`) is owned solely here and
|
|
||||||
// moved into the `OwnedHandle` (single owner, closes on drop); `verify_is_wudfhost` borrows it
|
|
||||||
// for the duration of the synchronous check and forms no lasting alias.
|
|
||||||
let process = unsafe {
|
|
||||||
let h = OpenProcess(
|
|
||||||
PROCESS_DUP_HANDLE | PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE,
|
|
||||||
false,
|
|
||||||
wudf_pid,
|
|
||||||
)
|
|
||||||
.context("OpenProcess(PROCESS_DUP_HANDLE) on the driver's WUDFHost")?;
|
|
||||||
let process = OwnedHandle::from_raw_handle(h.0 as _);
|
|
||||||
verify_is_wudfhost(HANDLE(process.as_raw_handle()), wudf_pid, "frame-channel")?;
|
|
||||||
process
|
|
||||||
};
|
|
||||||
Ok(Self {
|
|
||||||
process,
|
|
||||||
wudf_pid,
|
|
||||||
control,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the driver's WUDFHost is still alive. The pinned process handle doubles as the
|
|
||||||
/// liveness probe (`SYNCHRONIZE` requested at open): signaled ⇔ the process exited. This is the
|
|
||||||
/// definitive "driver died mid-session" signal — at the ring, a dead driver and an idle desktop
|
|
||||||
/// are indistinguishable (both simply stop publishing).
|
|
||||||
fn driver_alive(&self) -> bool {
|
|
||||||
// SAFETY: `process` is the live `OwnedHandle` this broker owns (borrowed for this synchronous
|
|
||||||
// call); a 0 ms wait only reads the handle's signaled state.
|
|
||||||
unsafe { WaitForSingleObject(HANDLE(self.process.as_raw_handle()), 0) != WAIT_OBJECT_0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Duplicate `h` into the WUDFHost handle table, returning the handle VALUE valid there (and only
|
|
||||||
/// there — the value is meaningless in any other process). `access = Some(rights)` grants the
|
|
||||||
/// driver's handle exactly those rights (least privilege — see [`SECTION_MAP_RW`]);
|
|
||||||
/// `access = None` copies the source handle's access (`DUPLICATE_SAME_ACCESS`), used only where the
|
|
||||||
/// source is already scoped (the DXGI shared-texture handles, minted by `CreateSharedHandle` with
|
|
||||||
/// just `DXGI_SHARED_RESOURCE_READ|WRITE`).
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// `h` must be a live handle of the current process.
|
|
||||||
unsafe fn dup_into(&self, h: HANDLE, access: Option<u32>) -> Result<u64> {
|
|
||||||
let mut out = HANDLE::default();
|
|
||||||
let (desired, options) = match access {
|
|
||||||
Some(rights) => (rights, DUPLICATE_HANDLE_OPTIONS(0)),
|
|
||||||
None => (0, DUPLICATE_SAME_ACCESS),
|
|
||||||
};
|
|
||||||
// SAFETY: `h` is live per the contract; `self.process` is the live PROCESS_DUP_HANDLE target;
|
|
||||||
// `&mut out` is a valid out-param. Either an explicit least-privilege access mask (options == 0)
|
|
||||||
// or `DUPLICATE_SAME_ACCESS` (desired ignored) — never both.
|
|
||||||
unsafe {
|
|
||||||
DuplicateHandle(
|
|
||||||
GetCurrentProcess(),
|
|
||||||
h,
|
|
||||||
HANDLE(self.process.as_raw_handle()),
|
|
||||||
&mut out,
|
|
||||||
desired,
|
|
||||||
false,
|
|
||||||
options,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.context("DuplicateHandle into the driver's WUDFHost")?;
|
|
||||||
Ok(out.0 as usize as u64)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Close a handle VALUE inside the WUDFHost table (the failure-path reaper): `DUPLICATE_CLOSE_SOURCE`
|
|
||||||
/// with no target closes the source handle regardless of the (ignored) result.
|
|
||||||
fn close_remote(&self, value: u64) {
|
|
||||||
if value == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// SAFETY: `self.process` is the live duplication target and `value` is a handle value THIS
|
|
||||||
// broker just created in that process's table (callers only pass back `dup_into` results the
|
|
||||||
// driver never received); closing it there cannot touch any other process's handles.
|
|
||||||
unsafe {
|
|
||||||
let _ = DuplicateHandle(
|
|
||||||
HANDLE(self.process.as_raw_handle()),
|
|
||||||
HANDLE(value as usize as *mut core::ffi::c_void),
|
|
||||||
HANDLE::default(),
|
|
||||||
std::ptr::null_mut(),
|
|
||||||
0,
|
|
||||||
false,
|
|
||||||
DUPLICATE_CLOSE_SOURCE,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Duplicate the whole ring (header + event + every slot texture) into WUDFHost and deliver the
|
|
||||||
/// values via `IOCTL_SET_FRAME_CHANNEL`. All-or-nothing: on any failure every duplicate already
|
|
||||||
/// made is reaped remotely and an error returns (the caller fails the open / logs the recreate).
|
|
||||||
/// The ownership contract with the driver is adopt-on-success only — it closes the handles iff the
|
|
||||||
/// IOCTL succeeded, we reap them iff it didn't, so no value is ever closed twice.
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// `header` and `event` must be live handles of the current process (the capturer's own section +
|
|
||||||
/// event, borrowed for this synchronous call).
|
|
||||||
unsafe fn send(
|
|
||||||
&self,
|
|
||||||
target_id: u32,
|
|
||||||
generation: u32,
|
|
||||||
header: HANDLE,
|
|
||||||
event: HANDLE,
|
|
||||||
slots: &[HostSlot],
|
|
||||||
) -> Result<()> {
|
|
||||||
debug_assert!(slots.len() <= control::RING_LEN_USIZE);
|
|
||||||
let mut req = control::SetFrameChannelRequest {
|
|
||||||
target_id,
|
|
||||||
generation,
|
|
||||||
ring_len: slots.len() as u32,
|
|
||||||
_pad: 0,
|
|
||||||
header_handle: 0,
|
|
||||||
event_handle: 0,
|
|
||||||
texture_handles: [0; control::RING_LEN_USIZE],
|
|
||||||
};
|
|
||||||
// SAFETY: `header`/`event` are live per this fn's contract; each slot's `shared` is the live
|
|
||||||
// `OwnedHandle` the slot keeps for exactly this purpose.
|
|
||||||
let result = unsafe { self.duplicate_and_deliver(&mut req, header, event, slots) };
|
|
||||||
if result.is_err() {
|
|
||||||
// The driver never adopted the delivery — reap every remote duplicate so nothing lingers.
|
|
||||||
self.close_remote(req.header_handle);
|
|
||||||
self.close_remote(req.event_handle);
|
|
||||||
for v in req.texture_handles {
|
|
||||||
self.close_remote(v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The fallible middle of [`Self::send`]: fill `req` with fresh duplicates, then issue the IOCTL.
|
|
||||||
/// Split out so `send` can reap whatever landed in `req` when any step errors.
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// As [`Self::send`].
|
|
||||||
unsafe fn duplicate_and_deliver(
|
|
||||||
&self,
|
|
||||||
req: &mut control::SetFrameChannelRequest,
|
|
||||||
header: HANDLE,
|
|
||||||
event: HANDLE,
|
|
||||||
slots: &[HostSlot],
|
|
||||||
) -> Result<()> {
|
|
||||||
// SAFETY: forwarded from the caller's contract — `header`/`event`/each `slot.shared` are live
|
|
||||||
// handles of this process, and `self.control` is the manager's control handle, never closed for
|
|
||||||
// the process lifetime (`send_frame_channel`'s precondition).
|
|
||||||
unsafe {
|
|
||||||
// Least privilege per handle: the header maps read/write, the event is only signalled, and
|
|
||||||
// the textures keep their already-scoped `CreateSharedHandle` access (see `dup_into`).
|
|
||||||
req.header_handle = self.dup_into(header, Some(SECTION_MAP_RW))?;
|
|
||||||
req.event_handle = self.dup_into(event, Some(EVENT_MODIFY_STATE))?;
|
|
||||||
for (k, s) in slots.iter().enumerate() {
|
|
||||||
req.texture_handles[k] = self.dup_into(HANDLE(s.shared.as_raw_handle()), None)?;
|
|
||||||
}
|
|
||||||
crate::vdisplay::pf_vdisplay::send_frame_channel(self.control, req)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates + owns the shared ring; yields the driver's frames as [`FramePayload::D3d11`].
|
|
||||||
/// The display descriptor the capture loop follows: live HDR state + active resolution of the
|
|
||||||
/// virtual target.
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
||||||
struct DisplayDescriptor {
|
|
||||||
hdr: bool,
|
|
||||||
width: u32,
|
|
||||||
height: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Off-thread poller for [`DisplayDescriptor`]. The CCD queries behind it (`QueryDisplayConfig`,
|
|
||||||
/// twice per sample) serialize on the session-global display-configuration lock, which display-
|
|
||||||
/// topology events and third-party display-poller software (the SteelSeries-GG class) can hold
|
|
||||||
/// for tens-to-hundreds of milliseconds at a time. Polled inline — the old design — that stall
|
|
||||||
/// landed ON the capture/encode thread: a periodic frame hitch on an otherwise healthy host, and
|
|
||||||
/// invisible in any log. Now a dedicated thread samples every [`Self::INTERVAL`] and publishes a
|
|
||||||
/// snapshot; the capture thread's per-frame cost is one uncontended mutex read, and a slow CCD
|
|
||||||
/// sample is *measured and logged* instead of silently stalling the stream.
|
|
||||||
///
|
|
||||||
/// Failure policy is last-known-good, per field: a transient CCD failure — including the target
|
|
||||||
/// briefly missing from the active-path list during a topology re-probe — keeps the previous
|
|
||||||
/// value instead of reading as `hdr = false` (the old behavior, which on an HDR session turned
|
|
||||||
/// every blip into TWO ring recreates: false, then true again a poll later). `seq` bumps only
|
|
||||||
/// when at least one query succeeded, so the consumer's debounce counts real observations, never
|
|
||||||
/// failures.
|
|
||||||
struct DescriptorPoller {
|
|
||||||
/// Latest merged sample + its sequence number; the poller holds the lock only to copy it.
|
|
||||||
snap: Arc<Mutex<(DisplayDescriptor, u64)>>,
|
|
||||||
stop: Arc<AtomicBool>,
|
|
||||||
thread: Option<std::thread::JoinHandle<()>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DescriptorPoller {
|
|
||||||
/// Poll cadence — the old inline throttle. With the consumer's two-strikes debounce on top, a
|
|
||||||
/// real "Use HDR" flip or mode-set is acted on within ~2 samples (≈ ½ s).
|
|
||||||
const INTERVAL: Duration = Duration::from_millis(250);
|
|
||||||
/// A sample slower than this means something is sitting on the display-config lock (topology
|
|
||||||
/// churn / display-poller software) — the disturbance class behind periodic virtual-display
|
|
||||||
/// stream hitches. Logged (rate-limited) so an affected host self-diagnoses.
|
|
||||||
const SLOW: Duration = Duration::from_millis(50);
|
|
||||||
|
|
||||||
fn spawn(target_id: u32, initial: DisplayDescriptor) -> Self {
|
|
||||||
let snap = Arc::new(Mutex::new((initial, 0u64)));
|
|
||||||
let stop = Arc::new(AtomicBool::new(false));
|
|
||||||
let (snap_t, stop_t) = (snap.clone(), stop.clone());
|
|
||||||
let thread = std::thread::Builder::new()
|
|
||||||
.name("pf-idd-desc-poll".into())
|
|
||||||
.spawn(move || {
|
|
||||||
let mut last = initial;
|
|
||||||
let mut seq = 0u64;
|
|
||||||
let mut last_slow_log: Option<Instant> = None;
|
|
||||||
while !stop_t.load(Ordering::Relaxed) {
|
|
||||||
let t = Instant::now();
|
|
||||||
// SAFETY: both are read-only CCD queries taking only a copy of the plain `u32`
|
|
||||||
// target id (see their own SAFETY docs); nothing is borrowed across the calls.
|
|
||||||
let (hdr, res) = unsafe {
|
|
||||||
(
|
|
||||||
crate::win_display::advanced_color_enabled(target_id),
|
|
||||||
crate::win_display::active_resolution(target_id),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
let took = t.elapsed();
|
|
||||||
if took >= Self::SLOW
|
|
||||||
&& last_slow_log.is_none_or(|t| t.elapsed() >= Duration::from_secs(10))
|
|
||||||
{
|
|
||||||
last_slow_log = Some(Instant::now());
|
|
||||||
tracing::warn!(
|
|
||||||
took_ms = took.as_millis() as u64,
|
|
||||||
target_id,
|
|
||||||
"slow display-descriptor poll — something is holding the Windows \
|
|
||||||
display-config lock (topology churn / display-poller software); on \
|
|
||||||
a host with periodic stream hitches, correlate this cadence"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if hdr.is_some() || res.is_some() {
|
|
||||||
if let Some(hdr) = hdr {
|
|
||||||
last.hdr = hdr;
|
|
||||||
}
|
|
||||||
if let Some((width, height)) = res {
|
|
||||||
last.width = width;
|
|
||||||
last.height = height;
|
|
||||||
}
|
|
||||||
seq += 1;
|
|
||||||
*snap_t.lock().unwrap() = (last, seq);
|
|
||||||
}
|
|
||||||
// Park (not sleep) so `drop` wakes the thread immediately via `unpark`.
|
|
||||||
std::thread::park_timeout(Self::INTERVAL);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.map_err(|e| {
|
|
||||||
// Degraded, not fatal: the session streams, it just never follows a mid-session
|
|
||||||
// HDR flip / mode-set (seq stays 0 → the consumer sees no changes).
|
|
||||||
tracing::warn!(error = %e, "IDD push: descriptor-poller thread failed to spawn — mid-session HDR/mode changes won't be followed");
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
Self { snap, stop, thread }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The latest sample (lock held only for the copy — the poller writes at 4 Hz).
|
|
||||||
fn snapshot(&self) -> (DisplayDescriptor, u64) {
|
|
||||||
*self.snap.lock().unwrap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for DescriptorPoller {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.stop.store(true, Ordering::Relaxed);
|
|
||||||
if let Some(t) = self.thread.take() {
|
|
||||||
t.thread().unpark();
|
|
||||||
let _ = t.join();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A detected capture stall: a multi-hundred-ms hole in DWM's frame delivery that opened while the
|
|
||||||
/// desktop was actively composing right beforehand (see [`StallWatch`]).
|
|
||||||
struct Stall {
|
|
||||||
/// How long the hole lasted (last fresh frame → the frame that ended it).
|
|
||||||
gap: Duration,
|
|
||||||
/// `Some(mean period)` when this stall completes a metronomic cycle (see
|
|
||||||
/// [`crate::metronome::Metronome`]).
|
|
||||||
metronomic: Option<Duration>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Capture-stall watch — the "sole virtual display" stutter diagnostic (field reports: Exclusive
|
|
||||||
/// topology = periodic double-jolt, Extend = smooth, i.e. the disturbance lives in the display/present
|
|
||||||
/// path BELOW capture and only while no physical output is active).
|
|
||||||
///
|
|
||||||
/// On a damage-driven capture an idle desktop legitimately goes quiet (no damage → no frames), so a
|
|
||||||
/// gap only counts as a stall when the [`Self::RECENT`] frames before it all arrived within
|
|
||||||
/// [`Self::ACTIVE_SPAN`] — sustained ≥ ~20 fps flow (a game or video), not a blinking caret or a
|
|
||||||
/// mouse twitch. Each stall feeds a [`crate::metronome::Metronome`], so periodic stalls self-diagnose
|
|
||||||
/// in the log WITHOUT needing any client keyframe request — discriminating "DWM stopped composing"
|
|
||||||
/// from encode/network causes that the recovery-cadence detector covers. Pure logic — unit-tested
|
|
||||||
/// below; the caller does the logging.
|
|
||||||
struct StallWatch {
|
|
||||||
/// The last [`Self::RECENT`] fresh-frame instants (pre-gap history for the activity gate).
|
|
||||||
recent: std::collections::VecDeque<Instant>,
|
|
||||||
cadence: crate::metronome::Metronome,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl StallWatch {
|
|
||||||
/// Frames of pre-gap history that must be tight for flow to count as active. Stalls are thus
|
|
||||||
/// naturally spaced ≥ RECENT frame times apart — no extra log rate limit needed.
|
|
||||||
const RECENT: usize = 8;
|
|
||||||
/// The RECENT pre-gap frames must all fit in this span (8 frames in 400 ms ≈ ≥ 20 fps flow —
|
|
||||||
/// loose enough for a 30 fps-capped game, tight enough to reject idle-desktop damage).
|
|
||||||
const ACTIVE_SPAN: Duration = Duration::from_millis(400);
|
|
||||||
/// The smallest hole that counts as a stall (~9 missed frames at 60 Hz) — well below the
|
|
||||||
/// reported 300–700 ms freezes, above encode/present jitter.
|
|
||||||
const STALL_MIN: Duration = Duration::from_millis(150);
|
|
||||||
|
|
||||||
fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
recent: std::collections::VecDeque::with_capacity(Self::RECENT + 1),
|
|
||||||
cadence: crate::metronome::Metronome::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Forget the flow history (a ring recreate's gap is self-inflicted, not a DWM stall — without
|
|
||||||
/// the reset the first post-recreate frame would read as one).
|
|
||||||
fn reset(&mut self) {
|
|
||||||
self.recent.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record a fresh driver frame at `now`; `Some` exactly when it ended a stall.
|
|
||||||
fn note_fresh(&mut self, now: Instant) -> Option<Stall> {
|
|
||||||
let was_active = self.recent.len() == Self::RECENT
|
|
||||||
&& self
|
|
||||||
.recent
|
|
||||||
.back()
|
|
||||||
.zip(self.recent.front())
|
|
||||||
.is_some_and(|(b, f)| b.duration_since(*f) <= Self::ACTIVE_SPAN);
|
|
||||||
let gap = self.recent.back().map(|last| now.duration_since(*last));
|
|
||||||
self.recent.push_back(now);
|
|
||||||
if self.recent.len() > Self::RECENT {
|
|
||||||
self.recent.pop_front();
|
|
||||||
}
|
|
||||||
let gap = gap?;
|
|
||||||
if !was_active || gap < Self::STALL_MIN {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(Stall {
|
|
||||||
gap,
|
|
||||||
metronomic: self.cadence.note(now),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct IddPushCapturer {
|
pub struct IddPushCapturer {
|
||||||
device: ID3D11Device,
|
device: ID3D11Device,
|
||||||
@@ -1923,6 +1554,7 @@ impl Drop for IddPushCapturer {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use super::stall::Stall;
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// Feed a [`StallWatch`] fresh frames at the given offsets (ms from a common origin) and
|
/// Feed a [`StallWatch`] fresh frames at the given offsets (ms from a common origin) and
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
//! The sealed frame channel's handle-duplication broker (plan §W4, carved out of the IDD-push
|
||||||
|
//! capturer): duplicates the unnamed shared header / ring / event handles into the driver's WUDFHost
|
||||||
|
//! and delivers them as bare handle values over the SYSTEM-only control device.
|
||||||
|
|
||||||
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The sealed channel's handle-duplication broker (`design/idd-push-security.md`): the frame objects
|
||||||
|
/// are unnamed, so the ONLY way the driver can reach them is handles this broker duplicates into its
|
||||||
|
/// WUDFHost process and delivers — as bare handle VALUES — over the SYSTEM-only control device
|
||||||
|
/// (`IOCTL_SET_FRAME_CHANNEL`). Ownership is a strict hand-off: on IOCTL success the DRIVER owns the
|
||||||
|
/// duplicates (it closes them); on any failure [`Self::send`] reaps every duplicate it already made
|
||||||
|
/// (`DUPLICATE_CLOSE_SOURCE`), so a half-delivered channel never leaks handles in WUDFHost.
|
||||||
|
pub(super) struct ChannelBroker {
|
||||||
|
/// `PROCESS_DUP_HANDLE | SYNCHRONIZE` handle to the driver's WUDFHost (pid from the ADD reply;
|
||||||
|
/// `ProcessSharingDisabled` makes that process exclusively pf-vdisplay's). `SYNCHRONIZE` lets the
|
||||||
|
/// handle double as the driver-death probe ([`Self::driver_alive`]).
|
||||||
|
process: OwnedHandle,
|
||||||
|
/// The WUDFHost pid `process` refers to (diagnostics for the driver-death bail).
|
||||||
|
pub(super) wudf_pid: u32,
|
||||||
|
/// The pf-vdisplay control device — owned by the `VirtualDisplayManager`, never closed for the
|
||||||
|
/// process lifetime (a dead one is retired, kept alive), so holding the bare `HANDLE` is sound.
|
||||||
|
control: HANDLE,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChannelBroker {
|
||||||
|
/// Open the duplication target. Fails when the driver predates the sealed channel (`wudf_pid == 0`
|
||||||
|
/// can't survive the v2 version handshake, but guard anyway) or the WUDFHost is gone (device
|
||||||
|
/// restart mid-open) — either way the caller fails the capture open cleanly.
|
||||||
|
///
|
||||||
|
/// `wudf_pid` comes from the driver's ADD reply, so before we duplicate whole-desktop frame handles
|
||||||
|
/// INTO it we VERIFY it is a genuine system WUDFHost ([`verify_is_wudfhost`]). Without that check a
|
||||||
|
/// spoofed devnode (same interface GUID) could name an arbitrary process and receive the frames; a
|
||||||
|
/// fully-compromised REAL pf_vdisplay driver is already a frame endpoint, so this specifically closes
|
||||||
|
/// the reachable-without-owning-the-driver case (`design/idd-push-security.md` §hardening).
|
||||||
|
pub(super) fn open(wudf_pid: u32) -> Result<Self> {
|
||||||
|
if wudf_pid == 0 {
|
||||||
|
bail!("driver reported no WUDFHost pid for the frame channel");
|
||||||
|
}
|
||||||
|
let control = crate::vdisplay::manager::control_device_handle().context(
|
||||||
|
"pf-vdisplay control device not open (monitor not created via the manager?)",
|
||||||
|
)?;
|
||||||
|
// SAFETY: plain FFI; `wudf_pid` is a copy. The handle (checked by `?`) is owned solely here and
|
||||||
|
// moved into the `OwnedHandle` (single owner, closes on drop); `verify_is_wudfhost` borrows it
|
||||||
|
// for the duration of the synchronous check and forms no lasting alias.
|
||||||
|
let process = unsafe {
|
||||||
|
let h = OpenProcess(
|
||||||
|
PROCESS_DUP_HANDLE | PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE,
|
||||||
|
false,
|
||||||
|
wudf_pid,
|
||||||
|
)
|
||||||
|
.context("OpenProcess(PROCESS_DUP_HANDLE) on the driver's WUDFHost")?;
|
||||||
|
let process = OwnedHandle::from_raw_handle(h.0 as _);
|
||||||
|
verify_is_wudfhost(HANDLE(process.as_raw_handle()), wudf_pid, "frame-channel")?;
|
||||||
|
process
|
||||||
|
};
|
||||||
|
Ok(Self {
|
||||||
|
process,
|
||||||
|
wudf_pid,
|
||||||
|
control,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the driver's WUDFHost is still alive. The pinned process handle doubles as the
|
||||||
|
/// liveness probe (`SYNCHRONIZE` requested at open): signaled ⇔ the process exited. This is the
|
||||||
|
/// definitive "driver died mid-session" signal — at the ring, a dead driver and an idle desktop
|
||||||
|
/// are indistinguishable (both simply stop publishing).
|
||||||
|
pub(super) fn driver_alive(&self) -> bool {
|
||||||
|
// SAFETY: `process` is the live `OwnedHandle` this broker owns (borrowed for this synchronous
|
||||||
|
// call); a 0 ms wait only reads the handle's signaled state.
|
||||||
|
unsafe { WaitForSingleObject(HANDLE(self.process.as_raw_handle()), 0) != WAIT_OBJECT_0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Duplicate `h` into the WUDFHost handle table, returning the handle VALUE valid there (and only
|
||||||
|
/// there — the value is meaningless in any other process). `access = Some(rights)` grants the
|
||||||
|
/// driver's handle exactly those rights (least privilege — see [`SECTION_MAP_RW`]);
|
||||||
|
/// `access = None` copies the source handle's access (`DUPLICATE_SAME_ACCESS`), used only where the
|
||||||
|
/// source is already scoped (the DXGI shared-texture handles, minted by `CreateSharedHandle` with
|
||||||
|
/// just `DXGI_SHARED_RESOURCE_READ|WRITE`).
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `h` must be a live handle of the current process.
|
||||||
|
unsafe fn dup_into(&self, h: HANDLE, access: Option<u32>) -> Result<u64> {
|
||||||
|
let mut out = HANDLE::default();
|
||||||
|
let (desired, options) = match access {
|
||||||
|
Some(rights) => (rights, DUPLICATE_HANDLE_OPTIONS(0)),
|
||||||
|
None => (0, DUPLICATE_SAME_ACCESS),
|
||||||
|
};
|
||||||
|
// SAFETY: `h` is live per the contract; `self.process` is the live PROCESS_DUP_HANDLE target;
|
||||||
|
// `&mut out` is a valid out-param. Either an explicit least-privilege access mask (options == 0)
|
||||||
|
// or `DUPLICATE_SAME_ACCESS` (desired ignored) — never both.
|
||||||
|
unsafe {
|
||||||
|
DuplicateHandle(
|
||||||
|
GetCurrentProcess(),
|
||||||
|
h,
|
||||||
|
HANDLE(self.process.as_raw_handle()),
|
||||||
|
&mut out,
|
||||||
|
desired,
|
||||||
|
false,
|
||||||
|
options,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.context("DuplicateHandle into the driver's WUDFHost")?;
|
||||||
|
Ok(out.0 as usize as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close a handle VALUE inside the WUDFHost table (the failure-path reaper): `DUPLICATE_CLOSE_SOURCE`
|
||||||
|
/// with no target closes the source handle regardless of the (ignored) result.
|
||||||
|
fn close_remote(&self, value: u64) {
|
||||||
|
if value == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// SAFETY: `self.process` is the live duplication target and `value` is a handle value THIS
|
||||||
|
// broker just created in that process's table (callers only pass back `dup_into` results the
|
||||||
|
// driver never received); closing it there cannot touch any other process's handles.
|
||||||
|
unsafe {
|
||||||
|
let _ = DuplicateHandle(
|
||||||
|
HANDLE(self.process.as_raw_handle()),
|
||||||
|
HANDLE(value as usize as *mut core::ffi::c_void),
|
||||||
|
HANDLE::default(),
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
DUPLICATE_CLOSE_SOURCE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Duplicate the whole ring (header + event + every slot texture) into WUDFHost and deliver the
|
||||||
|
/// values via `IOCTL_SET_FRAME_CHANNEL`. All-or-nothing: on any failure every duplicate already
|
||||||
|
/// made is reaped remotely and an error returns (the caller fails the open / logs the recreate).
|
||||||
|
/// The ownership contract with the driver is adopt-on-success only — it closes the handles iff the
|
||||||
|
/// IOCTL succeeded, we reap them iff it didn't, so no value is ever closed twice.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `header` and `event` must be live handles of the current process (the capturer's own section +
|
||||||
|
/// event, borrowed for this synchronous call).
|
||||||
|
pub(super) unsafe fn send(
|
||||||
|
&self,
|
||||||
|
target_id: u32,
|
||||||
|
generation: u32,
|
||||||
|
header: HANDLE,
|
||||||
|
event: HANDLE,
|
||||||
|
slots: &[HostSlot],
|
||||||
|
) -> Result<()> {
|
||||||
|
debug_assert!(slots.len() <= control::RING_LEN_USIZE);
|
||||||
|
let mut req = control::SetFrameChannelRequest {
|
||||||
|
target_id,
|
||||||
|
generation,
|
||||||
|
ring_len: slots.len() as u32,
|
||||||
|
_pad: 0,
|
||||||
|
header_handle: 0,
|
||||||
|
event_handle: 0,
|
||||||
|
texture_handles: [0; control::RING_LEN_USIZE],
|
||||||
|
};
|
||||||
|
// SAFETY: `header`/`event` are live per this fn's contract; each slot's `shared` is the live
|
||||||
|
// `OwnedHandle` the slot keeps for exactly this purpose.
|
||||||
|
let result = unsafe { self.duplicate_and_deliver(&mut req, header, event, slots) };
|
||||||
|
if result.is_err() {
|
||||||
|
// The driver never adopted the delivery — reap every remote duplicate so nothing lingers.
|
||||||
|
self.close_remote(req.header_handle);
|
||||||
|
self.close_remote(req.event_handle);
|
||||||
|
for v in req.texture_handles {
|
||||||
|
self.close_remote(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fallible middle of [`Self::send`]: fill `req` with fresh duplicates, then issue the IOCTL.
|
||||||
|
/// Split out so `send` can reap whatever landed in `req` when any step errors.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// As [`Self::send`].
|
||||||
|
unsafe fn duplicate_and_deliver(
|
||||||
|
&self,
|
||||||
|
req: &mut control::SetFrameChannelRequest,
|
||||||
|
header: HANDLE,
|
||||||
|
event: HANDLE,
|
||||||
|
slots: &[HostSlot],
|
||||||
|
) -> Result<()> {
|
||||||
|
// SAFETY: forwarded from the caller's contract — `header`/`event`/each `slot.shared` are live
|
||||||
|
// handles of this process, and `self.control` is the manager's control handle, never closed for
|
||||||
|
// the process lifetime (`send_frame_channel`'s precondition).
|
||||||
|
unsafe {
|
||||||
|
// Least privilege per handle: the header maps read/write, the event is only signalled, and
|
||||||
|
// the textures keep their already-scoped `CreateSharedHandle` access (see `dup_into`).
|
||||||
|
req.header_handle = self.dup_into(header, Some(SECTION_MAP_RW))?;
|
||||||
|
req.event_handle = self.dup_into(event, Some(EVENT_MODIFY_STATE))?;
|
||||||
|
for (k, s) in slots.iter().enumerate() {
|
||||||
|
req.texture_handles[k] = self.dup_into(HANDLE(s.shared.as_raw_handle()), None)?;
|
||||||
|
}
|
||||||
|
crate::vdisplay::pf_vdisplay::send_frame_channel(self.control, req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
//! Off-thread display-descriptor polling (plan §W4, carved out of the IDD-push capturer): the
|
||||||
|
//! live HDR state + active resolution of the virtual target, sampled off the capture loop via CCD.
|
||||||
|
|
||||||
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Creates + owns the shared ring; yields the driver's frames as [`FramePayload::D3d11`].
|
||||||
|
/// The display descriptor the capture loop follows: live HDR state + active resolution of the
|
||||||
|
/// virtual target.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(super) struct DisplayDescriptor {
|
||||||
|
pub(super) hdr: bool,
|
||||||
|
pub(super) width: u32,
|
||||||
|
pub(super) height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Off-thread poller for [`DisplayDescriptor`]. The CCD queries behind it (`QueryDisplayConfig`,
|
||||||
|
/// twice per sample) serialize on the session-global display-configuration lock, which display-
|
||||||
|
/// topology events and third-party display-poller software (the SteelSeries-GG class) can hold
|
||||||
|
/// for tens-to-hundreds of milliseconds at a time. Polled inline — the old design — that stall
|
||||||
|
/// landed ON the capture/encode thread: a periodic frame hitch on an otherwise healthy host, and
|
||||||
|
/// invisible in any log. Now a dedicated thread samples every [`Self::INTERVAL`] and publishes a
|
||||||
|
/// snapshot; the capture thread's per-frame cost is one uncontended mutex read, and a slow CCD
|
||||||
|
/// sample is *measured and logged* instead of silently stalling the stream.
|
||||||
|
///
|
||||||
|
/// Failure policy is last-known-good, per field: a transient CCD failure — including the target
|
||||||
|
/// briefly missing from the active-path list during a topology re-probe — keeps the previous
|
||||||
|
/// value instead of reading as `hdr = false` (the old behavior, which on an HDR session turned
|
||||||
|
/// every blip into TWO ring recreates: false, then true again a poll later). `seq` bumps only
|
||||||
|
/// when at least one query succeeded, so the consumer's debounce counts real observations, never
|
||||||
|
/// failures.
|
||||||
|
pub(super) struct DescriptorPoller {
|
||||||
|
/// Latest merged sample + its sequence number; the poller holds the lock only to copy it.
|
||||||
|
snap: Arc<Mutex<(DisplayDescriptor, u64)>>,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
thread: Option<std::thread::JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DescriptorPoller {
|
||||||
|
/// Poll cadence — the old inline throttle. With the consumer's two-strikes debounce on top, a
|
||||||
|
/// real "Use HDR" flip or mode-set is acted on within ~2 samples (≈ ½ s).
|
||||||
|
const INTERVAL: Duration = Duration::from_millis(250);
|
||||||
|
/// A sample slower than this means something is sitting on the display-config lock (topology
|
||||||
|
/// churn / display-poller software) — the disturbance class behind periodic virtual-display
|
||||||
|
/// stream hitches. Logged (rate-limited) so an affected host self-diagnoses.
|
||||||
|
const SLOW: Duration = Duration::from_millis(50);
|
||||||
|
|
||||||
|
pub(super) fn spawn(target_id: u32, initial: DisplayDescriptor) -> Self {
|
||||||
|
let snap = Arc::new(Mutex::new((initial, 0u64)));
|
||||||
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
|
let (snap_t, stop_t) = (snap.clone(), stop.clone());
|
||||||
|
let thread = std::thread::Builder::new()
|
||||||
|
.name("pf-idd-desc-poll".into())
|
||||||
|
.spawn(move || {
|
||||||
|
let mut last = initial;
|
||||||
|
let mut seq = 0u64;
|
||||||
|
let mut last_slow_log: Option<Instant> = None;
|
||||||
|
while !stop_t.load(Ordering::Relaxed) {
|
||||||
|
let t = Instant::now();
|
||||||
|
// SAFETY: both are read-only CCD queries taking only a copy of the plain `u32`
|
||||||
|
// target id (see their own SAFETY docs); nothing is borrowed across the calls.
|
||||||
|
let (hdr, res) = unsafe {
|
||||||
|
(
|
||||||
|
crate::win_display::advanced_color_enabled(target_id),
|
||||||
|
crate::win_display::active_resolution(target_id),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let took = t.elapsed();
|
||||||
|
if took >= Self::SLOW
|
||||||
|
&& last_slow_log.is_none_or(|t| t.elapsed() >= Duration::from_secs(10))
|
||||||
|
{
|
||||||
|
last_slow_log = Some(Instant::now());
|
||||||
|
tracing::warn!(
|
||||||
|
took_ms = took.as_millis() as u64,
|
||||||
|
target_id,
|
||||||
|
"slow display-descriptor poll — something is holding the Windows \
|
||||||
|
display-config lock (topology churn / display-poller software); on \
|
||||||
|
a host with periodic stream hitches, correlate this cadence"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if hdr.is_some() || res.is_some() {
|
||||||
|
if let Some(hdr) = hdr {
|
||||||
|
last.hdr = hdr;
|
||||||
|
}
|
||||||
|
if let Some((width, height)) = res {
|
||||||
|
last.width = width;
|
||||||
|
last.height = height;
|
||||||
|
}
|
||||||
|
seq += 1;
|
||||||
|
*snap_t.lock().unwrap() = (last, seq);
|
||||||
|
}
|
||||||
|
// Park (not sleep) so `drop` wakes the thread immediately via `unpark`.
|
||||||
|
std::thread::park_timeout(Self::INTERVAL);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map_err(|e| {
|
||||||
|
// Degraded, not fatal: the session streams, it just never follows a mid-session
|
||||||
|
// HDR flip / mode-set (seq stays 0 → the consumer sees no changes).
|
||||||
|
tracing::warn!(error = %e, "IDD push: descriptor-poller thread failed to spawn — mid-session HDR/mode changes won't be followed");
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
Self { snap, stop, thread }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The latest sample (lock held only for the copy — the poller writes at 4 Hz).
|
||||||
|
pub(super) fn snapshot(&self) -> (DisplayDescriptor, u64) {
|
||||||
|
*self.snap.lock().unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for DescriptorPoller {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop.store(true, Ordering::Relaxed);
|
||||||
|
if let Some(t) = self.thread.take() {
|
||||||
|
t.thread().unpark();
|
||||||
|
let _ = t.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
//! Capture-stall detection (plan §W4, carved out of the IDD-push capturer): flags multi-hundred-ms
|
||||||
|
//! holes in DWM frame delivery that open while the desktop was actively composing.
|
||||||
|
|
||||||
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A detected capture stall: a multi-hundred-ms hole in DWM's frame delivery that opened while the
|
||||||
|
/// desktop was actively composing right beforehand (see [`StallWatch`]).
|
||||||
|
pub(super) struct Stall {
|
||||||
|
/// How long the hole lasted (last fresh frame → the frame that ended it).
|
||||||
|
pub(super) gap: Duration,
|
||||||
|
/// `Some(mean period)` when this stall completes a metronomic cycle (see
|
||||||
|
/// [`crate::metronome::Metronome`]).
|
||||||
|
pub(super) metronomic: Option<Duration>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Capture-stall watch — the "sole virtual display" stutter diagnostic (field reports: Exclusive
|
||||||
|
/// topology = periodic double-jolt, Extend = smooth, i.e. the disturbance lives in the display/present
|
||||||
|
/// path BELOW capture and only while no physical output is active).
|
||||||
|
///
|
||||||
|
/// On a damage-driven capture an idle desktop legitimately goes quiet (no damage → no frames), so a
|
||||||
|
/// gap only counts as a stall when the [`Self::RECENT`] frames before it all arrived within
|
||||||
|
/// [`Self::ACTIVE_SPAN`] — sustained ≥ ~20 fps flow (a game or video), not a blinking caret or a
|
||||||
|
/// mouse twitch. Each stall feeds a [`crate::metronome::Metronome`], so periodic stalls self-diagnose
|
||||||
|
/// in the log WITHOUT needing any client keyframe request — discriminating "DWM stopped composing"
|
||||||
|
/// from encode/network causes that the recovery-cadence detector covers. Pure logic — unit-tested
|
||||||
|
/// below; the caller does the logging.
|
||||||
|
pub(super) struct StallWatch {
|
||||||
|
/// The last [`Self::RECENT`] fresh-frame instants (pre-gap history for the activity gate).
|
||||||
|
recent: std::collections::VecDeque<Instant>,
|
||||||
|
cadence: crate::metronome::Metronome,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StallWatch {
|
||||||
|
/// Frames of pre-gap history that must be tight for flow to count as active. Stalls are thus
|
||||||
|
/// naturally spaced ≥ RECENT frame times apart — no extra log rate limit needed.
|
||||||
|
const RECENT: usize = 8;
|
||||||
|
/// The RECENT pre-gap frames must all fit in this span (8 frames in 400 ms ≈ ≥ 20 fps flow —
|
||||||
|
/// loose enough for a 30 fps-capped game, tight enough to reject idle-desktop damage).
|
||||||
|
const ACTIVE_SPAN: Duration = Duration::from_millis(400);
|
||||||
|
/// The smallest hole that counts as a stall (~9 missed frames at 60 Hz) — well below the
|
||||||
|
/// reported 300–700 ms freezes, above encode/present jitter.
|
||||||
|
const STALL_MIN: Duration = Duration::from_millis(150);
|
||||||
|
|
||||||
|
pub(super) fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
recent: std::collections::VecDeque::with_capacity(Self::RECENT + 1),
|
||||||
|
cadence: crate::metronome::Metronome::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget the flow history (a ring recreate's gap is self-inflicted, not a DWM stall — without
|
||||||
|
/// the reset the first post-recreate frame would read as one).
|
||||||
|
pub(super) fn reset(&mut self) {
|
||||||
|
self.recent.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a fresh driver frame at `now`; `Some` exactly when it ended a stall.
|
||||||
|
pub(super) fn note_fresh(&mut self, now: Instant) -> Option<Stall> {
|
||||||
|
let was_active = self.recent.len() == Self::RECENT
|
||||||
|
&& self
|
||||||
|
.recent
|
||||||
|
.back()
|
||||||
|
.zip(self.recent.front())
|
||||||
|
.is_some_and(|(b, f)| b.duration_since(*f) <= Self::ACTIVE_SPAN);
|
||||||
|
let gap = self.recent.back().map(|last| now.duration_since(*last));
|
||||||
|
self.recent.push_back(now);
|
||||||
|
if self.recent.len() > Self::RECENT {
|
||||||
|
self.recent.pop_front();
|
||||||
|
}
|
||||||
|
let gap = gap?;
|
||||||
|
if !was_active || gap < Self::STALL_MIN {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Stall {
|
||||||
|
gap,
|
||||||
|
metronomic: self.cadence.note(now),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,6 +87,14 @@ pub struct HostConfig {
|
|||||||
/// `systemctl restart display-manager` under a polkit rule — with auto-login enabled the restart brings
|
/// `systemctl restart display-manager` under a polkit rule — with auto-login enabled the restart brings
|
||||||
/// the desktop back and the client's retry lands in it. Unset/empty = disabled (the default).
|
/// the desktop back and the client's retry lands in it. Unset/empty = disabled (the default).
|
||||||
pub recover_session_cmd: Option<String>,
|
pub recover_session_cmd: Option<String>,
|
||||||
|
/// `PUNKTFUNK_ON_CONNECT_CMD` — zero-config mirror of a `client.connected` hook
|
||||||
|
/// (`crate::hooks`): fired detached with the event JSON on stdin + `PF_EVENT_*` env when a
|
||||||
|
/// client connects, on either plane. The full hook surface (filters, webhooks, debounce)
|
||||||
|
/// lives in `hooks.json`. Unset/empty = disabled (the default).
|
||||||
|
pub on_connect_cmd: Option<String>,
|
||||||
|
/// `PUNKTFUNK_ON_DISCONNECT_CMD` — the `client.disconnected` sibling of
|
||||||
|
/// [`Self::on_connect_cmd`].
|
||||||
|
pub on_disconnect_cmd: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HostConfig {
|
impl HostConfig {
|
||||||
@@ -146,6 +154,8 @@ impl HostConfig {
|
|||||||
}),
|
}),
|
||||||
recover_session_cmd: val("PUNKTFUNK_RECOVER_SESSION_CMD")
|
recover_session_cmd: val("PUNKTFUNK_RECOVER_SESSION_CMD")
|
||||||
.filter(|s| !s.trim().is_empty()),
|
.filter(|s| !s.trim().is_empty()),
|
||||||
|
on_connect_cmd: val("PUNKTFUNK_ON_CONNECT_CMD").filter(|s| !s.trim().is_empty()),
|
||||||
|
on_disconnect_cmd: val("PUNKTFUNK_ON_DISCONNECT_CMD").filter(|s| !s.trim().is_empty()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ pub fn deck_windows_spike(args: &[String]) -> Result<()> {
|
|||||||
/// removes the devnode.
|
/// removes the devnode.
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
|
pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
|
||||||
use crate::gamestream::gamepad::{GamepadEvent, GamepadFrame};
|
use punktfunk_core::input::{GamepadEvent, GamepadFrame};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
let secs: u64 = args
|
let secs: u64 = args
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -959,6 +959,40 @@ pub(crate) fn windows_resolved_backend() -> WindowsBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True if the session's resolved encode backend produces GPU-resident frames (so the capturer should
|
||||||
|
/// hand GPU surfaces straight through rather than CPU-stage them) — only the GPU-less software encoder
|
||||||
|
/// wants CPU staging. This is the single source for [`crate::capture::OutputFormat`]'s `gpu` bit:
|
||||||
|
/// resolving it in `encode` and threading it *into* the capturer (rather than having `capture` re-derive
|
||||||
|
/// the backend) keeps the capture→encode dependency one-way, so the two can never disagree on whether
|
||||||
|
/// frames are GPU-resident (plan §2.4 / §W4).
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub(crate) fn resolved_backend_is_gpu() -> bool {
|
||||||
|
!matches!(windows_resolved_backend(), WindowsBackend::Software)
|
||||||
|
}
|
||||||
|
/// Linux/other: every backend but the GPU-less software encoder (openh264) is GPU-resident. Config-backed
|
||||||
|
/// (mirrors `session_plan::resolve_encoder`; the NVENC vs VAAPI split is auto-detected in [`open_video`]).
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
pub(crate) fn resolved_backend_is_gpu() -> bool {
|
||||||
|
!matches!(
|
||||||
|
crate::config::config().encoder_pref.as_str(),
|
||||||
|
"software" | "sw" | "openh264"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if the resolved encode backend can ingest a full-chroma (RGB) source and CSC it to 4:4:4 itself —
|
||||||
|
/// the *encoder* half of the 4:4:4 capture gate ([`crate::capture::capturer_supports_444`]). Only Windows
|
||||||
|
/// direct-NVENC does (measured on-glass: ARGB + `chromaFormatIDC=3` → true 4:4:4); AMF/QSV can't. On Linux
|
||||||
|
/// the 4:4:4 source is the capturer's own (portal RGB → `yuv444p`), independent of the auto-detected
|
||||||
|
/// backend, so the gate never consults this there.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub(crate) fn resolved_backend_ingests_rgb_444() -> bool {
|
||||||
|
windows_resolved_backend() == WindowsBackend::Nvenc
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
pub(crate) fn resolved_backend_ingests_rgb_444() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
/// True if the active Windows backend's codec advertisement comes from a **real GPU probe**
|
/// True if the active Windows backend's codec advertisement comes from a **real GPU probe**
|
||||||
/// ([`windows_codec_support`]) rather than the NVENC static superset. AMF always qualifies — the
|
/// ([`windows_codec_support`]) rather than the NVENC static superset. AMF always qualifies — the
|
||||||
/// native factory probe (`amf::probe_can_encode`) needs no build feature — while QSV still needs
|
/// native factory probe (`amf::probe_can_encode`) needs no build feature — while QSV still needs
|
||||||
|
|||||||
@@ -196,6 +196,77 @@ impl EventKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl EventKind {
|
||||||
|
/// The client/device name this event carries, if any — the `filter.client` axis of hooks
|
||||||
|
/// and scripts. (For `session.*` this is the short client *label* the Dashboard shows —
|
||||||
|
/// cert-fingerprint prefix or peer IP — since that is what the event carries.)
|
||||||
|
pub fn client_name(&self) -> Option<&str> {
|
||||||
|
match self {
|
||||||
|
EventKind::ClientConnected { client }
|
||||||
|
| EventKind::ClientDisconnected { client, .. } => Some(&client.name),
|
||||||
|
EventKind::SessionStarted { session } | EventKind::SessionEnded { session } => {
|
||||||
|
Some(&session.client)
|
||||||
|
}
|
||||||
|
EventKind::StreamStarted { stream } | EventKind::StreamStopped { stream } => {
|
||||||
|
Some(&stream.client)
|
||||||
|
}
|
||||||
|
EventKind::PairingPending { device }
|
||||||
|
| EventKind::PairingCompleted { device }
|
||||||
|
| EventKind::PairingDenied { device } => Some(&device.name),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The certificate fingerprint this event carries, if any.
|
||||||
|
pub fn fingerprint(&self) -> Option<&str> {
|
||||||
|
match self {
|
||||||
|
EventKind::ClientConnected { client }
|
||||||
|
| EventKind::ClientDisconnected { client, .. } => client.fingerprint.as_deref(),
|
||||||
|
EventKind::PairingPending { device }
|
||||||
|
| EventKind::PairingCompleted { device }
|
||||||
|
| EventKind::PairingDenied { device } => Some(&device.fingerprint),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The protocol plane this event carries, if any.
|
||||||
|
pub fn plane(&self) -> Option<Plane> {
|
||||||
|
match self {
|
||||||
|
EventKind::ClientConnected { client }
|
||||||
|
| EventKind::ClientDisconnected { client, .. } => Some(client.plane),
|
||||||
|
EventKind::StreamStarted { stream } | EventKind::StreamStopped { stream } => {
|
||||||
|
Some(stream.plane)
|
||||||
|
}
|
||||||
|
EventKind::PairingPending { device }
|
||||||
|
| EventKind::PairingCompleted { device }
|
||||||
|
| EventKind::PairingDenied { device } => Some(device.plane),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The launched app id/title this event carries, if any.
|
||||||
|
pub fn app(&self) -> Option<&str> {
|
||||||
|
match self {
|
||||||
|
EventKind::StreamStarted { stream } | EventKind::StreamStopped { stream } => {
|
||||||
|
stream.app.as_deref()
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does `pattern` select `kind`? Exact kind names (`stream.started`) or `domain.*` prefixes
|
||||||
|
/// matched on the dot boundary (`stream.*` matches `stream.started`, never `streamx.started`).
|
||||||
|
/// One vocabulary for the SSE `?kinds=` filter and the hooks `on:` field.
|
||||||
|
pub fn kind_matches(pattern: &str, kind: &str) -> bool {
|
||||||
|
match pattern.strip_suffix(".*") {
|
||||||
|
Some(prefix) => kind
|
||||||
|
.strip_prefix(prefix)
|
||||||
|
.is_some_and(|rest| rest.starts_with('.')),
|
||||||
|
None => pattern == kind,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Formats a mode as the wire's `WxH@Hz` string.
|
/// Formats a mode as the wire's `WxH@Hz` string.
|
||||||
pub fn mode_str(width: u32, height: u32, hz: u32) -> String {
|
pub fn mode_str(width: u32, height: u32, hz: u32) -> String {
|
||||||
format!("{width}x{height}@{hz}")
|
format!("{width}x{height}@{hz}")
|
||||||
@@ -262,13 +333,19 @@ impl EventBus {
|
|||||||
let _ = self.tx.send(ev);
|
let _ = self.tx.send(ev);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A live-tail-only subscription (no catch-up, no cursor) — for host-internal consumers
|
||||||
|
/// like the hook runner that only care about events from now on.
|
||||||
|
pub fn subscribe_live(&self) -> broadcast::Receiver<HostEvent> {
|
||||||
|
self.tx.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
/// Subscribe with a resume cursor: events with `seq > since` come back as catch-up, the
|
/// Subscribe with a resume cursor: events with `seq > since` come back as catch-up, the
|
||||||
/// returned receiver carries everything after. `since = 0` means "from the ring start".
|
/// returned receiver carries everything after. `since = 0` means "from the ring start".
|
||||||
pub fn subscribe(&self, since: u64) -> Subscription {
|
pub fn subscribe(&self, since: u64) -> Subscription {
|
||||||
let ring = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
let ring = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
let rx = self.tx.subscribe();
|
let rx = self.tx.subscribe();
|
||||||
let first_seq = ring.events.front().map_or(ring.next_seq, |e| e.seq);
|
let first_seq = ring.events.front().map_or(ring.next_seq, |e| e.seq);
|
||||||
let dropped = since != 0 && since + 1 < first_seq;
|
let dropped = since != 0 && since.saturating_add(1) < first_seq;
|
||||||
let catch_up = ring
|
let catch_up = ring
|
||||||
.events
|
.events
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ pub struct AppEntry {
|
|||||||
/// library ([`crate::library`]). When set, the launch path resolves + launches it against the
|
/// library ([`crate::library`]). When set, the launch path resolves + launches it against the
|
||||||
/// host's own library instead of running [`cmd`](Self::cmd). `None` for Desktop / apps.json entries.
|
/// host's own library instead of running [`cmd`](Self::cmd). `None` for Desktop / apps.json entries.
|
||||||
pub library_id: Option<String>,
|
pub library_id: Option<String>,
|
||||||
|
/// Per-app prep/undo steps (RFC §6, Sunshine `prep-cmd` parity): each `do` runs before the
|
||||||
|
/// app launches, each `undo` at stream end in reverse order (see [`crate::hooks::run_prep`]).
|
||||||
|
pub prep: Vec<crate::hooks::PrepCmd>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn config_path() -> Option<std::path::PathBuf> {
|
fn config_path() -> Option<std::path::PathBuf> {
|
||||||
@@ -68,6 +71,12 @@ fn base_catalog() -> Vec<AppEntry> {
|
|||||||
.and_then(parse_compositor),
|
.and_then(parse_compositor),
|
||||||
cmd: it.get("cmd").and_then(|c| c.as_str()).map(String::from),
|
cmd: it.get("cmd").and_then(|c| c.as_str()).map(String::from),
|
||||||
library_id: None,
|
library_id: None,
|
||||||
|
// `"prep": [{"do": …, "undo": …}, …]` — optional; a malformed
|
||||||
|
// array is ignored (the entry still launches, just unprepped).
|
||||||
|
prep: it
|
||||||
|
.get("prep")
|
||||||
|
.and_then(|p| serde_json::from_value(p.clone()).ok())
|
||||||
|
.unwrap_or_default(),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -88,6 +97,7 @@ fn base_catalog() -> Vec<AppEntry> {
|
|||||||
compositor: None,
|
compositor: None,
|
||||||
cmd: None,
|
cmd: None,
|
||||||
library_id: None,
|
library_id: None,
|
||||||
|
prep: Vec::new(),
|
||||||
}];
|
}];
|
||||||
if which("gamescope") {
|
if which("gamescope") {
|
||||||
if which("steam") {
|
if which("steam") {
|
||||||
@@ -97,6 +107,7 @@ fn base_catalog() -> Vec<AppEntry> {
|
|||||||
compositor: Some(crate::vdisplay::Compositor::Gamescope),
|
compositor: Some(crate::vdisplay::Compositor::Gamescope),
|
||||||
cmd: Some("steam -gamepadui".into()),
|
cmd: Some("steam -gamepadui".into()),
|
||||||
library_id: None,
|
library_id: None,
|
||||||
|
prep: Vec::new(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if which("vkcube") {
|
if which("vkcube") {
|
||||||
@@ -106,6 +117,7 @@ fn base_catalog() -> Vec<AppEntry> {
|
|||||||
compositor: Some(crate::vdisplay::Compositor::Gamescope),
|
compositor: Some(crate::vdisplay::Compositor::Gamescope),
|
||||||
cmd: Some("vkcube".into()),
|
cmd: Some("vkcube".into()),
|
||||||
library_id: None,
|
library_id: None,
|
||||||
|
prep: Vec::new(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,6 +151,7 @@ fn append_library(apps: &mut Vec<AppEntry>) {
|
|||||||
compositor: None, // auto-detect the desktop session (Windows ignores the compositor)
|
compositor: None, // auto-detect the desktop session (Windows ignores the compositor)
|
||||||
cmd: None,
|
cmd: None,
|
||||||
library_id: Some(g.id),
|
library_id: Some(g.id),
|
||||||
|
prep: Vec::new(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,6 +255,7 @@ mod tests {
|
|||||||
compositor: None,
|
compositor: None,
|
||||||
cmd: None,
|
cmd: None,
|
||||||
library_id: None,
|
library_id: None,
|
||||||
|
prep: Vec::new(),
|
||||||
}];
|
}];
|
||||||
append_library(&mut apps);
|
append_library(&mut apps);
|
||||||
let ids: Vec<u32> = apps.iter().map(|a| a.id).collect();
|
let ids: Vec<u32> = apps.iter().map(|a| a.id).collect();
|
||||||
|
|||||||
@@ -16,39 +16,10 @@ const MAGIC_MULTI_CONTROLLER: u32 = 0x0C;
|
|||||||
/// Sunshine extension: controller arrival metadata (type/capabilities).
|
/// Sunshine extension: controller arrival metadata (type/capabilities).
|
||||||
const MAGIC_CONTROLLER_ARRIVAL: u32 = 0x5500_0004;
|
const MAGIC_CONTROLLER_ARRIVAL: u32 = 0x5500_0004;
|
||||||
|
|
||||||
/// Most controllers a session tracks (Sunshine's MAX_GAMEPADS).
|
// The decoded controller types ([`GamepadEvent`]/[`GamepadFrame`]) and the pad count
|
||||||
pub const MAX_PADS: usize = 16;
|
// ([`punktfunk_core::input::MAX_PADS`]) are shared vocabulary between this Moonlight decode path and
|
||||||
|
// the platform-neutral injectors, so they live in `core::input` (below both) rather than here.
|
||||||
/// One decoded controller event.
|
use punktfunk_core::input::{GamepadEvent, GamepadFrame};
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub enum GamepadEvent {
|
|
||||||
/// Full state of one controller + the set of attached controllers.
|
|
||||||
State(GamepadFrame),
|
|
||||||
/// Sunshine arrival metadata (precedes the first State for that pad).
|
|
||||||
Arrival {
|
|
||||||
index: u8,
|
|
||||||
/// 0 unknown, 1 xbox, 2 ps, 3 nintendo.
|
|
||||||
kind: u8,
|
|
||||||
/// LI_CCAP_* bits (0x02 = rumble).
|
|
||||||
capabilities: u16,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Snapshot of one controller's inputs (Moonlight conventions: sticks −32768..32767 with +Y
|
|
||||||
/// up, triggers 0..255, buttons = `buttonFlags | buttonFlags2 << 16`).
|
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
||||||
pub struct GamepadFrame {
|
|
||||||
pub index: i16,
|
|
||||||
/// Bit n set = controller n attached; a clear bit for an allocated pad means unplug.
|
|
||||||
pub active_mask: u16,
|
|
||||||
pub buttons: u32,
|
|
||||||
pub left_trigger: u8,
|
|
||||||
pub right_trigger: u8,
|
|
||||||
pub ls_x: i16,
|
|
||||||
pub ls_y: i16,
|
|
||||||
pub rs_x: i16,
|
|
||||||
pub rs_y: i16,
|
|
||||||
}
|
|
||||||
|
|
||||||
// GameStream's `buttonFlags | buttonFlags2 << 16` layout (Limelight.h) is bit-identical to
|
// GameStream's `buttonFlags | buttonFlags2 << 16` layout (Limelight.h) is bit-identical to
|
||||||
// punktfunk's native gamepad wire, so source these from the single point of truth in `punktfunk_core`
|
// punktfunk's native gamepad wire, so source these from the single point of truth in `punktfunk_core`
|
||||||
|
|||||||
@@ -242,6 +242,9 @@ pub fn serve(
|
|||||||
// rustls needs a process-wide crypto provider before any TLS config is built.
|
// rustls needs a process-wide crypto provider before any TLS config is built.
|
||||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||||
let native_opts = crate::native::native_serve_opts(&native);
|
let native_opts = crate::native::native_serve_opts(&native);
|
||||||
|
// The hook runner consumes the live event tail for the host's lifetime — spawned BEFORE
|
||||||
|
// `host.started` is emitted so operator hooks observe the full lifecycle (RFC §6).
|
||||||
|
tokio::spawn(crate::hooks::runner());
|
||||||
// Lifecycle events (RFC §4): `host.started` as the serve planes come up; `host.stopping`
|
// Lifecycle events (RFC §4): `host.started` as the serve planes come up; `host.stopping`
|
||||||
// when they wind down (clean end OR error exit) — the ring holds it for a consumer that
|
// when they wind down (clean end OR error exit) — the ring holds it for a consumer that
|
||||||
// reconnects, and a graceful-signal path can move the emit earlier when one exists.
|
// reconnects, and a graceful-signal path can move the emit earlier when one exists.
|
||||||
|
|||||||
@@ -163,6 +163,20 @@ fn run(
|
|||||||
// `video_cap`, since a reconnect at a different resolution needs a freshly-sized output; the
|
// `video_cap`, since a reconnect at a different resolution needs a freshly-sized output; the
|
||||||
// output is released when this capturer drops at stream end (RAII via its keepalive).
|
// output is released when this capturer drops at stream end (RAII via its keepalive).
|
||||||
if crate::config::config().video_source.as_deref() == Some("virtual") {
|
if crate::config::config().video_source.as_deref() == Some("virtual") {
|
||||||
|
// Per-app prep steps (RFC §6): the entry's own `prep` plus a custom library title's,
|
||||||
|
// run synchronously BEFORE the virtual output opens or anything launches (an HDR
|
||||||
|
// toggle / sink switch must land first — and gamescope's nested launch happens inside
|
||||||
|
// `open_gs_virtual_source`). The guard's drop runs the undos at stream end — reverse
|
||||||
|
// order, best-effort, on every exit path including a panic-unwind.
|
||||||
|
let mut prep_cmds = app.map(|a| a.prep.clone()).unwrap_or_default();
|
||||||
|
if let Some(lib_id) = app.and_then(|a| a.library_id.as_deref()) {
|
||||||
|
prep_cmds.extend(crate::library::prep_for(lib_id));
|
||||||
|
}
|
||||||
|
let prep_env = [(
|
||||||
|
"PF_APP_TITLE".to_string(),
|
||||||
|
app.map(|a| a.title.clone()).unwrap_or_default(),
|
||||||
|
)];
|
||||||
|
let _prep = (!prep_cmds.is_empty()).then(|| crate::hooks::run_prep(&prep_cmds, &prep_env));
|
||||||
// Open the virtual-display source: pick the live compositor, normalize the session env
|
// Open the virtual-display source: pick the live compositor, normalize the session env
|
||||||
// (apply_session_env/apply_input_env — gamescope ATTACH/resize + KWin/Mutter retargeting,
|
// (apply_session_env/apply_input_env — gamescope ATTACH/resize + KWin/Mutter retargeting,
|
||||||
// exactly like the native plane), create a virtual output at the client mode, and capture it.
|
// exactly like the native plane), create a virtual output at the client mode, and capture it.
|
||||||
@@ -369,7 +383,7 @@ fn open_gs_virtual_source(
|
|||||||
// capturer follows the display). No-op on Linux (8-bit, and `cfg.hdr` is always false there).
|
// capturer follows the display). No-op on Linux (8-bit, and `cfg.hdr` is always false there).
|
||||||
let capturer = capture::capture_virtual_output(
|
let capturer = capture::capture_virtual_output(
|
||||||
vout,
|
vout,
|
||||||
capture::OutputFormat::resolve(cfg.hdr),
|
capture::OutputFormat::resolve(cfg.hdr, crate::encode::resolved_backend_is_gpu()),
|
||||||
crate::session_plan::CaptureBackend::resolve(),
|
crate::session_plan::CaptureBackend::resolve(),
|
||||||
)
|
)
|
||||||
.context("capture virtual output")?;
|
.context("capture virtual output")?;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -12,15 +12,21 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use punktfunk_core::input::{InputEvent, InputKind};
|
use punktfunk_core::input::{InputEvent, InputKind};
|
||||||
|
|
||||||
/// In-process tag on a key event's `flags`: the VK in `code` is **layout-semantic** (already
|
#[path = "inject/keymap.rs"]
|
||||||
/// resolved under the sending client's keyboard layout — the GameStream/Moonlight convention)
|
mod keymap;
|
||||||
/// rather than the punktfunk-native **US-positional** convention (the physical key's US-layout VK,
|
#[cfg(target_os = "linux")]
|
||||||
/// which every first-party client sends — the client's local layout never touches the wire).
|
pub(crate) use keymap::gs_button_to_evdev;
|
||||||
/// The Windows injector maps semantic VKs through the foreground app's layout and positional VKs
|
pub use keymap::KEY_FLAG_SEMANTIC_VK;
|
||||||
/// through a fixed table; conflating the two is exactly the German y↔z / ö→ü scramble.
|
// vk_to_evdev is consumed by the Linux injectors (kwin/libei/wlr) and — on Windows — only by the
|
||||||
/// Set ONLY by `gamestream::input::decode`; the punktfunk/1 ingest strips it from wire events, so
|
// SendInput mirror test; keep the shared `crate::inject::vk_to_evdev` re-export unconditionally.
|
||||||
/// a network client can never flip the host's key-decoding convention.
|
#[cfg_attr(not(target_os = "linux"), allow(unused_imports))]
|
||||||
pub const KEY_FLAG_SEMANTIC_VK: u32 = 0x8000_0000;
|
pub use keymap::vk_to_evdev;
|
||||||
|
|
||||||
|
/// Device-agnostic dedup for the rich HID-output feedback plane (0xCD), shared by the virtual-pad
|
||||||
|
/// managers ([`uhid_manager`]).
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||||
|
#[path = "inject/hidout_dedup.rs"]
|
||||||
|
pub mod hidout_dedup;
|
||||||
|
|
||||||
/// Injects input events into the host session. Not `Send`: an injector owns compositor
|
/// Injects input events into the host session. Not `Send`: an injector owns compositor
|
||||||
/// resources (a Wayland connection, an xkb state) and lives entirely on the control thread
|
/// resources (a Wayland connection, an xkb state) and lives entirely on the control thread
|
||||||
@@ -29,7 +35,10 @@ pub trait InputInjector {
|
|||||||
fn inject(&mut self, event: &InputEvent) -> Result<()>;
|
fn inject(&mut self, event: &InputEvent) -> Result<()>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Preferred injection backend.
|
/// Preferred injection backend. Which variants exist is **per-OS**: the factory ([`open`]) is a
|
||||||
|
/// single per-target block, so it can only be handed a backend that exists on the target — an
|
||||||
|
/// impossible OS/backend pairing is a compile error, not a runtime `bail!` (plan §2.3).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub enum Backend {
|
pub enum Backend {
|
||||||
/// wlroots virtual pointer + keyboard Wayland protocols — the headless-Sway path.
|
/// wlroots virtual pointer + keyboard Wayland protocols — the headless-Sway path.
|
||||||
@@ -37,77 +46,59 @@ pub enum Backend {
|
|||||||
/// KWin `org_kde_kwin_fake_input` — direct injection, no RemoteDesktop portal / approval dialog
|
/// KWin `org_kde_kwin_fake_input` — direct injection, no RemoteDesktop portal / approval dialog
|
||||||
/// (authorized by the host's `.desktop`). The headless KDE-Desktop path; what krdpserver uses.
|
/// (authorized by the host's `.desktop`). The headless KDE-Desktop path; what krdpserver uses.
|
||||||
KwinFakeInput,
|
KwinFakeInput,
|
||||||
/// libei via `reis` — Wayland-native (RemoteDesktop portal). Not yet implemented.
|
/// libei via `reis` — Wayland-native (RemoteDesktop portal).
|
||||||
Libei,
|
Libei,
|
||||||
/// libei directly against gamescope's own EIS socket (no portal): input lands in the
|
/// libei directly against gamescope's own EIS socket (no portal): input lands in the
|
||||||
/// nested game — the SteamOS-like session.
|
/// nested game — the SteamOS-like session.
|
||||||
GamescopeEi,
|
GamescopeEi,
|
||||||
/// `/dev/uinput` — universal fallback (but invisible to `WLR_LIBINPUT_NO_DEVICES=1`).
|
}
|
||||||
Uinput,
|
|
||||||
|
/// Preferred injection backend. Windows has exactly one path (`SendInput`).
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Backend {
|
||||||
/// Windows `SendInput` (Win32 KeyboardAndMouse) — the Windows host path.
|
/// Windows `SendInput` (Win32 KeyboardAndMouse) — the Windows host path.
|
||||||
SendInput,
|
SendInput,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Preferred injection backend. No injector exists on this platform; [`open`] rejects it.
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Backend {
|
||||||
|
/// Placeholder so the host still builds; the platform has no input injection.
|
||||||
|
Unsupported,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the injector for `backend`. The body is one per-OS block: on each target `backend` can only
|
||||||
|
/// name a backend that platform has, so there are no cross-OS `bail!` arms (plan §2.3).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
pub fn open(backend: Backend) -> Result<Box<dyn InputInjector>> {
|
pub fn open(backend: Backend) -> Result<Box<dyn InputInjector>> {
|
||||||
match backend {
|
match backend {
|
||||||
Backend::WlrVirtual => {
|
Backend::WlrVirtual => Ok(Box::new(wlr::WlrootsInjector::open()?)),
|
||||||
#[cfg(target_os = "linux")]
|
Backend::KwinFakeInput => Ok(Box::new(kwin_fake_input::KwinFakeInjector::open()?)),
|
||||||
{
|
Backend::Libei => Ok(Box::new(
|
||||||
Ok(Box::new(wlr::WlrootsInjector::open()?))
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
{
|
|
||||||
anyhow::bail!("wlroots virtual input requires Linux + a Wayland compositor")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Backend::KwinFakeInput => {
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
Ok(Box::new(kwin_fake_input::KwinFakeInjector::open()?))
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
{
|
|
||||||
anyhow::bail!("KWin fake_input requires Linux + a KWin Wayland session")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Backend::Libei => {
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
Ok(Box::new(
|
|
||||||
libei::LibeiInjector::open_with(libei_ei_source())?,
|
libei::LibeiInjector::open_with(libei_ei_source())?,
|
||||||
))
|
)),
|
||||||
}
|
Backend::GamescopeEi => Ok(Box::new(libei::LibeiInjector::open_with(
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
{
|
|
||||||
anyhow::bail!("libei input requires Linux + a RemoteDesktop portal")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Backend::GamescopeEi => {
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
Ok(Box::new(libei::LibeiInjector::open_with(
|
|
||||||
libei::EiSource::SocketPathFile(crate::vdisplay::gamescope_ei_socket_file()),
|
libei::EiSource::SocketPathFile(crate::vdisplay::gamescope_ei_socket_file()),
|
||||||
)?))
|
)?)),
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "linux"))]
|
}
|
||||||
{
|
|
||||||
anyhow::bail!("gamescope EIS input requires Linux")
|
/// Open the injector for `backend` (Windows: always `SendInput`).
|
||||||
}
|
#[cfg(target_os = "windows")]
|
||||||
}
|
pub fn open(backend: Backend) -> Result<Box<dyn InputInjector>> {
|
||||||
Backend::SendInput => {
|
match backend {
|
||||||
#[cfg(target_os = "windows")]
|
Backend::SendInput => Ok(Box::new(sendinput::SendInputInjector::open()?)),
|
||||||
{
|
|
||||||
Ok(Box::new(sendinput::SendInputInjector::open()?))
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
{
|
|
||||||
anyhow::bail!("SendInput injection requires Windows")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
other => anyhow::bail!("injection backend {other:?} not implemented"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// No input-injection backend exists on this platform.
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||||
|
pub fn open(_backend: Backend) -> Result<Box<dyn InputInjector>> {
|
||||||
|
anyhow::bail!("no input-injection backend on this platform")
|
||||||
|
}
|
||||||
|
|
||||||
/// Pick the injection backend for the current session. gamescope hosts its own EIS server (no
|
/// Pick the injection backend for the current session. gamescope hosts its own EIS server (no
|
||||||
/// portal), so a gamescope session injects directly into it. wlroots/Sway only implements the
|
/// portal), so a gamescope session injects directly into it. wlroots/Sway only implements the
|
||||||
/// ScreenCast portal (no RemoteDesktop), so libei can't run there — use the wlr virtual-input
|
/// ScreenCast portal (no RemoteDesktop), so libei can't run there — use the wlr virtual-input
|
||||||
@@ -115,7 +106,8 @@ pub fn open(backend: Backend) -> Result<Box<dyn InputInjector>> {
|
|||||||
/// dialog — the only headless-capable path; what krdpserver uses), so prefer it there. **GNOME**
|
/// dialog — the only headless-capable path; what krdpserver uses), so prefer it there. **GNOME**
|
||||||
/// has neither fake_input nor the wlr protocols, so it uses libei via the RemoteDesktop portal
|
/// has neither fake_input nor the wlr protocols, so it uses libei via the RemoteDesktop portal
|
||||||
/// (which needs a user to approve, or a pre-seeded grant — not truly headless).
|
/// (which needs a user to approve, or a pre-seeded grant — not truly headless).
|
||||||
/// `PUNKTFUNK_INPUT_BACKEND=wlr|kwin|libei|gamescope|uinput` overrides the auto-detection.
|
/// `PUNKTFUNK_INPUT_BACKEND=wlr|kwin|libei|gamescope` overrides the auto-detection.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
pub fn default_backend() -> Backend {
|
pub fn default_backend() -> Backend {
|
||||||
if let Ok(v) = std::env::var("PUNKTFUNK_INPUT_BACKEND") {
|
if let Ok(v) = std::env::var("PUNKTFUNK_INPUT_BACKEND") {
|
||||||
match v.trim().to_ascii_lowercase().as_str() {
|
match v.trim().to_ascii_lowercase().as_str() {
|
||||||
@@ -125,20 +117,12 @@ pub fn default_backend() -> Backend {
|
|||||||
}
|
}
|
||||||
"libei" | "ei" | "portal" => return Backend::Libei,
|
"libei" | "ei" | "portal" => return Backend::Libei,
|
||||||
"gamescope" | "gamescope-ei" => return Backend::GamescopeEi,
|
"gamescope" | "gamescope-ei" => return Backend::GamescopeEi,
|
||||||
"uinput" => return Backend::Uinput,
|
|
||||||
"sendinput" | "win" | "windows" => return Backend::SendInput,
|
|
||||||
other => tracing::warn!(
|
other => tracing::warn!(
|
||||||
value = other,
|
value = other,
|
||||||
"unknown PUNKTFUNK_INPUT_BACKEND — auto-detecting"
|
"unknown PUNKTFUNK_INPUT_BACKEND — auto-detecting"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
Backend::SendInput
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
{
|
|
||||||
// An explicit compositor pick (set per connect / mid-stream) is the strongest signal.
|
// An explicit compositor pick (set per connect / mid-stream) is the strongest signal.
|
||||||
let compositor = crate::config::config().compositor.clone();
|
let compositor = crate::config::config().compositor.clone();
|
||||||
if let Some(c) = compositor.as_deref() {
|
if let Some(c) = compositor.as_deref() {
|
||||||
@@ -168,135 +152,23 @@ pub fn default_backend() -> Backend {
|
|||||||
} else {
|
} else {
|
||||||
Backend::WlrVirtual
|
Backend::WlrVirtual
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Host-lifetime pointer/keyboard injector running on its OWN thread, fed over a clonable `Send`
|
/// The Windows host has a single injection backend.
|
||||||
/// channel. The injector backend owns non-`Send` compositor state (a Wayland connection / xkb / EIS
|
#[cfg(target_os = "windows")]
|
||||||
/// socket), so it must live on a single thread; both the GameStream control plane and the native
|
pub fn default_backend() -> Backend {
|
||||||
/// punktfunk/1 plane forward their decoded keyboard/mouse events here instead of injecting inline, so
|
Backend::SendInput
|
||||||
/// a slow inject (a portal stall, a desktop switch) never head-blocks the network thread's
|
|
||||||
/// keepalive/retransmit servicing.
|
|
||||||
pub(crate) struct InjectorService {
|
|
||||||
tx: std::sync::mpsc::Sender<InputEvent>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InjectorService {
|
/// No injector on this platform.
|
||||||
pub(crate) fn start() -> InjectorService {
|
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||||
let (tx, rx) = std::sync::mpsc::channel::<InputEvent>();
|
pub fn default_backend() -> Backend {
|
||||||
if let Err(e) = std::thread::Builder::new()
|
Backend::Unsupported
|
||||||
.name("punktfunk-injector".into())
|
|
||||||
.spawn(move || injector_service_thread(rx))
|
|
||||||
{
|
|
||||||
tracing::error!(error = %e, "injector service thread spawn failed — pointer/keyboard input disabled");
|
|
||||||
}
|
|
||||||
InjectorService { tx }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A sender a session/plane forwards its pointer/keyboard events to. Cloned per caller; dropping a
|
|
||||||
/// clone does NOT stop the service (it runs while any sender — incl. the service's own — lives).
|
|
||||||
pub(crate) fn sender(&self) -> std::sync::mpsc::Sender<InputEvent> {
|
|
||||||
self.tx.clone()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Backoff between reopen attempts after the injector backend fails to open or its worker dies, so a
|
#[path = "inject/service.rs"]
|
||||||
/// persistently-unavailable portal isn't hammered once per event.
|
mod service;
|
||||||
const INJECTOR_REOPEN_BACKOFF: std::time::Duration = std::time::Duration::from_secs(2);
|
pub(crate) use service::InjectorService;
|
||||||
|
|
||||||
/// The host-lifetime injector worker: lazily open the pointer/keyboard backend, then inject every
|
|
||||||
/// forwarded event. Reopen (after [`INJECTOR_REOPEN_BACKOFF`]) on open failure, on a backend change
|
|
||||||
/// (input follows the active session), or if the backend's worker dies mid-stream. Exits only when
|
|
||||||
/// every sender has dropped (host shutdown), which drops the injector and closes its portal session.
|
|
||||||
///
|
|
||||||
/// Each wake drains the whole backlog and [`coalesce`]s redundant motion before injecting, so a slow
|
|
||||||
/// backend never builds up a queue of stale relative-mouse/scroll events (latency) — while button,
|
|
||||||
/// key, and absolute-move ordering is preserved exactly.
|
|
||||||
fn injector_service_thread(rx: std::sync::mpsc::Receiver<InputEvent>) {
|
|
||||||
let mut injector: Option<Box<dyn InputInjector>> = None;
|
|
||||||
let mut open_backend: Option<Backend> = None;
|
|
||||||
let mut last_failed: Option<std::time::Instant> = None;
|
|
||||||
while let Ok(first) = rx.recv() {
|
|
||||||
// Drain everything already queued behind `first` so we coalesce a whole burst at once.
|
|
||||||
let mut batch = vec![first];
|
|
||||||
while let Ok(ev) = rx.try_recv() {
|
|
||||||
batch.push(ev);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The resolved input backend (PUNKTFUNK_INPUT_BACKEND, set per connect / mid-stream session
|
|
||||||
// switch) may have changed since we opened. Reopen against it so input FOLLOWS the active
|
|
||||||
// session instead of injecting into a stale, still-warm backend (e.g. the managed gamescope's
|
|
||||||
// EIS socket after the user switched to the KDE desktop).
|
|
||||||
let want = default_backend();
|
|
||||||
if injector.is_some() && open_backend != Some(want) {
|
|
||||||
tracing::info!(
|
|
||||||
?open_backend,
|
|
||||||
?want,
|
|
||||||
"input: backend changed — reopening injector for the active session"
|
|
||||||
);
|
|
||||||
injector = None;
|
|
||||||
last_failed = None; // re-resolve immediately
|
|
||||||
}
|
|
||||||
if injector.is_none() {
|
|
||||||
// Open on the first event; after a failure wait out the backoff before retrying (a few
|
|
||||||
// events drop during setup — acceptable, input is lossy).
|
|
||||||
let ready = last_failed.is_none_or(|t| t.elapsed() >= INJECTOR_REOPEN_BACKOFF);
|
|
||||||
if ready {
|
|
||||||
match open(want) {
|
|
||||||
Ok(i) => {
|
|
||||||
tracing::info!(backend = ?want, "input injector ready (host-lifetime)");
|
|
||||||
injector = Some(i);
|
|
||||||
open_backend = Some(want);
|
|
||||||
last_failed = None;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(error = %format!("{e:#}"), "pointer/keyboard injection unavailable — will retry");
|
|
||||||
last_failed = Some(std::time::Instant::now());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(inj) = injector.as_mut() {
|
|
||||||
for ev in coalesce(batch) {
|
|
||||||
if let Err(e) = inj.inject(&ev) {
|
|
||||||
// The backend's worker (portal session / EIS socket) died — drop it and reopen on
|
|
||||||
// a later event (covers a gamescope EIS socket that respawns with its session).
|
|
||||||
tracing::warn!(error = %format!("{e:#}"), "inject failed — reopening injector");
|
|
||||||
injector = None;
|
|
||||||
open_backend = None;
|
|
||||||
last_failed = Some(std::time::Instant::now());
|
|
||||||
break; // abandon the rest of this batch; the next one reopens
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tracing::debug!("injector service stopped (host shutting down)");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Coalesce a drained burst: sum consecutive relative-mouse deltas and consecutive same-axis scroll
|
|
||||||
/// deltas (identical net effect, far fewer injects), passing buttons, keys, absolute moves, and any
|
|
||||||
/// type change through untouched and in order. Only *adjacent* same-type events merge, so a button
|
|
||||||
/// or key between two moves flushes the accumulated motion first — ordering is never reshuffled.
|
|
||||||
fn coalesce(events: Vec<InputEvent>) -> Vec<InputEvent> {
|
|
||||||
let mut out: Vec<InputEvent> = Vec::with_capacity(events.len());
|
|
||||||
for ev in events {
|
|
||||||
match out.last_mut() {
|
|
||||||
Some(last) if last.kind == InputKind::MouseMove && ev.kind == InputKind::MouseMove => {
|
|
||||||
last.x = last.x.saturating_add(ev.x);
|
|
||||||
last.y = last.y.saturating_add(ev.y);
|
|
||||||
}
|
|
||||||
Some(last)
|
|
||||||
if last.kind == InputKind::MouseScroll
|
|
||||||
&& ev.kind == InputKind::MouseScroll
|
|
||||||
&& last.code == ev.code =>
|
|
||||||
{
|
|
||||||
last.x = last.x.saturating_add(ev.x);
|
|
||||||
}
|
|
||||||
_ => out.push(ev),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How the libei backend reaches its EIS server. KWin goes through the `RemoteDesktop` *portal*
|
/// How the libei backend reaches its EIS server. KWin goes through the `RemoteDesktop` *portal*
|
||||||
/// (with a pre-seeded grant), but GNOME's portal `Start()` needs an interactive approval a
|
/// (with a pre-seeded grant), but GNOME's portal `Start()` needs an interactive approval a
|
||||||
@@ -319,154 +191,6 @@ fn libei_ei_source() -> libei::EiSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map a Windows Virtual-Key code (as sent by Moonlight/GameStream) to a Linux evdev key code.
|
|
||||||
pub fn vk_to_evdev(vk: u8) -> Option<u16> {
|
|
||||||
match vk {
|
|
||||||
// --- Navigation / editing / whitespace ---
|
|
||||||
0x08 => Some(14), // VK_BACK -> KEY_BACKSPACE
|
|
||||||
0x09 => Some(15), // VK_TAB -> KEY_TAB
|
|
||||||
0x0D => Some(28), // VK_RETURN -> KEY_ENTER
|
|
||||||
0x13 => Some(119), // VK_PAUSE -> KEY_PAUSE
|
|
||||||
0x14 => Some(58), // VK_CAPITAL -> KEY_CAPSLOCK
|
|
||||||
0x1B => Some(1), // VK_ESCAPE -> KEY_ESC
|
|
||||||
0x20 => Some(57), // VK_SPACE -> KEY_SPACE
|
|
||||||
0x21 => Some(104), // VK_PRIOR -> KEY_PAGEUP
|
|
||||||
0x22 => Some(109), // VK_NEXT -> KEY_PAGEDOWN
|
|
||||||
0x23 => Some(107), // VK_END -> KEY_END
|
|
||||||
0x24 => Some(102), // VK_HOME -> KEY_HOME
|
|
||||||
0x25 => Some(105), // VK_LEFT -> KEY_LEFT
|
|
||||||
0x26 => Some(103), // VK_UP -> KEY_UP
|
|
||||||
0x27 => Some(106), // VK_RIGHT -> KEY_RIGHT
|
|
||||||
0x28 => Some(108), // VK_DOWN -> KEY_DOWN
|
|
||||||
0x2C => Some(99), // VK_SNAPSHOT -> KEY_SYSRQ
|
|
||||||
0x2D => Some(110), // VK_INSERT -> KEY_INSERT
|
|
||||||
0x2E => Some(111), // VK_DELETE -> KEY_DELETE
|
|
||||||
|
|
||||||
// --- Generic modifiers ---
|
|
||||||
0x10 => Some(42), // VK_SHIFT -> KEY_LEFTSHIFT
|
|
||||||
0x11 => Some(29), // VK_CONTROL -> KEY_LEFTCTRL
|
|
||||||
0x12 => Some(56), // VK_MENU -> KEY_LEFTALT
|
|
||||||
|
|
||||||
// --- Digit row (KEY_0 is 11, KEY_1..KEY_9 are 2..10) ---
|
|
||||||
0x30 => Some(11), // VK_0
|
|
||||||
0x31 => Some(2), // VK_1
|
|
||||||
0x32 => Some(3), // VK_2
|
|
||||||
0x33 => Some(4), // VK_3
|
|
||||||
0x34 => Some(5), // VK_4
|
|
||||||
0x35 => Some(6), // VK_5
|
|
||||||
0x36 => Some(7), // VK_6
|
|
||||||
0x37 => Some(8), // VK_7
|
|
||||||
0x38 => Some(9), // VK_8
|
|
||||||
0x39 => Some(10), // VK_9
|
|
||||||
|
|
||||||
// --- Letters A-Z (NOT sequential in evdev) ---
|
|
||||||
0x41 => Some(30), // A
|
|
||||||
0x42 => Some(48), // B
|
|
||||||
0x43 => Some(46), // C
|
|
||||||
0x44 => Some(32), // D
|
|
||||||
0x45 => Some(18), // E
|
|
||||||
0x46 => Some(33), // F
|
|
||||||
0x47 => Some(34), // G
|
|
||||||
0x48 => Some(35), // H
|
|
||||||
0x49 => Some(23), // I
|
|
||||||
0x4A => Some(36), // J
|
|
||||||
0x4B => Some(37), // K
|
|
||||||
0x4C => Some(38), // L
|
|
||||||
0x4D => Some(50), // M
|
|
||||||
0x4E => Some(49), // N
|
|
||||||
0x4F => Some(24), // O
|
|
||||||
0x50 => Some(25), // P
|
|
||||||
0x51 => Some(16), // Q
|
|
||||||
0x52 => Some(19), // R
|
|
||||||
0x53 => Some(31), // S
|
|
||||||
0x54 => Some(20), // T
|
|
||||||
0x55 => Some(22), // U
|
|
||||||
0x56 => Some(47), // V
|
|
||||||
0x57 => Some(17), // W
|
|
||||||
0x58 => Some(45), // X
|
|
||||||
0x59 => Some(21), // Y
|
|
||||||
0x5A => Some(44), // Z
|
|
||||||
|
|
||||||
// --- Meta / context-menu ---
|
|
||||||
0x5B => Some(125), // VK_LWIN -> KEY_LEFTMETA
|
|
||||||
0x5C => Some(126), // VK_RWIN -> KEY_RIGHTMETA
|
|
||||||
0x5D => Some(127), // VK_APPS -> KEY_COMPOSE
|
|
||||||
|
|
||||||
// --- Numpad ---
|
|
||||||
0x60 => Some(82), // KP0
|
|
||||||
0x61 => Some(79), // KP1
|
|
||||||
0x62 => Some(80), // KP2
|
|
||||||
0x63 => Some(81), // KP3
|
|
||||||
0x64 => Some(75), // KP4
|
|
||||||
0x65 => Some(76), // KP5
|
|
||||||
0x66 => Some(77), // KP6
|
|
||||||
0x67 => Some(71), // KP7
|
|
||||||
0x68 => Some(72), // KP8
|
|
||||||
0x69 => Some(73), // KP9
|
|
||||||
0x6A => Some(55), // VK_MULTIPLY -> KEY_KPASTERISK
|
|
||||||
0x6B => Some(78), // VK_ADD -> KEY_KPPLUS
|
|
||||||
0x6C => Some(96), // VK_SEPARATOR -> KEY_KPENTER
|
|
||||||
0x6D => Some(74), // VK_SUBTRACT -> KEY_KPMINUS
|
|
||||||
0x6E => Some(83), // VK_DECIMAL -> KEY_KPDOT
|
|
||||||
0x6F => Some(98), // VK_DIVIDE -> KEY_KPSLASH
|
|
||||||
|
|
||||||
// --- Function keys (F1..F10 = 59..68, F11/F12 = 87/88) ---
|
|
||||||
0x70 => Some(59),
|
|
||||||
0x71 => Some(60),
|
|
||||||
0x72 => Some(61),
|
|
||||||
0x73 => Some(62),
|
|
||||||
0x74 => Some(63),
|
|
||||||
0x75 => Some(64),
|
|
||||||
0x76 => Some(65),
|
|
||||||
0x77 => Some(66),
|
|
||||||
0x78 => Some(67),
|
|
||||||
0x79 => Some(68),
|
|
||||||
0x7A => Some(87),
|
|
||||||
0x7B => Some(88),
|
|
||||||
|
|
||||||
// --- Locks ---
|
|
||||||
0x90 => Some(69), // VK_NUMLOCK -> KEY_NUMLOCK
|
|
||||||
0x91 => Some(70), // VK_SCROLL -> KEY_SCROLLLOCK
|
|
||||||
|
|
||||||
// --- Left/right modifiers ---
|
|
||||||
0xA0 => Some(42), // VK_LSHIFT -> KEY_LEFTSHIFT
|
|
||||||
0xA1 => Some(54), // VK_RSHIFT -> KEY_RIGHTSHIFT
|
|
||||||
0xA2 => Some(29), // VK_LCONTROL -> KEY_LEFTCTRL
|
|
||||||
0xA3 => Some(97), // VK_RCONTROL -> KEY_RIGHTCTRL
|
|
||||||
0xA4 => Some(56), // VK_LMENU -> KEY_LEFTALT
|
|
||||||
0xA5 => Some(100), // VK_RMENU -> KEY_RIGHTALT
|
|
||||||
|
|
||||||
// --- OEM punctuation (US layout) ---
|
|
||||||
0xBA => Some(39), // VK_OEM_1 -> KEY_SEMICOLON
|
|
||||||
0xBB => Some(13), // VK_OEM_PLUS -> KEY_EQUAL
|
|
||||||
0xBC => Some(51), // VK_OEM_COMMA -> KEY_COMMA
|
|
||||||
0xBD => Some(12), // VK_OEM_MINUS -> KEY_MINUS
|
|
||||||
0xBE => Some(52), // VK_OEM_PERIOD -> KEY_DOT
|
|
||||||
0xBF => Some(53), // VK_OEM_2 -> KEY_SLASH
|
|
||||||
0xC0 => Some(41), // VK_OEM_3 -> KEY_GRAVE
|
|
||||||
0xDB => Some(26), // VK_OEM_4 -> KEY_LEFTBRACE
|
|
||||||
0xDC => Some(43), // VK_OEM_5 -> KEY_BACKSLASH
|
|
||||||
0xDD => Some(27), // VK_OEM_6 -> KEY_RIGHTBRACE
|
|
||||||
0xDE => Some(40), // VK_OEM_7 -> KEY_APOSTROPHE
|
|
||||||
0xE2 => Some(86), // VK_OEM_102 -> KEY_102ND
|
|
||||||
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Map a GameStream mouse button id (1=left … 5=X2) to a Linux evdev `BTN_*` code.
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
fn gs_button_to_evdev(b: u32) -> Option<u32> {
|
|
||||||
Some(match b {
|
|
||||||
1 => 0x110, // BTN_LEFT
|
|
||||||
2 => 0x112, // BTN_MIDDLE
|
|
||||||
3 => 0x111, // BTN_RIGHT
|
|
||||||
4 => 0x113, // BTN_SIDE (X1)
|
|
||||||
5 => 0x114, // BTN_EXTRA (X2)
|
|
||||||
_ => return None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Goal-1 stage 6: Linux UHID/uinput/libei/wlr backends under `inject/linux/`, the Windows UMDF/SendInput
|
// Goal-1 stage 6: Linux UHID/uinput/libei/wlr backends under `inject/linux/`, the Windows UMDF/SendInput
|
||||||
// backends under `inject/windows/`, and the transport-independent HID codecs under `inject/proto/`;
|
// backends under `inject/windows/`, and the transport-independent HID codecs under `inject/proto/`;
|
||||||
// `#[path]` keeps every `crate::inject::*` module name flat.
|
// `#[path]` keeps every `crate::inject::*` module name flat.
|
||||||
@@ -597,7 +321,7 @@ pub mod gamepad {
|
|||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
GamepadManager
|
GamepadManager
|
||||||
}
|
}
|
||||||
pub fn handle(&mut self, _ev: &crate::gamestream::gamepad::GamepadEvent) {}
|
pub fn handle(&mut self, _ev: &punktfunk_core::input::GamepadEvent) {}
|
||||||
pub fn pump_rumble(&mut self, _send: impl FnMut(u16, u16, u16)) {}
|
pub fn pump_rumble(&mut self, _send: impl FnMut(u16, u16, u16)) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -613,57 +337,3 @@ mod sendinput;
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[path = "inject/linux/wlr.rs"]
|
#[path = "inject/linux/wlr.rs"]
|
||||||
mod wlr;
|
mod wlr;
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn mk(kind: InputKind, code: u32, x: i32, y: i32) -> InputEvent {
|
|
||||||
InputEvent {
|
|
||||||
kind,
|
|
||||||
_pad: [0; 3],
|
|
||||||
code,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
flags: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn coalesce_sums_adjacent_motion_and_preserves_order() {
|
|
||||||
let events = vec![
|
|
||||||
mk(InputKind::MouseMove, 0, 1, 2),
|
|
||||||
mk(InputKind::MouseMove, 0, 3, -1), // → summed with the previous move
|
|
||||||
mk(InputKind::KeyDown, 30, 0, 0), // flushes the move, passes through verbatim
|
|
||||||
mk(InputKind::MouseMove, 0, 5, 5), // a NEW run after the key (not merged across it)
|
|
||||||
mk(InputKind::MouseScroll, 0, 1, 0),
|
|
||||||
mk(InputKind::MouseScroll, 0, 2, 0), // same axis (code 0) → summed
|
|
||||||
mk(InputKind::MouseScroll, 1, 1, 0), // different axis (code 1) → separate
|
|
||||||
];
|
|
||||||
let out = coalesce(events);
|
|
||||||
assert_eq!(out.len(), 5);
|
|
||||||
assert_eq!(
|
|
||||||
(out[0].kind, out[0].x, out[0].y),
|
|
||||||
(InputKind::MouseMove, 4, 1)
|
|
||||||
);
|
|
||||||
assert_eq!(out[1].kind, InputKind::KeyDown);
|
|
||||||
assert_eq!(
|
|
||||||
(out[2].kind, out[2].x, out[2].y),
|
|
||||||
(InputKind::MouseMove, 5, 5)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
(out[3].kind, out[3].code, out[3].x),
|
|
||||||
(InputKind::MouseScroll, 0, 3)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
(out[4].kind, out[4].code, out[4].x),
|
|
||||||
(InputKind::MouseScroll, 1, 1)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn coalesce_handles_empty_and_singleton() {
|
|
||||||
assert!(coalesce(vec![]).is_empty());
|
|
||||||
assert_eq!(coalesce(vec![mk(InputKind::MouseMove, 0, 7, 8)]).len(), 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
//! Per-pad dedup for the rich HID-output feedback plane (0xCD), carved out of `dualsense_proto`
|
||||||
|
//! (plan §W4 — it is device-agnostic, shared by the DualSense/DS4/Deck managers via
|
||||||
|
//! [`crate::inject::uhid_manager`], not DualSense-specific). A game bundles rumble + lightbar +
|
||||||
|
//! LEDs + adaptive triggers into one output report, so a merely-rumbling pad re-sends unchanged
|
||||||
|
//! rich state every report; this forwards only genuine changes (one-shot pulses always fire).
|
||||||
|
|
||||||
|
use punktfunk_core::quic::HidOutput;
|
||||||
|
|
||||||
|
/// Per-pad dedup for the DualSense HID-output feedback plane (0xCD). A game's DualSense output report
|
||||||
|
/// bundles rumble + lightbar + player-LEDs + adaptive-triggers into one report, so a pad that is
|
||||||
|
/// merely *rumbling* re-sends its (unchanged) lightbar / LED / trigger state on every output report.
|
||||||
|
/// The managers already dedup rumble; this does the same for the rich [`HidOutput`] feedback so the
|
||||||
|
/// 0xCD plane carries only genuine changes. State (`Led` / `PlayerLeds` / `Trigger`) is deduped by
|
||||||
|
/// value; a one-shot `TrackpadHaptic` pulse is always forwarded (each pulse must fire).
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct HidoutDedup {
|
||||||
|
led: Option<(u8, u8, u8)>,
|
||||||
|
player_leds: Option<u8>,
|
||||||
|
/// Last-forwarded adaptive-trigger effect per side: `[0]` = L2, `[1]` = R2.
|
||||||
|
trigger: [Option<Vec<u8>>; 2],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HidoutDedup {
|
||||||
|
/// Forget all remembered state — call when a pad is created or unplugged so the first feedback
|
||||||
|
/// after a (re)connect is always forwarded.
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
*self = HidoutDedup::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `h` should be forwarded: `true` for a genuine change (remembering the new value) or a
|
||||||
|
/// one-shot pulse; `false` if it repeats the last-forwarded value for its kind.
|
||||||
|
pub fn should_forward(&mut self, h: &HidOutput) -> bool {
|
||||||
|
match h {
|
||||||
|
HidOutput::Led { r, g, b, .. } => {
|
||||||
|
let v = Some((*r, *g, *b));
|
||||||
|
if self.led == v {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
self.led = v;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HidOutput::PlayerLeds { bits, .. } => {
|
||||||
|
let v = Some(*bits);
|
||||||
|
if self.player_leds == v {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
self.player_leds = v;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HidOutput::Trigger { which, effect, .. } => {
|
||||||
|
let slot = (*which as usize).min(1);
|
||||||
|
if self.trigger[slot].as_deref() == Some(effect.as_slice()) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
self.trigger[slot] = Some(effect.clone());
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// One-shot haptic pulse (Steam voice-coil) — state-less, always fires.
|
||||||
|
HidOutput::TrackpadHaptic { .. } => true,
|
||||||
|
// Raw as-is passthrough reports must NEVER dedup: the physical device's firmware
|
||||||
|
// watchdogs RELY on identical periodic refreshes (Triton rumble re-sent every ~40 ms
|
||||||
|
// against a ~50 ms safety timeout, lizard-off every ~3 s) — dropping a repeat would
|
||||||
|
// silence the motors / re-enable lizard mode on the real controller.
|
||||||
|
HidOutput::HidRaw { .. } => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// `HidoutDedup` forwards a value once, drops exact repeats, re-forwards a change, tracks the two
|
||||||
|
/// trigger sides independently, never dedups one-shot haptic pulses, and re-arms after `clear`.
|
||||||
|
#[test]
|
||||||
|
fn hidout_dedup_forwards_only_changes() {
|
||||||
|
let mut d = HidoutDedup::default();
|
||||||
|
let led = |r| HidOutput::Led {
|
||||||
|
pad: 0,
|
||||||
|
r,
|
||||||
|
g: 0,
|
||||||
|
b: 0,
|
||||||
|
};
|
||||||
|
// First value forwards; an exact repeat is dropped; a change forwards again.
|
||||||
|
assert!(d.should_forward(&led(10)));
|
||||||
|
assert!(!d.should_forward(&led(10)));
|
||||||
|
assert!(d.should_forward(&led(20)));
|
||||||
|
|
||||||
|
// Player LEDs dedup on their own field, independent of the lightbar.
|
||||||
|
let pl = |bits| HidOutput::PlayerLeds { pad: 0, bits };
|
||||||
|
assert!(d.should_forward(&pl(0b101)));
|
||||||
|
assert!(!d.should_forward(&pl(0b101)));
|
||||||
|
assert!(!d.should_forward(&led(20))); // lightbar still unchanged
|
||||||
|
|
||||||
|
// The two adaptive triggers (L2=0, R2=1) are tracked separately.
|
||||||
|
let trig = |which, byte| HidOutput::Trigger {
|
||||||
|
pad: 0,
|
||||||
|
which,
|
||||||
|
effect: vec![byte, 0, 0],
|
||||||
|
};
|
||||||
|
assert!(d.should_forward(&trig(0, 1)));
|
||||||
|
assert!(d.should_forward(&trig(1, 1))); // same bytes, other side → still forwards
|
||||||
|
assert!(!d.should_forward(&trig(0, 1)));
|
||||||
|
assert!(d.should_forward(&trig(0, 2))); // L2 effect changed
|
||||||
|
|
||||||
|
// One-shot haptic pulses are never deduped.
|
||||||
|
let haptic = HidOutput::TrackpadHaptic {
|
||||||
|
pad: 0,
|
||||||
|
side: 0,
|
||||||
|
amplitude: 1,
|
||||||
|
period: 2,
|
||||||
|
count: 3,
|
||||||
|
};
|
||||||
|
assert!(d.should_forward(&haptic));
|
||||||
|
assert!(d.should_forward(&haptic));
|
||||||
|
|
||||||
|
// `clear` re-arms every kind.
|
||||||
|
d.clear();
|
||||||
|
assert!(d.should_forward(&led(20)));
|
||||||
|
assert!(d.should_forward(&pl(0b101)));
|
||||||
|
assert!(d.should_forward(&trig(0, 2)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
//! Key/button mapping tables (plan §W4, carved out of the inject facade): the Windows Virtual-Key
|
||||||
|
//! → Linux-evdev keyboard map (mirrored bit-for-bit by the Windows SendInput positional table), the
|
||||||
|
//! GameStream mouse-button → evdev `BTN_*` map, and the in-process semantic-VK flag. Pure lookup
|
||||||
|
//! tables — no state, no OS handles.
|
||||||
|
|
||||||
|
/// In-process tag on a key event's `flags`: the VK in `code` is **layout-semantic** (already
|
||||||
|
/// resolved under the sending client's keyboard layout — the GameStream/Moonlight convention)
|
||||||
|
/// rather than the punktfunk-native **US-positional** convention (the physical key's US-layout VK,
|
||||||
|
/// which every first-party client sends — the client's local layout never touches the wire).
|
||||||
|
/// The Windows injector maps semantic VKs through the foreground app's layout and positional VKs
|
||||||
|
/// through a fixed table; conflating the two is exactly the German y↔z / ö→ü scramble.
|
||||||
|
/// Set ONLY by `gamestream::input::decode`; the punktfunk/1 ingest strips it from wire events, so
|
||||||
|
/// a network client can never flip the host's key-decoding convention.
|
||||||
|
pub const KEY_FLAG_SEMANTIC_VK: u32 = 0x8000_0000;
|
||||||
|
|
||||||
|
/// Map a Windows Virtual-Key code (as sent by Moonlight/GameStream) to a Linux evdev key code.
|
||||||
|
pub fn vk_to_evdev(vk: u8) -> Option<u16> {
|
||||||
|
match vk {
|
||||||
|
// --- Navigation / editing / whitespace ---
|
||||||
|
0x08 => Some(14), // VK_BACK -> KEY_BACKSPACE
|
||||||
|
0x09 => Some(15), // VK_TAB -> KEY_TAB
|
||||||
|
0x0D => Some(28), // VK_RETURN -> KEY_ENTER
|
||||||
|
0x13 => Some(119), // VK_PAUSE -> KEY_PAUSE
|
||||||
|
0x14 => Some(58), // VK_CAPITAL -> KEY_CAPSLOCK
|
||||||
|
0x1B => Some(1), // VK_ESCAPE -> KEY_ESC
|
||||||
|
0x20 => Some(57), // VK_SPACE -> KEY_SPACE
|
||||||
|
0x21 => Some(104), // VK_PRIOR -> KEY_PAGEUP
|
||||||
|
0x22 => Some(109), // VK_NEXT -> KEY_PAGEDOWN
|
||||||
|
0x23 => Some(107), // VK_END -> KEY_END
|
||||||
|
0x24 => Some(102), // VK_HOME -> KEY_HOME
|
||||||
|
0x25 => Some(105), // VK_LEFT -> KEY_LEFT
|
||||||
|
0x26 => Some(103), // VK_UP -> KEY_UP
|
||||||
|
0x27 => Some(106), // VK_RIGHT -> KEY_RIGHT
|
||||||
|
0x28 => Some(108), // VK_DOWN -> KEY_DOWN
|
||||||
|
0x2C => Some(99), // VK_SNAPSHOT -> KEY_SYSRQ
|
||||||
|
0x2D => Some(110), // VK_INSERT -> KEY_INSERT
|
||||||
|
0x2E => Some(111), // VK_DELETE -> KEY_DELETE
|
||||||
|
|
||||||
|
// --- Generic modifiers ---
|
||||||
|
0x10 => Some(42), // VK_SHIFT -> KEY_LEFTSHIFT
|
||||||
|
0x11 => Some(29), // VK_CONTROL -> KEY_LEFTCTRL
|
||||||
|
0x12 => Some(56), // VK_MENU -> KEY_LEFTALT
|
||||||
|
|
||||||
|
// --- Digit row (KEY_0 is 11, KEY_1..KEY_9 are 2..10) ---
|
||||||
|
0x30 => Some(11), // VK_0
|
||||||
|
0x31 => Some(2), // VK_1
|
||||||
|
0x32 => Some(3), // VK_2
|
||||||
|
0x33 => Some(4), // VK_3
|
||||||
|
0x34 => Some(5), // VK_4
|
||||||
|
0x35 => Some(6), // VK_5
|
||||||
|
0x36 => Some(7), // VK_6
|
||||||
|
0x37 => Some(8), // VK_7
|
||||||
|
0x38 => Some(9), // VK_8
|
||||||
|
0x39 => Some(10), // VK_9
|
||||||
|
|
||||||
|
// --- Letters A-Z (NOT sequential in evdev) ---
|
||||||
|
0x41 => Some(30), // A
|
||||||
|
0x42 => Some(48), // B
|
||||||
|
0x43 => Some(46), // C
|
||||||
|
0x44 => Some(32), // D
|
||||||
|
0x45 => Some(18), // E
|
||||||
|
0x46 => Some(33), // F
|
||||||
|
0x47 => Some(34), // G
|
||||||
|
0x48 => Some(35), // H
|
||||||
|
0x49 => Some(23), // I
|
||||||
|
0x4A => Some(36), // J
|
||||||
|
0x4B => Some(37), // K
|
||||||
|
0x4C => Some(38), // L
|
||||||
|
0x4D => Some(50), // M
|
||||||
|
0x4E => Some(49), // N
|
||||||
|
0x4F => Some(24), // O
|
||||||
|
0x50 => Some(25), // P
|
||||||
|
0x51 => Some(16), // Q
|
||||||
|
0x52 => Some(19), // R
|
||||||
|
0x53 => Some(31), // S
|
||||||
|
0x54 => Some(20), // T
|
||||||
|
0x55 => Some(22), // U
|
||||||
|
0x56 => Some(47), // V
|
||||||
|
0x57 => Some(17), // W
|
||||||
|
0x58 => Some(45), // X
|
||||||
|
0x59 => Some(21), // Y
|
||||||
|
0x5A => Some(44), // Z
|
||||||
|
|
||||||
|
// --- Meta / context-menu ---
|
||||||
|
0x5B => Some(125), // VK_LWIN -> KEY_LEFTMETA
|
||||||
|
0x5C => Some(126), // VK_RWIN -> KEY_RIGHTMETA
|
||||||
|
0x5D => Some(127), // VK_APPS -> KEY_COMPOSE
|
||||||
|
|
||||||
|
// --- Numpad ---
|
||||||
|
0x60 => Some(82), // KP0
|
||||||
|
0x61 => Some(79), // KP1
|
||||||
|
0x62 => Some(80), // KP2
|
||||||
|
0x63 => Some(81), // KP3
|
||||||
|
0x64 => Some(75), // KP4
|
||||||
|
0x65 => Some(76), // KP5
|
||||||
|
0x66 => Some(77), // KP6
|
||||||
|
0x67 => Some(71), // KP7
|
||||||
|
0x68 => Some(72), // KP8
|
||||||
|
0x69 => Some(73), // KP9
|
||||||
|
0x6A => Some(55), // VK_MULTIPLY -> KEY_KPASTERISK
|
||||||
|
0x6B => Some(78), // VK_ADD -> KEY_KPPLUS
|
||||||
|
0x6C => Some(96), // VK_SEPARATOR -> KEY_KPENTER
|
||||||
|
0x6D => Some(74), // VK_SUBTRACT -> KEY_KPMINUS
|
||||||
|
0x6E => Some(83), // VK_DECIMAL -> KEY_KPDOT
|
||||||
|
0x6F => Some(98), // VK_DIVIDE -> KEY_KPSLASH
|
||||||
|
|
||||||
|
// --- Function keys (F1..F10 = 59..68, F11/F12 = 87/88) ---
|
||||||
|
0x70 => Some(59),
|
||||||
|
0x71 => Some(60),
|
||||||
|
0x72 => Some(61),
|
||||||
|
0x73 => Some(62),
|
||||||
|
0x74 => Some(63),
|
||||||
|
0x75 => Some(64),
|
||||||
|
0x76 => Some(65),
|
||||||
|
0x77 => Some(66),
|
||||||
|
0x78 => Some(67),
|
||||||
|
0x79 => Some(68),
|
||||||
|
0x7A => Some(87),
|
||||||
|
0x7B => Some(88),
|
||||||
|
|
||||||
|
// --- Locks ---
|
||||||
|
0x90 => Some(69), // VK_NUMLOCK -> KEY_NUMLOCK
|
||||||
|
0x91 => Some(70), // VK_SCROLL -> KEY_SCROLLLOCK
|
||||||
|
|
||||||
|
// --- Left/right modifiers ---
|
||||||
|
0xA0 => Some(42), // VK_LSHIFT -> KEY_LEFTSHIFT
|
||||||
|
0xA1 => Some(54), // VK_RSHIFT -> KEY_RIGHTSHIFT
|
||||||
|
0xA2 => Some(29), // VK_LCONTROL -> KEY_LEFTCTRL
|
||||||
|
0xA3 => Some(97), // VK_RCONTROL -> KEY_RIGHTCTRL
|
||||||
|
0xA4 => Some(56), // VK_LMENU -> KEY_LEFTALT
|
||||||
|
0xA5 => Some(100), // VK_RMENU -> KEY_RIGHTALT
|
||||||
|
|
||||||
|
// --- OEM punctuation (US layout) ---
|
||||||
|
0xBA => Some(39), // VK_OEM_1 -> KEY_SEMICOLON
|
||||||
|
0xBB => Some(13), // VK_OEM_PLUS -> KEY_EQUAL
|
||||||
|
0xBC => Some(51), // VK_OEM_COMMA -> KEY_COMMA
|
||||||
|
0xBD => Some(12), // VK_OEM_MINUS -> KEY_MINUS
|
||||||
|
0xBE => Some(52), // VK_OEM_PERIOD -> KEY_DOT
|
||||||
|
0xBF => Some(53), // VK_OEM_2 -> KEY_SLASH
|
||||||
|
0xC0 => Some(41), // VK_OEM_3 -> KEY_GRAVE
|
||||||
|
0xDB => Some(26), // VK_OEM_4 -> KEY_LEFTBRACE
|
||||||
|
0xDC => Some(43), // VK_OEM_5 -> KEY_BACKSLASH
|
||||||
|
0xDD => Some(27), // VK_OEM_6 -> KEY_RIGHTBRACE
|
||||||
|
0xDE => Some(40), // VK_OEM_7 -> KEY_APOSTROPHE
|
||||||
|
0xE2 => Some(86), // VK_OEM_102 -> KEY_102ND
|
||||||
|
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a GameStream mouse button id (1=left … 5=X2) to a Linux evdev `BTN_*` code.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub(crate) fn gs_button_to_evdev(b: u32) -> Option<u32> {
|
||||||
|
Some(match b {
|
||||||
|
1 => 0x110, // BTN_LEFT
|
||||||
|
2 => 0x112, // BTN_MIDDLE
|
||||||
|
3 => 0x111, // BTN_RIGHT
|
||||||
|
4 => 0x113, // BTN_SIDE (X1)
|
||||||
|
5 => 0x114, // BTN_EXTRA (X2)
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -265,7 +265,7 @@ impl PadProto for DsLinuxProto {
|
|||||||
|
|
||||||
/// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (those
|
/// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (those
|
||||||
/// come on the rich-input plane and must survive a button-only frame).
|
/// come on the rich-input plane and must survive a button-only frame).
|
||||||
fn merge_frame(&self, prev: &DsState, f: &crate::gamestream::gamepad::GamepadFrame) -> DsState {
|
fn merge_frame(&self, prev: &DsState, f: &punktfunk_core::input::GamepadFrame) -> DsState {
|
||||||
// Steam back grips have no DualSense slot — fold them onto standard buttons per the
|
// Steam back grips have no DualSense slot — fold them onto standard buttons per the
|
||||||
// configured policy (default drop) so they aren't silently lost.
|
// configured policy (default drop) so they aren't silently lost.
|
||||||
let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
|
let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
|
||||||
@@ -355,7 +355,7 @@ impl PadProto for DsEdgeLinuxProto {
|
|||||||
/// Merge buttons/sticks/triggers from the frame, preserving the rich-plane fields — like the
|
/// Merge buttons/sticks/triggers from the frame, preserving the rich-plane fields — like the
|
||||||
/// plain DualSense, EXCEPT the wire paddles are not folded away: they land on the Edge's own
|
/// plain DualSense, EXCEPT the wire paddles are not folded away: they land on the Edge's own
|
||||||
/// `buttons[2]` bits (rebuilt from every button frame, so no extra persistence).
|
/// `buttons[2]` bits (rebuilt from every button frame, so no extra persistence).
|
||||||
fn merge_frame(&self, prev: &DsState, f: &crate::gamestream::gamepad::GamepadFrame) -> DsState {
|
fn merge_frame(&self, prev: &DsState, f: &punktfunk_core::input::GamepadFrame) -> DsState {
|
||||||
let mut s = DsState::from_gamepad(
|
let mut s = DsState::from_gamepad(
|
||||||
f.buttons,
|
f.buttons,
|
||||||
f.ls_x,
|
f.ls_x,
|
||||||
|
|||||||
@@ -335,7 +335,7 @@ impl PadProto for Ds4LinuxProto {
|
|||||||
|
|
||||||
/// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (those
|
/// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (those
|
||||||
/// arrive on the rich-input plane and must survive a button-only frame).
|
/// arrive on the rich-input plane and must survive a button-only frame).
|
||||||
fn merge_frame(&self, prev: &DsState, f: &crate::gamestream::gamepad::GamepadFrame) -> DsState {
|
fn merge_frame(&self, prev: &DsState, f: &punktfunk_core::input::GamepadFrame) -> DsState {
|
||||||
// Steam back grips have no DS4 slot — fold them onto standard buttons per the configured
|
// Steam back grips have no DS4 slot — fold them onto standard buttons per the configured
|
||||||
// policy (default drop) so they aren't silently lost.
|
// policy (default drop) so they aren't silently lost.
|
||||||
let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
|
let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//! 360 pad", `045e:028e`) so SDL/Steam/Proton match their built-in mapping with zero
|
//! 360 pad", `045e:028e`) so SDL/Steam/Proton match their built-in mapping with zero
|
||||||
//! configuration — exactly what Sunshine emulates. One [`VirtualPad`] per attached client
|
//! configuration — exactly what Sunshine emulates. One [`VirtualPad`] per attached client
|
||||||
//! controller, managed by [`GamepadManager`] from decoded
|
//! controller, managed by [`GamepadManager`] from decoded
|
||||||
//! [`GamepadFrame`](crate::gamestream::gamepad::GamepadFrame)s.
|
//! [`GamepadFrame`](punktfunk_core::input::GamepadFrame)s.
|
||||||
//!
|
//!
|
||||||
//! Rumble flows the *other* way on the same fd: games upload force-feedback effects
|
//! Rumble flows the *other* way on the same fd: games upload force-feedback effects
|
||||||
//! (`EV_UINPUT`/`UI_FF_UPLOAD` → `UI_BEGIN/END_FF_UPLOAD` ioctls) and trigger them with
|
//! (`EV_UINPUT`/`UI_FF_UPLOAD` → `UI_BEGIN/END_FF_UPLOAD` ioctls) and trigger them with
|
||||||
@@ -18,9 +18,10 @@
|
|||||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use crate::gamestream::gamepad::{self, GamepadFrame, MAX_PADS};
|
use crate::gamestream::gamepad;
|
||||||
use crate::inject::pad_slots::PadSlots;
|
use crate::inject::pad_slots::PadSlots;
|
||||||
use anyhow::{bail, Result};
|
use anyhow::{bail, Result};
|
||||||
|
use punktfunk_core::input::{GamepadFrame, MAX_PADS};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::os::fd::{AsRawFd, OwnedFd};
|
use std::os::fd::{AsRawFd, OwnedFd};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
@@ -582,8 +583,8 @@ impl GamepadManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Handle one decoded controller event (create/destroy by mask, then apply state).
|
/// Handle one decoded controller event (create/destroy by mask, then apply state).
|
||||||
pub fn handle(&mut self, ev: &crate::gamestream::gamepad::GamepadEvent) {
|
pub fn handle(&mut self, ev: &punktfunk_core::input::GamepadEvent) {
|
||||||
use crate::gamestream::gamepad::GamepadEvent;
|
use punktfunk_core::input::GamepadEvent;
|
||||||
match ev {
|
match ev {
|
||||||
GamepadEvent::Arrival { index, kind, .. } => {
|
GamepadEvent::Arrival { index, kind, .. } => {
|
||||||
tracing::info!(index, kind, "controller arrival ({})", self.slots.label());
|
tracing::info!(index, kind, "controller arrival ({})", self.slots.label());
|
||||||
|
|||||||
@@ -402,7 +402,7 @@ impl PadProto for SteamProto {
|
|||||||
fn merge_frame(
|
fn merge_frame(
|
||||||
&self,
|
&self,
|
||||||
prev: &SteamState,
|
prev: &SteamState,
|
||||||
f: &crate::gamestream::gamepad::GamepadFrame,
|
f: &punktfunk_core::input::GamepadFrame,
|
||||||
) -> SteamState {
|
) -> SteamState {
|
||||||
let mut s = SteamState::from_gamepad(
|
let mut s = SteamState::from_gamepad(
|
||||||
f.buttons,
|
f.buttons,
|
||||||
@@ -511,7 +511,7 @@ impl PadProto for ScProto {
|
|||||||
fn merge_frame(
|
fn merge_frame(
|
||||||
&self,
|
&self,
|
||||||
prev: &SteamState,
|
prev: &SteamState,
|
||||||
f: &crate::gamestream::gamepad::GamepadFrame,
|
f: &punktfunk_core::input::GamepadFrame,
|
||||||
) -> SteamState {
|
) -> SteamState {
|
||||||
use punktfunk_core::input::gamepad as gs;
|
use punktfunk_core::input::gamepad as gs;
|
||||||
let native = f.buttons & (gs::BTN_PADDLE1 | gs::BTN_PADDLE2);
|
let native = f.buttons & (gs::BTN_PADDLE1 | gs::BTN_PADDLE2);
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ impl PadProto for TritonProto {
|
|||||||
fn merge_frame(
|
fn merge_frame(
|
||||||
&self,
|
&self,
|
||||||
prev: &TritonState,
|
prev: &TritonState,
|
||||||
f: &crate::gamestream::gamepad::GamepadFrame,
|
f: &punktfunk_core::input::GamepadFrame,
|
||||||
) -> TritonState {
|
) -> TritonState {
|
||||||
let mut s = TritonState::from_gamepad(
|
let mut s = TritonState::from_gamepad(
|
||||||
f.buttons,
|
f.buttons,
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ impl PadProto for SwitchProProto {
|
|||||||
fn merge_frame(
|
fn merge_frame(
|
||||||
&self,
|
&self,
|
||||||
prev: &SwitchState,
|
prev: &SwitchState,
|
||||||
f: &crate::gamestream::gamepad::GamepadFrame,
|
f: &punktfunk_core::input::GamepadFrame,
|
||||||
) -> SwitchState {
|
) -> SwitchState {
|
||||||
let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
|
let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
|
||||||
let mut s = SwitchState::from_gamepad(
|
let mut s = SwitchState::from_gamepad(
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
//! Shared virtual-pad slot table + creation lifecycle, used by every backend manager (Linux
|
//! Shared virtual-pad slot table + creation lifecycle, used by every backend manager (Linux
|
||||||
//! uinput/uhid, Windows XUSB/UMDF). See [`PadSlots`].
|
//! uinput/uhid, Windows XUSB/UMDF). See [`PadSlots`].
|
||||||
|
|
||||||
use crate::gamestream::gamepad::MAX_PADS;
|
|
||||||
use crate::inject::pad_gate::PadGate;
|
use crate::inject::pad_gate::PadGate;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use punktfunk_core::input::MAX_PADS;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
// The unplug sweep walks a u16 `active_mask` (the wire type); every slot must have a bit.
|
// The unplug sweep walks a u16 `active_mask` (the wire type); every slot must have a bit.
|
||||||
|
|||||||
@@ -535,124 +535,10 @@ pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-pad dedup for the DualSense HID-output feedback plane (0xCD). A game's DualSense output report
|
|
||||||
/// bundles rumble + lightbar + player-LEDs + adaptive-triggers into one report, so a pad that is
|
|
||||||
/// merely *rumbling* re-sends its (unchanged) lightbar / LED / trigger state on every output report.
|
|
||||||
/// The managers already dedup rumble; this does the same for the rich [`HidOutput`] feedback so the
|
|
||||||
/// 0xCD plane carries only genuine changes. State (`Led` / `PlayerLeds` / `Trigger`) is deduped by
|
|
||||||
/// value; a one-shot `TrackpadHaptic` pulse is always forwarded (each pulse must fire).
|
|
||||||
#[derive(Clone, Default)]
|
|
||||||
pub struct HidoutDedup {
|
|
||||||
led: Option<(u8, u8, u8)>,
|
|
||||||
player_leds: Option<u8>,
|
|
||||||
/// Last-forwarded adaptive-trigger effect per side: `[0]` = L2, `[1]` = R2.
|
|
||||||
trigger: [Option<Vec<u8>>; 2],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HidoutDedup {
|
|
||||||
/// Forget all remembered state — call when a pad is created or unplugged so the first feedback
|
|
||||||
/// after a (re)connect is always forwarded.
|
|
||||||
pub fn clear(&mut self) {
|
|
||||||
*self = HidoutDedup::default();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether `h` should be forwarded: `true` for a genuine change (remembering the new value) or a
|
|
||||||
/// one-shot pulse; `false` if it repeats the last-forwarded value for its kind.
|
|
||||||
pub fn should_forward(&mut self, h: &HidOutput) -> bool {
|
|
||||||
match h {
|
|
||||||
HidOutput::Led { r, g, b, .. } => {
|
|
||||||
let v = Some((*r, *g, *b));
|
|
||||||
if self.led == v {
|
|
||||||
false
|
|
||||||
} else {
|
|
||||||
self.led = v;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
HidOutput::PlayerLeds { bits, .. } => {
|
|
||||||
let v = Some(*bits);
|
|
||||||
if self.player_leds == v {
|
|
||||||
false
|
|
||||||
} else {
|
|
||||||
self.player_leds = v;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
HidOutput::Trigger { which, effect, .. } => {
|
|
||||||
let slot = (*which as usize).min(1);
|
|
||||||
if self.trigger[slot].as_deref() == Some(effect.as_slice()) {
|
|
||||||
false
|
|
||||||
} else {
|
|
||||||
self.trigger[slot] = Some(effect.clone());
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// One-shot haptic pulse (Steam voice-coil) — state-less, always fires.
|
|
||||||
HidOutput::TrackpadHaptic { .. } => true,
|
|
||||||
// Raw as-is passthrough reports must NEVER dedup: the physical device's firmware
|
|
||||||
// watchdogs RELY on identical periodic refreshes (Triton rumble re-sent every ~40 ms
|
|
||||||
// against a ~50 ms safety timeout, lizard-off every ~3 s) — dropping a repeat would
|
|
||||||
// silence the motors / re-enable lizard mode on the real controller.
|
|
||||||
HidOutput::HidRaw { .. } => true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// `HidoutDedup` forwards a value once, drops exact repeats, re-forwards a change, tracks the two
|
|
||||||
/// trigger sides independently, never dedups one-shot haptic pulses, and re-arms after `clear`.
|
|
||||||
#[test]
|
|
||||||
fn hidout_dedup_forwards_only_changes() {
|
|
||||||
let mut d = HidoutDedup::default();
|
|
||||||
let led = |r| HidOutput::Led {
|
|
||||||
pad: 0,
|
|
||||||
r,
|
|
||||||
g: 0,
|
|
||||||
b: 0,
|
|
||||||
};
|
|
||||||
// First value forwards; an exact repeat is dropped; a change forwards again.
|
|
||||||
assert!(d.should_forward(&led(10)));
|
|
||||||
assert!(!d.should_forward(&led(10)));
|
|
||||||
assert!(d.should_forward(&led(20)));
|
|
||||||
|
|
||||||
// Player LEDs dedup on their own field, independent of the lightbar.
|
|
||||||
let pl = |bits| HidOutput::PlayerLeds { pad: 0, bits };
|
|
||||||
assert!(d.should_forward(&pl(0b101)));
|
|
||||||
assert!(!d.should_forward(&pl(0b101)));
|
|
||||||
assert!(!d.should_forward(&led(20))); // lightbar still unchanged
|
|
||||||
|
|
||||||
// The two adaptive triggers (L2=0, R2=1) are tracked separately.
|
|
||||||
let trig = |which, byte| HidOutput::Trigger {
|
|
||||||
pad: 0,
|
|
||||||
which,
|
|
||||||
effect: vec![byte, 0, 0],
|
|
||||||
};
|
|
||||||
assert!(d.should_forward(&trig(0, 1)));
|
|
||||||
assert!(d.should_forward(&trig(1, 1))); // same bytes, other side → still forwards
|
|
||||||
assert!(!d.should_forward(&trig(0, 1)));
|
|
||||||
assert!(d.should_forward(&trig(0, 2))); // L2 effect changed
|
|
||||||
|
|
||||||
// One-shot haptic pulses are never deduped.
|
|
||||||
let haptic = HidOutput::TrackpadHaptic {
|
|
||||||
pad: 0,
|
|
||||||
side: 0,
|
|
||||||
amplitude: 1,
|
|
||||||
period: 2,
|
|
||||||
count: 3,
|
|
||||||
};
|
|
||||||
assert!(d.should_forward(&haptic));
|
|
||||||
assert!(d.should_forward(&haptic));
|
|
||||||
|
|
||||||
// `clear` re-arms every kind.
|
|
||||||
d.clear();
|
|
||||||
assert!(d.should_forward(&led(20)));
|
|
||||||
assert!(d.should_forward(&pl(0b101)));
|
|
||||||
assert!(d.should_forward(&trig(0, 2)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The Steam dual-pad → DualSense touchpad SPLIT: left pad (surface 1) lands contact 0
|
/// The Steam dual-pad → DualSense touchpad SPLIT: left pad (surface 1) lands contact 0
|
||||||
/// on the left half, right pad (surface 2) contact 1 on the right half; y follows the
|
/// on the left half, right pad (surface 2) contact 1 on the right half; y follows the
|
||||||
/// shared screen convention (top → 0) with no flip; pad clicks set the touchpad-click
|
/// shared screen convention (top → 0) with no flip; pad clicks set the touchpad-click
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
//! The off-thread injector service (plan §W4, carved out of the inject facade): a host-lifetime
|
||||||
|
//! pointer/keyboard injector pinned to its OWN thread and fed over a clonable `Send` channel, plus
|
||||||
|
//! the pre-injection [`coalesce`] pass. The backend owns non-`Send` compositor state (a Wayland
|
||||||
|
//! connection / xkb / EIS socket), so it must live on one thread; both the GameStream control plane
|
||||||
|
//! and the native punktfunk/1 plane forward decoded input here instead of injecting inline.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Host-lifetime pointer/keyboard injector running on its OWN thread, fed over a clonable `Send`
|
||||||
|
/// channel. The injector backend owns non-`Send` compositor state (a Wayland connection / xkb / EIS
|
||||||
|
/// socket), so it must live on a single thread; both the GameStream control plane and the native
|
||||||
|
/// punktfunk/1 plane forward their decoded keyboard/mouse events here instead of injecting inline, so
|
||||||
|
/// a slow inject (a portal stall, a desktop switch) never head-blocks the network thread's
|
||||||
|
/// keepalive/retransmit servicing.
|
||||||
|
pub(crate) struct InjectorService {
|
||||||
|
tx: std::sync::mpsc::Sender<InputEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InjectorService {
|
||||||
|
pub(crate) fn start() -> InjectorService {
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel::<InputEvent>();
|
||||||
|
if let Err(e) = std::thread::Builder::new()
|
||||||
|
.name("punktfunk-injector".into())
|
||||||
|
.spawn(move || injector_service_thread(rx))
|
||||||
|
{
|
||||||
|
tracing::error!(error = %e, "injector service thread spawn failed — pointer/keyboard input disabled");
|
||||||
|
}
|
||||||
|
InjectorService { tx }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A sender a session/plane forwards its pointer/keyboard events to. Cloned per caller; dropping a
|
||||||
|
/// clone does NOT stop the service (it runs while any sender — incl. the service's own — lives).
|
||||||
|
pub(crate) fn sender(&self) -> std::sync::mpsc::Sender<InputEvent> {
|
||||||
|
self.tx.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Backoff between reopen attempts after the injector backend fails to open or its worker dies, so a
|
||||||
|
/// persistently-unavailable portal isn't hammered once per event.
|
||||||
|
const INJECTOR_REOPEN_BACKOFF: std::time::Duration = std::time::Duration::from_secs(2);
|
||||||
|
|
||||||
|
/// The host-lifetime injector worker: lazily open the pointer/keyboard backend, then inject every
|
||||||
|
/// forwarded event. Reopen (after [`INJECTOR_REOPEN_BACKOFF`]) on open failure, on a backend change
|
||||||
|
/// (input follows the active session), or if the backend's worker dies mid-stream. Exits only when
|
||||||
|
/// every sender has dropped (host shutdown), which drops the injector and closes its portal session.
|
||||||
|
///
|
||||||
|
/// Each wake drains the whole backlog and [`coalesce`]s redundant motion before injecting, so a slow
|
||||||
|
/// backend never builds up a queue of stale relative-mouse/scroll events (latency) — while button,
|
||||||
|
/// key, and absolute-move ordering is preserved exactly.
|
||||||
|
fn injector_service_thread(rx: std::sync::mpsc::Receiver<InputEvent>) {
|
||||||
|
let mut injector: Option<Box<dyn InputInjector>> = None;
|
||||||
|
let mut open_backend: Option<Backend> = None;
|
||||||
|
let mut last_failed: Option<std::time::Instant> = None;
|
||||||
|
while let Ok(first) = rx.recv() {
|
||||||
|
// Drain everything already queued behind `first` so we coalesce a whole burst at once.
|
||||||
|
let mut batch = vec![first];
|
||||||
|
while let Ok(ev) = rx.try_recv() {
|
||||||
|
batch.push(ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The resolved input backend (PUNKTFUNK_INPUT_BACKEND, set per connect / mid-stream session
|
||||||
|
// switch) may have changed since we opened. Reopen against it so input FOLLOWS the active
|
||||||
|
// session instead of injecting into a stale, still-warm backend (e.g. the managed gamescope's
|
||||||
|
// EIS socket after the user switched to the KDE desktop).
|
||||||
|
let want = default_backend();
|
||||||
|
if injector.is_some() && open_backend != Some(want) {
|
||||||
|
tracing::info!(
|
||||||
|
?open_backend,
|
||||||
|
?want,
|
||||||
|
"input: backend changed — reopening injector for the active session"
|
||||||
|
);
|
||||||
|
injector = None;
|
||||||
|
last_failed = None; // re-resolve immediately
|
||||||
|
}
|
||||||
|
if injector.is_none() {
|
||||||
|
// Open on the first event; after a failure wait out the backoff before retrying (a few
|
||||||
|
// events drop during setup — acceptable, input is lossy).
|
||||||
|
let ready = last_failed.is_none_or(|t| t.elapsed() >= INJECTOR_REOPEN_BACKOFF);
|
||||||
|
if ready {
|
||||||
|
match open(want) {
|
||||||
|
Ok(i) => {
|
||||||
|
tracing::info!(backend = ?want, "input injector ready (host-lifetime)");
|
||||||
|
injector = Some(i);
|
||||||
|
open_backend = Some(want);
|
||||||
|
last_failed = None;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %format!("{e:#}"), "pointer/keyboard injection unavailable — will retry");
|
||||||
|
last_failed = Some(std::time::Instant::now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(inj) = injector.as_mut() {
|
||||||
|
for ev in coalesce(batch) {
|
||||||
|
if let Err(e) = inj.inject(&ev) {
|
||||||
|
// The backend's worker (portal session / EIS socket) died — drop it and reopen on
|
||||||
|
// a later event (covers a gamescope EIS socket that respawns with its session).
|
||||||
|
tracing::warn!(error = %format!("{e:#}"), "inject failed — reopening injector");
|
||||||
|
injector = None;
|
||||||
|
open_backend = None;
|
||||||
|
last_failed = Some(std::time::Instant::now());
|
||||||
|
break; // abandon the rest of this batch; the next one reopens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracing::debug!("injector service stopped (host shutting down)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coalesce a drained burst: sum consecutive relative-mouse deltas and consecutive same-axis scroll
|
||||||
|
/// deltas (identical net effect, far fewer injects), passing buttons, keys, absolute moves, and any
|
||||||
|
/// type change through untouched and in order. Only *adjacent* same-type events merge, so a button
|
||||||
|
/// or key between two moves flushes the accumulated motion first — ordering is never reshuffled.
|
||||||
|
fn coalesce(events: Vec<InputEvent>) -> Vec<InputEvent> {
|
||||||
|
let mut out: Vec<InputEvent> = Vec::with_capacity(events.len());
|
||||||
|
for ev in events {
|
||||||
|
match out.last_mut() {
|
||||||
|
Some(last) if last.kind == InputKind::MouseMove && ev.kind == InputKind::MouseMove => {
|
||||||
|
last.x = last.x.saturating_add(ev.x);
|
||||||
|
last.y = last.y.saturating_add(ev.y);
|
||||||
|
}
|
||||||
|
Some(last)
|
||||||
|
if last.kind == InputKind::MouseScroll
|
||||||
|
&& ev.kind == InputKind::MouseScroll
|
||||||
|
&& last.code == ev.code =>
|
||||||
|
{
|
||||||
|
last.x = last.x.saturating_add(ev.x);
|
||||||
|
}
|
||||||
|
_ => out.push(ev),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use punktfunk_core::input::{InputEvent, InputKind};
|
||||||
|
|
||||||
|
fn mk(kind: InputKind, code: u32, x: i32, y: i32) -> InputEvent {
|
||||||
|
InputEvent {
|
||||||
|
kind,
|
||||||
|
_pad: [0; 3],
|
||||||
|
code,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
flags: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coalesce_sums_adjacent_motion_and_preserves_order() {
|
||||||
|
let events = vec![
|
||||||
|
mk(InputKind::MouseMove, 0, 1, 2),
|
||||||
|
mk(InputKind::MouseMove, 0, 3, -1), // → summed with the previous move
|
||||||
|
mk(InputKind::KeyDown, 30, 0, 0), // flushes the move, passes through verbatim
|
||||||
|
mk(InputKind::MouseMove, 0, 5, 5), // a NEW run after the key (not merged across it)
|
||||||
|
mk(InputKind::MouseScroll, 0, 1, 0),
|
||||||
|
mk(InputKind::MouseScroll, 0, 2, 0), // same axis (code 0) → summed
|
||||||
|
mk(InputKind::MouseScroll, 1, 1, 0), // different axis (code 1) → separate
|
||||||
|
];
|
||||||
|
let out = coalesce(events);
|
||||||
|
assert_eq!(out.len(), 5);
|
||||||
|
assert_eq!(
|
||||||
|
(out[0].kind, out[0].x, out[0].y),
|
||||||
|
(InputKind::MouseMove, 4, 1)
|
||||||
|
);
|
||||||
|
assert_eq!(out[1].kind, InputKind::KeyDown);
|
||||||
|
assert_eq!(
|
||||||
|
(out[2].kind, out[2].x, out[2].y),
|
||||||
|
(InputKind::MouseMove, 5, 5)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
(out[3].kind, out[3].code, out[3].x),
|
||||||
|
(InputKind::MouseScroll, 0, 3)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
(out[4].kind, out[4].code, out[4].x),
|
||||||
|
(InputKind::MouseScroll, 1, 1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coalesce_handles_empty_and_singleton() {
|
||||||
|
assert!(coalesce(vec![]).is_empty());
|
||||||
|
assert_eq!(coalesce(vec![mk(InputKind::MouseMove, 0, 7, 8)]).len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,10 +6,10 @@
|
|||||||
//! Windows XUSB) write frames straight through with no state vec / heartbeat / rich plane, so they
|
//! Windows XUSB) write frames straight through with no state vec / heartbeat / rich plane, so they
|
||||||
//! use [`PadSlots`] directly instead.
|
//! use [`PadSlots`] directly instead.
|
||||||
|
|
||||||
use crate::gamestream::gamepad::{GamepadEvent, GamepadFrame, MAX_PADS};
|
use crate::inject::hidout_dedup::HidoutDedup;
|
||||||
use crate::inject::dualsense_proto::HidoutDedup;
|
|
||||||
use crate::inject::pad_slots::PadSlots;
|
use crate::inject::pad_slots::PadSlots;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use punktfunk_core::input::{GamepadEvent, GamepadFrame, MAX_PADS};
|
||||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ impl PadProto for DsEdgeWinProto {
|
|||||||
/// Merge buttons/sticks/triggers from the frame, preserving the rich-plane fields — like the
|
/// Merge buttons/sticks/triggers from the frame, preserving the rich-plane fields — like the
|
||||||
/// plain DualSense, EXCEPT the wire paddles land on the Edge's own `buttons[2]` bits
|
/// plain DualSense, EXCEPT the wire paddles land on the Edge's own `buttons[2]` bits
|
||||||
/// (rebuilt from every button frame, so no extra persistence).
|
/// (rebuilt from every button frame, so no extra persistence).
|
||||||
fn merge_frame(&self, prev: &DsState, f: &crate::gamestream::gamepad::GamepadFrame) -> DsState {
|
fn merge_frame(&self, prev: &DsState, f: &punktfunk_core::input::GamepadFrame) -> DsState {
|
||||||
let mut s = DsState::from_gamepad(
|
let mut s = DsState::from_gamepad(
|
||||||
f.buttons,
|
f.buttons,
|
||||||
f.ls_x,
|
f.ls_x,
|
||||||
|
|||||||
@@ -438,7 +438,7 @@ impl PadProto for DsWinProto {
|
|||||||
|
|
||||||
/// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (rich-
|
/// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (rich-
|
||||||
/// plane fields that must survive a button-only frame) — exactly as `linux/dualsense.rs` does.
|
/// plane fields that must survive a button-only frame) — exactly as `linux/dualsense.rs` does.
|
||||||
fn merge_frame(&self, prev: &DsState, f: &crate::gamestream::gamepad::GamepadFrame) -> DsState {
|
fn merge_frame(&self, prev: &DsState, f: &punktfunk_core::input::GamepadFrame) -> DsState {
|
||||||
// Steam back grips have no DualSense slot — fold them onto standard buttons per the
|
// Steam back grips have no DualSense slot — fold them onto standard buttons per the
|
||||||
// configured policy (default drop) so they aren't silently lost.
|
// configured policy (default drop) so they aren't silently lost.
|
||||||
let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
|
let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ impl PadProto for Ds4WinProto {
|
|||||||
|
|
||||||
/// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (rich-
|
/// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (rich-
|
||||||
/// plane fields that must survive a button-only frame) — exactly as `linux/dualshock4.rs` does.
|
/// plane fields that must survive a button-only frame) — exactly as `linux/dualshock4.rs` does.
|
||||||
fn merge_frame(&self, prev: &DsState, f: &crate::gamestream::gamepad::GamepadFrame) -> DsState {
|
fn merge_frame(&self, prev: &DsState, f: &punktfunk_core::input::GamepadFrame) -> DsState {
|
||||||
// Steam back grips have no DS4 slot — fold them onto standard buttons per the configured
|
// Steam back grips have no DS4 slot — fold them onto standard buttons per the configured
|
||||||
// policy (default drop) so they aren't silently lost.
|
// policy (default drop) so they aren't silently lost.
|
||||||
let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
|
let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
|
||||||
|
|||||||
@@ -13,9 +13,9 @@
|
|||||||
//! level changes to the client (the universal 0xCA plane), mirroring the Linux `EV_FF` read path.
|
//! level changes to the client (the universal 0xCA plane), mirroring the Linux `EV_FF` read path.
|
||||||
|
|
||||||
use super::gamepad_raii::{sw_create_cb, PadChannel, SwCreateCtx};
|
use super::gamepad_raii::{sw_create_cb, PadChannel, SwCreateCtx};
|
||||||
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
|
|
||||||
use crate::inject::pad_slots::PadSlots;
|
use crate::inject::pad_slots::PadSlots;
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
|
use punktfunk_core::input::{GamepadEvent, MAX_PADS};
|
||||||
use std::ffi::c_void;
|
use std::ffi::c_void;
|
||||||
use std::sync::atomic::{fence, AtomicU32, Ordering};
|
use std::sync::atomic::{fence, AtomicU32, Ordering};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ impl PadProto for DeckWinProto {
|
|||||||
fn merge_frame(
|
fn merge_frame(
|
||||||
&self,
|
&self,
|
||||||
prev: &SteamState,
|
prev: &SteamState,
|
||||||
f: &crate::gamestream::gamepad::GamepadFrame,
|
f: &punktfunk_core::input::GamepadFrame,
|
||||||
) -> SteamState {
|
) -> SteamState {
|
||||||
use super::steam_proto::btn;
|
use super::steam_proto::btn;
|
||||||
let mut s = SteamState::from_gamepad(
|
let mut s = SteamState::from_gamepad(
|
||||||
|
|||||||
@@ -95,6 +95,11 @@ pub struct GameEntry {
|
|||||||
/// How the host would launch it, when known.
|
/// How the host would launch it, when known.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub launch: Option<LaunchSpec>,
|
pub launch: Option<LaunchSpec>,
|
||||||
|
/// The external provider owning this entry (custom-store entries synced by a provider
|
||||||
|
/// plugin, RFC §8) — `None` for installed-store titles and manual custom entries. The
|
||||||
|
/// console uses it for attribution; `GET /library?provider=` filters on it.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub provider: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A store that contributes titles to the library. The trait is the extension point for future
|
/// A store that contributes titles to the library. The trait is the extension point for future
|
||||||
|
|||||||
@@ -14,6 +14,20 @@ pub struct CustomEntry {
|
|||||||
pub art: Artwork,
|
pub art: Artwork,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub launch: Option<LaunchSpec>,
|
pub launch: Option<LaunchSpec>,
|
||||||
|
/// Per-title prep/undo steps (RFC §6): each `do` runs before this title launches, each
|
||||||
|
/// `undo` at session end in reverse order (see [`crate::hooks::run_prep`]).
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub prep: Vec<crate::hooks::PrepCmd>,
|
||||||
|
/// The external provider owning this entry (RFC §8), set ONLY by the provider reconcile
|
||||||
|
/// API — `None` = a manual entry, which no provider operation ever touches, and which the
|
||||||
|
/// manual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned
|
||||||
|
/// entries, so ownership is never ambiguous).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub provider: Option<String>,
|
||||||
|
/// The provider's own stable key for this title — the reconcile diff key, so the
|
||||||
|
/// host-assigned `id` stays stable across reconciles. Present iff `provider` is.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub external_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request body to create or replace a custom entry (no `id` — the host owns it).
|
/// Request body to create or replace a custom entry (no `id` — the host owns it).
|
||||||
@@ -24,6 +38,25 @@ pub struct CustomInput {
|
|||||||
pub art: Artwork,
|
pub art: Artwork,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub launch: Option<LaunchSpec>,
|
pub launch: Option<LaunchSpec>,
|
||||||
|
/// Per-title prep/undo steps — commands run as the host user; operator-privileged config.
|
||||||
|
#[serde(default)]
|
||||||
|
pub prep: Vec<crate::hooks::PrepCmd>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One title in a provider's declarative reconcile payload (RFC §8): [`CustomInput`] plus the
|
||||||
|
/// provider's required stable key.
|
||||||
|
#[derive(Clone, Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct ProviderEntryInput {
|
||||||
|
/// The provider's stable id for this title (the reconcile diff key).
|
||||||
|
pub external_id: String,
|
||||||
|
pub title: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub art: Artwork,
|
||||||
|
#[serde(default)]
|
||||||
|
pub launch: Option<LaunchSpec>,
|
||||||
|
/// Per-title prep/undo steps — commands run as the host user; operator-privileged config.
|
||||||
|
#[serde(default)]
|
||||||
|
pub prep: Vec<crate::hooks::PrepCmd>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<CustomEntry> for GameEntry {
|
impl From<CustomEntry> for GameEntry {
|
||||||
@@ -34,6 +67,7 @@ impl From<CustomEntry> for GameEntry {
|
|||||||
title: c.title,
|
title: c.title,
|
||||||
art: c.art,
|
art: c.art,
|
||||||
launch: c.launch,
|
launch: c.launch,
|
||||||
|
provider: c.provider,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,7 +115,17 @@ fn new_id(title: &str) -> String {
|
|||||||
hex::encode(&Sha256::digest(format!("{title}:{nanos}").as_bytes())[..6])
|
hex::encode(&Sha256::digest(format!("{title}:{nanos}").as_bytes())[..6])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a custom entry, returning it with its assigned id.
|
/// Outcome of a manual mutation against an id — distinguishes "no such entry" from "exists,
|
||||||
|
/// but a provider owns it" (the mgmt layer maps the latter to 409, not 404).
|
||||||
|
pub enum MutateOutcome<T> {
|
||||||
|
Done(T),
|
||||||
|
NotFound,
|
||||||
|
/// The entry belongs to this provider — mutate it through the provider reconcile API
|
||||||
|
/// (or remove the whole provider set); manual edits would be clobbered at the next sync.
|
||||||
|
ProviderOwned(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a custom (manual) entry, returning it with its assigned id.
|
||||||
pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
|
pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
|
||||||
let mut entries = load_custom();
|
let mut entries = load_custom();
|
||||||
let entry = CustomEntry {
|
let entry = CustomEntry {
|
||||||
@@ -89,46 +133,177 @@ pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
|
|||||||
title: input.title,
|
title: input.title,
|
||||||
art: input.art,
|
art: input.art,
|
||||||
launch: input.launch,
|
launch: input.launch,
|
||||||
|
prep: input.prep,
|
||||||
|
provider: None,
|
||||||
|
external_id: None,
|
||||||
};
|
};
|
||||||
entries.push(entry.clone());
|
entries.push(entry.clone());
|
||||||
save_custom(&entries)?;
|
save_custom(&entries)?;
|
||||||
emit_changed();
|
emit_changed("manual");
|
||||||
Ok(entry)
|
Ok(entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace a custom entry's fields (id preserved). `None` ⇒ no entry with that id.
|
/// Replace a manual entry's fields (id preserved). Provider-owned entries are refused —
|
||||||
pub fn update_custom(id: &str, input: CustomInput) -> Result<Option<CustomEntry>> {
|
/// their state belongs to the provider's reconcile (RFC §8 ownership rule).
|
||||||
|
pub fn update_custom(id: &str, input: CustomInput) -> Result<MutateOutcome<CustomEntry>> {
|
||||||
let mut entries = load_custom();
|
let mut entries = load_custom();
|
||||||
let Some(slot) = entries.iter_mut().find(|e| e.id == id) else {
|
let Some(slot) = entries.iter_mut().find(|e| e.id == id) else {
|
||||||
return Ok(None);
|
return Ok(MutateOutcome::NotFound);
|
||||||
};
|
};
|
||||||
|
if let Some(provider) = &slot.provider {
|
||||||
|
return Ok(MutateOutcome::ProviderOwned(provider.clone()));
|
||||||
|
}
|
||||||
slot.title = input.title;
|
slot.title = input.title;
|
||||||
slot.art = input.art;
|
slot.art = input.art;
|
||||||
slot.launch = input.launch;
|
slot.launch = input.launch;
|
||||||
|
slot.prep = input.prep;
|
||||||
let updated = slot.clone();
|
let updated = slot.clone();
|
||||||
save_custom(&entries)?;
|
save_custom(&entries)?;
|
||||||
emit_changed();
|
emit_changed("manual");
|
||||||
Ok(Some(updated))
|
Ok(MutateOutcome::Done(updated))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a custom entry. `false` ⇒ no entry with that id.
|
/// Delete a manual entry. Provider-owned entries are refused (see [`update_custom`]).
|
||||||
pub fn delete_custom(id: &str) -> Result<bool> {
|
pub fn delete_custom(id: &str) -> Result<MutateOutcome<()>> {
|
||||||
|
let mut entries = load_custom();
|
||||||
|
let Some(entry) = entries.iter().find(|e| e.id == id) else {
|
||||||
|
return Ok(MutateOutcome::NotFound);
|
||||||
|
};
|
||||||
|
if let Some(provider) = &entry.provider {
|
||||||
|
return Ok(MutateOutcome::ProviderOwned(provider.clone()));
|
||||||
|
}
|
||||||
|
entries.retain(|e| e.id != id);
|
||||||
|
save_custom(&entries)?;
|
||||||
|
emit_changed("manual");
|
||||||
|
Ok(MutateOutcome::Done(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ providers (RFC §8)
|
||||||
|
|
||||||
|
/// Provider ids are path segments, event sources, and console labels: keep them tame.
|
||||||
|
/// `manual` is reserved (it is the no-provider sentinel in `library.changed`).
|
||||||
|
pub fn validate_provider_name(provider: &str) -> Result<(), String> {
|
||||||
|
if provider == "manual" {
|
||||||
|
return Err("provider id `manual` is reserved".into());
|
||||||
|
}
|
||||||
|
let ok = !provider.is_empty()
|
||||||
|
&& provider.len() <= 64
|
||||||
|
&& provider
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.'))
|
||||||
|
&& provider.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit());
|
||||||
|
if ok {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("provider id must be 1–64 chars of [a-z0-9._-], starting alphanumeric".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a reconcile payload: non-empty titles and unique, non-empty external ids (the
|
||||||
|
/// diff key — a duplicate would make ownership of the surviving entry ambiguous).
|
||||||
|
pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), String> {
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
for (i, e) in inputs.iter().enumerate() {
|
||||||
|
if e.external_id.trim().is_empty() {
|
||||||
|
return Err(format!("entries[{i}]: `external_id` must not be empty"));
|
||||||
|
}
|
||||||
|
if e.title.trim().is_empty() {
|
||||||
|
return Err(format!("entries[{i}]: `title` must not be empty"));
|
||||||
|
}
|
||||||
|
if !seen.insert(e.external_id.as_str()) {
|
||||||
|
return Err(format!(
|
||||||
|
"entries[{i}]: duplicate `external_id` \"{}\"",
|
||||||
|
e.external_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pure reconcile (unit-tested without the filesystem): replace `provider`'s entry set
|
||||||
|
/// with `inputs` inside `entries` — keeping each surviving title's host id stable (keyed on
|
||||||
|
/// `external_id`), dropping the provider's orphans, and never touching manual entries or
|
||||||
|
/// other providers'. Returns the provider's resulting entries, payload order.
|
||||||
|
fn reconcile_entries(
|
||||||
|
entries: &mut Vec<CustomEntry>,
|
||||||
|
provider: &str,
|
||||||
|
inputs: Vec<ProviderEntryInput>,
|
||||||
|
) -> Vec<CustomEntry> {
|
||||||
|
// The provider's current entries, keyed by its own stable id.
|
||||||
|
let mut existing: std::collections::HashMap<String, CustomEntry> = entries
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.provider.as_deref() == Some(provider))
|
||||||
|
.filter_map(|e| e.external_id.clone().map(|x| (x, e.clone())))
|
||||||
|
.collect();
|
||||||
|
// Everything the provider does NOT own survives untouched.
|
||||||
|
entries.retain(|e| e.provider.as_deref() != Some(provider));
|
||||||
|
let mut result = Vec::with_capacity(inputs.len());
|
||||||
|
for input in inputs {
|
||||||
|
let id = existing
|
||||||
|
.remove(&input.external_id)
|
||||||
|
.map(|prev| prev.id) // same title as last sync → keep its host id
|
||||||
|
.unwrap_or_else(|| new_id(&format!("{provider}:{}", input.external_id)));
|
||||||
|
result.push(CustomEntry {
|
||||||
|
id,
|
||||||
|
title: input.title,
|
||||||
|
art: input.art,
|
||||||
|
launch: input.launch,
|
||||||
|
prep: input.prep,
|
||||||
|
provider: Some(provider.to_string()),
|
||||||
|
external_id: Some(input.external_id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// `existing`'s leftovers are the orphans — deliberately dropped (declarative reconcile).
|
||||||
|
entries.extend(result.iter().cloned());
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically replace `provider`'s entry set (RFC §8: `PUT /library/provider/{provider}`).
|
||||||
|
/// The caller validates the name and payload first. Emits `library.changed` with the provider
|
||||||
|
/// as the source.
|
||||||
|
pub fn reconcile_provider(
|
||||||
|
provider: &str,
|
||||||
|
inputs: Vec<ProviderEntryInput>,
|
||||||
|
) -> Result<Vec<CustomEntry>> {
|
||||||
|
let mut entries = load_custom();
|
||||||
|
let result = reconcile_entries(&mut entries, provider, inputs);
|
||||||
|
save_custom(&entries)?;
|
||||||
|
emit_changed(provider);
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove every entry of `provider` (RFC §8: `DELETE /library/provider/{provider}` — the
|
||||||
|
/// clean-uninstall path). Returns how many were removed; no event when nothing was.
|
||||||
|
pub fn delete_provider(provider: &str) -> Result<usize> {
|
||||||
let mut entries = load_custom();
|
let mut entries = load_custom();
|
||||||
let before = entries.len();
|
let before = entries.len();
|
||||||
entries.retain(|e| e.id != id);
|
entries.retain(|e| e.provider.as_deref() != Some(provider));
|
||||||
if entries.len() == before {
|
let removed = before - entries.len();
|
||||||
return Ok(false);
|
if removed > 0 {
|
||||||
}
|
|
||||||
save_custom(&entries)?;
|
save_custom(&entries)?;
|
||||||
emit_changed();
|
emit_changed(provider);
|
||||||
Ok(true)
|
}
|
||||||
|
Ok(removed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The custom-entry mutations are the only library writes today, all operator-driven — hence
|
/// The prep/undo steps for a library id — `custom:<id>` entries only (the other stores have no
|
||||||
/// `source: "manual"` (RFC §4; a provider id once the provider API of RFC §8 lands).
|
/// per-title config surface; a GameStream `apps.json` entry carries its own `prep` instead).
|
||||||
fn emit_changed() {
|
pub fn prep_for(library_id: &str) -> Vec<crate::hooks::PrepCmd> {
|
||||||
|
let Some(id) = library_id.strip_prefix("custom:") else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
load_custom()
|
||||||
|
.into_iter()
|
||||||
|
.find(|e| e.id == id)
|
||||||
|
.map(|e| e.prep)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every library mutation announces itself (RFC §4): `source` is `"manual"` for the operator
|
||||||
|
/// CRUD, the provider id for a reconcile/uninstall — hooks and the SDK filter on it.
|
||||||
|
fn emit_changed(source: &str) {
|
||||||
crate::events::emit(crate::events::EventKind::LibraryChanged {
|
crate::events::emit(crate::events::EventKind::LibraryChanged {
|
||||||
source: "manual".to_string(),
|
source: source.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,16 +319,131 @@ pub(crate) fn valid_steam_appid(value: &str) -> bool {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
fn manual(id: &str, title: &str) -> CustomEntry {
|
||||||
fn custom_entry_maps_to_game_entry() {
|
CustomEntry {
|
||||||
let g: GameEntry = CustomEntry {
|
id: id.into(),
|
||||||
id: "abc123".into(),
|
title: title.into(),
|
||||||
title: "My ROM".into(),
|
|
||||||
art: Artwork::default(),
|
art: Artwork::default(),
|
||||||
launch: None,
|
launch: None,
|
||||||
|
prep: Vec::new(),
|
||||||
|
provider: None,
|
||||||
|
external_id: None,
|
||||||
}
|
}
|
||||||
.into();
|
}
|
||||||
|
|
||||||
|
fn input(external_id: &str, title: &str) -> ProviderEntryInput {
|
||||||
|
ProviderEntryInput {
|
||||||
|
external_id: external_id.into(),
|
||||||
|
title: title.into(),
|
||||||
|
art: Artwork::default(),
|
||||||
|
launch: None,
|
||||||
|
prep: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_entry_maps_to_game_entry_with_provider() {
|
||||||
|
let g: GameEntry = manual("abc123", "My ROM").into();
|
||||||
assert_eq!(g.id, "custom:abc123");
|
assert_eq!(g.id, "custom:abc123");
|
||||||
assert_eq!(g.store, "custom");
|
assert_eq!(g.store, "custom");
|
||||||
|
assert_eq!(g.provider, None);
|
||||||
|
|
||||||
|
let mut e = manual("def456", "Synced");
|
||||||
|
e.provider = Some("romm".into());
|
||||||
|
e.external_id = Some("rom-1".into());
|
||||||
|
let g: GameEntry = e.into();
|
||||||
|
assert_eq!(g.provider.as_deref(), Some("romm"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The RFC §8 contract in one walk: add keeps ids stable across re-syncs, updates flow,
|
||||||
|
/// orphans drop, and neither manual entries nor other providers are ever touched.
|
||||||
|
#[test]
|
||||||
|
fn reconcile_is_declarative_with_stable_ids() {
|
||||||
|
let mut entries = vec![manual("man1", "Hand-added")];
|
||||||
|
// Another provider's entry must survive every romm reconcile.
|
||||||
|
let mut other = manual("oth1", "Other title");
|
||||||
|
other.provider = Some("itch".into());
|
||||||
|
other.external_id = Some("x1".into());
|
||||||
|
entries.push(other);
|
||||||
|
|
||||||
|
// First sync: two titles appear.
|
||||||
|
let r1 = reconcile_entries(
|
||||||
|
&mut entries,
|
||||||
|
"romm",
|
||||||
|
vec![input("rom-a", "Game A"), input("rom-b", "Game B")],
|
||||||
|
);
|
||||||
|
assert_eq!(r1.len(), 2);
|
||||||
|
assert!(r1.iter().all(|e| e.provider.as_deref() == Some("romm")));
|
||||||
|
let id_a = r1[0].id.clone();
|
||||||
|
assert_eq!(entries.len(), 4);
|
||||||
|
|
||||||
|
// Second sync: A renamed, B gone, C new — A's host id must be STABLE.
|
||||||
|
let r2 = reconcile_entries(
|
||||||
|
&mut entries,
|
||||||
|
"romm",
|
||||||
|
vec![input("rom-a", "Game A (v2)"), input("rom-c", "Game C")],
|
||||||
|
);
|
||||||
|
assert_eq!(r2.len(), 2);
|
||||||
|
assert_eq!(r2[0].id, id_a, "same external_id keeps its host id");
|
||||||
|
assert_eq!(r2[0].title, "Game A (v2)");
|
||||||
|
assert_ne!(r2[1].id, id_a);
|
||||||
|
assert!(
|
||||||
|
!entries
|
||||||
|
.iter()
|
||||||
|
.any(|e| e.external_id.as_deref() == Some("rom-b")),
|
||||||
|
"orphan dropped"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Idempotence: an identical re-PUT changes nothing.
|
||||||
|
let snapshot: Vec<String> = entries.iter().map(|e| e.id.clone()).collect();
|
||||||
|
let r3 = reconcile_entries(
|
||||||
|
&mut entries,
|
||||||
|
"romm",
|
||||||
|
vec![input("rom-a", "Game A (v2)"), input("rom-c", "Game C")],
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
r3.iter().map(|e| &e.id).collect::<Vec<_>>(),
|
||||||
|
r2.iter().map(|e| &e.id).collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
entries.iter().map(|e| e.id.clone()).collect::<Vec<_>>(),
|
||||||
|
snapshot
|
||||||
|
);
|
||||||
|
|
||||||
|
// The bystanders never moved.
|
||||||
|
assert!(entries
|
||||||
|
.iter()
|
||||||
|
.any(|e| e.id == "man1" && e.provider.is_none()));
|
||||||
|
assert!(entries
|
||||||
|
.iter()
|
||||||
|
.any(|e| e.id == "oth1" && e.provider.as_deref() == Some("itch")));
|
||||||
|
|
||||||
|
// Empty payload = remove everything the provider owns (same as DELETE).
|
||||||
|
let r4 = reconcile_entries(&mut entries, "romm", Vec::new());
|
||||||
|
assert!(r4.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
entries.len(),
|
||||||
|
2,
|
||||||
|
"only the manual + other-provider entries remain"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_name_and_payload_validation() {
|
||||||
|
assert!(validate_provider_name("romm").is_ok());
|
||||||
|
assert!(validate_provider_name("my-provider.v2").is_ok());
|
||||||
|
assert!(validate_provider_name("manual").is_err(), "reserved");
|
||||||
|
assert!(validate_provider_name("").is_err());
|
||||||
|
assert!(validate_provider_name("Bad/Name").is_err());
|
||||||
|
assert!(validate_provider_name("-lead").is_err());
|
||||||
|
assert!(validate_provider_name(&"x".repeat(65)).is_err());
|
||||||
|
|
||||||
|
assert!(validate_provider_payload(&[input("a", "A")]).is_ok());
|
||||||
|
assert!(validate_provider_payload(&[input("", "A")]).is_err());
|
||||||
|
assert!(validate_provider_payload(&[input("a", " ")]).is_err());
|
||||||
|
assert!(
|
||||||
|
validate_provider_payload(&[input("a", "A"), input("a", "B")]).is_err(),
|
||||||
|
"duplicate external_id"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ fn epic_entry(
|
|||||||
app_name.clone()
|
app_name.clone()
|
||||||
};
|
};
|
||||||
Some(GameEntry {
|
Some(GameEntry {
|
||||||
|
provider: None,
|
||||||
id: format!("epic:{app_name}"),
|
id: format!("epic:{app_name}"),
|
||||||
store: "epic".into(),
|
store: "epic".into(),
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ fn gog_games() -> Vec<GameEntry> {
|
|||||||
// whatever it has cached (title-only until warmed).
|
// whatever it has cached (title-only until warmed).
|
||||||
let art = cached_art(&id).unwrap_or_default();
|
let art = cached_art(&id).unwrap_or_default();
|
||||||
out.push(GameEntry {
|
out.push(GameEntry {
|
||||||
|
provider: None,
|
||||||
id,
|
id,
|
||||||
store: "gog".into(),
|
store: "gog".into(),
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<Game
|
|||||||
logo: http("art_logo"),
|
logo: http("art_logo"),
|
||||||
};
|
};
|
||||||
games.push(GameEntry {
|
games.push(GameEntry {
|
||||||
|
provider: None,
|
||||||
id: format!("heroic:{runner}:{app_name}"),
|
id: format!("heroic:{runner}:{app_name}"),
|
||||||
store: "heroic".into(),
|
store: "heroic".into(),
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ fn lutris_games(db: &Path) -> rusqlite::Result<Vec<GameEntry>> {
|
|||||||
let mut games = Vec::new();
|
let mut games = Vec::new();
|
||||||
for (id, slug, name) in rows.flatten() {
|
for (id, slug, name) in rows.flatten() {
|
||||||
games.push(GameEntry {
|
games.push(GameEntry {
|
||||||
|
provider: None,
|
||||||
id: format!("lutris:{id}"),
|
id: format!("lutris:{id}"),
|
||||||
store: "lutris".into(),
|
store: "lutris".into(),
|
||||||
title: name,
|
title: name,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ impl LibraryProvider for SteamProvider {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|(appid, name)| !is_steam_tool(*appid, name))
|
.filter(|(appid, name)| !is_steam_tool(*appid, name))
|
||||||
.map(|(appid, title)| GameEntry {
|
.map(|(appid, title)| GameEntry {
|
||||||
|
provider: None,
|
||||||
id: format!("steam:{appid}"),
|
id: format!("steam:{appid}"),
|
||||||
store: "steam".into(),
|
store: "steam".into(),
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ fn xbox_games() -> Vec<GameEntry> {
|
|||||||
// background warmer; read whatever it has cached (title-only until warmed / if no StoreId).
|
// background warmer; read whatever it has cached (title-only until warmed / if no StoreId).
|
||||||
let art = cached_art(&id).unwrap_or_default();
|
let art = cached_art(&id).unwrap_or_default();
|
||||||
games.push(GameEntry {
|
games.push(GameEntry {
|
||||||
|
provider: None,
|
||||||
id,
|
id,
|
||||||
store: "xbox".into(),
|
store: "xbox".into(),
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
//! Minimal CUDA Driver API FFI for the zero-copy path. No Rust crate exposes the GL-interop
|
//! CUDA driver-side state for the zero-copy path, layered over the raw driver-API FFI in [`ffi`]
|
||||||
//! driver calls we need (`cuGraphicsGLRegisterImage` & co.), so we hand-roll exactly those and
|
//! (the `dlopen`'d `libcuda.so.1` symbol table — hand-rolled because no Rust crate exposes the
|
||||||
//! `dlopen` `libcuda.so.1` at runtime (the driver library — NOT `libcudart`; NOT a link-time
|
//! GL-interop calls, and runtime-loaded so one binary runs on NVIDIA *and* on AMD/Intel where
|
||||||
//! `#[link]`, so one binary runs on NVIDIA and on AMD/Intel where `libcuda` is absent — see
|
//! `libcuda` is absent). This facade owns the higher-level pieces on top of that layer:
|
||||||
//! [`CudaApi`]). Symbol names verified against
|
|
||||||
//! `cust_raw` + `cudaGL.h`: the context/mem ops use the `_v2` ABI suffix; the graphics-interop
|
|
||||||
//! ops are unsuffixed. (We use GL interop, not EGL interop: `cuGraphicsEGLRegisterImage` is
|
|
||||||
//! Tegra-only on the desktop driver — see [`super::egl`].)
|
|
||||||
//!
|
//!
|
||||||
//! One process-wide `CUcontext` is created lazily and shared by the EGL importer (capture
|
//! * one process-wide `CUcontext`, created lazily and shared by the EGL importer (capture thread)
|
||||||
//! thread) and ffmpeg's `hevc_nvenc` (encode thread); each thread makes it current before use.
|
//! and ffmpeg's `hevc_nvenc` (encode thread) — each thread makes it current before use;
|
||||||
|
//! * device memory: pitched allocations, the reusable `BufferPool`/`DeviceBuffer`, IPC
|
||||||
|
//! export/import, host readback, and the plane copies;
|
||||||
|
//! * GL / external-memory interop (`RegisteredTexture`, `ExternalDmabuf`); and
|
||||||
|
//! * the CUDA cursor-blend kernel (`CursorBlend`).
|
||||||
|
//!
|
||||||
|
//! (We use GL interop, not EGL interop: `cuGraphicsEGLRegisterImage` is Tegra-only on the desktop
|
||||||
|
//! driver — see [`super::egl`].)
|
||||||
|
|
||||||
#![allow(non_camel_case_types, non_snake_case)]
|
#![allow(non_camel_case_types, non_snake_case)]
|
||||||
// Every `unsafe` block/impl below carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
// Every `unsafe` block/impl below carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
@@ -16,472 +19,12 @@
|
|||||||
|
|
||||||
use anyhow::{bail, Result};
|
use anyhow::{bail, Result};
|
||||||
use std::ffi::CStr;
|
use std::ffi::CStr;
|
||||||
use std::os::raw::{c_char, c_int, c_uint, c_void};
|
use std::os::raw::{c_uint, c_void};
|
||||||
use std::sync::{Arc, Mutex, OnceLock};
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
|
||||||
pub type CUresult = c_uint; // CUDA_SUCCESS == 0
|
#[path = "cuda/ffi.rs"]
|
||||||
pub type CUdevice = c_int;
|
mod ffi;
|
||||||
pub type CUcontext = *mut c_void; // opaque CUctx_st*
|
pub(crate) use ffi::*;
|
||||||
pub type CUstream = *mut c_void; // opaque CUstream_st*
|
|
||||||
pub type CUdeviceptr = u64;
|
|
||||||
pub type CUgraphicsResource = *mut c_void;
|
|
||||||
pub type CUarray = *mut c_void;
|
|
||||||
pub type CUexternalMemory = *mut c_void; // opaque CUextMemory_st*
|
|
||||||
pub type CUmodule = *mut c_void; // opaque CUmod_st*
|
|
||||||
pub type CUfunction = *mut c_void; // opaque CUfunc_st*
|
|
||||||
|
|
||||||
/// `CUmemorytype` (cuda.h): HOST=1, DEVICE=2, ARRAY=3, UNIFIED=4.
|
|
||||||
pub const CU_MEMORYTYPE_DEVICE: c_uint = 2;
|
|
||||||
pub const CU_MEMORYTYPE_ARRAY: c_uint = 3;
|
|
||||||
|
|
||||||
/// `CUctx_flags` (cuda.h): block the CPU on an OS primitive while waiting for the GPU instead of
|
|
||||||
/// busy-spinning. On this shared box (compositor + send thread on the same cores) spinning a core
|
|
||||||
/// to detect copy completion steals CPU from the very threads we want scheduled; BLOCKING_SYNC
|
|
||||||
/// frees it. Default (`CU_CTX_SCHED_AUTO=0`) heuristically picks SPIN vs YIELD by core count.
|
|
||||||
const CU_CTX_SCHED_BLOCKING_SYNC: c_uint = 0x04;
|
|
||||||
|
|
||||||
/// `cuStreamCreateWithPriority` flag: don't implicitly synchronize with the legacy NULL stream.
|
|
||||||
const CU_STREAM_NON_BLOCKING: c_uint = 0x01;
|
|
||||||
|
|
||||||
/// `CUDA_MEMCPY2D` (cuda.h, `_v2` ABI). Field order is load-bearing.
|
|
||||||
#[repr(C)]
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct CUDA_MEMCPY2D {
|
|
||||||
pub srcXInBytes: usize,
|
|
||||||
pub srcY: usize,
|
|
||||||
pub srcMemoryType: c_uint,
|
|
||||||
pub srcHost: *const c_void,
|
|
||||||
pub srcDevice: CUdeviceptr,
|
|
||||||
pub srcArray: CUarray,
|
|
||||||
pub srcPitch: usize,
|
|
||||||
pub dstXInBytes: usize,
|
|
||||||
pub dstY: usize,
|
|
||||||
pub dstMemoryType: c_uint,
|
|
||||||
pub dstHost: *mut c_void,
|
|
||||||
pub dstDevice: CUdeviceptr,
|
|
||||||
pub dstArray: CUarray,
|
|
||||||
pub dstPitch: usize,
|
|
||||||
pub WidthInBytes: usize,
|
|
||||||
pub Height: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `CUDA_EXTERNAL_MEMORY_HANDLE_DESC` (cuda.h, 64-bit layout). `handle` is a union whose
|
|
||||||
/// largest member is the win32 two-pointer struct (16 bytes, align 8); for the OPAQUE_FD type
|
|
||||||
/// only the first 4 bytes (the `int fd`) are read.
|
|
||||||
#[repr(C)]
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct CUDA_EXTERNAL_MEMORY_HANDLE_DESC {
|
|
||||||
pub type_: c_uint, // CU_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD = 1
|
|
||||||
_pad: u32,
|
|
||||||
pub handle: [u64; 2], // union { int fd; {void*,void*} win32; void* nvSciBufObject }
|
|
||||||
pub size: u64,
|
|
||||||
pub flags: c_uint,
|
|
||||||
reserved: [c_uint; 16],
|
|
||||||
_pad2: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `CUDA_EXTERNAL_MEMORY_BUFFER_DESC` (cuda.h, 64-bit layout).
|
|
||||||
#[repr(C)]
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct CUDA_EXTERNAL_MEMORY_BUFFER_DESC {
|
|
||||||
pub offset: u64,
|
|
||||||
pub size: u64,
|
|
||||||
pub flags: c_uint,
|
|
||||||
reserved: [c_uint; 16],
|
|
||||||
_pad: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const CU_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD: c_uint = 1;
|
|
||||||
|
|
||||||
/// `CUipcMemHandle` (cuda.h): an opaque 64-byte struct identifying a device allocation across
|
|
||||||
/// processes. Produced by `cuIpcGetMemHandle` in the exporting process, consumed by
|
|
||||||
/// `cuIpcOpenMemHandle` in the importer — passed **by value**, matching the C
|
|
||||||
/// `struct { char reserved[64]; }`. Plain bytes — safe to ship over a socket.
|
|
||||||
pub const CU_IPC_HANDLE_SIZE: usize = 64;
|
|
||||||
#[repr(C)]
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
pub struct CUipcMemHandle {
|
|
||||||
pub reserved: [u8; CU_IPC_HANDLE_SIZE],
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `CUipcMem_flags`: lazily enable peer access on open (the documented flag for
|
|
||||||
/// `cuIpcOpenMemHandle`; a no-op for a same-device open, which is our only case).
|
|
||||||
const CU_IPC_MEM_LAZY_ENABLE_PEER_ACCESS: c_uint = 0x1;
|
|
||||||
|
|
||||||
/// CUDA Driver API entry points, resolved at runtime from `libcuda.so.1` via `dlopen` rather than
|
|
||||||
/// a link-time `#[link(name = "cuda")]`. This is what lets ONE host binary run on NVIDIA
|
|
||||||
/// (zero-copy via CUDA → NVENC) *and* on AMD/Intel (VAAPI, where the NVIDIA driver — and thus
|
|
||||||
/// `libcuda` — is absent): with a hard link the loader would refuse to start the binary at all.
|
|
||||||
/// Every `cu*` call below goes through a same-named wrapper fn that forwards to this table; when
|
|
||||||
/// the driver isn't present the table is `None` and the wrappers return a non-zero `CUresult`, so
|
|
||||||
/// `context()` fails cleanly and the capturer falls back to the CPU path. The `cuda_api()` loader
|
|
||||||
/// is memoised; the library handle is intentionally leaked (process-lifetime, like the context).
|
|
||||||
struct CudaApi {
|
|
||||||
cuInit: unsafe extern "C" fn(c_uint) -> CUresult,
|
|
||||||
cuDeviceGet: unsafe extern "C" fn(*mut CUdevice, c_int) -> CUresult,
|
|
||||||
cuCtxCreate_v2: unsafe extern "C" fn(*mut CUcontext, c_uint, CUdevice) -> CUresult,
|
|
||||||
cuCtxDestroy_v2: unsafe extern "C" fn(CUcontext) -> CUresult,
|
|
||||||
cuCtxSetCurrent: unsafe extern "C" fn(CUcontext) -> CUresult,
|
|
||||||
cuMemAllocPitch_v2:
|
|
||||||
unsafe extern "C" fn(*mut CUdeviceptr, *mut usize, usize, usize, c_uint) -> CUresult,
|
|
||||||
cuMemFree_v2: unsafe extern "C" fn(CUdeviceptr) -> CUresult,
|
|
||||||
cuMemcpy2DAsync_v2: unsafe extern "C" fn(*const CUDA_MEMCPY2D, CUstream) -> CUresult,
|
|
||||||
cuStreamSynchronize: unsafe extern "C" fn(CUstream) -> CUresult,
|
|
||||||
cuCtxGetStreamPriorityRange: unsafe extern "C" fn(*mut c_int, *mut c_int) -> CUresult,
|
|
||||||
cuStreamCreateWithPriority: unsafe extern "C" fn(*mut CUstream, c_uint, c_int) -> CUresult,
|
|
||||||
cuGraphicsGLRegisterImage:
|
|
||||||
unsafe extern "C" fn(*mut CUgraphicsResource, c_uint, c_uint, c_uint) -> CUresult,
|
|
||||||
cuGraphicsMapResources:
|
|
||||||
unsafe extern "C" fn(c_uint, *mut CUgraphicsResource, *mut c_void) -> CUresult,
|
|
||||||
cuGraphicsUnmapResources:
|
|
||||||
unsafe extern "C" fn(c_uint, *mut CUgraphicsResource, *mut c_void) -> CUresult,
|
|
||||||
cuGraphicsSubResourceGetMappedArray:
|
|
||||||
unsafe extern "C" fn(*mut CUarray, CUgraphicsResource, c_uint, c_uint) -> CUresult,
|
|
||||||
cuGraphicsUnregisterResource: unsafe extern "C" fn(CUgraphicsResource) -> CUresult,
|
|
||||||
cuImportExternalMemory: unsafe extern "C" fn(
|
|
||||||
*mut CUexternalMemory,
|
|
||||||
*const CUDA_EXTERNAL_MEMORY_HANDLE_DESC,
|
|
||||||
) -> CUresult,
|
|
||||||
cuExternalMemoryGetMappedBuffer: unsafe extern "C" fn(
|
|
||||||
*mut CUdeviceptr,
|
|
||||||
CUexternalMemory,
|
|
||||||
*const CUDA_EXTERNAL_MEMORY_BUFFER_DESC,
|
|
||||||
) -> CUresult,
|
|
||||||
cuDestroyExternalMemory: unsafe extern "C" fn(CUexternalMemory) -> CUresult,
|
|
||||||
cuIpcGetMemHandle: unsafe extern "C" fn(*mut CUipcMemHandle, CUdeviceptr) -> CUresult,
|
|
||||||
cuIpcOpenMemHandle: unsafe extern "C" fn(*mut CUdeviceptr, CUipcMemHandle, c_uint) -> CUresult,
|
|
||||||
cuIpcCloseMemHandle: unsafe extern "C" fn(CUdeviceptr) -> CUresult,
|
|
||||||
// Cursor-overlay blend: a linear device alloc + a PTX module with the blend kernels launched
|
|
||||||
// over the cursor's small rectangle (see [`CursorBlend`]).
|
|
||||||
cuMemAlloc_v2: unsafe extern "C" fn(*mut CUdeviceptr, usize) -> CUresult,
|
|
||||||
cuModuleLoadData: unsafe extern "C" fn(*mut CUmodule, *const c_void) -> CUresult,
|
|
||||||
cuModuleUnload: unsafe extern "C" fn(CUmodule) -> CUresult,
|
|
||||||
cuModuleGetFunction: unsafe extern "C" fn(*mut CUfunction, CUmodule, *const c_char) -> CUresult,
|
|
||||||
#[allow(clippy::type_complexity)]
|
|
||||||
cuLaunchKernel: unsafe extern "C" fn(
|
|
||||||
CUfunction,
|
|
||||||
c_uint,
|
|
||||||
c_uint,
|
|
||||||
c_uint,
|
|
||||||
c_uint,
|
|
||||||
c_uint,
|
|
||||||
c_uint,
|
|
||||||
c_uint,
|
|
||||||
CUstream,
|
|
||||||
*mut *mut c_void,
|
|
||||||
*mut *mut c_void,
|
|
||||||
) -> CUresult,
|
|
||||||
}
|
|
||||||
// SAFETY: every field is a bare `extern "C" fn` address into the leaked, process-lifetime
|
|
||||||
// `libcuda` mapping (`cuda_api` `forget`s the `Library`, so it is never unloaded) — an immutable
|
|
||||||
// value with no interior mutability and no thread affinity. Moving the table to another thread
|
|
||||||
// cannot dangle (the code it points at stays mapped) or race (the fields are read-only).
|
|
||||||
unsafe impl Send for CudaApi {}
|
|
||||||
// SAFETY: as above — the table is a set of immutable fn-pointer addresses with no interior
|
|
||||||
// mutability, so concurrent shared reads from multiple threads cannot race; the driver entry
|
|
||||||
// points they address are themselves thread-safe.
|
|
||||||
unsafe impl Sync for CudaApi {}
|
|
||||||
|
|
||||||
/// `CUresult` returned by the wrappers when `libcuda` isn't loaded (no NVIDIA driver). Non-zero so
|
|
||||||
/// the existing `ck()`/`!= 0` checks treat it as an ordinary driver error; distinct from any real
|
|
||||||
/// `CUDA_ERROR_*` (all < 1000). Never produced by the actual driver.
|
|
||||||
const CU_ERROR_NOT_LOADED: CUresult = 999;
|
|
||||||
|
|
||||||
static CUDA_API: OnceLock<Option<CudaApi>> = OnceLock::new();
|
|
||||||
|
|
||||||
/// Resolve `libcuda.so.1` and its symbols once. `None` when the NVIDIA driver isn't installed
|
|
||||||
/// (the expected case on AMD/Intel hosts) — logged at debug, not an error.
|
|
||||||
fn cuda_api() -> Option<&'static CudaApi> {
|
|
||||||
CUDA_API
|
|
||||||
// SAFETY: `Library::new` runs `libcuda.so.1`'s initializers — it is the trusted NVIDIA
|
|
||||||
// driver library, so loading has no unexpected effects; `?`/`None` handle its absence.
|
|
||||||
// Each `lib.get::<T>(name)` asserts the symbol's real ABI equals `T`: every NUL-terminated
|
|
||||||
// name is a documented CUDA Driver API entry point and `T` is the exact
|
|
||||||
// `unsafe extern "C" fn(..)` signature from cuda.h/cudaGL.h (`_v2` for ctx/mem ops). Each
|
|
||||||
// `Symbol` only borrows `lib` until the end of the struct-literal statement; we deref-copy
|
|
||||||
// the raw fn-pointer out first, then `forget(lib)` leaks the mapping so those addresses
|
|
||||||
// stay valid for the whole process. Runs once under the `OnceLock` init — no aliasing.
|
|
||||||
.get_or_init(|| unsafe {
|
|
||||||
let lib = libloading::Library::new("libcuda.so.1")
|
|
||||||
.or_else(|_| libloading::Library::new("libcuda.so"))
|
|
||||||
.map_err(|e| {
|
|
||||||
tracing::debug!(error = %e, "libcuda not loadable — CUDA zero-copy unavailable (expected on AMD/Intel)");
|
|
||||||
})
|
|
||||||
.ok()?;
|
|
||||||
// Resolve all symbols; the field types drive `get`'s inference. `lib` is leaked after
|
|
||||||
// construction so the fn pointers stay valid for the process lifetime (the temporary
|
|
||||||
// `Symbol` borrows end with the struct-literal statement, before the forget).
|
|
||||||
let api = CudaApi {
|
|
||||||
cuInit: *lib.get(b"cuInit\0").ok()?,
|
|
||||||
cuDeviceGet: *lib.get(b"cuDeviceGet\0").ok()?,
|
|
||||||
cuCtxCreate_v2: *lib.get(b"cuCtxCreate_v2\0").ok()?,
|
|
||||||
cuCtxDestroy_v2: *lib.get(b"cuCtxDestroy_v2\0").ok()?,
|
|
||||||
cuCtxSetCurrent: *lib.get(b"cuCtxSetCurrent\0").ok()?,
|
|
||||||
cuMemAllocPitch_v2: *lib.get(b"cuMemAllocPitch_v2\0").ok()?,
|
|
||||||
cuMemFree_v2: *lib.get(b"cuMemFree_v2\0").ok()?,
|
|
||||||
cuMemcpy2DAsync_v2: *lib.get(b"cuMemcpy2DAsync_v2\0").ok()?,
|
|
||||||
cuStreamSynchronize: *lib.get(b"cuStreamSynchronize\0").ok()?,
|
|
||||||
cuCtxGetStreamPriorityRange: *lib.get(b"cuCtxGetStreamPriorityRange\0").ok()?,
|
|
||||||
cuStreamCreateWithPriority: *lib.get(b"cuStreamCreateWithPriority\0").ok()?,
|
|
||||||
cuGraphicsGLRegisterImage: *lib.get(b"cuGraphicsGLRegisterImage\0").ok()?,
|
|
||||||
cuGraphicsMapResources: *lib.get(b"cuGraphicsMapResources\0").ok()?,
|
|
||||||
cuGraphicsUnmapResources: *lib.get(b"cuGraphicsUnmapResources\0").ok()?,
|
|
||||||
cuGraphicsSubResourceGetMappedArray: *lib
|
|
||||||
.get(b"cuGraphicsSubResourceGetMappedArray\0")
|
|
||||||
.ok()?,
|
|
||||||
cuGraphicsUnregisterResource: *lib.get(b"cuGraphicsUnregisterResource\0").ok()?,
|
|
||||||
cuImportExternalMemory: *lib.get(b"cuImportExternalMemory\0").ok()?,
|
|
||||||
cuExternalMemoryGetMappedBuffer: *lib
|
|
||||||
.get(b"cuExternalMemoryGetMappedBuffer\0")
|
|
||||||
.ok()?,
|
|
||||||
cuDestroyExternalMemory: *lib.get(b"cuDestroyExternalMemory\0").ok()?,
|
|
||||||
cuIpcGetMemHandle: *lib.get(b"cuIpcGetMemHandle\0").ok()?,
|
|
||||||
// CUDA 11 renamed the entry point (per-thread-stream ABI split); every modern
|
|
||||||
// driver exports `_v2`, but accept the unsuffixed one too (same signature).
|
|
||||||
cuIpcOpenMemHandle: *lib
|
|
||||||
.get(b"cuIpcOpenMemHandle_v2\0")
|
|
||||||
.or_else(|_| lib.get(b"cuIpcOpenMemHandle\0"))
|
|
||||||
.ok()?,
|
|
||||||
cuIpcCloseMemHandle: *lib.get(b"cuIpcCloseMemHandle\0").ok()?,
|
|
||||||
cuMemAlloc_v2: *lib.get(b"cuMemAlloc_v2\0").ok()?,
|
|
||||||
cuModuleLoadData: *lib.get(b"cuModuleLoadData\0").ok()?,
|
|
||||||
cuModuleUnload: *lib.get(b"cuModuleUnload\0").ok()?,
|
|
||||||
cuModuleGetFunction: *lib.get(b"cuModuleGetFunction\0").ok()?,
|
|
||||||
cuLaunchKernel: *lib.get(b"cuLaunchKernel\0").ok()?,
|
|
||||||
};
|
|
||||||
std::mem::forget(lib); // keep libcuda mapped for the fn pointers' lifetime (process)
|
|
||||||
Some(api)
|
|
||||||
})
|
|
||||||
.as_ref()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same-named wrappers so the call sites below are unchanged. Each forwards through the dlopen'd
|
|
||||||
// table, or returns `CU_ERROR_NOT_LOADED` when the driver is absent (AMD/Intel) — which the
|
|
||||||
// `CUresult` checks already handle. Only `context()` is reachable before the driver is confirmed
|
|
||||||
// present; every other entry runs after `context()` succeeded, so its wrapper always hits `Some`.
|
|
||||||
unsafe fn cuInit(flags: c_uint) -> CUresult {
|
|
||||||
match cuda_api() {
|
|
||||||
Some(a) => (a.cuInit)(flags),
|
|
||||||
None => CU_ERROR_NOT_LOADED,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unsafe fn cuDeviceGet(device: *mut CUdevice, ordinal: c_int) -> CUresult {
|
|
||||||
match cuda_api() {
|
|
||||||
Some(a) => (a.cuDeviceGet)(device, ordinal),
|
|
||||||
None => CU_ERROR_NOT_LOADED,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unsafe fn cuCtxCreate_v2(pctx: *mut CUcontext, flags: c_uint, dev: CUdevice) -> CUresult {
|
|
||||||
match cuda_api() {
|
|
||||||
Some(a) => (a.cuCtxCreate_v2)(pctx, flags, dev),
|
|
||||||
None => CU_ERROR_NOT_LOADED,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unsafe fn cuCtxDestroy_v2(ctx: CUcontext) -> CUresult {
|
|
||||||
match cuda_api() {
|
|
||||||
Some(a) => (a.cuCtxDestroy_v2)(ctx),
|
|
||||||
None => CU_ERROR_NOT_LOADED,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unsafe fn cuCtxSetCurrent(ctx: CUcontext) -> CUresult {
|
|
||||||
match cuda_api() {
|
|
||||||
Some(a) => (a.cuCtxSetCurrent)(ctx),
|
|
||||||
None => CU_ERROR_NOT_LOADED,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unsafe fn cuMemAllocPitch_v2(
|
|
||||||
dptr: *mut CUdeviceptr,
|
|
||||||
pitch: *mut usize,
|
|
||||||
width_bytes: usize,
|
|
||||||
height: usize,
|
|
||||||
element_size: c_uint,
|
|
||||||
) -> CUresult {
|
|
||||||
match cuda_api() {
|
|
||||||
Some(a) => (a.cuMemAllocPitch_v2)(dptr, pitch, width_bytes, height, element_size),
|
|
||||||
None => CU_ERROR_NOT_LOADED,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unsafe fn cuMemFree_v2(dptr: CUdeviceptr) -> CUresult {
|
|
||||||
match cuda_api() {
|
|
||||||
Some(a) => (a.cuMemFree_v2)(dptr),
|
|
||||||
None => CU_ERROR_NOT_LOADED,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unsafe fn cuMemAlloc_v2(dptr: *mut CUdeviceptr, size: usize) -> CUresult {
|
|
||||||
match cuda_api() {
|
|
||||||
Some(a) => (a.cuMemAlloc_v2)(dptr, size),
|
|
||||||
None => CU_ERROR_NOT_LOADED,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unsafe fn cuModuleLoadData(m: *mut CUmodule, image: *const c_void) -> CUresult {
|
|
||||||
match cuda_api() {
|
|
||||||
Some(a) => (a.cuModuleLoadData)(m, image),
|
|
||||||
None => CU_ERROR_NOT_LOADED,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unsafe fn cuModuleUnload(m: CUmodule) -> CUresult {
|
|
||||||
match cuda_api() {
|
|
||||||
Some(a) => (a.cuModuleUnload)(m),
|
|
||||||
None => CU_ERROR_NOT_LOADED,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unsafe fn cuModuleGetFunction(f: *mut CUfunction, m: CUmodule, name: *const c_char) -> CUresult {
|
|
||||||
match cuda_api() {
|
|
||||||
Some(a) => (a.cuModuleGetFunction)(f, m, name),
|
|
||||||
None => CU_ERROR_NOT_LOADED,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
unsafe fn cuLaunchKernel(
|
|
||||||
f: CUfunction,
|
|
||||||
gx: c_uint,
|
|
||||||
gy: c_uint,
|
|
||||||
gz: c_uint,
|
|
||||||
bx: c_uint,
|
|
||||||
by: c_uint,
|
|
||||||
bz: c_uint,
|
|
||||||
shmem: c_uint,
|
|
||||||
stream: CUstream,
|
|
||||||
params: *mut *mut c_void,
|
|
||||||
extra: *mut *mut c_void,
|
|
||||||
) -> CUresult {
|
|
||||||
match cuda_api() {
|
|
||||||
Some(a) => (a.cuLaunchKernel)(f, gx, gy, gz, bx, by, bz, shmem, stream, params, extra),
|
|
||||||
None => CU_ERROR_NOT_LOADED,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unsafe fn cuMemcpy2DAsync_v2(copy: *const CUDA_MEMCPY2D, stream: CUstream) -> CUresult {
|
|
||||||
match cuda_api() {
|
|
||||||
Some(a) => (a.cuMemcpy2DAsync_v2)(copy, stream),
|
|
||||||
None => CU_ERROR_NOT_LOADED,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unsafe fn cuStreamSynchronize(stream: CUstream) -> CUresult {
|
|
||||||
match cuda_api() {
|
|
||||||
Some(a) => (a.cuStreamSynchronize)(stream),
|
|
||||||
None => CU_ERROR_NOT_LOADED,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unsafe fn cuCtxGetStreamPriorityRange(least: *mut c_int, greatest: *mut c_int) -> CUresult {
|
|
||||||
match cuda_api() {
|
|
||||||
Some(a) => (a.cuCtxGetStreamPriorityRange)(least, greatest),
|
|
||||||
None => CU_ERROR_NOT_LOADED,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unsafe fn cuStreamCreateWithPriority(
|
|
||||||
stream: *mut CUstream,
|
|
||||||
flags: c_uint,
|
|
||||||
priority: c_int,
|
|
||||||
) -> CUresult {
|
|
||||||
match cuda_api() {
|
|
||||||
Some(a) => (a.cuStreamCreateWithPriority)(stream, flags, priority),
|
|
||||||
None => CU_ERROR_NOT_LOADED,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unsafe fn cuGraphicsGLRegisterImage(
|
|
||||||
resource: *mut CUgraphicsResource,
|
|
||||||
texture: c_uint,
|
|
||||||
target: c_uint,
|
|
||||||
flags: c_uint,
|
|
||||||
) -> CUresult {
|
|
||||||
match cuda_api() {
|
|
||||||
Some(a) => (a.cuGraphicsGLRegisterImage)(resource, texture, target, flags),
|
|
||||||
None => CU_ERROR_NOT_LOADED,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unsafe fn cuGraphicsMapResources(
|
|
||||||
count: c_uint,
|
|
||||||
resources: *mut CUgraphicsResource,
|
|
||||||
stream: *mut c_void,
|
|
||||||
) -> CUresult {
|
|
||||||
match cuda_api() {
|
|
||||||
Some(a) => (a.cuGraphicsMapResources)(count, resources, stream),
|
|
||||||
None => CU_ERROR_NOT_LOADED,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unsafe fn cuGraphicsUnmapResources(
|
|
||||||
count: c_uint,
|
|
||||||
resources: *mut CUgraphicsResource,
|
|
||||||
stream: *mut c_void,
|
|
||||||
) -> CUresult {
|
|
||||||
match cuda_api() {
|
|
||||||
Some(a) => (a.cuGraphicsUnmapResources)(count, resources, stream),
|
|
||||||
None => CU_ERROR_NOT_LOADED,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unsafe fn cuGraphicsSubResourceGetMappedArray(
|
|
||||||
array: *mut CUarray,
|
|
||||||
resource: CUgraphicsResource,
|
|
||||||
array_index: c_uint,
|
|
||||||
mip_level: c_uint,
|
|
||||||
) -> CUresult {
|
|
||||||
match cuda_api() {
|
|
||||||
Some(a) => (a.cuGraphicsSubResourceGetMappedArray)(array, resource, array_index, mip_level),
|
|
||||||
None => CU_ERROR_NOT_LOADED,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unsafe fn cuGraphicsUnregisterResource(resource: CUgraphicsResource) -> CUresult {
|
|
||||||
match cuda_api() {
|
|
||||||
Some(a) => (a.cuGraphicsUnregisterResource)(resource),
|
|
||||||
None => CU_ERROR_NOT_LOADED,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unsafe fn cuImportExternalMemory(
|
|
||||||
ext_mem_out: *mut CUexternalMemory,
|
|
||||||
mem_handle_desc: *const CUDA_EXTERNAL_MEMORY_HANDLE_DESC,
|
|
||||||
) -> CUresult {
|
|
||||||
match cuda_api() {
|
|
||||||
Some(a) => (a.cuImportExternalMemory)(ext_mem_out, mem_handle_desc),
|
|
||||||
None => CU_ERROR_NOT_LOADED,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unsafe fn cuExternalMemoryGetMappedBuffer(
|
|
||||||
dev_ptr: *mut CUdeviceptr,
|
|
||||||
ext_mem: CUexternalMemory,
|
|
||||||
buffer_desc: *const CUDA_EXTERNAL_MEMORY_BUFFER_DESC,
|
|
||||||
) -> CUresult {
|
|
||||||
match cuda_api() {
|
|
||||||
Some(a) => (a.cuExternalMemoryGetMappedBuffer)(dev_ptr, ext_mem, buffer_desc),
|
|
||||||
None => CU_ERROR_NOT_LOADED,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unsafe fn cuDestroyExternalMemory(ext_mem: CUexternalMemory) -> CUresult {
|
|
||||||
match cuda_api() {
|
|
||||||
Some(a) => (a.cuDestroyExternalMemory)(ext_mem),
|
|
||||||
None => CU_ERROR_NOT_LOADED,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unsafe fn cuIpcGetMemHandle(handle: *mut CUipcMemHandle, dptr: CUdeviceptr) -> CUresult {
|
|
||||||
match cuda_api() {
|
|
||||||
Some(a) => (a.cuIpcGetMemHandle)(handle, dptr),
|
|
||||||
None => CU_ERROR_NOT_LOADED,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unsafe fn cuIpcOpenMemHandle(
|
|
||||||
dptr: *mut CUdeviceptr,
|
|
||||||
handle: CUipcMemHandle,
|
|
||||||
flags: c_uint,
|
|
||||||
) -> CUresult {
|
|
||||||
match cuda_api() {
|
|
||||||
Some(a) => (a.cuIpcOpenMemHandle)(dptr, handle, flags),
|
|
||||||
None => CU_ERROR_NOT_LOADED,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unsafe fn cuIpcCloseMemHandle(dptr: CUdeviceptr) -> CUresult {
|
|
||||||
match cuda_api() {
|
|
||||||
Some(a) => (a.cuIpcCloseMemHandle)(dptr),
|
|
||||||
None => CU_ERROR_NOT_LOADED,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn ck(r: CUresult, what: &str) -> Result<()> {
|
|
||||||
if r == 0 {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
bail!("CUDA driver error {r} in {what}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Copy a pitched device plane `(src_ptr, src_pitch)` down to a tightly-packed host buffer of
|
/// Copy a pitched device plane `(src_ptr, src_pitch)` down to a tightly-packed host buffer of
|
||||||
/// `width_bytes`×`height` (no row padding). Synchronous on the priority stream. Used by the NV12
|
/// `width_bytes`×`height` (no row padding). Synchronous on the priority stream. Used by the NV12
|
||||||
|
|||||||
@@ -0,0 +1,488 @@
|
|||||||
|
//! Raw CUDA Driver API FFI (plan §W4, carved out of the zero-copy CUDA facade): the opaque handle
|
||||||
|
//! typedefs + struct/const definitions, the `dlopen`'d `libcuda.so.1` symbol table ([`CudaApi`] +
|
||||||
|
//! [`cuda_api`]), the `unsafe` `cuXxx` wrappers, and the `ck` result check. No higher-level state —
|
||||||
|
//! the shared `CUcontext`, device buffers, GL/dmabuf interop, and cursor blend all live in [`super`]
|
||||||
|
//! and drive this layer.
|
||||||
|
|
||||||
|
#![allow(non_camel_case_types, non_snake_case)]
|
||||||
|
// Every `unsafe` block/impl below carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
use std::os::raw::{c_char, c_int, c_uint, c_void};
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
pub type CUresult = c_uint; // CUDA_SUCCESS == 0
|
||||||
|
pub type CUdevice = c_int;
|
||||||
|
pub type CUcontext = *mut c_void; // opaque CUctx_st*
|
||||||
|
pub type CUstream = *mut c_void; // opaque CUstream_st*
|
||||||
|
pub type CUdeviceptr = u64;
|
||||||
|
pub type CUgraphicsResource = *mut c_void;
|
||||||
|
pub type CUarray = *mut c_void;
|
||||||
|
pub type CUexternalMemory = *mut c_void; // opaque CUextMemory_st*
|
||||||
|
pub type CUmodule = *mut c_void; // opaque CUmod_st*
|
||||||
|
pub type CUfunction = *mut c_void; // opaque CUfunc_st*
|
||||||
|
|
||||||
|
/// `CUmemorytype` (cuda.h): HOST=1, DEVICE=2, ARRAY=3, UNIFIED=4.
|
||||||
|
pub const CU_MEMORYTYPE_DEVICE: c_uint = 2;
|
||||||
|
pub const CU_MEMORYTYPE_ARRAY: c_uint = 3;
|
||||||
|
|
||||||
|
/// `CUctx_flags` (cuda.h): block the CPU on an OS primitive while waiting for the GPU instead of
|
||||||
|
/// busy-spinning. On this shared box (compositor + send thread on the same cores) spinning a core
|
||||||
|
/// to detect copy completion steals CPU from the very threads we want scheduled; BLOCKING_SYNC
|
||||||
|
/// frees it. Default (`CU_CTX_SCHED_AUTO=0`) heuristically picks SPIN vs YIELD by core count.
|
||||||
|
pub(crate) const CU_CTX_SCHED_BLOCKING_SYNC: c_uint = 0x04;
|
||||||
|
|
||||||
|
/// `cuStreamCreateWithPriority` flag: don't implicitly synchronize with the legacy NULL stream.
|
||||||
|
pub(crate) const CU_STREAM_NON_BLOCKING: c_uint = 0x01;
|
||||||
|
|
||||||
|
/// `CUDA_MEMCPY2D` (cuda.h, `_v2` ABI). Field order is load-bearing.
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct CUDA_MEMCPY2D {
|
||||||
|
pub srcXInBytes: usize,
|
||||||
|
pub srcY: usize,
|
||||||
|
pub srcMemoryType: c_uint,
|
||||||
|
pub srcHost: *const c_void,
|
||||||
|
pub srcDevice: CUdeviceptr,
|
||||||
|
pub srcArray: CUarray,
|
||||||
|
pub srcPitch: usize,
|
||||||
|
pub dstXInBytes: usize,
|
||||||
|
pub dstY: usize,
|
||||||
|
pub dstMemoryType: c_uint,
|
||||||
|
pub dstHost: *mut c_void,
|
||||||
|
pub dstDevice: CUdeviceptr,
|
||||||
|
pub dstArray: CUarray,
|
||||||
|
pub dstPitch: usize,
|
||||||
|
pub WidthInBytes: usize,
|
||||||
|
pub Height: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `CUDA_EXTERNAL_MEMORY_HANDLE_DESC` (cuda.h, 64-bit layout). `handle` is a union whose
|
||||||
|
/// largest member is the win32 two-pointer struct (16 bytes, align 8); for the OPAQUE_FD type
|
||||||
|
/// only the first 4 bytes (the `int fd`) are read.
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct CUDA_EXTERNAL_MEMORY_HANDLE_DESC {
|
||||||
|
pub type_: c_uint, // CU_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD = 1
|
||||||
|
pub(crate) _pad: u32,
|
||||||
|
pub handle: [u64; 2], // union { int fd; {void*,void*} win32; void* nvSciBufObject }
|
||||||
|
pub size: u64,
|
||||||
|
pub flags: c_uint,
|
||||||
|
pub(crate) reserved: [c_uint; 16],
|
||||||
|
pub(crate) _pad2: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `CUDA_EXTERNAL_MEMORY_BUFFER_DESC` (cuda.h, 64-bit layout).
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct CUDA_EXTERNAL_MEMORY_BUFFER_DESC {
|
||||||
|
pub offset: u64,
|
||||||
|
pub size: u64,
|
||||||
|
pub flags: c_uint,
|
||||||
|
pub(crate) reserved: [c_uint; 16],
|
||||||
|
pub(crate) _pad: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const CU_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD: c_uint = 1;
|
||||||
|
|
||||||
|
/// `CUipcMemHandle` (cuda.h): an opaque 64-byte struct identifying a device allocation across
|
||||||
|
/// processes. Produced by `cuIpcGetMemHandle` in the exporting process, consumed by
|
||||||
|
/// `cuIpcOpenMemHandle` in the importer — passed **by value**, matching the C
|
||||||
|
/// `struct { char reserved[64]; }`. Plain bytes — safe to ship over a socket.
|
||||||
|
pub const CU_IPC_HANDLE_SIZE: usize = 64;
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct CUipcMemHandle {
|
||||||
|
pub reserved: [u8; CU_IPC_HANDLE_SIZE],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `CUipcMem_flags`: lazily enable peer access on open (the documented flag for
|
||||||
|
/// `cuIpcOpenMemHandle`; a no-op for a same-device open, which is our only case).
|
||||||
|
pub(crate) const CU_IPC_MEM_LAZY_ENABLE_PEER_ACCESS: c_uint = 0x1;
|
||||||
|
|
||||||
|
/// CUDA Driver API entry points, resolved at runtime from `libcuda.so.1` via `dlopen` rather than
|
||||||
|
/// a link-time `#[link(name = "cuda")]`. This is what lets ONE host binary run on NVIDIA
|
||||||
|
/// (zero-copy via CUDA → NVENC) *and* on AMD/Intel (VAAPI, where the NVIDIA driver — and thus
|
||||||
|
/// `libcuda` — is absent): with a hard link the loader would refuse to start the binary at all.
|
||||||
|
/// Every `cu*` call below goes through a same-named wrapper fn that forwards to this table; when
|
||||||
|
/// the driver isn't present the table is `None` and the wrappers return a non-zero `CUresult`, so
|
||||||
|
/// `context()` fails cleanly and the capturer falls back to the CPU path. The `cuda_api()` loader
|
||||||
|
/// is memoised; the library handle is intentionally leaked (process-lifetime, like the context).
|
||||||
|
pub(crate) struct CudaApi {
|
||||||
|
cuInit: unsafe extern "C" fn(c_uint) -> CUresult,
|
||||||
|
cuDeviceGet: unsafe extern "C" fn(*mut CUdevice, c_int) -> CUresult,
|
||||||
|
cuCtxCreate_v2: unsafe extern "C" fn(*mut CUcontext, c_uint, CUdevice) -> CUresult,
|
||||||
|
cuCtxDestroy_v2: unsafe extern "C" fn(CUcontext) -> CUresult,
|
||||||
|
cuCtxSetCurrent: unsafe extern "C" fn(CUcontext) -> CUresult,
|
||||||
|
cuMemAllocPitch_v2:
|
||||||
|
unsafe extern "C" fn(*mut CUdeviceptr, *mut usize, usize, usize, c_uint) -> CUresult,
|
||||||
|
cuMemFree_v2: unsafe extern "C" fn(CUdeviceptr) -> CUresult,
|
||||||
|
cuMemcpy2DAsync_v2: unsafe extern "C" fn(*const CUDA_MEMCPY2D, CUstream) -> CUresult,
|
||||||
|
cuStreamSynchronize: unsafe extern "C" fn(CUstream) -> CUresult,
|
||||||
|
cuCtxGetStreamPriorityRange: unsafe extern "C" fn(*mut c_int, *mut c_int) -> CUresult,
|
||||||
|
cuStreamCreateWithPriority: unsafe extern "C" fn(*mut CUstream, c_uint, c_int) -> CUresult,
|
||||||
|
cuGraphicsGLRegisterImage:
|
||||||
|
unsafe extern "C" fn(*mut CUgraphicsResource, c_uint, c_uint, c_uint) -> CUresult,
|
||||||
|
cuGraphicsMapResources:
|
||||||
|
unsafe extern "C" fn(c_uint, *mut CUgraphicsResource, *mut c_void) -> CUresult,
|
||||||
|
cuGraphicsUnmapResources:
|
||||||
|
unsafe extern "C" fn(c_uint, *mut CUgraphicsResource, *mut c_void) -> CUresult,
|
||||||
|
cuGraphicsSubResourceGetMappedArray:
|
||||||
|
unsafe extern "C" fn(*mut CUarray, CUgraphicsResource, c_uint, c_uint) -> CUresult,
|
||||||
|
cuGraphicsUnregisterResource: unsafe extern "C" fn(CUgraphicsResource) -> CUresult,
|
||||||
|
cuImportExternalMemory: unsafe extern "C" fn(
|
||||||
|
*mut CUexternalMemory,
|
||||||
|
*const CUDA_EXTERNAL_MEMORY_HANDLE_DESC,
|
||||||
|
) -> CUresult,
|
||||||
|
cuExternalMemoryGetMappedBuffer: unsafe extern "C" fn(
|
||||||
|
*mut CUdeviceptr,
|
||||||
|
CUexternalMemory,
|
||||||
|
*const CUDA_EXTERNAL_MEMORY_BUFFER_DESC,
|
||||||
|
) -> CUresult,
|
||||||
|
cuDestroyExternalMemory: unsafe extern "C" fn(CUexternalMemory) -> CUresult,
|
||||||
|
cuIpcGetMemHandle: unsafe extern "C" fn(*mut CUipcMemHandle, CUdeviceptr) -> CUresult,
|
||||||
|
cuIpcOpenMemHandle: unsafe extern "C" fn(*mut CUdeviceptr, CUipcMemHandle, c_uint) -> CUresult,
|
||||||
|
cuIpcCloseMemHandle: unsafe extern "C" fn(CUdeviceptr) -> CUresult,
|
||||||
|
// Cursor-overlay blend: a linear device alloc + a PTX module with the blend kernels launched
|
||||||
|
// over the cursor's small rectangle (see [`CursorBlend`]).
|
||||||
|
cuMemAlloc_v2: unsafe extern "C" fn(*mut CUdeviceptr, usize) -> CUresult,
|
||||||
|
cuModuleLoadData: unsafe extern "C" fn(*mut CUmodule, *const c_void) -> CUresult,
|
||||||
|
cuModuleUnload: unsafe extern "C" fn(CUmodule) -> CUresult,
|
||||||
|
cuModuleGetFunction: unsafe extern "C" fn(*mut CUfunction, CUmodule, *const c_char) -> CUresult,
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
cuLaunchKernel: unsafe extern "C" fn(
|
||||||
|
CUfunction,
|
||||||
|
c_uint,
|
||||||
|
c_uint,
|
||||||
|
c_uint,
|
||||||
|
c_uint,
|
||||||
|
c_uint,
|
||||||
|
c_uint,
|
||||||
|
c_uint,
|
||||||
|
CUstream,
|
||||||
|
*mut *mut c_void,
|
||||||
|
*mut *mut c_void,
|
||||||
|
) -> CUresult,
|
||||||
|
}
|
||||||
|
// SAFETY: every field is a bare `extern "C" fn` address into the leaked, process-lifetime
|
||||||
|
// `libcuda` mapping (`cuda_api` `forget`s the `Library`, so it is never unloaded) — an immutable
|
||||||
|
// value with no interior mutability and no thread affinity. Moving the table to another thread
|
||||||
|
// cannot dangle (the code it points at stays mapped) or race (the fields are read-only).
|
||||||
|
unsafe impl Send for CudaApi {}
|
||||||
|
// SAFETY: as above — the table is a set of immutable fn-pointer addresses with no interior
|
||||||
|
// mutability, so concurrent shared reads from multiple threads cannot race; the driver entry
|
||||||
|
// points they address are themselves thread-safe.
|
||||||
|
unsafe impl Sync for CudaApi {}
|
||||||
|
|
||||||
|
/// `CUresult` returned by the wrappers when `libcuda` isn't loaded (no NVIDIA driver). Non-zero so
|
||||||
|
/// the existing `ck()`/`!= 0` checks treat it as an ordinary driver error; distinct from any real
|
||||||
|
/// `CUDA_ERROR_*` (all < 1000). Never produced by the actual driver.
|
||||||
|
pub(crate) const CU_ERROR_NOT_LOADED: CUresult = 999;
|
||||||
|
|
||||||
|
pub(crate) static CUDA_API: OnceLock<Option<CudaApi>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Resolve `libcuda.so.1` and its symbols once. `None` when the NVIDIA driver isn't installed
|
||||||
|
/// (the expected case on AMD/Intel hosts) — logged at debug, not an error.
|
||||||
|
pub(crate) fn cuda_api() -> Option<&'static CudaApi> {
|
||||||
|
CUDA_API
|
||||||
|
// SAFETY: `Library::new` runs `libcuda.so.1`'s initializers — it is the trusted NVIDIA
|
||||||
|
// driver library, so loading has no unexpected effects; `?`/`None` handle its absence.
|
||||||
|
// Each `lib.get::<T>(name)` asserts the symbol's real ABI equals `T`: every NUL-terminated
|
||||||
|
// name is a documented CUDA Driver API entry point and `T` is the exact
|
||||||
|
// `unsafe extern "C" fn(..)` signature from cuda.h/cudaGL.h (`_v2` for ctx/mem ops). Each
|
||||||
|
// `Symbol` only borrows `lib` until the end of the struct-literal statement; we deref-copy
|
||||||
|
// the raw fn-pointer out first, then `forget(lib)` leaks the mapping so those addresses
|
||||||
|
// stay valid for the whole process. Runs once under the `OnceLock` init — no aliasing.
|
||||||
|
.get_or_init(|| unsafe {
|
||||||
|
let lib = libloading::Library::new("libcuda.so.1")
|
||||||
|
.or_else(|_| libloading::Library::new("libcuda.so"))
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::debug!(error = %e, "libcuda not loadable — CUDA zero-copy unavailable (expected on AMD/Intel)");
|
||||||
|
})
|
||||||
|
.ok()?;
|
||||||
|
// Resolve all symbols; the field types drive `get`'s inference. `lib` is leaked after
|
||||||
|
// construction so the fn pointers stay valid for the process lifetime (the temporary
|
||||||
|
// `Symbol` borrows end with the struct-literal statement, before the forget).
|
||||||
|
let api = CudaApi {
|
||||||
|
cuInit: *lib.get(b"cuInit\0").ok()?,
|
||||||
|
cuDeviceGet: *lib.get(b"cuDeviceGet\0").ok()?,
|
||||||
|
cuCtxCreate_v2: *lib.get(b"cuCtxCreate_v2\0").ok()?,
|
||||||
|
cuCtxDestroy_v2: *lib.get(b"cuCtxDestroy_v2\0").ok()?,
|
||||||
|
cuCtxSetCurrent: *lib.get(b"cuCtxSetCurrent\0").ok()?,
|
||||||
|
cuMemAllocPitch_v2: *lib.get(b"cuMemAllocPitch_v2\0").ok()?,
|
||||||
|
cuMemFree_v2: *lib.get(b"cuMemFree_v2\0").ok()?,
|
||||||
|
cuMemcpy2DAsync_v2: *lib.get(b"cuMemcpy2DAsync_v2\0").ok()?,
|
||||||
|
cuStreamSynchronize: *lib.get(b"cuStreamSynchronize\0").ok()?,
|
||||||
|
cuCtxGetStreamPriorityRange: *lib.get(b"cuCtxGetStreamPriorityRange\0").ok()?,
|
||||||
|
cuStreamCreateWithPriority: *lib.get(b"cuStreamCreateWithPriority\0").ok()?,
|
||||||
|
cuGraphicsGLRegisterImage: *lib.get(b"cuGraphicsGLRegisterImage\0").ok()?,
|
||||||
|
cuGraphicsMapResources: *lib.get(b"cuGraphicsMapResources\0").ok()?,
|
||||||
|
cuGraphicsUnmapResources: *lib.get(b"cuGraphicsUnmapResources\0").ok()?,
|
||||||
|
cuGraphicsSubResourceGetMappedArray: *lib
|
||||||
|
.get(b"cuGraphicsSubResourceGetMappedArray\0")
|
||||||
|
.ok()?,
|
||||||
|
cuGraphicsUnregisterResource: *lib.get(b"cuGraphicsUnregisterResource\0").ok()?,
|
||||||
|
cuImportExternalMemory: *lib.get(b"cuImportExternalMemory\0").ok()?,
|
||||||
|
cuExternalMemoryGetMappedBuffer: *lib
|
||||||
|
.get(b"cuExternalMemoryGetMappedBuffer\0")
|
||||||
|
.ok()?,
|
||||||
|
cuDestroyExternalMemory: *lib.get(b"cuDestroyExternalMemory\0").ok()?,
|
||||||
|
cuIpcGetMemHandle: *lib.get(b"cuIpcGetMemHandle\0").ok()?,
|
||||||
|
// CUDA 11 renamed the entry point (per-thread-stream ABI split); every modern
|
||||||
|
// driver exports `_v2`, but accept the unsuffixed one too (same signature).
|
||||||
|
cuIpcOpenMemHandle: *lib
|
||||||
|
.get(b"cuIpcOpenMemHandle_v2\0")
|
||||||
|
.or_else(|_| lib.get(b"cuIpcOpenMemHandle\0"))
|
||||||
|
.ok()?,
|
||||||
|
cuIpcCloseMemHandle: *lib.get(b"cuIpcCloseMemHandle\0").ok()?,
|
||||||
|
cuMemAlloc_v2: *lib.get(b"cuMemAlloc_v2\0").ok()?,
|
||||||
|
cuModuleLoadData: *lib.get(b"cuModuleLoadData\0").ok()?,
|
||||||
|
cuModuleUnload: *lib.get(b"cuModuleUnload\0").ok()?,
|
||||||
|
cuModuleGetFunction: *lib.get(b"cuModuleGetFunction\0").ok()?,
|
||||||
|
cuLaunchKernel: *lib.get(b"cuLaunchKernel\0").ok()?,
|
||||||
|
};
|
||||||
|
std::mem::forget(lib); // keep libcuda mapped for the fn pointers' lifetime (process)
|
||||||
|
Some(api)
|
||||||
|
})
|
||||||
|
.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same-named wrappers so the call sites below are unchanged. Each forwards through the dlopen'd
|
||||||
|
// table, or returns `CU_ERROR_NOT_LOADED` when the driver is absent (AMD/Intel) — which the
|
||||||
|
// `CUresult` checks already handle. Only `context()` is reachable before the driver is confirmed
|
||||||
|
// present; every other entry runs after `context()` succeeded, so its wrapper always hits `Some`.
|
||||||
|
pub(crate) unsafe fn cuInit(flags: c_uint) -> CUresult {
|
||||||
|
match cuda_api() {
|
||||||
|
Some(a) => (a.cuInit)(flags),
|
||||||
|
None => CU_ERROR_NOT_LOADED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) unsafe fn cuDeviceGet(device: *mut CUdevice, ordinal: c_int) -> CUresult {
|
||||||
|
match cuda_api() {
|
||||||
|
Some(a) => (a.cuDeviceGet)(device, ordinal),
|
||||||
|
None => CU_ERROR_NOT_LOADED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) unsafe fn cuCtxCreate_v2(
|
||||||
|
pctx: *mut CUcontext,
|
||||||
|
flags: c_uint,
|
||||||
|
dev: CUdevice,
|
||||||
|
) -> CUresult {
|
||||||
|
match cuda_api() {
|
||||||
|
Some(a) => (a.cuCtxCreate_v2)(pctx, flags, dev),
|
||||||
|
None => CU_ERROR_NOT_LOADED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) unsafe fn cuCtxDestroy_v2(ctx: CUcontext) -> CUresult {
|
||||||
|
match cuda_api() {
|
||||||
|
Some(a) => (a.cuCtxDestroy_v2)(ctx),
|
||||||
|
None => CU_ERROR_NOT_LOADED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) unsafe fn cuCtxSetCurrent(ctx: CUcontext) -> CUresult {
|
||||||
|
match cuda_api() {
|
||||||
|
Some(a) => (a.cuCtxSetCurrent)(ctx),
|
||||||
|
None => CU_ERROR_NOT_LOADED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) unsafe fn cuMemAllocPitch_v2(
|
||||||
|
dptr: *mut CUdeviceptr,
|
||||||
|
pitch: *mut usize,
|
||||||
|
width_bytes: usize,
|
||||||
|
height: usize,
|
||||||
|
element_size: c_uint,
|
||||||
|
) -> CUresult {
|
||||||
|
match cuda_api() {
|
||||||
|
Some(a) => (a.cuMemAllocPitch_v2)(dptr, pitch, width_bytes, height, element_size),
|
||||||
|
None => CU_ERROR_NOT_LOADED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) unsafe fn cuMemFree_v2(dptr: CUdeviceptr) -> CUresult {
|
||||||
|
match cuda_api() {
|
||||||
|
Some(a) => (a.cuMemFree_v2)(dptr),
|
||||||
|
None => CU_ERROR_NOT_LOADED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) unsafe fn cuMemAlloc_v2(dptr: *mut CUdeviceptr, size: usize) -> CUresult {
|
||||||
|
match cuda_api() {
|
||||||
|
Some(a) => (a.cuMemAlloc_v2)(dptr, size),
|
||||||
|
None => CU_ERROR_NOT_LOADED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) unsafe fn cuModuleLoadData(m: *mut CUmodule, image: *const c_void) -> CUresult {
|
||||||
|
match cuda_api() {
|
||||||
|
Some(a) => (a.cuModuleLoadData)(m, image),
|
||||||
|
None => CU_ERROR_NOT_LOADED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) unsafe fn cuModuleUnload(m: CUmodule) -> CUresult {
|
||||||
|
match cuda_api() {
|
||||||
|
Some(a) => (a.cuModuleUnload)(m),
|
||||||
|
None => CU_ERROR_NOT_LOADED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) unsafe fn cuModuleGetFunction(
|
||||||
|
f: *mut CUfunction,
|
||||||
|
m: CUmodule,
|
||||||
|
name: *const c_char,
|
||||||
|
) -> CUresult {
|
||||||
|
match cuda_api() {
|
||||||
|
Some(a) => (a.cuModuleGetFunction)(f, m, name),
|
||||||
|
None => CU_ERROR_NOT_LOADED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(crate) unsafe fn cuLaunchKernel(
|
||||||
|
f: CUfunction,
|
||||||
|
gx: c_uint,
|
||||||
|
gy: c_uint,
|
||||||
|
gz: c_uint,
|
||||||
|
bx: c_uint,
|
||||||
|
by: c_uint,
|
||||||
|
bz: c_uint,
|
||||||
|
shmem: c_uint,
|
||||||
|
stream: CUstream,
|
||||||
|
params: *mut *mut c_void,
|
||||||
|
extra: *mut *mut c_void,
|
||||||
|
) -> CUresult {
|
||||||
|
match cuda_api() {
|
||||||
|
Some(a) => (a.cuLaunchKernel)(f, gx, gy, gz, bx, by, bz, shmem, stream, params, extra),
|
||||||
|
None => CU_ERROR_NOT_LOADED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) unsafe fn cuMemcpy2DAsync_v2(copy: *const CUDA_MEMCPY2D, stream: CUstream) -> CUresult {
|
||||||
|
match cuda_api() {
|
||||||
|
Some(a) => (a.cuMemcpy2DAsync_v2)(copy, stream),
|
||||||
|
None => CU_ERROR_NOT_LOADED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) unsafe fn cuStreamSynchronize(stream: CUstream) -> CUresult {
|
||||||
|
match cuda_api() {
|
||||||
|
Some(a) => (a.cuStreamSynchronize)(stream),
|
||||||
|
None => CU_ERROR_NOT_LOADED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) unsafe fn cuCtxGetStreamPriorityRange(
|
||||||
|
least: *mut c_int,
|
||||||
|
greatest: *mut c_int,
|
||||||
|
) -> CUresult {
|
||||||
|
match cuda_api() {
|
||||||
|
Some(a) => (a.cuCtxGetStreamPriorityRange)(least, greatest),
|
||||||
|
None => CU_ERROR_NOT_LOADED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) unsafe fn cuStreamCreateWithPriority(
|
||||||
|
stream: *mut CUstream,
|
||||||
|
flags: c_uint,
|
||||||
|
priority: c_int,
|
||||||
|
) -> CUresult {
|
||||||
|
match cuda_api() {
|
||||||
|
Some(a) => (a.cuStreamCreateWithPriority)(stream, flags, priority),
|
||||||
|
None => CU_ERROR_NOT_LOADED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) unsafe fn cuGraphicsGLRegisterImage(
|
||||||
|
resource: *mut CUgraphicsResource,
|
||||||
|
texture: c_uint,
|
||||||
|
target: c_uint,
|
||||||
|
flags: c_uint,
|
||||||
|
) -> CUresult {
|
||||||
|
match cuda_api() {
|
||||||
|
Some(a) => (a.cuGraphicsGLRegisterImage)(resource, texture, target, flags),
|
||||||
|
None => CU_ERROR_NOT_LOADED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) unsafe fn cuGraphicsMapResources(
|
||||||
|
count: c_uint,
|
||||||
|
resources: *mut CUgraphicsResource,
|
||||||
|
stream: *mut c_void,
|
||||||
|
) -> CUresult {
|
||||||
|
match cuda_api() {
|
||||||
|
Some(a) => (a.cuGraphicsMapResources)(count, resources, stream),
|
||||||
|
None => CU_ERROR_NOT_LOADED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) unsafe fn cuGraphicsUnmapResources(
|
||||||
|
count: c_uint,
|
||||||
|
resources: *mut CUgraphicsResource,
|
||||||
|
stream: *mut c_void,
|
||||||
|
) -> CUresult {
|
||||||
|
match cuda_api() {
|
||||||
|
Some(a) => (a.cuGraphicsUnmapResources)(count, resources, stream),
|
||||||
|
None => CU_ERROR_NOT_LOADED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) unsafe fn cuGraphicsSubResourceGetMappedArray(
|
||||||
|
array: *mut CUarray,
|
||||||
|
resource: CUgraphicsResource,
|
||||||
|
array_index: c_uint,
|
||||||
|
mip_level: c_uint,
|
||||||
|
) -> CUresult {
|
||||||
|
match cuda_api() {
|
||||||
|
Some(a) => (a.cuGraphicsSubResourceGetMappedArray)(array, resource, array_index, mip_level),
|
||||||
|
None => CU_ERROR_NOT_LOADED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) unsafe fn cuGraphicsUnregisterResource(resource: CUgraphicsResource) -> CUresult {
|
||||||
|
match cuda_api() {
|
||||||
|
Some(a) => (a.cuGraphicsUnregisterResource)(resource),
|
||||||
|
None => CU_ERROR_NOT_LOADED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) unsafe fn cuImportExternalMemory(
|
||||||
|
ext_mem_out: *mut CUexternalMemory,
|
||||||
|
mem_handle_desc: *const CUDA_EXTERNAL_MEMORY_HANDLE_DESC,
|
||||||
|
) -> CUresult {
|
||||||
|
match cuda_api() {
|
||||||
|
Some(a) => (a.cuImportExternalMemory)(ext_mem_out, mem_handle_desc),
|
||||||
|
None => CU_ERROR_NOT_LOADED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) unsafe fn cuExternalMemoryGetMappedBuffer(
|
||||||
|
dev_ptr: *mut CUdeviceptr,
|
||||||
|
ext_mem: CUexternalMemory,
|
||||||
|
buffer_desc: *const CUDA_EXTERNAL_MEMORY_BUFFER_DESC,
|
||||||
|
) -> CUresult {
|
||||||
|
match cuda_api() {
|
||||||
|
Some(a) => (a.cuExternalMemoryGetMappedBuffer)(dev_ptr, ext_mem, buffer_desc),
|
||||||
|
None => CU_ERROR_NOT_LOADED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) unsafe fn cuDestroyExternalMemory(ext_mem: CUexternalMemory) -> CUresult {
|
||||||
|
match cuda_api() {
|
||||||
|
Some(a) => (a.cuDestroyExternalMemory)(ext_mem),
|
||||||
|
None => CU_ERROR_NOT_LOADED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) unsafe fn cuIpcGetMemHandle(handle: *mut CUipcMemHandle, dptr: CUdeviceptr) -> CUresult {
|
||||||
|
match cuda_api() {
|
||||||
|
Some(a) => (a.cuIpcGetMemHandle)(handle, dptr),
|
||||||
|
None => CU_ERROR_NOT_LOADED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) unsafe fn cuIpcOpenMemHandle(
|
||||||
|
dptr: *mut CUdeviceptr,
|
||||||
|
handle: CUipcMemHandle,
|
||||||
|
flags: c_uint,
|
||||||
|
) -> CUresult {
|
||||||
|
match cuda_api() {
|
||||||
|
Some(a) => (a.cuIpcOpenMemHandle)(dptr, handle, flags),
|
||||||
|
None => CU_ERROR_NOT_LOADED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) unsafe fn cuIpcCloseMemHandle(dptr: CUdeviceptr) -> CUresult {
|
||||||
|
match cuda_api() {
|
||||||
|
Some(a) => (a.cuIpcCloseMemHandle)(dptr),
|
||||||
|
None => CU_ERROR_NOT_LOADED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub(crate) fn ck(r: CUresult, what: &str) -> Result<()> {
|
||||||
|
if r == 0 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
bail!("CUDA driver error {r} in {what}")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use super::cuda::{self, DeviceBuffer};
|
use super::cuda::{self, DeviceBuffer};
|
||||||
use anyhow::{bail, ensure, Context as _, Result};
|
use anyhow::{ensure, Context as _, Result};
|
||||||
use khronos_egl as egl;
|
use khronos_egl as egl;
|
||||||
use std::os::raw::{c_int, c_void};
|
use std::os::raw::{c_int, c_void};
|
||||||
|
|
||||||
@@ -30,173 +30,9 @@ const EGL_DMA_BUF_PLANE0_PITCH_EXT: egl::Attrib = 0x3274;
|
|||||||
const EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT: egl::Attrib = 0x3443;
|
const EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT: egl::Attrib = 0x3443;
|
||||||
const EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT: egl::Attrib = 0x3444;
|
const EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT: egl::Attrib = 0x3444;
|
||||||
|
|
||||||
const GL_TEXTURE_2D: u32 = 0x0DE1;
|
#[path = "egl/gl.rs"]
|
||||||
const GL_TEXTURE_MIN_FILTER: u32 = 0x2801;
|
mod gl;
|
||||||
const GL_TEXTURE_MAG_FILTER: u32 = 0x2800;
|
use gl::*;
|
||||||
const GL_LINEAR: c_int = 0x2601;
|
|
||||||
const GL_NEAREST: c_int = 0x2600;
|
|
||||||
const GL_RGBA8: u32 = 0x8058;
|
|
||||||
// Single/dual-channel 8-bit formats for the NV12 convert targets: R8 luma (full-res),
|
|
||||||
// RG8 interleaved chroma (half-res). The `_RED`/`_RG` enums are the matching client formats.
|
|
||||||
const GL_R8: u32 = 0x8229;
|
|
||||||
const GL_RG8: u32 = 0x822B;
|
|
||||||
// Client pixel format/type for texture uploads (self-test only): RGBA bytes.
|
|
||||||
const GL_RGBA: u32 = 0x1908;
|
|
||||||
const GL_UNSIGNED_BYTE: u32 = 0x1401;
|
|
||||||
const GL_FRAMEBUFFER: u32 = 0x8D40;
|
|
||||||
const GL_COLOR_ATTACHMENT0: u32 = 0x8CE0;
|
|
||||||
const GL_FRAMEBUFFER_COMPLETE: u32 = 0x8CD5;
|
|
||||||
const GL_TEXTURE0: u32 = 0x84C0;
|
|
||||||
const GL_TRIANGLES: u32 = 0x0004;
|
|
||||||
const GL_VERTEX_SHADER: u32 = 0x8B31;
|
|
||||||
const GL_FRAGMENT_SHADER: u32 = 0x8B30;
|
|
||||||
const GL_COMPILE_STATUS: u32 = 0x8B81;
|
|
||||||
const GL_LINK_STATUS: u32 = 0x8B82;
|
|
||||||
|
|
||||||
// libglvnd's libGL dispatches these to the NVIDIA driver based on the current EGL/GL context.
|
|
||||||
#[link(name = "GL")]
|
|
||||||
extern "C" {
|
|
||||||
fn glGenTextures(n: c_int, textures: *mut u32);
|
|
||||||
fn glBindTexture(target: u32, texture: u32);
|
|
||||||
fn glTexParameteri(target: u32, pname: u32, param: c_int);
|
|
||||||
fn glDeleteTextures(n: c_int, textures: *const u32);
|
|
||||||
fn glTexStorage2D(target: u32, levels: c_int, internalformat: u32, width: c_int, height: c_int);
|
|
||||||
fn glGetError() -> u32;
|
|
||||||
fn glGenFramebuffers(n: c_int, framebuffers: *mut u32);
|
|
||||||
fn glDeleteFramebuffers(n: c_int, framebuffers: *const u32);
|
|
||||||
fn glBindFramebuffer(target: u32, framebuffer: u32);
|
|
||||||
fn glFramebufferTexture2D(
|
|
||||||
target: u32,
|
|
||||||
attachment: u32,
|
|
||||||
textarget: u32,
|
|
||||||
texture: u32,
|
|
||||||
level: c_int,
|
|
||||||
);
|
|
||||||
fn glCheckFramebufferStatus(target: u32) -> u32;
|
|
||||||
fn glViewport(x: c_int, y: c_int, width: c_int, height: c_int);
|
|
||||||
fn glGenVertexArrays(n: c_int, arrays: *mut u32);
|
|
||||||
fn glDeleteVertexArrays(n: c_int, arrays: *const u32);
|
|
||||||
fn glBindVertexArray(array: u32);
|
|
||||||
fn glDrawArrays(mode: u32, first: c_int, count: c_int);
|
|
||||||
fn glActiveTexture(texture: u32);
|
|
||||||
fn glUseProgram(program: u32);
|
|
||||||
fn glFlush();
|
|
||||||
fn glCreateShader(shader_type: u32) -> u32;
|
|
||||||
fn glShaderSource(shader: u32, count: c_int, string: *const *const i8, length: *const c_int);
|
|
||||||
fn glCompileShader(shader: u32);
|
|
||||||
fn glGetShaderiv(shader: u32, pname: u32, params: *mut c_int);
|
|
||||||
fn glDeleteShader(shader: u32);
|
|
||||||
fn glCreateProgram() -> u32;
|
|
||||||
fn glAttachShader(program: u32, shader: u32);
|
|
||||||
fn glLinkProgram(program: u32);
|
|
||||||
fn glGetProgramiv(program: u32, pname: u32, params: *mut c_int);
|
|
||||||
fn glGetUniformLocation(program: u32, name: *const i8) -> c_int;
|
|
||||||
fn glUniform1i(location: c_int, v0: c_int);
|
|
||||||
fn glDeleteProgram(program: u32);
|
|
||||||
fn glTexSubImage2D(
|
|
||||||
target: u32,
|
|
||||||
level: c_int,
|
|
||||||
xoffset: c_int,
|
|
||||||
yoffset: c_int,
|
|
||||||
width: c_int,
|
|
||||||
height: c_int,
|
|
||||||
format: u32,
|
|
||||||
type_: u32,
|
|
||||||
pixels: *const c_void,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[link(name = "gbm")]
|
|
||||||
extern "C" {
|
|
||||||
fn gbm_create_device(fd: c_int) -> *mut c_void;
|
|
||||||
fn gbm_device_destroy(device: *mut c_void);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `glEGLImageTargetTexture2DOES(target, EGLImage)` — loaded via `eglGetProcAddress`.
|
|
||||||
type EglImageTargetFn = unsafe extern "system" fn(u32, *mut c_void);
|
|
||||||
|
|
||||||
// Fullscreen-triangle blit: sample the dmabuf EGLImage texture and write it (swizzled to BGRA,
|
|
||||||
// to match the BGRx the encoder expects) into a normal GL_RGBA8 texture that CUDA *can* register.
|
|
||||||
const VERT_SRC: &[u8] = b"#version 330 core\nout vec2 v_tex;\nvoid main(){vec2 p=vec2(float((gl_VertexID<<1)&2),float(gl_VertexID&2));v_tex=p;gl_Position=vec4(p*2.0-1.0,0.0,1.0);}\n";
|
|
||||||
const FRAG_SRC: &[u8] = b"#version 330 core\nuniform sampler2D image;\nin vec2 v_tex;\nout vec4 o_color;\nvoid main(){o_color=texture(image,v_tex).bgra;}\n";
|
|
||||||
|
|
||||||
// NV12 BT.709 LIMITED-range convert from full-range RGB in [0,1]. Two passes share `VERT_SRC` and
|
|
||||||
// the same source texture (the de-tiled dmabuf):
|
|
||||||
// Y pass → GL_R8 luma, full-res: Y = (16 + 219·(0.2126R+0.7152G+0.0722B))/255
|
|
||||||
// UV pass → GL_RG8 chroma, half-res (GL_LINEAR averages the 2×2 footprint):
|
|
||||||
// U = (128 + 224·(-0.1146R-0.3854G+0.5000B))/255 → R channel
|
|
||||||
// V = (128 + 224·( 0.5000R-0.4542G-0.0458B))/255 → G channel
|
|
||||||
// RG8's (R=U, G=V) byte order matches NV12's interleaved [U,V]. All outputs clamped to [0,1].
|
|
||||||
// Matches the Windows VideoConverter (BT.709, limited/studio range) so the two hosts look identical.
|
|
||||||
const FRAG_Y_SRC: &[u8] = b"#version 330 core\nuniform sampler2D image;\nin vec2 v_tex;\nout vec4 o_color;\nvoid main(){vec3 c=texture(image,v_tex).rgb;float Y=(16.0+219.0*(0.2126*c.r+0.7152*c.g+0.0722*c.b))/255.0;o_color=vec4(clamp(Y,0.0,1.0),0.0,0.0,1.0);}\n";
|
|
||||||
const FRAG_UV_SRC: &[u8] = b"#version 330 core\nuniform sampler2D image;\nin vec2 v_tex;\nout vec4 o_color;\nvoid main(){vec3 c=texture(image,v_tex).rgb;float U=(128.0+224.0*(-0.1146*c.r-0.3854*c.g+0.5000*c.b))/255.0;float V=(128.0+224.0*(0.5000*c.r-0.4542*c.g-0.0458*c.b))/255.0;o_color=vec4(clamp(U,0.0,1.0),clamp(V,0.0,1.0),0.0,1.0);}\n";
|
|
||||||
|
|
||||||
/// The three planar-YUV444 convert shaders (full-res `R8` target each) — the [`Yuv444Blit`]
|
|
||||||
/// analogue of `FRAG_Y_SRC`/`FRAG_UV_SRC` with NO subsampling (4:4:4 keeps every chroma sample).
|
|
||||||
/// Same BT.709 coefficients; `full_range` flips the quantization from studio (16+219 / 128±112)
|
|
||||||
/// to the full 0..255 swing — the encoder flips the VUI (`PUNKTFUNK_444_FULLRANGE`, read by both
|
|
||||||
/// processes from the same inherited environment) in lockstep, so pixels and signaling agree.
|
|
||||||
fn yuv444_frag_sources(full_range: bool) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
|
|
||||||
let (y_scale, y_off, c_scale) = if full_range {
|
|
||||||
("255.0", "0.0", "255.0")
|
|
||||||
} else {
|
|
||||||
("219.0", "16.0", "224.0")
|
|
||||||
};
|
|
||||||
let head = "#version 330 core\nuniform sampler2D image;\nin vec2 v_tex;\nout vec4 o_color;\nvoid main(){vec3 c=texture(image,v_tex).rgb;";
|
|
||||||
let y = format!(
|
|
||||||
"{head}float Y=({y_off}+{y_scale}*(0.2126*c.r+0.7152*c.g+0.0722*c.b))/255.0;o_color=vec4(clamp(Y,0.0,1.0),0.0,0.0,1.0);}}\n"
|
|
||||||
);
|
|
||||||
let u = format!(
|
|
||||||
"{head}float U=(128.0+{c_scale}*(-0.1146*c.r-0.3854*c.g+0.5000*c.b))/255.0;o_color=vec4(clamp(U,0.0,1.0),0.0,0.0,1.0);}}\n"
|
|
||||||
);
|
|
||||||
let v = format!(
|
|
||||||
"{head}float V=(128.0+{c_scale}*(0.5000*c.r-0.4542*c.g-0.0458*c.b))/255.0;o_color=vec4(clamp(V,0.0,1.0),0.0,0.0,1.0);}}\n"
|
|
||||||
);
|
|
||||||
(y.into_bytes(), u.into_bytes(), v.into_bytes())
|
|
||||||
}
|
|
||||||
|
|
||||||
unsafe fn compile_shader(kind: u32, src: &[u8]) -> Result<u32> {
|
|
||||||
let sh = glCreateShader(kind);
|
|
||||||
ensure!(sh != 0, "glCreateShader failed");
|
|
||||||
let ptr = src.as_ptr() as *const i8;
|
|
||||||
let len = src.len() as c_int;
|
|
||||||
glShaderSource(sh, 1, &ptr, &len);
|
|
||||||
glCompileShader(sh);
|
|
||||||
let mut ok: c_int = 0;
|
|
||||||
glGetShaderiv(sh, GL_COMPILE_STATUS, &mut ok);
|
|
||||||
if ok == 0 {
|
|
||||||
glDeleteShader(sh);
|
|
||||||
bail!("GL shader compile failed");
|
|
||||||
}
|
|
||||||
Ok(sh)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compile+link the fullscreen-triangle program with fragment source `frag` and bind its `image`
|
|
||||||
/// sampler to texture unit 0.
|
|
||||||
unsafe fn compile_program_with(frag: &[u8]) -> Result<u32> {
|
|
||||||
let vs = compile_shader(GL_VERTEX_SHADER, VERT_SRC)?;
|
|
||||||
let fs = compile_shader(GL_FRAGMENT_SHADER, frag)?;
|
|
||||||
let prog = glCreateProgram();
|
|
||||||
glAttachShader(prog, vs);
|
|
||||||
glAttachShader(prog, fs);
|
|
||||||
glLinkProgram(prog);
|
|
||||||
glDeleteShader(vs);
|
|
||||||
glDeleteShader(fs);
|
|
||||||
let mut ok: c_int = 0;
|
|
||||||
glGetProgramiv(prog, GL_LINK_STATUS, &mut ok);
|
|
||||||
ensure!(ok != 0, "GL program link failed");
|
|
||||||
glUseProgram(prog);
|
|
||||||
let loc = glGetUniformLocation(prog, c"image".as_ptr());
|
|
||||||
if loc >= 0 {
|
|
||||||
glUniform1i(loc, 0); // sampler -> texture unit 0
|
|
||||||
}
|
|
||||||
glUseProgram(0);
|
|
||||||
Ok(prog)
|
|
||||||
}
|
|
||||||
|
|
||||||
unsafe fn compile_program() -> Result<u32> {
|
|
||||||
compile_program_with(FRAG_SRC)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Per-size GL machinery to blit a dmabuf EGLImage into a CUDA-registrable `GL_RGBA8` texture.
|
/// Per-size GL machinery to blit a dmabuf EGLImage into a CUDA-registrable `GL_RGBA8` texture.
|
||||||
struct GlBlit {
|
struct GlBlit {
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
//! GL plumbing for the EGL zero-copy blit (plan §W4, carved out of the EGL facade): the GL enum
|
||||||
|
//! constants, the `#[link]`'d libGL / libgbm entry points, the fullscreen-triangle shader sources
|
||||||
|
//! (BGRA swizzle + the NV12 / YUV444 BT.709 convert passes), and the shader/program compile
|
||||||
|
//! helpers. The de-tiling blit passes and the EGLDisplay importer that drive this all live in
|
||||||
|
//! [`super`].
|
||||||
|
|
||||||
|
#![allow(non_upper_case_globals)]
|
||||||
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
|
use anyhow::{bail, ensure, Result};
|
||||||
|
use std::os::raw::{c_int, c_void};
|
||||||
|
|
||||||
|
pub(crate) const GL_TEXTURE_2D: u32 = 0x0DE1;
|
||||||
|
pub(crate) const GL_TEXTURE_MIN_FILTER: u32 = 0x2801;
|
||||||
|
pub(crate) const GL_TEXTURE_MAG_FILTER: u32 = 0x2800;
|
||||||
|
pub(crate) const GL_LINEAR: c_int = 0x2601;
|
||||||
|
pub(crate) const GL_NEAREST: c_int = 0x2600;
|
||||||
|
pub(crate) const GL_RGBA8: u32 = 0x8058;
|
||||||
|
// Single/dual-channel 8-bit formats for the NV12 convert targets: R8 luma (full-res),
|
||||||
|
// RG8 interleaved chroma (half-res). The `_RED`/`_RG` enums are the matching client formats.
|
||||||
|
pub(crate) const GL_R8: u32 = 0x8229;
|
||||||
|
pub(crate) const GL_RG8: u32 = 0x822B;
|
||||||
|
// Client pixel format/type for texture uploads (self-test only): RGBA bytes.
|
||||||
|
pub(crate) const GL_RGBA: u32 = 0x1908;
|
||||||
|
pub(crate) const GL_UNSIGNED_BYTE: u32 = 0x1401;
|
||||||
|
pub(crate) const GL_FRAMEBUFFER: u32 = 0x8D40;
|
||||||
|
pub(crate) const GL_COLOR_ATTACHMENT0: u32 = 0x8CE0;
|
||||||
|
pub(crate) const GL_FRAMEBUFFER_COMPLETE: u32 = 0x8CD5;
|
||||||
|
pub(crate) const GL_TEXTURE0: u32 = 0x84C0;
|
||||||
|
pub(crate) const GL_TRIANGLES: u32 = 0x0004;
|
||||||
|
pub(crate) const GL_VERTEX_SHADER: u32 = 0x8B31;
|
||||||
|
pub(crate) const GL_FRAGMENT_SHADER: u32 = 0x8B30;
|
||||||
|
pub(crate) const GL_COMPILE_STATUS: u32 = 0x8B81;
|
||||||
|
pub(crate) const GL_LINK_STATUS: u32 = 0x8B82;
|
||||||
|
|
||||||
|
// libglvnd's libGL dispatches these to the NVIDIA driver based on the current EGL/GL context.
|
||||||
|
#[link(name = "GL")]
|
||||||
|
extern "C" {
|
||||||
|
pub(crate) fn glGenTextures(n: c_int, textures: *mut u32);
|
||||||
|
pub(crate) fn glBindTexture(target: u32, texture: u32);
|
||||||
|
pub(crate) fn glTexParameteri(target: u32, pname: u32, param: c_int);
|
||||||
|
pub(crate) fn glDeleteTextures(n: c_int, textures: *const u32);
|
||||||
|
pub(crate) fn glTexStorage2D(
|
||||||
|
target: u32,
|
||||||
|
levels: c_int,
|
||||||
|
internalformat: u32,
|
||||||
|
width: c_int,
|
||||||
|
height: c_int,
|
||||||
|
);
|
||||||
|
pub(crate) fn glGetError() -> u32;
|
||||||
|
pub(crate) fn glGenFramebuffers(n: c_int, framebuffers: *mut u32);
|
||||||
|
pub(crate) fn glDeleteFramebuffers(n: c_int, framebuffers: *const u32);
|
||||||
|
pub(crate) fn glBindFramebuffer(target: u32, framebuffer: u32);
|
||||||
|
pub(crate) fn glFramebufferTexture2D(
|
||||||
|
target: u32,
|
||||||
|
attachment: u32,
|
||||||
|
textarget: u32,
|
||||||
|
texture: u32,
|
||||||
|
level: c_int,
|
||||||
|
);
|
||||||
|
pub(crate) fn glCheckFramebufferStatus(target: u32) -> u32;
|
||||||
|
pub(crate) fn glViewport(x: c_int, y: c_int, width: c_int, height: c_int);
|
||||||
|
pub(crate) fn glGenVertexArrays(n: c_int, arrays: *mut u32);
|
||||||
|
pub(crate) fn glDeleteVertexArrays(n: c_int, arrays: *const u32);
|
||||||
|
pub(crate) fn glBindVertexArray(array: u32);
|
||||||
|
pub(crate) fn glDrawArrays(mode: u32, first: c_int, count: c_int);
|
||||||
|
pub(crate) fn glActiveTexture(texture: u32);
|
||||||
|
pub(crate) fn glUseProgram(program: u32);
|
||||||
|
pub(crate) fn glFlush();
|
||||||
|
pub(crate) fn glCreateShader(shader_type: u32) -> u32;
|
||||||
|
pub(crate) fn glShaderSource(
|
||||||
|
shader: u32,
|
||||||
|
count: c_int,
|
||||||
|
string: *const *const i8,
|
||||||
|
length: *const c_int,
|
||||||
|
);
|
||||||
|
pub(crate) fn glCompileShader(shader: u32);
|
||||||
|
pub(crate) fn glGetShaderiv(shader: u32, pname: u32, params: *mut c_int);
|
||||||
|
pub(crate) fn glDeleteShader(shader: u32);
|
||||||
|
pub(crate) fn glCreateProgram() -> u32;
|
||||||
|
pub(crate) fn glAttachShader(program: u32, shader: u32);
|
||||||
|
pub(crate) fn glLinkProgram(program: u32);
|
||||||
|
pub(crate) fn glGetProgramiv(program: u32, pname: u32, params: *mut c_int);
|
||||||
|
pub(crate) fn glGetUniformLocation(program: u32, name: *const i8) -> c_int;
|
||||||
|
pub(crate) fn glUniform1i(location: c_int, v0: c_int);
|
||||||
|
pub(crate) fn glDeleteProgram(program: u32);
|
||||||
|
pub(crate) fn glTexSubImage2D(
|
||||||
|
target: u32,
|
||||||
|
level: c_int,
|
||||||
|
xoffset: c_int,
|
||||||
|
yoffset: c_int,
|
||||||
|
width: c_int,
|
||||||
|
height: c_int,
|
||||||
|
format: u32,
|
||||||
|
type_: u32,
|
||||||
|
pixels: *const c_void,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[link(name = "gbm")]
|
||||||
|
extern "C" {
|
||||||
|
pub(crate) fn gbm_create_device(fd: c_int) -> *mut c_void;
|
||||||
|
pub(crate) fn gbm_device_destroy(device: *mut c_void);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `glEGLImageTargetTexture2DOES(target, EGLImage)` — loaded via `eglGetProcAddress`.
|
||||||
|
pub(crate) type EglImageTargetFn = unsafe extern "system" fn(u32, *mut c_void);
|
||||||
|
|
||||||
|
// Fullscreen-triangle blit: sample the dmabuf EGLImage texture and write it (swizzled to BGRA,
|
||||||
|
// to match the BGRx the encoder expects) into a normal GL_RGBA8 texture that CUDA *can* register.
|
||||||
|
pub(crate) const VERT_SRC: &[u8] = b"#version 330 core\nout vec2 v_tex;\nvoid main(){vec2 p=vec2(float((gl_VertexID<<1)&2),float(gl_VertexID&2));v_tex=p;gl_Position=vec4(p*2.0-1.0,0.0,1.0);}\n";
|
||||||
|
pub(crate) const FRAG_SRC: &[u8] = b"#version 330 core\nuniform sampler2D image;\nin vec2 v_tex;\nout vec4 o_color;\nvoid main(){o_color=texture(image,v_tex).bgra;}\n";
|
||||||
|
|
||||||
|
// NV12 BT.709 LIMITED-range convert from full-range RGB in [0,1]. Two passes share `VERT_SRC` and
|
||||||
|
// the same source texture (the de-tiled dmabuf):
|
||||||
|
// Y pass → GL_R8 luma, full-res: Y = (16 + 219·(0.2126R+0.7152G+0.0722B))/255
|
||||||
|
// UV pass → GL_RG8 chroma, half-res (GL_LINEAR averages the 2×2 footprint):
|
||||||
|
// U = (128 + 224·(-0.1146R-0.3854G+0.5000B))/255 → R channel
|
||||||
|
// V = (128 + 224·( 0.5000R-0.4542G-0.0458B))/255 → G channel
|
||||||
|
// RG8's (R=U, G=V) byte order matches NV12's interleaved [U,V]. All outputs clamped to [0,1].
|
||||||
|
// Matches the Windows VideoConverter (BT.709, limited/studio range) so the two hosts look identical.
|
||||||
|
pub(crate) const FRAG_Y_SRC: &[u8] = b"#version 330 core\nuniform sampler2D image;\nin vec2 v_tex;\nout vec4 o_color;\nvoid main(){vec3 c=texture(image,v_tex).rgb;float Y=(16.0+219.0*(0.2126*c.r+0.7152*c.g+0.0722*c.b))/255.0;o_color=vec4(clamp(Y,0.0,1.0),0.0,0.0,1.0);}\n";
|
||||||
|
pub(crate) const FRAG_UV_SRC: &[u8] = b"#version 330 core\nuniform sampler2D image;\nin vec2 v_tex;\nout vec4 o_color;\nvoid main(){vec3 c=texture(image,v_tex).rgb;float U=(128.0+224.0*(-0.1146*c.r-0.3854*c.g+0.5000*c.b))/255.0;float V=(128.0+224.0*(0.5000*c.r-0.4542*c.g-0.0458*c.b))/255.0;o_color=vec4(clamp(U,0.0,1.0),clamp(V,0.0,1.0),0.0,1.0);}\n";
|
||||||
|
|
||||||
|
/// The three planar-YUV444 convert shaders (full-res `R8` target each) — the [`Yuv444Blit`]
|
||||||
|
/// analogue of `FRAG_Y_SRC`/`FRAG_UV_SRC` with NO subsampling (4:4:4 keeps every chroma sample).
|
||||||
|
/// Same BT.709 coefficients; `full_range` flips the quantization from studio (16+219 / 128±112)
|
||||||
|
/// to the full 0..255 swing — the encoder flips the VUI (`PUNKTFUNK_444_FULLRANGE`, read by both
|
||||||
|
/// processes from the same inherited environment) in lockstep, so pixels and signaling agree.
|
||||||
|
pub(crate) fn yuv444_frag_sources(full_range: bool) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
|
||||||
|
let (y_scale, y_off, c_scale) = if full_range {
|
||||||
|
("255.0", "0.0", "255.0")
|
||||||
|
} else {
|
||||||
|
("219.0", "16.0", "224.0")
|
||||||
|
};
|
||||||
|
let head = "#version 330 core\nuniform sampler2D image;\nin vec2 v_tex;\nout vec4 o_color;\nvoid main(){vec3 c=texture(image,v_tex).rgb;";
|
||||||
|
let y = format!(
|
||||||
|
"{head}float Y=({y_off}+{y_scale}*(0.2126*c.r+0.7152*c.g+0.0722*c.b))/255.0;o_color=vec4(clamp(Y,0.0,1.0),0.0,0.0,1.0);}}\n"
|
||||||
|
);
|
||||||
|
let u = format!(
|
||||||
|
"{head}float U=(128.0+{c_scale}*(-0.1146*c.r-0.3854*c.g+0.5000*c.b))/255.0;o_color=vec4(clamp(U,0.0,1.0),0.0,0.0,1.0);}}\n"
|
||||||
|
);
|
||||||
|
let v = format!(
|
||||||
|
"{head}float V=(128.0+{c_scale}*(0.5000*c.r-0.4542*c.g-0.0458*c.b))/255.0;o_color=vec4(clamp(V,0.0,1.0),0.0,0.0,1.0);}}\n"
|
||||||
|
);
|
||||||
|
(y.into_bytes(), u.into_bytes(), v.into_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) unsafe fn compile_shader(kind: u32, src: &[u8]) -> Result<u32> {
|
||||||
|
let sh = glCreateShader(kind);
|
||||||
|
ensure!(sh != 0, "glCreateShader failed");
|
||||||
|
let ptr = src.as_ptr() as *const i8;
|
||||||
|
let len = src.len() as c_int;
|
||||||
|
glShaderSource(sh, 1, &ptr, &len);
|
||||||
|
glCompileShader(sh);
|
||||||
|
let mut ok: c_int = 0;
|
||||||
|
glGetShaderiv(sh, GL_COMPILE_STATUS, &mut ok);
|
||||||
|
if ok == 0 {
|
||||||
|
glDeleteShader(sh);
|
||||||
|
bail!("GL shader compile failed");
|
||||||
|
}
|
||||||
|
Ok(sh)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compile+link the fullscreen-triangle program with fragment source `frag` and bind its `image`
|
||||||
|
/// sampler to texture unit 0.
|
||||||
|
pub(crate) unsafe fn compile_program_with(frag: &[u8]) -> Result<u32> {
|
||||||
|
let vs = compile_shader(GL_VERTEX_SHADER, VERT_SRC)?;
|
||||||
|
let fs = compile_shader(GL_FRAGMENT_SHADER, frag)?;
|
||||||
|
let prog = glCreateProgram();
|
||||||
|
glAttachShader(prog, vs);
|
||||||
|
glAttachShader(prog, fs);
|
||||||
|
glLinkProgram(prog);
|
||||||
|
glDeleteShader(vs);
|
||||||
|
glDeleteShader(fs);
|
||||||
|
let mut ok: c_int = 0;
|
||||||
|
glGetProgramiv(prog, GL_LINK_STATUS, &mut ok);
|
||||||
|
ensure!(ok != 0, "GL program link failed");
|
||||||
|
glUseProgram(prog);
|
||||||
|
let loc = glGetUniformLocation(prog, c"image".as_ptr());
|
||||||
|
if loc >= 0 {
|
||||||
|
glUniform1i(loc, 0); // sampler -> texture unit 0
|
||||||
|
}
|
||||||
|
glUseProgram(0);
|
||||||
|
Ok(prog)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) unsafe fn compile_program() -> Result<u32> {
|
||||||
|
compile_program_with(FRAG_SRC)
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@ mod gpu;
|
|||||||
#[path = "linux/gpuclocks.rs"]
|
#[path = "linux/gpuclocks.rs"]
|
||||||
mod gpuclocks;
|
mod gpuclocks;
|
||||||
mod hdr;
|
mod hdr;
|
||||||
|
mod hooks;
|
||||||
mod inject;
|
mod inject;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
#[path = "windows/install.rs"]
|
#[path = "windows/install.rs"]
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ use utoipa_scalar::{Scalar, Servable};
|
|||||||
mod auth;
|
mod auth;
|
||||||
mod clients;
|
mod clients;
|
||||||
mod display;
|
mod display;
|
||||||
|
mod events;
|
||||||
mod gpu;
|
mod gpu;
|
||||||
|
mod hooks;
|
||||||
mod host;
|
mod host;
|
||||||
mod library;
|
mod library;
|
||||||
mod native;
|
mod native;
|
||||||
@@ -205,6 +207,10 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
|||||||
library::update_custom_game,
|
library::update_custom_game,
|
||||||
library::delete_custom_game
|
library::delete_custom_game
|
||||||
))
|
))
|
||||||
|
.routes(routes!(
|
||||||
|
library::reconcile_provider_entries,
|
||||||
|
library::delete_provider_entries
|
||||||
|
))
|
||||||
.routes(routes!(library::get_library_art))
|
.routes(routes!(library::get_library_art))
|
||||||
.routes(routes!(stats::stats_capture_start))
|
.routes(routes!(stats::stats_capture_start))
|
||||||
.routes(routes!(stats::stats_capture_stop))
|
.routes(routes!(stats::stats_capture_stop))
|
||||||
@@ -215,7 +221,9 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
|||||||
stats::stats_recording_get,
|
stats::stats_recording_get,
|
||||||
stats::stats_recording_delete
|
stats::stats_recording_delete
|
||||||
))
|
))
|
||||||
.routes(routes!(stats::logs_get)),
|
.routes(routes!(stats::logs_get))
|
||||||
|
.routes(routes!(events::stream_events))
|
||||||
|
.routes(routes!(hooks::get_hooks, hooks::set_hooks)),
|
||||||
)
|
)
|
||||||
.split_for_parts()
|
.split_for_parts()
|
||||||
}
|
}
|
||||||
@@ -251,6 +259,8 @@ pub fn openapi_json() -> String {
|
|||||||
(name = "library", description = "Game library: installed-store titles (Steam) plus user-curated custom entries"),
|
(name = "library", description = "Game library: installed-store titles (Steam) plus user-curated custom entries"),
|
||||||
(name = "stats", description = "Streaming performance-stats capture: arm/stop a recording, read the live + saved time-series for graphing"),
|
(name = "stats", description = "Streaming performance-stats capture: arm/stop a recording, read the live + saved time-series for graphing"),
|
||||||
(name = "logs", description = "Host log stream: the newest in-memory log entries, cursor-paged for live following"),
|
(name = "logs", description = "Host log stream: the newest in-memory log entries, cursor-paged for live following"),
|
||||||
|
(name = "events", description = "Host lifecycle events: an SSE stream (client/session/stream lifecycle, pairing, displays, library, host) with Last-Event-ID resume and server-side kind filters"),
|
||||||
|
(name = "hooks", description = "Operator hooks: commands and webhooks fired on lifecycle events (fire-and-forget — hooks observe, never veto)"),
|
||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
struct ApiDoc;
|
struct ApiDoc;
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
//! `GET /api/v1/events` — the host lifecycle event stream (scripting-and-hooks RFC §5, M1).
|
||||||
|
//!
|
||||||
|
//! Server-Sent Events over the existing HTTPS serve loop: each event goes out as one SSE frame
|
||||||
|
//! with `id:` = the event's `seq`, `event:` = its kind (`stream.started`, …), and `data:` = the
|
||||||
|
//! [`crate::events::HostEvent`] JSON. Consumers resume with the standard `Last-Event-ID` header
|
||||||
|
//! (or its `?since=` query twin); a consumer that fell off the catch-up ring gets a synthetic
|
||||||
|
//! `event: dropped` frame first and should resync via the REST snapshots (`/status`, `/clients`,
|
||||||
|
//! …). `?kinds=` filters server-side (exact kinds or `domain.*` prefixes, comma-separated).
|
||||||
|
//!
|
||||||
|
//! Bounds (RFC §9.6): at most [`MAX_EVENT_STREAMS`] concurrent streams (503 beyond — the
|
||||||
|
//! consumer retries; SSE clients reconnect by themselves), and a consumer too slow for the
|
||||||
|
//! live tail (broadcast lag) is **disconnected**, never buffered unboundedly — its reconnect
|
||||||
|
//! resumes from the ring via `Last-Event-ID`.
|
||||||
|
//!
|
||||||
|
//! Auth: deliberately NOT on the mTLS read-only allowlist (`auth::cert_may_access`) — the
|
||||||
|
//! stream is part of the loopback + bearer admin lane in v1 (RFC §5; revisit when paired
|
||||||
|
//! clients want an activity feed).
|
||||||
|
|
||||||
|
use super::shared::*;
|
||||||
|
use axum::http::HeaderMap;
|
||||||
|
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
|
/// Concurrent SSE connection cap. Operators run a handful of consumers (console feed, a couple
|
||||||
|
/// of scripts/plugins); 32 is generous headroom while bounding a runaway reconnect loop.
|
||||||
|
const MAX_EVENT_STREAMS: usize = 32;
|
||||||
|
|
||||||
|
/// SSE keep-alive comment interval — detects a dead peer and keeps middleboxes from idling
|
||||||
|
/// the connection out between (low-rate) lifecycle events.
|
||||||
|
const KEEP_ALIVE: Duration = Duration::from_secs(15);
|
||||||
|
|
||||||
|
static LIVE_STREAMS: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
|
/// RAII slot in the connection cap: taken before the stream starts, released when the SSE body
|
||||||
|
/// is dropped (client disconnect, slow-consumer cut, server shutdown). `pub(crate)` only for
|
||||||
|
/// the [`test_support`] saturation helper's return type.
|
||||||
|
pub(crate) struct StreamSlot;
|
||||||
|
|
||||||
|
fn try_acquire_slot() -> Option<StreamSlot> {
|
||||||
|
LIVE_STREAMS
|
||||||
|
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |n| {
|
||||||
|
(n < MAX_EVENT_STREAMS).then_some(n + 1)
|
||||||
|
})
|
||||||
|
.ok()
|
||||||
|
.map(|_| StreamSlot)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for StreamSlot {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
LIVE_STREAMS.fetch_sub(1, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `?kinds=stream.*,pairing.pending` — exact kind names or `domain.*` prefixes. `None` = all.
|
||||||
|
struct KindFilter(Option<Vec<String>>);
|
||||||
|
|
||||||
|
impl KindFilter {
|
||||||
|
fn parse(kinds: Option<&str>) -> Self {
|
||||||
|
let pats: Option<Vec<String>> = kinds.map(|s| {
|
||||||
|
s.split(',')
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|p| !p.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
KindFilter(pats.filter(|p| !p.is_empty()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matches(&self, kind: &str) -> bool {
|
||||||
|
match &self.0 {
|
||||||
|
None => true,
|
||||||
|
Some(pats) => pats.iter().any(|p| crate::events::kind_matches(p, kind)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(crate) struct EventsQuery {
|
||||||
|
/// Resume cursor: stream events with `seq > since`. The `Last-Event-ID` header (what an SSE
|
||||||
|
/// client sends on auto-reconnect) takes precedence when both are present.
|
||||||
|
since: Option<u64>,
|
||||||
|
/// Comma-separated kind filter (`stream.*,pairing.pending`).
|
||||||
|
kinds: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One [`crate::events::HostEvent`] as an SSE frame: `id:` = seq (drives `Last-Event-ID`),
|
||||||
|
/// `event:` = kind, `data:` = the full event JSON (kind included — the frame is self-contained
|
||||||
|
/// for consumers that read `data` only).
|
||||||
|
fn sse_event(ev: &crate::events::HostEvent) -> Event {
|
||||||
|
Event::default()
|
||||||
|
.id(ev.seq.to_string())
|
||||||
|
.event(ev.kind.name())
|
||||||
|
.data(serde_json::to_string(ev).unwrap_or_else(|_| "{}".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything the poll loop owns; dropping it (the response body going away) releases the
|
||||||
|
/// connection-cap slot and the broadcast receiver.
|
||||||
|
struct StreamState {
|
||||||
|
/// The dropped marker (when applicable) + the filtered catch-up, served before the live tail.
|
||||||
|
pending: VecDeque<Event>,
|
||||||
|
rx: broadcast::Receiver<crate::events::HostEvent>,
|
||||||
|
filter: KindFilter,
|
||||||
|
_slot: StreamSlot,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream host lifecycle events (SSE)
|
||||||
|
///
|
||||||
|
/// Server-Sent Events stream of the host's lifecycle events: client connect/disconnect, session
|
||||||
|
/// and stream start/end, pairing decisions, display create/release, library changes, host
|
||||||
|
/// start/stop — both protocol planes. Frames carry `id:` = the event's monotonic `seq`,
|
||||||
|
/// `event:` = its kind, and `data:` = the event JSON (schema-versioned, additive-only).
|
||||||
|
///
|
||||||
|
/// Resume: standard `Last-Event-ID` (or `?since=`) replays from the in-memory ring; a consumer
|
||||||
|
/// that fell off the ring receives an `event: dropped` frame first and should resync via the
|
||||||
|
/// REST snapshots. Keep-alive comments are sent every 15 s.
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/events",
|
||||||
|
tag = "events",
|
||||||
|
operation_id = "streamEvents",
|
||||||
|
params(
|
||||||
|
("since" = Option<u64>, Query, description = "Resume cursor: only events with `seq` greater than this are sent (the ring keeps the newest ~1024). `Last-Event-ID` takes precedence."),
|
||||||
|
("kinds" = Option<String>, Query, description = "Comma-separated server-side kind filter: exact kinds (`pairing.pending`) or `domain.*` prefixes (`stream.*`)."),
|
||||||
|
("Last-Event-ID" = Option<u64>, Header, description = "SSE auto-reconnect cursor — the `id:` of the last received frame."),
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "SSE stream; each frame's `data:` is one HostEvent", body = crate::events::HostEvent, content_type = "text/event-stream"),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
(status = SERVICE_UNAVAILABLE, description = "Concurrent event-stream cap reached — retry shortly", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub(crate) async fn stream_events(Query(q): Query<EventsQuery>, headers: HeaderMap) -> Response {
|
||||||
|
let Some(slot) = try_acquire_slot() else {
|
||||||
|
return api_error(
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"event-stream connection cap reached — close an existing stream or retry",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
// The header is what an SSE client re-sends on auto-reconnect, so it is always the newer
|
||||||
|
// cursor when both it and the original URL's `?since=` are present.
|
||||||
|
let since = headers
|
||||||
|
.get("last-event-id")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|v| v.trim().parse::<u64>().ok())
|
||||||
|
.or(q.since)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let filter = KindFilter::parse(q.kinds.as_deref());
|
||||||
|
let sub = crate::events::bus().subscribe(since);
|
||||||
|
|
||||||
|
let mut pending = VecDeque::new();
|
||||||
|
if sub.dropped {
|
||||||
|
// The consumer's cursor precedes the ring: tell it so (the `LogPage.dropped` contract)
|
||||||
|
// — its move is a REST resync, not trust in a complete replay.
|
||||||
|
pending.push_back(
|
||||||
|
Event::default()
|
||||||
|
.event("dropped")
|
||||||
|
.data(r#"{"dropped":true}"#),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
pending.extend(
|
||||||
|
sub.catch_up
|
||||||
|
.iter()
|
||||||
|
.filter(|ev| filter.matches(ev.kind.name()))
|
||||||
|
.map(sse_event),
|
||||||
|
);
|
||||||
|
|
||||||
|
let state = StreamState {
|
||||||
|
pending,
|
||||||
|
rx: sub.rx,
|
||||||
|
filter,
|
||||||
|
_slot: slot,
|
||||||
|
};
|
||||||
|
let stream = futures_util::stream::unfold(state, |mut st| async move {
|
||||||
|
loop {
|
||||||
|
if let Some(ev) = st.pending.pop_front() {
|
||||||
|
return Some((Ok::<_, std::convert::Infallible>(ev), st));
|
||||||
|
}
|
||||||
|
match st.rx.recv().await {
|
||||||
|
Ok(ev) => {
|
||||||
|
if st.filter.matches(ev.kind.name()) {
|
||||||
|
return Some((Ok(sse_event(&ev)), st));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Lagged = this consumer is too slow for the live tail: cut it loose (bounded
|
||||||
|
// memory, RFC §9.6) — its auto-reconnect resumes from the ring, which flags
|
||||||
|
// `dropped` if it also fell off that. Closed = host shutdown.
|
||||||
|
Err(broadcast::error::RecvError::Lagged(_))
|
||||||
|
| Err(broadcast::error::RecvError::Closed) => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Sse::new(stream)
|
||||||
|
.keep_alive(KeepAlive::new().interval(KEEP_ALIVE))
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod test_support {
|
||||||
|
/// Fill the connection cap and hand the slots to a test ([`Drop`] frees them). The events
|
||||||
|
/// tests serialize on a lock so the full cap can't 503 an unrelated concurrent test.
|
||||||
|
pub(crate) fn saturate_slots() -> Vec<super::StreamSlot> {
|
||||||
|
std::iter::from_fn(super::try_acquire_slot).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod unit_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kind_filter_semantics() {
|
||||||
|
let all = KindFilter::parse(None);
|
||||||
|
assert!(all.matches("stream.started"));
|
||||||
|
|
||||||
|
let f = KindFilter::parse(Some("stream.*, pairing.pending"));
|
||||||
|
assert!(f.matches("stream.started"));
|
||||||
|
assert!(f.matches("stream.stopped"));
|
||||||
|
assert!(f.matches("pairing.pending"));
|
||||||
|
assert!(!f.matches("pairing.completed"));
|
||||||
|
assert!(!f.matches("client.connected"));
|
||||||
|
// A prefix pattern must match on the dot boundary, not raw text.
|
||||||
|
assert!(!f.matches("streamx.started"));
|
||||||
|
|
||||||
|
// Empty/blank filter strings mean "no filter", not "nothing matches".
|
||||||
|
assert!(KindFilter::parse(Some("")).matches("host.started"));
|
||||||
|
assert!(KindFilter::parse(Some(" , ")).matches("host.started"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
//! Hooks management endpoints (scripting-and-hooks RFC §6): read and replace the operator's
|
||||||
|
//! `hooks.json` — validated on write, applied immediately (the runner reads the store per
|
||||||
|
//! event, so no restart is needed).
|
||||||
|
|
||||||
|
use super::shared::*;
|
||||||
|
|
||||||
|
/// Get the hook configuration
|
||||||
|
///
|
||||||
|
/// The operator's `hooks.json`: commands and webhooks fired on host lifecycle events. Empty
|
||||||
|
/// when unconfigured.
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/hooks",
|
||||||
|
tag = "hooks",
|
||||||
|
operation_id = "getHooks",
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "The stored hook configuration", body = crate::hooks::HooksConfig),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub(crate) async fn get_hooks() -> Json<crate::hooks::HooksConfig> {
|
||||||
|
Json(crate::hooks::store().get())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the hook configuration
|
||||||
|
///
|
||||||
|
/// Validates and persists a full `hooks.json` document (this is a whole-document PUT, not a
|
||||||
|
/// patch). Applies from the next event — no restart. Hook commands run as the host user
|
||||||
|
/// (interactive user session on Windows): treat this configuration as operator-privileged.
|
||||||
|
#[utoipa::path(
|
||||||
|
put,
|
||||||
|
path = "/hooks",
|
||||||
|
tag = "hooks",
|
||||||
|
operation_id = "setHooks",
|
||||||
|
request_body = crate::hooks::HooksConfig,
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "Configuration stored; the new state", body = crate::hooks::HooksConfig),
|
||||||
|
(status = BAD_REQUEST, description = "Structurally invalid configuration", body = ApiError),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
(status = INTERNAL_SERVER_ERROR, description = "Configuration could not be persisted", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub(crate) async fn set_hooks(ApiJson(cfg): ApiJson<crate::hooks::HooksConfig>) -> Response {
|
||||||
|
if let Err(e) = cfg.validate() {
|
||||||
|
return api_error(StatusCode::BAD_REQUEST, &e);
|
||||||
|
}
|
||||||
|
match crate::hooks::store().set(cfg) {
|
||||||
|
Ok(()) => {
|
||||||
|
tracing::info!("management API: hook configuration updated");
|
||||||
|
Json(crate::hooks::store().get()).into_response()
|
||||||
|
}
|
||||||
|
Err(e) => api_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
&format!("persist hooks.json: {e:#}"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,23 +4,39 @@
|
|||||||
use super::shared::*;
|
use super::shared::*;
|
||||||
use axum::http::header;
|
use axum::http::header;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(crate) struct LibraryQuery {
|
||||||
|
/// Only entries owned by this external provider (RFC §8).
|
||||||
|
provider: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// List the game library
|
/// List the game library
|
||||||
///
|
///
|
||||||
/// Every installed-store title (Steam, read from the host's local files — no Steam API key)
|
/// Every installed-store title (Steam, read from the host's local files — no Steam API key)
|
||||||
/// merged with the user's custom entries, sorted by title. Artwork fields are URLs the client
|
/// merged with the user's custom entries, sorted by title. Artwork fields are URLs the client
|
||||||
/// fetches directly (the public Steam CDN for Steam titles).
|
/// fetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the
|
||||||
|
/// entries a given external provider owns.
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/library",
|
path = "/library",
|
||||||
tag = "library",
|
tag = "library",
|
||||||
operation_id = "getLibrary",
|
operation_id = "getLibrary",
|
||||||
|
params(
|
||||||
|
("provider" = Option<String>, Query, description = "Only entries owned by this external provider"),
|
||||||
|
),
|
||||||
responses(
|
responses(
|
||||||
(status = OK, description = "Unified library across all stores", body = [crate::library::GameEntry]),
|
(status = OK, description = "Unified library across all stores", body = [crate::library::GameEntry]),
|
||||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub(crate) async fn get_library() -> Json<Vec<crate::library::GameEntry>> {
|
pub(crate) async fn get_library(
|
||||||
Json(crate::library::all_games())
|
Query(q): Query<LibraryQuery>,
|
||||||
|
) -> Json<Vec<crate::library::GameEntry>> {
|
||||||
|
let mut games = crate::library::all_games();
|
||||||
|
if let Some(provider) = q.provider.filter(|p| !p.is_empty()) {
|
||||||
|
games.retain(|g| g.provider.as_deref() == Some(provider.as_str()));
|
||||||
|
}
|
||||||
|
Json(games)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a custom library entry
|
/// Add a custom library entry
|
||||||
@@ -75,9 +91,16 @@ pub(crate) async fn update_custom_game(
|
|||||||
if input.title.trim().is_empty() {
|
if input.title.trim().is_empty() {
|
||||||
return api_error(StatusCode::BAD_REQUEST, "title must not be empty");
|
return api_error(StatusCode::BAD_REQUEST, "title must not be empty");
|
||||||
}
|
}
|
||||||
|
use crate::library::MutateOutcome;
|
||||||
match crate::library::update_custom(&id, input) {
|
match crate::library::update_custom(&id, input) {
|
||||||
Ok(Some(entry)) => Json(entry).into_response(),
|
Ok(MutateOutcome::Done(entry)) => Json(entry).into_response(),
|
||||||
Ok(None) => api_error(StatusCode::NOT_FOUND, "no custom entry with that id"),
|
Ok(MutateOutcome::NotFound) => {
|
||||||
|
api_error(StatusCode::NOT_FOUND, "no custom entry with that id")
|
||||||
|
}
|
||||||
|
Ok(MutateOutcome::ProviderOwned(p)) => api_error(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
&format!("entry is owned by provider `{p}` — update it through its reconcile"),
|
||||||
|
),
|
||||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,9 +120,101 @@ pub(crate) async fn update_custom_game(
|
|||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub(crate) async fn delete_custom_game(Path(id): Path<String>) -> Response {
|
pub(crate) async fn delete_custom_game(Path(id): Path<String>) -> Response {
|
||||||
|
use crate::library::MutateOutcome;
|
||||||
match crate::library::delete_custom(&id) {
|
match crate::library::delete_custom(&id) {
|
||||||
Ok(true) => StatusCode::NO_CONTENT.into_response(),
|
Ok(MutateOutcome::Done(())) => StatusCode::NO_CONTENT.into_response(),
|
||||||
Ok(false) => api_error(StatusCode::NOT_FOUND, "no custom entry with that id"),
|
Ok(MutateOutcome::NotFound) => {
|
||||||
|
api_error(StatusCode::NOT_FOUND, "no custom entry with that id")
|
||||||
|
}
|
||||||
|
Ok(MutateOutcome::ProviderOwned(p)) => api_error(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
&format!(
|
||||||
|
"entry is owned by provider `{p}` — remove it there, or DELETE the provider set"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The count envelope a provider uninstall returns.
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub(crate) struct ProviderRemoved {
|
||||||
|
/// How many entries the provider owned (and were removed).
|
||||||
|
removed: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace a provider's library entries (declarative reconcile)
|
||||||
|
///
|
||||||
|
/// Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the
|
||||||
|
/// provider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each
|
||||||
|
/// surviving title's host id stable across reconciles, drops orphans, and never touches manual
|
||||||
|
/// entries or other providers'. An empty array removes everything the provider owns. Emits
|
||||||
|
/// `library.changed` with the provider as `source`.
|
||||||
|
#[utoipa::path(
|
||||||
|
put,
|
||||||
|
path = "/library/provider/{provider}",
|
||||||
|
tag = "library",
|
||||||
|
operation_id = "reconcileProviderEntries",
|
||||||
|
params(("provider" = String, Path, description = "The provider id ([a-z0-9._-], `manual` reserved)")),
|
||||||
|
request_body = Vec<crate::library::ProviderEntryInput>,
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "The provider's resulting entries (host ids assigned/kept)", body = [crate::library::CustomEntry]),
|
||||||
|
(status = BAD_REQUEST, description = "Invalid provider id or payload", body = ApiError),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub(crate) async fn reconcile_provider_entries(
|
||||||
|
Path(provider): Path<String>,
|
||||||
|
ApiJson(inputs): ApiJson<Vec<crate::library::ProviderEntryInput>>,
|
||||||
|
) -> Response {
|
||||||
|
if let Err(e) = crate::library::validate_provider_name(&provider) {
|
||||||
|
return api_error(StatusCode::BAD_REQUEST, &e);
|
||||||
|
}
|
||||||
|
if let Err(e) = crate::library::validate_provider_payload(&inputs) {
|
||||||
|
return api_error(StatusCode::BAD_REQUEST, &e);
|
||||||
|
}
|
||||||
|
match crate::library::reconcile_provider(&provider, inputs) {
|
||||||
|
Ok(entries) => {
|
||||||
|
tracing::info!(
|
||||||
|
provider,
|
||||||
|
count = entries.len(),
|
||||||
|
"library provider reconciled"
|
||||||
|
);
|
||||||
|
Json(entries).into_response()
|
||||||
|
}
|
||||||
|
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a provider's library entries
|
||||||
|
///
|
||||||
|
/// Deletes every entry owned by `{provider}` — the clean-uninstall path for a provider plugin
|
||||||
|
/// (RFC §8). Emits `library.changed` when anything was removed.
|
||||||
|
#[utoipa::path(
|
||||||
|
delete,
|
||||||
|
path = "/library/provider/{provider}",
|
||||||
|
tag = "library",
|
||||||
|
operation_id = "deleteProviderEntries",
|
||||||
|
params(("provider" = String, Path, description = "The provider id")),
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "How many entries were removed", body = ProviderRemoved),
|
||||||
|
(status = BAD_REQUEST, description = "Invalid provider id", body = ApiError),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub(crate) async fn delete_provider_entries(Path(provider): Path<String>) -> Response {
|
||||||
|
if let Err(e) = crate::library::validate_provider_name(&provider) {
|
||||||
|
return api_error(StatusCode::BAD_REQUEST, &e);
|
||||||
|
}
|
||||||
|
match crate::library::delete_provider(&provider) {
|
||||||
|
Ok(removed) => {
|
||||||
|
if removed > 0 {
|
||||||
|
tracing::info!(provider, removed, "library provider entries removed");
|
||||||
|
}
|
||||||
|
Json(ProviderRemoved { removed }).into_response()
|
||||||
|
}
|
||||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -946,3 +946,316 @@ async fn logs_endpoint_pages_by_cursor() {
|
|||||||
assert!(json["entries"].as_array().unwrap().is_empty());
|
assert!(json["entries"].as_array().unwrap().is_empty());
|
||||||
assert_eq!(json["next"].as_u64().unwrap(), after);
|
assert_eq!(json["next"].as_u64().unwrap(), after);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ events (SSE)
|
||||||
|
|
||||||
|
/// Serializes the events-route tests: they share the process-global event bus and the
|
||||||
|
/// connection-cap counter, so the cap test must never 503 a concurrently running stream test.
|
||||||
|
static EVENTS_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||||
|
|
||||||
|
/// `get_req` + the default test bearer, pre-attached (these tests read streaming bodies
|
||||||
|
/// directly instead of going through `send`).
|
||||||
|
fn events_req(path: &str) -> axum::http::Request<Body> {
|
||||||
|
let mut req = get_req(path);
|
||||||
|
req.headers_mut().insert(
|
||||||
|
axum::http::header::AUTHORIZATION,
|
||||||
|
axum::http::HeaderValue::from_static("Bearer test-secret"),
|
||||||
|
);
|
||||||
|
req
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The next SSE frame as text, or `None` when the stream ended / nothing arrived in time.
|
||||||
|
async fn next_sse_chunk(body: &mut Body) -> Option<String> {
|
||||||
|
match tokio::time::timeout(std::time::Duration::from_secs(5), body.frame()).await {
|
||||||
|
Ok(Some(Ok(frame))) => frame
|
||||||
|
.into_data()
|
||||||
|
.ok()
|
||||||
|
.map(|b| String::from_utf8_lossy(&b).into_owned()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every `data:` payload in accumulated SSE text, parsed as JSON.
|
||||||
|
fn sse_data_events(text: &str) -> Vec<serde_json::Value> {
|
||||||
|
text.lines()
|
||||||
|
.filter_map(|l| l.strip_prefix("data: "))
|
||||||
|
.filter_map(|d| serde_json::from_str(d).ok())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn events_stream_requires_bearer() {
|
||||||
|
let app = test_app(test_state(), None);
|
||||||
|
let mut req = get_req("/api/v1/events");
|
||||||
|
req.headers_mut().insert(
|
||||||
|
axum::http::header::AUTHORIZATION,
|
||||||
|
axum::http::HeaderValue::from_static("Bearer wrong"),
|
||||||
|
);
|
||||||
|
let resp = app.clone().oneshot(req).await.expect("infallible");
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full consumer contract on one route: ring catch-up, the server-side kind filter, the
|
||||||
|
/// live tail on the same connection, `?since=`/`Last-Event-ID` resume, and the `dropped`
|
||||||
|
/// marker for a cursor that fell off the ring.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn events_stream_catch_up_filter_resume_tail_and_dropped() {
|
||||||
|
use crate::events::EventKind;
|
||||||
|
let _l = EVENTS_TEST_LOCK.lock().await;
|
||||||
|
let app = test_app(test_state(), None);
|
||||||
|
let uniq = format!("evt-{}-{:p}", std::process::id(), &0u8 as *const u8);
|
||||||
|
let m1 = format!("{uniq}-one");
|
||||||
|
|
||||||
|
// Noise of a different kind (must be filtered out), then our marker.
|
||||||
|
crate::events::emit(EventKind::DisplayReleased { count: 424_242 });
|
||||||
|
crate::events::emit(EventKind::LibraryChanged { source: m1.clone() });
|
||||||
|
|
||||||
|
let resp = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(events_req("/api/v1/events?kinds=library.changed"))
|
||||||
|
.await
|
||||||
|
.expect("infallible");
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let ctype = resp
|
||||||
|
.headers()
|
||||||
|
.get(axum::http::header::CONTENT_TYPE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
assert!(
|
||||||
|
ctype.starts_with("text/event-stream"),
|
||||||
|
"content-type: {ctype}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Catch-up must deliver m1 (other tests' library.changed events may interleave — scan).
|
||||||
|
let mut body = resp.into_body();
|
||||||
|
let mut seen = String::new();
|
||||||
|
while !seen.contains(&m1) {
|
||||||
|
let chunk = next_sse_chunk(&mut body)
|
||||||
|
.await
|
||||||
|
.expect("catch-up delivers the marker event");
|
||||||
|
seen.push_str(&chunk);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!seen.contains("event: display.released"),
|
||||||
|
"kind filter must drop other kinds: {seen}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
seen.contains("event: library.changed"),
|
||||||
|
"frame kind: {seen}"
|
||||||
|
);
|
||||||
|
let m1_seq = sse_data_events(&seen)
|
||||||
|
.iter()
|
||||||
|
.find(|e| e["source"] == m1.as_str())
|
||||||
|
.and_then(|e| e["seq"].as_u64())
|
||||||
|
.expect("marker frame carries the full event JSON with its seq");
|
||||||
|
|
||||||
|
// Live tail on the SAME connection. If a concurrent test floods the broadcast channel the
|
||||||
|
// slow-consumer cut ends this stream — then the documented client move (reconnect with the
|
||||||
|
// last seen id) must deliver m2 instead, so follow it rather than flaking.
|
||||||
|
let m2 = format!("{uniq}-two");
|
||||||
|
crate::events::emit(EventKind::LibraryChanged { source: m2.clone() });
|
||||||
|
let mut tail = String::new();
|
||||||
|
loop {
|
||||||
|
match next_sse_chunk(&mut body).await {
|
||||||
|
Some(chunk) => {
|
||||||
|
tail.push_str(&chunk);
|
||||||
|
if tail.contains(&m2) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let resp = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(events_req(&format!(
|
||||||
|
"/api/v1/events?since={m1_seq}&kinds=library.changed"
|
||||||
|
)))
|
||||||
|
.await
|
||||||
|
.expect("infallible");
|
||||||
|
body = resp.into_body();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(body);
|
||||||
|
|
||||||
|
// Resume from m1's seq: m2 is caught up, m1 is not.
|
||||||
|
let resp = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(events_req(&format!(
|
||||||
|
"/api/v1/events?since={m1_seq}&kinds=library.changed"
|
||||||
|
)))
|
||||||
|
.await
|
||||||
|
.expect("infallible");
|
||||||
|
let mut body = resp.into_body();
|
||||||
|
let mut resumed = String::new();
|
||||||
|
while !resumed.contains(&m2) {
|
||||||
|
let chunk = next_sse_chunk(&mut body)
|
||||||
|
.await
|
||||||
|
.expect("resume catch-up delivers m2");
|
||||||
|
resumed.push_str(&chunk);
|
||||||
|
}
|
||||||
|
assert!(!resumed.contains(&m1), "since-cursor must exclude m1");
|
||||||
|
drop(body);
|
||||||
|
|
||||||
|
// Last-Event-ID beats ?since (it is the newer cursor on an SSE auto-reconnect).
|
||||||
|
let mut req = events_req("/api/v1/events?since=0&kinds=library.changed");
|
||||||
|
req.headers_mut().insert(
|
||||||
|
"last-event-id",
|
||||||
|
axum::http::HeaderValue::from_str(&m1_seq.to_string()).unwrap(),
|
||||||
|
);
|
||||||
|
let resp = app.clone().oneshot(req).await.expect("infallible");
|
||||||
|
let mut body = resp.into_body();
|
||||||
|
let mut resumed = String::new();
|
||||||
|
while !resumed.contains(&m2) {
|
||||||
|
let chunk = next_sse_chunk(&mut body)
|
||||||
|
.await
|
||||||
|
.expect("header-resume catch-up delivers m2");
|
||||||
|
resumed.push_str(&chunk);
|
||||||
|
}
|
||||||
|
assert!(!resumed.contains(&m1), "Last-Event-ID must exclude m1");
|
||||||
|
drop(body);
|
||||||
|
|
||||||
|
// A cursor that fell off the ring gets the dropped marker first. Flood the ring past
|
||||||
|
// capacity, then resume from seq 1.
|
||||||
|
for _ in 0..1100 {
|
||||||
|
crate::events::emit(EventKind::DisplayReleased { count: 1 });
|
||||||
|
}
|
||||||
|
let resp = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(events_req("/api/v1/events?since=1"))
|
||||||
|
.await
|
||||||
|
.expect("infallible");
|
||||||
|
let mut body = resp.into_body();
|
||||||
|
let first = next_sse_chunk(&mut body).await.expect("dropped marker");
|
||||||
|
assert!(first.contains("event: dropped"), "first frame: {first}");
|
||||||
|
assert!(
|
||||||
|
first.contains(r#"{"dropped":true}"#),
|
||||||
|
"marker data: {first}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn events_stream_connection_cap() {
|
||||||
|
let _l = EVENTS_TEST_LOCK.lock().await;
|
||||||
|
let app = test_app(test_state(), None);
|
||||||
|
|
||||||
|
let slots = super::events::test_support::saturate_slots();
|
||||||
|
let resp = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(events_req("/api/v1/events"))
|
||||||
|
.await
|
||||||
|
.expect("infallible");
|
||||||
|
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||||
|
drop(slots);
|
||||||
|
|
||||||
|
let resp = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(events_req("/api/v1/events"))
|
||||||
|
.await
|
||||||
|
.expect("infallible");
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK, "cap frees with the slots");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ hooks
|
||||||
|
|
||||||
|
/// GET returns the (empty-when-unconfigured) config; PUT validation rejects structural errors
|
||||||
|
/// with the reason. A *successful* PUT is deliberately not exercised through the route — it
|
||||||
|
/// would write the developer's real config dir; persistence is unit-tested in `crate::hooks`
|
||||||
|
/// against a temp path.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hooks_get_shape_and_put_validation() {
|
||||||
|
let app = test_app(test_state(), None);
|
||||||
|
|
||||||
|
let (s, json) = send(&app, get_req("/api/v1/hooks")).await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
assert!(json["hooks"].is_array());
|
||||||
|
|
||||||
|
let put = |body: serde_json::Value| {
|
||||||
|
axum::http::Request::put("/api/v1/hooks")
|
||||||
|
.header(axum::http::header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(body.to_string()))
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Structurally invalid: an entry with no action.
|
||||||
|
let (s, json) = send(
|
||||||
|
&app,
|
||||||
|
put(serde_json::json!({"hooks": [{"on": "stream.started"}]})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||||
|
assert!(
|
||||||
|
json["error"].as_str().unwrap().contains("run"),
|
||||||
|
"error names the problem: {json}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Non-http(s) webhook.
|
||||||
|
let (s, _) = send(
|
||||||
|
&app,
|
||||||
|
put(serde_json::json!({"hooks": [{"on": "pairing.*", "webhook": "ftp://x"}]})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||||
|
|
||||||
|
// Wrong bearer → 401 (the hooks surface is admin-lane).
|
||||||
|
let mut req = get_req("/api/v1/hooks");
|
||||||
|
req.headers_mut().insert(
|
||||||
|
axum::http::header::AUTHORIZATION,
|
||||||
|
axum::http::HeaderValue::from_static("Bearer wrong"),
|
||||||
|
);
|
||||||
|
let resp = app.clone().oneshot(req).await.expect("infallible");
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ library providers
|
||||||
|
|
||||||
|
/// Provider reconcile validation (the write path itself is unit-tested in `library::custom`
|
||||||
|
/// against pure functions — a successful PUT here would touch the developer's real catalog).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn provider_reconcile_validation() {
|
||||||
|
let app = test_app(test_state(), None);
|
||||||
|
let put = |provider: &str, body: serde_json::Value| {
|
||||||
|
axum::http::Request::put(format!("/api/v1/library/provider/{provider}"))
|
||||||
|
.header(axum::http::header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(body.to_string()))
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reserved / malformed provider ids.
|
||||||
|
let (s, json) = send(&app, put("manual", serde_json::json!([]))).await;
|
||||||
|
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||||
|
assert!(json["error"].as_str().unwrap().contains("reserved"));
|
||||||
|
let (s, _) = send(&app, put("Bad%2FName", serde_json::json!([]))).await;
|
||||||
|
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||||
|
|
||||||
|
// Payload rules: empty external_id, duplicate external_id.
|
||||||
|
let (s, _) = send(
|
||||||
|
&app,
|
||||||
|
put(
|
||||||
|
"romm",
|
||||||
|
serde_json::json!([{"external_id": "", "title": "X"}]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||||
|
let (s, json) = send(
|
||||||
|
&app,
|
||||||
|
put(
|
||||||
|
"romm",
|
||||||
|
serde_json::json!([
|
||||||
|
{"external_id": "a", "title": "A"},
|
||||||
|
{"external_id": "a", "title": "B"}
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||||
|
assert!(json["error"].as_str().unwrap().contains("duplicate"));
|
||||||
|
|
||||||
|
// DELETE validates the name too.
|
||||||
|
let del = axum::http::Request::delete("/api/v1/library/provider/manual")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let (s, _) = send(&app, del).await;
|
||||||
|
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|||||||
+40
-2320
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,206 @@
|
|||||||
|
//! The native `punktfunk/1` mid-stream control task (plan §W1 — carved out of [`super`]'s
|
||||||
|
//! `serve_session`). After the handshake the control stream stays open for renegotiation and
|
||||||
|
//! speed tests; this task multiplexes the inbound client requests (`Reconfigure` /
|
||||||
|
//! `RequestKeyframe` / `RfiRequest` / `LossReport` / `SetBitrate` / `ProbeRequest` / `ClockProbe`)
|
||||||
|
//! with the outbound probe-result and mode-correction channels, handing every validated change to
|
||||||
|
//! the data-plane thread over the session's mpsc bridges.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Run the control task for one live session. Owns the control streams (`serve_session` hands them
|
||||||
|
/// off after negotiation) plus every channel end that bridges to the data-plane thread. Returns
|
||||||
|
/// when the control stream closes or a data-plane channel drops.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) async fn run(
|
||||||
|
mut ctrl_send: quinn::SendStream,
|
||||||
|
mut ctrl_recv: quinn::RecvStream,
|
||||||
|
initial_mode: punktfunk_core::Mode,
|
||||||
|
codec: crate::encode::Codec,
|
||||||
|
live_reconfig_ok: bool,
|
||||||
|
adaptive_fec: bool,
|
||||||
|
session_bitrate_kbps: u32,
|
||||||
|
fec_target_ctl: Arc<AtomicU8>,
|
||||||
|
reconfig_tx: std::sync::mpsc::Sender<punktfunk_core::Mode>,
|
||||||
|
keyframe_tx: std::sync::mpsc::Sender<()>,
|
||||||
|
rfi_tx: std::sync::mpsc::Sender<(u32, u32)>,
|
||||||
|
bitrate_tx: std::sync::mpsc::Sender<u32>,
|
||||||
|
probe_tx: std::sync::mpsc::Sender<ProbeRequest>,
|
||||||
|
mut probe_result_rx: tokio::sync::mpsc::UnboundedReceiver<ProbeResult>,
|
||||||
|
mut reconfig_result_rx: tokio::sync::mpsc::UnboundedReceiver<Reconfigured>,
|
||||||
|
) {
|
||||||
|
let mut active = initial_mode;
|
||||||
|
// Host-side switch rate limit (a backstop against a hostile/broken client spamming
|
||||||
|
// Reconfigure into pipeline-rebuild churn — the drain-to-newest in the data plane already
|
||||||
|
// coalesces a well-behaved resize drag; compliant clients self-limit to ≥ 1 s).
|
||||||
|
const MIN_SWITCH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(500);
|
||||||
|
let mut last_accepted_switch: Option<std::time::Instant> = None;
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
msg = io::read_msg(&mut ctrl_recv) => {
|
||||||
|
let Ok(msg) = msg else { break }; // stream closed
|
||||||
|
if let Ok(req) = Reconfigure::decode(&msg) {
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
let valid = req.mode.refresh_hz > 0
|
||||||
|
&& crate::encode::validate_dimensions(
|
||||||
|
codec,
|
||||||
|
req.mode.width,
|
||||||
|
req.mode.height,
|
||||||
|
)
|
||||||
|
.is_ok();
|
||||||
|
let too_soon = last_accepted_switch
|
||||||
|
.is_some_and(|t| now.duration_since(t) < MIN_SWITCH_INTERVAL);
|
||||||
|
let ok = if !live_reconfig_ok {
|
||||||
|
// Backend can't live-reconfigure (gamescope / synthetic /
|
||||||
|
// per-client-mode identity — see the gate above): honest downgrade,
|
||||||
|
// the client keeps scaling client-side.
|
||||||
|
tracing::info!(mode = ?req.mode,
|
||||||
|
"mode switch rejected (backend cannot live-reconfigure)");
|
||||||
|
false
|
||||||
|
} else if !valid {
|
||||||
|
tracing::warn!(mode = ?req.mode, "mode switch rejected (invalid dimensions)");
|
||||||
|
false
|
||||||
|
} else if too_soon {
|
||||||
|
tracing::warn!(mode = ?req.mode, "mode switch rejected (rate-limited)");
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
};
|
||||||
|
if ok {
|
||||||
|
active = req.mode;
|
||||||
|
last_accepted_switch = Some(now);
|
||||||
|
tracing::info!(mode = ?req.mode, "mode switch accepted");
|
||||||
|
}
|
||||||
|
let ack = Reconfigured { accepted: ok, mode: active };
|
||||||
|
if io::write_msg(&mut ctrl_send, &ack.encode()).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if ok && reconfig_tx.send(req.mode).is_err() {
|
||||||
|
break; // data plane gone
|
||||||
|
}
|
||||||
|
} else if RequestKeyframe::decode(&msg).is_ok() {
|
||||||
|
// Client recovery: its decoder wedged — force the next encoded frame to
|
||||||
|
// be an IDR. Coalesced in the encode loop (a wedge fires several before
|
||||||
|
// the IDR lands); a send error just means the data plane is gone.
|
||||||
|
tracing::debug!("client requested keyframe (decode recovery)");
|
||||||
|
if keyframe_tx.send(()).is_err() {
|
||||||
|
break; // data plane gone
|
||||||
|
}
|
||||||
|
} else if let Ok(req) = RfiRequest::decode(&msg) {
|
||||||
|
// Client LTR-RFI recovery: it lost the frame range `[first, last]` and asks
|
||||||
|
// the encoder to re-reference a known-good older frame instead of paying for
|
||||||
|
// a full IDR. The encode loop attempts `invalidate_ref_frames`, falling back
|
||||||
|
// to a coalesced keyframe when the encoder can't (range too old / no RFI).
|
||||||
|
tracing::debug!(
|
||||||
|
first = req.first_frame,
|
||||||
|
last = req.last_frame,
|
||||||
|
"client requested reference-frame invalidation (loss recovery)"
|
||||||
|
);
|
||||||
|
if rfi_tx.send((req.first_frame, req.last_frame)).is_err() {
|
||||||
|
break; // data plane gone
|
||||||
|
}
|
||||||
|
} else if let Ok(rep) = LossReport::decode(&msg) {
|
||||||
|
// Adaptive FEC: size recovery to the loss the client is seeing. The data-plane
|
||||||
|
// send loop reads `fec_target_ctl` and applies it per frame. Ignored when FEC
|
||||||
|
// is pinned via PUNKTFUNK_FEC_PCT.
|
||||||
|
if adaptive_fec {
|
||||||
|
// Fast attack, slow decay: jump straight to what the reported loss
|
||||||
|
// needs, but come DOWN only one point per clean report (~750 ms). The
|
||||||
|
// memoryless controller ping-ponged on periodic burst loss (Wi-Fi
|
||||||
|
// scans / BT coexistence, a burst every few seconds): a single clean
|
||||||
|
// window dropped FEC back to the floor, so every next burst hit an
|
||||||
|
// unprotected stream — an unrecoverable frame, a freeze, and a
|
||||||
|
// recovery-IDR burst, once per cycle. Decaying over ~10 windows keeps
|
||||||
|
// the stream covered across the gap while still converging to FEC_MIN
|
||||||
|
// on a genuinely clean link.
|
||||||
|
let prev = fec_target_ctl.load(Ordering::Relaxed);
|
||||||
|
let target = adapt_fec(rep.loss_ppm).max(prev.saturating_sub(1));
|
||||||
|
fec_target_ctl.store(target, Ordering::Relaxed);
|
||||||
|
if prev != target {
|
||||||
|
tracing::debug!(
|
||||||
|
loss_ppm = rep.loss_ppm,
|
||||||
|
fec_pct = target,
|
||||||
|
prev_fec_pct = prev,
|
||||||
|
"adaptive FEC adjusted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Ok(req) = SetBitrate::decode(&msg) {
|
||||||
|
// Mid-stream bitrate renegotiation (adaptive bitrate): clamp exactly like
|
||||||
|
// the Hello request, ack the resolved value, then hand it to the data-plane
|
||||||
|
// thread, which rebuilds the encoder in place at the same mode — the fresh
|
||||||
|
// encoder's first frame is an IDR with in-band parameter sets, so the
|
||||||
|
// client's decoder follows without a reconnect.
|
||||||
|
// PyroWave: the rate is PINNED (§4.6 — quality collapses under rate
|
||||||
|
// descent; recovery pressure is answered by codec fallback, not AIMD).
|
||||||
|
// Our client controller is off for this codec; this guards older or
|
||||||
|
// foreign clients by acking the unchanged session rate.
|
||||||
|
let resolved = if codec == crate::encode::Codec::PyroWave {
|
||||||
|
tracing::info!(
|
||||||
|
requested_kbps = req.bitrate_kbps,
|
||||||
|
pinned_kbps = session_bitrate_kbps,
|
||||||
|
"PyroWave session: mid-stream bitrate retarget refused (pinned)"
|
||||||
|
);
|
||||||
|
session_bitrate_kbps
|
||||||
|
} else {
|
||||||
|
resolve_bitrate_kbps(req.bitrate_kbps)
|
||||||
|
};
|
||||||
|
tracing::debug!(
|
||||||
|
requested_kbps = req.bitrate_kbps,
|
||||||
|
resolved_kbps = resolved,
|
||||||
|
"mid-stream bitrate change requested"
|
||||||
|
);
|
||||||
|
let ack = BitrateChanged {
|
||||||
|
bitrate_kbps: resolved,
|
||||||
|
};
|
||||||
|
if io::write_msg(&mut ctrl_send, &ack.encode()).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if bitrate_tx.send(resolved).is_err() {
|
||||||
|
break; // data plane gone
|
||||||
|
}
|
||||||
|
} else if let Ok(req) = ProbeRequest::decode(&msg) {
|
||||||
|
tracing::info!(
|
||||||
|
target_kbps = req.target_kbps,
|
||||||
|
duration_ms = req.duration_ms,
|
||||||
|
"speed-test probe requested"
|
||||||
|
);
|
||||||
|
if probe_tx.send(req).is_err() {
|
||||||
|
break; // data plane gone
|
||||||
|
}
|
||||||
|
} else if let Ok(probe) = ClockProbe::decode(&msg) {
|
||||||
|
// Wall-clock skew handshake: echo the client's t1 with our receive (t2) and
|
||||||
|
// send (t3) stamps, both in the host clock the AU pts_ns uses. Answered
|
||||||
|
// inline on the control stream — cheap, no data-plane involvement.
|
||||||
|
let t2_ns = now_ns();
|
||||||
|
let echo = ClockEcho {
|
||||||
|
t1_ns: probe.t1_ns,
|
||||||
|
t2_ns,
|
||||||
|
t3_ns: now_ns(),
|
||||||
|
};
|
||||||
|
if io::write_msg(&mut ctrl_send, &echo.encode()).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::warn!("unknown control message — ignoring");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = probe_result_rx.recv() => {
|
||||||
|
let Some(result) = result else { break }; // data plane gone
|
||||||
|
if io::write_msg(&mut ctrl_send, &result.encode()).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
correction = reconfig_result_rx.recv() => {
|
||||||
|
// H2 rollback/correction ack: the data plane reports the mode ACTUALLY live
|
||||||
|
// after a rebuild that failed (stayed at the old mode) or that the backend
|
||||||
|
// honored at a different refresh. Track it so a later rejection's
|
||||||
|
// `mode: active` echo is truthful too.
|
||||||
|
let Some(ack) = correction else { break }; // data plane gone
|
||||||
|
active = ack.mode;
|
||||||
|
if io::write_msg(&mut ctrl_send, &ack.encode()).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -246,7 +246,8 @@ pub(super) async fn negotiate(
|
|||||||
// today (full-chroma IDD-push capture is a follow-up), so it returns false there and the host
|
// today (full-chroma IDD-push capture is a follow-up), so it returns false there and the host
|
||||||
// negotiates 4:2:0. (Replaces the old `single_process` gate — single-process is now the only
|
// negotiates 4:2:0. (Replaces the old `single_process` gate — single-process is now the only
|
||||||
// topology, and 4:4:4 routed to DDA, which was removed.)
|
// topology, and 4:4:4 routed to DDA, which was removed.)
|
||||||
let capture_supports_444 = crate::capture::capturer_supports_444();
|
let capture_supports_444 =
|
||||||
|
crate::capture::capturer_supports_444(crate::encode::resolved_backend_ingests_rgb_444());
|
||||||
// The GPU probe opens a real (tiny) encoder on first use, so run it off the reactor like the
|
// The GPU probe opens a real (tiny) encoder on first use, so run it off the reactor like the
|
||||||
// compositor probe above (blocking probes → spawn_blocking). Short-circuit so it only runs when
|
// compositor probe above (blocking probes → spawn_blocking). Short-circuit so it only runs when
|
||||||
// the cheap gates already pass. The result is cached process-wide (a negative latches until
|
// the cheap gates already pass. The result is cached process-wide (a negative latches until
|
||||||
|
|||||||
@@ -56,8 +56,8 @@ impl PadState {
|
|||||||
self.rs_y = s.rs_y;
|
self.rs_y = s.rs_y;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn frame(&self, index: usize, active_mask: u16) -> crate::gamestream::gamepad::GamepadFrame {
|
fn frame(&self, index: usize, active_mask: u16) -> punktfunk_core::input::GamepadFrame {
|
||||||
crate::gamestream::gamepad::GamepadFrame {
|
punktfunk_core::input::GamepadFrame {
|
||||||
index: index as i16,
|
index: index as i16,
|
||||||
active_mask,
|
active_mask,
|
||||||
buttons: self.buttons,
|
buttons: self.buttons,
|
||||||
@@ -192,8 +192,8 @@ impl Pads {
|
|||||||
self.kinds[idx] = resolved;
|
self.kinds[idx] = resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle(&mut self, ev: &crate::gamestream::gamepad::GamepadEvent) {
|
fn handle(&mut self, ev: &punktfunk_core::input::GamepadEvent) {
|
||||||
use crate::gamestream::gamepad::GamepadEvent;
|
use punktfunk_core::input::GamepadEvent;
|
||||||
// Present = a create/update frame (the pad's mask bit is set); a cleared bit is the
|
// Present = a create/update frame (the pad's mask bit is set); a cleared bit is the
|
||||||
// removal frame emitted by the native detach path (`GamepadRemove`).
|
// removal frame emitted by the native detach path (`GamepadRemove`).
|
||||||
let (idx, present) = match ev {
|
let (idx, present) = match ev {
|
||||||
@@ -212,7 +212,7 @@ impl Pads {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Dispatch a decoded event to the manager for `kind`, creating it lazily.
|
/// Dispatch a decoded event to the manager for `kind`, creating it lazily.
|
||||||
fn route_handle(&mut self, kind: GamepadPref, ev: &crate::gamestream::gamepad::GamepadEvent) {
|
fn route_handle(&mut self, kind: GamepadPref, ev: &punktfunk_core::input::GamepadEvent) {
|
||||||
match kind {
|
match kind {
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
GamepadPref::DualSense => self
|
GamepadPref::DualSense => self
|
||||||
@@ -682,7 +682,7 @@ pub(super) fn input_thread(
|
|||||||
if idx < MAX_WIRE_PADS && pad_state[idx].apply(&ev) {
|
if idx < MAX_WIRE_PADS && pad_state[idx].apply(&ev) {
|
||||||
pad_mask |= 1 << idx;
|
pad_mask |= 1 << idx;
|
||||||
let frame = pad_state[idx].frame(idx, pad_mask);
|
let frame = pad_state[idx].frame(idx, pad_mask);
|
||||||
pads.handle(&crate::gamestream::gamepad::GamepadEvent::State(frame));
|
pads.handle(&punktfunk_core::input::GamepadEvent::State(frame));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
InputKind::GamepadState => {
|
InputKind::GamepadState => {
|
||||||
@@ -704,9 +704,7 @@ pub(super) fn input_thread(
|
|||||||
if first || pad_state[idx] != before {
|
if first || pad_state[idx] != before {
|
||||||
pad_mask |= 1 << idx;
|
pad_mask |= 1 << idx;
|
||||||
let frame = pad_state[idx].frame(idx, pad_mask);
|
let frame = pad_state[idx].frame(idx, pad_mask);
|
||||||
pads.handle(&crate::gamestream::gamepad::GamepadEvent::State(
|
pads.handle(&punktfunk_core::input::GamepadEvent::State(frame));
|
||||||
frame,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -729,7 +727,7 @@ pub(super) fn input_thread(
|
|||||||
pad_mask &= !(1 << idx);
|
pad_mask &= !(1 << idx);
|
||||||
pad_state[idx] = PadState::default();
|
pad_state[idx] = PadState::default();
|
||||||
let frame = pad_state[idx].frame(idx, pad_mask);
|
let frame = pad_state[idx].frame(idx, pad_mask);
|
||||||
pads.handle(&crate::gamestream::gamepad::GamepadEvent::State(frame));
|
pads.handle(&punktfunk_core::input::GamepadEvent::State(frame));
|
||||||
tracing::info!(pad = idx, "gamepad unplugged (native detach)");
|
tracing::info!(pad = idx, "gamepad unplugged (native detach)");
|
||||||
}
|
}
|
||||||
// Fresh feedback bookkeeping so a later re-plug on this index inherits no
|
// Fresh feedback bookkeeping so a later re-plug on this index inherits no
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -108,7 +108,7 @@ pub fn run(opts: Options) -> Result<()> {
|
|||||||
.context("create virtual output")?;
|
.context("create virtual output")?;
|
||||||
capture::capture_virtual_output(
|
capture::capture_virtual_output(
|
||||||
vout,
|
vout,
|
||||||
capture::OutputFormat::resolve(false),
|
capture::OutputFormat::resolve(false, crate::encode::resolved_backend_is_gpu()),
|
||||||
crate::session_plan::CaptureBackend::resolve(),
|
crate::session_plan::CaptureBackend::resolve(),
|
||||||
)
|
)
|
||||||
.context("capture virtual output")?
|
.context("capture virtual output")?
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,204 @@
|
|||||||
|
//! Virtual-display backend contract (plan §W3 — the trait facade carved out of [`super`]).
|
||||||
|
//! [`DisplayOwnership`] declares who owns an output's lifecycle, [`VirtualOutput`] is the created
|
||||||
|
//! output (PipeWire node + RAII keepalive), and [`VirtualDisplay`] is the per-compositor backend
|
||||||
|
//! trait `super::open` returns boxed. The per-backend `impl`s and the factory stay in `super`.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Who owns a [`VirtualOutput`]'s lifecycle — the honest declaration that lets the registry
|
||||||
|
/// (`design/gamemode-and-dedicated-sessions.md` Part A1) pool **only what it owns** instead of
|
||||||
|
/// keeping outputs whose real lifecycle lives elsewhere (the gamescope managed/attach paths, which
|
||||||
|
/// are governed by the gamescope module's own session machinery). Extends the CLAUDE.md invariant
|
||||||
|
/// "the registry owns display lifecycle" with its converse: what the registry does not own, it must
|
||||||
|
/// not pretend to keep.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub enum DisplayOwnership {
|
||||||
|
/// The registry owns the lifecycle: it may pool, linger, pin, and tear this display down (KWin,
|
||||||
|
/// Mutter, wlroots, gamescope **bare spawn**, and the Windows manager-delegated monitor). The
|
||||||
|
/// default — a backend that says nothing is registry-owned.
|
||||||
|
#[default]
|
||||||
|
Owned,
|
||||||
|
/// Someone else's display, merely mirrored: no keep-alive, no topology, no reuse (gamescope
|
||||||
|
/// **attach** to a foreign session). Codifies the design-doc §7 "attach = unmanaged pass-through"
|
||||||
|
/// row.
|
||||||
|
External,
|
||||||
|
/// A box-level session the gamescope module manages (the managed `gamescope-session-plus` /
|
||||||
|
/// SteamOS takeover). Passed through by the registry (its restore lifecycle is the gamescope
|
||||||
|
/// module's until Part A3 hands the registry a real keepalive + restore duty).
|
||||||
|
SessionManaged,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A created virtual output: a PipeWire source to capture, plus an owned keepalive whose drop
|
||||||
|
/// tears the output down (releases the compositor-side resource).
|
||||||
|
///
|
||||||
|
/// Allowed dead on non-Linux: the backends that construct it are all `cfg(target_os = "linux")`.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct VirtualOutput {
|
||||||
|
/// PipeWire node id of the output's screencast stream.
|
||||||
|
pub node_id: u32,
|
||||||
|
/// Portal/remote PipeWire fd when the node lives on a sandboxed remote (e.g. Mutter's
|
||||||
|
/// RemoteDesktop+ScreenCast). `None` means the node is on the user's default PipeWire daemon
|
||||||
|
/// (KWin `zkde_screencast`), captured by connecting to that daemon directly.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub remote_fd: Option<OwnedFd>,
|
||||||
|
/// `(width, height, refresh_hz)` to prefer in the PipeWire format negotiation. KWin and
|
||||||
|
/// gamescope outputs are created at the exact size, so this just confirms it; **Mutter sizes
|
||||||
|
/// its virtual monitor FROM the negotiation**, so here it's what makes the client's mode real.
|
||||||
|
pub preferred_mode: Option<(u32, u32, u32)>,
|
||||||
|
/// Windows capture identity (DXGI adapter LUID + GDI output name) for the pf-vdisplay backend —
|
||||||
|
/// what [`crate::capture::capture_virtual_output`] needs to duplicate the right output.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub win_capture: Option<crate::capture::dxgi::WinCaptureTarget>,
|
||||||
|
/// Keeps the output — and whatever connection/thread backs it — alive; dropped on teardown.
|
||||||
|
pub keepalive: Box<dyn Send>,
|
||||||
|
/// Who owns this display's lifecycle (`design/gamemode-and-dedicated-sessions.md` A1). The
|
||||||
|
/// registry pools/keep-alives only [`DisplayOwnership::Owned`] outputs; `External`/`SessionManaged`
|
||||||
|
/// pass through (the capturer holds the keepalive, teardown on drop). Defaults to `Owned`.
|
||||||
|
pub ownership: DisplayOwnership,
|
||||||
|
/// `Some(gen)` when [`registry::acquire`](crate::vdisplay::registry::acquire) handed this back as a
|
||||||
|
/// **reused** kept display (`design/gamemode-and-dedicated-sessions.md` A2), so the pipeline builder
|
||||||
|
/// can [`registry::mark_failed(gen)`](crate::vdisplay::registry::mark_failed) if the first frame
|
||||||
|
/// fails on it — tearing the corpse down so the retry loop's next acquire creates fresh instead of
|
||||||
|
/// re-wedging on the same dead node. `None` on a fresh create / non-poolable output. Linux-only (the
|
||||||
|
/// keep-alive pool is Linux).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub reused_gen: Option<u64>,
|
||||||
|
/// The registry pool generation of this display (fresh AND reused — unlike `reused_gen`), so a
|
||||||
|
/// mid-stream mode-switch rebuild can [`registry::retire`](crate::vdisplay::registry::retire) the
|
||||||
|
/// display it supersedes instead of leaving it to accumulate under a linger/forever keep-alive
|
||||||
|
/// policy (`design/midstream-resolution-resize.md` H4). `None` for non-poolable outputs.
|
||||||
|
/// Linux-only (the keep-alive pool is Linux).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub pool_gen: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VirtualOutput {
|
||||||
|
/// A registry-[owned](DisplayOwnership::Owned) output — the common case (KWin/Mutter/wlroots,
|
||||||
|
/// gamescope bare-spawn, Windows). Fills `ownership: Owned`; the caller sets the platform fields.
|
||||||
|
pub fn owned(
|
||||||
|
node_id: u32,
|
||||||
|
preferred_mode: Option<(u32, u32, u32)>,
|
||||||
|
keepalive: Box<dyn Send>,
|
||||||
|
) -> VirtualOutput {
|
||||||
|
VirtualOutput {
|
||||||
|
node_id,
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
remote_fd: None,
|
||||||
|
preferred_mode,
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
win_capture: None,
|
||||||
|
keepalive,
|
||||||
|
ownership: DisplayOwnership::Owned,
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
reused_gen: None,
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pool_gen: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pluggable virtual-output creation, per compositor.
|
||||||
|
pub trait VirtualDisplay: Send {
|
||||||
|
/// Human-readable backend name (e.g. `"kwin"`, `"wlroots"`, `"mutter"`).
|
||||||
|
fn name(&self) -> &'static str;
|
||||||
|
/// Create a virtual output of the given mode. Teardown is RAII: drop the returned
|
||||||
|
/// [`VirtualOutput`]'s `keepalive`.
|
||||||
|
fn create(&mut self, mode: Mode) -> Result<VirtualOutput>;
|
||||||
|
/// Set the per-session command this display should launch into its nested output (the resolved
|
||||||
|
/// app/game). Carried on the backend instance — NOT a process-global env var — so concurrent
|
||||||
|
/// sessions can't stomp each other's launch target. Default: no-op (backends that attach to an
|
||||||
|
/// existing session / don't spawn a nested command ignore it; only gamescope's spawn path uses it).
|
||||||
|
fn set_launch_command(&mut self, _cmd: Option<String>) {}
|
||||||
|
/// Set the connecting client's cert fingerprint so the backend can give that client a STABLE virtual
|
||||||
|
/// monitor identity across reconnects and its saved per-monitor config (notably DPI scaling) is
|
||||||
|
/// reapplied — via the OS (Windows EDID serial), the compositor (KWin per-slot output name), or
|
||||||
|
/// host-side persistence (Mutter, whose virtual monitors can't carry a stable identity). Carried on
|
||||||
|
/// the backend instance; set once before [`create`](Self::create). Default: no-op (wlroots/gamescope
|
||||||
|
/// have no per-client identity). `None` = anonymous/unpaired/GameStream → the backend's auto
|
||||||
|
/// (slot-based/shared) identity.
|
||||||
|
fn set_client_identity(&mut self, _fingerprint: Option<[u8; 32]>) {}
|
||||||
|
/// Hand the backend the session's deliberate-quit flag (set when the client closes with the QUIT
|
||||||
|
/// application code — a user "stop", not a network drop) so the last lease's drop can tear the
|
||||||
|
/// display down IMMEDIATELY, skipping the keep-alive linger — the Windows analogue of the Linux
|
||||||
|
/// registry's `Linger::Immediate` path. Carried on the backend instance; set once before
|
||||||
|
/// [`create`](Self::create). Default: no-op — only the Windows pf-vdisplay backend needs it (its
|
||||||
|
/// leases live in the `VirtualDisplayManager`, which the registry's quit plumbing does not reach;
|
||||||
|
/// Linux backends get the flag through `registry::acquire`).
|
||||||
|
fn set_quit_flag(&mut self, _quit: std::sync::Arc<std::sync::atomic::AtomicBool>) {}
|
||||||
|
/// Hand the backend the CLIENT display's HDR colour volume (`Hello::display_hdr` — primaries /
|
||||||
|
/// white point / luminance range as reported by the client OS), so a freshly created virtual
|
||||||
|
/// output can advertise the client's REAL panel in its EDID (pf-vdisplay codes the luminance
|
||||||
|
/// into the CTA-861.3 HDR static-metadata block) — host apps and the OS then tone-map to the
|
||||||
|
/// panel the stream actually lands on instead of a built-in placeholder volume. Carried on the
|
||||||
|
/// backend instance; set once before [`create`](Self::create). `None` = unknown/SDR client →
|
||||||
|
/// the backend's default EDID. Default: no-op — only the Windows pf-vdisplay backend can mint
|
||||||
|
/// per-monitor EDIDs today (the Linux compositors' virtual outputs take no EDID from us).
|
||||||
|
fn set_client_hdr(&mut self, _hdr: Option<punktfunk_core::quic::HdrMeta>) {}
|
||||||
|
/// The stable identity slot the backend resolved for the most recent [`create`](Self::create) —
|
||||||
|
/// the per-client id the identity policy assigned (`Some`), or `None` for shared/anonymous. The
|
||||||
|
/// registry reads it right after `create` to key the display's group **arrangement** (manual
|
||||||
|
/// per-slot positions) and to label the mgmt `/display/state` slot. Default `None`: a backend
|
||||||
|
/// with no per-client identity (wlroots/gamescope) always auto-rows. KWin (per-slot output
|
||||||
|
/// naming) and Mutter (host-persisted per-client scale) report a real slot on Linux.
|
||||||
|
fn last_identity_slot(&self) -> Option<u32> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
/// Place the most-recently-[created](Self::create) output at `(x, y)` in the desktop coordinate
|
||||||
|
/// space (design `display-management.md` §6.2 — layout). The registry, which owns the display
|
||||||
|
/// **group**, computes the position from the whole group (auto-row or the console's manual
|
||||||
|
/// arrangement) and calls this right after `create`. Default no-op: only backends that can position
|
||||||
|
/// an output (KWin) implement it; the registry never calls it for the desktop origin `(0, 0)`, so a
|
||||||
|
/// single-display / first-of-group session issues no positioning at all. Best-effort — a failure
|
||||||
|
/// leaves the compositor's default placement.
|
||||||
|
fn apply_position(&mut self, _x: i32, _y: i32) {}
|
||||||
|
/// Take the topology **restore** action this [`create`](Self::create) prepared — the work that
|
||||||
|
/// un-does an `exclusive`/`primary` topology change (e.g. re-enable the physical outputs KWin
|
||||||
|
/// disabled). The registry lifts it into the display **group** so it runs **once, when the group's
|
||||||
|
/// last display is torn down** (design §6.1 — per-group restore), not when this one session's
|
||||||
|
/// display drops: a sibling `exclusive` session must not have the physical re-enabled under it.
|
||||||
|
/// Called right after `create`; the backend must not also run it itself. Default `None` — a backend
|
||||||
|
/// whose topology auto-reverts (Mutter `APPLY_TEMPORARY`) or that changes nothing has nothing to
|
||||||
|
/// hand off.
|
||||||
|
fn take_topology_restore(&mut self) -> Option<Box<dyn FnOnce() + Send>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
/// Tell the backend whether this create will be the **first** display in its group — i.e. no
|
||||||
|
/// sibling of the same backend is already live (design §6.1). A backend that *establishes* the
|
||||||
|
/// group's topology (Mutter's sole-monitor `exclusive` `ApplyMonitorsConfig`) applies it only when
|
||||||
|
/// first; a later sibling **extends** into the already-exclusive desktop instead of re-clobbering it
|
||||||
|
/// (a fresh sole-monitor config would disable the first session's virtual output). Set by the
|
||||||
|
/// registry right before [`create`](Self::create). Default no-op: KWin recognises siblings at
|
||||||
|
/// runtime by output name (first-slot-wins + a group-aware disable filter), and single-display
|
||||||
|
/// backends never have a sibling.
|
||||||
|
fn set_first_in_group(&mut self, _first: bool) {}
|
||||||
|
/// Will a [`create`](Self::create) for the CURRENT request produce a registry-poolable
|
||||||
|
/// ([`DisplayOwnership::Owned`], keep-alive-able) display? The registry consults this **before**
|
||||||
|
/// its keep-alive reuse lookup, so it never hands a kept display of one flavor to a request of
|
||||||
|
/// another — specifically a gamescope managed/attach acquire must not reuse a kept **bare-spawn**
|
||||||
|
/// (they share the backend name `"gamescope"`). Default `true`; only gamescope overrides it,
|
||||||
|
/// returning `false` when the env selects attach/managed (consistent with the `ownership` its
|
||||||
|
/// `create` will report). See `design/gamemode-and-dedicated-sessions.md` A1.
|
||||||
|
fn poolable_now(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
/// The resolved launch command carried on this backend instance (set via
|
||||||
|
/// [`set_launch_command`](Self::set_launch_command)). The registry reads it to key keep-alive reuse
|
||||||
|
/// on `(backend, mode, launch)` (`design/gamemode-and-dedicated-sessions.md` A2) — a kept display
|
||||||
|
/// running game A must never be handed to a session that asked to launch game B. Default `None`
|
||||||
|
/// (backends that never nest a command); only gamescope reports its `cmd`.
|
||||||
|
fn launch_command(&self) -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
/// Is the kept display's `node_id` still live, checked **before** the registry REUSES it on a
|
||||||
|
/// reconnect (`design/gamemode-and-dedicated-sessions.md` A2)? A `false` tells the registry to tear
|
||||||
|
/// the dead entry down and create fresh instead of handing back a corpse (which would then fail
|
||||||
|
/// capture and burn a retry). Default `true` (honest optimism — the [`mark_failed`] path is the
|
||||||
|
/// backstop for a display that dies between this check and first frame). Only gamescope overrides
|
||||||
|
/// it (its nested session dies when the game exits, independently of any compositor); KWin/Mutter
|
||||||
|
/// nodes die only with their compositor, which the session-epoch invalidation (A4) already reaps.
|
||||||
|
///
|
||||||
|
/// [`mark_failed`]: crate::vdisplay::registry::mark_failed
|
||||||
|
fn kept_display_alive(&mut self, _node_id: u32) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,14 @@ use anyhow::{anyhow, bail, Context, Result};
|
|||||||
use std::process::{Child, Command, Stdio};
|
use std::process::{Child, Command, Stdio};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
#[path = "gamescope/discovery.rs"]
|
||||||
|
mod discovery;
|
||||||
|
use discovery::{
|
||||||
|
check_gamescope_version, find_gamescope_eis_socket, find_gamescope_node,
|
||||||
|
gamescope_node_present, poll_managed_node, wait_for_node,
|
||||||
|
};
|
||||||
|
pub(crate) use discovery::{game_session_exited, is_available};
|
||||||
|
|
||||||
/// The gamescope virtual-display driver. Three modes by env, in precedence order:
|
/// The gamescope virtual-display driver. Three modes by env, in precedence order:
|
||||||
/// * `PUNKTFUNK_GAMESCOPE_SESSION=<client>` — host-MANAGE a `gamescope-session-plus` session
|
/// * `PUNKTFUNK_GAMESCOPE_SESSION=<client>` — host-MANAGE a `gamescope-session-plus` session
|
||||||
/// (full Steam-Deck-UI polish) headless at the CLIENT's mode; relaunch it when the mode changes.
|
/// (full Steam-Deck-UI polish) headless at the CLIENT's mode; relaunch it when the mode changes.
|
||||||
@@ -1436,258 +1444,6 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> R
|
|||||||
.context("spawn gamescope (is it installed? `apt install gamescope`)")
|
.context("spawn gamescope (is it installed? `apt install gamescope`)")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wait for gamescope to report its PipeWire node. Authoritative source: gamescope's own log
|
|
||||||
/// line `stream available on node ID: N` (its node carries `node.name=gamescope` on TWO objects
|
|
||||||
/// — the adapter and the inner stream — and only the advertised id is the correct capture
|
|
||||||
/// target). Falls back to `pw-dump` discovery if the log line doesn't show.
|
|
||||||
/// B2 (game-exit detection): confirm a **dedicated** gamescope session's game has exited. gamescope is
|
|
||||||
/// a single-app compositor — it exits when its nested app exits — so once capture is lost, THIS
|
|
||||||
/// session's `node_id` not reappearing within a short confirmation window means the game quit (vs. a
|
|
||||||
/// transient PipeWire hiccup). Scoped to the session's own `node_id` (via [`gamescope_node_present`]),
|
|
||||||
/// so a **coexisting** gamescope (a second dedicated session, or the box's game-mode gamescope beside a
|
|
||||||
/// non-Steam dedicated launch) doesn't mask the exit (review findings #4/#8). Returns `true` when the
|
|
||||||
/// node stays absent across the window.
|
|
||||||
pub fn game_session_exited(node_id: u32) -> bool {
|
|
||||||
let deadline = Instant::now() + Duration::from_millis(1500);
|
|
||||||
loop {
|
|
||||||
if gamescope_node_present(node_id) {
|
|
||||||
return false; // OUR node is (still) present → not an exit (transient loss)
|
|
||||||
}
|
|
||||||
if Instant::now() >= deadline {
|
|
||||||
return true; // our node stayed gone across the window → the game exited
|
|
||||||
}
|
|
||||||
std::thread::sleep(Duration::from_millis(250));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Poll [`find_gamescope_node`] (unscoped) up to `timeout` — for the managed / SteamOS session, which
|
|
||||||
/// logs to journald (no per-spawn file) and is single-session (no scoping needed).
|
|
||||||
fn poll_managed_node(timeout: Duration) -> Option<u32> {
|
|
||||||
let deadline = Instant::now() + timeout;
|
|
||||||
loop {
|
|
||||||
if let Some(id) = find_gamescope_node() {
|
|
||||||
return Some(id);
|
|
||||||
}
|
|
||||||
if Instant::now() >= deadline {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
std::thread::sleep(Duration::from_millis(300));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn wait_for_node(timeout: Duration, log: &std::path::Path, child_pid: u32) -> Option<u32> {
|
|
||||||
let deadline = Instant::now() + timeout;
|
|
||||||
loop {
|
|
||||||
if let Some(id) = node_from_log(log) {
|
|
||||||
return Some(id);
|
|
||||||
}
|
|
||||||
if Instant::now() >= deadline {
|
|
||||||
// Last-resort fallback scoped to THIS spawn's process tree (A5), so a coexisting gamescope's
|
|
||||||
// node isn't picked by mistake.
|
|
||||||
return find_gamescope_node_scoped(Some(child_pid));
|
|
||||||
}
|
|
||||||
std::thread::sleep(Duration::from_millis(300));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse `stream available on node ID: N` from a spawned gamescope's per-instance log (ANSI-colored).
|
|
||||||
fn node_from_log(log: &std::path::Path) -> Option<u32> {
|
|
||||||
let log = std::fs::read_to_string(log).ok()?;
|
|
||||||
for line in log.lines().rev() {
|
|
||||||
if let Some(pos) = line.find("stream available on node ID:") {
|
|
||||||
let tail = &line[pos + "stream available on node ID:".len()..];
|
|
||||||
let digits: String = tail.chars().filter(|c| c.is_ascii_digit()).collect();
|
|
||||||
if let Ok(id) = digits.parse() {
|
|
||||||
return Some(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Is a PipeWire node with exactly `node_id` present on the default daemon right now? Used by the
|
|
||||||
/// keep-alive reuse liveness probe ([`GamescopeDisplay::kept_display_alive`]): a kept gamescope node
|
|
||||||
/// vanishes when its nested game exits, so a missing id means "recreate, don't reuse the corpse".
|
|
||||||
fn gamescope_node_present(node_id: u32) -> bool {
|
|
||||||
let Ok(out) = Command::new("pw-dump").arg(node_id.to_string()).output() else {
|
|
||||||
// pw-dump unavailable → don't block reuse (mark_failed is the backstop on a genuinely dead node).
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
let Ok(dump) = serde_json::from_slice::<serde_json::Value>(&out.stdout) else {
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
dump.as_array()
|
|
||||||
.map(|objs| {
|
|
||||||
objs.iter().any(|o| {
|
|
||||||
o.get("id").and_then(|i| i.as_u64()) == Some(node_id as u64)
|
|
||||||
&& o.get("type").and_then(|t| t.as_str()) == Some("PipeWire:Interface:Node")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.unwrap_or(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find the `gamescope` `Video/Source` node id in a `pw-dump` snapshot of the default daemon.
|
|
||||||
///
|
|
||||||
/// `node.name=gamescope` appears on TWO objects (the adapter *and* the inner stream node); only
|
|
||||||
/// the one whose `media.class` is `Video/Source` is a valid capture target — connecting to the
|
|
||||||
/// other wedges the link. So we require `Video/Source` first and fall back to a bare name match
|
|
||||||
/// only if no class-tagged node is present (older gamescope that doesn't set media.class).
|
|
||||||
fn find_gamescope_node() -> Option<u32> {
|
|
||||||
find_gamescope_node_scoped(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Like [`find_gamescope_node`], but when `scope` is `Some(pid)` only a node whose owning process
|
|
||||||
/// (`application.process.id`) is `pid` or a descendant of it qualifies (A5 — a spawn's node must
|
|
||||||
/// belong to OUR gamescope's process tree, so a coexisting foreign / other-session gamescope node is
|
|
||||||
/// never mistaken for ours). `None` = any gamescope node (the managed/attach paths, single-session).
|
|
||||||
fn find_gamescope_node_scoped(scope: Option<u32>) -> Option<u32> {
|
|
||||||
let out = Command::new("pw-dump").output().ok()?;
|
|
||||||
let dump: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
|
|
||||||
let nodes = dump.as_array()?;
|
|
||||||
let node_props = |obj: &serde_json::Value| -> Option<(u32, String, String, Option<u32>)> {
|
|
||||||
if obj.get("type").and_then(|t| t.as_str()) != Some("PipeWire:Interface:Node") {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let id = obj.get("id").and_then(|i| i.as_u64())? as u32;
|
|
||||||
let props = obj.get("info").and_then(|i| i.get("props"));
|
|
||||||
let name = props
|
|
||||||
.and_then(|p| p.get("node.name"))
|
|
||||||
.and_then(|n| n.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
let class = props
|
|
||||||
.and_then(|p| p.get("media.class"))
|
|
||||||
.and_then(|n| n.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
// PipeWire records the owning process id as a string or an int depending on version.
|
|
||||||
let pid = props
|
|
||||||
.and_then(|p| p.get("application.process.id"))
|
|
||||||
.and_then(|v| {
|
|
||||||
v.as_u64()
|
|
||||||
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
|
|
||||||
.map(|n| n as u32)
|
|
||||||
});
|
|
||||||
Some((id, name, class, pid))
|
|
||||||
};
|
|
||||||
// A node is in-scope when no scope is asked, or its owning pid descends from the scope pid. When
|
|
||||||
// the pid prop is absent (older gamescope / PipeWire) we DON'T exclude it — falling back to the
|
|
||||||
// per-instance log is the primary addressing (design §7 risk note).
|
|
||||||
let in_scope = |pid: Option<u32>| -> bool {
|
|
||||||
match scope {
|
|
||||||
None => true,
|
|
||||||
Some(root) => pid.map(|p| descends_from(p, root)).unwrap_or(true),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// Preferred: a Video/Source node named (or containing) "gamescope", in scope.
|
|
||||||
for obj in nodes {
|
|
||||||
if let Some((id, name, class, pid)) = node_props(obj) {
|
|
||||||
if class == "Video/Source"
|
|
||||||
&& (name == "gamescope" || name.contains("gamescope"))
|
|
||||||
&& in_scope(pid)
|
|
||||||
{
|
|
||||||
return Some(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fallback: a node literally named "gamescope" with no usable class tag, in scope.
|
|
||||||
for obj in nodes {
|
|
||||||
if let Some((id, name, _, pid)) = node_props(obj) {
|
|
||||||
if name == "gamescope" && in_scope(pid) {
|
|
||||||
tracing::warn!(
|
|
||||||
node_id = id,
|
|
||||||
"gamescope node has no media.class=Video/Source tag — capturing it anyway"
|
|
||||||
);
|
|
||||||
return Some(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find the live gamescope EIS (libei) socket to inject into when ATTACHING to an existing
|
|
||||||
/// session (the spawn path instead relays the nested gamescope's `LIBEI_SOCKET` through a file).
|
|
||||||
///
|
|
||||||
/// gamescope names its EIS socket `gamescope-<display>-ei` in `XDG_RUNTIME_DIR` (alongside the
|
|
||||||
/// `gamescope-<display>` wayland socket). Stale sockets from dead sessions linger, so we don't
|
|
||||||
/// trust the name — we `connect()` each candidate and keep the connectable ones, returning the
|
|
||||||
/// most recently created (the live session). Returns the bare socket *name* (the injector
|
|
||||||
/// resolves it against `XDG_RUNTIME_DIR`, matching libei's own `LIBEI_SOCKET` semantics).
|
|
||||||
fn find_gamescope_eis_socket() -> Option<String> {
|
|
||||||
let runtime = std::env::var("XDG_RUNTIME_DIR").ok()?;
|
|
||||||
let mut live: Vec<(std::time::SystemTime, String)> = Vec::new();
|
|
||||||
for entry in std::fs::read_dir(&runtime).ok()?.flatten() {
|
|
||||||
let name = entry.file_name().to_string_lossy().into_owned();
|
|
||||||
// The EIS socket itself, not its `.lock` sidecar or the bare wayland socket.
|
|
||||||
if !(name.starts_with("gamescope-") && name.ends_with("-ei")) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Connectable == a live listener is behind it (a dead session's socket refuses).
|
|
||||||
if std::os::unix::net::UnixStream::connect(entry.path()).is_err() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let mtime = entry
|
|
||||||
.metadata()
|
|
||||||
.and_then(|m| m.modified())
|
|
||||||
.unwrap_or(std::time::UNIX_EPOCH);
|
|
||||||
live.push((mtime, name));
|
|
||||||
}
|
|
||||||
live.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime)); // newest first
|
|
||||||
live.into_iter().next().map(|(_, n)| n)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// gamescope is usable wherever its binary runs — it spawns its own nested session, so it does
|
|
||||||
/// not require any particular desktop to be running. Quiet (no version warning — that's for the
|
|
||||||
/// create path); just checks the binary executes.
|
|
||||||
pub fn is_available() -> bool {
|
|
||||||
std::process::Command::new("gamescope")
|
|
||||||
.arg("--version")
|
|
||||||
.output()
|
|
||||||
.map(|o| o.status.success())
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Minimum gamescope that captures reliably: below 3.16.22, headless PipeWire capture deadlocks
|
|
||||||
/// against PipeWire ≥ 1.6 (a loop-lock bug) and a stuck link head-blocks the whole daemon.
|
|
||||||
const MIN_GAMESCOPE: (u32, u32, u32) = (3, 16, 22);
|
|
||||||
|
|
||||||
/// Best-effort: warn loudly if the installed gamescope is older than [`MIN_GAMESCOPE`]. Parsing
|
|
||||||
/// failures are silent (don't block a possibly-fine custom build) — this is a diagnostic, not a
|
|
||||||
/// gate. Returns the parsed version when it could read one.
|
|
||||||
fn check_gamescope_version() -> Option<(u32, u32, u32)> {
|
|
||||||
let out = Command::new("gamescope").arg("--version").output().ok()?;
|
|
||||||
// gamescope prints the version banner to stderr on some builds, stdout on others.
|
|
||||||
let text = format!(
|
|
||||||
"{}{}",
|
|
||||||
String::from_utf8_lossy(&out.stdout),
|
|
||||||
String::from_utf8_lossy(&out.stderr)
|
|
||||||
);
|
|
||||||
let ver = parse_version(&text)?;
|
|
||||||
if ver < MIN_GAMESCOPE {
|
|
||||||
tracing::warn!(
|
|
||||||
found = %format!("{}.{}.{}", ver.0, ver.1, ver.2),
|
|
||||||
min = %format!("{}.{}.{}", MIN_GAMESCOPE.0, MIN_GAMESCOPE.1, MIN_GAMESCOPE.2),
|
|
||||||
"gamescope is older than the minimum for reliable headless capture — expect a \
|
|
||||||
capture deadlock against PipeWire ≥ 1.6 (a wedged link head-blocks the daemon); \
|
|
||||||
upgrade gamescope or use PUNKTFUNK_COMPOSITOR=kwin|mutter"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some(ver)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract the first `X.Y.Z` version triple from arbitrary text (e.g. `gamescope version 3.16.22`).
|
|
||||||
fn parse_version(text: &str) -> Option<(u32, u32, u32)> {
|
|
||||||
for token in text.split(|c: char| !(c.is_ascii_digit() || c == '.')) {
|
|
||||||
let mut parts = token.split('.');
|
|
||||||
let (a, b, c) = (parts.next()?, parts.next(), parts.next());
|
|
||||||
let (Some(b), Some(c)) = (b, c) else { continue };
|
|
||||||
if let (Ok(a), Ok(b), Ok(c)) = (a.parse(), b.parse(), c.parse()) {
|
|
||||||
return Some((a, b, c));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Owns the spawned gamescope process (and its per-instance log, A5); killing it tears the virtual
|
/// Owns the spawned gamescope process (and its per-instance log, A5); killing it tears the virtual
|
||||||
/// output down.
|
/// output down.
|
||||||
struct GamescopeProc {
|
struct GamescopeProc {
|
||||||
@@ -1709,10 +1465,7 @@ impl Drop for GamescopeProc {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{cgroup_is_punktfunk_owned, is_steam_launch, shape_dedicated_command};
|
||||||
cgroup_is_punktfunk_owned, is_steam_launch, parse_version, shape_dedicated_command,
|
|
||||||
MIN_GAMESCOPE,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn steam_launch_detection() {
|
fn steam_launch_detection() {
|
||||||
@@ -1768,27 +1521,4 @@ mod tests {
|
|||||||
));
|
));
|
||||||
assert!(!cgroup_is_punktfunk_owned(""));
|
assert!(!cgroup_is_punktfunk_owned(""));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_version_banner() {
|
|
||||||
assert_eq!(
|
|
||||||
parse_version("gamescope version 3.16.22"),
|
|
||||||
Some((3, 16, 22))
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
parse_version("gamescope: version v3.15.9 (no PipeWire)"),
|
|
||||||
Some((3, 15, 9))
|
|
||||||
);
|
|
||||||
assert_eq!(parse_version("3.16.20-1.fc41"), Some((3, 16, 20)));
|
|
||||||
assert_eq!(parse_version("no version here"), None);
|
|
||||||
assert_eq!(parse_version("only 3.16 here"), None); // needs a full triple
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn flags_known_bad_versions() {
|
|
||||||
// The 26.04-shipped 3.16.20 is below the minimum (PipeWire 1.6 deadlock).
|
|
||||||
assert!(parse_version("gamescope version 3.16.20").unwrap() < MIN_GAMESCOPE);
|
|
||||||
assert!(parse_version("gamescope version 3.16.22").unwrap() >= MIN_GAMESCOPE);
|
|
||||||
assert!(parse_version("gamescope version 3.17.0").unwrap() >= MIN_GAMESCOPE);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
//! gamescope **discovery + probes** (plan §W3, carved out of the backend): finding the compositor's
|
||||||
|
//! PipeWire node (log line first, then a scoped `pw-dump` fallback), locating its live EIS/libei
|
||||||
|
//! socket, the version gate, and the dedicated-session game-exit check. Pure read-side plumbing — it
|
||||||
|
//! observes gamescope, never spawns or tears it down (that stays in [`super`]).
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Wait for gamescope to report its PipeWire node. Authoritative source: gamescope's own log
|
||||||
|
/// line `stream available on node ID: N` (its node carries `node.name=gamescope` on TWO objects
|
||||||
|
/// — the adapter and the inner stream — and only the advertised id is the correct capture
|
||||||
|
/// target). Falls back to `pw-dump` discovery if the log line doesn't show.
|
||||||
|
/// B2 (game-exit detection): confirm a **dedicated** gamescope session's game has exited. gamescope is
|
||||||
|
/// a single-app compositor — it exits when its nested app exits — so once capture is lost, THIS
|
||||||
|
/// session's `node_id` not reappearing within a short confirmation window means the game quit (vs. a
|
||||||
|
/// transient PipeWire hiccup). Scoped to the session's own `node_id` (via [`gamescope_node_present`]),
|
||||||
|
/// so a **coexisting** gamescope (a second dedicated session, or the box's game-mode gamescope beside a
|
||||||
|
/// non-Steam dedicated launch) doesn't mask the exit (review findings #4/#8). Returns `true` when the
|
||||||
|
/// node stays absent across the window.
|
||||||
|
pub(crate) fn game_session_exited(node_id: u32) -> bool {
|
||||||
|
let deadline = Instant::now() + Duration::from_millis(1500);
|
||||||
|
loop {
|
||||||
|
if gamescope_node_present(node_id) {
|
||||||
|
return false; // OUR node is (still) present → not an exit (transient loss)
|
||||||
|
}
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
return true; // our node stayed gone across the window → the game exited
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(250));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll [`find_gamescope_node`] (unscoped) up to `timeout` — for the managed / SteamOS session, which
|
||||||
|
/// logs to journald (no per-spawn file) and is single-session (no scoping needed).
|
||||||
|
pub(super) fn poll_managed_node(timeout: Duration) -> Option<u32> {
|
||||||
|
let deadline = Instant::now() + timeout;
|
||||||
|
loop {
|
||||||
|
if let Some(id) = find_gamescope_node() {
|
||||||
|
return Some(id);
|
||||||
|
}
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(300));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn wait_for_node(
|
||||||
|
timeout: Duration,
|
||||||
|
log: &std::path::Path,
|
||||||
|
child_pid: u32,
|
||||||
|
) -> Option<u32> {
|
||||||
|
let deadline = Instant::now() + timeout;
|
||||||
|
loop {
|
||||||
|
if let Some(id) = node_from_log(log) {
|
||||||
|
return Some(id);
|
||||||
|
}
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
// Last-resort fallback scoped to THIS spawn's process tree (A5), so a coexisting gamescope's
|
||||||
|
// node isn't picked by mistake.
|
||||||
|
return find_gamescope_node_scoped(Some(child_pid));
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(300));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `stream available on node ID: N` from a spawned gamescope's per-instance log (ANSI-colored).
|
||||||
|
fn node_from_log(log: &std::path::Path) -> Option<u32> {
|
||||||
|
let log = std::fs::read_to_string(log).ok()?;
|
||||||
|
for line in log.lines().rev() {
|
||||||
|
if let Some(pos) = line.find("stream available on node ID:") {
|
||||||
|
let tail = &line[pos + "stream available on node ID:".len()..];
|
||||||
|
let digits: String = tail.chars().filter(|c| c.is_ascii_digit()).collect();
|
||||||
|
if let Ok(id) = digits.parse() {
|
||||||
|
return Some(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is a PipeWire node with exactly `node_id` present on the default daemon right now? Used by the
|
||||||
|
/// keep-alive reuse liveness probe ([`GamescopeDisplay::kept_display_alive`]): a kept gamescope node
|
||||||
|
/// vanishes when its nested game exits, so a missing id means "recreate, don't reuse the corpse".
|
||||||
|
pub(super) fn gamescope_node_present(node_id: u32) -> bool {
|
||||||
|
let Ok(out) = Command::new("pw-dump").arg(node_id.to_string()).output() else {
|
||||||
|
// pw-dump unavailable → don't block reuse (mark_failed is the backstop on a genuinely dead node).
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
let Ok(dump) = serde_json::from_slice::<serde_json::Value>(&out.stdout) else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
dump.as_array()
|
||||||
|
.map(|objs| {
|
||||||
|
objs.iter().any(|o| {
|
||||||
|
o.get("id").and_then(|i| i.as_u64()) == Some(node_id as u64)
|
||||||
|
&& o.get("type").and_then(|t| t.as_str()) == Some("PipeWire:Interface:Node")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the `gamescope` `Video/Source` node id in a `pw-dump` snapshot of the default daemon.
|
||||||
|
///
|
||||||
|
/// `node.name=gamescope` appears on TWO objects (the adapter *and* the inner stream node); only
|
||||||
|
/// the one whose `media.class` is `Video/Source` is a valid capture target — connecting to the
|
||||||
|
/// other wedges the link. So we require `Video/Source` first and fall back to a bare name match
|
||||||
|
/// only if no class-tagged node is present (older gamescope that doesn't set media.class).
|
||||||
|
pub(super) fn find_gamescope_node() -> Option<u32> {
|
||||||
|
find_gamescope_node_scoped(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`find_gamescope_node`], but when `scope` is `Some(pid)` only a node whose owning process
|
||||||
|
/// (`application.process.id`) is `pid` or a descendant of it qualifies (A5 — a spawn's node must
|
||||||
|
/// belong to OUR gamescope's process tree, so a coexisting foreign / other-session gamescope node is
|
||||||
|
/// never mistaken for ours). `None` = any gamescope node (the managed/attach paths, single-session).
|
||||||
|
fn find_gamescope_node_scoped(scope: Option<u32>) -> Option<u32> {
|
||||||
|
let out = Command::new("pw-dump").output().ok()?;
|
||||||
|
let dump: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
|
||||||
|
let nodes = dump.as_array()?;
|
||||||
|
let node_props = |obj: &serde_json::Value| -> Option<(u32, String, String, Option<u32>)> {
|
||||||
|
if obj.get("type").and_then(|t| t.as_str()) != Some("PipeWire:Interface:Node") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let id = obj.get("id").and_then(|i| i.as_u64())? as u32;
|
||||||
|
let props = obj.get("info").and_then(|i| i.get("props"));
|
||||||
|
let name = props
|
||||||
|
.and_then(|p| p.get("node.name"))
|
||||||
|
.and_then(|n| n.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let class = props
|
||||||
|
.and_then(|p| p.get("media.class"))
|
||||||
|
.and_then(|n| n.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
// PipeWire records the owning process id as a string or an int depending on version.
|
||||||
|
let pid = props
|
||||||
|
.and_then(|p| p.get("application.process.id"))
|
||||||
|
.and_then(|v| {
|
||||||
|
v.as_u64()
|
||||||
|
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
|
||||||
|
.map(|n| n as u32)
|
||||||
|
});
|
||||||
|
Some((id, name, class, pid))
|
||||||
|
};
|
||||||
|
// A node is in-scope when no scope is asked, or its owning pid descends from the scope pid. When
|
||||||
|
// the pid prop is absent (older gamescope / PipeWire) we DON'T exclude it — falling back to the
|
||||||
|
// per-instance log is the primary addressing (design §7 risk note).
|
||||||
|
let in_scope = |pid: Option<u32>| -> bool {
|
||||||
|
match scope {
|
||||||
|
None => true,
|
||||||
|
Some(root) => pid.map(|p| descends_from(p, root)).unwrap_or(true),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Preferred: a Video/Source node named (or containing) "gamescope", in scope.
|
||||||
|
for obj in nodes {
|
||||||
|
if let Some((id, name, class, pid)) = node_props(obj) {
|
||||||
|
if class == "Video/Source"
|
||||||
|
&& (name == "gamescope" || name.contains("gamescope"))
|
||||||
|
&& in_scope(pid)
|
||||||
|
{
|
||||||
|
return Some(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: a node literally named "gamescope" with no usable class tag, in scope.
|
||||||
|
for obj in nodes {
|
||||||
|
if let Some((id, name, _, pid)) = node_props(obj) {
|
||||||
|
if name == "gamescope" && in_scope(pid) {
|
||||||
|
tracing::warn!(
|
||||||
|
node_id = id,
|
||||||
|
"gamescope node has no media.class=Video/Source tag — capturing it anyway"
|
||||||
|
);
|
||||||
|
return Some(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the live gamescope EIS (libei) socket to inject into when ATTACHING to an existing
|
||||||
|
/// session (the spawn path instead relays the nested gamescope's `LIBEI_SOCKET` through a file).
|
||||||
|
///
|
||||||
|
/// gamescope names its EIS socket `gamescope-<display>-ei` in `XDG_RUNTIME_DIR` (alongside the
|
||||||
|
/// `gamescope-<display>` wayland socket). Stale sockets from dead sessions linger, so we don't
|
||||||
|
/// trust the name — we `connect()` each candidate and keep the connectable ones, returning the
|
||||||
|
/// most recently created (the live session). Returns the bare socket *name* (the injector
|
||||||
|
/// resolves it against `XDG_RUNTIME_DIR`, matching libei's own `LIBEI_SOCKET` semantics).
|
||||||
|
pub(super) fn find_gamescope_eis_socket() -> Option<String> {
|
||||||
|
let runtime = std::env::var("XDG_RUNTIME_DIR").ok()?;
|
||||||
|
let mut live: Vec<(std::time::SystemTime, String)> = Vec::new();
|
||||||
|
for entry in std::fs::read_dir(&runtime).ok()?.flatten() {
|
||||||
|
let name = entry.file_name().to_string_lossy().into_owned();
|
||||||
|
// The EIS socket itself, not its `.lock` sidecar or the bare wayland socket.
|
||||||
|
if !(name.starts_with("gamescope-") && name.ends_with("-ei")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Connectable == a live listener is behind it (a dead session's socket refuses).
|
||||||
|
if std::os::unix::net::UnixStream::connect(entry.path()).is_err() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mtime = entry
|
||||||
|
.metadata()
|
||||||
|
.and_then(|m| m.modified())
|
||||||
|
.unwrap_or(std::time::UNIX_EPOCH);
|
||||||
|
live.push((mtime, name));
|
||||||
|
}
|
||||||
|
live.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime)); // newest first
|
||||||
|
live.into_iter().next().map(|(_, n)| n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// gamescope is usable wherever its binary runs — it spawns its own nested session, so it does
|
||||||
|
/// not require any particular desktop to be running. Quiet (no version warning — that's for the
|
||||||
|
/// create path); just checks the binary executes.
|
||||||
|
pub(crate) fn is_available() -> bool {
|
||||||
|
std::process::Command::new("gamescope")
|
||||||
|
.arg("--version")
|
||||||
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimum gamescope that captures reliably: below 3.16.22, headless PipeWire capture deadlocks
|
||||||
|
/// against PipeWire ≥ 1.6 (a loop-lock bug) and a stuck link head-blocks the whole daemon.
|
||||||
|
const MIN_GAMESCOPE: (u32, u32, u32) = (3, 16, 22);
|
||||||
|
|
||||||
|
/// Best-effort: warn loudly if the installed gamescope is older than [`MIN_GAMESCOPE`]. Parsing
|
||||||
|
/// failures are silent (don't block a possibly-fine custom build) — this is a diagnostic, not a
|
||||||
|
/// gate. Returns the parsed version when it could read one.
|
||||||
|
pub(super) fn check_gamescope_version() -> Option<(u32, u32, u32)> {
|
||||||
|
let out = Command::new("gamescope").arg("--version").output().ok()?;
|
||||||
|
// gamescope prints the version banner to stderr on some builds, stdout on others.
|
||||||
|
let text = format!(
|
||||||
|
"{}{}",
|
||||||
|
String::from_utf8_lossy(&out.stdout),
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
let ver = parse_version(&text)?;
|
||||||
|
if ver < MIN_GAMESCOPE {
|
||||||
|
tracing::warn!(
|
||||||
|
found = %format!("{}.{}.{}", ver.0, ver.1, ver.2),
|
||||||
|
min = %format!("{}.{}.{}", MIN_GAMESCOPE.0, MIN_GAMESCOPE.1, MIN_GAMESCOPE.2),
|
||||||
|
"gamescope is older than the minimum for reliable headless capture — expect a \
|
||||||
|
capture deadlock against PipeWire ≥ 1.6 (a wedged link head-blocks the daemon); \
|
||||||
|
upgrade gamescope or use PUNKTFUNK_COMPOSITOR=kwin|mutter"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Some(ver)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the first `X.Y.Z` version triple from arbitrary text (e.g. `gamescope version 3.16.22`).
|
||||||
|
fn parse_version(text: &str) -> Option<(u32, u32, u32)> {
|
||||||
|
for token in text.split(|c: char| !(c.is_ascii_digit() || c == '.')) {
|
||||||
|
let mut parts = token.split('.');
|
||||||
|
let (a, b, c) = (parts.next()?, parts.next(), parts.next());
|
||||||
|
let (Some(b), Some(c)) = (b, c) else { continue };
|
||||||
|
if let (Ok(a), Ok(b), Ok(c)) = (a.parse(), b.parse(), c.parse()) {
|
||||||
|
return Some((a, b, c));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{parse_version, MIN_GAMESCOPE};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_version_banner() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_version("gamescope version 3.16.22"),
|
||||||
|
Some((3, 16, 22))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_version("gamescope: version v3.15.9 (no PipeWire)"),
|
||||||
|
Some((3, 15, 9))
|
||||||
|
);
|
||||||
|
assert_eq!(parse_version("3.16.20-1.fc41"), Some((3, 16, 20)));
|
||||||
|
assert_eq!(parse_version("no version here"), None);
|
||||||
|
assert_eq!(parse_version("only 3.16 here"), None); // needs a full triple
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flags_known_bad_versions() {
|
||||||
|
// The 26.04-shipped 3.16.20 is below the minimum (PipeWire 1.6 deadlock).
|
||||||
|
assert!(parse_version("gamescope version 3.16.20").unwrap() < MIN_GAMESCOPE);
|
||||||
|
assert!(parse_version("gamescope version 3.16.22").unwrap() >= MIN_GAMESCOPE);
|
||||||
|
assert!(parse_version("gamescope version 3.17.0").unwrap() >= MIN_GAMESCOPE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
//! Gamescope-session routing (plan §W3 — carved out of [`super`]): mode selection
|
||||||
|
//! ([`pick_gamescope_mode`]), input-env routing ([`apply_input_env`]), dedicated-game-session
|
||||||
|
//! decisions/launch ([`wants_dedicated_game_session`], [`launch_into_gamescope_session`]), and the
|
||||||
|
//! managed-session restore workers.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// How a gamescope-backed session is realized. Chosen per connect by [`pick_gamescope_mode`],
|
||||||
|
/// written into the env knobs `GamescopeDisplay::create` dispatches on.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum GamescopeMode {
|
||||||
|
/// Host-managed `gamescope-session-plus` / SteamOS session at the client's mode.
|
||||||
|
Managed,
|
||||||
|
/// Attach to an already-running gamescope (capture + inject, no lifecycle ownership).
|
||||||
|
Attach,
|
||||||
|
/// Bare-spawn a headless gamescope per session, nesting the session's launch command.
|
||||||
|
Spawn,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure sub-mode ladder for gamescope (unit-testable — the env/probe inputs are parameters):
|
||||||
|
/// explicit `PUNKTFUNK_GAMESCOPE_MANAGED` forces managed; explicit ATTACH/NODE forces attach; an
|
||||||
|
/// operator-set `PUNKTFUNK_GAMESCOPE_SESSION` keeps managed; otherwise managed only **when the box
|
||||||
|
/// actually has the session infrastructure** (gamescope-session-plus / SteamOS — the old code
|
||||||
|
/// defaulted to managed unconditionally and then bailed on a plain distro, killing the session);
|
||||||
|
/// a foreign (not host-spawned) gamescope on an infra-less box is attached to; and the final
|
||||||
|
/// default is a per-session bare spawn — the path that nests the client's launch command.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn pick_gamescope_mode(
|
||||||
|
dedicated_launch: bool,
|
||||||
|
force_managed: bool,
|
||||||
|
attach_env: bool,
|
||||||
|
node_env: bool,
|
||||||
|
session_env: bool,
|
||||||
|
managed_infra: bool,
|
||||||
|
foreign_gamescope: bool,
|
||||||
|
) -> GamescopeMode {
|
||||||
|
if force_managed {
|
||||||
|
GamescopeMode::Managed
|
||||||
|
} else if attach_env || node_env {
|
||||||
|
GamescopeMode::Attach
|
||||||
|
} else if dedicated_launch {
|
||||||
|
// A dedicated game session always spawns its own headless gamescope at the client's mode,
|
||||||
|
// nesting just the game — outranking managed-infra / foreign-attach, but not the explicit
|
||||||
|
// operator MANAGED/ATTACH/NODE overrides above (debug/CI). (design/gamemode-and-dedicated-sessions.md §5.3)
|
||||||
|
GamescopeMode::Spawn
|
||||||
|
} else if session_env || managed_infra {
|
||||||
|
GamescopeMode::Managed
|
||||||
|
} else if foreign_gamescope {
|
||||||
|
GamescopeMode::Attach
|
||||||
|
} else {
|
||||||
|
GamescopeMode::Spawn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Route input to match the chosen video backend (they must not diverge), via the highest-priority
|
||||||
|
/// `PUNKTFUNK_INPUT_BACKEND` knob the injector honors. For gamescope the sub-mode ladder
|
||||||
|
/// ([`pick_gamescope_mode`]) selects **managed** (a host-managed session at the client's mode —
|
||||||
|
/// tears the TV's autologin down on connect, restored on a debounced idle; only where
|
||||||
|
/// session-plus/SteamOS actually exists), **attach** (mirror a running gamescope at its own mode;
|
||||||
|
/// explicit via `PUNKTFUNK_GAMESCOPE_ATTACH`/`PUNKTFUNK_GAMESCOPE_NODE`, or the fallback for a
|
||||||
|
/// foreign gamescope on an infra-less box), or **bare spawn** (a per-session headless gamescope
|
||||||
|
/// nesting the session's launch command — the plain-distro default). `PUNKTFUNK_GAMESCOPE_MANAGED`
|
||||||
|
/// forces managed over all of it.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn apply_input_env(chosen: Compositor, dedicated_launch: bool) {
|
||||||
|
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let backend = match chosen {
|
||||||
|
Compositor::Gamescope => "gamescope",
|
||||||
|
// KWin: org_kde_kwin_fake_input — direct injection, no RemoteDesktop portal / approval
|
||||||
|
// dialog (headless, the krdpserver path), authorized by the host's shipped .desktop.
|
||||||
|
Compositor::Kwin => "kwin",
|
||||||
|
// GNOME has neither fake_input nor the wlr protocols → RemoteDesktop portal via libei.
|
||||||
|
Compositor::Mutter => "libei",
|
||||||
|
// Hyprland kept `zwlr_virtual_pointer_v1` + `zwp_virtual_keyboard_v1` (D4) — same wlr
|
||||||
|
// injector as sway/river, no code change.
|
||||||
|
Compositor::Wlroots | Compositor::Hyprland => "wlr",
|
||||||
|
};
|
||||||
|
std::env::set_var("PUNKTFUNK_INPUT_BACKEND", backend);
|
||||||
|
if chosen == Compositor::Gamescope {
|
||||||
|
let mode = pick_gamescope_mode(
|
||||||
|
dedicated_launch,
|
||||||
|
std::env::var_os("PUNKTFUNK_GAMESCOPE_MANAGED").is_some(),
|
||||||
|
std::env::var_os("PUNKTFUNK_GAMESCOPE_ATTACH").is_some(),
|
||||||
|
std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_some(),
|
||||||
|
std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_some(),
|
||||||
|
gamescope::managed_session_available(),
|
||||||
|
gamescope::foreign_gamescope_running(),
|
||||||
|
);
|
||||||
|
tracing::info!(?mode, "gamescope sub-mode");
|
||||||
|
match mode {
|
||||||
|
GamescopeMode::Attach => {
|
||||||
|
std::env::remove_var("PUNKTFUNK_GAMESCOPE_SESSION");
|
||||||
|
if std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_none() {
|
||||||
|
std::env::set_var("PUNKTFUNK_GAMESCOPE_NODE", "auto");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GamescopeMode::Managed => {
|
||||||
|
if std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_none() {
|
||||||
|
std::env::set_var("PUNKTFUNK_GAMESCOPE_SESSION", "steam");
|
||||||
|
}
|
||||||
|
std::env::remove_var("PUNKTFUNK_GAMESCOPE_NODE");
|
||||||
|
}
|
||||||
|
GamescopeMode::Spawn => {
|
||||||
|
// Bare spawn: `create` must fall through to the spawn path, so neither knob may
|
||||||
|
// linger from an earlier connect's managed/attach selection.
|
||||||
|
std::env::remove_var("PUNKTFUNK_GAMESCOPE_SESSION");
|
||||||
|
std::env::remove_var("PUNKTFUNK_GAMESCOPE_NODE");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
pub fn apply_input_env(_chosen: Compositor, _dedicated_launch: bool) {}
|
||||||
|
|
||||||
|
/// Should a game-launching session get a **dedicated** headless gamescope (`game_session=dedicated`
|
||||||
|
/// policy, `design/gamemode-and-dedicated-sessions.md` B0)? True only when the session carries a
|
||||||
|
/// launch, the policy selects `dedicated`, AND gamescope is actually available (else it degrades to
|
||||||
|
/// `auto` honestly). Computed at the handshake and threaded into [`apply_input_env`] /
|
||||||
|
/// [`resolve_compositor`] as a value (no new env knob — the `ENV_LOCK` discipline).
|
||||||
|
pub fn wants_dedicated_game_session(has_launch: bool) -> bool {
|
||||||
|
use policy::GameSession;
|
||||||
|
if !has_launch || policy::prefs().game_session() != GameSession::Dedicated {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
if gamescope::is_available() {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
"game_session=dedicated but gamescope is unavailable — falling back to auto routing"
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
{
|
||||||
|
false // Windows: a launching session opens into the one desktop (no gamescope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Will `vd.create` on this backend NEST the session's launch command itself (gamescope's bare
|
||||||
|
/// spawn runs it inside the new gamescope)? When true the session must NOT also spawn the command
|
||||||
|
/// into the session — it would start twice. Read AFTER [`apply_input_env`] resolved the gamescope
|
||||||
|
/// sub-mode (the env knobs are that resolution's output).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn launch_is_nested(compositor: Compositor) -> bool {
|
||||||
|
compositor == Compositor::Gamescope
|
||||||
|
&& with_env_lock(|| {
|
||||||
|
std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_none()
|
||||||
|
&& std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_none()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Launch `cmd` into the live gamescope session (managed/attach — see
|
||||||
|
/// [`gamescope::launch_into_session`]). Split out so `library.rs` doesn't reach into the private
|
||||||
|
/// backend module.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn launch_into_gamescope_session(cmd: &str) -> Result<std::process::Child> {
|
||||||
|
gamescope::launch_into_session(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// B2: has a **dedicated** gamescope game session's game exited (its `node_id` doesn't reappear within a
|
||||||
|
/// short window after capture loss)? The dedicated-spawn session ends cleanly on `true` instead of the
|
||||||
|
/// capture-loss rebuild. Scoped to the session's OWN node so a coexisting gamescope doesn't mask the
|
||||||
|
/// exit (review #4/#8). Always `false` off Linux.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn dedicated_game_exited(node_id: u32) -> bool {
|
||||||
|
gamescope::game_session_exited(node_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
pub fn dedicated_game_exited(_node_id: u32) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel any pending TV-session restore because a client (re)connected (review #3). No-op off Linux.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn cancel_pending_tv_restore() {
|
||||||
|
gamescope::cancel_pending_restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
pub fn cancel_pending_tv_restore() {}
|
||||||
|
|
||||||
|
/// Path of the file where the gamescope backend relays the nested session's `LIBEI_SOCKET`
|
||||||
|
/// (gamescope's EIS server) for the input injector. Under `$XDG_RUNTIME_DIR` (per-user 0700).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn gamescope_ei_socket_file() -> std::path::PathBuf {
|
||||||
|
gamescope::ei_socket_file()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call when a client session ends: if the host-managed gamescope path took over a box's autologin
|
||||||
|
/// gaming session (stopped its single-instance Steam to stream at the client's mode), **schedule** a
|
||||||
|
/// debounced restore so the TV returns to gaming mode — unless a client reconnects within the window
|
||||||
|
/// (which reuses the warm session, avoiding the per-connect gamescope stop/relaunch that leaked GPU
|
||||||
|
/// context on F44). No-op on other compositors / when nothing was taken. Needs [`start_restore_worker`]
|
||||||
|
/// running to actually fire.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn restore_managed_session() {
|
||||||
|
gamescope::schedule_restore_tv_session();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
pub fn restore_managed_session() {}
|
||||||
|
|
||||||
|
/// Start the host-lifetime worker that fires debounced [`restore_managed_session`] restores once a
|
||||||
|
/// client has been gone long enough. Hold the returned handle for the host's lifetime; dropping it
|
||||||
|
/// stops the worker. Call once from `serve()`.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn start_restore_worker() -> std::sync::Arc<()> {
|
||||||
|
gamescope::start_restore_worker()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
pub fn start_restore_worker() -> std::sync::Arc<()> {
|
||||||
|
std::sync::Arc::new(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recover a stranded TV takeover from a crashed previous host instance
|
||||||
|
/// (`design/gamemode-and-dedicated-sessions.md` A3). Call once at `serve` startup, alongside
|
||||||
|
/// [`start_restore_worker`]. No-op when no takeover was persisted (a clean start).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn restore_takeover_on_startup() {
|
||||||
|
gamescope::restore_takeover_on_startup();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
pub fn restore_takeover_on_startup() {}
|
||||||
|
|
||||||
|
#[cfg(all(test, target_os = "linux"))]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gamescope_mode_ladder() {
|
||||||
|
use GamescopeMode::*;
|
||||||
|
let pick = pick_gamescope_mode;
|
||||||
|
// (dedicated_launch, force_managed, attach_env, node_env, session_env, managed_infra, foreign_gamescope)
|
||||||
|
// Plain distro, nothing running: bare spawn — the path that nests the launch command.
|
||||||
|
assert_eq!(pick(false, false, false, false, false, false, false), Spawn);
|
||||||
|
// Bazzite/SteamOS (session infra present): managed, as validated live.
|
||||||
|
assert_eq!(
|
||||||
|
pick(false, false, false, false, false, true, false),
|
||||||
|
Managed
|
||||||
|
);
|
||||||
|
assert_eq!(pick(false, false, false, false, false, true, true), Managed);
|
||||||
|
// Foreign gamescope on an infra-less box: attach and mirror it.
|
||||||
|
assert_eq!(pick(false, false, false, false, false, false, true), Attach);
|
||||||
|
// Operator-set PUNKTFUNK_GAMESCOPE_SESSION keeps managed even without detected infra.
|
||||||
|
assert_eq!(
|
||||||
|
pick(false, false, false, false, true, false, false),
|
||||||
|
Managed
|
||||||
|
);
|
||||||
|
// Explicit attach/node wins over infra…
|
||||||
|
assert_eq!(pick(false, false, true, false, false, true, false), Attach);
|
||||||
|
assert_eq!(pick(false, false, false, true, true, true, false), Attach);
|
||||||
|
// …and force-managed wins over everything.
|
||||||
|
assert_eq!(pick(false, true, true, true, false, false, false), Managed);
|
||||||
|
// A dedicated launch forces Spawn, outranking managed-infra + foreign-attach…
|
||||||
|
assert_eq!(pick(true, false, false, false, false, true, true), Spawn);
|
||||||
|
// …but the explicit operator overrides still win over dedicated.
|
||||||
|
assert_eq!(pick(true, true, false, false, false, true, false), Managed);
|
||||||
|
assert_eq!(pick(true, false, true, false, false, false, false), Attach);
|
||||||
|
assert_eq!(pick(true, false, false, true, false, false, false), Attach);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
//! Live graphical-session detection + session-epoch + process-env retargeting (plan §W3 — the
|
||||||
|
//! self-contained subsystem carved out of [`super`]). Detects the active compositor/session
|
||||||
|
//! ([`detect_active_session`]), tracks the session epoch so pooled displays never outlive their
|
||||||
|
//! compositor instance, and retargets the process env at the live session ([`apply_session_env`],
|
||||||
|
//! [`settle_desktop_portal`]) under `super::ENV_LOCK`.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The **session epoch** — bumped whenever session detection observes a different compositor
|
||||||
|
/// *instance*: an [`ActiveKind`] change, **or** a new compositor PID for the same kind (the
|
||||||
|
/// Desktop→Game→Desktop bounce that brings up a fresh KWin/gamescope with an unrelated node-id space).
|
||||||
|
/// Pooled displays stamp the epoch at creation; the registry only reuses an entry whose epoch still
|
||||||
|
/// matches, and its linger timer reaps entries from dead epochs — so a switch can never hand back a
|
||||||
|
/// node id that now means nothing (`design/gamemode-and-dedicated-sessions.md` A4).
|
||||||
|
static SESSION_EPOCH: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||||
|
|
||||||
|
/// The current [session epoch](SESSION_EPOCH). Read by the registry at acquire (to stamp new entries
|
||||||
|
/// and gate reuse) and by its linger timer (to reap dead-epoch zombies).
|
||||||
|
pub fn session_epoch() -> u64 {
|
||||||
|
SESSION_EPOCH.load(std::sync::atomic::Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bump the [session epoch](SESSION_EPOCH) — call when session detection sees a new compositor
|
||||||
|
/// instance (kind change, or same-kind new PID). Returns the new value.
|
||||||
|
pub fn bump_session_epoch() -> u64 {
|
||||||
|
SESSION_EPOCH.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last-observed compositor instance `(kind, pid)`, so [`observe_session_instance`] can tell a
|
||||||
|
/// genuine instance change from a stable re-detect.
|
||||||
|
static LAST_INSTANCE: std::sync::Mutex<Option<(ActiveKind, Option<u32>)>> =
|
||||||
|
std::sync::Mutex::new(None);
|
||||||
|
|
||||||
|
/// Observe the freshly-[detected](detect_active_session) live session and, if the compositor
|
||||||
|
/// *instance* changed since the last observation — a different [`ActiveKind`], **or** the same kind
|
||||||
|
/// with a new PID (a compositor restart / Desktop→Game→Desktop bounce) — bump the [session
|
||||||
|
/// epoch](SESSION_EPOCH) and [invalidate](registry::invalidate_backend) the previous backend's kept
|
||||||
|
/// displays, so a reconnect can never reuse a node id from the dead instance (A4). Idempotent per
|
||||||
|
/// instance; the first observation just records the baseline. Cheap on the steady state (one mutex
|
||||||
|
/// read); the registry lock is taken only on an actual change. Call from every site that detects the
|
||||||
|
/// session (the per-connect resolve, the mid-stream watcher, the capture-loss re-detect).
|
||||||
|
pub fn observe_session_instance(active: &ActiveSession) {
|
||||||
|
let cur = (active.kind, active.compositor_pid);
|
||||||
|
let mut last = LAST_INSTANCE.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
if let Some(prev) = *last {
|
||||||
|
// Only a **desktop** compositor (KWin / Mutter / wlroots) instance change bumps the epoch +
|
||||||
|
// invalidates its kept displays — its PipeWire node dies with the compositor. A **gamescope**
|
||||||
|
// session (`ActiveKind::Gaming`) is NOT the epoch's subject: the box's game-mode / managed
|
||||||
|
// gamescope isn't pooled, and dedicated **spawns** are independent nested sessions whose nodes
|
||||||
|
// outlive any active-session change. So a game-mode gamescope restart, a Gaming↔Gaming winning-PID
|
||||||
|
// flap (e.g. B1 stopping the autologin before a dedicated spawn), or a coexisting-gamescope set
|
||||||
|
// change must NOT bump/invalidate — that would tear down a live/kept dedicated session (review
|
||||||
|
// findings #6/#7/#10). Gate the whole action on a desktop kind being involved.
|
||||||
|
if prev != cur && (is_desktop_kind(prev.0) || is_desktop_kind(cur.0)) {
|
||||||
|
// Invalidate only the OLD backend, and only if it was a desktop compositor (never gamescope).
|
||||||
|
if is_desktop_kind(prev.0) {
|
||||||
|
if let Some(old) = compositor_for_kind(prev.0) {
|
||||||
|
registry::invalidate_backend(old.id());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let epoch = bump_session_epoch();
|
||||||
|
tracing::info!(
|
||||||
|
from = ?prev.0,
|
||||||
|
to = ?cur.0,
|
||||||
|
epoch,
|
||||||
|
"desktop compositor instance changed — session epoch bumped"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*last = Some(cur);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is `kind` a **desktop** compositor (KWin / Mutter / wlroots) — one whose kept PipeWire outputs die
|
||||||
|
/// with the compositor instance, so the session epoch tracks it? `Gaming` (gamescope) and `None` are
|
||||||
|
/// not (gamescope spawns are independent nested sessions — see [`observe_session_instance`]).
|
||||||
|
fn is_desktop_kind(kind: ActiveKind) -> bool {
|
||||||
|
matches!(
|
||||||
|
kind,
|
||||||
|
ActiveKind::DesktopKde
|
||||||
|
| ActiveKind::DesktopGnome
|
||||||
|
| ActiveKind::DesktopWlroots
|
||||||
|
| ActiveKind::DesktopHyprland
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The kind of graphical session live for our uid *right now* — the basis for per-connect backend
|
||||||
|
/// selection on a box that flips between Steam Gaming Mode and a KDE/GNOME desktop (Bazzite,
|
||||||
|
/// SteamOS). Detected by probing which compositor process is actually running, not by a static
|
||||||
|
/// env var, so the host follows the box as the user switches sessions.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum ActiveKind {
|
||||||
|
/// A `gamescope` session is live (Steam Gaming Mode / `gamescope-session-plus`).
|
||||||
|
Gaming,
|
||||||
|
/// A KWin / Plasma desktop is live.
|
||||||
|
DesktopKde,
|
||||||
|
/// A GNOME / Mutter desktop is live.
|
||||||
|
DesktopGnome,
|
||||||
|
/// A wlroots-proper (Sway / River) desktop is live.
|
||||||
|
DesktopWlroots,
|
||||||
|
/// A Hyprland desktop is live (distinct from [`DesktopWlroots`](ActiveKind::DesktopWlroots):
|
||||||
|
/// its own `hyprctl` IPC + xdph portal, though it shares the wlr virtual-input path).
|
||||||
|
DesktopHyprland,
|
||||||
|
/// No recognized graphical session is running for our uid.
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The session environment that points a backend at the [detected](detect_active_session) active
|
||||||
|
/// session: the Wayland socket (for the Wayland-protocol backends), the runtime dir + session bus
|
||||||
|
/// (for PipeWire capture + D-Bus / portal input), and the desktop name (for portal routing). The
|
||||||
|
/// host serves one session at a time, so [`apply_session_env`] writes these into the process env
|
||||||
|
/// per connect and every backend that reads them then opens against the live session.
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct SessionEnv {
|
||||||
|
/// `WAYLAND_DISPLAY` of the live compositor (`None` for Gaming-attach / Mutter, which are
|
||||||
|
/// PipeWire-node / D-Bus driven and don't talk Wayland to us).
|
||||||
|
pub wayland_display: Option<String>,
|
||||||
|
/// `/run/user/<uid>` — the trustworthy anchor (the default PipeWire daemon + bus live here).
|
||||||
|
pub xdg_runtime_dir: String,
|
||||||
|
/// `DBUS_SESSION_BUS_ADDRESS` (defaults to `unix:path=<runtime>/bus`).
|
||||||
|
pub dbus_session_bus_address: String,
|
||||||
|
/// `XDG_CURRENT_DESKTOP` to advertise (KDE/GNOME/sway/Hyprland/gamescope) — drives portal/EIS
|
||||||
|
/// routing (xdph keys its Hyprland-specific behavior off `Hyprland`).
|
||||||
|
pub xdg_current_desktop: Option<String>,
|
||||||
|
/// `HYPRLAND_INSTANCE_SIGNATURE` of the live Hyprland instance (`Some` only for
|
||||||
|
/// [`ActiveKind::DesktopHyprland`]). `hyprctl` needs it to reach the right instance socket;
|
||||||
|
/// [`apply_session_env`] exports it so the systemd-`--user` host works without inheriting the
|
||||||
|
/// session env (unlike sway's `SWAYSOCK`). `None` for every other compositor.
|
||||||
|
pub hyprland_signature: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The live session: its [`ActiveKind`] plus the [`SessionEnv`] to target it.
|
||||||
|
pub struct ActiveSession {
|
||||||
|
pub kind: ActiveKind,
|
||||||
|
pub env: SessionEnv,
|
||||||
|
/// PID of the winning compositor process (`None` when nothing live). The session watcher compares
|
||||||
|
/// it across polls so a **same-kind** compositor restart (Desktop→Game→Desktop) bumps the session
|
||||||
|
/// epoch — a fresh instance's node-id space is unrelated to the old one's (A4).
|
||||||
|
pub compositor_pid: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveSession {
|
||||||
|
/// A "nothing live" result carrying just the runtime-dir anchor.
|
||||||
|
fn none() -> ActiveSession {
|
||||||
|
ActiveSession {
|
||||||
|
kind: ActiveKind::None,
|
||||||
|
env: SessionEnv {
|
||||||
|
xdg_runtime_dir: default_runtime_dir(),
|
||||||
|
dbus_session_bus_address: default_bus(&default_runtime_dir()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
compositor_pid: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The concrete backend that drives a given live-session kind. `None` for [`ActiveKind::None`].
|
||||||
|
pub fn compositor_for_kind(kind: ActiveKind) -> Option<Compositor> {
|
||||||
|
match kind {
|
||||||
|
ActiveKind::Gaming => Some(Compositor::Gamescope),
|
||||||
|
ActiveKind::DesktopKde => Some(Compositor::Kwin),
|
||||||
|
ActiveKind::DesktopGnome => Some(Compositor::Mutter),
|
||||||
|
ActiveKind::DesktopWlroots => Some(Compositor::Wlroots),
|
||||||
|
ActiveKind::DesktopHyprland => Some(Compositor::Hyprland),
|
||||||
|
ActiveKind::None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn default_runtime_dir() -> String {
|
||||||
|
std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| {
|
||||||
|
// SAFETY: `getuid()` is a parameterless POSIX call that always succeeds and touches no
|
||||||
|
// memory — it just returns the calling process's real uid. Nothing is aliased or freed.
|
||||||
|
let uid = unsafe { libc::getuid() };
|
||||||
|
format!("/run/user/{uid}")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
fn default_runtime_dir() -> String {
|
||||||
|
std::env::var("XDG_RUNTIME_DIR").unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_bus(runtime: &str) -> String {
|
||||||
|
std::env::var("DBUS_SESSION_BUS_ADDRESS").unwrap_or_else(|_| format!("unix:path={runtime}/bus"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect the graphical session live for our uid right now (cheap, side-effect-free: a `/proc`
|
||||||
|
/// scan plus a runtime-dir socket scan — well under the handshake timeout). The authority is the
|
||||||
|
/// running compositor process; a desktop compositor outranks a lingering gamescope. Used to route
|
||||||
|
/// each connect to the correct backend, and to derive the [`SessionEnv`] that targets it.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn detect_active_session() -> ActiveSession {
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
// SAFETY: `getuid()` is a parameterless POSIX call that always succeeds and touches no memory —
|
||||||
|
// it just returns the calling process's real uid. Nothing is aliased or freed.
|
||||||
|
let uid = unsafe { libc::getuid() };
|
||||||
|
let xdg_runtime_dir = default_runtime_dir();
|
||||||
|
let dbus = default_bus(&xdg_runtime_dir);
|
||||||
|
|
||||||
|
// Process probe: the running graphical compositor of THIS uid decides the kind. Priority lets
|
||||||
|
// a real desktop (kwin/gnome/sway) win over a leftover gamescope child. comm names mirror the
|
||||||
|
// `pkill -x` discipline (exact, ≤15 chars so untruncated).
|
||||||
|
let mut kind = ActiveKind::None;
|
||||||
|
let mut best = 0u8;
|
||||||
|
// The winning compositor's PID — kept so a same-kind compositor RESTART (a new PID) bumps the
|
||||||
|
// session epoch (A4), not just a kind change.
|
||||||
|
let mut winning_pid: Option<u32> = None;
|
||||||
|
if let Ok(entries) = std::fs::read_dir("/proc") {
|
||||||
|
for e in entries.flatten() {
|
||||||
|
let name = e.file_name();
|
||||||
|
let Some(name) = name.to_str() else { continue };
|
||||||
|
if name.is_empty() || !name.bytes().all(|b| b.is_ascii_digit()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let pid_path = e.path();
|
||||||
|
let Ok(md) = std::fs::metadata(&pid_path) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if md.uid() != uid {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(comm) = std::fs::read_to_string(pid_path.join("comm")) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let (k, prio) = match comm.trim() {
|
||||||
|
"gamescope" | "gamescope-wl" => (ActiveKind::Gaming, 1),
|
||||||
|
"kwin_wayland" => (ActiveKind::DesktopKde, 4),
|
||||||
|
"gnome-shell" => (ActiveKind::DesktopGnome, 4),
|
||||||
|
// Hyprland is its own backend (hyprctl + xdph) — split it out of the sway/river
|
||||||
|
// wlroots-proper family (design/hyprland-support.md D1).
|
||||||
|
"Hyprland" | "hyprland" => (ActiveKind::DesktopHyprland, 4),
|
||||||
|
"sway" | "river" => (ActiveKind::DesktopWlroots, 4),
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
let pid = name.parse::<u32>().ok();
|
||||||
|
if prio > best {
|
||||||
|
best = prio;
|
||||||
|
kind = k;
|
||||||
|
winning_pid = pid;
|
||||||
|
} else if prio == best {
|
||||||
|
// Deterministic tie-break among same-top-priority processes: keep the LOWEST pid, so a
|
||||||
|
// duplicate same-kind compositor (two `kwin_wayland`) can't make `winning_pid` flap with
|
||||||
|
// `/proc` enumeration order — which `observe_session_instance` would misread as a
|
||||||
|
// compositor restart and tear a live display down (re-review low-severity note).
|
||||||
|
if let (Some(p), Some(w)) = (pid, winning_pid) {
|
||||||
|
if p < w {
|
||||||
|
kind = k;
|
||||||
|
winning_pid = Some(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wayland-protocol backends (KWin, wlroots, Hyprland) need the live socket for input (the wlr
|
||||||
|
// virtual pointer/keyboard client connects to it); Gaming-attach and Mutter are node/D-Bus
|
||||||
|
// driven and don't.
|
||||||
|
let wayland_display = match kind {
|
||||||
|
ActiveKind::DesktopKde | ActiveKind::DesktopWlroots | ActiveKind::DesktopHyprland => {
|
||||||
|
find_wayland_socket(&xdg_runtime_dir, uid)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let xdg_current_desktop = match kind {
|
||||||
|
ActiveKind::DesktopKde => Some("KDE".to_string()),
|
||||||
|
ActiveKind::DesktopGnome => Some("GNOME".to_string()),
|
||||||
|
ActiveKind::DesktopWlroots => Some("sway".to_string()),
|
||||||
|
// G4: advertise the real desktop so portal routing (portals.conf `[Hyprland]`) and xdph's
|
||||||
|
// own Hyprland checks work — NOT the old blanket `sway`.
|
||||||
|
ActiveKind::DesktopHyprland => Some("Hyprland".to_string()),
|
||||||
|
ActiveKind::Gaming => Some("gamescope".to_string()),
|
||||||
|
ActiveKind::None => None,
|
||||||
|
};
|
||||||
|
// Discover the Hyprland instance signature so `hyprctl` can reach the compositor even when the
|
||||||
|
// host runs as a systemd `--user` service that never inherited the session env.
|
||||||
|
let hyprland_signature = match kind {
|
||||||
|
ActiveKind::DesktopHyprland => find_hypr_signature(&xdg_runtime_dir, uid),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
ActiveSession {
|
||||||
|
kind,
|
||||||
|
env: SessionEnv {
|
||||||
|
wayland_display,
|
||||||
|
xdg_runtime_dir,
|
||||||
|
dbus_session_bus_address: dbus,
|
||||||
|
xdg_current_desktop,
|
||||||
|
hyprland_signature,
|
||||||
|
},
|
||||||
|
compositor_pid: winning_pid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the live Hyprland instance signature (`HYPRLAND_INSTANCE_SIGNATURE`) for our uid. Trust a
|
||||||
|
/// valid inherited value first (the host launched inside the session); otherwise pick the
|
||||||
|
/// newest-mtime instance directory under `$XDG_RUNTIME_DIR/hypr/` that we own and that still has a
|
||||||
|
/// live `.socket.sock` — the same "newest wins" heuristic as [`find_wayland_socket`]. A desktop
|
||||||
|
/// normally exposes exactly one. (Phase-2 refinement: match the instance to `compositor_pid` via
|
||||||
|
/// `hyprctl instances` when several coexist — `design/hyprland-support.md` §Phase-1.1.)
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn find_hypr_signature(runtime: &str, uid: u32) -> Option<String> {
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
let hypr = std::path::Path::new(runtime).join("hypr");
|
||||||
|
if let Ok(sig) = std::env::var("HYPRLAND_INSTANCE_SIGNATURE") {
|
||||||
|
if !sig.is_empty() && hypr.join(&sig).join(".socket.sock").exists() {
|
||||||
|
return Some(sig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut cands: Vec<(std::time::SystemTime, String)> = Vec::new();
|
||||||
|
for e in std::fs::read_dir(&hypr).ok()?.flatten() {
|
||||||
|
let Ok(md) = e.metadata() else { continue };
|
||||||
|
if !md.is_dir() || md.uid() != uid {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !e.path().join(".socket.sock").exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = e.file_name().to_string_lossy().into_owned();
|
||||||
|
let mtime = md.modified().unwrap_or(std::time::UNIX_EPOCH);
|
||||||
|
cands.push((mtime, name));
|
||||||
|
}
|
||||||
|
cands.sort_by_key(|(m, _)| std::cmp::Reverse(*m));
|
||||||
|
cands.into_iter().next().map(|(_, n)| n)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
pub fn detect_active_session() -> ActiveSession {
|
||||||
|
ActiveSession::none()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the live `wayland-*` socket in `runtime` for our uid (skipping `.lock` sidecars). Trust a
|
||||||
|
/// valid inherited `WAYLAND_DISPLAY` first; otherwise take the newest-mtime socket we own (a
|
||||||
|
/// desktop session normally exposes exactly one).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn find_wayland_socket(runtime: &str, uid: u32) -> Option<String> {
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
if let Ok(w) = std::env::var("WAYLAND_DISPLAY") {
|
||||||
|
if !w.is_empty() {
|
||||||
|
let p = if w.starts_with('/') {
|
||||||
|
std::path::PathBuf::from(&w)
|
||||||
|
} else {
|
||||||
|
std::path::Path::new(runtime).join(&w)
|
||||||
|
};
|
||||||
|
if p.exists() {
|
||||||
|
return Some(w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut cands: Vec<(std::time::SystemTime, String)> = Vec::new();
|
||||||
|
for e in std::fs::read_dir(runtime).ok()?.flatten() {
|
||||||
|
let name = e.file_name().to_string_lossy().into_owned();
|
||||||
|
if !name.starts_with("wayland-") || name.ends_with(".lock") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(md) = e.metadata() else { continue };
|
||||||
|
if md.uid() != uid {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mtime = md.modified().unwrap_or(std::time::UNIX_EPOCH);
|
||||||
|
cands.push((mtime, name));
|
||||||
|
}
|
||||||
|
cands.sort_by_key(|(m, _)| std::cmp::Reverse(*m));
|
||||||
|
cands.into_iter().next().map(|(_, n)| n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a detected session's [`SessionEnv`] into the process env so every backend (video capture
|
||||||
|
/// and input alike) that reads `WAYLAND_DISPLAY` / `XDG_RUNTIME_DIR` / `DBUS_SESSION_BUS_ADDRESS` /
|
||||||
|
/// `XDG_CURRENT_DESKTOP` at open time targets the live session. Serialized via [`ENV_LOCK`] so
|
||||||
|
/// concurrent session handshakes can't race the `set_var`s; the next connect re-detects and
|
||||||
|
/// re-applies.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn apply_session_env(active: &ActiveSession) {
|
||||||
|
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let e = &active.env;
|
||||||
|
std::env::set_var("XDG_RUNTIME_DIR", &e.xdg_runtime_dir);
|
||||||
|
std::env::set_var("DBUS_SESSION_BUS_ADDRESS", &e.dbus_session_bus_address);
|
||||||
|
if let Some(w) = &e.wayland_display {
|
||||||
|
std::env::set_var("WAYLAND_DISPLAY", w);
|
||||||
|
}
|
||||||
|
if let Some(d) = &e.xdg_current_desktop {
|
||||||
|
std::env::set_var("XDG_CURRENT_DESKTOP", d);
|
||||||
|
}
|
||||||
|
// Hyprland: export the discovered instance signature so `hyprctl` reaches the live compositor
|
||||||
|
// (fixes G4 for the systemd `--user` host, which never inherited it). Only set when detection
|
||||||
|
// found a Hyprland session; a stale value from a previous connect is cleared otherwise so a
|
||||||
|
// Hyprland→sway switch can't leave `hyprctl` pointed at a dead instance.
|
||||||
|
match &e.hyprland_signature {
|
||||||
|
Some(sig) => std::env::set_var("HYPRLAND_INSTANCE_SIGNATURE", sig),
|
||||||
|
None => std::env::remove_var("HYPRLAND_INSTANCE_SIGNATURE"),
|
||||||
|
}
|
||||||
|
// NOTHING live ⇒ every session-scoped var still in the env is a leftover from a previous
|
||||||
|
// connect's retarget, and the availability probes read them: after a gnome-shell crash
|
||||||
|
// (observed 2026-07-10: SIGSEGV → GDM greeter) a stale `XDG_CURRENT_DESKTOP=GNOME` kept
|
||||||
|
// `mutter::is_available()` true, so a client's explicit backend request routed into the dead
|
||||||
|
// session — 45 s create timeouts and a libei error loop instead of the crisp "no live
|
||||||
|
// graphical session" handshake error. Clear them so `available()` reports the truth and the
|
||||||
|
// client fails fast (and, when configured, `try_recover_session` can bring the desktop back).
|
||||||
|
if active.kind == ActiveKind::None {
|
||||||
|
std::env::remove_var("XDG_CURRENT_DESKTOP");
|
||||||
|
std::env::remove_var("WAYLAND_DISPLAY");
|
||||||
|
}
|
||||||
|
// Topology (Stage 2): the per-compositor backends (KWin/Mutter) now read
|
||||||
|
// [`effective_topology`] directly at create time — the console policy, else the legacy
|
||||||
|
// `PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY` env, else the Auto default (exclusive on the
|
||||||
|
// auto-desktop path). So this connect-path no longer writes that env (one fewer process-env
|
||||||
|
// mutation on the `ENV_LOCK` surface); `effective_topology()` computes the identical result.
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
pub fn apply_session_env(_active: &ActiveSession) {}
|
||||||
|
|
||||||
|
/// Fire the operator's session-recovery hook (`PUNKTFUNK_RECOVER_SESSION_CMD`) because a client
|
||||||
|
/// connected while NO graphical session is live for this uid — the state a compositor crash
|
||||||
|
/// leaves behind (gnome-shell SIGSEGV → GDM greeter, whose auto-login only fires once per boot,
|
||||||
|
/// so the box would otherwise sit headless until a walk-up login or a reboot). The command runs
|
||||||
|
/// detached via `sh -c` (typically a display-manager restart — see the config docs) and is
|
||||||
|
/// debounced to one launch per minute so a retrying client can't stack restarts. Returns whether
|
||||||
|
/// a recovery is underway (just launched, or launched within the debounce window), letting the
|
||||||
|
/// handshake error tell the client to simply retry.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn try_recover_session() -> bool {
|
||||||
|
let Some(cmd) = crate::config::config().recover_session_cmd.clone() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
static LAST_LAUNCH: std::sync::Mutex<Option<std::time::Instant>> = std::sync::Mutex::new(None);
|
||||||
|
const DEBOUNCE: std::time::Duration = std::time::Duration::from_secs(60);
|
||||||
|
let mut last = LAST_LAUNCH.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
if last.is_some_and(|t| t.elapsed() < DEBOUNCE) {
|
||||||
|
return true; // a launch is already in flight — the retry lands in the recovered session
|
||||||
|
}
|
||||||
|
match std::process::Command::new("/bin/sh")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(&cmd)
|
||||||
|
.stdin(std::process::Stdio::null())
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
{
|
||||||
|
Ok(mut child) => {
|
||||||
|
*last = Some(std::time::Instant::now());
|
||||||
|
tracing::warn!(cmd = %cmd,
|
||||||
|
"no live graphical session — launched the operator's session-recovery command");
|
||||||
|
// Reap off-thread so the finished child never lingers as a zombie.
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _ = child.wait();
|
||||||
|
});
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(cmd = %cmd, error = %e,
|
||||||
|
"session-recovery command failed to launch");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
pub fn try_recover_session() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// On a **mid-stream** switch to a desktop, the xdg-desktop-portal (D-Bus-activated) and the systemd
|
||||||
|
/// `--user` environment can still point at the OLD session, so the host's RemoteDesktop portal opens
|
||||||
|
/// against a half-stale env — it accepts events but they don't reach the compositor until a
|
||||||
|
/// reconnect. Push the live session env into the systemd/D-Bus activation environment and (for KWin,
|
||||||
|
/// whose input rides the xdg RemoteDesktop portal) restart the portal so it re-reads it — the same
|
||||||
|
/// settling a fresh desktop login does. Best-effort; mirrors the wlroots portal restart. GNOME uses
|
||||||
|
/// Mutter's *direct* EIS (no xdg portal), so it only needs the env push.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn settle_desktop_portal(chosen: Compositor) {
|
||||||
|
const VARS: &[&str] = &[
|
||||||
|
"WAYLAND_DISPLAY",
|
||||||
|
"XDG_CURRENT_DESKTOP",
|
||||||
|
"DBUS_SESSION_BUS_ADDRESS",
|
||||||
|
"XDG_RUNTIME_DIR",
|
||||||
|
];
|
||||||
|
// Push our (correct) env into the systemd --user manager + the D-Bus activation environment so a
|
||||||
|
// re-activated portal/backend inherits the live session.
|
||||||
|
let _ = std::process::Command::new("systemctl")
|
||||||
|
.args(["--user", "import-environment"])
|
||||||
|
.args(VARS)
|
||||||
|
.status();
|
||||||
|
let _ = std::process::Command::new("dbus-update-activation-environment")
|
||||||
|
.arg("--systemd")
|
||||||
|
.args(VARS)
|
||||||
|
.status();
|
||||||
|
// KWin input goes through the xdg RemoteDesktop portal; the frontend routes RemoteDesktop to a
|
||||||
|
// backend by its OWN startup XDG_CURRENT_DESKTOP, so restart it (+ the KDE backend) to re-read
|
||||||
|
// the now-live session, then let it settle before the injector reopens against it.
|
||||||
|
if chosen == Compositor::Kwin {
|
||||||
|
let _ = std::process::Command::new("systemctl")
|
||||||
|
.args([
|
||||||
|
"--user",
|
||||||
|
"try-restart",
|
||||||
|
"xdg-desktop-portal-kde.service",
|
||||||
|
"xdg-desktop-portal.service",
|
||||||
|
])
|
||||||
|
.status();
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(600));
|
||||||
|
}
|
||||||
|
// Hyprland capture rides the xdg ScreenCast portal serviced by xdph (G5): on a mid-stream switch
|
||||||
|
// xdph may still hold the old session's Wayland/instance env, so restart it (+ the frontend) to
|
||||||
|
// re-read the now-live session, mirroring the KWin settling above.
|
||||||
|
if chosen == Compositor::Hyprland {
|
||||||
|
let _ = std::process::Command::new("systemctl")
|
||||||
|
.args([
|
||||||
|
"--user",
|
||||||
|
"try-restart",
|
||||||
|
"xdg-desktop-portal-hyprland.service",
|
||||||
|
"xdg-desktop-portal.service",
|
||||||
|
])
|
||||||
|
.status();
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(600));
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
compositor = chosen.id(),
|
||||||
|
"settled desktop portal env for the switched-to session"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
pub fn settle_desktop_portal(_chosen: Compositor) {}
|
||||||
@@ -38,68 +38,18 @@ use crate::win_display::{
|
|||||||
restore_displays_ccd, set_active_mode, set_virtual_primary_ccd, SavedConfig,
|
restore_displays_ccd, set_active_mode, set_virtual_primary_ccd, SavedConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The per-backend REMOVE key the driver stamps on ADD and consumes on REMOVE. SudoVDA keys monitors by
|
#[path = "manager/driver.rs"]
|
||||||
/// a fresh `GUID`; pf-vdisplay keys them by a monotonic `u64` session id.
|
mod driver;
|
||||||
#[derive(Clone, Copy)]
|
pub(crate) use driver::{AddedMonitor, MonitorKey, VdisplayDriver};
|
||||||
pub(crate) enum MonitorKey {
|
|
||||||
Guid(windows::core::GUID),
|
|
||||||
Session(u64),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What a backend's `add_monitor` returns: the REMOVE key + the OS target id + the render LUID + the
|
#[path = "manager/instance.rs"]
|
||||||
/// driver's WUDFHost pid (the sealed frame channel's handle-duplication target) + the monitor id the
|
mod instance;
|
||||||
/// driver actually resolved (the per-client stable id when honored; diagnostics on the slot).
|
use instance::claim_instance;
|
||||||
pub(crate) struct AddedMonitor {
|
pub(crate) use instance::claim_instance_eagerly;
|
||||||
pub key: MonitorKey,
|
|
||||||
pub target_id: u32,
|
|
||||||
pub luid: LUID,
|
|
||||||
pub wudf_pid: u32,
|
|
||||||
pub resolved_monitor_id: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The backend-specific IOCTL surface — the *only* thing that differs between SudoVDA and pf-vdisplay.
|
#[path = "manager/knobs.rs"]
|
||||||
/// Everything else (the refcount machine, the linger, the pinger, the CCD/GDI glue) is shared in
|
mod knobs;
|
||||||
/// [`VirtualDisplayManager`]. `Send + Sync` because the manager (and so the boxed driver) is a
|
use knobs::{keep_alive_forever, linger_ms, topology_action};
|
||||||
/// `&'static` singleton reached from the pinger + linger threads.
|
|
||||||
pub(crate) trait VdisplayDriver: Send + Sync {
|
|
||||||
fn name(&self) -> &'static str;
|
|
||||||
/// Find + open the control device, validate it (version handshake), and read the watchdog
|
|
||||||
/// timeout. `reap_orphans` (the FIRST open of the process only) additionally `CLEAR_ALL`s
|
|
||||||
/// monitors orphaned by a crashed previous host — a REOPEN (after a dead handle was retired)
|
|
||||||
/// must NOT, since sessions this process still considers live may be racing it. Returns the
|
|
||||||
/// owned handle + watchdog seconds.
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// Issues setup-API + `DeviceIoControl` calls; runs in the caller's apartment.
|
|
||||||
unsafe fn open(&self, reap_orphans: bool) -> Result<(OwnedHandle, u32)>;
|
|
||||||
/// ADD a virtual monitor at `mode`, pinning the IDD render GPU to `render_luid` first if `Some`, and
|
|
||||||
/// requesting `preferred_monitor_id` (the host's per-client stable id; `0` = auto). `client_hdr`
|
|
||||||
/// is the CLIENT display's HDR volume for the monitor's EDID CTA HDR block (`None` = the
|
|
||||||
/// driver's built-in defaults). Returns the REMOVE key + target id + the IddCx DISPLAY adapter
|
|
||||||
/// LUID from the ADD reply (`IDARG_OUT_MONITORARRIVAL.OsAdapterLuid` — NOT the render GPU; the
|
|
||||||
/// driver reports its render adapter only in the shared frame header).
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// `dev` must be the live control handle from [`open`](Self::open).
|
|
||||||
unsafe fn add_monitor(
|
|
||||||
&self,
|
|
||||||
dev: HANDLE,
|
|
||||||
mode: Mode,
|
|
||||||
render_luid: Option<LUID>,
|
|
||||||
preferred_monitor_id: u32,
|
|
||||||
client_hdr: Option<punktfunk_core::quic::HdrMeta>,
|
|
||||||
) -> Result<AddedMonitor>;
|
|
||||||
/// REMOVE the monitor identified by `key`.
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// `dev` must be the live control handle.
|
|
||||||
unsafe fn remove_monitor(&self, dev: HANDLE, key: &MonitorKey) -> Result<()>;
|
|
||||||
/// Watchdog keepalive PING (issued every `watchdog/3` from the pinger thread).
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// `dev` must be the live control handle.
|
|
||||||
unsafe fn ping(&self, dev: HANDLE) -> Result<()>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The resources backing one live virtual monitor (owned by the [`VirtualDisplayManager`] state, not by
|
/// The resources backing one live virtual monitor (owned by the [`VirtualDisplayManager`] state, not by
|
||||||
/// any session). No `Drop` impl — [`teardown_removed`](VirtualDisplayManager::teardown_removed) must be
|
/// any session). No `Drop` impl — [`teardown_removed`](VirtualDisplayManager::teardown_removed) must be
|
||||||
@@ -308,70 +258,6 @@ pub(crate) fn control_device_handle() -> Option<HANDLE> {
|
|||||||
VDM.get().and_then(VirtualDisplayManager::device_handle)
|
VDM.get().and_then(VirtualDisplayManager::device_handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True when an IOCTL failure means the CONTROL DEVICE itself is gone (driver upgrade, WUDFHost
|
|
||||||
/// restart, device disable) — the cached handle can only keep failing and must be retired so the
|
|
||||||
/// next use reopens. The root `windows` error survives anyhow `.context` chains via `downcast_ref`.
|
|
||||||
/// NOTE: 0x80070490 (ERROR_NOT_FOUND, the ADD slot-exhaustion wedge) is deliberately NOT here — it
|
|
||||||
/// has its own reap-and-retry handling and the device is alive when it fires.
|
|
||||||
/// The held single-instance mutex (`None` until claimed). Process-global — not per-manager — so the
|
|
||||||
/// serve path can claim it EAGERLY at startup, before any session opens the backend: the claim is
|
|
||||||
/// first-comer-wins, and a lazily-claiming service could otherwise lose its own machine's driver to
|
|
||||||
/// a stray second host started while the service sat idle (observed on-glass). A failed claim is NOT
|
|
||||||
/// memoized: once the other instance exits, the next attempt succeeds.
|
|
||||||
static INSTANCE: Mutex<Option<OwnedHandle>> = Mutex::new(None);
|
|
||||||
|
|
||||||
/// Claim (or re-verify) the cross-process single-instance guard. Idempotent; retries after failure.
|
|
||||||
fn claim_instance() -> Result<()> {
|
|
||||||
let mut g = INSTANCE.lock().unwrap();
|
|
||||||
if g.is_none() {
|
|
||||||
*g = Some(acquire_single_instance()?);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Eager startup claim for the serve/service path (Windows): reserves this process as THE
|
|
||||||
/// pf-vdisplay manager before any client connects. Failure is a loud warning, not fatal — sessions
|
|
||||||
/// then fail with the same clear in-use error until the other instance exits.
|
|
||||||
pub(crate) fn claim_instance_eagerly() {
|
|
||||||
if let Err(e) = claim_instance() {
|
|
||||||
tracing::warn!("pf-vdisplay single-instance claim failed at startup: {e:#}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The cross-process single-instance guard for pf-vdisplay management. A SECOND host process's
|
|
||||||
/// first device open used to fire `IOCTL_CLEAR_ALL` and raze the live host's monitors mid-stream —
|
|
||||||
/// an admin footgun (run `punktfunk-host serve` while the SCM service streams), masked afterwards
|
|
||||||
/// because both processes' pings satisfy the shared driver watchdog. The named mutex makes the
|
|
||||||
/// second process fail its vdisplay open LOUDLY instead. Held, never released, for the process
|
|
||||||
/// lifetime; the OS reclaims it (and frees the name) when the process exits, however it exits.
|
|
||||||
fn acquire_single_instance() -> Result<OwnedHandle> {
|
|
||||||
const IN_USE: &str = "another punktfunk-host process is already managing pf-vdisplay on this \
|
|
||||||
machine — refusing to touch the driver (a second manager's startup CLEAR_ALL would raze \
|
|
||||||
the live host's monitors mid-stream). Stop the other instance (e.g. `punktfunk-host \
|
|
||||||
service stop`) first.";
|
|
||||||
// SAFETY: plain FFI create of a named mutex; the returned handle (checked) is solely owned by
|
|
||||||
// the `OwnedHandle`, and `GetLastError` is read immediately after the create — the documented
|
|
||||||
// ERROR_ALREADY_EXISTS protocol for pre-existing named objects.
|
|
||||||
unsafe {
|
|
||||||
let h = match CreateMutexW(None, false, w!("Global\\punktfunk-vdisplay-manager")) {
|
|
||||||
Ok(h) => h,
|
|
||||||
// The name exists but its creator's DACL denies this token the implicit OPEN (the SCM
|
|
||||||
// service creates it as SYSTEM; a second elevated-admin host lands here instead of in
|
|
||||||
// the ALREADY_EXISTS branch — validated on-glass). Same meaning: an instance is live.
|
|
||||||
Err(e) if e.code().0 == 0x8007_0005u32 as i32 => anyhow::bail!("{IN_USE}"),
|
|
||||||
Err(e) => {
|
|
||||||
return Err(e).context("CreateMutexW(punktfunk-vdisplay single-instance guard)");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let already = GetLastError() == ERROR_ALREADY_EXISTS;
|
|
||||||
let owned = OwnedHandle::from_raw_handle(h.0 as _);
|
|
||||||
if already {
|
|
||||||
anyhow::bail!("{IN_USE}");
|
|
||||||
}
|
|
||||||
Ok(owned)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Best-effort "is this WUDFHost pid still alive?" — the monitor-liveness probe for the JOIN path.
|
/// Best-effort "is this WUDFHost pid still alive?" — the monitor-liveness probe for the JOIN path.
|
||||||
/// `OpenProcess` failing (pid reaped) or the process being signaled ⇒ dead. Pid reuse could
|
/// `OpenProcess` failing (pid reaped) or the process being signaled ⇒ dead. Pid reuse could
|
||||||
/// theoretically alias a fresh process and read "alive"; the joining session then just retries into
|
/// theoretically alias a fresh process and read "alive"; the joining session then just retries into
|
||||||
@@ -392,6 +278,11 @@ fn wudf_alive(pid: u32) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True when an IOCTL failure means the CONTROL DEVICE itself is gone (driver upgrade, WUDFHost
|
||||||
|
/// restart, device disable) — the cached handle can only keep failing and must be retired so the
|
||||||
|
/// next use reopens. The root `windows` error survives anyhow `.context` chains via `downcast_ref`.
|
||||||
|
/// NOTE: 0x80070490 (ERROR_NOT_FOUND, the ADD slot-exhaustion wedge) is deliberately NOT here — it
|
||||||
|
/// has its own reap-and-retry handling and the device is alive when it fires.
|
||||||
fn is_device_gone(e: &anyhow::Error) -> bool {
|
fn is_device_gone(e: &anyhow::Error) -> bool {
|
||||||
let Some(w) = e.downcast_ref::<windows::core::Error>() else {
|
let Some(w) = e.downcast_ref::<windows::core::Error>() else {
|
||||||
return false;
|
return false;
|
||||||
@@ -1700,55 +1591,3 @@ pub(crate) fn snapshot() -> Vec<ManagedInfo> {
|
|||||||
pub(crate) fn force_release(slot: Option<u64>) -> usize {
|
pub(crate) fn force_release(slot: Option<u64>) -> usize {
|
||||||
VDM.get().map(|m| m.force_release(slot)).unwrap_or(0)
|
VDM.get().map(|m| m.force_release(slot)).unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Linger window before a session-less monitor is torn down. The console display-management policy
|
|
||||||
/// wins when configured (`keep_alive`); otherwise the legacy `PUNKTFUNK_MONITOR_LINGER_MS` env knob,
|
|
||||||
/// else the 10 s default.
|
|
||||||
fn linger_ms() -> u64 {
|
|
||||||
use crate::vdisplay::policy::{prefs, Linger};
|
|
||||||
if let Some(eff) = prefs().configured_effective() {
|
|
||||||
return match eff.keep_alive.linger() {
|
|
||||||
Linger::Immediate => 0,
|
|
||||||
Linger::For(d) => d.as_millis() as u64,
|
|
||||||
// `forever` is handled BEFORE this by `keep_alive_forever()` in `release` (→ `Pinned`), so
|
|
||||||
// this arm is only reached defensively (e.g. a caller that resolves ms without the pin
|
|
||||||
// check) — fall back to the default rather than a huge linger.
|
|
||||||
Linger::Forever => 10_000,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
std::env::var("PUNKTFUNK_MONITOR_LINGER_MS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| s.parse().ok())
|
|
||||||
.unwrap_or(10_000)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the configured console policy's `keep_alive` resolves to **forever** (`Pinned`) — the
|
|
||||||
/// gaming-rig preset. `release` uses this to keep the last-released monitor indefinitely instead of
|
|
||||||
/// lingering. Unconfigured hosts are never forever (default is a short linger).
|
|
||||||
fn keep_alive_forever() -> bool {
|
|
||||||
use crate::vdisplay::policy::{prefs, Linger};
|
|
||||||
prefs()
|
|
||||||
.configured_effective()
|
|
||||||
.map(|eff| matches!(eff.keep_alive.linger(), Linger::Forever))
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The effective display topology for a freshly-created monitor (never `Auto`): the console policy's
|
|
||||||
/// [`effective_topology`](crate::vdisplay::effective_topology) when configured, else the legacy
|
|
||||||
/// `PUNKTFUNK_NO_ISOLATE` env knob (`Extend`) / `Exclusive` (today's default). `Extend` leaves the IDD
|
|
||||||
/// extended; `Primary` makes it primary while keeping the physical(s) active; `Exclusive` disables the
|
|
||||||
/// physical(s) so the IDD is the sole composited desktop.
|
|
||||||
fn topology_action() -> crate::vdisplay::policy::Topology {
|
|
||||||
use crate::vdisplay::policy::Topology;
|
|
||||||
if crate::vdisplay::policy::prefs()
|
|
||||||
.configured_effective()
|
|
||||||
.is_some()
|
|
||||||
{
|
|
||||||
return crate::vdisplay::effective_topology();
|
|
||||||
}
|
|
||||||
if std::env::var("PUNKTFUNK_NO_ISOLATE").is_ok() {
|
|
||||||
Topology::Extend
|
|
||||||
} else {
|
|
||||||
Topology::Exclusive
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
//! The backend-specific virtual-display **seam** (SudoVDA vs pf-vdisplay), carved out of the manager
|
||||||
|
//! (plan §W3): the REMOVE-key type, the `add_monitor` reply, and the IOCTL trait. This is the ONLY
|
||||||
|
//! thing that differs between the two Windows backends — the refcount machine, linger, pinger, and
|
||||||
|
//! CCD/GDI glue are all backend-neutral in [`super::VirtualDisplayManager`].
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The per-backend REMOVE key the driver stamps on ADD and consumes on REMOVE. SudoVDA keys monitors by
|
||||||
|
/// a fresh `GUID`; pf-vdisplay keys them by a monotonic `u64` session id.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub(crate) enum MonitorKey {
|
||||||
|
Guid(windows::core::GUID),
|
||||||
|
Session(u64),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a backend's `add_monitor` returns: the REMOVE key + the OS target id + the render LUID + the
|
||||||
|
/// driver's WUDFHost pid (the sealed frame channel's handle-duplication target) + the monitor id the
|
||||||
|
/// driver actually resolved (the per-client stable id when honored; diagnostics on the slot).
|
||||||
|
pub(crate) struct AddedMonitor {
|
||||||
|
pub key: MonitorKey,
|
||||||
|
pub target_id: u32,
|
||||||
|
pub luid: LUID,
|
||||||
|
pub wudf_pid: u32,
|
||||||
|
pub resolved_monitor_id: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The backend-specific IOCTL surface — the *only* thing that differs between SudoVDA and pf-vdisplay.
|
||||||
|
/// Everything else (the refcount machine, the linger, the pinger, the CCD/GDI glue) is shared in
|
||||||
|
/// [`VirtualDisplayManager`]. `Send + Sync` because the manager (and so the boxed driver) is a
|
||||||
|
/// `&'static` singleton reached from the pinger + linger threads.
|
||||||
|
pub(crate) trait VdisplayDriver: Send + Sync {
|
||||||
|
fn name(&self) -> &'static str;
|
||||||
|
/// Find + open the control device, validate it (version handshake), and read the watchdog
|
||||||
|
/// timeout. `reap_orphans` (the FIRST open of the process only) additionally `CLEAR_ALL`s
|
||||||
|
/// monitors orphaned by a crashed previous host — a REOPEN (after a dead handle was retired)
|
||||||
|
/// must NOT, since sessions this process still considers live may be racing it. Returns the
|
||||||
|
/// owned handle + watchdog seconds.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Issues setup-API + `DeviceIoControl` calls; runs in the caller's apartment.
|
||||||
|
unsafe fn open(&self, reap_orphans: bool) -> Result<(OwnedHandle, u32)>;
|
||||||
|
/// ADD a virtual monitor at `mode`, pinning the IDD render GPU to `render_luid` first if `Some`, and
|
||||||
|
/// requesting `preferred_monitor_id` (the host's per-client stable id; `0` = auto). `client_hdr`
|
||||||
|
/// is the CLIENT display's HDR volume for the monitor's EDID CTA HDR block (`None` = the
|
||||||
|
/// driver's built-in defaults). Returns the REMOVE key + target id + the IddCx DISPLAY adapter
|
||||||
|
/// LUID from the ADD reply (`IDARG_OUT_MONITORARRIVAL.OsAdapterLuid` — NOT the render GPU; the
|
||||||
|
/// driver reports its render adapter only in the shared frame header).
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `dev` must be the live control handle from [`open`](Self::open).
|
||||||
|
unsafe fn add_monitor(
|
||||||
|
&self,
|
||||||
|
dev: HANDLE,
|
||||||
|
mode: Mode,
|
||||||
|
render_luid: Option<LUID>,
|
||||||
|
preferred_monitor_id: u32,
|
||||||
|
client_hdr: Option<punktfunk_core::quic::HdrMeta>,
|
||||||
|
) -> Result<AddedMonitor>;
|
||||||
|
/// REMOVE the monitor identified by `key`.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `dev` must be the live control handle.
|
||||||
|
unsafe fn remove_monitor(&self, dev: HANDLE, key: &MonitorKey) -> Result<()>;
|
||||||
|
/// Watchdog keepalive PING (issued every `watchdog/3` from the pinger thread).
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `dev` must be the live control handle.
|
||||||
|
unsafe fn ping(&self, dev: HANDLE) -> Result<()>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
//! The cross-process single-instance guard for pf-vdisplay management (plan §W3, carved out of the
|
||||||
|
//! manager). A named mutex makes a SECOND host process fail its vdisplay open loudly instead of firing
|
||||||
|
//! `IOCTL_CLEAR_ALL` and razing the live host's monitors mid-stream.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The held single-instance mutex (`None` until claimed). Process-global — not per-manager — so the
|
||||||
|
/// serve path can claim it EAGERLY at startup, before any session opens the backend: the claim is
|
||||||
|
/// first-comer-wins, and a lazily-claiming service could otherwise lose its own machine's driver to
|
||||||
|
/// a stray second host started while the service sat idle (observed on-glass). A failed claim is NOT
|
||||||
|
/// memoized: once the other instance exits, the next attempt succeeds.
|
||||||
|
static INSTANCE: Mutex<Option<OwnedHandle>> = Mutex::new(None);
|
||||||
|
|
||||||
|
/// Claim (or re-verify) the cross-process single-instance guard. Idempotent; retries after failure.
|
||||||
|
pub(super) fn claim_instance() -> Result<()> {
|
||||||
|
let mut g = INSTANCE.lock().unwrap();
|
||||||
|
if g.is_none() {
|
||||||
|
*g = Some(acquire_single_instance()?);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eager startup claim for the serve/service path (Windows): reserves this process as THE
|
||||||
|
/// pf-vdisplay manager before any client connects. Failure is a loud warning, not fatal — sessions
|
||||||
|
/// then fail with the same clear in-use error until the other instance exits.
|
||||||
|
pub(crate) fn claim_instance_eagerly() {
|
||||||
|
if let Err(e) = claim_instance() {
|
||||||
|
tracing::warn!("pf-vdisplay single-instance claim failed at startup: {e:#}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cross-process single-instance guard for pf-vdisplay management. A SECOND host process's
|
||||||
|
/// first device open used to fire `IOCTL_CLEAR_ALL` and raze the live host's monitors mid-stream —
|
||||||
|
/// an admin footgun (run `punktfunk-host serve` while the SCM service streams), masked afterwards
|
||||||
|
/// because both processes' pings satisfy the shared driver watchdog. The named mutex makes the
|
||||||
|
/// second process fail its vdisplay open LOUDLY instead. Held, never released, for the process
|
||||||
|
/// lifetime; the OS reclaims it (and frees the name) when the process exits, however it exits.
|
||||||
|
fn acquire_single_instance() -> Result<OwnedHandle> {
|
||||||
|
const IN_USE: &str = "another punktfunk-host process is already managing pf-vdisplay on this \
|
||||||
|
machine — refusing to touch the driver (a second manager's startup CLEAR_ALL would raze \
|
||||||
|
the live host's monitors mid-stream). Stop the other instance (e.g. `punktfunk-host \
|
||||||
|
service stop`) first.";
|
||||||
|
// SAFETY: plain FFI create of a named mutex; the returned handle (checked) is solely owned by
|
||||||
|
// the `OwnedHandle`, and `GetLastError` is read immediately after the create — the documented
|
||||||
|
// ERROR_ALREADY_EXISTS protocol for pre-existing named objects.
|
||||||
|
unsafe {
|
||||||
|
let h = match CreateMutexW(None, false, w!("Global\\punktfunk-vdisplay-manager")) {
|
||||||
|
Ok(h) => h,
|
||||||
|
// The name exists but its creator's DACL denies this token the implicit OPEN (the SCM
|
||||||
|
// service creates it as SYSTEM; a second elevated-admin host lands here instead of in
|
||||||
|
// the ALREADY_EXISTS branch — validated on-glass). Same meaning: an instance is live.
|
||||||
|
Err(e) if e.code().0 == 0x8007_0005u32 as i32 => anyhow::bail!("{IN_USE}"),
|
||||||
|
Err(e) => {
|
||||||
|
return Err(e).context("CreateMutexW(punktfunk-vdisplay single-instance guard)");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let already = GetLastError() == ERROR_ALREADY_EXISTS;
|
||||||
|
let owned = OwnedHandle::from_raw_handle(h.0 as _);
|
||||||
|
if already {
|
||||||
|
anyhow::bail!("{IN_USE}");
|
||||||
|
}
|
||||||
|
Ok(owned)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
//! Runtime display-management knobs read from the console policy (with legacy env-var fallbacks),
|
||||||
|
//! carved out of the manager (plan §W3): the linger window, the keep-alive-forever pin, and the
|
||||||
|
//! per-monitor topology action. Pure readers of [`crate::vdisplay::policy`] + env — no manager state.
|
||||||
|
|
||||||
|
/// Linger window before a session-less monitor is torn down. The console display-management policy
|
||||||
|
/// wins when configured (`keep_alive`); otherwise the legacy `PUNKTFUNK_MONITOR_LINGER_MS` env knob,
|
||||||
|
/// else the 10 s default.
|
||||||
|
pub(super) fn linger_ms() -> u64 {
|
||||||
|
use crate::vdisplay::policy::{prefs, Linger};
|
||||||
|
if let Some(eff) = prefs().configured_effective() {
|
||||||
|
return match eff.keep_alive.linger() {
|
||||||
|
Linger::Immediate => 0,
|
||||||
|
Linger::For(d) => d.as_millis() as u64,
|
||||||
|
// `forever` is handled BEFORE this by `keep_alive_forever()` in `release` (→ `Pinned`), so
|
||||||
|
// this arm is only reached defensively (e.g. a caller that resolves ms without the pin
|
||||||
|
// check) — fall back to the default rather than a huge linger.
|
||||||
|
Linger::Forever => 10_000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
std::env::var("PUNKTFUNK_MONITOR_LINGER_MS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(10_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the configured console policy's `keep_alive` resolves to **forever** (`Pinned`) — the
|
||||||
|
/// gaming-rig preset. `release` uses this to keep the last-released monitor indefinitely instead of
|
||||||
|
/// lingering. Unconfigured hosts are never forever (default is a short linger).
|
||||||
|
pub(super) fn keep_alive_forever() -> bool {
|
||||||
|
use crate::vdisplay::policy::{prefs, Linger};
|
||||||
|
prefs()
|
||||||
|
.configured_effective()
|
||||||
|
.map(|eff| matches!(eff.keep_alive.linger(), Linger::Forever))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The effective display topology for a freshly-created monitor (never `Auto`): the console policy's
|
||||||
|
/// [`effective_topology`](crate::vdisplay::effective_topology) when configured, else the legacy
|
||||||
|
/// `PUNKTFUNK_NO_ISOLATE` env knob (`Extend`) / `Exclusive` (today's default). `Extend` leaves the IDD
|
||||||
|
/// extended; `Primary` makes it primary while keeping the physical(s) active; `Exclusive` disables the
|
||||||
|
/// physical(s) so the IDD is the sole composited desktop.
|
||||||
|
pub(super) fn topology_action() -> crate::vdisplay::policy::Topology {
|
||||||
|
use crate::vdisplay::policy::Topology;
|
||||||
|
if crate::vdisplay::policy::prefs()
|
||||||
|
.configured_effective()
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return crate::vdisplay::effective_topology();
|
||||||
|
}
|
||||||
|
if std::env::var("PUNKTFUNK_NO_ISOLATE").is_ok() {
|
||||||
|
Topology::Extend
|
||||||
|
} else {
|
||||||
|
Topology::Exclusive
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,7 +104,11 @@ impl TrayStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The host detected another Moonlight-compatible host (Sunshine/Apollo/…) on this machine —
|
/// The host detected another Moonlight-compatible host (Sunshine/Apollo/…) on this machine —
|
||||||
/// unsupported side-by-side. Drives the tray's attention state.
|
/// unsupported side-by-side. Drives the Linux (ksni) backend's `NeedsAttention` state; the
|
||||||
|
/// Windows backend surfaces the same conflict through the tooltip `headline()` instead (it has
|
||||||
|
/// no distinct attention icon), so this accessor is unused there — allow it per-platform rather
|
||||||
|
/// than gate the shared API out.
|
||||||
|
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||||
pub fn has_conflicts(&self) -> bool {
|
pub fn has_conflicts(&self) -> bool {
|
||||||
matches!(self, TrayStatus::Running(s) if !s.conflicts.is_empty())
|
matches!(self, TrayStatus::Running(s) if !s.conflicts.is_empty())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
---
|
||||||
|
title: Events & hooks
|
||||||
|
description: React to what the host does — lifecycle events over SSE, hook commands and webhooks, per-app prep/undo — for notifications, DND toggles, Home Assistant, and more.
|
||||||
|
---
|
||||||
|
|
||||||
|
The host emits a **lifecycle event** for the things you'd want to react to: a client connects or
|
||||||
|
disconnects, a stream starts or stops, a pairing request arrives, a virtual display is created,
|
||||||
|
the library changes, the host starts or shuts down. Two ways to consume them:
|
||||||
|
|
||||||
|
- **Hooks** — zero-code: entries in `~/.config/punktfunk/hooks.json` run a **command** or POST a
|
||||||
|
**webhook** when a matching event fires. This covers the common automation: Do-Not-Disturb
|
||||||
|
during a stream, a phone notification on a pairing request, pausing downloads while playing.
|
||||||
|
- **The event stream** — code: `GET /api/v1/events` on the management API is a standard
|
||||||
|
[Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events)
|
||||||
|
stream of the same events, for scripts and integrations that want to *decide* things (e.g.
|
||||||
|
auto-approve pairing from a known subnet by calling the approve endpoint).
|
||||||
|
|
||||||
|
Hooks **observe** — they can never veto or delay a connection, a stream, or a pairing decision,
|
||||||
|
and nothing you configure here runs anywhere near the streaming path.
|
||||||
|
|
||||||
|
## The events
|
||||||
|
|
||||||
|
| Kind | Fires when | Carries |
|
||||||
|
|---|---|---|
|
||||||
|
| `client.connected` / `client.disconnected` | a client session is admitted / goes away | device name, cert fingerprint, plane (`native`/`gamestream`); disconnect adds `reason`: `quit` (user stop), `timeout` (vanished), `error` |
|
||||||
|
| `session.started` / `session.ended` | an A/V session registers / ends | session id, client label, mode (`3840x2160@120`), HDR |
|
||||||
|
| `stream.started` / `stream.stopped` | video actually starts / stops | mode, HDR, client name, launched app id/title (when one was requested), plane |
|
||||||
|
| `pairing.pending` | an unpaired device knocks (once per device, not per retry) | device name, fingerprint, plane |
|
||||||
|
| `pairing.completed` / `pairing.denied` | a pairing is approved+stored / denied | device name, fingerprint, plane |
|
||||||
|
| `display.created` / `display.released` | a virtual display is minted / kept displays are released | backend + mode / count |
|
||||||
|
| `library.changed` | the game library is mutated | source: `manual`, or the provider id that reconciled (`PUT /api/v1/library/provider/{p}`) |
|
||||||
|
| `host.started` / `host.stopping` | the serve planes come up / wind down | version, whether GameStream is enabled |
|
||||||
|
|
||||||
|
Every event is a small JSON document with a monotonic `seq`, a `ts_ms` timestamp, a `schema`
|
||||||
|
version (additive-only — fields get added, never renamed), and the fields above. Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "seq": 42, "ts_ms": 1784227449526, "schema": 1,
|
||||||
|
"kind": "stream.started",
|
||||||
|
"stream": { "mode": "2560x1440@120", "hdr": true,
|
||||||
|
"client": "Living Room TV", "app": "steam:570", "plane": "native" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hooks: `hooks.json`
|
||||||
|
|
||||||
|
Create `~/.config/punktfunk/hooks.json` (Windows: `%ProgramData%\punktfunk\hooks.json`), or PUT
|
||||||
|
the same document to `/api/v1/hooks` from a script — changes apply immediately, no restart:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{ "on": "stream.started", "run": "~/.config/punktfunk/scripts/on-stream.sh" },
|
||||||
|
{ "on": "stream.stopped", "run": "~/.config/punktfunk/scripts/off-stream.sh" },
|
||||||
|
{ "on": "client.connected", "filter": { "client": "Living Room TV" },
|
||||||
|
"run": "kscreen-doctor output.HDMI-A-1.mode.3840x2160@60" },
|
||||||
|
{ "on": "pairing.pending",
|
||||||
|
"webhook": "https://ha.local/api/webhook/punktfunk",
|
||||||
|
"hmac_secret_file": "/home/me/.config/punktfunk/webhook-secret" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each entry:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `on` | Which events fire it: an exact kind (`stream.started`) or a `domain.*` prefix (`pairing.*`). |
|
||||||
|
| `run` | A shell command (`sh -c` on Linux). Gets the event JSON on **stdin** and flat **`PF_EVENT_*`** env vars. |
|
||||||
|
| `webhook` | A URL the event JSON is POSTed to. TLS-verified, redirects are never followed, no punktfunk credentials attached. |
|
||||||
|
| `filter` | Optional exact-match constraints: `client` (device name), `fingerprint`, `plane` (`native`/`gamestream`), `app`. All present fields must match. |
|
||||||
|
| `timeout_s` | Command timeout (default 30, max 600) — on expiry the whole process group is killed. |
|
||||||
|
| `debounce_ms` | Minimum interval between firings of this hook (0 = every event). |
|
||||||
|
| `hmac_secret_file` | File with a secret; the webhook gains `X-Punktfunk-Signature: sha256=<hex HMAC-SHA256 of the body>` so your receiver can authenticate the host. |
|
||||||
|
|
||||||
|
A `run` command's shell one-liner vocabulary — the event flattened to env, values sanitized:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
#!/bin/sh
|
||||||
|
# PF_EVENT_KIND=stream.started PF_EVENT_SEQ=42
|
||||||
|
# PF_EVENT_STREAM_MODE=2560x1440@120 PF_EVENT_STREAM_HDR=true
|
||||||
|
# PF_EVENT_STREAM_CLIENT='Living Room TV' PF_EVENT_STREAM_APP=steam:570
|
||||||
|
# PF_EVENT_STREAM_PLANE=native PF_EVENT_JSON='{…the whole event…}'
|
||||||
|
[ "$PF_EVENT_KIND" = stream.started ] && makoctl mode -a do-not-disturb
|
||||||
|
```
|
||||||
|
|
||||||
|
Richer payloads (and the full document) are on stdin — `jq` away. On a Windows host running as
|
||||||
|
the service, the command runs **in your interactive session** (never as SYSTEM); that path can't
|
||||||
|
carry per-process env or stdin, so the event JSON's path is appended as the command's last
|
||||||
|
argument instead.
|
||||||
|
|
||||||
|
Verify a signed webhook (Python):
|
||||||
|
|
||||||
|
```python
|
||||||
|
import hmac, hashlib
|
||||||
|
expected = "sha256=" + hmac.new(secret, body, hashlib.sha256).hexdigest()
|
||||||
|
ok = hmac.compare_digest(request.headers["X-Punktfunk-Signature"], expected)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules of the road:** hooks are fire-and-forget and bounded — at most 8 in flight (extra
|
||||||
|
firings are dropped with a log line, never queued), and a command that outlives its timeout is
|
||||||
|
killed. Because hook commands run as the host user, `hooks.json` is operator-privileged config;
|
||||||
|
a hook **script** must be owned by you (or root) and not group/world-writable, or the host
|
||||||
|
refuses to run it — loudly, in the log.
|
||||||
|
|
||||||
|
The two simplest cases also exist as plain [host.env](/docs/configuration) settings, no
|
||||||
|
`hooks.json` needed: `PUNKTFUNK_ON_CONNECT_CMD` and `PUNKTFUNK_ON_DISCONNECT_CMD`.
|
||||||
|
|
||||||
|
## Per-app prep/undo
|
||||||
|
|
||||||
|
For per-title setup (HDR toggle, MangoHud, a VRR tweak), attach `prep` steps to a GameStream
|
||||||
|
`apps.json` entry or a custom library entry — each `do` runs **before** the title launches
|
||||||
|
(synchronously — the launch waits), each `undo` runs at session end in **reverse order**,
|
||||||
|
best-effort, even if the session crashed:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "id": 2, "title": "Steam", "compositor": "gamescope", "cmd": "steam -gamepadui",
|
||||||
|
"prep": [
|
||||||
|
{ "do": "~/bin/hdr on", "undo": "~/bin/hdr off" },
|
||||||
|
{ "do": "pactl set-default-sink game_sink", "undo": "pactl set-default-sink desk_sink" }
|
||||||
|
] }
|
||||||
|
```
|
||||||
|
|
||||||
|
A `do` that fails logs, keeps going, and its own `undo` is skipped (it never took effect).
|
||||||
|
|
||||||
|
## The event stream (`GET /api/v1/events`)
|
||||||
|
|
||||||
|
For code, subscribe to the SSE stream on the management API (loopback + bearer token — the
|
||||||
|
same credentials as the rest of the admin surface):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -Nk -H "Authorization: Bearer $(cat ~/.config/punktfunk/mgmt-token)" \
|
||||||
|
"https://127.0.0.1:47990/api/v1/events?kinds=pairing.*,stream.*"
|
||||||
|
```
|
||||||
|
|
||||||
|
- Frames carry `id:` (the event's `seq`), `event:` (the kind), `data:` (the event JSON).
|
||||||
|
- Reconnect with the standard `Last-Event-ID` header (or `?since=<seq>`) and the host replays
|
||||||
|
what you missed from its in-memory ring (~1024 events); if you fell off the ring you get one
|
||||||
|
`event: dropped` frame first — resync from the REST snapshots (`/status`, `/clients`, …).
|
||||||
|
- `?kinds=` filters server-side: exact kinds or `domain.*` prefixes, comma-separated.
|
||||||
|
|
||||||
|
## Scripts, plugins, and the runner
|
||||||
|
|
||||||
|
For anything beyond a `curl` one-liner there is **`@punktfunk/host`** — the TypeScript SDK
|
||||||
|
(`sdk/` in the repo): typed events with automatic reconnect/resume, the REST surface, and a
|
||||||
|
plugin convention (`punktfunk-plugin-*`). Its **runner** (`punktfunk-scripting`) supervises a
|
||||||
|
directory of scripts and installed plugins as one service: crash-restarts with backoff, and a
|
||||||
|
`systemctl stop` that interrupts plugins structurally so their cleanup runs. See the SDK README
|
||||||
|
for the five-line quickstart and unit templates.
|
||||||
|
|
||||||
|
The canonical "decide, don't just observe" pattern — approve pairing from your phone: watch
|
||||||
|
`pairing.pending`, send yourself a notification, and call
|
||||||
|
`POST /api/v1/native/pending/{id}/approve` when you tap yes. The full API is documented at
|
||||||
|
[`/api/docs`](/api) on your host.
|
||||||
@@ -80,6 +80,8 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
|
|||||||
| Setting | Values | Meaning |
|
| Setting | Values | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `PUNKTFUNK_RECOVER_SESSION_CMD` | command | Operator hook fired (debounced) when a client connects while **no graphical session is live** for the host's user — the state a compositor crash leaves behind (gnome-shell SIGSEGV → GDM greeter, whose auto-login is once-per-boot). Typically `sudo -n systemctl restart gdm` with a matching NOPASSWD sudoers rule, or `systemctl restart display-manager` under a polkit rule; with auto-login enabled the restart brings the desktop back and the client's automatic retry lands in it. Unset/empty = disabled (the default). |
|
| `PUNKTFUNK_RECOVER_SESSION_CMD` | command | Operator hook fired (debounced) when a client connects while **no graphical session is live** for the host's user — the state a compositor crash leaves behind (gnome-shell SIGSEGV → GDM greeter, whose auto-login is once-per-boot). Typically `sudo -n systemctl restart gdm` with a matching NOPASSWD sudoers rule, or `systemctl restart display-manager` under a polkit rule; with auto-login enabled the restart brings the desktop back and the client's automatic retry lands in it. Unset/empty = disabled (the default). |
|
||||||
|
| `PUNKTFUNK_ON_CONNECT_CMD` | command | Fired (detached) when a client connects, on either plane — the event JSON on stdin plus `PF_EVENT_*` env vars. The zero-config little sibling of [hooks.json](/docs/automation), which adds filters, webhooks, and debounce. |
|
||||||
|
| `PUNKTFUNK_ON_DISCONNECT_CMD` | command | The `client.disconnected` counterpart of `PUNKTFUNK_ON_CONNECT_CMD` (its `PF_EVENT_REASON` is `quit`, `timeout`, or `error`). |
|
||||||
|
|
||||||
## Video quality
|
## Video quality
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"virtual-displays",
|
"virtual-displays",
|
||||||
"pyrowave",
|
"pyrowave",
|
||||||
"host-cli",
|
"host-cli",
|
||||||
|
"automation",
|
||||||
"---Connecting---",
|
"---Connecting---",
|
||||||
"clients",
|
"clients",
|
||||||
"install-client",
|
"install-client",
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
let
|
let
|
||||||
pkgs = pkgsFor system;
|
pkgs = pkgsFor system;
|
||||||
in
|
in
|
||||||
pkgs.callPackage ./nix/packages.nix {
|
pkgs.callPackage ./packaging/nix/packages.nix {
|
||||||
craneLib = craneLibFor pkgs;
|
craneLib = craneLibFor pkgs;
|
||||||
src = self;
|
src = self;
|
||||||
inherit version;
|
inherit version;
|
||||||
@@ -128,10 +128,10 @@
|
|||||||
|
|
||||||
formatter = forAllSystems (system: (pkgsFor system).nixfmt-rfc-style);
|
formatter = forAllSystems (system: (pkgsFor system).nixfmt-rfc-style);
|
||||||
|
|
||||||
# NixOS integration — see nix/nixos-module.nix and nix/README.md.
|
# NixOS integration — see packaging/nix/nixos-module.nix and packaging/nix/README.md.
|
||||||
# imports = [ punktfunk.nixosModules.default ];
|
# imports = [ punktfunk.nixosModules.default ];
|
||||||
# services.punktfunk.host.enable = true;
|
# services.punktfunk.host.enable = true;
|
||||||
nixosModules.default = import ./nix/nixos-module.nix self;
|
nixosModules.default = import ./packaging/nix/nixos-module.nix self;
|
||||||
nixosModules.punktfunk = self.nixosModules.default;
|
nixosModules.punktfunk = self.nixosModules.default;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -27,7 +27,7 @@ The other packaging targets have their own READMEs: [`debian/`](debian/README.md
|
|||||||
[`flatpak/`](flatpak/README.md) (the client), [`windows/`](windows/README.md) (host installer +
|
[`flatpak/`](flatpak/README.md) (the client), [`windows/`](windows/README.md) (host installer +
|
||||||
drivers), plus `kde/` and `linux/` helpers. **NixOS / Nix** users get a flake (`flake.nix` at the
|
drivers), plus `kde/` and `linux/` helpers. **NixOS / Nix** users get a flake (`flake.nix` at the
|
||||||
repo root) with reproducible host + client packages and a `services.punktfunk` NixOS module —
|
repo root) with reproducible host + client packages and a `services.punktfunk` NixOS module —
|
||||||
see [`../nix/README.md`](../nix/README.md).
|
see [`nix/README.md`](nix/README.md).
|
||||||
|
|
||||||
## What's needed beyond base Fedora
|
## What's needed beyond base Fedora
|
||||||
|
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ in
|
|||||||
# build needs the full gn/ninja/python toolchain + network-fetched third-party. The `ui` feature
|
# build needs the full gn/ninja/python toolchain + network-fetched third-party. The `ui` feature
|
||||||
# is explicitly droppable (clients/session/Cargo.toml: "same streaming, stats on stdout only"),
|
# is explicitly droppable (clients/session/Cargo.toml: "same streaming, stats on stdout only"),
|
||||||
# so build the session without it. The GTK shell (punktfunk-client-linux) is skia-free and full.
|
# so build the session without it. The GTK shell (punktfunk-client-linux) is skia-free and full.
|
||||||
# Re-adding the Skia OSD under Nix is tracked in nix/README.md.
|
# Re-adding the Skia OSD under Nix is tracked in packaging/nix/README.md.
|
||||||
cargoExtraArgs =
|
cargoExtraArgs =
|
||||||
"--locked -p punktfunk-client-linux -p punktfunk-client-session "
|
"--locked -p punktfunk-client-linux -p punktfunk-client-session "
|
||||||
+ "--no-default-features --features punktfunk-client-session/pyrowave";
|
+ "--no-default-features --features punktfunk-client-session/pyrowave";
|
||||||
+172
@@ -0,0 +1,172 @@
|
|||||||
|
# @punktfunk/host
|
||||||
|
|
||||||
|
TypeScript SDK for the [punktfunk](https://git.unom.io/unom/punktfunk) streaming host: a typed
|
||||||
|
management-API client plus the host's lifecycle **event stream** (client connect/disconnect,
|
||||||
|
stream start/stop, pairing, displays, library) — built on [Effect](https://effect.website).
|
||||||
|
|
||||||
|
Two surfaces, one core:
|
||||||
|
|
||||||
|
- **`@punktfunk/host`** — the Promise facade, the front door. `connect()`, `await`, `.on()`.
|
||||||
|
You never need to know Effect exists.
|
||||||
|
- **`@punktfunk/host/effect`** — the Effect-native surface for plugins and composed programs:
|
||||||
|
the `PunktfunkHost` service + layer, `Stream`-based events, typed errors
|
||||||
|
(`AuthError | ApiError | TransportError | VersionSkew`), and every wire shape as an
|
||||||
|
`effect/Schema` (REST shapes generated from the host's OpenAPI spec; event shapes mirroring
|
||||||
|
the host's snapshot-tested wire format).
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { connect } from "@punktfunk/host";
|
||||||
|
|
||||||
|
const pf = await connect(); // zero config on the host box
|
||||||
|
|
||||||
|
pf.events.on("stream.started", (e) => {
|
||||||
|
console.log(`${e.stream.client} started ${e.stream.mode}${e.stream.hdr ? " HDR" : ""}`);
|
||||||
|
});
|
||||||
|
pf.events.on("pairing.pending", async (e) => {
|
||||||
|
// notify your phone, then decide through the API:
|
||||||
|
// await pf.request("POST", `/native/pending/${id}/approve`);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The same, Effect-native:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Effect, Stream } from "effect";
|
||||||
|
import { events, PunktfunkHostLive } from "@punktfunk/host/effect";
|
||||||
|
|
||||||
|
const program = events().pipe(
|
||||||
|
Stream.filter((e) => e.kind === "stream.started"),
|
||||||
|
Stream.runForEach((e) => Effect.log(`stream: ${e.stream.mode}`)),
|
||||||
|
);
|
||||||
|
Effect.runPromise(program.pipe(Effect.provide(PunktfunkHostLive())));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Connection resolution
|
||||||
|
|
||||||
|
`connect()` / `PunktfunkHostLive()` resolve, in order:
|
||||||
|
|
||||||
|
| What | Source |
|
||||||
|
|---|---|
|
||||||
|
| URL | `{ url }` → `PUNKTFUNK_MGMT_URL` → `https://127.0.0.1:47990` |
|
||||||
|
| Token | `{ token }` → `PUNKTFUNK_MGMT_TOKEN` → `<config_dir>/mgmt-token` |
|
||||||
|
| TLS pin | `{ ca }` → `PUNKTFUNK_MGMT_CA` (path) → `<config_dir>/cert.pem` |
|
||||||
|
|
||||||
|
`<config_dir>` is `~/.config/punktfunk` (Linux/macOS) or `%ProgramData%\punktfunk` (Windows) —
|
||||||
|
so a script running on the host box needs **zero configuration**. The TLS pin trusts exactly
|
||||||
|
the host's self-signed identity cert (chain-verified; the hostname check is waived — the cert
|
||||||
|
is deliberately CN-only, native clients pin its fingerprint). Bun and Node are first-class;
|
||||||
|
other runtimes fall back to system trust (point your runtime's CA option at `cert.pem`).
|
||||||
|
|
||||||
|
The bearer token is the host's **admin** credential and is honored from loopback only — run
|
||||||
|
scripts on the host box (or through an SSH tunnel).
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
- Reconnects automatically (exponential backoff + jitter, capped) and resumes with
|
||||||
|
`Last-Event-ID` — the host replays what you missed from its ring.
|
||||||
|
- Default is **live tail only** (a fresh notify script must not re-fire on history);
|
||||||
|
pass `{ since: 0 }` on the Effect surface to replay the host's full ring, or `since: N`
|
||||||
|
to resume after a seq you persisted.
|
||||||
|
- `on()` patterns: exact kinds (`"stream.started"`, typed callback), `"domain.*"` prefixes,
|
||||||
|
`"*"`, plus `"dropped"` (your cursor fell off the ring — resync via REST) and `"unknown"`
|
||||||
|
(an event kind newer than this SDK — the additive-only wire at work).
|
||||||
|
- Effect surface: `events()` is a `Stream<HostEvent, EventStreamError>`; `eventsRaw()` carries
|
||||||
|
every SSE frame verbatim.
|
||||||
|
|
||||||
|
## Plugins (`punktfunk-plugin-*`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { definePlugin } from "@punktfunk/host";
|
||||||
|
import { Effect } from "effect";
|
||||||
|
import { PunktfunkHost } from "@punktfunk/host/effect";
|
||||||
|
|
||||||
|
export default definePlugin({
|
||||||
|
name: "romm-library",
|
||||||
|
main: Effect.gen(function* () {
|
||||||
|
const pf = yield* PunktfunkHost;
|
||||||
|
// subscribe, sync, reconcile — scoped finalizers run on shutdown/interruption
|
||||||
|
}),
|
||||||
|
// …or the simple shape: main: async (pf) => { … }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
In v1 a plugin is a script you run (see below); the managed runner package is a later step.
|
||||||
|
|
||||||
|
## The runner: `punktfunk-scripting`
|
||||||
|
|
||||||
|
Instead of one unit file per script, run everything under the managed runner — it discovers
|
||||||
|
your units and supervises them:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun src/runner-cli.ts # runs <config_dir>/scripts/* + installed punktfunk-plugin-*
|
||||||
|
bun src/runner-cli.ts --list # show what it found
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Plugins** (a `definePlugin` default export, from the scripts dir or a
|
||||||
|
`punktfunk-plugin-*` package installed under `<config_dir>/plugins/`): supervised — a crash
|
||||||
|
restarts them with capped exponential backoff; a clean return completes them.
|
||||||
|
- **Bare scripts**: importing them is the run — one-shot, no restart (export a plugin to be
|
||||||
|
supervised).
|
||||||
|
- **Shutdown is structural**: SIGINT/SIGTERM interrupt every unit's fiber — Effect plugins'
|
||||||
|
scoped finalizers run (release the preset, deregister cleanly) and facade clients close
|
||||||
|
before the process exits. This is what makes `systemctl stop` clean.
|
||||||
|
- The sshd rule applies: a group/world-writable unit file is refused loudly.
|
||||||
|
|
||||||
|
systemd user unit for the runner (`~/.config/systemd/user/punktfunk-scripting.service`):
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=punktfunk script/plugin runner
|
||||||
|
After=punktfunk-host.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/bin/bun /path/to/sdk/src/runner-cli.ts
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
# SIGTERM (the default KillSignal) triggers the runner's structured shutdown.
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running a single script as a service
|
||||||
|
|
||||||
|
systemd user unit (`~/.config/systemd/user/punktfunk-myscript.service`):
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=punktfunk automation: myscript
|
||||||
|
After=punktfunk-host.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/bin/bun /home/me/punktfunk-scripts/myscript.ts
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows Task Scheduler: a task triggered *At log on* running
|
||||||
|
`bun C:\Users\me\punktfunk-scripts\myscript.ts` (the SDK reads
|
||||||
|
`%ProgramData%\punktfunk\mgmt-token` — run the task as an account that can).
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
- SDK **majors** track the management-API major; an event `schema` bump or an `effect` major
|
||||||
|
is an SDK major too.
|
||||||
|
- The wire is **additive-only** within a major: an older SDK keeps working against a newer
|
||||||
|
host (unknown response keys are ignored; unknown event kinds ride the `"unknown"` channel).
|
||||||
|
- A 2xx response that doesn't match its schema surfaces as `VersionSkew` on the Effect
|
||||||
|
surface — a typed nudge to update, not an `undefined` three frames later.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun install
|
||||||
|
bun run gen # regenerate src/gen/schemas.ts from ../api/openapi.json
|
||||||
|
bun run typecheck
|
||||||
|
bun test
|
||||||
|
```
|
||||||
+303
@@ -0,0 +1,303 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "@punktfunk/host",
|
||||||
|
"dependencies": {
|
||||||
|
"effect": "^3.19.0",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "^1.3.0",
|
||||||
|
"orval": "^8.20.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"undici": "^7.0.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@commander-js/extra-typings": ["@commander-js/extra-typings@15.0.0", "", { "peerDependencies": { "commander": "~15.0.0" } }, "sha512-yeJlba62xqmkgELUsn7356MEnzLLu/fw2x4lofFqGnXh6YysRdEs2BaLeLtg1+KU0AXvMeqQvTTp+3hBEBK+EA=="],
|
||||||
|
|
||||||
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
|
||||||
|
|
||||||
|
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
|
||||||
|
|
||||||
|
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
|
||||||
|
|
||||||
|
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
|
||||||
|
|
||||||
|
"@gerrit0/mini-shiki": ["@gerrit0/mini-shiki@3.23.0", "", { "dependencies": { "@shikijs/engine-oniguruma": "^3.23.0", "@shikijs/langs": "^3.23.0", "@shikijs/themes": "^3.23.0", "@shikijs/types": "^3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg=="],
|
||||||
|
|
||||||
|
"@orval/angular": ["@orval/angular@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0" } }, "sha512-lSvaNj+VHGIWBQOG104HfPNrk2xGjpArwy67u6ZBPzHo/OtDE5Tm1t+ARYseCOElFr4z3i3A62qjFLFy6zj00Q=="],
|
||||||
|
|
||||||
|
"@orval/axios": ["@orval/axios@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0" } }, "sha512-fFu0UgTbpI9a1ayM7qCs55MMy6MlVgOpE3BBXGukCTBSwNwFLN47WdzQev/x0mAcs58pNKYT0KL4R9MXGnmgfg=="],
|
||||||
|
|
||||||
|
"@orval/core": ["@orval/core@8.22.0", "", { "dependencies": { "@scalar/openapi-types": "0.8.0", "acorn": "^8.15.0", "compare-versions": "^6.1.1", "debug": "^4.4.3", "esbuild": "^0.28.0", "esutils": "2.0.3", "fs-extra": "^11.3.2", "jiti": "^2.6.1", "jsesc": "^3.0.0", "remeda": "^2.33.6", "tinyglobby": "^0.2.16", "typedoc": "^0.28.19" }, "peerDependencies": { "@faker-js/faker": ">=10" }, "optionalPeers": ["@faker-js/faker"] }, "sha512-uKYi7+Smg6oQ2MxE0AS2FNI7bwssoxGLh391Uk0FU+DcTcSv9q2GmvoM8uwd827Hok29kD7AegHE8Mhmsa5uWg=="],
|
||||||
|
|
||||||
|
"@orval/effect": ["@orval/effect@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0", "remeda": "^2.33.6" } }, "sha512-DACiw2+0ZsPJPKQbYlb9qFFFppaCBaT/HgIq2S1asH9ZCOOTZKm9VrRXpeCWINHC2w1BpdSATpytv+61UT5Y/A=="],
|
||||||
|
|
||||||
|
"@orval/fetch": ["@orval/fetch@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0" } }, "sha512-G0r6hOdZG963H/8S4Vk1kWfU/hkQC/cwpDZL+mzcXgzrdG9NDloshTqmge/jP5lPsFAwfcRUmmts10OmuIE4BQ=="],
|
||||||
|
|
||||||
|
"@orval/hono": ["@orval/hono@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0", "@orval/zod": "8.22.0", "fs-extra": "^11.3.2" }, "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-GMCpGZqCuGYSu10KTt2q3WYzCqEcTGxr18z3HgHv9dX22sv/V76dlLBh3SE72xp8Z7/jmoq3GKBdgU9k98ju0w=="],
|
||||||
|
|
||||||
|
"@orval/mcp": ["@orval/mcp@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0", "@orval/fetch": "8.22.0", "@orval/zod": "8.22.0" } }, "sha512-ySa4EenAF8YMCpbmddJGO3lItTPC8Uf/iT+P2ezaowVgxOCPX/fjG7dd0fnVmrr0vaphi53yWx5o5DlT17FzLg=="],
|
||||||
|
|
||||||
|
"@orval/mock": ["@orval/mock@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0", "remeda": "^2.33.6" } }, "sha512-ZCEFpdi4z+YOCg0Tsgz6DO/M1TSfYxE5urDWJgYNUKeD8XDEHFb2IgTiiaAwJunqWnRQuiMfGp9G81+u10Kx6w=="],
|
||||||
|
|
||||||
|
"@orval/query": ["@orval/query@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0", "@orval/fetch": "8.22.0", "remeda": "^2.33.6" } }, "sha512-IH9049z860CLUeGEezGv+yOvUvcAyDnd9doDe1Ryi0WxsDul2Vh3LjCRUEQf4JZiyZezadWYYGzs0lwnCn/afA=="],
|
||||||
|
|
||||||
|
"@orval/solid-start": ["@orval/solid-start@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0" } }, "sha512-Q1ZCWOrA6/VJnyj62XpPxXti9NpAA4lDZVqPL2uQ5gbxv+T+azEaazKqYIpSBJXbl1aEezujdcyg/DVUakWKEw=="],
|
||||||
|
|
||||||
|
"@orval/swr": ["@orval/swr@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0", "@orval/fetch": "8.22.0" } }, "sha512-OSeWX3Af8ESapKIo3XpbOaTMaG8oeEtuucFyOUauqg6NyC6/9Ikcf2csBdF0AuIKA0QTcjcXxra/ccj1f7KnRg=="],
|
||||||
|
|
||||||
|
"@orval/zod": ["@orval/zod@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0", "jsesc": "^3.0.0", "remeda": "^2.33.6" } }, "sha512-briHtUTz79fvCeIFfGW3IbmUIF9DOotUoWFa/sKUjRUG8PMGDWSjQzO8QdLzLoETVGo9g4TfGwp5Xjcs81GZTA=="],
|
||||||
|
|
||||||
|
"@scalar/helpers": ["@scalar/helpers@0.9.1", "", {}, "sha512-UKSLIPfN++f+zzbsZ+F6I0lNrR4yX4PtokjHgGwGiHapxT5VJqAF4zFTIP2KCd27XG7KnAIA7qq62O4xRBxc6w=="],
|
||||||
|
|
||||||
|
"@scalar/json-magic": ["@scalar/json-magic@0.12.18", "", { "dependencies": { "@scalar/helpers": "0.9.1", "pathe": "^2.0.3", "yaml": "^2.8.3" } }, "sha512-3oZr+jUUiwD3C+x2CROBtlyIUnwt9ScRhFfczuOTPZlD7Pnb5cyTTfkiHbxxU4qd66ij0Z3JA4X/Nj0OADyEiA=="],
|
||||||
|
|
||||||
|
"@scalar/openapi-parser": ["@scalar/openapi-parser@0.28.9", "", { "dependencies": { "@scalar/helpers": "0.9.1", "@scalar/json-magic": "0.12.18", "@scalar/openapi-types": "0.9.2", "@scalar/openapi-upgrader": "0.2.10", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-formats": "^3.0.1", "jsonpointer": "^5.0.1", "leven": "^4.0.0", "yaml": "^2.8.3" } }, "sha512-AYmqO7dR6zc1TSVhW5BpwY0nUMfG5JuF85/8YXn5ZXcCvjgoJu8dbxX52vIGK9rRwSn07HXCdKk7KPIF4xfOTA=="],
|
||||||
|
|
||||||
|
"@scalar/openapi-types": ["@scalar/openapi-types@0.8.0", "", {}, "sha512-WmaxVSfvY5K/TwcG2B2TU1WOe1As1uc2s7myswtP6dBlcjU3hM08SApxv/jmyGaCE8t4gO5BBhmHY4pDUfmr2g=="],
|
||||||
|
|
||||||
|
"@scalar/openapi-upgrader": ["@scalar/openapi-upgrader@0.2.10", "", { "dependencies": { "@scalar/openapi-types": "0.9.2" } }, "sha512-FRYwCl4IXRi7yAF5/3Jho0jc2akTZfr/vRTYMjhm+96Fq0LaEuHJAvPcSkkU+j3IWGie6LaCQMZTngcSjoIzgA=="],
|
||||||
|
|
||||||
|
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
|
||||||
|
|
||||||
|
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="],
|
||||||
|
|
||||||
|
"@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="],
|
||||||
|
|
||||||
|
"@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="],
|
||||||
|
|
||||||
|
"@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="],
|
||||||
|
|
||||||
|
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
|
||||||
|
|
||||||
|
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
|
||||||
|
|
||||||
|
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
|
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||||
|
|
||||||
|
"@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
|
||||||
|
|
||||||
|
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||||
|
|
||||||
|
"acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
|
||||||
|
|
||||||
|
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
||||||
|
|
||||||
|
"ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="],
|
||||||
|
|
||||||
|
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||||
|
|
||||||
|
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||||
|
|
||||||
|
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||||
|
|
||||||
|
"brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||||
|
|
||||||
|
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||||
|
|
||||||
|
"commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="],
|
||||||
|
|
||||||
|
"compare-versions": ["compare-versions@6.1.1", "", {}, "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg=="],
|
||||||
|
|
||||||
|
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||||
|
|
||||||
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
|
"effect": ["effect@3.22.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-jhYFe0zTlIRqYFrKTS+6luhmS/Tm0f+JLo0K9KUxvtFab1SUGEszQi2ehOP6QzAZvy831lDmTwwzvVDZSPNz3g=="],
|
||||||
|
|
||||||
|
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||||
|
|
||||||
|
"esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
|
||||||
|
|
||||||
|
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||||
|
|
||||||
|
"execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
|
||||||
|
|
||||||
|
"fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
|
||||||
|
|
||||||
|
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||||
|
|
||||||
|
"fast-uri": ["fast-uri@3.1.3", "", {}, "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg=="],
|
||||||
|
|
||||||
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
|
"figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
|
||||||
|
|
||||||
|
"find-up": ["find-up@8.0.0", "", { "dependencies": { "locate-path": "^8.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-JGG8pvDi2C+JxidYdIwQDyS/CgcrIdh18cvgxcBge3wSHRQOrooMD3GlFBcmMJAN9M42SAZjDp5zv1dglJjwww=="],
|
||||||
|
|
||||||
|
"fs-extra": ["fs-extra@11.3.6", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA=="],
|
||||||
|
|
||||||
|
"get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="],
|
||||||
|
|
||||||
|
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
|
||||||
|
|
||||||
|
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||||
|
|
||||||
|
"human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
|
||||||
|
|
||||||
|
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||||
|
|
||||||
|
"is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
|
||||||
|
|
||||||
|
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
|
||||||
|
|
||||||
|
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||||
|
|
||||||
|
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||||
|
|
||||||
|
"js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="],
|
||||||
|
|
||||||
|
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||||
|
|
||||||
|
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||||
|
|
||||||
|
"jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||||
|
|
||||||
|
"jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="],
|
||||||
|
|
||||||
|
"leven": ["leven@4.1.0", "", {}, "sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew=="],
|
||||||
|
|
||||||
|
"linkify-it": ["linkify-it@5.0.2", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q=="],
|
||||||
|
|
||||||
|
"locate-path": ["locate-path@8.0.0", "", { "dependencies": { "p-locate": "^6.0.0" } }, "sha512-XT9ewWAC43tiAV7xDAPflMkG0qOPn2QjHqlgX8FOqmWa/rxnyYDulF9T0F7tRy1u+TVTmK/M//6VIOye+2zDXg=="],
|
||||||
|
|
||||||
|
"lunr": ["lunr@2.3.9", "", {}, "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow=="],
|
||||||
|
|
||||||
|
"markdown-it": ["markdown-it@14.3.0", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.5.0", "linkify-it": "^5.0.2", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw=="],
|
||||||
|
|
||||||
|
"mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="],
|
||||||
|
|
||||||
|
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||||
|
|
||||||
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
|
||||||
|
|
||||||
|
"orval": ["orval@8.22.0", "", { "dependencies": { "@commander-js/extra-typings": "^15.0.0", "@orval/angular": "8.22.0", "@orval/axios": "8.22.0", "@orval/core": "8.22.0", "@orval/effect": "8.22.0", "@orval/fetch": "8.22.0", "@orval/hono": "8.22.0", "@orval/mcp": "8.22.0", "@orval/mock": "8.22.0", "@orval/query": "8.22.0", "@orval/solid-start": "8.22.0", "@orval/swr": "8.22.0", "@orval/zod": "8.22.0", "@scalar/json-magic": "^0.12.16", "@scalar/openapi-parser": "^0.28.7", "@scalar/openapi-types": "0.8.0", "chokidar": "^5.0.0", "commander": "^15.0.0", "execa": "^9.6.1", "find-up": "8.0.0", "fs-extra": "^11.3.2", "get-tsconfig": "^4.14.0", "jiti": "^2.6.1", "js-yaml": "4.2.0", "remeda": "^2.33.6", "string-argv": "^0.3.2", "typedoc": "^0.28.19", "typedoc-plugin-coverage": "^4.0.2", "typedoc-plugin-markdown": "^4.10.0" }, "peerDependencies": { "prettier": ">=3.0.0" }, "optionalPeers": ["prettier"], "bin": { "orval": "dist/bin/orval.mjs" } }, "sha512-N8UmB4DOhW+Z2SzVq4oS/pN3DsRPq+msrRU8NyRldP1i0yiqT6qo8mUxHPk2umqYjyY0FlR2mvPbi/Jf5CiP7A=="],
|
||||||
|
|
||||||
|
"p-limit": ["p-limit@4.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ=="],
|
||||||
|
|
||||||
|
"p-locate": ["p-locate@6.0.0", "", { "dependencies": { "p-limit": "^4.0.0" } }, "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw=="],
|
||||||
|
|
||||||
|
"parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="],
|
||||||
|
|
||||||
|
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||||
|
|
||||||
|
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||||
|
|
||||||
|
"picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
|
||||||
|
|
||||||
|
"pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
|
||||||
|
|
||||||
|
"punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="],
|
||||||
|
|
||||||
|
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
|
||||||
|
|
||||||
|
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
||||||
|
|
||||||
|
"remeda": ["remeda@2.39.0", "", {}, "sha512-3Ki8dU1o3OVu4dwIQ2Pj+yiuP7OnEbmWAGmJ3yDRqopily5jsj8NWzPvbS89H85d6UdONKEcUnrfuHY6jN9vyw=="],
|
||||||
|
|
||||||
|
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||||
|
|
||||||
|
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||||
|
|
||||||
|
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||||
|
|
||||||
|
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||||
|
|
||||||
|
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||||
|
|
||||||
|
"string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
|
||||||
|
|
||||||
|
"strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
|
||||||
|
|
||||||
|
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||||
|
|
||||||
|
"typedoc": ["typedoc@0.28.20", "", { "dependencies": { "@gerrit0/mini-shiki": "^3.23.0", "lunr": "^2.3.9", "markdown-it": "^14.3.0", "minimatch": "^10.2.5", "yaml": "^2.9.0" }, "peerDependencies": { "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x" }, "bin": { "typedoc": "bin/typedoc" } }, "sha512-uSKqkh8Cr48vllnEy+jdaAgOeR6Y+QCBW7usgUsKj7gJEfR7stw9U/fE49LBnj2tPRKPY0c0EBJSWe9Appmplg=="],
|
||||||
|
|
||||||
|
"typedoc-plugin-coverage": ["typedoc-plugin-coverage@4.0.3", "", { "peerDependencies": { "typedoc": "0.28.x" } }, "sha512-baim3wyMkqpX7rBzL/6iZ7wzKJuSr9ffP16RHOsdTUNoHUZeXLIZHSUBtUhXmNHaUNRgfqdmKLBwyggbJjGdeQ=="],
|
||||||
|
|
||||||
|
"typedoc-plugin-markdown": ["typedoc-plugin-markdown@4.12.0", "", { "peerDependencies": { "typedoc": "0.28.x" } }, "sha512-eJDEMAfxCmede22c/Jw7d0FA13ggAQv+KkwQYKYCdqI02cin6Rc9QRwbG/7XvvHWinuFejySnZVUWDtvGk3Vbg=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
|
"uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="],
|
||||||
|
|
||||||
|
"undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
||||||
|
|
||||||
|
"unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
|
||||||
|
|
||||||
|
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
||||||
|
|
||||||
|
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
|
|
||||||
|
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
|
||||||
|
|
||||||
|
"yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="],
|
||||||
|
|
||||||
|
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
|
||||||
|
|
||||||
|
"@scalar/openapi-parser/@scalar/openapi-types": ["@scalar/openapi-types@0.9.2", "", {}, "sha512-J0SZkNPgCrsFN0Rv8sRqZpDOQcRV44TCT82LsQcyWmcglTr7qtXukDmMlIyXs7JDd+DoPLbT2ea65bEHRs2W2g=="],
|
||||||
|
|
||||||
|
"@scalar/openapi-upgrader/@scalar/openapi-types": ["@scalar/openapi-types@0.9.2", "", {}, "sha512-J0SZkNPgCrsFN0Rv8sRqZpDOQcRV44TCT82LsQcyWmcglTr7qtXukDmMlIyXs7JDd+DoPLbT2ea65bEHRs2W2g=="],
|
||||||
|
|
||||||
|
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// The RFC §7 quickstart, Effect-native: when the living-room TV starts a stream, apply a
|
||||||
|
// display preset — a composed, typed, interruptible program.
|
||||||
|
import { Effect, Stream } from "effect";
|
||||||
|
import { events, PunktfunkHost, PunktfunkHostLive } from "../src/effect.js";
|
||||||
|
|
||||||
|
const program = Effect.gen(function* () {
|
||||||
|
const pf = yield* PunktfunkHost;
|
||||||
|
yield* events().pipe(
|
||||||
|
Stream.filter(
|
||||||
|
(e) => e.kind === "stream.started" && e.stream.client === "Living Room TV",
|
||||||
|
),
|
||||||
|
Stream.runForEach(() =>
|
||||||
|
pf
|
||||||
|
.request("PUT", "/display/settings", { mode: "preset", preset: "couch" })
|
||||||
|
.pipe(Effect.catchAll((e) => Effect.logWarning(`preset failed: ${e}`))),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Effect.runPromise(program.pipe(Effect.provide(PunktfunkHostLive()))).catch(console.error);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// The flagship hook-with-a-decision pattern (Promise facade): watch pairing requests, notify,
|
||||||
|
// and approve/deny THROUGH the API — asynchronously, the punktfunk way (hooks never veto).
|
||||||
|
import { connect } from "../src/index.js";
|
||||||
|
|
||||||
|
const pf = await connect();
|
||||||
|
console.log("watching for pairing requests…");
|
||||||
|
|
||||||
|
pf.events.on("pairing.pending", async (e) => {
|
||||||
|
console.log(`pairing request: ${e.device.name} (${e.device.fingerprint})`);
|
||||||
|
// Wire your real notifier here (ntfy, Pushover, Home Assistant, …), then decide:
|
||||||
|
// const pending = await pf.request("GET", "/native/pending") as { id: number }[];
|
||||||
|
// await pf.request("POST", `/native/pending/${pending[0].id}/approve`);
|
||||||
|
});
|
||||||
|
pf.events.on("pairing.completed", (e) => console.log(`paired: ${e.device.name}`));
|
||||||
|
pf.events.on("pairing.denied", (e) => console.log(`denied: ${e.device.name}`));
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// The external game-library provider pattern (RFC §8): compute your desired title list and
|
||||||
|
// declaratively PUT it — the host diffs by your `external_id`, keeps host ids stable across
|
||||||
|
// syncs, drops orphans, and never touches manual entries. Uninstall = one DELETE.
|
||||||
|
import { connect } from "../src/index.js";
|
||||||
|
|
||||||
|
const PROVIDER = "romm"; // your punktfunk-plugin-* name
|
||||||
|
|
||||||
|
const pf = await connect();
|
||||||
|
pf.events.on("library.changed", (e) => {
|
||||||
|
if (e.source === PROVIDER) console.log("library synced");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch your source of truth (a ROM manager, itch.io, a curated list…), then reconcile:
|
||||||
|
const desired = [
|
||||||
|
{ external_id: "rom-1", title: "Chrono Trigger", launch: { command: "retroarch ..." } },
|
||||||
|
{ external_id: "rom-2", title: "Super Metroid", launch: { command: "retroarch ..." } },
|
||||||
|
];
|
||||||
|
const entries = (await pf.request("PUT", `/library/provider/${PROVIDER}`, desired)) as {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
}[];
|
||||||
|
console.log(`synced ${entries.length} titles:`, entries.map((e) => `${e.title} (custom:${e.id})`));
|
||||||
|
|
||||||
|
// …run on a schedule, or keep watching your source. Clean uninstall:
|
||||||
|
// await pf.request("DELETE", `/library/provider/${PROVIDER}`);
|
||||||
|
pf.close();
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// Tail the host's lifecycle events — the SDK "hello world" (Promise facade).
|
||||||
|
//
|
||||||
|
// bun examples/tail-events.ts (on the host box: zero config)
|
||||||
|
// PUNKTFUNK_MGMT_URL=… PUNKTFUNK_MGMT_TOKEN=… bun examples/tail-events.ts
|
||||||
|
import { connect } from "../src/index.js";
|
||||||
|
|
||||||
|
const pf = await connect();
|
||||||
|
const host = (await pf.request("GET", "/host")) as { hostname?: string };
|
||||||
|
console.log(`connected to ${host.hostname ?? "host"} — tailing events (^C to stop)`);
|
||||||
|
|
||||||
|
pf.events.on("*", (e) => console.log(`[${e.seq}] ${e.kind}`, JSON.stringify(e)));
|
||||||
|
pf.events.on("unknown", (e) => console.log("[unknown kind]", JSON.stringify(e)));
|
||||||
|
pf.events.on("dropped", () => console.log("[cursor fell off the ring — resync]"));
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from "orval";
|
||||||
|
|
||||||
|
// Generates the SDK's Effect Schemas from the host's checked-in OpenAPI document — the same
|
||||||
|
// single source of truth the web console's react-query client is generated from (RFC §7: the
|
||||||
|
// chain is Rust structs (utoipa) → OpenAPI → Effect Schemas → inferred TS types). Regenerate
|
||||||
|
// after any management-API change: `bun run gen` (CI drift-tests the output).
|
||||||
|
export default defineConfig({
|
||||||
|
punktfunk: {
|
||||||
|
input: {
|
||||||
|
target: "../api/openapi.json",
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
mode: "single",
|
||||||
|
target: "./src/gen/schemas.ts",
|
||||||
|
client: "effect",
|
||||||
|
clean: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "@punktfunk/host",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "TypeScript SDK for the punktfunk streaming host: typed management-API client + lifecycle event stream, built on Effect.",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./effect": "./src/effect.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"gen": "orval --config ./orval.config.ts",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "bun test",
|
||||||
|
"runner": "bun src/runner-cli.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"effect": "^3.19.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"orval": "^8.20.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"@types/bun": "^1.3.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"undici": "^7.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"punktfunk-scripting": "./src/runner-cli.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
// The Effect-native surface (RFC §7): the `PunktfunkHost` service — a typed management-API
|
||||||
|
// client plus the lifecycle-event `Stream` — provided by [`PunktfunkHostLive`]. Wire shapes
|
||||||
|
// are Effect Schemas (generated for REST in ./gen/schemas.ts, hand-mirrored for events in
|
||||||
|
// ./wire.ts); API responses are validated by default, so host/SDK version skew surfaces as a
|
||||||
|
// typed [`VersionSkew`] instead of an `undefined` three frames later.
|
||||||
|
import {
|
||||||
|
Context,
|
||||||
|
Data,
|
||||||
|
Effect,
|
||||||
|
Layer,
|
||||||
|
Option,
|
||||||
|
Schema as S,
|
||||||
|
Stream,
|
||||||
|
} from "effect";
|
||||||
|
import {
|
||||||
|
type ConnectOptions,
|
||||||
|
type ResolvedConfig,
|
||||||
|
resolveConfig,
|
||||||
|
} from "./config.js";
|
||||||
|
import { HttpStatusError, httpRequest } from "./core.js";
|
||||||
|
import {
|
||||||
|
type EventStreamOptions,
|
||||||
|
type SseFrame,
|
||||||
|
SseAuthError,
|
||||||
|
sseFrames,
|
||||||
|
} from "./sse.js";
|
||||||
|
import { decodeHostEvent, type HostEvent } from "./wire.js";
|
||||||
|
|
||||||
|
/** Bad credentials — the token (or paired cert) was rejected. */
|
||||||
|
export class AuthError extends Data.TaggedError("AuthError")<{
|
||||||
|
message: string;
|
||||||
|
}> {}
|
||||||
|
/** The host answered with a non-2xx (the message is its `ApiError` envelope). */
|
||||||
|
export class ApiError extends Data.TaggedError("ApiError")<{
|
||||||
|
status: number;
|
||||||
|
message: string;
|
||||||
|
}> {}
|
||||||
|
/** The request never completed (connection refused, TLS, abort). */
|
||||||
|
export class TransportError extends Data.TaggedError("TransportError")<{
|
||||||
|
cause: unknown;
|
||||||
|
}> {}
|
||||||
|
/** A 2xx body did not match its schema — host and SDK disagree on the wire shape. */
|
||||||
|
export class VersionSkew extends Data.TaggedError("VersionSkew")<{
|
||||||
|
path: string;
|
||||||
|
issue: string;
|
||||||
|
}> {}
|
||||||
|
/** The event stream failed unrecoverably (auth) — transient trouble self-heals via reconnect. */
|
||||||
|
export class EventStreamError extends Data.TaggedError("EventStreamError")<{
|
||||||
|
cause: unknown;
|
||||||
|
}> {}
|
||||||
|
|
||||||
|
export type RequestError = AuthError | ApiError | TransportError;
|
||||||
|
|
||||||
|
export interface PunktfunkHostService {
|
||||||
|
readonly config: ResolvedConfig;
|
||||||
|
/** One management-API request under `/api/v1`; the parsed JSON body. */
|
||||||
|
readonly request: (
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
) => Effect.Effect<unknown, RequestError>;
|
||||||
|
/** GET + schema-validate (the generated schemas from `@punktfunk/host/effect`'s `api`). */
|
||||||
|
readonly get: <A, I>(
|
||||||
|
path: string,
|
||||||
|
schema: S.Schema<A, I>,
|
||||||
|
) => Effect.Effect<A, RequestError | VersionSkew>;
|
||||||
|
/**
|
||||||
|
* The lifecycle-event stream: decoded [`HostEvent`]s with automatic reconnect +
|
||||||
|
* `Last-Event-ID` resume. Unknown kinds and the `dropped` marker surface on
|
||||||
|
* [`eventsRaw`] (and the warning callback), never as a failure here.
|
||||||
|
*/
|
||||||
|
readonly events: (
|
||||||
|
opts?: EventStreamOptions,
|
||||||
|
) => Stream.Stream<HostEvent, EventStreamError>;
|
||||||
|
/** Every SSE frame verbatim — the `dropped` marker and unknown kinds included. */
|
||||||
|
readonly eventsRaw: (
|
||||||
|
opts?: EventStreamOptions,
|
||||||
|
) => Stream.Stream<SseFrame, EventStreamError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PunktfunkHost extends Context.Tag("@punktfunk/host/PunktfunkHost")<
|
||||||
|
PunktfunkHost,
|
||||||
|
PunktfunkHostService
|
||||||
|
>() {}
|
||||||
|
|
||||||
|
const toRequestError = (path: string, cause: unknown): RequestError => {
|
||||||
|
if (cause instanceof HttpStatusError) {
|
||||||
|
return cause.status === 401
|
||||||
|
? new AuthError({ message: cause.message })
|
||||||
|
: new ApiError({ status: cause.status, message: cause.message });
|
||||||
|
}
|
||||||
|
return new TransportError({ cause });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const makeService = (cfg: ResolvedConfig): PunktfunkHostService => {
|
||||||
|
const request = (method: string, path: string, body?: unknown) =>
|
||||||
|
Effect.tryPromise({
|
||||||
|
try: () => httpRequest(cfg, method, path, body),
|
||||||
|
catch: (cause) => toRequestError(path, cause),
|
||||||
|
});
|
||||||
|
const get = <A, I>(path: string, schema: S.Schema<A, I>) =>
|
||||||
|
request("GET", path).pipe(
|
||||||
|
Effect.flatMap((body) =>
|
||||||
|
S.decodeUnknown(schema)(body).pipe(
|
||||||
|
Effect.mapError(
|
||||||
|
(e) => new VersionSkew({ path, issue: String(e) }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const eventsRaw = (opts?: EventStreamOptions) =>
|
||||||
|
// suspend: each run must get a FRESH generator (a generator is single-use).
|
||||||
|
Stream.suspend(() =>
|
||||||
|
Stream.fromAsyncIterable(
|
||||||
|
sseFrames(cfg, opts),
|
||||||
|
(cause) => new EventStreamError({ cause }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const events = (opts?: EventStreamOptions) => {
|
||||||
|
const warn =
|
||||||
|
opts?.onWarning ?? ((m: string) => console.warn(`[punktfunk] ${m}`));
|
||||||
|
return eventsRaw(opts).pipe(
|
||||||
|
Stream.filterMap((frame) => {
|
||||||
|
if (frame.event === "dropped") {
|
||||||
|
warn(
|
||||||
|
"event cursor fell off the host's ring — resync via the REST snapshots",
|
||||||
|
);
|
||||||
|
return Option.none();
|
||||||
|
}
|
||||||
|
let json: unknown;
|
||||||
|
try {
|
||||||
|
json = JSON.parse(frame.data);
|
||||||
|
} catch {
|
||||||
|
warn(`unparseable event frame (${frame.event})`);
|
||||||
|
return Option.none();
|
||||||
|
}
|
||||||
|
const decoded = decodeHostEvent(json);
|
||||||
|
if (decoded._tag === "Left") {
|
||||||
|
// An unknown kind from a NEWER host is expected (additive-only wire) —
|
||||||
|
// it rides the raw channel; a consumer that wants it uses eventsRaw.
|
||||||
|
warn(`unknown/undecodable event kind "${frame.event}"`);
|
||||||
|
return Option.none();
|
||||||
|
}
|
||||||
|
return Option.some(decoded.right);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return { config: cfg, request, get, events, eventsRaw };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The live layer: resolves URL/token/CA (env → host files) and provides [`PunktfunkHost`].
|
||||||
|
*/
|
||||||
|
export const layer = (
|
||||||
|
options?: ConnectOptions,
|
||||||
|
): Layer.Layer<PunktfunkHost, TransportError> =>
|
||||||
|
Layer.effect(
|
||||||
|
PunktfunkHost,
|
||||||
|
Effect.tryPromise({
|
||||||
|
try: () => resolveConfig(options),
|
||||||
|
catch: (cause) => new TransportError({ cause }),
|
||||||
|
}).pipe(Effect.map(makeService)),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** RFC-spelled alias of [`layer`]. */
|
||||||
|
export const PunktfunkHostLive = layer;
|
||||||
|
|
||||||
|
export { SseAuthError };
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// Connection resolution (RFC §7): loopback URL + bearer token + the host's self-signed
|
||||||
|
// identity cert, from the environment with file fallbacks — so `connect()` on the host machine
|
||||||
|
// needs zero configuration.
|
||||||
|
//
|
||||||
|
// PUNKTFUNK_MGMT_URL (default https://127.0.0.1:47990)
|
||||||
|
// PUNKTFUNK_MGMT_TOKEN (else <config_dir>/mgmt-token)
|
||||||
|
// PUNKTFUNK_MGMT_CA (path; else <config_dir>/cert.pem when present)
|
||||||
|
//
|
||||||
|
// The CA is the host's own identity certificate — trusting exactly it (not the system roots)
|
||||||
|
// IS the pin for the loopback hop. Per-runtime plumbing differs: Bun takes `tls.ca` on fetch,
|
||||||
|
// Node (undici) takes a dispatcher with a CA-carrying TLS connector; anything else falls back
|
||||||
|
// to plain fetch (document PUNKTFUNK_MGMT_CA + NODE_EXTRA_CA_CERTS there).
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
|
||||||
|
export interface ConnectOptions {
|
||||||
|
/** Management API base URL (default `https://127.0.0.1:47990`). */
|
||||||
|
url?: string;
|
||||||
|
/** Bearer token (default: `PUNKTFUNK_MGMT_TOKEN`, else the host's `mgmt-token` file). */
|
||||||
|
token?: string;
|
||||||
|
/** PEM of the CA to trust — the host's identity cert (default: `PUNKTFUNK_MGMT_CA`, else `cert.pem`). */
|
||||||
|
ca?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedConfig {
|
||||||
|
url: string;
|
||||||
|
token: string;
|
||||||
|
ca?: string;
|
||||||
|
/** A fetch honoring `ca` on this runtime. */
|
||||||
|
fetch: typeof fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The host's config dir — the same resolution the host itself uses. */
|
||||||
|
export const configDir = (): string => {
|
||||||
|
const explicit = process.env.PUNKTFUNK_CONFIG_DIR;
|
||||||
|
if (explicit) return explicit;
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
const base = process.env.ProgramData ?? process.env.APPDATA ?? ".";
|
||||||
|
return path.join(base, "punktfunk");
|
||||||
|
}
|
||||||
|
const base =
|
||||||
|
process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
|
||||||
|
return path.join(base, "punktfunk");
|
||||||
|
};
|
||||||
|
|
||||||
|
const readIfExists = (p: string): string | undefined => {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(p, "utf8");
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** First token-looking line of the mgmt-token file (tolerates `TOKEN=`-style and blank lines). */
|
||||||
|
const parseTokenFile = (raw: string): string | undefined => {
|
||||||
|
for (const line of raw.split(/\r?\n/)) {
|
||||||
|
const t = line.trim();
|
||||||
|
if (t.length === 0 || t.startsWith("#")) continue;
|
||||||
|
return t.includes("=") ? t.slice(t.indexOf("=") + 1).trim() : t;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveConfig = async (
|
||||||
|
options?: ConnectOptions,
|
||||||
|
): Promise<ResolvedConfig> => {
|
||||||
|
const url = (
|
||||||
|
options?.url ??
|
||||||
|
process.env.PUNKTFUNK_MGMT_URL ??
|
||||||
|
"https://127.0.0.1:47990"
|
||||||
|
).replace(/\/+$/, "");
|
||||||
|
const token =
|
||||||
|
options?.token ??
|
||||||
|
process.env.PUNKTFUNK_MGMT_TOKEN ??
|
||||||
|
parseTokenFile(readIfExists(path.join(configDir(), "mgmt-token")) ?? "");
|
||||||
|
if (!token) {
|
||||||
|
throw new Error(
|
||||||
|
"no management token: set PUNKTFUNK_MGMT_TOKEN, pass { token }, or run where " +
|
||||||
|
`the host's token file exists (${path.join(configDir(), "mgmt-token")})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const caPath = process.env.PUNKTFUNK_MGMT_CA;
|
||||||
|
const ca =
|
||||||
|
options?.ca ??
|
||||||
|
(caPath ? readIfExists(caPath) : undefined) ??
|
||||||
|
(url.startsWith("https://")
|
||||||
|
? readIfExists(path.join(configDir(), "cert.pem"))
|
||||||
|
: undefined);
|
||||||
|
return { url, token, ca, fetch: await makeFetch(ca) };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fetch that PINS `ca` — the host's self-signed identity cert — on this runtime.
|
||||||
|
*
|
||||||
|
* The pin is chain verification against exactly that certificate (nothing else can pass),
|
||||||
|
* with the HOSTNAME check waived: the host identity cert is deliberately CN-only/no-SAN
|
||||||
|
* (native clients pin its fingerprint; see `web/nitro-entry/bun-https.mjs` for the same
|
||||||
|
* finding), so standard SAN matching would always fail — and it adds nothing when the chain
|
||||||
|
* already admits only the one pinned cert.
|
||||||
|
*/
|
||||||
|
const makeFetch = async (ca: string | undefined): Promise<typeof fetch> => {
|
||||||
|
if (!ca) return fetch;
|
||||||
|
const skipHostname = { checkServerIdentity: () => undefined };
|
||||||
|
// Bun: fetch takes node-compatible `tls` options.
|
||||||
|
if (typeof (globalThis as Record<string, unknown>).Bun !== "undefined") {
|
||||||
|
return ((input: Parameters<typeof fetch>[0], init?: RequestInit) =>
|
||||||
|
fetch(input, {
|
||||||
|
...init,
|
||||||
|
tls: { ca, ...skipHostname },
|
||||||
|
} as RequestInit)) as typeof fetch;
|
||||||
|
}
|
||||||
|
// Node: global fetch is undici — a per-request dispatcher carries the pin.
|
||||||
|
try {
|
||||||
|
// Optional dependency — declared in package.json optionalDependencies; absent on
|
||||||
|
// runtimes that don't need it (the catch below falls back).
|
||||||
|
const { Agent } = (await import("undici" as string)) as {
|
||||||
|
Agent: new (opts: unknown) => unknown;
|
||||||
|
};
|
||||||
|
const dispatcher = new Agent({ connect: { ca, ...skipHostname } });
|
||||||
|
return ((input: Parameters<typeof fetch>[0], init?: RequestInit) =>
|
||||||
|
fetch(input, { ...init, dispatcher } as RequestInit)) as typeof fetch;
|
||||||
|
} catch {
|
||||||
|
// Unknown runtime: plain fetch (system trust) — PUNKTFUNK_MGMT_CA via the runtime's
|
||||||
|
// own CA mechanism (e.g. NODE_EXTRA_CA_CERTS / --cert) is the documented fallback.
|
||||||
|
return fetch;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// The one HTTP implementation both surfaces share (RFC §7: two surfaces, one core): the
|
||||||
|
// Effect service wraps these with typed errors; the Promise facade calls them directly.
|
||||||
|
import type { ResolvedConfig } from "./config.js";
|
||||||
|
|
||||||
|
/** A non-2xx response, with the host's `ApiError` envelope message when present. */
|
||||||
|
export class HttpStatusError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One management-API request under `/api/v1`. Returns the parsed JSON body (or `undefined`
|
||||||
|
* for 204/empty). Throws [`HttpStatusError`] on a non-2xx (401 included — callers type it).
|
||||||
|
*/
|
||||||
|
export const httpRequest = async (
|
||||||
|
cfg: ResolvedConfig,
|
||||||
|
method: string,
|
||||||
|
apiPath: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<unknown> => {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
authorization: `Bearer ${cfg.token}`,
|
||||||
|
};
|
||||||
|
if (body !== undefined) headers["content-type"] = "application/json";
|
||||||
|
const resp = await cfg.fetch(`${cfg.url}/api/v1${apiPath}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
let message = `HTTP ${resp.status}`;
|
||||||
|
try {
|
||||||
|
const err = (await resp.json()) as { error?: string };
|
||||||
|
if (typeof err.error === "string") message = err.error;
|
||||||
|
} catch {
|
||||||
|
// non-JSON error body — keep the status message
|
||||||
|
}
|
||||||
|
throw new HttpStatusError(resp.status, message);
|
||||||
|
}
|
||||||
|
if (resp.status === 204) return undefined;
|
||||||
|
const text = await resp.text();
|
||||||
|
return text.length === 0 ? undefined : JSON.parse(text);
|
||||||
|
};
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// `@punktfunk/host/effect` — the Effect-native surface (RFC §7): the `PunktfunkHost` service
|
||||||
|
// tag + live layer, the wire schemas, typed errors, and Stream accessors. The package root
|
||||||
|
// (`@punktfunk/host`) is the Promise facade; this entry is for plugins and Effect programs.
|
||||||
|
//
|
||||||
|
// import { Effect, Stream } from "effect";
|
||||||
|
// import { PunktfunkHost, PunktfunkHostLive, events } from "@punktfunk/host/effect";
|
||||||
|
//
|
||||||
|
// const program = Effect.gen(function* () {
|
||||||
|
// yield* events().pipe(
|
||||||
|
// Stream.filter((e) => e.kind === "stream.started"),
|
||||||
|
// Stream.runForEach((e) => Effect.log(`stream started: ${e.stream.mode}`)),
|
||||||
|
// );
|
||||||
|
// });
|
||||||
|
// Effect.runPromise(program.pipe(Effect.provide(PunktfunkHostLive())));
|
||||||
|
import { Effect, type Schema as S, Stream } from "effect";
|
||||||
|
import {
|
||||||
|
EventStreamError,
|
||||||
|
PunktfunkHost,
|
||||||
|
type RequestError,
|
||||||
|
type VersionSkew,
|
||||||
|
} from "./client.js";
|
||||||
|
import type { EventStreamOptions, SseFrame } from "./sse.js";
|
||||||
|
import type { HostEvent } from "./wire.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
ApiError,
|
||||||
|
AuthError,
|
||||||
|
EventStreamError,
|
||||||
|
layer,
|
||||||
|
makeService,
|
||||||
|
PunktfunkHost,
|
||||||
|
type PunktfunkHostService,
|
||||||
|
PunktfunkHostLive,
|
||||||
|
type RequestError,
|
||||||
|
SseAuthError,
|
||||||
|
TransportError,
|
||||||
|
VersionSkew,
|
||||||
|
} from "./client.js";
|
||||||
|
export { type ConnectOptions, configDir, resolveConfig } from "./config.js";
|
||||||
|
export type { EventStreamOptions, SseFrame } from "./sse.js";
|
||||||
|
export * from "./wire.js";
|
||||||
|
/** The generated REST wire schemas (orval `client: 'effect'` over `api/openapi.json`). */
|
||||||
|
export * as api from "./gen/schemas.js";
|
||||||
|
|
||||||
|
/** The decoded lifecycle-event stream of the ambient [`PunktfunkHost`]. */
|
||||||
|
export const events = (
|
||||||
|
opts?: EventStreamOptions,
|
||||||
|
): Stream.Stream<HostEvent, EventStreamError, PunktfunkHost> =>
|
||||||
|
Stream.unwrap(Effect.map(PunktfunkHost, (s) => s.events(opts)));
|
||||||
|
|
||||||
|
/** Every SSE frame verbatim (the `dropped` marker + unknown kinds included). */
|
||||||
|
export const eventsRaw = (
|
||||||
|
opts?: EventStreamOptions,
|
||||||
|
): Stream.Stream<SseFrame, EventStreamError, PunktfunkHost> =>
|
||||||
|
Stream.unwrap(Effect.map(PunktfunkHost, (s) => s.eventsRaw(opts)));
|
||||||
|
|
||||||
|
/** One management-API request under `/api/v1` on the ambient service. */
|
||||||
|
export const request = (
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Effect.Effect<unknown, RequestError, PunktfunkHost> =>
|
||||||
|
Effect.flatMap(PunktfunkHost, (s) => s.request(method, path, body));
|
||||||
|
|
||||||
|
/** GET + schema-validate on the ambient service (schemas from [`api`]). */
|
||||||
|
export const get = <A, I>(
|
||||||
|
path: string,
|
||||||
|
schema: S.Schema<A, I>,
|
||||||
|
): Effect.Effect<A, RequestError | VersionSkew, PunktfunkHost> =>
|
||||||
|
Effect.flatMap(PunktfunkHost, (s) => s.get(path, schema));
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,182 @@
|
|||||||
|
// `@punktfunk/host` — the Promise facade, the package's front door (RFC §7): `connect()`,
|
||||||
|
// `await`, `.on()` — a thin veneer over the same core the Effect surface uses (config
|
||||||
|
// resolution, one HTTP implementation, one reconnecting SSE source). Effect is the substrate
|
||||||
|
// and the power surface (`@punktfunk/host/effect`), never a prerequisite:
|
||||||
|
//
|
||||||
|
// import { connect } from "@punktfunk/host";
|
||||||
|
//
|
||||||
|
// const pf = await connect();
|
||||||
|
// pf.events.on("pairing.pending", async (e) => {
|
||||||
|
// await notifyPhone(`Pairing request from ${e.device.name}`);
|
||||||
|
// });
|
||||||
|
import type { Effect } from "effect";
|
||||||
|
import type { PunktfunkHost } from "./client.js";
|
||||||
|
import {
|
||||||
|
type ConnectOptions,
|
||||||
|
type ResolvedConfig,
|
||||||
|
resolveConfig,
|
||||||
|
} from "./config.js";
|
||||||
|
import { HttpStatusError, httpRequest } from "./core.js";
|
||||||
|
import { type SseFrame, sseFrames } from "./sse.js";
|
||||||
|
import {
|
||||||
|
decodeHostEvent,
|
||||||
|
type EventOf,
|
||||||
|
type HostEvent,
|
||||||
|
type HostEventKind,
|
||||||
|
kindMatches,
|
||||||
|
} from "./wire.js";
|
||||||
|
|
||||||
|
export { HttpStatusError } from "./core.js";
|
||||||
|
export type { ConnectOptions } from "./config.js";
|
||||||
|
export type {
|
||||||
|
ClientRef,
|
||||||
|
DeviceRef,
|
||||||
|
DisconnectReason,
|
||||||
|
EventOf,
|
||||||
|
HostEvent,
|
||||||
|
HostEventKind,
|
||||||
|
Plane,
|
||||||
|
SessionRef,
|
||||||
|
StreamRef,
|
||||||
|
} from "./wire.js";
|
||||||
|
|
||||||
|
/** `on()` also accepts these beyond the typed kinds. */
|
||||||
|
type SpecialPattern = "*" | "dropped" | "unknown" | (string & {});
|
||||||
|
|
||||||
|
interface Listener {
|
||||||
|
pattern: string;
|
||||||
|
cb: (ev: never) => unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PunktfunkEvents {
|
||||||
|
/**
|
||||||
|
* Subscribe to lifecycle events. `pattern` is an exact kind (`"stream.started"` — the
|
||||||
|
* callback is typed to it), a `domain.*` prefix, `"*"` (every known event), `"dropped"`
|
||||||
|
* (the fell-off-the-ring marker), or `"unknown"` (kinds this SDK doesn't know — a newer
|
||||||
|
* host). Returns the unsubscribe function. Callback errors are caught and warned, never
|
||||||
|
* fatal to the stream.
|
||||||
|
*/
|
||||||
|
on<K extends HostEventKind>(
|
||||||
|
pattern: K,
|
||||||
|
cb: (ev: EventOf<K>) => unknown,
|
||||||
|
): () => void;
|
||||||
|
on(pattern: SpecialPattern, cb: (ev: HostEvent) => unknown): () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Punktfunk {
|
||||||
|
/** Resolved connection (URL/token/CA). */
|
||||||
|
readonly config: ResolvedConfig;
|
||||||
|
/** One management-API request under `/api/v1` (`request("GET", "/status")`). */
|
||||||
|
request(method: string, path: string, body?: unknown): Promise<unknown>;
|
||||||
|
/** The lifecycle-event subscription surface. */
|
||||||
|
readonly events: PunktfunkEvents;
|
||||||
|
/** Stop the event stream and release the connection. */
|
||||||
|
close(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to the host's management API: resolves the URL, bearer token, and the host's
|
||||||
|
* self-signed identity cert from the environment/host files (zero config on the host box),
|
||||||
|
* verifies the credentials with a `/health`-adjacent probe, and returns the client.
|
||||||
|
*/
|
||||||
|
export const connect = async (options?: ConnectOptions): Promise<Punktfunk> => {
|
||||||
|
const cfg = await resolveConfig(options);
|
||||||
|
// Fail fast on bad credentials/URL: one cheap authenticated probe.
|
||||||
|
await httpRequest(cfg, "GET", "/host");
|
||||||
|
|
||||||
|
const listeners = new Set<Listener>();
|
||||||
|
let pump: AsyncGenerator<SseFrame> | undefined;
|
||||||
|
let closed = false;
|
||||||
|
|
||||||
|
const warn = (m: string) => console.warn(`[punktfunk] ${m}`);
|
||||||
|
const dispatch = (pattern: string, ev: unknown) => {
|
||||||
|
for (const l of listeners) {
|
||||||
|
if (l.pattern === pattern || (pattern !== "dropped" && pattern !== "unknown" && (l.pattern === "*" || kindMatches(l.pattern, pattern)))) {
|
||||||
|
try {
|
||||||
|
(l.cb as (e: unknown) => unknown)(ev);
|
||||||
|
} catch (e) {
|
||||||
|
warn(`listener for "${l.pattern}" threw: ${e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const startPump = () => {
|
||||||
|
if (pump || closed) return;
|
||||||
|
pump = sseFrames(cfg, { onWarning: warn });
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
for await (const frame of pump) {
|
||||||
|
if (closed) break;
|
||||||
|
if (frame.event === "dropped") {
|
||||||
|
dispatch("dropped", frame);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let json: unknown;
|
||||||
|
try {
|
||||||
|
json = JSON.parse(frame.data);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const decoded = decodeHostEvent(json);
|
||||||
|
if (decoded._tag === "Left") {
|
||||||
|
dispatch("unknown", json);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
dispatch(decoded.right.kind, decoded.right);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!closed) warn(`event stream stopped: ${e}`);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
config: cfg,
|
||||||
|
request: (method, path, body) => httpRequest(cfg, method, path, body),
|
||||||
|
events: {
|
||||||
|
on(pattern: string, cb: (ev: never) => unknown) {
|
||||||
|
const l: Listener = { pattern, cb };
|
||||||
|
listeners.add(l);
|
||||||
|
startPump();
|
||||||
|
return () => listeners.delete(l);
|
||||||
|
},
|
||||||
|
} as PunktfunkEvents,
|
||||||
|
close() {
|
||||||
|
closed = true;
|
||||||
|
pump?.return(undefined).catch(() => {});
|
||||||
|
pump = undefined;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------- plugins (RFC §8)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A plugin's `main`: either a plain async function receiving the connected client, or an
|
||||||
|
* Effect requiring the `PunktfunkHost` service (`@punktfunk/host/effect`) — the shape that
|
||||||
|
* makes it well-behaved under the managed runner's supervision (structured interruption,
|
||||||
|
* scoped finalizers).
|
||||||
|
*/
|
||||||
|
export type PluginMain =
|
||||||
|
| ((pf: Punktfunk) => Promise<unknown> | unknown)
|
||||||
|
| Effect.Effect<unknown, unknown, PunktfunkHost>;
|
||||||
|
|
||||||
|
export interface PluginDef {
|
||||||
|
/** Package-convention name (`punktfunk-plugin-*` drops the prefix): kebab-case. */
|
||||||
|
name: string;
|
||||||
|
main: PluginMain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declare a plugin (the `punktfunk-plugin-*` convention, RFC §8). In v1 a plugin is a script
|
||||||
|
* the operator runs; the managed runner (a later, optional package) discovers this default
|
||||||
|
* export and supervises `main`.
|
||||||
|
*/
|
||||||
|
export const definePlugin = (def: PluginDef): PluginDef => {
|
||||||
|
if (!/^[a-z][a-z0-9-]*$/.test(def.name)) {
|
||||||
|
throw new Error(
|
||||||
|
`plugin name "${def.name}" must be kebab-case ([a-z][a-z0-9-]*)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return def;
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
// `punktfunk-scripting` — run the operator's scripts and punktfunk-plugin-* packages under
|
||||||
|
// supervision (see ./runner.ts). SIGINT/SIGTERM interrupt the whole tree structurally, so
|
||||||
|
// every plugin's finalizers run before exit (the systemd-stop story).
|
||||||
|
//
|
||||||
|
// bun src/runner-cli.ts [--scripts DIR] [--plugins DIR] [--list]
|
||||||
|
import { Effect, Fiber } from "effect";
|
||||||
|
import { discoverUnits, runner } from "./runner.js";
|
||||||
|
|
||||||
|
const arg = (flag: string): string | undefined => {
|
||||||
|
const i = process.argv.indexOf(flag);
|
||||||
|
return i >= 0 ? process.argv[i + 1] : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
scriptsDir: arg("--scripts"),
|
||||||
|
pluginsDir: arg("--plugins"),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (process.argv.includes("--list")) {
|
||||||
|
for (const u of discoverUnits(options)) console.log(`${u.name}\t${u.file}`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fiber = Effect.runFork(runner(options));
|
||||||
|
let stopping = false;
|
||||||
|
const shutdown = (signal: string) => {
|
||||||
|
if (stopping) return process.exit(1); // second signal = get out now
|
||||||
|
stopping = true;
|
||||||
|
console.log(`${new Date().toISOString()} [runner] ${signal} — interrupting units…`);
|
||||||
|
void Effect.runPromise(Fiber.interrupt(fiber)).finally(() => process.exit(0));
|
||||||
|
};
|
||||||
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||||
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||||
|
|
||||||
|
await Effect.runPromise(Fiber.await(fiber));
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
// The managed script/plugin runner (RFC §8, M5) — what the `punktfunk-scripting` package runs:
|
||||||
|
// discover the operator's units, supervise them as Effect fibers, shut down structurally.
|
||||||
|
//
|
||||||
|
// Units:
|
||||||
|
// - **Plugins** — a file whose default export is a [`PluginDef`] (`definePlugin`), from the
|
||||||
|
// scripts dir or an installed `punktfunk-plugin-*` package. Supervised: a failure restarts
|
||||||
|
// it with capped exponential backoff; a clean return completes it. The Effect `main` shape
|
||||||
|
// runs with the `PunktfunkHost` layer provided and is interrupted STRUCTURALLY on shutdown
|
||||||
|
// (scoped finalizers run — release the preset, deregister cleanly); the async-fn shape gets
|
||||||
|
// a connected facade client whose close is guaranteed by the same scope.
|
||||||
|
// - **Bare scripts** — any other `.ts`/`.js` file in the scripts dir: importing it IS the run
|
||||||
|
// (top-level await). One-shot: completion logs, failure logs — no restart (a bare script's
|
||||||
|
// background work is invisible to supervision; export a plugin to be supervised).
|
||||||
|
//
|
||||||
|
// Trust model (RFC §9.4): a unit is code the operator chose to run — no sandbox is pretended.
|
||||||
|
// The same sshd rule as hooks applies: a world-writable unit file is refused loudly.
|
||||||
|
import {
|
||||||
|
Cause,
|
||||||
|
Duration,
|
||||||
|
Effect,
|
||||||
|
Schedule,
|
||||||
|
} from "effect";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { layer as hostLayer, PunktfunkHost } from "./client.js";
|
||||||
|
import { type ConnectOptions, configDir } from "./config.js";
|
||||||
|
import { connect, type PluginDef } from "./index.js";
|
||||||
|
|
||||||
|
export interface RunnerOptions {
|
||||||
|
/** Where loose scripts live. Default `<config_dir>/scripts`. */
|
||||||
|
scriptsDir?: string;
|
||||||
|
/**
|
||||||
|
* Where plugin packages are installed (`<pluginsDir>/node_modules/punktfunk-plugin-*`,
|
||||||
|
* i.e. the operator runs `bun add punktfunk-plugin-x` there). Default `<config_dir>/plugins`.
|
||||||
|
*/
|
||||||
|
pluginsDir?: string;
|
||||||
|
/** Connection overrides handed to every unit's client/layer. */
|
||||||
|
connect?: ConnectOptions;
|
||||||
|
/** Restart backoff base (test seam). Default 1 s, capped at 60 s, jittered. */
|
||||||
|
restartBase?: Duration.DurationInput;
|
||||||
|
/** Line sink. Default: stamped stdout. */
|
||||||
|
log?: (line: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Unit {
|
||||||
|
/** Display name: the file stem, or the plugin package name. */
|
||||||
|
name: string;
|
||||||
|
/** Absolute path of the module to import. */
|
||||||
|
file: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultLog = (line: string) =>
|
||||||
|
console.log(`${new Date().toISOString()} ${line}`);
|
||||||
|
|
||||||
|
/** The sshd rule (RFC §9.1/§9.4): refuse group/world-writable unit files, loudly. */
|
||||||
|
const fileIsSafe = (file: string, log: (l: string) => void): boolean => {
|
||||||
|
if (process.platform === "win32") return true; // config dir is DACL'd; ACL check is a follow-up
|
||||||
|
try {
|
||||||
|
const mode = fs.statSync(file).mode & 0o022;
|
||||||
|
if (mode !== 0) {
|
||||||
|
log(
|
||||||
|
`[runner] REFUSING ${file} — group/world-writable (chmod go-w it first)`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SCRIPT_EXTENSIONS = new Set([".ts", ".js", ".mjs", ".mts", ".cjs"]);
|
||||||
|
|
||||||
|
/** Enumerate the operator's units: loose scripts plus installed plugin packages. */
|
||||||
|
export const discoverUnits = (
|
||||||
|
options: RunnerOptions = {},
|
||||||
|
log: (l: string) => void = options.log ?? defaultLog,
|
||||||
|
): Unit[] => {
|
||||||
|
const units: Unit[] = [];
|
||||||
|
const scriptsDir = options.scriptsDir ?? path.join(configDir(), "scripts");
|
||||||
|
const pluginsDir = options.pluginsDir ?? path.join(configDir(), "plugins");
|
||||||
|
try {
|
||||||
|
for (const entry of fs.readdirSync(scriptsDir).sort()) {
|
||||||
|
const file = path.join(scriptsDir, entry);
|
||||||
|
if (!SCRIPT_EXTENSIONS.has(path.extname(entry))) continue;
|
||||||
|
if (!fs.statSync(file).isFile()) continue;
|
||||||
|
if (!fileIsSafe(file, log)) continue;
|
||||||
|
units.push({ name: path.basename(entry, path.extname(entry)), file });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// no scripts dir — fine
|
||||||
|
}
|
||||||
|
const modules = path.join(pluginsDir, "node_modules");
|
||||||
|
try {
|
||||||
|
for (const pkg of fs.readdirSync(modules).sort()) {
|
||||||
|
if (!pkg.startsWith("punktfunk-plugin-")) continue;
|
||||||
|
try {
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(modules, pkg, "package.json"), "utf8"),
|
||||||
|
) as { main?: string; module?: string };
|
||||||
|
const rel = manifest.module ?? manifest.main ?? "index.js";
|
||||||
|
const file = path.join(modules, pkg, rel);
|
||||||
|
if (!fileIsSafe(file, log)) continue;
|
||||||
|
units.push({ name: pkg, file });
|
||||||
|
} catch (e) {
|
||||||
|
log(`[runner] skipping ${pkg}: unreadable package.json (${e})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// no plugins dir — fine
|
||||||
|
}
|
||||||
|
return units;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPluginDef = (v: unknown): v is PluginDef =>
|
||||||
|
typeof v === "object" &&
|
||||||
|
v !== null &&
|
||||||
|
typeof (v as PluginDef).name === "string" &&
|
||||||
|
(v as PluginDef).main !== undefined;
|
||||||
|
|
||||||
|
/** One attempt at a unit: import (cache-busted per attempt) and run whatever it exports. */
|
||||||
|
const attemptUnit = (
|
||||||
|
unit: Unit,
|
||||||
|
attempt: number,
|
||||||
|
options: RunnerOptions,
|
||||||
|
log: (l: string) => void,
|
||||||
|
): Effect.Effect<"plugin" | "script", unknown> =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const mod = (yield* Effect.tryPromise(
|
||||||
|
() => import(`${pathToFileURL(unit.file).href}?attempt=${attempt}`),
|
||||||
|
)) as { default?: unknown };
|
||||||
|
if (!isPluginDef(mod.default)) {
|
||||||
|
return "script" as const; // the import WAS the run (top-level await)
|
||||||
|
}
|
||||||
|
const def = mod.default;
|
||||||
|
if (Effect.isEffect(def.main)) {
|
||||||
|
// The well-behaved shape: interruption reaches it structurally, its scoped
|
||||||
|
// finalizers run on shutdown.
|
||||||
|
yield* (def.main as Effect.Effect<unknown, unknown, PunktfunkHost>).pipe(
|
||||||
|
Effect.provide(hostLayer(options.connect)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// The simple shape: a facade client whose close is guaranteed by the scope —
|
||||||
|
// on completion, failure, OR interruption (shutdown).
|
||||||
|
const main = def.main as (pf: unknown) => Promise<unknown> | unknown;
|
||||||
|
yield* Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const pf = yield* Effect.acquireRelease(
|
||||||
|
Effect.tryPromise(() => connect(options.connect)),
|
||||||
|
(client) => Effect.sync(() => client.close()),
|
||||||
|
);
|
||||||
|
yield* Effect.tryPromise(async () => await main(pf));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return "plugin" as const;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A unit under supervision: plugins restart on failure (capped exponential backoff, jittered);
|
||||||
|
* a clean completion ends the unit; bare scripts are one-shot either way. Never fails the
|
||||||
|
* runner — every outcome is logged.
|
||||||
|
*/
|
||||||
|
export const superviseUnit = (
|
||||||
|
unit: Unit,
|
||||||
|
options: RunnerOptions = {},
|
||||||
|
): Effect.Effect<void> => {
|
||||||
|
const log = options.log ?? defaultLog;
|
||||||
|
const restart = Schedule.exponential(options.restartBase ?? "1 second").pipe(
|
||||||
|
Schedule.union(Schedule.spaced("60 seconds")), // cap
|
||||||
|
Schedule.jittered,
|
||||||
|
);
|
||||||
|
let attempt = 0;
|
||||||
|
const once = Effect.suspend(() => {
|
||||||
|
attempt += 1;
|
||||||
|
if (attempt > 1) log(`[${unit.name}] restarting (attempt ${attempt})`);
|
||||||
|
return attemptUnit(unit, attempt, options, log);
|
||||||
|
});
|
||||||
|
return once.pipe(
|
||||||
|
Effect.tap((kind) =>
|
||||||
|
Effect.sync(() =>
|
||||||
|
log(
|
||||||
|
kind === "script"
|
||||||
|
? `[${unit.name}] script completed`
|
||||||
|
: `[${unit.name}] plugin completed`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Effect.tapErrorCause((cause) =>
|
||||||
|
Effect.sync(() =>
|
||||||
|
log(`[${unit.name}] failed: ${Cause.pretty(cause).split("\n")[0]}`),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Effect.retry(restart),
|
||||||
|
Effect.catchAllCause((cause) =>
|
||||||
|
// A retry schedule that gives up (it doesn't, but stay total) — log and end.
|
||||||
|
Effect.sync(() => log(`[${unit.name}] gave up: ${Cause.pretty(cause)}`)),
|
||||||
|
),
|
||||||
|
Effect.asVoid,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The runner: discover units, supervise each as a fiber, run until interrupted — at which
|
||||||
|
* point every unit is interrupted STRUCTURALLY (scoped finalizers run: facade clients close,
|
||||||
|
* Effect plugins release what they acquired).
|
||||||
|
*/
|
||||||
|
export const runner = (options: RunnerOptions = {}): Effect.Effect<void> => {
|
||||||
|
const log = options.log ?? defaultLog;
|
||||||
|
return Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const units = discoverUnits(options, log);
|
||||||
|
if (units.length === 0) {
|
||||||
|
log(
|
||||||
|
"[runner] nothing to run — add scripts to the scripts dir or install punktfunk-plugin-* packages",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const unit of units) {
|
||||||
|
log(`[runner] starting ${unit.name} (${unit.file})`);
|
||||||
|
yield* Effect.forkScoped(superviseUnit(unit, options));
|
||||||
|
}
|
||||||
|
yield* Effect.never; // interruption (shutdown) collapses the scope → all units
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
+172
@@ -0,0 +1,172 @@
|
|||||||
|
// The SSE half of the SDK: a spec-shaped parser plus one shared reconnecting frame source both
|
||||||
|
// surfaces consume — the Effect `Stream` wraps it, the Promise facade iterates it directly, so
|
||||||
|
// there is exactly one implementation of connect/parse/resume (RFC §7: "two surfaces, one
|
||||||
|
// core"). Reconnects carry `Last-Event-ID` (the host replays from its ring); backoff is
|
||||||
|
// exponential + jittered, capped, and resets after a healthy connection.
|
||||||
|
import type { ResolvedConfig } from "./config.js";
|
||||||
|
|
||||||
|
/** One parsed SSE frame. */
|
||||||
|
export interface SseFrame {
|
||||||
|
/** The `event:` name — an event kind, or `dropped`, or `message` when absent. */
|
||||||
|
event: string;
|
||||||
|
/** The joined `data:` payload. */
|
||||||
|
data: string;
|
||||||
|
/** The `id:` field (the host sets it to the event's `seq`). */
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Incremental SSE parser (the WHATWG dispatch rules the host's frames need). */
|
||||||
|
export class SseParser {
|
||||||
|
private buf = "";
|
||||||
|
private event = "";
|
||||||
|
private data: string[] = [];
|
||||||
|
private id: string | undefined;
|
||||||
|
|
||||||
|
/** Feed a chunk; returns the frames it completed. */
|
||||||
|
push(chunk: string): SseFrame[] {
|
||||||
|
this.buf += chunk;
|
||||||
|
const frames: SseFrame[] = [];
|
||||||
|
for (;;) {
|
||||||
|
const nl = this.buf.indexOf("\n");
|
||||||
|
if (nl < 0) break;
|
||||||
|
let line = this.buf.slice(0, nl);
|
||||||
|
this.buf = this.buf.slice(nl + 1);
|
||||||
|
if (line.endsWith("\r")) line = line.slice(0, -1);
|
||||||
|
if (line.length === 0) {
|
||||||
|
// Blank line = dispatch.
|
||||||
|
if (this.data.length > 0 || this.event.length > 0) {
|
||||||
|
frames.push({
|
||||||
|
event: this.event.length > 0 ? this.event : "message",
|
||||||
|
data: this.data.join("\n"),
|
||||||
|
id: this.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.event = "";
|
||||||
|
this.data = [];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith(":")) continue; // comment (the host's keep-alives)
|
||||||
|
const colon = line.indexOf(":");
|
||||||
|
const field = colon < 0 ? line : line.slice(0, colon);
|
||||||
|
let value = colon < 0 ? "" : line.slice(colon + 1);
|
||||||
|
if (value.startsWith(" ")) value = value.slice(1);
|
||||||
|
switch (field) {
|
||||||
|
case "event":
|
||||||
|
this.event = value;
|
||||||
|
break;
|
||||||
|
case "data":
|
||||||
|
this.data.push(value);
|
||||||
|
break;
|
||||||
|
case "id":
|
||||||
|
this.id = value;
|
||||||
|
break;
|
||||||
|
default: // unknown fields are ignored per spec (retry: handled by our own backoff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return frames;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventStreamOptions {
|
||||||
|
/**
|
||||||
|
* Resume cursor (`?since=`); reconnects use the newer `Last-Event-ID` automatically.
|
||||||
|
* **Omitted = live tail only** (events from now on — a fresh notify script must not
|
||||||
|
* re-fire on the host's replayed history); `0` = replay the host's full ring first;
|
||||||
|
* `N` = resume after seq N.
|
||||||
|
*/
|
||||||
|
since?: number;
|
||||||
|
/** Server-side kind filter (`["stream.*", "pairing.pending"]`). */
|
||||||
|
kinds?: string[];
|
||||||
|
/** Called on transient trouble (reconnects, decode warnings). Default: console.warn. */
|
||||||
|
onWarning?: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "live tail only" cursor: beyond any realistic seq, so the host's catch-up is empty and
|
||||||
|
* (being > 0 yet ≥ the ring head) it never trips the `dropped` marker. The first received
|
||||||
|
* frame's real id replaces it for reconnects.
|
||||||
|
*/
|
||||||
|
const LIVE_ONLY_CURSOR = Number.MAX_SAFE_INTEGER;
|
||||||
|
|
||||||
|
/** Thrown when the stream cannot ever work (bad credentials) — retrying would loop 401s. */
|
||||||
|
export class SseAuthError extends Error {
|
||||||
|
readonly _tag = "SseAuthError";
|
||||||
|
}
|
||||||
|
|
||||||
|
const BACKOFF_INITIAL_MS = 500;
|
||||||
|
const BACKOFF_CAP_MS = 15_000;
|
||||||
|
/** A connection that lived this long resets the backoff (it was healthy, not flapping). */
|
||||||
|
const HEALTHY_MS = 30_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared frame source: connect → parse → yield frames, forever — reconnecting with
|
||||||
|
* `Last-Event-ID` on any hiccup. Ends only by consumer break/return (both surfaces cancel by
|
||||||
|
* dropping the iterator, which aborts the in-flight request) or throws [`SseAuthError`].
|
||||||
|
*/
|
||||||
|
export async function* sseFrames(
|
||||||
|
cfg: ResolvedConfig,
|
||||||
|
opts: EventStreamOptions = {},
|
||||||
|
): AsyncGenerator<SseFrame> {
|
||||||
|
const warn = opts.onWarning ?? ((m) => console.warn(`[punktfunk] ${m}`));
|
||||||
|
let lastId: number = opts.since ?? LIVE_ONLY_CURSOR;
|
||||||
|
let backoff = BACKOFF_INITIAL_MS;
|
||||||
|
const abort = new AbortController();
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
const url = new URL(`${cfg.url}/api/v1/events`);
|
||||||
|
if (opts.kinds && opts.kinds.length > 0)
|
||||||
|
url.searchParams.set("kinds", opts.kinds.join(","));
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
authorization: `Bearer ${cfg.token}`,
|
||||||
|
accept: "text/event-stream",
|
||||||
|
};
|
||||||
|
if (lastId !== 0) headers["last-event-id"] = String(lastId);
|
||||||
|
|
||||||
|
const connectedAt = Date.now();
|
||||||
|
try {
|
||||||
|
const resp = await cfg.fetch(url, {
|
||||||
|
headers,
|
||||||
|
signal: abort.signal,
|
||||||
|
});
|
||||||
|
if (resp.status === 401) throw new SseAuthError("invalid credentials");
|
||||||
|
if (!resp.ok || !resp.body)
|
||||||
|
throw new Error(`event stream HTTP ${resp.status}`);
|
||||||
|
const reader = resp.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
const parser = new SseParser();
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break; // server closed (slow-consumer cut / shutdown) → reconnect
|
||||||
|
for (const frame of parser.push(
|
||||||
|
decoder.decode(value, { stream: true }),
|
||||||
|
)) {
|
||||||
|
if (frame.id !== undefined) {
|
||||||
|
const id = Number(frame.id);
|
||||||
|
if (Number.isFinite(id)) lastId = id;
|
||||||
|
}
|
||||||
|
yield frame;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.cancel().catch(() => {});
|
||||||
|
}
|
||||||
|
throw new Error("event stream ended");
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof SseAuthError) throw e;
|
||||||
|
if (abort.signal.aborted) return;
|
||||||
|
if (Date.now() - connectedAt >= HEALTHY_MS) backoff = BACKOFF_INITIAL_MS;
|
||||||
|
const jittered = backoff * (0.5 + Math.random());
|
||||||
|
warn(
|
||||||
|
`event stream reconnecting in ${Math.round(jittered)}ms (${
|
||||||
|
e instanceof Error ? e.message : String(e)
|
||||||
|
})`,
|
||||||
|
);
|
||||||
|
await new Promise((r) => setTimeout(r, jittered));
|
||||||
|
backoff = Math.min(backoff * 2, BACKOFF_CAP_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
abort.abort(); // consumer went away — tear down any in-flight request
|
||||||
|
}
|
||||||
|
}
|
||||||
+160
@@ -0,0 +1,160 @@
|
|||||||
|
// The lifecycle-event wire schemas (RFC §4/§7) — hand-written as a discriminated union on
|
||||||
|
// `kind` for precise types and decode errors. The REST surface is generated (./gen/schemas.ts);
|
||||||
|
// the event stream's `text/event-stream` payload is not expressible there, and the host's
|
||||||
|
// Rust-side JSON snapshot tests (crates/punktfunk-host/src/events.rs) are the wire's source of
|
||||||
|
// truth — this file mirrors them. Additive-only within `schema: 1`: decoding tolerates unknown
|
||||||
|
// keys (Effect's default), and an unknown `kind` surfaces on the raw channel, never a throw.
|
||||||
|
import { Schema as S } from "effect";
|
||||||
|
|
||||||
|
export const Plane = S.Literal("native", "gamestream");
|
||||||
|
export type Plane = S.Schema.Type<typeof Plane>;
|
||||||
|
|
||||||
|
export const DisconnectReason = S.Literal("quit", "timeout", "error");
|
||||||
|
export type DisconnectReason = S.Schema.Type<typeof DisconnectReason>;
|
||||||
|
|
||||||
|
export const ClientRef = S.Struct({
|
||||||
|
name: S.String,
|
||||||
|
fingerprint: S.optional(S.String),
|
||||||
|
plane: Plane,
|
||||||
|
});
|
||||||
|
export type ClientRef = S.Schema.Type<typeof ClientRef>;
|
||||||
|
|
||||||
|
export const SessionRef = S.Struct({
|
||||||
|
id: S.Number,
|
||||||
|
client: S.String,
|
||||||
|
mode: S.String,
|
||||||
|
hdr: S.Boolean,
|
||||||
|
});
|
||||||
|
export type SessionRef = S.Schema.Type<typeof SessionRef>;
|
||||||
|
|
||||||
|
export const StreamRef = S.Struct({
|
||||||
|
mode: S.String,
|
||||||
|
hdr: S.Boolean,
|
||||||
|
client: S.String,
|
||||||
|
app: S.optional(S.String),
|
||||||
|
plane: Plane,
|
||||||
|
});
|
||||||
|
export type StreamRef = S.Schema.Type<typeof StreamRef>;
|
||||||
|
|
||||||
|
export const DeviceRef = S.Struct({
|
||||||
|
name: S.String,
|
||||||
|
fingerprint: S.String,
|
||||||
|
plane: Plane,
|
||||||
|
});
|
||||||
|
export type DeviceRef = S.Schema.Type<typeof DeviceRef>;
|
||||||
|
|
||||||
|
/** The `{seq, ts_ms, schema}` envelope every event carries. */
|
||||||
|
const envelope = {
|
||||||
|
seq: S.Number,
|
||||||
|
ts_ms: S.Number,
|
||||||
|
schema: S.Number,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const ClientConnected = S.Struct({
|
||||||
|
...envelope,
|
||||||
|
kind: S.Literal("client.connected"),
|
||||||
|
client: ClientRef,
|
||||||
|
});
|
||||||
|
export const ClientDisconnected = S.Struct({
|
||||||
|
...envelope,
|
||||||
|
kind: S.Literal("client.disconnected"),
|
||||||
|
client: ClientRef,
|
||||||
|
reason: DisconnectReason,
|
||||||
|
});
|
||||||
|
export const SessionStarted = S.Struct({
|
||||||
|
...envelope,
|
||||||
|
kind: S.Literal("session.started"),
|
||||||
|
session: SessionRef,
|
||||||
|
});
|
||||||
|
export const SessionEnded = S.Struct({
|
||||||
|
...envelope,
|
||||||
|
kind: S.Literal("session.ended"),
|
||||||
|
session: SessionRef,
|
||||||
|
});
|
||||||
|
export const StreamStarted = S.Struct({
|
||||||
|
...envelope,
|
||||||
|
kind: S.Literal("stream.started"),
|
||||||
|
stream: StreamRef,
|
||||||
|
});
|
||||||
|
export const StreamStopped = S.Struct({
|
||||||
|
...envelope,
|
||||||
|
kind: S.Literal("stream.stopped"),
|
||||||
|
stream: StreamRef,
|
||||||
|
});
|
||||||
|
export const PairingPending = S.Struct({
|
||||||
|
...envelope,
|
||||||
|
kind: S.Literal("pairing.pending"),
|
||||||
|
device: DeviceRef,
|
||||||
|
});
|
||||||
|
export const PairingCompleted = S.Struct({
|
||||||
|
...envelope,
|
||||||
|
kind: S.Literal("pairing.completed"),
|
||||||
|
device: DeviceRef,
|
||||||
|
});
|
||||||
|
export const PairingDenied = S.Struct({
|
||||||
|
...envelope,
|
||||||
|
kind: S.Literal("pairing.denied"),
|
||||||
|
device: DeviceRef,
|
||||||
|
});
|
||||||
|
export const DisplayCreated = S.Struct({
|
||||||
|
...envelope,
|
||||||
|
kind: S.Literal("display.created"),
|
||||||
|
backend: S.String,
|
||||||
|
mode: S.String,
|
||||||
|
});
|
||||||
|
export const DisplayReleased = S.Struct({
|
||||||
|
...envelope,
|
||||||
|
kind: S.Literal("display.released"),
|
||||||
|
count: S.Number,
|
||||||
|
});
|
||||||
|
export const LibraryChanged = S.Struct({
|
||||||
|
...envelope,
|
||||||
|
kind: S.Literal("library.changed"),
|
||||||
|
source: S.String,
|
||||||
|
});
|
||||||
|
export const HostStarted = S.Struct({
|
||||||
|
...envelope,
|
||||||
|
kind: S.Literal("host.started"),
|
||||||
|
version: S.String,
|
||||||
|
gamestream: S.Boolean,
|
||||||
|
});
|
||||||
|
export const HostStopping = S.Struct({
|
||||||
|
...envelope,
|
||||||
|
kind: S.Literal("host.stopping"),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Every known lifecycle event — discriminated on `kind`. */
|
||||||
|
export const HostEvent = S.Union(
|
||||||
|
ClientConnected,
|
||||||
|
ClientDisconnected,
|
||||||
|
SessionStarted,
|
||||||
|
SessionEnded,
|
||||||
|
StreamStarted,
|
||||||
|
StreamStopped,
|
||||||
|
PairingPending,
|
||||||
|
PairingCompleted,
|
||||||
|
PairingDenied,
|
||||||
|
DisplayCreated,
|
||||||
|
DisplayReleased,
|
||||||
|
LibraryChanged,
|
||||||
|
HostStarted,
|
||||||
|
HostStopping,
|
||||||
|
);
|
||||||
|
export type HostEvent = S.Schema.Type<typeof HostEvent>;
|
||||||
|
|
||||||
|
/** The known event kinds (for filters and the facade's `on()`). */
|
||||||
|
export type HostEventKind = HostEvent["kind"];
|
||||||
|
|
||||||
|
/** Narrow a HostEvent by kind: `EventOf<"stream.started">`. */
|
||||||
|
export type EventOf<K extends HostEventKind> = Extract<HostEvent, { kind: K }>;
|
||||||
|
|
||||||
|
export const decodeHostEvent = S.decodeUnknownEither(HostEvent);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does `pattern` select `kind`? Exact kinds (`stream.started`) or `domain.*` prefixes on the
|
||||||
|
* dot boundary — the same vocabulary as the host's SSE `?kinds=` filter and hooks `on:` field.
|
||||||
|
*/
|
||||||
|
export const kindMatches = (pattern: string, kind: string): boolean =>
|
||||||
|
pattern.endsWith(".*")
|
||||||
|
? kind.startsWith(pattern.slice(0, -1)) // "stream.*" → prefix "stream."
|
||||||
|
: pattern === kind;
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
// The runner's contract: discovery (scripts + plugin packages + the sshd refusal), both plugin
|
||||||
|
// shapes running against a mock host, crash → supervised restart, one-shot scripts, and the
|
||||||
|
// M5 heart — structured interruption running scoped finalizers on shutdown.
|
||||||
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
|
import { Effect, Fiber } from "effect";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { discoverUnits, runner, superviseUnit } from "../src/runner.js";
|
||||||
|
|
||||||
|
const TOKEN = "runner-token";
|
||||||
|
// Fixtures live under sdk/ so the generated plugin files can resolve "effect" and the SDK.
|
||||||
|
const ROOT = path.join(import.meta.dir, "..", `.runner-fixtures-${process.pid}`);
|
||||||
|
fs.mkdirSync(ROOT, { recursive: true });
|
||||||
|
afterAll(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
const mkdirs = (name: string) => {
|
||||||
|
const dir = path.join(ROOT, name);
|
||||||
|
fs.mkdirSync(path.join(dir, "scripts"), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(dir, "plugins", "node_modules"), { recursive: true });
|
||||||
|
return {
|
||||||
|
scriptsDir: path.join(dir, "scripts"),
|
||||||
|
pluginsDir: path.join(dir, "plugins"),
|
||||||
|
dir,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const write = (file: string, content: string) => {
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
fs.writeFileSync(file, content);
|
||||||
|
fs.chmodSync(file, 0o644);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockHost = () =>
|
||||||
|
Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch(req) {
|
||||||
|
if (req.headers.get("authorization") !== `Bearer ${TOKEN}`)
|
||||||
|
return Response.json({ error: "no" }, { status: 401 });
|
||||||
|
const p = new URL(req.url).pathname;
|
||||||
|
if (p === "/api/v1/host") return Response.json({ hostname: "mock" });
|
||||||
|
if (p === "/api/v1/status") return Response.json({ ok: true });
|
||||||
|
return Response.json({ error: "nf" }, { status: 404 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const waitFor = async (predicate: () => boolean, ms = 5000) => {
|
||||||
|
const deadline = Date.now() + ms;
|
||||||
|
while (!predicate()) {
|
||||||
|
if (Date.now() > deadline) throw new Error("condition never became true");
|
||||||
|
await new Promise((r) => setTimeout(r, 25));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("discovery", () => {
|
||||||
|
test("finds scripts + plugin packages, refuses world-writable files", () => {
|
||||||
|
const d = mkdirs("discover");
|
||||||
|
write(path.join(d.scriptsDir, "b-script.ts"), "export {};");
|
||||||
|
write(path.join(d.scriptsDir, "a-script.ts"), "export {};");
|
||||||
|
write(path.join(d.scriptsDir, "notes.txt"), "not code");
|
||||||
|
const evil = path.join(d.scriptsDir, "evil.ts");
|
||||||
|
write(evil, "export {};");
|
||||||
|
fs.chmodSync(evil, 0o777);
|
||||||
|
write(
|
||||||
|
path.join(d.pluginsDir, "node_modules", "punktfunk-plugin-x", "package.json"),
|
||||||
|
JSON.stringify({ name: "punktfunk-plugin-x", main: "index.js" }),
|
||||||
|
);
|
||||||
|
write(
|
||||||
|
path.join(d.pluginsDir, "node_modules", "punktfunk-plugin-x", "index.js"),
|
||||||
|
"export default { name: 'x', main: async () => {} };",
|
||||||
|
);
|
||||||
|
write(
|
||||||
|
path.join(d.pluginsDir, "node_modules", "unrelated-pkg", "package.json"),
|
||||||
|
JSON.stringify({ name: "unrelated-pkg" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const logs: string[] = [];
|
||||||
|
const units = discoverUnits(d, (l) => logs.push(l));
|
||||||
|
expect(units.map((u) => u.name)).toEqual([
|
||||||
|
"a-script",
|
||||||
|
"b-script",
|
||||||
|
"punktfunk-plugin-x",
|
||||||
|
]);
|
||||||
|
expect(logs.join("\n")).toContain("REFUSING");
|
||||||
|
expect(logs.join("\n")).toContain("evil.ts");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("supervision", () => {
|
||||||
|
test("async-fn plugin runs with a facade client; clean return completes", async () => {
|
||||||
|
const server = mockHost();
|
||||||
|
const d = mkdirs("fn-plugin");
|
||||||
|
const out = path.join(d.dir, "out.txt");
|
||||||
|
write(
|
||||||
|
path.join(d.scriptsDir, "fn.ts"),
|
||||||
|
`export default { name: "fn", main: async (pf) => {
|
||||||
|
const host = await pf.request("GET", "/host");
|
||||||
|
require("node:fs").writeFileSync(${JSON.stringify(out)}, host.hostname);
|
||||||
|
}};`,
|
||||||
|
);
|
||||||
|
const logs: string[] = [];
|
||||||
|
try {
|
||||||
|
const fiber = Effect.runFork(
|
||||||
|
runner({
|
||||||
|
...d,
|
||||||
|
connect: { url: `http://127.0.0.1:${server.port}`, token: TOKEN },
|
||||||
|
log: (l) => logs.push(l),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await waitFor(() => fs.existsSync(out));
|
||||||
|
expect(fs.readFileSync(out, "utf8")).toBe("mock");
|
||||||
|
await waitFor(() => logs.some((l) => l.includes("[fn] plugin completed")));
|
||||||
|
await Effect.runPromise(Fiber.interrupt(fiber));
|
||||||
|
} finally {
|
||||||
|
server.stop(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a crashing plugin is restarted with backoff", async () => {
|
||||||
|
const server = mockHost();
|
||||||
|
const d = mkdirs("crashy");
|
||||||
|
const counter = path.join(d.dir, "count.txt");
|
||||||
|
write(
|
||||||
|
path.join(d.scriptsDir, "crashy.ts"),
|
||||||
|
`import * as fs from "node:fs";
|
||||||
|
export default { name: "crashy", main: async () => {
|
||||||
|
const n = fs.existsSync(${JSON.stringify(counter)}) ? Number(fs.readFileSync(${JSON.stringify(counter)}, "utf8")) : 0;
|
||||||
|
fs.writeFileSync(${JSON.stringify(counter)}, String(n + 1));
|
||||||
|
throw new Error("boom " + n);
|
||||||
|
}};`,
|
||||||
|
);
|
||||||
|
const logs: string[] = [];
|
||||||
|
try {
|
||||||
|
const fiber = Effect.runFork(
|
||||||
|
runner({
|
||||||
|
...d,
|
||||||
|
connect: { url: `http://127.0.0.1:${server.port}`, token: TOKEN },
|
||||||
|
restartBase: "20 millis",
|
||||||
|
log: (l) => logs.push(l),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
try {
|
||||||
|
return Number(fs.readFileSync(counter, "utf8")) >= 3;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(logs.some((l) => l.includes("[crashy] failed: "))).toBe(true);
|
||||||
|
expect(logs.some((l) => l.includes("restarting (attempt 2)"))).toBe(true);
|
||||||
|
await Effect.runPromise(Fiber.interrupt(fiber));
|
||||||
|
} finally {
|
||||||
|
server.stop(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a bare script is one-shot: runs on import, never restarts", async () => {
|
||||||
|
const d = mkdirs("bare");
|
||||||
|
const counter = path.join(d.dir, "ran.txt");
|
||||||
|
write(
|
||||||
|
path.join(d.scriptsDir, "once.ts"),
|
||||||
|
`import * as fs from "node:fs";
|
||||||
|
const n = fs.existsSync(${JSON.stringify(counter)}) ? Number(fs.readFileSync(${JSON.stringify(counter)}, "utf8")) : 0;
|
||||||
|
fs.writeFileSync(${JSON.stringify(counter)}, String(n + 1));`,
|
||||||
|
);
|
||||||
|
const logs: string[] = [];
|
||||||
|
const fiber = Effect.runFork(
|
||||||
|
runner({ ...d, restartBase: "20 millis", log: (l) => logs.push(l) }),
|
||||||
|
);
|
||||||
|
await waitFor(() => logs.some((l) => l.includes("[once] script completed")));
|
||||||
|
await new Promise((r) => setTimeout(r, 200)); // would have restarted by now
|
||||||
|
expect(fs.readFileSync(counter, "utf8")).toBe("1");
|
||||||
|
await Effect.runPromise(Fiber.interrupt(fiber));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shutdown interrupts an Effect plugin STRUCTURALLY — its finalizer runs", async () => {
|
||||||
|
const server = mockHost();
|
||||||
|
const d = mkdirs("finalizer");
|
||||||
|
const acquired = path.join(d.dir, "acquired.txt");
|
||||||
|
const released = path.join(d.dir, "released.txt");
|
||||||
|
write(
|
||||||
|
path.join(d.scriptsDir, "holder.ts"),
|
||||||
|
`import { Effect } from "effect";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
export default { name: "holder", main: Effect.scoped(Effect.gen(function* () {
|
||||||
|
yield* Effect.acquireRelease(
|
||||||
|
Effect.sync(() => fs.writeFileSync(${JSON.stringify(acquired)}, "1")),
|
||||||
|
() => Effect.sync(() => fs.writeFileSync(${JSON.stringify(released)}, "1")),
|
||||||
|
);
|
||||||
|
yield* Effect.never; // hold the resource for the plugin's lifetime
|
||||||
|
})) };`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const fiber = Effect.runFork(
|
||||||
|
runner({
|
||||||
|
...d,
|
||||||
|
connect: { url: `http://127.0.0.1:${server.port}`, token: TOKEN },
|
||||||
|
log: () => {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await waitFor(() => fs.existsSync(acquired));
|
||||||
|
expect(fs.existsSync(released)).toBe(false);
|
||||||
|
await Effect.runPromise(Fiber.interrupt(fiber)); // the SIGTERM path
|
||||||
|
await waitFor(() => fs.existsSync(released));
|
||||||
|
} finally {
|
||||||
|
server.stop(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("supervised unit ends cleanly when its plugin completes (no spin)", async () => {
|
||||||
|
const server = mockHost();
|
||||||
|
const d = mkdirs("done");
|
||||||
|
write(
|
||||||
|
path.join(d.scriptsDir, "done.ts"),
|
||||||
|
`export default { name: "done", main: async () => {} };`,
|
||||||
|
);
|
||||||
|
const logs: string[] = [];
|
||||||
|
try {
|
||||||
|
await Effect.runPromise(
|
||||||
|
superviseUnit(
|
||||||
|
{ name: "done", file: path.join(d.scriptsDir, "done.ts") },
|
||||||
|
{
|
||||||
|
connect: { url: `http://127.0.0.1:${server.port}`, token: TOKEN },
|
||||||
|
log: (l) => logs.push(l),
|
||||||
|
restartBase: "10 millis",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(logs.filter((l) => l.includes("restarting")).length).toBe(0);
|
||||||
|
} finally {
|
||||||
|
server.stop(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { SseAuthError, SseParser, sseFrames } from "../src/sse.js";
|
||||||
|
import type { ResolvedConfig } from "../src/config.js";
|
||||||
|
|
||||||
|
describe("SseParser", () => {
|
||||||
|
test("parses frames split across arbitrary chunks, skipping comments", () => {
|
||||||
|
const p = new SseParser();
|
||||||
|
let frames = p.push("id: 4\nevent: library.ch");
|
||||||
|
expect(frames.length).toBe(0);
|
||||||
|
frames = p.push('anged\ndata: {"seq":4}\n\n: keep-alive\n\nid: 5\n');
|
||||||
|
expect(frames.length).toBe(1);
|
||||||
|
expect(frames[0]).toEqual({ event: "library.changed", data: '{"seq":4}', id: "4" });
|
||||||
|
frames = p.push("data: x\n\n");
|
||||||
|
expect(frames.length).toBe(1);
|
||||||
|
expect(frames[0]?.id).toBe("5");
|
||||||
|
expect(frames[0]?.event).toBe("message");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("joins multi-line data and handles CRLF", () => {
|
||||||
|
const p = new SseParser();
|
||||||
|
const frames = p.push("data: a\r\ndata: b\r\n\r\n");
|
||||||
|
expect(frames[0]?.data).toBe("a\nb");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const cfgFor = (port: number, token = "t"): ResolvedConfig => ({
|
||||||
|
url: `http://127.0.0.1:${port}`,
|
||||||
|
token,
|
||||||
|
fetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sseFrames", () => {
|
||||||
|
test("reads frames, reconnects with Last-Event-ID after a server close", async () => {
|
||||||
|
const lastEventIds: Array<string | null> = [];
|
||||||
|
let connection = 0;
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch(req) {
|
||||||
|
lastEventIds.push(req.headers.get("last-event-id"));
|
||||||
|
connection += 1;
|
||||||
|
const first = connection === 1;
|
||||||
|
const body = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
if (first) {
|
||||||
|
controller.enqueue(enc.encode('id: 1\nevent: library.changed\ndata: {"seq":1}\n\n'));
|
||||||
|
controller.close(); // server closes → client must reconnect
|
||||||
|
} else {
|
||||||
|
controller.enqueue(enc.encode('id: 2\nevent: library.changed\ndata: {"seq":2}\n\n'));
|
||||||
|
// stay open
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return new Response(body, { headers: { "content-type": "text/event-stream" } });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const gen = sseFrames(cfgFor(server.port as number), { onWarning: () => {} });
|
||||||
|
const f1 = await gen.next();
|
||||||
|
expect(f1.value?.id).toBe("1");
|
||||||
|
const f2 = await gen.next(); // spans the reconnect
|
||||||
|
expect(f2.value?.id).toBe("2");
|
||||||
|
await gen.return(undefined);
|
||||||
|
// No `since` = live-tail-only: the first connect carries the beyond-tip cursor,
|
||||||
|
// the reconnect carries the last REAL id.
|
||||||
|
expect(lastEventIds[0]).toBe(String(Number.MAX_SAFE_INTEGER));
|
||||||
|
expect(lastEventIds[1]).toBe("1");
|
||||||
|
} finally {
|
||||||
|
server.stop(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("401 is terminal (no retry loop)", async () => {
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch: () => new Response("{}", { status: 401 }),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const gen = sseFrames(cfgFor(server.port as number), { onWarning: () => {} });
|
||||||
|
await expect(gen.next()).rejects.toBeInstanceOf(SseAuthError);
|
||||||
|
} finally {
|
||||||
|
server.stop(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user