46c0e0e483
apple / swift (push) Successful in 1m28s
release / apple (push) Successful in 6m9s
apple / screenshots (push) Successful in 4m39s
audit / bun-audit (push) Successful in 15s
audit / cargo-audit (push) Successful in 2m11s
ci / web (push) Successful in 57s
ci / docs-site (push) Successful in 1m10s
decky / build-publish (push) Successful in 18s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
ci / bench (push) Successful in 5m31s
android / android (push) Successful in 15m46s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
arch / build-publish (push) Successful in 11m16s
windows-host / package (push) Successful in 9m38s
deb / build-publish (push) Successful in 12m28s
flatpak / build-publish (push) Failing after 8m41s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m58s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m4s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m15s
ci / rust (push) Successful in 22m26s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m38s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m0s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m45s
docker / deploy-docs (push) Successful in 31s
hooks.json (RFC §6): commands and webhooks fired on host lifecycle events, managed over GET|PUT /api/v1/hooks (validated, applied immediately) and dispatched fire-and-forget by a bus-subscriber runner — hooks observe, never veto, and no operator code sits in any streaming path. - exec: detached sh -c with the event JSON on stdin + flat PF_EVENT_* env (the PF_STREAM_* vocabulary's sibling), per-hook timeout (default 30 s) with process-group kill, off-thread reap, per-hook debounce, bounded concurrency (8 in flight, excess dropped loudly). Windows runs hooks in the interactive user session (temp-file JSON argument; console-mode dev hosts get env + stdin like Unix). - webhook: POST the event JSON, TLS-verified, redirects never followed, no punktfunk credentials outbound; optional per-hook secret file yields X-Punktfunk-Signature: sha256=<hex HMAC> (fails closed if unreadable). - filters: exact-match client/fingerprint/plane/app + the same kind patterns as the SSE ?kinds= filter (shared crate::events::kind_matches). - hardening (RFC §9.1): hooks.json via the private-dir/secret-file helpers; a hook script path must be operator/root-owned and not group/world-writable or it is refused loudly (the sshd rule). - env mirrors PUNKTFUNK_ON_CONNECT_CMD / PUNKTFUNK_ON_DISCONNECT_CMD for the zero-config cases, beside PUNKTFUNK_RECOVER_SESSION_CMD. Live-verified on Linux: PUT config via API → library.changed fired a real script (env + stdin observed) and an HMAC webhook (receiver-verified signature); a chmod-777 script was refused. 342 host tests green (store/validation/filter/env-flatten/exec-timeout/ownership + routes), clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
527 lines
20 KiB
Rust
527 lines
20 KiB
Rust
//! Host lifecycle event bus (scripting-and-hooks RFC §4, phase 0).
|
|
//!
|
|
//! A process-wide broadcast bus + bounded catch-up ring for **lifecycle events**: client
|
|
//! connect/disconnect, session and stream start/end, pairing decisions, virtual-display
|
|
//! create/release, library mutations, host start/stop. Fire sites on BOTH planes call
|
|
//! [`emit`]; consumers ([`EventBus::subscribe`]) get a ring-backed catch-up plus a live
|
|
//! tail — the shape `GET /api/v1/events` (SSE, phase 1) and the hook runner (phase 2)
|
|
//! consume. Until those land, the bus is host-internal.
|
|
//!
|
|
//! Design notes (mirrors [`crate::log_capture`], the shipped ring precedent):
|
|
//! - Events carry a monotonically increasing `seq` (1-based) and a wall-clock `ts_ms`.
|
|
//! A consumer resumes with `since = last seen seq`; one that fell off the ring gets
|
|
//! `dropped = true` and should resync via the REST snapshots.
|
|
//! - The wire shape is **versioned and additive-only** within a major ([`SCHEMA_VERSION`]):
|
|
//! fields and kinds may be added, never removed or renamed. The JSON snapshot tests below
|
|
//! are the review gate — a failing snapshot IS a schema change.
|
|
//! - **Payload hygiene: events never carry secrets** — no PINs, no tokens, no key material.
|
|
//! Client names and fingerprints are fine (already exposed via the management API).
|
|
//! - Emission is fire-and-forget and cheap (a mutex push + a non-blocking broadcast send);
|
|
//! nothing here sits in a streaming hot path. Slow consumers lag (`RecvError::Lagged`)
|
|
//! rather than buffering unboundedly; the SSE layer disconnects them.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::VecDeque;
|
|
use std::sync::{Mutex, OnceLock};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
use tokio::sync::broadcast;
|
|
use utoipa::ToSchema;
|
|
|
|
/// Wire-shape major version, carried on every event. Additive-only within a major; removing
|
|
/// or renaming a field is a new major (and, at the API layer, a new endpoint negotiation).
|
|
pub const SCHEMA_VERSION: u32 = 1;
|
|
|
|
/// Catch-up ring capacity. Events are small (a few hundred bytes) and low-rate (lifecycle,
|
|
/// not per-frame), so 1024 spans hours of ordinary host activity.
|
|
const RING_CAPACITY: usize = 1024;
|
|
/// Live-tail channel depth per subscriber before a slow consumer starts lagging.
|
|
const BROADCAST_CAPACITY: usize = 256;
|
|
|
|
/// One host lifecycle event, as it will appear on the wire (`data:` of one SSE frame).
|
|
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
|
|
pub struct HostEvent {
|
|
/// Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.
|
|
pub seq: u64,
|
|
/// Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).
|
|
pub ts_ms: u64,
|
|
/// Wire-shape version ([`SCHEMA_VERSION`]).
|
|
pub schema: u32,
|
|
/// The event kind + payload, flattened: `"kind": "stream.started", …payload…`.
|
|
#[serde(flatten)]
|
|
pub kind: EventKind,
|
|
}
|
|
|
|
/// Which protocol plane an event originated from. Hooks and scripts filter on it — a hook
|
|
/// that fires for native clients but not Moonlight clients is a bug, not a v2 feature.
|
|
#[derive(Serialize, Deserialize, ToSchema, Clone, Copy, Debug, PartialEq, Eq)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum Plane {
|
|
/// The native punktfunk/1 plane (QUIC).
|
|
Native,
|
|
/// The GameStream/Moonlight compat plane (`--gamestream`).
|
|
Gamestream,
|
|
}
|
|
|
|
/// Why a client went away. `Quit` is a deliberate user "stop" (the typed close code);
|
|
/// `Timeout` is a transport idle timeout (the client vanished); `Error` is everything else.
|
|
#[derive(Serialize, Deserialize, ToSchema, Clone, Copy, Debug, PartialEq, Eq)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum DisconnectReason {
|
|
Quit,
|
|
Timeout,
|
|
Error,
|
|
}
|
|
|
|
/// The connecting/disconnecting client's identity.
|
|
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
|
|
pub struct ClientRef {
|
|
/// Client-supplied device name; may be empty (an anonymous or compat-plane client).
|
|
pub name: String,
|
|
/// Hex SHA-256 certificate fingerprint, when the client presented one.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub fingerprint: Option<String>,
|
|
pub plane: Plane,
|
|
}
|
|
|
|
/// A live A/V session (the plane-neutral notion the Dashboard shows).
|
|
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
|
|
pub struct SessionRef {
|
|
/// Host-local session id (unique within this host process).
|
|
pub id: u64,
|
|
/// Short client label (cert-fingerprint prefix, or peer IP for an anonymous client).
|
|
pub client: String,
|
|
/// Negotiated mode, `WxH@Hz` (e.g. `"3840x2160@120"`).
|
|
pub mode: String,
|
|
pub hdr: bool,
|
|
}
|
|
|
|
/// A live video stream (what the stream marker file reflects).
|
|
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
|
|
pub struct StreamRef {
|
|
/// Negotiated mode, `WxH@Hz`.
|
|
pub mode: String,
|
|
pub hdr: bool,
|
|
/// Client-supplied device name; may be empty.
|
|
pub client: String,
|
|
/// The launched app/title for this stream, when one was requested (store-qualified id on
|
|
/// the native plane, app title on the GameStream plane).
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub app: Option<String>,
|
|
pub plane: Plane,
|
|
}
|
|
|
|
/// A device in the pairing flow.
|
|
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
|
|
pub struct DeviceRef {
|
|
/// Sanitized device name (the pairing store's copy).
|
|
pub name: String,
|
|
/// Hex certificate fingerprint.
|
|
pub fingerprint: String,
|
|
pub plane: Plane,
|
|
}
|
|
|
|
/// The event catalog (RFC §4). Serialized internally tagged as `"kind": "<domain>.<verb>"`,
|
|
/// flattened into [`HostEvent`]. **Additive-only** within [`SCHEMA_VERSION`].
|
|
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
|
|
#[serde(tag = "kind")]
|
|
pub enum EventKind {
|
|
#[serde(rename = "client.connected")]
|
|
ClientConnected { client: ClientRef },
|
|
#[serde(rename = "client.disconnected")]
|
|
ClientDisconnected {
|
|
client: ClientRef,
|
|
reason: DisconnectReason,
|
|
},
|
|
#[serde(rename = "session.started")]
|
|
SessionStarted { session: SessionRef },
|
|
#[serde(rename = "session.ended")]
|
|
SessionEnded { session: SessionRef },
|
|
#[serde(rename = "stream.started")]
|
|
StreamStarted { stream: StreamRef },
|
|
#[serde(rename = "stream.stopped")]
|
|
StreamStopped { stream: StreamRef },
|
|
#[serde(rename = "pairing.pending")]
|
|
PairingPending { device: DeviceRef },
|
|
#[serde(rename = "pairing.completed")]
|
|
PairingCompleted { device: DeviceRef },
|
|
#[serde(rename = "pairing.denied")]
|
|
PairingDenied { device: DeviceRef },
|
|
#[serde(rename = "display.created")]
|
|
DisplayCreated {
|
|
/// The virtual-display backend that minted it (`VirtualDisplay::name`).
|
|
backend: String,
|
|
/// `WxH@Hz`.
|
|
mode: String,
|
|
},
|
|
#[serde(rename = "display.released")]
|
|
DisplayReleased {
|
|
/// How many kept displays this release retired.
|
|
count: u32,
|
|
},
|
|
#[serde(rename = "library.changed")]
|
|
LibraryChanged {
|
|
/// What mutated the library: `"manual"` today; a provider id once the provider
|
|
/// API (RFC §8) lands.
|
|
source: String,
|
|
},
|
|
#[serde(rename = "host.started")]
|
|
HostStarted {
|
|
version: String,
|
|
/// Whether the GameStream/Moonlight compat plane is enabled.
|
|
gamestream: bool,
|
|
},
|
|
#[serde(rename = "host.stopping")]
|
|
HostStopping,
|
|
}
|
|
|
|
impl EventKind {
|
|
/// The wire kind string (`"stream.started"`, …) — for filters and log lines.
|
|
pub fn name(&self) -> &'static str {
|
|
match self {
|
|
EventKind::ClientConnected { .. } => "client.connected",
|
|
EventKind::ClientDisconnected { .. } => "client.disconnected",
|
|
EventKind::SessionStarted { .. } => "session.started",
|
|
EventKind::SessionEnded { .. } => "session.ended",
|
|
EventKind::StreamStarted { .. } => "stream.started",
|
|
EventKind::StreamStopped { .. } => "stream.stopped",
|
|
EventKind::PairingPending { .. } => "pairing.pending",
|
|
EventKind::PairingCompleted { .. } => "pairing.completed",
|
|
EventKind::PairingDenied { .. } => "pairing.denied",
|
|
EventKind::DisplayCreated { .. } => "display.created",
|
|
EventKind::DisplayReleased { .. } => "display.released",
|
|
EventKind::LibraryChanged { .. } => "library.changed",
|
|
EventKind::HostStarted { .. } => "host.started",
|
|
EventKind::HostStopping => "host.stopping",
|
|
}
|
|
}
|
|
}
|
|
|
|
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.
|
|
pub fn mode_str(width: u32, height: u32, hz: u32) -> String {
|
|
format!("{width}x{height}@{hz}")
|
|
}
|
|
|
|
/// One consumer's view: the ring-backed catch-up plus a live-tail receiver, taken atomically
|
|
/// (no event can fall between `catch_up` and the first `rx.recv()`, and none is in both).
|
|
pub struct Subscription {
|
|
/// Events with `seq > since`, oldest first.
|
|
pub catch_up: Vec<HostEvent>,
|
|
/// True when events between `since` and the first caught-up one were already evicted —
|
|
/// the consumer should resync via the REST snapshots (the `LogPage.dropped` contract).
|
|
pub dropped: bool,
|
|
/// The live tail. A consumer that can't keep up sees `RecvError::Lagged`.
|
|
pub rx: broadcast::Receiver<HostEvent>,
|
|
}
|
|
|
|
/// The process-wide event bus: a bounded seq-numbered ring (catch-up) + a broadcast channel
|
|
/// (live tail).
|
|
pub struct EventBus {
|
|
inner: Mutex<Ring>,
|
|
tx: broadcast::Sender<HostEvent>,
|
|
}
|
|
|
|
struct Ring {
|
|
events: VecDeque<HostEvent>,
|
|
next_seq: u64,
|
|
}
|
|
|
|
impl EventBus {
|
|
fn new() -> Self {
|
|
let (tx, _) = broadcast::channel(BROADCAST_CAPACITY);
|
|
Self {
|
|
inner: Mutex::new(Ring {
|
|
events: VecDeque::with_capacity(RING_CAPACITY),
|
|
next_seq: 1,
|
|
}),
|
|
tx,
|
|
}
|
|
}
|
|
|
|
/// Stamp, ring-buffer, and broadcast one event. Fire-and-forget: no receivers is fine
|
|
/// (the ring still records it for a later subscriber's catch-up).
|
|
pub fn emit(&self, kind: EventKind) {
|
|
let ts_ms = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_millis() as u64)
|
|
.unwrap_or(0);
|
|
let mut ring = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
|
let ev = HostEvent {
|
|
seq: ring.next_seq,
|
|
ts_ms,
|
|
schema: SCHEMA_VERSION,
|
|
kind,
|
|
};
|
|
ring.next_seq += 1;
|
|
if ring.events.len() == RING_CAPACITY {
|
|
ring.events.pop_front();
|
|
}
|
|
ring.events.push_back(ev.clone());
|
|
// Send while still holding the ring lock: it serializes with `subscribe` (which also
|
|
// takes the lock), so an event lands either in a subscriber's catch-up or on its live
|
|
// tail — never both, never neither. `send` is non-blocking, the hold is trivial.
|
|
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
|
|
/// returned receiver carries everything after. `since = 0` means "from the ring start".
|
|
pub fn subscribe(&self, since: u64) -> Subscription {
|
|
let ring = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
|
let rx = self.tx.subscribe();
|
|
let first_seq = ring.events.front().map_or(ring.next_seq, |e| e.seq);
|
|
let dropped = since != 0 && since.saturating_add(1) < first_seq;
|
|
let catch_up = ring
|
|
.events
|
|
.iter()
|
|
.filter(|e| e.seq > since)
|
|
.cloned()
|
|
.collect();
|
|
Subscription {
|
|
catch_up,
|
|
dropped,
|
|
rx,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The process-wide bus — a `OnceLock` singleton (the [`crate::log_capture::ring`] shape) so
|
|
/// fire sites across both planes and the API layer share it without threading an `Arc`.
|
|
pub fn bus() -> &'static EventBus {
|
|
static BUS: OnceLock<EventBus> = OnceLock::new();
|
|
BUS.get_or_init(EventBus::new)
|
|
}
|
|
|
|
/// Emit one lifecycle event on the process-wide bus. Cheap and non-blocking — safe from any
|
|
/// thread, including RAII `Drop` paths.
|
|
pub fn emit(kind: EventKind) {
|
|
bus().emit(kind);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn ev(name: &str) -> EventKind {
|
|
EventKind::LibraryChanged {
|
|
source: name.to_string(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn seq_is_monotonic_and_catch_up_resumes() {
|
|
let bus = EventBus::new();
|
|
for i in 0..5 {
|
|
bus.emit(ev(&format!("m{i}")));
|
|
}
|
|
let sub = bus.subscribe(0);
|
|
assert_eq!(
|
|
sub.catch_up.iter().map(|e| e.seq).collect::<Vec<_>>(),
|
|
vec![1, 2, 3, 4, 5]
|
|
);
|
|
assert!(!sub.dropped);
|
|
assert!(sub.catch_up.iter().all(|e| e.schema == SCHEMA_VERSION));
|
|
|
|
// Resume from a cursor mid-ring.
|
|
let sub = bus.subscribe(3);
|
|
assert_eq!(
|
|
sub.catch_up.iter().map(|e| e.seq).collect::<Vec<_>>(),
|
|
vec![4, 5]
|
|
);
|
|
assert!(!sub.dropped);
|
|
|
|
// Cursor at the tip: empty catch-up, not a gap.
|
|
let sub = bus.subscribe(5);
|
|
assert!(sub.catch_up.is_empty());
|
|
assert!(!sub.dropped);
|
|
}
|
|
|
|
#[test]
|
|
fn eviction_reports_dropped() {
|
|
let bus = EventBus::new();
|
|
for i in 0..(RING_CAPACITY + 50) {
|
|
bus.emit(ev(&format!("m{i}")));
|
|
}
|
|
// Seqs 1..=50 were evicted; a cursor inside the gap must flag it.
|
|
let sub = bus.subscribe(10);
|
|
assert!(sub.dropped);
|
|
assert_eq!(sub.catch_up.first().map(|e| e.seq), Some(51));
|
|
// A fresh consumer (since = 0) is a backfill, not a gap.
|
|
let sub = bus.subscribe(0);
|
|
assert!(!sub.dropped);
|
|
assert_eq!(sub.catch_up.len(), RING_CAPACITY);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn live_tail_continues_exactly_after_catch_up() {
|
|
let bus = EventBus::new();
|
|
bus.emit(ev("before-1"));
|
|
bus.emit(ev("before-2"));
|
|
let mut sub = bus.subscribe(0);
|
|
assert_eq!(sub.catch_up.len(), 2);
|
|
// Emitted after subscribe → on the live tail only, starting at exactly seq 3.
|
|
bus.emit(ev("after"));
|
|
let live = sub.rx.recv().await.expect("live event");
|
|
assert_eq!(live.seq, 3);
|
|
assert_eq!(live.kind.name(), "library.changed");
|
|
// Nothing duplicated: the tail holds only what wasn't in the catch-up.
|
|
assert!(sub.rx.try_recv().is_err());
|
|
}
|
|
|
|
/// The wire shape IS the contract (additive-only, RFC §4): these snapshots are the review
|
|
/// gate — if one fails, the change renames/removes a field and needs a schema-version bump,
|
|
/// not a test update.
|
|
#[test]
|
|
fn wire_shape_snapshots() {
|
|
let ev = HostEvent {
|
|
seq: 4182,
|
|
ts_ms: 1_700_000_000_000,
|
|
schema: 1,
|
|
kind: EventKind::StreamStarted {
|
|
stream: StreamRef {
|
|
mode: mode_str(3840, 2160, 120),
|
|
hdr: true,
|
|
client: "Living Room TV".into(),
|
|
app: Some("steam:570".into()),
|
|
plane: Plane::Native,
|
|
},
|
|
},
|
|
};
|
|
assert_eq!(
|
|
serde_json::to_string(&ev).unwrap(),
|
|
r#"{"seq":4182,"ts_ms":1700000000000,"schema":1,"kind":"stream.started","stream":{"mode":"3840x2160@120","hdr":true,"client":"Living Room TV","app":"steam:570","plane":"native"}}"#
|
|
);
|
|
|
|
let ev = HostEvent {
|
|
seq: 1,
|
|
ts_ms: 1_700_000_000_000,
|
|
schema: 1,
|
|
kind: EventKind::ClientDisconnected {
|
|
client: ClientRef {
|
|
name: "Deck".into(),
|
|
fingerprint: Some("b1c2".into()),
|
|
plane: Plane::Gamestream,
|
|
},
|
|
reason: DisconnectReason::Timeout,
|
|
},
|
|
};
|
|
assert_eq!(
|
|
serde_json::to_string(&ev).unwrap(),
|
|
r#"{"seq":1,"ts_ms":1700000000000,"schema":1,"kind":"client.disconnected","client":{"name":"Deck","fingerprint":"b1c2","plane":"gamestream"},"reason":"timeout"}"#
|
|
);
|
|
|
|
let ev = HostEvent {
|
|
seq: 2,
|
|
ts_ms: 1_700_000_000_000,
|
|
schema: 1,
|
|
kind: EventKind::HostStopping,
|
|
};
|
|
assert_eq!(
|
|
serde_json::to_string(&ev).unwrap(),
|
|
r#"{"seq":2,"ts_ms":1700000000000,"schema":1,"kind":"host.stopping"}"#
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wire_shape_roundtrips() {
|
|
let ev = HostEvent {
|
|
seq: 7,
|
|
ts_ms: 3,
|
|
schema: 1,
|
|
kind: EventKind::PairingPending {
|
|
device: DeviceRef {
|
|
name: "iPad Pro".into(),
|
|
fingerprint: "ab12".into(),
|
|
plane: Plane::Native,
|
|
},
|
|
},
|
|
};
|
|
let json = serde_json::to_string(&ev).unwrap();
|
|
let back: HostEvent = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(back.seq, 7);
|
|
assert_eq!(back.kind.name(), "pairing.pending");
|
|
match back.kind {
|
|
EventKind::PairingPending { device } => {
|
|
assert_eq!(device.name, "iPad Pro");
|
|
assert_eq!(device.plane, Plane::Native);
|
|
}
|
|
other => panic!("wrong kind: {other:?}"),
|
|
}
|
|
}
|
|
}
|