Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 265554b755 | |||
| cb7091e1d5 | |||
| dd462787ec | |||
| 63efe0ecd5 | |||
| 384f8e00aa | |||
| 46c0e0e483 | |||
| f7ca641d76 | |||
| 2067b5ac81 | |||
| 09600163e2 | |||
| ea23408d1d | |||
| 9bc70e59fc | |||
| 393b47a062 | |||
| 329cf7b5d5 | |||
| 68bcfdac3e | |||
| ff55d0a608 |
Generated
+1
@@ -3106,6 +3106,7 @@ dependencies = [
|
||||
"ffmpeg-next",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"hmac",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
|
||||
+762
-2
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.11.0"
|
||||
"version": "0.12.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -587,6 +587,84 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/events": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"events"
|
||||
],
|
||||
"summary": "Stream host lifecycle events (SSE)",
|
||||
"description": "Server-Sent Events stream of the host's lifecycle events: client connect/disconnect, session\nand stream start/end, pairing decisions, display create/release, library changes, host\nstart/stop — both protocol planes. Frames carry `id:` = the event's monotonic `seq`,\n`event:` = its kind, and `data:` = the event JSON (schema-versioned, additive-only).\n\nResume: standard `Last-Event-ID` (or `?since=`) replays from the in-memory ring; a consumer\nthat fell off the ring receives an `event: dropped` frame first and should resync via the\nREST snapshots. Keep-alive comments are sent every 15 s.",
|
||||
"operationId": "streamEvents",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "since",
|
||||
"in": "query",
|
||||
"description": "Resume cursor: only events with `seq` greater than this are sent (the ring keeps the newest ~1024). `Last-Event-ID` takes precedence.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "kinds",
|
||||
"in": "query",
|
||||
"description": "Comma-separated server-side kind filter: exact kinds (`pairing.pending`) or `domain.*` prefixes (`stream.*`).",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Last-Event-ID",
|
||||
"in": "header",
|
||||
"description": "SSE auto-reconnect cursor — the `id:` of the last received frame.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "SSE stream; each frame's `data:` is one HostEvent",
|
||||
"content": {
|
||||
"text/event-stream": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HostEvent"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Concurrent event-stream cap reached — retry shortly",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/gpus": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -706,6 +784,98 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/hooks": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"hooks"
|
||||
],
|
||||
"summary": "Get the hook configuration",
|
||||
"description": "The operator's `hooks.json`: commands and webhooks fired on host lifecycle events. Empty\nwhen unconfigured.",
|
||||
"operationId": "getHooks",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The stored hook configuration",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HooksConfig"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"tags": [
|
||||
"hooks"
|
||||
],
|
||||
"summary": "Replace the hook configuration",
|
||||
"description": "Validates and persists a full `hooks.json` document (this is a whole-document PUT, not a\npatch). Applies from the next event — no restart. Hook commands run as the host user\n(interactive user session on Windows): treat this configuration as operator-privileged.",
|
||||
"operationId": "setHooks",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HooksConfig"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Configuration stored; the new state",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HooksConfig"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Structurally invalid configuration",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Configuration could not be persisted",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/host": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -2397,6 +2567,30 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ClientRef": {
|
||||
"type": "object",
|
||||
"description": "The connecting/disconnecting client's identity.",
|
||||
"required": [
|
||||
"name",
|
||||
"plane"
|
||||
],
|
||||
"properties": {
|
||||
"fingerprint": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Hex SHA-256 certificate fingerprint, when the client presented one."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Client-supplied device name; may be empty (an anonymous or compat-plane client)."
|
||||
},
|
||||
"plane": {
|
||||
"$ref": "#/components/schemas/Plane"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CustomEntry": {
|
||||
"type": "object",
|
||||
"description": "A user-added title, persisted in `~/.config/punktfunk/library.json`. Same shape the API\nreturns and the web console edits.",
|
||||
@@ -2422,6 +2616,13 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"prep": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PrepCmd"
|
||||
},
|
||||
"description": "Per-title prep/undo steps (RFC §6): each `do` runs before this title launches, each\n`undo` at session end in reverse order (see [`crate::hooks::run_prep`])."
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -2447,6 +2648,13 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"prep": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PrepCmd"
|
||||
},
|
||||
"description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config."
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -2498,6 +2706,37 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"DeviceRef": {
|
||||
"type": "object",
|
||||
"description": "A device in the pairing flow.",
|
||||
"required": [
|
||||
"name",
|
||||
"fingerprint",
|
||||
"plane"
|
||||
],
|
||||
"properties": {
|
||||
"fingerprint": {
|
||||
"type": "string",
|
||||
"description": "Hex certificate fingerprint."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Sanitized device name (the pairing store's copy)."
|
||||
},
|
||||
"plane": {
|
||||
"$ref": "#/components/schemas/Plane"
|
||||
}
|
||||
}
|
||||
},
|
||||
"DisconnectReason": {
|
||||
"type": "string",
|
||||
"description": "Why a client went away. `Quit` is a deliberate user \"stop\" (the typed close code);\n`Timeout` is a transport idle timeout (the client vanished); `Error` is everything else.",
|
||||
"enum": [
|
||||
"quit",
|
||||
"timeout",
|
||||
"error"
|
||||
]
|
||||
},
|
||||
"DisplayLayoutRequest": {
|
||||
"type": "object",
|
||||
"description": "Request body for `setDisplayLayout`: per-identity-slot desktop offsets, keyed by the identity-slot\nid as a string (the same id `/display/state` reports as `identity_slot`).",
|
||||
@@ -2546,7 +2785,7 @@
|
||||
},
|
||||
"pnp_disable_monitors": {
|
||||
"type": "boolean",
|
||||
"description": "EXPERIMENTAL (Windows): after an `Exclusive` isolate deactivates the physical monitors,\nadditionally DISABLE their PnP device nodes (persistently, so a standby monitor/TV whose\nhot-plug events re-arrive stays disabled) and re-enable them at restore. Targets the same\n\"connected-but-dark head\" periodic-stutter class as [`Self::ddc_power_off`], but at the\nWindows-reaction level: a disabled devnode's wake events trigger no PnP arrival, no CCD\nre-evaluation, no DWM invalidation. A crash-recovery journal re-enables leftovers on host\nstartup. Orthogonal to `preset` (like `game_session`); `#[serde(default)]` = off."
|
||||
"description": "EXPERIMENTAL (Windows): DISABLE physical monitors' PnP device nodes for the stream's\nduration (persistently, so a standby monitor/TV whose hot-plug events re-arrive stays\ndisabled) and re-enable them at teardown. Two selectors: the monitors an `Exclusive`\nisolate deactivated, plus — in ANY topology — external monitors that are connected but not\npart of the desktop (the standby TV that was never active, whose input auto-scan /\ninstant-on HPD cycling re-probes the link every few seconds). Targets the same\n\"connected-but-dark head\" periodic-stutter class as [`Self::ddc_power_off`], but at the\nWindows-reaction level: a disabled devnode's wake events trigger no PnP arrival, no CCD\nre-evaluation, no DWM invalidation. A crash-recovery journal re-enables leftovers on host\nstartup. Orthogonal to `preset` (like `game_session`); `#[serde(default)]` = off."
|
||||
},
|
||||
"preset": {
|
||||
"$ref": "#/components/schemas/Preset"
|
||||
@@ -2658,6 +2897,278 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"EventKind": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"client",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"client": {
|
||||
"$ref": "#/components/schemas/ClientRef"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"client.connected"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"client",
|
||||
"reason",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"client": {
|
||||
"$ref": "#/components/schemas/ClientRef"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"client.disconnected"
|
||||
]
|
||||
},
|
||||
"reason": {
|
||||
"$ref": "#/components/schemas/DisconnectReason"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"session",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"session.started"
|
||||
]
|
||||
},
|
||||
"session": {
|
||||
"$ref": "#/components/schemas/SessionRef"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"session",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"session.ended"
|
||||
]
|
||||
},
|
||||
"session": {
|
||||
"$ref": "#/components/schemas/SessionRef"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"stream",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"stream.started"
|
||||
]
|
||||
},
|
||||
"stream": {
|
||||
"$ref": "#/components/schemas/StreamRef"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"stream",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"stream.stopped"
|
||||
]
|
||||
},
|
||||
"stream": {
|
||||
"$ref": "#/components/schemas/StreamRef"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"device",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"device": {
|
||||
"$ref": "#/components/schemas/DeviceRef"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"pairing.pending"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"device",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"device": {
|
||||
"$ref": "#/components/schemas/DeviceRef"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"pairing.completed"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"device",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"device": {
|
||||
"$ref": "#/components/schemas/DeviceRef"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"pairing.denied"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"backend",
|
||||
"mode",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"backend": {
|
||||
"type": "string",
|
||||
"description": "The virtual-display backend that minted it (`VirtualDisplay::name`)."
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"display.created"
|
||||
]
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"description": "`WxH@Hz`."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"count",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"count": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "How many kept displays this release retired.",
|
||||
"minimum": 0
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"display.released"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"source",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"library.changed"
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "What mutated the library: `\"manual\"` today; a provider id once the provider\nAPI (RFC §8) lands."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"version",
|
||||
"gamestream",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"gamestream": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the GameStream/Moonlight compat plane is enabled."
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"host.started"
|
||||
]
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"host.stopping"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": "The event catalog (RFC §4). Serialized internally tagged as `\"kind\": \"<domain>.<verb>\"`,\nflattened into [`HostEvent`]. **Additive-only** within [`SCHEMA_VERSION`]."
|
||||
},
|
||||
"GameEntry": {
|
||||
"type": "object",
|
||||
"description": "One title in the unified library, regardless of which store it came from.",
|
||||
@@ -2800,6 +3311,150 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"HookEntry": {
|
||||
"type": "object",
|
||||
"description": "One hook: fire `run` and/or `webhook` when an event matching `on` (+ `filter`) occurs.",
|
||||
"required": [
|
||||
"on"
|
||||
],
|
||||
"properties": {
|
||||
"debounce_ms": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Minimum interval between firings of this hook, in milliseconds. 0 = fire every time.",
|
||||
"minimum": 0
|
||||
},
|
||||
"filter": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/HookFilter",
|
||||
"description": "Exact-match constraints on the event's fields; every present field must match."
|
||||
}
|
||||
]
|
||||
},
|
||||
"hmac_secret_file": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "File holding the webhook HMAC secret (`X-Punktfunk-Signature: sha256=<hex>`). The file\nshould be operator-owned and private; a world-readable secret is warned about."
|
||||
},
|
||||
"on": {
|
||||
"type": "string",
|
||||
"description": "Which events fire this hook: an exact kind (`stream.started`) or a `domain.*` prefix\n(`pairing.*`) — the same vocabulary as the SSE `?kinds=` filter."
|
||||
},
|
||||
"run": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Shell command to execute (detached, event JSON on stdin + `PF_EVENT_*` env)."
|
||||
},
|
||||
"timeout_s": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "Exec timeout in seconds (1–600, default 30); the process group is killed on expiry.",
|
||||
"minimum": 0
|
||||
},
|
||||
"webhook": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "URL to POST the event JSON to."
|
||||
}
|
||||
}
|
||||
},
|
||||
"HookFilter": {
|
||||
"type": "object",
|
||||
"description": "Exact-match filters against an event's identity fields (RFC open-question 3: exact match\nonly — anything richer is what the SDK is for). Absent fields don't constrain; a filter\nfield set on an event kind that doesn't carry it (e.g. `client` on `host.started`) never\nmatches.",
|
||||
"properties": {
|
||||
"app": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Launched app id/title (`stream.*` events)."
|
||||
},
|
||||
"client": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Client/device name (for `session.*`: the short client label the Dashboard shows)."
|
||||
},
|
||||
"fingerprint": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Certificate fingerprint (hex, case-insensitive)."
|
||||
},
|
||||
"plane": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Plane",
|
||||
"description": "Protocol plane (`native` / `gamestream`)."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"HooksConfig": {
|
||||
"type": "object",
|
||||
"description": "The operator's hook configuration — the `hooks.json` document and the `/api/v1/hooks` body.",
|
||||
"properties": {
|
||||
"hooks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/HookEntry"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"HostEvent": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/EventKind",
|
||||
"description": "The event kind + payload, flattened: `\"kind\": \"stream.started\", …payload…`."
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"seq",
|
||||
"ts_ms",
|
||||
"schema"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "Wire-shape version ([`SCHEMA_VERSION`]).",
|
||||
"minimum": 0
|
||||
},
|
||||
"seq": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.",
|
||||
"minimum": 0
|
||||
},
|
||||
"ts_ms": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": "One host lifecycle event, as it will appear on the wire (`data:` of one SSE frame)."
|
||||
},
|
||||
"HostInfo": {
|
||||
"type": "object",
|
||||
"description": "Host identity and advertised capabilities (static for the life of the process).",
|
||||
@@ -2990,6 +3645,13 @@
|
||||
"type": "boolean",
|
||||
"description": "True while the audio stream thread is running."
|
||||
},
|
||||
"conflicts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Other Moonlight-compatible hosts (Sunshine/Apollo/…) detected on this machine at startup —\nrunning one alongside Punktfunk is unsupported. Compact labels (e.g. `Sunshine (running)`);\nthe tray/console surface them so the clash is visible before pairing silently fails."
|
||||
},
|
||||
"kept_displays": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
@@ -3257,6 +3919,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Plane": {
|
||||
"type": "string",
|
||||
"description": "Which protocol plane an event originated from. Hooks and scripts filter on it — a hook\nthat fires for native clients but not Moonlight clients is a bug, not a v2 feature.",
|
||||
"enum": [
|
||||
"native",
|
||||
"gamestream"
|
||||
]
|
||||
},
|
||||
"PortMap": {
|
||||
"type": "object",
|
||||
"description": "Every port a client integration may need (Moonlight derives the stream ports from the\nHTTP base; a control pane should not have to).",
|
||||
@@ -3328,6 +3998,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"PrepCmd": {
|
||||
"type": "object",
|
||||
"description": "One per-app preparation step (RFC §6 — deliberate Sunshine `prep-cmd` parity): `do` runs\n**synchronously before the app launches** (an HDR toggle or a MangoHud env change must land\nfirst), `undo` runs at session end — reverse order across steps, best-effort, on every exit\npath including a crash-unwind (RAII via [`PrepGuard`]).",
|
||||
"required": [
|
||||
"do"
|
||||
],
|
||||
"properties": {
|
||||
"do": {
|
||||
"type": "string",
|
||||
"description": "Command run before launch. Same execution recipe and ownership checks as hook `run`\ncommands (event-less: stdin is empty JSON, env carries the `PF_APP_*` context)."
|
||||
},
|
||||
"undo": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Command run after the session ends. Skipped when its `do` failed (it never took effect)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"Preset": {
|
||||
"type": "string",
|
||||
"description": "A named bundle of the fields below. `Custom` (the default) means the explicit fields rule; any\nother preset ignores the stored fields and expands to its own ([`DisplayPolicy::effective`]).",
|
||||
@@ -3477,6 +4167,35 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SessionRef": {
|
||||
"type": "object",
|
||||
"description": "A live A/V session (the plane-neutral notion the Dashboard shows).",
|
||||
"required": [
|
||||
"id",
|
||||
"client",
|
||||
"mode",
|
||||
"hdr"
|
||||
],
|
||||
"properties": {
|
||||
"client": {
|
||||
"type": "string",
|
||||
"description": "Short client label (cert-fingerprint prefix, or peer IP for an anonymous client)."
|
||||
},
|
||||
"hdr": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Host-local session id (unique within this host process).",
|
||||
"minimum": 0
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"description": "Negotiated mode, `WxH@Hz` (e.g. `\"3840x2160@120\"`)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"SetGpuPreference": {
|
||||
"type": "object",
|
||||
"description": "Request body for `setGpuPreference`.",
|
||||
@@ -3694,6 +4413,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"StreamRef": {
|
||||
"type": "object",
|
||||
"description": "A live video stream (what the stream marker file reflects).",
|
||||
"required": [
|
||||
"mode",
|
||||
"hdr",
|
||||
"client",
|
||||
"plane"
|
||||
],
|
||||
"properties": {
|
||||
"app": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The launched app/title for this stream, when one was requested (store-qualified id on\nthe native plane, app title on the GameStream plane)."
|
||||
},
|
||||
"client": {
|
||||
"type": "string",
|
||||
"description": "Client-supplied device name; may be empty."
|
||||
},
|
||||
"hdr": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"description": "Negotiated mode, `WxH@Hz`."
|
||||
},
|
||||
"plane": {
|
||||
"$ref": "#/components/schemas/Plane"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SubmitPin": {
|
||||
"type": "object",
|
||||
"description": "The PIN Moonlight displays during pairing.",
|
||||
@@ -3771,6 +4523,14 @@
|
||||
{
|
||||
"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)"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -56,6 +56,12 @@ tokio-rustls = "0.26"
|
||||
hyper = { version = "1", features = ["server", "http1", "http2"] }
|
||||
hyper-util = { version = "0.1", features = ["server", "server-auto", "tokio", "service"] }
|
||||
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"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
@@ -87,6 +87,14 @@ pub struct HostConfig {
|
||||
/// `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).
|
||||
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 {
|
||||
@@ -146,6 +154,8 @@ impl HostConfig {
|
||||
}),
|
||||
recover_session_cmd: val("PUNKTFUNK_RECOVER_SESSION_CMD")
|
||||
.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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
pub fn mode_str(width: u32, height: u32, hz: u32) -> String {
|
||||
format!("{width}x{height}@{hz}")
|
||||
@@ -262,13 +333,19 @@ impl EventBus {
|
||||
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 + 1 < first_seq;
|
||||
let dropped = since != 0 && since.saturating_add(1) < first_seq;
|
||||
let catch_up = ring
|
||||
.events
|
||||
.iter()
|
||||
|
||||
@@ -21,6 +21,9 @@ pub struct AppEntry {
|
||||
/// 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.
|
||||
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> {
|
||||
@@ -68,6 +71,12 @@ fn base_catalog() -> Vec<AppEntry> {
|
||||
.and_then(parse_compositor),
|
||||
cmd: it.get("cmd").and_then(|c| c.as_str()).map(String::from),
|
||||
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();
|
||||
@@ -88,6 +97,7 @@ fn base_catalog() -> Vec<AppEntry> {
|
||||
compositor: None,
|
||||
cmd: None,
|
||||
library_id: None,
|
||||
prep: Vec::new(),
|
||||
}];
|
||||
if which("gamescope") {
|
||||
if which("steam") {
|
||||
@@ -97,6 +107,7 @@ fn base_catalog() -> Vec<AppEntry> {
|
||||
compositor: Some(crate::vdisplay::Compositor::Gamescope),
|
||||
cmd: Some("steam -gamepadui".into()),
|
||||
library_id: None,
|
||||
prep: Vec::new(),
|
||||
});
|
||||
}
|
||||
if which("vkcube") {
|
||||
@@ -106,6 +117,7 @@ fn base_catalog() -> Vec<AppEntry> {
|
||||
compositor: Some(crate::vdisplay::Compositor::Gamescope),
|
||||
cmd: Some("vkcube".into()),
|
||||
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)
|
||||
cmd: None,
|
||||
library_id: Some(g.id),
|
||||
prep: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -242,6 +255,7 @@ mod tests {
|
||||
compositor: None,
|
||||
cmd: None,
|
||||
library_id: None,
|
||||
prep: Vec::new(),
|
||||
}];
|
||||
append_library(&mut apps);
|
||||
let ids: Vec<u32> = apps.iter().map(|a| a.id).collect();
|
||||
|
||||
@@ -242,6 +242,9 @@ pub fn serve(
|
||||
// rustls needs a process-wide crypto provider before any TLS config is built.
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
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`
|
||||
// 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.
|
||||
|
||||
@@ -163,6 +163,20 @@ fn run(
|
||||
// `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).
|
||||
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
|
||||
// (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.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,15 +12,21 @@
|
||||
use anyhow::Result;
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
|
||||
/// 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;
|
||||
#[path = "inject/keymap.rs"]
|
||||
mod keymap;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) use keymap::gs_button_to_evdev;
|
||||
pub use keymap::KEY_FLAG_SEMANTIC_VK;
|
||||
// vk_to_evdev is consumed by the Linux injectors (kwin/libei/wlr) and — on Windows — only by the
|
||||
// SendInput mirror test; keep the shared `crate::inject::vk_to_evdev` re-export unconditionally.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(unused_imports))]
|
||||
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
|
||||
/// resources (a Wayland connection, an xkb state) and lives entirely on the control thread
|
||||
@@ -319,154 +325,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
|
||||
// backends under `inject/windows/`, and the transport-independent HID codecs under `inject/proto/`;
|
||||
// `#[path]` keeps every `crate::inject::*` module name flat.
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)]
|
||||
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)));
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// shared screen convention (top → 0) with no flip; pad clicks set the touchpad-click
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! use [`PadSlots`] directly instead.
|
||||
|
||||
use crate::gamestream::gamepad::{GamepadEvent, GamepadFrame, MAX_PADS};
|
||||
use crate::inject::dualsense_proto::HidoutDedup;
|
||||
use crate::inject::hidout_dedup::HidoutDedup;
|
||||
use crate::inject::pad_slots::PadSlots;
|
||||
use anyhow::Result;
|
||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
|
||||
@@ -14,6 +14,10 @@ pub struct CustomEntry {
|
||||
pub art: Artwork,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
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>,
|
||||
}
|
||||
|
||||
/// Request body to create or replace a custom entry (no `id` — the host owns it).
|
||||
@@ -24,6 +28,9 @@ pub struct CustomInput {
|
||||
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 {
|
||||
@@ -89,6 +96,7 @@ pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
|
||||
title: input.title,
|
||||
art: input.art,
|
||||
launch: input.launch,
|
||||
prep: input.prep,
|
||||
};
|
||||
entries.push(entry.clone());
|
||||
save_custom(&entries)?;
|
||||
@@ -105,6 +113,7 @@ pub fn update_custom(id: &str, input: CustomInput) -> Result<Option<CustomEntry>
|
||||
slot.title = input.title;
|
||||
slot.art = input.art;
|
||||
slot.launch = input.launch;
|
||||
slot.prep = input.prep;
|
||||
let updated = slot.clone();
|
||||
save_custom(&entries)?;
|
||||
emit_changed();
|
||||
@@ -124,6 +133,19 @@ pub fn delete_custom(id: &str) -> Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// The prep/undo steps for a library id — `custom:<id>` entries only (the other stores have no
|
||||
/// per-title config surface; a GameStream `apps.json` entry carries its own `prep` instead).
|
||||
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()
|
||||
}
|
||||
|
||||
/// The custom-entry mutations are the only library writes today, all operator-driven — hence
|
||||
/// `source: "manual"` (RFC §4; a provider id once the provider API of RFC §8 lands).
|
||||
fn emit_changed() {
|
||||
@@ -151,6 +173,7 @@ mod tests {
|
||||
title: "My ROM".into(),
|
||||
art: Artwork::default(),
|
||||
launch: None,
|
||||
prep: Vec::new(),
|
||||
}
|
||||
.into();
|
||||
assert_eq!(g.id, "custom:abc123");
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
//! Minimal CUDA Driver API FFI for the zero-copy path. No Rust crate exposes the GL-interop
|
||||
//! driver calls we need (`cuGraphicsGLRegisterImage` & co.), so we hand-roll exactly those and
|
||||
//! `dlopen` `libcuda.so.1` at runtime (the driver library — NOT `libcudart`; NOT a link-time
|
||||
//! `#[link]`, so one binary runs on NVIDIA and on AMD/Intel where `libcuda` is absent — see
|
||||
//! [`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`].)
|
||||
//! CUDA driver-side state for the zero-copy path, layered over the raw driver-API FFI in [`ffi`]
|
||||
//! (the `dlopen`'d `libcuda.so.1` symbol table — hand-rolled because no Rust crate exposes the
|
||||
//! GL-interop calls, and runtime-loaded so one binary runs on NVIDIA *and* on AMD/Intel where
|
||||
//! `libcuda` is absent). This facade owns the higher-level pieces on top of that layer:
|
||||
//!
|
||||
//! One process-wide `CUcontext` is created lazily and shared by the EGL importer (capture
|
||||
//! thread) and ffmpeg's `hevc_nvenc` (encode thread); each thread makes it current before use.
|
||||
//! * one process-wide `CUcontext`, created lazily and shared by the EGL importer (capture thread)
|
||||
//! 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)]
|
||||
// Every `unsafe` block/impl below carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
@@ -16,472 +19,12 @@
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
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};
|
||||
|
||||
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.
|
||||
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}")
|
||||
}
|
||||
}
|
||||
#[path = "cuda/ffi.rs"]
|
||||
mod ffi;
|
||||
pub(crate) use ffi::*;
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
use super::cuda::{self, DeviceBuffer};
|
||||
use anyhow::{bail, ensure, Context as _, Result};
|
||||
use anyhow::{ensure, Context as _, Result};
|
||||
use khronos_egl as egl;
|
||||
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_HI_EXT: egl::Attrib = 0x3444;
|
||||
|
||||
const GL_TEXTURE_2D: u32 = 0x0DE1;
|
||||
const GL_TEXTURE_MIN_FILTER: u32 = 0x2801;
|
||||
const GL_TEXTURE_MAG_FILTER: u32 = 0x2800;
|
||||
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)
|
||||
}
|
||||
#[path = "egl/gl.rs"]
|
||||
mod gl;
|
||||
use gl::*;
|
||||
|
||||
/// Per-size GL machinery to blit a dmabuf EGLImage into a CUDA-registrable `GL_RGBA8` texture.
|
||||
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"]
|
||||
mod gpuclocks;
|
||||
mod hdr;
|
||||
mod hooks;
|
||||
mod inject;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "windows/install.rs"]
|
||||
|
||||
@@ -32,7 +32,9 @@ use utoipa_scalar::{Scalar, Servable};
|
||||
mod auth;
|
||||
mod clients;
|
||||
mod display;
|
||||
mod events;
|
||||
mod gpu;
|
||||
mod hooks;
|
||||
mod host;
|
||||
mod library;
|
||||
mod native;
|
||||
@@ -215,7 +217,9 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
||||
stats::stats_recording_get,
|
||||
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()
|
||||
}
|
||||
@@ -251,6 +255,8 @@ pub fn openapi_json() -> String {
|
||||
(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 = "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;
|
||||
|
||||
@@ -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:#}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -946,3 +946,263 @@ async fn logs_endpoint_pages_by_cursor() {
|
||||
assert!(json["entries"].as_array().unwrap().is_empty());
|
||||
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);
|
||||
}
|
||||
|
||||
+41
-2321
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
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::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:
|
||||
/// * `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.
|
||||
@@ -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`)")
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// output down.
|
||||
struct GamescopeProc {
|
||||
@@ -1709,10 +1465,7 @@ impl Drop for GamescopeProc {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
cgroup_is_punktfunk_owned, is_steam_launch, parse_version, shape_dedicated_command,
|
||||
MIN_GAMESCOPE,
|
||||
};
|
||||
use super::{cgroup_is_punktfunk_owned, is_steam_launch, shape_dedicated_command};
|
||||
|
||||
#[test]
|
||||
fn steam_launch_detection() {
|
||||
@@ -1768,27 +1521,4 @@ mod tests {
|
||||
));
|
||||
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,
|
||||
};
|
||||
|
||||
/// 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),
|
||||
}
|
||||
#[path = "manager/driver.rs"]
|
||||
mod driver;
|
||||
pub(crate) use driver::{AddedMonitor, MonitorKey, VdisplayDriver};
|
||||
|
||||
/// 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,
|
||||
}
|
||||
#[path = "manager/instance.rs"]
|
||||
mod instance;
|
||||
use instance::claim_instance;
|
||||
pub(crate) use instance::claim_instance_eagerly;
|
||||
|
||||
/// 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<()>;
|
||||
}
|
||||
#[path = "manager/knobs.rs"]
|
||||
mod knobs;
|
||||
use knobs::{keep_alive_forever, linger_ms, topology_action};
|
||||
|
||||
/// 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
|
||||
@@ -308,70 +258,6 @@ pub(crate) fn control_device_handle() -> Option<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.
|
||||
/// `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
|
||||
@@ -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 {
|
||||
let Some(w) = e.downcast_ref::<windows::core::Error>() else {
|
||||
return false;
|
||||
@@ -1700,55 +1591,3 @@ pub(crate) fn snapshot() -> Vec<ManagedInfo> {
|
||||
pub(crate) fn force_release(slot: Option<u64>) -> usize {
|
||||
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 —
|
||||
/// 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 {
|
||||
matches!(self, TrayStatus::Running(s) if !s.conflicts.is_empty())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
---
|
||||
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`) |
|
||||
| `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.
|
||||
|
||||
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 |
|
||||
|---|---|---|
|
||||
| `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
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"virtual-displays",
|
||||
"pyrowave",
|
||||
"host-cli",
|
||||
"automation",
|
||||
"---Connecting---",
|
||||
"clients",
|
||||
"install-client",
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
let
|
||||
pkgs = pkgsFor system;
|
||||
in
|
||||
pkgs.callPackage ./nix/packages.nix {
|
||||
pkgs.callPackage ./packaging/nix/packages.nix {
|
||||
craneLib = craneLibFor pkgs;
|
||||
src = self;
|
||||
inherit version;
|
||||
@@ -128,10 +128,10 @@
|
||||
|
||||
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 ];
|
||||
# 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;
|
||||
};
|
||||
}
|
||||
|
||||
+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 +
|
||||
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 —
|
||||
see [`../nix/README.md`](../nix/README.md).
|
||||
see [`nix/README.md`](nix/README.md).
|
||||
|
||||
## 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
|
||||
# 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.
|
||||
# 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 =
|
||||
"--locked -p punktfunk-client-linux -p punktfunk-client-session "
|
||||
+ "--no-default-features --features punktfunk-client-session/pyrowave";
|
||||
Reference in New Issue
Block a user