Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ae524d801 | ||
|
|
8c6099da2a | ||
|
|
2b066b3e11 | ||
|
|
c3c24b5855 | ||
|
|
0d4f878f32 | ||
|
|
d886cd0124 | ||
|
|
2832b5d0f6 | ||
|
|
6863f8141a | ||
|
|
cf4c12ea52 | ||
|
|
5e5d6904d3 | ||
|
|
9ce347e4c0 | ||
|
|
dea6395772 | ||
|
|
55dbb14cf4 | ||
|
|
f0b35de92a | ||
|
|
588962f696 | ||
|
|
91b8f1a939 | ||
|
|
b5cace3a00 | ||
|
|
1e5dca4c25 | ||
|
|
b2146f33fe | ||
|
|
1ac6c9bf3d | ||
|
|
36e133ae66 | ||
|
|
d7e66fafe1 | ||
|
|
026dbe6153 | ||
|
|
cc70c64797 | ||
|
|
b66bcef528 | ||
|
|
f033d3f5df | ||
|
|
e3443da108 | ||
|
|
5a4dd7423e | ||
|
|
0a468c96da |
@@ -56,7 +56,21 @@ on:
|
||||
- 'rust-toolchain.toml'
|
||||
- 'scripts/ci/**'
|
||||
- '.gitea/workflows/android.yml'
|
||||
# Manual runs are BUILD-ONLY by default. The escape hatch below exists because a push run can
|
||||
# go missing entirely: merge two PRs seconds apart and Gitea attributes the window's runs to the
|
||||
# newer head, so the older merge sha gets no run at all — its android change then sits on main
|
||||
# having never been built, let alone published (2026-08-14: `1e5dca4c`, PR #235, lost its run to
|
||||
# `b5cace3a` 12 s later). Re-running the PR run does NOT recover it: a re-run replays the original
|
||||
# `pull_request` event, so every gate below stays false. Only a dispatch with publish=true can
|
||||
# ship that commit without inventing a filler push.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish:
|
||||
# String, not a boolean: matches apple.yml's `testflight` input, which is the form proven
|
||||
# to evaluate correctly on this Gitea. Compared as `inputs.publish == 'true'` below.
|
||||
description: "Also publish this build (registry + Google Play). main -> beta+alpha, vX.Y.Z tag -> production at 100%. Default false: a stray click must not reach testers."
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's
|
||||
# unbound). The NDK clang targets get their own key universes automatically (keys embed
|
||||
@@ -228,7 +242,9 @@ jobs:
|
||||
# Single source of the version name + the Play track for the release steps below. versionCode
|
||||
# stays github.run_number (monotonic across both tracks; Play rejects a regressed code).
|
||||
- name: Version + channel
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
if: >-
|
||||
(github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish == 'true'))
|
||||
&& (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
|
||||
case "$GITHUB_REF" in
|
||||
@@ -250,7 +266,9 @@ jobs:
|
||||
echo "android version $VN -> Play track '$TRACK'${ALSO:+ (+ '$ALSO')}"
|
||||
|
||||
- name: Build Release (signed AAB + universal APK)
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
if: >-
|
||||
(github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish == 'true'))
|
||||
&& (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
working-directory: clients/android
|
||||
env:
|
||||
VERSION_CODE: ${{ github.run_number }} # VERSION_NAME comes from the Version+channel step (GITHUB_ENV)
|
||||
@@ -285,7 +303,9 @@ jobs:
|
||||
# main = canary store + `canary/` sideload alias; a `vX.Y.Z` tag = `latest/` alias + attached
|
||||
# to the unified Gitea Release.
|
||||
- name: Publish to generic registry + attach to Gitea release
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
if: >-
|
||||
(github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish == 'true'))
|
||||
&& (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
env:
|
||||
REGISTRY: git.unom.io
|
||||
OWNER: unom
|
||||
@@ -328,7 +348,9 @@ jobs:
|
||||
# `--status inProgress --user-fraction 0.2`; to undo a bad one, halt or roll back from the
|
||||
# Console (or `android-promote.yml`, which can re-point production at an older versionCode).
|
||||
- name: Upload to Google Play
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
if: >-
|
||||
(github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish == 'true'))
|
||||
&& (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
env:
|
||||
SERVICE_ACCOUNT_JSON: ${{ secrets.SERVICE_ACCOUNT_JSON }}
|
||||
run: |
|
||||
|
||||
@@ -755,7 +755,7 @@ jobs:
|
||||
bash tools/screenshots.sh ipad || echo "::warning::iPad 13\" screenshots skipped"
|
||||
# tvOS shoots only the scenes that exist there — the 06–09 gamepad-console scenes are
|
||||
# compiled out on tvOS (native focus engine), and an unknown name = a normal app launch.
|
||||
SCENES="01-stream 02-hosts 05-settings 03-pair" \
|
||||
SCENES="01-stream 02-hosts 11-library 05-settings 03-pair" \
|
||||
bash tools/screenshots.sh tvos || echo "::warning::Apple TV screenshots skipped"
|
||||
echo "Produced:"; ls -la screenshots || true
|
||||
|
||||
|
||||
@@ -70,12 +70,18 @@
|
||||
# latest stable tag via scripts/ci/pf-version.ps1, run number climbs monotonically).
|
||||
# Both arches share the version; artifacts are arch-suffixed (..._x64.msix / ..._arm64.msix).
|
||||
#
|
||||
# Signing (clients/windows/packaging/pack-msix.ps1): if the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD
|
||||
# Actions secrets are set (a real or shared code-signing .pfx whose subject DN == Publisher), the
|
||||
# package is signed with them. Otherwise an ephemeral self-signed cert is generated and its public
|
||||
# .cer is published next to the .msix (users import it to Trusted People before install).
|
||||
# Signing (clients/windows/packaging/pack-msix.ps1), first match wins:
|
||||
# 1. Azure Artifact Signing — what this workflow always takes, since the AZURE_CODESIGNING_*
|
||||
# endpoint/account/profile are literals below and only the AZURE_TENANT_ID / AZURE_CLIENT_ID /
|
||||
# AZURE_CLIENT_SECRET secrets are needed. Publicly trusted, so NO .cer is emitted or published
|
||||
# and users import nothing. NOTE the Publisher DN is the Azure profile's verified subject, and
|
||||
# MSIX identity is name + publisher: moving to it changed the package identity, so installs
|
||||
# predating it need an uninstall, not an upgrade.
|
||||
# 2. MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD — the older self-signed .pfx, kept as a fallback.
|
||||
# 3. an ephemeral self-signed cert. Modes 2 and 3 DO emit a .cer next to the .msix, which users
|
||||
# would have to import into Trusted People before Windows will install the package.
|
||||
#
|
||||
# That fallback is for canary/CI ONLY. On a v* tag the pack script FAILS CLOSED — a missing secret
|
||||
# Modes 2 and 3 are for canary/CI ONLY. On a v* tag the pack script FAILS CLOSED — a missing secret
|
||||
# aborts the build instead of quietly shipping a release signed by a per-build throwaway cert that
|
||||
# no one can pin. Nothing to opt into here: the script reads GITHUB_REF itself.
|
||||
name: windows-client
|
||||
|
||||
@@ -142,6 +142,10 @@ dependencies {
|
||||
// job runs `:app:testDebugUnitTest -PskipRustBuild` (see kit/build.gradle.kts). ---
|
||||
testImplementation(composeBom)
|
||||
testImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
// Deterministic cover art for the library scene: FakeImageLoaderEngine answers the coverflow's
|
||||
// AsyncImage synchronously with generated posters — no network, no async race under the frozen
|
||||
// animation clock.
|
||||
testImplementation("io.coil-kt:coil-test:2.7.0")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest") // the ComponentActivity test host
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
// Real `org.json` for the shared-vectors test: the `org.json` inside `android.jar` is a stub
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
@@ -14,6 +13,7 @@ import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
@@ -206,7 +206,9 @@ fun AwaitingApprovalPrompt(gamepadUi: Boolean, hostLabel: String, onCancel: () -
|
||||
actions = listOf(DialogAction("Cancel", primary = true, onClick = onCancel)),
|
||||
dismissOnOutsideTap = false,
|
||||
) {
|
||||
val deviceName = Build.MODEL ?: "this device"
|
||||
// MUST be the name the connect actually knocked with (`HostConnect`), or this sends the
|
||||
// user looking for a row the console does not show.
|
||||
val label = deviceName(LocalContext.current)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
@@ -222,7 +224,7 @@ fun AwaitingApprovalPrompt(gamepadUi: Boolean, hostLabel: String, onCancel: () -
|
||||
)
|
||||
}
|
||||
PromptText(
|
||||
"Open the host's console (or web UI) and approve “$deviceName”. It connects " +
|
||||
"Open the host's console (or web UI) and approve “$label”. It connects " +
|
||||
"automatically once you approve — no PIN needed.",
|
||||
gamepadUi,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -31,6 +30,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
@@ -137,7 +137,8 @@ internal fun PairPinDialog(
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var pin by remember(pt) { mutableStateOf("") }
|
||||
var name by remember(pt) { mutableStateOf(Build.MODEL ?: "Android") }
|
||||
val context = LocalContext.current
|
||||
var name by remember(pt) { mutableStateOf(deviceName(context)) }
|
||||
var pairing by remember(pt) { mutableStateOf(false) }
|
||||
var err by remember(pt) { mutableStateOf<String?>(null) }
|
||||
AlertDialog(
|
||||
|
||||
@@ -69,7 +69,7 @@ import kotlinx.coroutines.delay
|
||||
* to be the same one whichever interface asked.
|
||||
*/
|
||||
@Composable
|
||||
fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
||||
internal fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit, padsOverride: List<PadInfo>? = null) {
|
||||
BackHandler(onBack = onBack)
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
ControllersBody(
|
||||
@@ -77,6 +77,7 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
||||
scroll = rememberScrollState(),
|
||||
testing = testing,
|
||||
onTestingChange = { testing = it },
|
||||
padsOverride = padsOverride,
|
||||
// The touch screen holds the probes for its whole life: events are OBSERVED (not consumed)
|
||||
// while the test is off, which is what keeps the "Last input" line live while browsing.
|
||||
// Nothing else here wants the pad, so there is no one to hand them to.
|
||||
@@ -99,7 +100,12 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
||||
* drops out of the probe slots and B is a HOLD (below). Everything reverts the moment it ends.
|
||||
*/
|
||||
@Composable
|
||||
fun ConsoleControllersScreen(gamepadSetting: Int, onBack: () -> Unit, navActive: Boolean = true) {
|
||||
internal fun ConsoleControllersScreen(
|
||||
gamepadSetting: Int,
|
||||
onBack: () -> Unit,
|
||||
navActive: Boolean = true,
|
||||
padsOverride: List<PadInfo>? = null,
|
||||
) {
|
||||
BackHandler(onBack = onBack)
|
||||
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
val hazeState = remember { HazeState() }
|
||||
@@ -139,6 +145,7 @@ fun ConsoleControllersScreen(gamepadSetting: Int, onBack: () -> Unit, navActive:
|
||||
scroll = scroll,
|
||||
testing = testing,
|
||||
onTestingChange = { testing = it },
|
||||
padsOverride = padsOverride,
|
||||
// Only while testing: the rest of the time the screen's own nav holds the
|
||||
// probes, so the "Last input" line is a test-time readout here rather than
|
||||
// an always-on one. A pad that reaches this screen at all has already
|
||||
@@ -200,14 +207,17 @@ private fun ControllersBody(
|
||||
onTestingChange: (Boolean) -> Unit,
|
||||
observeInput: Boolean,
|
||||
contentPadding: PaddingValues,
|
||||
padsOverride: List<PadInfo>? = null,
|
||||
heading: @Composable () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val activity = context as? MainActivity
|
||||
|
||||
// Device list, re-read on every hot-plug event.
|
||||
// Device list, re-read on every hot-plug event. [padsOverride] replaces it wholesale: the
|
||||
// screenshot harness runs where no InputDevice can exist, and the connected-pad card is the
|
||||
// point of that shot.
|
||||
var generation by remember { mutableIntStateOf(0) }
|
||||
val pads = remember(generation) { Gamepad.pads() }
|
||||
val pads = padsOverride ?: remember(generation) { Gamepad.pads() }.map(::padInfoOf)
|
||||
val others = remember(generation) {
|
||||
InputDevice.getDeviceIds()
|
||||
.toList()
|
||||
@@ -392,8 +402,8 @@ private fun ControllersBody(
|
||||
// Every real controller is forwarded now (Automatic forwards them all, each on its own
|
||||
// wire pad index) — not just the first. A joystick-only device Android doesn't classify as
|
||||
// a gamepad still can't be forwarded (the host wants a gamepad), so gate the badge on it.
|
||||
pads.forEach { dev ->
|
||||
PadRow(dev, forwarded = isForwarded(dev), gamepadSetting = gamepadSetting)
|
||||
pads.forEach { info ->
|
||||
PadRow(info, gamepadSetting = gamepadSetting)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -675,19 +685,19 @@ private fun DsRow(usbDev: android.hardware.usb.UsbDevice) {
|
||||
|
||||
/** One detected gamepad: identity, what it streams as, and a rumble test. */
|
||||
@Composable
|
||||
private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
|
||||
private fun PadRow(info: PadInfo, gamepadSetting: Int) {
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(dev.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||
if (forwarded) {
|
||||
Text(info.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||
if (info.forwarded) {
|
||||
// Android's own controller number (1-based; 0 = unassigned), shown so a multi-pad
|
||||
// user can tell which physical pad is which. The stream's wire pad index is
|
||||
// assigned separately (lowest-free per device) once streaming starts.
|
||||
val number = dev.controllerNumber
|
||||
val number = info.controllerNumber
|
||||
Text(
|
||||
if (number > 0) "forwarded · player $number" else "forwarded to host",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
@@ -696,11 +706,11 @@ private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
|
||||
}
|
||||
}
|
||||
Text(
|
||||
deviceDetail(dev),
|
||||
info.detail,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
val resolved = Gamepad.prefFor(dev)
|
||||
val resolved = info.resolvedPref
|
||||
Text(
|
||||
if (gamepadSetting == Gamepad.PREF_AUTO) {
|
||||
"Streams as: ${prefLabel(resolved)} (automatic)"
|
||||
@@ -711,9 +721,8 @@ private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
val canRumble = deviceHasVibrator(dev)
|
||||
if (canRumble) {
|
||||
OutlinedButton(onClick = { testRumble(dev) }) { Text("Test rumble") }
|
||||
if (info.canRumble) {
|
||||
OutlinedButton(onClick = { info.dev?.let(::testRumble) }) { Text("Test rumble") }
|
||||
} else {
|
||||
Text(
|
||||
"No rumble motors reported — host rumble will be silent",
|
||||
@@ -794,6 +803,32 @@ private fun Group(title: String, content: @Composable ColumnScope.() -> Unit) {
|
||||
private fun isForwarded(dev: InputDevice): Boolean =
|
||||
!dev.isVirtual && dev.sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD
|
||||
|
||||
/**
|
||||
* Everything [PadRow] renders, decoupled from [InputDevice] so the screenshot harness can compose
|
||||
* the connected-pad card at all — Robolectric enumerates no input devices, and a marketing shot of
|
||||
* "no controller detected" sells nothing. Production always maps a real device via [padInfoOf];
|
||||
* [dev] powers the rumble test and is absent only in the harness (the button then no-ops).
|
||||
*/
|
||||
internal data class PadInfo(
|
||||
val name: String,
|
||||
val detail: String,
|
||||
val forwarded: Boolean,
|
||||
val controllerNumber: Int,
|
||||
val resolvedPref: Int,
|
||||
val canRumble: Boolean,
|
||||
val dev: InputDevice? = null,
|
||||
)
|
||||
|
||||
internal fun padInfoOf(dev: InputDevice): PadInfo = PadInfo(
|
||||
name = dev.name,
|
||||
detail = deviceDetail(dev),
|
||||
forwarded = isForwarded(dev),
|
||||
controllerNumber = dev.controllerNumber,
|
||||
resolvedPref = Gamepad.prefFor(dev),
|
||||
canRumble = deviceHasVibrator(dev),
|
||||
dev = dev,
|
||||
)
|
||||
|
||||
/** Whether the controller reports a rumble motor — via VibratorManager (API 31+) or the legacy Vibrator. */
|
||||
private fun deviceHasVibrator(dev: InputDevice): Boolean =
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
|
||||
/**
|
||||
* The name the user knows this device by — what a host shows in its pending-approval list (the web
|
||||
* console's outstanding-pairings view and the dialog that approves a knock) and files the device
|
||||
* under once approved.
|
||||
*
|
||||
* `Settings.Global.DEVICE_NAME` is the name the user typed in Settings ("Enrico's Pixel", "TV im
|
||||
* Wohnzimmer"); it is what every other protocol on the network already calls this device. Only when
|
||||
* it is unset does this fall back to [Build.MODEL], which names the *product* and so reads
|
||||
* identically on every unit of it — two of the same tablet pending approval are indistinguishable.
|
||||
* Available unconditionally here: `DEVICE_NAME` landed in API 25 and this app's floor is 28.
|
||||
*/
|
||||
internal fun deviceName(context: Context): String {
|
||||
val userNamed = runCatching {
|
||||
Settings.Global.getString(context.contentResolver, Settings.Global.DEVICE_NAME)
|
||||
}.getOrNull()
|
||||
return userNamed?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: Build.MODEL?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: "Android"
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Spring
|
||||
@@ -44,6 +43,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -396,7 +396,8 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired:
|
||||
var slot by remember(pt) { mutableIntStateOf(0) } // 0..3 = digit slots, 4 = Pair button
|
||||
var pairing by remember(pt) { mutableStateOf(false) }
|
||||
var err by remember(pt) { mutableStateOf<String?>(null) }
|
||||
val name = remember { Build.MODEL ?: "Android" }
|
||||
val context = LocalContext.current
|
||||
val name = remember(context) { deviceName(context) }
|
||||
|
||||
fun pair() {
|
||||
val id = identity ?: return
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
@@ -82,8 +81,8 @@ suspend fun connectToHost(
|
||||
codecBits, preferredCodec, timeoutMs,
|
||||
launch,
|
||||
// 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",
|
||||
// user-set device name the pairing dialogs offer for nativePair.
|
||||
deviceName(context),
|
||||
// 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,
|
||||
|
||||
@@ -255,8 +255,10 @@ private fun MessageState(text: String) {
|
||||
)
|
||||
}
|
||||
|
||||
// Internal (not private): the screenshot harness composes the real coverflow with mock games —
|
||||
// the library screen itself can't be shot, its state comes off the network.
|
||||
@Composable
|
||||
private fun Coverflow(
|
||||
internal fun Coverflow(
|
||||
games: List<GameEntry>,
|
||||
loader: ImageLoader,
|
||||
navActive: Boolean,
|
||||
|
||||
@@ -526,10 +526,25 @@ class MainActivity : ComponentActivity() {
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
val handle = streamHandle
|
||||
if (handle != 0L) {
|
||||
// A mouse's side buttons, when they arrive key-shaped, are X1/X2 — not navigation.
|
||||
// Resolved before the gamepad and remote-pointer hooks so neither can claim them as
|
||||
// its own BACK. See [mouseSideButton] for how a mouse's BACK is told from a pad's or
|
||||
// a remote's; it answers null for every device that cannot be a mouse, so asking it
|
||||
// first re-routes nothing else.
|
||||
mouseSideButton(event)?.let { back ->
|
||||
when (event.action) {
|
||||
KeyEvent.ACTION_DOWN ->
|
||||
if (event.repeatCount == 0) mouseForwarder?.sideButtonKey(back, true)
|
||||
KeyEvent.ACTION_UP -> mouseForwarder?.sideButtonKey(back, false)
|
||||
}
|
||||
return true
|
||||
}
|
||||
// Gamepad buttons (incl. DPAD only when truly from a gamepad — else KEYCODE_DPAD_* are
|
||||
// keyboard arrows and belong to the VK path below).
|
||||
// keyboard arrows and belong to the VK path below — and BACK, which is how a pad with
|
||||
// no BUTTON_SELECT scancode delivers its Select: see [Gamepad.padButtonBit], which is
|
||||
// why this asks it rather than `buttonBit`).
|
||||
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||
val bit = Gamepad.buttonBit(event.keyCode)
|
||||
val bit = Gamepad.padButtonBit(event.keyCode, event.flags)
|
||||
if (bit != 0) {
|
||||
// The router forwards the bit on this device's own wire pad index and tracks held
|
||||
// state per pad. The emergency-exit chord (Select + Start + L1 + R1) is handled
|
||||
@@ -540,17 +555,6 @@ class MainActivity : ComponentActivity() {
|
||||
return true // consumed
|
||||
}
|
||||
}
|
||||
// A mouse's side buttons, when they arrive key-shaped, are X1/X2 — not navigation.
|
||||
// Resolved before the remote-pointer hook so pointer mode can't eat them as its own
|
||||
// BACK. See [mouseSideButton] for how a mouse's BACK is told from a remote's.
|
||||
mouseSideButton(event)?.let { back ->
|
||||
when (event.action) {
|
||||
KeyEvent.ACTION_DOWN ->
|
||||
if (event.repeatCount == 0) mouseForwarder?.sideButtonKey(back, true)
|
||||
KeyEvent.ACTION_UP -> mouseForwarder?.sideButtonKey(back, false)
|
||||
}
|
||||
return true
|
||||
}
|
||||
// TV remote-as-pointer sees non-gamepad keys first (SELECT long-press toggles it;
|
||||
// while active it owns the D-pad/SELECT/PLAY-PAUSE/BACK).
|
||||
if (!event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||
@@ -567,12 +571,13 @@ class MainActivity : ComponentActivity() {
|
||||
return true
|
||||
}
|
||||
when (event.keyCode) {
|
||||
// Whatever [mouseSideButton] didn't claim. A view-level FALLBACK BACK appears when
|
||||
// a BUTTON_* press goes unconsumed, and an air-mouse remote stamps its own BACK
|
||||
// SOURCE_MOUSE; both are duplicates of something already handled, and letting
|
||||
// either through doubles as Android navigation and yanks the user out of the
|
||||
// stream. A remote/keyboard BACK is never mouse-sourced, so it still falls through
|
||||
// to the BackHandler and exits.
|
||||
// Whatever [mouseSideButton] and the pad branch didn't claim. A view-level FALLBACK
|
||||
// BACK appears when a BUTTON_* press goes unconsumed, and an air-mouse remote stamps
|
||||
// its own BACK SOURCE_MOUSE; both are duplicates of something already handled, and
|
||||
// letting either through doubles as Android navigation and yanks the user out of the
|
||||
// stream. A remote/keyboard BACK is never mouse-sourced and never gamepad-sourced,
|
||||
// so it still falls through to the BackHandler and exits — which for a device with
|
||||
// no pad on it is the documented way out.
|
||||
KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_FORWARD ->
|
||||
if (event.isFromSource(InputDevice.SOURCE_MOUSE) ||
|
||||
event.flags and KeyEvent.FLAG_FALLBACK != 0
|
||||
|
||||
+69
-23
@@ -34,19 +34,34 @@ class ScreenshotTest {
|
||||
// cursor via an infinite animation that otherwise keeps Compose perpetually "busy", so
|
||||
// setContent's wait-for-idle never returns. Frozen, the capture is also deterministic.
|
||||
|
||||
/** Full-screen content scenes: the compose root fills the device, so a root capture is the shot. */
|
||||
private fun shootRoot(name: String, content: @androidx.compose.runtime.Composable () -> Unit) {
|
||||
/**
|
||||
* Full-screen content scenes: the compose root fills the device, so a root capture is the
|
||||
* shot. [statusBar] draws the fake system bar and pushes content below it (see
|
||||
* [ShotStatusFrame]) — off for the immersive surfaces (stream, console shell), which hide
|
||||
* the real bar too.
|
||||
*/
|
||||
private fun shootRoot(
|
||||
name: String,
|
||||
statusBar: Boolean = true,
|
||||
content: @androidx.compose.runtime.Composable () -> Unit,
|
||||
) {
|
||||
compose.mainClock.autoAdvance = false
|
||||
compose.setContent { ShotTheme(content) }
|
||||
compose.setContent { ShotTheme { if (statusBar) ShotStatusFrame(content) else content() } }
|
||||
compose.mainClock.advanceTimeBy(800)
|
||||
compose.onRoot().captureRoboImage("$out/phone-$name.png")
|
||||
}
|
||||
|
||||
/** Dialog scenes: the AlertDialog is a separate window, so capture the whole screen (all windows). */
|
||||
private fun shootScreen(name: String, content: @androidx.compose.runtime.Composable () -> Unit) {
|
||||
private fun shootScreen(
|
||||
name: String,
|
||||
statusBar: Boolean = true,
|
||||
content: @androidx.compose.runtime.Composable () -> Unit,
|
||||
) {
|
||||
compose.mainClock.autoAdvance = false
|
||||
compose.setContent { ShotTheme(content) }
|
||||
compose.mainClock.advanceTimeBy(800)
|
||||
compose.setContent { ShotTheme { if (statusBar) ShotStatusFrame(content) else content() } }
|
||||
// 1.6 s, not 0.8: a ModalBottomSheet's entrance spring is still mid-rise at 0.8 s and the
|
||||
// add-host sheet's Connect button was captured half below the frame.
|
||||
compose.mainClock.advanceTimeBy(1600)
|
||||
captureScreenRoboImage("$out/phone-$name.png")
|
||||
}
|
||||
|
||||
@@ -73,25 +88,25 @@ class ScreenshotTest {
|
||||
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") // landscape — the stream is immersive
|
||||
fun stream() = shootRoot("stream") { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) }
|
||||
fun stream() = shootRoot("stream", statusBar = false) { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) }
|
||||
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun streamCompact() = shootRoot("stream-compact") { StreamScene(io.unom.punktfunk.StatsVerbosity.COMPACT) }
|
||||
fun streamCompact() = shootRoot("stream-compact", statusBar = false) { StreamScene(io.unom.punktfunk.StatsVerbosity.COMPACT) }
|
||||
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun streamNormal() = shootRoot("stream-normal") { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) }
|
||||
fun streamNormal() = shootRoot("stream-normal", statusBar = false) { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) }
|
||||
|
||||
// Both banner texts, in the stream's own landscape geometry — it is bottom-centre, so the
|
||||
// aspect is load-bearing.
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun streamBannerPad() = shootRoot("stream-banner-pad") { StreamBannerScene(pad = true) }
|
||||
fun streamBannerPad() = shootRoot("stream-banner-pad", statusBar = false) { StreamBannerScene(pad = true) }
|
||||
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun streamBannerTouch() = shootRoot("stream-banner-touch") { StreamBannerScene(pad = false) }
|
||||
fun streamBannerTouch() = shootRoot("stream-banner-touch", statusBar = false) { StreamBannerScene(pad = false) }
|
||||
|
||||
// The touch flow is a Material dialog over the host grid (a separate window → shootScreen).
|
||||
@Test
|
||||
@@ -114,15 +129,15 @@ class ScreenshotTest {
|
||||
|
||||
// The console flow is the full-screen aurora takeover (a root capture).
|
||||
@Test
|
||||
fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() }
|
||||
fun connectingConsole() = shootRoot("connecting-console", statusBar = false) { ConnectConsoleScene() }
|
||||
|
||||
@Test
|
||||
fun consoleSettings() = shootRoot("console-settings") { ConsoleSettingsScene() }
|
||||
fun consoleSettings() = shootRoot("console-settings", statusBar = false) { ConsoleSettingsScene() }
|
||||
|
||||
/** A PALE palette: the whole UI flips to dark ink on white frost, which only a shot proves. */
|
||||
@Test
|
||||
fun consoleSettingsLight() =
|
||||
shootRoot("console-settings-light") { ConsoleSettingsScene(paletteId = "holo") }
|
||||
shootRoot("console-settings-light", statusBar = false) { ConsoleSettingsScene(paletteId = "holo") }
|
||||
|
||||
/**
|
||||
* Landscape — the orientation the console actually runs in, and a DIFFERENT layout since the
|
||||
@@ -132,16 +147,16 @@ class ScreenshotTest {
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun consoleSettingsLandscape() =
|
||||
shootRoot("console-settings-landscape") { ConsoleSettingsScene() }
|
||||
shootRoot("console-settings-landscape", statusBar = false) { ConsoleSettingsScene() }
|
||||
|
||||
// The console home, the screen the living backdrop is most of. The default sdk (36) draws the
|
||||
// real AGSL MESH field; the paired API-31 shot below draws the blob fallback, so the two
|
||||
// renderings of the same palette can be compared rather than assumed equivalent.
|
||||
@Test
|
||||
fun consoleHome() = shootRoot("console-home") { ConsoleHomeScene() }
|
||||
fun consoleHome() = shootRoot("console-home", statusBar = false) { ConsoleHomeScene() }
|
||||
|
||||
@Test
|
||||
fun consoleHomeLight() = shootRoot("console-home-light") { ConsoleHomeScene(paletteId = "holo") }
|
||||
fun consoleHomeLight() = shootRoot("console-home-light", statusBar = false) { ConsoleHomeScene(paletteId = "holo") }
|
||||
|
||||
/**
|
||||
* Landscape — the orientation the console UI actually runs in, and the only one wide enough to
|
||||
@@ -149,7 +164,7 @@ class ScreenshotTest {
|
||||
*/
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun consoleHomeLandscape() = shootRoot("console-home-landscape") { ConsoleHomeScene() }
|
||||
fun consoleHomeLandscape() = shootRoot("console-home-landscape", statusBar = false) { ConsoleHomeScene() }
|
||||
|
||||
/**
|
||||
* The API 31/32 field. `RuntimeShader` is API 33+, so everything below it keeps the four
|
||||
@@ -158,24 +173,46 @@ class ScreenshotTest {
|
||||
*/
|
||||
@Test
|
||||
@Config(sdk = [31], qualifiers = "w360dp-h800dp-xxhdpi")
|
||||
fun consoleHomeBlobFallback() = shootRoot("console-home-blobs") { ConsoleHomeScene() }
|
||||
fun consoleHomeBlobFallback() = shootRoot("console-home-blobs", statusBar = false) { ConsoleHomeScene() }
|
||||
|
||||
// The two screens the console reached for the first time in WP8.3. Each is shot on a dark AND a
|
||||
// pale palette, because the console draws them through a ColorScheme derived from the palette's
|
||||
// ink — and the pale one is the only place a grey-on-pastel slip can show up.
|
||||
@Test
|
||||
fun consoleLicenses() = shootRoot("console-licenses") { ConsoleLicensesScene() }
|
||||
fun consoleLicenses() = shootRoot("console-licenses", statusBar = false) { ConsoleLicensesScene() }
|
||||
|
||||
@Test
|
||||
fun consoleLicensesLight() =
|
||||
shootRoot("console-licenses-light") { ConsoleLicensesScene(paletteId = "holo") }
|
||||
shootRoot("console-licenses-light", statusBar = false) { ConsoleLicensesScene(paletteId = "holo") }
|
||||
|
||||
@Test
|
||||
fun consoleControllers() = shootRoot("console-controllers") { ConsoleControllersScene() }
|
||||
fun consoleControllers() = shootRoot("console-controllers", statusBar = false) { ConsoleControllersScene() }
|
||||
|
||||
/**
|
||||
* The touch presentation, pads connected — landscape, like every store frame: the app is
|
||||
* built for horizontal use, and a portrait capture shows a layout nobody streams in.
|
||||
*/
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun controllers() = shootRoot("controllers") { ControllersScene() }
|
||||
|
||||
/** The console presentation at the same landscape geometry — the store's FEEL THE GAME frame. */
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun consoleControllersLandscape() =
|
||||
shootRoot("console-controllers-landscape", statusBar = false) { ConsoleControllersScene() }
|
||||
|
||||
/**
|
||||
* The library coverflow with a mock shelf — the store's PICK & PLAY frame. Landscape: the
|
||||
* orientation the coverflow actually runs in, and the only one wide enough for neighbours.
|
||||
*/
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun library() = shootRoot("library", statusBar = false) { LibraryScene() }
|
||||
|
||||
@Test
|
||||
fun consoleControllersLight() =
|
||||
shootRoot("console-controllers-light") { ConsoleControllersScene(paletteId = "holo") }
|
||||
shootRoot("console-controllers-light", statusBar = false) { ConsoleControllersScene(paletteId = "holo") }
|
||||
|
||||
@Test
|
||||
fun trust() = shootScreen("trust") {
|
||||
@@ -197,4 +234,13 @@ class ScreenshotTest {
|
||||
HostsScene()
|
||||
PairDialog()
|
||||
}
|
||||
|
||||
/**
|
||||
* The add-host sheet (separate window → whole-screen capture). Pixel-like geometry, not the
|
||||
* default 360×800dp: same 1080×2400 px, but at 420 dpi the extra dp headroom is what lets the
|
||||
* sheet's Connect button — the row that carries the resolution promise — fit in frame.
|
||||
*/
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w411dp-h915dp-420dpi")
|
||||
fun addHost() = shootScreen("add-host") { AddHostScene() }
|
||||
}
|
||||
|
||||
@@ -1,14 +1,35 @@
|
||||
package io.unom.punktfunk.screenshots
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BlendMode
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.LinearGradient
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.RadialGradient
|
||||
import android.graphics.Shader
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.BatteryFull
|
||||
import androidx.compose.material.icons.filled.SignalCellular4Bar
|
||||
import androidx.compose.material.icons.filled.Wifi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
@@ -35,8 +56,27 @@ import androidx.compose.runtime.CompositionLocalProvider
|
||||
import io.unom.punktfunk.GamepadHome
|
||||
import io.unom.punktfunk.GamepadInk
|
||||
import io.unom.punktfunk.GamepadPalette
|
||||
import coil.ImageLoader
|
||||
import coil.test.FakeImageLoaderEngine
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import io.unom.punktfunk.AddHostSheet
|
||||
import io.unom.punktfunk.ConsoleControllersScreen
|
||||
import io.unom.punktfunk.ConsoleHeader
|
||||
import io.unom.punktfunk.ConsoleLegendInset
|
||||
import io.unom.punktfunk.ConsoleLicensesScreen
|
||||
import io.unom.punktfunk.ControllersScreen
|
||||
import io.unom.punktfunk.Coverflow
|
||||
import io.unom.punktfunk.GamepadAuroraBackground
|
||||
import io.unom.punktfunk.GamepadHintBar
|
||||
import io.unom.punktfunk.PadGlyph
|
||||
import io.unom.punktfunk.PadInfo
|
||||
import io.unom.punktfunk.consoleLegendInsets
|
||||
import io.unom.punktfunk.consoleSafeArea
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.library.Artwork
|
||||
import io.unom.punktfunk.kit.library.GameEntry
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import io.unom.punktfunk.GamepadSettingsScreen
|
||||
import io.unom.punktfunk.HomeTile
|
||||
import io.unom.punktfunk.LocalGamepadInk
|
||||
@@ -70,6 +110,51 @@ internal fun ShotTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(colorScheme = BrandDark, content = content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Robolectric has no system UI, so every capture was missing the status bar and the content sat
|
||||
* where the bar belongs — on the Pixel render the app title collided with the camera punch-hole.
|
||||
* This frame draws a plausible bar (time left, radios right, the CENTRE left empty for the hole)
|
||||
* and pushes the scene below it, the same geometry real insets produce. The height mirrors a
|
||||
* Pixel's tall bar as measured off a real 1344×2992 capture (~145 px ≈ 40 dp).
|
||||
*/
|
||||
@Composable
|
||||
internal fun ShotStatusFrame(content: @Composable () -> Unit) {
|
||||
Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().height(40.dp).padding(horizontal = 28.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"21:47",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.9f),
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Wifi, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.9f),
|
||||
modifier = Modifier.size(15.dp),
|
||||
)
|
||||
Icon(
|
||||
Icons.Filled.SignalCellular4Bar, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.9f),
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
Icon(
|
||||
Icons.Filled.BatteryFull, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.9f),
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
private data class MockHost(
|
||||
val name: String,
|
||||
val address: String,
|
||||
@@ -510,8 +595,8 @@ internal fun ConsoleHomeScene(paletteId: String = "violet") {
|
||||
* whole risk. Their touch presentation is inked by the app theme, which is always dark, so nothing
|
||||
* before this could catch light-grey body text stranded on a pastel field.
|
||||
*
|
||||
* Robolectric enumerates no input devices, so the controllers scene renders its deterministic
|
||||
* "nothing connected" state.
|
||||
* Robolectric enumerates no input devices, so the controllers scenes inject [shotPads] — the
|
||||
* deterministic connected-pads state the store listing needs.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConsoleLicensesScene(paletteId: String = "violet") =
|
||||
@@ -520,14 +605,358 @@ internal fun ConsoleLicensesScene(paletteId: String = "violet") =
|
||||
@Composable
|
||||
internal fun ConsoleControllersScene(paletteId: String = "violet") =
|
||||
ConsolePalette(paletteId) {
|
||||
ConsoleControllersScreen(gamepadSetting = 0, onBack = {}, navActive = false)
|
||||
// Robolectric enumerates no input devices, so the shot injects the two pads the store
|
||||
// listing talks about — the empty "no controller detected" state proves the palette but
|
||||
// sells nothing.
|
||||
ConsoleControllersScreen(
|
||||
gamepadSetting = 0, onBack = {}, navActive = false, padsOverride = shotPads(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The touch presentation of the same screen, with the same injected pads. Wrapped in a background
|
||||
* [Surface]: the activity provides the dark ground in the app, and without one here the content
|
||||
* color falls back to black-on-white while the cards stay dark.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ControllersScene() =
|
||||
Surface(color = MaterialTheme.colorScheme.background) {
|
||||
ControllersScreen(gamepadSetting = 0, onBack = {}, padsOverride = shotPads())
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Add a host" bottom sheet over the host grid — the store's onboarding frame. State is
|
||||
* hoisted in production (ConnectScreen), so the scene passes a filled-in form directly; the
|
||||
* mode label mirrors what a paired 120 Hz phone shows on the connect button.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AddHostScene() {
|
||||
HostsScene()
|
||||
AddHostSheet(
|
||||
hostName = "Living Room PC", onHostNameChange = {},
|
||||
host = "192.168.1.42", onHostChange = {},
|
||||
port = "9777", onPortChange = {},
|
||||
connecting = false, modeLabel = "2992×1344@120",
|
||||
onDismiss = {}, onConnect = { _, _, _ -> },
|
||||
)
|
||||
}
|
||||
|
||||
/** The two pads the store listing names: DualSense (adaptive triggers, LEDs, rumble) and Xbox. */
|
||||
internal fun shotPads() = listOf(
|
||||
PadInfo(
|
||||
name = "DualSense Wireless Controller",
|
||||
detail = "054C:0CE6 · gamepad · joystick",
|
||||
forwarded = true, controllerNumber = 1,
|
||||
resolvedPref = Gamepad.PREF_DUALSENSE, canRumble = true,
|
||||
),
|
||||
PadInfo(
|
||||
name = "Xbox Wireless Controller",
|
||||
detail = "045E:0B13 · gamepad · joystick",
|
||||
forwarded = true, controllerNumber = 2,
|
||||
resolvedPref = Gamepad.PREF_XBOXONE, canRumble = true,
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Publish the palette locals `App` would normally provide. A scene that calls a console screen
|
||||
* directly gets the DEFAULT dark ink without this, and a pale-palette shot would then silently
|
||||
* prove nothing at all.
|
||||
*/
|
||||
/**
|
||||
* The game-library coverflow (the real [Coverflow] over the real console chrome) with a mock shelf.
|
||||
* The library screen itself can't be shot — its state comes off the network — so the scene rebuilds
|
||||
* the same shell [io.unom.punktfunk.LibraryScreen] draws around it: aurora, header, floating hint
|
||||
* bar. Cover art is answered synchronously by coil-test's [FakeImageLoaderEngine] with generated
|
||||
* posters, so the frozen animation clock never races an async load.
|
||||
*/
|
||||
@Composable
|
||||
internal fun LibraryScene(paletteId: String = "violet") = ConsolePalette(paletteId) {
|
||||
val context = LocalContext.current
|
||||
val loader = remember { shotLibraryLoader(context) }
|
||||
val games = remember { shotGames() }
|
||||
val hazeState = remember { HazeState() }
|
||||
val landscape =
|
||||
LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||
GamepadAuroraBackground(Modifier.fillMaxSize())
|
||||
Column(Modifier.fillMaxSize().consoleSafeArea()) {
|
||||
ConsoleHeader("Living Room PC — Library")
|
||||
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Coverflow(games, loader, navActive = false, onLaunch = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(
|
||||
Modifier.align(Alignment.BottomStart)
|
||||
.consoleLegendInsets(landscape)
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(
|
||||
listOf(PadGlyph.hint('A', "Launch"), PadGlyph.hint('B', "Close")),
|
||||
hazeState = hazeState,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A believable shelf: four titles with art plus the Steam launcher entry (brand-mark tile). */
|
||||
private fun shotGames() = listOf(
|
||||
GameEntry("custom:aurora", "custom", "Aurora Drift", Artwork("shot://art/aurora", null, null)),
|
||||
GameEntry("steam:starfall", "steam", "Starfall Vale", Artwork("shot://art/starfall", null, null)),
|
||||
GameEntry("heroic:neon", "heroic", "Neon Circuit", Artwork("shot://art/neon", null, null)),
|
||||
GameEntry("gog:ember", "gog", "Ember Peaks", Artwork("shot://art/ember", null, null)),
|
||||
GameEntry("steam:launcher", "steam", "Steam", Artwork(null, null, null), role = "launcher", icon = "steam"),
|
||||
)
|
||||
|
||||
private fun shotLibraryLoader(context: Context): ImageLoader {
|
||||
val engine = FakeImageLoaderEngine.Builder()
|
||||
.intercept("shot://art/aurora", poster(context, "AURORA DRIFT", ::drawAurora))
|
||||
.intercept("shot://art/starfall", poster(context, "STARFALL VALE", ::drawStarfall))
|
||||
.intercept("shot://art/neon", poster(context, "NEON CIRCUIT", ::drawNeon))
|
||||
.intercept("shot://art/ember", poster(context, "EMBER PEAKS", ::drawEmber))
|
||||
.default(ColorDrawable(0xFF221E44.toInt()))
|
||||
.build()
|
||||
return ImageLoader.Builder(context).components { add(engine) }.build()
|
||||
}
|
||||
|
||||
// The four shelf posters, drawn procedurally at capture time — the same designs the Apple
|
||||
// harness draws with CoreGraphics (`ShotPosterArt.swift`), so both listings show the same shelf.
|
||||
// All geometry below is in a 600×900, y-UP space (matching the CG source); `posterY()` flips it.
|
||||
|
||||
private const val POSTER_W = 600
|
||||
private const val POSTER_H = 900
|
||||
|
||||
private fun posterY(v: Float) = POSTER_H - v
|
||||
|
||||
/** Deterministic LCG (same constants and seeds as the Swift twin) so every capture is identical. */
|
||||
private class ShotRand(var state: ULong) {
|
||||
fun next(): Float {
|
||||
state = state * 6364136223846793005UL + 1442695040888963407UL
|
||||
return (state shr 33).toFloat() / (1L shl 31).toFloat()
|
||||
}
|
||||
fun range(lo: Float, hi: Float) = lo + next() * (hi - lo)
|
||||
}
|
||||
|
||||
private fun poster(context: Context, title: String, draw: (Canvas) -> Unit): Drawable {
|
||||
val bmp = Bitmap.createBitmap(POSTER_W, POSTER_H, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bmp)
|
||||
draw(canvas)
|
||||
posterTitle(canvas, title)
|
||||
return BitmapDrawable(context.resources, bmp)
|
||||
}
|
||||
|
||||
/** Vertical gradient over the full canvas; stops bottom-to-top as (location, color). */
|
||||
private fun sky(canvas: Canvas, stops: List<Pair<Float, Int>>) {
|
||||
canvas.drawRect(
|
||||
0f, 0f, POSTER_W.toFloat(), POSTER_H.toFloat(),
|
||||
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
shader = LinearGradient(
|
||||
0f, POSTER_H.toFloat(), 0f, 0f,
|
||||
stops.map { it.second }.toIntArray(),
|
||||
stops.map { it.first }.toFloatArray(),
|
||||
Shader.TileMode.CLAMP,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun glowDot(canvas: Canvas, x: Float, y: Float, radius: Float, color: Int) {
|
||||
canvas.drawCircle(
|
||||
x, posterY(y), radius,
|
||||
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
shader = RadialGradient(
|
||||
x, posterY(y), radius, color, color and 0x00FFFFFF, Shader.TileMode.CLAMP,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun shotAlpha(color: Int, a: Float) = (color and 0x00FFFFFF) or ((a * 255).toInt() shl 24)
|
||||
|
||||
/** Three strokes, wide-and-faint to thin-and-bright, in screen blend — the cheap neon glow. */
|
||||
private fun glowStroke(canvas: Canvas, path: Path, width: Float, color: Int) {
|
||||
for ((mult, a) in listOf(2.6f to 0.12f, 1.3f to 0.28f, 0.55f to 0.85f)) {
|
||||
canvas.drawPath(
|
||||
path,
|
||||
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.STROKE
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
strokeJoin = Paint.Join.ROUND
|
||||
strokeWidth = width * mult
|
||||
this.color = shotAlpha(color, a)
|
||||
blendMode = BlendMode.SCREEN
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun posterTitle(canvas: Canvas, title: String) {
|
||||
sky(canvas, listOf(0f to shotAlpha(0x000000, 0.55f), 0.22f to shotAlpha(0x000000, 0f)))
|
||||
canvas.drawText(
|
||||
title, POSTER_W / 2f, posterY(72f),
|
||||
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = shotAlpha(0xFFFFFF, 0.94f)
|
||||
textSize = 46f
|
||||
letterSpacing = 5f / 46f
|
||||
typeface = Typeface.create("sans-serif-condensed", Typeface.BOLD)
|
||||
textAlign = Paint.Align.CENTER
|
||||
setShadowLayer(8f, 0f, 2f, shotAlpha(0x000000, 0.6f))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun drawAurora(canvas: Canvas) {
|
||||
sky(canvas, listOf(0f to 0xFF221E5C.toInt(), 0.45f to 0xFF141040.toInt(), 1f to 0xFF0B0830.toInt()))
|
||||
val rng = ShotRand(11UL)
|
||||
repeat(48) {
|
||||
val x = rng.range(0f, 600f)
|
||||
val y = rng.range(300f, 890f)
|
||||
val r = rng.range(1.4f, 3.2f)
|
||||
glowDot(canvas, x, y, r, shotAlpha(0xFFFFFF, rng.range(0.25f, 0.8f)))
|
||||
}
|
||||
data class Ribbon(
|
||||
val base: Float, val amp: Float, val freq: Float,
|
||||
val phase: Float, val w: Float, val c: Int,
|
||||
)
|
||||
for (r in listOf(
|
||||
Ribbon(700f, 55f, 1.15f, 0.4f, 30f, 0xFF6656F2.toInt()),
|
||||
Ribbon(615f, 70f, 1.4f, 2.2f, 24f, 0xFF8F7BFF.toInt()),
|
||||
Ribbon(530f, 45f, 0.95f, 4.1f, 18f, 0xFF35D0C5.toInt()),
|
||||
)) {
|
||||
val path = Path()
|
||||
for (i in 0..60) {
|
||||
val t = i / 60f
|
||||
val x = t * 600f
|
||||
val y = r.base + r.amp * kotlin.math.sin(t * Math.PI.toFloat() * r.freq + r.phase) + 40f * t
|
||||
if (i == 0) path.moveTo(x, posterY(y)) else path.lineTo(x, posterY(y))
|
||||
}
|
||||
glowStroke(canvas, path, r.w, r.c)
|
||||
}
|
||||
// A low ridge grounds the scene — without it the poster's bottom half is bare sky.
|
||||
for ((fill, baseline, rough) in listOf(
|
||||
Triple(0xFF191345.toInt(), 212f, 30f),
|
||||
Triple(0xFF0E0A2E.toInt(), 148f, 38f),
|
||||
)) {
|
||||
val path = Path()
|
||||
path.moveTo(0f, posterY(0f))
|
||||
path.lineTo(0f, posterY(baseline + rng.range(-rough, rough)))
|
||||
for (i in 1..9) {
|
||||
val x = i / 9f * 600f
|
||||
path.lineTo(x, posterY(baseline + rng.range(-rough, rough)))
|
||||
}
|
||||
path.lineTo(600f, posterY(0f))
|
||||
path.close()
|
||||
canvas.drawPath(path, Paint(Paint.ANTI_ALIAS_FLAG).apply { color = fill })
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawStarfall(canvas: Canvas) {
|
||||
sky(
|
||||
canvas,
|
||||
listOf(
|
||||
0f to 0xFF2A0C24.toInt(), 0.35f to 0xFF7A2B58.toInt(),
|
||||
0.8f to 0xFFE86FA8.toInt(), 1f to 0xFFF7A8C8.toInt(),
|
||||
),
|
||||
)
|
||||
val rng = ShotRand(23UL)
|
||||
repeat(6) {
|
||||
val hx = rng.range(60f, 560f)
|
||||
val hy = rng.range(420f, 840f)
|
||||
val len = rng.range(90f, 170f)
|
||||
val dx = kotlin.math.cos(2.15f)
|
||||
val dy = kotlin.math.sin(2.15f)
|
||||
val path = Path()
|
||||
path.moveTo(hx, posterY(hy))
|
||||
path.lineTo(hx + dx * len, posterY(hy + dy * len))
|
||||
glowStroke(canvas, path, 4f, 0xFFFFE3EF.toInt())
|
||||
glowDot(canvas, hx, hy, 11f, shotAlpha(0xFFFFFF, 0.9f))
|
||||
}
|
||||
for ((fill, baseline, rough) in listOf(
|
||||
Triple(0xFF3A1430.toInt(), 300f, 26f),
|
||||
Triple(0xFF1D0818.toInt(), 216f, 34f),
|
||||
)) {
|
||||
val path = Path()
|
||||
path.moveTo(0f, posterY(0f))
|
||||
path.lineTo(0f, posterY(baseline))
|
||||
for (i in 1..8) {
|
||||
val x = i / 8f * 600f
|
||||
path.lineTo(x, posterY(baseline + rng.range(-rough, rough)))
|
||||
}
|
||||
path.lineTo(600f, posterY(0f))
|
||||
path.close()
|
||||
canvas.drawPath(path, Paint(Paint.ANTI_ALIAS_FLAG).apply { color = fill })
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawNeon(canvas: Canvas) {
|
||||
sky(canvas, listOf(0f to 0xFF0A2A33.toInt(), 1f to 0xFF04161C.toInt()))
|
||||
val rng = ShotRand(7UL)
|
||||
val ring = Path().apply {
|
||||
addOval(300f - 105f, posterY(560f) - 105f, 300f + 105f, posterY(560f) + 105f, Path.Direction.CW)
|
||||
}
|
||||
glowStroke(canvas, ring, 10f, 0xFF35D0C5.toInt())
|
||||
val gateX = listOf(-105f, 105f, 0f, 0f)
|
||||
val gateY = listOf(0f, 0f, -105f, 105f)
|
||||
for (i in 0 until 9) {
|
||||
var px: Float
|
||||
var py: Float
|
||||
if (i < 4) {
|
||||
px = 300f + gateX[i]
|
||||
py = 560f + gateY[i]
|
||||
} else {
|
||||
px = 40f * kotlin.math.round(rng.range(1f, 14f))
|
||||
py = 40f * kotlin.math.round(rng.range(1f, 21f))
|
||||
}
|
||||
val path = Path()
|
||||
path.moveTo(px, posterY(py))
|
||||
var horizontal = rng.next() > 0.5f
|
||||
repeat(rng.range(3f, 6f).toInt()) {
|
||||
val step = 40f * kotlin.math.round(rng.range(1f, 4f)) * (if (rng.next() > 0.5f) 1f else -1f)
|
||||
if (horizontal) px = (px + step).coerceIn(20f, 580f) else py = (py + step).coerceIn(20f, 880f)
|
||||
path.lineTo(px, posterY(py))
|
||||
horizontal = !horizontal
|
||||
}
|
||||
val color = if (rng.next() > 0.6f) 0xFF7FE8DE.toInt() else 0xFF35D0C5.toInt()
|
||||
glowStroke(canvas, path, 5f, color)
|
||||
glowDot(canvas, px, py, 12f, shotAlpha(color, 0.9f))
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawEmber(canvas: Canvas) {
|
||||
sky(
|
||||
canvas,
|
||||
listOf(
|
||||
0f to 0xFF200A04.toInt(), 0.3f to 0xFF7A2E12.toInt(),
|
||||
0.42f to 0xFFEF8F4B.toInt(), 1f to 0xFF2A0E06.toInt(),
|
||||
),
|
||||
)
|
||||
glowDot(canvas, 300f, 385f, 160f, shotAlpha(0xFFC37A, 0.85f))
|
||||
val rng = ShotRand(41UL)
|
||||
for ((fill, baseline, rough) in listOf(
|
||||
Triple(0xFF5A2410.toInt(), 340f, 42f),
|
||||
Triple(0xFF401708.toInt(), 255f, 56f),
|
||||
Triple(0xFF200A04.toInt(), 165f, 48f),
|
||||
)) {
|
||||
val path = Path()
|
||||
path.moveTo(0f, posterY(0f))
|
||||
path.lineTo(0f, posterY(baseline + rng.range(-rough, rough)))
|
||||
for (i in 1..10) {
|
||||
val x = i / 10f * 600f
|
||||
path.lineTo(x, posterY(baseline + rng.range(-rough, rough)))
|
||||
}
|
||||
path.lineTo(600f, posterY(0f))
|
||||
path.close()
|
||||
canvas.drawPath(path, Paint(Paint.ANTI_ALIAS_FLAG).apply { color = fill })
|
||||
}
|
||||
repeat(20) {
|
||||
val x = rng.range(30f, 570f)
|
||||
val y = rng.range(180f, 620f)
|
||||
val r = rng.range(2.5f, 6f)
|
||||
glowDot(canvas, x, y, r, shotAlpha(0xFFB067, rng.range(0.35f, 0.9f)))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConsolePalette(paletteId: String, content: @Composable () -> Unit) {
|
||||
val palette = GamepadPalette.named(paletteId)
|
||||
|
||||
@@ -50,6 +50,10 @@ class TvScreenshotTest {
|
||||
@Test
|
||||
fun consoleControllers() = shootRoot("console-controllers") { ConsoleControllersScene() }
|
||||
|
||||
/** The library coverflow at TV geometry — the store's PICK & PLAY frame for the TV listing. */
|
||||
@Test
|
||||
fun library() = shootRoot("library") { LibraryScene() }
|
||||
|
||||
@Test
|
||||
fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() }
|
||||
}
|
||||
|
||||
@@ -230,6 +230,46 @@ object Gamepad {
|
||||
else -> 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The BTN_* bit for one key event from a SOURCE_GAMEPAD device — [buttonBit] plus the
|
||||
* Select-family button of every pad that carries no `BUTTON_SELECT` scancode at all.
|
||||
*
|
||||
* Plenty of controllers deliver that button as the plain `KEYCODE_BACK` a remote's Back uses,
|
||||
* with no `BUTTON_SELECT` behind it: it is the Android-TV shape, where every input device is
|
||||
* expected to offer Back, and a pad reaches it whether the vendor prints "Back" on the button
|
||||
* (NVIDIA's SHIELD controller) or "Select"/"View" (most pads in an Android mode). Which one is
|
||||
* on the couch cannot be told from here, and does not need to be — the keycode is what routes.
|
||||
*
|
||||
* Read through [buttonBit] alone that button mapped to nothing, so it fell out of the
|
||||
* streaming branch unconsumed and reached the activity's back stack, which is the
|
||||
* deliberate-quit exit: ONE press of Select dropped the session and the host logged a client
|
||||
* quit. `KEYCODE_BACK` is in fact the ONLY keycode that can get there from a pad — a mapped
|
||||
* button is consumed here, anything with a VK is consumed on the keycode path, volume/power go
|
||||
* to the system, and a FLAG_FALLBACK BACK is swallowed — which is what identifies this as the
|
||||
* cause of such a report without knowing the hardware.
|
||||
*
|
||||
* It also meant such a pad could not produce [BTN_BACK] at all, so every shortcut built on
|
||||
* Select — the emergency exit chord this client's own start banner advertises, the mic mute,
|
||||
* the stats tier — was unreachable on exactly the devices whose users have no keyboard.
|
||||
*
|
||||
* A pad that DOES carry `BUTTON_SELECT` is unaffected in both directions: it never had the
|
||||
* bug, and this changes nothing for it.
|
||||
*
|
||||
* FLAG_FALLBACK events are excluded: those are the synthetic BACK the framework raises after
|
||||
* an unconsumed `BUTTON_*` press (a pad reporting L2/R2 as keys, say), not a button anyone
|
||||
* touched, and forwarding one would put a phantom Select on the wire. `MainActivity` drops
|
||||
* them on the keycode path for the same reason.
|
||||
*
|
||||
* Callers must gate on `SOURCE_GAMEPAD` before asking, exactly as [buttonBit]'s `KEYCODE_DPAD_*`
|
||||
* rows require: a remote's or keyboard's BACK shares this keycode and has to keep leaving the
|
||||
* stream — for a device with no pad on it, Back IS the documented way out.
|
||||
*/
|
||||
fun padButtonBit(keyCode: Int, flags: Int): Int = when {
|
||||
keyCode != KeyEvent.KEYCODE_BACK -> buttonBit(keyCode)
|
||||
flags and KeyEvent.FLAG_FALLBACK != 0 -> 0
|
||||
else -> BTN_BACK
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps one controller's joystick MotionEvents to axis (+ HAT→dpad) sends on wire pad index [pad],
|
||||
* **on change only**. Holds the previous axis/hat state so an unchanged frame emits nothing. One
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.view.KeyEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pure JVM test of [Gamepad.padButtonBit] — the streaming branch's gamepad keycode resolution
|
||||
* (`KeyEvent`'s keycode/flag constants are compile-time-inlined ints, so no Android runtime is
|
||||
* involved). Run: `./gradlew :kit:testDebugUnitTest`.
|
||||
*
|
||||
* The regression it pins is a field report: one press of Select disconnected the session. Plenty
|
||||
* of pads deliver that button as the plain `KEYCODE_BACK` a remote uses, with no `BUTTON_SELECT`
|
||||
* scancode behind it — so it mapped to nothing, fell out of the gamepad branch unconsumed, and
|
||||
* reached the activity back stack, which is the deliberate-quit exit. The same gap made
|
||||
* [Gamepad.BTN_BACK] unreachable on those pads, and with it every shortcut built on Select: the
|
||||
* exit chord `StreamScreen`'s own start banner advertises, the mic mute, the stats tier.
|
||||
*
|
||||
* Which controller the report came from is not knowable from the logs and does not matter:
|
||||
* `KEYCODE_BACK` is the only keycode that reaches the back stack from a SOURCE_GAMEPAD device, so
|
||||
* a one-press quit identifies the button's keycode on its own.
|
||||
*/
|
||||
class PadButtonBitTest {
|
||||
|
||||
/** The report: Select on an Android-TV pad arrives as BACK and must be the Select bit. */
|
||||
@Test
|
||||
fun `a pad's BACK is its Select button`() {
|
||||
assertEquals(Gamepad.BTN_BACK, Gamepad.padButtonBit(KeyEvent.KEYCODE_BACK, 0))
|
||||
// Same bit either spelling reaches us by — a pad that DOES carry BUTTON_SELECT is unchanged.
|
||||
assertEquals(
|
||||
Gamepad.padButtonBit(KeyEvent.KEYCODE_BUTTON_SELECT, 0),
|
||||
Gamepad.padButtonBit(KeyEvent.KEYCODE_BACK, 0),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* With Select mapped, the three Select chords are reachable on a pad that has only a BACK
|
||||
* keycode — which is the whole point of the mapping, not a side effect of it. Held-state
|
||||
* assembly is [GamepadRouter]'s (see `GamepadChordTest`); what is pinned here is that the
|
||||
* bits a SHIELD can actually produce cover each chord.
|
||||
*/
|
||||
@Test
|
||||
fun `the Select chords are reachable from a BACK-only pad`() {
|
||||
val select = Gamepad.padButtonBit(KeyEvent.KEYCODE_BACK, 0)
|
||||
val start = Gamepad.padButtonBit(KeyEvent.KEYCODE_BUTTON_START, 0)
|
||||
val l1 = Gamepad.padButtonBit(KeyEvent.KEYCODE_BUTTON_L1, 0)
|
||||
val r1 = Gamepad.padButtonBit(KeyEvent.KEYCODE_BUTTON_R1, 0)
|
||||
val x = Gamepad.padButtonBit(KeyEvent.KEYCODE_BUTTON_X, 0)
|
||||
val y = Gamepad.padButtonBit(KeyEvent.KEYCODE_BUTTON_Y, 0)
|
||||
assertEquals(GamepadRouter.EXIT_CHORD, select or start or l1 or r1)
|
||||
assertEquals(GamepadRouter.STATS_CHORD, select or x)
|
||||
assertEquals(GamepadRouter.MIC_CHORD, select or y)
|
||||
}
|
||||
|
||||
/**
|
||||
* The synthetic BACK the framework raises after an unconsumed `BUTTON_*` press is not a button
|
||||
* anyone touched — forwarding it would put a phantom Select on the wire, and one of those
|
||||
* landing while Start + L1 + R1 were held would complete the exit chord out of nowhere.
|
||||
*/
|
||||
@Test
|
||||
fun `a fallback BACK is not a button press`() {
|
||||
assertEquals(0, Gamepad.padButtonBit(KeyEvent.KEYCODE_BACK, KeyEvent.FLAG_FALLBACK))
|
||||
// Only BACK is filtered on the flag; a real button keeps its bit whatever rides alongside.
|
||||
assertEquals(
|
||||
Gamepad.BTN_A,
|
||||
Gamepad.padButtonBit(KeyEvent.KEYCODE_BUTTON_A, KeyEvent.FLAG_FALLBACK),
|
||||
)
|
||||
}
|
||||
|
||||
/** Everything else is [Gamepad.buttonBit] verbatim — BACK is the only row this adds. */
|
||||
@Test
|
||||
fun `every other keycode is unchanged`() {
|
||||
for (code in 0..0x400) {
|
||||
if (code == KeyEvent.KEYCODE_BACK) continue
|
||||
assertEquals(Gamepad.buttonBit(code), Gamepad.padButtonBit(code, 0))
|
||||
}
|
||||
// And BACK is genuinely a new row, not one buttonBit already had.
|
||||
assertEquals(0, Gamepad.buttonBit(KeyEvent.KEYCODE_BACK))
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ struct LibraryCoverflowView: View {
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
private var ink: GamepadInk { .stored(paletteID) }
|
||||
let games: [GameEntry]
|
||||
let artLoader: LibraryArtLoader?
|
||||
let artLoader: (any LibraryArtSource)?
|
||||
var onLaunch: ((String) -> Void)?
|
||||
/// Button B (back) — dismisses the library screen. No touch equivalent needed here (the toolbar
|
||||
/// Close button already covers that); this is what makes gamepad-only exit possible.
|
||||
|
||||
@@ -74,7 +74,7 @@ struct LibraryView: View {
|
||||
@State private var errorText: String?
|
||||
/// Cover-art loader (the same paired identity + host pinning as the list fetch, reused across
|
||||
/// every poster in the grid). Built alongside `games` in `load()`; dropped on disappear.
|
||||
@State private var artLoader: LibraryArtLoader?
|
||||
@State private var artLoader: (any LibraryArtSource)?
|
||||
#if os(iOS) || os(macOS)
|
||||
/// The plain grid's hardware-keyboard cursor (a game id), and the grid width the column count
|
||||
/// is derived from. nil until the first arrow press, so a touch user never sees a selection
|
||||
@@ -409,7 +409,7 @@ private struct LibraryBackCatcher: View {
|
||||
/// (portrait → header → hero) and finally a text placeholder.
|
||||
private struct GameCard: View {
|
||||
let game: GameEntry
|
||||
let artLoader: LibraryArtLoader?
|
||||
let artLoader: (any LibraryArtSource)?
|
||||
/// The hardware-keyboard cursor is on this tile — drawn as an accent ring, since the plain
|
||||
/// grid has no other way to say "Return launches THIS one".
|
||||
var selected = false
|
||||
|
||||
@@ -70,7 +70,7 @@ private extension Image {
|
||||
struct PosterImage: View {
|
||||
let candidates: [URL]
|
||||
let title: String
|
||||
let loader: LibraryArtLoader?
|
||||
let loader: (any LibraryArtSource)?
|
||||
/// The entry's brand-mark token (`GameEntry.iconToken`), when it has one. A launcher tile ships
|
||||
/// no cover art by design, so for those the mark IS the poster — see `placeholder`.
|
||||
var icon: String?
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
// can wait for layout instead of guessing with a fixed sleep.
|
||||
|
||||
#if DEBUG
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
@@ -43,6 +44,17 @@ enum ScreenshotMode {
|
||||
/// readiness ping for the capture script.
|
||||
struct ScreenshotHostView: View {
|
||||
let scene: ShotScene
|
||||
|
||||
init(scene: ShotScene) {
|
||||
self.scene = scene
|
||||
// Pin the palette for the capture. The aurora screens read the LIVE `uiPalette` default,
|
||||
// and a reused Simulator (or a dev Mac) carries whatever was last picked there — the
|
||||
// Apple TV set once shipped out on a sunset palette that a test device had persisted.
|
||||
// Idempotent, and only ever runs in shot mode (this view exists behind that gate).
|
||||
UserDefaults.standard.set(
|
||||
ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_PALETTE"] ?? "violet",
|
||||
forKey: DefaultsKey.uiPalette)
|
||||
}
|
||||
#if os(iOS)
|
||||
@Environment(\.horizontalSizeClass) private var hSizeClass
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
|
||||
@@ -35,6 +35,11 @@ enum ShotScenes {
|
||||
ShotScene(name: "05-settings", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotSettings())
|
||||
},
|
||||
// 06–10 are the iOS/macOS console-shell block below; the library is cross-platform
|
||||
// (tvOS renders the same coverflow), hence the number above that range.
|
||||
ShotScene(name: "11-library", orientation: .landscape, colorScheme: .dark) {
|
||||
AnyView(ShotLibrary())
|
||||
},
|
||||
]
|
||||
#if os(iOS) || os(macOS)
|
||||
// The gamepad-mode console screens (no tvOS — native focus engine there). Dev-only shots
|
||||
@@ -68,6 +73,13 @@ enum ShotScenes {
|
||||
ShotScene(name: "09f-wake-timed-out-modal", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotConnect(kind: .timedOut, gamepadUI: false))
|
||||
},
|
||||
// FEEL THE GAME — the controller test panel with injected pads. Gated with the
|
||||
// console block because ControllerTestView doesn't build on tvOS, not because it
|
||||
// is a console screen. Landscape like the rest of the store set: the app is built
|
||||
// for horizontal use, so the two pads sit as side-by-side columns (see the scene).
|
||||
ShotScene(name: "12-controllers", orientation: .landscape, colorScheme: .dark) {
|
||||
AnyView(ShotControllers())
|
||||
},
|
||||
]
|
||||
#endif
|
||||
scenes.append(ShotScene(name: "10-edithost", orientation: .natural, colorScheme: .dark) {
|
||||
@@ -193,6 +205,29 @@ enum ShotMock {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// A believable shelf for the library coverflow. Decoded rather than constructed:
|
||||
/// `GameEntry`'s memberwise init is internal to PunktfunkKit, and Codable is its public
|
||||
/// construction surface. The `shot://art/…` posters are answered by [`ShotPosterArt.source`]
|
||||
/// (drawn at capture time), so the shot stays offline; the Steam launcher entry stays artless
|
||||
/// by design and renders its brand mark.
|
||||
static let games: [GameEntry] = {
|
||||
let json = """
|
||||
[
|
||||
{"id": "custom:aurora", "store": "custom", "title": "Aurora Drift",
|
||||
"art": {"portrait": "shot://art/aurora"}},
|
||||
{"id": "steam:starfall", "store": "steam", "title": "Starfall Vale",
|
||||
"art": {"portrait": "shot://art/starfall"}},
|
||||
{"id": "heroic:neon", "store": "heroic", "title": "Neon Circuit",
|
||||
"art": {"portrait": "shot://art/neon"}},
|
||||
{"id": "gog:ember", "store": "gog", "title": "Ember Peaks",
|
||||
"art": {"portrait": "shot://art/ember"}},
|
||||
{"id": "steam:launcher", "store": "steam", "title": "Steam", "art": {},
|
||||
"role": "launcher", "icon": "steam"}
|
||||
]
|
||||
"""
|
||||
return (try? JSONDecoder().decode([GameEntry].self, from: Data(json.utf8))) ?? []
|
||||
}()
|
||||
|
||||
/// A plausible-looking 32-byte SHA-256 for the trust card / pin lock glyphs.
|
||||
static let fingerprint = hostFingerprint(0)
|
||||
|
||||
@@ -230,6 +265,19 @@ private struct ShotHome: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Library
|
||||
|
||||
/// The library coverflow with the mock shelf — the store listing's PICK & PLAY frame. The real
|
||||
/// `LibraryCoverflowView`, no network: `ShotPosterArt` answers the mock entries' art immediately,
|
||||
/// so the cards swing in already carrying posters (the entrance waits on art settling).
|
||||
private struct ShotLibrary: View {
|
||||
var body: some View {
|
||||
LibraryCoverflowView(
|
||||
games: ShotMock.games, artLoader: ShotPosterArt.source,
|
||||
onLaunch: { _ in }, onDismiss: {}, controllerActive: false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Gamepad-mode console screens (dev-only glass preview)
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
@@ -311,6 +359,61 @@ private struct ShotConnect: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Controllers (the pads the store listing names)
|
||||
|
||||
/// The FEEL THE GAME frame: the controller test panel rendering the two pads the listing talks
|
||||
/// about. A GCController cannot be constructed, so the panel draws injected `ShotPad`s — the
|
||||
/// DualSense leads with the feedback surface (adaptive-trigger effects, rumble backend, lightbar
|
||||
/// + player LEDs), the Xbox pad carries the input readout, frozen mid-game.
|
||||
private struct ShotControllers: View {
|
||||
var body: some View {
|
||||
#if os(macOS)
|
||||
// The panel is a window-modal sheet in the app — float it at sheet width over the
|
||||
// dimmed host grid, the way the other mac sheet shots read.
|
||||
ZStack {
|
||||
ShotHome().blur(radius: 24).overlay(Color.black.opacity(0.45))
|
||||
ControllerTestView(shotPads: Self.pads)
|
||||
.frame(width: 500, height: 840)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.shadow(radius: 40, y: 16)
|
||||
}
|
||||
#else
|
||||
// Landscape canvas: one column per pad, so neither story is cut by the short height —
|
||||
// the DualSense feedback surface left, the Xbox live-input readout right.
|
||||
HStack(spacing: 0) {
|
||||
ControllerTestView(shotPads: [Self.pads[0]])
|
||||
ControllerTestView(shotPads: [Self.pads[1]])
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Transport/battery/player ride in `detail` — the panel has no dedicated battery row.
|
||||
/// Each pad shows a different half of the panel: the DualSense skips the input card (the
|
||||
/// effect grid is the marketing point), the Xbox pad skips rumble and shows the readout.
|
||||
static let pads: [ControllerTestView.ShotPad] = [
|
||||
.init(
|
||||
name: "DualSense Wireless Controller",
|
||||
detail: "Bluetooth · 85% · Player 1",
|
||||
isDualSense: true, hasAdaptiveTriggers: true, hasLight: true,
|
||||
rumbleBackend: "DualSense HID · Bluetooth"),
|
||||
.init(
|
||||
name: "Xbox Wireless Controller",
|
||||
detail: "Bluetooth · 60% · Player 2",
|
||||
isDualSense: false, hasAdaptiveTriggers: false, hasLight: false,
|
||||
input: .init(
|
||||
leftStick: .init(x: -0.31, y: 0.54),
|
||||
rightStick: .init(x: 0.72, y: -0.16),
|
||||
leftTrigger: 0.08, rightTrigger: 0.62,
|
||||
buttons: [
|
||||
("A", true), ("B", false), ("X", false), ("Y", false),
|
||||
("LB", false), ("RB", true), ("L3", false), ("R3", false),
|
||||
("Menu", false), ("Opts", false),
|
||||
("↑", false), ("↓", false), ("←", false), ("→", false),
|
||||
])),
|
||||
]
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Edit host (add/edit sheet with the Wake-on-LAN MAC field)
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
// Procedural cover art for the screenshot shelf. The store's library frames used to render the
|
||||
// deterministic text-placeholder posters (`artLoader: nil`), which read as an empty library next
|
||||
// to the Android listing's populated one. These four posters are drawn with CoreGraphics at
|
||||
// capture time — no bundled assets, nothing in a release build, and the same designs the Android
|
||||
// harness draws in Canvas, so the two listings show the same shelf.
|
||||
|
||||
#if DEBUG
|
||||
import CoreText
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import PunktfunkKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// A canned `LibraryArtSource`: poster bytes by URL, no network. What the screenshot shelf hands
|
||||
/// the real coverflow in place of the paired-host loader.
|
||||
struct ShotArtSource: LibraryArtSource {
|
||||
let fixtures: [String: Data]
|
||||
|
||||
func data(for url: URL) async throws -> Data {
|
||||
guard let data = fixtures[url.absoluteString] else {
|
||||
throw CocoaError(.fileNoSuchFile)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func close() async {}
|
||||
}
|
||||
|
||||
enum ShotPosterArt {
|
||||
/// Art for `ShotMock.games` — keyed by the `shot://art/…` URLs those entries carry.
|
||||
static let source = ShotArtSource(fixtures: [
|
||||
"shot://art/aurora": poster("AURORA DRIFT", draw: drawAurora),
|
||||
"shot://art/starfall": poster("STARFALL VALE", draw: drawStarfall),
|
||||
"shot://art/neon": poster("NEON CIRCUIT", draw: drawNeon),
|
||||
"shot://art/ember": poster("EMBER PEAKS", draw: drawEmber),
|
||||
])
|
||||
|
||||
private static let W = 600
|
||||
private static let H = 900
|
||||
|
||||
// MARK: - Canvas plumbing
|
||||
|
||||
private static func poster(_ title: String, draw: (CGContext) -> Void) -> Data {
|
||||
let space = CGColorSpace(name: CGColorSpace.sRGB)!
|
||||
let ctx = CGContext(
|
||||
data: nil, width: W, height: H, bitsPerComponent: 8, bytesPerRow: 0,
|
||||
space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
|
||||
draw(ctx)
|
||||
drawTitle(ctx, title)
|
||||
let image = ctx.makeImage()!
|
||||
let out = NSMutableData()
|
||||
let dest = CGImageDestinationCreateWithData(
|
||||
out, UTType.png.identifier as CFString, 1, nil)!
|
||||
CGImageDestinationAddImage(dest, image, nil)
|
||||
CGImageDestinationFinalize(dest)
|
||||
return out as Data
|
||||
}
|
||||
|
||||
private static func rgb(_ hex: UInt32, _ alpha: CGFloat = 1) -> CGColor {
|
||||
CGColor(
|
||||
srgbRed: CGFloat((hex >> 16) & 0xff) / 255,
|
||||
green: CGFloat((hex >> 8) & 0xff) / 255,
|
||||
blue: CGFloat(hex & 0xff) / 255, alpha: alpha)
|
||||
}
|
||||
|
||||
/// Vertical gradient over the full canvas; `stops` bottom-to-top as (location, color).
|
||||
private static func sky(_ ctx: CGContext, _ stops: [(CGFloat, CGColor)]) {
|
||||
let gradient = CGGradient(
|
||||
colorsSpace: CGColorSpace(name: CGColorSpace.sRGB)!,
|
||||
colors: stops.map(\.1) as CFArray,
|
||||
locations: stops.map(\.0))!
|
||||
ctx.drawLinearGradient(
|
||||
gradient, start: .zero, end: CGPoint(x: 0, y: CGFloat(H)), options: [])
|
||||
}
|
||||
|
||||
private static func glowDot(
|
||||
_ ctx: CGContext, at center: CGPoint, radius: CGFloat, color: CGColor
|
||||
) {
|
||||
let clear = color.copy(alpha: 0)!
|
||||
let gradient = CGGradient(
|
||||
colorsSpace: CGColorSpace(name: CGColorSpace.sRGB)!,
|
||||
colors: [color, clear] as CFArray, locations: [0, 1])!
|
||||
ctx.drawRadialGradient(
|
||||
gradient, startCenter: center, startRadius: 0,
|
||||
endCenter: center, endRadius: radius, options: [])
|
||||
}
|
||||
|
||||
/// Stroke `path` three times, wide-and-faint to thin-and-bright, in screen blend — the cheap
|
||||
/// neon-glow trick every one of these posters leans on.
|
||||
private static func glowStroke(
|
||||
_ ctx: CGContext, _ path: CGPath, width: CGFloat, color: CGColor
|
||||
) {
|
||||
ctx.saveGState()
|
||||
ctx.setBlendMode(.screen)
|
||||
ctx.setLineCap(.round)
|
||||
ctx.setLineJoin(.round)
|
||||
for (mult, alpha) in [(2.6, 0.12), (1.3, 0.28), (0.55, 0.85)] {
|
||||
ctx.addPath(path)
|
||||
ctx.setLineWidth(width * mult)
|
||||
ctx.setStrokeColor(color.copy(alpha: alpha)!)
|
||||
ctx.strokePath()
|
||||
}
|
||||
ctx.restoreGState()
|
||||
}
|
||||
|
||||
private static func drawTitle(_ ctx: CGContext, _ title: String) {
|
||||
// A soft floor behind the caption keeps it legible over any art.
|
||||
sky(ctx, [(0, rgb(0x000000, 0.55)), (0.22, rgb(0x000000, 0))])
|
||||
let font = CTFontCreateWithName("HelveticaNeue-CondensedBold" as CFString, 46, nil)
|
||||
let text = NSAttributedString(string: title, attributes: [
|
||||
.font: font, .kern: 5, .foregroundColor: rgb(0xFFFFFF, 0.94),
|
||||
] as [NSAttributedString.Key: Any])
|
||||
let line = CTLineCreateWithAttributedString(text)
|
||||
let bounds = CTLineGetBoundsWithOptions(line, [])
|
||||
ctx.saveGState()
|
||||
ctx.setShadow(offset: CGSize(width: 0, height: -2), blur: 8, color: rgb(0x000000, 0.6))
|
||||
ctx.textPosition = CGPoint(x: (CGFloat(W) - bounds.width) / 2, y: 72)
|
||||
CTLineDraw(line, ctx)
|
||||
ctx.restoreGState()
|
||||
}
|
||||
|
||||
/// Deterministic LCG so every capture draws the identical poster.
|
||||
private struct Rand {
|
||||
var state: UInt64
|
||||
mutating func next() -> CGFloat {
|
||||
state = state &* 6364136223846793005 &+ 1442695040888963407
|
||||
return CGFloat(state >> 33) / CGFloat(UInt64(1) << 31)
|
||||
}
|
||||
mutating func in_(_ lo: CGFloat, _ hi: CGFloat) -> CGFloat { lo + next() * (hi - lo) }
|
||||
}
|
||||
|
||||
// MARK: - The four posters
|
||||
|
||||
private static func drawAurora(_ ctx: CGContext) {
|
||||
sky(ctx, [(0, rgb(0x221E5C)), (0.45, rgb(0x141040)), (1, rgb(0x0B0830))])
|
||||
var rng = Rand(state: 11)
|
||||
for _ in 0..<48 {
|
||||
let p = CGPoint(x: rng.in_(0, 600), y: rng.in_(300, 890))
|
||||
glowDot(ctx, at: p, radius: rng.in_(1.4, 3.2), color: rgb(0xFFFFFF, rng.in_(0.25, 0.8)))
|
||||
}
|
||||
let ribbons: [(base: CGFloat, amp: CGFloat, freq: CGFloat, phase: CGFloat, w: CGFloat, c: UInt32)] = [
|
||||
(700, 55, 1.15, 0.4, 30, 0x6656F2),
|
||||
(615, 70, 1.4, 2.2, 24, 0x8F7BFF),
|
||||
(530, 45, 0.95, 4.1, 18, 0x35D0C5),
|
||||
]
|
||||
for r in ribbons {
|
||||
let path = CGMutablePath()
|
||||
for i in 0...60 {
|
||||
let t = CGFloat(i) / 60
|
||||
let p = CGPoint(
|
||||
x: t * 600,
|
||||
y: r.base + r.amp * sin(t * .pi * r.freq + r.phase) + 40 * t)
|
||||
if i == 0 { path.move(to: p) } else { path.addLine(to: p) }
|
||||
}
|
||||
glowStroke(ctx, path, width: r.w, color: rgb(r.c))
|
||||
}
|
||||
// A low ridge grounds the scene — without it the poster's bottom half is bare sky.
|
||||
for (fill, baseline, rough) in [
|
||||
(rgb(0x191345), CGFloat(212), CGFloat(30)),
|
||||
(rgb(0x0E0A2E), CGFloat(148), CGFloat(38)),
|
||||
] {
|
||||
let path = CGMutablePath()
|
||||
path.move(to: CGPoint(x: 0, y: 0))
|
||||
path.addLine(to: CGPoint(x: 0, y: baseline + rng.in_(-rough, rough)))
|
||||
for i in 1...9 {
|
||||
let x = CGFloat(i) / 9 * 600
|
||||
path.addLine(to: CGPoint(x: x, y: baseline + rng.in_(-rough, rough)))
|
||||
}
|
||||
path.addLine(to: CGPoint(x: 600, y: 0))
|
||||
path.closeSubpath()
|
||||
ctx.setFillColor(fill)
|
||||
ctx.addPath(path)
|
||||
ctx.fillPath()
|
||||
}
|
||||
}
|
||||
|
||||
private static func drawStarfall(_ ctx: CGContext) {
|
||||
sky(ctx, [(0, rgb(0x2A0C24)), (0.35, rgb(0x7A2B58)), (0.8, rgb(0xE86FA8)), (1, rgb(0xF7A8C8))])
|
||||
var rng = Rand(state: 23)
|
||||
for _ in 0..<6 {
|
||||
let head = CGPoint(x: rng.in_(60, 560), y: rng.in_(420, 840))
|
||||
let len = rng.in_(90, 170)
|
||||
let dir = CGVector(dx: cos(2.15), dy: sin(2.15)) // ~123° — up-left tails
|
||||
let path = CGMutablePath()
|
||||
path.move(to: head)
|
||||
path.addLine(to: CGPoint(x: head.x + dir.dx * len, y: head.y + dir.dy * len))
|
||||
glowStroke(ctx, path, width: 4, color: rgb(0xFFE3EF))
|
||||
glowDot(ctx, at: head, radius: 11, color: rgb(0xFFFFFF, 0.9))
|
||||
}
|
||||
for (fill, baseline, rough) in [
|
||||
(rgb(0x3A1430), CGFloat(300), CGFloat(26)),
|
||||
(rgb(0x1D0818), CGFloat(216), CGFloat(34)),
|
||||
] {
|
||||
let path = CGMutablePath()
|
||||
path.move(to: CGPoint(x: 0, y: 0))
|
||||
path.addLine(to: CGPoint(x: 0, y: baseline))
|
||||
for i in 1...8 {
|
||||
let x = CGFloat(i) / 8 * 600
|
||||
path.addLine(to: CGPoint(x: x, y: baseline + rng.in_(-rough, rough)))
|
||||
}
|
||||
path.addLine(to: CGPoint(x: 600, y: 0))
|
||||
path.closeSubpath()
|
||||
ctx.setFillColor(fill)
|
||||
ctx.addPath(path)
|
||||
ctx.fillPath()
|
||||
}
|
||||
}
|
||||
|
||||
private static func drawNeon(_ ctx: CGContext) {
|
||||
sky(ctx, [(0, rgb(0x0A2A33)), (1, rgb(0x04161C))])
|
||||
var rng = Rand(state: 7)
|
||||
let ring = CGPath(
|
||||
ellipseIn: CGRect(x: 300 - 105, y: 560 - 105, width: 210, height: 210), transform: nil)
|
||||
glowStroke(ctx, ring, width: 10, color: rgb(0x35D0C5))
|
||||
for i in 0..<9 {
|
||||
// Right-angle traces on a 40 px grid, some feeding out of the ring's four gates.
|
||||
var p = i < 4
|
||||
? CGPoint(x: 300 + [-105, 105, 0, 0][i], y: 560 + [0, 0, -105, 105][i])
|
||||
: CGPoint(x: 40 * (rng.in_(1, 14)).rounded(), y: 40 * (rng.in_(1, 21)).rounded())
|
||||
let path = CGMutablePath()
|
||||
path.move(to: p)
|
||||
var horizontal = rng.next() > 0.5
|
||||
for _ in 0..<Int(rng.in_(3, 6)) {
|
||||
let step = 40 * rng.in_(1, 4).rounded() * (rng.next() > 0.5 ? 1 : -1)
|
||||
p = horizontal ? CGPoint(x: min(max(p.x + step, 20), 580), y: p.y)
|
||||
: CGPoint(x: p.x, y: min(max(p.y + step, 20), 880))
|
||||
path.addLine(to: p)
|
||||
horizontal.toggle()
|
||||
}
|
||||
let color = rng.next() > 0.6 ? rgb(0x7FE8DE) : rgb(0x35D0C5)
|
||||
glowStroke(ctx, path, width: 5, color: color)
|
||||
glowDot(ctx, at: p, radius: 12, color: color.copy(alpha: 0.9)!)
|
||||
}
|
||||
}
|
||||
|
||||
private static func drawEmber(_ ctx: CGContext) {
|
||||
sky(ctx, [(0, rgb(0x200A04)), (0.3, rgb(0x7A2E12)), (0.42, rgb(0xEF8F4B)), (1, rgb(0x2A0E06))])
|
||||
glowDot(ctx, at: CGPoint(x: 300, y: 385), radius: 160, color: rgb(0xFFC37A, 0.85))
|
||||
var rng = Rand(state: 41)
|
||||
for (fill, baseline, rough) in [
|
||||
(rgb(0x5A2410), CGFloat(340), CGFloat(42)),
|
||||
(rgb(0x401708), CGFloat(255), CGFloat(56)),
|
||||
(rgb(0x200A04), CGFloat(165), CGFloat(48)),
|
||||
] {
|
||||
let path = CGMutablePath()
|
||||
path.move(to: CGPoint(x: 0, y: 0))
|
||||
path.addLine(to: CGPoint(x: 0, y: baseline + rng.in_(-rough, rough)))
|
||||
for i in 1...10 {
|
||||
let x = CGFloat(i) / 10 * 600
|
||||
path.addLine(to: CGPoint(x: x, y: baseline + rng.in_(-rough, rough)))
|
||||
}
|
||||
path.addLine(to: CGPoint(x: 600, y: 0))
|
||||
path.closeSubpath()
|
||||
ctx.setFillColor(fill)
|
||||
ctx.addPath(path)
|
||||
ctx.fillPath()
|
||||
}
|
||||
for _ in 0..<20 {
|
||||
let p = CGPoint(x: rng.in_(30, 570), y: rng.in_(180, 620))
|
||||
glowDot(ctx, at: p, radius: rng.in_(2.5, 6), color: rgb(0xFFB067, rng.in_(0.35, 0.9)))
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -4,6 +4,11 @@
|
||||
// physical pad (no host needed), so the rendering paths a session uses can be confirmed
|
||||
// on-device. Driven by PunktfunkKit's `ControllerTester`, which reuses the real renderers.
|
||||
//
|
||||
// Every card renders a plain value model (`ShotPad` / `InputSnapshot`) that the live path samples
|
||||
// out of the real pad each timeline tick. A GCController cannot be constructed, and the App Store
|
||||
// screenshot harness needs this panel with pads the capture machine doesn't have — ShotScenes
|
||||
// injects them via `shotPads` (the same seam Android's ControllersScreen grew for its capture).
|
||||
//
|
||||
// tvOS is excluded for now (it has no segmented picker / the panel wants a pointer-style
|
||||
// layout); macOS + iOS/iPadOS cover the validation need.
|
||||
|
||||
@@ -14,10 +19,63 @@ import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct ControllerTestView: View {
|
||||
/// What one panel section says about a pad, as plain values. The live path flattens the
|
||||
/// active `DiscoveredController` into one; the screenshot harness hands the panel pads that
|
||||
/// were never connected. `input`/`rumbleBackend` are the harness's section knobs (nil hides
|
||||
/// that card) — the live path always shows both, fed from the live pad and tester.
|
||||
struct ShotPad: Identifiable {
|
||||
let name: String
|
||||
/// The header's second line. Production shows the GC product category; a shot packs
|
||||
/// transport/battery/player facts into it (the panel has no dedicated battery row).
|
||||
let detail: String
|
||||
let isDualSense: Bool
|
||||
let hasAdaptiveTriggers: Bool
|
||||
let hasLight: Bool
|
||||
var input: InputSnapshot? = nil
|
||||
var rumbleBackend: String? = nil
|
||||
var id: String { name }
|
||||
}
|
||||
|
||||
/// One frame of the input readout. The live path samples the real `GCExtendedGamepad` into
|
||||
/// one of these on every 30 Hz tick; the harness writes a mid-game frame by hand.
|
||||
struct InputSnapshot {
|
||||
struct Stick {
|
||||
var x: Float
|
||||
var y: Float
|
||||
var pressed = false
|
||||
}
|
||||
struct Touch {
|
||||
/// Finger position in GC's -1...1 axes; nil = lifted. (GC snaps a lifted finger to
|
||||
/// exactly (0, 0), so a real (0, 0) contact is indistinguishable anyway.)
|
||||
var primary: CGPoint?
|
||||
var secondary: CGPoint?
|
||||
var clicked = false
|
||||
}
|
||||
struct Motion {
|
||||
var gyro: SIMD3<Double>
|
||||
var accel: SIMD3<Double>
|
||||
}
|
||||
var leftStick: Stick
|
||||
var rightStick: Stick
|
||||
var leftTrigger: Float = 0
|
||||
var rightTrigger: Float = 0
|
||||
/// Grid order; label → pressed.
|
||||
var buttons: [(String, Bool)]
|
||||
var touchpad: Touch?
|
||||
var motion: Motion?
|
||||
}
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@ObservedObject private var gamepads = GamepadManager.shared
|
||||
@StateObject private var tester = ControllerTester()
|
||||
|
||||
/// Screenshot-harness injection — nil (the app) renders the live active pad.
|
||||
private let shotPads: [ShotPad]?
|
||||
|
||||
init(shotPads: [ShotPad]? = nil) {
|
||||
self.shotPads = shotPads
|
||||
}
|
||||
|
||||
@State private var heavyOn = false
|
||||
@State private var lightOn = false
|
||||
@State private var intensity = 0.75
|
||||
@@ -62,12 +120,12 @@ struct ControllerTestView: View {
|
||||
Divider()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if let active = gamepads.active {
|
||||
header(active)
|
||||
inputCard
|
||||
rumbleCard()
|
||||
triggerCard(active)
|
||||
extrasCard(active)
|
||||
if let shotPads {
|
||||
ForEach(shotPads) { pad in
|
||||
shotPanel(pad)
|
||||
}
|
||||
} else if let active = gamepads.active {
|
||||
livePanel(active)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"No controller",
|
||||
@@ -81,9 +139,10 @@ struct ControllerTestView: View {
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 420, minHeight: 540)
|
||||
.onAppear { tester.target(gamepads.active?.controller) }
|
||||
.onDisappear { tester.stop() }
|
||||
.onAppear { if shotPads == nil { tester.target(gamepads.active?.controller) } }
|
||||
.onDisappear { if shotPads == nil { tester.stop() } }
|
||||
.onChange(of: gamepads.active?.id) { _, _ in
|
||||
guard shotPads == nil else { return }
|
||||
heavyOn = false
|
||||
lightOn = false
|
||||
playerLED = -1
|
||||
@@ -91,16 +150,53 @@ struct ControllerTestView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Panels
|
||||
|
||||
@ViewBuilder
|
||||
private func livePanel(_ active: GamepadManager.DiscoveredController) -> some View {
|
||||
let pad = Self.describe(active)
|
||||
header(pad)
|
||||
liveInputCard
|
||||
rumbleCard(backend: tester.rumbleBackend, health: tester.rumbleHealth)
|
||||
triggerCard(pad)
|
||||
extrasCard(pad)
|
||||
}
|
||||
|
||||
/// An injected pad's cards, in the live panel's order. The adaptive-trigger card is skipped
|
||||
/// outright for a pad without them — the live path's "needs a DualSense" hint is a diagnosis,
|
||||
/// and a capture has nothing to diagnose.
|
||||
@ViewBuilder
|
||||
private func shotPanel(_ pad: ShotPad) -> some View {
|
||||
header(pad)
|
||||
if let input = pad.input {
|
||||
card("Input") { inputReadout(input) }
|
||||
}
|
||||
if let backend = pad.rumbleBackend {
|
||||
rumbleCard(backend: backend, health: nil)
|
||||
}
|
||||
if pad.hasAdaptiveTriggers {
|
||||
triggerCard(pad)
|
||||
}
|
||||
extrasCard(pad)
|
||||
}
|
||||
|
||||
/// The live pad, flattened to what the panel renders about it.
|
||||
private static func describe(_ c: GamepadManager.DiscoveredController) -> ShotPad {
|
||||
ShotPad(
|
||||
name: c.name, detail: c.productCategory, isDualSense: c.isDualSense,
|
||||
hasAdaptiveTriggers: c.hasAdaptiveTriggers, hasLight: c.hasLight)
|
||||
}
|
||||
|
||||
// MARK: Header
|
||||
|
||||
private func header(_ c: GamepadManager.DiscoveredController) -> some View {
|
||||
private func header(_ pad: ShotPad) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: c.isDualSense ? "playstation.logo" : "gamecontroller.fill")
|
||||
Image(systemName: pad.isDualSense ? "playstation.logo" : "gamecontroller.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(c.name).font(.geist(17, .semibold, relativeTo: .headline))
|
||||
Text(c.productCategory).font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
Text(pad.name).font(.geist(17, .semibold, relativeTo: .headline))
|
||||
Text(pad.detail).font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
@@ -108,13 +204,13 @@ struct ControllerTestView: View {
|
||||
|
||||
// MARK: Input
|
||||
|
||||
private var inputCard: some View {
|
||||
private var liveInputCard: some View {
|
||||
card("Input") {
|
||||
// Poll the live controller at 30 Hz — no handlers installed, so nothing else's
|
||||
// capture is disturbed.
|
||||
TimelineView(.periodic(from: .now, by: 1.0 / 30.0)) { _ in
|
||||
if let gp = gamepads.active?.controller.extendedGamepad {
|
||||
inputReadout(gp, controller: gamepads.active?.controller)
|
||||
inputReadout(Self.snapshot(gp, controller: gamepads.active?.controller))
|
||||
} else {
|
||||
Text("Not an extended gamepad").foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -122,40 +218,82 @@ struct ControllerTestView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// One readout frame off the live pad.
|
||||
private static func snapshot(
|
||||
_ g: GCExtendedGamepad, controller: GCController?
|
||||
) -> InputSnapshot {
|
||||
var buttons: [(String, Bool)] = [
|
||||
("A", g.buttonA.isPressed), ("B", g.buttonB.isPressed),
|
||||
("X", g.buttonX.isPressed), ("Y", g.buttonY.isPressed),
|
||||
("LB", g.leftShoulder.isPressed), ("RB", g.rightShoulder.isPressed),
|
||||
("L3", g.leftThumbstickButton?.isPressed ?? false),
|
||||
("R3", g.rightThumbstickButton?.isPressed ?? false),
|
||||
("Menu", g.buttonMenu.isPressed),
|
||||
("Opts", g.buttonOptions?.isPressed ?? false),
|
||||
("↑", g.dpad.up.isPressed), ("↓", g.dpad.down.isPressed),
|
||||
("←", g.dpad.left.isPressed), ("→", g.dpad.right.isPressed),
|
||||
]
|
||||
let tp = touchpad(g)
|
||||
if let tp { buttons.append(("Pad", tp.button.isPressed)) }
|
||||
return InputSnapshot(
|
||||
leftStick: .init(
|
||||
x: g.leftThumbstick.xAxis.value, y: g.leftThumbstick.yAxis.value,
|
||||
pressed: g.leftThumbstickButton?.isPressed ?? false),
|
||||
rightStick: .init(
|
||||
x: g.rightThumbstick.xAxis.value, y: g.rightThumbstick.yAxis.value,
|
||||
pressed: g.rightThumbstickButton?.isPressed ?? false),
|
||||
leftTrigger: g.leftTrigger.value, rightTrigger: g.rightTrigger.value,
|
||||
buttons: buttons,
|
||||
touchpad: tp.map {
|
||||
.init(primary: finger($0.primary), secondary: finger($0.secondary),
|
||||
clicked: $0.button.isPressed)
|
||||
},
|
||||
motion: controller?.motion.map { m -> InputSnapshot.Motion in
|
||||
let a = totalAccel(m)
|
||||
return .init(
|
||||
gyro: .init(m.rotationRate.x, m.rotationRate.y, m.rotationRate.z),
|
||||
accel: .init(a.0, a.1, a.2))
|
||||
})
|
||||
}
|
||||
|
||||
private static func finger(_ pad: GCControllerDirectionPad) -> CGPoint? {
|
||||
let x = pad.xAxis.value, y = pad.yAxis.value
|
||||
// GC snaps a lifted finger to exactly (0, 0).
|
||||
return (x == 0 && y == 0) ? nil : CGPoint(x: CGFloat(x), y: CGFloat(y))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func inputReadout(_ g: GCExtendedGamepad, controller: GCController?) -> some View {
|
||||
private func inputReadout(_ s: InputSnapshot) -> some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(alignment: .top, spacing: 20) {
|
||||
stick("L", x: g.leftThumbstick.xAxis.value, y: g.leftThumbstick.yAxis.value,
|
||||
pressed: g.leftThumbstickButton?.isPressed ?? false)
|
||||
stick("R", x: g.rightThumbstick.xAxis.value, y: g.rightThumbstick.yAxis.value,
|
||||
pressed: g.rightThumbstickButton?.isPressed ?? false)
|
||||
stick("L", s.leftStick)
|
||||
stick("R", s.rightStick)
|
||||
VStack(spacing: 8) {
|
||||
triggerBar("L2", value: g.leftTrigger.value)
|
||||
triggerBar("R2", value: g.rightTrigger.value)
|
||||
triggerBar("L2", value: s.leftTrigger)
|
||||
triggerBar("R2", value: s.rightTrigger)
|
||||
}
|
||||
}
|
||||
buttonGrid(g)
|
||||
if let tp = Self.touchpad(g) {
|
||||
buttonGrid(s.buttons)
|
||||
if let tp = s.touchpad {
|
||||
touchpadView(tp)
|
||||
}
|
||||
if let m = controller?.motion {
|
||||
if let m = s.motion {
|
||||
motionReadout(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stick(_ label: String, x: Float, y: Float, pressed: Bool) -> some View {
|
||||
private func stick(_ label: String, _ s: InputSnapshot.Stick) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
ZStack {
|
||||
Circle().stroke(Color.secondary.opacity(0.3))
|
||||
Circle()
|
||||
.fill(pressed ? Color.accentColor : Color.secondary)
|
||||
.fill(s.pressed ? Color.accentColor : Color.secondary)
|
||||
.frame(width: 12, height: 12)
|
||||
.offset(x: CGFloat(x) * 22, y: CGFloat(-y) * 22) // GC y is +up
|
||||
.offset(x: CGFloat(s.x) * 22, y: CGFloat(-s.y) * 22) // GC y is +up
|
||||
}
|
||||
.frame(width: 56, height: 56)
|
||||
Text("\(label) \(sgn(x)),\(sgn(y))").font(.caption2.monospaced()).foregroundStyle(.secondary)
|
||||
Text("\(label) \(sgn(s.x)),\(sgn(s.y))").font(.caption2.monospaced()).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,20 +313,8 @@ struct ControllerTestView: View {
|
||||
.frame(width: 150)
|
||||
}
|
||||
|
||||
private func buttonGrid(_ g: GCExtendedGamepad) -> some View {
|
||||
var items: [(String, Bool)] = [
|
||||
("A", g.buttonA.isPressed), ("B", g.buttonB.isPressed),
|
||||
("X", g.buttonX.isPressed), ("Y", g.buttonY.isPressed),
|
||||
("LB", g.leftShoulder.isPressed), ("RB", g.rightShoulder.isPressed),
|
||||
("L3", g.leftThumbstickButton?.isPressed ?? false),
|
||||
("R3", g.rightThumbstickButton?.isPressed ?? false),
|
||||
("Menu", g.buttonMenu.isPressed),
|
||||
("Opts", g.buttonOptions?.isPressed ?? false),
|
||||
("↑", g.dpad.up.isPressed), ("↓", g.dpad.down.isPressed),
|
||||
("←", g.dpad.left.isPressed), ("→", g.dpad.right.isPressed),
|
||||
]
|
||||
if let tp = Self.touchpad(g) { items.append(("Pad", tp.button.isPressed)) }
|
||||
return LazyVGrid(
|
||||
private func buttonGrid(_ items: [(String, Bool)]) -> some View {
|
||||
LazyVGrid(
|
||||
columns: Array(repeating: GridItem(.flexible(), spacing: 6), count: 5), spacing: 6
|
||||
) {
|
||||
ForEach(items.indices, id: \.self) { i in
|
||||
@@ -203,12 +329,9 @@ struct ControllerTestView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func touchpadView(
|
||||
_ tp: (primary: GCControllerDirectionPad, secondary: GCControllerDirectionPad,
|
||||
button: GCControllerButtonInput)
|
||||
) -> some View {
|
||||
private func touchpadView(_ tp: InputSnapshot.Touch) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Touchpad\(tp.button.isPressed ? " — click" : "")")
|
||||
Text("Touchpad\(tp.clicked ? " — click" : "")")
|
||||
.font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.3))
|
||||
@@ -219,29 +342,25 @@ struct ControllerTestView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func fingerDot(_ pad: GCControllerDirectionPad, color: Color) -> some View {
|
||||
let x = pad.xAxis.value, y = pad.yAxis.value
|
||||
let active = !(x == 0 && y == 0) // GC snaps a lifted finger to exactly (0, 0)
|
||||
return Circle().fill(color).frame(width: 10, height: 10)
|
||||
.offset(x: CGFloat(x) * 71, y: CGFloat(-y) * 33)
|
||||
.opacity(active ? 1 : 0)
|
||||
private func fingerDot(_ p: CGPoint?, color: Color) -> some View {
|
||||
Circle().fill(color).frame(width: 10, height: 10)
|
||||
.offset(x: (p?.x ?? 0) * 71, y: -(p?.y ?? 0) * 33)
|
||||
.opacity(p == nil ? 0 : 1)
|
||||
}
|
||||
|
||||
private func motionReadout(_ m: GCMotion) -> some View {
|
||||
let a = Self.totalAccel(m)
|
||||
return VStack(alignment: .leading, spacing: 2) {
|
||||
private func motionReadout(_ m: InputSnapshot.Motion) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Motion").font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
|
||||
Text(String(format: "gyro %+.2f %+.2f %+.2f",
|
||||
m.rotationRate.x, m.rotationRate.y, m.rotationRate.z))
|
||||
Text(String(format: "gyro %+.2f %+.2f %+.2f", m.gyro.x, m.gyro.y, m.gyro.z))
|
||||
.font(.caption2.monospaced())
|
||||
Text(String(format: "accel %+.2f %+.2f %+.2f", a.0, a.1, a.2))
|
||||
Text(String(format: "accel %+.2f %+.2f %+.2f", m.accel.x, m.accel.y, m.accel.z))
|
||||
.font(.caption2.monospaced())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Rumble
|
||||
|
||||
private func rumbleCard() -> some View {
|
||||
private func rumbleCard(backend: String, health: String?) -> some View {
|
||||
card("Rumble") {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Picker("Strength", selection: $intensity) {
|
||||
@@ -253,9 +372,9 @@ struct ControllerTestView: View {
|
||||
.pickerStyle(.segmented)
|
||||
Toggle("Heavy motor (left)", isOn: $heavyOn)
|
||||
Toggle("Light motor (right)", isOn: $lightOn)
|
||||
Label("Backend: \(tester.rumbleBackend)", systemImage: "waveform")
|
||||
Label("Backend: \(backend)", systemImage: "waveform")
|
||||
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
if let problem = tester.rumbleHealth {
|
||||
if let problem = health {
|
||||
Label(problem, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.orange)
|
||||
}
|
||||
@@ -276,9 +395,9 @@ struct ControllerTestView: View {
|
||||
|
||||
// MARK: Adaptive triggers
|
||||
|
||||
private func triggerCard(_ c: GamepadManager.DiscoveredController) -> some View {
|
||||
private func triggerCard(_ pad: ShotPad) -> some View {
|
||||
card("Adaptive triggers") {
|
||||
if c.hasAdaptiveTriggers {
|
||||
if pad.hasAdaptiveTriggers {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Picker("Apply to", selection: $triggerTarget) {
|
||||
ForEach(TriggerTarget.allCases) { Text($0.rawValue).tag($0) }
|
||||
@@ -315,8 +434,8 @@ struct ControllerTestView: View {
|
||||
// MARK: Lightbar + player LED
|
||||
|
||||
@ViewBuilder
|
||||
private func extrasCard(_ c: GamepadManager.DiscoveredController) -> some View {
|
||||
if c.hasLight {
|
||||
private func extrasCard(_ pad: ShotPad) -> some View {
|
||||
if pad.hasLight {
|
||||
card("Lightbar & player LED") {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
|
||||
@@ -114,6 +114,10 @@ enum SettingsFields {
|
||||
.init(name: "invert_scroll", key: DefaultsKey.invertScroll,
|
||||
overlay: \.invertScroll, effective: \.invertScroll)
|
||||
}
|
||||
static var inhibitShortcuts: SettingsField<Bool> {
|
||||
.init(name: "inhibit_shortcuts", key: DefaultsKey.inhibitShortcuts,
|
||||
overlay: \.inhibitShortcuts, effective: \.inhibitShortcuts)
|
||||
}
|
||||
static var modifierLayout: SettingsField<String> {
|
||||
.init(name: "modifier_layout", key: DefaultsKey.modifierLayout,
|
||||
overlay: \.modifierLayout, effective: \.modifierLayout)
|
||||
@@ -205,6 +209,7 @@ extension SettingsView {
|
||||
#endif
|
||||
#if os(macOS)
|
||||
base.mouseMode = mouseMode
|
||||
base.inhibitShortcuts = inhibitShortcuts
|
||||
base.vsync = vsync
|
||||
base.windowedSafePresent = windowedSafePresent
|
||||
#endif
|
||||
|
||||
@@ -515,6 +515,9 @@ extension SettingsView {
|
||||
Text("Desktop (absolute)").tag(MouseInputMode.desktop.rawValue)
|
||||
}
|
||||
}
|
||||
described(inhibitShortcutsDescription, field: "inhibit_shortcuts") {
|
||||
Toggle("Capture system shortcuts", isOn: scoped(SettingsFields.inhibitShortcuts))
|
||||
}
|
||||
#endif
|
||||
described(
|
||||
(ModifierLayout(rawValue: effective.modifierLayout) ?? .mac).detail,
|
||||
@@ -534,6 +537,19 @@ extension SettingsView {
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// Dynamic like the captions above, because the setting genuinely has no effect under the
|
||||
/// desktop mouse model (system chords stay local there on every client) — and a toggle that
|
||||
/// silently does nothing should say so instead of leaving the user to find out.
|
||||
private var inhibitShortcutsDescription: String {
|
||||
if (MouseInputMode(rawValue: effective.mouseMode) ?? .capture) == .desktop {
|
||||
return "⌘ shortcuts stay on this Mac under the desktop mouse model. Switch Mouse "
|
||||
+ "input to Capture to send them to the host."
|
||||
}
|
||||
return "Sends ⌘ shortcuts to the host while input is captured, so ⌘Q and friends reach "
|
||||
+ "the remote desktop instead of this app. ⌘⎋ always stays local — it is what "
|
||||
+ "releases capture."
|
||||
}
|
||||
|
||||
/// The SELECTED mouse model explained — dynamic, like the touch-mode caption.
|
||||
private var mouseModeDescription: String {
|
||||
switch MouseInputMode(rawValue: effective.mouseMode) ?? .capture {
|
||||
|
||||
@@ -115,6 +115,10 @@ struct SettingsView: View {
|
||||
#endif
|
||||
#if os(macOS)
|
||||
@AppStorage(DefaultsKey.mouseMode) var mouseMode = MouseInputMode.capture.rawValue
|
||||
/// Cross-client `inhibit_shortcuts` — here, the ⌘-chord passthrough (⌘Q & co. reach the host
|
||||
/// instead of the app menu while captured). macOS-only: it is the one platform whose window
|
||||
/// system hands a plain app no keyboard grab, so the client has to claim the chords itself.
|
||||
@AppStorage(DefaultsKey.inhibitShortcuts) var inhibitShortcuts = true
|
||||
@AppStorage(DefaultsKey.speakerUID) var speakerUID = ""
|
||||
@AppStorage(DefaultsKey.micUID) var micUID = ""
|
||||
@AppStorage(DefaultsKey.micChannel) var micChannel = 0
|
||||
|
||||
@@ -48,11 +48,8 @@ struct GamepadPairView: View {
|
||||
|
||||
@StateObject private var ceremony = PairCeremony()
|
||||
@State private var pin = ""
|
||||
#if os(macOS)
|
||||
@State private var clientName = Host.current().localizedName ?? "Mac"
|
||||
#else
|
||||
@State private var clientName = UIDevice.current.name
|
||||
#endif
|
||||
// Same source the connect path knocks with — see the note in `PairSheet`.
|
||||
@State private var clientName = DeviceName.current
|
||||
@State private var focusID: String?
|
||||
/// The field row the keyboard tray is editing; nil ⇒ the row list owns the controller.
|
||||
@State private var editing: String?
|
||||
|
||||
@@ -49,7 +49,7 @@ final class PairCeremony: ObservableObject {
|
||||
let identity = try ClientIdentityStore.shared.loadForPairing()
|
||||
return try PunktfunkKit.pair(
|
||||
host: address, port: port, identity: identity,
|
||||
pin: pin, name: name.isEmpty ? "Mac" : name)
|
||||
pin: pin, name: name.isEmpty ? DeviceName.current : name)
|
||||
}
|
||||
await MainActor.run {
|
||||
guard !token.cancelled else { return } // screen dismissed mid-ceremony
|
||||
|
||||
@@ -21,11 +21,9 @@ struct PairSheet: View {
|
||||
let onPaired: (Data) -> Void
|
||||
|
||||
@State private var pin = ""
|
||||
#if os(macOS)
|
||||
@State private var clientName = Host.current().localizedName ?? "Mac"
|
||||
#else
|
||||
@State private var clientName = UIDevice.current.name
|
||||
#endif
|
||||
// Same source the connect path knocks with (`DeviceName.current`), so a device the operator
|
||||
// approves from the console's pending list and one that pairs by PIN land under one name.
|
||||
@State private var clientName = DeviceName.current
|
||||
@StateObject private var ceremony = PairCeremony()
|
||||
|
||||
private var busy: Bool { ceremony.busy }
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// The name this device tells a host it is — the label an operator approves in the web console.
|
||||
|
||||
import Foundation
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
/// The name the USER knows this device by: "Enrico's iPad", "Wohnzimmer UG", "Enricos MacBook Pro".
|
||||
///
|
||||
/// The host shows it in its pending-approval list — the web console's outstanding-pairings view and
|
||||
/// the dialog that approves a knock — and files the device under it in the trust store. It is the
|
||||
/// ONLY thing distinguishing one waiting device from another there, so it must come from the OS
|
||||
/// name the user set, not from a placeholder.
|
||||
///
|
||||
/// The core's own default (`punktfunk_connect_ex9` and earlier) reads `COMPUTERNAME` / `HOSTNAME`
|
||||
/// — a Windows variable and a shell variable. Neither exists in a `launchd`-started GUI app, so
|
||||
/// every Apple client used to fall through to the literal "This device" and a console with an
|
||||
/// iPad, an Apple TV and a Mac pending showed three rows of it. Pass this to
|
||||
/// `punktfunk_connect_ex10` instead (`PunktfunkConnection.init` does, by default).
|
||||
public enum DeviceName {
|
||||
/// This device's user-facing name, never empty.
|
||||
public static var current: String {
|
||||
#if os(macOS)
|
||||
let name = (Host.current().localizedName ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return name.isEmpty ? (hostName ?? kind) : name
|
||||
#else
|
||||
let name = UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
// iOS/tvOS 16+ answer `name` with the MODEL ("iPad") unless the app holds the
|
||||
// user-assigned-device-name entitlement — which turns a household's three iPads into
|
||||
// three identical rows in the host's approval list. The hostname is not behind that
|
||||
// gate on every OS version, and when the user has named the device it carries that
|
||||
// name ("Enricos-iPad"), so prefer it whenever `name` came back generic.
|
||||
if name.isEmpty || name == kind {
|
||||
if let host = hostName { return host }
|
||||
}
|
||||
return name.isEmpty ? kind : name
|
||||
#endif
|
||||
}
|
||||
|
||||
/// The OS hostname without its mDNS `.local` suffix — nil when it is unset or the placeholder
|
||||
/// every unconfigured device reports, which would name nothing.
|
||||
private static var hostName: String? {
|
||||
let host = ProcessInfo.processInfo.hostName
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let bare = host.hasSuffix(".local") ? String(host.dropLast(6)) : host
|
||||
guard !bare.isEmpty, bare.caseInsensitiveCompare("localhost") != .orderedSame else {
|
||||
return nil
|
||||
}
|
||||
return bare
|
||||
}
|
||||
|
||||
/// What to call the device when the OS has no name for it — the product, which at least tells
|
||||
/// an operator which of the pending rows is the Apple TV. (iOS/tvOS 16+ answer
|
||||
/// `UIDevice.current.name` with exactly this unless the app holds the user-assigned-name
|
||||
/// entitlement, so the two agree more often than not.)
|
||||
public static var kind: String {
|
||||
#if os(macOS)
|
||||
return "Mac"
|
||||
#elseif os(tvOS)
|
||||
return "Apple TV"
|
||||
#else
|
||||
return UIDevice.current.model // "iPad" / "iPhone"
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -228,6 +228,16 @@ extension Artwork {
|
||||
}
|
||||
}
|
||||
|
||||
/// Anything that answers poster bytes for a cover-art URL. The production implementation is
|
||||
/// [`LibraryArtLoader`]; the screenshot harness substitutes a canned source so store frames carry
|
||||
/// artwork without a host on the network.
|
||||
public protocol LibraryArtSource: Sendable {
|
||||
func data(for url: URL) async throws -> Data
|
||||
/// Release pooled connections when the owning screen goes away. Sources without connections
|
||||
/// have nothing to do.
|
||||
func close() async
|
||||
}
|
||||
|
||||
/// Loads cover art for the library UI, routing each URL to the transport that suits its origin.
|
||||
///
|
||||
/// A `GameEntry`'s art candidates mix two very different things: the host's own art proxy
|
||||
@@ -242,7 +252,7 @@ extension Artwork {
|
||||
/// TLS handshake per tile.
|
||||
///
|
||||
/// Built once per library screen and reused across a whole grid's worth of posters.
|
||||
public final class LibraryArtLoader: @unchecked Sendable {
|
||||
public final class LibraryArtLoader: LibraryArtSource, @unchecked Sendable {
|
||||
private let address: String
|
||||
private let port: UInt16
|
||||
private let identity: SecIdentity
|
||||
|
||||
@@ -604,6 +604,7 @@ public final class PunktfunkConnection {
|
||||
preferredCodec: UInt8 = 0, // 0 = auto; else PUNKTFUNK_CODEC_* soft preference
|
||||
clientCaps: UInt8 = 0, // ABI v11: PUNKTFUNK_CLIENT_CAP_CURSOR = render the host cursor locally
|
||||
launchID: String? = nil,
|
||||
deviceName: String? = nil, // nil = this device's OS name (`DeviceName.current`)
|
||||
timeoutMs: UInt32 = 10_000
|
||||
) throws {
|
||||
if let pin = pinSHA256, pin.count != 32 { throw PunktfunkClientError.invalidPin }
|
||||
@@ -616,25 +617,33 @@ public final class PunktfunkConnection {
|
||||
// host upgrades to a 10-bit / BT.2020 PQ stream only when set. 0 = 8-bit BT.709 SDR.
|
||||
// `launchID` (a host library id like "steam:570") asks the host to launch that title in
|
||||
// the session; the host resolves it against its own library — nil = the host's default.
|
||||
// `label` is what an unpaired knock shows up as in the host's approval list (and the web
|
||||
// console's outstanding-pairings view): this device's OS name unless the caller overrode
|
||||
// it. Without it the core falls back to environment variables no Apple app has, and every
|
||||
// device pending approval reads "This device".
|
||||
let override = deviceName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let label = override.isEmpty ? DeviceName.current : override
|
||||
handle = host.withCString { cs in
|
||||
withOptionalCString(identity?.certPEM) { cert in
|
||||
withOptionalCString(identity?.keyPEM) { key in
|
||||
withOptionalCString(launchID) { launch in
|
||||
if let pin = pinSHA256 {
|
||||
return pin.withUnsafeBytes { p in
|
||||
punktfunk_connect_ex9(
|
||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
|
||||
videoCodecs, preferredCodec, clientCaps, launch,
|
||||
p.bindMemory(to: UInt8.self).baseAddress, &observed,
|
||||
cert, key, timeoutMs, &connectStatus)
|
||||
label.withCString { name in
|
||||
if let pin = pinSHA256 {
|
||||
return pin.withUnsafeBytes { p in
|
||||
punktfunk_connect_ex10(
|
||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
|
||||
videoCodecs, preferredCodec, clientCaps, launch,
|
||||
p.bindMemory(to: UInt8.self).baseAddress, &observed,
|
||||
cert, key, name, timeoutMs, &connectStatus)
|
||||
}
|
||||
}
|
||||
return punktfunk_connect_ex10(
|
||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
|
||||
videoCodecs, preferredCodec, clientCaps, launch,
|
||||
nil, &observed, cert, key, name, timeoutMs, &connectStatus)
|
||||
}
|
||||
return punktfunk_connect_ex9(
|
||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
|
||||
videoCodecs, preferredCodec, clientCaps, launch,
|
||||
nil, &observed, cert, key, timeoutMs, &connectStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,21 @@ public final class InputCapture {
|
||||
/// its Esc suppression need it in both states).
|
||||
private var cmdKeysDown: Set<UInt32> = []
|
||||
|
||||
#if os(macOS)
|
||||
/// Windows VKs the ⌘-chord passthrough sent DOWN (see the keyDown monitor). macOS stops
|
||||
/// delivering keyUp for ordinary keys while Command is held, so the release half of ⌘Q/⌘W/…
|
||||
/// cannot be relied on to arrive through the responder chain at all: these are flushed when
|
||||
/// the last ⌘ comes up (`flushCommandChord`), which is what stands between the host and a
|
||||
/// key held down for the rest of the session.
|
||||
private var commandChordVKs: Set<UInt32> = []
|
||||
|
||||
/// Mirrors StreamLayerView's live mouse model — ⌃⌥⇧M flips it mid-session, so it can't be
|
||||
/// read from the settings. The ⌘-chord passthrough stays off under the desktop model, matching
|
||||
/// what the SDL clients' keyboard grab does: a remote desktop is something you ⌘Tab away from,
|
||||
/// not into.
|
||||
public var desktopMouse = false
|
||||
#endif
|
||||
|
||||
#if !os(macOS)
|
||||
/// The key currently auto-repeating, and the timer driving it. iOS/tvOS only — see
|
||||
/// `startAutoRepeat`. Main-queue only, like every other field here.
|
||||
@@ -244,19 +259,27 @@ public final class InputCapture {
|
||||
) { [weak self] _ in
|
||||
self?.releaseAll()
|
||||
})
|
||||
// ⌘⎋ — the capture toggle — is detected here so it works in both states. ONLY
|
||||
// that one combo is intercepted: swallowing keys wholesale at the monitor level
|
||||
// risks starving GC's own delivery, so the no-beep behavior lives in
|
||||
// StreamLayerView (first responder consumes keyDown/keyUp while captured).
|
||||
// (On iOS there is no NSEvent monitor — the GC key handler detects the combo.)
|
||||
// This monitor is the FIRST thing in the app to see a key: AppKit calls it before
|
||||
// `sendEvent:`, so before any menu key equivalent and before StreamLayerView's keyDown.
|
||||
// Returning nil discards the event outright — which cuts BOTH of those off, and on macOS
|
||||
// the second one is the host's only key path (the GCKeyboard send is iOS-only; see
|
||||
// `attach(keyboard:)`). So the rule here is: anything swallowed must either be handled
|
||||
// client-side or forwarded to the host from inside this block, because nothing downstream
|
||||
// will get a second chance at it.
|
||||
//
|
||||
// ⌘⎋ (capture toggle) and ⌃⌥⇧M (mouse model) are client-side in BOTH states; ⌃⌥⇧Q/D/S/A
|
||||
// and ⌃⌘F are client-side only while forwarding (released, the events pass through and the
|
||||
// menu's identical key equivalents handle them). Every OTHER ⌘ chord is the HOST's while
|
||||
// captured — see `forwardsCommandChord`. (On iOS there is no NSEvent monitor — the GC key
|
||||
// handler detects the combos.)
|
||||
#if os(macOS)
|
||||
keyEventMonitor = NSEvent.addLocalMonitorForEvents(
|
||||
matching: [.keyDown]
|
||||
) { [weak self] event in
|
||||
guard let self else { return event }
|
||||
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
||||
let flags = Self.chordFlags(event)
|
||||
if event.keyCode == 53 /* Esc */, flags == .command {
|
||||
self.suppressedVK = 0x1B // the same physical Esc is en route via GC
|
||||
self.suppressedVK = 0x1B // VK_ESC — its keyUp still reaches the responder chain
|
||||
self.onToggleCapture?()
|
||||
return nil
|
||||
}
|
||||
@@ -266,7 +289,7 @@ public final class InputCapture {
|
||||
// (latched like ⌘⎋'s Esc) so it doesn't type into the host, and swallow the
|
||||
// event so it doesn't beep.
|
||||
if event.keyCode == 46 /* M */, flags == [.control, .option, .shift] {
|
||||
self.suppressedVK = 0x4D // VK_M — the same physical M is en route via GC
|
||||
self.suppressedVK = 0x4D // VK_M — its keyUp still reaches the responder chain
|
||||
self.onToggleMouseMode?()
|
||||
return nil
|
||||
}
|
||||
@@ -304,10 +327,34 @@ public final class InputCapture {
|
||||
// captured stream view swallows the menu's identical equivalent); the F is latched so its
|
||||
// keyUp can't type into the host. keyCode 3 = kVK_ANSI_F (layout-independent).
|
||||
if self.forwarding, flags == [.control, .command], event.keyCode == 3 /* F */ {
|
||||
self.suppressedVK = 0x46 // VK_F — the same physical F is en route via GC
|
||||
self.suppressedVK = 0x46 // VK_F — its keyUp still reaches the responder chain
|
||||
self.onToggleFullscreen?()
|
||||
return nil
|
||||
}
|
||||
// Every OTHER ⌘ chord belongs to the HOST while captured — the cross-client "capture
|
||||
// system shortcuts" setting, which the Apple client had no answer to because SDL's
|
||||
// keyboard grab is what implements it everywhere else. Without this the app menu's key
|
||||
// equivalents fire first, so ⌘Q quits the client instead of reaching the compositor as
|
||||
// Super+Q — one of the most-bound chords on a Linux desktop, and the reported break.
|
||||
//
|
||||
// It has to SEND from here: returning nil is what keeps the menu out, and it takes
|
||||
// StreamLayerView's keyDown — the host's only key path on macOS — out with it.
|
||||
// Chords with no host VK are swallowed but not sent: doing nothing beats a menu
|
||||
// opening under a captured stream. The ⌘ itself needs no handling — modifiers arrive
|
||||
// as flagsChanged, which this monitor never sees, so it was already forwarded as
|
||||
// VK_LWIN/VK_RWIN (or Alt, under the Windows modifier layout) when it went down.
|
||||
//
|
||||
// The two cheap conditions are repeated in front of the call on purpose: off-session,
|
||||
// `SessionSettings.current` re-reads the whole defaults suite, and this monitor sees
|
||||
// every keystroke the app receives — including the ones typed into the host list.
|
||||
if self.forwarding, flags.contains(.command), Self.forwardsCommandChord(
|
||||
keyCode: event.keyCode, flags: flags, forwarding: self.forwarding,
|
||||
inhibitShortcuts: SessionSettings.current.inhibitShortcuts,
|
||||
desktopMouse: self.desktopMouse
|
||||
) {
|
||||
if let vk = Self.keyCodeToVK[event.keyCode] { self.sendCommandChordKey(vk) }
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
}
|
||||
#endif
|
||||
@@ -358,6 +405,9 @@ public final class InputCapture {
|
||||
cmdKeysDown.removeAll()
|
||||
chordModifiersDown.removeAll()
|
||||
suppressedVK = nil
|
||||
#if os(macOS)
|
||||
commandChordVKs.removeAll() // their releases are in `pressedVKs`, flushed just below
|
||||
#endif
|
||||
for vk in pressedVKs {
|
||||
emitKey(vk, down: false)
|
||||
}
|
||||
@@ -522,7 +572,15 @@ public final class InputCapture {
|
||||
// Keep cmdKeysDown in step (the ⌘⎋ toggle + Esc suppression read it); sendKey
|
||||
// adds the VK to pressedVKs so releaseAll/blur flushes a held modifier cleanly.
|
||||
if vk == 0x5B || vk == 0x5C {
|
||||
if down { cmdKeysDown.insert(vk) } else { cmdKeysDown.remove(vk) }
|
||||
if down {
|
||||
cmdKeysDown.insert(vk)
|
||||
} else {
|
||||
cmdKeysDown.remove(vk)
|
||||
// Last ⌘ up: release the chord keys whose own keyUp macOS never delivered. BEFORE
|
||||
// the ⌘'s own release goes out, so the host never sees the letter outlive the
|
||||
// modifier it was pressed with.
|
||||
if cmdKeysDown.isEmpty { flushCommandChord() }
|
||||
}
|
||||
}
|
||||
sendKey(vk, down: down)
|
||||
}
|
||||
@@ -552,6 +610,68 @@ public final class InputCapture {
|
||||
}
|
||||
return (mod.vk, down)
|
||||
}
|
||||
|
||||
// MARK: - ⌘ chord passthrough
|
||||
|
||||
/// The four modifiers a client chord is ever spelled with, isolated from the incidental bits
|
||||
/// `deviceIndependentFlagsMask` also carries: Caps Lock, and the `.function`/`.numericPad`
|
||||
/// pair every arrow and F-key sets. Equality against the raw masked flags meant a chord
|
||||
/// stopped being recognized the moment Caps Lock was on — ⌘⎋ and ⌃⌥⇧Q, both escape hatches,
|
||||
/// included. That was survivable while the monitor claimed six chords; it is not, now that it
|
||||
/// swallows every ⌘ chord there is.
|
||||
static let chordFlagMask: NSEvent.ModifierFlags = [.command, .control, .option, .shift]
|
||||
|
||||
/// One event's chord modifiers (see `chordFlagMask`).
|
||||
static func chordFlags(_ event: NSEvent) -> NSEvent.ModifierFlags {
|
||||
event.modifierFlags.intersection(chordFlagMask)
|
||||
}
|
||||
|
||||
/// The ⌘ chords the CLIENT keeps while captured, which is to say: the way out. ⌘⎋ releases
|
||||
/// the mouse/keyboard and ⌃⌘F leaves fullscreen — hand either of those to the host and a
|
||||
/// captured stream becomes a room with no door. (⌃⌥⇧Q/D/S/A carry no ⌘ and never reach here.)
|
||||
static func isClientReservedChord(keyCode: UInt16, flags: NSEvent.ModifierFlags) -> Bool {
|
||||
if keyCode == 53, flags == .command { return true } // ⌘⎋ — capture toggle
|
||||
if keyCode == 3, flags == [.control, .command] { return true } // ⌃⌘F — fullscreen
|
||||
return false
|
||||
}
|
||||
|
||||
/// Does this keyDown get taken off AppKit and forwarded to the host instead? Only while input
|
||||
/// is actually captured, only with the cross-client `inhibit_shortcuts` on, and never under the
|
||||
/// desktop mouse model (where the chords stay local by design) — and never for the client's own
|
||||
/// reserved chords, whatever the setting says.
|
||||
static func forwardsCommandChord(
|
||||
keyCode: UInt16, flags: NSEvent.ModifierFlags,
|
||||
forwarding: Bool, inhibitShortcuts: Bool, desktopMouse: Bool
|
||||
) -> Bool {
|
||||
guard forwarding, inhibitShortcuts, !desktopMouse else { return false }
|
||||
guard flags.contains(.command) else { return false }
|
||||
return !isClientReservedChord(keyCode: keyCode, flags: flags)
|
||||
}
|
||||
|
||||
/// Forward one key of a ⌘ chord the monitor just took off AppKit, remembering it so its
|
||||
/// release can be synthesized (see `commandChordVKs`).
|
||||
private func sendCommandChordKey(_ vk: UInt32) {
|
||||
commandChordVKs.insert(vk)
|
||||
sendKey(vk, down: true)
|
||||
}
|
||||
|
||||
/// Release whatever the ⌘-chord passthrough sent down and is still held — called when the last
|
||||
/// physical ⌘ comes up. A keyUp that DID arrive has already taken its VK out of `pressedVKs`,
|
||||
/// so this only fires for the ones macOS swallowed.
|
||||
private func flushCommandChord() {
|
||||
// Same cause, different victim: a one-shot latch whose key-up never arrived goes on to eat
|
||||
// the NEXT press of that key (⌃⌘F's F, ⌘⎋'s Esc). Once ⌘ is up, a pending latch is stale.
|
||||
suppressedVK = nil
|
||||
guard !commandChordVKs.isEmpty else { return }
|
||||
for vk in commandChordVKs where pressedVKs.contains(vk) {
|
||||
pressedVKs.remove(vk)
|
||||
emitKey(vk, down: false)
|
||||
if inputDebug {
|
||||
inputLog.debug("key \(vk, privacy: .public) up SYNTHESIZED (⌘ chord release)")
|
||||
}
|
||||
}
|
||||
commandChordVKs.removeAll()
|
||||
}
|
||||
#endif
|
||||
|
||||
private func attach(mouse: GCMouse) {
|
||||
|
||||
@@ -410,8 +410,9 @@ public final class StreamLayerView: NSView {
|
||||
// keycode) → Windows VK and forward via InputCapture.sendKey, then CONSUME (return without
|
||||
// super) to stop the responder chain's "unhandled keyDown" beep. Keys with no VK mapping
|
||||
// are still consumed while captured so they don't beep either. The ⌘⎋ toggle's Esc is
|
||||
// swallowed upstream by InputCapture's keyDown monitor (suppressedVK), so it never gets
|
||||
// here as a send; ⌘-combos still arrive via performKeyEquivalent and stay functional (⌘D).
|
||||
// swallowed upstream by InputCapture's keyDown monitor (suppressedVK), so it never gets here
|
||||
// as a send — and so are ⌘ combos generally while captured, which that monitor forwards to the
|
||||
// host itself (`forwardsCommandChord`) rather than letting a menu key equivalent claim them.
|
||||
// Modifier keys never fire keyDown/keyUp — they come through flagsChanged below.
|
||||
public override var acceptsFirstResponder: Bool { true }
|
||||
// A click after the app was inactive (Cmd-Tab away and back) must reach mouseDown so the
|
||||
@@ -570,6 +571,9 @@ public final class StreamLayerView: NSView {
|
||||
let wasCaptured = captured
|
||||
if wasCaptured { releaseCapture() }
|
||||
desktopMouse = on
|
||||
// The ⌘-chord passthrough is off under the desktop model (system chords stay local there,
|
||||
// as on every other client) — and the model moves live, so the capture is told, not asked.
|
||||
inputCapture?.desktopMouse = on
|
||||
if wasCaptured { engageCapture(fromClick: false) }
|
||||
window?.invalidateCursorRects(for: self)
|
||||
if on, let p = reappearAt, let sp = cgScreenPoint(forHostX: p.x, p.y) {
|
||||
@@ -917,6 +921,7 @@ public final class StreamLayerView: NSView {
|
||||
) ?? .capture
|
||||
let absOK = connection.resolvedCompositor != .gamescope
|
||||
desktopMouse = mode == .desktop && absOK
|
||||
capture.desktopMouse = desktopMouse
|
||||
if mode == .desktop && !absOK {
|
||||
streamInputLog.info("desktop mouse mode unavailable on a gamescope host (relative-only) — using capture")
|
||||
}
|
||||
|
||||
@@ -157,6 +157,16 @@ public enum DefaultsKey {
|
||||
/// Read live at the wire boundary by `InputCapture`. Control/Shift never move (same position on
|
||||
/// both keyboards).
|
||||
public static let modifierLayout = "punktfunk.modifierLayout"
|
||||
/// Send system chords to the host while input is captured — the cross-client
|
||||
/// `inhibit_shortcuts`, ON by default. On the SDL clients it is SDL's keyboard grab (Alt+Tab,
|
||||
/// the Windows key); macOS has no such grab from a plain app, so `InputCapture`'s keyDown
|
||||
/// monitor implements it by taking every ⌘ chord off AppKit before a menu key equivalent can
|
||||
/// fire and forwarding it instead — which is what makes ⌘Q reach the host's compositor rather
|
||||
/// than quitting the client. Off keeps the chords local (the second-screen/work profile).
|
||||
/// The client's own reserved chords (⌘⎋, ⌃⌘F, ⌃⌥⇧…) are never forwarded either way, and — as
|
||||
/// on the SDL clients — the setting has no effect under the `desktop` mouse model, which is
|
||||
/// something you ⌘Tab *away* from. macOS-only today; nothing reads it on iOS/tvOS.
|
||||
public static let inhibitShortcuts = "punktfunk.inhibitShortcuts"
|
||||
/// iPad: capture the mouse/trackpad pointer (pointer lock → relative movement) for games,
|
||||
/// rather than forwarding an absolute cursor position. On by default. Only meaningful on iPad
|
||||
/// with a hardware mouse/trackpad; the system grants the lock only to a full-screen, frontmost
|
||||
|
||||
@@ -33,6 +33,9 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
public var touchMode = "trackpad"
|
||||
public var mouseMode = "capture"
|
||||
public var invertScroll = false
|
||||
/// Cross-client `inhibit_shortcuts` (default on): system chords reach the host while input is
|
||||
/// captured. See `DefaultsKey.inhibitShortcuts` — on macOS this is the ⌘-chord passthrough.
|
||||
public var inhibitShortcuts = true
|
||||
public var gamepadType = 0
|
||||
public var gamepadForwarding = true
|
||||
/// Cross-client `system_buttons`: "auto" | "forward" | "local".
|
||||
@@ -97,6 +100,7 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
touchMode = str(DefaultsKey.touchMode, touchMode)
|
||||
mouseMode = str(DefaultsKey.mouseMode, mouseMode)
|
||||
invertScroll = bool(DefaultsKey.invertScroll, invertScroll)
|
||||
inhibitShortcuts = bool(DefaultsKey.inhibitShortcuts, inhibitShortcuts)
|
||||
gamepadType = int(DefaultsKey.gamepadType, gamepadType)
|
||||
gamepadForwarding = bool(DefaultsKey.gamepadForwarding, gamepadForwarding)
|
||||
systemButtons = str(DefaultsKey.systemButtons, systemButtons)
|
||||
@@ -177,6 +181,7 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
if let v = overlay.touchMode { s.touchMode = v }
|
||||
if let v = overlay.mouseMode { s.mouseMode = v }
|
||||
if let v = overlay.invertScroll { s.invertScroll = v }
|
||||
if let v = overlay.inhibitShortcuts { s.inhibitShortcuts = v }
|
||||
if let v = overlay.gamepadType { s.gamepadType = v }
|
||||
if let v = overlay.gamepadForwarding { s.gamepadForwarding = v }
|
||||
if let v = overlay.systemButtons { s.systemButtons = v }
|
||||
|
||||
@@ -109,6 +109,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
public var touchMode: String?
|
||||
public var mouseMode: String?
|
||||
public var invertScroll: Bool?
|
||||
public var inhibitShortcuts: Bool?
|
||||
public var gamepadType: Int?
|
||||
public var gamepadForwarding: Bool?
|
||||
public var systemButtons: String?
|
||||
@@ -153,6 +154,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
case touchMode = "touch_mode"
|
||||
case mouseMode = "mouse_mode"
|
||||
case invertScroll = "invert_scroll"
|
||||
case inhibitShortcuts = "inhibit_shortcuts"
|
||||
case gamepadType = "gamepad"
|
||||
case gamepadForwarding = "gamepad_forwarding"
|
||||
case systemButtons = "system_buttons"
|
||||
@@ -189,6 +191,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
touchMode = str(.touchMode)
|
||||
mouseMode = str(.mouseMode)
|
||||
invertScroll = bool(.invertScroll)
|
||||
inhibitShortcuts = bool(.inhibitShortcuts)
|
||||
gamepadType = int(.gamepadType)
|
||||
gamepadForwarding = bool(.gamepadForwarding)
|
||||
systemButtons = str(.systemButtons)
|
||||
@@ -227,6 +230,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
try c.encodeIfPresent(touchMode, forKey: AnyKey(Key.touchMode.rawValue))
|
||||
try c.encodeIfPresent(mouseMode, forKey: AnyKey(Key.mouseMode.rawValue))
|
||||
try c.encodeIfPresent(invertScroll, forKey: AnyKey(Key.invertScroll.rawValue))
|
||||
try c.encodeIfPresent(inhibitShortcuts, forKey: AnyKey(Key.inhibitShortcuts.rawValue))
|
||||
try c.encodeIfPresent(gamepadType, forKey: AnyKey(Key.gamepadType.rawValue))
|
||||
try c.encodeIfPresent(
|
||||
gamepadForwarding, forKey: AnyKey(Key.gamepadForwarding.rawValue))
|
||||
@@ -283,6 +287,7 @@ public enum OverlayField {
|
||||
case "touch_mode": overlay.touchMode = nil
|
||||
case "mouse_mode": overlay.mouseMode = nil
|
||||
case "invert_scroll": overlay.invertScroll = nil
|
||||
case "inhibit_shortcuts": overlay.inhibitShortcuts = nil
|
||||
case "gamepad": overlay.gamepadType = nil
|
||||
case "gamepad_forwarding": overlay.gamepadForwarding = nil
|
||||
case "system_buttons": overlay.systemButtons = nil
|
||||
@@ -321,6 +326,7 @@ public enum OverlayField {
|
||||
case "touch_mode": return o.touchMode != nil
|
||||
case "mouse_mode": return o.mouseMode != nil
|
||||
case "invert_scroll": return o.invertScroll != nil
|
||||
case "inhibit_shortcuts": return o.inhibitShortcuts != nil
|
||||
case "gamepad": return o.gamepadType != nil
|
||||
case "gamepad_forwarding": return o.gamepadForwarding != nil
|
||||
case "system_buttons": return o.systemButtons != nil
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
/// Pins the macOS ⌘-chord passthrough — the rule deciding which keyDowns `InputCapture`'s local
|
||||
/// monitor takes off AppKit and forwards to the host instead of letting a menu key equivalent
|
||||
/// claim them. Two things are worth a test rather than a comment:
|
||||
///
|
||||
/// * ⌘Q reaching the host at all. That is the whole point — it is the compositor chord on
|
||||
/// Hyprland/KDE/GNOME, and it used to quit the client.
|
||||
/// * ⌘⎋ and ⌃⌘F NOT reaching it, under every combination. They are the way out of a captured
|
||||
/// stream; forward either and the user is locked in.
|
||||
final class CommandChordTests: XCTestCase {
|
||||
// kVK_ANSI_* — physical positions, layout-independent (the same constants the monitor uses).
|
||||
private let q: UInt16 = 12, w: UInt16 = 13, h: UInt16 = 4, m: UInt16 = 46
|
||||
private let f: UInt16 = 3, esc: UInt16 = 53, leftArrow: UInt16 = 123
|
||||
|
||||
/// Captured, setting on, capture mouse model — the shipping default.
|
||||
private func forwards(
|
||||
_ keyCode: UInt16, _ flags: NSEvent.ModifierFlags,
|
||||
forwarding: Bool = true, inhibit: Bool = true, desktop: Bool = false
|
||||
) -> Bool {
|
||||
InputCapture.forwardsCommandChord(
|
||||
keyCode: keyCode, flags: flags, forwarding: forwarding,
|
||||
inhibitShortcuts: inhibit, desktopMouse: desktop)
|
||||
}
|
||||
|
||||
func testCommandChordsGoToTheHostWhileCaptured() {
|
||||
XCTAssertTrue(forwards(q, .command)) // ⌘Q — the reported break
|
||||
XCTAssertTrue(forwards(w, .command))
|
||||
XCTAssertTrue(forwards(h, .command))
|
||||
XCTAssertTrue(forwards(m, .command))
|
||||
XCTAssertTrue(forwards(q, [.command, .shift])) // ⇧⌘Q
|
||||
XCTAssertTrue(forwards(m, [.command, .control, .option, .shift]))
|
||||
}
|
||||
|
||||
func testTheEscapeHatchesAreNeverForwarded() {
|
||||
// ⌘⎋ releases capture, ⌃⌘F leaves fullscreen. Neither may ever reach the host.
|
||||
XCTAssertFalse(forwards(esc, .command))
|
||||
XCTAssertFalse(forwards(f, [.control, .command]))
|
||||
XCTAssertTrue(InputCapture.isClientReservedChord(keyCode: esc, flags: .command))
|
||||
XCTAssertTrue(
|
||||
InputCapture.isClientReservedChord(keyCode: f, flags: [.control, .command]))
|
||||
}
|
||||
|
||||
/// The reservation is exact: it is ⌘⎋ and ⌃⌘F specifically, not "anything with Esc or F in
|
||||
/// it". ⇧⌘⎋ and ⌘F are the host's like any other chord.
|
||||
func testNeighbouringChordsAreNotReserved() {
|
||||
XCTAssertTrue(forwards(esc, [.command, .shift]))
|
||||
XCTAssertTrue(forwards(f, .command))
|
||||
XCTAssertFalse(InputCapture.isClientReservedChord(keyCode: f, flags: .command))
|
||||
}
|
||||
|
||||
func testNothingWithoutCommandIsClaimedHere() {
|
||||
// The ⌃⌥⇧ family and bare keys reach the monitor's earlier blocks / the responder chain.
|
||||
XCTAssertFalse(forwards(q, [.control, .option, .shift]))
|
||||
XCTAssertFalse(forwards(q, []))
|
||||
XCTAssertFalse(forwards(esc, []))
|
||||
}
|
||||
|
||||
func testReleasedCaptureLeavesTheMenuAlone() {
|
||||
// Not forwarding = the user is in the local UI: ⌘Q must quit the app, ⌘W close the window.
|
||||
XCTAssertFalse(forwards(q, .command, forwarding: false))
|
||||
XCTAssertFalse(forwards(w, .command, forwarding: false))
|
||||
}
|
||||
|
||||
func testTheCrossClientSettingTurnsItOff() {
|
||||
XCTAssertFalse(forwards(q, .command, inhibit: false))
|
||||
}
|
||||
|
||||
func testTheDesktopMouseModelKeepsChordsLocal() {
|
||||
// Matches the SDL clients' keyboard grab: a remote desktop is something you ⌘Tab away from.
|
||||
XCTAssertFalse(forwards(q, .command, desktop: true))
|
||||
XCTAssertFalse(forwards(q, .command, inhibit: true, desktop: true))
|
||||
}
|
||||
|
||||
/// `deviceIndependentFlagsMask` also carries Caps Lock and the `.function`/`.numericPad` bits
|
||||
/// every arrow key sets, so comparing it for equality made chords stop being recognized in
|
||||
/// exactly the states a user does not connect to their keyboard: Caps Lock on, or the chord
|
||||
/// spelled with an arrow. `chordFlags` isolates the four real modifiers.
|
||||
func testCapsLockAndArrowBitsDoNotChangeAChord() throws {
|
||||
let capsQ = try XCTUnwrap(keyEvent(q, [.command, .capsLock]))
|
||||
XCTAssertEqual(InputCapture.chordFlags(capsQ), .command)
|
||||
XCTAssertTrue(forwards(q, InputCapture.chordFlags(capsQ)))
|
||||
|
||||
// ⌘⎋ with Caps Lock on is still the escape hatch, not a chord for the host.
|
||||
let capsEsc = try XCTUnwrap(keyEvent(esc, [.command, .capsLock]))
|
||||
XCTAssertEqual(InputCapture.chordFlags(capsEsc), .command)
|
||||
XCTAssertFalse(forwards(esc, InputCapture.chordFlags(capsEsc)))
|
||||
|
||||
// ⌘← — arrows set .function|.numericPad, which say nothing about the chord.
|
||||
let cmdLeft = try XCTUnwrap(keyEvent(leftArrow, [.command, .function, .numericPad]))
|
||||
XCTAssertEqual(InputCapture.chordFlags(cmdLeft), .command)
|
||||
XCTAssertTrue(forwards(leftArrow, InputCapture.chordFlags(cmdLeft)))
|
||||
}
|
||||
|
||||
/// A forwarded chord is only useful if the key has a host VK — the monitor swallows either
|
||||
/// way, so an unmapped one would silently do nothing. Spot-check the common ⌘ letters.
|
||||
func testTheCommonChordKeysMapToHostVKs() {
|
||||
XCTAssertEqual(InputCapture.keyCodeToVK[q], 0x51) // VK 'Q'
|
||||
XCTAssertEqual(InputCapture.keyCodeToVK[w], 0x57) // VK 'W'
|
||||
XCTAssertEqual(InputCapture.keyCodeToVK[h], 0x48) // VK 'H'
|
||||
XCTAssertEqual(InputCapture.keyCodeToVK[m], 0x4D) // VK 'M'
|
||||
XCTAssertEqual(InputCapture.keyCodeToVK[leftArrow], 0x25) // VK_LEFT
|
||||
}
|
||||
|
||||
private func keyEvent(_ keyCode: UInt16, _ flags: NSEvent.ModifierFlags) -> NSEvent? {
|
||||
NSEvent.keyEvent(
|
||||
with: .keyDown, location: .zero, modifierFlags: flags, timestamp: 0,
|
||||
windowNumber: 0, context: nil, characters: "", charactersIgnoringModifiers: "",
|
||||
isARepeat: false, keyCode: keyCode)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -45,7 +45,7 @@ BUNDLE_ID="io.unom.punktfunk"
|
||||
# The App Store set, in listing order — the first three are what most people ever see, so they are
|
||||
# the stream itself, the machines it found, and the couch/controller mode. Everything else in
|
||||
# ShotScenes.all is a dev scene; capture those with `SCENES="06-gamepad-home 10-edithost" ...`.
|
||||
SCENES=(${SCENES:-01-stream 02-hosts 06-gamepad-home 09e-waking-modal 05-settings 03-pair})
|
||||
SCENES=(${SCENES:-01-stream 02-hosts 11-library 12-controllers 06-gamepad-home 09e-waking-modal 05-settings 03-pair})
|
||||
SETTLE="${SETTLE:-4}" # seconds to let a scene lay out before capturing
|
||||
|
||||
mkdir -p "$OUT"
|
||||
@@ -63,9 +63,13 @@ require_xcode() {
|
||||
# ---------------------------------------------------------------------------- macOS
|
||||
|
||||
shoot_macos() {
|
||||
log "macOS — building (swift build -c release)…"
|
||||
swift build -c release >/dev/null
|
||||
local bin=".build/release/PunktfunkClient"
|
||||
# DEBUG build, deliberately: the whole shot harness lives behind `#if DEBUG`
|
||||
# (ScreenshotHost/ScreenshotScenes), so a release binary launches as the NORMAL app, never
|
||||
# prints PF_SHOT_WINDOW, and every scene "never reported a window". Debug renders the same
|
||||
# pixels — SwiftUI has no release-only visuals.
|
||||
log "macOS — building (swift build)…"
|
||||
swift build >/dev/null
|
||||
local bin=".build/debug/PunktfunkClient"
|
||||
[ -x "$bin" ] || die "build produced no $bin"
|
||||
|
||||
for scene in "${SCENES[@]}"; do
|
||||
@@ -142,6 +146,14 @@ shoot_sim() {
|
||||
# incremental build instead of cold-building into a throwaway tmpdir — CI pins this
|
||||
# (apple.yml); local runs keep the self-cleaning mktemp default.
|
||||
local dd; dd="${PF_SHOT_DERIVED_DATA:-$(mktemp -d)}"; mkdir -p "$dd"
|
||||
# tvOS-SIMULATOR trap (Xcode 26.6 and the 27 beta, local only so far): the build planner
|
||||
# schedules the SwiftPM MACRO plugin targets that swiftui-navigation-transitions pulls in
|
||||
# (OnceMacro/SwizzlingMacro/AssociationMacro) for the *tvOS* triple and never plans their
|
||||
# swift-syntax dependencies at all — "unable to resolve module dependency: 'SwiftSyntax'".
|
||||
# Device archives and iOS builds don't hit it (only the tvOS target links that package), and
|
||||
# prebuilt-vs-source swift-syntax makes no difference. Until Xcode fixes the planner, the
|
||||
# workaround is temporarily unlinking SwiftUINavigationTransitions from the tvOS target
|
||||
# (HomeView's use is canImport-guarded — the push transition degrades to the crossfade).
|
||||
xcodebuild -project Punktfunk.xcodeproj -scheme "$scheme" -configuration Debug \
|
||||
-sdk "$sdk" -destination "id=$udid" -derivedDataPath "$dd" \
|
||||
CODE_SIGNING_ALLOWED=NO build >/dev/null \
|
||||
|
||||
@@ -50,6 +50,7 @@ fn plan_for(req: &ConnectRequest, fp_hex: &str, tofu: bool, opts: &SpawnOpts) ->
|
||||
fp_hex: Some(fp_hex.to_string()),
|
||||
mac: req.mac.clone(),
|
||||
id: None,
|
||||
mgmt_port: None, // this shell resolves the library port itself (`mgmt_port_for`)
|
||||
},
|
||||
req.launch.as_ref().map(|(id, _)| id.clone()),
|
||||
// A plain card click carries no one-off: the resolver honors the host's own binding
|
||||
|
||||
@@ -12,7 +12,8 @@ x64 Windows runner — `x86_64-pc-windows-msvc` builds natively, `aarch64-pc-win
|
||||
cross-compiled (the x64 MSVC toolset ships the ARM64 cross compiler; since M10 nothing in the
|
||||
package links FFmpeg, so neither arch needs a per-arch `FFMPEG_DIR` tree staged on the runner —
|
||||
one less thing the ARM64 leg can be missing). Artifacts are arch-suffixed
|
||||
(`..._x64.msix` / `..._arm64.msix`, each with its matching `.cer`); `pack-msix.ps1 -Arch x64|arm64`
|
||||
(`..._x64.msix` / `..._arm64.msix`, plus a matching `.cer` only in the fallback signing modes 2 and 3
|
||||
— Azure signing emits none); `pack-msix.ps1 -Arch x64|arm64`
|
||||
stamps the manifest `ProcessorArchitecture` and names the output. See
|
||||
[`windows-client.yml`](../../../.gitea/workflows/windows-client.yml) for the cross-build rationale.
|
||||
|
||||
@@ -52,7 +53,8 @@ low-level input hooks, WASAPI and SDL3.
|
||||
MSIX requires a strictly 4-part numeric version. The workflow computes:
|
||||
- `vX.Y.Z` tag → `X.Y.Z.0` (THE release; any `-rc`/`+meta` suffix is dropped for MSIX). Published to
|
||||
the stable `latest/` alias and attached to the unified Gitea Release.
|
||||
- `main` push / `workflow_dispatch` → `0.3.<run_number>.0` (canary, climbs by run number; `canary/` alias).
|
||||
- `main` push / `workflow_dispatch` → `X.<Y+1>.<run_number>.0` (canary — the minor *after* the latest
|
||||
`v*` tag, per `scripts/ci/pf-version.ps1`, climbing by run number; `canary/` alias).
|
||||
|
||||
## Signing & install
|
||||
|
||||
|
||||
@@ -28,6 +28,11 @@ pub struct DiscoveredHost {
|
||||
/// `linux[/<family>][/<id>]`), sanitized — drives the host tile's OS mark and is
|
||||
/// persisted like `mac`. Empty if absent (older host).
|
||||
pub os: String,
|
||||
/// The management API's port from the mDNS `mgmt` TXT — where the game library is served.
|
||||
/// Persisted like `mac` (`trust::learn_mgmt_port`), and load-bearing rather than cosmetic:
|
||||
/// a host moved off 47990 loses its library once mDNS is gone unless we write this down.
|
||||
/// `None` if absent (older host) — resolve via `library::DEFAULT_MGMT_PORT`.
|
||||
pub mgmt_port: Option<u16>,
|
||||
}
|
||||
|
||||
/// Forces the running browse to re-query now — the hosts page's Refresh. Mirrors
|
||||
@@ -124,6 +129,7 @@ pub fn browse() -> (async_channel::Receiver<DiscoveredHost>, Rescan) {
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect(),
|
||||
os: pf_client_core::os::sanitize_os(&val("os")),
|
||||
mgmt_port: val("mgmt").parse().ok(),
|
||||
};
|
||||
if tx.send_blocking(host).is_err() {
|
||||
break; // UI gone — stop browsing
|
||||
|
||||
@@ -160,6 +160,7 @@ pub(crate) fn spawn_session(
|
||||
fp_hex: Some(fp_hex.to_string()),
|
||||
mac: Vec::new(), // wake ran before this spawn (initiate_waking) — not the plan's job
|
||||
id: None,
|
||||
mgmt_port: None, // the library fetch runs in the shell (`Target`), never off a spawn plan
|
||||
},
|
||||
launch.map(str::to_string),
|
||||
profile.map(str::to_string),
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
//! still load via a serde alias in core.
|
||||
|
||||
pub use pf_client_core::trust::{
|
||||
hex, learn_mac, learn_os, load_or_create_identity, pair_error_message, parse_hex32, KnownHost,
|
||||
KnownHosts, Settings,
|
||||
hex, learn_mac, learn_mgmt_port, learn_os, load_or_create_identity, pair_error_message,
|
||||
parse_hex32, KnownHost, KnownHosts, Settings,
|
||||
};
|
||||
|
||||
@@ -1450,16 +1450,21 @@ pub fn pipewire_thread(
|
||||
RGB CSC; PUNKTFUNK_PIPEWIRE_NV12=0 restores the packed-RGB negotiation)"
|
||||
);
|
||||
}
|
||||
// Modifiers our import stack handles for BGRx: the EGL-importable (tiled) set, plus LINEAR
|
||||
// (0) — NVIDIA's EGL won't list it, but LINEAR dmabufs (gamescope's only offer) import via
|
||||
// CUDA external memory instead. For the VAAPI passthrough path we advertise LINEAR only:
|
||||
// radeonsi/iHD import it and any compositor can allocate it.
|
||||
let mut modifiers = importer
|
||||
.as_mut()
|
||||
.map(|i| i.supported_modifiers(pf_frame::drm_fourcc(PixelFormat::Bgrx).unwrap()))
|
||||
.unwrap_or_default();
|
||||
if (importer.is_some() || vaapi_passthrough) && !modifiers.contains(&0) {
|
||||
modifiers.push(0); // DRM_FORMAT_MOD_LINEAR
|
||||
// Modifiers our import stack handles, enumerated PER FOURCC. `XR24` (BGRx) and `AR24` (BGRA)
|
||||
// are asked separately on purpose: EGL/libva answer per format, and nothing entitles us to
|
||||
// assume a driver that imports one imports the other. Keeping them apart is also what makes
|
||||
// the BGRA pod below correct on AMD and Intel rather than an NVIDIA-shaped guess — each list
|
||||
// is whatever THIS GPU's stack actually said.
|
||||
//
|
||||
// To each list we add LINEAR (0) — NVIDIA's EGL won't list it, but LINEAR dmabufs (gamescope's
|
||||
// only offer) import via CUDA external memory instead. For the VAAPI passthrough path there is
|
||||
// no importer at all, so the lists start empty and LINEAR is all we advertise: radeonsi/iHD
|
||||
// import it and any compositor can allocate it.
|
||||
let mut modifiers = Vec::new();
|
||||
let mut modifiers_bgra = Vec::new();
|
||||
if let Some(i) = importer.as_mut() {
|
||||
modifiers = i.supported_modifiers(pf_frame::drm_fourcc(PixelFormat::Bgrx).unwrap());
|
||||
modifiers_bgra = i.supported_modifiers(pf_frame::drm_fourcc(PixelFormat::Bgra).unwrap());
|
||||
}
|
||||
// PyroWave passthrough: the encoder imports through Vulkan, not libva — extend the
|
||||
// advertisement with every modifier its device samples from, so compositors that
|
||||
@@ -1468,12 +1473,20 @@ pub fn pipewire_thread(
|
||||
// the host's `pyrowave` feature is on AND the session (or the global encoder pref) is
|
||||
// PyroWave — so capture never calls back into `encode` and needs no feature gate of its
|
||||
// own (the emptiness check gates it).
|
||||
if vaapi_passthrough && !policy.pyrowave_modifiers.is_empty() {
|
||||
for &m in &policy.pyrowave_modifiers {
|
||||
if !modifiers.contains(&m) {
|
||||
modifiers.push(m);
|
||||
let extend_pyrowave = vaapi_passthrough && !policy.pyrowave_modifiers.is_empty();
|
||||
for list in [&mut modifiers, &mut modifiers_bgra] {
|
||||
if (importer.is_some() || vaapi_passthrough) && !list.contains(&0) {
|
||||
list.push(0); // DRM_FORMAT_MOD_LINEAR
|
||||
}
|
||||
if extend_pyrowave {
|
||||
for &m in &policy.pyrowave_modifiers {
|
||||
if !list.contains(&m) {
|
||||
list.push(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if extend_pyrowave {
|
||||
tracing::info!(
|
||||
count = modifiers.len(),
|
||||
"zero-copy: advertising the PyroWave device's Vulkan-importable dmabuf modifiers"
|
||||
@@ -1540,9 +1553,14 @@ pub fn pipewire_thread(
|
||||
);
|
||||
} else if want_dmabuf {
|
||||
tracing::info!(
|
||||
count = modifiers.len(),
|
||||
bgrx_count = modifiers.len(),
|
||||
bgra_count = modifiers_bgra.len(),
|
||||
// `sample` is TRUNCATED to 6, and LINEAR is pushed last — so reading the sample as the
|
||||
// whole list makes a perfectly good offer look tiled-only. That misreading cost a full
|
||||
// debugging session on 2026-08-14, hence stating the one bit that was actually wanted.
|
||||
linear_offered = modifiers.contains(&0),
|
||||
sample = ?&modifiers[..modifiers.len().min(6)],
|
||||
"zero-copy: advertising EGL-importable dmabuf modifiers"
|
||||
"zero-copy: advertising EGL-importable dmabuf modifiers (BGRx + BGRA pods)"
|
||||
);
|
||||
} else if consumer.cpu_is_downgrade() {
|
||||
// Reached only when no dmabuf is advertised at all (every arm above rules out a
|
||||
@@ -2094,17 +2112,39 @@ pub fn pipewire_thread(
|
||||
.map(|fmt| build_hdr_dmabuf_format(*fmt, preferred))
|
||||
.collect::<Result<Vec<_>>>()?
|
||||
} else if want_dmabuf {
|
||||
let mut pods = Vec::with_capacity(if prefer_native_nv12 { 2 } else { 1 });
|
||||
let mut pods = Vec::with_capacity(if prefer_native_nv12 { 3 } else { 2 });
|
||||
if prefer_native_nv12 {
|
||||
// First compatible consumer pod wins. Gamescope advertises NV12 and BGRx; pinning
|
||||
// BT.709 limited here selects its RGB→NV12 shader with our bitstream colorimetry.
|
||||
pods.push(build_dmabuf_format(VideoFormat::NV12, &[0], preferred)?);
|
||||
}
|
||||
pods.push(build_dmabuf_format(
|
||||
VideoFormat::BGRx,
|
||||
&modifiers,
|
||||
preferred,
|
||||
)?);
|
||||
if !modifiers.is_empty() {
|
||||
pods.push(build_dmabuf_format(
|
||||
VideoFormat::BGRx,
|
||||
&modifiers,
|
||||
preferred,
|
||||
)?);
|
||||
}
|
||||
// xdph (Hyprland/sway) offers ONLY **BGRA** on its dmabuf EnumFormat — it lists BGRA *and*
|
||||
// BGRx on the SHM pod, so a BGRx-only dmabuf offer intersects with nothing and PipeWire
|
||||
// fails the link outright:
|
||||
// pw.link: negotiating -> error no more input formats (-22)
|
||||
// Measured 2026-08-14 on Hyprland 0.55.4 + xdph 1.3.12: the 12 tiled modifiers matched on
|
||||
// both sides perfectly — only the fourcc never did, which is why the failure reads like a
|
||||
// GPU/modifier problem and is not one.
|
||||
//
|
||||
// BGRA and BGRx are the same 32-bit layout; the alpha byte is ignored the whole way to the
|
||||
// encoder (`vk_util` maps both to `B8G8R8A8_UNORM`, VAAPI both to `Pixel::BGRA`), and the
|
||||
// dmabuf import is driven by the NEGOTIATED format's fourcc, so an AR24 frame imports as
|
||||
// AR24. Listed AFTER BGRx so a producer offering both still lands on the pre-existing path
|
||||
// — first compatible consumer pod wins, so this is purely additive.
|
||||
if !modifiers_bgra.is_empty() {
|
||||
pods.push(build_dmabuf_format(
|
||||
VideoFormat::BGRA,
|
||||
&modifiers_bgra,
|
||||
preferred,
|
||||
)?);
|
||||
}
|
||||
pods
|
||||
} else {
|
||||
vec![serialize_pod(obj)?]
|
||||
|
||||
@@ -38,6 +38,12 @@ pub struct HostTarget {
|
||||
pub fp_hex: Option<String>,
|
||||
pub mac: Vec<String>,
|
||||
pub id: Option<String>,
|
||||
/// The host's management-API port (saved store or live advert) — where the library is
|
||||
/// served, distinct from `port` (the native QUIC plane). Carried on the target for the same
|
||||
/// reason as `mac`: a front-end holding a plan has no `KnownHost` in hand, and resolving to
|
||||
/// [`crate::library::DEFAULT_MGMT_PORT`] there is what made a moved mgmt port work on the
|
||||
/// LAN but not over a VPN. `None` = unknown, fall back to the constant.
|
||||
pub mgmt_port: Option<u16>,
|
||||
}
|
||||
|
||||
impl From<&KnownHost> for HostTarget {
|
||||
@@ -49,6 +55,7 @@ impl From<&KnownHost> for HostTarget {
|
||||
fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()),
|
||||
mac: h.mac.clone(),
|
||||
id: h.id.clone(),
|
||||
mgmt_port: h.mgmt_port,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//! The compositor output absolute coordinates belong to, by NAME — the Linux counterpart of the
|
||||
//! Windows `stream_target` slot, and what the wlroots virtual-pointer backend aims at.
|
||||
//!
|
||||
//! `MouseMoveAbs` carries its own reference extent (`w`/`h` — the client's letterboxed video rect
|
||||
//! in ITS window, not the streamed mode), and the wlr protocol normalizes `x`/`y` against it and
|
||||
//! maps the result onto whichever `wl_output` the virtual pointer was **created with**. So the
|
||||
//! extent takes care of itself and the OUTPUT is the whole question. The injector used to pass the
|
||||
//! first `wl_output` the registry advertised, which is the oldest global — on any multi-head box
|
||||
//! the operator's physical head, never the per-session headless output the client is looking at.
|
||||
//! On the EXTEND backends (Hyprland, wlroots/sway) the streamed head sits *beside* the operator's,
|
||||
//! so absolute samples landed on a screen no session was streaming. Reported from the field as
|
||||
//! "no cursor was visible in the session", and later as a cursor pinned near the left edge that
|
||||
//! vanished part-way across.
|
||||
//!
|
||||
//! The host publishes the streamed output's compositor name at capture bring-up
|
||||
//! ([`set_stream_output`]) — Hyprland's `PF-<pid>-<n>`, sway's `HEADLESS-N`, or a mirrored head's
|
||||
//! connector — and the wlr backend re-creates its virtual pointer bound to the matching `wl_output`
|
||||
//! (`wl_output.name`, protocol v4; the name is explicitly "the same for all clients", so the name
|
||||
//! `hyprctl`/`swaymsg` minted is the name we can match here).
|
||||
//!
|
||||
//! **One slot per process**, exactly like the Windows original: the injector is host-lifetime and
|
||||
//! every concurrent session's input flows through it, so with parallel sessions the LAST capture
|
||||
//! bring-up wins for every session's absolute input. Per-session routing needs source-tagged input
|
||||
//! events (the injector has to become session-aware first — see [`crate::set_absolute_anchor`]'s
|
||||
//! note), and the single slot is never worse than what it replaces: today EVERY session's absolute
|
||||
//! input lands on a head that no session is streaming.
|
||||
//!
|
||||
//! With nothing published — before the first bring-up, or on a compositor whose `wl_output` is
|
||||
//! older than v4 and therefore nameless — the pointer is bound to NO output, which maps absolute
|
||||
//! coordinates over the whole layout. On a single-output compositor that is identical to binding
|
||||
//! that output; on a multi-head one it is at least *reachable*, unlike a pin to the wrong head.
|
||||
|
||||
use std::sync::RwLock;
|
||||
|
||||
/// The streamed output's compositor name, or `None` when nothing has been published yet.
|
||||
static STREAM_OUTPUT: RwLock<Option<String>> = RwLock::new(None);
|
||||
|
||||
/// Publish the compositor output (by name) that absolute input maps into. The host calls this at
|
||||
/// capture bring-up, and ONLY there: nothing clears it at teardown, because an output that goes
|
||||
/// away simply stops resolving (the backend falls back to whole-layout mapping, and between
|
||||
/// sessions nothing injects anyway). A later bring-up is what rewrites it — including to `None`,
|
||||
/// which a backend that needs no named binding passes so a stale name cannot outlive its
|
||||
/// compositor. See the module doc for the one-slot-per-process trade with parallel sessions.
|
||||
pub fn set_stream_output(name: Option<String>) {
|
||||
let mut cur = STREAM_OUTPUT.write().unwrap_or_else(|e| e.into_inner());
|
||||
if *cur != name {
|
||||
tracing::info!(output = ?name, "absolute-input stream output set");
|
||||
*cur = name;
|
||||
}
|
||||
}
|
||||
|
||||
/// The streamed output's compositor name, if one has been published.
|
||||
pub fn stream_output() -> Option<String> {
|
||||
STREAM_OUTPUT
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// ONE test on purpose, like the libei anchor's: the slot is process-wide and cargo runs
|
||||
/// tests on threads in one process, so splitting this into several would let them race.
|
||||
#[test]
|
||||
fn publishes_clears_and_round_trips() {
|
||||
set_stream_output(Some("PF-1643-1".into()));
|
||||
assert_eq!(stream_output().as_deref(), Some("PF-1643-1"));
|
||||
// Re-publishing the same name is a no-op, not a second "set" (the backend keys its
|
||||
// pointer re-creation off the resolved name, but the log line should not repeat).
|
||||
set_stream_output(Some("PF-1643-1".into()));
|
||||
assert_eq!(stream_output().as_deref(), Some("PF-1643-1"));
|
||||
set_stream_output(Some("HEADLESS-2".into()));
|
||||
assert_eq!(stream_output().as_deref(), Some("HEADLESS-2"));
|
||||
set_stream_output(None);
|
||||
assert_eq!(stream_output(), None);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,11 @@
|
||||
//! virtual keyboard (the host's layout via the standard `XKB_DEFAULT_LAYOUT` et al., defaulting
|
||||
//! to evdev/US), and translate events into virtual pointer/keyboard requests, tracking modifier
|
||||
//! state so the compositor resolves shifted keysyms correctly.
|
||||
//!
|
||||
//! **Absolute** motion is mapped by the compositor onto the `wl_output` the virtual pointer was
|
||||
//! CREATED with, so which output that is decides where every absolute sample lands. We aim it at
|
||||
//! the head the session is actually streaming — published by name in [`crate::stream_output`] and
|
||||
//! re-resolved (re-creating the pointer) whenever it changes; see [`WlrootsInjector::retarget`].
|
||||
|
||||
use super::{gs_button_to_evdev, vk_to_evdev, InputEvent, InputInjector};
|
||||
use anyhow::{bail, Context, Result};
|
||||
@@ -12,7 +17,12 @@ use punktfunk_core::input::InputKind;
|
||||
use std::io::Write;
|
||||
use std::os::fd::{AsFd, FromRawFd};
|
||||
use std::time::Instant;
|
||||
use wayland_client::protocol::{wl_output::WlOutput, wl_pointer, wl_registry, wl_seat::WlSeat};
|
||||
use wayland_client::backend::WaylandError;
|
||||
use wayland_client::protocol::{
|
||||
wl_output::{self, WlOutput},
|
||||
wl_pointer, wl_registry,
|
||||
wl_seat::WlSeat,
|
||||
};
|
||||
use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle};
|
||||
use wayland_protocols_misc::zwp_virtual_keyboard_v1::client::{
|
||||
zwp_virtual_keyboard_manager_v1::ZwpVirtualKeyboardManagerV1,
|
||||
@@ -27,13 +37,65 @@ use xkbcommon::xkb;
|
||||
/// `code` value marking a horizontal scroll event (mirrors `gamestream::input`).
|
||||
const SCROLL_HORIZONTAL: u32 = 1;
|
||||
|
||||
/// `wl_output.name` — the connector name we match the streamed head on — arrived in v4. Nothing
|
||||
/// else we ask of an output needs more than v1, so a lower advert only costs us the names (and
|
||||
/// with them the ability to aim absolute input; see [`index_named`]). Same constant, same reason,
|
||||
/// as `pf_vdisplay`'s `kwin_dpms`.
|
||||
const WL_OUTPUT_MAX: u32 = 4;
|
||||
|
||||
/// One `wl_output` the compositor has advertised.
|
||||
struct Output {
|
||||
/// The registry global name — the key `wl_registry.global_remove` reports, and the user data
|
||||
/// each `wl_output` event carries back so we know which head it describes.
|
||||
global: u32,
|
||||
proxy: WlOutput,
|
||||
/// `wl_output.name` (protocol v4): the compositor's own name for the head — `HDMI-A-1`,
|
||||
/// Hyprland's `PF-<pid>-<n>`, sway's `HEADLESS-N`. The protocol guarantees this is "the same
|
||||
/// output name for all clients", which is what lets us match the name `hyprctl`/`swaymsg`
|
||||
/// minted on the vdisplay side. `None` on a compositor stuck at v3, which has no name event at
|
||||
/// all — then there is nothing to match on and the pointer stays unbound.
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
/// Globals bound from the registry (the Wayland dispatch state).
|
||||
#[derive(Default)]
|
||||
struct Globals {
|
||||
pointer_mgr: Option<ZwlrVirtualPointerManagerV1>,
|
||||
keyboard_mgr: Option<ZwpVirtualKeyboardManagerV1>,
|
||||
seat: Option<WlSeat>,
|
||||
output: Option<WlOutput>,
|
||||
/// EVERY advertised output, in advertisement order — not just the first. The streamed head is
|
||||
/// created per session, so it is never the first one advertised (that is the operator's
|
||||
/// oldest physical head), and binding only the first is what aimed absolute input at the
|
||||
/// wrong screen on every EXTEND box.
|
||||
outputs: Vec<Output>,
|
||||
}
|
||||
|
||||
/// Which advertised output — by position in `names`, which is advertisement order — the virtual
|
||||
/// pointer should bind to for the published target `want`.
|
||||
///
|
||||
/// The rule has **no fallback on purpose**, and that absence is the fix: what this replaced was a
|
||||
/// fallback ("bind whatever `wl_output` came first"), and the first-advertised output is the oldest
|
||||
/// global, i.e. the operator's physical head — never the per-session headless one the client is
|
||||
/// looking at. A target that matches nothing therefore yields `None`, which binds the pointer to no
|
||||
/// output and maps absolute coordinates over the whole layout: wrong-ish, but reachable, where a
|
||||
/// pin to the wrong head is unreachable.
|
||||
///
|
||||
/// Split out of [`Globals::output_named`] so the rule is testable — a `WlOutput` proxy cannot be
|
||||
/// constructed without a live Wayland connection, but the decision it feeds can.
|
||||
fn index_named<'a>(
|
||||
names: impl IntoIterator<Item = Option<&'a str>>,
|
||||
want: Option<&str>,
|
||||
) -> Option<usize> {
|
||||
let want = want?;
|
||||
names.into_iter().position(|n| n == Some(want))
|
||||
}
|
||||
|
||||
impl Globals {
|
||||
/// The `wl_output` whose compositor name is `want`, if it is currently advertised.
|
||||
fn output_named(&self, want: &str) -> Option<WlOutput> {
|
||||
index_named(self.outputs.iter().map(|o| o.name.as_deref()), Some(want))
|
||||
.map(|i| self.outputs[i].proxy.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<wl_registry::WlRegistry, ()> for Globals {
|
||||
@@ -45,13 +107,12 @@ impl Dispatch<wl_registry::WlRegistry, ()> for Globals {
|
||||
_: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_registry::Event::Global {
|
||||
name,
|
||||
interface,
|
||||
version,
|
||||
} = event
|
||||
{
|
||||
match interface.as_str() {
|
||||
match event {
|
||||
wl_registry::Event::Global {
|
||||
name,
|
||||
interface,
|
||||
version,
|
||||
} => match interface.as_str() {
|
||||
"zwlr_virtual_pointer_manager_v1" => {
|
||||
state.pointer_mgr = Some(registry.bind(name, version.min(2), qh, ()));
|
||||
}
|
||||
@@ -61,16 +122,52 @@ impl Dispatch<wl_registry::WlRegistry, ()> for Globals {
|
||||
"wl_seat" => {
|
||||
state.seat = Some(registry.bind(name, version.min(7), qh, ()));
|
||||
}
|
||||
"wl_output" if state.output.is_none() => {
|
||||
state.output = Some(registry.bind(name, version.min(3), qh, ()));
|
||||
"wl_output" => {
|
||||
// The `name` event is the only thing that tells the streamed head from the
|
||||
// operator's. Older compositors bind lower and stay nameless (harmless:
|
||||
// `output_named` then matches nothing and the pointer maps over the layout).
|
||||
// The registry global name rides along as user data so the events that follow
|
||||
// land on the right entry.
|
||||
let proxy = registry.bind(name, version.min(WL_OUTPUT_MAX), qh, name);
|
||||
state.outputs.push(Output {
|
||||
global: name,
|
||||
proxy,
|
||||
name: None,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
// A head went away — a session's headless output being torn down is the common case,
|
||||
// and the pointer must stop being aimed at a dead object (`retarget` re-resolves and
|
||||
// falls back to the whole layout on the next absolute sample).
|
||||
wl_registry::Event::GlobalRemove { name } => {
|
||||
state.outputs.retain(|o| o.global != name);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<WlOutput, u32> for Globals {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &WlOutput,
|
||||
event: wl_output::Event,
|
||||
global: &u32,
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
// Only the name matters here: geometry/mode/scale are the compositor's problem, because
|
||||
// binding the pointer to an output makes IT do the mapping (see `retarget`).
|
||||
if let wl_output::Event::Name { name } = event {
|
||||
if let Some(o) = state.outputs.iter_mut().find(|o| o.global == *global) {
|
||||
o.name = Some(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The managers, the two virtual devices, the seat and the output emit no events we use.
|
||||
// The managers, the two virtual devices and the seat emit no events we use.
|
||||
macro_rules! ignore_events {
|
||||
($($t:ty),* $(,)?) => {$(
|
||||
impl Dispatch<$t, ()> for Globals {
|
||||
@@ -80,7 +177,6 @@ macro_rules! ignore_events {
|
||||
}
|
||||
ignore_events!(
|
||||
WlSeat,
|
||||
WlOutput,
|
||||
ZwlrVirtualPointerManagerV1,
|
||||
ZwlrVirtualPointerV1,
|
||||
ZwpVirtualKeyboardManagerV1,
|
||||
@@ -92,6 +188,14 @@ pub struct WlrootsInjector {
|
||||
queue: EventQueue<Globals>,
|
||||
globals: Globals,
|
||||
pointer: ZwlrVirtualPointerV1,
|
||||
/// The compositor name of the output `pointer` is bound to, or `None` when it is bound to no
|
||||
/// output (absolute coordinates then span the whole layout). Compared against
|
||||
/// [`crate::stream_output`] on every absolute sample; a difference re-creates the pointer.
|
||||
bound_output: Option<String>,
|
||||
/// evdev codes of the mouse buttons currently held on `pointer`, so re-creating the device
|
||||
/// can release them first — the compositor has no reason to, and a virtual pointer destroyed
|
||||
/// mid-press leaves the host with a stuck mouse button.
|
||||
pressed: Vec<u32>,
|
||||
keyboard: ZwpVirtualKeyboardV1,
|
||||
xkb_state: xkb::State,
|
||||
_keymap_file: std::fs::File, // keep the memfd alive for the compositor's mmap
|
||||
@@ -100,6 +204,25 @@ pub struct WlrootsInjector {
|
||||
start: Instant,
|
||||
}
|
||||
|
||||
/// Resolve the published stream output ([`crate::stream_output`]) against the outputs this
|
||||
/// connection has bound: `(proxy, name)` when the target is live, `(None, None)` otherwise.
|
||||
///
|
||||
/// `(None, None)` covers three cases that all want the same answer — nothing published yet (before
|
||||
/// the first capture bring-up), the target's `wl_output` global not advertised yet (the injector
|
||||
/// opens on the first input event, which can beat the session's display), and the target torn down
|
||||
/// (session end). A pointer bound to no output maps absolute coordinates over the whole layout,
|
||||
/// which on a single-output compositor is exactly that output and on a multi-head one at least
|
||||
/// keeps the streamed head reachable — unlike a pin to a head nobody is streaming.
|
||||
fn resolve_target(globals: &Globals) -> (Option<WlOutput>, Option<String>) {
|
||||
let Some(want) = crate::stream_output() else {
|
||||
return (None, None);
|
||||
};
|
||||
match globals.output_named(&want) {
|
||||
Some(proxy) => (Some(proxy), Some(want)),
|
||||
None => (None, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cap on distinct characters the dynamic text keymap holds before it restarts from scratch
|
||||
/// (keycodes grow upward from 9; xkb tops out at 255, so stay well under).
|
||||
const TEXT_KEYMAP_MAX: usize = 200;
|
||||
@@ -140,12 +263,16 @@ impl WlrootsInjector {
|
||||
.clone()
|
||||
.context("compositor advertised no wl_seat")?;
|
||||
|
||||
let pointer = pointer_mgr.create_virtual_pointer_with_output(
|
||||
Some(&seat),
|
||||
globals.output.as_ref(),
|
||||
&qh,
|
||||
(),
|
||||
);
|
||||
// A second roundtrip: the first only said WHICH globals exist. The `wl_output.name` events
|
||||
// that identify each head are emitted on the objects we bound *during* that roundtrip, so
|
||||
// they only land now — and the pointer's output has to be resolved before we create it.
|
||||
queue
|
||||
.roundtrip(&mut globals)
|
||||
.context("Wayland output-name roundtrip")?;
|
||||
|
||||
let (target, bound_output) = resolve_target(&globals);
|
||||
let pointer =
|
||||
pointer_mgr.create_virtual_pointer_with_output(Some(&seat), target.as_ref(), &qh, ());
|
||||
let keyboard = keyboard_mgr.create_virtual_keyboard(&seat, &qh, ());
|
||||
|
||||
// The keymap the compositor resolves our raw evdev keycodes with. Empty names defer to
|
||||
@@ -174,7 +301,9 @@ impl WlrootsInjector {
|
||||
conn.flush().ok();
|
||||
|
||||
tracing::info!(
|
||||
output = globals.output.is_some(),
|
||||
outputs = globals.outputs.len(),
|
||||
want = ?crate::stream_output(),
|
||||
bound = ?bound_output,
|
||||
"wlroots virtual input ready (pointer + keyboard)"
|
||||
);
|
||||
Ok(Self {
|
||||
@@ -182,6 +311,8 @@ impl WlrootsInjector {
|
||||
queue,
|
||||
globals,
|
||||
pointer,
|
||||
bound_output,
|
||||
pressed: Vec::new(),
|
||||
keyboard,
|
||||
xkb_state,
|
||||
_keymap_file: file,
|
||||
@@ -190,6 +321,90 @@ impl WlrootsInjector {
|
||||
})
|
||||
}
|
||||
|
||||
/// Aim the virtual pointer at the output the session is streaming, re-creating it when that
|
||||
/// changes — the fix for absolute input landing on the operator's screen.
|
||||
///
|
||||
/// The wlr protocol maps `motion_absolute` onto the output the pointer was **created with**
|
||||
/// and offers no way to re-aim one, so a change means destroy + create. Cheap and rare: the
|
||||
/// host publishes the target once per capture bring-up, so a re-create fires at most a couple
|
||||
/// of times per session. The no-change path — every other absolute sample — costs one `RwLock`
|
||||
/// read and a scan of the output list, which has one entry per head.
|
||||
///
|
||||
/// Called from the `MouseMoveAbs` arm immediately BEFORE the motion is sent, so a re-created
|
||||
/// pointer gets its first position in the same batch rather than sitting wherever the
|
||||
/// compositor puts a brand-new device.
|
||||
///
|
||||
/// Resolution is by NAME, never by size: `MouseMoveAbs`'s extent is the client's letterboxed
|
||||
/// content rect in ITS window, not the streamed mode, so no size ladder could identify the
|
||||
/// head. Falling back to no output at all (whole-layout mapping) when the target is unknown is
|
||||
/// deliberate — see [`crate::stream_output`]'s module doc.
|
||||
fn retarget(&mut self) {
|
||||
let (target, want) = resolve_target(&self.globals);
|
||||
if want == self.bound_output {
|
||||
return;
|
||||
}
|
||||
let (Some(mgr), Some(seat)) = (self.globals.pointer_mgr.clone(), self.globals.seat.clone())
|
||||
else {
|
||||
return; // cannot re-create without the manager/seat; keep the pointer we have
|
||||
};
|
||||
// Never destroy a device with a button held: nothing else will release it.
|
||||
if !self.pressed.is_empty() {
|
||||
let t = self.now_ms();
|
||||
for btn in std::mem::take(&mut self.pressed) {
|
||||
self.pointer
|
||||
.button(t, btn, wl_pointer::ButtonState::Released);
|
||||
}
|
||||
self.pointer.frame();
|
||||
}
|
||||
self.pointer.destroy();
|
||||
self.pointer = mgr.create_virtual_pointer_with_output(
|
||||
Some(&seat),
|
||||
target.as_ref(),
|
||||
&self.queue.handle(),
|
||||
(),
|
||||
);
|
||||
tracing::info!(
|
||||
from = ?self.bound_output,
|
||||
to = ?want,
|
||||
"wlroots virtual pointer re-aimed (absolute input now maps into this output)"
|
||||
);
|
||||
self.bound_output = want;
|
||||
}
|
||||
|
||||
/// Drain the compositor's half of the connection, then push our batch to it — run after every
|
||||
/// injected event.
|
||||
///
|
||||
/// The **read** is the load-bearing half, and it used to be missing: `dispatch_pending`'s own
|
||||
/// documentation says it "will not perform reads on the Wayland socket", so the queue only
|
||||
/// ever held what [`Self::open`]'s roundtrips put there. Two consequences, both real. The
|
||||
/// injector could never learn about a `wl_output` created AFTER it opened — which is exactly
|
||||
/// the ordering the field report was captured in, and would have left [`Self::retarget`] with
|
||||
/// nothing to resolve. And everything the compositor sent us piled up unread in the socket
|
||||
/// buffer for the host's lifetime, including the protocol errors the code here claimed to be
|
||||
/// surfacing but structurally could not.
|
||||
///
|
||||
/// Non-blocking by construction: `read()` is documented to answer `WouldBlock` when the socket
|
||||
/// has nothing for us, which is the common case at input rates and is not an error.
|
||||
fn pump(&mut self) -> Result<()> {
|
||||
// `prepare_read` will not hand out a guard while events are still queued, so dispatch first.
|
||||
self.queue
|
||||
.dispatch_pending(&mut self.globals)
|
||||
.context("wayland dispatch")?;
|
||||
if let Some(guard) = self.conn.prepare_read() {
|
||||
match guard.read() {
|
||||
Ok(_) => {
|
||||
self.queue
|
||||
.dispatch_pending(&mut self.globals)
|
||||
.context("wayland dispatch (post-read)")?;
|
||||
}
|
||||
Err(WaylandError::Io(e)) if e.kind() == std::io::ErrorKind::WouldBlock => {}
|
||||
Err(e) => return Err(e).context("wayland read"),
|
||||
}
|
||||
}
|
||||
self.conn.flush().context("wayland flush")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn now_ms(&self) -> u32 {
|
||||
self.start.elapsed().as_millis() as u32
|
||||
}
|
||||
@@ -271,6 +486,12 @@ impl InputInjector for WlrootsInjector {
|
||||
let w = (event.flags >> 16) & 0xffff;
|
||||
let h = event.flags & 0xffff;
|
||||
if w > 0 && h > 0 {
|
||||
// The compositor maps these onto the pointer's bound output, so make sure that
|
||||
// is the head this session streams before sending any. Checked here rather
|
||||
// than per inject: only absolute motion depends on the binding, and a pointer
|
||||
// swapped mid-drag is the one thing `retarget` has to work to be safe about.
|
||||
self.retarget();
|
||||
let t = self.now_ms(); // `retarget` may have consumed time releasing buttons
|
||||
let x = event.x.clamp(0, w as i32) as u32;
|
||||
let y = event.y.clamp(0, h as i32) as u32;
|
||||
self.pointer.motion_absolute(t, x, y, w, h);
|
||||
@@ -280,8 +501,12 @@ impl InputInjector for WlrootsInjector {
|
||||
InputKind::MouseButtonDown | InputKind::MouseButtonUp => {
|
||||
if let Some(btn) = gs_button_to_evdev(event.code) {
|
||||
let st = if event.kind == InputKind::MouseButtonDown {
|
||||
if !self.pressed.contains(&btn) {
|
||||
self.pressed.push(btn);
|
||||
}
|
||||
wl_pointer::ButtonState::Pressed
|
||||
} else {
|
||||
self.pressed.retain(|&b| b != btn);
|
||||
wl_pointer::ButtonState::Released
|
||||
};
|
||||
self.pointer.button(t, btn, st);
|
||||
@@ -328,12 +553,7 @@ impl InputInjector for WlrootsInjector {
|
||||
// wlroots has no virtual-touch protocol wired here; touch is the libei path only.
|
||||
InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp => {}
|
||||
}
|
||||
// Surface protocol errors / disconnects, then push the batch to the compositor.
|
||||
self.queue
|
||||
.dispatch_pending(&mut self.globals)
|
||||
.context("wayland dispatch")?;
|
||||
self.conn.flush().context("wayland flush")?;
|
||||
Ok(())
|
||||
self.pump()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,3 +603,40 @@ fn memfd_with(s: &str) -> Result<std::fs::File> {
|
||||
f.write_all(&[0]).context("write keymap NUL")?;
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The live-box layout the field report came from: the operator's `HDMI-A-1` is advertised
|
||||
/// FIRST (it exists from compositor start), and the session's headless head is added later —
|
||||
/// so "first advertised" is always the wrong answer, whichever order the injector and the
|
||||
/// display happen to come up in.
|
||||
const HYPRLAND_BOX: [Option<&str>; 2] = [Some("HDMI-A-1"), Some("PF-87756-3")];
|
||||
|
||||
#[test]
|
||||
fn binds_the_streamed_head_not_the_first_advertised_one() {
|
||||
assert_eq!(index_named(HYPRLAND_BOX, Some("PF-87756-3")), Some(1));
|
||||
assert_eq!(index_named(HYPRLAND_BOX, Some("HDMI-A-1")), Some(0));
|
||||
// sway's own naming, and a mirrored physical head, resolve the same way.
|
||||
let sway = [Some("HEADLESS-1"), Some("DP-2"), Some("HEADLESS-2")];
|
||||
assert_eq!(index_named(sway, Some("HEADLESS-2")), Some(2));
|
||||
assert_eq!(index_named(sway, Some("DP-2")), Some(1));
|
||||
}
|
||||
|
||||
/// Every "we don't know" must land on NO output (whole-layout mapping), never on a guess —
|
||||
/// the regression this whole change exists to prevent.
|
||||
#[test]
|
||||
fn an_unknown_target_binds_nothing_rather_than_falling_back() {
|
||||
// Published but not advertised (yet, or any more — the injector opens on the first input
|
||||
// event, which can beat the display, and the head goes away at session end).
|
||||
assert_eq!(index_named(HYPRLAND_BOX, Some("PF-87756-9")), None);
|
||||
// Nothing published at all — before the first capture bring-up.
|
||||
assert_eq!(index_named(HYPRLAND_BOX, None), None);
|
||||
// A compositor older than wl_output v4 emits no `name` event, so nothing is matchable.
|
||||
assert_eq!(index_named([None, None], Some("PF-87756-3")), None);
|
||||
// …and a compositor advertising no outputs at all cannot resolve anything either.
|
||||
let headless: [Option<&str>; 0] = [];
|
||||
assert_eq!(index_named(headless, Some("PF-87756-3")), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +149,15 @@ static ABSOLUTE_ANCHOR: std::sync::RwLock<Option<AbsoluteAnchor>> = std::sync::R
|
||||
/// record in `design/per-monitor-portal-capture.md` §5.3) and wrong for anything per-client. A
|
||||
/// per-session anchor needs the injector to become session-aware first; don't call this from a
|
||||
/// session path until it is.
|
||||
///
|
||||
/// The wlroots backend does **not** consult this — it aims at a named output via
|
||||
/// `stream_output::set_stream_output` (Linux), which the host DOES publish per session and which
|
||||
/// therefore takes exactly the last-bring-up-wins trade this warning describes: on purpose, and
|
||||
/// stated in the open in that module's doc, matching the Windows `stream_target` slot that already
|
||||
/// made the same call. The two are separate slots because they answer different questions and are
|
||||
/// written by different owners: this anchor is the operator's host-wide capture pin, recomputed
|
||||
/// from policy whenever the console writes it — which would wipe a per-session value written here —
|
||||
/// while the stream output is whatever head the session's capture actually attached to.
|
||||
pub fn set_absolute_anchor(anchor: Option<AbsoluteAnchor>) {
|
||||
let anchor = anchor.filter(|a| !a.is_empty());
|
||||
tracing::debug!(?anchor, "input: absolute-coordinate anchor set");
|
||||
@@ -529,6 +538,14 @@ pub mod pen;
|
||||
pub mod stream_target;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use stream_target::set_stream_target;
|
||||
/// Linux: the streamed compositor output (by name) that absolute coordinates map into — the
|
||||
/// counterpart of the Windows `stream_target` module, published by the host at capture bring-up and
|
||||
/// consumed by the wlroots virtual-pointer backend, which binds its pointer to that `wl_output`.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/stream_output.rs"]
|
||||
pub mod stream_output;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use stream_output::{set_stream_output, stream_output};
|
||||
/// Stub — pen injection needs the Linux uinput tablet or Windows synthetic pointers;
|
||||
/// `pen_supported()` is false here, so no host advertises the cap and no batches arrive.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
|
||||
@@ -79,6 +79,10 @@ pub(crate) fn emit_display_event(ev: DisplayEvent) {
|
||||
#[path = "vdisplay/backend.rs"]
|
||||
pub(crate) mod backend;
|
||||
pub use backend::{DisplayOwnership, VirtualDisplay, VirtualOutput};
|
||||
/// The NEGOTIATED ScreenCast cursor mode of a portal-backed output, reported per session by
|
||||
/// [`VirtualDisplay::last_portal_cursor_mode`]. (The module itself stays private — the ladder that
|
||||
/// picks the mode is this crate's business; the verdict is the caller's.)
|
||||
pub use portal_cursor::Mode as PortalCursorMode;
|
||||
|
||||
/// Time-bounded child-process helpers — every compositor query shells out, and an unbounded one
|
||||
/// can wedge the calling (session) thread forever.
|
||||
@@ -833,6 +837,21 @@ mod portal_config;
|
||||
#[path = "vdisplay/linux/portal_cursor.rs"]
|
||||
mod portal_cursor;
|
||||
|
||||
/// The line fed to xdph's custom picker to select an output headlessly.
|
||||
///
|
||||
/// Declared unconditionally for the same reason again: it is a wire format with no schema and no
|
||||
/// error report, so the transcribed-parser tests are the only place a malformed line is visible
|
||||
/// without a compositor. That is not hypothetical — a missing separator shipped, and the one
|
||||
/// assertion that existed for it passed throughout.
|
||||
#[path = "vdisplay/linux/portal_picker.rs"]
|
||||
mod portal_picker;
|
||||
|
||||
/// The single, never-dropped tokio runtime the portal handshakes run on. Linux-only: it exists to
|
||||
/// outlive ashpd's process-global cached D-Bus connection, and only the Linux backends speak to it.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/portal_rt.rs"]
|
||||
mod portal_rt;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/hyprland.rs"]
|
||||
mod hyprland;
|
||||
|
||||
@@ -76,6 +76,20 @@ pub struct VirtualOutput {
|
||||
/// capturer must hold frames until that renegotiation lands. Linux-only.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub expect_exact_dims: bool,
|
||||
/// The compositor's own name for this output (Hyprland's `PF-<pid>-<n>`, sway's `HEADLESS-N`,
|
||||
/// a mirrored head's connector) — the Linux answer to what `win_capture` carries on Windows:
|
||||
/// the identity the host needs to aim **absolute input** at the head it is streaming
|
||||
/// (`pf_inject::set_stream_output`, called from `capture::capture_virtual_output`).
|
||||
///
|
||||
/// It is the `wl_output.name` of that head, which the protocol guarantees is the same string
|
||||
/// for every client — so the injector can match it on its own Wayland connection. `None` on
|
||||
/// the backends whose absolute mapping does not need it (KWin/Mutter inject through libei,
|
||||
/// which selects by region; gamescope owns its whole seat).
|
||||
///
|
||||
/// This crate must not depend on pf-inject (see the crate doc), so the name is only CARRIED
|
||||
/// here — the host publishes it.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub output_name: Option<String>,
|
||||
}
|
||||
|
||||
impl VirtualOutput {
|
||||
@@ -101,6 +115,8 @@ impl VirtualOutput {
|
||||
pool_gen: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
expect_exact_dims: false,
|
||||
#[cfg(target_os = "linux")]
|
||||
output_name: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,6 +200,33 @@ pub trait VirtualDisplay: Send {
|
||||
fn hw_cursor(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// The ScreenCast cursor mode the backend's portal actually NEGOTIATED for the most recent
|
||||
/// [`create`](Self::create) — the answer to [`set_hw_cursor`](Self::set_hw_cursor), which is
|
||||
/// only ever a *request*.
|
||||
///
|
||||
/// This is the difference between the two that matters downstream: on the whole wlr family
|
||||
/// (xdph, xdpw) `AvailableCursorModes` is `Hidden|Embedded`, so a session that asked for
|
||||
/// metadata is served **`Embedded`** — the compositor paints the pointer into the frames and
|
||||
/// sends no `SPA_META_Cursor`, ever, wherever the pointer is. A consumer that reads "no cursor
|
||||
/// overlay" as a symptom (the host's park schedule reads it as "the seat pointer has not
|
||||
/// reached the streamed output" — true on Mutter, which suppresses metadata while the pointer
|
||||
/// is off the recorded view) is then acting on noise; see
|
||||
/// [`PortalCursorMode::delivers_metadata`](crate::PortalCursorMode::delivers_metadata).
|
||||
///
|
||||
/// `None` — the default, and what every non-portal backend reports — means "nothing was
|
||||
/// negotiated through the xdg ScreenCast portal here, so this says nothing at all": KWin
|
||||
/// (`zkde_screencast` `pointer` mode), Mutter (`RecordVirtual` `cursor-mode`), gamescope (no
|
||||
/// pointer either way) and Windows (IddCx) all get exactly what they ask for through their own
|
||||
/// protocols, and their consumers must keep behaving as they always did. It is also `None`
|
||||
/// before the first `create`.
|
||||
///
|
||||
/// Reported by the wlr-family backends (`hyprland`, `wlroots`) and by the monitor
|
||||
/// [`mirror`](crate::open_mirror) when it delegates to one. Those outputs are never registry-
|
||||
/// pooled (`remote_fd.is_some()` — the portal fd cannot be re-opened per attach), so a reused
|
||||
/// kept display can never hand back a *stale* answer here.
|
||||
fn last_portal_cursor_mode(&self) -> Option<crate::PortalCursorMode> {
|
||||
None
|
||||
}
|
||||
/// The stable identity slot the backend resolved for the most recent [`create`](Self::create) —
|
||||
/// the per-client id the identity policy assigned (`Some`), or `None` for shared/anonymous. The
|
||||
/// registry reads it right after `create` to key the display's group **arrangement** (manual
|
||||
|
||||
@@ -525,6 +525,9 @@ impl VirtualDisplay for GamescopeDisplay {
|
||||
reused_gen: None,
|
||||
pool_gen: None,
|
||||
expect_exact_dims: false,
|
||||
// gamescope owns its own seat and injects through its EIS socket, not the wlr
|
||||
// virtual pointer (`point_injector_at_eis`) — nothing here to aim by name.
|
||||
output_name: None,
|
||||
});
|
||||
}
|
||||
check_gamescope_version(); // diagnostic only — warns on known-deadlock-prone versions
|
||||
@@ -718,6 +721,9 @@ fn create_managed_session(client: &str, mode: Mode, hdr: bool) -> Result<Virtual
|
||||
reused_gen: None,
|
||||
pool_gen: None,
|
||||
expect_exact_dims: false,
|
||||
// gamescope owns its own seat and injects through its EIS socket, not the wlr
|
||||
// virtual pointer (`point_injector_at_eis`) — nothing here to aim by name.
|
||||
output_name: None,
|
||||
});
|
||||
}
|
||||
// B1b: a desktop-session Steam (outside any gamescope unit) also holds the single instance and
|
||||
@@ -834,6 +840,9 @@ fn managed_output(node_id: u32, mode: Mode) -> VirtualOutput {
|
||||
reused_gen: None,
|
||||
pool_gen: None,
|
||||
expect_exact_dims: false,
|
||||
// gamescope owns its own seat and injects through its EIS socket, not the wlr
|
||||
// virtual pointer (`point_injector_at_eis`) — nothing here to aim by name.
|
||||
output_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3595,6 +3604,9 @@ pub(crate) fn stream_existing_output(
|
||||
Ok(crate::mirror::MirrorStream {
|
||||
node_id,
|
||||
remote_fd: None,
|
||||
// No xdg portal in this path at all (gamescope publishes the node itself), and no pointer
|
||||
// in the node either way — nothing to report.
|
||||
cursor_mode: None,
|
||||
keepalive: Box::new(()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,10 +16,12 @@
|
||||
//! 3. The xdg ScreenCast portal (served by **xdph**) yields the output's PipeWire node. There is
|
||||
//! no GUI to pick an output headlessly, so xdph is steered through its **custom picker**: a
|
||||
//! managed config (`~/.config/hypr/xdph.conf`) points `screencopy:custom_picker_binary` at a tiny
|
||||
//! installed shim that cats a per-session selection file we write (`[SELECTION]screen:<NAME>`)
|
||||
//! right before the handshake — byte-for-byte the xdpw pattern, xdph's picker wire format.
|
||||
//! 4. Teardown is RAII: drop stops the portal thread (its zbus connection ends the cast) and runs
|
||||
//! `hyprctl output remove NAME`.
|
||||
//! installed shim that cats a per-session selection file we write right before the handshake —
|
||||
//! `[SELECTION]/screen:<NAME>`, whose leading `/` is xdph's mandatory empty-flags separator (see
|
||||
//! [`crate::portal_picker`], which owns the format and its tests).
|
||||
//! 4. Teardown is RAII **and ordered**: drop closes the ScreenCast session and WAITS for the portal
|
||||
//! to confirm it, and only then runs `hyprctl output remove NAME`. Removing the output first is
|
||||
//! what made every stream after the first one fail on Hyprland — see [`StopGuard`].
|
||||
//!
|
||||
//! Requirements: the host runs inside (or can reach) the Hyprland session — either
|
||||
//! `HYPRLAND_INSTANCE_SIGNATURE` is inherited, or [`is_available`] discovers it from
|
||||
@@ -27,7 +29,8 @@
|
||||
//! the ScreenCast interface routed to xdph (`scripts/headless/portals.conf`).
|
||||
//!
|
||||
//! Contracts verified on **Hyprland 0.55.4 + xdph 1.3.x** (`design/hyprland-support.md` Phase 0):
|
||||
//! `hyprctl` subcommands / JSON shapes, the `[SELECTION]screen:<name>` picker format, the
|
||||
//! `hyprctl` subcommands / JSON shapes, the `[SELECTION]/screen:<name>` picker format (re-derived
|
||||
//! from xdph 1.3.12's own parser on 2026-08-14, which is when the missing `/` turned up), the
|
||||
//! `~/.config/hypr/xdph.conf` path + `screencopy:custom_picker_binary` key, and that `eval` needs
|
||||
//! the Lua config manager. Not yet exercised end-to-end on real DRM hardware: a headless output's
|
||||
//! GBM/dmabuf allocation (fails on a nested/NVIDIA test box — Sunshine#4197); `set_monitor_rule`
|
||||
@@ -44,7 +47,7 @@ use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Per-session file the xdph custom picker reads the selected output from. We write
|
||||
/// `screen:<NAME>\n` here right before the portal handshake selects sources. Lives under
|
||||
/// [`picker_selection_line`] here right before the portal handshake selects sources. Lives under
|
||||
/// `$XDG_RUNTIME_DIR` (per-user, 0700) — NOT a world-writable /tmp path another local user could
|
||||
/// pre-create or rewrite between our write and xdph's read (steer capture elsewhere). Mirrors the
|
||||
/// wlroots chooser file.
|
||||
@@ -61,13 +64,11 @@ fn picker_shim_path() -> String {
|
||||
format!("{dir}/punktfunk-xdph-picker.sh")
|
||||
}
|
||||
|
||||
/// The picker line for output `name`. Verified against xdph 1.3.x / hyprland-share-picker on
|
||||
/// Hyprland 0.55.4: xdph reads the custom picker's stdout and requires the `[SELECTION]` marker
|
||||
/// followed by `screen:<name>` (or `window:<addr>` / `region:…`); anything else is rejected as
|
||||
/// "strange output" and falls back to the interactive picker. So a monitor selection is
|
||||
/// `[SELECTION]screen:<name>`.
|
||||
/// The picker line for output `name` — `[SELECTION]/screen:<name>`, whose every byte is load-bearing.
|
||||
/// Lives in [`crate::portal_picker`] with a transcription of xdph's parser, because it is a wire
|
||||
/// format with no error report and this file only compiles on Linux.
|
||||
fn picker_selection_line(name: &str) -> String {
|
||||
format!("[SELECTION]screen:{name}\n")
|
||||
crate::portal_picker::selection_line(name)
|
||||
}
|
||||
|
||||
/// Monotonic per-process counter for headless output names (`PF-<pid>-1`, `PF-<pid>-2`, …). Named
|
||||
@@ -131,11 +132,18 @@ pub struct HyprlandDisplay {
|
||||
/// only. Every session on this backend therefore resolves to `Embedded` today; KWin/Mutter
|
||||
/// remain the legs where the metadata channel is actually exercised.
|
||||
hw_cursor: bool,
|
||||
/// What the portal actually gave us on the most recent [`create`](VirtualDisplay::create) — see
|
||||
/// [`VirtualDisplay::last_portal_cursor_mode`], which is how the host learns that a cursor
|
||||
/// overlay is never coming instead of inferring it from an absence.
|
||||
last_cursor_mode: Option<crate::portal_cursor::Mode>,
|
||||
}
|
||||
|
||||
impl HyprlandDisplay {
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(HyprlandDisplay { hw_cursor: false })
|
||||
Ok(HyprlandDisplay {
|
||||
hw_cursor: false,
|
||||
last_cursor_mode: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,6 +209,10 @@ impl VirtualDisplay for HyprlandDisplay {
|
||||
self.hw_cursor
|
||||
}
|
||||
|
||||
fn last_portal_cursor_mode(&self) -> Option<crate::PortalCursorMode> {
|
||||
self.last_cursor_mode
|
||||
}
|
||||
|
||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
||||
// Log the permission-system caveat once per process (silent black frames otherwise).
|
||||
preflight_once();
|
||||
@@ -224,16 +236,21 @@ impl VirtualDisplay for HyprlandDisplay {
|
||||
// thread (it parks to keep the cast alive, like the other backends). Serialized: the
|
||||
// selection is one per-user file, so a concurrent session's write between ours and xdph's
|
||||
// read would silently capture the wrong output (see `SELECTION_LOCK`).
|
||||
let (fd, node_id, stop) = {
|
||||
let (fd, node_id, cursor_mode, stop) = {
|
||||
let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
select_and_cast(&name, self.hw_cursor)?
|
||||
};
|
||||
// Latched for `last_portal_cursor_mode`: on today's xdph this is `embedded` whatever we
|
||||
// asked for, and the session's whole cursor behaviour follows from that fact rather than
|
||||
// from `hw_cursor`.
|
||||
self.last_cursor_mode = Some(cursor_mode);
|
||||
tracing::info!(
|
||||
node_id,
|
||||
output = %name,
|
||||
w = mode.width,
|
||||
h = mode.height,
|
||||
hz = mode.refresh_hz,
|
||||
cursor = cursor_mode.name(),
|
||||
"hyprland headless output ready"
|
||||
);
|
||||
Ok(VirtualOutput {
|
||||
@@ -251,24 +268,100 @@ impl VirtualDisplay for HyprlandDisplay {
|
||||
reused_gen: None,
|
||||
pool_gen: None,
|
||||
expect_exact_dims: false,
|
||||
// Hyprland is an EXTEND topology: this head sits BESIDE the operator's, so absolute
|
||||
// input has to be aimed at it by name or it lands on their screen. `hyprctl`'s monitor
|
||||
// name is the head's `wl_output.name`, which is what the injector matches.
|
||||
output_name: Some(name),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop order matters: stop the portal thread first (zbus connection drop ends the cast), then
|
||||
/// remove the output (fields drop in declaration order).
|
||||
/// Drop order matters, and it is the whole fix: [`StopGuard`] **blocks until the ScreenCast session
|
||||
/// is actually closed**, and only then does [`OutputGuard`] remove the compositor output (fields drop
|
||||
/// in declaration order).
|
||||
///
|
||||
/// 🛑 THIS ORDERING USED TO BE A LIE. `StopGuard::drop` only set an atomic and returned, while the
|
||||
/// portal thread noticed it 200 ms later — so `OutputGuard::drop` ran `hyprctl output remove` on an
|
||||
/// output xdph was still actively capturing, every single teardown. See [`StopGuard`] for what that
|
||||
/// did to xdph.
|
||||
struct Keepalive {
|
||||
_stop: StopGuard,
|
||||
_output: OutputGuard,
|
||||
}
|
||||
|
||||
/// Dropping this ends the portal keepalive thread, closing its zbus connection — the portal then
|
||||
/// tears the screencast session down.
|
||||
struct StopGuard(Arc<AtomicBool>);
|
||||
/// How long teardown waits for the portal to confirm the ScreenCast session is closed before giving
|
||||
/// up and removing the output anyway. One D-Bus round trip through xdg-desktop-portal to xdph; three
|
||||
/// seconds is generous. Bounded on purpose: a portal that has already wedged must not be able to
|
||||
/// wedge the host's teardown with it — every other blocking helper on this path is bounded the same
|
||||
/// way (see [`HYPRCTL_BUDGET`]).
|
||||
const CAST_CLOSE_BUDGET: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Ends the cast: signals the portal thread, then **waits for it to have closed the ScreenCast
|
||||
/// session**, so the caller may safely remove the output afterwards.
|
||||
///
|
||||
/// 🛑 THE WAIT IS THE POINT — "only the first stream after a portal start works" on Hyprland was
|
||||
/// this, root-caused 2026-08-14 against Hyprland 0.55.4 + xdph 1.3.12 + xdg-desktop-portal 1.20.4.
|
||||
///
|
||||
/// This used to be a bare `AtomicBool` that `drop` merely SET. The portal thread polled it every
|
||||
/// 200 ms and then just dropped its zbus connection, and xdph destroys a session on exactly one
|
||||
/// event — an explicit `org.freedesktop.impl.portal.Session.Close` (`Session.cpp:37`,
|
||||
/// `onCloseSession`); it has no peer-vanished watcher of its own. The frontend does have one
|
||||
/// (`xdg-desktop-portal.c:230` `peer_died_cb` → `close_sessions_for_sender`), but it only fires once
|
||||
/// our unique bus name goes away, which is *after* the 200 ms poll, and it runs asynchronously on a
|
||||
/// GTask thread. Meanwhile `OutputGuard::drop` had already removed the output — synchronously,
|
||||
/// microseconds after the flag was set.
|
||||
///
|
||||
/// So every teardown destroyed the `wl_output` out from under a live screencopy session. xdph's next
|
||||
/// `Start` then built a PipeWire stream against that wreckage and fell into
|
||||
///
|
||||
/// ```text
|
||||
/// while (pSession->sharingData.nodeID == SPA_ID_INVALID) { // Screencopy.cpp:307-313
|
||||
/// int ret = pw_loop_iterate(g_pPortalManager->m_sPipewire.loop, 0); // timeout 0 = NON-blocking
|
||||
/// ```
|
||||
///
|
||||
/// — an unbounded hot spin on xdph's ONLY event-loop thread, inside the `Start` handler, holding its
|
||||
/// `m_mEventLock`. From that moment xdph answers no D-Bus, no Wayland and no PipeWire, ever again, and
|
||||
/// every later `select_and_cast` dies on our 20 s timeout. MEASURED on the box: the wedged instance's
|
||||
/// unit reported `Consumed 3min 51.971s CPU time over 23min 41.092s wall clock`, and there were
|
||||
/// exactly 232.7 s of wall clock between its last log flush and its restart — 231.971 s of CPU
|
||||
/// against 232.7 s of wall, i.e. one core pinned solid for precisely the wedged interval.
|
||||
///
|
||||
/// Waiting here closes that window: `Session.Close` is answered synchronously by the frontend
|
||||
/// (`xdp-session.c:217` `handle_close` → `xdp_session_close` →
|
||||
/// `xdp_dbus_impl_session_call_close_sync`), so by the time `close()` returns, xdph has already run
|
||||
/// `destroyStream` and logged `Session destroyed`. The output we remove next is one nobody is
|
||||
/// capturing.
|
||||
struct StopGuard {
|
||||
stop: Arc<AtomicBool>,
|
||||
/// Signalled by the portal thread once it has closed the ScreenCast session.
|
||||
///
|
||||
/// `None` on every path where no cast was ever established (a rejected or timed-out handshake):
|
||||
/// there is nothing to close, and a portal that just failed to answer for 20 s is precisely the
|
||||
/// one that would burn the whole budget here for nothing.
|
||||
closed: Option<std::sync::mpsc::Receiver<()>>,
|
||||
}
|
||||
|
||||
impl Drop for StopGuard {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
let Some(closed) = self.closed.take() else {
|
||||
return;
|
||||
};
|
||||
match closed.recv_timeout(CAST_CLOSE_BUDGET) {
|
||||
// Closed — xdph has torn the capture down, the output is safe to remove.
|
||||
Ok(()) => {}
|
||||
// The thread is gone without confirming (it panicked, or the runtime died). Nothing is
|
||||
// holding the cast either way, so there is nothing left to wait for.
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {}
|
||||
// Still going after the budget. Fall through and remove the output anyway — a leaked
|
||||
// output is worse than a racy one — but say so, because this is the state that wedges
|
||||
// xdph and the next session will be the one that pays for it.
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => tracing::warn!(
|
||||
budget_s = CAST_CLOSE_BUDGET.as_secs(),
|
||||
"the ScreenCast session did not close in time — removing the output underneath it, \
|
||||
which is what wedges xdph's frame loop; the next cast may find the portal busy"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,6 +442,12 @@ impl Drop for OutputGuard {
|
||||
/// stream thread, whose only way to end a session is to return, so one hung query used to wedge the
|
||||
/// session for good. Generous next to a healthy call (single-digit milliseconds), and every call
|
||||
/// site already has a failed-query path.
|
||||
/// Ceiling on the whole ScreenCast handshake (`create_session` → `select_sources` → `start` →
|
||||
/// `open_pipe_wire_remote`). Deliberately under [`select_and_cast`]'s 20 s wait so a stuck portal is
|
||||
/// reported by the thread that owns it, with a reason, instead of the caller timing out on it — and,
|
||||
/// far more importantly, so that thread EXITS. See the note at the handshake itself.
|
||||
const HANDSHAKE_BUDGET: Duration = Duration::from_secs(15);
|
||||
|
||||
const HYPRCTL_BUDGET: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Budget for the one-shot xdph restart. `systemctl --user try-restart` waits for the user manager's
|
||||
@@ -401,19 +500,31 @@ impl Drop for SelectionFile {
|
||||
|
||||
/// Point xdph's custom picker at `output` and run the ScreenCast handshake, returning the portal fd
|
||||
/// + node id and the guard that stops the cast. The caller must hold [`SELECTION_LOCK`].
|
||||
fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, StopGuard)> {
|
||||
fn select_and_cast(
|
||||
output: &str,
|
||||
hw_cursor: bool,
|
||||
) -> Result<(OwnedFd, u32, crate::portal_cursor::Mode, StopGuard)> {
|
||||
ensure_xdph_config()?;
|
||||
let sel = selection_file();
|
||||
std::fs::write(&sel, picker_selection_line(output)).with_context(|| format!("write {sel}"))?;
|
||||
// Owned from the write on: every arm below (and every `?`) leaves the handshake, which is the
|
||||
// only thing that reads it.
|
||||
let _sel_file = SelectionFile(sel);
|
||||
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
|
||||
// The NEGOTIATED cursor mode rides back with the fd and node id: it is decided inside the
|
||||
// portal thread (only there is the proxy to ask), and nothing downstream can re-derive it —
|
||||
// `hw_cursor` is the request, not the answer.
|
||||
let (setup_tx, setup_rx) =
|
||||
std::sync::mpsc::channel::<Result<(OwnedFd, u32, crate::portal_cursor::Mode), String>>();
|
||||
// The teardown handshake: the thread signals this once it has closed the ScreenCast session, and
|
||||
// `StopGuard::drop` waits on it before the output is removed (see `StopGuard`). Kept a SEPARATE
|
||||
// channel from the setup one above — it fires at the other end of the cast's life, long after
|
||||
// `setup_rx` has been consumed.
|
||||
let (closed_tx, closed_rx) = std::sync::mpsc::channel::<()>();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_thread = stop.clone();
|
||||
thread::Builder::new()
|
||||
.name("punktfunk-hypr-cast".into())
|
||||
.spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor))
|
||||
.spawn(move || portal_thread(setup_tx, closed_tx, stop_thread, hw_cursor))
|
||||
.context("spawn hyprland portal thread")?;
|
||||
// Built BEFORE the wait so EVERY error arm below sets the flag on its way out — as Mutter's
|
||||
// `create` does. Returning the bare `Arc` and letting the CALLER wrap it left the two failure
|
||||
@@ -422,9 +533,14 @@ fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, StopG
|
||||
// parks forever on `while !stop`, holding a live ScreenCast session, its zbus connection, an
|
||||
// `OwnedFd` and a 2-worker tokio runtime — one more set per slow-portal connect, for the host's
|
||||
// lifetime, against an output that no longer exists.
|
||||
let guard = StopGuard(stop);
|
||||
let mut guard = StopGuard { stop, closed: None };
|
||||
match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
||||
Ok(Ok((fd, node_id))) => Ok((fd, node_id, guard)),
|
||||
Ok(Ok((fd, node_id, cursor_mode))) => {
|
||||
// A cast exists now, so teardown has something to close and must wait for it. Only this
|
||||
// arm arms the wait: see the field note on `StopGuard::closed`.
|
||||
guard.closed = Some(closed_rx);
|
||||
Ok((fd, node_id, cursor_mode, guard))
|
||||
}
|
||||
Ok(Err(e)) => bail!("ScreenCast portal on {output} failed: {e}"),
|
||||
Err(_) => bail!("timed out waiting for the ScreenCast portal on {output}"),
|
||||
}
|
||||
@@ -440,10 +556,11 @@ pub(crate) fn stream_existing_output(
|
||||
hw_cursor: bool,
|
||||
) -> Result<crate::mirror::MirrorStream> {
|
||||
let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (fd, node_id, stop) = select_and_cast(connector, hw_cursor)?;
|
||||
let (fd, node_id, cursor_mode, stop) = select_and_cast(connector, hw_cursor)?;
|
||||
Ok(crate::mirror::MirrorStream {
|
||||
node_id,
|
||||
remote_fd: Some(fd),
|
||||
cursor_mode: Some(cursor_mode),
|
||||
keepalive: Box::new(stop),
|
||||
})
|
||||
}
|
||||
@@ -793,7 +910,8 @@ fn ensure_xdph_config() -> Result<()> {
|
||||
/// custom picker, no dialog. (Kept separate from wlroots' copy so each wlr-family backend stays
|
||||
/// self-owned per D1; unify if they ever diverge no further.)
|
||||
fn portal_thread(
|
||||
setup_tx: Sender<Result<(OwnedFd, u32), String>>,
|
||||
setup_tx: Sender<Result<(OwnedFd, u32, crate::portal_cursor::Mode), String>>,
|
||||
closed_tx: Sender<()>,
|
||||
stop: Arc<AtomicBool>,
|
||||
hw_cursor: bool,
|
||||
) {
|
||||
@@ -801,16 +919,15 @@ fn portal_thread(
|
||||
use ashpd::desktop::PersistMode;
|
||||
use ashpd::enumflags2::BitFlags;
|
||||
|
||||
// Multi-thread runtime: the zbus background reader must be pumped across the
|
||||
// create_session → select_sources → start handshake (see capture/linux.rs).
|
||||
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
// 🛑 The SHARED, never-dropped runtime — NOT a per-cast one. ashpd caches its D-Bus connection
|
||||
// process-globally, and a per-cast runtime takes that connection's background reader down with
|
||||
// it when the cast ends, leaving every later handshake in this process awaiting a reply nothing
|
||||
// is alive to read. That is the whole "the first stream works, the rest are black" bug. See
|
||||
// [`crate::portal_rt`] for the measurement.
|
||||
let rt = match crate::portal_rt::portal_runtime() {
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
let _ = setup_tx.send(Err(format!("build tokio runtime: {e}")));
|
||||
let _ = setup_tx.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -818,9 +935,21 @@ fn portal_thread(
|
||||
|
||||
rt.block_on(async move {
|
||||
let result: Result<()> = async {
|
||||
let proxy = Screencast::new().await.context(
|
||||
"connect ScreenCast portal (is xdg-desktop-portal running with the hyprland backend/xdph?)",
|
||||
)?;
|
||||
// Inside the bound below, deliberately: when the cached connection was orphaned this is
|
||||
// where the thread hung — `Screencast::new()` itself, before a single handshake call —
|
||||
// and a bound that started after it reported the caller's generic timeout instead.
|
||||
let connect = async {
|
||||
Screencast::new().await.context(
|
||||
"connect ScreenCast portal (is xdg-desktop-portal running with the hyprland backend/xdph?)",
|
||||
)
|
||||
};
|
||||
let proxy = match tokio::time::timeout(HANDSHAKE_BUDGET, connect).await {
|
||||
Ok(v) => v?,
|
||||
Err(_) => bail!(
|
||||
"connecting to the ScreenCast portal did not return within {}s",
|
||||
HANDSHAKE_BUDGET.as_secs()
|
||||
),
|
||||
};
|
||||
// NEGOTIATED against what xdph advertises, never asserted from `hw_cursor` alone: a
|
||||
// cursor mode the backend does not offer does not degrade — xdg-desktop-portal's
|
||||
// FRONTEND fails the call ("Unavailable cursor mode %x") before xdph sees it.
|
||||
@@ -829,51 +958,99 @@ fn portal_thread(
|
||||
// hardcode killed EVERY cursor-forward session here, on today's packages, not just on
|
||||
// old installs: `unavailable cursor mode 4`, "pipeline build failed", black client.
|
||||
let cursor_mode = crate::portal_cursor::negotiate(&proxy, hw_cursor, "xdph").await;
|
||||
let session = proxy
|
||||
.create_session(Default::default())
|
||||
.await
|
||||
.context("create_session")?;
|
||||
proxy
|
||||
.select_sources(
|
||||
&session,
|
||||
SelectSourcesOptions::default()
|
||||
.set_cursor_mode(cursor_mode)
|
||||
// xdph offers MONITOR; the custom picker selects our output.
|
||||
.set_sources(BitFlags::from_flag(SourceType::Monitor))
|
||||
.set_multiple(false)
|
||||
.set_persist_mode(PersistMode::DoNot),
|
||||
)
|
||||
.await
|
||||
.context("select_sources")?
|
||||
.response()
|
||||
.context("select_sources rejected")?;
|
||||
let streams = proxy
|
||||
.start(&session, None, Default::default())
|
||||
.await
|
||||
.context("start cast")?
|
||||
.response()
|
||||
.context("start response (custom picker declined? check the xdph config/shim/selection file)")?;
|
||||
let stream = streams
|
||||
.streams()
|
||||
.first()
|
||||
.context("portal returned no streams")?
|
||||
.clone();
|
||||
let node_id = stream.pipe_wire_node_id();
|
||||
let fd = proxy
|
||||
.open_pipe_wire_remote(&session, Default::default())
|
||||
.await
|
||||
.context("open_pipe_wire_remote")?;
|
||||
// 🛑 BOUNDED, and that bound is load-bearing. `select_sources`/`start` await a D-Bus
|
||||
// reply a wedged portal never sends, and an await that never returns CANNOT be cancelled
|
||||
// by the `stop` flag — the thread never reaches the park loop that reads it. That is how
|
||||
// one host accumulated NINE live cast threads (28 tokio workers) on 2026-08-14: each
|
||||
// timed-out attempt left one behind holding a half-created portal session on this
|
||||
// process's shared D-Bus connection, and from the first hang onwards EVERY later request
|
||||
// from this process hung too — while a freshly-spawned process talking to the very same
|
||||
// portal completed the identical handshake fine. Shorter than the caller's 20 s wait, so
|
||||
// the failure is reported HERE with a reason instead of surfacing as a bare timeout.
|
||||
let handshake = async {
|
||||
let session = proxy
|
||||
.create_session(Default::default())
|
||||
.await
|
||||
.context("create_session")?;
|
||||
proxy
|
||||
.select_sources(
|
||||
&session,
|
||||
SelectSourcesOptions::default()
|
||||
.set_cursor_mode(cursor_mode.to_ashpd())
|
||||
// xdph offers MONITOR; the custom picker selects our output.
|
||||
.set_sources(BitFlags::from_flag(SourceType::Monitor))
|
||||
.set_multiple(false)
|
||||
.set_persist_mode(PersistMode::DoNot),
|
||||
)
|
||||
.await
|
||||
.context("select_sources")?
|
||||
.response()
|
||||
.context("select_sources rejected")?;
|
||||
let streams = proxy
|
||||
.start(&session, None, Default::default())
|
||||
.await
|
||||
.context("start cast")?
|
||||
.response()
|
||||
.context("start response (custom picker declined? check the xdph config/shim/selection file)")?;
|
||||
let stream = streams
|
||||
.streams()
|
||||
.first()
|
||||
.context("portal returned no streams")?
|
||||
.clone();
|
||||
let node_id = stream.pipe_wire_node_id();
|
||||
let fd = proxy
|
||||
.open_pipe_wire_remote(&session, Default::default())
|
||||
.await
|
||||
.context("open_pipe_wire_remote")?;
|
||||
Ok::<_, anyhow::Error>((session, fd, node_id))
|
||||
};
|
||||
let (session, fd, node_id) =
|
||||
match tokio::time::timeout(HANDSHAKE_BUDGET, handshake).await {
|
||||
Ok(v) => v?,
|
||||
Err(_) => bail!(
|
||||
"the ScreenCast portal did not complete the handshake within {}s — \
|
||||
abandoning it instead of parking this thread on it forever (a hung \
|
||||
request poisons every later one from this process)",
|
||||
HANDSHAKE_BUDGET.as_secs()
|
||||
),
|
||||
};
|
||||
|
||||
setup_tx
|
||||
.send(Ok((fd, node_id)))
|
||||
.send(Ok((fd, node_id, cursor_mode)))
|
||||
.map_err(|_| anyhow!("virtual-output opener went away"))?;
|
||||
|
||||
// Park, keeping `proxy` + `session` (the zbus connection) alive until stopped — the cast
|
||||
// is torn down when the connection drops.
|
||||
// Park, keeping `proxy` + `session` alive until stopped. Polled at 20 ms rather than the
|
||||
// 200 ms this used to use, because the teardown now WAITS on what follows — every
|
||||
// millisecond here is a millisecond of stream teardown.
|
||||
let _keep_alive = (&proxy, &session);
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
|
||||
// 🛑 CLOSE THE SESSION, AND CLOSE IT *BEFORE* THE OUTPUT GOES AWAY. Dropping the
|
||||
// connection and trusting the peer to notice is what this used to do, and it is not the
|
||||
// contract: xdph destroys a session only on an explicit
|
||||
// `org.freedesktop.impl.portal.Session.Close` (`Session.cpp:37`). The caller is blocked
|
||||
// in `StopGuard::drop` waiting for the signal below, and only removes the compositor
|
||||
// output afterwards — that ordering is the whole fix; see `StopGuard`.
|
||||
//
|
||||
// Bounded: `close()` goes through xdg-desktop-portal to xdph, and an already-wedged xdph
|
||||
// never answers. Timing out here still signals, so teardown pays the budget once and
|
||||
// moves on rather than hanging on a portal that is already gone.
|
||||
match tokio::time::timeout(CAST_CLOSE_BUDGET, session.close()).await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => tracing::warn!(
|
||||
error = %e,
|
||||
"closing the ScreenCast session failed — the next cast may find xdph busy"
|
||||
),
|
||||
Err(_) => tracing::warn!(
|
||||
budget_s = CAST_CLOSE_BUDGET.as_secs(),
|
||||
"the ScreenCast portal did not answer Session.Close in time — it is probably \
|
||||
already wedged"
|
||||
),
|
||||
}
|
||||
// Release the teardown. Best-effort: the receiver is gone if the caller already gave up.
|
||||
let _ = closed_tx.send(());
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
@@ -929,9 +1106,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The backend hands the picker exactly what [`crate::portal_picker`] says — that module owns the
|
||||
/// format and its xdph-parser tests, which run on every platform rather than only this leg.
|
||||
#[test]
|
||||
fn picker_line_carries_the_selection_marker() {
|
||||
// xdph requires the `[SELECTION]` prefix; a bare `screen:NAME` is rejected as strange output.
|
||||
assert_eq!(picker_selection_line("PF-1"), "[SELECTION]screen:PF-1\n");
|
||||
fn picker_line_is_the_shared_selection_format() {
|
||||
assert_eq!(picker_selection_line("PF-1"), "[SELECTION]/screen:PF-1\n");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1467,6 +1467,9 @@ pub(crate) fn stream_existing_output(
|
||||
node_id,
|
||||
// KWin publishes on the user's own PipeWire daemon — no portal remote to carry.
|
||||
remote_fd: None,
|
||||
// Not an xdg-portal session either: the `zkde_screencast` pointer mode was asked of KWin
|
||||
// directly and KWin honours it, so the request IS the answer.
|
||||
cursor_mode: None,
|
||||
keepalive: Box::new(StopOnDrop(stop)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -602,6 +602,9 @@ pub(crate) fn stream_existing_output(
|
||||
node_id,
|
||||
// Mutter's RecordMonitor node lives on the user's PipeWire daemon (like RecordVirtual).
|
||||
remote_fd: None,
|
||||
// Not an xdg-portal session: `cursor-mode` was set directly on `RecordMonitor` and Mutter
|
||||
// honours it, so the request IS the answer and there is nothing to report back.
|
||||
cursor_mode: None,
|
||||
keepalive: Box::new(guard),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -33,8 +33,15 @@
|
||||
|
||||
/// A ScreenCast cursor mode, valued as the portal's own wire bits — which is what a backend prints
|
||||
/// when it rejects one, so `Metadata`'s `4` is literally the number in the field report.
|
||||
///
|
||||
/// Public because the NEGOTIATED mode is a per-session fact the consumer needs: the host's stream
|
||||
/// loop reads it back off the backend ([`VirtualDisplay::last_portal_cursor_mode`]) to know whether
|
||||
/// `SPA_META_Cursor` can ever arrive on this output. Re-exported as
|
||||
/// [`crate::PortalCursorMode`](crate::PortalCursorMode).
|
||||
///
|
||||
/// [`VirtualDisplay::last_portal_cursor_mode`]: crate::VirtualDisplay::last_portal_cursor_mode
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Mode {
|
||||
pub enum Mode {
|
||||
/// No pointer in the cast at all.
|
||||
Hidden = 1,
|
||||
/// The compositor paints the pointer into the frames it hands us.
|
||||
@@ -52,7 +59,7 @@ impl Mode {
|
||||
}
|
||||
|
||||
/// The spelling used in logs and in `PUNKTFUNK_PORTAL_CURSOR_MODE`.
|
||||
pub(crate) const fn name(self) -> &'static str {
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Mode::Hidden => "hidden",
|
||||
Mode::Embedded => "embedded",
|
||||
@@ -60,6 +67,20 @@ impl Mode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Can `SPA_META_Cursor` EVER arrive under this mode? Only under [`Metadata`](Mode::Metadata) —
|
||||
/// and this is the whole point of surfacing the negotiated mode.
|
||||
///
|
||||
/// Under `Embedded` the compositor paints the pointer into the frames and sends no cursor
|
||||
/// metadata **regardless of where the pointer is**, so on such a session the absence of a cursor
|
||||
/// overlay carries NO information: not about the pointer's position, not about whether the
|
||||
/// capture is healthy. Consumers that treat "no overlay" as a symptom (the host's seat-pointer
|
||||
/// park schedule, which reads it as "the pointer has not reached the streamed output" — true on
|
||||
/// Mutter, which suppresses metadata while the pointer is off the recorded view) must ask this
|
||||
/// first. Under `Hidden` there is no pointer at all, so the same holds.
|
||||
pub const fn delivers_metadata(self) -> bool {
|
||||
matches!(self, Mode::Metadata)
|
||||
}
|
||||
|
||||
/// What to ask for instead, best first, when this mode is not advertised.
|
||||
const fn fallbacks(self) -> [Mode; 2] {
|
||||
match self {
|
||||
@@ -185,7 +206,7 @@ pub(crate) fn want(hw_cursor: bool, backend: &str) -> Mode {
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl Mode {
|
||||
fn to_ashpd(self) -> ashpd::desktop::screencast::CursorMode {
|
||||
pub(crate) fn to_ashpd(self) -> ashpd::desktop::screencast::CursorMode {
|
||||
use ashpd::desktop::screencast::CursorMode;
|
||||
match self {
|
||||
Mode::Hidden => CursorMode::Hidden,
|
||||
@@ -198,12 +219,17 @@ impl Mode {
|
||||
/// Ask the portal what it supports, run the ladder, and hand back the mode to put in
|
||||
/// `SelectSources`. Infallible by construction: a backend we cannot interrogate gets `Embedded`,
|
||||
/// the mode that predates the property and that every implementation has always had.
|
||||
///
|
||||
/// Returns OUR [`Mode`], not ashpd's — the caller converts with [`Mode::to_ashpd`] for the request
|
||||
/// and carries the value out of the portal thread, because what was negotiated (as opposed to
|
||||
/// asked for) governs how the session's cursor behaves for its whole life. See
|
||||
/// [`Mode::delivers_metadata`].
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) async fn negotiate(
|
||||
proxy: &ashpd::desktop::screencast::Screencast,
|
||||
hw_cursor: bool,
|
||||
backend: &str,
|
||||
) -> ashpd::desktop::screencast::CursorMode {
|
||||
) -> Mode {
|
||||
let want = want(hw_cursor, backend);
|
||||
let advertised = match proxy.available_cursor_modes().await {
|
||||
Ok(avail) => avail.bits(),
|
||||
@@ -216,7 +242,7 @@ pub(crate) async fn negotiate(
|
||||
error = %e,
|
||||
"ScreenCast: AvailableCursorModes query failed — requesting Embedded cursor"
|
||||
);
|
||||
return Mode::Embedded.to_ashpd();
|
||||
return Mode::Embedded;
|
||||
}
|
||||
};
|
||||
let choice = pick(advertised, want);
|
||||
@@ -238,7 +264,7 @@ pub(crate) async fn negotiate(
|
||||
(requesting it anyway would close the session)"
|
||||
),
|
||||
}
|
||||
choice.mode.to_ashpd()
|
||||
choice.mode
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -286,6 +312,21 @@ mod tests {
|
||||
assert_eq!(c.wanted, Some(Mode::Metadata));
|
||||
}
|
||||
|
||||
/// The consumer-facing half of the same incident: xdph negotiates `3` down to `Embedded`, and
|
||||
/// under Embedded no `SPA_META_Cursor` ever arrives — so a host that reads "no cursor overlay"
|
||||
/// as "the pointer has not reached the streamed output" (true on Mutter, which suppresses
|
||||
/// metadata off-view) re-centres the user's pointer forever. Field report 2026-08-14: the seat
|
||||
/// pointer warped to centre once a second for the full park cap on a working Hyprland stream.
|
||||
#[test]
|
||||
fn only_metadata_can_deliver_a_cursor_overlay() {
|
||||
assert!(Mode::Metadata.delivers_metadata());
|
||||
assert!(!Mode::Embedded.delivers_metadata());
|
||||
assert!(!Mode::Hidden.delivers_metadata());
|
||||
// The negotiated mode is what governs, not the wanted one: this is the exact ladder result
|
||||
// on xdph/xdpw, and it says "no overlay is ever coming" even though metadata was requested.
|
||||
assert!(!pick(3, Mode::Metadata).mode.delivers_metadata());
|
||||
}
|
||||
|
||||
/// The same portal, a session with no cursor channel: already asking for what exists, so the
|
||||
/// fix must not perturb it.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//! The line we feed xdg-desktop-portal-hyprland's **custom picker** to select an output headlessly.
|
||||
//!
|
||||
//! xdph has no headless source-selection API: it runs `screencopy:custom_picker_binary` and parses
|
||||
//! one line from its stdout. The Hyprland backend points that at a shim which cats a per-session
|
||||
//! file, and this module is the format of what goes in the file — a wire format with no schema, no
|
||||
//! validation and no error report, whose only observable failure is a line in the portal's log.
|
||||
//!
|
||||
//! Declared unconditionally although only `hyprland.rs` calls it, for the reason `portal_config` and
|
||||
//! `portal_cursor` give: this is pure string handling whose tests are the only place its behaviour
|
||||
//! is checkable without a compositor in front of you, so they run on every platform's CI rather than
|
||||
//! only on the leg that compiles `mod hyprland`. That is not hypothetical here — the bug below
|
||||
//! shipped, and the one test that existed for it passed the whole time.
|
||||
|
||||
/// The picker line selecting monitor `name`: `[SELECTION]<flags>/<selection>`, with **empty flags**.
|
||||
///
|
||||
/// 🛑 THE `/` IS MANDATORY AND WE USED TO OMIT IT. xdph splits the line on the FIRST `/` into flags
|
||||
/// and selection ([xdph 1.3.12] `src/shared/ScreencopyShared.cpp:86-87`):
|
||||
///
|
||||
/// ```text
|
||||
/// const auto FLAGS = SELECTION.substr(0, SELECTION.find_first_of('/'));
|
||||
/// const auto SEL = SELECTION.substr(SELECTION.find_first_of('/') + 1);
|
||||
/// ```
|
||||
///
|
||||
/// With no `/` anywhere, `find_first_of` returns `npos`, so `FLAGS` becomes the WHOLE payload — and
|
||||
/// `SEL` becomes the whole payload too, purely because `npos + 1` wraps to `0`. The output name
|
||||
/// therefore still parsed correctly, which is exactly why this survived: the only thing it broke was
|
||||
/// the flag loop (`:89-94`), which then walked `screen:<name>` one character at a time —
|
||||
///
|
||||
/// ```text
|
||||
/// [screencopy] unknown flag from share-picker: s
|
||||
/// [screencopy] unknown flag from share-picker: c
|
||||
/// [screencopy] unknown flag from share-picker: e … one line per character
|
||||
/// ```
|
||||
///
|
||||
/// — and, because `sc*r*een` contains an `r`, which is xdph's "allow restore token" flag, set
|
||||
/// `data.allowToken = true`. xdph then answered every `Start` with a `restore_data` +
|
||||
/// `persist_mode: 2` we never asked for (we request `PersistMode::DoNot`), which is the
|
||||
/// `[screencopy] Sent restore token to …` on every single session in the field log.
|
||||
///
|
||||
/// The reference picker prints the separator unconditionally
|
||||
/// (`hyprland-share-picker/main.cpp:133-136`):
|
||||
///
|
||||
/// ```text
|
||||
/// std::cout << "[SELECTION]";
|
||||
/// std::cout << (ALLOWTOKENBUTTON->isChecked() ? "r" : "");
|
||||
/// std::cout << "/";
|
||||
/// std::cout << "screen:" << outputName.toStdString() << "\n";
|
||||
/// ```
|
||||
///
|
||||
/// so empty flags are spelled as a bare leading `/`, not as nothing at all.
|
||||
///
|
||||
/// ⚠️ This was NOT the cause of the "only the first stream works" stall — see `hyprland.rs`'s
|
||||
/// `StopGuard` for that. The sessions that streamed fine logged the identical flag spam and the
|
||||
/// identical restore token, so it never discriminated. It is a real bug on its own terms and nothing
|
||||
/// more.
|
||||
///
|
||||
/// The trailing newline is equally load-bearing: xdph does `data.output.pop_back()` unconditionally
|
||||
/// after `SEL.substr(7)` (`:96-100`), so without it the last character of the output name is eaten.
|
||||
///
|
||||
/// [xdph 1.3.12]: https://github.com/hyprwm/xdg-desktop-portal-hyprland/blob/v1.3.12/src/shared/ScreencopyShared.cpp
|
||||
pub(crate) fn selection_line(name: &str) -> String {
|
||||
format!("[SELECTION]/screen:{name}\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// xdph 1.3.12's parser (`ScreencopyShared.cpp:82-100`), transcribed — including the `npos`
|
||||
/// arithmetic, which is the entire subtlety. Returns `(flags, output)`, or `None` where xdph
|
||||
/// would fall through to its interactive picker.
|
||||
///
|
||||
/// Transcribed rather than asserted on the string, because the bug this catches is invisible in
|
||||
/// the string: the old line yielded the RIGHT OUTPUT NAME while handing xdph the whole selection
|
||||
/// as a FLAG STRING. Only running its parser tells the two apart.
|
||||
fn xdph_parse(picker_stdout: &str) -> Option<(String, String)> {
|
||||
// `if (!RETVAL.contains("[SELECTION]")) return data;` — a default `SSelectionData`, i.e.
|
||||
// TYPE_INVALID, which makes `SelectSources` fail.
|
||||
let marker = picker_stdout.find("[SELECTION]")?;
|
||||
let selection = &picker_stdout[marker + "[SELECTION]".len()..];
|
||||
// `substr(0, npos)` is the whole string, and `substr(npos + 1)` is `substr(0)` — also the
|
||||
// whole string. Unsigned wraparound, not a special case in xdph.
|
||||
let (flags, sel) = match selection.find('/') {
|
||||
Some(i) => (&selection[..i], &selection[i + 1..]),
|
||||
None => (selection, selection),
|
||||
};
|
||||
let name = sel.strip_prefix("screen:")?;
|
||||
// `data.output.pop_back()` — unconditional, hence the mandatory trailing newline.
|
||||
let mut output = name.to_string();
|
||||
output.pop();
|
||||
Some((flags.to_string(), output))
|
||||
}
|
||||
|
||||
/// The three load-bearing parts of the line, pinned as bytes.
|
||||
#[test]
|
||||
fn the_line_carries_marker_empty_flags_separator_and_newline() {
|
||||
assert_eq!(selection_line("PF-1"), "[SELECTION]/screen:PF-1\n");
|
||||
}
|
||||
|
||||
/// What xdph actually makes of our line: the exact output, and NO flags.
|
||||
#[test]
|
||||
fn xdph_reads_our_line_as_an_output_with_no_flags() {
|
||||
for name in ["PF-1", "PF-1620-1", "HDMI-A-1", "DP-2"] {
|
||||
let (flags, output) = xdph_parse(&selection_line(name)).expect("xdph parses our line");
|
||||
assert_eq!(output, name, "xdph must recover the exact output name");
|
||||
assert_eq!(flags, "", "we ask for no flags at all");
|
||||
assert!(
|
||||
!flags.contains('r'),
|
||||
"an `r` in the flags makes xdph hand back restore_data + persist_mode=2 we never \
|
||||
requested (Screencopy.cpp:261-267)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The regression itself, so it cannot come back by "simplifying" the leading `/` away: the line
|
||||
/// we used to send parsed the whole selection as flags, `r` included.
|
||||
#[test]
|
||||
fn without_the_separator_the_whole_selection_becomes_flags() {
|
||||
let (flags, output) = xdph_parse("[SELECTION]screen:PF-1620-1\n").expect("still parses");
|
||||
assert_eq!(
|
||||
output, "PF-1620-1",
|
||||
"the output name did survive — which is precisely why this hid for so long"
|
||||
);
|
||||
assert_eq!(
|
||||
flags, "screen:PF-1620-1\n",
|
||||
"…while the entire selection was handed to the flag loop"
|
||||
);
|
||||
assert!(
|
||||
flags.contains('r'),
|
||||
"the `r` of `sc*r*een` is xdph's allow-restore-token flag"
|
||||
);
|
||||
}
|
||||
|
||||
/// Without the trailing newline xdph's unconditional `pop_back()` eats a character of the name —
|
||||
/// a silently wrong output, not an error.
|
||||
#[test]
|
||||
fn the_trailing_newline_is_what_pop_back_consumes() {
|
||||
assert!(selection_line("PF-1620-1").ends_with('\n'));
|
||||
let (_, truncated) = xdph_parse("[SELECTION]/screen:PF-1620-1").expect("parses");
|
||||
assert_eq!(
|
||||
truncated, "PF-1620-",
|
||||
"pop_back() takes the last real character"
|
||||
);
|
||||
}
|
||||
|
||||
/// A line with no marker at all is xdph's documented empty-read fallback: it prompts instead. The
|
||||
/// shim relies on this when no session has written the selection file.
|
||||
#[test]
|
||||
fn an_empty_read_is_not_a_selection() {
|
||||
assert!(xdph_parse("").is_none());
|
||||
assert!(xdph_parse("screen:PF-1\n").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//! The ONE tokio runtime every portal handshake runs on, for the life of the process.
|
||||
//!
|
||||
//! 🛑🛑🛑 This exists because of a lifetime bug that cost a full day of misdiagnosis, so the reason
|
||||
//! is written down rather than left to be rediscovered.
|
||||
//!
|
||||
//! ashpd caches its D-Bus connection **process-globally** — `static SESSION: OnceLock<Connection>`
|
||||
//! (ashpd 0.13.13, `src/proxy.rs:27`). The first `Screencast::new()` in the process creates that
|
||||
//! connection, and zbus spawns the connection's background reader as a task **on whichever tokio
|
||||
//! runtime happens to be current at that moment**.
|
||||
//!
|
||||
//! Each backend used to build its own multi-thread runtime per cast and drop it at teardown. So the
|
||||
//! FIRST cast of a host process created the cached connection on a runtime that was then destroyed
|
||||
//! when that cast ended — and the `OnceLock` went on handing the same, now-executor-less connection
|
||||
//! to every later `Screencast::new()`, which then awaited a reply nothing was left alive to read.
|
||||
//!
|
||||
//! MEASURED 2026-08-14 (Hyprland 0.55.4 + xdph 1.3.12): the first cast of a host process streamed;
|
||||
//! every cast after it hung, in a process whose surviving cast thread sat in `futex_do_wait` inside
|
||||
//! runtime shutdown. The discriminator that pins it on us rather than on the compositor stack: a
|
||||
//! freshly spawned process completed the identical handshake against the identical xdph, repeatedly,
|
||||
//! while the long-lived host could complete none — and xdph itself was idle (28 ms of CPU).
|
||||
//!
|
||||
//! ⚠ Therefore: **never build a per-cast runtime, and never drop this one.** A `OnceLock` that is
|
||||
//! only ever read keeps the connection's reader alive for the process lifetime, which is exactly as
|
||||
//! long as the cached connection itself lives. `block_on` takes `&self`, so every cast thread can
|
||||
//! park on this one runtime concurrently.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
/// Build failures are reported to the caller rather than panicking: a host that cannot build a
|
||||
/// runtime should fail the cast with a reason, not abort the process.
|
||||
static PORTAL_RT: OnceLock<std::io::Result<Runtime>> = OnceLock::new();
|
||||
|
||||
/// The shared portal runtime, or the error from trying to build it.
|
||||
///
|
||||
/// Multi-thread with 2 workers: the zbus background reader must be pumped *across* the
|
||||
/// `create_session` → `select_sources` → `start` handshake while a cast thread blocks on it, which a
|
||||
/// current-thread runtime cannot do.
|
||||
pub(crate) fn portal_runtime() -> Result<&'static Runtime, String> {
|
||||
match PORTAL_RT.get_or_init(|| {
|
||||
tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.thread_name("punktfunk-portal-rt")
|
||||
.enable_all()
|
||||
.build()
|
||||
}) {
|
||||
Ok(rt) => Ok(rt),
|
||||
Err(e) => Err(format!("build the shared portal runtime: {e}")),
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,10 @@
|
||||
//! (`~/.config/xdg-desktop-portal-wlr/config`, written once + portal restarted on change)
|
||||
//! sets `chooser_type=simple` with a `chooser_cmd` that cats the chooser file, which we
|
||||
//! write per session (`Monitor: <NAME>` — xdpw 0.8 parses that prefix strictly).
|
||||
//! 4. Teardown is RAII: drop stops the portal thread (its zbus connection ends the cast) and
|
||||
//! runs `swaymsg output <NAME> unplug` (headless outputs support unplug since sway 1.8).
|
||||
//! 4. Teardown is RAII **and ordered**: drop closes the ScreenCast session and WAITS for the portal
|
||||
//! to confirm it, and only then runs `swaymsg output <NAME> unplug` (headless outputs support
|
||||
//! unplug since sway 1.8). See [`StopGuard`] — and the long root-cause note on `hyprland.rs`'s
|
||||
//! copy, which is where this was measured.
|
||||
//!
|
||||
//! Requirements: the host runs inside the sway session's environment (`SWAYSOCK` for swaymsg,
|
||||
//! and the portal activation env — `WAYLAND_DISPLAY`/`XDG_CURRENT_DESKTOP=sway` imported into
|
||||
@@ -67,11 +69,18 @@ pub struct WlrootsDisplay {
|
||||
/// never be served out-of-band: it now degrades to `Embedded` and streams, where it used to
|
||||
/// cancel the cast and hand the client a black screen.
|
||||
hw_cursor: bool,
|
||||
/// What the portal actually gave us on the most recent [`create`](VirtualDisplay::create) — see
|
||||
/// [`VirtualDisplay::last_portal_cursor_mode`], which is how the host learns that a cursor
|
||||
/// overlay is never coming instead of inferring it from an absence.
|
||||
last_cursor_mode: Option<crate::portal_cursor::Mode>,
|
||||
}
|
||||
|
||||
impl WlrootsDisplay {
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(WlrootsDisplay { hw_cursor: false })
|
||||
Ok(WlrootsDisplay {
|
||||
hw_cursor: false,
|
||||
last_cursor_mode: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +109,10 @@ impl VirtualDisplay for WlrootsDisplay {
|
||||
self.hw_cursor
|
||||
}
|
||||
|
||||
fn last_portal_cursor_mode(&self) -> Option<crate::PortalCursorMode> {
|
||||
self.last_cursor_mode
|
||||
}
|
||||
|
||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
||||
warn_topology_is_extend_only();
|
||||
// Snapshot → create → identify, all under CREATE_LOCK. sway names the headless output
|
||||
@@ -146,16 +159,21 @@ impl VirtualDisplay for WlrootsDisplay {
|
||||
// its own thread (it parks to keep the cast alive, like the other backends). Serialized:
|
||||
// the chooser is one per-user file, so a concurrent session's write between ours and xdpw's
|
||||
// read would silently capture the wrong output (see `SELECTION_LOCK`).
|
||||
let (fd, node_id, stop) = {
|
||||
let (fd, node_id, cursor_mode, stop) = {
|
||||
let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
select_and_cast(&name, self.hw_cursor)?
|
||||
};
|
||||
// Latched for `last_portal_cursor_mode`: xdpw refuses metadata by construction, so this is
|
||||
// `embedded` whatever we asked for, and the session's whole cursor behaviour follows from
|
||||
// that fact rather than from `hw_cursor`.
|
||||
self.last_cursor_mode = Some(cursor_mode);
|
||||
tracing::info!(
|
||||
node_id,
|
||||
output = %name,
|
||||
w = mode.width,
|
||||
h = mode.height,
|
||||
hz = mode.refresh_hz,
|
||||
cursor = cursor_mode.name(),
|
||||
"sway headless output ready"
|
||||
);
|
||||
Ok(VirtualOutput {
|
||||
@@ -173,24 +191,75 @@ impl VirtualDisplay for WlrootsDisplay {
|
||||
reused_gen: None,
|
||||
pool_gen: None,
|
||||
expect_exact_dims: false,
|
||||
// Same EXTEND problem as Hyprland: on a sway session with real heads this `HEADLESS-N`
|
||||
// sits beside them, and absolute input must be aimed at it by name. `swaymsg`'s output
|
||||
// name is the head's `wl_output.name`, which is what the injector matches.
|
||||
output_name: Some(name),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop order matters: stop the portal thread first (zbus connection drop ends the cast),
|
||||
/// then unplug the output (fields drop in declaration order).
|
||||
/// Drop order matters, and it is the whole fix: [`StopGuard`] **blocks until the ScreenCast session
|
||||
/// is actually closed**, and only then does [`OutputGuard`] unplug the output (fields drop in
|
||||
/// declaration order). This used to unplug first — see [`StopGuard`].
|
||||
struct Keepalive {
|
||||
_stop: StopGuard,
|
||||
_output: OutputGuard,
|
||||
}
|
||||
|
||||
/// Dropping this ends the portal keepalive thread, closing its zbus connection — the portal
|
||||
/// then tears the screencast session down.
|
||||
struct StopGuard(Arc<AtomicBool>);
|
||||
/// How long teardown waits for the portal to confirm the ScreenCast session is closed before giving
|
||||
/// up and unplugging the output anyway. See `hyprland.rs`'s twin.
|
||||
const CAST_CLOSE_BUDGET: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Ceiling on the whole ScreenCast handshake, under the caller's 20 s wait — see the note at the
|
||||
/// handshake, and the longer one on `hyprland.rs`'s copy.
|
||||
const HANDSHAKE_BUDGET: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Ends the cast: signals the portal thread, then **waits for it to have closed the ScreenCast
|
||||
/// session**, so the caller may safely unplug the output afterwards.
|
||||
///
|
||||
/// 🛑 THE WAIT IS THE POINT. Root-caused on the Hyprland leg (see the long note on `hyprland.rs`'s
|
||||
/// `StopGuard`, which carries the measurements); the defect is the same here, and this is NOT an
|
||||
/// assumption of symmetry — xdpw was read to confirm it, against `emersion/xdg-desktop-portal-wlr`:
|
||||
///
|
||||
/// * **Only `Close` tears a session down.** `src/core/session.c` gives the session object exactly
|
||||
/// one method — `SD_BUS_METHOD("Close", …, method_close, …)` — and nothing else calls
|
||||
/// `xdpw_session_destroy` for a live cast. Like xdph, xdpw has no peer-vanished watcher of its own
|
||||
/// and depends entirely on xdg-desktop-portal's `peer_died_cb` calling `Close` for us, which
|
||||
/// happens only after our bus name goes away, asynchronously, and therefore after the old
|
||||
/// `StopGuard` had already let `OutputGuard` unplug the output.
|
||||
/// * **The same unbounded busy-wait is waiting for it.** `src/screencast/screencast.c:599-605`:
|
||||
/// `while (cast->node_id == SPA_ID_INVALID) { pw_loop_iterate(state->pw_loop, 0); }` — timeout 0,
|
||||
/// i.e. non-blocking, i.e. a hot spin on the portal's only loop with no escape if the stream never
|
||||
/// gets a node id. xdph's copy (`Screencopy.cpp:307-313`) is this code; that is the one measured
|
||||
/// pinning a core solid until it was restarted.
|
||||
///
|
||||
/// So sway's `output unplug` yanks a captured output out from under a live session exactly the way
|
||||
/// Hyprland's `output remove` did. Whether xdpw wedges *identically* has not been observed on glass
|
||||
/// — no sway box was available — but the two preconditions are present in its source, and closing
|
||||
/// the session before unplugging is the correct order regardless of what the backend does with it.
|
||||
struct StopGuard {
|
||||
stop: Arc<AtomicBool>,
|
||||
/// Signalled by the portal thread once it has closed the ScreenCast session. `None` when no cast
|
||||
/// was ever established — nothing to close, and nothing worth spending the budget on.
|
||||
closed: Option<std::sync::mpsc::Receiver<()>>,
|
||||
}
|
||||
|
||||
impl Drop for StopGuard {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
let Some(closed) = self.closed.take() else {
|
||||
return;
|
||||
};
|
||||
match closed.recv_timeout(CAST_CLOSE_BUDGET) {
|
||||
// Closed, or the thread is gone without confirming — either way nothing holds the cast.
|
||||
Ok(()) | Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => tracing::warn!(
|
||||
budget_s = CAST_CLOSE_BUDGET.as_secs(),
|
||||
"the ScreenCast session did not close in time — unplugging the output underneath \
|
||||
it; the next cast may find the portal busy"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,7 +415,10 @@ impl Drop for ChooserFile {
|
||||
|
||||
/// Point xdpw's chooser at `output` and run the ScreenCast handshake, returning the portal fd +
|
||||
/// node id and the guard that stops the cast. The caller must hold [`SELECTION_LOCK`].
|
||||
fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, StopGuard)> {
|
||||
fn select_and_cast(
|
||||
output: &str,
|
||||
hw_cursor: bool,
|
||||
) -> Result<(OwnedFd, u32, crate::portal_cursor::Mode, StopGuard)> {
|
||||
ensure_xdpw_config()?;
|
||||
let chooser = chooser_file();
|
||||
std::fs::write(&chooser, format!("Monitor: {output}\n"))
|
||||
@@ -354,12 +426,21 @@ fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, StopG
|
||||
// Owned from the write on: every arm below (and every `?`) leaves the handshake, which is the
|
||||
// only thing that reads it.
|
||||
let _chooser = ChooserFile(chooser);
|
||||
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
|
||||
// The NEGOTIATED cursor mode rides back with the fd and node id: it is decided inside the
|
||||
// portal thread (only there is the proxy to ask), and nothing downstream can re-derive it —
|
||||
// `hw_cursor` is the request, not the answer.
|
||||
let (setup_tx, setup_rx) =
|
||||
std::sync::mpsc::channel::<Result<(OwnedFd, u32, crate::portal_cursor::Mode), String>>();
|
||||
// The teardown handshake: the thread signals this once it has closed the ScreenCast session, and
|
||||
// `StopGuard::drop` waits on it before the output is unplugged (see `StopGuard`). Kept a
|
||||
// SEPARATE channel from the setup one above — it fires at the other end of the cast's life,
|
||||
// long after `setup_rx` has been consumed.
|
||||
let (closed_tx, closed_rx) = std::sync::mpsc::channel::<()>();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_thread = stop.clone();
|
||||
thread::Builder::new()
|
||||
.name("punktfunk-wlr-cast".into())
|
||||
.spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor))
|
||||
.spawn(move || portal_thread(setup_tx, closed_tx, stop_thread, hw_cursor))
|
||||
.context("spawn wlroots portal thread")?;
|
||||
// Built BEFORE the wait so EVERY error arm below sets the flag on its way out — as Mutter's
|
||||
// `create` does. Returning the bare `Arc` and letting the CALLER wrap it left the two failure
|
||||
@@ -368,9 +449,13 @@ fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, StopG
|
||||
// parks forever on `while !stop`, holding a live ScreenCast session, its zbus connection, an
|
||||
// `OwnedFd` and a 2-worker tokio runtime — one more set per slow-portal connect, for the host's
|
||||
// lifetime, against an output that no longer exists.
|
||||
let guard = StopGuard(stop);
|
||||
let mut guard = StopGuard { stop, closed: None };
|
||||
match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
||||
Ok(Ok((fd, node_id))) => Ok((fd, node_id, guard)),
|
||||
Ok(Ok((fd, node_id, cursor_mode))) => {
|
||||
// A cast exists now, so teardown has something to close and must wait for it.
|
||||
guard.closed = Some(closed_rx);
|
||||
Ok((fd, node_id, cursor_mode, guard))
|
||||
}
|
||||
Ok(Err(e)) => bail!("ScreenCast portal on {output} failed: {e}"),
|
||||
Err(_) => bail!("timed out waiting for the ScreenCast portal on {output}"),
|
||||
}
|
||||
@@ -387,10 +472,11 @@ pub(crate) fn stream_existing_output(
|
||||
hw_cursor: bool,
|
||||
) -> Result<crate::mirror::MirrorStream> {
|
||||
let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (fd, node_id, stop) = select_and_cast(connector, hw_cursor)?;
|
||||
let (fd, node_id, cursor_mode, stop) = select_and_cast(connector, hw_cursor)?;
|
||||
Ok(crate::mirror::MirrorStream {
|
||||
node_id,
|
||||
remote_fd: Some(fd),
|
||||
cursor_mode: Some(cursor_mode),
|
||||
keepalive: Box::new(stop),
|
||||
})
|
||||
}
|
||||
@@ -513,7 +599,8 @@ fn ensure_xdpw_config() -> Result<()> {
|
||||
/// reports the fd + node id and parks until stopped — the zbus connection is the cast's
|
||||
/// lifetime). xdpw answers the source selection via the chooser, no dialog.
|
||||
fn portal_thread(
|
||||
setup_tx: Sender<Result<(OwnedFd, u32), String>>,
|
||||
setup_tx: Sender<Result<(OwnedFd, u32, crate::portal_cursor::Mode), String>>,
|
||||
closed_tx: Sender<()>,
|
||||
stop: Arc<AtomicBool>,
|
||||
hw_cursor: bool,
|
||||
) {
|
||||
@@ -523,14 +610,13 @@ fn portal_thread(
|
||||
|
||||
// Multi-thread runtime: the zbus background reader must be pumped across the
|
||||
// create_session → select_sources → start handshake (see capture/linux.rs).
|
||||
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
// The SHARED, never-dropped runtime — see [`crate::portal_rt`] and the long note on
|
||||
// `hyprland.rs`'s copy: a per-cast runtime kills ashpd's process-global cached connection when
|
||||
// the cast ends, and every later handshake in the process then hangs.
|
||||
let rt = match crate::portal_rt::portal_runtime() {
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
let _ = setup_tx.send(Err(format!("build tokio runtime: {e}")));
|
||||
let _ = setup_tx.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -538,9 +624,20 @@ fn portal_thread(
|
||||
|
||||
rt.block_on(async move {
|
||||
let result: Result<()> = async {
|
||||
let proxy = Screencast::new().await.context(
|
||||
"connect ScreenCast portal (is xdg-desktop-portal running with the wlr backend?)",
|
||||
)?;
|
||||
// Bounded, like `hyprland.rs`'s copy: an orphaned cached connection hangs HERE, before
|
||||
// any handshake call, so a bound that starts later never fires.
|
||||
let connect = async {
|
||||
Screencast::new().await.context(
|
||||
"connect ScreenCast portal (is xdg-desktop-portal running with the wlr backend?)",
|
||||
)
|
||||
};
|
||||
let proxy = match tokio::time::timeout(HANDSHAKE_BUDGET, connect).await {
|
||||
Ok(v) => v?,
|
||||
Err(_) => bail!(
|
||||
"connecting to the ScreenCast portal did not return within {}s",
|
||||
HANDSHAKE_BUDGET.as_secs()
|
||||
),
|
||||
};
|
||||
// NEGOTIATED against what xdpw advertises, never asserted from `hw_cursor` alone — see
|
||||
// the xdph copy in `hyprland.rs` for the incident. xdpw is the sharper case: its
|
||||
// screencast.c refuses the mode outright —
|
||||
@@ -549,51 +646,91 @@ fn portal_thread(
|
||||
// — so EVERY cursor-forward session on this backend asked for a mode that cancelled the
|
||||
// cast. Different wording from xdph's "unavailable cursor mode 4", same dead session.
|
||||
let cursor_mode = crate::portal_cursor::negotiate(&proxy, hw_cursor, "xdpw").await;
|
||||
let session = proxy
|
||||
.create_session(Default::default())
|
||||
.await
|
||||
.context("create_session")?;
|
||||
proxy
|
||||
.select_sources(
|
||||
&session,
|
||||
SelectSourcesOptions::default()
|
||||
.set_cursor_mode(cursor_mode)
|
||||
// xdpw offers MONITOR only; the chooser picks our output.
|
||||
.set_sources(BitFlags::from_flag(SourceType::Monitor))
|
||||
.set_multiple(false)
|
||||
.set_persist_mode(PersistMode::DoNot),
|
||||
)
|
||||
.await
|
||||
.context("select_sources")?
|
||||
.response()
|
||||
.context("select_sources rejected")?;
|
||||
let streams = proxy
|
||||
.start(&session, None, Default::default())
|
||||
.await
|
||||
.context("start cast")?
|
||||
.response()
|
||||
.context("start response (chooser declined? check the xdpw config/chooser file)")?;
|
||||
let stream = streams
|
||||
.streams()
|
||||
.first()
|
||||
.context("portal returned no streams")?
|
||||
.clone();
|
||||
let node_id = stream.pipe_wire_node_id();
|
||||
let fd = proxy
|
||||
.open_pipe_wire_remote(&session, Default::default())
|
||||
.await
|
||||
.context("open_pipe_wire_remote")?;
|
||||
// Bounded for the same reason as `hyprland.rs`'s copy (the long note lives there): an
|
||||
// await on a wedged portal never returns, the `stop` flag is only read by the park loop
|
||||
// further down, so the thread leaks — and a leaked half-handshake poisons every later
|
||||
// portal request from this process. xdpw has the identical unbounded node-id spin as
|
||||
// xdph (`screencast.c`), so it can wedge the same way.
|
||||
let handshake = async {
|
||||
let session = proxy
|
||||
.create_session(Default::default())
|
||||
.await
|
||||
.context("create_session")?;
|
||||
proxy
|
||||
.select_sources(
|
||||
&session,
|
||||
SelectSourcesOptions::default()
|
||||
.set_cursor_mode(cursor_mode.to_ashpd())
|
||||
// xdpw offers MONITOR only; the chooser picks our output.
|
||||
.set_sources(BitFlags::from_flag(SourceType::Monitor))
|
||||
.set_multiple(false)
|
||||
.set_persist_mode(PersistMode::DoNot),
|
||||
)
|
||||
.await
|
||||
.context("select_sources")?
|
||||
.response()
|
||||
.context("select_sources rejected")?;
|
||||
let streams = proxy
|
||||
.start(&session, None, Default::default())
|
||||
.await
|
||||
.context("start cast")?
|
||||
.response()
|
||||
.context(
|
||||
"start response (chooser declined? check the xdpw config/chooser file)",
|
||||
)?;
|
||||
let stream = streams
|
||||
.streams()
|
||||
.first()
|
||||
.context("portal returned no streams")?
|
||||
.clone();
|
||||
let node_id = stream.pipe_wire_node_id();
|
||||
let fd = proxy
|
||||
.open_pipe_wire_remote(&session, Default::default())
|
||||
.await
|
||||
.context("open_pipe_wire_remote")?;
|
||||
Ok::<_, anyhow::Error>((session, fd, node_id))
|
||||
};
|
||||
let (session, fd, node_id) =
|
||||
match tokio::time::timeout(HANDSHAKE_BUDGET, handshake).await {
|
||||
Ok(v) => v?,
|
||||
Err(_) => bail!(
|
||||
"the ScreenCast portal did not complete the handshake within {}s — \
|
||||
abandoning it instead of parking this thread on it forever (a hung \
|
||||
request poisons every later one from this process)",
|
||||
HANDSHAKE_BUDGET.as_secs()
|
||||
),
|
||||
};
|
||||
|
||||
setup_tx
|
||||
.send(Ok((fd, node_id)))
|
||||
.send(Ok((fd, node_id, cursor_mode)))
|
||||
.map_err(|_| anyhow!("virtual-output opener went away"))?;
|
||||
|
||||
// Park, keeping `proxy` + `session` (the zbus connection) alive until stopped —
|
||||
// the cast is torn down when the connection drops.
|
||||
// Park, keeping `proxy` + `session` alive until stopped. Polled at 20 ms rather than the
|
||||
// 200 ms this used to use, because teardown now WAITS on what follows.
|
||||
let _keep_alive = (&proxy, &session);
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
|
||||
// 🛑 CLOSE THE SESSION, AND CLOSE IT *BEFORE* THE OUTPUT IS UNPLUGGED. `Session.Close` is
|
||||
// the only thing that ends an xdpw session (`src/core/session.c`); dropping the
|
||||
// connection and trusting the peer to notice is not the contract. The caller is blocked
|
||||
// in `StopGuard::drop` on the signal below — see `StopGuard`. Bounded, so an
|
||||
// already-wedged portal cannot hang teardown with it.
|
||||
match tokio::time::timeout(CAST_CLOSE_BUDGET, session.close()).await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => tracing::warn!(
|
||||
error = %e,
|
||||
"closing the ScreenCast session failed — the next cast may find the portal busy"
|
||||
),
|
||||
Err(_) => tracing::warn!(
|
||||
budget_s = CAST_CLOSE_BUDGET.as_secs(),
|
||||
"the ScreenCast portal did not answer Session.Close in time — it is probably \
|
||||
already wedged"
|
||||
),
|
||||
}
|
||||
// Release the teardown. Best-effort: the receiver is gone if the caller already gave up.
|
||||
let _ = closed_tx.send(());
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
@@ -31,6 +31,12 @@ use anyhow::{bail, Context, Result};
|
||||
pub(crate) struct MirrorStream {
|
||||
pub node_id: u32,
|
||||
pub remote_fd: Option<std::os::fd::OwnedFd>,
|
||||
/// The cursor mode the xdg ScreenCast portal NEGOTIATED for this recording, for the two
|
||||
/// portal-based backends; `None` for the compositor-protocol ones (KWin/Mutter/gamescope),
|
||||
/// which get what they ask for. Reported on to the host as
|
||||
/// [`VirtualDisplay::last_portal_cursor_mode`] — same split as `remote_fd` above, and for the
|
||||
/// same reason: only the portal path has an answer that can differ from the request.
|
||||
pub cursor_mode: Option<crate::portal_cursor::Mode>,
|
||||
/// Dropping this ends the recording. It never owns the monitor — we did not create it.
|
||||
pub keepalive: Box<dyn Send>,
|
||||
}
|
||||
@@ -40,6 +46,9 @@ pub struct MirrorDisplay {
|
||||
compositor: Compositor,
|
||||
connector: String,
|
||||
hw_cursor: bool,
|
||||
/// What the portal gave the most recent [`create`](VirtualDisplay::create), when this mirror
|
||||
/// delegated to a portal-based backend. See [`VirtualDisplay::last_portal_cursor_mode`].
|
||||
last_cursor_mode: Option<crate::portal_cursor::Mode>,
|
||||
}
|
||||
|
||||
impl MirrorDisplay {
|
||||
@@ -48,6 +57,7 @@ impl MirrorDisplay {
|
||||
compositor,
|
||||
connector,
|
||||
hw_cursor: false,
|
||||
last_cursor_mode: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -65,6 +75,10 @@ impl VirtualDisplay for MirrorDisplay {
|
||||
self.hw_cursor
|
||||
}
|
||||
|
||||
fn last_portal_cursor_mode(&self) -> Option<crate::PortalCursorMode> {
|
||||
self.last_cursor_mode
|
||||
}
|
||||
|
||||
fn poolable_now(&self) -> bool {
|
||||
// Never. `create` below always reports `DisplayOwnership::External` — we did not make this
|
||||
// head and must not keep it — so the registry never pools a mirror, and the trait's `true`
|
||||
@@ -125,10 +139,15 @@ impl VirtualDisplay for MirrorDisplay {
|
||||
),
|
||||
};
|
||||
|
||||
// Latched for `last_portal_cursor_mode` — the delegate's verdict is this mirror's verdict.
|
||||
self.last_cursor_mode = stream.cursor_mode;
|
||||
|
||||
// NOTE: aiming absolute input at this head is the HOST's job, not ours — this crate must
|
||||
// not depend on pf-inject (see the crate doc: "never on capture/inject"). The host sets the
|
||||
// anchor from the same pin at startup; §7.2 of the design doc explains why it is host-level
|
||||
// rather than set here per session.
|
||||
// rather than set here per session. We only CARRY the head's name out (`output_name`
|
||||
// below), which is what the wlr injector needs to bind its virtual pointer to this head —
|
||||
// the libei anchor above cannot serve it, because that backend selects by region.
|
||||
tracing::info!(
|
||||
connector = %target.connector,
|
||||
mode = %target.mode_label(),
|
||||
@@ -145,6 +164,9 @@ impl VirtualDisplay for MirrorDisplay {
|
||||
out.remote_fd = stream.remote_fd;
|
||||
// Never pooled, never lingered, never made primary/exclusive: we don't own this head.
|
||||
out.ownership = DisplayOwnership::External;
|
||||
// The head absolute input maps into is the one we mirror — its connector IS its
|
||||
// `wl_output.name` on the wlroots/Hyprland backends, where the injector matches on it.
|
||||
out.output_name = Some(target.connector.clone());
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,6 +302,14 @@ mod pool {
|
||||
pub(super) keepalive: Box<dyn Send>,
|
||||
pub(super) node_id: u32,
|
||||
pub(super) preferred_mode: Option<(u32, u32, u32)>,
|
||||
/// The compositor's name for this output ([`VirtualOutput::output_name`]) — the identity the
|
||||
/// host aims absolute input with. Kept across a keep-alive reuse for the same reason
|
||||
/// `preferred_mode` is: the reused display IS the same head, so the output the caller is
|
||||
/// handed must answer with the same name a fresh create would. No poolable backend sets it
|
||||
/// today — the ones that do are all passed through unpooled (Hyprland/sway carry a portal
|
||||
/// fd, a mirror is `External`) — so this only exists so that stops being a silent trap the
|
||||
/// day one does.
|
||||
pub(super) output_name: Option<String>,
|
||||
pub(super) mode: Mode,
|
||||
pub(super) backend: &'static str,
|
||||
/// The identity slot the backend resolved for this display (KWin per-slot naming; `None` for
|
||||
@@ -601,6 +609,7 @@ mod pool {
|
||||
keepalive: Box::new(()),
|
||||
node_id: 0,
|
||||
preferred_mode: None,
|
||||
output_name: None,
|
||||
mode: Mode {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
@@ -1083,6 +1092,7 @@ mod linux {
|
||||
fn output_for(
|
||||
node_id: u32,
|
||||
preferred_mode: Option<(u32, u32, u32)>,
|
||||
output_name: Option<String>,
|
||||
generation: u64,
|
||||
quit: Arc<AtomicBool>,
|
||||
reused: bool,
|
||||
@@ -1093,6 +1103,8 @@ mod linux {
|
||||
preferred_mode,
|
||||
Box::new(DisplayLease { generation, quit }),
|
||||
);
|
||||
// The head is the same one the entry was created for, so it answers with the same name.
|
||||
out.output_name = output_name;
|
||||
// A2: tell the pipeline builder this was a REUSED kept display, so a first-frame failure can
|
||||
// `mark_failed(generation)` (tear the corpse down) rather than re-wedge the retry loop on the same node.
|
||||
out.reused_gen = reused.then_some(generation);
|
||||
@@ -1176,6 +1188,7 @@ mod linux {
|
||||
let generation = r.generation.fetch_add(1, Ordering::Relaxed);
|
||||
es[idx].generation = generation;
|
||||
let preferred_mode = es[idx].preferred_mode;
|
||||
let output_name = es[idx].output_name.clone();
|
||||
tracing::info!(
|
||||
backend,
|
||||
node_id,
|
||||
@@ -1184,6 +1197,7 @@ mod linux {
|
||||
ReuseOutcome::Reused(output_for(
|
||||
node_id,
|
||||
preferred_mode,
|
||||
output_name,
|
||||
generation,
|
||||
quit.clone(),
|
||||
true,
|
||||
@@ -1279,6 +1293,7 @@ mod linux {
|
||||
|
||||
let node_id = real.node_id;
|
||||
let preferred_mode = real.preferred_mode;
|
||||
let output_name = real.output_name.clone();
|
||||
// Fresh creates only: the backend may have birthed the output at a sacrificial mode whose
|
||||
// stream must renegotiate before frames count (KWin >60 Hz — see backend.rs). A REUSED kept
|
||||
// display already renegotiated in its prior session (the producer's rebuilt offer persists
|
||||
@@ -1295,6 +1310,7 @@ mod linux {
|
||||
keepalive: real.keepalive,
|
||||
node_id,
|
||||
preferred_mode,
|
||||
output_name: output_name.clone(),
|
||||
mode,
|
||||
backend,
|
||||
identity_slot,
|
||||
@@ -1349,7 +1365,14 @@ mod linux {
|
||||
if (position.x, position.y) != (0, 0) {
|
||||
vd.apply_position(position.x, position.y);
|
||||
}
|
||||
let mut out = output_for(node_id, preferred_mode, generation, quit, false);
|
||||
let mut out = output_for(
|
||||
node_id,
|
||||
preferred_mode,
|
||||
output_name,
|
||||
generation,
|
||||
quit,
|
||||
false,
|
||||
);
|
||||
out.expect_exact_dims = expect_exact_dims;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -1811,6 +1811,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex7(
|
||||
observed_sha256_out,
|
||||
client_cert_pem,
|
||||
client_key_pem,
|
||||
std::ptr::null(), // pre-v21 variant: no device name, so the OS default stands
|
||||
timeout_ms,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
@@ -1873,6 +1874,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex8(
|
||||
observed_sha256_out,
|
||||
client_cert_pem,
|
||||
client_key_pem,
|
||||
std::ptr::null(), // pre-v21 variant: no device name, so the OS default stands
|
||||
timeout_ms,
|
||||
status_out,
|
||||
)
|
||||
@@ -1935,6 +1937,79 @@ pub unsafe extern "C" fn punktfunk_connect_ex9(
|
||||
observed_sha256_out,
|
||||
client_cert_pem,
|
||||
client_key_pem,
|
||||
std::ptr::null(), // pre-v21 variant: no device name, so the OS default stands
|
||||
timeout_ms,
|
||||
status_out,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`punktfunk_connect_ex9`], plus `device_name` (ABI v21): the human-readable label this
|
||||
/// device knocks with — what the host's **pending-approval** list (and the web console's
|
||||
/// outstanding-pairings view and its approve dialog) shows for an unpaired client, and what the
|
||||
/// trust store files it under once approved. Pass the name the user already recognises this
|
||||
/// device by: `Host.current().localizedName` on macOS, `UIDevice.current.name` on iOS/tvOS,
|
||||
/// `Settings.Global.DEVICE_NAME` on Android.
|
||||
///
|
||||
/// NULL / empty = the [`crate::client::device_name`] default, exactly as every earlier variant.
|
||||
/// That default is an OS hostname, which no Apple GUI process could reach until v21 — every one
|
||||
/// of them knocked as the literal "This device", so a console with three of them pending showed
|
||||
/// three identical rows. Longer than [`crate::quic::HELLO_NAME_MAX`] bytes of UTF-8 is truncated
|
||||
/// (on a character boundary) rather than rejected: a too-long label is a cosmetic problem, and
|
||||
/// failing a connect over it would be a much worse one.
|
||||
///
|
||||
/// # Safety
|
||||
/// Same as [`punktfunk_connect_ex9`]; `device_name`, when non-null, must be a NUL-terminated C
|
||||
/// string that stays valid for the duration of the call.
|
||||
#[cfg(feature = "quic")]
|
||||
#[unsafe(no_mangle)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub unsafe extern "C" fn punktfunk_connect_ex10(
|
||||
host: *const std::os::raw::c_char,
|
||||
port: u16,
|
||||
width: u32,
|
||||
height: u32,
|
||||
refresh_hz: u32,
|
||||
compositor: u32,
|
||||
gamepad: u32,
|
||||
bitrate_kbps: u32,
|
||||
video_caps: u8,
|
||||
audio_channels: u8,
|
||||
video_codecs: u8,
|
||||
preferred_codec: u8,
|
||||
client_caps: u8,
|
||||
launch_id: *const std::os::raw::c_char,
|
||||
pin_sha256: *const u8,
|
||||
observed_sha256_out: *mut u8,
|
||||
client_cert_pem: *const std::os::raw::c_char,
|
||||
client_key_pem: *const std::os::raw::c_char,
|
||||
device_name: *const std::os::raw::c_char,
|
||||
timeout_ms: u32,
|
||||
status_out: *mut i32,
|
||||
) -> *mut PunktfunkConnection {
|
||||
// SAFETY: the pointer arguments are forwarded UNCHANGED to the versioned entry point, which
|
||||
// applies the same ABI contract to them; this shim dereferences nothing itself.
|
||||
unsafe {
|
||||
connect_ex_impl(
|
||||
host,
|
||||
port,
|
||||
client_caps,
|
||||
width,
|
||||
height,
|
||||
refresh_hz,
|
||||
compositor,
|
||||
gamepad,
|
||||
bitrate_kbps,
|
||||
video_caps,
|
||||
audio_channels,
|
||||
video_codecs,
|
||||
preferred_codec,
|
||||
launch_id,
|
||||
pin_sha256,
|
||||
observed_sha256_out,
|
||||
client_cert_pem,
|
||||
client_key_pem,
|
||||
device_name,
|
||||
timeout_ms,
|
||||
status_out,
|
||||
)
|
||||
@@ -1958,9 +2033,27 @@ pub const PUNKTFUNK_CLIENT_CAP_PHASE_LOCK: u8 = 0x02;
|
||||
/// with [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`]. (Mirrors `quic::CLIENT_CAP_PAD_AUDIO`.)
|
||||
pub const PUNKTFUNK_CLIENT_CAP_PAD_AUDIO: u8 = 0x08;
|
||||
|
||||
/// A [`punktfunk_connect_ex10`] device name cut to what a [`crate::quic::Hello`] carries.
|
||||
/// [`crate::quic::HELLO_NAME_MAX`] is a BYTE cap while the cut must land on a character
|
||||
/// boundary — "Wohnzimmer-Fernseher überm Sofa" is 33 characters and 34 bytes, and slicing a
|
||||
/// name mid-scalar panics. Too long is truncated rather than rejected: the wire encoder would
|
||||
/// truncate it anyway, and failing a connect over a cosmetic label would be far worse than
|
||||
/// showing a shortened one.
|
||||
#[cfg(feature = "quic")]
|
||||
fn clamp_device_name(s: &str) -> String {
|
||||
let end = s
|
||||
.char_indices()
|
||||
.map(|(i, c)| i + c.len_utf8())
|
||||
.take_while(|&i| i <= crate::quic::HELLO_NAME_MAX)
|
||||
.last()
|
||||
.unwrap_or(0);
|
||||
s[..end].to_string()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// `InvalidArg` for bad arguments, `Panic` if the connect panicked. `device_name` (nullable,
|
||||
/// [`punktfunk_connect_ex10`]) is the label this device knocks with; null = the OS default.
|
||||
#[cfg(feature = "quic")]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
unsafe fn connect_ex_impl(
|
||||
@@ -1982,6 +2075,7 @@ unsafe fn connect_ex_impl(
|
||||
observed_sha256_out: *mut u8,
|
||||
client_cert_pem: *const std::os::raw::c_char,
|
||||
client_key_pem: *const std::os::raw::c_char,
|
||||
device_name: *const std::os::raw::c_char,
|
||||
timeout_ms: u32,
|
||||
status_out: *mut i32,
|
||||
) -> *mut PunktfunkConnection {
|
||||
@@ -2013,6 +2107,16 @@ unsafe fn connect_ex_impl(
|
||||
Ok(Some(s)) if !s.is_empty() => Some(s.to_string()),
|
||||
_ => None,
|
||||
};
|
||||
// The label the host's pending-approval list shows. Same non-fatal treatment as `launch`:
|
||||
// an absent / empty / bad-UTF-8 name falls back to the OS default rather than failing a
|
||||
// connect over a cosmetic field. Truncation is on a CHARACTER boundary — `HELLO_NAME_MAX`
|
||||
// is a byte cap, and slicing a multi-byte name mid-scalar would panic.
|
||||
// SAFETY: per the ABI contract - a caller-supplied C string, NUL-terminated or null,
|
||||
// borrowed only for this call.
|
||||
let name = match unsafe { opt_cstr(device_name) } {
|
||||
Ok(Some(s)) if !s.trim().is_empty() => clamp_device_name(s.trim()),
|
||||
_ => crate::client::device_name(),
|
||||
};
|
||||
let mode = crate::config::Mode {
|
||||
width,
|
||||
height,
|
||||
@@ -2069,15 +2173,15 @@ unsafe fn connect_ex_impl(
|
||||
client_caps,
|
||||
// The C ABI cannot carry slice-progressive parts yet — `PunktfunkFrame` has no
|
||||
// part/completeness fields, so a part would be indistinguishable from a whole AU.
|
||||
// An `ex10` variant adds the opt-in together with those fields when an ABI embedder
|
||||
// An `ex11` variant adds the opt-in together with those fields when an ABI embedder
|
||||
// (Apple) grows a partial-feed decode path.
|
||||
false,
|
||||
launch,
|
||||
// The C ABI has no device-name parameter (only `punktfunk_pair` takes one), so every
|
||||
// embedder gets the OS hostname default — this is what the host's pending-approval
|
||||
// list shows when an unpaired embedder knocks. An `ex10` variant can make it explicit
|
||||
// if an embedder ever wants a custom label (e.g. the platform's marketing name).
|
||||
Some(crate::client::device_name()),
|
||||
// What the host's pending-approval list shows when this embedder knocks unpaired, and
|
||||
// the trust-store label on approval. [`punktfunk_connect_ex10`]'s `device_name` when
|
||||
// the embedder supplied one (the name the USER knows the device by — an Apple app has
|
||||
// it and the OS default cannot reach it), else that OS default.
|
||||
Some(name),
|
||||
pin,
|
||||
identity,
|
||||
std::time::Duration::from_millis(timeout_ms as u64),
|
||||
@@ -4940,6 +5044,29 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_is_holding(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The `ex10` device name is cut to the Hello's BYTE budget on a CHARACTER boundary — the
|
||||
/// naive `s[..HELLO_NAME_MAX]` panics on any multi-byte name that straddles it, and an
|
||||
/// operator naming a device in German or Japanese is not an edge case.
|
||||
#[test]
|
||||
fn device_name_truncates_on_a_character_boundary() {
|
||||
let max = crate::quic::HELLO_NAME_MAX;
|
||||
assert_eq!(clamp_device_name("Enrico's iPad"), "Enrico's iPad");
|
||||
|
||||
// Straddling: 2-byte characters over an odd-length prefix, so the cap lands mid-scalar.
|
||||
let straddle = format!("{}{}", "x".repeat(max - 1), "ü".repeat(4));
|
||||
let cut = clamp_device_name(&straddle);
|
||||
assert!(cut.len() <= max, "{} bytes exceeds the cap", cut.len());
|
||||
assert_eq!(
|
||||
cut,
|
||||
"x".repeat(max - 1),
|
||||
"must drop the whole ü, not half of it"
|
||||
);
|
||||
|
||||
// A name whose FIRST character already exceeds the cap has nothing to keep — the
|
||||
// `unwrap_or(0)` path, which must yield "" rather than panicking on an empty iterator.
|
||||
assert_eq!(clamp_device_name(&"あ".repeat(max)), "あ".repeat(max / 3));
|
||||
}
|
||||
|
||||
/// A C embedder writing `ev->kind = 42` must come back as a status code, not UB. The test
|
||||
/// stages the event in `MaybeUninit` storage so no `&InputEvent` to an invalid value ever
|
||||
/// exists on the test's own side either.
|
||||
|
||||
@@ -479,8 +479,16 @@ fn register_hot_tid(reg: &Mutex<Vec<i32>>) {
|
||||
/// This machine's name — the default value for [`NativeClient::connect`]'s `name` parameter
|
||||
/// (what a host shows in its pending-approval list and files this client under when approved).
|
||||
/// `/etc/hostname` first (the answer on any Linux box, and available in a minimal build with no
|
||||
/// desktop toolkit to ask), then the usual environment fallbacks. Lives here (not in a client
|
||||
/// shell crate) so the C ABI's `punktfunk_connect` can share the same default.
|
||||
/// desktop toolkit to ask), then the usual environment fallbacks, then the OS hostname itself.
|
||||
/// Lives here (not in a client shell crate) so the C ABI's `punktfunk_connect` can share the
|
||||
/// same default.
|
||||
///
|
||||
/// The `gethostname` step is what saves the GUI clients: **no** Apple app has `COMPUTERNAME`
|
||||
/// (Windows-only) or `HOSTNAME` (a shell variable — never exported into a `launchd`-started
|
||||
/// process) in its environment, so before it every Mac, iPad, iPhone and Apple TV knocked as
|
||||
/// the literal "This device" and the console's pending list could not tell them apart. An
|
||||
/// embedder that knows a better, user-facing name should pass it explicitly instead
|
||||
/// ([`crate::abi::punktfunk_connect_ex10`]'s `device_name`) — this is only the floor.
|
||||
pub fn device_name() -> String {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Ok(s) = std::fs::read_to_string("/etc/hostname") {
|
||||
@@ -493,9 +501,36 @@ pub fn device_name() -> String {
|
||||
.or_else(|_| std::env::var("HOSTNAME"))
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.or_else(os_hostname)
|
||||
.unwrap_or_else(|| "This device".into())
|
||||
}
|
||||
|
||||
/// The OS hostname (`gethostname`), or `None` when it is missing/unset/useless. macOS returns
|
||||
/// the user's computer name as an mDNS host label ("Enricos-MacBook-Pro.local"), iOS/tvOS the
|
||||
/// device name — so the `.local` suffix comes off, and the placeholder answers every platform
|
||||
/// gives when nothing is configured ("localhost") is rejected: it labels nothing.
|
||||
#[cfg(unix)]
|
||||
fn os_hostname() -> Option<String> {
|
||||
let mut buf = [0u8; 256];
|
||||
// SAFETY: `gethostname` writes at most `len` bytes into the caller's buffer; this one is a
|
||||
// stack array we own and pass its true length. A truncating write may omit the NUL, which
|
||||
// the `position` fallback below covers.
|
||||
if unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) } != 0 {
|
||||
return None;
|
||||
}
|
||||
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
|
||||
let s = std::str::from_utf8(&buf[..end]).ok()?.trim();
|
||||
let s = s.strip_suffix(".local").unwrap_or(s);
|
||||
(!s.is_empty() && !s.eq_ignore_ascii_case("localhost")).then(|| s.to_string())
|
||||
}
|
||||
|
||||
/// Windows has no `gethostname` without linking winsock (and `COMPUTERNAME` is always set there
|
||||
/// anyway, so the env step above never falls through to this).
|
||||
#[cfg(not(unix))]
|
||||
fn os_hostname() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
impl NativeClient {
|
||||
/// Connect to a `punktfunk/1` host and start the session at (up to) `mode`. Blocks until the
|
||||
/// handshake completes or `timeout` elapses.
|
||||
|
||||
@@ -185,7 +185,17 @@ pub use stats::Stats;
|
||||
/// every existing function keeps its signature and behaviour, and an embedder that never calls it
|
||||
/// is unchanged. The `Welcome` grew a trailing field, which older peers skip in both directions
|
||||
/// (see `Welcome::encode`), so [`WIRE_VERSION`] is unchanged.
|
||||
pub const ABI_VERSION: u32 = 20;
|
||||
/// v21: added `punktfunk_connect_ex10` — `connect_ex9` plus `device_name`, the label an unpaired
|
||||
/// client knocks with: what the host's pending-approval list (and the web console's
|
||||
/// outstanding-pairings view and approve dialog) shows, and the trust-store name on approval. The
|
||||
/// C ABI had no such parameter, so every embedder took [`client::device_name`]'s OS default —
|
||||
/// which resolves through `COMPUTERNAME`/`HOSTNAME`, neither of which exists in an Apple GUI
|
||||
/// process, leaving every Mac, iPad, iPhone and Apple TV knocking as the literal "This device"
|
||||
/// (a console with three of them pending showed three identical rows). A NEW symbol, not a
|
||||
/// widened one: `ex9` keeps its parameter list AND its behaviour — it passes a null name, which
|
||||
/// selects that same default. Additive and client-local: the name rides the `Hello::name` field
|
||||
/// hosts have read since the pending list existed, so [`WIRE_VERSION`] is unchanged.
|
||||
pub const ABI_VERSION: u32 = 21;
|
||||
|
||||
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
|
||||
@@ -135,6 +135,17 @@ pub fn capture_virtual_output(
|
||||
// handshake already resolved that through [`capturer_supports_hdr_for`] before the Welcome,
|
||||
// so passing it through here is the whole of this arm's HDR logic. It used to be dropped on
|
||||
// the floor, which is what kept the Linux native plane at 8 bits.
|
||||
//
|
||||
// Aim the wlr injector's absolute mapping (abs-mouse, and `park_pointer`'s opening warp) at
|
||||
// THIS head — the Linux counterpart of the `set_stream_target` call in the Windows arm below.
|
||||
// The wlroots virtual pointer maps `motion_absolute` onto the `wl_output` it was created with,
|
||||
// and on the EXTEND backends (Hyprland, sway) the streamed head sits BESIDE the operator's, so
|
||||
// without this every absolute sample landed on their screen and the cursor never entered the
|
||||
// stream at all. `None` (KWin/Mutter/gamescope, none of which inject through that backend)
|
||||
// CLEARS the slot rather than leaving a stale name: one compositor serves the whole host, so a
|
||||
// `None` here means no session on this host wants a named binding — e.g. a Game-Mode switch
|
||||
// from a Hyprland desktop to gamescope, after which the old `PF-…` name means nothing.
|
||||
crate::inject::set_stream_output(vout.output_name.clone());
|
||||
pf_capture::open_virtual_output(
|
||||
vout.remote_fd,
|
||||
vout.node_id,
|
||||
|
||||
@@ -1347,8 +1347,19 @@ pub(super) struct SessionContext {
|
||||
/// its embedded mode paints nothing either) the stream has NO cursor at all, in both the
|
||||
/// embedded and the cursor-channel composite models. Parking once per (re)built display — and
|
||||
/// again on the mid-stream flip to the capture model, which heals a pointer that drifted off the
|
||||
/// output's edge — pins the pointer to the surface the client actually sees. A desktop-model
|
||||
/// client overrides it with its first absolute move, so the jump is invisible in practice.
|
||||
/// output's edge — pins the pointer to the surface the client actually sees.
|
||||
///
|
||||
/// **Retried only for a relative-only client.** The schedule used to repeat this for every session,
|
||||
/// on the theory that "a desktop-model client overrides it with its first absolute move, so the
|
||||
/// jump is invisible in practice". One park at bring-up is; a *repeat* is not. A desktop-model
|
||||
/// client sends absolute positions — the very same [`MouseMoveAbs`] this synthesizes, only aimed
|
||||
/// where the user is actually pointing — so once those are flowing the park has nothing left to add
|
||||
/// and every later attempt is a visible yank to centre that fights them (field report 2026-08-14).
|
||||
/// The schedule below therefore keeps the single bring-up park for such a client (so its first
|
||||
/// click cannot land on the monitor the seat pointer was left on) and drops the retry; the
|
||||
/// capture-model flip re-arms the full schedule.
|
||||
///
|
||||
/// [`MouseMoveAbs`]: punktfunk_core::input::InputKind::MouseMoveAbs
|
||||
#[cfg(target_os = "linux")]
|
||||
fn park_pointer(input_tx: &std::sync::mpsc::SyncSender<super::input::ClientInput>, w: u32, h: u32) {
|
||||
let ev = punktfunk_core::input::InputEvent {
|
||||
@@ -1375,6 +1386,65 @@ fn park_pointer(input_tx: &std::sync::mpsc::SyncSender<super::input::ClientInput
|
||||
}
|
||||
}
|
||||
|
||||
/// Settle this session's cursor plan against the cursor mode the display's portal ACTUALLY
|
||||
/// negotiated — THE rule, shared by bring-up and the capture-loss rebuild so the two cannot drift.
|
||||
///
|
||||
/// Returns whether "the capture has no cursor overlay" still MEANS anything on this display, and
|
||||
/// clears `metadata_composite` when the negotiated mode makes it a fiction. Both answers come from
|
||||
/// the same fact, which is why they are settled together.
|
||||
///
|
||||
/// The fact: [`set_hw_cursor`] is a *request*. On the whole wlr family (xdph, xdpw) the portal
|
||||
/// advertises `Hidden|Embedded` — measured, `AvailableCursorModes = 3`, on current packages — so a
|
||||
/// session that asked for metadata is served **Embedded**: the compositor paints the pointer into
|
||||
/// the frames and sends no `SPA_META_Cursor`, ever, wherever the pointer is. That breaks two
|
||||
/// downstream beliefs at once:
|
||||
///
|
||||
/// * the host planned a metadata composite it can never feed (the stream logs "host-composite
|
||||
/// active but the capture has no live cursor overlay yet" forever, and shows no host pointer —
|
||||
/// the compositor's own burnt-in one is the real cursor there), and
|
||||
/// * the seat-pointer park schedule reads "no overlay" as "the pointer has not reached the streamed
|
||||
/// output". That inference is sound on **Mutter**, which suppresses cursor metadata while the
|
||||
/// pointer is off the recorded view (`should_cursor_metadata_be_set`) — it is the signal
|
||||
/// [`park_pointer`] was built on — and it is pure noise under Embedded, where the host then
|
||||
/// re-centres the user's pointer once a second for the whole cap, fighting every mouse movement
|
||||
/// (field report 2026-08-14, Hyprland).
|
||||
///
|
||||
/// A backend that reports no negotiated mode (`None` — KWin, Mutter, gamescope, Windows) is served
|
||||
/// the cursor mode it asked for through its own protocol, so nothing here applies and both answers
|
||||
/// stay exactly as they were.
|
||||
///
|
||||
/// What this does NOT undo is [`SessionPlan::cursor_blend`]: the blend capability is resolved
|
||||
/// before any display exists, and the negotiation only happens inside `create`, so a session that
|
||||
/// lands on Embedded still captures RGB for a blend stage it will never be handed an overlay for.
|
||||
/// That costs a colour conversion, not correctness — and pre-judging it would mean re-asserting
|
||||
/// what the wlr portals advertise, which is exactly the hardcode `pf_vdisplay::portal_cursor`
|
||||
/// exists to have deleted.
|
||||
///
|
||||
/// [`set_hw_cursor`]: pf_vdisplay::VirtualDisplay::set_hw_cursor
|
||||
/// [`SessionPlan::cursor_blend`]: crate::session_plan::SessionPlan::cursor_blend
|
||||
#[cfg(target_os = "linux")]
|
||||
fn settle_portal_cursor(
|
||||
vd: &dyn crate::vdisplay::VirtualDisplay,
|
||||
metadata_composite: &mut bool,
|
||||
) -> bool {
|
||||
let Some(negotiated) = vd.last_portal_cursor_mode() else {
|
||||
return true;
|
||||
};
|
||||
if negotiated.delivers_metadata() {
|
||||
return true;
|
||||
}
|
||||
if *metadata_composite {
|
||||
*metadata_composite = false;
|
||||
tracing::info!(
|
||||
negotiated = negotiated.name(),
|
||||
"the portal negotiated a cursor mode that carries no cursor metadata — dropping the \
|
||||
host composite; the pointer in this stream is the compositor's own, burnt into the \
|
||||
frames"
|
||||
);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDisplay>) -> Result<()> {
|
||||
// This thread runs the capture+encode loop (single-process — the only topology: Linux portal /
|
||||
// synthetic, Windows in-process IDD-push). Elevate it so a CPU-heavy game can't deschedule our GPU
|
||||
@@ -1705,6 +1775,11 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
mut cur_display_gen,
|
||||
built_bitrate,
|
||||
) = pipe;
|
||||
// The display exists now, so the portal has answered: settle the cursor plan against what it
|
||||
// actually negotiated rather than what this session asked for (see `settle_portal_cursor`).
|
||||
// `mut`: every capture-loss rebuild re-runs `create`, hence re-negotiates.
|
||||
#[cfg(target_os = "linux")]
|
||||
let mut no_overlay_means_off_output = settle_portal_cursor(&*vd, &mut metadata_composite);
|
||||
// The encoder may have opened at a re-resolved rate (a mirrored head delivering a size this
|
||||
// session never negotiated). Adopt it before anything downstream reads `bitrate_kbps`.
|
||||
adopt_built_bitrate(
|
||||
@@ -2064,12 +2139,15 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// compositing), NOT an encoder problem. Logged every 2 s when `PUNKTFUNK_PERF`.
|
||||
let (mut diag_new, mut diag_repeat) = (0u64, 0u64);
|
||||
// Seat-pointer park schedule (see `park_pointer`): per (re)built display, and re-armed by
|
||||
// the capture-model flip. More than one attempt because the first park of a session can
|
||||
// land on a still-cold EIS connection (devices not yet resumed → the injector DROPS it) —
|
||||
// observed on-glass; the retry a second later goes through. While the session is in the
|
||||
// the capture-model flip. More than one attempt for a RELATIVE-ONLY session, because the
|
||||
// first park can land on a still-cold EIS connection (devices not yet resumed → the injector
|
||||
// DROPS it) — observed on-glass; the retry a second later goes through. A client that steers
|
||||
// the pointer itself gets the bring-up park only: its own absolute moves are the retry, and
|
||||
// a synthetic one on top of them is just a yank to centre. While the session is in the
|
||||
// capture model with no live cursor overlay, keep trying up to the cap: no overlay there
|
||||
// means the pointer still isn't on the streamed output, and a relative-only client can
|
||||
// never fix that itself.
|
||||
// never fix that itself — but only where an absent overlay is evidence of anything at all
|
||||
// (`no_overlay_means_off_output`, settled per display by `settle_portal_cursor`).
|
||||
#[cfg(target_os = "linux")]
|
||||
let mut parked_display = None;
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -3033,6 +3111,17 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
interval = new_interval;
|
||||
cur_node_id = new_node_id;
|
||||
cur_display_gen = new_display_gen;
|
||||
// The rebuild re-ran `create`, so the portal answered again — possibly a different
|
||||
// backend's portal (the retarget above), possibly with a different verdict. Settle
|
||||
// the cursor plan against THIS display, exactly as bring-up did: the retarget arm
|
||||
// recomputes `metadata_composite` from the compositor alone (it has to — it runs
|
||||
// BEFORE the rebuild, to set `hw_cursor`), so without this a switch onto a wlr
|
||||
// backend would re-arm both defects mid-session.
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
no_overlay_means_off_output =
|
||||
settle_portal_cursor(&*vd, &mut metadata_composite);
|
||||
}
|
||||
// A capture-loss rebuild can land on a different source than it lost (this loop
|
||||
// re-detects the session every cycle, precisely so it can follow a switch), so the
|
||||
// delivered size — and with it an Automatic rate — may have changed under us.
|
||||
@@ -3136,10 +3225,15 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
None => {
|
||||
if !composite_saw_none {
|
||||
composite_saw_none = true;
|
||||
// NOT necessarily "cursorless", which this line used to assert:
|
||||
// where the portal negotiated Embedded (the whole wlr family) no
|
||||
// `SPA_META_Cursor` is ever sent and the compositor's own pointer
|
||||
// is already in the pixels — the host blend has nothing to add.
|
||||
// `settle_portal_cursor` logs which of the two this session is.
|
||||
tracing::info!(
|
||||
"host-composite active but the capture has no live cursor \
|
||||
overlay yet (no SPA_META_Cursor bitmap) — the stream is \
|
||||
cursorless until one arrives"
|
||||
overlay (no SPA_META_Cursor bitmap) — nothing for the encoder \
|
||||
blend to draw; the pointer, if any, is the compositor's own"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3189,14 +3283,32 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
frame.cursor = None;
|
||||
}
|
||||
// The seat-pointer park schedule (state + rationale at the declarations above; armed by
|
||||
// the first frame of every (re)built display and by the capture-model flip). The first
|
||||
// two attempts run unconditionally — attempt 1 can be swallowed by a cold EIS
|
||||
// connection. Past those, only a host-composite session that STILL has no live overlay
|
||||
// keeps trying — a channel session in the capture model, or a no-channel
|
||||
// metadata-composite session (both relative-only): no overlay there means the pointer
|
||||
// has not reached the streamed output (the compositor reports cursor metadata only
|
||||
// while it is over the recorded view), and a relative-only client cannot get it there
|
||||
// on its own.
|
||||
// the first frame of every (re)built display and by the capture-model flip).
|
||||
//
|
||||
// Two unconditional attempts for a RELATIVE-ONLY session — attempt 1 can be swallowed by
|
||||
// a cold EIS connection, and nothing else will ever move that pointer onto the output.
|
||||
// Exactly ONE for a client that steers the seat pointer itself (a channel client in the
|
||||
// desktop model): the park still runs once at bring-up, so the session's first click
|
||||
// cannot land on whatever monitor the seat pointer was left on, but the retry a second
|
||||
// later is dropped — by then the client's own absolute moves are doing this job, better
|
||||
// (same event, aimed where the user is actually pointing), and the retry is a visible
|
||||
// yank to centre that fights them (field report 2026-08-14; see `park_pointer`). A cold
|
||||
// EIS swallows the client's moves too, and those keep coming, so it needs no retry.
|
||||
//
|
||||
// Past the unconditional attempts, only a host-composite session that STILL has no live
|
||||
// overlay keeps trying — a channel session in the capture model, or a no-channel
|
||||
// metadata-composite session: no overlay there means the pointer has not reached the
|
||||
// streamed output (the compositor reports cursor metadata only while it is over the
|
||||
// recorded view), and a relative-only client cannot get it there on its own.
|
||||
//
|
||||
// ...but ONLY where that inference holds — `no_overlay_means_off_output`, settled per
|
||||
// display by `settle_portal_cursor`. Under a portal that negotiated Embedded no cursor
|
||||
// metadata is EVER sent, so "no overlay" says nothing about where the pointer is, and
|
||||
// this heuristic re-centred the user's pointer for the full cap on every Hyprland/sway
|
||||
// session (the same field report). Those sessions still get the two unconditional
|
||||
// attempts, which are what puts the pointer — and thus the compositor's burnt-in cursor,
|
||||
// and the client's input — on the streamed output in the first place.
|
||||
//
|
||||
// Armed from the loop's first tick — a static desktop may never deliver a fresh frame
|
||||
// (`parked_display` is only bookkeeping for rebuild re-arming), and the pointer must be
|
||||
// parked regardless.
|
||||
@@ -3205,17 +3317,22 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
&& park_attempts < PARK_ATTEMPTS_MAX
|
||||
&& std::time::Instant::now() >= next_park_at
|
||||
{
|
||||
let composite_starved = ((cursor_fwd.is_some()
|
||||
&& !cursor_client_draws.load(Ordering::Relaxed))
|
||||
let client_steers = cursor_fwd.is_some() && cursor_client_draws.load(Ordering::Relaxed);
|
||||
let unconditional = if client_steers { 1 } else { 2 };
|
||||
// Never true while `client_steers`: the channel term excludes it outright, and
|
||||
// `metadata_composite` implies no channel at all.
|
||||
let composite_starved = ((cursor_fwd.is_some() && !client_steers)
|
||||
|| metadata_composite)
|
||||
&& capturer.cursor().is_none();
|
||||
if park_attempts < 2 || composite_starved {
|
||||
&& capturer.cursor().is_none()
|
||||
&& no_overlay_means_off_output;
|
||||
if park_attempts < unconditional || composite_starved {
|
||||
park_pointer(&input_tx, frame.width, frame.height);
|
||||
park_attempts += 1;
|
||||
next_park_at = std::time::Instant::now() + std::time::Duration::from_secs(1);
|
||||
} else {
|
||||
// Settled (overlay flowing, or the client draws): stop scheduling until a
|
||||
// rebuild or a capture-model flip re-arms it.
|
||||
// Settled (the client steers, the overlay is flowing, or its absence carries no
|
||||
// information here): stop scheduling until a rebuild or a capture-model flip
|
||||
// re-arms it.
|
||||
park_attempts = PARK_ATTEMPTS_MAX;
|
||||
}
|
||||
}
|
||||
@@ -5115,4 +5232,65 @@ mod tests {
|
||||
"a +2 ms offset must shift the next target by +2 ms mod P, got {shift}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The 2026-08-14 Hyprland field report, in one function: xdph advertises `Hidden|Embedded`,
|
||||
/// so a session that asked for cursor metadata is served **Embedded** — no `SPA_META_Cursor`
|
||||
/// is ever sent, whatever the pointer does. The host must then (a) stop planning a metadata
|
||||
/// composite it can never feed, and (b) stop reading "no cursor overlay" as "the seat pointer
|
||||
/// is not on the streamed output" — the inference that re-centred the user's pointer once a
|
||||
/// second for the whole park cap.
|
||||
///
|
||||
/// The `None` case is the regression guard for GNOME: Mutter is served the cursor mode it
|
||||
/// asks for through its own protocol and DOES suppress metadata while the pointer is off the
|
||||
/// recorded view, which is the signal `park_pointer` exists for. Nothing here may touch it.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn an_embedded_portal_voids_both_the_composite_and_the_starvation_signal() {
|
||||
struct Fake(Option<pf_vdisplay::PortalCursorMode>);
|
||||
impl crate::vdisplay::VirtualDisplay for Fake {
|
||||
fn name(&self) -> &'static str {
|
||||
"fake"
|
||||
}
|
||||
fn create(
|
||||
&mut self,
|
||||
_mode: pf_vdisplay::Mode,
|
||||
) -> anyhow::Result<crate::vdisplay::VirtualOutput> {
|
||||
anyhow::bail!("this test never creates a display")
|
||||
}
|
||||
fn last_portal_cursor_mode(&self) -> Option<pf_vdisplay::PortalCursorMode> {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
// The whole wlr family, today: metadata wanted, Embedded served.
|
||||
let mut composite = true;
|
||||
assert!(!settle_portal_cursor(
|
||||
&Fake(Some(pf_vdisplay::PortalCursorMode::Embedded)),
|
||||
&mut composite
|
||||
));
|
||||
assert!(!composite, "the composite can never be fed — drop it");
|
||||
|
||||
// Hidden is the same story from the other end: no pointer, so no overlay, so no signal.
|
||||
let mut composite = true;
|
||||
assert!(!settle_portal_cursor(
|
||||
&Fake(Some(pf_vdisplay::PortalCursorMode::Hidden)),
|
||||
&mut composite
|
||||
));
|
||||
assert!(!composite);
|
||||
|
||||
// A portal that really does serve metadata (xdph ≥ #366, or the portal path on
|
||||
// KWin/Mutter): everything stays exactly as it was.
|
||||
let mut composite = true;
|
||||
assert!(settle_portal_cursor(
|
||||
&Fake(Some(pf_vdisplay::PortalCursorMode::Metadata)),
|
||||
&mut composite
|
||||
));
|
||||
assert!(composite);
|
||||
|
||||
// Not portal-negotiated at all — KWin `zkde_screencast`, Mutter `RecordVirtual`,
|
||||
// gamescope, Windows. THE no-regression case.
|
||||
let mut composite = true;
|
||||
assert!(settle_portal_cursor(&Fake(None), &mut composite));
|
||||
assert!(composite);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,8 +224,8 @@ regular pad). Automatic arms it only where the raw guide press can't reach the h
|
||||
Gaming Mode, iPhone/iPad, Apple TV — because the gesture has a cost: a Select *tap* arrives a beat
|
||||
late, and a game that expects a *held* Select would trigger it. Set **On** or **Off** to overrule.
|
||||
|
||||
**Capture system shortcuts** — *default: on.* Offered by the Linux and Windows apps and the console
|
||||
home; Windows spells the row out as *Capture system shortcuts (Alt+Tab, Win, …)*. On a Deck it
|
||||
**Capture system shortcuts** — *default: on.* Offered by the Linux, Windows and macOS apps and the
|
||||
console home; Windows spells the row out as *Capture system shortcuts (Alt+Tab, Win, …)*. On a Deck it
|
||||
matters only for a keyboard you attached yourself, for the reason the paragraph below gives: Gaming
|
||||
Mode is gamescope, which has nothing to hold back. On, Alt+Tab and the Windows key
|
||||
(Super on Linux) reach the host while the stream has input captured. Off, they act on this machine
|
||||
@@ -234,6 +234,14 @@ back the moment you release capture with **Ctrl+Alt+Shift+Q**, the window loses
|
||||
ends, and [Desktop mouse mode](/docs/input#mouse-modes) never takes them at all. Leaving this on does
|
||||
mean **Ctrl+Alt+Shift+Q is your way out** of a captured stream, since Alt+Tab no longer is.
|
||||
|
||||
On macOS the chords in question are the **⌘** ones — ⌘Q above all, which reaches the host as Super+Q,
|
||||
one of the most-bound chords on a Linux desktop. On, ⌘Q, ⌘W, ⌘H and the rest go to the host instead
|
||||
of this app's menu bar while input is captured. Off, they act on the Mac as usual, which means ⌘Q
|
||||
quits Punktfunk mid-stream. **⌘⎋ always stays local whichever way the toggle is set** — it is what
|
||||
releases capture, as is ⌃⌥⇧Q, and ⌃⌘F keeps working on the window. A few chords never reach the host
|
||||
either way, because macOS claims them before any app can see them: ⌘Tab, ⌘Space, and the Mission
|
||||
Control keys.
|
||||
|
||||
On Linux this needs a compositor that supports keyboard-shortcuts-inhibit — KDE Plasma, GNOME and
|
||||
the wlroots compositors all do, and X11 sessions grab the keyboard directly. Under
|
||||
[gamescope](/docs/gamescope) there is nothing to inhibit: it hands the session everything already.
|
||||
|
||||
@@ -68,6 +68,10 @@ to one readable line.
|
||||
**⌃⌥⇧Q / M / D / S** — but not the microphone mute. **⌘⎋** also toggles capture,
|
||||
**⌃⌘F** toggles fullscreen, and **⌃⌥⇧C** starts or stops [clipboard sharing](/docs/clipboard). The
|
||||
**Stream** menu lists them all except the mouse-mode combo, which works but has no menu item.
|
||||
Every *other* ⌘ chord goes to the host while input is captured — ⌘Q reaches the host's compositor
|
||||
rather than quitting the app — unless you turn **Capture system shortcuts** off in
|
||||
[client settings](/docs/client-settings#input). ⌘⎋ and ⌃⌘F are held back either way, so there is
|
||||
always a way out.
|
||||
- **iPhone and iPad** with a hardware keyboard: **⌃⌥⇧Q** releases input while it is captured, and
|
||||
**⌘⎋** toggles capture in either direction. **⌃⌥⇧D** (disconnect) and **⌃⌥⇧S** (stats) come from
|
||||
the app's Stream shortcuts rather than from the stream itself; if they don't respond while you're
|
||||
@@ -154,7 +158,8 @@ There are two, and they are a per-client setting called **Mouse input**:
|
||||
- **Capture (games)** — the pointer locks to the stream and only relative movement is sent. The only
|
||||
cursor you see is the host's. This is what mouse-look in a game needs. The session window also
|
||||
grabs the keyboard here, so Alt+Tab and the Windows key (Super on Linux) reach the host rather than
|
||||
your own desktop — turn **Capture system shortcuts** off in
|
||||
your own desktop — on macOS that is the ⌘ chords, ⌘Q included, with ⌘⎋ kept back as the way out.
|
||||
Turn **Capture system shortcuts** off in
|
||||
[client settings](/docs/client-settings#input) to keep them local.
|
||||
- **Desktop (absolute)** — the pointer is not locked. It moves in and out of the stream freely and
|
||||
its position is sent as an absolute point — what you want for remote desktop work. Your local
|
||||
|
||||
@@ -103,29 +103,24 @@ See [packaging/flatpak](https://git.unom.io/unom/punktfunk/src/branch/main/packa
|
||||
|
||||
## Windows
|
||||
|
||||
The Windows client ships as a **signed MSIX** in the package registry. Builds use a self-signed
|
||||
certificate, so you import that certificate once before Windows will install the package.
|
||||
The Windows client ships as a **signed MSIX** in the package registry, signed with a publicly
|
||||
trusted certificate — so there is nothing to import and nothing to trust by hand. Download, install.
|
||||
|
||||
1. Download the package and its certificate. Each channel keeps one fixed URL, so these two lines
|
||||
always fetch the current build — in PowerShell:
|
||||
1. Download the package. Each channel keeps one fixed URL, so this line always fetches the current
|
||||
build — in PowerShell:
|
||||
|
||||
```powershell
|
||||
curl.exe -LO https://git.unom.io/api/packages/unom/generic/punktfunk-client-windows/latest/punktfunk-client-windows_x64.msix
|
||||
curl.exe -LO https://git.unom.io/api/packages/unom/generic/punktfunk-client-windows/latest/punktfunk-client-windows_x64.cer
|
||||
```
|
||||
|
||||
Swap `_x64` for `_arm64` on an Arm device, and `latest` for `canary` to track `main`. The same
|
||||
two files are attached to every [release](https://git.unom.io/unom/punktfunk/releases), and every
|
||||
file is attached to every [release](https://git.unom.io/unom/punktfunk/releases), and every
|
||||
build is also kept under its own version on the
|
||||
[packages page](https://git.unom.io/unom/-/packages) (generic group, `punktfunk-client-windows`).
|
||||
2. **Trust the publisher certificate**, then install. The MSIX won't install until the certificate is
|
||||
trusted — but it's the **same certificate for every release**, so this is genuinely one-time and
|
||||
later updates need nothing. In an **admin** PowerShell:
|
||||
2. Install it:
|
||||
|
||||
```powershell
|
||||
# use the _arm64 files instead on an Arm device
|
||||
Import-Certificate -FilePath .\punktfunk-client-windows_x64.cer `
|
||||
-CertStoreLocation Cert:\LocalMachine\TrustedPeople
|
||||
# use the _arm64 file instead on an Arm device
|
||||
Add-AppxPackage .\punktfunk-client-windows_x64.msix
|
||||
```
|
||||
|
||||
@@ -133,6 +128,10 @@ certificate, so you import that certificate once before Windows will install the
|
||||
[Windows App Runtime 2.x](https://learn.microsoft.com/windows/apps/windows-app-sdk/downloads)
|
||||
(the MSIX depends on `Microsoft.WindowsAppRuntime.2`), then re-run `Add-AppxPackage`.
|
||||
|
||||
Install from a signed-in desktop session. Over a remote, non-interactive session (SSH, an RMM
|
||||
tool) `Add-AppxPackage` can fail with `0x80070005` when the Windows App Runtime it depends on is
|
||||
in use and Windows can't restart the apps holding it.
|
||||
|
||||
3. Launch **Punktfunk** from the Start menu and pick your host. The package also adds a second
|
||||
entry, **Punktfunk Console** — the same client as a controller-driven fullscreen interface for a
|
||||
TV or HTPC — and the headless `punktfunk` command on your PATH.
|
||||
@@ -223,7 +222,7 @@ but keeping them close is the least surprising. (Updating the **host** is its ow
|
||||
| **Linux Flatpak** | `flatpak update --user io.unom.Punktfunk` — **without `sudo`** (see the [Flatpak section](#linux-desktop-flatpak)) |
|
||||
| **Linux apt / dnf / pacman** | your normal `sudo apt upgrade` / `sudo dnf upgrade` / `sudo pacman -Syu`, or the app's own updater below |
|
||||
| **Fedora Atomic (layered)** | `rpm-ostree upgrade` on its own is not enough — see the note below the table |
|
||||
| **Windows MSIX** | no self-update — download the newer `.msix` as in [Windows](#windows) and re-run `Add-AppxPackage`. The certificate is the same every release, so you don't import it again |
|
||||
| **Windows MSIX** | no self-update — download the newer `.msix` as in [Windows](#windows) and re-run `Add-AppxPackage`. Coming from **0.28.1 or earlier**, see the note below the table |
|
||||
| **macOS `.dmg`** | download the newer `Punktfunk-<version>.dmg` and drag it over the copy in Applications |
|
||||
| **iOS / iPadOS / tvOS** | TestFlight updates it |
|
||||
| **Android** | Google Play updates it; if you sideloaded, download the APK again and install over it |
|
||||
@@ -244,6 +243,20 @@ systemctl reboot
|
||||
The client's own updater below runs exactly that dance for you, if you'd rather not remember it. (A
|
||||
layered **host** has the same trap — [Updating](/docs/updating) covers it.)
|
||||
|
||||
**Windows, coming from 0.28.1 or earlier — uninstall first.** Those builds were signed with our own
|
||||
self-signed certificate. The move to a publicly trusted one changes the package's *publisher*, and an
|
||||
MSIX's identity is its name **plus** its publisher — so Windows treats the new package as a different
|
||||
app rather than an update, and installing it leaves you with two **Punktfunk** entries. Remove the
|
||||
old one first, then install the new `.msix` as above:
|
||||
|
||||
```powershell
|
||||
Get-AppxPackage *Punktfunk* | Remove-AppxPackage
|
||||
```
|
||||
|
||||
This is one-time; releases after that upgrade in place. Note that a packaged app's settings live
|
||||
*inside* its package, so removing the old one also removes this client's identity and its paired
|
||||
hosts — expect to [pair](/docs/pairing) again once. Nothing on the host side is affected.
|
||||
|
||||
### The Linux client can update itself
|
||||
|
||||
The native Linux client checks its own channel and can apply the update in place, so you don't have
|
||||
|
||||
@@ -70,22 +70,22 @@ everything the installer puts on the machine, its optional tasks, the console pa
|
||||
This is also the path for **canary** builds, which winget doesn't carry — see
|
||||
[Release Channels](/docs/channels) for that download.
|
||||
|
||||
> **About the Unknown Publisher prompt.** The installer is signed with a self-signed certificate, so
|
||||
> Windows warns before it runs — accepting the prompt is enough, nothing else is required. The winget
|
||||
> route is no different: it downloads and runs that same installer. If you'd
|
||||
> rather silence it, the matching **`punktfunk-host-windows_<ver>.cer`** is published next to the
|
||||
> installer, and it's the **same certificate for every release**, so this is one-time. A self-signed
|
||||
> certificate is its own root, so it has to go in both stores. In an **admin** PowerShell:
|
||||
> **About signing.** The installer is signed with a publicly trusted certificate, so Windows shows
|
||||
> the publisher by name at the UAC prompt — there is no Unknown Publisher warning and nothing to
|
||||
> import. The winget route is no different: it downloads and runs that same installer.
|
||||
>
|
||||
> ```powershell
|
||||
> Import-Certificate -FilePath .\punktfunk-host-windows_<ver>.cer `
|
||||
> -CertStoreLocation Cert:\LocalMachine\Root
|
||||
> Import-Certificate -FilePath .\punktfunk-host-windows_<ver>.cer `
|
||||
> -CertStoreLocation Cert:\LocalMachine\TrustedPublisher
|
||||
> ```
|
||||
> SmartScreen is a separate mechanism that builds reputation per publisher, so shortly after a new
|
||||
> signing certificate starts being used it can still show *"Windows protected your PC"* on the first
|
||||
> downloads — **More info → Run anyway**. It settles as installs accumulate.
|
||||
>
|
||||
> This is a different certificate from the one the bundled **drivers** are signed with — the
|
||||
> installer imports that one for you.
|
||||
> The bundled **drivers** are a separate matter — they carry their own certificate, and the installer
|
||||
> imports that one for you. [Windows Host](/docs/windows-host#about-the-signatures) has the detail.
|
||||
>
|
||||
> Releases **0.28.1 and earlier** were signed with our own self-signed certificate, and older docs
|
||||
> told you to import it. Nothing needs it any more: if you imported
|
||||
> `punktfunk-host-windows_<ver>.cer` back then, you can remove it from `Cert:\LocalMachine\Root` and
|
||||
> `Cert:\LocalMachine\TrustedPublisher` (look for the certificate issued to **unom**, thumbprint
|
||||
> `CD1EFDEEEC9743AFC38F56C5AF30C5A3009BE941`).
|
||||
|
||||
## NixOS
|
||||
|
||||
|
||||
@@ -227,9 +227,12 @@ Three things are left on purpose:
|
||||
component other apps may be using, so the Punktfunk uninstaller never touches it. Remove it
|
||||
with its own uninstaller — `VBCABLE_Setup_x64.exe -u -h` — or the **VB-Audio Virtual Cable**
|
||||
entry in Installed apps.
|
||||
- **The publisher certificate**, if you imported it by hand to silence the Unknown Publisher prompt.
|
||||
Remove it in `certlm.msc` under **Trusted Publishers** and **Trusted Root Certification
|
||||
Authorities**. (This is *not* the driver certificate above, which the uninstaller does remove.)
|
||||
- **The old publisher certificate**, if you imported it by hand to silence the Unknown Publisher
|
||||
prompt on 0.28.1 or earlier. Releases since then are signed by a publicly trusted CA and never
|
||||
needed it, so it is safe to drop: remove the certificate issued to **unom** (thumbprint
|
||||
`CD1EFDEEEC9743AFC38F56C5AF30C5A3009BE941`) in `certlm.msc` under **Trusted Publishers** and
|
||||
**Trusted Root Certification Authorities**. (This is *not* the driver certificate above, which the
|
||||
uninstaller does remove.)
|
||||
|
||||
If you registered the winget source, drop it too — in an **admin** PowerShell, the same as
|
||||
registering it:
|
||||
|
||||
@@ -94,12 +94,14 @@ are `winget upgrade unom.PunktfunkHost` and removal is `winget uninstall unom.Pu
|
||||
|
||||
### About the signatures
|
||||
|
||||
Punktfunk signs with its own certificates rather than a publicly trusted one, so Windows warns about
|
||||
an unknown publisher before setup runs. Accepting the prompt is enough. To silence it for good, the
|
||||
matching **`punktfunk-host-windows_<ver>.cer`** is published next to the installer and it is the
|
||||
same certificate for every release — the one-time import is in
|
||||
[Install → Windows](/docs/install#windows). This applies to the winget path too: winget downloads
|
||||
and runs that same installer.
|
||||
Setup, and the Punktfunk executables it installs, are signed with a **publicly trusted** certificate,
|
||||
so UAC names the publisher rather than warning about an unknown one, and there is nothing for you to
|
||||
import. This applies to the winget path too: winget downloads and runs that same installer. (The
|
||||
bundled third-party pieces keep whatever their own vendors shipped.)
|
||||
|
||||
Releases **0.28.1 and earlier** were signed with our own self-signed certificate, and the docs then
|
||||
asked you to import `punktfunk-host-windows_<ver>.cer`. Current builds no longer produce or need it —
|
||||
[Install → Windows](/docs/install#windows) shows how to remove it if you imported one.
|
||||
|
||||
The bundled drivers carry a **second, separate** self-signed certificate (`CN=punktfunk-driver`,
|
||||
SHA-1 thumbprint `4B8493E7CD565758D335F8F4F05C5A7261A13E02`). The installer adds it to the machine's
|
||||
@@ -114,12 +116,22 @@ punktfunk-host-setup-<ver>.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-
|
||||
|
||||
Keep `/SUPPRESSMSGBOXES`: without it an unattended run can stop on a message box nobody can see.
|
||||
|
||||
A silent run takes the same defaults as the wizard, so add or drop individual options with Inno's
|
||||
`/MERGETASKS` — a bare name adds a task, a `!` prefix removes one. `/MERGETASKS="gamestream"` turns
|
||||
on Moonlight compatibility; `/MERGETASKS="!trayicon"` skips the status icon. The task names are
|
||||
`installdriver`, `installgamepad`, `installaudiocable`, `installhdrlayer`, `gamestream`,
|
||||
A silent **first** install takes the same defaults as the wizard, so add or drop individual options
|
||||
with Inno's `/MERGETASKS` — a bare name adds a task, a `!` prefix removes one.
|
||||
`/MERGETASKS="gamestream"` turns on Moonlight compatibility; `/MERGETASKS="!trayicon"` skips the
|
||||
status icon. The task names are `installdriver`, `installgamepad`, `installhdrlayer`, `gamestream`,
|
||||
`allowpublicfw`, `startservice` and `trayicon`.
|
||||
|
||||
An **upgrade** is different: it reuses the choices the previous install recorded, not the defaults
|
||||
above. That is usually what you want, but it means anything you once declined stays declined — and
|
||||
if that includes `installgamepad`, the gamepad drivers are never updated alongside the host, which
|
||||
surfaces later as a virtual controller games stop seeing. To force the full set on an upgrade, name
|
||||
them (`/MERGETASKS` merges with the remembered set rather than replacing it):
|
||||
|
||||
```powershell
|
||||
punktfunk-host-setup-<ver>.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /MERGETASKS="installdriver,installgamepad,installhdrlayer,startservice"
|
||||
```
|
||||
|
||||
Two things to know before you script it:
|
||||
|
||||
- Setup **aborts with a non-zero exit code** if another Moonlight-compatible host — Sunshine, Apollo,
|
||||
|
||||
@@ -24,6 +24,16 @@ release is born complete and the announcement always has something to say.
|
||||
Discord `#releases`. Pressing "go" is the quality gate — a half-built release is never
|
||||
announced. Stable-only; a `-rc` tag is refused unless `allow_prerelease=true`.
|
||||
|
||||
**If a platform's run never appears, do not re-run the PR run — it cannot publish.** A re-run
|
||||
replays the original event (`pull_request`), and android's publish steps are gated on a `push`, so
|
||||
they stay skipped no matter how often you press it. Merging two PRs seconds apart can leave the
|
||||
older merge sha with **no run at all** — Gitea attributes the window's runs to the newer head
|
||||
(2026-08-14: `1e5dca4c` lost its run to `b5cace3a`, 12 s later), which is how an android change
|
||||
reaches main having never been built. Recover it by dispatching `android.yml` on that ref with
|
||||
**`publish=true`**; that is the only manual path reaching the registry and Play, and a plain
|
||||
dispatch stays build-only so a stray click can't ship to testers. Check for the gap by matching
|
||||
your own merge sha in the run list — "CI ran" is not the same as "your commit ran".
|
||||
|
||||
Editing the notes after the tag is fine: update this file, then re-run step 4 (or PATCH the body
|
||||
via the API) — the announce step always re-syncs from the file, so the file stays authoritative
|
||||
even across a tag re-point.
|
||||
|
||||
@@ -114,7 +114,17 @@
|
||||
// every existing function keeps its signature and behaviour, and an embedder that never calls it
|
||||
// is unchanged. The `Welcome` grew a trailing field, which older peers skip in both directions
|
||||
// (see `Welcome::encode`), so [`WIRE_VERSION`] is unchanged.
|
||||
#define PUNKTFUNK_ABI_VERSION 20
|
||||
// v21: added `punktfunk_connect_ex10` — `connect_ex9` plus `device_name`, the label an unpaired
|
||||
// client knocks with: what the host's pending-approval list (and the web console's
|
||||
// outstanding-pairings view and approve dialog) shows, and the trust-store name on approval. The
|
||||
// C ABI had no such parameter, so every embedder took [`client::device_name`]'s OS default —
|
||||
// which resolves through `COMPUTERNAME`/`HOSTNAME`, neither of which exists in an Apple GUI
|
||||
// process, leaving every Mac, iPad, iPhone and Apple TV knocking as the literal "This device"
|
||||
// (a console with three of them pending showed three identical rows). A NEW symbol, not a
|
||||
// widened one: `ex9` keeps its parameter list AND its behaviour — it passes a null name, which
|
||||
// selects that same default. Additive and client-local: the name rides the `Hello::name` field
|
||||
// hosts have read since the pending list existed, so [`WIRE_VERSION`] is unchanged.
|
||||
#define PUNKTFUNK_ABI_VERSION 21
|
||||
|
||||
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
@@ -2578,6 +2588,47 @@ PunktfunkConnection *punktfunk_connect_ex9(const char *host,
|
||||
int32_t *status_out);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Like [`punktfunk_connect_ex9`], plus `device_name` (ABI v21): the human-readable label this
|
||||
// device knocks with — what the host's **pending-approval** list (and the web console's
|
||||
// outstanding-pairings view and its approve dialog) shows for an unpaired client, and what the
|
||||
// trust store files it under once approved. Pass the name the user already recognises this
|
||||
// device by: `Host.current().localizedName` on macOS, `UIDevice.current.name` on iOS/tvOS,
|
||||
// `Settings.Global.DEVICE_NAME` on Android.
|
||||
//
|
||||
// NULL / empty = the [`crate::client::device_name`] default, exactly as every earlier variant.
|
||||
// That default is an OS hostname, which no Apple GUI process could reach until v21 — every one
|
||||
// of them knocked as the literal "This device", so a console with three of them pending showed
|
||||
// three identical rows. Longer than [`crate::quic::HELLO_NAME_MAX`] bytes of UTF-8 is truncated
|
||||
// (on a character boundary) rather than rejected: a too-long label is a cosmetic problem, and
|
||||
// failing a connect over it would be a much worse one.
|
||||
//
|
||||
// # Safety
|
||||
// Same as [`punktfunk_connect_ex9`]; `device_name`, when non-null, must be a NUL-terminated C
|
||||
// string that stays valid for the duration of the call.
|
||||
PunktfunkConnection *punktfunk_connect_ex10(const char *host,
|
||||
uint16_t port,
|
||||
uint32_t width,
|
||||
uint32_t height,
|
||||
uint32_t refresh_hz,
|
||||
uint32_t compositor,
|
||||
uint32_t gamepad,
|
||||
uint32_t bitrate_kbps,
|
||||
uint8_t video_caps,
|
||||
uint8_t audio_channels,
|
||||
uint8_t video_codecs,
|
||||
uint8_t preferred_codec,
|
||||
uint8_t client_caps,
|
||||
const char *launch_id,
|
||||
const uint8_t *pin_sha256,
|
||||
uint8_t *observed_sha256_out,
|
||||
const char *client_cert_pem,
|
||||
const char *client_key_pem,
|
||||
const char *device_name,
|
||||
uint32_t timeout_ms,
|
||||
int32_t *status_out);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Generate a persistent client identity: a self-signed certificate + private key, both
|
||||
// PEM, NUL-terminated, written into the caller's buffers. Generate ONCE, store both
|
||||
|
||||
@@ -303,7 +303,8 @@ pwsh -File packaging\windows\pack-host-installer.ps1 -Version 0.0.0-dev -TargetD
|
||||
|
||||
Push a `vX.Y.Z` tag — one tag releases every platform (see
|
||||
[Release Channels](https://punktfunk.unom.io/docs/channels)). The workflow builds, signs, and
|
||||
publishes `punktfunk-host-setup-X.Y.Z.exe` + the public `.cer`, refreshes the stable `latest/`
|
||||
publishes `punktfunk-host-setup-X.Y.Z.exe` (no `.cer` — Azure signing is publicly trusted, and mode 2
|
||||
or 3 would be needed to emit one), refreshes the stable `latest/`
|
||||
alias, and attaches the installer to the unified Gitea Release. Main pushes publish rolling
|
||||
`<next-minor>.<run>` **canary** builds (base derived from the latest stable tag by
|
||||
`scripts/ci/pf-version.ps1`) to the `canary/` alias.
|
||||
|
||||
@@ -276,6 +276,7 @@
|
||||
"pairing_pending_deny": "Ablehnen",
|
||||
"pairing_pending_name_prompt": "Gerät benennen",
|
||||
"pairing_pending_name_title": "Dieses Gerät zulassen",
|
||||
"pairing_pending_name_desc": "{name} — Fingerprint {fp}. Gespeichert wird der Name, der hier steht.",
|
||||
"pairing_pending_age_just_now": "gerade eben",
|
||||
"pairing_pending_age_secs": "vor {s}s",
|
||||
"pairing_pending_age_mins": "vor {min} min",
|
||||
|
||||
@@ -276,6 +276,7 @@
|
||||
"pairing_pending_deny": "Deny",
|
||||
"pairing_pending_name_prompt": "Name this device",
|
||||
"pairing_pending_name_title": "Approve this device",
|
||||
"pairing_pending_name_desc": "{name} — fingerprint {fp}. It is stored under the name you leave here.",
|
||||
"pairing_pending_age_just_now": "just now",
|
||||
"pairing_pending_age_secs": "{s}s ago",
|
||||
"pairing_pending_age_mins": "{min} min ago",
|
||||
|
||||
@@ -37,9 +37,17 @@ export const PendingDevicesSection: FC = () => {
|
||||
qc.invalidateQueries({ queryKey: getListPendingDevicesQueryKey() });
|
||||
qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() });
|
||||
};
|
||||
const onApprove = async (id: number, currentName: string) => {
|
||||
// The dialog names the device it is about — the field is pre-filled with the same string, but a
|
||||
// pre-filled field is editable text, not a statement of WHICH knock this is. With two devices
|
||||
// waiting the operator would otherwise be approving whichever row they hope they clicked, so the
|
||||
// fingerprint rides along: it is the only thing that stays unique when two devices share a name.
|
||||
const onApprove = async (id: number, currentName: string, fingerprint: string) => {
|
||||
const name = await promptText({
|
||||
title: m.pairing_pending_name_title(),
|
||||
description: m.pairing_pending_name_desc({
|
||||
name: currentName,
|
||||
fp: `${fingerprint.slice(0, 16)}…`,
|
||||
}),
|
||||
label: m.pairing_pending_name_prompt(),
|
||||
defaultValue: currentName,
|
||||
confirmLabel: m.pairing_pending_approve(),
|
||||
@@ -75,7 +83,7 @@ export const PendingDevicesSection: FC = () => {
|
||||
*/
|
||||
export const PendingDevices: FC<{
|
||||
pending: Loadable<PendingDevice[]>;
|
||||
onApprove: (id: number, currentName: string) => void;
|
||||
onApprove: (id: number, currentName: string, fingerprint: string) => void;
|
||||
onDeny: (id: number) => void;
|
||||
/** Id of the row whose approve/deny is in flight, or null — only that row disables. */
|
||||
pendingId: number | null;
|
||||
@@ -136,7 +144,7 @@ export const PendingDevices: FC<{
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={pendingId === p.id}
|
||||
onClick={() => onApprove(p.id, p.name)}
|
||||
onClick={() => onApprove(p.id, p.name, p.fingerprint)}
|
||||
>
|
||||
{m.pairing_pending_approve()}
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user