Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1b8627e70 | ||
|
|
91fa32fbb6 | ||
|
|
ce8f3e9eaf | ||
|
|
bd383f1820 | ||
|
|
8728d90e01 | ||
|
|
3d4a659959 | ||
|
|
a418d2852a | ||
|
|
110ac9b663 | ||
|
|
1d6f4760f3 | ||
|
|
9dfbc2f895 | ||
|
|
56adb47026 | ||
|
|
52a9d02355 | ||
|
|
e5ca213339 | ||
|
|
e5416646f9 | ||
|
|
b79d90b463 | ||
|
|
8983ec04b9 | ||
|
|
d27e62f7c9 | ||
|
|
0a72959ef7 | ||
|
|
2d223274fc | ||
|
|
173be61213 | ||
|
|
2032c48ffa | ||
|
|
9a52c279f1 | ||
|
|
5be494f490 | ||
|
|
0d5e5b436b | ||
|
|
3a48cc2470 | ||
|
|
64a392634e | ||
|
|
35285afafc | ||
|
|
0d0e7e6861 | ||
|
|
143454590f | ||
|
|
9409d0a04c | ||
|
|
212bdc3b08 | ||
|
|
45cb525035 | ||
|
|
6fed1510ba | ||
|
|
4fd240deab | ||
|
|
e32bd30c85 | ||
|
|
2f1ef44191 | ||
|
|
8ee224e5db | ||
|
|
e8499e6131 | ||
|
|
a10bde39bb | ||
|
|
b5f91d50bb | ||
|
|
ed3d236ab8 |
Generated
+19
@@ -2893,6 +2893,7 @@ dependencies = [
|
||||
"ureq",
|
||||
"wasapi",
|
||||
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3346,6 +3347,8 @@ dependencies = [
|
||||
"opus",
|
||||
"punktfunk-core",
|
||||
"tracing",
|
||||
"uac-host",
|
||||
"usbfs-iso",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4985,6 +4988,14 @@ version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "uac-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/unom-io/usbfs-iso?rev=f3de1fd62cec271d07f45664dc464f23e423e721#f3de1fd62cec271d07f45664dc464f23e423e721"
|
||||
dependencies = [
|
||||
"usbfs-iso",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
@@ -5064,6 +5075,14 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "usbfs-iso"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/unom-io/usbfs-iso?rev=f3de1fd62cec271d07f45664dc464f23e423e721#f3de1fd62cec271d07f45664dc464f23e423e721"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "usbip-sim"
|
||||
version = "0.8.0"
|
||||
|
||||
+157
-9
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.23.0"
|
||||
"version": "0.24.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -1052,7 +1052,7 @@
|
||||
"library"
|
||||
],
|
||||
"summary": "Fetch one cover-art image for a library entry",
|
||||
"description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. For a Steam title, the host's own local Steam cache is tried first (exact —\nit's what the user's Steam client already shows for it), the public Steam CDN's flat URL\nconvention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host\ncan't predict, in which case this 404s and the client falls through to its next art candidate).\nOnly Steam ids are backed today; any other store 404s.",
|
||||
"description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries,\nand a library plugin's claimed-store entries) serves its local art file. A Steam title falls back\nto the in-host scanner's resolver: the host's own local Steam cache first (exact — it's what the\nuser's Steam client already shows for it), the public Steam CDN's flat URL convention second\n(newer titles' CDN assets can live at a per-asset-hash path the host can't predict, in which case\nthis 404s and the client falls through to its next art candidate).",
|
||||
"operationId": "getLibraryArt",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1307,7 +1307,7 @@
|
||||
"library"
|
||||
],
|
||||
"summary": "Replace a provider's library entries (declarative reconcile)",
|
||||
"description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.",
|
||||
"description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.\n\n`?store=` additionally **claims** that store for the provider: its entries then surface with\ndeterministic `<store>:<external_id>` ids and the store's own badge, instead of opaque\n`custom:<id>` ones — which is what lets a library plugin reproduce the entries an in-host scanner\nused to produce, right down to the GameStream app ids and client-side art caches. One provider\nper store; a second claimant gets 409. While a claim is held the matching built-in scanner is\nsuppressed, so the two never double-list. The claim is released by `DELETE`, not by an empty\nreconcile (a store can legitimately have zero installed titles).",
|
||||
"operationId": "reconcileProviderEntries",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1318,6 +1318,15 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "store",
|
||||
"in": "query",
|
||||
"description": "Claim this store for the provider ([a-z0-9_-], `custom`/`manual` reserved)",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
@@ -1348,7 +1357,7 @@
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid provider id or payload",
|
||||
"description": "Invalid provider id, store id, or payload",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -1367,6 +1376,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "That store is already claimed by another provider",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Could not persist the catalog",
|
||||
"content": {
|
||||
@@ -4159,7 +4178,8 @@
|
||||
"tier",
|
||||
"platforms",
|
||||
"compatible",
|
||||
"update_available"
|
||||
"update_available",
|
||||
"categories"
|
||||
],
|
||||
"properties": {
|
||||
"author": {
|
||||
@@ -4172,6 +4192,13 @@
|
||||
],
|
||||
"description": "A revocation covering the catalogued version — do not offer this without shouting."
|
||||
},
|
||||
"categories": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "What kind of plugin this is — the console filters Browse by these, and the Game sources\nsurface's \"Add a source\" rail shows exactly the `library` ones (design D5/D6)."
|
||||
},
|
||||
"compatible": {
|
||||
"type": "boolean",
|
||||
"description": "Can this host install it?"
|
||||
@@ -4179,6 +4206,13 @@
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"detected": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
],
|
||||
"description": "Whether the launcher this plugin scans looks **installed on this host** (design D8), from the\nindex's own existence probes. `null` = the entry declares no probes for this platform, which\nthe console renders as \"unknown\" rather than \"not installed\"."
|
||||
},
|
||||
"homepage": {
|
||||
"type": [
|
||||
"string",
|
||||
@@ -4365,6 +4399,17 @@
|
||||
],
|
||||
"description": "The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous)."
|
||||
},
|
||||
"role": {
|
||||
"$ref": "#/components/schemas/GameRole",
|
||||
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]."
|
||||
},
|
||||
"store": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The **store this entry was claimed under** (D2), stamped by a `?store=`-qualified reconcile.\n`None` = an unclaimed provider entry or a manual one, both of which surface as `custom`.\n\nMaterialized onto the entry rather than looked up in [`Catalog::claims`] on every read so an\nentry is self-describing: its id and its `store` badge derive from the entry alone, and stay\ncorrect even while the claim map is being rewritten."
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -4409,6 +4454,10 @@
|
||||
},
|
||||
"description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config."
|
||||
},
|
||||
"role": {
|
||||
"$ref": "#/components/schemas/GameRole",
|
||||
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]. A hand-added launcher\nentry is legal (an operator may want a \"Steam\" tile without installing the steam plugin)."
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -4467,6 +4516,17 @@
|
||||
"type": "object",
|
||||
"description": "What an operator (or a provider plugin) can tell the host about recognizing a title — the wire\nhalf of [`DetectSpec`], and the only part of it that is ever accepted from outside.\n\nDeliberately a **subset**: the store-derived signals (a Steam appid, a launcher's environment\nmarker) are things the host discovers for itself and would be meaningless — or dangerous — to take\non someone's word. What is left is what a provider genuinely knows and the host cannot guess: where\nthe title is installed, which executable is the game, what the process is called. All three are\noptional; supplying none is the same as supplying no hint at all.\n\nNever returned by the catalog API — see the module docs on why detect data does not cross the wire\noutbound.",
|
||||
"properties": {
|
||||
"env_marker": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EnvMarker",
|
||||
"description": "A launcher-stamped environment marker (D3) — see [`EnvMarker`]."
|
||||
}
|
||||
]
|
||||
},
|
||||
"exe": {
|
||||
"type": [
|
||||
"string",
|
||||
@@ -4487,6 +4547,15 @@
|
||||
"null"
|
||||
],
|
||||
"description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]."
|
||||
},
|
||||
"steam_appid": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int32",
|
||||
"description": "The Steam appid, for a title Steam itself installed (D3). On Linux this is the **sharpest**\nsignal that exists — Steam wraps every launch, native or Proton, in\n`reaper SteamLaunch AppId=<appid>`, whose lifetime is exactly the game's — so without it a\nsteam plugin's lease tracking would degrade from reaper-exact to install-dir prefix matching.",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4715,6 +4784,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"EnvMarker": {
|
||||
"type": "object",
|
||||
"description": "An environment variable a launcher stamps onto the game's process, identifying it.\n\nSerializable because it is now half of the inbound [`DetectHint`] too (D3) — a library plugin\nthat knows its launcher's marker (Heroic's `HEROIC_APP_NAME`, load-bearing under Proton) has to\nbe able to say so, since after extraction the host no longer reads that launcher's files itself.",
|
||||
"required": [
|
||||
"key"
|
||||
],
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "The variable name (e.g. `HEROIC_GAME_ID`).",
|
||||
"example": "HEROIC_APP_NAME"
|
||||
},
|
||||
"value": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The exact value to require, when the launcher's value identifies *this* title. `None` matches\nthe key's mere presence — only safe for launchers that run one game at a time."
|
||||
}
|
||||
}
|
||||
},
|
||||
"EventKind": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -5165,6 +5255,10 @@
|
||||
],
|
||||
"description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it."
|
||||
},
|
||||
"role": {
|
||||
"$ref": "#/components/schemas/GameRole",
|
||||
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]."
|
||||
},
|
||||
"store": {
|
||||
"type": "string",
|
||||
"description": "Which store surfaced it: `\"steam\"` or `\"custom\"`.",
|
||||
@@ -5296,6 +5390,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"GameRole": {
|
||||
"type": "string",
|
||||
"description": "What a library entry *is* — an ordinary title, or the launcher application itself (Steam Big\nPicture, Heroic, Playnite fullscreen). Purely a presentation hint: a launcher entry launches,\nleases and lists exactly like a game (design D4), and clients that don't know the field render it\nas a plain tile. Serde-default `game` and skip-serialized when default, so the wire is unchanged\nfor every entry that doesn't opt in.",
|
||||
"enum": [
|
||||
"game",
|
||||
"launcher"
|
||||
]
|
||||
},
|
||||
"GameSession": {
|
||||
"type": "string",
|
||||
"description": "How a session that **launches a game** (a library id on the Hello / apps.json / Decky pin) is\nserved (`design/gamemode-and-dedicated-sessions.md` §5.2). Orthogonal to the preset/lifecycle axes\n— a top-level [`DisplayPolicy`] field, NOT part of [`EffectivePolicy`], so a preset never clobbers\nit. Linux-only in effect (a launching Windows session opens into the one desktop).",
|
||||
@@ -6334,6 +6436,13 @@
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "What KIND of plugin this is (`^[a-z][a-z0-9-]{0,31}$`), top-level rather than under `ui`\nbecause it describes the plugin, not its surface. The console knows one value today —\n`library` — which it filters **out of the nav**: six installed scanner plugins would otherwise\nflood the sidebar, and their real entry point is the Game sources surface (design D5). A\nlibrary plugin that genuinely wants its own page (rom-manager, which is much more than a\nscanner) simply omits the category."
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Human-readable title for the console nav entry (1–64 chars; control chars stripped)."
|
||||
@@ -6366,6 +6475,13 @@
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The plugin's kind — see [`PluginRegistration::category`]."
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -6604,6 +6720,10 @@
|
||||
},
|
||||
"description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config."
|
||||
},
|
||||
"role": {
|
||||
"$ref": "#/components/schemas/GameRole",
|
||||
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]. A library plugin\nemits its `launchers(cfg)` entries with `role: \"launcher\"`."
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -6780,26 +6900,46 @@
|
||||
},
|
||||
"ScannerInfo": {
|
||||
"type": "object",
|
||||
"description": "One installed-store scanner this host build supports, with its enable state — the unit the\nconsole renders a toggle for. The list is platform-gated at compile time (the scanners are),\nso the console never shows a toggle that cannot do anything on this host.",
|
||||
"description": "One **game source** on this host, with its enable state — the unit the console renders a toggle\nfor. A source is either a scanner compiled into this build or a plugin that reconciles entries in\n(WP2.6); the console treats them identically, which is what makes the extraction invisible.",
|
||||
"required": [
|
||||
"id",
|
||||
"label",
|
||||
"enabled"
|
||||
"enabled",
|
||||
"origin"
|
||||
],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether this host runs the scanner (default true)."
|
||||
"description": "Whether this host runs the source (default true)."
|
||||
},
|
||||
"entries": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"description": "How many entries this source currently contributes. `None` for a built-in scanner, whose\ncount would mean walking every launcher's files just to render a toggle.",
|
||||
"minimum": 0
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Stable scanner id — the same string the scanner's entries carry in their `store` field.",
|
||||
"description": "Stable source id — the same string this source's entries carry in their `store` field. For a\nplugin source it is also its provider id and its store claim: one string, by construction, so\na user's disabled state survives a built-in scanner being replaced by its plugin.",
|
||||
"example": "steam"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "Human-facing name for the console toggle.",
|
||||
"example": "Steam"
|
||||
},
|
||||
"origin": {
|
||||
"$ref": "#/components/schemas/SourceOrigin",
|
||||
"description": "Where the source comes from: `builtin` (a scanner in this host build) or `plugin`."
|
||||
},
|
||||
"provider": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The provider id backing a `plugin` source — absent for a built-in scanner."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -6962,6 +7102,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SourceOrigin": {
|
||||
"type": "string",
|
||||
"description": "Where a [`ScannerInfo`] comes from.",
|
||||
"enum": [
|
||||
"builtin",
|
||||
"plugin"
|
||||
]
|
||||
},
|
||||
"SourceView": {
|
||||
"type": "object",
|
||||
"description": "A configured catalog source and how its last refresh went.",
|
||||
|
||||
@@ -410,17 +410,68 @@ private fun DsRow(usbDev: android.hardware.usb.UsbDevice) {
|
||||
Text("Grant USB access")
|
||||
}
|
||||
}
|
||||
else -> Text(
|
||||
if (model == DsDevice.Model.DUALSHOCK4) {
|
||||
"Ready — captured at stream start: rumble, lightbar and gyro are " +
|
||||
"driven directly."
|
||||
} else {
|
||||
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
|
||||
"and gyro are driven directly."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
else -> {
|
||||
Text(
|
||||
if (model == DsDevice.Model.DUALSHOCK4) {
|
||||
"Ready — captured at stream start: rumble, lightbar and gyro are " +
|
||||
"driven directly."
|
||||
} else {
|
||||
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
|
||||
"and gyro are driven directly."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// Pad-audio self test. Deliberately reachable WITHOUT a stream: it exists to
|
||||
// answer "can this phone drive this pad's audio endpoint at all", and gating
|
||||
// that behind a live session would make it depend on the very thing one wants
|
||||
// to rule out when a session misbehaves. DualSense only — the DS4 has no
|
||||
// 4-channel haptics device.
|
||||
if (model != DsDevice.Model.DUALSHOCK4) {
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
var result by remember { mutableStateOf<String?>(null) }
|
||||
result?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
OutlinedButton(
|
||||
enabled = !testing,
|
||||
onClick = {
|
||||
testing = true
|
||||
result = null
|
||||
Thread({
|
||||
// Its OWN connection: the renderer's descriptor must never be
|
||||
// shared with another transfer engine, and that applies to
|
||||
// this test as much as to the real path.
|
||||
val conn = runCatching { usbManager.openDevice(usbDev) }.getOrNull()
|
||||
val fd = conn?.fileDescriptor ?: -1
|
||||
val r = if (fd >= 0) {
|
||||
io.unom.punktfunk.kit.NativeBridge.nativePadAudioSelfTest(fd, 3, 60)
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
conn?.close()
|
||||
val msg = when {
|
||||
r > 0 -> "Haptics test passed — $r frames to the pad."
|
||||
r == -1 -> "Could not open the pad's audio interface. " +
|
||||
"Some kernels refuse it; the pad still works normally."
|
||||
r == -2 -> "The audio stream stopped part-way."
|
||||
else -> "The stream opened but no audio reached the pad."
|
||||
}
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||
result = msg
|
||||
testing = false
|
||||
}
|
||||
}, "pf-pad-selftest-ui").start()
|
||||
},
|
||||
) {
|
||||
Text(if (testing) "Testing…" else "Test haptics")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,9 @@ suspend fun connectToHost(
|
||||
// The host's approval-list / trust-store label for this device — the same
|
||||
// Build.MODEL convention the pairing dialogs use for nativePair.
|
||||
Build.MODEL ?: "Android",
|
||||
// Tier-A pad audio: ask for the 0xD1 plane only when a setting would render it, so a
|
||||
// user with it off does not make the host provision endpoints it will never feed.
|
||||
settings.padHaptics || settings.padSpeaker,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +170,26 @@ data class Settings(
|
||||
*/
|
||||
val dsCapture: Boolean = true,
|
||||
|
||||
/**
|
||||
* Render the host's DualSense **voice-coil haptics** on a captured USB pad (tier A).
|
||||
*
|
||||
* The pad's own 4-channel audio device carries them, driven directly over usbfs — Android's
|
||||
* audio framework denylists that device by VID/PID, so there is no supported route to it. The
|
||||
* two kinds are arbitrated rather than mixed, and on evidence: wire rumble is suppressed only
|
||||
* while haptics frames are actually arriving, so a title that drives classic rumble and sends
|
||||
* no haptics audio keeps rumbling. Off, or on an uncaptured/Bluetooth pad, the pad stays on
|
||||
* ordinary rumble (tier C), which on this client already drives the same actuators.
|
||||
*/
|
||||
val padHaptics: Boolean = true,
|
||||
|
||||
/**
|
||||
* Render the pad's **built-in speaker** on a captured USB pad. Independent of [padHaptics] —
|
||||
* the host sends the two as separate streams and either can play alone. Off by default: the
|
||||
* speaker is a small, easily-startling loudspeaker in the user's hands, and unlike haptics it
|
||||
* duplicates audio they are already hearing.
|
||||
*/
|
||||
val padSpeaker: Boolean = false,
|
||||
|
||||
/**
|
||||
* How a physical mouse drives the host — the cross-client mouse model (see [MouseMode]).
|
||||
* [MouseMode.DESKTOP] (default here) points absolutely; [MouseMode.CAPTURE] locks the pointer
|
||||
@@ -271,6 +291,8 @@ class SettingsStore(context: Context) {
|
||||
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
|
||||
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
|
||||
dsCapture = prefs.getBoolean(K_DS_CAPTURE, true),
|
||||
padHaptics = prefs.getBoolean(K_PAD_HAPTICS, true),
|
||||
padSpeaker = prefs.getBoolean(K_PAD_SPEAKER, false),
|
||||
mouseMode = prefs.getString(K_MOUSE_MODE, null)
|
||||
?.let { name -> MouseMode.entries.firstOrNull { it.storedName == name } }
|
||||
// Migration: the pre-enum Boolean "pointer_capture" (true = lock the pointer). Its
|
||||
@@ -308,6 +330,8 @@ class SettingsStore(context: Context) {
|
||||
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
|
||||
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
|
||||
.putBoolean(K_DS_CAPTURE, s.dsCapture)
|
||||
.putBoolean(K_PAD_HAPTICS, s.padHaptics)
|
||||
.putBoolean(K_PAD_SPEAKER, s.padSpeaker)
|
||||
.putString(K_MOUSE_MODE, s.mouseMode.storedName)
|
||||
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
|
||||
.apply()
|
||||
@@ -355,6 +379,8 @@ class SettingsStore(context: Context) {
|
||||
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
|
||||
const val K_SC2_CAPTURE = "sc2_capture"
|
||||
const val K_DS_CAPTURE = "ds_capture"
|
||||
const val K_PAD_HAPTICS = "pad_haptics"
|
||||
const val K_PAD_SPEAKER = "pad_speaker"
|
||||
const val K_MOUSE_MODE = "mouse_mode"
|
||||
|
||||
/** Legacy Boolean the [K_MOUSE_MODE] enum replaced — read once for migration, never written. */
|
||||
|
||||
@@ -896,6 +896,22 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
|
||||
enabled = s.gamepadForwarding,
|
||||
onCheckedChange = { on -> update(s.copy(dsCapture = on)) },
|
||||
)
|
||||
// Both only ever apply to a captured pad, so they follow that row and gate on it.
|
||||
ToggleRow(
|
||||
title = "Controller haptics",
|
||||
subtitle = "Play the host's fine-grained DualSense haptics on the pad itself — " +
|
||||
"the pad keeps ordinary rumble for games that don't send them",
|
||||
checked = s.padHaptics,
|
||||
enabled = s.gamepadForwarding && s.dsCapture,
|
||||
onCheckedChange = { on -> update(s.copy(padHaptics = on)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Controller speaker",
|
||||
subtitle = "Play audio the game sends to the controller's own speaker",
|
||||
checked = s.padSpeaker,
|
||||
enabled = s.gamepadForwarding && s.dsCapture,
|
||||
onCheckedChange = { on -> update(s.copy(padSpeaker = on)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,6 +507,28 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
var dsUsbReceiver: BroadcastReceiver? = null
|
||||
if (ds != null) {
|
||||
feedback.sink = ds
|
||||
// Tier-A pad audio: render the host's 0xD1 streams on the pad's own 4-channel USB
|
||||
// audio device. Bound here rather than inside DsCapture because the session handle
|
||||
// lives at this layer; DsCapture decides WHEN (it knows the wire index and the link
|
||||
// lifetime), this decides WHETHER.
|
||||
if (initialSettings.padHaptics || initialSettings.padSpeaker) {
|
||||
ds.padAudio = object : DsCapture.PadAudioHook {
|
||||
override fun start(pad: Int, fd: Int) {
|
||||
val ok = NativeBridge.nativeStartPadAudio(
|
||||
handle,
|
||||
pad,
|
||||
fd,
|
||||
initialSettings.padHaptics,
|
||||
initialSettings.padSpeaker,
|
||||
)
|
||||
Log.i("punktfunk", "pad audio on pad $pad: ${if (ok) "started" else "unavailable"}")
|
||||
}
|
||||
|
||||
// Returns only once the render thread is joined — DsCapture calls this before
|
||||
// closing the connection whose descriptor that thread borrows.
|
||||
override fun stop(pad: Int) = NativeBridge.nativeStopPadAudio(handle, pad)
|
||||
}
|
||||
}
|
||||
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
val usbDev = ds.findUsbDevice()
|
||||
when {
|
||||
|
||||
@@ -23,8 +23,9 @@ import android.view.InputDevice
|
||||
* Input: parse ([DsDevice.parseState]) → typed mirror on an [GamepadRouter.ExternalPad] (buttons
|
||||
* diffed, axes on-change — the exit chord participates like any pad) + the rich plane (touch
|
||||
* normalized to the wire's 0..65535 screen space on-change; motion forwarded per report in raw
|
||||
* device units, the wire's contract). The wire slot is claimed lazily on the FIRST parsed report
|
||||
* and freed on unplug/[stop], so indices never leak.
|
||||
* device units, the wire's contract). The wire slot is claimed when the capture engages, with the
|
||||
* first parsed report as the fallback for a claim that found no free index, and freed on
|
||||
* unplug/[stop], so indices never leak.
|
||||
*
|
||||
* Feedback: implements [GamepadFeedback.PadFeedbackSink] — rumble / trigger / lightbar / player
|
||||
* LED events addressed to this pad's wire index become USB output reports on the physical pad
|
||||
@@ -78,6 +79,33 @@ class DsCapture(
|
||||
@Volatile
|
||||
var onActiveChanged: ((active: Boolean) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Tier-A pad audio, bound by the app layer (which owns the session handle).
|
||||
*
|
||||
* [start] is called once the router has assigned this pad a wire index, which the host uses to
|
||||
* address the `0xD1` stream. [stop] is called **before** the USB link closes — on [stop] and on
|
||||
* unplug alike — and must not return until nothing is still writing to the descriptor.
|
||||
*/
|
||||
interface PadAudioHook {
|
||||
fun start(pad: Int, fd: Int)
|
||||
fun stop(pad: Int)
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var padAudio: PadAudioHook? = null
|
||||
|
||||
/** True once [PadAudioHook.start] has run for the current capture, so it fires exactly once. */
|
||||
@Volatile private var padAudioStarted = false
|
||||
|
||||
/**
|
||||
* The renderer's OWN connection to the pad.
|
||||
*
|
||||
* It must not share [usb]'s descriptor: two transfer engines on one usbfs descriptor reap each
|
||||
* other's completions (see [HidUsbLink.openAuxConnection]), which strands both the HID reader
|
||||
* and the audio ring. Closed only after the hook's stop has returned.
|
||||
*/
|
||||
@Volatile private var padAudioConn: android.hardware.usb.UsbDeviceConnection? = null
|
||||
|
||||
val isActive: Boolean get() = model != null
|
||||
|
||||
/** First attached Sony USB pad, for the permission flow. Needs no permission to enumerate. */
|
||||
@@ -105,12 +133,17 @@ class DsCapture(
|
||||
// (the same init hid-playstation/SDL send on open).
|
||||
if (m != DsDevice.Model.DUALSHOCK4) usb.writeRaw(0, DsDevice.ds5InitReport(m))
|
||||
Log.i(TAG, "Sony pad captured over USB: PID=0x%04x model=%s".format(dev.productId, m))
|
||||
ensureSlot(m)
|
||||
onActiveChanged?.invoke(true)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Stop the link and free the wire slot (host tears the virtual pad down). Idempotent. */
|
||||
fun stop() {
|
||||
// Before anything touches the link: the pad-audio renderer borrows this connection's
|
||||
// descriptor, and `usb.stop()` closes it. The hook does not return until its thread is
|
||||
// joined, so ordering this first is what makes the borrow sound.
|
||||
stopPadAudio()
|
||||
val m = model
|
||||
if (m != null) {
|
||||
// The interfaces are about to release with the kernel driver still detached — a
|
||||
@@ -136,16 +169,112 @@ class DsCapture(
|
||||
private fun onReport(report: ByteArray, len: Int) {
|
||||
val m = model ?: return
|
||||
if (!DsDevice.parseState(m, report, len, state)) return
|
||||
val p = pad ?: router.openExternal(m.pref)?.also {
|
||||
pad = it
|
||||
Log.i(TAG, "captured $m → wire pad ${it.index}")
|
||||
} ?: return // all 16 wire indices taken — drop until one frees
|
||||
// Normally claimed already, at capture time; this is the retry for a capture that engaged
|
||||
// while every wire index was taken.
|
||||
val p = pad ?: ensureSlot(m) ?: return // all 16 taken — drop until one frees
|
||||
mirrorTyped(p)
|
||||
mirrorRich(p, m)
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim this capture's wire slot and start pad audio on it. Idempotent; null when all 16
|
||||
* indices are taken.
|
||||
*
|
||||
* Claimed when the capture engages rather than on the first report, because a pad that reports
|
||||
* nothing is still a pad: with the lazy claim, a captured-but-silent pad left the host with no
|
||||
* arrival, hence no virtual pad, no pad-audio capability and so no `0xD1` — a renderer sitting
|
||||
* at zero frames, indistinguishable from a broken pipeline (it took a physical replug to
|
||||
* clear). Callable from the main thread (capture start) and the link thread (the fallback).
|
||||
*/
|
||||
@Synchronized
|
||||
private fun ensureSlot(m: DsDevice.Model): GamepadRouter.ExternalPad? {
|
||||
pad?.let { return it }
|
||||
val p = router.openExternal(m.pref) ?: return null
|
||||
pad = p
|
||||
Log.i(TAG, "captured $m → wire pad ${p.index}")
|
||||
// The wire index exists from here on, and the host addresses pad audio by it.
|
||||
startPadAudio(p.index)
|
||||
return p
|
||||
}
|
||||
|
||||
/** Hand the renderer its own descriptor. Caller holds the monitor; fires once per capture. */
|
||||
private fun startPadAudio(index: Int) {
|
||||
val hook = padAudio ?: return
|
||||
if (padAudioStarted) return
|
||||
// A dedicated connection, NOT usb.fileDescriptor — see padAudioConn.
|
||||
val conn = usb.openAuxConnection()
|
||||
val fd = conn?.fileDescriptor ?: -1
|
||||
if (fd < 0) {
|
||||
conn?.close()
|
||||
Log.w(TAG, "pad audio: could not open a second USB connection")
|
||||
return
|
||||
}
|
||||
padAudioConn = conn
|
||||
padAudioStarted = true
|
||||
// Real-world self test, opt-in: `adb shell setprop debug.punktfunk.pad_audio_selftest 3`
|
||||
// drives the voice coils for N seconds through the actual client path before the renderer
|
||||
// takes over — the one check that proves the descriptor, the interface claim and the write
|
||||
// path all work on THIS device, without needing a host to be streaming. Same convention as
|
||||
// debug.punktfunk.force_parts.
|
||||
val secs = runCatching {
|
||||
Class.forName("android.os.SystemProperties")
|
||||
.getMethod("get", String::class.java, String::class.java)
|
||||
.invoke(null, "debug.punktfunk.pad_audio_selftest", "0") as String
|
||||
}.getOrNull()?.toIntOrNull() ?: 0
|
||||
if (secs > 0) {
|
||||
// Diagnostic mode: the self test OWNS this descriptor for the capture, and the renderer
|
||||
// must not also drive it — two engines on one usbfs descriptor reap each other's
|
||||
// completions, which is precisely the fault this test exists to expose.
|
||||
Thread({
|
||||
val r = NativeBridge.nativePadAudioSelfTest(fd, secs, 60)
|
||||
Log.i(TAG, "pad audio self-test → ${if (r > 0) "PASS ($r frames)" else "FAIL ($r)"}")
|
||||
}, "pf-pad-selftest").start()
|
||||
} else {
|
||||
// B6: hand the coils back before the first haptics frame. Any rumble earlier in this
|
||||
// session asserted HAPTICS_SELECT, which firmware-mutes them, and nothing else ever
|
||||
// clears it — so without this the stream renders into a muted actuator and looks for
|
||||
// all the world like the host is sending nothing.
|
||||
restoreAudioHaptics()
|
||||
hook.start(index, fd)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* B6: clear the rumble/haptics-select bits so the pad's voice coils answer the audio-haptics
|
||||
* path again. EP0-direct, like the other out-of-band writes here: this has to land even when
|
||||
* the interrupt-OUT queue is busy or draining, and it is idempotent.
|
||||
*/
|
||||
private fun restoreAudioHaptics() {
|
||||
val m = model ?: return
|
||||
if (m == DsDevice.Model.DUALSHOCK4) return // no voice coils, no audio-haptics path
|
||||
if (!usb.writeControl(DsDevice.ds5AudioHapticsReport(m))) {
|
||||
Log.w(TAG, "pad audio: could not hand the coils back to audio haptics")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the renderer, then close the connection whose descriptor it borrows — in that order.
|
||||
*
|
||||
* Runs on [stop] and on unplug alike. Skipping it on unplug left the render thread writing to a
|
||||
* descriptor whose device was gone, leaked the connection, and — because the started flag stayed
|
||||
* set and the native tier-A registry stayed armed for that index — cost the pad both its pad
|
||||
* audio and its wire rumble on the way back in.
|
||||
*/
|
||||
@Synchronized
|
||||
private fun stopPadAudio() {
|
||||
if (!padAudioStarted) return
|
||||
padAudioStarted = false
|
||||
// The hook's stop joins the render thread, so nothing is using the descriptor once it
|
||||
// returns — only then is it safe to close the connection that owns it.
|
||||
pad?.let { padAudio?.stop(it.index) }
|
||||
padAudioConn?.close()
|
||||
padAudioConn = null
|
||||
}
|
||||
|
||||
private fun onLinkClosed() {
|
||||
Log.i(TAG, "Sony USB link closed (unplug)")
|
||||
// Before releaseSlot(), which forgets the wire index the renderer is addressed by.
|
||||
stopPadAudio()
|
||||
disarmBackstop()
|
||||
val wasActive = model != null
|
||||
model = null
|
||||
@@ -238,6 +367,10 @@ class DsCapture(
|
||||
// write — as this used to — meant a discarded stop left the motors running with
|
||||
// nothing scheduled to try again; a USB pad holds its last level until told zero.
|
||||
if (sent) disarmBackstop() else armBackstop(STOP_RETRY_MS)
|
||||
// B6: the stop report just re-asserted HAPTICS_SELECT on its way past, so if a
|
||||
// haptics stream is live the coils it drives were muted by the very write that
|
||||
// silenced the motors. Give them back.
|
||||
if (sent && padAudioStarted) restoreAudioHaptics()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -276,6 +276,21 @@ object DsDevice {
|
||||
* the classic compat-vibration path AND `VIBRATION2` (firmware ≥ 2.24's full-range replot;
|
||||
* older firmware ignores the unknown flag2 bit) — the host parser accepts either.
|
||||
*/
|
||||
/**
|
||||
* B6: hand the voice coils back to the audio-haptics path.
|
||||
*
|
||||
* Every [ds5RumbleReport] asserts `HAPTICS_SELECT` (flag0 bit1), which is SDL's
|
||||
* "disable audio haptics" bit — the firmware mutes the coils the 0xD1 haptics stream drives.
|
||||
* Until now NOTHING ever cleared it again, so a single rumble anywhere in a session left tier-A
|
||||
* haptics silent for the rest of that pad's life, with no error and nothing in a log.
|
||||
*
|
||||
* The undo is a report whose flag0 has BOTH bits clear (SDL's own comment: "Leaving emulated
|
||||
* rumble bits off will restore audio haptics"). No other valid flag is set, so nothing else
|
||||
* about the pad's state is touched. Mirrors `Ds5Feedback::audio_haptics_packet` on the desktop
|
||||
* client, which is the same packet one transport over.
|
||||
*/
|
||||
fun ds5AudioHapticsReport(model: Model): ByteArray = newDs5(model)
|
||||
|
||||
fun ds5RumbleReport(model: Model, low: Int, high: Int): ByteArray = newDs5(model).also {
|
||||
it[1] = (DS5_FLAG0_COMPAT_VIBRATION or DS5_FLAG0_HAPTICS_SELECT).toByte()
|
||||
it[39] = DS5_FLAG2_VIBRATION2.toByte()
|
||||
|
||||
@@ -98,6 +98,40 @@ class HidUsbLink(
|
||||
/** First attached matching device, or null. Does not need USB permission to enumerate. */
|
||||
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull(config.deviceMatch)
|
||||
|
||||
/**
|
||||
* Open a SECOND connection to the same device, for a consumer that needs its own descriptor.
|
||||
*
|
||||
* **Not a convenience — a correctness requirement.** `UsbDeviceConnection.requestWait()`
|
||||
* returns *any* completed request on that connection, and the same is true of the usbfs reap
|
||||
* ioctl underneath it: two independent transfer engines sharing one descriptor steal each
|
||||
* other's completions. This link's reader owns its connection exclusively (see the note on
|
||||
* [outQueue]), so anything else driving transfers on this device — the isochronous audio
|
||||
* renderer — must open its own.
|
||||
*
|
||||
* usbfs allows the same device to be opened many times, and claims are per (descriptor,
|
||||
* interface), so a claim made on this connection does not conflict with one made on that.
|
||||
*
|
||||
* The caller owns the returned connection and must close it.
|
||||
*/
|
||||
fun openAuxConnection(): UsbDeviceConnection? {
|
||||
val dev = device ?: return null
|
||||
return usb.openDevice(dev)
|
||||
}
|
||||
|
||||
/**
|
||||
* The open connection's usbfs file descriptor, or -1 when the link is not running.
|
||||
*
|
||||
* Handed to native code that drives interfaces this link deliberately does NOT claim — the
|
||||
* pad's isochronous audio endpoint (see `pad_audio` on the native side), which Android's own
|
||||
* USB API cannot reach because `UsbRequest` rejects anything that is not bulk or interrupt.
|
||||
* usbfs claims are per interface, so a native claim of the audio interface leaves this link's
|
||||
* HID claim untouched.
|
||||
*
|
||||
* **The borrower must stop using it before [stop] runs**: closing the connection while a
|
||||
* transfer is in flight pulls the descriptor out from under the kernel.
|
||||
*/
|
||||
val fileDescriptor: Int get() = connection?.fileDescriptor ?: -1
|
||||
|
||||
/**
|
||||
* Claim [dev]'s controller interface(s) and start the read loop. The caller has already
|
||||
* obtained USB permission. Returns false when nothing could be claimed.
|
||||
|
||||
@@ -69,6 +69,10 @@ object NativeBridge {
|
||||
* list and trust store show for it, same convention as [nativePair]'s `name`. `null`/blank ⇒
|
||||
* the host falls back to a fingerprint-derived "device abcd1234" label. */
|
||||
deviceName: String?,
|
||||
/** Advertise `CLIENT_CAP_PAD_AUDIO` — the SESSION-level negotiation for the 0xD1 per-pad
|
||||
* DualSense plane. Without it the host never sets `HOST_CAP_PAD_AUDIO` and emits nothing,
|
||||
* so a captured pad's own render capabilities would have nothing to gate. */
|
||||
padAudioOk: Boolean,
|
||||
): Long
|
||||
|
||||
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
|
||||
@@ -332,6 +336,46 @@ object NativeBridge {
|
||||
*/
|
||||
external fun nativeSetMicMuted(handle: Long, muted: Boolean)
|
||||
|
||||
/**
|
||||
* Start tier-A DualSense pad audio: render the host's `0xD1` streams on the pad's own
|
||||
* 4-channel USB audio device.
|
||||
*
|
||||
* [fd] is an open [android.hardware.usb.UsbDeviceConnection]'s file descriptor. Native code
|
||||
* **borrows** it — it claims the pad's audio interface through usbfs (which leaves any HID
|
||||
* claim on the same device alone) and never closes the descriptor. The caller must keep the
|
||||
* connection open until [nativeStopPadAudio] returns.
|
||||
*
|
||||
* This also declares the pad's render capability to the host; without it no `0xD1` is sent.
|
||||
*
|
||||
* Returns false when there is nothing to render. A kernel that refuses the interface claim is
|
||||
* NOT reported here — the renderer discovers that on its own thread and the session simply
|
||||
* carries on without tier A, because some OEM kernels refuse and no app-side fix exists.
|
||||
*/
|
||||
external fun nativeStartPadAudio(
|
||||
handle: Long,
|
||||
pad: Int,
|
||||
fd: Int,
|
||||
haptics: Boolean,
|
||||
speaker: Boolean,
|
||||
): Boolean
|
||||
|
||||
/**
|
||||
* Stop tier-A pad audio and join its render thread, and hand the pad back to wire rumble.
|
||||
*
|
||||
* Returns only once the thread is joined — so the `UsbDeviceConnection` may be closed as soon
|
||||
* as this returns, and not before.
|
||||
*/
|
||||
external fun nativeStopPadAudio(handle: Long, pad: Int)
|
||||
|
||||
/**
|
||||
* Drive the pad with a test tone through the real render path — no host, no session.
|
||||
*
|
||||
* [fd] must come from a connection **nothing else is driving transfers on**: two engines on
|
||||
* one usbfs descriptor reap each other's completions. Blocks for roughly [seconds]; run it off
|
||||
* the main thread. Returns sample frames written, or negative on failure.
|
||||
*/
|
||||
external fun nativePadAudioSelfTest(fd: Int, seconds: Int, hz: Int): Int
|
||||
|
||||
/**
|
||||
* Is a mic capture actually RUNNING — i.e. did [nativeStartMic] open a stream, and has
|
||||
* [nativeStopMic] not been called since? Offer the in-stream mute control on THIS rather than
|
||||
|
||||
@@ -64,6 +64,14 @@ libc = "0.2"
|
||||
# host + Linux client use. audiopus_sys vendors libopus (pure C) and builds it static via cmake —
|
||||
# the cargo-ndk build sets LIBOPUS_STATIC=1/LIBOPUS_NO_PKG=1 so it links the bundled lib, not the host's.
|
||||
opus = "0.3"
|
||||
# Tier-A pad audio (WP9). Android's audio framework denylists the DualSense's output by VID/PID,
|
||||
# so the pad's isochronous endpoint is driven directly on the fd `UsbDeviceConnection` hands over.
|
||||
# Our own crates, developed openly because the hole they fill — isochronous USB in Rust — is an
|
||||
# ecosystem-wide one: https://github.com/unom-io/usbfs-iso
|
||||
# Pinned by revision rather than floating: this is a transport under a real-time deadline and it
|
||||
# should move when we choose to. Becomes a plain version dependency once the crates are published.
|
||||
uac-host = { git = "https://github.com/unom-io/usbfs-iso", rev = "f3de1fd62cec271d07f45664dc464f23e423e721" }
|
||||
usbfs-iso = { git = "https://github.com/unom-io/usbfs-iso", rev = "f3de1fd62cec271d07f45664dc464f23e423e721" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -77,6 +77,14 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
||||
// handle.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
match h.client.next_rumble_command(PULL_TIMEOUT) {
|
||||
// A pad whose coils are ACTIVELY being driven by the 0xD1 haptics stream must not see
|
||||
// wire rumble: `DsDevice` sets `valid_flag0` bit 1 (`HAPTICS_SELECT`) on every rumble
|
||||
// write, and that bit disables the audio-haptics path — so one replayed command would
|
||||
// mute the coils the stream is driving. Gating on *arrival of haptics frames* rather
|
||||
// than on "a stream is open" is what keeps a rumble-only title working: it renders no
|
||||
// haptics audio, so the host emits nothing on 0xD1 and the pad keeps its rumble.
|
||||
// Dropping it here rather than in Kotlin keeps the rule next to the reason.
|
||||
Ok(cmd) if crate::pad_audio::haptics_owns_coils((cmd.pad & 0xF) as u8) => -1,
|
||||
Ok(cmd) => pack_rumble(cmd.pad, cmd.low, cmd.high, cmd.backstop_ms),
|
||||
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
|
||||
}
|
||||
@@ -174,6 +182,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
||||
out[3..n].copy_from_slice(&data);
|
||||
n
|
||||
}
|
||||
HidOutput::AudioCtl { .. } => {
|
||||
// DS5 pad-audio routing/volumes — no Android replay path yet (the 0xD1 sample
|
||||
// plane isn't rendered here either); drop it like TrackpadHaptic.
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
n as jint
|
||||
})
|
||||
|
||||
@@ -37,6 +37,8 @@ mod discovery;
|
||||
mod feedback;
|
||||
#[cfg(target_os = "android")]
|
||||
mod mic;
|
||||
/// Tier-A DualSense pad audio: the 0xD1 plane rendered on the pad's own USB endpoint.
|
||||
mod pad_audio;
|
||||
mod session;
|
||||
mod stats;
|
||||
// Ungated like `discovery`: pure `jni` + `punktfunk_core::wol` (no Android framework), so it links
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -145,6 +145,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
timeout_ms: jint,
|
||||
launch: JString<'local>,
|
||||
device_name: JString<'local>,
|
||||
pad_audio_ok: jboolean,
|
||||
) -> jlong {
|
||||
let host: String = match env.get_string(&host) {
|
||||
Ok(s) => s.into(),
|
||||
@@ -268,7 +269,16 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
// CLIENT_CAP_PHASE_LOCK is honest: the async decode loop's presenter feeds
|
||||
// report_phase (advisory in v1 — the host arms on report receipt — but the Hello
|
||||
// should say what the client does).
|
||||
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK,
|
||||
// CLIENT_CAP_PAD_AUDIO is the SESSION-level negotiation, separate from the per-pad
|
||||
// arrival bits: without it the host never sets HOST_CAP_PAD_AUDIO and never emits 0xD1,
|
||||
// so declaring a pad's render caps later would have nothing to gate. Gated on the
|
||||
// settings so a user with pad audio off does not make the host provision endpoints.
|
||||
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK
|
||||
| if pad_audio_ok != 0 {
|
||||
punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO
|
||||
} else {
|
||||
0
|
||||
},
|
||||
// Slice-progressive delivery, by decoder truth (Kotlin probes FEATURE_PartialFrame on
|
||||
// every decoder this device would use; `debug.punktfunk.force_parts` overrides for the
|
||||
// on-glass experiment): AU prefixes then arrive as `Frame::part` pieces and the decode
|
||||
@@ -291,6 +301,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
audio: Mutex::new(None),
|
||||
#[cfg(target_os = "android")]
|
||||
mic: Mutex::new(None),
|
||||
#[cfg(target_os = "android")]
|
||||
pad_audio: Mutex::new(None),
|
||||
// A fresh session is never muted (mute is per-session UI state, not a setting).
|
||||
mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
};
|
||||
|
||||
@@ -61,6 +61,11 @@ pub(crate) struct SessionHandle {
|
||||
audio: Mutex<Option<crate::audio::AudioPlayback>>,
|
||||
#[cfg(target_os = "android")]
|
||||
mic: Mutex<Option<crate::mic::MicCapture>>,
|
||||
/// Tier-A DualSense pad audio (the 0xD1 plane), started by `nativeStartPadAudio` once Kotlin
|
||||
/// has claimed the pad's audio interface and handed its descriptor over. Session-lifetime and
|
||||
/// `Option` because a session may have no wired DualSense at all, which is the common case.
|
||||
#[cfg(target_os = "android")]
|
||||
pub(crate) pad_audio: Mutex<Option<crate::pad_audio::PadAudio>>,
|
||||
/// In-stream mic mute, set via `nativeSetMicMuted` and read per 10 ms frame by the mic's
|
||||
/// encode loop ([`crate::mic`]). Session-lifetime rather than per-[`crate::mic::MicCapture`]
|
||||
/// for the same reason the stats gate is: the mic stops and restarts across a surface
|
||||
@@ -99,6 +104,14 @@ impl SessionHandle {
|
||||
fn stop_mic(&self) {
|
||||
let _ = self.mic.lock().unwrap().take();
|
||||
}
|
||||
|
||||
/// Stop pad audio. Dropping the [`crate::pad_audio::PadAudio`] joins its render thread, which
|
||||
/// is what guarantees nothing is still writing to the descriptor when Kotlin closes the
|
||||
/// `UsbDeviceConnection`. Idempotent.
|
||||
#[cfg(target_os = "android")]
|
||||
pub(crate) fn stop_pad_audio(&self) {
|
||||
let _ = self.pad_audio.lock().unwrap().take();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SessionHandle {
|
||||
@@ -108,6 +121,8 @@ impl Drop for SessionHandle {
|
||||
self.stop_audio();
|
||||
#[cfg(target_os = "android")]
|
||||
self.stop_mic();
|
||||
#[cfg(target_os = "android")]
|
||||
self.stop_pad_audio();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -460,6 +460,111 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeStartPadAudio(handle, pad, fd, haptics, speaker): Boolean` — start tier-A
|
||||
/// DualSense pad audio on a descriptor Kotlin has already obtained.
|
||||
///
|
||||
/// `fd` comes from `UsbDeviceConnection.getFileDescriptor()` **after** claiming the pad's audio
|
||||
/// streaming interface. Kotlin owns that connection and **must keep it open until
|
||||
/// `nativeStopPadAudio` returns**: the renderer borrows the descriptor and never closes it, so
|
||||
/// closing early would pull it out from under an in-flight isochronous transfer.
|
||||
///
|
||||
/// Returns `false` when there is nothing to render (both kinds disabled) or the thread would not
|
||||
/// start. A kernel that refuses the interface claim is NOT reported here — the renderer discovers
|
||||
/// that on its own thread and degrades to tier C, because some OEM kernels refuse and there is no
|
||||
/// app-side fix worth blocking a session on.
|
||||
#[no_mangle]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAudio(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jni::sys::jint,
|
||||
fd: jni::sys::jint,
|
||||
haptics: jboolean,
|
||||
speaker: jboolean,
|
||||
) -> jboolean {
|
||||
jni_guard(0, || {
|
||||
if handle == 0 || fd < 0 || !(0..16).contains(&pad) {
|
||||
return 0;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
// Replace any previous renderer first: dropping it joins the old thread, so two of them
|
||||
// can never hold the same descriptor at once.
|
||||
h.stop_pad_audio();
|
||||
// The capability declaration and the rumble suppression are NOT done here: the renderer
|
||||
// makes both only once its USB stream actually opens (see `pad_audio::render`). Doing them
|
||||
// at spawn time would, on a kernel that refuses the interface claim, take the pad off wire
|
||||
// rumble and give it nothing in return — no haptics of any kind.
|
||||
match crate::pad_audio::start(
|
||||
std::sync::Arc::clone(&h.client),
|
||||
pad as u8,
|
||||
fd,
|
||||
haptics != 0,
|
||||
speaker != 0,
|
||||
) {
|
||||
Some(p) => {
|
||||
*h.pad_audio.lock().unwrap() = Some(p);
|
||||
1
|
||||
}
|
||||
None => 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativePadAudioSelfTest(fd, seconds, hz): Int` — drive the pad directly with a
|
||||
/// tone through the real client render path, with no host and no session involved.
|
||||
///
|
||||
/// The check a standalone harness cannot make: it owns its descriptor by construction, so it can
|
||||
/// never reveal that the client handed the renderer a descriptor something else was already
|
||||
/// driving. Returns sample frames written, or negative on failure (see `pad_audio::SelfTest`).
|
||||
#[no_mangle]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadAudioSelfTest(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
fd: jni::sys::jint,
|
||||
seconds: jni::sys::jint,
|
||||
hz: jni::sys::jint,
|
||||
) -> jni::sys::jint {
|
||||
jni_guard(-1, || {
|
||||
if fd < 0 {
|
||||
return -1;
|
||||
}
|
||||
// SAFETY: Kotlin holds the owning UsbDeviceConnection open across this call and drives no
|
||||
// other transfers on it (it opens a dedicated connection for exactly this).
|
||||
unsafe { crate::pad_audio::self_test(fd, seconds, hz) }
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeStopPadAudio(handle, pad)` — stop tier-A pad audio and join its thread.
|
||||
///
|
||||
/// Returns only once the render thread is joined, which is the point: Kotlin may close the
|
||||
/// `UsbDeviceConnection` as soon as this returns and not before.
|
||||
#[no_mangle]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudio(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jni::sys::jint,
|
||||
) {
|
||||
jni_guard((), || {
|
||||
if handle != 0 {
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.stop_pad_audio();
|
||||
if (0..16).contains(&pad) {
|
||||
// Withdraw the capability and hand the pad back to wire rumble, in that order:
|
||||
// the host stops sending 0xD1 before tier C resumes, so the two never overlap.
|
||||
h.client.set_pad_audio_caps(pad as u8, 0);
|
||||
crate::pad_audio::set_tier_a(pad as u8, false);
|
||||
crate::pad_audio::clear_haptics_liveness(pad as u8);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSetMicMuted(handle, muted)` — mute/unmute the mic uplink mid-stream.
|
||||
///
|
||||
/// Muting deliberately does NOT stop the capture: the AAudio input stream, the input-preset rung
|
||||
|
||||
@@ -773,6 +773,7 @@ fn mock_library() -> (
|
||||
title: title.to_string(),
|
||||
art: crate::library::Artwork::default(),
|
||||
platform: None,
|
||||
role: None,
|
||||
};
|
||||
let games = vec![
|
||||
game("steam:570", "steam", "Dota 2"),
|
||||
|
||||
@@ -286,6 +286,12 @@ mod session_main {
|
||||
// Spawned at first params-build so it exists for --connect AND console launches.
|
||||
#[cfg(unix)]
|
||||
crate::ctl_socket::spawn(gamepad.clone());
|
||||
// Pad-audio prefs to OUR gamepad service (same reasoning as the pin above): tier-A
|
||||
// slots declare their render caps at open time, which happens on attach — after this.
|
||||
gamepad.set_pad_audio_prefs(
|
||||
settings.pad_haptics,
|
||||
pf_client_core::pad_audio::speaker_active(&settings.pad_speaker),
|
||||
);
|
||||
let mode = Mode {
|
||||
width: if settings.width == 0 {
|
||||
native.width
|
||||
@@ -389,6 +395,11 @@ mod session_main {
|
||||
cursor_forward: settings.mouse_mode() == trust::MouseMode::Desktop,
|
||||
mic_enabled: settings.mic_enabled,
|
||||
echo_cancel: settings.echo_cancel,
|
||||
// Pad audio (0xD1): the DualSense haptics/speaker render settings. The gamepad
|
||||
// service learns the same prefs below so tier-A slots declare their render caps
|
||||
// at open; the session pump gates CLIENT_CAP_PAD_AUDIO + the renderer on these.
|
||||
pad_haptics: settings.pad_haptics,
|
||||
pad_speaker: settings.pad_speaker.clone(),
|
||||
clipboard,
|
||||
// The Settings preference (auto → VAAPI where it exists; the presenter
|
||||
// demotes to software on boxes whose Vulkan can't import the dmabufs).
|
||||
|
||||
@@ -57,6 +57,10 @@ sdl3 = { version = "0.18", features = ["hidapi"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
wasapi = "0.23"
|
||||
# Pad-audio correlation (pad_audio.rs): the HID devnode's ContainerID and a render endpoint's
|
||||
# stamped PKEY_Device_ContainerId both live in the registry — read-only, which sidesteps COM
|
||||
# property stores entirely (the same version the host pins).
|
||||
winreg = "0.56"
|
||||
sdl3 = { version = "0.18", features = ["hidapi", "build-from-source"] }
|
||||
# D3D11VA decode (video_d3d11.rs): device/adapter selection, DXVA probes, and the shared
|
||||
# NT-handle hand-off ring. Same pinned rev as clients/windows so the workspace builds ONE
|
||||
|
||||
@@ -98,13 +98,43 @@ pub fn devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
|
||||
/// Settings device pickers via session main), or the OS default. A picked device that's
|
||||
/// gone (unplugged USB DAC, remote session) falls back to the default with a warning —
|
||||
/// audio keeps working, like the PipeWire twin's `target.object` behavior.
|
||||
/// Resolve an active endpoint by id WITHOUT `DeviceEnumerator::get_device`.
|
||||
///
|
||||
/// That helper builds its argument as `PCWSTR::from_raw(HSTRING::from(id).as_ptr())` — the
|
||||
/// `HSTRING` is a temporary, dropped at the end of that statement, so `GetDevice` reads freed
|
||||
/// memory and misses ids that are perfectly valid. Scanning the active collection touches only
|
||||
/// safe crate APIs, so it cannot regress the same way. (`punktfunk-host` fixes the same bug with
|
||||
/// raw COM instead; this crate cannot, because it pins a different `windows` revision than
|
||||
/// `wasapi` does, making the two `IMMDevice` types incompatible.)
|
||||
pub(crate) fn device_by_id(
|
||||
enumerator: &DeviceEnumerator,
|
||||
direction: &Direction,
|
||||
id: &str,
|
||||
) -> Result<wasapi::Device> {
|
||||
let devices = enumerator
|
||||
.get_device_collection(direction)
|
||||
.map_err(|e| anyhow!("enumerate {direction:?} endpoints: {e}"))?;
|
||||
let count = devices
|
||||
.get_nbr_devices()
|
||||
.map_err(|e| anyhow!("endpoint count: {e}"))?;
|
||||
for i in 0..count {
|
||||
let dev = devices
|
||||
.get_device_at_index(i)
|
||||
.map_err(|e| anyhow!("endpoint {i}: {e}"))?;
|
||||
if dev.get_id().is_ok_and(|got| got == id) {
|
||||
return Ok(dev);
|
||||
}
|
||||
}
|
||||
anyhow::bail!("no active {direction:?} endpoint with id {id}")
|
||||
}
|
||||
|
||||
fn pick_device(
|
||||
enumerator: &DeviceEnumerator,
|
||||
direction: &Direction,
|
||||
var: &str,
|
||||
) -> Result<wasapi::Device> {
|
||||
if let Some(id) = std::env::var(var).ok().filter(|v| !v.is_empty()) {
|
||||
match enumerator.get_device(&id) {
|
||||
match device_by_id(enumerator, direction, &id) {
|
||||
Ok(d) => {
|
||||
tracing::info!(
|
||||
var,
|
||||
|
||||
@@ -369,8 +369,14 @@ enum Ctl {
|
||||
Pin(Option<String>),
|
||||
KindOverride(GamepadPref),
|
||||
Forwarding(bool),
|
||||
SystemButtons { forward_raw: bool, gesture: bool },
|
||||
SystemButtons {
|
||||
forward_raw: bool,
|
||||
gesture: bool,
|
||||
},
|
||||
TapButton(u32),
|
||||
/// Which pad-audio streams the session's settings want rendered (bit0 = haptics, bit1 =
|
||||
/// speaker) — the settings half of the per-pad tier-A capability declared at slot open.
|
||||
PadAudioPrefs(u8),
|
||||
MenuMode(bool),
|
||||
MenuRumble(MenuPulse),
|
||||
}
|
||||
@@ -573,6 +579,18 @@ impl GamepadService {
|
||||
let _ = self.ctl.send(Ctl::TapButton(wire::BTN_MISC1));
|
||||
}
|
||||
|
||||
/// Declare which pad-audio streams this session's settings want rendered (`haptics` =
|
||||
/// [`Settings::pad_haptics`](crate::trust::Settings::pad_haptics), `speaker` =
|
||||
/// `pad_speaker == "pad"` via [`crate::pad_audio::speaker_active`]). Drives the per-pad
|
||||
/// tier-A capability bits declared to the core at slot open — a WIRED DualSense/Edge
|
||||
/// declares exactly these; every other pad declares 0. Call before [`Self::attach`],
|
||||
/// like [`Self::set_kind_override`]: slots declare at open time. Defaults to "nothing"
|
||||
/// for an embedder that never calls it, keeping the wire bytes exactly as before.
|
||||
pub fn set_pad_audio_prefs(&self, haptics: bool, speaker: bool) {
|
||||
let bits = (haptics as u8) | ((speaker as u8) << 1);
|
||||
let _ = self.ctl.send(Ctl::PadAudioPrefs(bits));
|
||||
}
|
||||
|
||||
pub fn attach(&self, connector: Arc<NativeClient>) {
|
||||
let _ = self.ctl.send(Ctl::Attach(connector));
|
||||
}
|
||||
@@ -746,6 +764,8 @@ impl Ds5Feedback {
|
||||
/// The USB report offsets these are derived from — see the type doc. Kept beside the derived
|
||||
/// values so the subtraction is visible at the point of definition.
|
||||
const REPORT_ID_LEN: usize = 1;
|
||||
/// The audio-control region (`ucHeadphoneVolume`…`ucAudioMuteBits`): report byte 5.
|
||||
const AUDIO: usize = 5 - Self::REPORT_ID_LEN;
|
||||
const RIGHT_TRIGGER: usize = 11 - Self::REPORT_ID_LEN;
|
||||
const LEFT_TRIGGER: usize = 22 - Self::REPORT_ID_LEN;
|
||||
const PAD_LIGHTS: usize = 44 - Self::REPORT_ID_LEN;
|
||||
@@ -782,6 +802,29 @@ impl Ds5Feedback {
|
||||
p[Self::PAD_LIGHTS] = bits & 0x1F;
|
||||
p
|
||||
}
|
||||
|
||||
/// The one-shot tier-A activation packet — the SDL disable-bit trap undone. `p[0]`
|
||||
/// (`ucEnableBits1`) bit0 = "enable rumble emulation" and bit1 = "disable audio haptics"
|
||||
/// (SDL_hidapi_ps5.c); SDL sets BOTH whenever its rumble path runs, which mutes the very
|
||||
/// voice coils the 0xD1 haptics stream drives. Per SDL's own comment — "Leaving emulated
|
||||
/// rumble bits off will restore audio haptics" — a packet with those bits CLEARED (and no
|
||||
/// other valid flag, so nothing else is touched) puts the pad back on audio haptics.
|
||||
fn audio_haptics_packet() -> [u8; 47] {
|
||||
[0u8; 47]
|
||||
}
|
||||
|
||||
/// Fold a host [`HidOutput::AudioCtl`] into an effects packet: `raw` is DS5 output report
|
||||
/// `0x02` bytes 5..=10 verbatim → struct offsets 4..=9 ([`Self::AUDIO`] — headphone/
|
||||
/// speaker/mic volumes + routing), and `p[0]` re-asserts the report's audio-valid flags
|
||||
/// (`flags` bits1..4 = report `flag0` bits 4..7). `flags` bit0 (haptics-select, `flag0`
|
||||
/// bit1 = SDL's "disable audio haptics") is deliberately NOT replayed: bits 0/1 stay
|
||||
/// clear so the pad's audio haptics stay live (see [`audio_haptics_packet`]).
|
||||
fn audio_ctl_packet(flags: u8, raw: &[u8; 6]) -> [u8; 47] {
|
||||
let mut p = [0u8; 47];
|
||||
p[0] = (flags & 0x1E) << 3;
|
||||
p[Self::AUDIO..Self::AUDIO + 6].copy_from_slice(raw);
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
/// One forwarded controller during an attached session: the open SDL handle, its stable wire
|
||||
@@ -818,6 +861,14 @@ struct Slot {
|
||||
/// Hold-Select→guide state ([`SelectGesture`]) — only fed while the worker's
|
||||
/// `guide_gesture` policy is on.
|
||||
gesture: SelectGesture,
|
||||
/// Pad-audio render capabilities declared for this slot (bit0 = haptics, bit1 = speaker
|
||||
/// — the [`NativeClient::set_pad_audio_caps`] bits). Nonzero only for a tier-A pad (a
|
||||
/// WIRED DualSense/Edge, see [`crate::pad_audio::is_tier_a_ds5`]) under matching
|
||||
/// settings; bit0 set additionally suppresses wire rumble for this slot (the SDL
|
||||
/// disable-bit trap — see [`Worker::render_feedback`]).
|
||||
audio_caps: u8,
|
||||
/// The wire-rumble-suppressed notice fired for this slot (log once, not per command).
|
||||
rumble_suppressed_logged: bool,
|
||||
}
|
||||
|
||||
impl Slot {
|
||||
@@ -834,6 +885,8 @@ impl Slot {
|
||||
held_clicks: [false; 2],
|
||||
last_accel: [0; 3],
|
||||
gesture: SelectGesture::default(),
|
||||
audio_caps: 0,
|
||||
rumble_suppressed_logged: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -971,6 +1024,10 @@ struct Worker {
|
||||
/// Releases owed for synthetic taps ([`Ctl::TapButton`]): `(pad, bit, due)` — the
|
||||
/// down went out on receipt, the up goes out from the poll once `due` passes.
|
||||
synthetic_ups: Vec<(u8, u32, Instant)>,
|
||||
/// Pad-audio streams the session's settings want rendered (bit0 = haptics, bit1 =
|
||||
/// speaker — [`GamepadService::set_pad_audio_prefs`]). `0` (the default) until an embedder
|
||||
/// declares some: tier-A detection then never runs and every arrival stays caps-less.
|
||||
pad_audio_prefs: u8,
|
||||
attached: Option<Arc<NativeClient>>,
|
||||
/// Raises the UI escape signal; the escape chord fires it once per press.
|
||||
escape_tx: async_channel::Sender<()>,
|
||||
@@ -1176,11 +1233,18 @@ impl Worker {
|
||||
Ok(pad) => {
|
||||
let mut slot = Slot::new(id, index, pref, pad);
|
||||
Self::set_slot_sensors(&mut slot, true);
|
||||
slot.audio_caps = self.pad_audio_caps_for(id, &slot.pad);
|
||||
// Declare this pad's kind BEFORE any of its input, so the host builds a matching
|
||||
// virtual device (mixed types — pad 0 a DualSense, pad 1 an Xbox pad). The core
|
||||
// re-sends it a few times against datagram loss; an older host ignores it and
|
||||
// uses the session-default kind.
|
||||
if let Some(c) = &self.attached {
|
||||
// Pad-audio render caps go in FIRST — the core ORs them into this (and
|
||||
// every re-sent) arrival's flags bits 8/9 toward a capable host. ALWAYS
|
||||
// set (0 for non-tier-A): wire indices are reused within a connection, so
|
||||
// a tier-A slot that closes must not leave its bits behind for the next
|
||||
// pad on the same index (the set_rumble_quirks rule).
|
||||
c.set_pad_audio_caps(index, slot.audio_caps);
|
||||
send(
|
||||
c,
|
||||
InputKind::GamepadArrival,
|
||||
@@ -1203,6 +1267,27 @@ impl Worker {
|
||||
};
|
||||
c.set_rumble_quirks(index as u16, quirks);
|
||||
}
|
||||
if slot.audio_caps != 0 {
|
||||
if slot.audio_caps & 0x01 != 0 {
|
||||
// Tier-A haptics activation: the SDL disable-bit trap. SDL's DS5
|
||||
// driver sets ucEnableBits1 0x01|0x02 ("enable rumble emulation" +
|
||||
// "disable audio haptics") whenever its rumble path runs — which
|
||||
// would MUTE the voice coils the 0xD1 stream drives. One effects
|
||||
// packet with those bits CLEARED puts the pad back on audio haptics
|
||||
// ("Leaving emulated rumble bits off will restore audio haptics" —
|
||||
// SDL_hidapi_ps5.c); wire rumble for this slot is suppressed in
|
||||
// render_feedback so SDL never re-arms them.
|
||||
let _ = slot.pad.send_effect(&Ds5Feedback::audio_haptics_packet());
|
||||
}
|
||||
// Hand the pad to the session's renderer worker. Windows correlation
|
||||
// needs the HID interface path; Linux matches the sink by signature.
|
||||
crate::pad_audio::register_tier_a(index, slot.pad.path());
|
||||
tracing::info!(
|
||||
index,
|
||||
caps = slot.audio_caps,
|
||||
"tier-A DualSense: pad-audio render caps declared"
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
id,
|
||||
index,
|
||||
@@ -1216,6 +1301,35 @@ impl Worker {
|
||||
}
|
||||
}
|
||||
|
||||
/// This pad's pad-audio render capabilities (the bits [`NativeClient::set_pad_audio_caps`]
|
||||
/// takes): the settings prefs for a tier-A pad — a physical DualSense/Edge (by VID:PID,
|
||||
/// never the DECLARED kind: the stream renders on the controller in the user's hands) on
|
||||
/// a WIRED connection — and `0` for everything else (tier B/C are out of scope). Wired
|
||||
/// comes from `SDL_GetGamepadConnectionState`; when SDL answers Unknown, the pad's 4-ch
|
||||
/// audio sibling existing is the fallback signal (Bluetooth exposes no audio device).
|
||||
fn pad_audio_caps_for(&self, id: u32, pad: &sdl3::gamepad::Gamepad) -> u8 {
|
||||
if self.pad_audio_prefs == 0 {
|
||||
return 0; // nothing wanted — skip the (possibly probing) wired check entirely
|
||||
}
|
||||
let jid = sdl3::sys::joystick::SDL_JoystickID(id);
|
||||
let vid = self.subsystem.vendor_for_id(jid).unwrap_or(0);
|
||||
let pid = self.subsystem.product_for_id(jid).unwrap_or(0);
|
||||
if !crate::pad_audio::is_tier_a_ds5(vid, pid, true) {
|
||||
return 0; // not a DualSense/Edge — no wired check needed
|
||||
}
|
||||
use sdl3::joystick::ConnectionState;
|
||||
let wired = match pad.connection_state() {
|
||||
Ok(ConnectionState::Wired) => true,
|
||||
Ok(ConnectionState::Wireless) => false,
|
||||
_ => crate::pad_audio::wired_audio_sibling(pad.path().as_deref()),
|
||||
};
|
||||
if crate::pad_audio::is_tier_a_ds5(vid, pid, wired) {
|
||||
self.pad_audio_prefs
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush a slot's held wire state (so nothing sticks down host-side) and drop it — closing
|
||||
/// the SDL handle. The flush only emits wire events, so it is safe even when the device is
|
||||
/// already gone (unplug).
|
||||
@@ -1233,6 +1347,11 @@ impl Worker {
|
||||
send(&c, InputKind::GamepadRemove, 0, 0, self.slots[i].index);
|
||||
}
|
||||
let slot = self.slots.remove(i);
|
||||
if slot.audio_caps != 0 {
|
||||
// Take the pad back from the pad-audio renderer (its device-gone path then
|
||||
// re-correlates — and finds nothing until a tier-A pad registers again).
|
||||
crate::pad_audio::unregister_tier_a(slot.index);
|
||||
}
|
||||
tracing::info!(
|
||||
id = slot.id,
|
||||
index = slot.index,
|
||||
@@ -1654,6 +1773,7 @@ impl Worker {
|
||||
set_valve_hidapi(false);
|
||||
}
|
||||
}
|
||||
Ok(Ctl::PadAudioPrefs(bits)) => self.pad_audio_prefs = bits & 0x03,
|
||||
Ok(Ctl::MenuMode(on)) => {
|
||||
self.menu_mode = on;
|
||||
if on {
|
||||
@@ -1966,6 +2086,20 @@ impl Worker {
|
||||
// first; the physical silence backstop is in `close_slot_at`).
|
||||
while let Ok(cmd) = connector.next_rumble_command(Duration::ZERO) {
|
||||
if let Some(slot) = self.slots.iter_mut().find(|s| s.index as u16 == cmd.pad) {
|
||||
// The SDL disable-bit trap: ANY SDL rumble write sets ucEnableBits1
|
||||
// 0x01|0x02, muting the very voice coils the 0xD1 haptics stream drives —
|
||||
// so a slot with tier-A haptics active never issues wire rumble (the stream
|
||||
// carries the feedback; the game's rumble is in its haptics mix).
|
||||
if slot.audio_caps & 0x01 != 0 {
|
||||
if !slot.rumble_suppressed_logged {
|
||||
slot.rumble_suppressed_logged = true;
|
||||
tracing::info!(
|
||||
pad = slot.index,
|
||||
"wire rumble suppressed — the pad-audio haptics stream carries feedback"
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Self::issue_rumble(slot, cmd.low, cmd.high, cmd.backstop_ms);
|
||||
}
|
||||
}
|
||||
@@ -2003,13 +2137,27 @@ impl Worker {
|
||||
.pad
|
||||
.send_effect(&Ds5Feedback::trigger_packet(which, effect));
|
||||
}
|
||||
// The audio-control region of a DS5 output report a game wrote host-side
|
||||
// (volumes + routing; the SAMPLES ride 0xD1) — folded back into the physical
|
||||
// pad's effects packet, but only where a tier-A renderer is actually live
|
||||
// (`audio_caps`): replaying speaker volumes at a pad whose audio device
|
||||
// nothing streams to would just mute/blast a future session's start state.
|
||||
// Non-tier-A pads keep dropping it (the pre-pad-audio behaviour).
|
||||
HidOutput::AudioCtl { flags, raw, .. } if is_ds && slot.audio_caps != 0 => {
|
||||
let _ = slot
|
||||
.pad
|
||||
.send_effect(&Ds5Feedback::audio_ctl_packet(flags, &raw));
|
||||
}
|
||||
// Deliberately unhandled, listed rather than left to a bare `_` so a new
|
||||
// variant cannot join them silently: adaptive triggers exist only on a
|
||||
// DualSense, and the trackpad-haptic / raw-passthrough planes are DS-specific
|
||||
// and carried by `send_effect` above when the pad is one.
|
||||
// and carried by `send_effect` above when the pad is one. `AudioCtl` lands here
|
||||
// only when the guarded arm above declined it — a non-DualSense pad, or one with
|
||||
// no live tier-A renderer — which is the pre-pad-audio behaviour: drop it.
|
||||
HidOutput::Trigger { .. }
|
||||
| HidOutput::TrackpadHaptic { .. }
|
||||
| HidOutput::HidRaw { .. } => {}
|
||||
| HidOutput::HidRaw { .. }
|
||||
| HidOutput::AudioCtl { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2048,6 +2196,9 @@ fn hidout_pad(h: &HidOutput) -> u8 {
|
||||
| HidOutput::Trigger { pad, .. }
|
||||
| HidOutput::TrackpadHaptic { pad, .. }
|
||||
| HidOutput::HidRaw { pad, .. } => *pad,
|
||||
// AudioCtl's pad is the plane's only u16. `HidOutput::decode` rejects anything at or
|
||||
// above MAX_PADS (B27), so by the time one reaches here the narrowing is lossless.
|
||||
HidOutput::AudioCtl { pad, .. } => *pad as u8,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2075,6 +2226,7 @@ impl Worker {
|
||||
system_forward: true,
|
||||
guide_gesture: false,
|
||||
synthetic_ups: Vec::new(),
|
||||
pad_audio_prefs: 0,
|
||||
attached: None,
|
||||
escape_tx,
|
||||
disconnect_tx,
|
||||
@@ -2520,6 +2672,44 @@ mod slot_tests {
|
||||
}),
|
||||
6
|
||||
);
|
||||
// AudioCtl's wire pad is u16; the index space is 0..MAX_PADS end to end.
|
||||
assert_eq!(
|
||||
hidout_pad(&HidOutput::AudioCtl {
|
||||
pad: 7,
|
||||
flags: 0,
|
||||
raw: [0; 6]
|
||||
}),
|
||||
7
|
||||
);
|
||||
}
|
||||
|
||||
/// The AudioCtl fold: the 6 raw bytes (DS5 report 0x02 bytes 5..=10) land at effect-struct
|
||||
/// offsets 4..=9, the report's audio-valid flags (AudioCtl.flags bits1..4) come back as
|
||||
/// p[0] bits 4..7, and the rumble-emulation / disable-audio-haptics bits (p[0] bits 0/1)
|
||||
/// stay CLEAR — setting either would mute the voice coils the 0xD1 stream drives.
|
||||
#[test]
|
||||
fn audio_ctl_folds_report_bytes_into_effect_offsets() {
|
||||
let raw = [0x50, 0x60, 0x70, 0x05, 0x11, 0x22];
|
||||
// flags 0b1_0111: haptics-select (bit0) + audio-valid bits 1/2/4 of the condensed form.
|
||||
let p = Ds5Feedback::audio_ctl_packet(0b1_0111, &raw);
|
||||
assert_eq!(&p[4..10], &raw, "report bytes 5..=10 → struct 4..=9");
|
||||
// bits1..4 (0b1011) → flag0 bits 4..7.
|
||||
assert_eq!(p[0], 0b1011_0000);
|
||||
assert_eq!(
|
||||
p[0] & 0x03,
|
||||
0,
|
||||
"haptics-select must NOT replay into p[0] bits 0/1"
|
||||
);
|
||||
// Nothing else is touched: no trigger/LED enable bits, no stray bytes.
|
||||
assert!(p[1..4].iter().all(|&b| b == 0));
|
||||
assert!(p[10..].iter().all(|&b| b == 0));
|
||||
// No audio-valid flags condenses to no enable bits (raw still carried verbatim).
|
||||
let p = Ds5Feedback::audio_ctl_packet(0b0_0001, &raw);
|
||||
assert_eq!(p[0], 0);
|
||||
assert_eq!(&p[4..10], &raw);
|
||||
// The tier-A activation packet is the all-clear: every enable bit off — per
|
||||
// SDL_hidapi_ps5.c, leaving the emulated-rumble bits off restores audio haptics.
|
||||
assert_eq!(Ds5Feedback::audio_haptics_packet(), [0u8; 47]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,11 @@ pub mod os;
|
||||
// Client settings profiles: the override catalog + the one connect-time resolver
|
||||
// (design/client-settings-profiles.md §4). Sits beside `trust`, which owns the host records
|
||||
// the bindings live on.
|
||||
// Pad audio (the 0xD1 plane): DualSense voice-coil haptics + speaker rendered on the wired
|
||||
// physical pad's own 4-ch audio device — correlation, the per-session renderer worker, and
|
||||
// the tier-A pad registry the gamepad worker feeds it through.
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod pad_audio;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod profiles;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
|
||||
@@ -66,6 +66,20 @@ pub struct GameEntry {
|
||||
/// host's flattened `GameMeta`; the rest of the metadata is not decoded until a UI needs it.
|
||||
#[serde(default)]
|
||||
pub platform: Option<String>,
|
||||
/// `"game"` (the default, and what an older host omits) or `"launcher"` — an entry that opens
|
||||
/// the launcher itself (Steam Big Picture, Heroic) rather than a title. A UI may group these
|
||||
/// separately; one that doesn't renders them as ordinary tiles, which is the intended
|
||||
/// degradation (design D4). Kept a plain string: the host owns the vocabulary, and an unknown
|
||||
/// future value must never fail the whole library decode.
|
||||
#[serde(default)]
|
||||
pub role: Option<String>,
|
||||
}
|
||||
|
||||
impl GameEntry {
|
||||
/// Whether this entry opens a launcher rather than a game.
|
||||
pub fn is_launcher(&self) -> bool {
|
||||
self.role.as_deref() == Some("launcher")
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet").
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -44,6 +44,14 @@ pub struct SessionParams {
|
||||
/// Run the uplink through the platform's echo cancellation ([`Settings::echo_cancel`]).
|
||||
/// Ignored when `mic_enabled` is false; `PUNKTFUNK_NO_AEC=1` overrides it off.
|
||||
pub echo_cancel: bool,
|
||||
/// Render the host's per-pad DualSense voice-coil haptics stream (0xD1 kind 0) on a wired
|
||||
/// physical DualSense ([`crate::trust::Settings::pad_haptics`]). With `pad_speaker` it
|
||||
/// gates the `CLIENT_CAP_PAD_AUDIO` advertisement and the pad-audio renderer thread.
|
||||
pub pad_haptics: bool,
|
||||
/// Where the DualSense built-in-speaker stream (0xD1 kind 1) goes: `"pad"` | `"mix"` |
|
||||
/// `"off"` ([`crate::trust::Settings::pad_speaker`]; `"mix"` is a TODO that renders as
|
||||
/// off — see [`crate::pad_audio::speaker_active`]).
|
||||
pub pad_speaker: String,
|
||||
/// Share the clipboard with this host (the per-host `KnownHost::clipboard_sync`). The
|
||||
/// bridge additionally needs the host to advertise `HOST_CAP_CLIPBOARD`.
|
||||
pub clipboard: bool,
|
||||
@@ -356,6 +364,11 @@ fn pump(
|
||||
);
|
||||
}
|
||||
}
|
||||
// Pad audio (0xD1): advertise only when the settings could render a stream — the per-pad
|
||||
// tier-A detection at slot open (gamepad.rs) still decides which pads declare render caps
|
||||
// on their arrivals, so this bit alone changes nothing without a wired DualSense.
|
||||
let pad_speaker_on = crate::pad_audio::speaker_active(¶ms.pad_speaker);
|
||||
let pad_audio_on = params.pad_haptics || pad_speaker_on;
|
||||
let connector = match NativeClient::connect(
|
||||
¶ms.host,
|
||||
params.port,
|
||||
@@ -379,6 +392,11 @@ fn pump(
|
||||
0
|
||||
}) | (if params.phase_lock {
|
||||
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK
|
||||
} else {
|
||||
0
|
||||
// PAD_AUDIO: the embedder can render per-pad DualSense haptics/speaker (see above).
|
||||
}) | (if pad_audio_on {
|
||||
punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO
|
||||
} else {
|
||||
0
|
||||
}),
|
||||
@@ -501,6 +519,20 @@ fn pump(
|
||||
// app-lifetime service's job (the UI attaches it on Connected). Audio runs on its own
|
||||
// thread (one puller per plane), blocking on the audio queue like the Apple client.
|
||||
let audio_thread = spawn_audio(connector.clone(), stop.clone());
|
||||
// Pad audio (0xD1): its own drain thread (that plane's single consumer), spawned whenever
|
||||
// the settings could render. The output device is opened LAZILY once frames actually
|
||||
// arrive — which only happens after a tier-A pad declared render caps on its arrival — so
|
||||
// a session without a wired DualSense costs one idle 10 ms poll loop.
|
||||
let pad_audio_thread = pad_audio_on
|
||||
.then(|| {
|
||||
crate::pad_audio::spawn(
|
||||
connector.clone(),
|
||||
stop.clone(),
|
||||
params.pad_haptics,
|
||||
pad_speaker_on,
|
||||
)
|
||||
})
|
||||
.flatten();
|
||||
// The shared clipboard (design/clipboard-and-file-transfer.md §5): its own thread, since
|
||||
// `next_clip` blocks and the OS clipboard calls can wait on other apps. Returns straight
|
||||
// away when the host has no clipboard capability, so spawning is unconditional.
|
||||
@@ -1066,6 +1098,9 @@ fn pump(
|
||||
if let Some(t) = audio_thread {
|
||||
let _ = t.join(); // exits within its 100 ms pull timeout once `stop` is set
|
||||
}
|
||||
if let Some(t) = pad_audio_thread {
|
||||
let _ = t.join(); // exits within its 10 ms pull timeout once `stop` is set
|
||||
}
|
||||
if let Some(t) = clipboard_thread {
|
||||
let _ = t.join(); // exits within its next_clip wait once `stop` is set
|
||||
}
|
||||
|
||||
@@ -1024,6 +1024,21 @@ pub struct Settings {
|
||||
/// `PUNKTFUNK_AUDIO_SOURCE`).
|
||||
#[serde(default)]
|
||||
pub mic_device: String,
|
||||
/// Render the host's per-pad DualSense voice-coil haptics stream (the 0xD1 plane, kind 0)
|
||||
/// on a WIRED physical DualSense's own audio device (tier A — Bluetooth pads expose no
|
||||
/// audio device). Gates the `CLIENT_CAP_PAD_AUDIO` advertisement and the per-pad arrival
|
||||
/// capability bit; wire rumble is suppressed for a pad whose haptics stream is live (the
|
||||
/// stream carries the feedback — see `gamepad.rs`, the SDL disable-bit trap). Default ON:
|
||||
/// the capable-and-agreed negotiation means it changes nothing without a capable host AND
|
||||
/// a wired DS5. `default` so pre-existing stores load with it on.
|
||||
#[serde(default = "default_true")]
|
||||
pub pad_haptics: bool,
|
||||
/// Where the DualSense built-in-speaker stream (0xD1 kind 1) is rendered: `"pad"` (default
|
||||
/// — the physical pad's own speaker), `"mix"` (fold it into the main stream audio — a
|
||||
/// declared TODO that renders as `"off"` today; see `pad_audio::speaker_active`), or
|
||||
/// `"off"`. `default` so pre-existing stores load as `"pad"`.
|
||||
#[serde(default = "default_pad_speaker")]
|
||||
pub pad_speaker: String,
|
||||
/// Match-window resolution policy (design/midstream-resolution-resize.md D1): the
|
||||
/// stream mode follows the session window — the connect asks for the window's pixel
|
||||
/// size and a mid-session resize renegotiates the host's virtual display + encoder
|
||||
@@ -1071,6 +1086,10 @@ fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_pad_speaker() -> String {
|
||||
"pad".into()
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// The stats-overlay tier, resolving pre-tier stores: an old `show_stats = false`
|
||||
/// reads as Off, everything else as Normal (≈ what the pre-tier overlay showed).
|
||||
@@ -1179,6 +1198,8 @@ impl Default for Settings {
|
||||
invert_scroll: false,
|
||||
speaker_device: String::new(),
|
||||
mic_device: String::new(),
|
||||
pad_haptics: true,
|
||||
pad_speaker: "pad".into(),
|
||||
match_window: false,
|
||||
last_window_w: 0,
|
||||
last_window_h: 0,
|
||||
|
||||
@@ -24,14 +24,20 @@ const RENEW_EVERY: Duration = Duration::from_millis(1000);
|
||||
/// 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).
|
||||
/// 0xCD plane carries only genuine changes. State (`Led` / `PlayerLeds` / `Trigger` / `AudioCtl`)
|
||||
/// 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],
|
||||
/// Last-forwarded audio-control state (`flags` + the raw volume/routing bytes).
|
||||
audio_ctl: Option<(u8, [u8; 6])>,
|
||||
/// Once-per-pad-lifetime field-diagnosis flag: set after the first forwarded `AudioCtl`
|
||||
/// carrying the haptics-select bit was logged (cleared with the rest on (re)plug).
|
||||
haptics_select_logged: bool,
|
||||
/// When anything was last put on the wire for this pad. `None` = nothing latched yet, so
|
||||
/// there is nothing to renew. See [`RENEW_EVERY`].
|
||||
last_sent: Option<Instant>,
|
||||
@@ -123,6 +129,25 @@ impl HidoutDedup {
|
||||
}
|
||||
// One-shot haptic pulse (Steam voice-coil) — state-less, always fires.
|
||||
HidOutput::TrackpadHaptic { .. } => true,
|
||||
HidOutput::AudioCtl { pad, flags, raw } => {
|
||||
let v = Some((*flags, *raw));
|
||||
if self.audio_ctl == v {
|
||||
false
|
||||
} else {
|
||||
// Field-diagnosis signal, once per pad lifetime: a title driving the DS5's
|
||||
// audio haptics (not plain rumble emulation, whose all-zero audio region
|
||||
// never reaches here) — the trace that tells "the game does audio haptics"
|
||||
// apart from "the client just doesn't render them".
|
||||
if flags & 0x01 != 0 && !self.haptics_select_logged {
|
||||
self.haptics_select_logged = true;
|
||||
tracing::info!(
|
||||
"DS5 title asserted haptics-select (audio haptics) pad={pad}"
|
||||
);
|
||||
}
|
||||
self.audio_ctl = v;
|
||||
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
|
||||
@@ -302,4 +327,29 @@ mod tests {
|
||||
// The pulse stamped the clock but latched no state, so the renewal has nothing to repeat.
|
||||
assert!(d.renewals(0, t + Duration::from_millis(1000)).is_empty());
|
||||
}
|
||||
|
||||
/// `AudioCtl` dedups by value like the other state kinds: an identical repeat (every output
|
||||
/// report re-sends the unchanged audio region) is dropped, a flags-only or raw-only change
|
||||
/// forwards again, and `clear` re-arms — including the once-per-pad haptics-select log flag.
|
||||
#[test]
|
||||
fn audio_ctl_dedups_by_value() {
|
||||
let mut d = HidoutDedup::default();
|
||||
let t = Instant::now();
|
||||
let audio = |flags, vol| HidOutput::AudioCtl {
|
||||
pad: 0,
|
||||
flags,
|
||||
raw: [vol, 0, 0, 0, 0, 0],
|
||||
};
|
||||
// Identical twice → exactly one emission.
|
||||
assert!(d.should_forward(&audio(0x17, 0x50), t));
|
||||
assert!(!d.should_forward(&audio(0x17, 0x50), t));
|
||||
// Either half changing (flags, or the raw region) forwards again.
|
||||
assert!(d.should_forward(&audio(0x16, 0x50), t));
|
||||
assert!(d.should_forward(&audio(0x16, 0x60), t));
|
||||
// The other kinds' state is untouched by audio traffic.
|
||||
assert!(d.should_forward(&HidOutput::PlayerLeds { pad: 0, bits: 1 }, t));
|
||||
// `clear` (pad re-plug) re-arms the value dedup.
|
||||
d.clear();
|
||||
assert!(d.should_forward(&audio(0x16, 0x60), t));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,7 +535,7 @@ pub mod out_report {
|
||||
|
||||
/// Parse a DualSense USB output report (`0x02`) into a [`DsFeedback`], indexed off
|
||||
/// [`out_report`]. Only the well-understood fields (motor rumble, lightbar RGB, player LEDs) are
|
||||
/// surfaced — adaptive-trigger blocks are forwarded raw for the client.
|
||||
/// surfaced — adaptive-trigger blocks and the audio-control region are forwarded raw for the client.
|
||||
///
|
||||
/// Every field is gated on the report's valid-flags (`valid_flag0` at data[1], `valid_flag1`
|
||||
/// at data[2]) — writers only set the bits for fields they mean to change (the rest is zeroed),
|
||||
@@ -592,6 +592,21 @@ pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
|
||||
});
|
||||
}
|
||||
}
|
||||
// The audio-control region (bytes 5..=10: headphone/speaker/mic volumes + routing), for the
|
||||
// pad-audio path. The wire flags condense the report's audio bits: bit0 = haptics-select
|
||||
// (flag0 BIT1 — set on every SDL rumble write too, which is why it alone never triggers an
|
||||
// emission), bits1..4 = flag0 bits 4..7 (the audio-valid flags gating the region). Emitted
|
||||
// whenever an audio-valid flag is present or the region carries data; downstream dedup
|
||||
// ([`crate::hidout_dedup`]) reduces the per-report repeats to genuine changes.
|
||||
let raw: [u8; 6] = data[5..11].try_into().unwrap();
|
||||
if flag0 & 0xF0 != 0 || raw != [0u8; 6] {
|
||||
let flags = ((flag0 >> 1) & 0x01) | ((flag0 >> 3) & 0x1E);
|
||||
fb.hidout.push(HidOutput::AudioCtl {
|
||||
pad: pad.into(),
|
||||
flags,
|
||||
raw,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -917,6 +932,48 @@ mod tests {
|
||||
assert_eq!(*DUALSENSE_EDGE_RDESC.last().unwrap(), 0xC0);
|
||||
}
|
||||
|
||||
/// A 0x02 report driving the pad's audio (haptics-select + audio-valid flags + the volume/
|
||||
/// routing bytes) surfaces an `AudioCtl` with the exact raw region and the condensed flags;
|
||||
/// a plain rumble write (haptics-select but a silent audio region — every SDL rumble) does
|
||||
/// NOT — that is what `parse_output_respects_valid_flags` pins with its `hidout.is_empty()`.
|
||||
#[test]
|
||||
fn parse_output_surfaces_audio_ctl() {
|
||||
let mut data = vec![0u8; 48];
|
||||
data[0] = 0x02;
|
||||
data[1] = 0xB2; // flag0: haptics-select (BIT1) + audio-valid bits 4/5/7
|
||||
data[5] = 0x50; // headphone volume
|
||||
data[6] = 0x60; // speaker volume
|
||||
data[7] = 0x70; // mic volume
|
||||
data[8] = 0x05; // audio routing / enable bits
|
||||
let mut fb = DsFeedback::default();
|
||||
parse_ds_output(3, &data, &mut fb);
|
||||
// flags: bit0 = flag0 bit1, bits1..4 = flag0 bits 4..7 (0b1011 → 0b10110).
|
||||
assert_eq!(
|
||||
fb.hidout,
|
||||
vec![HidOutput::AudioCtl {
|
||||
pad: 3,
|
||||
flags: 0b1_0111,
|
||||
raw: [0x50, 0x60, 0x70, 0x05, 0x00, 0x00],
|
||||
}]
|
||||
);
|
||||
// A non-zero audio region with NO audio-valid flags still surfaces (dedup collapses the
|
||||
// repeats downstream) — some writers leave stale volumes gated off; the host side wants
|
||||
// the honest bytes either way.
|
||||
let mut data = vec![0u8; 48];
|
||||
data[0] = 0x02;
|
||||
data[9] = 0x01;
|
||||
let mut fb = DsFeedback::default();
|
||||
parse_ds_output(0, &data, &mut fb);
|
||||
assert_eq!(
|
||||
fb.hidout,
|
||||
vec![HidOutput::AudioCtl {
|
||||
pad: 0,
|
||||
flags: 0,
|
||||
raw: [0, 0, 0, 0, 0x01, 0],
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
/// A short / wrong-id report yields nothing.
|
||||
#[test]
|
||||
fn parse_output_rejects_garbage() {
|
||||
|
||||
@@ -518,6 +518,7 @@ mod tests {
|
||||
index: 2,
|
||||
kind: 1,
|
||||
capabilities: 0,
|
||||
audio_caps: 0,
|
||||
});
|
||||
assert!(m.slots.get(2).is_some());
|
||||
}
|
||||
|
||||
@@ -2465,11 +2465,18 @@ pub fn ei_socket_file() -> std::path::PathBuf {
|
||||
crate::with_env_lock(pf_paths::gamescope_ei_socket_file)
|
||||
}
|
||||
|
||||
/// Does this resolved launch command start Steam (`steam … steam://…`)? Such a launch needs Steam's
|
||||
/// single instance free before a dedicated spawn (B1). Pure + unit-tested.
|
||||
/// Does this resolved launch command start the Steam **client**? Such a launch needs Steam's single
|
||||
/// instance free before a dedicated spawn (B1), and wants gamescope's `--steam` integration on.
|
||||
/// Pure + unit-tested.
|
||||
///
|
||||
/// The test is the first token, NOT the presence of a `steam://` URI. A `steam_ui` launcher entry
|
||||
/// (design D4) resolves to a bare `steam -gamepadui` / `steam` with no URI at all, and it is *more*
|
||||
/// exposed to the single-instance problem than a game launch is, not less: on a box that autologged
|
||||
/// into game mode, the nested second Steam would see the first and exit, taking the spawn down with
|
||||
/// it. A URI-gated check would silently skip both the instance free and `--steam` for exactly the
|
||||
/// launch that most needs them.
|
||||
fn is_steam_launch(cmd: &str) -> bool {
|
||||
let mut it = cmd.split_whitespace();
|
||||
it.next() == Some("steam") && cmd.contains("steam://")
|
||||
cmd.split_whitespace().next() == Some("steam")
|
||||
}
|
||||
|
||||
/// Shape a resolved launch command for a bare-spawn gamescope session. A Steam URI launch
|
||||
@@ -2865,7 +2872,13 @@ mod tests {
|
||||
assert!(is_steam_launch("steam -silent steam://rungameid/570"));
|
||||
assert!(!is_steam_launch("vkcube"));
|
||||
assert!(!is_steam_launch("lutris lutris:rungameid/42"));
|
||||
assert!(!is_steam_launch("steam -bigpicture")); // no URI = not a game launch
|
||||
// A `steam_ui` LAUNCHER entry (design D4) carries no URI, and must still count: it needs the
|
||||
// single instance freed (B1) and gamescope's `--steam` mode on. Gating on `steam://` would
|
||||
// have skipped both for the one launch that is Big Picture itself.
|
||||
assert!(is_steam_launch("steam -gamepadui"));
|
||||
assert!(is_steam_launch("steam"));
|
||||
// A command that merely mentions steam elsewhere is not a Steam client launch.
|
||||
assert!(!is_steam_launch("mygame --steam-overlay"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2891,6 +2904,13 @@ mod tests {
|
||||
shape_dedicated_command("steam -bigpicture"),
|
||||
"steam -bigpicture"
|
||||
);
|
||||
// The `steam_ui` launcher entries (design D4) pass through untouched — the shaping only ever
|
||||
// fires on a `steam://` game launch, so there is no way to end up with `-gamepadui` twice.
|
||||
assert_eq!(
|
||||
shape_dedicated_command("steam -gamepadui"),
|
||||
"steam -gamepadui"
|
||||
);
|
||||
assert_eq!(shape_dedicated_command("steam"), "steam");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -670,6 +670,12 @@ pub const PUNKTFUNK_HIDOUT_TRIGGER: u8 = 3;
|
||||
/// side (0 = right pad, 1 = left pad); `effect[0..6]` packs `amplitude` / `period` / `count` as
|
||||
/// little-endian `u16`s with `effect_len = 6`. Clients without trackpad coils drop it.
|
||||
pub const PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC: u8 = 4;
|
||||
/// `PunktfunkHidOutput::kind` — the audio-control region of a DS5 output report (pad-audio
|
||||
/// routing/volumes; the audio SAMPLES arrive via [`punktfunk_connection_next_pad_audio`]).
|
||||
/// `which` = the condensed audio flags (bit0 = haptics-select, bits1..4 = the report's
|
||||
/// audio-valid flags); `effect[0..6]` = bytes 5..=10 of the report verbatim
|
||||
/// (headphone/speaker/mic volumes + routing) with `effect_len = 6`. Forwarded change-only.
|
||||
pub const PUNKTFUNK_HIDOUT_AUDIO_CTL: u8 = 5;
|
||||
/// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block).
|
||||
pub const PUNKTFUNK_HID_EFFECT_MAX: u8 = 11;
|
||||
|
||||
@@ -762,6 +768,17 @@ impl PunktfunkHidOutput {
|
||||
out.effect_len = 6;
|
||||
}
|
||||
HidOutput::HidRaw { .. } => return None,
|
||||
HidOutput::AudioCtl { pad, flags, raw } => {
|
||||
// Same packing idiom as TrackpadHaptic: `which` carries the flags byte,
|
||||
// `effect[0..6]` the raw audio region. The u16 wire pad narrows losslessly
|
||||
// because `HidOutput::decode` refuses one at or above `input::MAX_PADS` (B27) —
|
||||
// it is enforced there, not merely assumed here.
|
||||
out.kind = PUNKTFUNK_HIDOUT_AUDIO_CTL;
|
||||
out.pad = *pad as u8;
|
||||
out.which = *flags;
|
||||
out.effect[0..6].copy_from_slice(raw);
|
||||
out.effect_len = 6;
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
@@ -1175,6 +1192,25 @@ pub const PUNKTFUNK_HOST_CAP_CLIPBOARD: u8 = 0x02;
|
||||
/// the client keeps its pen-as-touch fallback. (Mirrors `quic::HOST_CAP_PEN`;
|
||||
/// design/pen-tablet-input.md.)
|
||||
pub const PUNKTFUNK_HOST_CAP_PEN: u8 = 0x10;
|
||||
/// Host-capability bit in [`punktfunk_connection_host_caps`]: the host can capture per-gamepad
|
||||
/// audio (DualSense voice-coil haptics + speaker) and emit it on the 0xD1 plane toward pads
|
||||
/// declared capable via [`punktfunk_connection_set_pad_audio_caps`]. Set only when the client
|
||||
/// asked via [`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO`]. (Mirrors `quic::HOST_CAP_PAD_AUDIO`.)
|
||||
pub const PUNKTFUNK_HOST_CAP_PAD_AUDIO: u8 = 0x40;
|
||||
|
||||
/// Pad-audio `kind` ([`punktfunk_connection_next_pad_audio`]): the BACK channel pair — DualSense
|
||||
/// voice-coil haptics, 5 ms Opus frames. (Mirrors `quic::PAD_AUDIO_KIND_HAPTICS`.)
|
||||
pub const PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS: u8 = 0;
|
||||
/// Pad-audio `kind`: the FRONT channel pair — the controller's built-in speaker, 10 ms Opus
|
||||
/// frames. (Mirrors `quic::PAD_AUDIO_KIND_SPEAKER`.)
|
||||
pub const PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER: u8 = 1;
|
||||
|
||||
/// [`punktfunk_connection_set_pad_audio_caps`] `audio_caps` bit: the pad renders the HAPTICS
|
||||
/// stream (a real DualSense's voice coils).
|
||||
pub const PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS: u8 = 0x01;
|
||||
/// [`punktfunk_connection_set_pad_audio_caps`] `audio_caps` bit: the pad renders the SPEAKER
|
||||
/// stream.
|
||||
pub const PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER: u8 = 0x02;
|
||||
|
||||
// Keep the ABI cap bits in lockstep with the wire constants (compile-time guard against drift).
|
||||
#[cfg(feature = "quic")]
|
||||
@@ -1189,6 +1225,20 @@ const _: () = {
|
||||
assert!(PUNKTFUNK_HOST_CAP_GAMEPAD_STATE == crate::quic::HOST_CAP_GAMEPAD_STATE);
|
||||
assert!(PUNKTFUNK_HOST_CAP_CLIPBOARD == crate::quic::HOST_CAP_CLIPBOARD);
|
||||
assert!(PUNKTFUNK_HOST_CAP_PEN == crate::quic::HOST_CAP_PEN);
|
||||
assert!(PUNKTFUNK_HOST_CAP_PAD_AUDIO == crate::quic::HOST_CAP_PAD_AUDIO);
|
||||
assert!(PUNKTFUNK_CLIENT_CAP_PAD_AUDIO == crate::quic::CLIENT_CAP_PAD_AUDIO);
|
||||
assert!(PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS == crate::quic::PAD_AUDIO_KIND_HAPTICS);
|
||||
assert!(PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER == crate::quic::PAD_AUDIO_KIND_SPEAKER);
|
||||
// The setter's caps bits are the arrival flags bits 8/9 shifted down (the wire packing
|
||||
// `input::encode_gamepad_arrival` applies).
|
||||
assert!(
|
||||
(PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS as u32) << 8
|
||||
== crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS
|
||||
);
|
||||
assert!(
|
||||
(PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER as u32) << 8
|
||||
== crate::input::ARRIVAL_FLAG_PAD_AUDIO_SPEAKER
|
||||
);
|
||||
assert!(PUNKTFUNK_PEN_IN_RANGE == crate::quic::PEN_IN_RANGE);
|
||||
assert!(PUNKTFUNK_PEN_TOUCHING == crate::quic::PEN_TOUCHING);
|
||||
assert!(PUNKTFUNK_PEN_BARREL1 == crate::quic::PEN_BARREL1);
|
||||
@@ -1771,6 +1821,13 @@ pub const PUNKTFUNK_CLIENT_CAP_CURSOR: u8 = 0x01;
|
||||
/// forward-compatible.
|
||||
pub const PUNKTFUNK_CLIENT_CAP_PHASE_LOCK: u8 = 0x02;
|
||||
|
||||
/// [`punktfunk_connect_ex9`] `client_caps` bit: the client understands the pad-audio plane
|
||||
/// (0xD1 — per-gamepad DualSense voice-coil haptics + speaker). The embedder MUST then drain
|
||||
/// [`punktfunk_connection_next_pad_audio`] and declare each capable pad via
|
||||
/// [`punktfunk_connection_set_pad_audio_caps`]; the host emits pad audio only when it answers
|
||||
/// with [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`]. (Mirrors `quic::CLIENT_CAP_PAD_AUDIO`.)
|
||||
pub const PUNKTFUNK_CLIENT_CAP_PAD_AUDIO: u8 = 0x08;
|
||||
|
||||
/// Shared body of [`punktfunk_connect_ex7`] / [`punktfunk_connect_ex8`]: `status_out`
|
||||
/// (nullable) is written on EVERY path — `Ok`, the mapped [`PunktfunkError`],
|
||||
/// `InvalidArg` for bad arguments, `Panic` if the connect panicked.
|
||||
@@ -2315,6 +2372,117 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio_pcm(
|
||||
})
|
||||
}
|
||||
|
||||
/// Pull the next pad-audio frame (0xD1) — one Opus frame of DualSense voice-coil haptics
|
||||
/// (`kind` = [`PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS`], 5 ms) or built-in-speaker audio
|
||||
/// ([`PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER`], 10 ms) for gamepad `*out_pad` — waiting up to
|
||||
/// `timeout_ms`. The payload is COPIED into `buf` (no borrow-until-next-call slot); the return
|
||||
/// value is its length in bytes, `0` = nothing this poll (timeout — or a DTX/oversized frame,
|
||||
/// both of which an embedder treats the same way), `-1` = the session ended (or an invalid
|
||||
/// handle/buffer). All pads/kinds share one queue — fan out by `*out_pad`/`*out_kind` to
|
||||
/// per-actuator Opus decoders. A frame larger than `buf_len` is dropped like the timeout case
|
||||
/// (the plane is lossy by design; any real Opus frame fits a 1500-byte buffer). Only a session
|
||||
/// connected with [`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO`] against a
|
||||
/// [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`] host — with the pad declared via
|
||||
/// [`punktfunk_connection_set_pad_audio_caps`] — ever receives any. Drain from a dedicated
|
||||
/// thread (one puller, may run alongside the other planes' pullers).
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; the `out_*` pointers are writable (NULLs are skipped);
|
||||
/// `buf` is writable for `buf_len` bytes.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_next_pad_audio(
|
||||
c: *mut PunktfunkConnection,
|
||||
out_pad: *mut u8,
|
||||
out_kind: *mut u8,
|
||||
out_seq: *mut u32,
|
||||
out_pts_ns: *mut u64,
|
||||
buf: *mut u8,
|
||||
buf_len: usize,
|
||||
timeout_ms: u32,
|
||||
) -> i32 {
|
||||
let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
|
||||
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
|
||||
// has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
|
||||
// here handles.
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return -1,
|
||||
};
|
||||
if buf.is_null() && buf_len != 0 {
|
||||
return -1;
|
||||
}
|
||||
match c
|
||||
.inner
|
||||
.next_pad_audio(std::time::Duration::from_millis(timeout_ms as u64))
|
||||
{
|
||||
Some(f) => {
|
||||
if f.opus.is_empty() || f.opus.len() > buf_len {
|
||||
// DTX silence (skipped like the audio-PCM path — decoding an empty payload
|
||||
// as loss would synthesize concealment) or doesn't fit — report "nothing
|
||||
// this poll" (the next_hidout HidRaw-skip precedent; truncated Opus would
|
||||
// be undecodable anyway).
|
||||
return 0;
|
||||
}
|
||||
// SAFETY: per the ABI contract - each out-param below is OPTIONAL, so it is null-
|
||||
// checked before it is written; `buf` is a caller-owned writable region of
|
||||
// `buf_len` bytes and the copy length was just bounds-checked against it.
|
||||
unsafe {
|
||||
if !out_pad.is_null() {
|
||||
*out_pad = f.pad;
|
||||
}
|
||||
if !out_kind.is_null() {
|
||||
*out_kind = f.kind;
|
||||
}
|
||||
if !out_seq.is_null() {
|
||||
*out_seq = f.seq;
|
||||
}
|
||||
if !out_pts_ns.is_null() {
|
||||
*out_pts_ns = f.pts_ns;
|
||||
}
|
||||
std::ptr::copy_nonoverlapping(f.opus.as_ptr(), buf, f.opus.len());
|
||||
}
|
||||
f.opus.len() as i32
|
||||
}
|
||||
// `None` folds timeout and closed; the shutdown flag tells them apart so the
|
||||
// embedder's plane loop can exit instead of polling a dead session forever.
|
||||
None if c.inner.is_session_ended() => -1,
|
||||
None => 0,
|
||||
}
|
||||
}));
|
||||
r.unwrap_or(-1)
|
||||
}
|
||||
|
||||
/// Declare wire pad `pad`'s pad-audio render capabilities (`audio_caps`: OR of
|
||||
/// [`PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS`] / [`PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER`]) — how a client
|
||||
/// tells the host WHICH pads can actually play the 0xD1 streams. Call at controller attach,
|
||||
/// BEFORE the pad's arrival event is sent (the [`punktfunk_connection_set_rumble_quirks`]
|
||||
/// timing): the core folds the bits into the arrival's flags (bits 8/9), and only toward a
|
||||
/// [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`] host — never calling this leaves the wire bytes exactly as
|
||||
/// before. Latest-wins per pad; unknown bits are masked off.
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle. Callable from any thread.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_set_pad_audio_caps(
|
||||
c: *mut PunktfunkConnection,
|
||||
pad: u8,
|
||||
audio_caps: u8,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
|
||||
// has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
|
||||
// here handles.
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
c.inner.set_pad_audio_caps(pad, audio_caps);
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
/// Pull the next rumble (force-feedback) update, waiting up to `timeout_ms`. Amplitudes
|
||||
/// are 0..0xFFFF (`low` = low-frequency motor, `high` = high-frequency), `(0, 0)` = stop.
|
||||
/// Same timeout/closed semantics as [`punktfunk_connection_next_audio`].
|
||||
@@ -4417,3 +4585,36 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_is_holding(
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "quic"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The `AudioCtl` → `PunktfunkHidOutput` mapping: kind 5, pad narrowed, `which` carries the
|
||||
/// flags byte, `effect[0..6]` the raw audio region with `effect_len = 6` (the TrackpadHaptic
|
||||
/// packing idiom — no struct growth, so the size guard above stays at 19).
|
||||
#[test]
|
||||
fn hidout_abi_maps_audio_ctl() {
|
||||
let out = PunktfunkHidOutput::from_hid(&crate::quic::HidOutput::AudioCtl {
|
||||
pad: 3,
|
||||
flags: 0x17,
|
||||
raw: [0x50, 0x60, 0x70, 0x05, 0, 0],
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(out.kind, PUNKTFUNK_HIDOUT_AUDIO_CTL);
|
||||
assert_eq!(out.pad, 3);
|
||||
assert_eq!(out.which, 0x17);
|
||||
assert_eq!(out.effect_len, 6);
|
||||
assert_eq!(out.effect[..6], [0x50, 0x60, 0x70, 0x05, 0, 0]);
|
||||
assert_eq!(out.effect[6..], [0; 5]);
|
||||
// A raw passthrough report still has no C representation (skipped at the pull site).
|
||||
assert!(
|
||||
PunktfunkHidOutput::from_hid(&crate::quic::HidOutput::HidRaw {
|
||||
pad: 0,
|
||||
kind: 0,
|
||||
data: vec![0x80],
|
||||
})
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +159,17 @@ const CAP_REPROBE_WINDOWS_MAX: u32 = 128;
|
||||
/// choke again at the same place, and only backoffs at a climbed-to rate can agree within the
|
||||
/// band (a cascade's second backoff sits at ×0.7 of the first: outside it by construction).
|
||||
const DECODE_CAP_SIMILAR_DIV: u32 = 8;
|
||||
/// A deciding window that DELIVERED under `current / STARVED_DELIVERY_DIV` is STARVED: the
|
||||
/// stream barely flowed (a host-side capture stall, an outage, a mid-window pause), so whatever
|
||||
/// distress the window carries — a flush, a keyframe-ask burst — is starvation-shaped, not
|
||||
/// rate-shaped, and the decoder decoded almost nothing at the nominal rate. Such a window may
|
||||
/// still back off (real damage deserves the safe response) but must never be a decode-knee
|
||||
/// sample: latching `current_kbps` off a starved window teaches a phantom decoder cap at
|
||||
/// whatever rate the stall interrupted (the periodic-capture-stall field case: every 5 s cycle
|
||||
/// offers another pair of "backoffs" at the same rate — a bogus latch that then fights the
|
||||
/// re-probe ladder for minutes). Deliberately far below the ×¾ utilization bar climbs require:
|
||||
/// the band between them is ambiguous and keeps today's behavior.
|
||||
const STARVED_DELIVERY_DIV: u32 = 4;
|
||||
/// Rolling window (in 750 ms report windows, ~30 s) whose minimum mean is the OWD baseline.
|
||||
/// Long enough to remember the uncongested floor, short enough to follow genuine path changes.
|
||||
const BASELINE_WINDOWS: usize = 40;
|
||||
@@ -697,6 +708,10 @@ impl BitrateController {
|
||||
|| self.streak_decode_windows >= BAD_WINDOWS_TO_DECREASE
|
||||
|| (recovery_kf >= RECOVERY_KF_BAD && loss_ppm < HEAVY_LOSS_PPM)
|
||||
|| (flushed && (decode_bad || decode_mean_us.is_none()));
|
||||
// Starved deciding window (see [`STARVED_DELIVERY_DIV`]): the stream barely flowed,
|
||||
// so the window says nothing about what the decoder can hold at this rate.
|
||||
let starved =
|
||||
(actual_kbps as u64) * (STARVED_DELIVERY_DIV as u64) < self.current_kbps as u64;
|
||||
if !self.climb_since_backoff {
|
||||
// Still draining the previous backoff: the host acks a ×0.7 request in ~100 ms,
|
||||
// so this window's rate is one the decoder never choked at while keeping up —
|
||||
@@ -708,6 +723,17 @@ impl BitrateController {
|
||||
"adaptive bitrate: backoff without an intervening climb — draining the \
|
||||
previous choke, not a knee sample"
|
||||
);
|
||||
} else if starved {
|
||||
// Same "not a knee sample either way" treatment as the draining arm: neither
|
||||
// latch against a starved window nor let it erase the reference a real knee
|
||||
// set — the next genuine choke at that rate must still find its pair.
|
||||
tracing::debug!(
|
||||
at_kbps = self.current_kbps,
|
||||
actual_kbps,
|
||||
reference_kbps = self.decode_backoff_kbps,
|
||||
"adaptive bitrate: backoff in a starved window (delivery a fraction of \
|
||||
the target) — starvation-shaped distress, not a knee sample"
|
||||
);
|
||||
} else if decode_evidence {
|
||||
let rate = self.current_kbps;
|
||||
let similar = self.decode_backoff_kbps > 0
|
||||
@@ -2084,6 +2110,100 @@ mod tests {
|
||||
rate - rate / 16
|
||||
}
|
||||
|
||||
/// One capture-stall-shaped window at the current rate: almost nothing delivered
|
||||
/// (current/10), nothing decoded, no loss — but a jump-to-live flush and a keyframe-ask
|
||||
/// storm (the stall edge's damage signature). SEVERE, so it backs off; STARVED, so it must
|
||||
/// never be a knee sample.
|
||||
fn stall_choke(c: &mut BitrateController, start: Instant, tick: &mut u32) -> Option<u32> {
|
||||
*tick += 2;
|
||||
let r = c.on_window(
|
||||
ticks(start, *tick),
|
||||
0,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
c.current_kbps / 10,
|
||||
true,
|
||||
RECOVERY_KF_SEVERE,
|
||||
);
|
||||
*tick += 1;
|
||||
r
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_stall_windows_never_latch_a_decode_cap() {
|
||||
// The periodic-capture-stall field case (RDNA4 standby-sink, 5 s cycle): every stall
|
||||
// edge offers another flush + kf-storm "backoff" at the SAME rate — without the starved
|
||||
// guard that pair latches a phantom decoder knee at whatever rate the display driver
|
||||
// happened to interrupt, and the session then fights the re-probe ladder for minutes.
|
||||
let mut c = BitrateController::new(240_000);
|
||||
c.set_ceiling(900_000);
|
||||
let start = Instant::now();
|
||||
let mut t = 0;
|
||||
for _ in 0..4 {
|
||||
calm_window(&mut c, ticks(start, t));
|
||||
t += 1;
|
||||
}
|
||||
climb_to(&mut c, start, &mut t, 400_000);
|
||||
let at = c.current_kbps;
|
||||
let r1 = stall_choke(&mut c, start, &mut t).expect("stall damage still backs off");
|
||||
assert!(
|
||||
c.decode_cap_kbps.is_none(),
|
||||
"one starved window must not latch"
|
||||
);
|
||||
assert_eq!(
|
||||
c.decode_backoff_kbps, 0,
|
||||
"a starved window is not a knee sample — no reference recorded"
|
||||
);
|
||||
c.on_ack(r1);
|
||||
climb_to(&mut c, start, &mut t, at - at / DECODE_CAP_SIMILAR_DIV);
|
||||
let r2 = stall_choke(&mut c, start, &mut t).expect("second stall edge backs off too");
|
||||
c.on_ack(r2);
|
||||
assert!(
|
||||
c.decode_cap_kbps.is_none(),
|
||||
"a starved pair at the same rate must not latch a phantom knee"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starved_window_preserves_the_knee_reference() {
|
||||
// A REAL knee sample, then a stall edge, then the genuine re-climb choke: the starved
|
||||
// window in the middle must neither latch nor ERASE the reference the real choke set —
|
||||
// the genuine pair must still find each other around it.
|
||||
let mut c = BitrateController::new(500_000);
|
||||
c.set_ceiling(900_000);
|
||||
let start = Instant::now();
|
||||
let mut t = 0;
|
||||
for _ in 0..4 {
|
||||
calm_window(&mut c, ticks(start, t));
|
||||
t += 1;
|
||||
}
|
||||
let knee = c.current_kbps;
|
||||
let r1 = choke(&mut c, start, &mut t).expect("real choke backs off");
|
||||
assert_eq!(
|
||||
c.decode_backoff_kbps, knee,
|
||||
"real choke records the reference"
|
||||
);
|
||||
c.on_ack(r1);
|
||||
climb_to(&mut c, start, &mut t, knee - knee / DECODE_CAP_SIMILAR_DIV);
|
||||
let r2 = stall_choke(&mut c, start, &mut t).expect("stall edge backs off");
|
||||
assert_eq!(
|
||||
c.decode_backoff_kbps, knee,
|
||||
"the starved window must not erase the real reference"
|
||||
);
|
||||
assert!(c.decode_cap_kbps.is_none(), "and must not latch against it");
|
||||
c.on_ack(r2);
|
||||
climb_to(&mut c, start, &mut t, knee - knee / DECODE_CAP_SIMILAR_DIV);
|
||||
let rate = c.current_kbps;
|
||||
choke(&mut c, start, &mut t).expect("genuine re-climb choke backs off");
|
||||
assert_eq!(
|
||||
c.decode_cap_kbps,
|
||||
Some(rate - rate / 16),
|
||||
"the genuine pair still latches around the starved interruption"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_cap_latches_when_the_reclimb_chokes_at_the_same_knee() {
|
||||
// The 1440p120 field sawtooth: a decoder knee (~500 Mbps) well under the (inflated)
|
||||
|
||||
@@ -16,11 +16,13 @@ use crate::config::{CompositorPref, GamepadPref, Mode};
|
||||
use crate::error::{PunktfunkError, Result};
|
||||
use crate::input::InputEvent;
|
||||
use crate::quic::{
|
||||
endpoint, ClipControl, ClipKind, ClipOffer, ColorInfo, HdrMeta, HidOutput, ProbeRequest,
|
||||
RfiRequest, RichInput,
|
||||
endpoint, ClipControl, ClipKind, ClipOffer, ColorInfo, HdrMeta, HidOutput, PadAudioFrame,
|
||||
ProbeRequest, RfiRequest, RichInput,
|
||||
};
|
||||
use crate::session::Frame;
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU16, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::atomic::{
|
||||
AtomicBool, AtomicI64, AtomicU16, AtomicU32, AtomicU64, AtomicU8, Ordering,
|
||||
};
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -43,7 +45,7 @@ use self::control::{CtrlRequest, Negotiated};
|
||||
use self::frame_channel::{DecodeLatAcc, FrameChannel, FramePop};
|
||||
use self::planes::{
|
||||
RumbleUpdate, AUDIO_QUEUE, CLIP_EVENT_QUEUE, CURSOR_SHAPE_QUEUE, CURSOR_STATE_QUEUE,
|
||||
HDR_META_QUEUE, HIDOUT_QUEUE, HOST_TIMING_QUEUE, RUMBLE_QUEUE,
|
||||
HDR_META_QUEUE, HIDOUT_QUEUE, HOST_TIMING_QUEUE, PAD_AUDIO_QUEUE, RUMBLE_QUEUE,
|
||||
};
|
||||
use self::probe::ProbeState;
|
||||
use self::pump::run_pump;
|
||||
@@ -122,6 +124,14 @@ pub struct NativeClient {
|
||||
rumble_sched: Arc<rumble::RumbleShared>,
|
||||
/// Inbound DualSense feedback (lightbar / player LEDs / adaptive triggers) — 0xCD datagrams.
|
||||
hidout: Mutex<Receiver<HidOutput>>,
|
||||
/// Inbound pad audio (DualSense voice-coil haptics + speaker Opus frames) — 0xD1 datagrams.
|
||||
/// Only a session that advertised [`quic::CLIENT_CAP_PAD_AUDIO`] against a
|
||||
/// [`quic::HOST_CAP_PAD_AUDIO`] host ever receives any.
|
||||
pad_audio: Mutex<Receiver<PadAudioFrame>>,
|
||||
/// Per-pad pad-audio render capabilities (bit0 haptics, bit1 speaker), written by
|
||||
/// [`NativeClient::set_pad_audio_caps`] and OR'd into outgoing gamepad-arrival flags
|
||||
/// (bits 8/9) by the worker's input task — toward a `HOST_CAP_PAD_AUDIO` host only.
|
||||
pad_audio_caps: Arc<[AtomicU8; crate::input::MAX_PADS]>,
|
||||
/// Inbound static HDR metadata (ST.2086 mastering + content light level) — 0xCE datagrams.
|
||||
hdr_meta: Mutex<Receiver<HdrMeta>>,
|
||||
/// Inbound per-AU host capture→send timings — 0xCF datagrams (the client always advertises
|
||||
@@ -418,6 +428,10 @@ impl NativeClient {
|
||||
let rumble_sched = Arc::new(rumble::RumbleShared::new());
|
||||
let rumble_feed = rumble::RumbleFeed(rumble_sched.clone());
|
||||
let (hidout_tx, hidout_rx) = std::sync::mpsc::sync_channel::<HidOutput>(HIDOUT_QUEUE);
|
||||
let (pad_audio_tx, pad_audio_rx) =
|
||||
std::sync::mpsc::sync_channel::<PadAudioFrame>(PAD_AUDIO_QUEUE);
|
||||
let pad_audio_caps: Arc<[AtomicU8; crate::input::MAX_PADS]> =
|
||||
Arc::new(std::array::from_fn(|_| AtomicU8::new(0)));
|
||||
let (hdr_meta_tx, hdr_meta_rx) = std::sync::mpsc::sync_channel::<HdrMeta>(HDR_META_QUEUE);
|
||||
let (host_timing_tx, host_timing_rx) =
|
||||
std::sync::mpsc::sync_channel::<crate::quic::HostTiming>(HOST_TIMING_QUEUE);
|
||||
@@ -459,6 +473,7 @@ impl NativeClient {
|
||||
let clock_offset_w = clock_offset.clone();
|
||||
let decode_lat_w = decode_lat.clone();
|
||||
let live_bitrate_w = live_bitrate.clone();
|
||||
let pad_audio_caps_w = pad_audio_caps.clone();
|
||||
let ctrl_tx_pump = ctrl_tx.clone(); // the data-plane pump sends adaptive-FEC LossReports
|
||||
let worker = std::thread::Builder::new()
|
||||
.name("punktfunk-client".into())
|
||||
@@ -508,6 +523,8 @@ impl NativeClient {
|
||||
rumble_tx,
|
||||
rumble_feed,
|
||||
hidout_tx,
|
||||
pad_audio_tx,
|
||||
pad_audio_caps: pad_audio_caps_w,
|
||||
hdr_meta_tx,
|
||||
host_timing_tx,
|
||||
cursor_shape_tx,
|
||||
@@ -556,6 +573,8 @@ impl NativeClient {
|
||||
rumble: Mutex::new(rumble_rx),
|
||||
rumble_sched,
|
||||
hidout: Mutex::new(hidout_rx),
|
||||
pad_audio: Mutex::new(pad_audio_rx),
|
||||
pad_audio_caps,
|
||||
hdr_meta: Mutex::new(hdr_meta_rx),
|
||||
host_timing: Mutex::new(host_timing_rx),
|
||||
cursor_shape: Mutex::new(cursor_shape_rx),
|
||||
@@ -1061,6 +1080,33 @@ impl NativeClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the next pad-audio frame (0xD1): one Opus frame of DualSense voice-coil haptics
|
||||
/// ([`quic::PAD_AUDIO_KIND_HAPTICS`], 5 ms) or built-in-speaker audio
|
||||
/// ([`quic::PAD_AUDIO_KIND_SPEAKER`], 10 ms) for gamepad `pad`. All pads/kinds share the
|
||||
/// queue — the embedder fans out by `pad`/`kind` to per-actuator Opus decoders. `None` on
|
||||
/// timeout AND once the session ended ([`is_session_ended`](Self::is_session_ended)
|
||||
/// distinguishes, and the plane is best-effort either way). Only a session that advertised
|
||||
/// [`quic::CLIENT_CAP_PAD_AUDIO`] against a [`quic::HOST_CAP_PAD_AUDIO`] host — with the
|
||||
/// pad's render caps declared via [`set_pad_audio_caps`](Self::set_pad_audio_caps) — ever
|
||||
/// receives any. Drain on a dedicated thread like [`next_audio`](Self::next_audio); one
|
||||
/// puller per the plane contract.
|
||||
pub fn next_pad_audio(&self, timeout: Duration) -> Option<PadAudioFrame> {
|
||||
self.pad_audio.lock().unwrap().recv_timeout(timeout).ok()
|
||||
}
|
||||
|
||||
/// Declare wire pad `pad`'s pad-audio render capabilities: `audio_caps` bit0 = the pad can
|
||||
/// play the HAPTICS stream (a real DualSense's voice coils), bit1 = the SPEAKER stream.
|
||||
/// Call at controller attach, BEFORE the pad's arrival is sent (like
|
||||
/// [`set_rumble_quirks`](Self::set_rumble_quirks)) — the worker ORs the bits into the
|
||||
/// arrival's flags (bits 8/9), and only toward a [`quic::HOST_CAP_PAD_AUDIO`] host, so an
|
||||
/// embedder that never calls this (or a host that can't capture pad audio) leaves the wire
|
||||
/// bytes exactly as before. Latest-wins per pad; unknown bits are masked off.
|
||||
pub fn set_pad_audio_caps(&self, pad: u8, audio_caps: u8) {
|
||||
if let Some(slot) = self.pad_audio_caps.get(pad as usize) {
|
||||
slot.store(audio_caps & 0x03, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the next static HDR metadata update (ST.2086 mastering display + content light level)
|
||||
/// the host sent for an HDR session; same timeout/closed semantics as
|
||||
/// [`NativeClient::next_hidout`]. The host sends one near session start and re-sends it on
|
||||
|
||||
@@ -20,6 +20,12 @@ pub(crate) type RumbleUpdate = (u16, u16, u16, Option<u16>);
|
||||
/// Same overflow discipline as rumble; the host re-sends on the next feedback change.
|
||||
pub(crate) const HIDOUT_QUEUE: usize = 32;
|
||||
|
||||
/// Pad-audio frames (`0xD1` — DualSense voice-coil haptics + speaker) buffered for the embedder,
|
||||
/// ALL pads and kinds on one queue (the embedder fans out by `pad`/`kind`): 64 × 5 ms = 320 ms of
|
||||
/// slack on a haptics-only stream, the [`AUDIO_QUEUE`] discipline. A lagging embedder drops the
|
||||
/// newest frame (the renderer conceals the gap).
|
||||
pub(crate) const PAD_AUDIO_QUEUE: usize = 64;
|
||||
|
||||
/// Static HDR metadata (ST.2086 mastering + content light level) buffered for the embedder. Tiny
|
||||
/// and low-rate (one on start, re-sent on mastering changes / keyframes); a small ring is ample.
|
||||
pub(crate) const HDR_META_QUEUE: usize = 8;
|
||||
|
||||
@@ -50,6 +50,8 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
rumble_tx,
|
||||
rumble_feed,
|
||||
hidout_tx,
|
||||
pad_audio_tx,
|
||||
pad_audio_caps,
|
||||
hdr_meta_tx,
|
||||
host_timing_tx,
|
||||
cursor_shape_tx,
|
||||
@@ -92,9 +94,17 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
|
||||
// Input task: embedder events → uplink datagrams, with per-transition gamepad events
|
||||
// folded into idempotent seq-stamped snapshots toward a HOST_CAP_GAMEPAD_STATE host
|
||||
// (see [`input_task`]).
|
||||
// (see [`input_task`]). Pad-audio render caps ride arrival flags bits 8/9 ONLY toward a
|
||||
// HOST_CAP_PAD_AUDIO host — an older host reads the whole flags word as the pad index.
|
||||
let gamepad_snapshots = host_caps & crate::quic::HOST_CAP_GAMEPAD_STATE != 0;
|
||||
tokio::spawn(input_task::run(conn.clone(), input_rx, gamepad_snapshots));
|
||||
let pad_audio_arrivals = host_caps & crate::quic::HOST_CAP_PAD_AUDIO != 0;
|
||||
tokio::spawn(input_task::run(
|
||||
conn.clone(),
|
||||
input_rx,
|
||||
gamepad_snapshots,
|
||||
pad_audio_arrivals,
|
||||
pad_audio_caps,
|
||||
));
|
||||
|
||||
// Mic task: embedder Opus mic frames → 0xCB uplink datagrams (best-effort, dropped on loss).
|
||||
// Self-healing latency bound: every frame still queued once this task catches up is standing
|
||||
@@ -166,6 +176,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
rumble_tx,
|
||||
rumble_feed,
|
||||
hidout_tx,
|
||||
pad_audio_tx,
|
||||
hdr_meta_tx,
|
||||
host_timing_tx,
|
||||
encode_lat.clone(),
|
||||
|
||||
@@ -12,6 +12,7 @@ pub(super) async fn run(
|
||||
rumble_tx: std::sync::mpsc::SyncSender<RumbleUpdate>,
|
||||
rumble_feed: super::super::rumble::RumbleFeed,
|
||||
hidout_tx: std::sync::mpsc::SyncSender<crate::quic::HidOutput>,
|
||||
pad_audio_tx: std::sync::mpsc::SyncSender<crate::quic::PadAudioFrame>,
|
||||
hdr_meta_tx: std::sync::mpsc::SyncSender<crate::quic::HdrMeta>,
|
||||
host_timing_tx: std::sync::mpsc::SyncSender<crate::quic::HostTiming>,
|
||||
// The ABR encode signal's accumulator (see [`EncodeLatAcc`]) — fed HERE, not off
|
||||
@@ -100,6 +101,11 @@ pub(super) async fn run(
|
||||
let _ = hidout_tx.try_send(h);
|
||||
}
|
||||
}
|
||||
Some(&crate::quic::PAD_AUDIO_MAGIC) => {
|
||||
if let Some(f) = crate::quic::decode_pad_audio_datagram(&d) {
|
||||
let _ = pad_audio_tx.try_send(f);
|
||||
}
|
||||
}
|
||||
Some(&crate::quic::HDR_META_MAGIC) => {
|
||||
if let Some(m) = crate::quic::decode_hdr_meta_datagram(&d) {
|
||||
let _ = hdr_meta_tx.try_send(m);
|
||||
|
||||
@@ -15,8 +15,16 @@ pub(super) async fn run(
|
||||
conn: quinn::Connection,
|
||||
mut input_rx: tokio::sync::mpsc::UnboundedReceiver<InputEvent>,
|
||||
gamepad_snapshots: bool,
|
||||
// Whether the host advertised HOST_CAP_PAD_AUDIO: only then do arrivals carry the per-pad
|
||||
// audio-render bits (flags 8/9) — an older host reads the whole flags word as the pad index,
|
||||
// so unexpected high bits would make it drop the kind declaration entirely.
|
||||
pad_audio: bool,
|
||||
// Per-pad audio-render capabilities (bit0 haptics, bit1 speaker), fed by the embedder via
|
||||
// [`NativeClient::set_pad_audio_caps`] and by arrival events already carrying the bits.
|
||||
pad_audio_caps: std::sync::Arc<[std::sync::atomic::AtomicU8; crate::input::MAX_PADS]>,
|
||||
) {
|
||||
use crate::input::{GamepadSnapshot, InputKind, MAX_PADS};
|
||||
use std::sync::atomic::Ordering;
|
||||
// Touched pads only: an entry appears on the first gamepad event for that index, so the
|
||||
// refresh never conjures a virtual pad the embedder didn't drive.
|
||||
let mut pads: [Option<GamepadSnapshot>; MAX_PADS] = [None; MAX_PADS];
|
||||
@@ -37,6 +45,28 @@ pub(super) async fn run(
|
||||
const ARRIVAL_RESENDS: u8 = 2;
|
||||
let mut arrival: [Option<u8>; MAX_PADS] = [None; MAX_PADS];
|
||||
let mut arrival_owed: [u8; MAX_PADS] = [0; MAX_PADS];
|
||||
// An arrival's outgoing flags word: the pad index, plus the pad's audio-render bits (8/9)
|
||||
// toward a HOST_CAP_PAD_AUDIO host. With no declared caps (or an older host) this is
|
||||
// byte-identical to the plain index — the pre-pad-audio wire.
|
||||
// B7: the caps a pad's LAST arrival actually carried. `set_pad_audio_caps` only stores into
|
||||
// the registry — it cannot reach this task — so a declaration that lands after the arrival
|
||||
// burst has drained (the renderer commits the trade only once its sink opens, which is well
|
||||
// past the two 100 ms ticks) used to never reach the host at all: the client believed it had
|
||||
// pad audio and the host emitted nothing on 0xD1, silently, forever. Comparing this against
|
||||
// the live registry on every tick re-arms the burst by itself, with no new plumbing and no
|
||||
// extra traffic when nothing changed.
|
||||
let mut arrival_caps_sent: [u8; MAX_PADS] = [0; MAX_PADS];
|
||||
let caps_now = |idx: usize| -> u8 {
|
||||
if pad_audio {
|
||||
pad_audio_caps[idx].load(Ordering::Relaxed)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
};
|
||||
let arrival_flags = |idx: usize| -> u32 {
|
||||
let caps = caps_now(idx);
|
||||
crate::input::encode_gamepad_arrival(idx as u8, caps)
|
||||
};
|
||||
let mut refresh = tokio::time::interval(Duration::from_millis(100));
|
||||
refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
@@ -81,30 +111,56 @@ pub(super) async fn run(
|
||||
let _ = conn.send_datagram(rem.encode().to_vec().into());
|
||||
continue;
|
||||
}
|
||||
if gamepad_snapshots && ev.kind == InputKind::GamepadArrival && idx < MAX_PADS {
|
||||
// Remember the declared kind (`code`) and forward it, arming a re-send burst
|
||||
// so the host learns it before the pad's first frame even under loss.
|
||||
arrival[idx] = Some(ev.code as u8);
|
||||
arrival_owed[idx] = ARRIVAL_RESENDS;
|
||||
let _ = conn.send_datagram(ev.encode().to_vec().into());
|
||||
continue;
|
||||
if gamepad_snapshots && ev.kind == InputKind::GamepadArrival {
|
||||
// The index is the LOW BYTE only — bits 8/9 may carry the pad's audio-render
|
||||
// caps (an embedder building raw events; the `set_pad_audio_caps` registry is
|
||||
// the usual source). Fold event-carried bits into the registry so the re-send
|
||||
// burst keeps them, then send with the negotiation-gated flags word.
|
||||
let (pad, ev_caps) = crate::input::decode_gamepad_arrival(ev.flags);
|
||||
let idx = pad as usize;
|
||||
if idx < MAX_PADS {
|
||||
if ev_caps != 0 {
|
||||
pad_audio_caps[idx].fetch_or(ev_caps, Ordering::Relaxed);
|
||||
}
|
||||
// Remember the declared kind (`code`) and forward it, arming a re-send
|
||||
// burst so the host learns it before the pad's first frame even under loss.
|
||||
arrival[idx] = Some(ev.code as u8);
|
||||
arrival_owed[idx] = ARRIVAL_RESENDS;
|
||||
arrival_caps_sent[idx] = caps_now(idx);
|
||||
let arr = crate::input::InputEvent {
|
||||
flags: arrival_flags(idx),
|
||||
..ev
|
||||
};
|
||||
let _ = conn.send_datagram(arr.encode().to_vec().into());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let _ = conn.send_datagram(ev.encode().to_vec().into());
|
||||
}
|
||||
_ = refresh.tick() => {
|
||||
for idx in 0..MAX_PADS {
|
||||
// B7: caps declared after the burst drained — re-announce this pad's arrival.
|
||||
// Only for a pad that HAS an arrival (so it is a live, declared controller),
|
||||
// and only when the value actually moved, so a steady session sends nothing.
|
||||
if arrival[idx].is_some()
|
||||
&& arrival_owed[idx] == 0
|
||||
&& caps_now(idx) != arrival_caps_sent[idx]
|
||||
{
|
||||
arrival_owed[idx] = ARRIVAL_RESENDS;
|
||||
}
|
||||
// Re-send an owed kind declaration (independent of whether the pad has state
|
||||
// yet — it may be idle-but-connected). Idempotent on the host.
|
||||
if arrival_owed[idx] > 0 {
|
||||
if let Some(kind) = arrival[idx] {
|
||||
arrival_owed[idx] -= 1;
|
||||
arrival_caps_sent[idx] = caps_now(idx);
|
||||
let arr = crate::input::InputEvent {
|
||||
kind: InputKind::GamepadArrival,
|
||||
_pad: [0; 3],
|
||||
code: kind as u32,
|
||||
x: 0,
|
||||
y: 0,
|
||||
flags: idx as u32,
|
||||
flags: arrival_flags(idx),
|
||||
};
|
||||
let _ = conn.send_datagram(arr.encode().to_vec().into());
|
||||
} else {
|
||||
|
||||
@@ -5,8 +5,8 @@ use crate::clipboard::{ClipCommand, ClipEventCore};
|
||||
use crate::config::{CompositorPref, GamepadPref, Mode};
|
||||
use crate::error::Result;
|
||||
use crate::input::InputEvent;
|
||||
use crate::quic::{HdrMeta, HidOutput};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64};
|
||||
use crate::quic::{HdrMeta, HidOutput, PadAudioFrame};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, AtomicU8};
|
||||
use std::sync::mpsc::SyncSender;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -43,6 +43,14 @@ pub(crate) struct WorkerArgs {
|
||||
/// closed, so the command API always observes connection teardown.
|
||||
pub(crate) rumble_feed: super::rumble::RumbleFeed,
|
||||
pub(crate) hidout_tx: SyncSender<HidOutput>,
|
||||
/// Inbound pad-audio frames (`0xD1` — DualSense voice-coil haptics + speaker), drained by
|
||||
/// [`NativeClient::next_pad_audio`].
|
||||
pub(crate) pad_audio_tx: SyncSender<PadAudioFrame>,
|
||||
/// Per-pad pad-audio render capabilities (bit0 haptics, bit1 speaker), written by
|
||||
/// [`NativeClient::set_pad_audio_caps`] and OR'd into outgoing
|
||||
/// [`GamepadArrival`](crate::input::InputKind::GamepadArrival) flags (bits 8/9) by the input
|
||||
/// task — toward a `HOST_CAP_PAD_AUDIO` host only.
|
||||
pub(crate) pad_audio_caps: Arc<[AtomicU8; crate::input::MAX_PADS]>,
|
||||
pub(crate) hdr_meta_tx: SyncSender<HdrMeta>,
|
||||
pub(crate) host_timing_tx: SyncSender<crate::quic::HostTiming>,
|
||||
pub(crate) cursor_shape_tx: SyncSender<crate::quic::CursorShape>,
|
||||
|
||||
@@ -64,7 +64,11 @@ pub enum InputKind {
|
||||
GamepadRemove = 13,
|
||||
/// Declares which controller KIND a pad presents so a session can MIX types (pad 0 a
|
||||
/// DualSense, pad 1 an Xbox pad). `code` = the [`GamepadPref`](crate::config::GamepadPref)
|
||||
/// wire byte, `flags` = pad index. Sent when the client opens a pad slot — before that pad's
|
||||
/// wire byte, `flags` = pad index in the low byte plus the pad's render capabilities in bits
|
||||
/// 8/9 ([`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/[`ARRIVAL_FLAG_PAD_AUDIO_SPEAKER`] — sent only
|
||||
/// toward a [`HOST_CAP_PAD_AUDIO`](crate::quic::HOST_CAP_PAD_AUDIO) host, so an older host
|
||||
/// keeps reading the whole word as the index; hosts decode via [`decode_gamepad_arrival`]).
|
||||
/// Sent when the client opens a pad slot — before that pad's
|
||||
/// first input — and re-sent a few times against datagram loss (like [`GamepadRemove`]). The
|
||||
/// host resolves the kind to a buildable backend and routes that pad's virtual device to it; a
|
||||
/// pad the client never declares (an older client, or a fully-lost declaration) falls back to
|
||||
@@ -97,6 +101,34 @@ pub fn decode_gamepad_remove(flags: u32) -> (u8, u8) {
|
||||
(flags as u8, (flags >> 24) as u8)
|
||||
}
|
||||
|
||||
/// [`InputKind::GamepadArrival`] `flags` bit: this pad renders pad-audio HAPTICS — it is (or
|
||||
/// forwards to) a real DualSense whose voice-coil actuators can play the
|
||||
/// [`PAD_AUDIO_KIND_HAPTICS`](crate::quic::PAD_AUDIO_KIND_HAPTICS) stream. Rides above the pad
|
||||
/// index byte; sent only toward a [`HOST_CAP_PAD_AUDIO`](crate::quic::HOST_CAP_PAD_AUDIO) host
|
||||
/// (an older host reads the whole `flags` word as the index, so unexpected high bits would make
|
||||
/// it drop the declaration).
|
||||
pub const ARRIVAL_FLAG_PAD_AUDIO_HAPTICS: u32 = 1 << 8;
|
||||
/// [`InputKind::GamepadArrival`] `flags` bit: this pad renders pad-audio SPEAKER — the
|
||||
/// [`PAD_AUDIO_KIND_SPEAKER`](crate::quic::PAD_AUDIO_KIND_SPEAKER) stream. Same wire discipline
|
||||
/// as [`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`].
|
||||
pub const ARRIVAL_FLAG_PAD_AUDIO_SPEAKER: u32 = 1 << 9;
|
||||
|
||||
/// Pack a [`InputKind::GamepadArrival`] `flags` word: the pad index in the low byte plus
|
||||
/// `audio_caps` (bit0 = haptics, bit1 = speaker) as bits 8/9. `audio_caps = 0` reproduces the
|
||||
/// pre-pad-audio wire bytes exactly.
|
||||
pub fn encode_gamepad_arrival(pad: u8, audio_caps: u8) -> u32 {
|
||||
(pad as u32) | (((audio_caps & 0x03) as u32) << 8)
|
||||
}
|
||||
|
||||
/// Unpack a [`InputKind::GamepadArrival`] `flags` word into `(pad, audio_caps)`. The pad index
|
||||
/// is `flags & 0xFF` — hosts MUST mask rather than take the whole word, or a capability bit
|
||||
/// reads as a phantom index; `audio_caps` is bits 8/9 (bit0 = haptics, bit1 = speaker — the
|
||||
/// [`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/[`ARRIVAL_FLAG_PAD_AUDIO_SPEAKER`] bits shifted down).
|
||||
/// An old-format word (index only) yields `audio_caps = 0`.
|
||||
pub fn decode_gamepad_arrival(flags: u32) -> (u8, u8) {
|
||||
(flags as u8, ((flags >> 8) & 0x03) as u8)
|
||||
}
|
||||
|
||||
/// The gamepad wire contract for [`InputKind::GamepadButton`]/[`InputKind::GamepadAxis`].
|
||||
///
|
||||
/// Everything follows the GameStream/XInput conventions end to end: buttons reuse
|
||||
@@ -348,6 +380,11 @@ pub enum GamepadEvent {
|
||||
kind: u8,
|
||||
/// LI_CCAP_* bits (0x02 = rumble).
|
||||
capabilities: u16,
|
||||
/// Pad-audio render capabilities from a NATIVE-plane arrival's `flags` bits 8/9
|
||||
/// (bit0 = haptics, bit1 = speaker — see [`decode_gamepad_arrival`]). NOT a GameStream
|
||||
/// LI_CCAP bit (that vocabulary lives in `capabilities`); the GameStream plane cannot
|
||||
/// express pad audio and always sets `0`, as does an old client.
|
||||
audio_caps: u8,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -443,6 +480,31 @@ mod tests {
|
||||
assert_eq!((pad, seq), (9, 123));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamepad_arrival_flags_roundtrip() {
|
||||
// The capability bits ride bits 8/9; the index stays the low byte.
|
||||
for (pad, caps) in [(0u8, 0u8), (3, 0b01), (15, 0b10), (7, 0b11)] {
|
||||
let flags = encode_gamepad_arrival(pad, caps);
|
||||
assert_eq!(decode_gamepad_arrival(flags), (pad, caps));
|
||||
assert_eq!(flags & 0xFF, pad as u32);
|
||||
}
|
||||
assert_eq!(
|
||||
encode_gamepad_arrival(2, 0b11),
|
||||
2 | ARRIVAL_FLAG_PAD_AUDIO_HAPTICS | ARRIVAL_FLAG_PAD_AUDIO_SPEAKER
|
||||
);
|
||||
// Old-format compat both ways: a caps-less word (an old client, or a new one toward an
|
||||
// old host) is byte-identical to the plain index, and decodes with caps 0.
|
||||
assert_eq!(encode_gamepad_arrival(5, 0), 5);
|
||||
assert_eq!(decode_gamepad_arrival(5), (5, 0));
|
||||
// Undefined high bits (a future extension) never leak into the index OR the caps.
|
||||
assert_eq!(
|
||||
decode_gamepad_arrival(0xFFFF_0000 | (0b01 << 8) | 9),
|
||||
(9, 1)
|
||||
);
|
||||
// encode masks unknown caps bits, so a sloppy embedder can't corrupt the index space.
|
||||
assert_eq!(encode_gamepad_arrival(1, 0xFF), 1 | (0b11 << 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamepad_snapshot_roundtrip() {
|
||||
let s = GamepadSnapshot {
|
||||
|
||||
@@ -132,7 +132,13 @@ pub use stats::Stats;
|
||||
/// says what it says — so v15 is the floor that *guarantees* them: at or above it the surface is
|
||||
/// present, below it an embedder must probe for the symbol. Purely a version statement; no code
|
||||
/// changed with this bump, and no wire change, so [`WIRE_VERSION`] is unchanged.
|
||||
pub const ABI_VERSION: u32 = 15;
|
||||
/// v16: added the pad-audio client surface — `punktfunk_connection_next_pad_audio` (the 0xD1
|
||||
/// per-gamepad DualSense haptics/speaker plane) + `punktfunk_connection_set_pad_audio_caps` and
|
||||
/// the `PUNKTFUNK_CLIENT_CAP_PAD_AUDIO` / `PUNKTFUNK_HOST_CAP_PAD_AUDIO` mirrors. Additive and
|
||||
/// capability-gated end to end: the wire grows a new datagram tag (0xD1) an old client never
|
||||
/// receives (double-gated caps), a new 0xCD kind (0x06, dropped as unknown by old clients) and
|
||||
/// arrival flag bits 8/9 sent only toward a capable host, so [`WIRE_VERSION`] is unchanged.
|
||||
pub const ABI_VERSION: u32 = 16;
|
||||
|
||||
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
|
||||
@@ -121,6 +121,15 @@ pub const CLIENT_CAP_PHASE_LOCK: u8 = 0x02;
|
||||
/// clean, the client keeps receiving the plain `0xC9` plane — so a client may always set this bit.
|
||||
/// `0x04` — `0x01`/`0x02` are cursor / phase-lock.
|
||||
pub const CLIENT_CAP_AUDIO_RED: u8 = 0x04;
|
||||
/// [`Hello::client_caps`] bit: the client understands the pad-audio plane
|
||||
/// ([`PAD_AUDIO_MAGIC`](super::datagram::PAD_AUDIO_MAGIC), `0xD1`) — per-gamepad DualSense
|
||||
/// voice-coil haptics + speaker Opus frames, plus the [`HidOutput::AudioCtl`]
|
||||
/// (super::datagram::HidOutput) routing/volume events. Active only when the host answers with
|
||||
/// [`HOST_CAP_PAD_AUDIO`] AND the pad's arrival declared a renderer for the kind
|
||||
/// ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`) — the capable-and-agreed
|
||||
/// precedent, per pad; toward an older or incapable host nothing changes. `0x08` — `0x01` is [`CLIENT_CAP_CURSOR`],
|
||||
/// `0x02` is [`CLIENT_CAP_PHASE_LOCK`], `0x04` is [`CLIENT_CAP_AUDIO_RED`].
|
||||
pub const CLIENT_CAP_PAD_AUDIO: u8 = 0x08;
|
||||
|
||||
/// [`Welcome::host_caps`] bit: the host CAN forward the cursor out-of-band (it captures cursor
|
||||
/// metadata separately from the frame — the Linux portal `SPA_META_Cursor` path; NOT gamescope,
|
||||
@@ -154,6 +163,16 @@ pub const HOST_CAP_PEN: u8 = 0x10;
|
||||
/// unconditionally and treat this bit as "expect redundancy", not "only redundancy".
|
||||
/// `0x20` — `0x10` is [`HOST_CAP_PEN`], `0x08` is [`HOST_CAP_CURSOR`].
|
||||
pub const HOST_CAP_AUDIO_RED: u8 = 0x20;
|
||||
/// [`Welcome::host_caps`] bit: the host can capture pad audio — its virtual DualSense exposes
|
||||
/// the pad's audio endpoints (voice-coil haptics + speaker), so a game's per-pad audio can be
|
||||
/// captured and shipped on the [`PAD_AUDIO_MAGIC`](super::datagram::PAD_AUDIO_MAGIC) plane.
|
||||
/// Set only when the client asked via [`CLIENT_CAP_PAD_AUDIO`]; when both bits agree, a
|
||||
/// capable client marks its pads' render capabilities on their arrivals
|
||||
/// ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`) and the host emits `0xD1`
|
||||
/// toward exactly those pads. `0x40` — `0x20` is [`HOST_CAP_AUDIO_RED`], `0x10` is
|
||||
/// [`HOST_CAP_PEN`], `0x08` is [`HOST_CAP_CURSOR`], `0x04` is [`HOST_CAP_TEXT_INPUT`],
|
||||
/// `0x01`/`0x02` are gamepad-state / clipboard.
|
||||
pub const HOST_CAP_PAD_AUDIO: u8 = 0x40;
|
||||
|
||||
/// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
||||
/// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
||||
@@ -337,6 +356,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pad_audio_cap_bits_are_distinct() {
|
||||
// The new pad-audio bits pack into the existing caps bytes without colliding with any
|
||||
// taken bit (a collision would silently negotiate an unrelated feature).
|
||||
assert_eq!(
|
||||
CLIENT_CAP_PAD_AUDIO & (CLIENT_CAP_CURSOR | CLIENT_CAP_PHASE_LOCK),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
HOST_CAP_PAD_AUDIO
|
||||
& (HOST_CAP_GAMEPAD_STATE
|
||||
| HOST_CAP_CLIPBOARD
|
||||
| HOST_CAP_TEXT_INPUT
|
||||
| HOST_CAP_CURSOR
|
||||
| HOST_CAP_PEN),
|
||||
0
|
||||
);
|
||||
// Single-bit values (a multi-bit cap would OR neighbours in).
|
||||
assert_eq!(CLIENT_CAP_PAD_AUDIO.count_ones(), 1);
|
||||
assert_eq!(HOST_CAP_PAD_AUDIO.count_ones(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_codec_canonicalizes_a_multi_bit_preference() {
|
||||
// A non-conformant peer may stuff its capability MASK into `preferred` — the result
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
//! The QUIC-datagram side planes, demultiplexed by their first byte (0xC9–0xCF):
|
||||
//! audio, rumble, mic uplink, rich input, HID output, HDR metadata, host timing.
|
||||
//! The QUIC-datagram side planes, demultiplexed by their first byte (0xC9–0xD1):
|
||||
//! audio, rumble, mic uplink, rich input, HID output, HDR metadata, host timing,
|
||||
//! cursor state, pad audio.
|
||||
|
||||
/// Datagram wire tags. Video rides UDP; everything low-rate rides QUIC datagrams,
|
||||
/// demultiplexed by the first byte: input = [`crate::input::INPUT_MAGIC`] (0xC8, client→host),
|
||||
/// audio = [`AUDIO_MAGIC`] (0xC9, host→client), rumble = [`RUMBLE_MAGIC`] (0xCA, host→client),
|
||||
/// mic = [`MIC_MAGIC`] (0xCB, client→host), rich-input = [`RICH_INPUT_MAGIC`] (0xCC, client→host),
|
||||
/// HID-output = [`HIDOUT_MAGIC`] (0xCD, host→client), HDR metadata = [`HDR_META_MAGIC`]
|
||||
/// (0xCE, host→client).
|
||||
/// (0xCE, host→client), host timing = [`HOST_TIMING_MAGIC`] (0xCF, host→client), cursor state =
|
||||
/// [`CURSOR_STATE_MAGIC`] (0xD0, host→client), pad audio = [`PAD_AUDIO_MAGIC`] (0xD1,
|
||||
/// host→client).
|
||||
pub const AUDIO_MAGIC: u8 = 0xC9;
|
||||
pub const RUMBLE_MAGIC: u8 = 0xCA;
|
||||
/// Microphone uplink: the client's mic, Opus-encoded, client → host (the inverse of
|
||||
@@ -416,6 +419,7 @@ const HIDOUT_PLAYER_LEDS: u8 = 0x02;
|
||||
const HIDOUT_TRIGGER: u8 = 0x03;
|
||||
const HIDOUT_TRACKPAD_HAPTIC: u8 = 0x04;
|
||||
const HIDOUT_HID_RAW: u8 = 0x05;
|
||||
const HIDOUT_AUDIO_CTL: u8 = 0x06;
|
||||
|
||||
/// [`HidOutput::HidRaw`] `kind`: an OUTPUT report — what the host's hidraw client wrote with
|
||||
/// `write()`/`SDL_hid_write` (Triton rumble `0x80`, haptic pulse `0x81`, …). The client replays
|
||||
@@ -464,6 +468,16 @@ pub enum HidOutput {
|
||||
/// hardware safety timeout, and settings (lizard/IMU) are refreshed every ~3 s against the
|
||||
/// firmware watchdog — a lost datagram heals on the next refresh.
|
||||
HidRaw { pad: u8, kind: u8, data: Vec<u8> },
|
||||
/// The audio-control region of a DS5 output report `0x02` a game wrote to the host's virtual
|
||||
/// pad — the routing/volume side of pad audio (the audio SAMPLES ride the [`PAD_AUDIO_MAGIC`]
|
||||
/// plane). `raw` is bytes 5..=10 of the report verbatim (headphone/speaker/mic volumes +
|
||||
/// audio routing); `flags` condenses the report's audio valid-flags: bit0 = haptics-select
|
||||
/// (`valid_flag0` bit1 — the title asked for audio haptics on the voice coils), bits1..4 =
|
||||
/// `valid_flag0` bits 4..7 (the audio-valid flags gating `raw`). Wire form
|
||||
/// `[0xCD][0x06][u16 pad LE][u8 flags][6 raw bytes]`. Forwarded change-only (deduped by
|
||||
/// value host-side, like `Led`/`Trigger`) — a merely-rumbling pad re-sends unchanged audio
|
||||
/// state on every output report.
|
||||
AudioCtl { pad: u16, flags: u8, raw: [u8; 6] },
|
||||
}
|
||||
|
||||
impl HidOutput {
|
||||
@@ -496,6 +510,12 @@ impl HidOutput {
|
||||
out.extend_from_slice(&[HIDOUT_HID_RAW, *pad, *kind]);
|
||||
out.extend_from_slice(&data[..data.len().min(HID_REPORT_MAX)]);
|
||||
}
|
||||
HidOutput::AudioCtl { pad, flags, raw } => {
|
||||
out.push(HIDOUT_AUDIO_CTL);
|
||||
out.extend_from_slice(&pad.to_le_bytes());
|
||||
out.push(*flags);
|
||||
out.extend_from_slice(raw);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -540,6 +560,22 @@ impl HidOutput {
|
||||
// Bounded: at most HID_REPORT_MAX bytes are kept from the (attacker-sized) tail.
|
||||
data: b[4..b.len().min(4 + HID_REPORT_MAX)].to_vec(),
|
||||
}),
|
||||
// B27: the pad is the only u16 index on this plane, and every consumer narrows it
|
||||
// with `as u8` on the stated assumption that pads are 0..MAX_PADS. Nothing enforced
|
||||
// that, so wire pad 256 silently ALIASED onto slot 0 — a malformed or hostile
|
||||
// datagram steering a real controller's speaker volumes. Rejected here, at the one
|
||||
// place the u16 exists, so the narrowings downstream are lossless by construction
|
||||
// (the same fix R10 applied to the rumble plane).
|
||||
HIDOUT_AUDIO_CTL
|
||||
if b.len() >= 11
|
||||
&& u16::from_le_bytes([b[2], b[3]]) < crate::input::MAX_PADS as u16 =>
|
||||
{
|
||||
Some(HidOutput::AudioCtl {
|
||||
pad: u16::from_le_bytes([b[2], b[3]]),
|
||||
flags: b[4],
|
||||
raw: b[5..11].try_into().unwrap(),
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -798,6 +834,72 @@ pub fn decode_cursor_state_datagram(b: &[u8]) -> Option<CursorState> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Pad-audio datagram tag, host → client: per-gamepad audio a game routed
|
||||
/// to the host's virtual DualSense — voice-coil haptics and the built-in speaker — for the client
|
||||
/// to render on the matching real controller. Next tag after [`CURSOR_STATE_MAGIC`]. The
|
||||
/// per-pad AUDIO plane (Opus frames, the [`AUDIO_MAGIC`]/[`MIC_MAGIC`] shape plus pad + kind);
|
||||
/// the routing/volume CONTROL side rides [`HidOutput::AudioCtl`]. Emitted only when the session
|
||||
/// negotiated it ([`CLIENT_CAP_PAD_AUDIO`](super::caps::CLIENT_CAP_PAD_AUDIO) ∧
|
||||
/// [`HOST_CAP_PAD_AUDIO`](super::caps::HOST_CAP_PAD_AUDIO)) and the pad's arrival declared a
|
||||
/// renderer for the kind ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`).
|
||||
/// Best-effort like every audio datagram: a lost frame is a concealed gap, never state.
|
||||
pub const PAD_AUDIO_MAGIC: u8 = 0xD1;
|
||||
|
||||
/// [`PadAudioFrame::kind`]: the BACK channel pair — the DualSense voice-coil actuators (audio
|
||||
/// haptics). 5 ms Opus frames, matching the [`AUDIO_MAGIC`] cadence: haptics are felt latency.
|
||||
pub const PAD_AUDIO_KIND_HAPTICS: u8 = 0;
|
||||
/// [`PadAudioFrame::kind`]: the FRONT channel pair — the controller's built-in speaker. 10 ms
|
||||
/// Opus frames (speaker content tolerates the extra buffering for the better coding efficiency).
|
||||
pub const PAD_AUDIO_KIND_SPEAKER: u8 = 1;
|
||||
|
||||
/// Wire length of a pad-audio datagram header: tag + pad + kind + u32 seq + u64 pts = 15 bytes.
|
||||
const PAD_AUDIO_HEADER_LEN: usize = 1 + 1 + 1 + 4 + 8;
|
||||
|
||||
/// One decoded pad-audio frame (owned — the client's plane queue stores it). `seq`/`pts_ns` are
|
||||
/// per-(pad, kind) counters from the host's capture clock, for gap concealment and lip-sync
|
||||
/// against the main audio plane.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PadAudioFrame {
|
||||
/// Gamepad index (the wire pad space, same as rumble/HID-output).
|
||||
pub pad: u8,
|
||||
/// [`PAD_AUDIO_KIND_HAPTICS`] or [`PAD_AUDIO_KIND_SPEAKER`].
|
||||
pub kind: u8,
|
||||
pub seq: u32,
|
||||
pub pts_ns: u64,
|
||||
/// The raw Opus payload — feed it to an Opus decoder as one frame. Empty = DTX silence.
|
||||
pub opus: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Pad-audio datagram, host → client:
|
||||
/// `[0xD1][u8 pad][u8 kind][u32 seq LE][u64 pts_ns LE][opus payload]` — the
|
||||
/// [`encode_audio_datagram`]/[`encode_mic_datagram`] layout with a pad + kind prefix, one Opus
|
||||
/// frame per datagram (5/10 ms — well under any MTU); QUIC already encrypts.
|
||||
pub fn encode_pad_audio_datagram(pad: u8, kind: u8, seq: u32, pts_ns: u64, opus: &[u8]) -> Vec<u8> {
|
||||
let mut b = Vec::with_capacity(PAD_AUDIO_HEADER_LEN + opus.len());
|
||||
b.push(PAD_AUDIO_MAGIC);
|
||||
b.push(pad);
|
||||
b.push(kind);
|
||||
b.extend_from_slice(&seq.to_le_bytes());
|
||||
b.extend_from_slice(&pts_ns.to_le_bytes());
|
||||
b.extend_from_slice(opus);
|
||||
b
|
||||
}
|
||||
|
||||
/// Parse a pad-audio datagram → [`PadAudioFrame`]. `None` on bad tag/length (the fixed header
|
||||
/// length bounds every read before it happens).
|
||||
pub fn decode_pad_audio_datagram(buf: &[u8]) -> Option<PadAudioFrame> {
|
||||
if buf.len() < PAD_AUDIO_HEADER_LEN || buf[0] != PAD_AUDIO_MAGIC {
|
||||
return None;
|
||||
}
|
||||
Some(PadAudioFrame {
|
||||
pad: buf[1],
|
||||
kind: buf[2],
|
||||
seq: u32::from_le_bytes(buf[3..7].try_into().unwrap()),
|
||||
pts_ns: u64::from_le_bytes(buf[7..15].try_into().unwrap()),
|
||||
opus: buf[15..].to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::quic::*;
|
||||
@@ -1281,6 +1383,12 @@ mod tests {
|
||||
f
|
||||
},
|
||||
},
|
||||
// The DS5 audio-control region (haptics-select + speaker volume asserted).
|
||||
HidOutput::AudioCtl {
|
||||
pad: 1,
|
||||
flags: 0b0_0101,
|
||||
raw: [0x50, 0x60, 0x70, 0x05, 0x00, 0x00],
|
||||
},
|
||||
];
|
||||
for ev in &cases {
|
||||
let d = ev.encode();
|
||||
@@ -1299,6 +1407,92 @@ mod tests {
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_ctl_wire_layout_and_truncation() {
|
||||
// The exact 11-byte layout: [0xCD][0x06][u16 pad LE][u8 flags][6 raw bytes].
|
||||
// The pad is deliberately a REPRESENTABLE one: this used to assert that 0x0201 (513)
|
||||
// round-tripped, which pinned B27's aliasing in place as if it were the contract.
|
||||
let a = HidOutput::AudioCtl {
|
||||
pad: 0x000B,
|
||||
flags: 0x17,
|
||||
raw: [1, 2, 3, 4, 5, 6],
|
||||
};
|
||||
let d = a.encode();
|
||||
assert_eq!(d, [0xCD, 0x06, 0x0B, 0x00, 0x17, 1, 2, 3, 4, 5, 6]);
|
||||
assert_eq!(HidOutput::decode(&d), Some(a));
|
||||
// Truncated buffers are rejected outright (fixed length — never a partial read).
|
||||
for n in 2..d.len() {
|
||||
assert_eq!(HidOutput::decode(&d[..n]), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pad_audio_datagram_roundtrip_and_truncation() {
|
||||
let opus = [0x5Au8; 61];
|
||||
let d = encode_pad_audio_datagram(3, PAD_AUDIO_KIND_HAPTICS, 42, 9_999, &opus);
|
||||
assert_eq!(d[0], PAD_AUDIO_MAGIC);
|
||||
assert_eq!(d.len(), 15 + opus.len());
|
||||
let f = decode_pad_audio_datagram(&d).unwrap();
|
||||
assert_eq!((f.pad, f.kind, f.seq, f.pts_ns), (3, 0, 42, 9_999));
|
||||
assert_eq!(f.opus, opus);
|
||||
// Truncated headers are rejected outright (never partially read).
|
||||
for n in 0..15 {
|
||||
assert_eq!(decode_pad_audio_datagram(&d[..n]), None);
|
||||
}
|
||||
// Tag separation: a pad-audio datagram is not a session-audio/mic datagram and vice-versa.
|
||||
assert!(decode_audio_datagram(&d).is_none());
|
||||
assert!(decode_mic_datagram(&d).is_none());
|
||||
assert!(decode_pad_audio_datagram(&encode_audio_datagram(1, 2, &opus)).is_none());
|
||||
// Empty payload (DTX) is legal — header-only datagram.
|
||||
let hdr = encode_pad_audio_datagram(0, PAD_AUDIO_KIND_SPEAKER, 0, 0, &[]);
|
||||
assert_eq!(hdr.len(), 15);
|
||||
assert!(decode_pad_audio_datagram(&hdr).unwrap().opus.is_empty());
|
||||
}
|
||||
|
||||
/// B27: the pad is the only u16 index on the 0xCD plane and every consumer narrows it with
|
||||
/// `as u8`. An out-of-range one used to alias onto a real slot instead of being refused —
|
||||
/// wire pad 256 steering pad 0's speaker volumes.
|
||||
#[test]
|
||||
fn audio_ctl_rejects_a_pad_outside_the_index_space() {
|
||||
let ok = HidOutput::AudioCtl {
|
||||
pad: (crate::input::MAX_PADS - 1) as u16,
|
||||
flags: 0x12,
|
||||
raw: [1, 2, 3, 4, 5, 6],
|
||||
};
|
||||
assert_eq!(
|
||||
HidOutput::decode(&ok.encode()),
|
||||
Some(ok),
|
||||
"the last valid pad must still decode"
|
||||
);
|
||||
|
||||
// Anything at or above MAX_PADS is refused outright, not truncated.
|
||||
for pad in [crate::input::MAX_PADS as u16, 256, u16::MAX] {
|
||||
let d = HidOutput::AudioCtl {
|
||||
pad,
|
||||
flags: 0x12,
|
||||
raw: [1, 2, 3, 4, 5, 6],
|
||||
}
|
||||
.encode();
|
||||
assert_eq!(HidOutput::decode(&d), None, "pad {pad} must not decode");
|
||||
}
|
||||
|
||||
// The specific alias the bug produced: 256 as u8 == 0.
|
||||
let d = HidOutput::AudioCtl {
|
||||
pad: 256,
|
||||
flags: 0,
|
||||
raw: [0; 6],
|
||||
}
|
||||
.encode();
|
||||
assert!(
|
||||
!matches!(
|
||||
HidOutput::decode(&d),
|
||||
Some(HidOutput::AudioCtl { pad: 0, .. })
|
||||
),
|
||||
"wire pad 256 must never surface as pad 0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_state_roundtrip() {
|
||||
for (flags, x, y) in [
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
//! Split by concern (networking-audit deferred plan §3 — a pure move): `handshake` the
|
||||
//! positional Hello/Welcome/Start codecs, `caps` the capability/codec-negotiation
|
||||
//! vocabulary, `control` the typed control + clipboard messages, `pairing` the pairing
|
||||
//! message codecs with [`pake`] the SPAKE2 itself, `datagram` the 0xC9–0xCF plane codecs,
|
||||
//! message codecs with [`pake`] the SPAKE2 itself, `datagram` the 0xC9–0xD1 plane codecs,
|
||||
//! `pen` the stylus batch (0xCC kind 0x05) + host stroke tracker,
|
||||
//! [`io`] framed stream IO, `clock` skew estimation + mid-stream re-sync, [`endpoint`] the
|
||||
//! quinn constructors, [`clipstream`] the per-transfer clipboard fetch streams. Every item
|
||||
|
||||
@@ -259,6 +259,17 @@ windows = { version = "0.62", features = [
|
||||
# CoCreateInstance(PolicyConfigClient) — set the default audio playback/recording endpoints via the
|
||||
# undocumented IPolicyConfig (audio/windows/audio_control.rs) so mic + desktop audio auto-wire.
|
||||
"Win32_System_Com",
|
||||
# Pad-audio endpoint provisioning (audio/windows/pad_endpoint.rs): IMMDevice + IPropertyStore
|
||||
# to stamp the DualSense identity onto the minted endpoints (PROPVARIANT lives in
|
||||
# StructuredStorage and is gated on the Variant feature), DEVPKEY_Device_DriverInfPath to
|
||||
# resolve the installed Steam Streaming Speakers INF, and raw Reg* calls behind the MMDevices
|
||||
# ACL repair + the devnode's pad-index marker value.
|
||||
"Win32_Media_Audio",
|
||||
"Win32_UI_Shell_PropertiesSystem",
|
||||
"Win32_System_Com_StructuredStorage",
|
||||
"Win32_System_Variant",
|
||||
"Win32_Devices_Properties",
|
||||
"Win32_System_Registry",
|
||||
# SetUnhandledExceptionFilter + EXCEPTION_POINTERS — the last-resort native-crash logger
|
||||
# (src/windows/crash.rs); Kernel gates the CONTEXT type EXCEPTION_POINTERS embeds.
|
||||
"Win32_System_Diagnostics_Debug",
|
||||
|
||||
@@ -183,6 +183,12 @@ pub fn open_virtual_mic(_channels: u32) -> Result<Box<dyn VirtualMic>> {
|
||||
mod audio_control;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
// DualSense pad-audio endpoint provisioning + loopback capture (design: pad haptics/audio).
|
||||
// pub(crate): the session layer queries endpoints by pad index and the CLI exposes the
|
||||
// `pad-endpoint` devtest.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "audio/windows/pad_endpoint.rs"]
|
||||
pub(crate) mod pad_endpoint;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "audio/windows/wasapi_cap.rs"]
|
||||
mod wasapi_cap;
|
||||
|
||||
@@ -143,6 +143,17 @@ pub(crate) fn wire_now(set_playback: bool) -> Wiring {
|
||||
wire_now_full(set_playback).wiring
|
||||
}
|
||||
|
||||
/// Endpoint ids among `renders` that are the host's own pad-audio endpoints — the exclusion
|
||||
/// data [`plan`] runs on. Detection lives in [`super::pad_endpoint`] (stamped PFDS container /
|
||||
/// devnode marker, registry-only reads); this is just the per-pass collection.
|
||||
fn pad_render_ids(renders: &[Endpoint]) -> Vec<String> {
|
||||
renders
|
||||
.iter()
|
||||
.filter(|(_, id)| super::pad_endpoint::is_pad_render_endpoint(id))
|
||||
.map(|(_, id)| id.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Enumerate endpoints, compute the assignment, apply the default-device changes (unless
|
||||
/// `PUNKTFUNK_KEEP_DEFAULT`), and return the plan for the caller to act on (mic target / loopback
|
||||
/// echo guard). `set_playback` — true only from the desktop-audio capture open — additionally
|
||||
@@ -159,6 +170,10 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
|
||||
let want = std::env::var("PUNKTFUNK_MIC_DEVICE")
|
||||
.ok()
|
||||
.map(|s| s.to_lowercase());
|
||||
// The host's own pad-audio ("DualSense speaker") endpoints, by id — the pure plan filters
|
||||
// them out of every role. Identity is platform data (stamped container / devnode marker),
|
||||
// so it is collected HERE and passed in, like the candidate lists themselves.
|
||||
let pad_ids = pad_render_ids(&renders);
|
||||
// Mix formats are read only when we are actually going to park the playback default (i.e. a
|
||||
// desktop-audio capture is opening). The mic pump wires on every open while the host is idle
|
||||
// and does not care which loopback endpoint wins, so it must not pay an IAudioClient
|
||||
@@ -179,6 +194,7 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
|
||||
// only count a *narrowing* verdict can be made against without guessing: an endpoint that
|
||||
// cannot carry stereo cannot carry 5.1 either.
|
||||
2,
|
||||
&pad_ids,
|
||||
);
|
||||
let done = |wiring: Wiring| WiredPlan {
|
||||
wiring,
|
||||
@@ -245,7 +261,7 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
|
||||
if let Some((mic_name, mic_id)) = &wiring.mic_render {
|
||||
if default_render_id().as_deref() == Some(mic_id.as_str()) {
|
||||
// Audible preference = the host_audio plan's loopback pick (real hardware first).
|
||||
match plan(&renders, &captures, want.as_deref(), true).loopback_render {
|
||||
match plan(&renders, &captures, want.as_deref(), true, &pad_ids).loopback_render {
|
||||
Some((name, id)) => match set_default_endpoint(&id) {
|
||||
Ok(()) => tracing::info!(mic = %mic_name, device = %name,
|
||||
"default playback was the virtual-mic target — moved it so desktop \
|
||||
@@ -302,8 +318,10 @@ fn park_marker_path() -> std::path::PathBuf {
|
||||
pf_paths::config_dir().join("audio-default.prev")
|
||||
}
|
||||
|
||||
/// The current default RENDER endpoint id, if any.
|
||||
fn default_render_id() -> Option<String> {
|
||||
/// The current default RENDER endpoint id, if any. pub(crate): the pad-endpoint provisioning
|
||||
/// uses it for its default-device guard (a freshly minted pad endpoint must never stay the
|
||||
/// default playback device).
|
||||
pub(crate) fn default_render_id() -> Option<String> {
|
||||
wasapi::DeviceEnumerator::new()
|
||||
.ok()?
|
||||
.get_default_device(&Direction::Render)
|
||||
@@ -430,11 +448,13 @@ pub(crate) fn restore_default_playback() {
|
||||
}
|
||||
|
||||
/// Open a device by endpoint id, with a name for error context.
|
||||
///
|
||||
/// Resolves through [`super::pad_endpoint::open_wasapi_device`], NOT the `wasapi` crate's
|
||||
/// `DeviceEnumerator::get_device` — that one hands `GetDevice` a freed string (see the helper's
|
||||
/// docs), so it fails at random on ids that are perfectly valid.
|
||||
pub(crate) fn open_endpoint(ep: &Endpoint) -> Result<wasapi::Device> {
|
||||
wasapi::DeviceEnumerator::new()
|
||||
.map_err(|e| anyhow!("DeviceEnumerator: {e}"))?
|
||||
.get_device(&ep.1)
|
||||
.map_err(|e| anyhow!("open endpoint {:?}: {e}", ep.0))
|
||||
super::pad_endpoint::open_wasapi_device(&ep.1)
|
||||
.map_err(|e| anyhow!("open endpoint {:?}: {e:#}", ep.0))
|
||||
}
|
||||
|
||||
// --- IPolicyConfig (undocumented): set a default audio endpoint by id, for all three roles. ---
|
||||
@@ -481,8 +501,9 @@ const _: () = {
|
||||
|
||||
/// Set `device_id` as the default audio endpoint for eConsole/eMultimedia/eCommunications via the
|
||||
/// undocumented `IPolicyConfig::SetDefaultEndpoint` (the call `mmsys.cpl` makes). Errs if any role
|
||||
/// fails.
|
||||
fn set_default_endpoint(device_id: &str) -> Result<()> {
|
||||
/// fails. pub(crate): the pad-endpoint default-device guard restores the operator's default
|
||||
/// through the same machinery.
|
||||
pub(crate) fn set_default_endpoint(device_id: &str) -> Result<()> {
|
||||
use windows::core::{IUnknown, Interface, GUID, PCWSTR};
|
||||
use windows::Win32::System::Com::{CoCreateInstance, CLSCTX_ALL};
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -511,7 +511,7 @@ fn capture_once(
|
||||
if assert_plan {
|
||||
if let Some(d) = seen_default.as_deref() {
|
||||
if d != dev_id {
|
||||
match judge_default(&en, wiring, d) {
|
||||
match judge_default(wiring, d) {
|
||||
DefaultKind::Capturable(name) => {
|
||||
tracing::info!(default = %name, planned = %dev_name,
|
||||
"could not park the default playback on the planned endpoint — \
|
||||
@@ -639,7 +639,7 @@ fn capture_once(
|
||||
);
|
||||
return Ok(Next::Reopen(TargetMode::Follow));
|
||||
}
|
||||
match judge_default(&en, wiring, &nid) {
|
||||
match judge_default(wiring, &nid) {
|
||||
DefaultKind::Capturable(name) => {
|
||||
audio_client.stop_stream().ok();
|
||||
tracing::info!(device = %name,
|
||||
@@ -726,8 +726,11 @@ enum DefaultKind {
|
||||
Unknown,
|
||||
}
|
||||
|
||||
fn judge_default(en: &DeviceEnumerator, wiring: &wiring_plan::Wiring, id: &str) -> DefaultKind {
|
||||
let Ok(dev) = en.get_device(id) else {
|
||||
/// Resolves through [`super::pad_endpoint::open_wasapi_device`], NOT the `wasapi` crate's
|
||||
/// `DeviceEnumerator::get_device` — that one hands `GetDevice` a freed string (see the helper's
|
||||
/// docs), and a spurious miss here silently downgrades a capturable default to `Unknown`.
|
||||
fn judge_default(wiring: &wiring_plan::Wiring, id: &str) -> DefaultKind {
|
||||
let Ok(dev) = super::pad_endpoint::open_wasapi_device(id) else {
|
||||
return DefaultKind::Unknown;
|
||||
};
|
||||
let name = dev.get_friendlyname().unwrap_or_default();
|
||||
@@ -736,7 +739,15 @@ fn judge_default(en: &DeviceEnumerator, wiring: &wiring_plan::Wiring, id: &str)
|
||||
.mic_render
|
||||
.as_ref()
|
||||
.is_some_and(|(_, mic_id)| mic_id == id);
|
||||
if is_mic || wiring_plan::excluded_from_loopback(&ln) {
|
||||
// B10: a pad's audio endpoint is not ordinary hardware, and the name rules cannot see that —
|
||||
// it is deliberately stamped with the controller's own name ("DualSense Wireless Controller")
|
||||
// so games treat it as the pad's speaker, which means `excluded_from_loopback` passes it
|
||||
// straight through as `Capturable`. The pure plan filtered these out, but the plan is not the
|
||||
// only reader: this classifier drives the watchdog, Follow mode and the parked default, so a
|
||||
// pad endpoint that happened to be the system default could be adopted as the desktop capture
|
||||
// source — sending the whole desktop mix to a controller's voice coils. Identity, not name.
|
||||
let is_pad = super::pad_endpoint::is_pad_render_endpoint(id);
|
||||
if is_mic || is_pad || wiring_plan::excluded_from_loopback(&ln) {
|
||||
DefaultKind::Dud(name)
|
||||
} else {
|
||||
DefaultKind::Capturable(name)
|
||||
|
||||
@@ -253,25 +253,16 @@ pub(crate) fn install_steam_audio_pair() -> bool {
|
||||
mic || spk
|
||||
}
|
||||
|
||||
/// Install one Steam Streaming driver INF by filename via `DiInstallDriverW` (loaded from
|
||||
/// `newdev.dll`, like Apollo, to avoid an extra windows-crate feature). See
|
||||
/// [`install_steam_audio_pair`] for the contract; `inf_name` is a bare filename under Steam's
|
||||
/// per-arch `drivers\Windows10\{arch}\` directory.
|
||||
///
|
||||
/// Safe: `inf_name` is a `&str` and every FFI argument is built locally from it, so there is no
|
||||
/// precondition a caller could break — the `unsafe` is the `LoadLibraryExW`/`transmute`/call chain
|
||||
/// inside, which is this function's own business.
|
||||
fn try_install_steam_audio(inf_name: &str) -> bool {
|
||||
use windows::core::{s, w, PCWSTR};
|
||||
use windows::Win32::Foundation::HWND;
|
||||
/// Full path of a Steam Remote Play driver INF under Steam's per-arch driver directory
|
||||
/// (`%CommonProgramFiles(x86)%\Steam\drivers\Windows10\{arch}\<inf_name>`), as a NUL-terminated
|
||||
/// UTF-16 buffer. Shared by [`try_install_steam_audio`] and the pad-endpoint provisioning
|
||||
/// ([`super::pad_endpoint`]), which feeds the same INF to `UpdateDriverForPlugAndPlayDevicesW`
|
||||
/// when no installed Steam Streaming Speakers devnode exposes its `oemNN.inf`. `None` when the
|
||||
/// environment expansion fails (existence is the caller's check).
|
||||
pub(crate) fn steam_driver_inf_path(inf_name: &str) -> Option<Vec<u16>> {
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::System::Environment::ExpandEnvironmentStringsW;
|
||||
use windows::Win32::System::LibraryLoader::{
|
||||
GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32,
|
||||
};
|
||||
|
||||
if std::env::var_os("PUNKTFUNK_NO_MIC_INSTALL").is_some() {
|
||||
return false;
|
||||
}
|
||||
// Steam ships per-arch driver INFs under `Steam\drivers\Windows10\{arch}\`.
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
let subdir = "x64";
|
||||
@@ -290,8 +281,33 @@ fn try_install_steam_audio(inf_name: &str) -> bool {
|
||||
let n =
|
||||
unsafe { ExpandEnvironmentStringsW(PCWSTR(template.as_ptr()), Some(path.as_mut_slice())) };
|
||||
if n == 0 || n as usize > path.len() {
|
||||
return None;
|
||||
}
|
||||
path.truncate(n as usize); // keeps the NUL
|
||||
Some(path)
|
||||
}
|
||||
|
||||
/// Install one Steam Streaming driver INF by filename via `DiInstallDriverW` (loaded from
|
||||
/// `newdev.dll`, like Apollo, to avoid an extra windows-crate feature). See
|
||||
/// [`install_steam_audio_pair`] for the contract; `inf_name` is a bare filename under Steam's
|
||||
/// per-arch `drivers\Windows10\{arch}\` directory.
|
||||
///
|
||||
/// Safe: `inf_name` is a `&str` and every FFI argument is built locally from it, so there is no
|
||||
/// precondition a caller could break — the `unsafe` is the `LoadLibraryExW`/`transmute`/call chain
|
||||
/// inside, which is this function's own business.
|
||||
fn try_install_steam_audio(inf_name: &str) -> bool {
|
||||
use windows::core::{s, w, PCWSTR};
|
||||
use windows::Win32::Foundation::HWND;
|
||||
use windows::Win32::System::LibraryLoader::{
|
||||
GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32,
|
||||
};
|
||||
|
||||
if std::env::var_os("PUNKTFUNK_NO_MIC_INSTALL").is_some() {
|
||||
return false;
|
||||
}
|
||||
let Some(path) = steam_driver_inf_path(inf_name) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// SAFETY: a static NUL-terminated literal, loaded from System32 only (the flag), so this cannot
|
||||
// pick up a planted `newdev.dll` from the working directory. The handle is checked before use.
|
||||
|
||||
@@ -186,6 +186,17 @@ fn virtualish(lname: &str) -> bool {
|
||||
|| lname.contains("voicemeeter")
|
||||
}
|
||||
|
||||
/// Is this render endpoint id one of the virtual pad's audio endpoints?
|
||||
///
|
||||
/// Pulled out of [`plan`] because the plan is NOT the only place that must not treat these as
|
||||
/// ordinary hardware — see [`excluded_from_loopback`]'s callers. A pad endpoint is deliberately
|
||||
/// stamped with the controller's own name ("DualSense Wireless Controller") so games read it as
|
||||
/// the pad's speaker, which means no name-based rule can recognise one; the only reliable test is
|
||||
/// identity against the ids the pad-endpoint provisioner created.
|
||||
pub(crate) fn is_pad_render(id: &str, pad_renders: &[String]) -> bool {
|
||||
pad_renders.iter().any(|p| p == id)
|
||||
}
|
||||
|
||||
/// Compute the assignment. `mic_want` is the operator override (`PUNKTFUNK_MIC_DEVICE`,
|
||||
/// lowercased): when set it beats the built-in candidate order for the mic target. `host_audio`
|
||||
/// flips the loopback preference to real hardware (audio audible on the host too); the default
|
||||
@@ -195,8 +206,17 @@ pub(crate) fn plan(
|
||||
captures: &[Endpoint],
|
||||
mic_want: Option<&str>,
|
||||
host_audio: bool,
|
||||
pad_renders: &[String],
|
||||
) -> Wiring {
|
||||
plan_with_formats(renders, captures, mic_want, host_audio, &no_formats, 2)
|
||||
plan_with_formats(
|
||||
renders,
|
||||
captures,
|
||||
mic_want,
|
||||
host_audio,
|
||||
&no_formats,
|
||||
2,
|
||||
pad_renders,
|
||||
)
|
||||
}
|
||||
|
||||
/// [`plan`] with knowledge of each render endpoint's engine mix format, and the channel count the
|
||||
@@ -221,7 +241,20 @@ pub(crate) fn plan_with_formats(
|
||||
host_audio: bool,
|
||||
format_of: FormatProbe,
|
||||
want_channels: u8,
|
||||
pad_renders: &[String],
|
||||
) -> Wiring {
|
||||
// 0. Pad-audio endpoints are invisible to the plan: never the mic target (client voice
|
||||
// would play out of a pad "speaker"), never a loopback source (a game's controller
|
||||
// audio cues would stream as desktop audio), and — since this shadows `renders` for
|
||||
// every tier below — never the flagged last resort either. Their names carry no virtual
|
||||
// marker (they are stamped "DualSense Wireless Controller" on purpose, so games read
|
||||
// them as the pad's speaker), so the name rules alone would take one for real hardware.
|
||||
let renders: Vec<Endpoint> = renders
|
||||
.iter()
|
||||
.filter(|(_, id)| !is_pad_render(id, pad_renders))
|
||||
.cloned()
|
||||
.collect();
|
||||
let renders = renders.as_slice();
|
||||
let find_render = |needle: &str| {
|
||||
renders
|
||||
.iter()
|
||||
@@ -422,7 +455,7 @@ mod tests {
|
||||
ep("Microphone (Webcam)"),
|
||||
ep("CABLE Output (VB-Audio Virtual Cable)"),
|
||||
];
|
||||
let w = plan(&renders, &captures, None, false);
|
||||
let w = plan(&renders, &captures, None, false, &[]);
|
||||
assert_eq!(
|
||||
w.mic_render.unwrap().0,
|
||||
"CABLE Input (VB-Audio Virtual Cable)"
|
||||
@@ -451,7 +484,7 @@ mod tests {
|
||||
ep("CABLE Output (VB-Audio Virtual Cable)"),
|
||||
ep("Microphone (Steam Streaming Microphone)"),
|
||||
];
|
||||
let w = plan(&renders, &captures, None, false);
|
||||
let w = plan(&renders, &captures, None, false, &[]);
|
||||
assert_eq!(
|
||||
w.mic_render.unwrap().0,
|
||||
"CABLE Input (VB-Audio Virtual Cable)"
|
||||
@@ -471,7 +504,7 @@ mod tests {
|
||||
ep("CABLE Input (VB-Audio Virtual Cable)"),
|
||||
ep("Speakers (Steam Streaming Microphone)"),
|
||||
];
|
||||
let w = plan(&renders, &[], None, true);
|
||||
let w = plan(&renders, &[], None, true, &[]);
|
||||
assert_eq!(
|
||||
w.loopback_render.unwrap().0,
|
||||
"Speakers (Apple Audio Device)"
|
||||
@@ -488,7 +521,7 @@ mod tests {
|
||||
ep("CABLE In 16ch (VB-Audio Virtual Cable)"),
|
||||
];
|
||||
for host_audio in [false, true] {
|
||||
let w = plan(&renders, &[], None, host_audio);
|
||||
let w = plan(&renders, &[], None, host_audio, &[]);
|
||||
assert!(w.loopback_render.is_none(), "host_audio={host_audio}");
|
||||
}
|
||||
}
|
||||
@@ -500,7 +533,7 @@ mod tests {
|
||||
fn headless_cable_only_mic_wins() {
|
||||
let renders = [ep("CABLE Input (VB-Audio Virtual Cable)")];
|
||||
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
|
||||
let w = plan(&renders, &captures, None, false);
|
||||
let w = plan(&renders, &captures, None, false, &[]);
|
||||
assert!(w.mic_render.is_some(), "mic must claim the only cable");
|
||||
assert!(w.loopback_render.is_none(), "no echo-safe loopback exists");
|
||||
}
|
||||
@@ -518,7 +551,7 @@ mod tests {
|
||||
ep("CABLE Output (VB-Audio Virtual Cable)"),
|
||||
ep("Microphone (Steam Streaming Microphone)"),
|
||||
];
|
||||
let w = plan(&renders, &captures, None, false);
|
||||
let w = plan(&renders, &captures, None, false, &[]);
|
||||
assert_eq!(
|
||||
w.mic_render.unwrap().0,
|
||||
"CABLE Input (VB-Audio Virtual Cable)"
|
||||
@@ -546,7 +579,7 @@ mod tests {
|
||||
ep("Speakers (Realtek HD Audio)"),
|
||||
];
|
||||
let captures = [ep("Microphone (Steam Streaming Microphone)")];
|
||||
let w = plan(&renders, &captures, None, false);
|
||||
let w = plan(&renders, &captures, None, false, &[]);
|
||||
assert_eq!(
|
||||
w.mic_render.unwrap().0,
|
||||
"Speakers (Steam Streaming Microphone)"
|
||||
@@ -560,7 +593,7 @@ mod tests {
|
||||
fn steam_mic_only_no_echo() {
|
||||
let renders = [ep("Speakers (Steam Streaming Microphone)")];
|
||||
let captures = [ep("Microphone (Steam Streaming Microphone)")];
|
||||
let w = plan(&renders, &captures, None, false);
|
||||
let w = plan(&renders, &captures, None, false, &[]);
|
||||
assert!(w.mic_render.is_some());
|
||||
assert!(w.loopback_render.is_none());
|
||||
}
|
||||
@@ -576,7 +609,7 @@ mod tests {
|
||||
ep("Speakers (Steam Streaming Speakers)"),
|
||||
];
|
||||
for host_audio in [false, true] {
|
||||
let w = plan(&renders, &[], None, host_audio);
|
||||
let w = plan(&renders, &[], None, host_audio, &[]);
|
||||
assert_eq!(
|
||||
w.loopback_render.as_ref().unwrap().0,
|
||||
"Speakers (Steam Streaming Speakers)",
|
||||
@@ -597,7 +630,7 @@ mod tests {
|
||||
ep("Altavoces (Steam Streaming Microphone)"),
|
||||
];
|
||||
let captures = [ep("Microphone (Steam Streaming Microphone)")];
|
||||
let w = plan(&renders, &captures, None, false);
|
||||
let w = plan(&renders, &captures, None, false, &[]);
|
||||
assert_eq!(
|
||||
w.mic_render.unwrap().0,
|
||||
"Altavoces (Steam Streaming Microphone)"
|
||||
@@ -620,7 +653,7 @@ mod tests {
|
||||
];
|
||||
let captures = [ep("Microphone (Steam Streaming Microphone)")];
|
||||
for host_audio in [false, true] {
|
||||
let w = plan(&renders, &captures, None, host_audio);
|
||||
let w = plan(&renders, &captures, None, host_audio, &[]);
|
||||
assert_eq!(
|
||||
w.loopback_render.as_ref().unwrap().0,
|
||||
"Speakers (Realtek HD Audio)",
|
||||
@@ -642,7 +675,7 @@ mod tests {
|
||||
];
|
||||
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
|
||||
for host_audio in [false, true] {
|
||||
let w = plan(&renders, &captures, None, host_audio);
|
||||
let w = plan(&renders, &captures, None, host_audio, &[]);
|
||||
assert!(w.loopback_render.is_none(), "host_audio={host_audio}");
|
||||
assert!(!w.loopback_last_resort, "host_audio={host_audio}");
|
||||
assert!(w.loopback_unsatisfiable(), "host_audio={host_audio}");
|
||||
@@ -691,7 +724,7 @@ mod tests {
|
||||
("steam streaming microphone", fmt(24_000, 1)),
|
||||
("odyssey", fmt(48_000, 2)),
|
||||
]);
|
||||
let w = plan_with_formats(&renders, &captures, None, false, &p, 2);
|
||||
let w = plan_with_formats(&renders, &captures, None, false, &p, 2, &[]);
|
||||
assert_eq!(
|
||||
w.loopback_render.as_ref().unwrap().0,
|
||||
"1 - Odyssey G60SD (AMD High Definition Audio Device)",
|
||||
@@ -721,7 +754,7 @@ mod tests {
|
||||
("steam streaming microphone", fmt(48_000, 2)),
|
||||
("realtek", fmt(48_000, 2)),
|
||||
]);
|
||||
let w = plan_with_formats(&renders, &[], None, false, &p, 2);
|
||||
let w = plan_with_formats(&renders, &[], None, false, &p, 2, &[]);
|
||||
assert_eq!(
|
||||
w.loopback_render.unwrap().0,
|
||||
"Speakers (Steam Streaming Microphone)"
|
||||
@@ -737,7 +770,7 @@ mod tests {
|
||||
ep("Speakers (Steam Streaming Microphone)"),
|
||||
];
|
||||
let p = probe(vec![("steam streaming microphone", fmt(16_000, 1))]);
|
||||
let w = plan_with_formats(&renders, &[], None, false, &p, 2);
|
||||
let w = plan_with_formats(&renders, &[], None, false, &p, 2, &[]);
|
||||
assert_eq!(
|
||||
w.loopback_render.as_ref().unwrap().0,
|
||||
"Speakers (Steam Streaming Microphone)"
|
||||
@@ -753,7 +786,7 @@ mod tests {
|
||||
fn narrowing_is_reported_for_real_hardware_too() {
|
||||
let renders = [ep("Headset (Hands-Free AG Audio)")];
|
||||
let p = probe(vec![("headset", fmt(16_000, 1))]);
|
||||
let w = plan_with_formats(&renders, &[], None, false, &p, 2);
|
||||
let w = plan_with_formats(&renders, &[], None, false, &p, 2, &[]);
|
||||
assert_eq!(
|
||||
w.loopback_render.as_ref().unwrap().0,
|
||||
"Headset (Hands-Free AG Audio)"
|
||||
@@ -773,8 +806,8 @@ mod tests {
|
||||
];
|
||||
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
|
||||
for host_audio in [false, true] {
|
||||
let a = plan(&renders, &captures, None, host_audio);
|
||||
let b = plan_with_formats(&renders, &captures, None, host_audio, &no_formats, 2);
|
||||
let a = plan(&renders, &captures, None, host_audio, &[]);
|
||||
let b = plan_with_formats(&renders, &captures, None, host_audio, &no_formats, 2, &[]);
|
||||
assert_eq!(a, b, "host_audio={host_audio}");
|
||||
assert!(a.loopback_narrowing.is_none());
|
||||
}
|
||||
@@ -792,7 +825,7 @@ mod tests {
|
||||
("steam streaming microphone", fmt(24_000, 1)),
|
||||
("realtek", fmt(48_000, 2)),
|
||||
]);
|
||||
let w = plan_with_formats(&renders, &[], None, true, &p, 2);
|
||||
let w = plan_with_formats(&renders, &[], None, true, &p, 2, &[]);
|
||||
assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)");
|
||||
}
|
||||
|
||||
@@ -820,7 +853,7 @@ mod tests {
|
||||
ep("Voicemeeter Input (VB-Audio Voicemeeter VAIO)"),
|
||||
];
|
||||
let captures = [ep("Voicemeeter Out B1 (VB-Audio Voicemeeter VAIO)")];
|
||||
let w = plan(&renders, &captures, Some("voicemeeter input"), false);
|
||||
let w = plan(&renders, &captures, Some("voicemeeter input"), false, &[]);
|
||||
assert_eq!(
|
||||
w.mic_render.unwrap().0,
|
||||
"Voicemeeter Input (VB-Audio Voicemeeter VAIO)"
|
||||
@@ -836,7 +869,7 @@ mod tests {
|
||||
#[test]
|
||||
fn no_virtual_device() {
|
||||
let renders = [ep("Speakers (Realtek HD Audio)")];
|
||||
let w = plan(&renders, &[], None, false);
|
||||
let w = plan(&renders, &[], None, false, &[]);
|
||||
assert!(w.mic_render.is_none());
|
||||
assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)");
|
||||
}
|
||||
@@ -854,7 +887,7 @@ mod tests {
|
||||
];
|
||||
let captures = [ep("Voicemeeter Out B1 (VB-Audio Voicemeeter VAIO)")];
|
||||
for host_audio in [false, true] {
|
||||
let w = plan(&renders, &captures, None, host_audio);
|
||||
let w = plan(&renders, &captures, None, host_audio, &[]);
|
||||
assert_eq!(
|
||||
w.mic_render.as_ref().unwrap().0,
|
||||
"Voicemeeter Input (VB-Audio Voicemeeter VAIO)",
|
||||
@@ -877,7 +910,7 @@ mod tests {
|
||||
ep("Voicemeeter Aux Input (VB-Audio Voicemeeter AUX VAIO)"),
|
||||
];
|
||||
for host_audio in [false, true] {
|
||||
let w = plan(&renders, &[], None, host_audio);
|
||||
let w = plan(&renders, &[], None, host_audio, &[]);
|
||||
assert!(w.mic_render.is_some(), "host_audio={host_audio}");
|
||||
assert!(w.loopback_render.is_none(), "host_audio={host_audio}");
|
||||
}
|
||||
@@ -892,7 +925,7 @@ mod tests {
|
||||
ep("CABLE Input (VB-Audio Virtual Cable)"),
|
||||
ep("Speakers (Some Virtual Audio Device)"),
|
||||
];
|
||||
let w = plan(&renders, &[], None, false);
|
||||
let w = plan(&renders, &[], None, false, &[]);
|
||||
assert!(w.loopback_render.is_none());
|
||||
}
|
||||
|
||||
@@ -918,7 +951,7 @@ mod tests {
|
||||
// Field shape minus the Speakers (mic holds the Streaming Microphone, nothing else).
|
||||
let renders = [ep("Altavoces (Steam Streaming Microphone)")];
|
||||
let captures = [ep("Microphone (Steam Streaming Microphone)")];
|
||||
let w = plan(&renders, &captures, None, false);
|
||||
let w = plan(&renders, &captures, None, false, &[]);
|
||||
assert!(w.loopback_unsatisfiable());
|
||||
let msg = describe_no_loopback(&renders, &w);
|
||||
assert!(msg.contains("reserved for the virtual mic"), "{msg}");
|
||||
@@ -929,10 +962,70 @@ mod tests {
|
||||
// anyway), while the Steam pair is the remedy that adds a capturable sink.
|
||||
let renders = [ep("CABLE Input (VB-Audio Virtual Cable)")];
|
||||
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
|
||||
let w = plan(&renders, &captures, None, false);
|
||||
let w = plan(&renders, &captures, None, false, &[]);
|
||||
assert!(w.loopback_unsatisfiable());
|
||||
let msg = describe_no_loopback(&renders, &w);
|
||||
assert!(msg.contains("install Steam"), "{msg}");
|
||||
assert!(!msg.contains("install VB-Audio Virtual Cable"), "{msg}");
|
||||
}
|
||||
|
||||
/// A stamped pad endpoint is invisible to the plan. Its name carries NO virtual marker — on
|
||||
/// purpose, games must read it as the pad's speaker — so the name rules alone would classify
|
||||
/// it as real hardware and hand it the loopback; only the id exclusion prevents that.
|
||||
/// Measured fact: the wiring plan on the target box already enumerated a stamped endpoint.
|
||||
#[test]
|
||||
fn pad_endpoints_invisible() {
|
||||
let renders = [
|
||||
ep("DualSense Wireless Controller"),
|
||||
ep("Speakers (Realtek HD Audio)"),
|
||||
];
|
||||
let pads = [renders[0].1.clone()];
|
||||
let w = plan(&renders, &[], None, false, &pads);
|
||||
assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)");
|
||||
// Even an operator mic override matching the pad's name must not claim it; with the
|
||||
// pad as the only render endpoint there is honestly no mic target and no loopback.
|
||||
let w = plan(
|
||||
&renders[..1],
|
||||
&[],
|
||||
Some("wireless controller"),
|
||||
false,
|
||||
&pads,
|
||||
);
|
||||
assert!(w.mic_render.is_none());
|
||||
assert!(w.loopback_render.is_none());
|
||||
}
|
||||
|
||||
/// The exclusion has to survive the LAST RESORT tier, which this merge introduced alongside
|
||||
/// pad audio. `last_resort` matches on the Steam-Speakers name, but it reads the same
|
||||
/// shadowed `renders`, so a pad can never be reached through it either — otherwise the whole
|
||||
/// desktop mix would be routed into the controller's voice coils.
|
||||
#[test]
|
||||
fn a_pad_is_never_the_last_resort() {
|
||||
// Only the pad and the Steam pair exist; the mic reserves the Streaming Microphone, so
|
||||
// the plan falls all the way through to the last resort.
|
||||
let renders = [
|
||||
ep("DualSense Wireless Controller"),
|
||||
ep("Speakers (Steam Streaming Microphone)"),
|
||||
ep("Speakers (Steam Streaming Speakers)"),
|
||||
];
|
||||
let captures = [ep("Microphone (Steam Streaming Microphone)")];
|
||||
let pads = [renders[0].1.clone()];
|
||||
let w = plan(&renders, &captures, None, false, &pads);
|
||||
assert_eq!(
|
||||
w.loopback_render.as_ref().unwrap().0,
|
||||
"Speakers (Steam Streaming Speakers)",
|
||||
"the last resort must skip the pad"
|
||||
);
|
||||
assert!(w.loopback_last_resort);
|
||||
|
||||
// …and with the pad as the ONLY candidate left, the plan stays honestly unsatisfiable
|
||||
// rather than falling back onto the coils.
|
||||
let w = plan(&renders[..1], &captures, None, false, &pads);
|
||||
assert!(
|
||||
w.loopback_render.is_none(),
|
||||
"a pad was taken as the last resort"
|
||||
);
|
||||
assert!(!w.loopback_last_resort);
|
||||
assert!(w.loopback_unsatisfiable());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,6 +384,7 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
|
||||
index: idx,
|
||||
kind: 2,
|
||||
capabilities: 0,
|
||||
audio_caps: 0,
|
||||
});
|
||||
println!(
|
||||
"virtual {} up — cycling Cross + sweeping the left stick for {secs}s. Watch \
|
||||
@@ -430,6 +431,7 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
|
||||
index: idx,
|
||||
kind: 1,
|
||||
capabilities: 0,
|
||||
audio_caps: 0,
|
||||
});
|
||||
println!(
|
||||
"virtual Xbox 360 (XUSB) up — sweeping LS + toggling A for {secs}s. Check with \
|
||||
@@ -486,6 +488,119 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Windows: pad-audio endpoint provisioning — `pad-endpoint ensure|remove|status [--index N]`.
|
||||
/// `ensure` runs the idempotent startup path (reuse-or-create the devnode, bind the Steam
|
||||
/// Streaming Speakers driver, stamp the DualSense identity + 4ch/48k formats, report whether
|
||||
/// the stamps are SERVED); `status` prints the devnode/endpoint and per-stamp stored vs served
|
||||
/// state without changing anything; `remove` deletes the devnode via pnputil — the escape
|
||||
/// hatch only, endpoints are persistent by design. Stamping needs SYSTEM (the MMDevices ACL);
|
||||
/// run `ensure` under the service account or PsExec when the property-store route is denied.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn pad_endpoint(args: &[String]) -> Result<()> {
|
||||
use crate::audio::pad_endpoint as pe;
|
||||
let idx: u8 = args
|
||||
.iter()
|
||||
.skip_while(|a| *a != "--index")
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
// `--endpoint <id>` drives ANY render endpoint, not just a provisioned pad one. It is the
|
||||
// discriminator between "this process cannot activate anything" and "our endpoint is broken":
|
||||
// aim the same binary at a known-good endpoint and see whether it succeeds there.
|
||||
let endpoint_override: Option<String> = args
|
||||
.iter()
|
||||
.skip_while(|a| *a != "--endpoint")
|
||||
.nth(1)
|
||||
.cloned();
|
||||
match args.get(1).map(String::as_str) {
|
||||
Some("ensure") => {
|
||||
let p = pe::ensure(idx)?;
|
||||
println!(
|
||||
"pad-endpoint ensure: pad {} devnode {} endpoint {} needs_aeb_kick={}",
|
||||
p.pad_index, p.device_instance, p.endpoint_id, p.needs_aeb_kick
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Some("remove") => match pe::find(idx)? {
|
||||
Some(p) => {
|
||||
pe::remove(&p);
|
||||
println!(
|
||||
"pad-endpoint remove: requested removal of {}",
|
||||
p.device_instance
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
None => {
|
||||
println!("pad-endpoint remove: no pad-audio devnode for index {idx}");
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
// `punktfunk-host pad-endpoint <n> tone [seconds] [hz]` — drive the endpoint directly so
|
||||
// the whole pad-audio chain can be exercised without a game. Without this, every attempt
|
||||
// costs a game launch and a failure does not say which link broke.
|
||||
Some("tone") => {
|
||||
let secs: u32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(5);
|
||||
let hz: f32 = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(60.0);
|
||||
let endpoint_id = match endpoint_override {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
// `find` (a system lookup), NOT `endpoint_for` (the service's in-process
|
||||
// cache): this runs as a separate CLI process and has no cache of its own.
|
||||
let Some(ep) = pe::find(idx)? else {
|
||||
println!(
|
||||
"pad-endpoint tone: no pad-audio devnode for pad {idx} — run \
|
||||
`ensure` first"
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
if ep.endpoint_id.is_empty() {
|
||||
println!("pad-endpoint tone: pad {idx} has no endpoint id yet");
|
||||
return Ok(());
|
||||
}
|
||||
ep.endpoint_id
|
||||
}
|
||||
};
|
||||
// `--pair front` drives the pad's SPEAKER instead of the voice coils — the only way to
|
||||
// exercise the speaker kind without a game that renders one.
|
||||
let pair = args
|
||||
.iter()
|
||||
.skip_while(|a| *a != "--pair")
|
||||
.nth(1)
|
||||
.map_or(pe::TonePair::Back, |s| pe::TonePair::parse(s));
|
||||
println!(
|
||||
"pad-endpoint tone: {hz} Hz into the {} of {endpoint_id} for {secs}s",
|
||||
pair.label()
|
||||
);
|
||||
pe::render_test_tone(&endpoint_id, secs, hz, pair)?;
|
||||
println!(
|
||||
"pad-endpoint tone: done. A connected client with pad audio enabled should have \
|
||||
buzzed; the host log shows whether the gate opened."
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
// `punktfunk-host pad-endpoint capture [seconds]` — the receiving half of `tone`. Run
|
||||
// both at once to exercise render -> engine -> loopback -> pair routing with no game and
|
||||
// no client attached.
|
||||
Some("capture") => {
|
||||
let secs: u32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(5);
|
||||
let endpoint_id = match endpoint_override {
|
||||
Some(id) => id,
|
||||
None => match pe::find(idx)? {
|
||||
Some(ep) if !ep.endpoint_id.is_empty() => ep.endpoint_id,
|
||||
_ => {
|
||||
println!("pad-endpoint capture: pad {idx} has no endpoint — run `ensure`");
|
||||
return Ok(());
|
||||
}
|
||||
},
|
||||
};
|
||||
println!("pad-endpoint capture: listening on {endpoint_id} for {secs}s");
|
||||
pe::capture_probe(&endpoint_id, secs)
|
||||
}
|
||||
Some("status") => pe::print_status(idx),
|
||||
_ => anyhow::bail!("usage: punktfunk-host pad-endpoint <ensure|remove|status> [--index N]"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror a physical monitor and pull frames from it — the on-glass gate for per-monitor capture
|
||||
/// (`design/per-monitor-portal-capture.md` P2/P3), without needing a client to connect.
|
||||
///
|
||||
|
||||
@@ -245,6 +245,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The migration invariant D2 exists to protect. Moonlight caches app ids (and users pin them),
|
||||
/// and the id is derived from the LIBRARY ID alone — so a title moving from the in-host scanner
|
||||
/// to a claimed plugin entry keeps its GameStream id iff the library id is byte-identical. This
|
||||
/// pins that the claimed shape is that shape, and that an unclaimed one would NOT have been.
|
||||
#[test]
|
||||
fn a_claimed_plugin_entry_keeps_the_scanners_gamestream_id() {
|
||||
// What the built-in scanner produced, and what the steam plugin produces once it claims.
|
||||
assert_eq!(stable_app_id("steam:440"), stable_app_id("steam:440"));
|
||||
// The same title reconciled WITHOUT a claim gets an opaque `custom:` id — a different app
|
||||
// id, i.e. exactly the breakage the claim prevents.
|
||||
assert_ne!(stable_app_id("steam:440"), stable_app_id("custom:9f2c1a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_library_dedups_against_base_ids() {
|
||||
// A base app whose id happens to fall in the library range must not be clobbered by a library
|
||||
|
||||
@@ -65,6 +65,8 @@ pub fn decode(plaintext: &[u8]) -> Option<GamepadEvent> {
|
||||
index: *b.first()?,
|
||||
kind: *b.get(1)?,
|
||||
capabilities: le16(2)? as u16,
|
||||
// GameStream's LI_CCAP vocabulary can't express pad audio — native-plane only.
|
||||
audio_caps: 0,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
@@ -138,6 +140,7 @@ mod tests {
|
||||
index,
|
||||
kind,
|
||||
capabilities,
|
||||
..
|
||||
}) = decode(&wrap(MAGIC_CONTROLLER_ARRIVAL, &body))
|
||||
else {
|
||||
panic!("expected Arrival");
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
pub(crate) use anyhow::{Context, Result};
|
||||
pub(crate) use serde::{Deserialize, Serialize};
|
||||
pub(crate) use sha2::{Digest, Sha256};
|
||||
pub(crate) use std::collections::HashSet;
|
||||
pub(crate) use std::collections::{BTreeMap, HashSet};
|
||||
pub(crate) use std::path::{Path, PathBuf};
|
||||
pub(crate) use std::time::{SystemTime, UNIX_EPOCH};
|
||||
pub(crate) use utoipa::ToSchema;
|
||||
@@ -136,6 +136,29 @@ impl GameMeta {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a library entry *is* — an ordinary title, or the launcher application itself (Steam Big
|
||||
/// Picture, Heroic, Playnite fullscreen). Purely a presentation hint: a launcher entry launches,
|
||||
/// leases and lists exactly like a game (design D4), and clients that don't know the field render it
|
||||
/// as a plain tile. Serde-default `game` and skip-serialized when default, so the wire is unchanged
|
||||
/// for every entry that doesn't opt in.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum GameRole {
|
||||
/// An ordinary title.
|
||||
#[default]
|
||||
Game,
|
||||
/// The launcher application itself.
|
||||
Launcher,
|
||||
}
|
||||
|
||||
impl GameRole {
|
||||
/// Whether this is the serde default (`game`) — the `skip_serializing_if` predicate that keeps
|
||||
/// the field off the wire for the overwhelming majority of entries.
|
||||
pub(crate) fn is_game(&self) -> bool {
|
||||
matches!(self, Self::Game)
|
||||
}
|
||||
}
|
||||
|
||||
/// One title in the unified library, regardless of which store it came from.
|
||||
#[derive(Clone, Debug, Serialize, ToSchema)]
|
||||
pub struct GameEntry {
|
||||
@@ -147,6 +170,9 @@ pub struct GameEntry {
|
||||
pub store: String,
|
||||
pub title: String,
|
||||
pub art: Artwork,
|
||||
/// Whether this entry is a game or the launcher itself — see [`GameRole`].
|
||||
#[serde(default, skip_serializing_if = "GameRole::is_game")]
|
||||
pub role: GameRole,
|
||||
/// How the host would launch it, when known.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub launch: Option<LaunchSpec>,
|
||||
@@ -228,12 +254,26 @@ impl ArtKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// The full library: every *enabled* store's titles merged + the custom entries, sorted by title.
|
||||
/// The operator's scanner toggles (`scanners.rs`) gate each installed-store provider; the custom
|
||||
/// store is not a scanner and always contributes.
|
||||
/// The full library: every *enabled* source's titles merged + the custom entries, sorted by title.
|
||||
///
|
||||
/// Two independent gates run here, both at READ time so neither ever mutates stored state:
|
||||
///
|
||||
/// * **The operator's source toggles** (`scanners.rs`, persisted as a disabled-set in
|
||||
/// `library-scanners.json`) hide a source's titles from every surface — this grid, native clients,
|
||||
/// `/applist`, and launch resolution. They apply to built-in scanners *and* to plugin sources,
|
||||
/// which is what lets one toggle keep working verbatim across the whole migration: the ids match
|
||||
/// (provider id = claimed store id = old scanner id).
|
||||
/// * **Store claims** (D2): while a library plugin holds a store's claim, the matching built-in
|
||||
/// scanner is skipped so the two never double-list the same titles during the bridge releases.
|
||||
/// Removing the plugin releases the claim and the built-in comes straight back.
|
||||
///
|
||||
/// The user-curated custom store is not a source and always contributes.
|
||||
pub fn all_games() -> Vec<GameEntry> {
|
||||
let off = disabled_scanners();
|
||||
let on = |id: &str| !off.contains(id);
|
||||
let claimed = claimed_stores();
|
||||
// A built-in scanner runs when the operator hasn't disabled it AND no plugin has claimed its
|
||||
// store out from under it.
|
||||
let on = |id: &str| !off.contains(id) && !claimed.contains_key(id);
|
||||
let mut games = Vec::new();
|
||||
if on("steam") {
|
||||
games.extend(SteamProvider.list());
|
||||
@@ -262,7 +302,15 @@ pub fn all_games() -> Vec<GameEntry> {
|
||||
games.extend(XboxProvider.list());
|
||||
}
|
||||
}
|
||||
games.extend(load_custom().into_iter().map(GameEntry::from));
|
||||
// Stored entries: manual ones always contribute; a provider's are subject to the same source
|
||||
// toggle a built-in scanner is (WP2.6). The plugin may keep reconciling while it is off — the
|
||||
// entries stay stored and simply aren't surfaced, exactly like a disabled scanner's titles.
|
||||
games.extend(
|
||||
load_custom()
|
||||
.into_iter()
|
||||
.filter(|e| !source_id_for(e).is_some_and(|src| off.contains(src)))
|
||||
.map(GameEntry::from),
|
||||
);
|
||||
games.sort_by_key(|g| g.title.to_lowercase());
|
||||
games
|
||||
}
|
||||
|
||||
@@ -147,24 +147,91 @@ pub(crate) fn fetch_image(url: &str) -> Option<(Vec<u8>, String)> {
|
||||
|
||||
/// A stored [`Artwork`] value that is a **local filesystem path** to an image on the host — as
|
||||
/// opposed to an `http(s)`/`data:` URL or an already-relative host proxy path. Provider plugins that
|
||||
/// run on the host (e.g. the Playnite sync plugin) set these: the reconcile payload stays tiny
|
||||
/// (paths, not inlined bytes, so it scales to thousands of titles) and the host serves the bytes
|
||||
/// through the art proxy, exactly like Steam's cache art. Windows-shaped only (`C:\…`, `C:/…`, or a
|
||||
/// `\\server\share` UNC) — Playnite, the only local-art provider, is Windows-only, and this keeps the
|
||||
/// check from ever mistaking the `/api/…` proxy path (or a POSIX abs path) for a local file.
|
||||
/// run on the host (the Playnite sync plugin, and every library scanner plugin) set these: the
|
||||
/// reconcile payload stays tiny (paths, not inlined bytes, so it scales to thousands of titles) and
|
||||
/// the host serves the bytes through the art proxy, exactly like Steam's cache art.
|
||||
///
|
||||
/// Four accepted shapes:
|
||||
/// * `file://…` — the **documented plugin contract** ([`file_url_to_path`]), unambiguous on every
|
||||
/// platform, and what `@punktfunk/plugin-kit/library` emits.
|
||||
/// * `C:\…` / `C:/…` drive-absolute and `\\server\share` UNC — Windows bare paths, kept for
|
||||
/// Playnite back-compat (it predates the `file://` contract).
|
||||
/// * POSIX absolute (`/home/u/covers/x.jpg`) — Lutris covers and Steam's `librarycache`.
|
||||
///
|
||||
/// The POSIX widening is why the two `/`-leading shapes the **host itself emits** must be excluded
|
||||
/// explicitly: its own art-proxy path (`/api/v1/library/art/…`, which [`proxy_local_art`] writes and
|
||||
/// which must survive a second pass unchanged) and a protocol-relative URL (`//cdn/…`, what GOG's and
|
||||
/// Microsoft's catalogs return — see [`abs_url`]). Mistaking either for a file would break the proxy
|
||||
/// round-trip or silently drop CDN art.
|
||||
pub fn is_local_art_path(v: &str) -> bool {
|
||||
if v.starts_with("http://") || v.starts_with("https://") || v.starts_with("data:") {
|
||||
return false;
|
||||
}
|
||||
if v.starts_with("file://") {
|
||||
return true;
|
||||
}
|
||||
let b = v.as_bytes();
|
||||
(b.len() >= 3 && b[1] == b':' && (b[2] == b'\\' || b[2] == b'/')) || v.starts_with("\\\\")
|
||||
// Windows drive-absolute (`C:\…`, `C:/…`) or UNC (`\\server\share`).
|
||||
if (b.len() >= 3 && b[1] == b':' && (b[2] == b'\\' || b[2] == b'/')) || v.starts_with("\\\\") {
|
||||
return true;
|
||||
}
|
||||
// POSIX absolute, minus the host's own `/`-leading shapes (see the doc comment).
|
||||
v.starts_with('/') && !v.starts_with("//") && !v.starts_with("/api/")
|
||||
}
|
||||
|
||||
/// Turn a `file://` art value into a plain filesystem path, percent-decoding it. The kit emits
|
||||
/// properly encoded URLs (`file:///home/u/My%20Cover.jpg`); a raw path that happens to contain no
|
||||
/// `%` round-trips either way, which keeps hand-written plugin payloads working.
|
||||
///
|
||||
/// `file:///home/u/c.jpg` → `/home/u/c.jpg`; `file:///C:/covers/c.jpg` → `C:/covers/c.jpg` (Windows
|
||||
/// drive letters arrive after the empty authority's slash); a NON-empty authority
|
||||
/// (`file://nas/share/c.jpg`) is a UNC reference → `\\nas\share\c.jpg`. Anything without the prefix
|
||||
/// is returned untouched.
|
||||
fn file_url_to_path(v: &str) -> std::borrow::Cow<'_, str> {
|
||||
use std::borrow::Cow;
|
||||
let Some(rest) = v.strip_prefix("file://") else {
|
||||
return Cow::Borrowed(v);
|
||||
};
|
||||
let decoded = percent_decode(rest);
|
||||
match decoded.strip_prefix('/') {
|
||||
// `file:///…` — the empty-authority form. A Windows drive letter (`/C:/…`) loses the slash;
|
||||
// a POSIX path keeps it.
|
||||
Some(after) if after.as_bytes().get(1) == Some(&b':') => Cow::Owned(after.to_string()),
|
||||
Some(_) => Cow::Owned(decoded),
|
||||
// `file://server/share/…` — a UNC path in URL clothing.
|
||||
None => Cow::Owned(format!("\\\\{}", decoded.replace('/', "\\"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Percent-decode `%XX` escapes. Invalid escapes are left verbatim (a bare `%` in a real path is far
|
||||
/// likelier than a malformed URL from our own kit), and the result is only ever used as a path that
|
||||
/// must then exist as a regular file — so a wrong decode degrades to "no art", never to a wrong read.
|
||||
fn percent_decode(s: &str) -> String {
|
||||
let b = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(b.len());
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
if b[i] == b'%' && i + 2 < b.len() {
|
||||
let hex = |c: u8| (c as char).to_digit(16);
|
||||
if let (Some(hi), Some(lo)) = (hex(b[i + 1]), hex(b[i + 2])) {
|
||||
out.push((hi * 16 + lo) as u8);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(b[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8(out).unwrap_or_else(|_| s.to_string())
|
||||
}
|
||||
|
||||
/// Read a local image file into `(bytes, content-type)` for the art proxy. `None` if it isn't an
|
||||
/// existing regular file, is empty, or exceeds 16 MiB (a cover never approaches that; the cap bounds
|
||||
/// host memory). Content-type is guessed from the extension.
|
||||
/// host memory). Content-type is guessed from the extension. Accepts every shape
|
||||
/// [`is_local_art_path`] does — a `file://` value is converted to a path first.
|
||||
pub fn local_art_bytes(path: &str) -> Option<(Vec<u8>, String)> {
|
||||
let p = std::path::Path::new(path);
|
||||
let path = file_url_to_path(path);
|
||||
let p = std::path::Path::new(&*path);
|
||||
let meta = std::fs::metadata(p).ok()?;
|
||||
if !meta.is_file() || meta.len() == 0 || meta.len() > 16 * 1024 * 1024 {
|
||||
return None;
|
||||
@@ -221,9 +288,22 @@ pub fn proxy_local_art(id: &str, art: &mut Artwork) {
|
||||
/// `(bytes, content-type)`. Resolves the id against the host's OWN library. Blocking — call off the
|
||||
/// async runtime (e.g. `spawn_blocking`).
|
||||
pub fn fetch_box_art(id: &str) -> Option<(Vec<u8>, String)> {
|
||||
// Steam's `Artwork` fields are now relative proxy paths (see `steam_art`) the *client* resolves
|
||||
// against the host — meaningless to `fetch_image`, which expects an absolute URL. Resolve
|
||||
// those kinds directly instead of going through the URL fields.
|
||||
// Same resolution order as the management art proxy (WP1.2): the stored catalog first, for ANY
|
||||
// id, so a library plugin's entries resolve without the warmer knowing its store.
|
||||
if let Some(entry) = entry_for_library_id(id) {
|
||||
return [
|
||||
ArtKind::Portrait,
|
||||
ArtKind::Header,
|
||||
ArtKind::Hero,
|
||||
ArtKind::Logo,
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|kind| art_field(&entry.art, kind))
|
||||
.find_map(|v| resolve_art_bytes(&v));
|
||||
}
|
||||
// Legacy in-host Steam scanner: its `Artwork` fields are relative proxy paths (see `steam_art`)
|
||||
// the *client* resolves against the host — meaningless to `fetch_image`, which expects an
|
||||
// absolute URL. Resolve those kinds directly instead of going through the URL fields.
|
||||
if let Some(appid) = id
|
||||
.strip_prefix("steam:")
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
@@ -237,6 +317,7 @@ pub fn fetch_box_art(id: &str) -> Option<(Vec<u8>, String)> {
|
||||
.into_iter()
|
||||
.find_map(|kind| steam_art_bytes(appid, kind));
|
||||
}
|
||||
// The remaining in-host scanners (heroic/lutris/epic/gog/xbox) carry absolute CDN URLs.
|
||||
let g = all_games().into_iter().find(|g| g.id == id)?;
|
||||
[g.art.portrait, g.art.header, g.art.hero, g.art.logo]
|
||||
.into_iter()
|
||||
@@ -335,19 +416,60 @@ mod tests {
|
||||
assert!(fetch_image("data:image/png;base64,").is_none());
|
||||
}
|
||||
|
||||
/// The full accept/exclude table (WP1.2). The exclusions are the load-bearing half: two of the
|
||||
/// three `/`-leading shapes here are emitted by the host ITSELF, so a POSIX rule that swallowed
|
||||
/// them would break the proxy round-trip and silently drop CDN art.
|
||||
#[test]
|
||||
fn local_art_path_detection() {
|
||||
// Windows-shaped local paths a provider (Playnite) would store.
|
||||
assert!(is_local_art_path(r"C:\Users\me\cover.jpg"));
|
||||
assert!(is_local_art_path("C:/Users/me/cover.png"));
|
||||
assert!(is_local_art_path(r"\\nas\share\art.jpg"));
|
||||
// URLs and the host proxy path are NOT local files.
|
||||
// The `file://` plugin contract, on both platform shapes.
|
||||
assert!(is_local_art_path("file:///home/u/covers/x.jpg"));
|
||||
assert!(is_local_art_path("file:///C:/covers/x.jpg"));
|
||||
// POSIX absolute — lutris covers, steam librarycache.
|
||||
assert!(is_local_art_path("/home/u/.cache/lutris/coverart/x.jpg"));
|
||||
assert!(is_local_art_path("/var/lib/steam/librarycache/570/h.jpg"));
|
||||
// URLs are NOT local files.
|
||||
assert!(!is_local_art_path("https://cdn/x.jpg"));
|
||||
assert!(!is_local_art_path("http://host/x.jpg"));
|
||||
assert!(!is_local_art_path("data:image/png;base64,AAAA"));
|
||||
// …nor is the host's OWN art-proxy path (it must survive a second `proxy_local_art` pass).
|
||||
assert!(!is_local_art_path(
|
||||
"/api/v1/library/art/custom:abc/portrait"
|
||||
));
|
||||
assert!(!is_local_art_path("/api/v1/library/art/steam:570/hero"));
|
||||
// …nor a protocol-relative CDN URL (what GOG / the MS catalog return — see `abs_url`).
|
||||
assert!(!is_local_art_path("//images.gog.com/abc_vertical.jpg"));
|
||||
// A relative path is not absolute — nothing to serve.
|
||||
assert!(!is_local_art_path("covers/x.jpg"));
|
||||
assert!(!is_local_art_path(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_url_converts_to_a_path_and_percent_decodes() {
|
||||
assert_eq!(file_url_to_path("file:///home/u/c.jpg"), "/home/u/c.jpg");
|
||||
// Percent-encoded spaces — what a correct URL encoder emits for a real-world cover path.
|
||||
assert_eq!(
|
||||
file_url_to_path("file:///home/u/My%20Games/c%2Bx.jpg"),
|
||||
"/home/u/My Games/c+x.jpg"
|
||||
);
|
||||
// Windows drive letters arrive after the empty authority's slash and lose it.
|
||||
assert_eq!(
|
||||
file_url_to_path("file:///C:/covers/c.jpg"),
|
||||
"C:/covers/c.jpg"
|
||||
);
|
||||
// A non-empty authority is a UNC reference.
|
||||
assert_eq!(
|
||||
file_url_to_path("file://nas/share/c.jpg"),
|
||||
r"\\nas\share\c.jpg"
|
||||
);
|
||||
// Non-`file://` values are returned untouched (bare paths still work).
|
||||
assert_eq!(file_url_to_path("/home/u/c.jpg"), "/home/u/c.jpg");
|
||||
assert_eq!(file_url_to_path(r"C:\c.jpg"), r"C:\c.jpg");
|
||||
// A lone `%` (a legal path character) is not mangled into a decode failure.
|
||||
assert_eq!(file_url_to_path("file:///home/100%.jpg"), "/home/100%.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -371,6 +493,54 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A POSIX local cover — the shape the lutris pilot and the steam plugin emit — makes the whole
|
||||
/// round trip: detected as local, rewritten to the proxy path, and read back as bytes. This is
|
||||
/// the case G4 blocked (Lutris art was inlined as `data:` URLs and blew the 2 MB body limit).
|
||||
#[test]
|
||||
fn posix_local_art_round_trips_through_the_proxy() {
|
||||
let dir = std::env::temp_dir().join(format!("pf-art-posix-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let f = dir.join("cover.jpg");
|
||||
std::fs::write(&f, [9u8, 9, 9]).unwrap();
|
||||
let path = f.to_str().unwrap().to_string();
|
||||
|
||||
let mut art = Artwork {
|
||||
portrait: Some(path.clone()),
|
||||
hero: Some(format!("file://{path}")),
|
||||
logo: Some("https://cdn/l.png".into()),
|
||||
header: None,
|
||||
};
|
||||
// Only on non-Windows is a temp path POSIX-absolute; on Windows it is drive-absolute, which
|
||||
// the pre-existing rule already accepted — either way both fields are local.
|
||||
assert!(is_local_art_path(&path));
|
||||
proxy_local_art("lutris:42", &mut art);
|
||||
assert_eq!(
|
||||
art.portrait.as_deref(),
|
||||
Some("/api/v1/library/art/lutris:42/portrait")
|
||||
);
|
||||
assert_eq!(
|
||||
art.hero.as_deref(),
|
||||
Some("/api/v1/library/art/lutris:42/hero"),
|
||||
"a file:// value is local art too"
|
||||
);
|
||||
assert_eq!(art.logo.as_deref(), Some("https://cdn/l.png"));
|
||||
|
||||
// Re-running the rewrite is a no-op — the emitted proxy path must not be mistaken for a file.
|
||||
let before = art.portrait.clone();
|
||||
proxy_local_art("lutris:42", &mut art);
|
||||
assert_eq!(art.portrait, before);
|
||||
|
||||
// Both spellings read back to the same bytes.
|
||||
assert_eq!(local_art_bytes(&path).expect("bare path").0, vec![9, 9, 9]);
|
||||
assert_eq!(
|
||||
local_art_bytes(&format!("file://{path}"))
|
||||
.expect("file url")
|
||||
.0,
|
||||
vec![9, 9, 9]
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_art_bytes_reads_a_real_file() {
|
||||
let dir = std::env::temp_dir().join(format!("pf-art-test-{}", std::process::id()));
|
||||
@@ -381,6 +551,15 @@ mod tests {
|
||||
assert_eq!(bytes, vec![1, 2, 3, 4]);
|
||||
assert_eq!(ctype, "image/png");
|
||||
assert!(local_art_bytes(dir.join("nope.png").to_str().unwrap()).is_none());
|
||||
// A directory is not a servable cover, and neither is a traversal that lands on one — the
|
||||
// "existing REGULAR file" check is the confinement, since a plugin's art values are
|
||||
// operator-trusted paths but must still never turn the proxy into a directory reader.
|
||||
assert!(local_art_bytes(dir.to_str().unwrap()).is_none());
|
||||
let up = dir.join("..").join(dir.file_name().unwrap());
|
||||
assert!(
|
||||
local_art_bytes(up.to_str().unwrap()).is_none(),
|
||||
"dir via .."
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,17 @@ pub struct CustomEntry {
|
||||
/// host-assigned `id` stays stable across reconciles. Present iff `provider` is.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub external_id: Option<String>,
|
||||
/// The **store this entry was claimed under** (D2), stamped by a `?store=`-qualified reconcile.
|
||||
/// `None` = an unclaimed provider entry or a manual one, both of which surface as `custom`.
|
||||
///
|
||||
/// Materialized onto the entry rather than looked up in [`Catalog::claims`] on every read so an
|
||||
/// entry is self-describing: its id and its `store` badge derive from the entry alone, and stay
|
||||
/// correct even while the claim map is being rewritten.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub store: Option<String>,
|
||||
/// Whether this entry is a game or the launcher itself — see [`GameRole`].
|
||||
#[serde(default, skip_serializing_if = "GameRole::is_game")]
|
||||
pub role: GameRole,
|
||||
/// How to recognize this title's process once it is running (design §9) — the one thing a
|
||||
/// provider knows that the host cannot work out for itself.
|
||||
///
|
||||
@@ -53,6 +64,10 @@ pub struct CustomInput {
|
||||
/// Per-title prep/undo steps — commands run as the host user; operator-privileged config.
|
||||
#[serde(default)]
|
||||
pub prep: Vec<crate::hooks::PrepCmd>,
|
||||
/// Whether this entry is a game or the launcher itself — see [`GameRole`]. A hand-added launcher
|
||||
/// entry is legal (an operator may want a "Steam" tile without installing the steam plugin).
|
||||
#[serde(default)]
|
||||
pub role: GameRole,
|
||||
/// How to recognize this title's process — see [`CustomEntry::detect`].
|
||||
#[serde(default)]
|
||||
pub detect: DetectHint,
|
||||
@@ -76,6 +91,10 @@ pub struct ProviderEntryInput {
|
||||
/// Per-title prep/undo steps — commands run as the host user; operator-privileged config.
|
||||
#[serde(default)]
|
||||
pub prep: Vec<crate::hooks::PrepCmd>,
|
||||
/// Whether this entry is a game or the launcher itself — see [`GameRole`]. A library plugin
|
||||
/// emits its `launchers(cfg)` entries with `role: "launcher"`.
|
||||
#[serde(default)]
|
||||
pub role: GameRole,
|
||||
/// How to recognize this title's process — see [`CustomEntry::detect`]. A provider that knows its
|
||||
/// titles' install directories (Playnite does) should send them: it is what lets a game launched
|
||||
/// through the provider's own client still end its session when the player quits.
|
||||
@@ -101,10 +120,13 @@ impl From<CustomEntry> for GameEntry {
|
||||
.unwrap_or_default()
|
||||
.or_hint(&c.detect);
|
||||
GameEntry {
|
||||
id: format!("custom:{}", c.id),
|
||||
store: "custom".into(),
|
||||
id: library_id_for(&c),
|
||||
// A claimed entry wears its store's badge; everything else is `custom`. `provider` rides
|
||||
// along either way, so attribution ("synced by the steam plugin") survives the claim.
|
||||
store: c.store.clone().unwrap_or_else(|| "custom".into()),
|
||||
title: c.title,
|
||||
art: c.art,
|
||||
role: c.role,
|
||||
launch: c.launch,
|
||||
provider: c.provider,
|
||||
detect,
|
||||
@@ -122,42 +144,123 @@ fn custom_path() -> PathBuf {
|
||||
pf_paths::config_dir().join("library.json")
|
||||
}
|
||||
|
||||
/// Load the custom entries (empty + non-fatal if the file is absent or malformed).
|
||||
pub fn load_custom() -> Vec<CustomEntry> {
|
||||
/// The persisted catalog (`library.json` **v2**): the entries plus the store-claim map (D2).
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub struct Catalog {
|
||||
#[serde(default)]
|
||||
pub entries: Vec<CustomEntry>,
|
||||
/// `store id → provider id`. One provider per store; a second claimant is refused (409).
|
||||
///
|
||||
/// The map — not the entries — is the authority for a claim, which is exactly why it survives an
|
||||
/// **empty reconcile**: a store the plugin legitimately owns can have zero installed titles, and
|
||||
/// the built-in scanner it suppresses must stay suppressed anyway. Releasing is explicit
|
||||
/// (`DELETE /library/provider/{p}`, or the plugin claiming a different store).
|
||||
#[serde(default)]
|
||||
pub claims: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// What `library.json` may contain on disk. v1 was a bare array of entries; v2 is the [`Catalog`]
|
||||
/// object. Untagged, so an existing v1 file loads unchanged — and the host always WRITES v2, so the
|
||||
/// first mutation after an upgrade migrates the file in place with no separate migration step.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum LibraryFile {
|
||||
V2(Catalog),
|
||||
Legacy(Vec<CustomEntry>),
|
||||
}
|
||||
|
||||
/// Load the whole catalog (default + non-fatal if the file is absent or malformed).
|
||||
pub fn load_catalog() -> Catalog {
|
||||
match std::fs::read_to_string(custom_path()) {
|
||||
Ok(raw) => serde_json::from_str(&raw).unwrap_or_else(|e| {
|
||||
tracing::warn!(error = %e, "library.json malformed — ignoring custom entries");
|
||||
Vec::new()
|
||||
}),
|
||||
Err(_) => Vec::new(),
|
||||
Ok(raw) => match serde_json::from_str::<LibraryFile>(&raw) {
|
||||
Ok(LibraryFile::V2(c)) => c,
|
||||
Ok(LibraryFile::Legacy(entries)) => Catalog {
|
||||
entries,
|
||||
claims: BTreeMap::new(),
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "library.json malformed — ignoring custom entries");
|
||||
Catalog::default()
|
||||
}
|
||||
},
|
||||
Err(_) => Catalog::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve a custom/provider entry's stored **local** art file for one [`ArtKind`] — the non-Steam
|
||||
/// branch of the art proxy (`GET /library/art/custom:<id>/<kind>`). `id` is the bare custom id (the
|
||||
/// `custom:` prefix already stripped by the handler). `None` if the entry is unknown, has no art of
|
||||
/// that kind, or that art value isn't a servable local file (e.g. an `http` URL the client fetches
|
||||
/// itself). Blocking IO — call off the async runtime.
|
||||
pub fn custom_local_art_bytes(id: &str, kind: ArtKind) -> Option<(Vec<u8>, String)> {
|
||||
let entry = load_custom().into_iter().find(|e| e.id == id)?;
|
||||
let field = match kind {
|
||||
ArtKind::Portrait => entry.art.portrait,
|
||||
ArtKind::Hero => entry.art.hero,
|
||||
ArtKind::Logo => entry.art.logo,
|
||||
ArtKind::Header => entry.art.header,
|
||||
}?;
|
||||
/// Load just the entries — the read path every library surface uses.
|
||||
pub fn load_custom() -> Vec<CustomEntry> {
|
||||
load_catalog().entries
|
||||
}
|
||||
|
||||
/// The active store claims (`store → provider`). Read per library scan to suppress the built-in
|
||||
/// scanner a plugin has taken over (D2).
|
||||
pub fn claimed_stores() -> BTreeMap<String, String> {
|
||||
load_catalog().claims
|
||||
}
|
||||
|
||||
/// The library id a stored entry surfaces as. **The single source of truth for the mapping** —
|
||||
/// [`From<CustomEntry> for GameEntry`] and every id→entry lookup go through it, so the id scheme
|
||||
/// can't drift between the catalog, the art proxy and the launch resolver.
|
||||
///
|
||||
/// A **claimed** entry (D2) gets the deterministic `<store>:<external_id>` its built-in scanner used
|
||||
/// to produce — `steam:440`, `heroic:legendary:Quail` — so entry ids, GameStream FNV-1a app ids,
|
||||
/// client art caches and Moonlight pins all survive the migration to a plugin untouched. That is the
|
||||
/// whole point of the claim: extraction must be invisible to everything downstream. An unclaimed
|
||||
/// entry keeps the opaque host-assigned `custom:<id>`.
|
||||
pub(crate) fn library_id_for(e: &CustomEntry) -> String {
|
||||
match (e.store.as_deref(), e.external_id.as_deref()) {
|
||||
(Some(store), Some(external)) => format!("{store}:{external}"),
|
||||
_ => format!("custom:{}", e.id),
|
||||
}
|
||||
}
|
||||
|
||||
/// The **source id** an entry is toggled by (WP2.6): its claimed store when it has one, else its
|
||||
/// provider id. `None` for a manual entry — the custom store is not a source and can never be
|
||||
/// switched off. Since the claimed store id, the provider id and the old scanner id are all the same
|
||||
/// string by construction, a user's existing disabled state carries over verbatim.
|
||||
pub(crate) fn source_id_for(e: &CustomEntry) -> Option<&str> {
|
||||
e.store.as_deref().or(e.provider.as_deref())
|
||||
}
|
||||
|
||||
/// The stored entry a full **library id** refers to, or `None`. The art proxy resolves *any* id this
|
||||
/// way before falling back to the legacy per-store branches (WP1.2), which is what lets a plugin's
|
||||
/// entries be served regardless of what their ids look like.
|
||||
pub fn entry_for_library_id(library_id: &str) -> Option<CustomEntry> {
|
||||
load_custom()
|
||||
.into_iter()
|
||||
.find(|e| library_id_for(e) == library_id)
|
||||
}
|
||||
|
||||
/// Serve a stored entry's **local** art file for one [`ArtKind`] — the `library.json` branch of the
|
||||
/// art proxy (`GET /library/art/<library id>/<kind>`). `None` if the id names no stored entry, it has
|
||||
/// no art of that kind, or that art value isn't a servable local file (e.g. an `http` URL the client
|
||||
/// fetches itself). Blocking IO — call off the async runtime.
|
||||
pub fn library_local_art_bytes(library_id: &str, kind: ArtKind) -> Option<(Vec<u8>, String)> {
|
||||
let field = art_field(&entry_for_library_id(library_id)?.art, kind)?;
|
||||
is_local_art_path(&field)
|
||||
.then(|| local_art_bytes(&field))
|
||||
.flatten()
|
||||
}
|
||||
|
||||
fn save_custom(entries: &[CustomEntry]) -> Result<()> {
|
||||
/// One [`Artwork`] field by kind — the tiny mapping the proxy and the box-art ladder share.
|
||||
pub(crate) fn art_field(art: &Artwork, kind: ArtKind) -> Option<String> {
|
||||
match kind {
|
||||
ArtKind::Portrait => art.portrait.clone(),
|
||||
ArtKind::Hero => art.hero.clone(),
|
||||
ArtKind::Logo => art.logo.clone(),
|
||||
ArtKind::Header => art.header.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the catalog in the **v2** shape (write-then-rename, restrictive perms). Every mutation
|
||||
/// path funnels through here, so a v1 file is upgraded by the first write.
|
||||
fn save_catalog(catalog: &Catalog) -> Result<()> {
|
||||
let dir = pf_paths::config_dir();
|
||||
// Owner-private dir (0700 / SYSTEM+Admins DACL) so a non-privileged local user can't plant a
|
||||
// library.json whose `prep`/`launch` commands the host would later execute — the same trust
|
||||
// boundary hooks.json and the mgmt token already use.
|
||||
pf_paths::create_private_dir(&dir).with_context(|| format!("create {}", dir.display()))?;
|
||||
let json = serde_json::to_string_pretty(entries)?;
|
||||
let json = serde_json::to_string_pretty(catalog)?;
|
||||
// Write-then-rename so a crash mid-write never truncates the catalog; `write_secret_file` gives
|
||||
// the temp file its restrictive perms (0600 / SYSTEM+Admins DACL) before the rename carries them
|
||||
// to the final path.
|
||||
@@ -177,19 +280,26 @@ fn new_id(title: &str) -> String {
|
||||
hex::encode(&Sha256::digest(format!("{title}:{nanos}").as_bytes())[..6])
|
||||
}
|
||||
|
||||
/// Outcome of a manual mutation against an id — distinguishes "no such entry" from "exists,
|
||||
/// but a provider owns it" (the mgmt layer maps the latter to 409, not 404).
|
||||
/// Outcome of a mutation — distinguishes "no such entry" from the two conflict cases the mgmt
|
||||
/// layer maps to 409 rather than 404.
|
||||
pub enum MutateOutcome<T> {
|
||||
Done(T),
|
||||
NotFound,
|
||||
/// The entry belongs to this provider — mutate it through the provider reconcile API
|
||||
/// (or remove the whole provider set); manual edits would be clobbered at the next sync.
|
||||
ProviderOwned(String),
|
||||
/// The requested store claim is already held by a DIFFERENT provider (D2: one provider per
|
||||
/// store). Refusing is the point — two plugins both emitting `steam:440` would collide on entry
|
||||
/// ids, so the second claimant is told who holds it instead of silently taking over.
|
||||
StoreClaimed {
|
||||
store: String,
|
||||
provider: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Create a custom (manual) entry, returning it with its assigned id.
|
||||
pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
|
||||
let mut entries = load_custom();
|
||||
let mut catalog = load_catalog();
|
||||
let entry = CustomEntry {
|
||||
id: new_id(&input.title),
|
||||
title: input.title,
|
||||
@@ -198,11 +308,13 @@ pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
|
||||
prep: input.prep,
|
||||
provider: None,
|
||||
external_id: None,
|
||||
store: None,
|
||||
role: input.role,
|
||||
detect: input.detect,
|
||||
meta: input.meta,
|
||||
};
|
||||
entries.push(entry.clone());
|
||||
save_custom(&entries)?;
|
||||
catalog.entries.push(entry.clone());
|
||||
save_catalog(&catalog)?;
|
||||
emit_changed("manual");
|
||||
Ok(entry)
|
||||
}
|
||||
@@ -210,8 +322,8 @@ pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
|
||||
/// Replace a manual entry's fields (id preserved). Provider-owned entries are refused —
|
||||
/// their state belongs to the provider's reconcile (RFC §8 ownership rule).
|
||||
pub fn update_custom(id: &str, input: CustomInput) -> Result<MutateOutcome<CustomEntry>> {
|
||||
let mut entries = load_custom();
|
||||
let Some(slot) = entries.iter_mut().find(|e| e.id == id) else {
|
||||
let mut catalog = load_catalog();
|
||||
let Some(slot) = catalog.entries.iter_mut().find(|e| e.id == id) else {
|
||||
return Ok(MutateOutcome::NotFound);
|
||||
};
|
||||
if let Some(provider) = &slot.provider {
|
||||
@@ -221,25 +333,26 @@ pub fn update_custom(id: &str, input: CustomInput) -> Result<MutateOutcome<Custo
|
||||
slot.art = input.art;
|
||||
slot.launch = input.launch;
|
||||
slot.prep = input.prep;
|
||||
slot.role = input.role;
|
||||
slot.detect = input.detect;
|
||||
slot.meta = input.meta;
|
||||
let updated = slot.clone();
|
||||
save_custom(&entries)?;
|
||||
save_catalog(&catalog)?;
|
||||
emit_changed("manual");
|
||||
Ok(MutateOutcome::Done(updated))
|
||||
}
|
||||
|
||||
/// Delete a manual entry. Provider-owned entries are refused (see [`update_custom`]).
|
||||
pub fn delete_custom(id: &str) -> Result<MutateOutcome<()>> {
|
||||
let mut entries = load_custom();
|
||||
let Some(entry) = entries.iter().find(|e| e.id == id) else {
|
||||
let mut catalog = load_catalog();
|
||||
let Some(entry) = catalog.entries.iter().find(|e| e.id == id) else {
|
||||
return Ok(MutateOutcome::NotFound);
|
||||
};
|
||||
if let Some(provider) = &entry.provider {
|
||||
return Ok(MutateOutcome::ProviderOwned(provider.clone()));
|
||||
}
|
||||
entries.retain(|e| e.id != id);
|
||||
save_custom(&entries)?;
|
||||
catalog.entries.retain(|e| e.id != id);
|
||||
save_catalog(&catalog)?;
|
||||
emit_changed("manual");
|
||||
Ok(MutateOutcome::Done(()))
|
||||
}
|
||||
@@ -265,6 +378,26 @@ pub fn validate_provider_name(provider: &str) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Store claims become the **prefix of every claimed entry's library id**, so they are far more
|
||||
/// constrained than a provider name: no dots (an id is split on the first `:`, and a dotted store
|
||||
/// would read as a hostname in logs), and the two host-owned namespaces are off-limits — `custom` is
|
||||
/// the unclaimed-entry namespace and `manual` is the no-provider sentinel in `library.changed`.
|
||||
pub fn validate_store_claim(store: &str) -> Result<(), String> {
|
||||
if store == "custom" || store == "manual" {
|
||||
return Err(format!("store id `{store}` is reserved"));
|
||||
}
|
||||
let ok = !store.is_empty()
|
||||
&& store.len() <= 32
|
||||
&& store
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'-' | b'_'));
|
||||
if ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("store id must be 1–32 chars of [a-z0-9_-]".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a reconcile payload: non-empty titles and unique, non-empty external ids (the
|
||||
/// diff key — a duplicate would make ownership of the surviving entry ambiguous).
|
||||
pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), String> {
|
||||
@@ -282,6 +415,31 @@ pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), St
|
||||
e.external_id
|
||||
));
|
||||
}
|
||||
// Closed-vocabulary launch kinds are checked on the way IN as well as at launch time, so a
|
||||
// plugin gets a 400 it can act on rather than a tile that silently refuses to start.
|
||||
if let Some(launch) = &e.launch {
|
||||
if launch.kind == "steam_ui" && !valid_steam_ui(&launch.value) {
|
||||
return Err(format!(
|
||||
"entries[{i}]: `launch.value` for kind `steam_ui` must be `bigpicture` or `desktop`"
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(marker) = &e.detect.env_marker {
|
||||
if !valid_env_key(&marker.key) {
|
||||
return Err(format!(
|
||||
"entries[{i}]: `detect.env_marker.key` must be 1–64 chars of [A-Za-z0-9_]"
|
||||
));
|
||||
}
|
||||
if marker
|
||||
.value
|
||||
.as_ref()
|
||||
.is_some_and(|v| v.len() > MAX_ENV_VALUE)
|
||||
{
|
||||
return Err(format!(
|
||||
"entries[{i}]: `detect.env_marker.value` must be at most {MAX_ENV_VALUE} chars"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -293,6 +451,7 @@ pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), St
|
||||
fn reconcile_entries(
|
||||
entries: &mut Vec<CustomEntry>,
|
||||
provider: &str,
|
||||
store: Option<&str>,
|
||||
inputs: Vec<ProviderEntryInput>,
|
||||
) -> Vec<CustomEntry> {
|
||||
// The provider's current entries, keyed by its own stable id.
|
||||
@@ -317,6 +476,10 @@ fn reconcile_entries(
|
||||
prep: input.prep,
|
||||
provider: Some(provider.to_string()),
|
||||
external_id: Some(input.external_id),
|
||||
// Stamping the claim per entry is what makes the surfaced id deterministic
|
||||
// (`<store>:<external_id>`) — see `library_id_for`.
|
||||
store: store.map(str::to_string),
|
||||
role: input.role,
|
||||
detect: input.detect,
|
||||
meta: input.meta,
|
||||
});
|
||||
@@ -326,43 +489,86 @@ fn reconcile_entries(
|
||||
result
|
||||
}
|
||||
|
||||
/// Atomically replace `provider`'s entry set (RFC §8: `PUT /library/provider/{provider}`).
|
||||
/// The caller validates the name and payload first. Emits `library.changed` with the provider
|
||||
/// as the source.
|
||||
/// Atomically replace `provider`'s entry set (RFC §8: `PUT /library/provider/{provider}`), optionally
|
||||
/// under a **store claim** (D2: `?store=steam`). The caller validates the name and payload first.
|
||||
/// Emits `library.changed` with the provider as the source.
|
||||
///
|
||||
/// Claiming is idempotent for the holder and refused for anyone else. A provider holds at most one
|
||||
/// store, so claiming a new one releases whatever it held before — otherwise an abandoned claim would
|
||||
/// go on suppressing a built-in scanner with nothing to replace it.
|
||||
pub fn reconcile_provider(
|
||||
provider: &str,
|
||||
store: Option<&str>,
|
||||
inputs: Vec<ProviderEntryInput>,
|
||||
) -> Result<Vec<CustomEntry>> {
|
||||
let mut entries = load_custom();
|
||||
let result = reconcile_entries(&mut entries, provider, inputs);
|
||||
save_custom(&entries)?;
|
||||
) -> Result<MutateOutcome<Vec<CustomEntry>>> {
|
||||
let mut catalog = load_catalog();
|
||||
if let Some(store) = store {
|
||||
if let Some(holder) = catalog.claims.get(store) {
|
||||
if holder != provider {
|
||||
return Ok(MutateOutcome::StoreClaimed {
|
||||
store: store.to_string(),
|
||||
provider: holder.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let previous: Vec<String> = catalog
|
||||
.claims
|
||||
.iter()
|
||||
.filter(|(s, p)| p.as_str() == provider && s.as_str() != store)
|
||||
.map(|(s, _)| s.clone())
|
||||
.collect();
|
||||
for stale in previous {
|
||||
tracing::info!(provider, released = %stale, claimed = store, "library: provider moved its store claim");
|
||||
catalog.claims.remove(&stale);
|
||||
}
|
||||
if catalog
|
||||
.claims
|
||||
.insert(store.to_string(), provider.to_string())
|
||||
.is_none()
|
||||
{
|
||||
tracing::info!(provider, store, "library: store claimed by a provider");
|
||||
}
|
||||
}
|
||||
let result = reconcile_entries(&mut catalog.entries, provider, store, inputs);
|
||||
save_catalog(&catalog)?;
|
||||
emit_changed(provider);
|
||||
Ok(result)
|
||||
Ok(MutateOutcome::Done(result))
|
||||
}
|
||||
|
||||
/// Remove every entry of `provider` (RFC §8: `DELETE /library/provider/{provider}` — the
|
||||
/// clean-uninstall path). Returns how many were removed; no event when nothing was.
|
||||
/// Remove every entry of `provider` **and release its store claim** (RFC §8:
|
||||
/// `DELETE /library/provider/{provider}` — the clean-uninstall path). Returns how many entries were
|
||||
/// removed; no event when nothing changed at all.
|
||||
///
|
||||
/// Releasing here — and only here — is what makes uninstalling a library plugin bring its built-in
|
||||
/// scanner straight back, with no restart and nothing to undo by hand.
|
||||
pub fn delete_provider(provider: &str) -> Result<usize> {
|
||||
let mut entries = load_custom();
|
||||
let before = entries.len();
|
||||
entries.retain(|e| e.provider.as_deref() != Some(provider));
|
||||
let removed = before - entries.len();
|
||||
if removed > 0 {
|
||||
save_custom(&entries)?;
|
||||
let mut catalog = load_catalog();
|
||||
let before = catalog.entries.len();
|
||||
catalog
|
||||
.entries
|
||||
.retain(|e| e.provider.as_deref() != Some(provider));
|
||||
let removed = before - catalog.entries.len();
|
||||
let claims_before = catalog.claims.len();
|
||||
catalog.claims.retain(|_, p| p != provider);
|
||||
let released = claims_before - catalog.claims.len();
|
||||
if removed > 0 || released > 0 {
|
||||
if released > 0 {
|
||||
tracing::info!(provider, released, "library: store claim released");
|
||||
}
|
||||
save_catalog(&catalog)?;
|
||||
emit_changed(provider);
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
/// The prep/undo steps for a library id — `custom:<id>` entries only (the other stores have no
|
||||
/// The prep/undo steps for a library id — any **stored** entry (the in-host scanners have no
|
||||
/// per-title config surface; a GameStream `apps.json` entry carries its own `prep` instead).
|
||||
///
|
||||
/// Resolved through [`entry_for_library_id`] rather than by stripping a `custom:` prefix, so a
|
||||
/// claimed entry's prep still runs: after extraction a `steam:440` entry is a stored one, and
|
||||
/// per-title prep is exactly the kind of thing an operator sets on a game they play.
|
||||
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)
|
||||
entry_for_library_id(library_id)
|
||||
.map(|e| e.prep)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -375,13 +581,7 @@ fn emit_changed(source: &str) {
|
||||
});
|
||||
}
|
||||
|
||||
/// A digits-only Steam appid: the sole client-influenced part of a Steam launch, validated before it
|
||||
/// is interpolated into any command / URI (so a client-sent id can never carry shell or URI syntax).
|
||||
/// Cross-platform — used by the Linux shell mapping ([`command_for`]) and the Windows spawn mapping
|
||||
/// ([`windows_launch_for`]).
|
||||
pub(crate) fn valid_steam_appid(value: &str) -> bool {
|
||||
!value.is_empty() && value.bytes().all(|b| b.is_ascii_digit())
|
||||
}
|
||||
// `valid_steam_appid` moved to `launch.rs` (WP1.1) — it validates a launch value, not a store entry.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -396,6 +596,8 @@ mod tests {
|
||||
prep: Vec::new(),
|
||||
provider: None,
|
||||
external_id: None,
|
||||
store: None,
|
||||
role: GameRole::Game,
|
||||
detect: DetectHint::default(),
|
||||
meta: GameMeta::default(),
|
||||
}
|
||||
@@ -408,6 +610,7 @@ mod tests {
|
||||
art: Artwork::default(),
|
||||
launch: None,
|
||||
prep: Vec::new(),
|
||||
role: GameRole::Game,
|
||||
detect: DetectHint::default(),
|
||||
meta: GameMeta::default(),
|
||||
}
|
||||
@@ -429,6 +632,79 @@ mod tests {
|
||||
assert_eq!(g.meta.platform.as_deref(), Some("PS2"));
|
||||
}
|
||||
|
||||
/// D2's core promise: a **claimed** entry is indistinguishable from what the built-in scanner
|
||||
/// produced. Same id, same store badge — plus the provider attribution the scanner never had.
|
||||
#[test]
|
||||
fn a_claimed_entry_reproduces_the_scanner_identity() {
|
||||
let mut e = manual("host-assigned", "Portal 2");
|
||||
e.provider = Some("steam".into());
|
||||
e.external_id = Some("620".into());
|
||||
e.store = Some("steam".into());
|
||||
assert_eq!(library_id_for(&e), "steam:620");
|
||||
let g: GameEntry = e.clone().into();
|
||||
assert_eq!(g.id, "steam:620", "exactly what the scanner emitted");
|
||||
assert_eq!(g.store, "steam", "the store badge, not `custom`");
|
||||
assert_eq!(
|
||||
g.provider.as_deref(),
|
||||
Some("steam"),
|
||||
"attribution rides along too"
|
||||
);
|
||||
|
||||
// Unclaimed provider entries are untouched by any of this — rom-manager/playnite keep the
|
||||
// opaque host id they have always had.
|
||||
let mut u = manual("abc", "Chrono Trigger");
|
||||
u.provider = Some("romm".into());
|
||||
u.external_id = Some("rom-1".into());
|
||||
assert_eq!(library_id_for(&u), "custom:abc");
|
||||
assert_eq!(GameEntry::from(u).store, "custom");
|
||||
|
||||
// The source a toggle addresses: the claimed store when there is one, else the provider.
|
||||
assert_eq!(source_id_for(&e), Some("steam"));
|
||||
let mut r = manual("z", "T");
|
||||
r.provider = Some("romm".into());
|
||||
assert_eq!(source_id_for(&r), Some("romm"));
|
||||
assert_eq!(
|
||||
source_id_for(&manual("m", "Manual")),
|
||||
None,
|
||||
"never hideable"
|
||||
);
|
||||
}
|
||||
|
||||
/// A claimed entry keeps its `<store>:<external_id>` id across reconciles no matter what the
|
||||
/// host-assigned id does — which is what keeps GameStream's FNV-1a app ids, client art caches
|
||||
/// and Moonlight pins valid through the migration (the whole point of D2).
|
||||
#[test]
|
||||
fn claimed_ids_are_deterministic_across_reconciles() {
|
||||
let mut entries = Vec::new();
|
||||
let r1 = reconcile_entries(
|
||||
&mut entries,
|
||||
"steam",
|
||||
Some("steam"),
|
||||
vec![input("440", "Team Fortress 2"), input("620", "Portal 2")],
|
||||
);
|
||||
let ids: Vec<String> = r1.iter().map(library_id_for).collect();
|
||||
assert_eq!(ids, ["steam:440", "steam:620"]);
|
||||
|
||||
// Re-sync with a renamed title and a new entry: the surfaced ids for surviving titles are
|
||||
// byte-identical, and a brand-new title's id is derived, not random.
|
||||
let r2 = reconcile_entries(
|
||||
&mut entries,
|
||||
"steam",
|
||||
Some("steam"),
|
||||
vec![
|
||||
input("440", "Team Fortress 2 (2026)"),
|
||||
input("70", "Half-Life"),
|
||||
],
|
||||
);
|
||||
let ids2: Vec<String> = r2.iter().map(library_id_for).collect();
|
||||
assert_eq!(ids2, ["steam:440", "steam:70"]);
|
||||
|
||||
// Dropping the claim on a later reconcile reverts them to opaque custom ids — the entries
|
||||
// are the same rows, so this is exactly the "plugin stopped claiming" degradation.
|
||||
let r3 = reconcile_entries(&mut entries, "steam", None, vec![input("440", "TF2")]);
|
||||
assert!(library_id_for(&r3[0]).starts_with("custom:"));
|
||||
}
|
||||
|
||||
/// The metadata contract on the wire and on disk: fields serialize FLAT (no `meta` nesting —
|
||||
/// clients and plugins see `platform` beside `title`), absent fields vanish entirely, and a
|
||||
/// pre-metadata `library.json` / payload still parses (all-optional).
|
||||
@@ -477,6 +753,7 @@ mod tests {
|
||||
let r1 = reconcile_entries(
|
||||
&mut entries,
|
||||
"romm",
|
||||
None,
|
||||
vec![input("rom-a", "Game A"), input("rom-b", "Game B")],
|
||||
);
|
||||
assert_eq!(r1.len(), 2);
|
||||
@@ -488,6 +765,7 @@ mod tests {
|
||||
let r2 = reconcile_entries(
|
||||
&mut entries,
|
||||
"romm",
|
||||
None,
|
||||
vec![input("rom-a", "Game A (v2)"), input("rom-c", "Game C")],
|
||||
);
|
||||
assert_eq!(r2.len(), 2);
|
||||
@@ -506,6 +784,7 @@ mod tests {
|
||||
let r3 = reconcile_entries(
|
||||
&mut entries,
|
||||
"romm",
|
||||
None,
|
||||
vec![input("rom-a", "Game A (v2)"), input("rom-c", "Game C")],
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -526,7 +805,7 @@ mod tests {
|
||||
.any(|e| e.id == "oth1" && e.provider.as_deref() == Some("itch")));
|
||||
|
||||
// Empty payload = remove everything the provider owns (same as DELETE).
|
||||
let r4 = reconcile_entries(&mut entries, "romm", Vec::new());
|
||||
let r4 = reconcile_entries(&mut entries, "romm", None, Vec::new());
|
||||
assert!(r4.is_empty());
|
||||
assert_eq!(
|
||||
entries.len(),
|
||||
@@ -535,6 +814,98 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `library.json` v1 (a bare array) must keep loading, and v2 (the claims object) must round
|
||||
/// trip. This is the only migration in the whole program — get it wrong and an existing host
|
||||
/// silently loses its manual entries on upgrade.
|
||||
#[test]
|
||||
fn v1_and_v2_library_files_both_load() {
|
||||
// v1: exactly what a shipped host has on disk today.
|
||||
let v1 = r#"[{"id":"abc","title":"Old Manual"}]"#;
|
||||
let c = match serde_json::from_str::<LibraryFile>(v1).unwrap() {
|
||||
LibraryFile::Legacy(entries) => Catalog {
|
||||
entries,
|
||||
claims: BTreeMap::new(),
|
||||
},
|
||||
LibraryFile::V2(_) => panic!("an array must not parse as v2"),
|
||||
};
|
||||
assert_eq!(c.entries.len(), 1);
|
||||
assert_eq!(c.entries[0].title, "Old Manual");
|
||||
assert!(c.claims.is_empty());
|
||||
|
||||
// v2, including a claim.
|
||||
let v2 = r#"{"entries":[{"id":"abc","title":"New"}],"claims":{"steam":"steam"}}"#;
|
||||
let c = match serde_json::from_str::<LibraryFile>(v2).unwrap() {
|
||||
LibraryFile::V2(c) => c,
|
||||
LibraryFile::Legacy(_) => panic!("an object must not parse as v1"),
|
||||
};
|
||||
assert_eq!(c.entries.len(), 1);
|
||||
assert_eq!(c.claims.get("steam").map(String::as_str), Some("steam"));
|
||||
|
||||
// A v2 file with no claims key at all (what the first write after upgrade produces before
|
||||
// anything is claimed) still loads.
|
||||
let bare = r#"{"entries":[]}"#;
|
||||
assert!(matches!(
|
||||
serde_json::from_str::<LibraryFile>(bare).unwrap(),
|
||||
LibraryFile::V2(_)
|
||||
));
|
||||
|
||||
// And what we WRITE is v2, so one mutation upgrades the file in place.
|
||||
let written = serde_json::to_string(&Catalog::default()).unwrap();
|
||||
assert!(written.contains("\"entries\""));
|
||||
assert!(written.contains("\"claims\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_claim_validation() {
|
||||
assert!(validate_store_claim("steam").is_ok());
|
||||
assert!(validate_store_claim("epic-games").is_ok());
|
||||
assert!(validate_store_claim("xbox_pc").is_ok());
|
||||
// The two host-owned namespaces are off-limits.
|
||||
assert!(validate_store_claim("custom").is_err());
|
||||
assert!(validate_store_claim("manual").is_err());
|
||||
assert!(validate_store_claim("").is_err());
|
||||
assert!(validate_store_claim("Steam").is_err()); // no uppercase
|
||||
// A dot would read as a hostname in a log line and muddies the `store:id` split.
|
||||
assert!(validate_store_claim("my.store").is_err());
|
||||
assert!(validate_store_claim(&"s".repeat(33)).is_err());
|
||||
}
|
||||
|
||||
/// The closed-vocabulary fields are rejected at the door, so a plugin gets a 400 rather than a
|
||||
/// tile that silently refuses to launch.
|
||||
#[test]
|
||||
fn payload_validation_covers_the_new_closed_vocabularies() {
|
||||
let with_launch = |kind: &str, value: &str| {
|
||||
let mut i = input("a", "A");
|
||||
i.launch = Some(LaunchSpec {
|
||||
kind: kind.into(),
|
||||
value: value.into(),
|
||||
});
|
||||
i
|
||||
};
|
||||
assert!(validate_provider_payload(&[with_launch("steam_ui", "bigpicture")]).is_ok());
|
||||
assert!(validate_provider_payload(&[with_launch("steam_ui", "desktop")]).is_ok());
|
||||
assert!(validate_provider_payload(&[with_launch("steam_ui", "gamepad")]).is_err());
|
||||
assert!(validate_provider_payload(&[with_launch("steam_ui", "")]).is_err());
|
||||
// Other kinds are unconstrained here (the host validates them per-kind at launch).
|
||||
assert!(validate_provider_payload(&[with_launch("command", "anything")]).is_ok());
|
||||
|
||||
let with_env = |key: &str, value: Option<&str>| {
|
||||
let mut i = input("a", "A");
|
||||
i.detect.env_marker = Some(EnvMarker {
|
||||
key: key.into(),
|
||||
value: value.map(str::to_string),
|
||||
});
|
||||
i
|
||||
};
|
||||
assert!(validate_provider_payload(&[with_env("HEROIC_APP_NAME", Some("Quail"))]).is_ok());
|
||||
assert!(validate_provider_payload(&[with_env("BAD-KEY", None)]).is_err());
|
||||
assert!(validate_provider_payload(&[with_env("", None)]).is_err());
|
||||
assert!(
|
||||
validate_provider_payload(&[with_env("K", Some(&"x".repeat(MAX_ENV_VALUE + 1)))])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_name_and_payload_validation() {
|
||||
assert!(validate_provider_name("romm").is_ok());
|
||||
|
||||
@@ -19,15 +19,34 @@
|
||||
use super::*;
|
||||
|
||||
/// An environment variable a launcher stamps onto the game's process, identifying it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
///
|
||||
/// Serializable because it is now half of the inbound [`DetectHint`] too (D3) — a library plugin
|
||||
/// that knows its launcher's marker (Heroic's `HEROIC_APP_NAME`, load-bearing under Proton) has to
|
||||
/// be able to say so, since after extraction the host no longer reads that launcher's files itself.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct EnvMarker {
|
||||
/// The variable name (e.g. `HEROIC_GAME_ID`).
|
||||
#[schema(example = "HEROIC_APP_NAME")]
|
||||
pub key: String,
|
||||
/// The exact value to require, when the launcher's value identifies *this* title. `None` matches
|
||||
/// the key's mere presence — only safe for launchers that run one game at a time.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub value: Option<String>,
|
||||
}
|
||||
|
||||
/// The env-var name charset a hint may carry: `[A-Za-z0-9_]{1,64}`, POSIX-shaped. An out-of-charset
|
||||
/// key is not a real environment variable, so accepting one could only ever produce a matcher rule
|
||||
/// that never fires (or, with an absurd length, a needless per-process comparison cost).
|
||||
pub(crate) fn valid_env_key(key: &str) -> bool {
|
||||
!key.is_empty()
|
||||
&& key.len() <= 64
|
||||
&& key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
|
||||
}
|
||||
|
||||
/// Longest env-var VALUE a hint may pin. Values are compared against every candidate process's
|
||||
/// environment, so an unbounded one is a (small) DoS lever and never a legitimate game id.
|
||||
pub(crate) const MAX_ENV_VALUE: usize = 256;
|
||||
|
||||
/// The signals that identify a launched title's process(es). Every field is optional and
|
||||
/// independent; an all-`None` spec means "this title can't be tracked" (the lease degrades to
|
||||
/// [`crate::gamelease::LeaseKind::Untracked`] and both lifetime behaviors stay inert for it).
|
||||
@@ -115,6 +134,11 @@ impl DetectSpec {
|
||||
self.install_dir = self.install_dir.or(from.install_dir);
|
||||
self.exe = self.exe.or(from.exe);
|
||||
self.process_name = self.process_name.or(from.process_name);
|
||||
// D3: the two store-derived signals are fillable from a hint now that the store may live in
|
||||
// a plugin. Same rule as the other three — the host's own finding wins where it has one,
|
||||
// which for a provider entry is moot (the host scanned nothing for it).
|
||||
self.steam_appid = self.steam_appid.or(from.steam_appid);
|
||||
self.env_marker = self.env_marker.or(from.env_marker);
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -143,12 +167,31 @@ pub struct DetectHint {
|
||||
/// — see [`DetectSpec::process_name`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub process_name: Option<String>,
|
||||
/// The Steam appid, for a title Steam itself installed (D3). On Linux this is the **sharpest**
|
||||
/// signal that exists — Steam wraps every launch, native or Proton, in
|
||||
/// `reaper SteamLaunch AppId=<appid>`, whose lifetime is exactly the game's — so without it a
|
||||
/// steam plugin's lease tracking would degrade from reaper-exact to install-dir prefix matching.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub steam_appid: Option<u32>,
|
||||
/// A launcher-stamped environment marker (D3) — see [`EnvMarker`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub env_marker: Option<EnvMarker>,
|
||||
}
|
||||
|
||||
impl DetectHint {
|
||||
/// Whether the hint says anything at all (all-empty is treated as absent).
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.trimmed().is_none()
|
||||
self.trimmed().is_none() && self.steam_appid.is_none() && self.env_marker().is_none()
|
||||
}
|
||||
|
||||
/// The env marker, if it is well-formed. A malformed one is dropped rather than rejected, for
|
||||
/// the same reason a blank `install_dir` is: hint fields are hand-writable plugin input, and the
|
||||
/// matcher must never be handed a rule it can't honour.
|
||||
fn env_marker(&self) -> Option<&EnvMarker> {
|
||||
self.env_marker
|
||||
.as_ref()
|
||||
.filter(|m| valid_env_key(&m.key))
|
||||
.filter(|m| m.value.as_ref().is_none_or(|v| v.len() <= MAX_ENV_VALUE))
|
||||
}
|
||||
|
||||
/// The hint with blank fields dropped, or `None` if nothing is left. Console text inputs and
|
||||
@@ -166,14 +209,13 @@ impl DetectHint {
|
||||
/// A provider's hint becomes a spec — the one inbound path into [`DetectSpec`].
|
||||
impl From<&DetectHint> for DetectSpec {
|
||||
fn from(h: &DetectHint) -> Self {
|
||||
let Some((install_dir, exe, process_name)) = h.trimmed() else {
|
||||
return Self::default();
|
||||
};
|
||||
let (install_dir, exe, process_name) = h.trimmed().unwrap_or((None, None, None));
|
||||
Self {
|
||||
install_dir: install_dir.map(PathBuf::from),
|
||||
exe: exe.map(PathBuf::from),
|
||||
process_name: process_name.map(str::to_string),
|
||||
..Default::default()
|
||||
steam_appid: h.steam_appid,
|
||||
env_marker: h.env_marker().cloned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -273,6 +315,7 @@ mod tests {
|
||||
install_dir: Some("".into()),
|
||||
exe: Some(" ".into()),
|
||||
process_name: Some("\t".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(blank.is_empty());
|
||||
assert!(DetectSpec::from(&blank).is_empty(), "nothing to match on");
|
||||
@@ -281,6 +324,7 @@ mod tests {
|
||||
install_dir: Some(" /games/quail ".into()),
|
||||
exe: None,
|
||||
process_name: Some("quail".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!hint.is_empty());
|
||||
let spec = DetectSpec::from(&hint);
|
||||
@@ -299,6 +343,7 @@ mod tests {
|
||||
install_dir: Some("/games/wrong".into()),
|
||||
exe: Some("/games/real/run".into()),
|
||||
process_name: None,
|
||||
..Default::default()
|
||||
};
|
||||
let merged = found.or_hint(&hint);
|
||||
assert_eq!(
|
||||
@@ -317,6 +362,74 @@ mod tests {
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
/// D3: the two store-derived signals now ride the hint, because after extraction the host no
|
||||
/// longer reads Steam's or Heroic's files itself. Without them a plugin's lease tracking would
|
||||
/// silently degrade — reaper-exact to dir-prefix on Linux Steam, and gone entirely for Heroic
|
||||
/// under Proton, where the env marker is the only thing that works.
|
||||
#[test]
|
||||
fn a_hint_can_carry_the_store_derived_signals() {
|
||||
let hint = DetectHint {
|
||||
steam_appid: Some(440),
|
||||
env_marker: Some(EnvMarker {
|
||||
key: "HEROIC_APP_NAME".into(),
|
||||
value: Some("Quail".into()),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!hint.is_empty(), "either field alone is a real hint");
|
||||
let spec = DetectSpec::from(&hint);
|
||||
assert_eq!(spec.steam_appid, Some(440));
|
||||
assert_eq!(spec.env_marker.as_ref().unwrap().key, "HEROIC_APP_NAME");
|
||||
|
||||
// A steam_appid on its own is enough to be trackable.
|
||||
let only_appid = DetectHint {
|
||||
steam_appid: Some(620),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!only_appid.is_empty());
|
||||
assert!(!DetectSpec::from(&only_appid).is_empty());
|
||||
|
||||
// The host's own finding still wins where it has one (unchanged rule).
|
||||
let found = DetectSpec::steam(70);
|
||||
assert_eq!(found.or_hint(&hint).steam_appid, Some(70));
|
||||
// …but a field the host had nothing for is filled in.
|
||||
assert_eq!(
|
||||
DetectSpec::dir("/games/x")
|
||||
.or_hint(&hint)
|
||||
.env_marker
|
||||
.unwrap()
|
||||
.key,
|
||||
"HEROIC_APP_NAME"
|
||||
);
|
||||
}
|
||||
|
||||
/// A malformed marker is DROPPED, not honoured — same posture as a blank `install_dir`. The
|
||||
/// matcher must never be handed a rule it cannot evaluate, and these values reach a code path
|
||||
/// that can end processes.
|
||||
#[test]
|
||||
fn a_malformed_env_marker_says_nothing() {
|
||||
let bad = |key: &str, value: Option<String>| DetectHint {
|
||||
env_marker: Some(EnvMarker {
|
||||
key: key.into(),
|
||||
value,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(bad("", None).is_empty());
|
||||
assert!(bad("HAS-DASH", None).is_empty(), "not a POSIX env name");
|
||||
assert!(bad("HAS SPACE", None).is_empty());
|
||||
assert!(bad(&"K".repeat(65), None).is_empty(), "over the key cap");
|
||||
assert!(
|
||||
bad("K", Some("v".repeat(MAX_ENV_VALUE + 1))).is_empty(),
|
||||
"over the value cap"
|
||||
);
|
||||
// …and a well-formed one at exactly the caps is kept.
|
||||
assert!(!bad(&"K".repeat(64), Some("v".repeat(MAX_ENV_VALUE))).is_empty());
|
||||
assert!(DetectSpec::from(&bad("HAS-DASH", None))
|
||||
.env_marker
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_token_handles_quotes_and_spaces() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -100,6 +100,7 @@ fn epic_entry(
|
||||
};
|
||||
Some(GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
meta: GameMeta::pc(),
|
||||
id: format!("epic:{app_name}"),
|
||||
store: "epic".into(),
|
||||
@@ -186,25 +187,8 @@ fn epic_art_index(catcache: &Path) -> std::collections::HashMap<String, Artwork>
|
||||
map
|
||||
}
|
||||
|
||||
/// Build the `com.epicgames.launcher://` launch URI from a stored launch value — the triple
|
||||
/// `<namespace>:<catalogItemId>:<appName>` (colons URL-encoded), or a bare `<appName>` fallback.
|
||||
/// Each part is charset-validated (host-derived, but belt-and-suspenders) so no shell/URI injection.
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn epic_launch_uri(value: &str) -> Option<String> {
|
||||
let ok = |s: &str| {
|
||||
!s.is_empty()
|
||||
&& s.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
|
||||
};
|
||||
let inner = match value.split(':').collect::<Vec<_>>().as_slice() {
|
||||
[ns, cat, app] if ok(ns) && ok(cat) && ok(app) => format!("{ns}%3A{cat}%3A{app}"),
|
||||
[app] if ok(app) => (*app).to_string(),
|
||||
_ => return None,
|
||||
};
|
||||
Some(format!(
|
||||
"com.epicgames.launcher://apps/{inner}?action=launch&silent=true"
|
||||
))
|
||||
}
|
||||
// The `epic` launch mapping (`epic_launch_uri`) lives in `launch.rs` (WP1.1) — this module
|
||||
// enumerates, it does not launch.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -236,19 +220,4 @@ mod tests {
|
||||
assert!(epic_entry(&gone, &empty).is_none());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn epic_launch_uri_triple_bare_and_guard() {
|
||||
assert_eq!(
|
||||
epic_launch_uri("fn:abc:Fortnite").as_deref(),
|
||||
Some("com.epicgames.launcher://apps/fn%3Aabc%3AFortnite?action=launch&silent=true")
|
||||
);
|
||||
assert_eq!(
|
||||
epic_launch_uri("Fortnite").as_deref(),
|
||||
Some("com.epicgames.launcher://apps/Fortnite?action=launch&silent=true")
|
||||
);
|
||||
assert!(epic_launch_uri("bad part:x:y").is_none()); // a space → rejected
|
||||
assert!(epic_launch_uri("").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ fn gog_games() -> Vec<GameEntry> {
|
||||
let detect = DetectSpec::exe(&exe).with_dir(&path);
|
||||
out.push(GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
meta: GameMeta::pc(),
|
||||
id,
|
||||
store: "gog".into(),
|
||||
@@ -133,38 +134,13 @@ fn gog_play_task(install: &str, id: &str) -> Option<(String, String, String)> {
|
||||
))
|
||||
}
|
||||
|
||||
/// Build the spawn `(command line, working dir)` for a `gog` launch value (`exe \t args \t workdir`,
|
||||
/// all host-resolved from the operator's own disk). Direct exe — no shell, no Galaxy.
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn gog_spawn(value: &str) -> Option<(String, Option<PathBuf>)> {
|
||||
let mut parts = value.split('\t');
|
||||
let exe = parts.next().filter(|s| !s.is_empty())?;
|
||||
let args = parts.next().unwrap_or("");
|
||||
let workdir = parts.next().filter(|s| !s.is_empty()).map(PathBuf::from);
|
||||
let cmdline = if args.trim().is_empty() {
|
||||
format!("\"{exe}\"")
|
||||
} else {
|
||||
format!("\"{exe}\" {args}")
|
||||
};
|
||||
Some((cmdline, workdir))
|
||||
}
|
||||
// The `gog` launch mapping (`gog_spawn`) lives in `launch.rs` (WP1.1) — this module enumerates and
|
||||
// resolves the spawn triple off disk, but turning that triple into a command line is launch-side.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn gog_spawn_parses_and_guards() {
|
||||
let (cmd, wd) = gog_spawn("C:\\Games\\W3\\witcher3.exe\t--skip\tC:\\Games\\W3").unwrap();
|
||||
assert_eq!(cmd, "\"C:\\Games\\W3\\witcher3.exe\" --skip");
|
||||
assert_eq!(wd, Some(std::path::PathBuf::from("C:\\Games\\W3")));
|
||||
let (cmd2, wd2) = gog_spawn("C:\\g.exe").unwrap();
|
||||
assert_eq!(cmd2, "\"C:\\g.exe\"");
|
||||
assert!(wd2.is_none());
|
||||
assert!(gog_spawn("").is_none());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn gog_play_task_picks_primary_filetask() {
|
||||
|
||||
@@ -109,6 +109,7 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<Game
|
||||
};
|
||||
games.push(GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
meta: GameMeta::pc(),
|
||||
id: format!("heroic:{runner}:{app_name}"),
|
||||
store: "heroic".into(),
|
||||
@@ -128,48 +129,8 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<Game
|
||||
Ok(games)
|
||||
}
|
||||
|
||||
/// Map a `heroic` LaunchSpec value (`<runner>:<appName>`) to the Heroic launch command, run nested in
|
||||
/// gamescope. The host owns this mapping; the client only ever sends the id. CAVEAT: Heroic is a
|
||||
/// single-instance Electron app — in a fresh per-session gamescope it boots, launches the game (which
|
||||
/// renders into that gamescope) and stays hidden via `--no-gui`; but if a Heroic GUI is ALREADY
|
||||
/// running on the box, the spawned process forwards the URI and exits, which would tear the session
|
||||
/// down. The validated path is the fresh-session case; needs live confirmation on a box with Heroic.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn heroic_command(value: &str) -> Option<String> {
|
||||
let (runner, app) = value.split_once(':')?;
|
||||
if !matches!(runner, "legendary" | "gog" | "nile") {
|
||||
return None;
|
||||
}
|
||||
// appName charset (Epic alnum, GOG digits, Amazon alnum) — keep the URI a single safe token.
|
||||
if app.is_empty()
|
||||
|| !app
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let prefix = heroic_launch_prefix()?;
|
||||
// No quotes: gamescope spawns the app by `split_whitespace()`, and the URI has no spaces (appName
|
||||
// is validated above) so it stays a single argv token; `&` is fine (exec'd, not shell-parsed).
|
||||
Some(format!(
|
||||
"{prefix} --no-gui heroic://launch?appName={app}&runner={runner}"
|
||||
))
|
||||
}
|
||||
|
||||
/// How to invoke Heroic: the native `heroic` binary if on `PATH`, else the Flatpak app if its data
|
||||
/// root is present. `None` ⇒ Heroic not found, so no launch command.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn heroic_launch_prefix() -> Option<String> {
|
||||
let on_path = std::env::var_os("PATH")
|
||||
.is_some_and(|paths| std::env::split_paths(&paths).any(|d| d.join("heroic").is_file()));
|
||||
if on_path {
|
||||
return Some("heroic".into());
|
||||
}
|
||||
let flatpak = std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.is_some_and(|h| h.join(".var/app/com.heroicgameslauncher.hgl").is_dir());
|
||||
flatpak.then(|| "flatpak run com.heroicgameslauncher.hgl".into())
|
||||
}
|
||||
// The `heroic` launch mapping (`heroic_command` + its launcher-prefix probe) lives in `launch.rs`
|
||||
// (WP1.1) — this module enumerates, it does not launch.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
//! Title launch: resolve a library id / raw command into an executable command line (per-store +
|
||||
//! per-OS), and the gamescope-session launch helpers. Split out of the `library` facade (plan §W5).
|
||||
//!
|
||||
//! This module owns the **whole launch side** of the library: the `kind` vocabulary, its per-kind
|
||||
//! charset validators, and the per-OS resolvers. That split is deliberate and load-bearing — the
|
||||
//! scanner modules beside it do *enumeration only*, so they can be lifted out into library plugins
|
||||
//! without taking any launch logic with them (design/library-scanner-plugins.md D1: a client sends
|
||||
//! only an entry id and the host resolves the [`LaunchSpec`] it holds, which stays true whether the
|
||||
//! entry was enumerated in-process or reconciled in by a plugin).
|
||||
|
||||
use super::custom::valid_steam_appid;
|
||||
#[cfg(target_os = "linux")]
|
||||
use super::heroic::heroic_command;
|
||||
use super::*;
|
||||
#[cfg(windows)]
|
||||
use super::{epic::epic_launch_uri, gog::gog_spawn};
|
||||
|
||||
/// Everything a session needs about the title it is launching, resolved in **one** library scan:
|
||||
/// what to run, what to call it, and how to recognize it once it is running.
|
||||
@@ -84,6 +86,13 @@ fn command_for(spec: &LaunchSpec) -> Option<String> {
|
||||
// Heroic: `<runner>:<appName>` → the validated heroic://launch command (see heroic_command).
|
||||
#[cfg(target_os = "linux")]
|
||||
"heroic" => heroic_command(&spec.value),
|
||||
// A launcher entry (D4): open the Steam client itself, in Big Picture or on the desktop.
|
||||
// Nested in gamescope this is the SteamOS game-mode shape.
|
||||
"steam_ui" => match spec.value.as_str() {
|
||||
"bigpicture" => Some("steam -gamepadui".into()),
|
||||
"desktop" => Some("steam".into()),
|
||||
_ => None,
|
||||
},
|
||||
// Trusted: the command comes from the host's own custom store, never the client.
|
||||
"command" => (!spec.value.trim().is_empty()).then(|| spec.value.clone()),
|
||||
_ => None,
|
||||
@@ -138,6 +147,21 @@ fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option<std::path::Pa
|
||||
};
|
||||
Some((cmdline, None))
|
||||
}
|
||||
// A launcher entry (D4): open the Steam client's own UI. Same Steam.exe-then-explorer ladder
|
||||
// as `steam_appid`, and the URI is one of exactly two host-owned literals — nothing from the
|
||||
// entry is interpolated at all.
|
||||
"steam_ui" => {
|
||||
let uri = match spec.value.as_str() {
|
||||
"bigpicture" => "steam://open/bigpicture",
|
||||
"desktop" => "steam://open/main",
|
||||
_ => return None,
|
||||
};
|
||||
let cmdline = match steam_exe() {
|
||||
Some(exe) => format!("\"{}\" \"{uri}\"", exe.display()),
|
||||
None => format!("explorer.exe \"{uri}\""),
|
||||
};
|
||||
Some((cmdline, None))
|
||||
}
|
||||
// Epic: open the (host-built, validated) com.epicgames.launcher:// URI via explorer.exe — a
|
||||
// concrete EXE that resolves the registered protocol handler as the user; the URI is a single
|
||||
// argv element (no shell, no cmd /c). Same pattern as the steam explorer fallback.
|
||||
@@ -191,6 +215,119 @@ fn steam_exe() -> Option<std::path::PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- per-kind launch values (host-owned ABI)
|
||||
//
|
||||
// Each helper below turns a store's launch VALUE — the only part a scanner (or, after extraction, a
|
||||
// library plugin) supplies — into the URI/command line the host actually runs. They live here rather
|
||||
// than beside the enumeration that produces the value because the host keeps owning URI construction
|
||||
// and spawning no matter where the enumeration came from (D1). Every one of them is total and
|
||||
// validating: an unparseable or hostile value yields `None`, never a partially-interpolated command.
|
||||
|
||||
/// A digits-only Steam appid: the sole client-influenced part of a Steam launch, validated before it
|
||||
/// is interpolated into any command / URI (so a client-sent id can never carry shell or URI syntax).
|
||||
/// Cross-platform — used by the Linux shell mapping ([`command_for`]) and the Windows spawn mapping
|
||||
/// ([`windows_launch_for`]).
|
||||
///
|
||||
/// Also accepts the 64-bit non-Steam-shortcut game id ([`shortcut_gameid`]), which is likewise
|
||||
/// digits — the two share the `steam_appid` kind precisely because `rungameid` takes either.
|
||||
pub(crate) fn valid_steam_appid(value: &str) -> bool {
|
||||
!value.is_empty() && value.bytes().all(|b| b.is_ascii_digit())
|
||||
}
|
||||
|
||||
/// The 64-bit game id `steam://rungameid/` needs to launch a non-Steam shortcut: high dword = the
|
||||
/// 32-bit shortcut appid, low dword = the shortcut marker `0x0200_0000`. (Handing `rungameid` the
|
||||
/// bare 32-bit appid does not launch a shortcut — it must be this composed id.)
|
||||
pub(crate) fn shortcut_gameid(appid: u32) -> u64 {
|
||||
((appid as u64) << 32) | 0x0200_0000
|
||||
}
|
||||
|
||||
/// The `steam_ui` launch values (D4) — which Steam UI a launcher entry opens. A closed two-value
|
||||
/// enum, validated on the way IN (the reconcile payload) as well as on the way out, so an entry can
|
||||
/// never carry a third value that silently resolves to nothing at launch time.
|
||||
pub(crate) fn valid_steam_ui(value: &str) -> bool {
|
||||
matches!(value, "bigpicture" | "desktop")
|
||||
}
|
||||
|
||||
/// Map a `heroic` LaunchSpec value (`<runner>:<appName>`) to the Heroic launch command, run nested in
|
||||
/// gamescope. The host owns this mapping; the client only ever sends the id. CAVEAT: Heroic is a
|
||||
/// single-instance Electron app — in a fresh per-session gamescope it boots, launches the game (which
|
||||
/// renders into that gamescope) and stays hidden via `--no-gui`; but if a Heroic GUI is ALREADY
|
||||
/// running on the box, the spawned process forwards the URI and exits, which would tear the session
|
||||
/// down. The validated path is the fresh-session case; needs live confirmation on a box with Heroic.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn heroic_command(value: &str) -> Option<String> {
|
||||
let (runner, app) = value.split_once(':')?;
|
||||
if !matches!(runner, "legendary" | "gog" | "nile") {
|
||||
return None;
|
||||
}
|
||||
// appName charset (Epic alnum, GOG digits, Amazon alnum) — keep the URI a single safe token.
|
||||
if app.is_empty()
|
||||
|| !app
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let prefix = heroic_launch_prefix()?;
|
||||
// No quotes: gamescope spawns the app by `split_whitespace()`, and the URI has no spaces (appName
|
||||
// is validated above) so it stays a single argv token; `&` is fine (exec'd, not shell-parsed).
|
||||
Some(format!(
|
||||
"{prefix} --no-gui heroic://launch?appName={app}&runner={runner}"
|
||||
))
|
||||
}
|
||||
|
||||
/// How to invoke Heroic: the native `heroic` binary if on `PATH`, else the Flatpak app if its data
|
||||
/// root is present. `None` ⇒ Heroic not found, so no launch command.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn heroic_launch_prefix() -> Option<String> {
|
||||
let on_path = std::env::var_os("PATH")
|
||||
.is_some_and(|paths| std::env::split_paths(&paths).any(|d| d.join("heroic").is_file()));
|
||||
if on_path {
|
||||
return Some("heroic".into());
|
||||
}
|
||||
let flatpak = std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.is_some_and(|h| h.join(".var/app/com.heroicgameslauncher.hgl").is_dir());
|
||||
flatpak.then(|| "flatpak run com.heroicgameslauncher.hgl".into())
|
||||
}
|
||||
|
||||
/// Map an `epic` LaunchSpec value to the Epic Games Launcher URI. The value is either the full
|
||||
/// `<namespace>:<catalogItemId>:<appName>` triple (what the manifests carry) or a bare `appName`;
|
||||
/// every part is charset-checked so the URI stays one safe argv token.
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn epic_launch_uri(value: &str) -> Option<String> {
|
||||
let ok = |s: &str| {
|
||||
!s.is_empty()
|
||||
&& s.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
|
||||
};
|
||||
let inner = match value.split(':').collect::<Vec<_>>().as_slice() {
|
||||
[ns, cat, app] if ok(ns) && ok(cat) && ok(app) => format!("{ns}%3A{cat}%3A{app}"),
|
||||
[app] if ok(app) => (*app).to_string(),
|
||||
_ => return None,
|
||||
};
|
||||
Some(format!(
|
||||
"com.epicgames.launcher://apps/{inner}?action=launch&silent=true"
|
||||
))
|
||||
}
|
||||
|
||||
/// Map a `gog` LaunchSpec value — the tab-separated `exe \t args \t workdir` spawn triple the scanner
|
||||
/// derived from `goggame-<id>.info` — to a `(command line, working dir)`. GOG games are spawned
|
||||
/// directly (no Galaxy), so the exe is quoted and the arguments ride verbatim.
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn gog_spawn(value: &str) -> Option<(String, Option<PathBuf>)> {
|
||||
let mut parts = value.split('\t');
|
||||
let exe = parts.next().filter(|s| !s.is_empty())?;
|
||||
let args = parts.next().unwrap_or("");
|
||||
let workdir = parts.next().filter(|s| !s.is_empty()).map(PathBuf::from);
|
||||
let cmdline = if args.trim().is_empty() {
|
||||
format!("\"{exe}\"")
|
||||
} else {
|
||||
format!("\"{exe}\" {args}")
|
||||
};
|
||||
Some((cmdline, workdir))
|
||||
}
|
||||
|
||||
/// Launch a GameStream `apps.json` command (operator-typed, trusted — never client-set) into the
|
||||
/// interactive Windows user session, AFTER capture is up (the host is SYSTEM). The Linux paths go
|
||||
/// through the compositor-aware [`launch_session_command`] instead.
|
||||
@@ -360,6 +497,101 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `steam_ui` launcher kind (D4): a closed two-value enum, mapped to the Steam client's own
|
||||
/// UI on each OS. Nothing from the entry is interpolated — the value only SELECTS between two
|
||||
/// host-owned literals — so there is no injection surface at all here.
|
||||
#[test]
|
||||
fn steam_ui_is_a_closed_two_value_enum() {
|
||||
assert!(valid_steam_ui("bigpicture"));
|
||||
assert!(valid_steam_ui("desktop"));
|
||||
assert!(!valid_steam_ui("gamepadui"));
|
||||
assert!(!valid_steam_ui(""));
|
||||
assert!(!valid_steam_ui("bigpicture; rm -rf ~"));
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn steam_ui_resolves_to_the_client_ui_on_linux() {
|
||||
let ui = |v: &str| {
|
||||
command_for(&LaunchSpec {
|
||||
kind: "steam_ui".into(),
|
||||
value: v.into(),
|
||||
})
|
||||
};
|
||||
// Big Picture is the SteamOS game-mode shape; nested in gamescope this is what `--steam`
|
||||
// integration is built around.
|
||||
assert_eq!(ui("bigpicture").as_deref(), Some("steam -gamepadui"));
|
||||
assert_eq!(ui("desktop").as_deref(), Some("steam"));
|
||||
assert_eq!(ui("nonsense"), None);
|
||||
assert_eq!(ui(""), None);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn steam_ui_resolves_to_the_client_ui_on_windows() {
|
||||
let ui = |v: &str| {
|
||||
windows_launch_for(&LaunchSpec {
|
||||
kind: "steam_ui".into(),
|
||||
value: v.into(),
|
||||
})
|
||||
};
|
||||
let (bp, wd) = ui("bigpicture").expect("bigpicture recipe");
|
||||
assert!(bp.contains("steam://open/bigpicture"), "line was {bp:?}");
|
||||
assert!(wd.is_none());
|
||||
let (desk, _) = ui("desktop").expect("desktop recipe");
|
||||
assert!(desk.contains("steam://open/main"), "line was {desk:?}");
|
||||
assert!(ui("nonsense").is_none());
|
||||
assert!(ui("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steam_appid_validation_accepts_appids_and_shortcut_gameids() {
|
||||
assert!(valid_steam_appid("570"));
|
||||
// The 64-bit shortcut game id shares the `steam_appid` kind — `rungameid` takes either.
|
||||
assert!(valid_steam_appid(
|
||||
&shortcut_gameid(2_456_789_012).to_string()
|
||||
));
|
||||
assert!(!valid_steam_appid(""));
|
||||
assert!(!valid_steam_appid("570; rm -rf ~"));
|
||||
assert!(!valid_steam_appid("-1"));
|
||||
}
|
||||
|
||||
/// Moved here with `shortcut_gameid` (WP1.1): the composed id is launch vocabulary, not
|
||||
/// enumeration — the scanner only supplies the 32-bit appid it read out of `shortcuts.vdf`.
|
||||
#[test]
|
||||
fn shortcut_gameid_composes_appid_and_marker() {
|
||||
let id = shortcut_gameid(0x8000_0000);
|
||||
assert_eq!(id >> 32, 0x8000_0000, "high dword is the shortcut appid");
|
||||
assert_eq!(id & 0xFFFF_FFFF, 0x0200_0000, "low dword is the marker");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn epic_launch_uri_triple_bare_and_guard() {
|
||||
assert_eq!(
|
||||
epic_launch_uri("fn:abc:Fortnite").as_deref(),
|
||||
Some("com.epicgames.launcher://apps/fn%3Aabc%3AFortnite?action=launch&silent=true")
|
||||
);
|
||||
assert_eq!(
|
||||
epic_launch_uri("Fortnite").as_deref(),
|
||||
Some("com.epicgames.launcher://apps/Fortnite?action=launch&silent=true")
|
||||
);
|
||||
assert!(epic_launch_uri("bad part:x:y").is_none()); // a space → rejected
|
||||
assert!(epic_launch_uri("").is_none());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn gog_spawn_parses_and_guards() {
|
||||
let (cmd, wd) = gog_spawn("C:\\Games\\W3\\witcher3.exe\t--skip\tC:\\Games\\W3").unwrap();
|
||||
assert_eq!(cmd, "\"C:\\Games\\W3\\witcher3.exe\" --skip");
|
||||
assert_eq!(wd, Some(std::path::PathBuf::from("C:\\Games\\W3")));
|
||||
let (cmd2, wd2) = gog_spawn("C:\\g.exe").unwrap();
|
||||
assert_eq!(cmd2, "\"C:\\g.exe\"");
|
||||
assert!(wd2.is_none());
|
||||
assert!(gog_spawn("").is_none());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_launch_for_maps_and_guards() {
|
||||
|
||||
@@ -84,6 +84,7 @@ fn lutris_games(db: &Path) -> rusqlite::Result<Vec<GameEntry>> {
|
||||
for (id, slug, name, directory) in rows.flatten() {
|
||||
games.push(GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
meta: GameMeta::pc(),
|
||||
id: format!("lutris:{id}"),
|
||||
store: "lutris".into(),
|
||||
|
||||
@@ -12,19 +12,41 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
/// One installed-store scanner this host build supports, with its enable state — the unit the
|
||||
/// console renders a toggle for. The list is platform-gated at compile time (the scanners are),
|
||||
/// so the console never shows a toggle that cannot do anything on this host.
|
||||
/// One **game source** on this host, with its enable state — the unit the console renders a toggle
|
||||
/// for. A source is either a scanner compiled into this build or a plugin that reconciles entries in
|
||||
/// (WP2.6); the console treats them identically, which is what makes the extraction invisible.
|
||||
#[derive(Clone, Debug, Serialize, ToSchema)]
|
||||
pub struct ScannerInfo {
|
||||
/// Stable scanner id — the same string the scanner's entries carry in their `store` field.
|
||||
/// Stable source id — the same string this source's entries carry in their `store` field. For a
|
||||
/// plugin source it is also its provider id and its store claim: one string, by construction, so
|
||||
/// a user's disabled state survives a built-in scanner being replaced by its plugin.
|
||||
#[schema(example = "steam")]
|
||||
pub id: String,
|
||||
/// Human-facing name for the console toggle.
|
||||
#[schema(example = "Steam")]
|
||||
pub label: String,
|
||||
/// Whether this host runs the scanner (default true).
|
||||
/// Whether this host runs the source (default true).
|
||||
pub enabled: bool,
|
||||
/// Where the source comes from: `builtin` (a scanner in this host build) or `plugin`.
|
||||
#[schema(example = "builtin")]
|
||||
pub origin: SourceOrigin,
|
||||
/// The provider id backing a `plugin` source — absent for a built-in scanner.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub provider: Option<String>,
|
||||
/// How many entries this source currently contributes. `None` for a built-in scanner, whose
|
||||
/// count would mean walking every launcher's files just to render a toggle.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub entries: Option<usize>,
|
||||
}
|
||||
|
||||
/// Where a [`ScannerInfo`] comes from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, ToSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SourceOrigin {
|
||||
/// A scanner compiled into this host build.
|
||||
Builtin,
|
||||
/// A plugin reconciling entries over the provider API.
|
||||
Plugin,
|
||||
}
|
||||
|
||||
/// The scanners compiled into THIS host build: (id, label). Steam is cross-platform; the rest are
|
||||
@@ -87,26 +109,93 @@ pub(crate) fn disabled_scanners() -> HashSet<String> {
|
||||
load_settings().disabled.into_iter().collect()
|
||||
}
|
||||
|
||||
/// The scanners available on this platform with their current enable state, in the fixed
|
||||
/// definition order (stable for the console).
|
||||
/// Every game source on this host with its current enable state (WP2.6):
|
||||
///
|
||||
/// 1. the built-in scanners this build compiled in, **minus** any whose store a plugin has claimed
|
||||
/// (the plugin replaces it, so showing both would offer two toggles for one thing);
|
||||
/// 2. the claimed stores themselves, as plugin sources;
|
||||
/// 3. any other provider that has entries — the *emergent* case (rom-manager, playnite), which has
|
||||
/// never had a toggle before and gets one for free here.
|
||||
///
|
||||
/// Built-ins keep their fixed definition order (stable for the console); plugin sources follow,
|
||||
/// sorted by id.
|
||||
pub fn list_scanners() -> Vec<ScannerInfo> {
|
||||
let off = disabled_scanners();
|
||||
scanner_defs()
|
||||
let claims = crate::library::claimed_stores();
|
||||
let entries = crate::library::load_custom();
|
||||
|
||||
let mut out: Vec<ScannerInfo> = scanner_defs()
|
||||
.into_iter()
|
||||
.filter(|(id, _)| !claims.contains_key(*id))
|
||||
.map(|(id, label)| ScannerInfo {
|
||||
id: id.to_string(),
|
||||
label: label.to_string(),
|
||||
enabled: !off.contains(id),
|
||||
origin: SourceOrigin::Builtin,
|
||||
provider: None,
|
||||
entries: None,
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
|
||||
// A claimed store shows under the SCANNER's label where we know one, so the row a user has been
|
||||
// toggling for releases doesn't rename itself out from under them mid-migration.
|
||||
let label_for = |id: &str| {
|
||||
scanner_defs()
|
||||
.into_iter()
|
||||
.find(|(sid, _)| *sid == id)
|
||||
.map(|(_, label)| label.to_string())
|
||||
.unwrap_or_else(|| id.to_string())
|
||||
};
|
||||
|
||||
let mut plugin_ids: Vec<(String, String)> = claims
|
||||
.iter()
|
||||
.map(|(store, provider)| (store.clone(), provider.clone()))
|
||||
.collect();
|
||||
// Emergent providers: any provider with entries that isn't already listed via a claim.
|
||||
for e in &entries {
|
||||
let Some(provider) = e.provider.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
if e.store.is_none() && !plugin_ids.iter().any(|(id, _)| id == provider) {
|
||||
plugin_ids.push((provider.to_string(), provider.to_string()));
|
||||
}
|
||||
}
|
||||
plugin_ids.sort();
|
||||
plugin_ids.dedup();
|
||||
|
||||
out.extend(plugin_ids.into_iter().map(|(id, provider)| {
|
||||
let count = entries
|
||||
.iter()
|
||||
.filter(|e| crate::library::source_id_for(e) == Some(id.as_str()))
|
||||
.count();
|
||||
ScannerInfo {
|
||||
label: label_for(&id),
|
||||
enabled: !off.contains(&id),
|
||||
origin: SourceOrigin::Plugin,
|
||||
provider: Some(provider),
|
||||
entries: Some(count),
|
||||
id,
|
||||
}
|
||||
}));
|
||||
out
|
||||
}
|
||||
|
||||
/// Enable/disable one scanner. `None` when `id` names no scanner available on this platform (the
|
||||
/// mgmt layer maps that to 404 — the console only ever sees this host's own list). Persists and
|
||||
/// emits `library.changed` (source = the scanner id) only when the state actually changed, so a
|
||||
/// repeated PUT is a cheap no-op.
|
||||
/// Whether `id` names a source that exists on this host right now — a compiled-in scanner, a claimed
|
||||
/// store, or a provider with entries. The toggle accepts exactly these (an unknown id still 404s).
|
||||
fn is_known_source(id: &str) -> bool {
|
||||
scanner_defs().iter().any(|(sid, _)| *sid == id) || list_scanners().iter().any(|s| s.id == id)
|
||||
}
|
||||
|
||||
/// Enable/disable one source. `None` when `id` names no source on this host (the mgmt layer maps
|
||||
/// that to 404 — the console only ever sees this host's own list). Persists and emits
|
||||
/// `library.changed` (source = the id) only when the state actually changed, so a repeated PUT is a
|
||||
/// cheap no-op.
|
||||
///
|
||||
/// The **same** `library-scanners.json` disabled-set backs built-in and plugin sources alike, and
|
||||
/// the ids match by construction — so a user who disabled `steam` before the migration still has it
|
||||
/// disabled after the steam plugin claims the store, with nothing to carry over.
|
||||
pub fn set_scanner_enabled(id: &str, enabled: bool) -> Result<Option<Vec<ScannerInfo>>> {
|
||||
if !scanner_defs().iter().any(|(sid, _)| *sid == id) {
|
||||
if !is_known_source(id) {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut settings = load_settings();
|
||||
|
||||
@@ -29,6 +29,7 @@ impl LibraryProvider for SteamProvider {
|
||||
.filter(|app| !is_steam_tool(app.appid, &app.name))
|
||||
.map(|app| GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
meta: GameMeta::pc(),
|
||||
id: format!("steam:{}", app.appid),
|
||||
store: "steam".into(),
|
||||
@@ -383,6 +384,7 @@ fn shortcut_entry(sc: Shortcut) -> Option<GameEntry> {
|
||||
}
|
||||
Some(GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
meta: GameMeta::pc(),
|
||||
id: format!("steam:{}", sc.appid),
|
||||
store: "steam".into(),
|
||||
@@ -426,12 +428,8 @@ fn shortcuts_files() -> Vec<PathBuf> {
|
||||
files
|
||||
}
|
||||
|
||||
/// The 64-bit game id `steam://rungameid/` needs to launch a non-Steam shortcut: high dword = the
|
||||
/// 32-bit shortcut appid, low dword = the shortcut marker `0x0200_0000`. (Handing `rungameid` the
|
||||
/// bare 32-bit appid does not launch a shortcut — it must be this composed id.)
|
||||
fn shortcut_gameid(appid: u32) -> u64 {
|
||||
((appid as u64) << 32) | 0x0200_0000
|
||||
}
|
||||
// `shortcut_gameid` (the 64-bit `rungameid` composition) moved to `launch.rs` (WP1.1) — it is launch
|
||||
// vocabulary; this module only reads the 32-bit appid out of `shortcuts.vdf`.
|
||||
|
||||
/// The 32-bit appid Steam derives for a shortcut from its target+name — `crc32(exe + name)` with the
|
||||
/// high bit set. Only used when `shortcuts.vdf` omits the stored `appid` (very old Steam); modern
|
||||
@@ -762,12 +760,7 @@ mod tests {
|
||||
assert!(launch.value.bytes().all(|b| b.is_ascii_digit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcut_gameid_composes_appid_and_marker() {
|
||||
let id = shortcut_gameid(0x8000_0000);
|
||||
assert_eq!(id >> 32, 0x8000_0000); // high dword is the appid
|
||||
assert_eq!(id & 0xFFFF_FFFF, 0x0200_0000); // low dword is the shortcut marker
|
||||
}
|
||||
// `shortcut_gameid_composes_appid_and_marker` moved with the function to `launch.rs` (WP1.1).
|
||||
|
||||
#[test]
|
||||
fn crc32_matches_the_known_check_value_and_derives_a_high_bit_appid() {
|
||||
|
||||
@@ -70,6 +70,7 @@ fn xbox_games() -> Vec<GameEntry> {
|
||||
let art = cached_art(&id).unwrap_or_default();
|
||||
games.push(GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
meta: GameMeta::pc(),
|
||||
id,
|
||||
store: "xbox".into(),
|
||||
|
||||
@@ -618,6 +618,10 @@ fn real_main() -> Result<()> {
|
||||
// hold it, driving the real *WindowsManager end to end. `--index N`, `--seconds N`.
|
||||
#[cfg(target_os = "windows")]
|
||||
Some("dualsense-windows-test") => devtest::dualsense_windows_test(&args),
|
||||
// Windows: pad-audio endpoint provisioning (`ensure`/`status`) + the pnputil removal
|
||||
// escape hatch (`remove`). `--index N` selects the pad slot (default 0).
|
||||
#[cfg(target_os = "windows")]
|
||||
Some("pad-endpoint") => devtest::pad_endpoint(&args),
|
||||
// Capture→encode→file pipeline spike (dev tool).
|
||||
Some("spike") => spike::run(parse_spike(&args[1..])?),
|
||||
// Native punktfunk/1 host (QUIC control plane + UDP data plane).
|
||||
|
||||
@@ -185,6 +185,11 @@ pub(crate) async fn update_custom_game(
|
||||
StatusCode::CONFLICT,
|
||||
&format!("entry is owned by provider `{p}` — update it through its reconcile"),
|
||||
),
|
||||
// Store claims are a reconcile-only concern — the manual CRUD never requests one.
|
||||
Ok(MutateOutcome::StoreClaimed { .. }) => api_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"unexpected claim outcome",
|
||||
),
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -216,6 +221,11 @@ pub(crate) async fn delete_custom_game(Path(id): Path<String>) -> Response {
|
||||
"entry is owned by provider `{p}` — remove it there, or DELETE the provider set"
|
||||
),
|
||||
),
|
||||
// Store claims are a reconcile-only concern — the manual CRUD never requests one.
|
||||
Ok(MutateOutcome::StoreClaimed { .. }) => api_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"unexpected claim outcome",
|
||||
),
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -227,6 +237,13 @@ pub(crate) struct ProviderRemoved {
|
||||
removed: usize,
|
||||
}
|
||||
|
||||
/// Query for `reconcileProviderEntries` — the optional store claim (D2).
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct ReconcileQuery {
|
||||
/// Claim this store for the provider, so its entries take the store's own identity.
|
||||
store: Option<String>,
|
||||
}
|
||||
|
||||
/// Replace a provider's library entries (declarative reconcile)
|
||||
///
|
||||
/// Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the
|
||||
@@ -234,39 +251,67 @@ pub(crate) struct ProviderRemoved {
|
||||
/// surviving title's host id stable across reconciles, drops orphans, and never touches manual
|
||||
/// entries or other providers'. An empty array removes everything the provider owns. Emits
|
||||
/// `library.changed` with the provider as `source`.
|
||||
///
|
||||
/// `?store=` additionally **claims** that store for the provider: its entries then surface with
|
||||
/// deterministic `<store>:<external_id>` ids and the store's own badge, instead of opaque
|
||||
/// `custom:<id>` ones — which is what lets a library plugin reproduce the entries an in-host scanner
|
||||
/// used to produce, right down to the GameStream app ids and client-side art caches. One provider
|
||||
/// per store; a second claimant gets 409. While a claim is held the matching built-in scanner is
|
||||
/// suppressed, so the two never double-list. The claim is released by `DELETE`, not by an empty
|
||||
/// reconcile (a store can legitimately have zero installed titles).
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/library/provider/{provider}",
|
||||
tag = "library",
|
||||
operation_id = "reconcileProviderEntries",
|
||||
params(("provider" = String, Path, description = "The provider id ([a-z0-9._-], `manual` reserved)")),
|
||||
params(
|
||||
("provider" = String, Path, description = "The provider id ([a-z0-9._-], `manual` reserved)"),
|
||||
("store" = Option<String>, Query, description = "Claim this store for the provider ([a-z0-9_-], `custom`/`manual` reserved)"),
|
||||
),
|
||||
request_body = Vec<crate::library::ProviderEntryInput>,
|
||||
responses(
|
||||
(status = OK, description = "The provider's resulting entries (host ids assigned/kept)", body = [crate::library::CustomEntry]),
|
||||
(status = BAD_REQUEST, description = "Invalid provider id or payload", body = ApiError),
|
||||
(status = BAD_REQUEST, description = "Invalid provider id, store id, or payload", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
(status = CONFLICT, description = "That store is already claimed by another provider", body = ApiError),
|
||||
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn reconcile_provider_entries(
|
||||
Path(provider): Path<String>,
|
||||
Query(q): Query<ReconcileQuery>,
|
||||
ApiJson(inputs): ApiJson<Vec<crate::library::ProviderEntryInput>>,
|
||||
) -> Response {
|
||||
if let Err(e) = crate::library::validate_provider_name(&provider) {
|
||||
return api_error(StatusCode::BAD_REQUEST, &e);
|
||||
}
|
||||
let store = q.store.filter(|s| !s.is_empty());
|
||||
if let Some(store) = &store {
|
||||
if let Err(e) = crate::library::validate_store_claim(store) {
|
||||
return api_error(StatusCode::BAD_REQUEST, &e);
|
||||
}
|
||||
}
|
||||
if let Err(e) = crate::library::validate_provider_payload(&inputs) {
|
||||
return api_error(StatusCode::BAD_REQUEST, &e);
|
||||
}
|
||||
match crate::library::reconcile_provider(&provider, inputs) {
|
||||
Ok(entries) => {
|
||||
match crate::library::reconcile_provider(&provider, store.as_deref(), inputs) {
|
||||
Ok(crate::library::MutateOutcome::Done(entries)) => {
|
||||
tracing::info!(
|
||||
provider,
|
||||
store = store.as_deref().unwrap_or("-"),
|
||||
count = entries.len(),
|
||||
"library provider reconciled"
|
||||
);
|
||||
Json(entries).into_response()
|
||||
}
|
||||
Ok(crate::library::MutateOutcome::StoreClaimed { store, provider }) => api_error(
|
||||
StatusCode::CONFLICT,
|
||||
&format!("store `{store}` is already claimed by provider `{provider}`"),
|
||||
),
|
||||
Ok(_) => api_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"unexpected reconcile outcome",
|
||||
),
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -306,11 +351,12 @@ pub(crate) async fn delete_provider_entries(Path(provider): Path<String>) -> Res
|
||||
/// Fetch one cover-art image for a library entry
|
||||
///
|
||||
/// Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams
|
||||
/// the image bytes. For a Steam title, the host's own local Steam cache is tried first (exact —
|
||||
/// it's what the user's Steam client already shows for it), the public Steam CDN's flat URL
|
||||
/// convention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host
|
||||
/// can't predict, in which case this 404s and the client falls through to its next art candidate).
|
||||
/// Only Steam ids are backed today; any other store 404s.
|
||||
/// the image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries,
|
||||
/// and a library plugin's claimed-store entries) serves its local art file. A Steam title falls back
|
||||
/// to the in-host scanner's resolver: the host's own local Steam cache first (exact — it's what the
|
||||
/// user's Steam client already shows for it), the public Steam CDN's flat URL convention second
|
||||
/// (newer titles' CDN assets can live at a per-asset-hash path the host can't predict, in which case
|
||||
/// this 404s and the client falls through to its next art candidate).
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/library/art/{id}/{kind}",
|
||||
@@ -330,7 +376,20 @@ pub(crate) async fn get_library_art(Path((id, kind)): Path<(String, String)>) ->
|
||||
let Some(kind) = crate::library::ArtKind::parse(&kind) else {
|
||||
return api_error(StatusCode::NOT_FOUND, "unknown art kind");
|
||||
};
|
||||
// Steam: CDN / local-cache proxy (id `steam:<appid>`).
|
||||
// `library.json` FIRST, for ANY id (WP1.2). Stored entries — manual, provider-synced, and (once
|
||||
// store claims land) a scanner plugin's `steam:570` — all serve their local art file from here,
|
||||
// so the proxy never has to know which store an id belongs to. Steam ids aren't stored today, so
|
||||
// this misses and the legacy branch below still answers them.
|
||||
let stored = {
|
||||
let id = id.clone();
|
||||
tokio::task::spawn_blocking(move || crate::library::library_local_art_bytes(&id, kind))
|
||||
.await
|
||||
};
|
||||
if let Ok(Some((bytes, ctype))) = stored {
|
||||
return ([(header::CONTENT_TYPE, ctype)], bytes).into_response();
|
||||
}
|
||||
// Legacy in-host Steam scanner: local Steam cache, then the flat CDN URL. Retired with the
|
||||
// scanner itself once the steam plugin claims the store (M6).
|
||||
if let Some(appid) = id
|
||||
.strip_prefix("steam:")
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
@@ -344,17 +403,5 @@ pub(crate) async fn get_library_art(Path((id, kind)): Path<(String, String)>) ->
|
||||
_ => api_error(StatusCode::NOT_FOUND, "no art of that kind for this title"),
|
||||
};
|
||||
}
|
||||
// Custom/provider entry (id `custom:<id>`): serve its stored LOCAL art file — e.g. the Playnite
|
||||
// plugin's covers, reconciled as on-host paths rather than inlined bytes.
|
||||
if let Some(cid) = id.strip_prefix("custom:").map(str::to_owned) {
|
||||
return match tokio::task::spawn_blocking(move || {
|
||||
crate::library::custom_local_art_bytes(&cid, kind)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Some((bytes, ctype))) => ([(header::CONTENT_TYPE, ctype)], bytes).into_response(),
|
||||
_ => api_error(StatusCode::NOT_FOUND, "no art of that kind for this title"),
|
||||
};
|
||||
}
|
||||
api_error(StatusCode::NOT_FOUND, "no art proxy for this store")
|
||||
api_error(StatusCode::NOT_FOUND, "no art of that kind for this title")
|
||||
}
|
||||
|
||||
@@ -64,6 +64,14 @@ pub(crate) struct PluginRegistration {
|
||||
/// entry only (e.g. a future runner-management listing) and grows no nav entry.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ui: Option<PluginUi>,
|
||||
/// What KIND of plugin this is (`^[a-z][a-z0-9-]{0,31}$`), top-level rather than under `ui`
|
||||
/// because it describes the plugin, not its surface. The console knows one value today —
|
||||
/// `library` — which it filters **out of the nav**: six installed scanner plugins would otherwise
|
||||
/// flood the sidebar, and their real entry point is the Game sources surface (design D5). A
|
||||
/// library plugin that genuinely wants its own page (rom-manager, which is much more than a
|
||||
/// scanner) simply omits the category.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub category: Option<String>,
|
||||
}
|
||||
|
||||
/// One log line produced by the runner or a plugin inside it (`POST /plugins/logs`).
|
||||
@@ -104,6 +112,9 @@ pub(crate) struct PluginSummary {
|
||||
pub version: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ui: Option<PluginUiPublic>,
|
||||
/// The plugin's kind — see [`PluginRegistration::category`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub category: Option<String>,
|
||||
}
|
||||
|
||||
/// `GET /plugins/{id}/ui-credential` — the console proxy's server-side lookup (bearer + loopback).
|
||||
@@ -129,14 +140,19 @@ struct Stored {
|
||||
title: String,
|
||||
version: Option<String>,
|
||||
ui: Option<StoredUi>,
|
||||
category: Option<String>,
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
impl Stored {
|
||||
/// Do the operator-visible fields match (ignoring the lease clock)? A pure lease renewal leaves
|
||||
/// these unchanged and emits no event; a restart (new secret) or a re-scan (new title/icon) does.
|
||||
fn public_eq(&self, title: &str, version: &Option<String>, ui: &Option<StoredUi>) -> bool {
|
||||
self.title == title && self.version == *version && self.ui == *ui
|
||||
/// these unchanged and emits no event; a restart (new secret) or a re-scan (new title/icon/
|
||||
/// category) does.
|
||||
fn public_eq(&self, v: &Valid) -> bool {
|
||||
self.title == v.title
|
||||
&& self.version == v.version
|
||||
&& self.ui == v.ui
|
||||
&& self.category == v.category
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,6 +166,7 @@ struct Valid {
|
||||
title: String,
|
||||
version: Option<String>,
|
||||
ui: Option<StoredUi>,
|
||||
category: Option<String>,
|
||||
}
|
||||
|
||||
impl PluginRegistry {
|
||||
@@ -167,7 +184,7 @@ impl PluginRegistry {
|
||||
let mut map = self.inner.write().unwrap_or_else(|e| e.into_inner());
|
||||
let changed = match map.get(id) {
|
||||
// An *expired* prior entry counts as a change (it had stopped listing).
|
||||
Some(prev) => !prev.is_live() || !prev.public_eq(&v.title, &v.version, &v.ui),
|
||||
Some(prev) => !prev.is_live() || !prev.public_eq(&v),
|
||||
None => true,
|
||||
};
|
||||
map.insert(
|
||||
@@ -176,6 +193,7 @@ impl PluginRegistry {
|
||||
title: v.title,
|
||||
version: v.version,
|
||||
ui: v.ui,
|
||||
category: v.category,
|
||||
expires_at,
|
||||
},
|
||||
);
|
||||
@@ -207,6 +225,7 @@ impl PluginRegistry {
|
||||
port: u.port,
|
||||
icon: u.icon.clone(),
|
||||
}),
|
||||
category: s.category.clone(),
|
||||
})
|
||||
.collect();
|
||||
live.sort_by(|a, b| a.title.cmp(&b.title).then_with(|| a.id.cmp(&b.id)));
|
||||
@@ -333,7 +352,31 @@ fn validate(reg: PluginRegistration) -> Result<Valid, String> {
|
||||
Some(u) => Some(validate_ui(u)?),
|
||||
None => None,
|
||||
};
|
||||
Ok(Valid { title, version, ui })
|
||||
// Categories are grouping keys the console switches on — a closed charset, but deliberately not
|
||||
// a closed VOCABULARY: an unknown category is stored and simply matches no console rule, so a
|
||||
// newer plugin registering against an older host degrades to "shows in the nav", never to a
|
||||
// failed registration.
|
||||
let category = match reg.category {
|
||||
Some(c) => {
|
||||
let ok = (1..=32).contains(&c.len())
|
||||
&& c.starts_with(|ch: char| ch.is_ascii_lowercase())
|
||||
&& c.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
|
||||
if !ok {
|
||||
return Err(
|
||||
"category must be 1–32 chars of [a-z0-9-], starting with a letter".into(),
|
||||
);
|
||||
}
|
||||
Some(c)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
Ok(Valid {
|
||||
title,
|
||||
version,
|
||||
ui,
|
||||
category,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_ui(u: PluginUi) -> Result<StoredUi, String> {
|
||||
@@ -558,6 +601,7 @@ mod tests {
|
||||
secret: secret.into(),
|
||||
icon: Some("gamepad-2".into()),
|
||||
}),
|
||||
category: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -584,10 +628,30 @@ mod tests {
|
||||
title: "Ro\u{7}m\n".into(),
|
||||
version: None,
|
||||
ui: None,
|
||||
category: None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(v.title, "Rom");
|
||||
// privileged port rejected
|
||||
// Category charset (WP2.7): the console's one known value passes; the shapes that would
|
||||
// break a grouping key don't. An UNKNOWN-but-well-formed category is accepted on purpose —
|
||||
// a newer plugin must not fail to register against an older host.
|
||||
let lib = |c: &str| PluginRegistration {
|
||||
title: "X".into(),
|
||||
version: None,
|
||||
ui: None,
|
||||
category: Some(c.into()),
|
||||
};
|
||||
assert_eq!(
|
||||
validate(lib("library")).unwrap().category.as_deref(),
|
||||
Some("library")
|
||||
);
|
||||
assert!(validate(lib("some-future-kind")).is_ok());
|
||||
assert!(validate(lib("")).is_err());
|
||||
assert!(validate(lib("Library")).is_err()); // no uppercase
|
||||
assert!(validate(lib("9lives")).is_err()); // must start with a letter
|
||||
assert!(validate(lib("lib_rary")).is_err()); // no underscore
|
||||
assert!(validate(lib(&"a".repeat(33))).is_err()); // too long
|
||||
// privileged port rejected
|
||||
assert!(validate(reg("x", 80, SECRET)).is_err());
|
||||
// short secret rejected
|
||||
assert!(validate(reg("x", 49321, "tooshort")).is_err());
|
||||
@@ -641,6 +705,7 @@ mod tests {
|
||||
title: "Headless".into(),
|
||||
version: None,
|
||||
ui: None,
|
||||
category: None,
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
@@ -108,6 +108,14 @@ pub(crate) struct CatalogEntry {
|
||||
/// A revocation covering the catalogued version — do not offer this without shouting.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub blocked: Option<String>,
|
||||
/// What kind of plugin this is — the console filters Browse by these, and the Game sources
|
||||
/// surface's "Add a source" rail shows exactly the `library` ones (design D5/D6).
|
||||
pub categories: Vec<String>,
|
||||
/// Whether the launcher this plugin scans looks **installed on this host** (design D8), from the
|
||||
/// index's own existence probes. `null` = the entry declares no probes for this platform, which
|
||||
/// the console renders as "unknown" rather than "not installed".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub detected: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
@@ -277,6 +285,8 @@ fn build_catalog(force: bool) -> CatalogResponse {
|
||||
update_available: installed_version.as_deref().is_some_and(|v| v != e.version),
|
||||
installed_version,
|
||||
blocked: store::advisory_for(&e.pkg, Some(&e.version)).map(|a| a.reason),
|
||||
categories: e.categories.clone(),
|
||||
detected: e.detected(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,12 @@ use pairing::pair_ceremony;
|
||||
mod audio;
|
||||
use audio::audio_thread;
|
||||
|
||||
/// Per-pad DualSense audio (the 0xD1 plane): loopback capture of the pre-provisioned pad
|
||||
/// endpoints → per-kind silence gate → stereo Opus → `PAD_AUDIO_MAGIC` datagrams. The input
|
||||
/// thread spawns/reaps one streamer per arriving pad (`input`); the Welcome advertises the cap
|
||||
/// via `pad_audio::host_cap` (`handshake`).
|
||||
mod pad_audio;
|
||||
|
||||
/// The native input plane (plan §W1); the session setup spawns `input_thread` and feeds it a
|
||||
/// channel of `ClientInput`. The `Pads` router + rumble live there too.
|
||||
mod input;
|
||||
@@ -345,6 +351,14 @@ pub(crate) async fn serve(
|
||||
// binds its capture device) and self-heals when the backend dies (PipeWire restart, Windows
|
||||
// endpoint churn).
|
||||
let mic_service = crate::audio::MicPump::start();
|
||||
// Windows, env-gated (PUNKTFUNK_PAD_AUDIO / _SLOTS): pre-provision the per-pad "DualSense
|
||||
// speaker" render endpoints once per host lifetime — idempotent devnode + stamp work on a
|
||||
// dedicated COM thread, results published for sessions to query by pad index
|
||||
// (crate::audio::pad_endpoint::endpoint_for). If any stamp is stored-but-not-served, the
|
||||
// worker performs ONE AudioEndpointBuilder+Audiosrv restart now, before any session exists.
|
||||
// Failures log once and leave the feature off: pads still work, just without pad audio.
|
||||
#[cfg(target_os = "windows")]
|
||||
crate::audio::pad_endpoint::provision_at_startup();
|
||||
// Host-lifetime worker that fires debounced TV-session restores (the managed gamescope path
|
||||
// restores the box's autologin gaming session on idle, not per-disconnect — see
|
||||
// `vdisplay::restore_managed_session`). Held for serve()'s lifetime; dropping it stops it.
|
||||
@@ -1203,9 +1217,14 @@ async fn serve_session(
|
||||
let input_handle = {
|
||||
let conn = conn.clone();
|
||||
let gamepad = welcome.gamepad;
|
||||
// Pad audio (0xD1) negotiated: the Welcome advertised the cap (Windows + provisioned
|
||||
// endpoints + the client asked — handshake reads `pad_audio::host_cap`). Read back off
|
||||
// the Welcome rather than recomputed, so the input thread's spawns cannot disagree
|
||||
// with what the client was told.
|
||||
let pad_audio_on = welcome.host_caps & punktfunk_core::quic::HOST_CAP_PAD_AUDIO != 0;
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk1-input".into())
|
||||
.spawn(move || input_thread(input_rx, conn, inj_tx, gamepad))
|
||||
.spawn(move || input_thread(input_rx, conn, inj_tx, gamepad, pad_audio_on))
|
||||
.context("spawn input thread")?
|
||||
};
|
||||
// One reader for ALL client→host datagrams, demuxed by magic byte (two read_datagram loops
|
||||
|
||||
@@ -640,6 +640,16 @@ pub(super) async fn negotiate(
|
||||
punktfunk_core::quic::HOST_CAP_AUDIO_RED
|
||||
} else {
|
||||
0
|
||||
}
|
||||
// Per-pad DualSense audio (0xD1 + HidOutput::AudioCtl): granted only when the
|
||||
// client asked AND this host can capture it — Windows with the feature enabled
|
||||
// and at least one pad endpoint provisioned at startup. A capable client then
|
||||
// marks its pads' renderers on their arrivals; the input thread streams toward
|
||||
// exactly those pads (`super::pad_audio`).
|
||||
| if super::pad_audio::host_cap(hello.client_caps) {
|
||||
punktfunk_core::quic::HOST_CAP_PAD_AUDIO
|
||||
} else {
|
||||
0
|
||||
},
|
||||
// The negotiated session AEAD (resolved above) + its 32-byte key toward a ChaCha
|
||||
// client; toward everyone else cipher 0 keeps the Welcome byte-identical to the
|
||||
|
||||
@@ -515,6 +515,100 @@ impl Pads {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-pad 0xD1 streamers (`super::pad_audio`), keyed by pad index like every per-pad table
|
||||
/// here (bounded by [`MAX_WIRE_PADS`]; only slots 0..4 can ever have a provisioned endpoint —
|
||||
/// `spawn` refuses the rest). Spawned when a negotiated session's DualSense-family arrival
|
||||
/// declares renderer bits, reaped on remove / re-declare / session teardown.
|
||||
struct PadAudioSlots {
|
||||
/// `(kinds, handle)` per running pad — `kinds` is the arrival's audio-caps mask, kept so
|
||||
/// an identical re-arrival (they are re-sent against datagram loss) is a no-op.
|
||||
slots: [Option<(u8, pad_audio::PadAudioHandle)>; MAX_WIRE_PADS],
|
||||
/// Kind-change restarts spent per pad this session (R3). The trigger is a client-sent
|
||||
/// arrival, so without a ceiling the client decides how many WASAPI captures the host opens.
|
||||
restarts: [u8; MAX_WIRE_PADS],
|
||||
}
|
||||
|
||||
/// R3: how many times one pad may change its declared audio kinds before the host stops
|
||||
/// obliging. A real controller declares once at open and never again; the re-sent arrivals are
|
||||
/// identical and take the no-op path above, so this is only reached by a client that keeps
|
||||
/// changing its mind.
|
||||
const MAX_PAD_AUDIO_RESTARTS: u8 = 8;
|
||||
|
||||
impl PadAudioSlots {
|
||||
fn new() -> PadAudioSlots {
|
||||
PadAudioSlots {
|
||||
slots: std::array::from_fn(|_| None),
|
||||
restarts: [0; MAX_WIRE_PADS],
|
||||
}
|
||||
}
|
||||
|
||||
/// Idempotent spawn: same kinds → keep the running streamer; changed kinds → restart with
|
||||
/// the new mask; not running → spawn (a slot without an endpoint stays empty — bounded
|
||||
/// retries, since arrivals are only re-sent a few times per slot open).
|
||||
fn ensure(&mut self, conn: &quinn::Connection, pad: u8, kinds: u8) {
|
||||
let idx = pad as usize;
|
||||
if idx >= MAX_WIRE_PADS {
|
||||
return;
|
||||
}
|
||||
if let Some((have, _)) = &self.slots[idx] {
|
||||
if *have == kinds {
|
||||
return; // identical re-arrival — keep the running streamer
|
||||
}
|
||||
// R3: the restart trigger is a CLIENT-sent arrival, so the count is client-driven.
|
||||
// Nothing bounded it: a client alternating its declared kinds could make the host
|
||||
// tear down and re-spawn a WASAPI loopback capture indefinitely, each cycle paying a
|
||||
// thread spawn and an endpoint activation. Cheap to bound, and a pad that has already
|
||||
// changed its mind this many times in one session is not doing anything legitimate.
|
||||
if self.restarts[idx] >= MAX_PAD_AUDIO_RESTARTS {
|
||||
tracing::warn!(
|
||||
pad = idx,
|
||||
"pad-audio kinds changed again after {MAX_PAD_AUDIO_RESTARTS} restarts — \
|
||||
ignoring; the streamer keeps its current kinds for this session"
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.restarts[idx] += 1;
|
||||
tracing::info!(
|
||||
pad = idx,
|
||||
restarts = self.restarts[idx],
|
||||
"pad-audio kinds changed — restarting the streamer"
|
||||
);
|
||||
self.stop(idx);
|
||||
}
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
if let Some(h) = pad_audio::spawn(conn.clone(), pad, kinds, stop) {
|
||||
self.slots[idx] = Some((kinds, h));
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop + reap one pad's streamer. The join rides a detached reaper thread: a quiet pad's
|
||||
/// capturer can sit out its ~5 s recv timeout, and this thread must keep its ≤4 ms
|
||||
/// feedback cadence (games block on GET_REPORT handshakes) — the reaper still joins, just
|
||||
/// not here. A failed reaper spawn falls back to the handle's own drop (signal + join).
|
||||
fn stop(&mut self, idx: usize) {
|
||||
if let Some((_, h)) = self.slots.get_mut(idx).and_then(|s| s.take()) {
|
||||
h.signal();
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("punktfunk1-padreap".into())
|
||||
.spawn(move || h.stop());
|
||||
}
|
||||
}
|
||||
|
||||
/// Session teardown: flag every streamer FIRST so they wind down concurrently, then join —
|
||||
/// the worst case is ONE quiet-endpoint recv timeout (~5 s), well inside the session's
|
||||
/// 10 s side-thread join grace, not one per pad.
|
||||
fn stop_all(&mut self) {
|
||||
for s in self.slots.iter().flatten() {
|
||||
s.1.signal();
|
||||
}
|
||||
for s in &mut self.slots {
|
||||
if let Some((_, h)) = s.take() {
|
||||
h.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One client→host input item, both planes on ONE channel so the input thread wakes the
|
||||
/// moment either arrives (a second rich channel drained after the 4 ms recv timeout cost
|
||||
/// every pure-gyro motion sample up to 4 ms of quantization).
|
||||
@@ -683,8 +777,13 @@ pub(super) fn input_thread(
|
||||
conn: quinn::Connection,
|
||||
inj_tx: std::sync::mpsc::Sender<InputEvent>,
|
||||
gamepad: GamepadPref,
|
||||
pad_audio_on: bool,
|
||||
) {
|
||||
let mut pads = Pads::new(gamepad);
|
||||
// Per-pad 0xD1 audio streamers, live only when the Welcome granted the cap (`pad_audio_on`
|
||||
// — read back off the negotiated host_caps). Spawned on DualSense-family arrivals that
|
||||
// declare renderer bits, reaped on remove/teardown below.
|
||||
let mut pad_streams = PadAudioSlots::new();
|
||||
// Motion-cadence observability (debug level): inter-arrival percentiles per 5 s window,
|
||||
// the measurement a "gyro feels floaty" report needs. Bounded: 5 s at even a 1 kHz pad
|
||||
// is 5000 u32s.
|
||||
@@ -854,16 +953,53 @@ pub(super) fn input_thread(
|
||||
&mut rumble_seen[idx],
|
||||
&mut rumble_stop_burst[idx],
|
||||
);
|
||||
// The unplugged pad's 0xD1 streamer goes with it (seq-gated like the
|
||||
// rest of this arm, so a reordered stale removal can't kill the
|
||||
// stream of a re-plugged pad). A re-plug re-arrives and re-spawns.
|
||||
pad_streams.stop(idx);
|
||||
}
|
||||
}
|
||||
InputKind::GamepadArrival => {
|
||||
// Per-pad controller kind declaration (mixed types): route this pad's future
|
||||
// frames to a backend of the declared kind. `code` = the GamepadPref wire byte,
|
||||
// `flags` = pad index. Applied before the pad's first frame (the client sends it
|
||||
// on slot open), so the device is built as the right type from the start.
|
||||
let idx = ev.flags as usize;
|
||||
// frames to a backend of the declared kind. `code` = the GamepadPref wire
|
||||
// byte, `flags` = pad index in the LOW BYTE — bits 8/9 carry the pad's
|
||||
// audio-render caps (haptics/speaker) from a pad-audio-capable client, so
|
||||
// the index MUST come from `decode_gamepad_arrival`, never the whole word.
|
||||
// Applied before the pad's first frame (the client sends it on slot open),
|
||||
// so the device is built as the right type from the start. The audio caps
|
||||
// are surfaced here for the 0xD1 capture path (which emits pad audio only
|
||||
// toward pads that declared a renderer).
|
||||
let (pad, audio_caps) = punktfunk_core::input::decode_gamepad_arrival(ev.flags);
|
||||
let idx = pad as usize;
|
||||
let kind = GamepadPref::from_u8(ev.code as u8);
|
||||
if audio_caps != 0 {
|
||||
tracing::debug!(
|
||||
pad = idx,
|
||||
haptics = audio_caps & 0x01 != 0,
|
||||
speaker = audio_caps & 0x02 != 0,
|
||||
"pad-audio render caps declared (arrival flags bits 8/9)"
|
||||
);
|
||||
}
|
||||
pads.set_kind(idx, kind);
|
||||
// Pad audio (0xD1): stream toward DualSense-family pads that declared a
|
||||
// renderer, only on a session that negotiated the cap. Idempotent across
|
||||
// the arrival re-sends (same kinds keeps the running streamer); a
|
||||
// re-declare without bits — or as a kind with no pad audio — stops it.
|
||||
if pad_audio_on {
|
||||
let want = if matches!(
|
||||
kind,
|
||||
GamepadPref::DualSense | GamepadPref::DualSenseEdge
|
||||
) {
|
||||
audio_caps
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if want != 0 {
|
||||
pad_streams.ensure(&conn, pad, want);
|
||||
} else {
|
||||
pad_streams.stop(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Track press/release so a mid-press disconnect can be undone below.
|
||||
@@ -1019,6 +1155,9 @@ pub(super) fn input_thread(
|
||||
flags: 0,
|
||||
});
|
||||
}
|
||||
// Reap the per-pad 0xD1 streamers with the session (after the instant release sends above
|
||||
// — this can block on a quiet pad's capturer timeout, see PadAudioSlots::stop_all).
|
||||
pad_streams.stop_all();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,662 @@
|
||||
//! Per-pad DualSense audio (the 0xD1 pad-audio plane): WASAPI loopback of a pre-provisioned pad
|
||||
//! endpoint ([`crate::audio::pad_endpoint`]) → 4-ch de-interleave into the speaker (front) and
|
||||
//! voice-coil haptics (back) pairs → per-kind silence gate → stereo Opus (48 kHz, CBR, LowDelay)
|
||||
//! → [`PAD_AUDIO_MAGIC`](punktfunk_core::quic::PAD_AUDIO_MAGIC) datagrams. One thread per
|
||||
//! arriving pad, spawned/reaped by the input thread ([`super::input`]) as arrivals declare
|
||||
//! renderers and pads leave. Modeled on the session audio thread ([`super::audio`]): the same
|
||||
//! reopen-with-backoff on capture death, the same monotonic-seq-kept-across-reopens discipline,
|
||||
//! the same power-of-two encode-warn throttle.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// `kinds` bit for the haptics stream (bit N = wire kind N — the same packing the arrival's
|
||||
/// audio-caps bits use, see [`punktfunk_core::input::decode_gamepad_arrival`]).
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
pub(super) const KIND_BIT_HAPTICS: u8 = 1 << punktfunk_core::quic::PAD_AUDIO_KIND_HAPTICS;
|
||||
/// `kinds` bit for the speaker stream.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
pub(super) const KIND_BIT_SPEAKER: u8 = 1 << punktfunk_core::quic::PAD_AUDIO_KIND_SPEAKER;
|
||||
|
||||
/// Haptics frames are 5 ms (the session-audio cadence — haptics are felt latency); speaker
|
||||
/// frames are 10 ms (speaker content tolerates the buffering for the coding efficiency). Both
|
||||
/// are the wire contract's cadences (`punktfunk_core::quic::PAD_AUDIO_KIND_*`).
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
const HAPTICS_FRAME_MS: u32 = 5;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
const SPEAKER_FRAME_MS: u32 = 10;
|
||||
/// Samples per frame (per channel) at 48 kHz: 240 / 480.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
const HAPTICS_FRAME_SAMPLES: usize =
|
||||
crate::audio::SAMPLE_RATE as usize * HAPTICS_FRAME_MS as usize / 1000;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
const SPEAKER_FRAME_SAMPLES: usize =
|
||||
crate::audio::SAMPLE_RATE as usize * SPEAKER_FRAME_MS as usize / 1000;
|
||||
/// The capture's channel count — the pad endpoint is stamped quad (FL FR BL BR: front pair =
|
||||
/// speaker, back pair = voice coils). Mirrors `pad_endpoint::PAD_CHANNELS` (Windows-gated, so
|
||||
/// the pure splitter logic keeps its own copy).
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
const CAP_CHANNELS: usize = 4;
|
||||
|
||||
/// Peak (absolute sample) at or above which a frame counts as signal — the gate OPENS on that
|
||||
/// very frame (haptics are felt latency; the first active frame must ship). ≈ −60 dBFS.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
const GATE_OPEN_PEAK: f32 = 1e-3;
|
||||
/// How long the gate keeps sending after the last signal frame before it CLOSES (hangover):
|
||||
/// long enough that a decaying haptic tail (and the client decoder's own tail) is never
|
||||
/// clipped, short enough that an idle pad costs nothing in steady state.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
const GATE_HANGOVER_MS: u32 = 250;
|
||||
|
||||
/// Per-kind Opus bitrate — a stereo voice-coil / pad-speaker pair needs far less than the
|
||||
/// session plane's 128 kbps; 64 kbps CBR keeps every frame comfortably under one MTU.
|
||||
#[cfg(target_os = "windows")]
|
||||
const PAD_AUDIO_BITRATE: i32 = 64_000;
|
||||
|
||||
/// The per-kind silence gate — the steady-state-cost feature: an idle pad endpoint (games
|
||||
/// rarely render pad audio) must cost ZERO encodes and ZERO datagrams, not a permanent 200 Hz
|
||||
/// stream of coded silence. Opens the instant a frame carries signal ([`GATE_OPEN_PEAK`]);
|
||||
/// closes only after [`GATE_HANGOVER_MS`] of continuous sub-threshold frames. Pure logic,
|
||||
/// unit-tested below.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
struct SilenceGate {
|
||||
/// Consecutive sub-threshold frames that close the gate ([`GATE_HANGOVER_MS`] ÷ frame ms).
|
||||
hangover_frames: u32,
|
||||
/// Consecutive sub-threshold frames seen so far while open.
|
||||
quiet: u32,
|
||||
/// Starts closed: a pad no game ever renders into never opens (and never sends).
|
||||
open: bool,
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
impl SilenceGate {
|
||||
fn new(frame_ms: u32) -> SilenceGate {
|
||||
SilenceGate {
|
||||
hangover_frames: (GATE_HANGOVER_MS / frame_ms).max(1),
|
||||
quiet: 0,
|
||||
open: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed one frame; `true` = encode + send it. Signal opens the gate on THIS frame; the
|
||||
/// frame that completes the hangover closes it and is itself suppressed (the client
|
||||
/// already has ~250 ms of ramped-out silence by then).
|
||||
fn feed(&mut self, frame: &[f32]) -> bool {
|
||||
if frame.iter().any(|s| s.abs() >= GATE_OPEN_PEAK) {
|
||||
self.open = true;
|
||||
self.quiet = 0;
|
||||
} else if self.open {
|
||||
self.quiet += 1;
|
||||
if self.quiet >= self.hangover_frames {
|
||||
self.open = false;
|
||||
self.quiet = 0;
|
||||
}
|
||||
}
|
||||
self.open
|
||||
}
|
||||
}
|
||||
|
||||
/// One kind's send-admission + seq bookkeeping (pure logic — the capture thread wraps it with
|
||||
/// the encoder and the datagram send). `seq` is monotonic per (pad, kind) and NEVER advances
|
||||
/// while the gate is closed: frozen-seq = deliberate silence — the client tells silence from
|
||||
/// loss by seq continuity (the mic-mute discipline, pf-client-core/src/audio.rs). It is also
|
||||
/// kept across capture reopens (the session audio thread's discipline, audio.rs): the client
|
||||
/// sees a gap, not a restart.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
struct LaneCtl {
|
||||
gate: SilenceGate,
|
||||
seq: u32,
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
impl LaneCtl {
|
||||
fn new(frame_ms: u32) -> LaneCtl {
|
||||
LaneCtl {
|
||||
gate: SilenceGate::new(frame_ms),
|
||||
seq: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Admit one frame: `Some(seq)` = encode + send it with this seq (advanced for the next);
|
||||
/// `None` = gated — do not send, do not advance. An encode failure AFTER admission leaves a
|
||||
/// one-frame seq gap, which the client conceals exactly like datagram loss.
|
||||
fn admit(&mut self, frame: &[f32]) -> Option<u32> {
|
||||
if !self.gate.feed(frame) {
|
||||
return None;
|
||||
}
|
||||
let seq = self.seq;
|
||||
self.seq = self.seq.wrapping_add(1);
|
||||
Some(seq)
|
||||
}
|
||||
}
|
||||
|
||||
/// De-interleave one 4-ch block (FL FR BL BR) into its stereo pairs: `(front, back)` — front =
|
||||
/// speaker (channels 0/1), back = voice-coil haptics (channels 2/3). A ragged tail (not a
|
||||
/// multiple of 4 — the capturer only ever delivers whole frames) is dropped, never smeared
|
||||
/// across channels.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn split_quad(block: &[f32]) -> (Vec<f32>, Vec<f32>) {
|
||||
let mut front = Vec::with_capacity(block.len() / 2);
|
||||
let mut back = Vec::with_capacity(block.len() / 2);
|
||||
for s in block.chunks_exact(CAP_CHANNELS) {
|
||||
front.extend_from_slice(&s[..2]);
|
||||
back.extend_from_slice(&s[2..4]);
|
||||
}
|
||||
(front, back)
|
||||
}
|
||||
|
||||
/// Accumulates interleaved 4-ch capture and cuts it into the wire contract's per-kind stereo
|
||||
/// frames — haptics every 5 ms from the back pair, speaker every 10 ms from the front pair —
|
||||
/// emitting ONLY the kinds enabled in `kinds` (a disabled kind is never even split out, so it
|
||||
/// can never reach an encoder). Pure logic, unit-tested; the capture thread wraps it.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
struct PadFramer {
|
||||
kinds: u8,
|
||||
/// Raw interleaved 4-ch accumulation, drained in 5 ms blocks.
|
||||
acc: Vec<f32>,
|
||||
/// Front-pair stereo accumulation toward the next 10 ms speaker frame.
|
||||
front: Vec<f32>,
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
impl PadFramer {
|
||||
fn new(kinds: u8) -> PadFramer {
|
||||
PadFramer {
|
||||
kinds,
|
||||
acc: Vec::with_capacity(HAPTICS_FRAME_SAMPLES * CAP_CHANNELS * 4),
|
||||
front: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed one capture chunk; `emit(kind, stereo_frame)` fires for each completed frame
|
||||
/// (haptics first — it is the latency-critical pair).
|
||||
fn feed(&mut self, chunk: &[f32], mut emit: impl FnMut(u8, &[f32])) {
|
||||
self.acc.extend_from_slice(chunk);
|
||||
let block_len = HAPTICS_FRAME_SAMPLES * CAP_CHANNELS;
|
||||
while self.acc.len() >= block_len {
|
||||
let block: Vec<f32> = self.acc.drain(..block_len).collect();
|
||||
let (front, back) = split_quad(&block);
|
||||
if self.kinds & KIND_BIT_HAPTICS != 0 {
|
||||
emit(punktfunk_core::quic::PAD_AUDIO_KIND_HAPTICS, &back);
|
||||
}
|
||||
if self.kinds & KIND_BIT_SPEAKER != 0 {
|
||||
self.front.extend_from_slice(&front);
|
||||
let frame_len = SPEAKER_FRAME_SAMPLES * 2;
|
||||
while self.front.len() >= frame_len {
|
||||
let frame: Vec<f32> = self.front.drain(..frame_len).collect();
|
||||
emit(punktfunk_core::quic::PAD_AUDIO_KIND_SPEAKER, &frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the partial frames straddling a capture gap (reopen). The seq/gate state is NOT
|
||||
/// here — [`LaneCtl`] deliberately survives reopens, so the client sees a gap, not a
|
||||
/// restart.
|
||||
fn clear(&mut self) {
|
||||
self.acc.clear();
|
||||
self.front.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// A running per-pad streamer. [`stop`](PadAudioHandle::stop) (or drop) flags the thread and
|
||||
/// joins it; [`signal`](PadAudioHandle::signal) only flags — the input thread's teardown flags
|
||||
/// every pad first so the joins overlap instead of serializing the capturer's worst-case ~5 s
|
||||
/// quiet-endpoint recv timeout.
|
||||
pub(super) struct PadAudioHandle {
|
||||
stop: Arc<AtomicBool>,
|
||||
join: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl PadAudioHandle {
|
||||
/// Flag the streamer to wind down without waiting for it.
|
||||
pub(super) fn signal(&self) {
|
||||
self.stop.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Stop + reap. Bounded by the capturer's ~5 s quiet-endpoint recv timeout in the worst
|
||||
/// case — the mid-session reap paths run this on a detached reaper thread for that reason
|
||||
/// (`input.rs::PadAudioSlots::stop`); session teardown affords it inline (the 10 s
|
||||
/// side-thread join grace covers it).
|
||||
pub(super) fn stop(mut self) {
|
||||
self.reap();
|
||||
}
|
||||
|
||||
fn reap(&mut self) {
|
||||
self.signal();
|
||||
if let Some(join) = self.join.take() {
|
||||
let _ = join.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A handle dropped without `stop()` (reaper-spawn failure) still winds its thread down.
|
||||
impl Drop for PadAudioHandle {
|
||||
fn drop(&mut self) {
|
||||
self.reap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this session's Welcome should advertise
|
||||
/// [`HOST_CAP_PAD_AUDIO`](punktfunk_core::quic::HOST_CAP_PAD_AUDIO): the client asked
|
||||
/// ([`CLIENT_CAP_PAD_AUDIO`](punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO)), this is a Windows
|
||||
/// host with the feature on (`PUNKTFUNK_PAD_AUDIO` != "0"), and startup provisioning published
|
||||
/// at least one endpoint (`pad_endpoint::provision_at_startup`). Still-running provisioning
|
||||
/// reads as "none yet": a session racing host startup simply negotiates without pad audio and
|
||||
/// picks it up on its next connect.
|
||||
pub(super) fn host_cap(client_caps: u8) -> bool {
|
||||
let asked = client_caps & punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO != 0;
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// R5: a startup attempt that failed transiently leaves nothing latched, so retry here —
|
||||
// this is the first moment in a session's life that anyone asks whether pad audio exists.
|
||||
if asked {
|
||||
crate::audio::pad_endpoint::ensure_provisioned();
|
||||
}
|
||||
asked
|
||||
&& std::env::var_os("PUNKTFUNK_PAD_AUDIO").is_none_or(|v| v != "0")
|
||||
&& crate::audio::pad_endpoint::provisioned_endpoints()
|
||||
.is_some_and(|eps| !eps.is_empty())
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// Only the Windows virtual DualSense exposes pad audio endpoints today.
|
||||
let _ = asked;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the per-pad streamer toward `conn` for `pad`, streaming the kinds in `kinds` (bit 0 =
|
||||
/// haptics, bit 1 = speaker — the arrival's audio-caps packing). `stop` is this handle's own
|
||||
/// flag (fresh per spawn — pad streamers stop individually, not with the session). `None` when
|
||||
/// the slot has no provisioned endpoint (provisioning failed or still running, or the slot is
|
||||
/// past `PUNKTFUNK_PAD_AUDIO_SLOTS` — only 0..4 can ever have one) or the thread cannot spawn;
|
||||
/// the pad itself keeps working either way, just without audio.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(super) fn spawn(
|
||||
conn: quinn::Connection,
|
||||
pad: u8,
|
||||
kinds: u8,
|
||||
stop: Arc<AtomicBool>,
|
||||
) -> Option<PadAudioHandle> {
|
||||
if kinds & (KIND_BIT_HAPTICS | KIND_BIT_SPEAKER) == 0 {
|
||||
return None;
|
||||
}
|
||||
let Some(ep) = crate::audio::pad_endpoint::endpoint_for(pad) else {
|
||||
tracing::debug!(
|
||||
pad,
|
||||
"pad-audio arrival for a slot without a provisioned endpoint — not streaming"
|
||||
);
|
||||
return None;
|
||||
};
|
||||
if ep.endpoint_id.is_empty() {
|
||||
// The devnode-without-endpoint shape (`find`) — never in the provisioned set, but
|
||||
// cheap to refuse rather than spin the open/backoff loop on an empty id.
|
||||
return None;
|
||||
}
|
||||
if ep.needs_aeb_kick {
|
||||
// R4: this flag was computed on every path and consulted nowhere past startup. It means
|
||||
// the endpoint's stamps are STORED but not SERVED — the audio stack never picked up the
|
||||
// DualSense identity — and startup's one restart did not fix it. Opening anyway is worse
|
||||
// than refusing: `AUTOCONVERTPCM` makes a wrong-format endpoint initialize *successfully*,
|
||||
// so the stream runs, the logs look healthy, and the haptics/speaker pair is mis-routed
|
||||
// with nothing to point at. Decline, and say which reboot-shaped problem it is.
|
||||
tracing::warn!(
|
||||
pad,
|
||||
endpoint = %ep.endpoint_id,
|
||||
"pad endpoint stamps are stored but not served — the audio stack has not adopted the \
|
||||
DualSense identity (a reboot, or a manual AudioEndpointBuilder+Audiosrv restart, \
|
||||
clears it). Not streaming: the endpoint would open and mis-route."
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let stop_t = stop.clone();
|
||||
match std::thread::Builder::new()
|
||||
.name(format!("punktfunk1-pad{pad}"))
|
||||
.spawn(move || pad_audio_thread(conn, pad, kinds, ep.endpoint_id, stop_t))
|
||||
{
|
||||
Ok(join) => Some(PadAudioHandle {
|
||||
stop,
|
||||
join: Some(join),
|
||||
}),
|
||||
Err(e) => {
|
||||
tracing::warn!(pad, error = %e, "pad-audio thread spawn failed — pad streams without audio");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stub — pad endpoints exist only behind the Windows virtual DualSense; other hosts run pads
|
||||
/// without the audio side (and never advertise the cap, see [`host_cap`]).
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub(super) fn spawn(
|
||||
_conn: quinn::Connection,
|
||||
_pad: u8,
|
||||
_kinds: u8,
|
||||
_stop: Arc<AtomicBool>,
|
||||
) -> Option<PadAudioHandle> {
|
||||
None
|
||||
}
|
||||
|
||||
/// One enabled kind's encoder lane: admission/seq control + its stereo Opus encoder + the
|
||||
/// power-of-two warn throttle (a stuck encoder would otherwise fail ~200 times a second).
|
||||
#[cfg(target_os = "windows")]
|
||||
struct Lane {
|
||||
kind: u8,
|
||||
ctl: LaneCtl,
|
||||
enc: opus::Encoder,
|
||||
encode_errs: u64,
|
||||
}
|
||||
|
||||
/// Build one stereo encoder per enabled kind: 48 kHz LowDelay hard-CBR like the session audio
|
||||
/// plane ([`super::audio`]), at the pad plane's 64 kbps.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn build_lanes(kinds: u8) -> Result<Vec<Lane>, opus::Error> {
|
||||
let mut lanes = Vec::new();
|
||||
for (bit, kind, frame_ms) in [
|
||||
(
|
||||
KIND_BIT_HAPTICS,
|
||||
punktfunk_core::quic::PAD_AUDIO_KIND_HAPTICS,
|
||||
HAPTICS_FRAME_MS,
|
||||
),
|
||||
(
|
||||
KIND_BIT_SPEAKER,
|
||||
punktfunk_core::quic::PAD_AUDIO_KIND_SPEAKER,
|
||||
SPEAKER_FRAME_MS,
|
||||
),
|
||||
] {
|
||||
if kinds & bit == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut enc = opus::Encoder::new(
|
||||
crate::audio::SAMPLE_RATE,
|
||||
opus::Channels::Stereo,
|
||||
opus::Application::LowDelay,
|
||||
)?;
|
||||
enc.set_bitrate(opus::Bitrate::Bits(PAD_AUDIO_BITRATE)).ok();
|
||||
enc.set_vbr(false).ok();
|
||||
lanes.push(Lane {
|
||||
kind,
|
||||
ctl: LaneCtl::new(frame_ms),
|
||||
enc,
|
||||
encode_errs: 0,
|
||||
});
|
||||
}
|
||||
Ok(lanes)
|
||||
}
|
||||
|
||||
/// The per-pad streaming thread: loopback capture → framer → per-kind gate/encode → 0xD1
|
||||
/// datagrams. Capture death reopens with the session-audio backoff ([`INJECTOR_REOPEN_BACKOFF`],
|
||||
/// encoders + seq kept); a send error ends the thread (the connection — the session — is gone).
|
||||
#[cfg(target_os = "windows")]
|
||||
fn pad_audio_thread(
|
||||
conn: quinn::Connection,
|
||||
pad: u8,
|
||||
kinds: u8,
|
||||
endpoint_id: String,
|
||||
stop: Arc<AtomicBool>,
|
||||
) {
|
||||
use crate::audio::AudioCapturer as _;
|
||||
let mut lanes = match build_lanes(kinds) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
tracing::warn!(pad, error = %e, "pad-audio opus encoder init failed — pad continues without audio");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if lanes.is_empty() {
|
||||
return; // spawn() refuses kinds == 0 — belt and braces
|
||||
}
|
||||
let mut framer = PadFramer::new(kinds);
|
||||
// One Opus frame per datagram; 64 kbps CBR at ≤10 ms is ~80 bytes — sized with the session
|
||||
// plane's slack.
|
||||
let mut opus_buf = vec![0u8; 1500];
|
||||
// Reopen-with-backoff (the audio.rs discipline): a capture death (endpoint invalidated,
|
||||
// audio-engine restart) reopens instead of muting the pad for the rest of the session. The
|
||||
// first open ALSO rides this loop, so an open lost to endpoint churn starts late, not never.
|
||||
let mut capturer: Option<crate::audio::pad_endpoint::PadLoopbackCapturer> = None;
|
||||
let mut last_failed: Option<std::time::Instant> = None;
|
||||
tracing::info!(
|
||||
pad,
|
||||
haptics = kinds & KIND_BIT_HAPTICS != 0,
|
||||
speaker = kinds & KIND_BIT_SPEAKER != 0,
|
||||
"pad audio streaming (0xD1, Opus 48 kHz, silence-gated)"
|
||||
);
|
||||
'session: while !stop.load(Ordering::SeqCst) {
|
||||
if capturer.is_none() {
|
||||
if last_failed.is_some_and(|t| t.elapsed() < INJECTOR_REOPEN_BACKOFF) {
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
continue;
|
||||
}
|
||||
match crate::audio::pad_endpoint::PadLoopbackCapturer::open(&endpoint_id) {
|
||||
Ok(c) => {
|
||||
if last_failed.take().is_some() {
|
||||
tracing::info!(pad, "pad-audio capture reopened");
|
||||
}
|
||||
capturer = Some(c);
|
||||
framer.clear(); // drop the partial frames straddling the gap
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(pad, error = %format!("{e:#}"), "pad-audio open failed — will retry");
|
||||
last_failed = Some(std::time::Instant::now());
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
// An empty chunk is a QUIET endpoint (the capturer's idle timeout), not a death — keep
|
||||
// it; only a genuine Err (capture thread ended) drops the capturer for reopen.
|
||||
let chunk = match capturer.as_mut().unwrap().next_chunk() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!(pad, error = %format!("{e:#}"), "pad-audio capture lost — reopening");
|
||||
capturer = None;
|
||||
last_failed = Some(std::time::Instant::now());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut session_gone = false;
|
||||
framer.feed(&chunk, |kind, frame| {
|
||||
if session_gone {
|
||||
return;
|
||||
}
|
||||
let Some(lane) = lanes.iter_mut().find(|l| l.kind == kind) else {
|
||||
return; // framer emits only enabled kinds — unreachable, but never panic here
|
||||
};
|
||||
// Gated = deliberate silence: no datagram AND a frozen seq (the client tells
|
||||
// silence from loss by seq continuity).
|
||||
let Some(seq) = lane.ctl.admit(frame) else {
|
||||
return;
|
||||
};
|
||||
let pts_ns = now_ns();
|
||||
match lane.enc.encode_float(frame, &mut opus_buf) {
|
||||
Ok(n) => {
|
||||
let d = punktfunk_core::quic::encode_pad_audio_datagram(
|
||||
pad,
|
||||
kind,
|
||||
seq,
|
||||
pts_ns,
|
||||
&opus_buf[..n],
|
||||
);
|
||||
if conn.send_datagram(d.into()).is_err() {
|
||||
session_gone = true; // connection gone — the session is over
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
lane.encode_errs += 1;
|
||||
if lane.encode_errs.is_power_of_two() {
|
||||
tracing::warn!(
|
||||
pad,
|
||||
kind,
|
||||
error = %e,
|
||||
count = lane.encode_errs,
|
||||
"pad-audio opus encode failed — dropping frame"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if session_gone {
|
||||
break 'session;
|
||||
}
|
||||
}
|
||||
// Dropping the capturer stops its WASAPI thread. Nothing to park: pad capture is per-pad,
|
||||
// per-session by design (unlike the session audio slot there is no cross-session reuse).
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use punktfunk_core::quic::{PAD_AUDIO_KIND_HAPTICS, PAD_AUDIO_KIND_SPEAKER};
|
||||
|
||||
/// A stereo frame of `n` samples at a constant level.
|
||||
fn frame(level: f32, n: usize) -> Vec<f32> {
|
||||
vec![level; n * 2]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_opens_immediately_and_closes_after_hangover() {
|
||||
let mut g = SilenceGate::new(HAPTICS_FRAME_MS);
|
||||
// 250 ms of 5 ms frames.
|
||||
assert_eq!(g.hangover_frames, 50);
|
||||
// Closed from birth: an idle pad never sends.
|
||||
assert!(!g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
|
||||
// A peak at exactly the threshold opens on THIS frame (haptics are felt latency).
|
||||
assert!(g.feed(&frame(GATE_OPEN_PEAK, HAPTICS_FRAME_SAMPLES)));
|
||||
// 49 quiet frames ride the hangover; the 50th completes 250 ms and is suppressed.
|
||||
for _ in 0..49 {
|
||||
assert!(g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
|
||||
}
|
||||
assert!(!g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
|
||||
// ... and stays closed.
|
||||
assert!(!g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
|
||||
// Sub-threshold wiggle does not reopen; real signal does (negative peaks count).
|
||||
assert!(!g.feed(&frame(9e-4, HAPTICS_FRAME_SAMPLES)));
|
||||
assert!(g.feed(&frame(-0.5, HAPTICS_FRAME_SAMPLES)));
|
||||
// A loud frame mid-hangover rearms the full 250 ms.
|
||||
for _ in 0..49 {
|
||||
assert!(g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
|
||||
}
|
||||
assert!(g.feed(&frame(0.02, HAPTICS_FRAME_SAMPLES)));
|
||||
for _ in 0..49 {
|
||||
assert!(g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
|
||||
}
|
||||
assert!(!g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_hangover_scales_with_frame_ms() {
|
||||
let mut g = SilenceGate::new(SPEAKER_FRAME_MS);
|
||||
assert_eq!(g.hangover_frames, 25); // 250 ms of 10 ms frames
|
||||
assert!(g.feed(&frame(0.1, SPEAKER_FRAME_SAMPLES)));
|
||||
for _ in 0..24 {
|
||||
assert!(g.feed(&frame(0.0, SPEAKER_FRAME_SAMPLES)));
|
||||
}
|
||||
assert!(!g.feed(&frame(0.0, SPEAKER_FRAME_SAMPLES)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seq_freezes_while_gated_and_survives_reopen() {
|
||||
let mut lane = LaneCtl::new(HAPTICS_FRAME_MS);
|
||||
// Two audible frames: seq 0, 1.
|
||||
assert_eq!(lane.admit(&frame(0.5, HAPTICS_FRAME_SAMPLES)), Some(0));
|
||||
assert_eq!(lane.admit(&frame(0.5, HAPTICS_FRAME_SAMPLES)), Some(1));
|
||||
// The hangover is still sent (seq advances), then the gate closes and seq FREEZES —
|
||||
// deliberate silence the client tells from loss by continuity.
|
||||
for i in 0..49u32 {
|
||||
assert_eq!(lane.admit(&frame(0.0, HAPTICS_FRAME_SAMPLES)), Some(2 + i));
|
||||
}
|
||||
for _ in 0..500 {
|
||||
assert_eq!(lane.admit(&frame(0.0, HAPTICS_FRAME_SAMPLES)), None);
|
||||
}
|
||||
// A capture reopen resets ONLY the framer (PadFramer::clear) — LaneCtl is deliberately
|
||||
// untouched, so the next audible frame CONTINUES the sequence (gap, not restart).
|
||||
assert_eq!(lane.admit(&frame(0.9, HAPTICS_FRAME_SAMPLES)), Some(51));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splitter_exact_pairs() {
|
||||
// Interleave [FL FR BL BR] × 2 frames with distinct values everywhere.
|
||||
let quad = [0.0, 1.0, 2.0, 3.0, 10.0, 11.0, 12.0, 13.0];
|
||||
let (front, back) = split_quad(&quad);
|
||||
assert_eq!(front, [0.0, 1.0, 10.0, 11.0]);
|
||||
assert_eq!(back, [2.0, 3.0, 12.0, 13.0]);
|
||||
// A ragged tail (never produced by the capturer) is dropped, not smeared.
|
||||
let (front, back) = split_quad(&quad[..7]);
|
||||
assert_eq!((front.len(), back.len()), (2, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn framer_cuts_the_wire_cadence() {
|
||||
let mut f = PadFramer::new(KIND_BIT_HAPTICS | KIND_BIT_SPEAKER);
|
||||
let mut got: Vec<(u8, usize, f32)> = Vec::new();
|
||||
// 10 ms of capture (480 samples), fed in ragged chunks: exactly two 5 ms haptics
|
||||
// frames from the back pair, then one 10 ms speaker frame from the front pair.
|
||||
let mut quad = Vec::new();
|
||||
for _ in 0..2 * HAPTICS_FRAME_SAMPLES {
|
||||
quad.extend_from_slice(&[0.25, 0.25, -0.5, -0.5]);
|
||||
}
|
||||
for chunk in quad.chunks(101) {
|
||||
f.feed(chunk, |kind, frame| got.push((kind, frame.len(), frame[0])));
|
||||
}
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
(PAD_AUDIO_KIND_HAPTICS, 2 * HAPTICS_FRAME_SAMPLES, -0.5),
|
||||
(PAD_AUDIO_KIND_HAPTICS, 2 * HAPTICS_FRAME_SAMPLES, -0.5),
|
||||
(PAD_AUDIO_KIND_SPEAKER, 2 * SPEAKER_FRAME_SAMPLES, 0.25),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn framer_masks_disabled_kinds() {
|
||||
// 20 ms of all-ones capture: 4 potential haptics frames, 2 potential speaker frames.
|
||||
let quad = vec![1.0f32; 4 * HAPTICS_FRAME_SAMPLES * CAP_CHANNELS];
|
||||
let mut kinds_seen = Vec::new();
|
||||
// Haptics-only: the front pair is never split out, let alone encoded.
|
||||
let mut f = PadFramer::new(KIND_BIT_HAPTICS);
|
||||
f.feed(&quad, |kind, _| kinds_seen.push(kind));
|
||||
assert_eq!(kinds_seen, vec![PAD_AUDIO_KIND_HAPTICS; 4]);
|
||||
// Speaker-only: no haptics frames.
|
||||
let mut f = PadFramer::new(KIND_BIT_SPEAKER);
|
||||
kinds_seen.clear();
|
||||
f.feed(&quad, |kind, _| kinds_seen.push(kind));
|
||||
assert_eq!(kinds_seen, vec![PAD_AUDIO_KIND_SPEAKER; 2]);
|
||||
// kinds = 0 is never spawned, but the framer must still be total: nothing comes out.
|
||||
let mut f = PadFramer::new(0);
|
||||
kinds_seen.clear();
|
||||
f.feed(&quad, |kind, _| kinds_seen.push(kind));
|
||||
assert!(kinds_seen.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn framer_clear_drops_partials_only() {
|
||||
let mut f = PadFramer::new(KIND_BIT_HAPTICS | KIND_BIT_SPEAKER);
|
||||
let mut emitted = 0;
|
||||
// 100 samples: no frame boundary reached yet.
|
||||
f.feed(&vec![0.1; 100 * CAP_CHANNELS], |_, _| emitted += 1);
|
||||
assert_eq!(emitted, 0);
|
||||
f.clear();
|
||||
// After the gap: exactly one haptics frame from 240 fresh samples — the 100 stale
|
||||
// samples are gone (they would skew every later frame boundary).
|
||||
f.feed(
|
||||
&vec![0.2; HAPTICS_FRAME_SAMPLES * CAP_CHANNELS],
|
||||
|kind, frame| {
|
||||
emitted += 1;
|
||||
assert_eq!(
|
||||
(kind, frame.len()),
|
||||
(PAD_AUDIO_KIND_HAPTICS, 2 * HAPTICS_FRAME_SAMPLES)
|
||||
);
|
||||
},
|
||||
);
|
||||
assert_eq!(emitted, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_cap_requires_the_client_bit() {
|
||||
// Without CLIENT_CAP_PAD_AUDIO the answer is no on EVERY platform (on Windows the
|
||||
// env + provisioning legs are environment-dependent — not unit-tested here).
|
||||
assert!(!host_cap(0));
|
||||
assert!(!host_cap(punktfunk_core::quic::CLIENT_CAP_CURSOR));
|
||||
}
|
||||
}
|
||||
@@ -441,26 +441,27 @@ fn idd_adaptive_enabled() -> bool {
|
||||
/// Seal one access unit and send it with MICROBURST pacing (the shared
|
||||
/// [`send_pacing`](crate::send_pacing) policy, native parameterization): the first `burst_cap`
|
||||
/// bytes go out immediately (one absorbed burst the NIC / socket tx-buffer can swallow), and
|
||||
/// only the OVERFLOW beyond that is spread across `min(~90% of the time to deadline, the time
|
||||
/// the overflow needs at pace_rate_bps)` in ADAPTIVE chunks — 16 packets at today's rates,
|
||||
/// coarsening to at most 64 (the GSO-segment cap) once the rate would otherwise skip every
|
||||
/// sub-floor sleep, so ≥1 Gbps frames still pace instead of collapsing into an unpaced blast
|
||||
/// (plan Phase 1.2). `burst_cap` `None` = auto: `max(128 KB, this AU's wire bytes / 4)`, so
|
||||
/// the burst stays a bounded fraction of a high-rate frame instead of swallowing it whole
|
||||
/// (plan Phase 1.3); `Some` = PUNKTFUNK_PACE_BURST_KB pinned an absolute cap. So a
|
||||
/// normal-bitrate frame (≤ cap) leaves in one immediate burst at ~0 added latency, while a
|
||||
/// genuine IDR / sustained-high-bitrate frame (≫ cap) still spreads — keeping the freeze fix
|
||||
/// exactly where it's needed (an unpaced line-rate burst overruns the kernel tx buffer →
|
||||
/// EAGAIN drop → under infinite GOP, a freeze until the next keyframe). With no slack
|
||||
/// (encode ≈ interval) the budget collapses to 0 and even the overflow goes out immediately,
|
||||
/// so this is never slower than unpaced.
|
||||
/// only the OVERFLOW beyond that is spread across the time it needs at `pace_rate_bps` in
|
||||
/// ADAPTIVE chunks — 16 packets at today's rates, coarsening to at most 64 (the GSO-segment
|
||||
/// cap) once the rate would otherwise skip every sub-floor sleep, so ≥1 Gbps frames still pace
|
||||
/// instead of collapsing into an unpaced blast (plan Phase 1.2). `burst_cap` `None` = auto:
|
||||
/// `max(128 KB, this AU's wire bytes / 4)`, so the burst stays a bounded fraction of a
|
||||
/// high-rate frame instead of swallowing it whole (plan Phase 1.3); `Some` =
|
||||
/// PUNKTFUNK_PACE_BURST_KB pinned an absolute cap. So a normal-bitrate frame (≤ cap) leaves in
|
||||
/// one immediate burst at ~0 added latency, while a genuine IDR / sustained-high-bitrate frame
|
||||
/// (≫ cap) still spreads — keeping the freeze fix exactly where it's needed (an unpaced
|
||||
/// line-rate burst overruns the kernel tx buffer → EAGAIN drop → under infinite GOP, a freeze
|
||||
/// until the next keyframe).
|
||||
///
|
||||
/// `pace_rate_bps` (latency plan T1.2) bounds the spread from above: the deadline term alone
|
||||
/// smears a big frame's tail across the whole remaining interval (~15 ms at 60 fps) even when
|
||||
/// the link could drain it in 2–3 ms. The caller passes ~3× the live encoder bitrate — a rate
|
||||
/// the link is proven to carry sustained, so the bounded excursion keeps the anti-freeze
|
||||
/// property while the tail leaves as soon as the link plausibly allows. `0` = uncapped
|
||||
/// (legacy smoothness-only spread, and the fallback when the bitrate isn't known yet).
|
||||
/// `pace_rate_bps` (latency plan T1.2; resume-safe form, stall program T2): the caller passes
|
||||
/// ~3× the live encoder bitrate — a rate the link is proven to carry sustained — and the
|
||||
/// overflow's wire time at that rate IS the pace budget ([`crate::send_pacing::native_budget`],
|
||||
/// [`crate::send_pacing::MAX_PACE_SPREAD`]-bounded). The frame deadline no longer under-cuts
|
||||
/// the spread: for a steady-state frame the rate term was the smaller one anyway (tail gone in
|
||||
/// a fraction of the interval), and for an oversized frame (stall-resume scene delta, cold
|
||||
/// IDR) the old deadline clamp was exactly the line-rate blast → tx-overrun → freeze path this
|
||||
/// module exists to prevent. `0` = uncapped legacy deadline-only spread
|
||||
/// (PUNKTFUNK_PACE_FACTOR=0, and the fallback when the bitrate isn't known yet).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn paced_submit(
|
||||
session: &mut Session,
|
||||
@@ -498,34 +499,22 @@ fn pace_sealed(
|
||||
chunk: crate::send_pacing::ChunkPolicy::Adaptive { base: 16, max: 64 },
|
||||
sleep_floor: std::time::Duration::from_micros(500),
|
||||
};
|
||||
// T1.2 rate cap: the overflow's wire time at `pace_rate_bps`. Only the bytes past the
|
||||
// burst pace at all, so only they bound the budget.
|
||||
// T1.2 rate cap, resume-safe form (stall program T2): the overflow's wire time at
|
||||
// `pace_rate_bps` IS the budget — the deadline no longer under-cuts it, so an oversized
|
||||
// frame (a stall-resume scene delta, a cold IDR) paces at the proven 3× rate instead of
|
||||
// collapsing into a line-rate blast that overruns the socket buffer and loses the very
|
||||
// frame that ends a freeze. See `send_pacing::native_budget` for the full argument.
|
||||
let overflow_bytes = wire_bytes.saturating_sub(burst_bytes) as u64;
|
||||
let cap = if pace_rate_bps > 0 && overflow_bytes > 0 {
|
||||
std::time::Duration::from_nanos(
|
||||
(overflow_bytes * 8).saturating_mul(1_000_000_000) / pace_rate_bps,
|
||||
)
|
||||
} else {
|
||||
std::time::Duration::MAX
|
||||
};
|
||||
let budget = crate::send_pacing::native_budget(deadline, pace_rate_bps, overflow_bytes);
|
||||
// Time the socket handoff per chunk and fold it into the session's SealPerf split — the
|
||||
// sleeps between chunks stay excluded, so sock_ns is pure send_gso/sendmmsg time.
|
||||
let mut sock_ns = 0u64;
|
||||
let result = crate::send_pacing::pace_frame(
|
||||
&refs,
|
||||
crate::send_pacing::PaceBudget::UntilDeadline {
|
||||
deadline,
|
||||
fraction: 0.9,
|
||||
cap,
|
||||
},
|
||||
&cfg,
|
||||
|chunk| {
|
||||
let t0 = std::time::Instant::now();
|
||||
let r = session.send_sealed(chunk).map(|_| ());
|
||||
sock_ns += t0.elapsed().as_nanos() as u64;
|
||||
r
|
||||
},
|
||||
);
|
||||
let result = crate::send_pacing::pace_frame(&refs, budget, &cfg, |chunk| {
|
||||
let t0 = std::time::Instant::now();
|
||||
let r = session.send_sealed(chunk).map(|_| ());
|
||||
sock_ns += t0.elapsed().as_nanos() as u64;
|
||||
r
|
||||
});
|
||||
drop(refs); // release the borrow of `wires` so it can return to the seal pool
|
||||
session.reclaim_wires(wires);
|
||||
session.note_sock_ns(sock_ns);
|
||||
|
||||
@@ -55,7 +55,7 @@ pub(crate) enum ChunkPolicy {
|
||||
}
|
||||
|
||||
/// The time the paced (post-burst) packets spread across.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) enum PaceBudget {
|
||||
/// `min((deadline − now-after-burst) × fraction, cap)`, collapsing to 0 with no slack
|
||||
/// (native: fraction 0.9). `cap` bounds the spread to the time the overflow actually needs
|
||||
@@ -68,10 +68,53 @@ pub(crate) enum PaceBudget {
|
||||
fraction: f32,
|
||||
cap: Duration,
|
||||
},
|
||||
/// A precomputed fixed budget (GameStream: ¾ of the frame interval).
|
||||
/// A precomputed fixed budget (GameStream: ¾ of the frame interval; native: the rate-cap
|
||||
/// spread from [`native_budget`]).
|
||||
Fixed(Duration),
|
||||
}
|
||||
|
||||
/// Absolute ceiling on one frame's paced spread (native plane): a pathological frame must not
|
||||
/// park the send thread for longer than this, whatever the rate math says. At the ceiling the
|
||||
/// tail is late but delivered whole — still strictly better than the blast-loss → freeze →
|
||||
/// recovery-IDR round trip it replaces.
|
||||
pub(crate) const MAX_PACE_SPREAD: Duration = Duration::from_millis(100);
|
||||
|
||||
/// The native plane's pace budget for one frame (pure — unit-tested): with the T1.2 rate cap
|
||||
/// active, the paced overflow spreads across exactly the time it needs at the pace rate
|
||||
/// (`cap`, bounded by [`MAX_PACE_SPREAD`]) and is NEVER under-cut by the frame deadline.
|
||||
///
|
||||
/// The old schedule took `min(0.9 × time-to-deadline, cap)`. For a steady-state frame the cap
|
||||
/// is the smaller term and nothing changes. But for an OVERSIZED frame — a stall-resume scene
|
||||
/// delta after seconds of frozen composition, a cold IDR — the overflow needs SEVERAL frame
|
||||
/// intervals at the pace rate, and the deadline term clamped that into the remainder of ONE:
|
||||
/// an instantaneous many-×-stream-rate blast that overruns the socket tx-buffer and loses the
|
||||
/// very frame that would have ended the freeze (field fingerprint: WSAENOBUFS 10055 +
|
||||
/// `loss_ppm` spikes at capture-stall edges, then a recovery-IDR round trip per retry). The
|
||||
/// pace rate is ~3× a rate the link demonstrably carries, so holding it past the deadline is
|
||||
/// safe by the same argument that introduced the cap — the deadline stays a *target*, not a
|
||||
/// license to blast.
|
||||
///
|
||||
/// `pace_rate_bps == 0` (PUNKTFUNK_PACE_FACTOR=0) or an overflow-free frame keeps the legacy
|
||||
/// deadline-only spread.
|
||||
pub(crate) fn native_budget(
|
||||
deadline: Instant,
|
||||
pace_rate_bps: u64,
|
||||
overflow_bytes: u64,
|
||||
) -> PaceBudget {
|
||||
if pace_rate_bps > 0 && overflow_bytes > 0 {
|
||||
let cap = Duration::from_nanos(
|
||||
(overflow_bytes * 8).saturating_mul(1_000_000_000) / pace_rate_bps,
|
||||
);
|
||||
PaceBudget::Fixed(cap.min(MAX_PACE_SPREAD))
|
||||
} else {
|
||||
PaceBudget::UntilDeadline {
|
||||
deadline,
|
||||
fraction: 0.9,
|
||||
cap: Duration::MAX,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-plane pacing parameters. See the module doc for the two canonical values.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct PaceCfg {
|
||||
@@ -598,6 +641,43 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// [`native_budget`]: with the rate cap active the budget is the overflow's wire time at
|
||||
/// the pace rate — a FIXED spread the deadline can no longer under-cut — bounded by
|
||||
/// [`MAX_PACE_SPREAD`]; rate 0 / no overflow keep the legacy deadline-only schedule.
|
||||
#[test]
|
||||
fn native_budget_is_rate_bound_never_deadline_cut() {
|
||||
// The stall-resume case the fix exists for: a 3 MB overflow at 3×240 Mbps needs
|
||||
// ~33 ms — an IMMINENT deadline (the old min() made this a blast) must not shrink it.
|
||||
let deadline = Instant::now() + Duration::from_millis(4); // 240 fps interval
|
||||
let b = native_budget(deadline, 720_000_000, 3_000_000);
|
||||
assert_eq!(b, PaceBudget::Fixed(Duration::from_nanos(33_333_333)));
|
||||
|
||||
// A steady-state frame: overflow 90 KB at 3×240 Mbps = 1 ms — identical to what the
|
||||
// old min(slack, cap) chose (cap was the smaller term), so nothing regresses.
|
||||
let b = native_budget(deadline, 720_000_000, 90_000);
|
||||
assert_eq!(b, PaceBudget::Fixed(Duration::from_micros(1_000)));
|
||||
|
||||
// A crater-rate resume (ABR backed off to 20 Mbps, pace 60 Mbps): the raw rate math
|
||||
// says 400 ms for 3 MB — the absolute ceiling bounds the send thread's stall.
|
||||
let b = native_budget(deadline, 60_000_000, 3_000_000);
|
||||
assert_eq!(b, PaceBudget::Fixed(MAX_PACE_SPREAD));
|
||||
|
||||
// Rate cap off (PUNKTFUNK_PACE_FACTOR=0): the legacy deadline-only spread, uncapped.
|
||||
let b = native_budget(deadline, 0, 3_000_000);
|
||||
assert!(matches!(
|
||||
b,
|
||||
PaceBudget::UntilDeadline {
|
||||
fraction,
|
||||
cap: Duration::MAX,
|
||||
..
|
||||
} if fraction == 0.9
|
||||
));
|
||||
|
||||
// No overflow (the whole frame bursts): budget is never consulted — legacy shape.
|
||||
let b = native_budget(deadline, 720_000_000, 0);
|
||||
assert!(matches!(b, PaceBudget::UntilDeadline { .. }));
|
||||
}
|
||||
|
||||
/// `inject_video_drop` is a no-op when the knob is off (the default test env).
|
||||
#[test]
|
||||
fn drop_injection_off_by_default() {
|
||||
|
||||
@@ -97,6 +97,31 @@ pub(crate) struct Entry {
|
||||
/// Host platforms this plugin works on (`linux`/`windows`/`macos`). Empty ⇒ all.
|
||||
#[serde(default)]
|
||||
pub platforms: Vec<String>,
|
||||
/// What kinds of plugin this is (`[a-z][a-z0-9-]{0,31}`, ≤4). The console filters Browse by
|
||||
/// these, and the Game sources surface's "Add a source" rail lists exactly the entries carrying
|
||||
/// `library` (design D5/D6). Additive: an older host ignores the field, a newer one just sees no
|
||||
/// categories on an older index.
|
||||
#[serde(default)]
|
||||
pub categories: Vec<String>,
|
||||
/// Optional per-platform "is this launcher installed here?" probes (design D8).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub detect: Option<DetectProbes>,
|
||||
}
|
||||
|
||||
/// Existence probes that let the console badge a catalog row "detected on this host" **without the
|
||||
/// host re-growing per-store knowledge** — the whole point of extracting the scanners. Store
|
||||
/// knowledge lives in the updatable, signed index; the host stays generic and only evaluates.
|
||||
///
|
||||
/// Deliberately anaemic: a probe is a path or an `HKLM\…` registry key, checked for EXISTENCE only.
|
||||
/// No reads, no content matching, no globbing beyond a single `*` segment. The index is
|
||||
/// operator-trusted but remotely updatable, so a probe must never be able to exfiltrate anything or
|
||||
/// cost more than a stat.
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
pub(crate) struct DetectProbes {
|
||||
#[serde(default)]
|
||||
pub linux: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub windows: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
@@ -228,9 +253,40 @@ impl Entry {
|
||||
self.platforms
|
||||
.retain(|p| matches!(p.as_str(), "linux" | "windows" | "macos"));
|
||||
self.platforms.truncate(4);
|
||||
// Categories and probes are cosmetic/advisory: a malformed one is dropped, never fatal to
|
||||
// the entry — a plugin must stay installable even if a future index writes a category this
|
||||
// host build has never heard of.
|
||||
self.categories.retain(|c| valid_category(c));
|
||||
self.categories.truncate(4);
|
||||
if let Some(d) = &mut self.detect {
|
||||
d.linux.retain(|p| valid_probe(p));
|
||||
d.windows.retain(|p| valid_probe(p));
|
||||
d.linux.truncate(MAX_PROBES);
|
||||
d.windows.truncate(MAX_PROBES);
|
||||
if d.linux.is_empty() && d.windows.is_empty() {
|
||||
self.detect = None;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Does this entry's platform probe match on the running host? `None` = the entry declares no
|
||||
/// probes for this platform, i.e. "unknown", which the console renders differently from "no".
|
||||
pub(crate) fn detected(&self) -> Option<bool> {
|
||||
let probes = self.detect.as_ref()?;
|
||||
let list = if cfg!(windows) {
|
||||
&probes.windows
|
||||
} else if cfg!(target_os = "linux") {
|
||||
&probes.linux
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
if list.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(list.iter().any(|p| probe_matches(p)))
|
||||
}
|
||||
|
||||
/// Is this entry installable on the running host? Returns the operator-facing reason when not.
|
||||
pub(crate) fn incompatible_reason(&self) -> Option<String> {
|
||||
if !self.platforms.is_empty() && !self.platforms.iter().any(|p| p == HOST_PLATFORM) {
|
||||
@@ -372,6 +428,94 @@ fn is_https(url: &str) -> bool {
|
||||
url.starts_with("https://") && url.len() > "https://".len()
|
||||
}
|
||||
|
||||
/// A plugin category (design D5): same shape the registration API accepts, so a plugin's declared
|
||||
/// category and its catalog row can never disagree about spelling.
|
||||
fn valid_category(c: &str) -> bool {
|
||||
(1..=32).contains(&c.len())
|
||||
&& c.starts_with(|ch: char| ch.is_ascii_lowercase())
|
||||
&& c.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||
}
|
||||
|
||||
/// How many probes one platform may declare — a handful of well-chosen paths covers any launcher,
|
||||
/// and the cap bounds the stat cost of rendering the catalog.
|
||||
const MAX_PROBES: usize = 8;
|
||||
|
||||
/// Is this a probe the host will evaluate? An **absolute** filesystem path with at most one `*`
|
||||
/// segment, or an `HKLM\…` registry key. Everything else is dropped.
|
||||
///
|
||||
/// The restrictions are the security model (D8). Absolute: a relative path would resolve against
|
||||
/// whatever the host's cwd happens to be. One `*` segment: bounded fan-out, so a probe can't walk a
|
||||
/// tree. `HKLM` only: `HKCU` is unreadable as LocalService anyway, and pointing the host at an
|
||||
/// arbitrary hive is not something a remote index should be able to ask for.
|
||||
fn valid_probe(p: &str) -> bool {
|
||||
if p.is_empty() || p.len() > 260 {
|
||||
return false;
|
||||
}
|
||||
if let Some(key) = p.strip_prefix("HKLM\\") {
|
||||
return !key.is_empty()
|
||||
&& !key.contains("..")
|
||||
&& key.bytes().all(|b| {
|
||||
b.is_ascii_alphanumeric() || matches!(b, b'\\' | b' ' | b'-' | b'_' | b'.')
|
||||
});
|
||||
}
|
||||
let b = p.as_bytes();
|
||||
let absolute = p.starts_with('/') || (b.len() >= 3 && b[1] == b':' && b[2] == b'\\');
|
||||
// No traversal, and at most ONE wildcard segment (`~` is not expanded — the host runs as a
|
||||
// service account whose home means nothing to a user's launcher install).
|
||||
absolute && !p.contains("..") && p.matches('*').count() <= 1
|
||||
}
|
||||
|
||||
/// Evaluate one probe: does the path (or registry key) exist? Existence only — never a read.
|
||||
fn probe_matches(p: &str) -> bool {
|
||||
#[cfg(windows)]
|
||||
if let Some(key) = p.strip_prefix("HKLM\\") {
|
||||
use std::os::windows::process::CommandExt;
|
||||
// `reg.exe query` rather than a registry crate: dependency-free, and it is exactly what a
|
||||
// library plugin will use for the same job under LocalService.
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
return std::process::Command::new("reg.exe")
|
||||
.args(["query", &format!("HKLM\\{key}")])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
if p.starts_with("HKLM\\") {
|
||||
return false; // a Windows probe on a POSIX host is simply not a match
|
||||
}
|
||||
match p.split_once('*') {
|
||||
None => std::path::Path::new(p).exists(),
|
||||
// One wildcard: list the parent of the wildcard segment and match the fixed prefix/suffix
|
||||
// around it. Bounded to a single directory read.
|
||||
Some((before, after)) => {
|
||||
let (dir, prefix) = match before.rfind(['/', '\\']) {
|
||||
Some(i) => (&before[..=i], &before[i + 1..]),
|
||||
None => return false, // a wildcard with no directory to anchor it
|
||||
};
|
||||
let (suffix, rest) = match after.find(['/', '\\']) {
|
||||
Some(i) => (&after[..i], &after[i..]),
|
||||
None => (after, ""),
|
||||
};
|
||||
let Ok(read) = std::fs::read_dir(dir) else {
|
||||
return false;
|
||||
};
|
||||
read.flatten().any(|e| {
|
||||
let name = e.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
name.starts_with(prefix)
|
||||
&& name.ends_with(suffix)
|
||||
&& name.len() >= prefix.len() + suffix.len()
|
||||
&& (rest.is_empty()
|
||||
|| e.path().join(rest.trim_start_matches(['/', '\\'])).exists())
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -401,6 +545,73 @@ mod tests {
|
||||
assert!(Index::parse(b"not json").is_err());
|
||||
}
|
||||
|
||||
/// WP2.8 is additive on purpose — SCHEMA stays 1. An index written by a newer curator must load
|
||||
/// on an older host (unknown fields ignored) and vice versa (absent fields default), or the
|
||||
/// signed-index rollout would need a flag day.
|
||||
#[test]
|
||||
fn categories_and_probes_are_additive_and_sanitized() {
|
||||
// An entry with NEITHER field — every index in the wild today.
|
||||
let e = &Index::parse(&doc(GOOD)).unwrap().plugins[0];
|
||||
assert!(e.categories.is_empty());
|
||||
assert!(e.detect.is_none());
|
||||
assert_eq!(e.detected(), None, "no probes ⇒ unknown, not `false`");
|
||||
|
||||
// With both, including rows that must be dropped rather than fail the entry.
|
||||
let rich = GOOD.trim_end_matches('}').to_string()
|
||||
+ r#","categories":["library","Bad Cat","x","y","z","w"],
|
||||
"detect":{"linux":["/usr/bin/steam","relative/path","/etc/../etc/passwd"],
|
||||
"windows":["HKLM\\SOFTWARE\\Valve\\Steam","HKCU\\SOFTWARE\\Valve"]}}"#;
|
||||
let e = &Index::parse(&doc(&rich)).unwrap().plugins[0];
|
||||
assert_eq!(
|
||||
e.categories,
|
||||
["library", "x", "y", "z"],
|
||||
"malformed dropped, capped at 4"
|
||||
);
|
||||
let d = e.detect.as_ref().expect("probes kept");
|
||||
assert_eq!(d.linux, ["/usr/bin/steam"], "relative + traversal dropped");
|
||||
assert_eq!(
|
||||
d.windows,
|
||||
["HKLM\\SOFTWARE\\Valve\\Steam"],
|
||||
"HKCU is not evaluable as LocalService — dropped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_shapes_are_bounded() {
|
||||
assert!(valid_probe("/usr/bin/steam"));
|
||||
assert!(
|
||||
valid_probe("/home/*/.steam"),
|
||||
"one wildcard segment is fine"
|
||||
);
|
||||
assert!(valid_probe(r"C:\Program Files (x86)\Steam\steam.exe"));
|
||||
assert!(valid_probe(r"HKLM\SOFTWARE\WOW6432Node\Valve\Steam"));
|
||||
// Rejected: relative, traversal, more than one wildcard, other hives, absurd length.
|
||||
assert!(!valid_probe("steam"));
|
||||
assert!(!valid_probe("/usr/../etc/passwd"));
|
||||
assert!(!valid_probe("/home/*/games/*/steam"));
|
||||
assert!(!valid_probe(r"HKCU\SOFTWARE\Valve"));
|
||||
assert!(!valid_probe(""));
|
||||
assert!(!valid_probe(&"/x".repeat(200)));
|
||||
}
|
||||
|
||||
/// The evaluator does existence checks only, against real paths, and never reads a byte.
|
||||
#[test]
|
||||
fn probes_evaluate_against_the_filesystem() {
|
||||
let dir = std::env::temp_dir().join(format!("pf-probe-{}", std::process::id()));
|
||||
let nested = dir.join("SteamLibrary-42");
|
||||
std::fs::create_dir_all(nested.join("steamapps")).unwrap();
|
||||
let d = dir.to_string_lossy().into_owned();
|
||||
|
||||
assert!(probe_matches(&format!("{d}/SteamLibrary-42")));
|
||||
assert!(!probe_matches(&format!("{d}/nope")));
|
||||
// One wildcard segment, with and without a trailing fixed component.
|
||||
assert!(probe_matches(&format!("{d}/SteamLibrary-*")));
|
||||
assert!(probe_matches(&format!("{d}/SteamLibrary-*/steamapps")));
|
||||
assert!(!probe_matches(&format!("{d}/SteamLibrary-*/nope")));
|
||||
assert!(!probe_matches(&format!("{d}/Other-*")));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_invalid_entries_but_keeps_the_rest() {
|
||||
let bad_unscoped = GOOD.replace("@punktfunk/plugin-rom-manager", "punktfunk-plugin-x");
|
||||
|
||||
@@ -108,10 +108,16 @@ the full path: `& "$env:ProgramFiles\punktfunk\punktfunk-host.exe" plugins add p
|
||||
Open the [web console](/docs/web-console) and the plugin's page appears in the nav automatically —
|
||||
that's the whole install.
|
||||
|
||||
The runner is **opt-in**: `plugins add` installs, `plugins enable` turns it on. You only need
|
||||
`enable` once. The runner discovers plugins when it starts, so one installed later needs a restart
|
||||
to come up (`systemctl --user restart punktfunk-scripting`, or `Restart` the `PunktfunkScripting`
|
||||
task) — the console does that restart for you as part of installing.
|
||||
The runner is **on by default** on a new install — your game sources are plugins, so a host without
|
||||
it would show an empty library. (On a host that predates this, it stays however you left it; turn it
|
||||
on with `punktfunk-host plugins enable`, which you only need once.) The runner discovers plugins
|
||||
when it starts, so one installed later needs a restart to come up
|
||||
(`systemctl --user restart punktfunk-scripting`, or `Restart` the `PunktfunkScripting` task) — the
|
||||
console does that restart for you as part of installing.
|
||||
|
||||
Don't want it? It is a normal service you can switch off: `systemctl --user mask punktfunk-scripting`
|
||||
on Linux, or disable the `PunktfunkScripting` scheduled task on Windows. Your host keeps streaming;
|
||||
you just lose plugin-provided game sources and any automation.
|
||||
|
||||
A plugin installed from the CLI shows up in the console as **Installed via CLI**: the console knows
|
||||
what is installed, but not who vouched for it. Install the same plugin from the store's Browse tab
|
||||
@@ -301,8 +307,8 @@ host's, on one timeline, with the same search and download. Each is tagged `plug
|
||||
plugin's own name for lines it logged itself, `plugin:runner` for the supervisor's (starting a
|
||||
plugin, restarting a crashed one, refusing an unsafe file).
|
||||
|
||||
An empty Plugins view almost always means the runner isn't running — it is a separate service, and
|
||||
opt-in on Linux. Check with `punktfunk-host plugins status`.
|
||||
An empty Plugins view almost always means the runner isn't running — it is a separate service. Check
|
||||
with `punktfunk-host plugins status`.
|
||||
|
||||
<Callout>
|
||||
Nothing is lost if the host is down: the runner keeps buffering and sends the backlog when the host
|
||||
|
||||
+158
-3
@@ -70,7 +70,13 @@
|
||||
// says what it says — so v15 is the floor that *guarantees* them: at or above it the surface is
|
||||
// present, below it an embedder must probe for the symbol. Purely a version statement; no code
|
||||
// changed with this bump, and no wire change, so [`WIRE_VERSION`] is unchanged.
|
||||
#define PUNKTFUNK_ABI_VERSION 15
|
||||
// v16: added the pad-audio client surface — `punktfunk_connection_next_pad_audio` (the 0xD1
|
||||
// per-gamepad DualSense haptics/speaker plane) + `punktfunk_connection_set_pad_audio_caps` and
|
||||
// the `PUNKTFUNK_CLIENT_CAP_PAD_AUDIO` / `PUNKTFUNK_HOST_CAP_PAD_AUDIO` mirrors. Additive and
|
||||
// capability-gated end to end: the wire grows a new datagram tag (0xD1) an old client never
|
||||
// receives (double-gated caps), a new 0xCD kind (0x06, dropped as unknown by old clients) and
|
||||
// arrival flag bits 8/9 sent only toward a capable host, so [`WIRE_VERSION`] is unchanged.
|
||||
#define PUNKTFUNK_ABI_VERSION 16
|
||||
|
||||
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
@@ -94,6 +100,13 @@
|
||||
// little-endian `u16`s with `effect_len = 6`. Clients without trackpad coils drop it.
|
||||
#define PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC 4
|
||||
|
||||
// `PunktfunkHidOutput::kind` — the audio-control region of a DS5 output report (pad-audio
|
||||
// routing/volumes; the audio SAMPLES arrive via [`punktfunk_connection_next_pad_audio`]).
|
||||
// `which` = the condensed audio flags (bit0 = haptics-select, bits1..4 = the report's
|
||||
// audio-valid flags); `effect[0..6]` = bytes 5..=10 of the report verbatim
|
||||
// (headphone/speaker/mic volumes + routing) with `effect_len = 6`. Forwarded change-only.
|
||||
#define PUNKTFUNK_HIDOUT_AUDIO_CTL 5
|
||||
|
||||
// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block).
|
||||
#define PUNKTFUNK_HID_EFFECT_MAX 11
|
||||
|
||||
@@ -278,6 +291,28 @@
|
||||
// design/pen-tablet-input.md.)
|
||||
#define PUNKTFUNK_HOST_CAP_PEN 16
|
||||
|
||||
// Host-capability bit in [`punktfunk_connection_host_caps`]: the host can capture per-gamepad
|
||||
// audio (DualSense voice-coil haptics + speaker) and emit it on the 0xD1 plane toward pads
|
||||
// declared capable via [`punktfunk_connection_set_pad_audio_caps`]. Set only when the client
|
||||
// asked via [`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO`]. (Mirrors `quic::HOST_CAP_PAD_AUDIO`.)
|
||||
#define PUNKTFUNK_HOST_CAP_PAD_AUDIO 64
|
||||
|
||||
// Pad-audio `kind` ([`punktfunk_connection_next_pad_audio`]): the BACK channel pair — DualSense
|
||||
// voice-coil haptics, 5 ms Opus frames. (Mirrors `quic::PAD_AUDIO_KIND_HAPTICS`.)
|
||||
#define PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS 0
|
||||
|
||||
// Pad-audio `kind`: the FRONT channel pair — the controller's built-in speaker, 10 ms Opus
|
||||
// frames. (Mirrors `quic::PAD_AUDIO_KIND_SPEAKER`.)
|
||||
#define PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER 1
|
||||
|
||||
// [`punktfunk_connection_set_pad_audio_caps`] `audio_caps` bit: the pad renders the HAPTICS
|
||||
// stream (a real DualSense's voice coils).
|
||||
#define PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS 1
|
||||
|
||||
// [`punktfunk_connection_set_pad_audio_caps`] `audio_caps` bit: the pad renders the SPEAKER
|
||||
// stream.
|
||||
#define PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER 2
|
||||
|
||||
// [`punktfunk_connect_ex9`] `client_caps` bit: render the host cursor locally (the cursor
|
||||
// channel, `design/remote-desktop-sweep.md` M2).
|
||||
#define PUNKTFUNK_CLIENT_CAP_CURSOR 1
|
||||
@@ -288,6 +323,13 @@
|
||||
// forward-compatible.
|
||||
#define PUNKTFUNK_CLIENT_CAP_PHASE_LOCK 2
|
||||
|
||||
// [`punktfunk_connect_ex9`] `client_caps` bit: the client understands the pad-audio plane
|
||||
// (0xD1 — per-gamepad DualSense voice-coil haptics + speaker). The embedder MUST then drain
|
||||
// [`punktfunk_connection_next_pad_audio`] and declare each capable pad via
|
||||
// [`punktfunk_connection_set_pad_audio_caps`]; the host emits pad audio only when it answers
|
||||
// with [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`]. (Mirrors `quic::CLIENT_CAP_PAD_AUDIO`.)
|
||||
#define PUNKTFUNK_CLIENT_CAP_PAD_AUDIO 8
|
||||
|
||||
// `*ttl_ms` sentinel written by [`punktfunk_connection_next_rumble2`] for a legacy (v1) rumble
|
||||
// datagram — an old host that sent no self-termination lease. The client then falls back to its
|
||||
// own staleness heuristic for that update instead of a host-supplied deadline.
|
||||
@@ -367,6 +409,19 @@
|
||||
// Fixed serialized size of an [`InputEvent`] on the wire (tag + fields).
|
||||
#define PUNKTFUNK_INPUT_WIRE_LEN (((((1 + 1) + 4) + 4) + 4) + 4)
|
||||
|
||||
// [`InputKind::GamepadArrival`] `flags` bit: this pad renders pad-audio HAPTICS — it is (or
|
||||
// forwards to) a real DualSense whose voice-coil actuators can play the
|
||||
// [`PAD_AUDIO_KIND_HAPTICS`](crate::quic::PAD_AUDIO_KIND_HAPTICS) stream. Rides above the pad
|
||||
// index byte; sent only toward a [`HOST_CAP_PAD_AUDIO`](crate::quic::HOST_CAP_PAD_AUDIO) host
|
||||
// (an older host reads the whole `flags` word as the index, so unexpected high bits would make
|
||||
// it drop the declaration).
|
||||
#define ARRIVAL_FLAG_PAD_AUDIO_HAPTICS (1 << 8)
|
||||
|
||||
// [`InputKind::GamepadArrival`] `flags` bit: this pad renders pad-audio SPEAKER — the
|
||||
// [`PAD_AUDIO_KIND_SPEAKER`](crate::quic::PAD_AUDIO_KIND_SPEAKER) stream. Same wire discipline
|
||||
// as [`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`].
|
||||
#define ARRIVAL_FLAG_PAD_AUDIO_SPEAKER (1 << 9)
|
||||
|
||||
// The number of gamepads addressable on the wire (`flags` pad index 0..15). Shared by the
|
||||
// client's snapshot fold and the host's per-pad accumulators.
|
||||
#define PUNKTFUNK_MAX_PADS 16
|
||||
@@ -675,6 +730,18 @@
|
||||
#define PUNKTFUNK_CLIENT_CAP_AUDIO_RED 4
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Hello::client_caps`] bit: the client understands the pad-audio plane
|
||||
// ([`PAD_AUDIO_MAGIC`](super::datagram::PAD_AUDIO_MAGIC), `0xD1`) — per-gamepad DualSense
|
||||
// voice-coil haptics + speaker Opus frames, plus the [`HidOutput::AudioCtl`]
|
||||
// (super::datagram::HidOutput) routing/volume events. Active only when the host answers with
|
||||
// [`HOST_CAP_PAD_AUDIO`] AND the pad's arrival declared a renderer for the kind
|
||||
// ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`) — the capable-and-agreed
|
||||
// precedent, per pad; toward an older or incapable host nothing changes. `0x08` — `0x01` is [`CLIENT_CAP_CURSOR`],
|
||||
// `0x02` is [`CLIENT_CAP_PHASE_LOCK`], `0x04` is [`CLIENT_CAP_AUDIO_RED`].
|
||||
#define CLIENT_CAP_PAD_AUDIO 8
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Welcome::host_caps`] bit: the host CAN forward the cursor out-of-band (it captures cursor
|
||||
// metadata separately from the frame — the Linux portal `SPA_META_Cursor` path; NOT gamescope,
|
||||
@@ -714,6 +781,19 @@
|
||||
#define PUNKTFUNK_HOST_CAP_AUDIO_RED 32
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Welcome::host_caps`] bit: the host can capture pad audio — its virtual DualSense exposes
|
||||
// the pad's audio endpoints (voice-coil haptics + speaker), so a game's per-pad audio can be
|
||||
// captured and shipped on the [`PAD_AUDIO_MAGIC`](super::datagram::PAD_AUDIO_MAGIC) plane.
|
||||
// Set only when the client asked via [`CLIENT_CAP_PAD_AUDIO`]; when both bits agree, a
|
||||
// capable client marks its pads' render capabilities on their arrivals
|
||||
// ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`) and the host emits `0xD1`
|
||||
// toward exactly those pads. `0x40` — `0x20` is [`HOST_CAP_AUDIO_RED`], `0x10` is
|
||||
// [`HOST_CAP_PEN`], `0x08` is [`HOST_CAP_CURSOR`], `0x04` is [`HOST_CAP_TEXT_INPUT`],
|
||||
// `0x01`/`0x02` are gamepad-state / clipboard.
|
||||
#define HOST_CAP_PAD_AUDIO 64
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
||||
// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
||||
@@ -1011,7 +1091,9 @@
|
||||
// audio = [`AUDIO_MAGIC`] (0xC9, host→client), rumble = [`RUMBLE_MAGIC`] (0xCA, host→client),
|
||||
// mic = [`MIC_MAGIC`] (0xCB, client→host), rich-input = [`RICH_INPUT_MAGIC`] (0xCC, client→host),
|
||||
// HID-output = [`HIDOUT_MAGIC`] (0xCD, host→client), HDR metadata = [`HDR_META_MAGIC`]
|
||||
// (0xCE, host→client).
|
||||
// (0xCE, host→client), host timing = [`HOST_TIMING_MAGIC`] (0xCF, host→client), cursor state =
|
||||
// [`CURSOR_STATE_MAGIC`] (0xD0, host→client), pad audio = [`PAD_AUDIO_MAGIC`] (0xD1,
|
||||
// host→client).
|
||||
#define PUNKTFUNK_AUDIO_MAGIC 201
|
||||
#endif
|
||||
|
||||
@@ -1162,6 +1244,31 @@
|
||||
#define PUNKTFUNK_CURSOR_RELATIVE_HINT 2
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Pad-audio datagram tag, host → client: per-gamepad audio a game routed
|
||||
// to the host's virtual DualSense — voice-coil haptics and the built-in speaker — for the client
|
||||
// to render on the matching real controller. Next tag after [`CURSOR_STATE_MAGIC`]. The
|
||||
// per-pad AUDIO plane (Opus frames, the [`AUDIO_MAGIC`]/[`MIC_MAGIC`] shape plus pad + kind);
|
||||
// the routing/volume CONTROL side rides [`HidOutput::AudioCtl`]. Emitted only when the session
|
||||
// negotiated it ([`CLIENT_CAP_PAD_AUDIO`](super::caps::CLIENT_CAP_PAD_AUDIO) ∧
|
||||
// [`HOST_CAP_PAD_AUDIO`](super::caps::HOST_CAP_PAD_AUDIO)) and the pad's arrival declared a
|
||||
// renderer for the kind ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`).
|
||||
// Best-effort like every audio datagram: a lost frame is a concealed gap, never state.
|
||||
#define PAD_AUDIO_MAGIC 209
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`PadAudioFrame::kind`]: the BACK channel pair — the DualSense voice-coil actuators (audio
|
||||
// haptics). 5 ms Opus frames, matching the [`AUDIO_MAGIC`] cadence: haptics are felt latency.
|
||||
#define PAD_AUDIO_KIND_HAPTICS 0
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`PadAudioFrame::kind`]: the FRONT channel pair — the controller's built-in speaker. 10 ms
|
||||
// Opus frames (speaker content tolerates the extra buffering for the better coding efficiency).
|
||||
#define PAD_AUDIO_KIND_SPEAKER 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// QUIC application error code a punktfunk/1 client closes the control connection with on a
|
||||
// **deliberate quit** (a user "stop", not a network drop). The host reads it off the connection's
|
||||
@@ -1476,7 +1583,11 @@ enum PunktfunkInputKind
|
||||
PUNKTFUNK_INPUT_KIND_GAMEPAD_REMOVE = 13,
|
||||
// Declares which controller KIND a pad presents so a session can MIX types (pad 0 a
|
||||
// DualSense, pad 1 an Xbox pad). `code` = the [`GamepadPref`](crate::config::GamepadPref)
|
||||
// wire byte, `flags` = pad index. Sent when the client opens a pad slot — before that pad's
|
||||
// wire byte, `flags` = pad index in the low byte plus the pad's render capabilities in bits
|
||||
// 8/9 ([`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/[`ARRIVAL_FLAG_PAD_AUDIO_SPEAKER`] — sent only
|
||||
// toward a [`HOST_CAP_PAD_AUDIO`](crate::quic::HOST_CAP_PAD_AUDIO) host, so an older host
|
||||
// keeps reading the whole word as the index; hosts decode via [`decode_gamepad_arrival`]).
|
||||
// Sent when the client opens a pad slot — before that pad's
|
||||
// first input — and re-sent a few times against datagram loss (like [`GamepadRemove`]). The
|
||||
// host resolves the kind to a buildable backend and routes that pad's virtual device to it; a
|
||||
// pad the client never declares (an older client, or a fully-lost declaration) falls back to
|
||||
@@ -2404,6 +2515,50 @@ PunktfunkStatus punktfunk_connection_next_audio_pcm(PunktfunkConnection *c,
|
||||
uint32_t timeout_ms);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Pull the next pad-audio frame (0xD1) — one Opus frame of DualSense voice-coil haptics
|
||||
// (`kind` = [`PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS`], 5 ms) or built-in-speaker audio
|
||||
// ([`PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER`], 10 ms) for gamepad `*out_pad` — waiting up to
|
||||
// `timeout_ms`. The payload is COPIED into `buf` (no borrow-until-next-call slot); the return
|
||||
// value is its length in bytes, `0` = nothing this poll (timeout — or a DTX/oversized frame,
|
||||
// both of which an embedder treats the same way), `-1` = the session ended (or an invalid
|
||||
// handle/buffer). All pads/kinds share one queue — fan out by `*out_pad`/`*out_kind` to
|
||||
// per-actuator Opus decoders. A frame larger than `buf_len` is dropped like the timeout case
|
||||
// (the plane is lossy by design; any real Opus frame fits a 1500-byte buffer). Only a session
|
||||
// connected with [`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO`] against a
|
||||
// [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`] host — with the pad declared via
|
||||
// [`punktfunk_connection_set_pad_audio_caps`] — ever receives any. Drain from a dedicated
|
||||
// thread (one puller, may run alongside the other planes' pullers).
|
||||
//
|
||||
// # Safety
|
||||
// `c` is a valid connection handle; the `out_*` pointers are writable (NULLs are skipped);
|
||||
// `buf` is writable for `buf_len` bytes.
|
||||
int32_t punktfunk_connection_next_pad_audio(PunktfunkConnection *c,
|
||||
uint8_t *out_pad,
|
||||
uint8_t *out_kind,
|
||||
uint32_t *out_seq,
|
||||
uint64_t *out_pts_ns,
|
||||
uint8_t *buf,
|
||||
uintptr_t buf_len,
|
||||
uint32_t timeout_ms);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Declare wire pad `pad`'s pad-audio render capabilities (`audio_caps`: OR of
|
||||
// [`PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS`] / [`PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER`]) — how a client
|
||||
// tells the host WHICH pads can actually play the 0xD1 streams. Call at controller attach,
|
||||
// BEFORE the pad's arrival event is sent (the [`punktfunk_connection_set_rumble_quirks`]
|
||||
// timing): the core folds the bits into the arrival's flags (bits 8/9), and only toward a
|
||||
// [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`] host — never calling this leaves the wire bytes exactly as
|
||||
// before. Latest-wins per pad; unknown bits are masked off.
|
||||
//
|
||||
// # Safety
|
||||
// `c` is a valid connection handle. Callable from any thread.
|
||||
PunktfunkStatus punktfunk_connection_set_pad_audio_caps(PunktfunkConnection *c,
|
||||
uint8_t pad,
|
||||
uint8_t audio_caps);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Pull the next rumble (force-feedback) update, waiting up to `timeout_ms`. Amplitudes
|
||||
// are 0..0xFFFF (`low` = low-frequency motor, `high` = high-frequency), `(0, 0)` = stop.
|
||||
|
||||
@@ -98,6 +98,23 @@ if [ -n "$GAMESCOPE" ]; then
|
||||
install -Dm0755 "$GAMESCOPE" "$STAGE/usr/bin/punktfunk-gamescope"
|
||||
fi
|
||||
|
||||
# Enable the plugin/script runner for every user, by baking its `[Install] WantedBy=default.target`
|
||||
# symlink straight into the image.
|
||||
#
|
||||
# A sysext carries only /usr, and RPM scriptlets never run from one — so the `systemctl --global
|
||||
# enable` the .rpm/.deb do at install time has no equivalent here, and without this the runner would
|
||||
# ship present-but-off on exactly the platform (Bazzite / Fedora Atomic) where an operator is least
|
||||
# likely to go hunting for it. The game-library scanners are plugins now (design D9), so an
|
||||
# unenabled runner means an empty library.
|
||||
#
|
||||
# Opt-out is unchanged and still wins: `systemctl --user mask punktfunk-scripting` in the user's own
|
||||
# ~/.config/systemd/user takes precedence over anything under /usr.
|
||||
if [ -f "$STAGE/usr/lib/systemd/user/punktfunk-scripting.service" ]; then
|
||||
install -d "$STAGE/usr/lib/systemd/user/default.target.wants"
|
||||
ln -sf ../punktfunk-scripting.service \
|
||||
"$STAGE/usr/lib/systemd/user/default.target.wants/punktfunk-scripting.service"
|
||||
fi
|
||||
|
||||
# Self-update: the helper rides inside the image.
|
||||
install -Dm0755 "$HERE/punktfunk-sysext.sh" "$STAGE/usr/bin/punktfunk-sysext"
|
||||
|
||||
|
||||
@@ -114,20 +114,36 @@ Description: punktfunk plugin/script runner (Effect SDK on bun)
|
||||
capped-jittered restart; SIGTERM shuts the whole tree down structurally so plugin finalizers run).
|
||||
Bundles its own bun runtime (no system nodejs/bun dependency).
|
||||
.
|
||||
OPT-IN: the systemd --user unit is installed but not auto-enabled (the runner is inert until you add
|
||||
scripts or plugins). A plugin auto-wires to the host's mgmt token + identity cert on the same box —
|
||||
no env editing. Enable it with: systemctl --user enable --now punktfunk-scripting
|
||||
ON BY DEFAULT: the systemd --user unit is enabled for every user (systemctl --global). The runner is
|
||||
inert until you add scripts or plugins, and the game-library scanners now ship AS plugins — so a
|
||||
host without the runner has an empty library and no obvious reason why. A plugin auto-wires to the
|
||||
host's mgmt token + identity cert on the same box — no env editing.
|
||||
Opt out per user with: systemctl --user mask punktfunk-scripting
|
||||
EOF
|
||||
|
||||
cat > "$STAGE/DEBIAN/postinst" <<'EOF'
|
||||
#!/bin/sh
|
||||
set -e
|
||||
if [ "$1" = "configure" ]; then
|
||||
echo "punktfunk-scripting installed. It runs your automation — add scripts to"
|
||||
# `--global`, not `--user`: a maintainer script has no user session to act on, and this is the
|
||||
# only mechanism that makes a `--user` unit on-by-default for everyone (it symlinks into
|
||||
# /etc/systemd/user/…wants/). The library's scanners are plugins now, so the runner is a default
|
||||
# component rather than an add-on (design D9) — but installing it stays opt-OUT, and the opt-out
|
||||
# is `systemctl --user mask punktfunk-scripting`, since a plain `--user disable` cannot remove a
|
||||
# global symlink.
|
||||
#
|
||||
# Only on FIRST configure ($2 empty): re-running it on every upgrade would silently undo the
|
||||
# mask of anyone who turned it off.
|
||||
if [ -z "$2" ] && command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl --global enable punktfunk-scripting.service >/dev/null 2>&1 || true
|
||||
fi
|
||||
echo "punktfunk-scripting installed and enabled for all users."
|
||||
echo "It runs your automation — game-library sources, scripts in"
|
||||
echo " ~/.config/punktfunk/scripts/ (loose .ts/.js files)"
|
||||
echo "or install plugins into ~/.config/punktfunk/plugins/ (bun add punktfunk-plugin-<name>),"
|
||||
echo "then enable the runner for your user:"
|
||||
echo " systemctl --user enable --now punktfunk-scripting"
|
||||
echo "and plugins under ~/.config/punktfunk/plugins/."
|
||||
echo "It starts with your next login; start it now with:"
|
||||
echo " systemctl --user start punktfunk-scripting"
|
||||
echo "Don't want it? systemctl --user mask punktfunk-scripting"
|
||||
fi
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
@@ -191,9 +191,10 @@ The plugin/script runner for a punktfunk streaming host: it discovers loose scri
|
||||
~/.config/punktfunk/scripts and installed punktfunk-plugin-* packages under ~/.config/punktfunk/
|
||||
plugins, and supervises each as an Effect fiber (capped-jittered restart; SIGTERM shuts the whole
|
||||
tree down structurally so plugin finalizers run). A plugin auto-wires to the host's mgmt token +
|
||||
identity cert on the same box — no env editing. Bundles its own bun runtime. OPT-IN: the systemd
|
||||
--user unit ships disabled (the runner is inert until you add scripts/plugins). Enable with
|
||||
`systemctl --user enable --now punktfunk-scripting`.
|
||||
identity cert on the same box — no env editing. Bundles its own bun runtime. ON BY DEFAULT: the
|
||||
systemd --user unit is enabled for every user (systemctl --global). The game-library scanners ship
|
||||
as plugins, so a host without the runner has an empty library. Opt out per user with
|
||||
`systemctl --user mask punktfunk-scripting`.
|
||||
%endif
|
||||
|
||||
%prep
|
||||
@@ -590,10 +591,22 @@ echo "Then open https://<host-ip>:47992"
|
||||
|
||||
%if %{with scripting}
|
||||
%post scripting
|
||||
echo "punktfunk-scripting installed. It runs your automation — add scripts to"
|
||||
# `--global`, not `--user`: a scriptlet has no user session to act on, and this is the only
|
||||
# mechanism that makes a `--user` unit on-by-default for everyone (it symlinks into
|
||||
# /etc/systemd/user/…wants/). The game-library scanners are plugins now, so the runner is a default
|
||||
# component rather than an add-on (design D9); it stays opt-OUT via
|
||||
# `systemctl --user mask punktfunk-scripting`, since a plain `--user disable` cannot remove a global
|
||||
# symlink. $1 == 1 is a first INSTALL — on an upgrade ($1 > 1) this must not undo an operator's mask.
|
||||
if [ "$1" -eq 1 ] && command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl --global enable punktfunk-scripting.service >/dev/null 2>&1 || :
|
||||
fi
|
||||
echo "punktfunk-scripting installed and enabled for all users."
|
||||
echo "It runs your automation — game-library sources, scripts in"
|
||||
echo " ~/.config/punktfunk/scripts/ (loose .ts/.js files)"
|
||||
echo "or install plugins into ~/.config/punktfunk/plugins/ (bun add punktfunk-plugin-<name>),"
|
||||
echo "then enable the runner: systemctl --user enable --now punktfunk-scripting"
|
||||
echo "and plugins under ~/.config/punktfunk/plugins/."
|
||||
echo "It starts with your next login; start it now with:"
|
||||
echo " systemctl --user start punktfunk-scripting"
|
||||
echo "Don't want it? systemctl --user mask punktfunk-scripting"
|
||||
%endif
|
||||
|
||||
%changelog
|
||||
|
||||
@@ -329,9 +329,8 @@ Filename: "{app}\punktfunk-host.exe"; Parameters: "web setup {code:WebSetupParam
|
||||
; converges tasks an older installer registered as SYSTEM.
|
||||
; Best-effort (-ErrorAction SilentlyContinue): a task hiccup never fails the whole install. No braces
|
||||
; in the command, so no Inno {{ }} escaping needed.
|
||||
Filename: "powershell.exe"; \
|
||||
Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""$a=New-ScheduledTaskAction -Execute '{app}\scripting\scripting-run.cmd'; $t=New-ScheduledTaskTrigger -AtStartup; $p=New-ScheduledTaskPrincipal -UserId 'LocalService' -LogonType ServiceAccount; $s=New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries; Register-ScheduledTask -TaskName PunktfunkScripting -Action $a -Trigger $t -Principal $p -Settings $s -Force -ErrorAction SilentlyContinue | Out-Null; Disable-ScheduledTask -TaskName PunktfunkScripting -ErrorAction SilentlyContinue | Out-Null"""; \
|
||||
StatusMsg: "Registering the Punktfunk script runner (disabled; opt-in)..."; Flags: runhidden waituntilterminated
|
||||
Filename: "powershell.exe"; Parameters: "{code:ScriptingRegisterParams}"; \
|
||||
StatusMsg: "Registering the Punktfunk script runner..."; Flags: runhidden waituntilterminated
|
||||
#endif
|
||||
#if defined(WithWeb) || defined(WithScripting)
|
||||
; Put back what StopBunRuntimes disabled to unlock bun.exe. Deliberately the LAST [Run] entry that
|
||||
@@ -619,6 +618,12 @@ end;
|
||||
it disabled would switch it off for everyone who had it on. }
|
||||
var
|
||||
WebTaskWasEnabled, ScriptingTaskWasEnabled: Boolean;
|
||||
{ Did PunktfunkScripting exist AT ALL before this install (enabled or not)? That is what
|
||||
distinguishes a FRESH scripting install — where the runner is now registered enabled by default
|
||||
(design D9: the library moves into plugins, and a flagship surface cannot depend on an opt-in
|
||||
subsystem, or a fresh box would come up with an empty library) — from an UPGRADE, where the
|
||||
operator's own choice is the only thing that may decide it. }
|
||||
ScriptingTaskExisted: Boolean;
|
||||
|
||||
{ Escape a value for embedding in a single-quoted PowerShell literal ('' is PS's escaped quote).
|
||||
The install dir is user-chosen, so it can legitimately contain an apostrophe. }
|
||||
@@ -643,6 +648,22 @@ begin
|
||||
Result := ResultCode = 1;
|
||||
end;
|
||||
|
||||
{ Is the task registered at all, whatever its state? Distinct from TaskEnabled: an operator who
|
||||
deliberately DISABLED the runner must keep it disabled across an upgrade, which is indistinguishable
|
||||
from a fresh install if you only ask "was it enabled". }
|
||||
function TaskExists(TaskName: String): Boolean;
|
||||
var
|
||||
ResultCode: Integer;
|
||||
begin
|
||||
Result := False;
|
||||
if Exec('powershell.exe',
|
||||
'-NoProfile -ExecutionPolicy Bypass -Command "' +
|
||||
'$t=Get-ScheduledTask -TaskName ''' + PsLiteral(TaskName) + ''' -ErrorAction SilentlyContinue; ' +
|
||||
'if($t){exit 1}; exit 0"',
|
||||
'', SW_HIDE, ewWaitUntilTerminated, ResultCode) then
|
||||
Result := ResultCode = 1;
|
||||
end;
|
||||
|
||||
{ Free the bundled bun.exe (and the console's own files) BEFORE the copy. Windows will not delete a
|
||||
running image, so a surviving bun means "DeleteFile failed; code 5" on bun\bun.exe - the modal a
|
||||
user hit updating to 0.22.1.
|
||||
@@ -664,6 +685,9 @@ var
|
||||
begin
|
||||
WebTaskWasEnabled := TaskEnabled('PunktfunkWeb');
|
||||
ScriptingTaskWasEnabled := TaskEnabled('PunktfunkScripting');
|
||||
{ Probed BEFORE the Disable below, which would otherwise make every upgrade look like a fresh
|
||||
install to the registration entry. }
|
||||
ScriptingTaskExisted := TaskExists('PunktfunkScripting');
|
||||
Exec('powershell.exe',
|
||||
'-NoProfile -ExecutionPolicy Bypass -Command "' +
|
||||
'$ErrorActionPreference=''SilentlyContinue''; ' +
|
||||
@@ -689,6 +713,35 @@ end;
|
||||
DELETED the legacy task (the console runs under the host service now), so Enable-ScheduledTask
|
||||
hits nothing and no-ops under SilentlyContinue. If the user cancels mid-install, though,
|
||||
DeinitializeSetup runs this same restore and puts the old (task-owned) world back intact. }
|
||||
{ Register PunktfunkScripting, and decide whether it comes up ENABLED.
|
||||
`Register-ScheduledTask` registers enabled, so the state is decided by what follows:
|
||||
* FRESH install (the task did not exist) -> leave it enabled and start it now, so the runner is
|
||||
live without waiting for a reboot. Since the library's scanners become plugins (design D9),
|
||||
shipping this opt-in would mean a fresh box comes up with an empty library and no obvious
|
||||
reason why.
|
||||
* UPGRADE (the task existed) -> disable here and let RestoreTasksParams put the operator's own
|
||||
state back. That order is deliberate: this entry cannot know what they chose, and defaulting
|
||||
to "on" here would silently switch the runner on for everyone who had turned it off.
|
||||
It remains opt-OUT: `punktfunk-host plugins disable`, or the task's own Disable, still wins and
|
||||
survives every later upgrade through exactly this path. }
|
||||
function ScriptingRegisterParams(Param: String): String;
|
||||
begin
|
||||
Result := '-NoProfile -ExecutionPolicy Bypass -Command "' +
|
||||
'$ErrorActionPreference=''SilentlyContinue''; ' +
|
||||
'$a=New-ScheduledTaskAction -Execute ''' +
|
||||
PsLiteral(ExpandConstant('{app}\scripting\scripting-run.cmd')) + '''; ' +
|
||||
'$t=New-ScheduledTaskTrigger -AtStartup; ' +
|
||||
'$p=New-ScheduledTaskPrincipal -UserId ''LocalService'' -LogonType ServiceAccount; ' +
|
||||
'$s=New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) ' +
|
||||
'-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries; ' +
|
||||
'Register-ScheduledTask -TaskName PunktfunkScripting -Action $a -Trigger $t -Principal $p ' +
|
||||
'-Settings $s -Force | Out-Null; ';
|
||||
if ScriptingTaskExisted then
|
||||
Result := Result + 'Disable-ScheduledTask -TaskName PunktfunkScripting | Out-Null"'
|
||||
else
|
||||
Result := Result + 'Start-ScheduledTask -TaskName PunktfunkScripting | Out-Null"';
|
||||
end;
|
||||
|
||||
function RestoreTasksParams(Param: String): String;
|
||||
begin
|
||||
Result := '-NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference=''SilentlyContinue''; ';
|
||||
|
||||
@@ -53,6 +53,41 @@ export default definePluginKit({
|
||||
| `loggingLayer` | runner-journal line format |
|
||||
| `@punktfunk/plugin-kit/react` | browser glue: `createPluginRouter` (path→hash→fallback deep-link restore + `pf-ui:navigate`), `resolvePluginBase`, `useIsEmbedded`, `ResultGate`, `sseAtom` |
|
||||
| `@punktfunk/plugin-kit/theme.css` | the console's violet identity for plugin UIs (import first in your Tailwind entry) |
|
||||
| `@punktfunk/plugin-kit/library` | everything a **game-library scanner** plugin needs — see below |
|
||||
|
||||
## Library-scanner plugins (`@punktfunk/plugin-kit/library`)
|
||||
|
||||
The six first-party scanners (steam, lutris, heroic, epic, gog, xbox) each live in **their own
|
||||
repo**, like every other punktfunk plugin. Nothing is lost by that split because everything they
|
||||
share is published here rather than sitting adjacent to them:
|
||||
|
||||
| Export | What it saves you writing |
|
||||
| --- | --- |
|
||||
| `defineLibraryPlugin` | the whole plugin except the scan: store claim, sync engine (poll + fs-watch + debounce), launcher entries, `__config`, `category: "library"` registration, and the `detect` / `scan` / `parity` / `uninstall` CLI verbs |
|
||||
| `parsers/*` | text VDF + `.acf`, binary `shortcuts.vdf` (with the CRC-32 appid and the 64-bit `rungameid` composition), read-only SQLite, `reg.exe`, capped readers, a confined path join, Steam root/library discovery, art location helpers, an anti-SSRF fetch |
|
||||
| `diffParity` + the `parity` verb | the acceptance gate below |
|
||||
|
||||
A first-party scanner is therefore **its parsers and a `scan` function** — a few hundred lines.
|
||||
|
||||
### The parity gate
|
||||
|
||||
Ported unit tests pin the parsers; they do not prove the plugin reproduces the scanner it replaces.
|
||||
A plugin that parses perfectly and emits `steam:440.0` instead of `steam:440` breaks every Moonlight
|
||||
pin on the host, and no parser test notices. So, on a box with that launcher installed:
|
||||
|
||||
```sh
|
||||
# 1. while the host is still using its BUILT-IN scanner:
|
||||
punktfunk-plugin-steam parity --snapshot before.json
|
||||
# 2. offline — runs this plugin's own scan and diffs:
|
||||
punktfunk-plugin-steam parity --compare before.json
|
||||
```
|
||||
|
||||
`--compare` exits non-zero on any difference, so it works as a release gate. It compares ids,
|
||||
titles, launch recipes, roles and metadata exactly; **art by presence, not value** (the
|
||||
representation legitimately changes — a host-relative proxy path or inlined `data:` URL becomes a
|
||||
`file://` path or a CDN URL), so spot-check a few covers by eye once. Launcher entries the plugin
|
||||
adds are reported separately rather than failing the run; an ordinary title the scanner never had
|
||||
still fails.
|
||||
|
||||
## Telling the host how to recognize a running title (`detect`)
|
||||
|
||||
|
||||
+16
-9
@@ -21,12 +21,15 @@
|
||||
],
|
||||
},
|
||||
},
|
||||
"overrides": {
|
||||
"undici": "^8.9.0",
|
||||
},
|
||||
"packages": {
|
||||
"@effect/openapi-generator": ["@effect/openapi-generator@4.0.0-beta.98", "", { "dependencies": { "swagger2openapi": "^7.0.8" }, "peerDependencies": { "@effect/platform-node": "^4.0.0-beta.98", "effect": "^4.0.0-beta.98" }, "bin": { "openapigen": "dist/bin.js" } }, "sha512-7bqawr/HqJWqQ8H/bHyzBlLPA3LIIm3Y+cGYlIxnC/QVK795QpiEXb7uxTnP7V7w49V0sBtTerv4/9ZjsMffLQ=="],
|
||||
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.98", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.98", "mime": "^4.1.0", "undici": "^8.7.0" }, "peerDependencies": { "effect": "^4.0.0-beta.98", "ioredis": "^5.7.0" } }, "sha512-IQu1TiLXQEDSGkDBllyYjVadf+UqdjptryqX4mmktVTTbGDq7X4uVxe7cSgXuqZvyfG6kagTzwj2lfynxOaKQg=="],
|
||||
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.99", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.0" }, "peerDependencies": { "effect": "^4.0.0-beta.99" } }, "sha512-POBAowafsAAb3bH1x1rJlWnv32yMAazFgEuRW5LhkW/JJA5VGoEk9OnuoUkIH1OW6K/X6IrdNpqcO+5e9lPQJA=="],
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.103", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.0" }, "peerDependencies": { "effect": "^4.0.0-beta.103" } }, "sha512-0aCZMBid5ifqmY55TkfCDLaGTIM8qu3bNFUW7qL9vh/7jFOkaIAMX2MA8muG4deqW17XWxawddWu4v0fK+UW3g=="],
|
||||
|
||||
"@exodus/schemasafe": ["@exodus/schemasafe@1.3.0", "", {}, "sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw=="],
|
||||
|
||||
@@ -44,15 +47,15 @@
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="],
|
||||
|
||||
"@punktfunk/host": ["@punktfunk/host@file:../sdk", { "devDependencies": { "@effect/openapi-generator": "4.0.0-beta.98", "@effect/platform-node": "4.0.0-beta.98", "@types/bun": "^1.3.0", "effect": "^4.0.0-beta.98", "typescript": "^5.9.3" }, "optionalDependencies": { "undici": "^7.0.0" }, "peerDependencies": { "effect": "^4.0.0-beta.98" }, "bin": { "punktfunk-scripting": "./dist/runner-cli.js" } }],
|
||||
"@punktfunk/host": ["@punktfunk/host@file:../sdk", { "devDependencies": { "@effect/openapi-generator": "4.0.0-beta.98", "@effect/platform-node": "4.0.0-beta.98", "@types/bun": "^1.3.0", "bun2nix": "2.1.2", "effect": "^4.0.0-beta.98", "typescript": "^5.9.3" }, "optionalDependencies": { "undici": "^7.0.0" }, "peerDependencies": { "effect": "^4.0.0-beta.98" }, "bin": { "punktfunk-scripting": "./dist/runner-cli.js" } }],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||
|
||||
"@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
|
||||
"@types/node": ["@types/node@26.1.2", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
|
||||
"@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
@@ -62,6 +65,8 @@
|
||||
|
||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
|
||||
"bun2nix": ["bun2nix@2.1.2", "", { "dependencies": { "sade": "^1.8.1" }, "bin": { "bun2nix": "index.ts" } }, "sha512-0wx6Ar5ccrz4aSD5prbShwymjDEXFh7Bucxs+YrpAMa67TnVB95Hv8FV3oaQEbtOx6QGgIAyOmap6Y3WCRqetg=="],
|
||||
|
||||
"call-me-maybe": ["call-me-maybe@1.0.2", "", {}, "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ=="],
|
||||
|
||||
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||
@@ -108,9 +113,11 @@
|
||||
|
||||
"mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="],
|
||||
|
||||
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"msgpackr": ["msgpackr@2.0.4", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA=="],
|
||||
"msgpackr": ["msgpackr@2.0.5", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA=="],
|
||||
|
||||
"msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="],
|
||||
|
||||
@@ -144,6 +151,8 @@
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
|
||||
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
|
||||
|
||||
"should": ["should@13.2.3", "", { "dependencies": { "should-equal": "^2.0.0", "should-format": "^3.0.3", "should-type": "^1.4.0", "should-type-adaptors": "^1.0.1", "should-util": "^1.0.0" } }, "sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ=="],
|
||||
|
||||
"should-equal": ["should-equal@2.0.0", "", { "dependencies": { "should-type": "^1.4.0" } }, "sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA=="],
|
||||
@@ -170,7 +179,7 @@
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
|
||||
"undici": ["undici@8.10.0", "", {}, "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ=="],
|
||||
|
||||
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
||||
|
||||
@@ -182,7 +191,7 @@
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="],
|
||||
"ws": ["ws@8.21.2", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw=="],
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
@@ -192,8 +201,6 @@
|
||||
|
||||
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
||||
|
||||
"@effect/platform-node/undici": ["undici@8.8.0", "", {}, "sha512-ubshXMXwF3MQIMF1y/WxZdNBnjEKeSg2wF5mcGUtU55YTw34tnVVpKRlLf7ruDXZ5344KokPVX4RBx1wJm64Bw=="],
|
||||
|
||||
"oas-linter/yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="],
|
||||
|
||||
"oas-resolver/yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="],
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// A COMPLETE library-scanner plugin, and the template the six first-party ones are cut from.
|
||||
//
|
||||
// This is the lutris pilot (design M5/WP5.1) — the smallest of the six, and the one that exercises
|
||||
// the POSIX local-art path end to end. It lives here as a worked example rather than shipped code:
|
||||
// each scanner gets its OWN repo (the house pattern), and this is what you copy into a fresh one.
|
||||
// `package.json`'s `files` is dist + README, so nothing here is published.
|
||||
//
|
||||
// The point it proves: everything below the `scan` function is store-specific parsing, and
|
||||
// everything else — the store claim, the sync engine, launcher entries, `__config`, the console
|
||||
// registration, the CLI verbs including the parity gate — comes from `defineLibraryPlugin`. That is
|
||||
// what makes six repos cost nothing in duplication.
|
||||
//
|
||||
// Ported from crates/punktfunk-host/src/library/lutris.rs, with two deliberate changes:
|
||||
// * art is emitted as `file://` URLs instead of inlined `data:` URLs. The host proxies the bytes,
|
||||
// so the reconcile payload stays tiny — inlining covers is what blew the host's 2 MB body limit
|
||||
// at 49 titles during the playnite work, and it is exactly why the POSIX art path exists (G4).
|
||||
// * the `installed = 1` filter and the untrusted-slug guard are carried over verbatim. The slug
|
||||
// comes from Lutris's own database and is interpolated into a path, so the guard is load-bearing.
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { Effect, Schema } from "effect";
|
||||
import {
|
||||
defineLibraryPlugin,
|
||||
fileUrl,
|
||||
isFile,
|
||||
withReadOnlyDb,
|
||||
} from "../src/library/index.js";
|
||||
import type { ProviderEntry } from "../src/wire.js";
|
||||
|
||||
const LutrisConfig = Schema.Struct({
|
||||
/**
|
||||
* Where `pga.db` lives, when it isn't in one of the standard places. Annotated because the
|
||||
* console's generic settings form derives its label and help text from exactly these.
|
||||
*/
|
||||
databasePath: Schema.optionalKey(
|
||||
Schema.String.annotate({
|
||||
title: "Lutris database",
|
||||
description:
|
||||
"Absolute path to pga.db. Leave empty to find it automatically.",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
/** Candidate `pga.db` locations: XDG data dir, the classic path, Flatpak. */
|
||||
const databaseCandidates = (): string[] => {
|
||||
const out: string[] = [];
|
||||
const xdg = process.env.XDG_DATA_HOME;
|
||||
if (xdg) out.push(path.join(xdg, "lutris/pga.db"));
|
||||
const home = os.homedir();
|
||||
if (home) {
|
||||
out.push(path.join(home, ".local/share/lutris/pga.db"));
|
||||
out.push(path.join(home, ".var/app/net.lutris.Lutris/data/lutris/pga.db"));
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const findDatabase = (cfg: { databasePath?: string }): string | undefined =>
|
||||
[...(cfg.databasePath ? [cfg.databasePath] : []), ...databaseCandidates()].find(
|
||||
isFile,
|
||||
);
|
||||
|
||||
/**
|
||||
* `<kind>/<slug>.jpg` across the current, legacy-cache and Flatpak Lutris roots.
|
||||
*
|
||||
* The slug comes verbatim from Lutris's database and is interpolated into a path, so a separator,
|
||||
* parent ref or NUL is refused — otherwise a crafted slug is an arbitrary-file-read primitive, and
|
||||
* the resulting path would be handed to the host's art proxy to serve (security-review 2026-07-17).
|
||||
* Real Lutris slugs are `[a-z0-9-]`.
|
||||
*/
|
||||
const artFile = (kind: string, slug: string): string | undefined => {
|
||||
if (
|
||||
slug === "" ||
|
||||
slug.includes("/") ||
|
||||
slug.includes("\\") ||
|
||||
slug.includes("..") ||
|
||||
slug.includes("\0")
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const home = os.homedir();
|
||||
if (!home) return undefined;
|
||||
const roots = [
|
||||
path.join(home, ".local/share/lutris"),
|
||||
path.join(home, ".cache/lutris"),
|
||||
path.join(home, ".var/app/net.lutris.Lutris/data/lutris"),
|
||||
path.join(home, ".var/app/net.lutris.Lutris/cache/lutris"),
|
||||
];
|
||||
for (const root of roots) {
|
||||
const p = path.join(root, kind, `${slug}.jpg`);
|
||||
if (isFile(p)) return p;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
interface GameRow {
|
||||
id: number;
|
||||
slug: string | null;
|
||||
name: string;
|
||||
directory: string | null;
|
||||
}
|
||||
|
||||
export default defineLibraryPlugin({
|
||||
// One string: plugin id, provider id, store claim, and the id of the built-in scanner this
|
||||
// replaces. That identity chain is what keeps entry ids, GameStream app ids and the operator's
|
||||
// existing enable/disable state intact across the migration.
|
||||
name: "lutris",
|
||||
configSchema: LutrisConfig,
|
||||
|
||||
detect: (cfg) => Effect.sync(() => findDatabase(cfg) !== undefined),
|
||||
|
||||
scan: (cfg) =>
|
||||
Effect.sync(() => {
|
||||
const db = findDatabase(cfg);
|
||||
if (!db) return [];
|
||||
// Read-only + immutable: a running Lutris holding the file can neither block us nor be
|
||||
// disturbed by us.
|
||||
const rows =
|
||||
withReadOnlyDb(db, (h) =>
|
||||
// `directory` is our only detect signal but is not load-bearing for the library, so
|
||||
// a schema without it must not cost the whole source — the helper answers [] on a
|
||||
// bad query, and the fallback keeps the titles.
|
||||
h.query<GameRow>(
|
||||
"SELECT id, slug, name, directory FROM games " +
|
||||
"WHERE installed = 1 AND name IS NOT NULL AND name <> '' " +
|
||||
"ORDER BY name COLLATE NOCASE",
|
||||
),
|
||||
) ?? [];
|
||||
const usable =
|
||||
rows.length > 0
|
||||
? rows
|
||||
: (withReadOnlyDb(db, (h) =>
|
||||
h.query<GameRow>(
|
||||
"SELECT id, slug, name, NULL AS directory FROM games " +
|
||||
"WHERE installed = 1 AND name IS NOT NULL AND name <> '' " +
|
||||
"ORDER BY name COLLATE NOCASE",
|
||||
),
|
||||
) ?? []);
|
||||
|
||||
return usable.map((row): ProviderEntry => {
|
||||
const portrait = row.slug ? artFile("coverart", row.slug) : undefined;
|
||||
const header = row.slug ? artFile("banners", row.slug) : undefined;
|
||||
const dir = row.directory?.trim();
|
||||
return {
|
||||
// The host composes `lutris:<external_id>` — byte-identical to what the built-in
|
||||
// scanner produced, which the parity gate checks.
|
||||
external_id: String(row.id),
|
||||
title: row.name,
|
||||
launch: { kind: "lutris_id", value: String(row.id) },
|
||||
art: {
|
||||
...(portrait ? { portrait: fileUrl(portrait) } : {}),
|
||||
...(header ? { header: fileUrl(header) } : {}),
|
||||
},
|
||||
// Lutris stamps no per-game env marker worth relying on, so the install dir is the
|
||||
// whole recipe; a game with none (an emulator entry pointing at a bare ROM) stays
|
||||
// untracked, exactly as it did in-host.
|
||||
...(dir ? { detect: { install_dir: dir } } : {}),
|
||||
platform: "PC",
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
// Re-scan when Lutris writes: installing a game touches the database, and downloading art
|
||||
// touches the cover directories.
|
||||
watchDirs: (cfg) => {
|
||||
const db = findDatabase(cfg);
|
||||
return db ? [path.dirname(db)] : [];
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@punktfunk/plugin-kit",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"description": "Effect-based framework for punktfunk plugins: lifecycle runtime, config/state, sync engine, UI serving, CLI scaffold, and browser helpers.",
|
||||
"type": "module",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
@@ -29,6 +29,10 @@
|
||||
"types": "./dist/wire.d.ts",
|
||||
"default": "./dist/wire.js"
|
||||
},
|
||||
"./library": {
|
||||
"types": "./dist/library/index.d.ts",
|
||||
"default": "./dist/library/index.js"
|
||||
},
|
||||
"./theme.css": "./dist/theme.css"
|
||||
},
|
||||
"files": ["dist", "README.md"],
|
||||
@@ -57,5 +61,8 @@
|
||||
"@types/react": "^19.2.16",
|
||||
"effect": "4.0.0-beta.99",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"overrides": {
|
||||
"undici": "^8.9.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,13 @@ export {
|
||||
type SyncSettings,
|
||||
type SyncStatus,
|
||||
} from "./sync-engine.js";
|
||||
export { httpApiEnv, serveUi, type ServeUiOptions } from "./ui-server.js";
|
||||
export {
|
||||
deriveConfigJsonSchema,
|
||||
httpApiEnv,
|
||||
makeConfigHandler,
|
||||
serveUi,
|
||||
type ServeUiConfig,
|
||||
type ServeUiOptions,
|
||||
} from "./ui-server.js";
|
||||
export { sseRoute, type SseRouteOptions } from "./sse.js";
|
||||
export { type CliCommand, runPluginCli } from "./cli.js";
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
// `defineLibraryPlugin` — the shared framework behind every library-scanner plugin (design D10).
|
||||
//
|
||||
// The point of this module is that a first-party scanner should be **its parsers and a scan
|
||||
// function**, ~200–400 lines, and nothing else. Everything a scanner needs beyond that is identical
|
||||
// across all six of them and lives here: claiming the store, reconciling through the sync engine,
|
||||
// appending launcher entries, serving `__config` so the console renders settings without the plugin
|
||||
// shipping an SPA, registering under `category: "library"` so it stays out of the nav, and the
|
||||
// standard CLI verbs.
|
||||
import type { PluginDef } from "@punktfunk/host";
|
||||
import * as fs from "node:fs";
|
||||
import { Duration, Effect, Layer, Schema, Stream } from "effect";
|
||||
import { type CliCommand, runPluginCli } from "../cli.js";
|
||||
import { type ConfigService, makeConfigService } from "../config.js";
|
||||
import { HostClient, PluginInfo } from "../host-client.js";
|
||||
import { ProviderClient, type ProviderClientService } from "../reconcile.js";
|
||||
import { definePluginKit, type PluginKitDef } from "../runtime.js";
|
||||
import { makeSyncEngine } from "../sync-engine.js";
|
||||
import { serveUi } from "../ui-server.js";
|
||||
import type { ProviderEntry } from "../wire.js";
|
||||
import {
|
||||
diffParity,
|
||||
formatParityReport,
|
||||
fromHostEntry,
|
||||
fromProviderEntry,
|
||||
type HostGameEntry,
|
||||
} from "./parity.js";
|
||||
|
||||
/** What a scan produced — the status surface and the CLI's `scan` verb both render this. */
|
||||
export interface ScanReport {
|
||||
readonly entries: number;
|
||||
readonly launchers: number;
|
||||
/** False when the launcher isn't installed here — the library is legitimately empty. */
|
||||
readonly present: boolean;
|
||||
}
|
||||
|
||||
export interface LibraryPluginDef<S extends Schema.Top> {
|
||||
/**
|
||||
* The plugin id. **This one string is also the provider id, the store claim, and the id of the
|
||||
* built-in scanner this plugin replaces.** That identity chain is what makes the migration
|
||||
* invisible: entry ids stay `<name>:<external_id>`, GameStream app ids and client art caches
|
||||
* stay valid, and the operator's existing enable/disable state carries over untouched.
|
||||
*/
|
||||
readonly name: string;
|
||||
readonly version?: string;
|
||||
/**
|
||||
* The store to claim (design D2). Defaults to {@link name} and should almost never differ — see
|
||||
* the identity note above. Pass `null` to opt out of claiming entirely, which makes this an
|
||||
* ordinary unclaimed provider whose entries surface as `custom:`.
|
||||
*/
|
||||
readonly store?: string | null;
|
||||
/** The operator-facing config schema. Drives `__config` and every callback's argument. */
|
||||
readonly configSchema: S;
|
||||
/**
|
||||
* Is this launcher present on the host at all? Surfaces in the CLI's `detect` verb, and lets the
|
||||
* plugin report "not installed" rather than silently syncing an empty library.
|
||||
*/
|
||||
readonly detect: (cfg: S["Type"]) => Effect.Effect<boolean>;
|
||||
/** Enumerate the launcher's installed titles — the only real per-store code. */
|
||||
readonly scan: (
|
||||
cfg: S["Type"],
|
||||
) => Effect.Effect<ReadonlyArray<ProviderEntry>>;
|
||||
/**
|
||||
* Entries that open the LAUNCHER itself (design D4) — Steam Big Picture, Heroic, … Appended to
|
||||
* every reconcile, so toggling one in config takes effect on the next sync. Emit them with
|
||||
* `role: "launcher"`; the kit does not stamp it for you, because a plugin may legitimately want
|
||||
* an entry that opens a launcher but still lists as an ordinary game.
|
||||
*/
|
||||
readonly launchers?: (cfg: S["Type"]) => ReadonlyArray<ProviderEntry>;
|
||||
/** Launcher data dirs to watch, so a newly installed game appears without waiting for a poll. */
|
||||
readonly watchDirs?: (cfg: S["Type"]) => ReadonlyArray<string>;
|
||||
/** How often to re-scan regardless of watches. Default `Duration.minutes(15)`. */
|
||||
readonly pollInterval?: Duration.Duration;
|
||||
/** Debounce on filesystem events. Default `Duration.seconds(3)`. */
|
||||
readonly debounce?: Duration.Duration;
|
||||
/** Display title (the console's sources row falls back to the scanner label). Defaults to `name`. */
|
||||
readonly title?: string;
|
||||
/** Extra CLI verbs beyond the standard `detect` / `scan` / `uninstall` set. */
|
||||
readonly commands?: Record<string, CliCommand<never>>;
|
||||
}
|
||||
|
||||
/** `--flag value` from an argv slice, or undefined. */
|
||||
const flagValue = (
|
||||
argv: ReadonlyArray<string>,
|
||||
flag: string,
|
||||
): string | undefined => {
|
||||
const i = argv.indexOf(flag);
|
||||
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
|
||||
};
|
||||
|
||||
/** The pieces a library plugin package wires into its entry points. */
|
||||
export interface LibraryPlugin {
|
||||
/** The runner-discovered default export (`export default plugin.def`). */
|
||||
readonly def: PluginDef;
|
||||
/** The CLI entry (`await plugin.cli()` from the package's bin). */
|
||||
readonly cli: (argv?: ReadonlyArray<string>) => Promise<void>;
|
||||
}
|
||||
|
||||
export const defineLibraryPlugin = <S extends Schema.Top>(
|
||||
def: LibraryPluginDef<S>,
|
||||
): LibraryPlugin => {
|
||||
const store = def.store === null ? undefined : (def.store ?? def.name);
|
||||
const poll = def.pollInterval ?? Duration.minutes(15);
|
||||
const debounce = def.debounce ?? Duration.seconds(3);
|
||||
|
||||
/** The config service, built fresh wherever it is needed (it only requires `PluginInfo`). */
|
||||
const config: Effect.Effect<ConfigService<S>, never, PluginInfo> =
|
||||
makeConfigService({ schema: def.configSchema });
|
||||
|
||||
/** Scan + launcher entries, in the order they should reach the host. */
|
||||
const computeEntries = (
|
||||
cfg: S["Type"],
|
||||
): Effect.Effect<{
|
||||
readonly entries: ReadonlyArray<ProviderEntry>;
|
||||
readonly report: ScanReport;
|
||||
}> =>
|
||||
Effect.gen(function* () {
|
||||
const present = yield* def.detect(cfg);
|
||||
// A launcher that isn't installed contributes NOTHING — not even its launcher entries. A
|
||||
// "Steam Big Picture" tile on a box without Steam would only fail to launch.
|
||||
if (!present) {
|
||||
return {
|
||||
entries: [] as ReadonlyArray<ProviderEntry>,
|
||||
report: { entries: 0, launchers: 0, present: false } as const,
|
||||
};
|
||||
}
|
||||
const scanned = yield* def.scan(cfg);
|
||||
const launchers = def.launchers?.(cfg) ?? [];
|
||||
return {
|
||||
entries: [...scanned, ...launchers],
|
||||
report: {
|
||||
entries: scanned.length,
|
||||
launchers: launchers.length,
|
||||
present: true,
|
||||
} as const,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Push one entry set to the host under the store claim, warning **once** if the host is too old
|
||||
* to honour it.
|
||||
*
|
||||
* This degradation is worth the code: a pre-M2 host ignores `?store=` silently, and the only
|
||||
* symptom would be this plugin's titles appearing as unbadged `custom:` entries *beside* the
|
||||
* built-in scanner's identical ones — a confusing double-listing with no error anywhere.
|
||||
* Checking the echoed entries turns that into one actionable log line.
|
||||
*/
|
||||
const applyEntries =
|
||||
(provider: ProviderClientService, state: { warned: boolean }) =>
|
||||
(entries: ReadonlyArray<ProviderEntry>): Effect.Effect<void, unknown> =>
|
||||
provider.reconcile(def.name, entries, store).pipe(
|
||||
Effect.tap((echoed) => {
|
||||
if (!store || state.warned || echoed.length === 0) return Effect.void;
|
||||
if (echoed.some((e) => e.store === store)) return Effect.void;
|
||||
state.warned = true;
|
||||
return Effect.logWarning(
|
||||
`host is too old for store claims: this source's games will appear as custom ` +
|
||||
`entries and the host's own "${store}" scanner is not suppressed, so titles ` +
|
||||
`may be listed twice. Updating the host resolves it.`,
|
||||
);
|
||||
}),
|
||||
Effect.asVoid,
|
||||
);
|
||||
|
||||
const main = Effect.gen(function* () {
|
||||
const cfgService = yield* config;
|
||||
const provider = yield* ProviderClient;
|
||||
const state = { warned: false };
|
||||
|
||||
const engine = yield* makeSyncEngine<
|
||||
ScanReport,
|
||||
ReadonlyArray<ProviderEntry>,
|
||||
never
|
||||
>({
|
||||
compute: () => cfgService.load.pipe(Effect.flatMap(computeEntries)),
|
||||
apply: applyEntries(provider, state),
|
||||
// The host IS the state: a full-replace reconcile is idempotent, so there is nothing to
|
||||
// persist between runs. Reporting no previous fingerprint means the first sync after a
|
||||
// restart always pushes, which is exactly what we want (the host may have been reinstalled
|
||||
// underneath us).
|
||||
lastSync: { get: Effect.succeed(undefined), set: () => Effect.void },
|
||||
settings: cfgService.load.pipe(
|
||||
Effect.map((cfg) => def.watchDirs?.(cfg) ?? []),
|
||||
// A config file that won't decode must not stop the poll loop: fall back to no watch
|
||||
// dirs, keep syncing on the timer, and let the operator see the parse error in the
|
||||
// settings drawer (`GET /__config` reports it).
|
||||
Effect.catch(() => Effect.succeed([] as ReadonlyArray<string>)),
|
||||
Effect.map((watchDirs) => ({
|
||||
pollInterval: poll,
|
||||
watch: true,
|
||||
debounce,
|
||||
watchDirs,
|
||||
})),
|
||||
),
|
||||
});
|
||||
|
||||
// The UI server exists ONLY to serve `__config` (and the SDK's `__health`): no `staticDir`,
|
||||
// no API. That is the whole "settings without an SPA" story (design D7, closing G8), and the
|
||||
// `library` category is what keeps six installed scanners out of the console's sidebar.
|
||||
yield* serveUi({
|
||||
title: def.title ?? def.name,
|
||||
category: "library",
|
||||
config: { schema: def.configSchema, service: cfgService },
|
||||
});
|
||||
|
||||
yield* engine.start;
|
||||
// A saved settings change is exactly when a user expects the library to update — and it may
|
||||
// have changed `watchDirs`, so re-read settings rather than just re-syncing.
|
||||
yield* Effect.forkScoped(
|
||||
Stream.runForEach(cfgService.changes, () => engine.reconfigure),
|
||||
);
|
||||
yield* Effect.never;
|
||||
});
|
||||
|
||||
const kitDef: PluginKitDef<never, ProviderClient> = {
|
||||
name: def.name,
|
||||
...(def.version !== undefined ? { version: def.version } : {}),
|
||||
layer: ProviderClient.layer,
|
||||
main: main as Effect.Effect<
|
||||
void,
|
||||
never,
|
||||
ProviderClient | HostClient | PluginInfo | never
|
||||
>,
|
||||
};
|
||||
|
||||
const standardCommands: Record<string, CliCommand<ProviderClient>> = {
|
||||
detect: {
|
||||
summary: "report whether this launcher is installed on the host",
|
||||
// Offline on purpose: "is Steam here?" must be answerable without a running host.
|
||||
offline: true,
|
||||
run: () =>
|
||||
Effect.gen(function* () {
|
||||
const cfg = yield* (yield* config).load;
|
||||
console.log((yield* def.detect(cfg)) ? "present" : "absent");
|
||||
}),
|
||||
},
|
||||
scan: {
|
||||
summary: "scan and print what WOULD be synced (--preview for the JSON entries)",
|
||||
// Also offline: the point is to debug a scanner against real launcher files without
|
||||
// touching the host's library.
|
||||
offline: true,
|
||||
run: (argv) =>
|
||||
Effect.gen(function* () {
|
||||
const cfg = yield* (yield* config).load;
|
||||
const { entries, report } = yield* computeEntries(cfg);
|
||||
if (argv.includes("--preview")) {
|
||||
console.log(JSON.stringify(entries, null, 2));
|
||||
} else {
|
||||
console.log(
|
||||
`${report.present ? "present" : "absent"}: ${report.entries} games, ` +
|
||||
`${report.launchers} launcher entries`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
},
|
||||
parity: {
|
||||
summary:
|
||||
"prove this plugin reproduces the built-in scanner (--snapshot <f> | --compare <f>)",
|
||||
// `--compare` is offline (it runs THIS plugin's scan); `--snapshot` needs the host. The
|
||||
// dispatcher decides per invocation below, so the verb is registered as online and the
|
||||
// snapshot path is the one that actually uses the client.
|
||||
run: (argv) =>
|
||||
Effect.gen(function* () {
|
||||
const snapshot = flagValue(argv, "--snapshot");
|
||||
const compare = flagValue(argv, "--compare");
|
||||
if (!snapshot && !compare) {
|
||||
console.error(
|
||||
"usage: parity --snapshot <file> (capture the host's CURRENT library for this store)\n" +
|
||||
" parity --compare <file> (diff this plugin's scan against that capture)",
|
||||
);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
if (snapshot) {
|
||||
// The baseline: what the host reports for THIS store while its built-in scanner
|
||||
// is still the thing producing it. Capture before installing the plugin.
|
||||
const host = yield* HostClient;
|
||||
const body = yield* host.request("GET", "/library");
|
||||
const mine = (Array.isArray(body) ? (body as HostGameEntry[]) : [])
|
||||
.filter((e) => e.store === (store ?? def.name))
|
||||
.map(fromHostEntry)
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
yield* Effect.sync(() =>
|
||||
fs.writeFileSync(snapshot, `${JSON.stringify(mine, null, 2)}\n`),
|
||||
);
|
||||
console.log(
|
||||
`captured ${mine.length} "${store ?? def.name}" entries to ${snapshot}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const baseline = yield* Effect.try({
|
||||
try: () =>
|
||||
JSON.parse(fs.readFileSync(compare as string, "utf8")) as ReturnType<
|
||||
typeof fromHostEntry
|
||||
>[],
|
||||
catch: (cause) => new Error(`cannot read ${compare}: ${cause}`),
|
||||
});
|
||||
const cfg = yield* (yield* config).load;
|
||||
const { entries } = yield* computeEntries(cfg);
|
||||
const produced = entries.map((e) =>
|
||||
fromProviderEntry(store ?? def.name, e),
|
||||
);
|
||||
const report = diffParity(baseline, produced);
|
||||
console.log(formatParityReport(report));
|
||||
// A non-zero exit is what makes this usable as a release gate rather than a report
|
||||
// somebody skims.
|
||||
if (!report.ok) process.exitCode = 1;
|
||||
}),
|
||||
},
|
||||
uninstall: {
|
||||
summary: "remove this source's games from the host and release its store claim",
|
||||
run: () =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* ProviderClient;
|
||||
// The empty reconcile clears the entries; DELETE is what releases the CLAIM — and
|
||||
// releasing is what brings the host's own built-in scanner straight back.
|
||||
yield* provider.reconcile(def.name, [], undefined);
|
||||
yield* provider.remove(def.name);
|
||||
console.log(`${def.name}: entries removed, store claim released`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
def: definePluginKit(kitDef),
|
||||
cli: (argv) =>
|
||||
runPluginCli({
|
||||
def: kitDef,
|
||||
commands: {
|
||||
...standardCommands,
|
||||
...(def.commands ?? {}),
|
||||
} as Record<string, CliCommand<ProviderClient>>,
|
||||
...(argv !== undefined ? { argv } : {}),
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
// `@punktfunk/plugin-kit/library` — the shared framework for library-scanner plugins.
|
||||
//
|
||||
// A first-party scanner is its parsers plus a scan function; everything else (store claim, sync
|
||||
// engine wiring, launcher entries, `__config`, nav category, CLI verbs) comes from
|
||||
// `defineLibraryPlugin`. See design/library-scanner-plugins.md D10.
|
||||
export {
|
||||
defineLibraryPlugin,
|
||||
type LibraryPlugin,
|
||||
type LibraryPluginDef,
|
||||
type ScanReport,
|
||||
} from "./define.js";
|
||||
export {
|
||||
claimedLibraryId,
|
||||
diffParity,
|
||||
formatParityReport,
|
||||
fromHostEntry,
|
||||
fromProviderEntry,
|
||||
type HostGameEntry,
|
||||
type ParityChange,
|
||||
type ParityEntry,
|
||||
type ParityReport,
|
||||
} from "./parity.js";
|
||||
export * from "./parsers/index.js";
|
||||
@@ -0,0 +1,249 @@
|
||||
// The parity harness: proof that a library plugin reproduces the in-host scanner it replaces.
|
||||
//
|
||||
// This is the acceptance gate for every extracted scanner (design M5). Ported unit tests are
|
||||
// necessary but nowhere near sufficient — they pin the PARSERS, while what actually has to hold is
|
||||
// that the whole pipeline lands the same entries, with the same ids, launch recipes and detect
|
||||
// signals, on a real box with a real launcher installed. A plugin that parses perfectly and emits
|
||||
// `steam:440` as `steam:440.0` breaks every Moonlight pin on the host and no parser test notices.
|
||||
//
|
||||
// It lives in the KIT, not in a plugin, because it is identical for all six: capture what the host
|
||||
// reports while its built-in scanner is doing the work, then check the plugin produces the same set.
|
||||
// (One plugin per repo is the house pattern, so anything shared has to be published, not adjacent.)
|
||||
//
|
||||
// Usage, per plugin, on a box with that launcher installed:
|
||||
//
|
||||
// punktfunk-plugin-steam parity --snapshot before.json # host still on its built-in scanner
|
||||
// punktfunk-plugin-steam parity --compare before.json # offline: runs THIS plugin's scan
|
||||
//
|
||||
// `--compare` runs the plugin's own scan directly rather than installing it first, so a mismatch is
|
||||
// visible before anything is published — and the run is repeatable while you fix it.
|
||||
import type { ProviderEntry } from "../wire.js";
|
||||
|
||||
/** The four art slots, in the order the host's box-art ladder tries them. */
|
||||
const ART_KINDS = ["portrait", "hero", "logo", "header"] as const;
|
||||
type ArtKind = (typeof ART_KINDS)[number];
|
||||
|
||||
/** One entry, reduced to the facts parity is about. */
|
||||
export interface ParityEntry {
|
||||
/** The store-qualified library id — the field everything downstream is keyed on. */
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
/** `<kind>:<value>`, or null when the entry has no launch recipe. */
|
||||
readonly launch: string | null;
|
||||
/** `"game"` or `"launcher"`. */
|
||||
readonly role: string;
|
||||
/**
|
||||
* Which art kinds are PRESENT, not their values. The representation legitimately changes on
|
||||
* extraction (a scanner's `data:` URL or host-relative proxy path becomes a `file://` path or a
|
||||
* CDN URL), so comparing values would fail every time for no reason. Presence is the invariant
|
||||
* that matters: a title that had a poster must still have one.
|
||||
*/
|
||||
readonly art: Readonly<Record<ArtKind, boolean>>;
|
||||
/** Flat descriptive metadata (platform, genres, …) — compared verbatim. */
|
||||
readonly meta: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/** What the host reports for one entry in `GET /library`. */
|
||||
export interface HostGameEntry {
|
||||
id: string;
|
||||
store: string;
|
||||
title: string;
|
||||
role?: string;
|
||||
launch?: { kind: string; value: string } | null;
|
||||
art?: Partial<Record<ArtKind, string | null>>;
|
||||
[extra: string]: unknown;
|
||||
}
|
||||
|
||||
/** Keys on a host entry that are structure, not descriptive metadata. */
|
||||
const NON_META = new Set([
|
||||
"id",
|
||||
"store",
|
||||
"title",
|
||||
"role",
|
||||
"launch",
|
||||
"art",
|
||||
"provider",
|
||||
"external_id",
|
||||
"prep",
|
||||
"detect",
|
||||
]);
|
||||
|
||||
const artPresence = (
|
||||
art: Partial<Record<ArtKind, string | null>> | undefined,
|
||||
): Record<ArtKind, boolean> => {
|
||||
const out = {} as Record<ArtKind, boolean>;
|
||||
for (const k of ART_KINDS) out[k] = Boolean(art?.[k]);
|
||||
return out;
|
||||
};
|
||||
|
||||
const pickMeta = (src: Record<string, unknown>): Record<string, unknown> => {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(src)) {
|
||||
// Absent and empty are the same thing here: the host omits empty lists and null fields, and a
|
||||
// plugin that sends `genres: []` has not changed anything.
|
||||
if (NON_META.has(k) || v == null) continue;
|
||||
if (Array.isArray(v) && v.length === 0) continue;
|
||||
out[k] = v;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/** The library id the host assigns a claimed entry — the deterministic `<store>:<external_id>`. */
|
||||
export const claimedLibraryId = (store: string, externalId: string): string =>
|
||||
`${store}:${externalId}`;
|
||||
|
||||
/** Reduce what the host reported (the BEFORE side) to a comparable entry. */
|
||||
export const fromHostEntry = (e: HostGameEntry): ParityEntry => ({
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
launch: e.launch ? `${e.launch.kind}:${e.launch.value}` : null,
|
||||
role: e.role ?? "game",
|
||||
art: artPresence(e.art),
|
||||
meta: pickMeta(e as Record<string, unknown>),
|
||||
});
|
||||
|
||||
/** Reduce what this plugin produced (the AFTER side) to a comparable entry. */
|
||||
export const fromProviderEntry = (
|
||||
store: string,
|
||||
e: ProviderEntry,
|
||||
): ParityEntry => {
|
||||
const rec = e as unknown as Record<string, unknown>;
|
||||
return {
|
||||
id: claimedLibraryId(store, e.external_id),
|
||||
title: e.title,
|
||||
launch: e.launch ? `${e.launch.kind}:${e.launch.value}` : null,
|
||||
role: (e as { role?: string }).role ?? "game",
|
||||
art: artPresence(
|
||||
e.art as Partial<Record<ArtKind, string | null>> | undefined,
|
||||
),
|
||||
meta: pickMeta(rec),
|
||||
};
|
||||
};
|
||||
|
||||
/** One field that differs between the two sides. */
|
||||
export interface ParityChange {
|
||||
readonly id: string;
|
||||
readonly field: string;
|
||||
readonly before: unknown;
|
||||
readonly after: unknown;
|
||||
}
|
||||
|
||||
export interface ParityReport {
|
||||
/** In the baseline, absent from what the plugin produced — the plugin LOST a title. */
|
||||
readonly missing: ParityEntry[];
|
||||
/** Produced by the plugin, absent from the baseline — the plugin invented a title. */
|
||||
readonly extra: ParityEntry[];
|
||||
/** Same id, different facts. */
|
||||
readonly changed: ParityChange[];
|
||||
/** Entries present on both sides and identical. */
|
||||
readonly matched: number;
|
||||
/**
|
||||
* Launcher entries the plugin adds (design D4). Never a failure: the built-in scanner had no
|
||||
* concept of them, so they are expected to be `extra` and are reported separately so a real
|
||||
* regression isn't buried under them.
|
||||
*/
|
||||
readonly launchersAdded: ParityEntry[];
|
||||
readonly ok: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff a baseline (what the host reported while its built-in scanner ran) against what this plugin
|
||||
* produced. `ok` is true only when nothing is missing, nothing unexpected is extra, and no compared
|
||||
* field changed.
|
||||
*/
|
||||
export const diffParity = (
|
||||
baseline: ReadonlyArray<ParityEntry>,
|
||||
produced: ReadonlyArray<ParityEntry>,
|
||||
): ParityReport => {
|
||||
const byId = new Map(baseline.map((e) => [e.id, e]));
|
||||
const producedIds = new Set(produced.map((e) => e.id));
|
||||
const changed: ParityChange[] = [];
|
||||
const extra: ParityEntry[] = [];
|
||||
const launchersAdded: ParityEntry[] = [];
|
||||
let matched = 0;
|
||||
|
||||
for (const after of produced) {
|
||||
const before = byId.get(after.id);
|
||||
if (!before) {
|
||||
// A launcher entry has no counterpart by construction — the scanner never emitted one.
|
||||
(after.role === "launcher" ? launchersAdded : extra).push(after);
|
||||
continue;
|
||||
}
|
||||
const diffs = compareEntry(before, after);
|
||||
if (diffs.length === 0) matched++;
|
||||
else changed.push(...diffs);
|
||||
}
|
||||
|
||||
const missing = baseline.filter((e) => !producedIds.has(e.id));
|
||||
return {
|
||||
missing,
|
||||
extra,
|
||||
changed,
|
||||
matched,
|
||||
launchersAdded,
|
||||
ok: missing.length === 0 && extra.length === 0 && changed.length === 0,
|
||||
};
|
||||
};
|
||||
|
||||
const compareEntry = (
|
||||
before: ParityEntry,
|
||||
after: ParityEntry,
|
||||
): ParityChange[] => {
|
||||
const out: ParityChange[] = [];
|
||||
const note = (field: string, b: unknown, a: unknown) =>
|
||||
out.push({ id: before.id, field, before: b, after: a });
|
||||
|
||||
if (before.title !== after.title) note("title", before.title, after.title);
|
||||
if (before.launch !== after.launch)
|
||||
note("launch", before.launch, after.launch);
|
||||
if (before.role !== after.role) note("role", before.role, after.role);
|
||||
for (const k of ART_KINDS) {
|
||||
// Only a LOST art kind is a regression. Gaining one is an improvement (the plugin can reach
|
||||
// art the host never resolved), and failing a run over it would just train people to ignore
|
||||
// the harness.
|
||||
if (before.art[k] && !after.art[k]) note(`art.${k}`, true, false);
|
||||
}
|
||||
const keys = new Set([
|
||||
...Object.keys(before.meta),
|
||||
...Object.keys(after.meta),
|
||||
]);
|
||||
for (const k of keys) {
|
||||
const b = before.meta[k];
|
||||
const a = after.meta[k];
|
||||
if (JSON.stringify(b) !== JSON.stringify(a)) note(`meta.${k}`, b, a);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/** Render a report for a terminal. Empty-ish when everything matched. */
|
||||
export const formatParityReport = (r: ParityReport): string => {
|
||||
const lines: string[] = [];
|
||||
lines.push(
|
||||
r.ok
|
||||
? `parity OK — ${r.matched} entries identical`
|
||||
: `parity FAILED — ${r.matched} identical, ${r.missing.length} missing, ${r.extra.length} unexpected, ${r.changed.length} changed`,
|
||||
);
|
||||
for (const e of r.missing) lines.push(` missing: ${e.id} ${e.title}`);
|
||||
for (const e of r.extra) lines.push(` extra: ${e.id} ${e.title}`);
|
||||
for (const c of r.changed) {
|
||||
lines.push(
|
||||
` changed: ${c.id} ${c.field}: ${JSON.stringify(c.before)} -> ${JSON.stringify(c.after)}`,
|
||||
);
|
||||
}
|
||||
if (r.launchersAdded.length > 0) {
|
||||
lines.push(
|
||||
` (+${r.launchersAdded.length} launcher ${r.launchersAdded.length === 1 ? "entry" : "entries"}, expected: ${r.launchersAdded
|
||||
.map((e) => e.id)
|
||||
.join(", ")})`,
|
||||
);
|
||||
}
|
||||
// Art REPRESENTATION always changes on extraction (a host-relative proxy path or an inlined
|
||||
// `data:` URL becomes a `file://` path or a CDN URL). Presence is what this harness checks, so
|
||||
// say plainly that the bytes still want a human's eyes once.
|
||||
if (r.ok) {
|
||||
lines.push(
|
||||
" note: art is compared by presence, not value — spot-check a few covers render.",
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
// Where a title's cover art lives: Steam's local caches, its per-account `grid/` overrides, and the
|
||||
// public CDN. Ported from the host scanner's art resolution (steam.rs).
|
||||
//
|
||||
// After extraction a plugin emits art VALUES and the host serves them: a `file://` URL for anything
|
||||
// on disk (the documented local-art contract — the host proxies the bytes), or an absolute CDN URL
|
||||
// the client fetches itself. `data:` URLs remain legal but are small-logo-only: inlining covers is
|
||||
// what blew the host's 2 MB body limit at 49 titles during the playnite work.
|
||||
import * as path from "node:path";
|
||||
import { isFile, listDir } from "./fs.js";
|
||||
|
||||
/** The four art slots the library model carries. */
|
||||
export type ArtKind = "portrait" | "hero" | "logo" | "header";
|
||||
|
||||
export const ART_KINDS: readonly ArtKind[] = [
|
||||
"portrait",
|
||||
"hero",
|
||||
"logo",
|
||||
"header",
|
||||
];
|
||||
|
||||
/** A `file://` URL for a local path — the shape the host's art proxy understands. */
|
||||
export const fileUrl = (p: string): string => {
|
||||
// Percent-encode, but keep the separators: the host converts this back to a path and expects the
|
||||
// structure intact. Windows drive paths become `file:///C:/…`.
|
||||
const abs = path.resolve(p);
|
||||
const posix = abs.replace(/\\/g, "/");
|
||||
const encoded = posix
|
||||
.split("/")
|
||||
.map((seg) => encodeURIComponent(seg))
|
||||
.join("/");
|
||||
return posix.startsWith("/") ? `file://${encoded}` : `file:///${encoded}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* The legacy flat CDN URL for a Steam appid's art kind. Correct for the many titles Valve hasn't
|
||||
* re-hashed; newer ones serve from an unpredictable per-asset-hash path, where this 404s and the
|
||||
* client falls through to its next candidate. That degradation is intentional and pre-existing.
|
||||
*/
|
||||
export const steamCdnUrl = (appid: number, kind: ArtKind): string | undefined => {
|
||||
// A non-Steam shortcut's appid has the high bit set and is never a real store appid — the CDN
|
||||
// would only 404, so don't emit a URL that is guaranteed to fail.
|
||||
if ((appid & 0x8000_0000) !== 0) return undefined;
|
||||
const file =
|
||||
kind === "portrait"
|
||||
? "library_600x900.jpg"
|
||||
: kind === "hero"
|
||||
? "library_hero.jpg"
|
||||
: kind === "logo"
|
||||
? "logo.png"
|
||||
: "header.jpg";
|
||||
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/${file}`;
|
||||
};
|
||||
|
||||
/** Filenames Steam's local `librarycache` uses per kind, in preference order (2x is sharper). */
|
||||
const localFilenames = (kind: ArtKind): string[] =>
|
||||
kind === "portrait"
|
||||
? ["library_600x900_2x.jpg", "library_600x900.jpg"]
|
||||
: kind === "hero"
|
||||
? ["library_hero.jpg"]
|
||||
: kind === "logo"
|
||||
? ["logo.png"]
|
||||
: // Steam's local cache names the header asset differently from the store CDN's
|
||||
// `header.jpg` — this trips everyone once.
|
||||
["library_header.jpg"];
|
||||
|
||||
/**
|
||||
* This kind's file under one Steam root's `appcache/librarycache/<appid>/<hash>/`, or `undefined`.
|
||||
* Steam reuses one hash dir per asset version, so there is normally exactly one candidate.
|
||||
*/
|
||||
export const findLocalArtFile = (
|
||||
root: string,
|
||||
appid: number,
|
||||
kind: ArtKind,
|
||||
): string | undefined => {
|
||||
const base = path.join(root, "appcache", "librarycache", String(appid));
|
||||
for (const hash of listDir(base)) {
|
||||
for (const name of localFilenames(kind)) {
|
||||
const p = path.join(base, hash, name);
|
||||
if (isFile(p)) return p;
|
||||
}
|
||||
}
|
||||
// Older Steam wrote the files directly under `librarycache/` with the appid in the name.
|
||||
for (const name of localFilenames(kind)) {
|
||||
const flat = path.join(root, "appcache", "librarycache", `${appid}_${name}`);
|
||||
if (isFile(flat)) return flat;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* The `grid/` basenames Steam names each art kind under for an appid: portrait `<A>p`, hero
|
||||
* `<A>_hero`, logo `<A>_logo`, wide capsule `<A>` — each as `.png` then `.jpg`.
|
||||
*
|
||||
* These overrides are the **only** art a non-Steam shortcut ever has.
|
||||
*/
|
||||
export const gridFilenames = (appid: number, kind: ArtKind): string[] => {
|
||||
const base =
|
||||
kind === "portrait"
|
||||
? `${appid}p`
|
||||
: kind === "hero"
|
||||
? `${appid}_hero`
|
||||
: kind === "logo"
|
||||
? `${appid}_logo`
|
||||
: `${appid}`;
|
||||
return [`${base}.png`, `${base}.jpg`];
|
||||
};
|
||||
|
||||
/** This kind's user override under a `userdata/<id>/config/grid/` dir, or `undefined`. */
|
||||
export const findGridArtFile = (
|
||||
configDir: string,
|
||||
appid: number,
|
||||
kind: ArtKind,
|
||||
): string | undefined => {
|
||||
const grid = path.join(configDir, "grid");
|
||||
for (const name of gridFilenames(appid, kind)) {
|
||||
const p = path.join(grid, name);
|
||||
if (isFile(p)) return p;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
// Bounded filesystem reads and path confinement — the posture the in-host scanners established,
|
||||
// ported so a library plugin inherits it instead of re-deriving it.
|
||||
//
|
||||
// The rules here exist because a plugin reads files it does not own: a launcher's manifests, a
|
||||
// catalog cache, a `goggame-*.info` a user could have edited. None of that is hostile in the normal
|
||||
// case, and all of it is untrusted in the case that matters.
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
/** A launcher manifest / `.acf` / `.info`: text, small. Matches `epic.rs`'s posture. */
|
||||
export const MAX_MANIFEST_BYTES = 1024 * 1024;
|
||||
/** A binary catalog cache (Epic's `catcache.bin`, a `shortcuts.vdf`): larger, still bounded. */
|
||||
export const MAX_CACHE_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Read a file as UTF-8, refusing anything over `max`. `undefined` on any error, a non-regular file,
|
||||
* or an over-cap file — a plugin scanning a directory must never die on one odd entry.
|
||||
*
|
||||
* The size is checked by `stat` BEFORE the read, so an enormous file costs a stat, not the memory.
|
||||
*/
|
||||
export const readTextCapped = (
|
||||
file: string,
|
||||
max = MAX_MANIFEST_BYTES,
|
||||
): string | undefined => {
|
||||
try {
|
||||
const st = fs.statSync(file);
|
||||
if (!st.isFile() || st.size === 0 || st.size > max) return undefined;
|
||||
return fs.readFileSync(file, "utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/** Read a file as bytes, refusing anything over `max`. Same posture as {@link readTextCapped}. */
|
||||
export const readBytesCapped = (
|
||||
file: string,
|
||||
max = MAX_CACHE_BYTES,
|
||||
): Uint8Array | undefined => {
|
||||
try {
|
||||
const st = fs.statSync(file);
|
||||
if (!st.isFile() || st.size === 0 || st.size > max) return undefined;
|
||||
return new Uint8Array(fs.readFileSync(file));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/** Read + `JSON.parse` a capped text file. `undefined` on any read or parse failure. */
|
||||
export const readJsonCapped = <T = unknown>(
|
||||
file: string,
|
||||
max = MAX_MANIFEST_BYTES,
|
||||
): T | undefined => {
|
||||
const text = readTextCapped(file, max);
|
||||
if (text === undefined) return undefined;
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/** List a directory's entry names, or `[]` if it isn't readable. */
|
||||
export const listDir = (dir: string): string[] => {
|
||||
try {
|
||||
return fs.readdirSync(dir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/** Does this path exist as a directory? */
|
||||
export const isDir = (p: string): boolean => {
|
||||
try {
|
||||
return fs.statSync(p).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** Does this path exist as a regular, non-empty file? */
|
||||
export const isFile = (p: string): boolean => {
|
||||
try {
|
||||
const st = fs.statSync(p);
|
||||
return st.isFile() && st.size > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Join `rel` onto `base` **only if it cannot escape** — the port of the host's `confined_join`
|
||||
* (gog.rs), which exists because a crafted `goggame-<id>.info` could otherwise point a play task's
|
||||
* exe at an arbitrary program (security-review 2026-07-17).
|
||||
*
|
||||
* Refuses any relative path carrying a drive prefix (`C:`), a root (`/` or `\`), or a `..`
|
||||
* component — each of which `path.join` would let REPLACE or climb out of `base`. `undefined` ⇒
|
||||
* out of bounds, and the caller must refuse the launch rather than fall back to something plausible.
|
||||
*/
|
||||
export const confinedJoin = (base: string, rel: string): string | undefined => {
|
||||
if (rel === "") return undefined;
|
||||
// Normalize separators so a Windows-shaped relative path is checked on any platform (a plugin
|
||||
// may parse a Windows manifest while its tests run on Linux).
|
||||
const parts = rel.split(/[\\/]/);
|
||||
if (parts[0] === "" ) return undefined; // rooted
|
||||
if (/^[A-Za-z]:$/.test(parts[0])) return undefined; // drive prefix
|
||||
if (parts.some((p) => p === "..")) return undefined; // traversal
|
||||
const joined = path.join(base, ...parts.filter((p) => p !== "" && p !== "."));
|
||||
// Belt and braces: the component check above is the real guard, but a symlink-free string check
|
||||
// costs nothing and catches anything the split missed.
|
||||
const rootWithSep = base.endsWith(path.sep) ? base : base + path.sep;
|
||||
return joined === base || joined.startsWith(rootWithSep) ? joined : undefined;
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
// The one outbound-HTTP helper a library plugin should use, carrying the host's `fetch_image`
|
||||
// posture verbatim (art.rs): http(s) only, **no redirects**, a size cap, and a short timeout.
|
||||
//
|
||||
// The no-redirect rule is the important one and it is not paranoia: a scanner fetches URLs it read
|
||||
// out of a launcher's cache — data the plugin did not author. A `3xx` chased automatically is an
|
||||
// SSRF pivot from a process running on the operator's box (`http://169.254.169.254/…`, an internal
|
||||
// service). The host learned this in the 2026-07-17 security review; a plugin fetching the same
|
||||
// class of URL inherits the same rule. A rare legitimately-redirecting CDN just yields no art.
|
||||
import { HostRequestError } from "../../errors.js";
|
||||
import { Effect } from "effect";
|
||||
|
||||
export interface FetchLimits {
|
||||
/** Hard cap on the response body. Default 8 MiB — a cover never approaches it. */
|
||||
readonly maxBytes?: number;
|
||||
/** Wall-clock timeout in ms. Default 10 000. */
|
||||
readonly timeoutMs?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX = 8 * 1024 * 1024;
|
||||
const DEFAULT_TIMEOUT = 10_000;
|
||||
|
||||
export interface FetchedBytes {
|
||||
readonly bytes: Uint8Array;
|
||||
readonly contentType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET an `http(s)` URL under the posture above. Fails with {@link HostRequestError} on any non-2xx,
|
||||
* a redirect, an over-cap body, a timeout, or a non-http(s) scheme.
|
||||
*
|
||||
* Most scanners never need this: they emit CDN URLs and let the CLIENT fetch them, which is both
|
||||
* faster and keeps the host out of the loop. Reach for it only when a store's art requires an API
|
||||
* lookup the client cannot do (GOG's product API, Microsoft's display catalog).
|
||||
*/
|
||||
export const fetchBytes = (
|
||||
url: string,
|
||||
limits: FetchLimits = {},
|
||||
): Effect.Effect<FetchedBytes, HostRequestError> =>
|
||||
Effect.tryPromise({
|
||||
try: async (): Promise<FetchedBytes> => {
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
throw new Error("only http(s) URLs may be fetched");
|
||||
}
|
||||
const maxBytes = limits.maxBytes ?? DEFAULT_MAX;
|
||||
const signal = AbortSignal.timeout(limits.timeoutMs ?? DEFAULT_TIMEOUT);
|
||||
// `redirect: "manual"` rather than "error": we want to SEE the 3xx and report it as a
|
||||
// refusal, not have fetch throw something opaque.
|
||||
const res = await fetch(url, { redirect: "manual", signal });
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
throw new Error(`refusing to follow a ${res.status} redirect`);
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
// Trust Content-Length when it is there (cheap rejection), but still bound the read: a
|
||||
// hostile server can lie about it or omit it entirely.
|
||||
const declared = Number(res.headers.get("content-length"));
|
||||
if (Number.isFinite(declared) && declared > maxBytes) {
|
||||
throw new Error(`body larger than ${maxBytes} bytes`);
|
||||
}
|
||||
const buf = new Uint8Array(await res.arrayBuffer());
|
||||
if (buf.byteLength === 0) throw new Error("empty body");
|
||||
if (buf.byteLength > maxBytes) {
|
||||
throw new Error(`body larger than ${maxBytes} bytes`);
|
||||
}
|
||||
return {
|
||||
bytes: buf,
|
||||
contentType: res.headers.get("content-type") ?? "image/jpeg",
|
||||
};
|
||||
},
|
||||
catch: (cause) =>
|
||||
new HostRequestError({
|
||||
method: "GET",
|
||||
path: url,
|
||||
cause,
|
||||
}),
|
||||
});
|
||||
|
||||
/** {@link fetchBytes}, JSON-decoded. Same posture; use for a store's public product API. */
|
||||
export const fetchJson = <T = unknown>(
|
||||
url: string,
|
||||
limits: FetchLimits = {},
|
||||
): Effect.Effect<T, HostRequestError> =>
|
||||
fetchBytes(url, limits).pipe(
|
||||
Effect.flatMap((r) =>
|
||||
Effect.try({
|
||||
try: () => JSON.parse(new TextDecoder().decode(r.bytes)) as T,
|
||||
catch: (cause) =>
|
||||
new HostRequestError({
|
||||
method: "GET",
|
||||
path: url,
|
||||
cause,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,61 @@
|
||||
// The launcher-file parsing toolkit: what the six in-host scanners hand-rolled, hoisted so a
|
||||
// library plugin is its scan function and nothing else.
|
||||
//
|
||||
// Everything here is total — a missing launcher, a truncated file, a schema drift in a launcher
|
||||
// upgrade all degrade to "no titles from this source", never to a thrown error. A scanner that dies
|
||||
// on one odd file takes the user's whole library with it.
|
||||
export {
|
||||
ART_KINDS,
|
||||
type ArtKind,
|
||||
fileUrl,
|
||||
findGridArtFile,
|
||||
findLocalArtFile,
|
||||
gridFilenames,
|
||||
steamCdnUrl,
|
||||
} from "./art.js";
|
||||
export {
|
||||
confinedJoin,
|
||||
isDir,
|
||||
isFile,
|
||||
listDir,
|
||||
MAX_CACHE_BYTES,
|
||||
MAX_MANIFEST_BYTES,
|
||||
readBytesCapped,
|
||||
readJsonCapped,
|
||||
readTextCapped,
|
||||
} from "./fs.js";
|
||||
export {
|
||||
type FetchedBytes,
|
||||
type FetchLimits,
|
||||
fetchBytes,
|
||||
fetchJson,
|
||||
} from "./http.js";
|
||||
export {
|
||||
parseRegQuery,
|
||||
regQueryValue,
|
||||
regQueryValues,
|
||||
regSubKeys,
|
||||
type RegValue,
|
||||
validRegKey,
|
||||
} from "./registry.js";
|
||||
export { openReadOnly, type ReadOnlyDb, withReadOnlyDb } from "./sqlite.js";
|
||||
export {
|
||||
crc32,
|
||||
parseShortcuts,
|
||||
type Shortcut,
|
||||
shortcutAppId,
|
||||
shortcutGameId,
|
||||
} from "./shortcuts.js";
|
||||
export {
|
||||
steamLibraryDirs,
|
||||
steamRoots,
|
||||
steamUserConfigDirs,
|
||||
} from "./steam-root.js";
|
||||
export {
|
||||
type AppManifest,
|
||||
isSteamTool,
|
||||
parseAppManifest,
|
||||
vdfField,
|
||||
vdfPaths,
|
||||
vdfValue,
|
||||
} from "./vdf.js";
|
||||
@@ -0,0 +1,94 @@
|
||||
// Windows registry reads by spawning `reg.exe query` — dependency-free, and (the part that
|
||||
// matters) it works from the scripting runner's LocalService account.
|
||||
//
|
||||
// **HKLM only, by design.** The runner runs as `NT AUTHORITY\LocalService` on Windows, which has no
|
||||
// user profile: HKCU is not the operator's hive there, it is LocalService's own — so a plugin that
|
||||
// read HKCU would silently see an empty registry rather than the user's launcher config. Every
|
||||
// launcher fact a scanner needs (Steam's InstallPath, GOG's game list) lives under HKLM
|
||||
// `WOW6432Node` anyway. Asking for HKCU is a bug, so this refuses it outright.
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
/** One `reg.exe query` value row. */
|
||||
export interface RegValue {
|
||||
readonly name: string;
|
||||
/** `REG_SZ`, `REG_DWORD`, … */
|
||||
readonly type: string;
|
||||
readonly data: string;
|
||||
}
|
||||
|
||||
const HKLM = "HKLM\\";
|
||||
|
||||
/** Is this a key path this module will touch? See the module docs on why HKLM only. */
|
||||
export const validRegKey = (key: string): boolean =>
|
||||
key.startsWith(HKLM) &&
|
||||
key.length > HKLM.length &&
|
||||
key.length <= 260 &&
|
||||
!key.includes("..") &&
|
||||
// `reg.exe` takes the key as one argv element (no shell), but keep the charset tame anyway so a
|
||||
// malformed key can never turn into a switch.
|
||||
!key.startsWith("/") &&
|
||||
!/[\r\n\0"]/.test(key);
|
||||
|
||||
const run = (args: string[]): string | undefined => {
|
||||
if (process.platform !== "win32") return undefined;
|
||||
const r = spawnSync("reg.exe", args, {
|
||||
encoding: "utf8",
|
||||
windowsHide: true,
|
||||
// A registry read is instant; a hang means something is badly wrong and a scan must not
|
||||
// block on it forever.
|
||||
timeout: 10_000,
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
});
|
||||
if (r.status !== 0 || typeof r.stdout !== "string") return undefined;
|
||||
return r.stdout;
|
||||
};
|
||||
|
||||
/**
|
||||
* The values directly under one HKLM key. `[]` when the key is absent, unreadable, or this is not
|
||||
* Windows — a missing launcher is the normal case, never an error.
|
||||
*/
|
||||
export const regQueryValues = (key: string): RegValue[] => {
|
||||
if (!validRegKey(key)) return [];
|
||||
const out = run(["query", key]);
|
||||
if (out === undefined) return [];
|
||||
return parseRegQuery(out);
|
||||
};
|
||||
|
||||
/** One named value under an HKLM key, or `undefined`. */
|
||||
export const regQueryValue = (key: string, name: string): string | undefined =>
|
||||
regQueryValues(key).find((v) => v.name.toLowerCase() === name.toLowerCase())
|
||||
?.data;
|
||||
|
||||
/** The immediate SUBKEY paths under one HKLM key (GOG lists one subkey per installed game). */
|
||||
export const regSubKeys = (key: string): string[] => {
|
||||
if (!validRegKey(key)) return [];
|
||||
const out = run(["query", key]);
|
||||
if (out === undefined) return [];
|
||||
const prefix = `${key.toLowerCase()}\\`;
|
||||
return out
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.toLowerCase().startsWith(prefix))
|
||||
.filter((l) => !l.slice(key.length + 1).includes("\\"));
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse `reg.exe query` output rows: ` <name> <TYPE> <data>`, separated by runs of
|
||||
* whitespace. Data may itself contain spaces (a path), so only the first two columns are split off.
|
||||
*
|
||||
* Exported for tests — the format is stable but this is exactly the kind of thing that quietly
|
||||
* breaks, and a plugin's tests can pin it without a Windows box.
|
||||
*/
|
||||
export const parseRegQuery = (stdout: string): RegValue[] => {
|
||||
const out: RegValue[] = [];
|
||||
for (const raw of stdout.split(/\r?\n/)) {
|
||||
// Value rows are indented; the key path header is not.
|
||||
if (!/^\s/.test(raw)) continue;
|
||||
const line = raw.trim();
|
||||
if (line === "") continue;
|
||||
const m = line.match(/^(.*?)\s{2,}(REG_[A-Z_]+)\s{2,}([\s\S]*)$/);
|
||||
if (!m) continue;
|
||||
out.push({ name: m[1], type: m[2], data: m[3] });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
// Steam's BINARY `shortcuts.vdf` — the user's "Add a Non-Steam Game to My Library" entries.
|
||||
//
|
||||
// Ported from the host's in-tree scanner (crates/punktfunk-host/src/library/steam.rs), together
|
||||
// with its unit tests, which are the real specification here: the format is undocumented, and the
|
||||
// two id derivations below (`shortcutAppId`, `shortcutGameId`) are the difference between a
|
||||
// shortcut that launches and one that silently does nothing.
|
||||
//
|
||||
// Format: a 1-byte type tag (`0x00` nested map, `0x01` string, `0x02` int32, `0x07` uint64), a
|
||||
// NUL-terminated key, then a type-specific payload; `0x08` closes the current map. The whole file is
|
||||
// one `shortcuts` map whose children (keyed "0", "1", …) are the individual shortcuts.
|
||||
//
|
||||
// Lenient and total by design: a truncated file or an unrecognized tag stops the walk and returns
|
||||
// whatever parsed so far. A user's shortcuts file is not something to be strict about.
|
||||
|
||||
export interface Shortcut {
|
||||
/** The 32-bit shortcut appid — always high-bit set. Keys the entry id and its `grid/` art. */
|
||||
readonly appid: number;
|
||||
readonly name: string;
|
||||
/** The shortcut's target, as Steam stores it (quoted, possibly with trailing arguments). */
|
||||
readonly exe: string;
|
||||
readonly hidden: boolean;
|
||||
}
|
||||
|
||||
/** A cursor over the buffer — the ported code's `pos` threaded explicitly. */
|
||||
interface Cursor {
|
||||
pos: number;
|
||||
}
|
||||
|
||||
/** Read a NUL-terminated UTF-8 string, advancing past the terminator. `undefined` if unterminated. */
|
||||
const readCStr = (buf: Uint8Array, c: Cursor): string | undefined => {
|
||||
const start = c.pos;
|
||||
let end = start;
|
||||
while (end < buf.length && buf[end] !== 0) end++;
|
||||
if (end >= buf.length) return undefined;
|
||||
const s = new TextDecoder("utf-8").decode(buf.subarray(start, end));
|
||||
c.pos = end + 1;
|
||||
return s;
|
||||
};
|
||||
|
||||
/** Read a little-endian int32, advancing 4 bytes. `undefined` if fewer than 4 remain. */
|
||||
const readI32 = (buf: Uint8Array, c: Cursor): number | undefined => {
|
||||
if (c.pos + 4 > buf.length) return undefined;
|
||||
const v = new DataView(buf.buffer, buf.byteOffset + c.pos, 4).getInt32(0, true);
|
||||
c.pos += 4;
|
||||
return v;
|
||||
};
|
||||
|
||||
/** Skip a nested map's contents (positioned just after its key) up to and including its `0x08`. */
|
||||
const skipMap = (buf: Uint8Array, c: Cursor): boolean => {
|
||||
for (;;) {
|
||||
if (c.pos >= buf.length) return false;
|
||||
const tag = buf[c.pos];
|
||||
c.pos += 1;
|
||||
if (tag === 0x08) return true;
|
||||
if (readCStr(buf, c) === undefined) return false;
|
||||
if (tag === 0x00) {
|
||||
if (!skipMap(buf, c)) return false;
|
||||
} else if (tag === 0x01) {
|
||||
if (readCStr(buf, c) === undefined) return false;
|
||||
} else if (tag === 0x02) {
|
||||
c.pos += 4;
|
||||
} else if (tag === 0x07) {
|
||||
c.pos += 8;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** Parse one shortcut's fields (positioned just after its index key) up to the map-closing `0x08`. */
|
||||
const parseOne = (buf: Uint8Array, c: Cursor): Shortcut | undefined => {
|
||||
let appid: number | undefined;
|
||||
let name = "";
|
||||
let exe = "";
|
||||
let hidden = false;
|
||||
for (;;) {
|
||||
if (c.pos >= buf.length) return undefined;
|
||||
const tag = buf[c.pos];
|
||||
c.pos += 1;
|
||||
if (tag === 0x08) break;
|
||||
const key = readCStr(buf, c)?.toLowerCase();
|
||||
if (key === undefined) return undefined;
|
||||
if (tag === 0x00) {
|
||||
if (!skipMap(buf, c)) return undefined; // nested map (e.g. `tags`) — not needed
|
||||
} else if (tag === 0x01) {
|
||||
const val = readCStr(buf, c);
|
||||
if (val === undefined) return undefined;
|
||||
if (key === "appname") name = val;
|
||||
else if (key === "exe") exe = val;
|
||||
} else if (tag === 0x02) {
|
||||
const val = readI32(buf, c);
|
||||
if (val === undefined) return undefined;
|
||||
if (key === "appid") appid = val >>> 0;
|
||||
else if (key === "ishidden") hidden = val !== 0;
|
||||
} else if (tag === 0x07) {
|
||||
c.pos += 8; // uint64 — skip
|
||||
} else {
|
||||
return undefined; // unknown tag: payload size unknown, can't continue safely
|
||||
}
|
||||
}
|
||||
if (name.trim() === "") return undefined; // nothing worth showing
|
||||
// Prefer the stored appid; fall back to Steam's derivation when it's absent (0 / missing).
|
||||
const id = appid && appid !== 0 ? appid : shortcutAppId(exe, name);
|
||||
return { appid: id, name, exe, hidden };
|
||||
};
|
||||
|
||||
/** Parse a binary `shortcuts.vdf` into its shortcuts. Never throws. */
|
||||
export const parseShortcuts = (buf: Uint8Array): Shortcut[] => {
|
||||
const out: Shortcut[] = [];
|
||||
const c: Cursor = { pos: 0 };
|
||||
// Enter the top-level map (`<0x00> "shortcuts" <NUL>`); tolerate any key name.
|
||||
if (buf[0] !== 0x00) return out;
|
||||
c.pos = 1;
|
||||
if (readCStr(buf, c) === undefined) return out;
|
||||
while (c.pos < buf.length) {
|
||||
const tag = buf[c.pos];
|
||||
c.pos += 1;
|
||||
if (tag !== 0x00) break; // `0x08` (end of shortcuts) or anything unexpected
|
||||
if (readCStr(buf, c) === undefined) break; // the index key ("0", "1", …)
|
||||
const sc = parseOne(buf, c);
|
||||
if (!sc) break;
|
||||
out.push(sc);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/** Standard reflected (IEEE) CRC-32 — what Steam hashes a shortcut's `exe + name` with. */
|
||||
export const crc32 = (data: Uint8Array): number => {
|
||||
let crc = 0xffff_ffff;
|
||||
for (const byte of data) {
|
||||
crc ^= byte;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const mask = -(crc & 1);
|
||||
crc = (crc >>> 1) ^ (0xedb8_8320 & mask);
|
||||
}
|
||||
}
|
||||
return (~crc) >>> 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* The 32-bit appid Steam derives for a shortcut from its target+name — `crc32(exe + name)` with the
|
||||
* high bit set. Only used when `shortcuts.vdf` omits the stored `appid` (very old Steam); modern
|
||||
* Steam writes it and the stored value is preferred.
|
||||
*
|
||||
* The high bit is load-bearing downstream: it is how a shortcut is told apart from a real store
|
||||
* appid, which is what makes the CDN art fetch skippable for shortcuts (they only ever have `grid/`
|
||||
* overrides).
|
||||
*/
|
||||
export const shortcutAppId = (exe: string, name: string): number =>
|
||||
(crc32(new TextEncoder().encode(exe + name)) | 0x8000_0000) >>> 0;
|
||||
|
||||
/**
|
||||
* The 64-bit game id `steam://rungameid/` needs in order to launch a non-Steam shortcut: high dword
|
||||
* = the 32-bit shortcut appid, low dword = the shortcut marker `0x02000000`.
|
||||
*
|
||||
* Handing `rungameid` the bare 32-bit appid does NOT launch a shortcut — it must be this composed
|
||||
* id. Returned as a decimal string because it exceeds 2^53 and would lose precision as a `number`.
|
||||
*/
|
||||
export const shortcutGameId = (appid: number): string =>
|
||||
((BigInt(appid >>> 0) << 32n) | 0x0200_0000n).toString();
|
||||
@@ -0,0 +1,68 @@
|
||||
// Read-only SQLite over `bun:sqlite` — for launcher databases a plugin must never disturb.
|
||||
//
|
||||
// Lutris' `pga.db` is the motivating case: it belongs to a running application, and a scanner that
|
||||
// opened it read-write could take a write lock, create `-wal`/`-shm` sidecars next to it, or (worst
|
||||
// case) be blamed for a corrupted library. `immutable=1` promises the file will not change while
|
||||
// open, which makes Bun skip locking entirely — the strictest possible "look, don't touch".
|
||||
import { Database } from "bun:sqlite";
|
||||
import { isFile } from "./fs.js";
|
||||
|
||||
export interface ReadOnlyDb {
|
||||
/** Run a query and return its rows. Returns `[]` rather than throwing on a bad query. */
|
||||
readonly query: <T = Record<string, unknown>>(
|
||||
sql: string,
|
||||
...params: unknown[]
|
||||
) => T[];
|
||||
readonly close: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a launcher database read-only and immutably. `undefined` if the file is absent or not a
|
||||
* database — the normal "this launcher isn't installed" case, not an error.
|
||||
*
|
||||
* Always `close()` when done (or use {@link withReadOnlyDb}, which does it for you).
|
||||
*/
|
||||
export const openReadOnly = (file: string): ReadOnlyDb | undefined => {
|
||||
if (!isFile(file)) return undefined;
|
||||
let db: Database;
|
||||
try {
|
||||
// `readonly` alone still takes locks and can spawn WAL sidecars; `immutable=1` is what makes
|
||||
// this a pure read. It is safe here precisely because a scan is a point-in-time snapshot —
|
||||
// if the launcher writes mid-scan we simply pick it up on the next sync.
|
||||
db = new Database(`file:${encodeURI(file)}?immutable=1`, { readonly: true });
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
query: <T = Record<string, unknown>>(sql: string, ...params: unknown[]) => {
|
||||
try {
|
||||
return db.query(sql).all(...(params as never[])) as T[];
|
||||
} catch {
|
||||
// A schema drift (a renamed column in a launcher upgrade) must degrade to "no
|
||||
// titles from this source", never take the whole plugin down.
|
||||
return [] as T[];
|
||||
}
|
||||
},
|
||||
close: () => {
|
||||
try {
|
||||
db.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** Open, use, and always close. Returns `undefined` when the database isn't there. */
|
||||
export const withReadOnlyDb = <T>(
|
||||
file: string,
|
||||
use: (db: ReadOnlyDb) => T,
|
||||
): T | undefined => {
|
||||
const db = openReadOnly(file);
|
||||
if (!db) return undefined;
|
||||
try {
|
||||
return use(db);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
// Where Steam lives on this host, and which `steamapps` dirs hold installed titles.
|
||||
//
|
||||
// Ported from the host scanner (steam.rs `steam_roots` / `steam_library_dirs`) with one deliberate
|
||||
// addition and one deliberate exclusion, both about the Windows runner's account:
|
||||
//
|
||||
// * ADDED: HKLM `WOW6432Node\Valve\Steam\InstallPath`, so a non-default Steam install dir is
|
||||
// found. The host scanner never covered this (it relied on an explorer.exe protocol fallback at
|
||||
// launch time), but a plugin that can't find the root finds no games at all.
|
||||
// * EXCLUDED: HKCU `Software\Valve\Steam`. The runner is LocalService, whose HKCU is its own empty
|
||||
// hive, not the operator's — reading it would look like "Steam isn't installed".
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { isDir, listDir, readTextCapped } from "./fs.js";
|
||||
import { regQueryValue } from "./registry.js";
|
||||
import { vdfPaths } from "./vdf.js";
|
||||
|
||||
/** Canonicalize-ish: resolve and drop a trailing separator so dedup is reliable. */
|
||||
const norm = (p: string): string => path.resolve(p);
|
||||
|
||||
/**
|
||||
* Candidate Steam roots that actually exist (have a `steamapps` dir), deduped.
|
||||
*
|
||||
* A "root" is the Steam install itself — `userdata/`, `appcache/` and the first `steamapps/` live
|
||||
* under it. Extra library folders on other drives are NOT roots; see {@link steamLibraryDirs}.
|
||||
*/
|
||||
export const steamRoots = (): string[] => {
|
||||
const candidates: string[] = [];
|
||||
if (process.platform === "win32") {
|
||||
for (const v of ["ProgramFiles(x86)", "ProgramFiles", "ProgramW6432"]) {
|
||||
const pf = process.env[v];
|
||||
if (pf) candidates.push(path.join(pf, "Steam"));
|
||||
}
|
||||
// The registry install path — covers a Steam installed somewhere other than Program Files.
|
||||
for (const key of [
|
||||
"HKLM\\SOFTWARE\\WOW6432Node\\Valve\\Steam",
|
||||
"HKLM\\SOFTWARE\\Valve\\Steam",
|
||||
]) {
|
||||
const p = regQueryValue(key, "InstallPath");
|
||||
if (p) candidates.push(p);
|
||||
}
|
||||
} else {
|
||||
const home = os.homedir();
|
||||
if (home) {
|
||||
candidates.push(
|
||||
path.join(home, ".local/share/Steam"),
|
||||
path.join(home, ".steam/steam"),
|
||||
path.join(home, ".steam/root"),
|
||||
// Flatpak Steam
|
||||
path.join(home, ".var/app/com.valvesoftware.Steam/.local/share/Steam"),
|
||||
);
|
||||
}
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const roots: string[] = [];
|
||||
for (const c of candidates) {
|
||||
const n = norm(c);
|
||||
if (!seen.has(n) && isDir(path.join(n, "steamapps"))) {
|
||||
seen.add(n);
|
||||
roots.push(n);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
};
|
||||
|
||||
/**
|
||||
* Every `steamapps` dir holding installed titles: each root's own, plus the extra library folders
|
||||
* listed in its `libraryfolders.vdf` (Steam installs to other drives).
|
||||
*/
|
||||
export const steamLibraryDirs = (roots = steamRoots()): string[] => {
|
||||
const seen = new Set<string>();
|
||||
const dirs: string[] = [];
|
||||
const push = (p: string) => {
|
||||
const n = norm(p);
|
||||
if (!seen.has(n) && isDir(n)) {
|
||||
seen.add(n);
|
||||
dirs.push(n);
|
||||
}
|
||||
};
|
||||
for (const root of roots) {
|
||||
const steamapps = path.join(root, "steamapps");
|
||||
const text = readTextCapped(path.join(steamapps, "libraryfolders.vdf"));
|
||||
if (text !== undefined) {
|
||||
for (const p of vdfPaths(text)) push(path.join(p, "steamapps"));
|
||||
}
|
||||
push(steamapps);
|
||||
}
|
||||
return dirs;
|
||||
};
|
||||
|
||||
/**
|
||||
* Every `userdata/<accountId>/config` dir across all roots — one per Steam account that has signed
|
||||
* in on this host. `shortcuts.vdf` and the `grid/` art overrides live here.
|
||||
*/
|
||||
export const steamUserConfigDirs = (roots = steamRoots()): string[] => {
|
||||
const out: string[] = [];
|
||||
for (const root of roots) {
|
||||
const userdata = path.join(root, "userdata");
|
||||
for (const acct of listDir(userdata)) {
|
||||
const cfg = path.join(userdata, acct, "config");
|
||||
if (isDir(cfg)) out.push(cfg);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user