diff --git a/crates/punktfunk-host/src/gamestream/mod.rs b/crates/punktfunk-host/src/gamestream/mod.rs index 6db65e9..93aeb77 100644 --- a/crates/punktfunk-host/src/gamestream/mod.rs +++ b/crates/punktfunk-host/src/gamestream/mod.rs @@ -144,17 +144,30 @@ impl AppState { } } -/// Run the GameStream control plane (blocks): mDNS advertisement, the nvhttp servers, and -/// the management REST API. -pub fn serve(mgmt: crate::mgmt::Options) -> Result<()> { +/// Run the host (blocks): mDNS, the nvhttp servers, and the management REST API. +/// `native_port = Some(p)` makes this the **unified** host — it also runs the native punktfunk/1 +/// QUIC server on `p` in the same process, sharing one [`crate::native_pairing`] handle with the +/// management API so the web console can arm pairing and show the PIN. `None` = GameStream only +/// (the mgmt API's native endpoints report `enabled: false`). +pub fn serve(mgmt: crate::mgmt::Options, native_port: Option) -> Result<()> { let host = Host::detect()?; let identity = cert::ServerIdentity::load_or_create().context("host certificate")?; let state = Arc::new(AppState::new(host, identity)); + // The shared native-pairing handle exists only when we run the native host; it links the QUIC + // ceremony and the management API. + let native = match native_port { + Some(_) => Some(Arc::new( + crate::native_pairing::NativePairing::load_with(None, None, false) + .context("native pairing store")?, + )), + None => None, + }; tracing::info!( hostname = %state.host.hostname, uniqueid = %state.host.uniqueid, ip = %state.host.local_ip, - "punktfunk GameStream host (P1.1: serverinfo + pairing + mDNS)" + native = native_port.is_some(), + "punktfunk host (GameStream P1.1: serverinfo + pairing + mDNS)" ); let rt = tokio::runtime::Runtime::new().context("build tokio runtime")?; rt.block_on(async move { @@ -163,7 +176,22 @@ pub fn serve(mgmt: crate::mgmt::Options) -> Result<()> { let _advert = mdns::advertise(&state.host).context("mDNS advertise")?; rtsp::spawn(state.clone()).context("start RTSP server")?; control::spawn(state.clone()).context("start ENet control server")?; - tokio::try_join!(nvhttp::run(state.clone()), crate::mgmt::run(state, mgmt))?; + match (native_port, native) { + (Some(port), Some(np)) => { + tracing::info!(port, "unified host: also serving native punktfunk/1 (QUIC)"); + tokio::try_join!( + nvhttp::run(state.clone()), + crate::mgmt::run(state.clone(), mgmt, Some(np.clone())), + crate::m3::serve(crate::m3::native_serve_opts(port), np), + )?; + } + _ => { + tokio::try_join!( + nvhttp::run(state.clone()), + crate::mgmt::run(state, mgmt, None) + )?; + } + } Ok(()) }) } diff --git a/crates/punktfunk-host/src/m3.rs b/crates/punktfunk-host/src/m3.rs index 1df5f20..f33bf9d 100644 --- a/crates/punktfunk-host/src/m3.rs +++ b/crates/punktfunk-host/src/m3.rs @@ -115,7 +115,24 @@ fn fingerprint_hex(fp: &[u8; 32]) -> String { /// served one at a time (the virtual output + NVENC are single-tenant); a client that /// connects mid-session waits in the accept queue. A failed session logs and the loop /// keeps serving — only endpoint-level failures are fatal. -async fn serve(opts: M3Options, np: Arc) -> Result<()> { +/// Default options for the native host when the unified `serve --native` runs it in-process: +/// real virtual capture, persistent (no session/duration cut), pairing armed on demand via the +/// management API (the shared [`NativePairing`] starts disarmed). +pub(crate) fn native_serve_opts(port: u16) -> M3Options { + M3Options { + port, + source: M3Source::Virtual, + seconds: 7 * 24 * 3600, // per-session cap; large enough not to cut a live stream + frames: 0, + max_sessions: 0, + require_pairing: false, + allow_pairing: false, + pairing_pin: None, + paired_store: None, + } +} + +pub(crate) async fn serve(opts: M3Options, np: Arc) -> Result<()> { let identity = crate::gamestream::cert::ServerIdentity::load_or_create() .context("load host identity (~/.config/punktfunk)")?; let fingerprint = endpoint::fingerprint_of_pem(&identity.cert_pem) diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index a5444bc..76fbce2 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -56,8 +56,12 @@ fn real_main() -> Result<()> { let args: Vec = std::env::args().skip(1).collect(); match args.first().map(String::as_str) { - // M2 GameStream host control plane (P1.1: mDNS + serverinfo) + management API. - Some("serve") => gamestream::serve(parse_serve(&args[1..])?), + // GameStream host control plane (P1.1: mDNS + serverinfo) + management API, and (with + // --native) the native punktfunk/1 host in the same process — the unified host. + Some("serve") => { + let (mgmt_opts, native_port) = parse_serve(&args[1..])?; + gamestream::serve(mgmt_opts, native_port) + } // Print the management API's OpenAPI document (for client codegen). Some("openapi") => { print!("{}", mgmt::openapi_json()); @@ -220,9 +224,12 @@ fn input_test() -> Result<()> { bail!("input-test requires Linux") } -/// `serve` options — all about the management API; the GameStream ports are protocol-fixed. -fn parse_serve(args: &[String]) -> Result { +/// `serve` options: the management API (GameStream ports are protocol-fixed) + whether to also run +/// the native punktfunk/1 host in-process (`--native`, the unified host). Returns the mgmt options +/// and the native QUIC port (`None` = GameStream only). +fn parse_serve(args: &[String]) -> Result<(mgmt::Options, Option)> { let mut opts = mgmt::Options::default(); + let mut native_port: Option = None; let mut i = 0; while i < args.len() { let arg = args[i].as_str(); @@ -248,6 +255,16 @@ fn parse_serve(args: &[String]) -> Result { } opts.token = Some(token); } + // Also run the native punktfunk/1 (QUIC) host in this process — the unified host. + // Pairing is then armed on demand from the management API / web console. + "--native" => native_port = Some(native_port.unwrap_or(9777)), + "--native-port" => { + native_port = Some( + next()? + .parse() + .map_err(|_| anyhow::anyhow!("bad --native-port (want a port number)"))?, + ) + } "-h" | "--help" => { print_usage(); std::process::exit(0); @@ -262,7 +279,7 @@ fn parse_serve(args: &[String]) -> Result { .ok() .filter(|t| !t.is_empty()); } - Ok(opts) + Ok((opts, native_port)) } fn parse_m0(args: &[String]) -> Result { @@ -378,6 +395,9 @@ SERVE OPTIONS: --mgmt-bind management API address (default: 127.0.0.1:47990) --mgmt-token bearer token for the management API (or PUNKTFUNK_MGMT_TOKEN); required when --mgmt-bind is not loopback + --native also run the native punktfunk/1 (QUIC) host in this process — + the unified host; pairing is armed from the management API/console + --native-port native QUIC port (default 9777; implies --native) M3-HOST OPTIONS: --port QUIC listen port (default: 9777) diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index ef33c9e..42c3789 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -61,13 +61,21 @@ impl Default for Options { /// Axum state for the management routes: the shared control-plane state + auth config. struct MgmtState { app: Arc, + /// Native (punktfunk/1) pairing — shared with the QUIC host when the unified `serve --native` + /// runs it. `None` ⇒ GameStream-only host (the native endpoints report `enabled: false`). + native: Option>, token: Option, /// The port we serve on, echoed in [`PortMap`] so a client can persist a full endpoint map. port: u16, } -/// Run the management API server (control plane; spawned alongside the nvhttp servers). -pub async fn run(state: Arc, opts: Options) -> Result<()> { +/// Run the management API server (control plane; spawned alongside the nvhttp servers). `native` +/// is the shared punktfunk/1 pairing handle when the unified host runs the native QUIC server. +pub async fn run( + state: Arc, + opts: Options, + native: Option>, +) -> Result<()> { // A blank token is no token: it must neither satisfy the non-loopback guard below nor // become a credential an empty `Authorization: Bearer ` header would match. let token = opts.token.filter(|t| !t.trim().is_empty()); @@ -83,7 +91,7 @@ pub async fn run(state: Arc, opts: Options) -> Result<()> { auth = if token.is_some() { "bearer" } else { "none (loopback)" }, "management API listening (docs at /api/docs, spec at /api/v1/openapi.json)" ); - let app = app(state, token, opts.bind.port()); + let app = app(state, token, opts.bind.port(), native); axum_server::bind(opts.bind) .serve(app.into_make_service()) .await @@ -91,9 +99,15 @@ pub async fn run(state: Arc, opts: Options) -> Result<()> { } /// Compose the full management router (also used directly by the handler tests). -fn app(state: Arc, token: Option, port: u16) -> Router { +fn app( + state: Arc, + token: Option, + port: u16, + native: Option>, +) -> Router { let shared = Arc::new(MgmtState { app: state, + native, token, port, }); @@ -126,6 +140,11 @@ fn api_router_parts() -> (Router>, utoipa::openapi::OpenApi) { .routes(routes!(unpair_client)) .routes(routes!(get_pairing_status)) .routes(routes!(submit_pairing_pin)) + .routes(routes!(get_native_pairing)) + .routes(routes!(arm_native_pairing)) + .routes(routes!(disarm_native_pairing)) + .routes(routes!(list_native_clients)) + .routes(routes!(unpair_native_client)) .routes(routes!(stop_session)) .routes(routes!(request_idr)), ) @@ -156,6 +175,7 @@ pub fn openapi_json() -> String { (name = "host", description = "Host identity, capabilities, and liveness"), (name = "clients", description = "Paired Moonlight client management"), (name = "pairing", description = "Pairing PIN delivery (the out-of-band half of the GameStream pairing handshake)"), + (name = "native", description = "Native punktfunk/1 pairing: arm a window, display the host PIN, manage paired devices"), (name = "session", description = "Active streaming session control"), ) )] @@ -323,6 +343,42 @@ struct SubmitPin { pin: String, } +/// Native (punktfunk/1) pairing status. Unlike GameStream, the **host** mints the PIN (the SPAKE2 +/// ceremony needs it client-side first), so the console **displays** `pin` for the user to enter on +/// their device — armed on demand for a short window. +#[derive(Serialize, ToSchema)] +struct NativePairStatus { + /// Whether the native host is running (the unified host started with `--native`). + enabled: bool, + /// True while a pairing window is open. + armed: bool, + /// The PIN to display while armed (null when disarmed). + #[schema(example = "1234")] + pin: Option, + /// Seconds left in the window (null = disarmed, or armed with no expiry via the CLI flag). + expires_in_secs: Option, + /// Number of paired native clients. + paired_clients: u32, +} + +/// Arm-native-pairing request body. +#[derive(Deserialize, ToSchema)] +struct ArmNativePairing { + /// Window length in seconds (default 120; clamped to 15–600). + #[schema(example = 120)] + ttl_secs: Option, +} + +/// A paired native (punktfunk/1) client. +#[derive(Serialize, ToSchema)] +struct NativeClient { + /// The name the client supplied when pairing. + #[schema(example = "Living Room iPad")] + name: String, + /// Hex SHA-256 of the client certificate — its stable id here. + fingerprint: String, +} + /// Error envelope for every non-2xx response. #[derive(Serialize, Deserialize, ToSchema)] struct ApiError { @@ -667,6 +723,168 @@ async fn submit_pairing_pin( StatusCode::NO_CONTENT.into_response() } +fn native_status(st: &MgmtState) -> NativePairStatus { + match &st.native { + Some(np) => { + let s = np.status(); + NativePairStatus { + enabled: true, + armed: s.armed, + pin: s.pin, + expires_in_secs: s.expires_in_secs, + paired_clients: s.paired_clients, + } + } + None => NativePairStatus { + enabled: false, + armed: false, + pin: None, + expires_in_secs: None, + paired_clients: 0, + }, + } +} + +/// Native pairing status +/// +/// The native (punktfunk/1) pairing window. Poll while armed to show the PIN + countdown. +/// `enabled: false` means this host runs GameStream only (no `--native`). +#[utoipa::path( + get, + path = "/native/pair", + tag = "native", + operation_id = "getNativePairing", + responses( + (status = OK, description = "Native pairing status", body = NativePairStatus), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +async fn get_native_pairing(State(st): State>) -> Json { + Json(native_status(&st)) +} + +/// Arm native pairing +/// +/// Opens a pairing window and mints a fresh PIN to display. The user enters it on their device +/// within `ttl_secs`; the device then appears in the native client list. +#[utoipa::path( + post, + path = "/native/pair/arm", + tag = "native", + operation_id = "armNativePairing", + request_body = ArmNativePairing, + responses( + (status = OK, description = "Pairing armed; the response carries the PIN to display", body = NativePairStatus), + (status = SERVICE_UNAVAILABLE, description = "Native host not enabled (run `serve --native`)", body = ApiError), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +async fn arm_native_pairing( + State(st): State>, + ApiJson(req): ApiJson, +) -> Response { + let Some(np) = &st.native else { + return api_error( + StatusCode::SERVICE_UNAVAILABLE, + "native host not enabled (run `serve --native`)", + ); + }; + let ttl = req.ttl_secs.unwrap_or(120).clamp(15, 600); + let _pin = np.arm(std::time::Duration::from_secs(ttl as u64)); + tracing::info!(ttl_secs = ttl, "management API: native pairing armed"); + Json(native_status(&st)).into_response() +} + +/// Disarm native pairing +/// +/// Closes the pairing window immediately (no new ceremonies accepted). +#[utoipa::path( + delete, + path = "/native/pair", + tag = "native", + operation_id = "disarmNativePairing", + responses( + (status = NO_CONTENT, description = "Pairing disarmed"), + (status = SERVICE_UNAVAILABLE, description = "Native host not enabled", body = ApiError), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +async fn disarm_native_pairing(State(st): State>) -> Response { + let Some(np) = &st.native else { + return api_error(StatusCode::SERVICE_UNAVAILABLE, "native host not enabled"); + }; + np.disarm(); + StatusCode::NO_CONTENT.into_response() +} + +/// List native paired clients +#[utoipa::path( + get, + path = "/native/clients", + tag = "native", + operation_id = "listNativeClients", + responses( + (status = OK, description = "Paired native clients", body = [NativeClient]), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +async fn list_native_clients(State(st): State>) -> Json> { + let clients = match &st.native { + Some(np) => np + .list() + .into_iter() + .map(|c| NativeClient { + name: c.name, + fingerprint: c.fingerprint, + }) + .collect(), + None => Vec::new(), + }; + Json(clients) +} + +/// Unpair a native client +/// +/// Removes a punktfunk/1 client from the native trust store by fingerprint. +#[utoipa::path( + delete, + path = "/native/clients/{fingerprint}", + tag = "native", + operation_id = "unpairNativeClient", + params( + ("fingerprint" = String, Path, + description = "Hex SHA-256 of the client certificate (case-insensitive)") + ), + responses( + (status = NO_CONTENT, description = "Client unpaired"), + (status = SERVICE_UNAVAILABLE, description = "Native host not enabled", body = ApiError), + (status = NOT_FOUND, description = "No paired native client with that fingerprint", body = ApiError), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +async fn unpair_native_client( + State(st): State>, + Path(fingerprint): Path, +) -> Response { + let Some(np) = &st.native else { + return api_error(StatusCode::SERVICE_UNAVAILABLE, "native host not enabled"); + }; + match np.remove(&fingerprint) { + Ok(true) => { + tracing::info!(fingerprint, "management API: native client unpaired"); + StatusCode::NO_CONTENT.into_response() + } + Ok(false) => api_error( + StatusCode::NOT_FOUND, + "no paired native client with that fingerprint", + ), + Err(e) => api_error( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("could not persist trust store: {e}"), + ), + } +} + /// Stop the active session /// /// Kicks the connected client: stops the video/audio stream threads and clears the launch @@ -739,7 +957,14 @@ mod tests { } fn test_app(state: Arc, token: Option<&str>) -> Router { - app(state, token.map(String::from), DEFAULT_PORT) + app(state, token.map(String::from), DEFAULT_PORT, None) + } + + fn test_app_native( + state: Arc, + np: Arc, + ) -> Router { + app(state, None, DEFAULT_PORT, Some(np)) } async fn send(app: &Router, req: axum::http::Request) -> (StatusCode, serde_json::Value) { @@ -957,7 +1182,7 @@ mod tests { bind: "0.0.0.0:0".parse().unwrap(), token: Some(" ".into()), }; - let err = run(test_state(), opts).await.unwrap_err(); + let err = run(test_state(), opts, None).await.unwrap_err(); assert!(err.to_string().contains("not loopback"), "{err}"); } @@ -1048,4 +1273,89 @@ mod tests { cargo run -p punktfunk-host -- openapi > docs/api/openapi.json" ); } + + fn post_json(path: &str, body: serde_json::Value) -> axum::http::Request { + axum::http::Request::post(path) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap() + } + + #[tokio::test] + async fn native_pairing_arm_show_and_unpair() { + let np = Arc::new( + crate::native_pairing::NativePairing::load_with( + Some(std::env::temp_dir().join(format!("pf-mgmt-np-{}.json", std::process::id()))), + None, + false, + ) + .unwrap(), + ); + let app = test_app_native(test_state(), np.clone()); + + // Disarmed: enabled, not armed, no PIN. + let (s, b) = send(&app, get_req("/api/v1/native/pair")).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(b["enabled"], true); + assert_eq!(b["armed"], false); + assert!(b["pin"].is_null()); + + // Arm → a PIN appears and is readable via status. + let (s, b) = send( + &app, + post_json( + "/api/v1/native/pair/arm", + serde_json::json!({"ttl_secs": 60}), + ), + ) + .await; + assert_eq!(s, StatusCode::OK); + assert_eq!(b["armed"], true); + let pin = b["pin"].as_str().unwrap().to_string(); + assert_eq!(pin.len(), 4); + let (_, b) = send(&app, get_req("/api/v1/native/pair")).await; + assert_eq!(b["pin"], pin); + assert!(b["expires_in_secs"].as_u64().unwrap() <= 60); + + // The QUIC side would read the same live PIN. + assert_eq!(np.current_pin().as_deref(), Some(pin.as_str())); + + // Pair a client out-of-band, then it shows in the list + can be unpaired. + np.add("Test Device", "abc123").unwrap(); + let (s, b) = send(&app, get_req("/api/v1/native/clients")).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(b[0]["name"], "Test Device"); + assert_eq!(b[0]["fingerprint"], "abc123"); + let del = axum::http::Request::delete("/api/v1/native/clients/ABC123") + .body(Body::empty()) + .unwrap(); + assert_eq!(send(&app, del).await.0, StatusCode::NO_CONTENT); + let missing = axum::http::Request::delete("/api/v1/native/clients/abc123") + .body(Body::empty()) + .unwrap(); + assert_eq!(send(&app, missing).await.0, StatusCode::NOT_FOUND); + + // Disarm clears the window. + let del = axum::http::Request::delete("/api/v1/native/pair") + .body(Body::empty()) + .unwrap(); + assert_eq!(send(&app, del).await.0, StatusCode::NO_CONTENT); + let (_, b) = send(&app, get_req("/api/v1/native/pair")).await; + assert_eq!(b["armed"], false); + } + + #[tokio::test] + async fn native_endpoints_report_disabled_without_native_host() { + let app = test_app(test_state(), None); + let (s, b) = send(&app, get_req("/api/v1/native/pair")).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(b["enabled"], false); + // Arming a host that isn't running the native server is a 503. + let (s, _) = send( + &app, + post_json("/api/v1/native/pair/arm", serde_json::json!({})), + ) + .await; + assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE); + } } diff --git a/docs/api/openapi.json b/docs/api/openapi.json index d3cca74..59c04ea 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -194,6 +194,213 @@ } } }, + "/api/v1/native/clients": { + "get": { + "tags": [ + "native" + ], + "summary": "List native paired clients", + "operationId": "listNativeClients", + "responses": { + "200": { + "description": "Paired native clients", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NativeClient" + } + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/native/clients/{fingerprint}": { + "delete": { + "tags": [ + "native" + ], + "summary": "Unpair a native client", + "description": "Removes a punktfunk/1 client from the native trust store by fingerprint.", + "operationId": "unpairNativeClient", + "parameters": [ + { + "name": "fingerprint", + "in": "path", + "description": "Hex SHA-256 of the client certificate (case-insensitive)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Client unpaired" + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No paired native client with that fingerprint", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "503": { + "description": "Native host not enabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/native/pair": { + "get": { + "tags": [ + "native" + ], + "summary": "Native pairing status", + "description": "The native (punktfunk/1) pairing window. Poll while armed to show the PIN + countdown.\n`enabled: false` means this host runs GameStream only (no `--native`).", + "operationId": "getNativePairing", + "responses": { + "200": { + "description": "Native pairing status", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NativePairStatus" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "native" + ], + "summary": "Disarm native pairing", + "description": "Closes the pairing window immediately (no new ceremonies accepted).", + "operationId": "disarmNativePairing", + "responses": { + "204": { + "description": "Pairing disarmed" + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "503": { + "description": "Native host not enabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/native/pair/arm": { + "post": { + "tags": [ + "native" + ], + "summary": "Arm native pairing", + "description": "Opens a pairing window and mints a fresh PIN to display. The user enters it on their device\nwithin `ttl_secs`; the device then appears in the native client list.", + "operationId": "armNativePairing", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArmNativePairing" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Pairing armed; the response carries the PIN to display", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NativePairStatus" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "503": { + "description": "Native host not enabled (run `serve --native`)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/v1/pair": { "get": { "tags": [ @@ -416,6 +623,22 @@ } } }, + "ArmNativePairing": { + "type": "object", + "description": "Arm-native-pairing request body.", + "properties": { + "ttl_secs": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Window length in seconds (default 120; clamped to 15–600).", + "example": 120, + "minimum": 0 + } + } + }, "AvailableCompositor": { "type": "object", "description": "A compositor backend the host can drive a virtual output on, and whether it's usable now.", @@ -526,6 +749,67 @@ } } }, + "NativeClient": { + "type": "object", + "description": "A paired native (punktfunk/1) client.", + "required": [ + "name", + "fingerprint" + ], + "properties": { + "fingerprint": { + "type": "string", + "description": "Hex SHA-256 of the client certificate — its stable id here." + }, + "name": { + "type": "string", + "description": "The name the client supplied when pairing.", + "example": "Living Room iPad" + } + } + }, + "NativePairStatus": { + "type": "object", + "description": "Native (punktfunk/1) pairing status. Unlike GameStream, the **host** mints the PIN (the SPAKE2\nceremony needs it client-side first), so the console **displays** `pin` for the user to enter on\ntheir device — armed on demand for a short window.", + "required": [ + "enabled", + "armed", + "paired_clients" + ], + "properties": { + "armed": { + "type": "boolean", + "description": "True while a pairing window is open." + }, + "enabled": { + "type": "boolean", + "description": "Whether the native host is running (the unified host started with `--native`)." + }, + "expires_in_secs": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Seconds left in the window (null = disarmed, or armed with no expiry via the CLI flag).", + "minimum": 0 + }, + "paired_clients": { + "type": "integer", + "format": "int32", + "description": "Number of paired native clients.", + "minimum": 0 + }, + "pin": { + "type": [ + "string", + "null" + ], + "description": "The PIN to display while armed (null when disarmed).", + "example": "1234" + } + } + }, "PairedClient": { "type": "object", "description": "A paired (certificate-pinned) Moonlight client.", @@ -797,6 +1081,10 @@ "name": "pairing", "description": "Pairing PIN delivery (the out-of-band half of the GameStream pairing handshake)" }, + { + "name": "native", + "description": "Native punktfunk/1 pairing: arm a window, display the host PIN, manage paired devices" + }, { "name": "session", "description": "Active streaming session control"